PPT 생성 고도화 3차를 반영하고 deck planning·quality gate를 추가

- DeckPlanningService와 DeckQualityReviewService를 추가해 deck brief 정규화, consulting storyline 보강, 누락된 Executive Summary/Recommendation/Roadmap/Appendix 자동 보강, deck-level 품질 점수와 경고 계산을 지원합니다.

- PptxSkill에 audience/objective/decision_ask/storyline 파라미터를 추가하고, issue_tree/before_after/decision_matrix/risk_heatmap/benefit_waterfall/operating_model/appendix_evidence 레이아웃을 네이티브 슬라이드 타입으로 정규화한 뒤 planning summary와 quality summary를 함께 반환하도록 보강했습니다.

- pptx-creator 및 strategy-deck/board-update/pmo-steering/sales-review-deck/operating-model-deck 번들 스킬을 추가·정리하고, DeckPlanningServiceTests/DeckQualityReviewServiceTests/PptxSkillAutoRepairTests로 회귀 검증을 보강했습니다.

- README.md와 docs/DEVELOPMENT.md에 2026-04-14 21:50, 22:00 (KST) 기준 변경 이력과 검증 결과를 반영했습니다.

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify_ppt_phase3\ -p:IntermediateOutputPath=obj\verify_ppt_phase3\ (경고 0 / 오류 0)

- 검증: dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter 'DeckPlanningServiceTests|DeckQualityReviewServiceTests|PptxSkillAutoRepairTests|PptxSkillConsultingDeckTests' -p:OutputPath=bin\verify_ppt_phase3_tests\ -p:IntermediateOutputPath=obj\verify_ppt_phase3_tests\ (통과 5)
This commit is contained in:
2026-04-14 22:01:41 +09:00
parent 6c7fba9dff
commit 8571a83ed0
14 changed files with 1450 additions and 85 deletions

View File

@@ -1782,3 +1782,15 @@ MIT License
- 테스트로 [DocumentAssemblerSemanticTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/DocumentAssemblerSemanticTests.cs), [ExcelSkillSummarySheetTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/ExcelSkillSummarySheetTests.cs), [HtmlSkillConsultingSectionsTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/HtmlSkillConsultingSectionsTests.cs), [DocumentPlannerBusinessDocumentTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/DocumentPlannerBusinessDocumentTests.cs)를 추가했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_doc_phase1\\ -p:IntermediateOutputPath=obj\\verify_doc_phase1\\` 경고 0 / 오류 0
- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "DocumentAssemblerSemanticTests|ExcelSkillSummarySheetTests|HtmlSkillConsultingSectionsTests|DocumentPlannerBusinessDocumentTests|DocumentPlannerPresentationTests" -p:OutputPath=bin\\verify_doc_phase1_tests\\ -p:IntermediateOutputPath=obj\\verify_doc_phase1_tests\\` 통과 5
- 업데이트: 2026-04-14 21:50 (KST)
- PPT 생성 고도화 3차를 반영했습니다. [DeckPlanningService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/DeckPlanningService.cs)는 `audience`, `objective`, `decision_ask`, `storyline` 힌트를 바탕으로 슬라이드를 생성 전에 정규화하고, 누락된 `title`, `Executive Summary`, `Recommendation`, `Roadmap`, `Appendix`를 자동 보강합니다.
- [DeckQualityReviewService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/DeckQualityReviewService.cs)는 deck 단위 품질 점수를 계산해 `Executive Summary`, `Recommendation`, 근거 슬라이드, 텍스트 과밀, placeholder 잔존 여부를 점검합니다.
- [PptxSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PptxSkill.cs)는 `issue_tree`, `before_after`, `decision_matrix`, `risk_heatmap`, `benefit_waterfall`, `operating_model`, `appendix_evidence` 같은 컨설팅형 레이아웃을 생성 전에 네이티브 레이아웃으로 정규화하고, 결과 메시지에 deck planning/quality review 요약을 함께 반환합니다.
- [pptx-creator.skill.md](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/skills/pptx-creator.skill.md)는 `document_plan -> deck brief -> pptx_create` 흐름을 중심으로 재작성했고, [strategy-deck.skill.md](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/skills/strategy-deck.skill.md), [board-update.skill.md](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/skills/board-update.skill.md), [pmo-steering.skill.md](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/skills/pmo-steering.skill.md), [sales-review-deck.skill.md](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/skills/sales-review-deck.skill.md), [operating-model-deck.skill.md](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/skills/operating-model-deck.skill.md)를 추가해 목적형 deck 진입점을 늘렸습니다.
- 테스트로 [DeckPlanningServiceTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/DeckPlanningServiceTests.cs), [DeckQualityReviewServiceTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/DeckQualityReviewServiceTests.cs), [PptxSkillAutoRepairTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/PptxSkillAutoRepairTests.cs)를 추가했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_ppt_phase3\\ -p:IntermediateOutputPath=obj\\verify_ppt_phase3\\` 경고 0 / 오류 0
- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "DeckPlanningServiceTests|DeckQualityReviewServiceTests|PptxSkillAutoRepairTests|PptxSkillConsultingDeckTests" -p:OutputPath=bin\\verify_ppt_phase3_tests\\ -p:IntermediateOutputPath=obj\\verify_ppt_phase3_tests\\` 통과 5
????: 2026-04-14 22:00 (KST)
- PPT 3? ?? ??? ??????. DeckPlanningService, DeckQualityReviewService, PptxSkill? planning/quality summary ??? ??? PPT ?? ?? ??? ???? ?? ??? ?? ?? ??? ??? ?????.
- ??: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_ppt_phase3\\ -p:IntermediateOutputPath=obj\\verify_ppt_phase3\\ ?? 0 / ?? 0
- ??: dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "DeckPlanningServiceTests|DeckQualityReviewServiceTests|PptxSkillAutoRepairTests|PptxSkillConsultingDeckTests" -p:OutputPath=bin\\verify_ppt_phase3_tests\\ -p:IntermediateOutputPath=obj\\verify_ppt_phase3_tests\\ ?? 5

View File

@@ -852,3 +852,13 @@ UI ?붿옄???€洹쒕え 由ы뙥?좊쭅 ???꾪뿕 ?묒뾽 ??湲곕줉???덉쟾
- 테스트: `DocumentAssemblerSemanticTests`, `ExcelSkillSummarySheetTests`, `HtmlSkillConsultingSectionsTests`, `DocumentPlannerBusinessDocumentTests` 추가
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_doc_phase1\\ -p:IntermediateOutputPath=obj\\verify_doc_phase1\\` 경고 0 / 오류 0
- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "DocumentAssemblerSemanticTests|ExcelSkillSummarySheetTests|HtmlSkillConsultingSectionsTests|DocumentPlannerBusinessDocumentTests|DocumentPlannerPresentationTests" -p:OutputPath=bin\\verify_doc_phase1_tests\\ -p:IntermediateOutputPath=obj\\verify_doc_phase1_tests\\` 통과 5
업데이트: 2026-04-14 21:50 (KST)
- PPT 생성 고도화 3차를 반영했습니다. `DeckPlanningService`를 추가해 deck brief 정규화, consulting storyline 보강, 누락된 `Executive Summary`/`Recommendation`/`Roadmap`/`Appendix` 자동 삽입, 레이아웃 alias 정규화를 내부 파이프라인으로 처리합니다.
- `DeckQualityReviewService`를 추가해 deck-level 품질 점수와 경고를 계산합니다. 템플릿 사용 여부, 레이아웃 다양성, executive summary/recommendation 유무, 텍스트 과밀, 근거 슬라이드 부족, placeholder 잔존을 함께 점검합니다.
- `PptxSkill`은 `audience`, `objective`, `decision_ask`, `storyline` 파라미터를 추가했고, `issue_tree`, `before_after`, `decision_matrix`, `risk_heatmap`, `benefit_waterfall`, `operating_model`, `appendix_evidence` 같은 상위 deck 레이아웃을 네이티브 슬라이드 타입으로 자동 정규화해 렌더링 전에 보정합니다.
- 결과 메시지도 고도화했습니다. `pptx_create` 실행 후 파일 경로만 반환하던 흐름에서, 이제 planning summary와 deck quality summary를 함께 반환해 모델과 사용자가 결과물 완성도를 바로 확인할 수 있습니다.
- `pptx-creator.skill.md`를 deck planning 중심으로 재작성했고, `strategy-deck`, `board-update`, `pmo-steering`, `sales-review-deck`, `operating-model-deck` 번들 스킬을 추가해 목적형 deck 생성 진입점을 늘렸습니다.
- 테스트로 `DeckPlanningServiceTests`, `DeckQualityReviewServiceTests`, `PptxSkillAutoRepairTests`를 추가했고, 기존 `PptxSkillConsultingDeckTests`와 함께 검증했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_ppt_phase3\\ -p:IntermediateOutputPath=obj\\verify_ppt_phase3\\` 경고 0 / 오류 0
- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "DeckPlanningServiceTests|DeckQualityReviewServiceTests|PptxSkillAutoRepairTests|PptxSkillConsultingDeckTests" -p:OutputPath=bin\\verify_ppt_phase3_tests\\ -p:IntermediateOutputPath=obj\\verify_ppt_phase3_tests\\` 통과 5

View File

@@ -0,0 +1,54 @@
using System.Text.Json;
using AxCopilot.Services.Agent;
using FluentAssertions;
using Xunit;
namespace AxCopilot.Tests.Services;
public class DeckPlanningServiceTests
{
[Fact]
public void Prepare_ShouldInjectConsultingStructure_WhenKeySlidesAreMissing()
{
using var args = JsonDocument.Parse(
"""
{
"title": "Growth Plan",
"objective": "growth strategy recommendation",
"decision_ask": "approve phase-1 funding",
"slides": [
{
"layout": "content",
"title": "Current State",
"body": [
"Top-line growth has slowed in the enterprise segment.",
"Retention is declining in two priority cohorts.",
"Manual CRM operations drive avoidable cost."
]
},
{
"layout": "comparison",
"title": "Options",
"options": [
{ "name": "Option A", "pros": "Fast", "cons": "Shallow", "verdict": "Fastest" },
{ "name": "Option B", "pros": "Balanced", "cons": "Requires alignment", "verdict": "Recommended" }
]
}
]
}
""");
using var result = DeckPlanningService.Prepare(args.RootElement, args.RootElement.GetProperty("slides"), "Growth Plan");
var slides = result.Slides.EnumerateArray().ToList();
var layouts = slides.Select(slide => slide.GetProperty("layout").GetString()).ToList();
var titles = slides.Select(slide => slide.GetProperty("title").GetString()).ToList();
layouts.Should().Contain("title");
layouts.Should().Contain("executive_summary");
layouts.Should().Contain("recommendation");
layouts.Should().Contain("roadmap");
titles.Should().Contain(title => title != null && title.Contains("Appendix", StringComparison.OrdinalIgnoreCase));
result.AutoRepairCount.Should().BeGreaterThan(0);
result.Storyline.Should().NotBeEmpty();
}
}

View File

@@ -0,0 +1,57 @@
using System.Text.Json;
using AxCopilot.Services.Agent;
using FluentAssertions;
using Xunit;
namespace AxCopilot.Tests.Services;
public class DeckQualityReviewServiceTests
{
[Fact]
public void ReviewDeck_ShouldFlagMissingExecutiveSummaryAndRecommendation()
{
using var slides = JsonDocument.Parse(
"""
[
{
"layout": "content",
"title": "Current State",
"body": "Point 1\nPoint 2\nPoint 3\nPoint 4\nPoint 5\nPoint 6\nPoint 7"
},
{
"layout": "content",
"title": "Findings",
"body": "Observation A\nObservation B\nObservation C"
}
]
""");
var review = DeckQualityReviewService.ReviewDeck("Weak Deck", slides.RootElement, hasTemplate: false, autoRepairCount: 0);
review.Score.Should().BeLessThan(75);
review.Issues.Should().Contain(issue => issue.Message.Contains("Executive Summary"));
review.Issues.Should().Contain(issue => issue.Message.Contains("Recommendation"));
}
[Fact]
public void ReviewDeck_ShouldRewardBalancedConsultingDeck()
{
using var slides = JsonDocument.Parse(
"""
[
{ "layout": "title", "title": "Growth Strategy" },
{ "layout": "executive_summary", "title": "Executive Summary", "headline": "Headline", "summary_points": ["A","B","C"], "recommendation": "Do B" },
{ "layout": "comparison", "title": "Options", "headline": "Compare", "options": [{ "name": "A", "pros": "Fast", "cons": "Shallow", "verdict": "Fastest" }, { "name": "B", "pros": "Balanced", "cons": "Needs alignment", "verdict": "Recommended" }] },
{ "layout": "roadmap", "title": "Roadmap", "headline": "Execute in three phases" },
{ "layout": "recommendation", "title": "Recommendation", "recommendation": "Approve phase-1", "summary_points": ["Reason 1", "Reason 2"] },
{ "layout": "table", "title": "Appendix & Evidence", "headers": ["Evidence","Detail"], "rows": [["Metric","Value"]] }
]
""");
var review = DeckQualityReviewService.ReviewDeck("Strong Deck", slides.RootElement, hasTemplate: true, autoRepairCount: 2, storyline: ["Executive Summary", "Options", "Roadmap"]);
review.Score.Should().BeGreaterThan(80);
review.Strengths.Should().Contain(strength => strength.Contains("Executive Summary"));
review.Issues.Should().NotContain(issue => issue.Severity == DeckReviewSeverity.Critical);
}
}

View File

@@ -0,0 +1,79 @@
using System.IO;
using System.Text.Json;
using AxCopilot.Services.Agent;
using DocumentFormat.OpenXml.Packaging;
using FluentAssertions;
using Xunit;
namespace AxCopilot.Tests.Services;
public class PptxSkillAutoRepairTests
{
[Fact]
public async Task ExecuteAsync_ShouldAugmentDeckAndReturnQualitySummary()
{
var workDir = Path.Combine(Path.GetTempPath(), "ax-pptx-repair-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(workDir);
try
{
var context = new AgentContext
{
WorkFolder = workDir,
Permission = "Auto",
OperationMode = "external",
};
var tool = new PptxSkill();
var args = JsonDocument.Parse(
"""
{
"path": "auto-repair-deck.pptx",
"title": "Operating Improvement",
"objective": "operating model proposal",
"decision_ask": "approve phase-1 launch",
"slides": [
{
"layout": "before_after",
"title": "Current vs Future",
"before": ["Manual coordination", "Slow approvals", "Fragmented KPI tracking"],
"after": ["Standard workflow", "Faster approvals", "Single KPI view"]
},
{
"layout": "benefit_waterfall",
"title": "Benefits",
"benefits": [
{ "label": "Cost", "value": 8 },
{ "label": "Speed", "value": 12 },
{ "label": "Quality", "value": 6 }
]
}
]
}
""").RootElement;
var result = await tool.ExecuteAsync(args, context, CancellationToken.None);
var outputPath = Path.Combine(workDir, "auto-repair-deck.pptx");
result.Success.Should().BeTrue();
result.Output.Should().Contain("PPT quality");
result.Output.Should().Contain("Storyline:");
File.Exists(outputPath).Should().BeTrue();
using var doc = PresentationDocument.Open(outputPath, false);
doc.PresentationPart!.Presentation.SlideIdList!.Count().Should().BeGreaterThanOrEqualTo(6);
}
finally
{
try
{
if (Directory.Exists(workDir))
Directory.Delete(workDir, true);
}
catch
{
}
}
}
}

View File

@@ -0,0 +1,806 @@
using System.Text.Json;
using System.Text.Json.Nodes;
namespace AxCopilot.Services.Agent;
public sealed class DeckPreparationResult : IDisposable
{
public required JsonDocument SlidesDocument { get; init; }
public required IReadOnlyList<string> Storyline { get; init; }
public required int AutoRepairCount { get; init; }
public string? Audience { get; init; }
public string? Objective { get; init; }
public string? DecisionAsk { get; init; }
public string? SuggestedTheme { get; init; }
public bool InjectedTitleSlide { get; init; }
public bool InjectedExecutiveSummary { get; init; }
public bool InjectedRecommendation { get; init; }
public bool InjectedRoadmap { get; init; }
public bool InjectedAppendix { get; init; }
public JsonElement Slides => SlidesDocument.RootElement;
public string ToToolSummary()
{
var parts = new List<string>();
if (!string.IsNullOrWhiteSpace(Audience))
parts.Add($"Audience: {Audience}");
if (!string.IsNullOrWhiteSpace(Objective))
parts.Add($"Objective: {Objective}");
if (!string.IsNullOrWhiteSpace(DecisionAsk))
parts.Add($"Ask: {DecisionAsk}");
var injected = new List<string>();
if (InjectedTitleSlide) injected.Add("Title");
if (InjectedExecutiveSummary) injected.Add("Executive Summary");
if (InjectedRecommendation) injected.Add("Recommendation");
if (InjectedRoadmap) injected.Add("Roadmap");
if (InjectedAppendix) injected.Add("Appendix");
if (injected.Count > 0)
parts.Add("Added: " + string.Join(", ", injected));
if (AutoRepairCount > 0)
parts.Add($"Auto-repair: {AutoRepairCount}");
if (Storyline.Count > 0)
parts.Add("Storyline: " + string.Join(" -> ", Storyline.Take(6)));
return string.Join(" | ", parts);
}
public void Dispose()
{
SlidesDocument.Dispose();
}
}
public static class DeckPlanningService
{
public static DeckPreparationResult Prepare(
JsonElement args,
JsonElement slides,
string presentationTitle)
{
var audience = ReadString(args, "audience");
var objective = ReadString(args, "objective");
var decisionAsk = ReadString(args, "decision_ask");
var storylineHint = ReadHintList(args, "storyline", "storyline_hint", "deck_outline");
var slideNodes = ParseSlides(slides);
var autoRepairCount = 0;
for (var i = 0; i < slideNodes.Count; i++)
autoRepairCount += NormalizeSlide(slideNodes[i], i, presentationTitle, objective, decisionAsk);
var hasTitle = slideNodes.Any(slide => string.Equals(ReadString(slide, "layout"), "title", StringComparison.OrdinalIgnoreCase));
var hasExecutiveSummary = slideNodes.Any(slide => string.Equals(ReadString(slide, "layout"), "executive_summary", StringComparison.OrdinalIgnoreCase));
var hasRecommendation = slideNodes.Any(slide => string.Equals(ReadString(slide, "layout"), "recommendation", StringComparison.OrdinalIgnoreCase));
var hasRoadmap = slideNodes.Any(slide => string.Equals(ReadString(slide, "layout"), "roadmap", StringComparison.OrdinalIgnoreCase));
var hasAppendix = slideNodes.Any(slide =>
{
var title = ReadString(slide, "title");
return title.Contains("appendix", StringComparison.OrdinalIgnoreCase) ||
title.Contains("\uBD80\uB85D", StringComparison.OrdinalIgnoreCase) ||
title.Contains("evidence", StringComparison.OrdinalIgnoreCase);
});
var injectedTitle = false;
var injectedExecutiveSummary = false;
var injectedRecommendation = false;
var injectedRoadmap = false;
var injectedAppendix = false;
if (!hasTitle && slideNodes.Count > 0)
{
slideNodes.Insert(0, BuildTitleSlide(presentationTitle, audience, objective));
injectedTitle = true;
autoRepairCount++;
}
if (!hasExecutiveSummary)
{
var insertAt = injectedTitle || (slideNodes.Count > 0 && string.Equals(ReadString(slideNodes[0], "layout"), "title", StringComparison.OrdinalIgnoreCase))
? 1
: 0;
slideNodes.Insert(insertAt, BuildExecutiveSummarySlide(slideNodes, objective, decisionAsk));
injectedExecutiveSummary = true;
autoRepairCount++;
}
if (!hasRecommendation && slideNodes.Count >= 3)
{
slideNodes.Add(BuildRecommendationSlide(slideNodes, objective, decisionAsk));
injectedRecommendation = true;
autoRepairCount++;
}
if (!hasRoadmap && slideNodes.Count >= 4)
{
slideNodes.Add(BuildRoadmapSlide(decisionAsk));
injectedRoadmap = true;
autoRepairCount++;
}
if (!hasAppendix && slideNodes.Count >= 6)
{
slideNodes.Add(BuildAppendixSlide(slideNodes));
injectedAppendix = true;
autoRepairCount++;
}
for (var i = 0; i < slideNodes.Count; i++)
autoRepairCount += NormalizeSlide(slideNodes[i], i, presentationTitle, objective, decisionAsk);
var storyline = BuildStoryline(slideNodes, storylineHint);
var json = JsonSerializer.SerializeToUtf8Bytes(ToJsonArray(slideNodes));
var document = JsonDocument.Parse(json);
return new DeckPreparationResult
{
SlidesDocument = document,
Storyline = storyline,
AutoRepairCount = autoRepairCount,
Audience = audience,
Objective = objective,
DecisionAsk = decisionAsk,
SuggestedTheme = SuggestTheme(objective, audience),
InjectedTitleSlide = injectedTitle,
InjectedExecutiveSummary = injectedExecutiveSummary,
InjectedRecommendation = injectedRecommendation,
InjectedRoadmap = injectedRoadmap,
InjectedAppendix = injectedAppendix,
};
}
private static int NormalizeSlide(
JsonObject slide,
int slideIndex,
string presentationTitle,
string? objective,
string? decisionAsk)
{
var repairs = 0;
var layout = ReadString(slide, "layout");
if (string.IsNullOrWhiteSpace(layout))
{
slide["layout"] = "content";
layout = "content";
repairs++;
}
switch (layout.Trim().ToLowerInvariant())
{
case "issue_tree":
slide["layout"] = "two_column";
slide["title"] = Coalesce(ReadString(slide, "title"), "Issue Tree");
slide["left"] = ToMultiline(slide, "issues", "left", "body");
slide["right"] = ToMultiline(slide, "implications", "next_steps", "right");
repairs++;
break;
case "before_after":
slide["layout"] = "two_column";
slide["title"] = Coalesce(ReadString(slide, "title"), "Before / After");
slide["left"] = Coalesce(ToMultiline(slide, "before", "current_state", "left"), "Before state");
slide["right"] = Coalesce(ToMultiline(slide, "after", "future_state", "right"), "After state");
repairs++;
break;
case "decision_matrix":
slide["layout"] = "comparison";
if (!slide.ContainsKey("options") && slide.TryGetPropertyValue("choices", out var choicesNode))
slide["options"] = choicesNode?.DeepClone();
slide["title"] = Coalesce(ReadString(slide, "title"), "Decision Matrix");
repairs++;
break;
case "risk_heatmap":
slide["layout"] = "table";
slide["title"] = Coalesce(ReadString(slide, "title"), "Risk Overview");
EnsureRiskRows(slide);
repairs++;
break;
case "benefit_waterfall":
slide["layout"] = "chart";
slide["chart_type"] = "bar";
slide["title"] = Coalesce(ReadString(slide, "title"), "Benefit Waterfall");
EnsureChartFromBenefits(slide);
repairs++;
break;
case "operating_model":
slide["layout"] = "two_column";
slide["title"] = Coalesce(ReadString(slide, "title"), "Operating Model");
slide["left"] = Coalesce(ToMultiline(slide, "current_state", "capabilities", "left"), "Current model");
slide["right"] = Coalesce(ToMultiline(slide, "target_state", "changes", "right"), "Target model");
repairs++;
break;
case "appendix_evidence":
slide["title"] = Coalesce(ReadString(slide, "title"), "Appendix");
if (slide.TryGetPropertyValue("evidence", out var evidenceNode) && evidenceNode is JsonArray evidence && evidence.Count > 0)
{
slide["layout"] = "table";
slide["headers"] = new JsonArray("Evidence", "Detail", "Source");
slide["rows"] = BuildEvidenceRows(evidence);
}
else
{
slide["layout"] = "content";
slide["body"] = ToMultiline(slide, "summary_points", "body");
}
repairs++;
break;
}
repairs += ClampArray(slide, "summary_points", 4);
repairs += ClampArray(slide, "next_steps", 3);
repairs += ClampArray(slide, "options", 3);
repairs += ClampArray(slide, "kpis", 4);
repairs += ClampArray(slide, "phases", 4);
repairs += ClampArray(slide, "rows", 6);
var normalizedLayout = ReadString(slide, "layout").ToLowerInvariant();
if (normalizedLayout is "content" or "title" or "section" or "two_column" or "quote")
repairs += NormalizeMultilineText(slide, normalizedLayout);
if (normalizedLayout == "chart" && !HasChartData(slide))
{
slide["layout"] = "comparison";
slide["title"] = Coalesce(ReadString(slide, "title"), "Decision Comparison");
slide["headline"] = Coalesce(ReadString(slide, "headline"), Coalesce(objective, "?듭떖 ?€?덉쓣 鍮꾧탳?⑸땲??"));
EnsureFallbackOptions(slide);
repairs++;
}
if (string.IsNullOrWhiteSpace(ReadString(slide, "headline")) &&
normalizedLayout is "executive_summary" or "comparison" or "recommendation" or "roadmap" or "kpi_dashboard")
{
slide["headline"] = BuildHeadline(slide, slideIndex, presentationTitle, objective, decisionAsk);
repairs++;
}
if (normalizedLayout == "recommendation" && string.IsNullOrWhiteSpace(ReadString(slide, "recommendation")))
{
slide["recommendation"] = Coalesce(decisionAsk, objective, "利됱떆 ?ㅽ뻾??沅뚭퀬?덉쓣 ??臾몄옣?쇰줈 ?쒖떆?⑸땲??");
repairs++;
}
if (normalizedLayout == "title" && string.IsNullOrWhiteSpace(ReadString(slide, "subtitle")))
{
slide["subtitle"] = Coalesce(objective, decisionAsk, "?듭떖 硫붿떆吏€?€ ?섏궗寃곗젙 ?ъ씤?몃? ?뺣━?⑸땲??");
repairs++;
}
return repairs;
}
private static int NormalizeMultilineText(JsonObject slide, string normalizedLayout)
{
var repairs = 0;
if (normalizedLayout == "content")
{
var lines = ReadTextLines(slide, "body");
if (lines.Count > 0)
{
var compact = string.Join("\n", lines.Take(5).Select(TrimBullet));
if (!string.Equals(compact, ReadString(slide, "body"), StringComparison.Ordinal))
{
slide["body"] = compact;
repairs++;
}
}
}
if (normalizedLayout == "two_column")
{
foreach (var key in new[] { "left", "right" })
{
var lines = ReadTextLines(slide, key);
if (lines.Count == 0)
continue;
var compact = string.Join("\n", lines.Take(5).Select(TrimBullet));
if (!string.Equals(compact, ReadString(slide, key), StringComparison.Ordinal))
{
slide[key] = compact;
repairs++;
}
}
}
return repairs;
}
private static JsonObject BuildTitleSlide(string title, string? audience, string? objective)
{
return new JsonObject
{
["layout"] = "title",
["title"] = Coalesce(title, "Presentation"),
["subtitle"] = Coalesce(objective, audience, "Core message and decision ask"),
};
}
private static JsonObject BuildExecutiveSummarySlide(IReadOnlyList<JsonObject> slides, string? objective, string? decisionAsk)
{
var findings = ExtractEvidenceLines(slides, 3);
var kpis = ExtractMetrics(slides, 3);
return new JsonObject
{
["layout"] = "executive_summary",
["title"] = "Executive Summary",
["headline"] = Coalesce(decisionAsk, objective, "?듭떖 ?ㅽ뻾?덉쓣 ?곗꽑 ?곸슜?섎㈃ ?④린 ?깃낵?€ 以묒옣湲??뺤옣?깆쓣 ?숈떆???뺣낫?????덉뒿?덈떎."),
["summary_points"] = ToJsonArray(findings.Count > 0
? findings
: ["?듭떖 ?댁뒋?€ ?ㅽ뻾 ?곗꽑?쒖쐞瑜????덉뿉 ?뺣━?덉뒿?덈떎.", "利됱떆 ?ㅽ뻾 怨쇱젣?€ 以묎린 怨쇱젣瑜?援щ텇?덉뒿?덈떎.", "寃쎌쁺吏??섏궗寃곗젙???꾪븳 ?듭떖 洹쇨굅瑜??뺤텞?덉뒿?덈떎."]),
["recommendation"] = Coalesce(decisionAsk, "?곗꽑?쒖쐞媛€ ?믪? 媛쒖꽑?덉쓣 癒쇱? ?ㅽ뻾?섍퀬, 蹂묓뻾 怨쇱젣???④퀎?곸쑝濡??뺤옣?⑸땲??"),
["kpis"] = ToMetricArray(kpis),
["next_steps"] = ToJsonArray(new[]
{
"?듭떖 ?섏궗寃곗젙 1嫄댁쓣 ?뺤젙?⑸땲??",
"1李??ㅽ뻾 踰붿쐞?€ 梨낆엫 二쇱껜瑜??뺥빀?덈떎.",
"?깃낵 異붿쟻 KPI瑜??붽컙 ?⑥쐞濡?由щ럭?⑸땲??",
}),
};
}
private static JsonObject BuildRecommendationSlide(IReadOnlyList<JsonObject> slides, string? objective, string? decisionAsk)
{
var reasons = ExtractEvidenceLines(slides, 4);
return new JsonObject
{
["layout"] = "recommendation",
["title"] = "Recommendation",
["headline"] = Coalesce(decisionAsk, objective, "媛€???④낵媛€ ???ㅽ뻾?덉쓣 癒쇱? ?좏깮?댁빞 ?⑸땲??"),
["recommendation"] = Coalesce(decisionAsk, "媛€移섍? ?믪? ?곗꽑 怨쇱젣??吏묒쨷?섍퀬, ?€?⑥쑉 ??ぉ?€ 異뺤냼 ?먮뒗 ?⑥닚?뷀빀?덈떎."),
["summary_points"] = ToJsonArray(reasons.Count > 0
? reasons
: ["?④린 ?④낵?€ ?ㅽ뻾 媛€?μ꽦???④퍡 怨좊젮?덉뒿?덈떎.", "由ъ뒪???€鍮??④낵媛€ ?믪? ?€?덉쓣 ?곗꽑?덉뒿?덈떎.", "?섏궗寃곗젙 ?댄썑 諛붾줈 ?ㅽ뻾 媛€?ν븳 ?≪뀡???꾩텧?덉뒿?덈떎."]),
["next_steps"] = ToJsonArray(new[]
{
"?뱀씤 踰붿쐞瑜??뺤젙?⑸땲??",
"梨낆엫?먯? ?쇱젙 湲곗??좎쓣 ?ㅼ젙?⑸땲??",
"珥덇린 ?깃낵瑜?2~4二??⑥쐞濡??먭??⑸땲??",
}),
};
}
private static JsonObject BuildRoadmapSlide(string? decisionAsk)
{
return new JsonObject
{
["layout"] = "roadmap",
["title"] = "Implementation Roadmap",
["headline"] = Coalesce(decisionAsk, "?④퀎蹂??곗꽑?쒖쐞?€ ?곗텧臾쇱쓣 湲곗??쇰줈 ?ㅽ뻾?⑸땲??"),
["phases"] = new JsonArray
{
new JsonObject
{
["title"] = "Phase 1",
["detail"] = "Diagnose and lock the phase-1 scope",
["timeline"] = "0-30 days",
["owner"] = "PM / Business",
},
new JsonObject
{
["title"] = "Phase 2",
["detail"] = "Build and validate early outcomes",
["timeline"] = "30-60 days",
["owner"] = "Delivery",
},
new JsonObject
{
["title"] = "Phase 3",
["detail"] = "Scale and stabilize the operating model",
["timeline"] = "60-90 days",
["owner"] = "Business / Ops",
},
},
};
}
private static JsonObject BuildAppendixSlide(IReadOnlyList<JsonObject> slides)
{
var lines = ExtractEvidenceLines(slides, 4);
return new JsonObject
{
["layout"] = "content",
["title"] = "Appendix & Evidence",
["body"] = string.Join("\n", (lines.Count > 0 ? lines : new List<string>
{
"洹쇨굅 ?곗씠?곗? ?곸꽭 ?댁꽍??遺€濡앹쑝濡??뺣━?⑸땲??",
"?듭떖 ?섏튂??異쒖쿂?€ 怨꾩궛 湲곗???遺€?고빀?덈떎.",
"?ㅽ뻾 ?④퀎?먯꽌 李멸퀬???곸꽭 硫붾え瑜??ы븿?⑸땲??",
}).Select(line => "- " + TrimBullet(line))),
};
}
private static void EnsureRiskRows(JsonObject slide)
{
slide["headers"] ??= new JsonArray("Risk", "Impact", "Likelihood", "Mitigation");
if (slide.ContainsKey("rows"))
return;
if (slide.TryGetPropertyValue("risks", out var risksNode) && risksNode is JsonArray risks && risks.Count > 0)
{
var rows = new JsonArray();
foreach (var risk in risks.OfType<JsonObject>().Take(6))
{
rows.Add(new JsonArray(
Coalesce(ReadString(risk, "title"), ReadString(risk, "risk"), "Key risk"),
Coalesce(ReadString(risk, "impact"), "Medium"),
Coalesce(ReadString(risk, "likelihood"), "Medium"),
Coalesce(ReadString(risk, "mitigation"), ReadString(risk, "action"), "Mitigation required")));
}
slide["rows"] = rows;
return;
}
slide["rows"] = new JsonArray
{
new JsonArray("Execution delay", "High", "Medium", "Lock owners and weekly steering"),
new JsonArray("Budget overrun", "Medium", "Medium", "Stage-gate investment decisions"),
new JsonArray("Change adoption", "High", "Low", "Sponsor-led change management"),
};
}
private static void EnsureChartFromBenefits(JsonObject slide)
{
if (slide.ContainsKey("chart_labels") && slide.ContainsKey("chart_values"))
return;
if (slide.TryGetPropertyValue("benefits", out var benefitsNode) && benefitsNode is JsonArray benefits && benefits.Count > 0)
{
var labels = new JsonArray();
var values = new JsonArray();
foreach (var benefit in benefits.OfType<JsonObject>().Take(6))
{
labels.Add(Coalesce(ReadString(benefit, "label"), ReadString(benefit, "title"), "Benefit"));
values.Add(ReadDouble(benefit, "value", 0));
}
slide["chart_labels"] = labels;
slide["chart_values"] = values;
slide["chart_series_name"] ??= "Benefit";
return;
}
slide["chart_labels"] = new JsonArray("Revenue uplift", "Cost reduction", "Retention");
slide["chart_values"] = new JsonArray(12, 8, 6);
slide["chart_series_name"] ??= "Impact";
}
private static void EnsureFallbackOptions(JsonObject slide)
{
if (slide.ContainsKey("options"))
return;
slide["options"] = new JsonArray
{
new JsonObject
{
["name"] = "Option A",
["pros"] = "鍮좊Ⅸ ?곸슜",
["cons"] = "?뺤옣???쒗븳",
["verdict"] = "Fastest",
},
new JsonObject
{
["name"] = "Option B",
["pros"] = "?④낵?€ ?ㅽ뻾?깆쓽 洹좏삎",
["cons"] = "珥덇린 ?뺣젹 ?꾩슂",
["verdict"] = "Recommended",
},
new JsonObject
{
["name"] = "Option C",
["pros"] = "High upside",
["cons"] = "由щ뱶?€??湲몄쓬",
["verdict"] = "Strategic",
},
};
}
private static bool HasChartData(JsonObject slide)
{
return slide.TryGetPropertyValue("chart_labels", out var labelsNode) &&
slide.TryGetPropertyValue("chart_values", out var valuesNode) &&
labelsNode is JsonArray labels &&
valuesNode is JsonArray values &&
labels.Count > 0 &&
values.Count > 0;
}
private static int ClampArray(JsonObject slide, string propertyName, int maxCount)
{
if (!slide.TryGetPropertyValue(propertyName, out var node) || node is not JsonArray array || array.Count <= maxCount)
return 0;
while (array.Count > maxCount)
array.RemoveAt(array.Count - 1);
return 1;
}
private static List<string> BuildStoryline(IReadOnlyList<JsonObject> slides, IReadOnlyList<string> hint)
{
if (hint.Count > 0)
return hint.ToList();
return slides
.Select(slide => ReadString(slide, "title"))
.Where(title => !string.IsNullOrWhiteSpace(title))
.Take(8)
.ToList();
}
private static string? SuggestTheme(string? objective, string? audience)
{
var source = (objective + " " + audience).Trim();
if (source.Contains("board", StringComparison.OrdinalIgnoreCase) ||
source.Contains("?꾩썝", StringComparison.OrdinalIgnoreCase) ||
source.Contains("executive", StringComparison.OrdinalIgnoreCase))
return "corporate";
if (source.Contains("finance", StringComparison.OrdinalIgnoreCase) ||
source.Contains("?щТ", StringComparison.OrdinalIgnoreCase))
return "slate";
if (source.Contains("strategy", StringComparison.OrdinalIgnoreCase) ||
source.Contains("?꾨왂", StringComparison.OrdinalIgnoreCase))
return "professional";
if (source.Contains("training", StringComparison.OrdinalIgnoreCase) ||
source.Contains("援먯쑁", StringComparison.OrdinalIgnoreCase))
return "modern";
return null;
}
private static List<JsonObject> ParseSlides(JsonElement slides)
{
var results = new List<JsonObject>();
if (slides.ValueKind != JsonValueKind.Array)
return results;
foreach (var slide in slides.EnumerateArray())
{
var node = JsonNode.Parse(slide.GetRawText()) as JsonObject;
if (node != null)
results.Add(node);
}
return results;
}
private static JsonArray ToJsonArray(IEnumerable<JsonObject> slides)
{
var array = new JsonArray();
foreach (var slide in slides)
array.Add(slide);
return array;
}
private static JsonArray ToJsonArray(IEnumerable<string> values)
{
var array = new JsonArray();
foreach (var value in values)
array.Add(value);
return array;
}
private static JsonArray ToMetricArray(IReadOnlyList<(string Label, string Value, string Trend, string Note)> metrics)
{
var array = new JsonArray();
foreach (var metric in metrics)
{
array.Add(new JsonObject
{
["label"] = metric.Label,
["value"] = metric.Value,
["trend"] = metric.Trend,
["note"] = metric.Note,
});
}
return array;
}
private static JsonArray BuildEvidenceRows(JsonArray evidence)
{
var rows = new JsonArray();
foreach (var item in evidence.OfType<JsonObject>().Take(6))
{
rows.Add(new JsonArray(
Coalesce(ReadString(item, "title"), ReadString(item, "label"), "Evidence"),
Coalesce(ReadString(item, "detail"), ReadString(item, "summary"), ReadString(item, "value"), "-"),
Coalesce(ReadString(item, "source"), ReadString(item, "note"), "-")));
}
return rows;
}
private static List<string> ExtractEvidenceLines(IReadOnlyList<JsonObject> slides, int maxCount)
{
var lines = new List<string>();
foreach (var slide in slides)
{
foreach (var line in ReadTextLines(slide, "summary_points"))
{
if (lines.Count >= maxCount)
return lines;
lines.Add(TrimBullet(line));
}
foreach (var line in ReadTextLines(slide, "body"))
{
if (lines.Count >= maxCount)
return lines;
lines.Add(TrimBullet(line));
}
}
return lines;
}
private static List<(string Label, string Value, string Trend, string Note)> ExtractMetrics(IReadOnlyList<JsonObject> slides, int maxCount)
{
var metrics = new List<(string Label, string Value, string Trend, string Note)>();
foreach (var slide in slides)
{
if (!slide.TryGetPropertyValue("kpis", out var node) || node is not JsonArray items)
continue;
foreach (var item in items.OfType<JsonObject>())
{
metrics.Add((
Coalesce(ReadString(item, "label"), ReadString(item, "title"), "Metric"),
Coalesce(ReadString(item, "value"), "-"),
Coalesce(ReadString(item, "trend"), string.Empty),
Coalesce(ReadString(item, "note"), string.Empty)));
if (metrics.Count >= maxCount)
return metrics;
}
}
if (metrics.Count == 0)
{
metrics.Add(("Value", "Impact", "Near term", "Priority"));
metrics.Add(("Risk", "Managed", "Execution", "Tracked"));
}
return metrics;
}
private static string BuildHeadline(JsonObject slide, int slideIndex, string presentationTitle, string? objective, string? decisionAsk)
{
var title = ReadString(slide, "title");
var recommendation = ReadString(slide, "recommendation");
var bodyLine = ReadTextLines(slide, "body").FirstOrDefault();
return Coalesce(
recommendation,
bodyLine,
objective,
decisionAsk,
title,
slideIndex == 0 ? presentationTitle : "?듭떖 硫붿떆吏€瑜???臾몄옣?쇰줈 ?뺣━?⑸땲??");
}
private static List<string> ReadHintList(JsonElement args, params string[] keys)
{
foreach (var key in keys)
{
if (!args.SafeTryGetProperty(key, out var value))
continue;
if (value.ValueKind == JsonValueKind.Array)
{
return value.EnumerateArray()
.Select(item => item.ValueKind == JsonValueKind.String ? item.SafeGetString() ?? string.Empty : string.Empty)
.Where(item => !string.IsNullOrWhiteSpace(item))
.ToList();
}
if (value.ValueKind == JsonValueKind.String)
{
return (value.SafeGetString() ?? string.Empty)
.Split(new[] { '\n', '/', '>', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(item => !string.IsNullOrWhiteSpace(item))
.ToList();
}
}
return [];
}
private static List<string> ReadTextLines(JsonObject slide, string propertyName)
{
if (!slide.TryGetPropertyValue(propertyName, out var node) || node == null)
return [];
return node switch
{
JsonArray array => array
.Select(item => item?.ToJsonString())
.Where(item => !string.IsNullOrWhiteSpace(item))
.Select(item => UnquoteJson(item ?? string.Empty))
.Select(TrimBullet)
.Where(item => !string.IsNullOrWhiteSpace(item))
.ToList(),
JsonValue => (node.ToJsonString().Trim('"'))
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(TrimBullet)
.Where(item => !string.IsNullOrWhiteSpace(item))
.ToList(),
_ => [],
};
}
private static string ToMultiline(JsonObject slide, params string[] propertyNames)
{
foreach (var propertyName in propertyNames)
{
var lines = ReadTextLines(slide, propertyName);
if (lines.Count > 0)
return string.Join("\n", lines.Take(5));
}
return string.Empty;
}
private static string ReadString(JsonElement element, string propertyName)
{
return element.SafeTryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String
? value.SafeGetString() ?? string.Empty
: string.Empty;
}
private static string ReadString(JsonObject obj, string propertyName)
{
if (!obj.TryGetPropertyValue(propertyName, out var node) || node == null)
return string.Empty;
return node switch
{
JsonValue value => UnquoteJson(value.ToJsonString()),
_ => string.Empty,
};
}
private static double ReadDouble(JsonObject obj, string propertyName, double fallback)
{
if (!obj.TryGetPropertyValue(propertyName, out var node) || node == null)
return fallback;
try
{
return JsonSerializer.Deserialize<double>(node.ToJsonString());
}
catch
{
return fallback;
}
}
private static string Coalesce(params string?[] values)
{
return values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty;
}
private static string TrimBullet(string line)
{
return line.Trim().TrimStart('-', '*', '\u2022', ' ', '\t');
}
private static string UnquoteJson(string value)
{
if (value.Length >= 2 && value[0] == '"' && value[^1] == '"')
{
try
{
return JsonSerializer.Deserialize<string>(value) ?? string.Empty;
}
catch
{
return value.Trim('"');
}
}
return value;
}
}

View File

@@ -0,0 +1,218 @@
using System.Text.Json;
using System.Text.RegularExpressions;
namespace AxCopilot.Services.Agent;
public enum DeckReviewSeverity
{
Info,
Warning,
Critical,
}
public sealed record DeckReviewIssue(string Message, DeckReviewSeverity Severity);
public sealed record DeckQualityReport(
int Score,
IReadOnlyList<string> Strengths,
IReadOnlyList<DeckReviewIssue> Issues,
IReadOnlyList<string> Storyline)
{
public string ToToolSummary()
{
var strengths = Strengths.Take(3).ToList();
var issues = Issues
.OrderByDescending(issue => issue.Severity)
.Take(3)
.Select(issue => issue.Message)
.ToList();
var parts = new List<string> { $"PPT quality {Score}/100" };
if (strengths.Count > 0)
parts.Add("Strengths: " + string.Join(", ", strengths));
if (issues.Count > 0)
parts.Add("Needs work: " + string.Join(", ", issues));
else
parts.Add("Needs work: none");
return string.Join(" | ", parts);
}
}
public static class DeckQualityReviewService
{
public static DeckQualityReport ReviewDeck(
string title,
JsonElement slides,
bool hasTemplate,
int autoRepairCount,
IEnumerable<string>? storyline = null)
{
var strengths = new List<string>();
var issues = new List<DeckReviewIssue>();
var storylineSteps = (storyline ?? []).Where(step => !string.IsNullOrWhiteSpace(step)).ToList();
const string KoreanAppendix = "\uBD80\uB85D";
if (slides.ValueKind != JsonValueKind.Array)
return new DeckQualityReport(40, strengths, [new("Slides must be an array.", DeckReviewSeverity.Critical)], storylineSteps);
var slidesList = slides.EnumerateArray().ToList();
var slideCount = slidesList.Count;
var titleSlides = 0;
var executiveSlides = 0;
var recommendationSlides = 0;
var roadmapSlides = 0;
var appendixSlides = 0;
var chartSlides = 0;
var tableSlides = 0;
var comparisonSlides = 0;
var textHeavySlides = 0;
var duplicateHeadlines = 0;
var placeholderCount = 0;
var headlines = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var layoutCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var slide in slidesList)
{
var layout = ReadString(slide, "layout", "content");
var normalizedLayout = layout.ToLowerInvariant();
layoutCounts[normalizedLayout] = layoutCounts.GetValueOrDefault(normalizedLayout) + 1;
switch (normalizedLayout)
{
case "title":
titleSlides++;
break;
case "executive_summary":
executiveSlides++;
break;
case "recommendation":
recommendationSlides++;
break;
case "roadmap":
roadmapSlides++;
break;
case "comparison":
comparisonSlides++;
break;
case "chart":
chartSlides++;
break;
case "table":
tableSlides++;
break;
}
var titleText = ReadString(slide, "title");
if (titleText.Contains("appendix", StringComparison.OrdinalIgnoreCase) ||
titleText.Contains(KoreanAppendix, StringComparison.OrdinalIgnoreCase) ||
titleText.Contains("evidence", StringComparison.OrdinalIgnoreCase))
{
appendixSlides++;
}
var headline = ReadString(slide, "headline");
if (!string.IsNullOrWhiteSpace(headline) && !headlines.Add(headline.Trim()))
duplicateHeadlines++;
var bulletCount =
ReadStringList(slide, "summary_points").Count +
ReadStringList(slide, "next_steps").Count +
ReadStringList(slide, "body").Count;
var bodyText = ReadString(slide, "body");
var textLength = bodyText.Length +
headline.Length +
ReadString(slide, "recommendation").Length +
ReadString(slide, "left").Length +
ReadString(slide, "right").Length;
if (bulletCount >= 7 || textLength >= 420)
textHeavySlides++;
placeholderCount += CountPlaceholders(slide.GetRawText());
}
if (hasTemplate) strengths.Add("Uses template or branded theme");
if (titleSlides > 0) strengths.Add("Includes title slide");
if (executiveSlides > 0) strengths.Add("Includes Executive Summary");
if (recommendationSlides > 0) strengths.Add("Includes Recommendation");
if (roadmapSlides > 0) strengths.Add("Includes Roadmap");
if (chartSlides + tableSlides + comparisonSlides >= 2)
strengths.Add("Contains evidence-oriented comparison, table, or chart slides");
if (layoutCounts.Count >= 4)
strengths.Add($"Uses {layoutCounts.Count} distinct layouts");
if (autoRepairCount > 0)
strengths.Add($"Auto-repair applied {autoRepairCount} time(s)");
if (slideCount < 4)
issues.Add(new("Slide count may be too low for an executive deck.", DeckReviewSeverity.Warning));
if (titleSlides == 0)
issues.Add(new("Title slide is missing.", DeckReviewSeverity.Warning));
if (executiveSlides == 0)
issues.Add(new("Executive Summary slide is missing.", DeckReviewSeverity.Critical));
if (recommendationSlides == 0)
issues.Add(new("Recommendation or decision request slide is missing.", DeckReviewSeverity.Critical));
if (roadmapSlides == 0 && slideCount >= 5)
issues.Add(new("Execution roadmap slide is missing.", DeckReviewSeverity.Warning));
if (appendixSlides == 0 && slideCount >= 6)
issues.Add(new("Appendix or evidence slide is limited.", DeckReviewSeverity.Info));
if (duplicateHeadlines > 0)
issues.Add(new($"Found {duplicateHeadlines} duplicate headline(s).", DeckReviewSeverity.Warning));
if (textHeavySlides > Math.Max(1, slideCount / 2))
issues.Add(new("Too many slides are text-heavy.", DeckReviewSeverity.Warning));
if (chartSlides + tableSlides + comparisonSlides == 0 && slideCount >= 4)
issues.Add(new("Evidence slides such as charts, tables, or comparisons are limited.", DeckReviewSeverity.Warning));
if (placeholderCount > 0)
issues.Add(new($"Found {placeholderCount} placeholder or unfinished marker(s).", DeckReviewSeverity.Critical));
var score = 70;
score += Math.Min(18, strengths.Count * 3);
score -= issues.Sum(issue => issue.Severity switch
{
DeckReviewSeverity.Critical => 12,
DeckReviewSeverity.Warning => 6,
_ => 2,
});
score = Math.Clamp(score, 30, 98);
return new DeckQualityReport(score, strengths, issues, storylineSteps);
}
private static string ReadString(JsonElement element, string propertyName, string fallback = "")
{
if (!element.SafeTryGetProperty(propertyName, out var value))
return fallback;
return value.ValueKind switch
{
JsonValueKind.String => value.SafeGetString() ?? fallback,
JsonValueKind.Number => value.ToString(),
_ => fallback,
};
}
private static List<string> ReadStringList(JsonElement element, string propertyName)
{
if (!element.SafeTryGetProperty(propertyName, out var value))
return [];
return value.ValueKind switch
{
JsonValueKind.Array => value.EnumerateArray()
.Select(item => item.ValueKind switch
{
JsonValueKind.String => item.SafeGetString() ?? string.Empty,
JsonValueKind.Number => item.ToString(),
JsonValueKind.Object => item.ToString(),
_ => string.Empty,
})
.Where(item => !string.IsNullOrWhiteSpace(item))
.ToList(),
JsonValueKind.String => (value.SafeGetString() ?? string.Empty)
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList(),
_ => [],
};
}
private static int CountPlaceholders(string text)
{
if (string.IsNullOrWhiteSpace(text))
return 0;
var patterns = new[]
{
@"\bTBD\b",
@"\bTODO\b",
@"\[(placeholder|fill me|todo)\]",
@"lorem ipsum",
};
return patterns.Sum(pattern => Regex.Matches(text, pattern, RegexOptions.IgnoreCase).Count);
}
}

View File

@@ -1,4 +1,4 @@
using System.IO;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
@@ -155,6 +155,27 @@ public class PptxSkill : IAgentTool
Description = "Slide aspect ratio. Default: widescreen (16:9)",
Enum = ["widescreen", "standard"]
},
["audience"] = new()
{
Type = "string",
Description = "Target audience such as executives, steering committee, PMO, sales leadership, or team leads.",
},
["objective"] = new()
{
Type = "string",
Description = "Presentation objective such as strategy recommendation, board update, PMO steering, operating model proposal, or KPI review.",
},
["decision_ask"] = new()
{
Type = "string",
Description = "The exact decision or approval being requested from the audience.",
},
["storyline"] = new()
{
Type = "array",
Description = "Optional storyline or ordered section hints.",
Items = new() { Type = "string" }
},
},
Required = ["slides"]
};
@@ -506,9 +527,15 @@ public class PptxSkill : IAgentTool
if (safe.Length > 60) safe = safe[..60].TrimEnd();
path = (string.IsNullOrWhiteSpace(safe) ? "presentation" : safe) + ".pptx";
}
var theme = args.SafeTryGetProperty("theme", out var th) ? th.SafeGetString() ?? "basic100" : "basic100";
var aspect = args.SafeTryGetProperty("aspect", out var asp) ? asp.SafeGetString() ?? "widescreen" : "widescreen";
var hasExplicitTheme = args.SafeTryGetProperty("theme", out var th) &&
!string.IsNullOrWhiteSpace(th.SafeGetString());
var theme = hasExplicitTheme ? th.SafeGetString() ?? "basic100" : "basic100";
var aspect = args.SafeTryGetProperty("aspect", out var asp) ? asp.SafeGetString() ?? "widescreen" : "widescreen";
var cloneAll = args.SafeTryGetProperty("clone_slides", out var cloneEl) && cloneEl.ValueKind == JsonValueKind.True;
var hasThemeFile = args.SafeTryGetProperty("theme_file", out var themeFileEl) &&
!string.IsNullOrWhiteSpace(themeFileEl.SafeGetString());
var hasExplicitTemplate = args.SafeTryGetProperty("template", out var templateArgEl) &&
!string.IsNullOrWhiteSpace(templateArgEl.SafeGetString());
// 전체 복제용 텍스트 교체 맵
Dictionary<string, string>? globalReplacements = null;
@@ -526,6 +553,20 @@ public class PptxSkill : IAgentTool
if (!hasSlidesArray && !cloneAll)
return ToolResult.Fail("slides 배열이 필요합니다.");
using var preparedDeck = hasSlidesArray
? DeckPlanningService.Prepare(args, slidesEl, presTitle)
: null;
if (preparedDeck != null)
{
slidesEl = preparedDeck.Slides;
if (!hasExplicitTheme && !hasExplicitTemplate && !hasThemeFile &&
!string.IsNullOrWhiteSpace(preparedDeck.SuggestedTheme))
{
theme = preparedDeck.SuggestedTheme!;
}
}
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
if (!fullPath.EndsWith(".pptx", StringComparison.OrdinalIgnoreCase))
@@ -923,9 +964,23 @@ public class PptxSkill : IAgentTool
: templatePptxPath != null
? $"theme_file:{cloneInfo_srcLabel} (master cloned{(cloneAll ? " + slides cloned" : "")})"
: theme;
return ToolResult.Ok(
$"✅ PPTX 생성 완료: {fullPath} ({slideCount_final}슬라이드, {themeLabel}, {(isWide ? "16:9" : "4:3")})",
fullPath);
var deckReview = hasSlidesArray
? DeckQualityReviewService.ReviewDeck(
presTitle,
slidesEl,
!string.IsNullOrWhiteSpace(templateName) || templatePptxPath != null,
preparedDeck?.AutoRepairCount ?? 0,
preparedDeck?.Storyline)
: null;
var outputParts = new List<string>
{
$"PPTX created: {fullPath} ({slideCount_final} slides, {themeLabel}, {(isWide ? "16:9" : "4:3")})"
};
if (preparedDeck != null)
outputParts.Add(preparedDeck.ToToolSummary());
if (deckReview != null)
outputParts.Add(deckReview.ToToolSummary());
return ToolResult.Ok(string.Join("\n", outputParts), fullPath);
}
catch (Exception ex)
{

View File

@@ -0,0 +1,21 @@
---
name: board-update
label: Board update deck
description: Create a concise board or executive update deck.
icon: \uE7BE
when_to_use: Use for board reporting, steering committee updates, quarterly executive reviews, or decision update packs.
argument-hint: <update topic>
allowed-tools:
- document_plan
- document_read
- file_read
- pptx_create
tabs: cowork
---
Create a concise board update deck with `pptx_create`.
- Set `audience` to `board` or `executives`.
- Set `objective` to `board update`.
- Aim for 6-8 slides when possible.
- Include `Executive Summary`, `Key Findings`, `Recommendation`, `Roadmap`, and `Impact & Ask`.

View File

@@ -0,0 +1,20 @@
---
name: operating-model-deck
label: Operating model deck
description: Create a deck that explains current and target operating models with transition steps.
icon: \uE7BE
when_to_use: Use for operating model proposals, process redesign decks, governance changes, or target-state transformation materials.
argument-hint: <operating model topic>
allowed-tools:
- document_plan
- document_read
- file_read
- pptx_create
tabs: cowork
---
Create an operating model deck with `pptx_create`.
- Set `objective` to `operating model proposal`.
- Prefer `before_after`, `operating_model`, `recommendation`, and `roadmap`.
- Separate current state, target state, transition actions, and governance implications.

View File

@@ -0,0 +1,20 @@
---
name: pmo-steering
label: PMO steering deck
description: Create a project or program steering deck with risks, status, and decisions.
icon: \uE7BE
when_to_use: Use for PMO weekly or monthly updates, project steering meetings, or risk and decision review decks.
argument-hint: <project or program name>
allowed-tools:
- document_plan
- document_read
- file_read
- pptx_create
tabs: cowork
---
Create a PMO steering deck with `pptx_create`.
- Set `objective` to `program steering update`.
- Prefer `risk_heatmap`, `roadmap`, `kpi_dashboard`, and `recommendation`.
- Separate current status, key risks, decisions needed, and next actions.

View File

@@ -1,10 +1,10 @@
---
name: pptx-creator
label: PPT 프레젠테이션 생성
description: AX 기본 PPT 엔진으로 고급 제안서, 보고서, 발표 자료를 생성합니다. 작업 폴더의 양식 파일과 프로젝트 자료를 함께 활용합니다.
label: PPT deck creator
description: Create consulting-style PowerPoint decks with AX native PPT generation.
icon: \uE7BE
when_to_use: 제안서 deck, 경영보고, 임원 보고자료, 프로젝트 현황 발표, 교육용 슬라이드, 회의 발표 자료를 새로 만들어야 하거나 기존 양식 PPT를 활용해야 할 때
argument-hint: <주제 또는 문서 목적>
when_to_use: Use for executive decks, board updates, PMO steering materials, operating model proposals, KPI review decks, or any request that needs a polished PPTX deliverable.
argument-hint: <topic or deck objective>
allowed-tools:
- folder_map
- document_plan
@@ -14,96 +14,68 @@ allowed-tools:
tabs: cowork
---
사용자의 요구에 맞는 PowerPoint 프레젠테이션을 AX 기본 `pptx_create` 도구로 생성하세요.
Use `pptx_create` as the default path. Do not start with ad-hoc slide arrays unless the user already gave a very detailed deck structure.
## 기본 원칙
- 기본 경로는 항상 `pptx_create`입니다. Python 스크립트 생성은 예외 상황에서만 고려하세요.
- 슬라이드마다 메시지는 하나만 두고, 제목은 설명형 문장이 아니라 결론형 headline으로 작성하세요.
- bullet은 슬라이드당 3~5개 이내의 짧고 강한 문장으로 유지하세요. 공간을 채우기 위한 장문 bullet은 금지합니다.
- 숫자, 비교, 일정, 권고안은 각각 맞는 레이아웃으로 분리하세요.
## Default workflow
1. Review the workspace for existing presentation files, reports, or templates.
2. Use `document_plan` with `document_type: presentation` to define the storyline first.
3. Build a deck brief with `audience`, `objective`, `decision_ask`, and optional `storyline`.
4. Generate the deck with `pptx_create`.
5. Return the file path together with the storyline and quality summary.
## 권장 작업 순서
1. `folder_map`으로 작업 폴더를 확인하고 기존 `.pptx`, 보고서, 분석 문서, 회의록이 있는지 찾으세요.
2. 참고할 양식 PPT가 있으면 파일명을 기억하고, 필요 시 `document_read`로 슬라이드 구조를 파악하세요.
3. 먼저 `document_plan``document_type: presentation`으로 호출해서 스토리라인을 정리하세요.
4. 그 결과를 바탕으로 `pptx_create`용 슬라이드 배열을 설계하세요.
5. 생성 후 결과 파일 경로와 핵심 구성(슬라이드 수, 사용 템플릿/테마, 주요 레이아웃)을 간단히 안내하세요.
## Quality rules
- Keep one message per slide.
- Prefer 3-5 concise bullets, not long paragraphs.
- Use a message headline, not a vague topic label.
- Include `Executive Summary`, `Recommendation`, and `Roadmap` in most business decks.
- Add `Appendix` or `Evidence` when the deck is 6 slides or longer.
- Use comparison, chart, KPI, or evidence slides when data exists.
## 컨설팅형 스토리라인 규칙
- 기본 구조는 가능하면 아래 순서를 따르세요.
- `Executive Summary`
- `Situation & Imperative`
- `Key Findings`
- `Options & Recommendation`
- `Implementation Roadmap`
- `Impact & Ask`
## Useful layouts
- `title`
- `executive_summary`
- `comparison`
- `recommendation`
- `roadmap`
- `kpi_dashboard`
- `chart`
- `table`
- `issue_tree`
- `before_after`
- `decision_matrix`
- `risk_heatmap`
- `benefit_waterfall`
- `operating_model`
- `appendix_evidence`
## 레이아웃 선택 기준
- `executive_summary`: 경영진 요약, 한 줄 권고안, 핵심 takeaways, KPI 요약
- `recommendation`: 단일 권고안과 근거, 즉시 실행 항목
- `comparison`: 대안 비교, 옵션별 pros/risks, 추천안 강조
- `roadmap`: 단계별 일정, 소유자, 주요 산출물
- `kpi_dashboard`: 핵심 수치, 추세, 시사점
- `chart`: 정량 데이터 시각화
- `table`: 비교표, 상세 데이터표
- `section`: 장 구분
- `content`: 일반 설명 슬라이드
## 양식 활용
- 작업 폴더에 기존 양식 PPT가 있으면 `template` 또는 `theme_file`로 우선 활용하세요.
- 양식 후보 예시:
- 파일명에 `양식`, `template`, `표준`, `기본`, `보고`, `proposal`, `deck` 포함
- 사용자가 특정 `.pptx` 파일명을 직접 언급
- 양식이 명확하지 않으면 기본 `template: basic100` 또는 문서 성격에 맞는 `corporate`, `professional`, `modern` 중 하나를 사용하세요.
## 슬라이드 작성 규칙
- 제목은 `무엇을 다룰지`가 아니라 `무슨 결론인지`를 말해야 합니다.
- 각 슬라이드는 headline, supporting evidence, takeaway의 3요소를 갖추세요.
- 데이터가 없으면 억지 차트를 만들지 말고, 비교 카드나 권고안 슬라이드로 전환하세요.
- 표는 설명보다 비교에 유리할 때만 사용하세요.
- speaker notes가 유용하면 `notes`를 넣어 발표 포인트를 남기세요.
## 예시 흐름
- `document_plan`으로 발표 구조 생성
- `pptx_create`에서 아래와 같은 레이아웃 조합 사용
- `title`
- `executive_summary`
- `comparison`
- `recommendation`
- `roadmap`
- `kpi_dashboard`
- `section`
- `content`
## 예시 파라미터 스케치
## Example
```json
{
"path": "strategy-deck.pptx",
"template": "basic100",
"audience": "executive committee",
"objective": "customer growth strategy recommendation",
"decision_ask": "approve phase-1 CRM and retention program funding",
"storyline": [
"Executive Summary",
"Current State",
"Options",
"Recommendation",
"Roadmap",
"Impact & Ask"
],
"slides": [
{
"layout": "title",
"title": "2026 사업 전략 제안",
"subtitle": "성장 가속과 운영 효율 동시 달성"
},
{
"layout": "executive_summary",
"title": "Executive Summary",
"headline": "핵심 투자 우선순위를 재배치하면 2개 분기 내 수익성을 개선할 수 있습니다.",
"headline": "Focus on retention and CRM automation to restore growth within two quarters.",
"summary_points": [
"핵심 비용 3개 영역에서 구조적 비효율이 확인되었습니다.",
"고객 유지율 개선이 신규 확보보다 더 큰 수익 기회를 만듭니다.",
"선행 투자 없이 가능한 빠른 실행과제도 존재합니다."
"Three priority cohorts explain most of the growth gap.",
"Workflow automation reduces manual operating cost.",
"Phase-1 actions and medium-term changes are clearly separated."
],
"recommendation": "우선 CRM 고도화와 운영 자동화를 병행하고, 저효율 캠페인은 즉시 축소합니다.",
"kpis": [
{ "label": "매출총이익", "value": "+4.2%p", "trend": "2Q forecast", "note": "product mix 개선" },
{ "label": "CAC", "value": "-11%", "trend": "target", "note": "퍼널 최적화" },
{ "label": "Retention", "value": "+6pt", "trend": "12M", "note": "핵심 세그먼트" }
]
"recommendation": "Approve phase-1 CRM upgrade and retention program launch."
}
]
}
```
한국어로 안내하고, 결과 파일은 작업 폴더에 저장하세요.

View File

@@ -0,0 +1,20 @@
---
name: sales-review-deck
label: Sales review deck
description: Create a sales review deck with KPI, pipeline, and growth actions.
icon: \uE7BE
when_to_use: Use for sales performance reviews, pipeline reviews, or growth action meetings.
argument-hint: <review topic>
allowed-tools:
- document_plan
- document_read
- file_read
- pptx_create
tabs: cowork
---
Create a sales review deck with `pptx_create`.
- Set `objective` to `sales review`.
- Prefer `kpi_dashboard`, `comparison`, `recommendation`, and `roadmap`.
- Use numeric KPI, segment insight, and pipeline evidence when available.

View File

@@ -0,0 +1,21 @@
---
name: strategy-deck
label: Strategy deck
description: Create a consulting-style strategy recommendation deck for leadership.
icon: \uE7BE
when_to_use: Use for business strategy recommendations, growth agenda reviews, priority-setting decks, or executive decision materials.
argument-hint: <strategy topic>
allowed-tools:
- document_plan
- document_read
- file_read
- pptx_create
tabs: cowork
---
Create a strategy recommendation deck with `pptx_create`.
- Set `audience` to `executive committee` or `leadership team`.
- Set `objective` to `strategy recommendation`.
- Prefer `Executive Summary -> Situation -> Key Findings -> Options -> Recommendation -> Roadmap -> Impact & Ask`.
- Prefer `professional` or `corporate` themes unless a branded template exists.