diff --git a/README.md b/README.md index 5d82f94..52dff18 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index abe884b..c16af34 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -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 diff --git a/src/AxCopilot.Tests/Services/DeckPlanningServiceTests.cs b/src/AxCopilot.Tests/Services/DeckPlanningServiceTests.cs new file mode 100644 index 0000000..c43d167 --- /dev/null +++ b/src/AxCopilot.Tests/Services/DeckPlanningServiceTests.cs @@ -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(); + } +} diff --git a/src/AxCopilot.Tests/Services/DeckQualityReviewServiceTests.cs b/src/AxCopilot.Tests/Services/DeckQualityReviewServiceTests.cs new file mode 100644 index 0000000..80fe93d --- /dev/null +++ b/src/AxCopilot.Tests/Services/DeckQualityReviewServiceTests.cs @@ -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); + } +} diff --git a/src/AxCopilot.Tests/Services/PptxSkillAutoRepairTests.cs b/src/AxCopilot.Tests/Services/PptxSkillAutoRepairTests.cs new file mode 100644 index 0000000..e01b596 --- /dev/null +++ b/src/AxCopilot.Tests/Services/PptxSkillAutoRepairTests.cs @@ -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 + { + } + } + } +} + diff --git a/src/AxCopilot/Services/Agent/DeckPlanningService.cs b/src/AxCopilot/Services/Agent/DeckPlanningService.cs new file mode 100644 index 0000000..aeea39d --- /dev/null +++ b/src/AxCopilot/Services/Agent/DeckPlanningService.cs @@ -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 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(); + 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(); + 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 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 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 slides) + { + var lines = ExtractEvidenceLines(slides, 4); + return new JsonObject + { + ["layout"] = "content", + ["title"] = "Appendix & Evidence", + ["body"] = string.Join("\n", (lines.Count > 0 ? lines : new List + { + "洹쇨굅 ?곗씠?곗? ?곸꽭 ?댁꽍??遺€濡앹쑝濡??뺣━?⑸땲??", + "?듭떖 ?섏튂??異쒖쿂?€ 怨꾩궛 湲곗???遺€?고빀?덈떎.", + "?ㅽ뻾 ?④퀎?먯꽌 李멸퀬???곸꽭 硫붾え瑜??ы븿?⑸땲??", + }).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().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().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 BuildStoryline(IReadOnlyList slides, IReadOnlyList 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 ParseSlides(JsonElement slides) + { + var results = new List(); + 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 slides) + { + var array = new JsonArray(); + foreach (var slide in slides) + array.Add(slide); + return array; + } + + private static JsonArray ToJsonArray(IEnumerable 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().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 ExtractEvidenceLines(IReadOnlyList slides, int maxCount) + { + var lines = new List(); + 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 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()) + { + 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 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 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(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(value) ?? string.Empty; + } + catch + { + return value.Trim('"'); + } + } + + return value; + } +} + diff --git a/src/AxCopilot/Services/Agent/DeckQualityReviewService.cs b/src/AxCopilot/Services/Agent/DeckQualityReviewService.cs new file mode 100644 index 0000000..8bd9a1f --- /dev/null +++ b/src/AxCopilot/Services/Agent/DeckQualityReviewService.cs @@ -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 Strengths, + IReadOnlyList Issues, + IReadOnlyList 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 { $"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? storyline = null) + { + var strengths = new List(); + var issues = new List(); + 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(StringComparer.OrdinalIgnoreCase); + var layoutCounts = new Dictionary(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 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); + } +} + diff --git a/src/AxCopilot/Services/Agent/PptxSkill.cs b/src/AxCopilot/Services/Agent/PptxSkill.cs index 12bd4bf..a76c662 100644 --- a/src/AxCopilot/Services/Agent/PptxSkill.cs +++ b/src/AxCopilot/Services/Agent/PptxSkill.cs @@ -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? 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 + { + $"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) { diff --git a/src/AxCopilot/skills/board-update.skill.md b/src/AxCopilot/skills/board-update.skill.md new file mode 100644 index 0000000..cdf43ff --- /dev/null +++ b/src/AxCopilot/skills/board-update.skill.md @@ -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: +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`. diff --git a/src/AxCopilot/skills/operating-model-deck.skill.md b/src/AxCopilot/skills/operating-model-deck.skill.md new file mode 100644 index 0000000..a299f61 --- /dev/null +++ b/src/AxCopilot/skills/operating-model-deck.skill.md @@ -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: +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. diff --git a/src/AxCopilot/skills/pmo-steering.skill.md b/src/AxCopilot/skills/pmo-steering.skill.md new file mode 100644 index 0000000..b766b89 --- /dev/null +++ b/src/AxCopilot/skills/pmo-steering.skill.md @@ -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: +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. diff --git a/src/AxCopilot/skills/pptx-creator.skill.md b/src/AxCopilot/skills/pptx-creator.skill.md index abc1766..b78b93b 100644 --- a/src/AxCopilot/skills/pptx-creator.skill.md +++ b/src/AxCopilot/skills/pptx-creator.skill.md @@ -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: 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." } ] } ``` - -한국어로 안내하고, 결과 파일은 작업 폴더에 저장하세요. diff --git a/src/AxCopilot/skills/sales-review-deck.skill.md b/src/AxCopilot/skills/sales-review-deck.skill.md new file mode 100644 index 0000000..9155611 --- /dev/null +++ b/src/AxCopilot/skills/sales-review-deck.skill.md @@ -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: +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. diff --git a/src/AxCopilot/skills/strategy-deck.skill.md b/src/AxCopilot/skills/strategy-deck.skill.md new file mode 100644 index 0000000..fb15cdb --- /dev/null +++ b/src/AxCopilot/skills/strategy-deck.skill.md @@ -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: +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.