diff --git a/README.md b/README.md index dd3004f..a034007 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,14 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저 개발 참고: Claw Code 동등성 작업 추적 문서 `docs/claw-code-parity-plan.md` +- 업데이트: 2026-04-15 15:45 (KST) +- Cowork의 PPT 생성 경로를 더 일반적인 고품질 흐름으로 보강했습니다. [ChatWindow.SystemPromptBuilder.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.SystemPromptBuilder.cs)와 [AgentLoopExplorationPolicy.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopExplorationPolicy.cs)는 이제 프레젠테이션/슬라이드 덱 요청에서 `document_plan`을 무조건 먼저 타지 않고, 명시적으로 계획을 요구한 경우가 아니면 `pptx_create`를 우선하도록 안내합니다. +- [DeckPlanningService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/DeckPlanningService.cs)에 generic `RefineForQuality(...)` 루프를 추가했습니다. executive summary, comparison, roadmap, chart, KPI dashboard 같은 특화 슬라이드에서 headline, takeaway, verdict, owner/timeline, KPI trend/note를 자동 보강하고, 필요하면 appendix/evidence 슬라이드까지 추가합니다. +- [PptxSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PptxSkill.cs)는 초기 deck review 결과가 약할 때 한 번 더 자동 보정을 수행한 뒤 점수와 이슈가 실제로 개선된 경우에만 refined deck을 최종 렌더링에 사용합니다. 특정 업종 전용이 아니라 어떤 PPT 요청에도 적용되는 공통 품질 루프입니다. +- 테스트: [DeckPlanningServiceTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/DeckPlanningServiceTests.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_generic_quality\\ -p:IntermediateOutputPath=obj\\verify_ppt_generic_quality\\` 경고 0 / 오류 0 +- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "DeckPlanningServiceTests|PptxSkillAutoRepairTests|PptxSkillGoldenDeckTests|DeckQualityReviewServiceTests" -p:OutputPath=bin\\verify_ppt_generic_quality_tests\\ -p:IntermediateOutputPath=obj\\verify_ppt_generic_quality_tests\\` 통과 14 + - 업데이트: 2026-04-15 15:18 (KST) - 코워크/코드 내부설정의 최대 컨텍스트 토큰 기본값을 32K로 올렸습니다. [AppSettings.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Models/AppSettings.cs)의 `MaxContextTokens` 기본값을 `32_768`로 변경해 신규 설정/신규 세션이 바로 32K 기준으로 시작됩니다. - [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml)과 [SettingsWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml.cs)에는 `32K` 선택 카드를 추가해 메인 설정에서도 기본 선택과 표시가 어긋나지 않게 맞췄습니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 3dd9d14..d18b05e 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -49,6 +49,13 @@ - 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentStatusNarrativeCatalogTests|AgentLoopIterationPreparationServiceTests|AgentToolResultBudgetTests|ChatStorageServiceTests|AgentMessageInvariantHelperTests" -p:OutputPath=bin\\verify_status_narrative_tests\\ -p:IntermediateOutputPath=obj\\verify_status_narrative_tests\\` 통과 15 업데이트: 2026-04-14 19:50 (KST) +업데이트: 2026-04-15 15:45 (KST) +- Cowork PPT 생성 경로를 특정 업종 전용 archetype이 아니라 공통 품질 루프로 강화했습니다. `src/AxCopilot/Views/ChatWindow.SystemPromptBuilder.cs`와 `src/AxCopilot/Services/Agent/AgentLoopExplorationPolicy.cs`는 presentation/deck 요청에서 `document_plan`을 무조건 선행하지 않고, 계획 요청이 명시되지 않으면 `pptx_create`를 우선하도록 안내합니다. +- `src/AxCopilot/Services/Agent/DeckPlanningService.cs`에 `RefineForQuality(...)`를 추가했습니다. 이 루프는 executive summary, recommendation, comparison, roadmap, chart, KPI dashboard 슬라이드를 다시 점검해 summary takeaways, verdict/trade-off, timeline/owner/detail, KPI trend/note, chart takeaway, appendix evidence를 자동으로 보강합니다. +- `src/AxCopilot/Services/Agent/PptxSkill.cs`는 초기 `DeckQualityReviewService.ReviewDeck(...)` 결과가 약할 때 한 번 더 보정한 deck을 만들고, 실제로 점수/경고가 개선된 경우에만 refined deck을 최종 렌더링에 사용합니다. 결과적으로 weak deck은 한 번 더 자동으로 다듬어진 뒤 export됩니다. +- 테스트: `src/AxCopilot.Tests/Services/DeckPlanningServiceTests.cs`, `src/AxCopilot.Tests/Services/PptxSkillAutoRepairTests.cs` +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_ppt_generic_quality\\ -p:IntermediateOutputPath=obj\\verify_ppt_generic_quality\\` 경고 0 / 오류 0 +- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "DeckPlanningServiceTests|PptxSkillAutoRepairTests|PptxSkillGoldenDeckTests|DeckQualityReviewServiceTests" -p:OutputPath=bin\\verify_ppt_generic_quality_tests\\ -p:IntermediateOutputPath=obj\\verify_ppt_generic_quality_tests\\` 통과 14 - Agent loop/queue/context 품질을 보강했습니다. `src/AxCopilot/Services/Agent/AgentCommandQueue.cs`로 실행 중 추가 입력을 우선순위와 interrupt 여부까지 포함해 관리하고, `AgentLoopService`는 이를 안전하게 반영합니다. - `AgentToolResultBudget`, `AgentQueryContextBuilder`, `ChatModels`는 tool result preview를 메시지에 캐시해 긴 세션과 재질문에서도 같은 축약 결과를 재사용하도록 정리했습니다. - 코드 탭의 내장 언어 지원을 `src/AxCopilot/Services/CodeLanguageCatalog.cs`로 통합했고, 설정의 코드 탭에 지원 언어(LSP)와 코드 탭 기본 지원 언어를 명시적으로 표시합니다. diff --git a/src/AxCopilot.Tests/Services/DeckPlanningServiceTests.cs b/src/AxCopilot.Tests/Services/DeckPlanningServiceTests.cs index 6942020..bdcdd32 100644 --- a/src/AxCopilot.Tests/Services/DeckPlanningServiceTests.cs +++ b/src/AxCopilot.Tests/Services/DeckPlanningServiceTests.cs @@ -98,4 +98,97 @@ public class DeckPlanningServiceTests layouts.Should().Contain("comparison"); layouts.Should().Contain("roadmap"); } + + [Fact] + public void RefineForQuality_ShouldStrengthenWeakSpecializedSlides() + { + using var args = JsonDocument.Parse( + """ + { + "title": "Executive Operating Review", + "objective": "improve execution quality", + "decision_ask": "approve the first delivery wave", + "slides": [ + { + "layout": "executive_summary", + "title": "Executive Summary", + "body": [ + "Improve execution quality", + "Approve the first delivery wave" + ] + }, + { + "layout": "comparison", + "title": "Execution Options", + "options": [ + { "name": "Pilot" }, + { "name": "Wave rollout" } + ] + }, + { + "layout": "roadmap", + "title": "Roadmap", + "phases": [ + { "title": "Kickoff", "owner": "담당 미정" } + ] + }, + { + "layout": "chart", + "title": "Trend", + "chart_labels": ["Q1", "Q2", "Q3"], + "chart_values": [100, 120, 138] + }, + { + "layout": "kpi_dashboard", + "title": "KPI Snapshot", + "kpis": [ + { "label": "Revenue", "value": "+12%" }, + { "label": "Cycle Time", "value": "-8%" }, + { "label": "Quality", "value": "97%" } + ] + } + ] + } + """); + + using var prepared = DeckPlanningService.Prepare(args.RootElement, args.RootElement.GetProperty("slides"), "Executive Operating Review"); + var review = DeckQualityReviewService.ReviewDeck( + "Executive Operating Review", + prepared.Slides, + hasTemplate: false, + prepared.AutoRepairCount, + prepared.Storyline); + + using var refined = DeckPlanningService.RefineForQuality(prepared, review, "Executive Operating Review"); + var slides = refined.Slides.EnumerateArray().ToList(); + + var executive = slides.First(slide => slide.GetProperty("layout").GetString() == "executive_summary"); + executive.TryGetProperty("summary_points", out var executivePoints).Should().BeTrue(); + executivePoints.GetArrayLength().Should().BeGreaterThan(0); + executive.TryGetProperty("kpis", out var executiveKpis).Should().BeTrue(); + executiveKpis.GetArrayLength().Should().BeGreaterThan(0); + + var comparison = slides.First(slide => slide.GetProperty("layout").GetString() == "comparison"); + comparison.TryGetProperty("options", out var options).Should().BeTrue(); + options[0].GetProperty("verdict").GetString().Should().NotBeNullOrWhiteSpace(); + options[0].GetProperty("summary").GetString().Should().NotBeNullOrWhiteSpace(); + + var roadmap = slides.First(slide => slide.GetProperty("layout").GetString() == "roadmap"); + var firstPhase = roadmap.GetProperty("phases")[0]; + firstPhase.GetProperty("owner").GetString().Should().NotContain("미정"); + firstPhase.GetProperty("timeline").GetString().Should().NotBeNullOrWhiteSpace(); + firstPhase.GetProperty("detail").GetString().Should().NotBeNullOrWhiteSpace(); + + var chart = slides.First(slide => slide.GetProperty("layout").GetString() == "chart"); + chart.TryGetProperty("summary_points", out var chartPoints).Should().BeTrue(); + chartPoints.GetArrayLength().Should().BeGreaterThan(0); + + var kpi = slides.First(slide => slide.GetProperty("layout").GetString() == "kpi_dashboard"); + kpi.TryGetProperty("summary_points", out var kpiPoints).Should().BeTrue(); + kpiPoints.GetArrayLength().Should().BeGreaterThan(0); + kpi.GetProperty("kpis")[0].GetProperty("trend").GetString().Should().NotBeNullOrWhiteSpace(); + kpi.GetProperty("kpis")[0].GetProperty("note").GetString().Should().NotBeNullOrWhiteSpace(); + + refined.AutoRepairCount.Should().BeGreaterThan(prepared.AutoRepairCount); + } } diff --git a/src/AxCopilot.Tests/Services/PptxSkillAutoRepairTests.cs b/src/AxCopilot.Tests/Services/PptxSkillAutoRepairTests.cs index e01b596..521dfcd 100644 --- a/src/AxCopilot.Tests/Services/PptxSkillAutoRepairTests.cs +++ b/src/AxCopilot.Tests/Services/PptxSkillAutoRepairTests.cs @@ -75,5 +75,91 @@ public class PptxSkillAutoRepairTests } } } + + [Fact] + public async Task ExecuteAsync_ShouldRefineWeakDeckBeforeExport() + { + var workDir = Path.Combine(Path.GetTempPath(), "ax-pptx-refine-" + 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": "refined-deck.pptx", + "title": "Quarterly Steering Deck", + "objective": "approve the next delivery wave", + "slides": [ + { + "layout": "executive_summary", + "title": "Executive Summary", + "body": [ + "Approve the next delivery wave", + "The delivery plan should move forward" + ] + }, + { + "layout": "comparison", + "title": "Options", + "options": [ + { "name": "Conservative" }, + { "name": "Balanced" } + ] + }, + { + "layout": "roadmap", + "title": "Roadmap", + "phases": [ + { "title": "Kickoff", "owner": "담당 미정" } + ] + }, + { + "layout": "chart", + "title": "Trend", + "chart_labels": ["Jan", "Feb", "Mar"], + "chart_values": [10, 13, 16] + }, + { + "layout": "kpi_dashboard", + "title": "KPI Snapshot", + "kpis": [ + { "label": "Revenue", "value": "+9%" }, + { "label": "Cycle Time", "value": "-6%" }, + { "label": "Stability", "value": "96%" } + ] + } + ] + } + """).RootElement; + + var result = await tool.ExecuteAsync(args, context, CancellationToken.None); + + result.Success.Should().BeTrue(); + result.Output.Should().Contain("PPT quality"); + result.Output.Should().Contain("Needs work: none"); + result.Output.Should().Contain("Auto-repair:"); + File.Exists(Path.Combine(workDir, "refined-deck.pptx")).Should().BeTrue(); + } + finally + { + try + { + if (Directory.Exists(workDir)) + Directory.Delete(workDir, true); + } + catch + { + } + } + } } diff --git a/src/AxCopilot/Services/Agent/AgentLoopExplorationPolicy.cs b/src/AxCopilot/Services/Agent/AgentLoopExplorationPolicy.cs index 40f8473..8d919ad 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopExplorationPolicy.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopExplorationPolicy.cs @@ -206,7 +206,7 @@ public partial class AgentLoopService if (string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase)) return "file_write -> file_edit -> build_run/test_loop as needed"; - return "document_plan -> docx_create/html_create/excel_create -> self-review"; + return "pptx_create for presentations/decks, otherwise document_plan -> creation tool -> self-review"; } if (HasExplicitFolderIntent(userQuery)) @@ -336,7 +336,8 @@ public partial class AgentLoopService "Exploration scope = direct-creation. The user wants to CREATE a new file or document. " + "Do NOT search for existing files with glob/grep/folder_map unless the user explicitly asked to inspect the workspace. " + "If you are in the Code tab, call file_write immediately with a relative path inside the current work folder, then use file_edit/build_run/test_loop only if needed. " + - "If you are in Cowork, call document_plan first and then the appropriate creation tool. " + + "If you are in Cowork and the user asked for a presentation, deck, slide pack, or PPT, call pptx_create directly unless the user explicitly asked for a plan or outline. " + + "Use document_plan first only when you are drafting a multi-section written document or when structure planning materially improves the result. " + "The output MUST be a real file on disk, not a text response."; } diff --git a/src/AxCopilot/Services/Agent/DeckPlanningService.cs b/src/AxCopilot/Services/Agent/DeckPlanningService.cs index dd7072e..86b1528 100644 --- a/src/AxCopilot/Services/Agent/DeckPlanningService.cs +++ b/src/AxCopilot/Services/Agent/DeckPlanningService.cs @@ -147,6 +147,118 @@ public static class DeckPlanningService }; } + public static DeckPreparationResult RefineForQuality( + DeckPreparationResult prepared, + DeckQualityReport review, + string presentationTitle) + { + var slideNodes = ParseSlides(prepared.Slides); + var repairs = 0; + var injectedAppendix = prepared.InjectedAppendix; + + for (var i = 0; i < slideNodes.Count; i++) + { + repairs += RefineSlideForQuality( + slideNodes[i], + i, + presentationTitle, + prepared.Objective, + prepared.DecisionAsk); + repairs += NormalizeSlide( + slideNodes[i], + i, + presentationTitle, + prepared.Objective, + prepared.DecisionAsk); + } + + if (ShouldAddEvidenceAppendix(review) && !HasAppendixSlide(slideNodes)) + { + slideNodes.Add(BuildAppendixSlide(slideNodes)); + injectedAppendix = true; + repairs++; + } + + var storyline = BuildStoryline(slideNodes, prepared.Storyline); + var json = JsonSerializer.SerializeToUtf8Bytes(ToJsonArray(slideNodes)); + var document = JsonDocument.Parse(json); + + return new DeckPreparationResult + { + SlidesDocument = document, + Storyline = storyline, + AutoRepairCount = prepared.AutoRepairCount + repairs, + Audience = prepared.Audience, + Objective = prepared.Objective, + DecisionAsk = prepared.DecisionAsk, + SuggestedTheme = prepared.SuggestedTheme, + InjectedTitleSlide = prepared.InjectedTitleSlide, + InjectedExecutiveSummary = prepared.InjectedExecutiveSummary, + InjectedRecommendation = prepared.InjectedRecommendation, + InjectedRoadmap = prepared.InjectedRoadmap, + InjectedAppendix = injectedAppendix, + }; + } + + private static int RefineSlideForQuality( + JsonObject slide, + int slideIndex, + string presentationTitle, + string? objective, + string? decisionAsk) + { + var repairs = 0; + var layout = ReadString(slide, "layout").Trim().ToLowerInvariant(); + + switch (layout) + { + case "executive_summary": + repairs += EnsureExecutiveSummaryContent(slide, objective, decisionAsk); + repairs += EnsureKpiContent(slide); + repairs += EnsureKpiNarrative(slide); + repairs += EnsureSummaryTakeaways(slide, objective, decisionAsk); + repairs += RemoveRedundantBody(slide, objective, decisionAsk); + break; + + case "recommendation": + repairs += EnsureRecommendationSupport(slide, objective, decisionAsk); + repairs += RemoveRedundantBody(slide, objective, decisionAsk); + break; + + case "comparison": + repairs += EnsureComparisonRationale(slide); + repairs += RemoveRedundantBody(slide, objective, decisionAsk); + break; + + case "roadmap": + repairs += EnsureRoadmapExecutionLabels(slide); + repairs += RemoveRedundantBody(slide, objective, decisionAsk); + break; + + case "chart": + repairs += EnsureChartTakeaway(slide, objective, decisionAsk); + break; + + case "kpi_dashboard": + repairs += EnsureKpiContent(slide); + repairs += EnsureKpiNarrative(slide); + repairs += EnsureSummaryTakeaways(slide, objective, decisionAsk); + repairs += RemoveRedundantBody(slide, objective, decisionAsk); + break; + } + + if (layout is "executive_summary" or "comparison" or "recommendation" or "roadmap" or "kpi_dashboard" or "chart") + { + if (EnsureDistinctiveHeadline(slide, layout, slideIndex, presentationTitle, objective, decisionAsk)) + repairs++; + } + + if (TrimLongHeadline(slide)) + repairs++; + + return repairs; + } + private static int NormalizeSlide( JsonObject slide, int slideIndex, @@ -608,6 +720,396 @@ public static class DeckPlanningService return 1; } + private static int EnsureKpiNarrative(JsonObject slide) + { + if (!slide.TryGetPropertyValue("kpis", out var kpisNode) || kpisNode is not JsonArray kpis || kpis.Count == 0) + return 0; + + var repairs = 0; + foreach (var (item, index) in kpis.OfType().Select((value, index) => (value, index))) + { + if (string.IsNullOrWhiteSpace(ReadString(item, "trend"))) + { + item["trend"] = index switch + { + 0 => "Current trend", + 1 => "Execution watch", + _ => "Next review", + }; + repairs++; + } + + if (string.IsNullOrWhiteSpace(ReadString(item, "note"))) + { + item["note"] = index switch + { + 0 => "Primary takeaway", + 1 => "Operational context", + _ => "Decision context", + }; + repairs++; + } + } + + return repairs; + } + + private static int EnsureSummaryTakeaways(JsonObject slide, string? objective, string? decisionAsk) + { + if (ReadTextLines(slide, "summary_points").Count > 0) + return 0; + + var takeaways = BuildTakeawaysFromMetrics(slide); + if (takeaways.Count == 0) + { + takeaways = new List + { + Coalesce(decisionAsk, objective, "Translate the slide into one decision-ready takeaway."), + "Separate the most urgent action from the supporting evidence.", + "Keep the message anchored to impact, risk, and execution timing." + }; + } + + slide["summary_points"] = ToJsonArray(takeaways.Take(3)); + return 1; + } + + private static int EnsureRecommendationSupport(JsonObject slide, string? objective, string? decisionAsk) + { + var repairs = 0; + if (string.IsNullOrWhiteSpace(ReadString(slide, "recommendation"))) + { + slide["recommendation"] = Coalesce(decisionAsk, objective, "Confirm the preferred path and move into execution."); + repairs++; + } + + if (ReadTextLines(slide, "summary_points").Count == 0) + { + slide["summary_points"] = new JsonArray( + "The recommendation balances impact, delivery risk, and implementation effort.", + "The preferred option can start quickly without blocking later scale-up.", + "The recommendation links directly to the requested decision and operating next step."); + repairs++; + } + + if (ReadTextLines(slide, "next_steps").Count == 0) + { + slide["next_steps"] = new JsonArray( + "Confirm scope, timing, and accountable owner.", + "Launch the first execution milestone with explicit success metrics.", + "Review early results and expand only after the first checkpoint."); + repairs++; + } + + return repairs; + } + + private static int EnsureComparisonRationale(JsonObject slide) + { + var repairs = EnsureMinimumOptions(slide) ? 1 : 0; + if (!slide.TryGetPropertyValue("options", out var optionsNode) || optionsNode is not JsonArray options) + return repairs; + + var hasRecommendedVerdict = options.OfType() + .Any(option => ReadString(option, "verdict").Contains("recommended", StringComparison.OrdinalIgnoreCase)); + + foreach (var (option, index) in options.OfType().Select((value, index) => (value, index))) + { + if (string.IsNullOrWhiteSpace(ReadString(option, "pros"))) + { + option["pros"] = index == 0 ? "Fastest route to an initial result" : "Balanced delivery profile"; + repairs++; + } + + if (string.IsNullOrWhiteSpace(ReadString(option, "cons"))) + { + option["cons"] = index == 0 ? "May leave key follow-up work for later" : "Needs more alignment up front"; + repairs++; + } + + if (string.IsNullOrWhiteSpace(ReadString(option, "summary"))) + { + option["summary"] = Coalesce( + ReadString(option, "pros"), + "Summarize why this option matters and what trade-off it brings."); + repairs++; + } + + if (string.IsNullOrWhiteSpace(ReadString(option, "verdict"))) + { + option["verdict"] = !hasRecommendedVerdict && index == 0 + ? "Recommended" + : "Consider"; + hasRecommendedVerdict = true; + repairs++; + } + } + + return repairs; + } + + private static int EnsureRoadmapExecutionLabels(JsonObject slide) + { + var repairs = EnsureRoadmapPhases(slide) ? 1 : 0; + if (!slide.TryGetPropertyValue("phases", out var phasesNode) || phasesNode is not JsonArray phases) + return repairs; + + while (phases.Count < 3) + { + var nextIndex = phases.Count; + phases.Add(new JsonObject + { + ["title"] = $"Phase {nextIndex + 1}", + ["detail"] = nextIndex switch + { + 1 => "Execute the first delivery wave and validate performance", + _ => "Scale the operating rhythm and lock the next checkpoint", + }, + ["timeline"] = nextIndex switch + { + 1 => "30-60 days", + _ => "60-90 days", + }, + ["owner"] = nextIndex switch + { + 1 => "Delivery lead", + _ => "Business owner", + } + }); + repairs++; + } + + foreach (var (phase, index) in phases.OfType().Select((value, index) => (value, index))) + { + if (string.IsNullOrWhiteSpace(ReadString(phase, "title"))) + { + phase["title"] = $"Phase {index + 1}"; + repairs++; + } + + if (string.IsNullOrWhiteSpace(ReadString(phase, "detail"))) + { + phase["detail"] = index switch + { + 0 => "Lock the scope, decision, and success criteria", + 1 => "Execute the first delivery wave and validate results", + _ => "Scale the operating rhythm and stabilize outcomes", + }; + repairs++; + } + + if (string.IsNullOrWhiteSpace(ReadString(phase, "timeline"))) + { + phase["timeline"] = index switch + { + 0 => "0-30 days", + 1 => "30-60 days", + _ => "60-90 days", + }; + repairs++; + } + + var owner = ReadString(phase, "owner"); + if (string.IsNullOrWhiteSpace(owner) || IsPlaceholderOwner(owner)) + { + phase["owner"] = index switch + { + 0 => "Program sponsor", + 1 => "Delivery lead", + _ => "Business owner", + }; + repairs++; + } + } + + return repairs; + } + + private static bool EnsureDistinctiveHeadline( + JsonObject slide, + string layout, + int slideIndex, + string presentationTitle, + string? objective, + string? decisionAsk) + { + var headline = ReadString(slide, "headline"); + var shouldReplace = + string.IsNullOrWhiteSpace(headline) || + string.Equals(headline, objective, StringComparison.OrdinalIgnoreCase) || + string.Equals(headline, decisionAsk, StringComparison.OrdinalIgnoreCase); + + if (!shouldReplace) + return false; + + var replacement = layout switch + { + "executive_summary" => Coalesce( + decisionAsk is { Length: > 0 } ? $"Decision: {decisionAsk}" : null, + objective is { Length: > 0 } ? $"Executive takeaway: {objective}" : null, + "Executive takeaway: align the decision, evidence, and next step."), + "comparison" => "Preferred option balances impact, delivery speed, and implementation risk.", + "recommendation" => Coalesce( + decisionAsk is { Length: > 0 } ? $"Recommendation: {decisionAsk}" : null, + "Recommendation: move with the preferred path and confirm the first milestone."), + "roadmap" => "Execution can move in phased milestones with clear owners and timing.", + "kpi_dashboard" => "Core KPIs show the operating trend and the next action focus.", + "chart" => Coalesce(BuildChartHeadline(slide), "The chart translates trend movement into a decision takeaway."), + _ => BuildHeadline(slide, slideIndex, presentationTitle, objective, decisionAsk) + }; + + slide["headline"] = replacement; + return true; + } + + private static int EnsureChartTakeaway(JsonObject slide, string? objective, string? decisionAsk) + { + var repairs = 0; + if (!HasChartData(slide)) + return repairs; + + if (string.IsNullOrWhiteSpace(ReadString(slide, "headline"))) + { + slide["headline"] = Coalesce( + decisionAsk, + objective, + BuildChartHeadline(slide), + "Translate the chart into a single decision-ready takeaway."); + repairs++; + } + + if (ReadTextLines(slide, "summary_points").Count == 0) + { + var takeaway = BuildChartTakeaways(slide); + if (takeaway.Count > 0) + { + slide["summary_points"] = ToJsonArray(takeaway); + repairs++; + } + } + + return repairs; + } + + private static int RemoveRedundantBody(JsonObject slide, string? objective, string? decisionAsk) + { + var bodyLines = ReadTextLines(slide, "body"); + if (bodyLines.Count == 0) + return 0; + + var normalizedBody = string.Join(" ", bodyLines).Trim(); + var objectiveText = objective?.Trim() ?? string.Empty; + var askText = decisionAsk?.Trim() ?? string.Empty; + + if ((!string.IsNullOrWhiteSpace(objectiveText) && normalizedBody.Contains(objectiveText, StringComparison.OrdinalIgnoreCase)) || + (!string.IsNullOrWhiteSpace(askText) && normalizedBody.Contains(askText, StringComparison.OrdinalIgnoreCase))) + { + slide.Remove("body"); + return 1; + } + + return 0; + } + + private static bool HasAppendixSlide(IReadOnlyList slides) + { + return slides.Any(slide => + { + var title = ReadString(slide, "title"); + return title.Contains("appendix", StringComparison.OrdinalIgnoreCase) || + title.Contains("\uBD80\uB85D", StringComparison.OrdinalIgnoreCase) || + title.Contains("evidence", StringComparison.OrdinalIgnoreCase); + }); + } + + private static bool ShouldAddEvidenceAppendix(DeckQualityReport review) + { + return review.Issues.Any(issue => + issue.Message.Contains("Appendix or evidence slide", StringComparison.OrdinalIgnoreCase) || + issue.Message.Contains("appendix or evidence support", StringComparison.OrdinalIgnoreCase) || + issue.Message.Contains("Evidence slides", StringComparison.OrdinalIgnoreCase)); + } + + private static bool IsPlaceholderOwner(string owner) + { + return owner.Contains("담당 미정", StringComparison.OrdinalIgnoreCase) || + owner.Contains("미정", StringComparison.OrdinalIgnoreCase) || + owner.Contains("tbd", StringComparison.OrdinalIgnoreCase) || + owner.Contains("unknown", StringComparison.OrdinalIgnoreCase); + } + + private static List BuildTakeawaysFromMetrics(JsonObject slide) + { + var takeaways = new List(); + if (!slide.TryGetPropertyValue("kpis", out var kpisNode) || kpisNode is not JsonArray kpis) + return takeaways; + + foreach (var item in kpis.OfType().Take(3)) + { + var label = Coalesce(ReadString(item, "label"), ReadString(item, "title"), "Metric"); + var value = Coalesce(ReadString(item, "value"), "tracked"); + var trend = ReadString(item, "trend"); + var note = ReadString(item, "note"); + takeaways.Add($"{label} is {value}{FormatContextSuffix(trend, note)}."); + } + + return takeaways; + } + + private static string BuildChartHeadline(JsonObject slide) + { + if (!slide.TryGetPropertyValue("chart_labels", out var labelsNode) || + !slide.TryGetPropertyValue("chart_values", out var valuesNode) || + labelsNode is not JsonArray labels || + valuesNode is not JsonArray values || + labels.Count == 0 || + values.Count == 0) + { + return string.Empty; + } + + var firstLabel = UnquoteJson(labels[0]?.ToJsonString() ?? string.Empty); + var firstValue = UnquoteJson(values[0]?.ToJsonString() ?? string.Empty); + if (string.IsNullOrWhiteSpace(firstLabel) || string.IsNullOrWhiteSpace(firstValue)) + return string.Empty; + + return $"{firstLabel} anchors the chart story at {firstValue}."; + } + + private static List BuildChartTakeaways(JsonObject slide) + { + var takeaways = new List(); + if (!slide.TryGetPropertyValue("chart_labels", out var labelsNode) || + !slide.TryGetPropertyValue("chart_values", out var valuesNode) || + labelsNode is not JsonArray labels || + valuesNode is not JsonArray values) + { + return takeaways; + } + + for (var i = 0; i < Math.Min(3, Math.Min(labels.Count, values.Count)); i++) + { + var label = UnquoteJson(labels[i]?.ToJsonString() ?? string.Empty); + var value = UnquoteJson(values[i]?.ToJsonString() ?? string.Empty); + if (string.IsNullOrWhiteSpace(label) || string.IsNullOrWhiteSpace(value)) + continue; + + takeaways.Add($"{label} is tracked at {value} and should inform the decision message."); + } + + return takeaways; + } + + private static string FormatContextSuffix(string trend, string note) + { + var context = new List(); + if (!string.IsNullOrWhiteSpace(trend)) + context.Add(trend); + if (!string.IsNullOrWhiteSpace(note)) + context.Add(note); + return context.Count == 0 ? string.Empty : $" ({string.Join(", ", context)})"; + } + private static bool TrimLongHeadline(JsonObject slide) { var headline = ReadString(slide, "headline"); diff --git a/src/AxCopilot/Services/Agent/PptxSkill.cs b/src/AxCopilot/Services/Agent/PptxSkill.cs index f0eef91..ac7746f 100644 --- a/src/AxCopilot/Services/Agent/PptxSkill.cs +++ b/src/AxCopilot/Services/Agent/PptxSkill.cs @@ -564,28 +564,69 @@ public class PptxSkill : IAgentTool if (!hasSlidesArray && !cloneAll) return ToolResult.Fail("slides 배열이 필요합니다."); - using var preparedDeck = hasSlidesArray + DeckPreparationResult? preparedDeck = hasSlidesArray ? DeckPlanningService.Prepare(args, slidesEl, presTitle) : null; + DeckPreparationResult? refinedDeck = null; + DeckPreparationResult? renderDeck = preparedDeck; + DeckQualityReport? deckReview = null; - if (preparedDeck != null) + if (renderDeck != null) { - slidesEl = preparedDeck.Slides; + slidesEl = renderDeck.Slides; if (!hasExplicitTheme && !hasExplicitTemplate && !hasThemeFile && - !string.IsNullOrWhiteSpace(preparedDeck.SuggestedTheme)) + !string.IsNullOrWhiteSpace(renderDeck.SuggestedTheme)) { - theme = preparedDeck.SuggestedTheme!; + theme = renderDeck.SuggestedTheme!; } } var templatePack = !string.IsNullOrWhiteSpace(requestedTemplatePack) ? PptxTemplatePackRegistry.Resolve(requestedTemplatePack) : (!hasExplicitTheme && !hasExplicitTemplate && !hasThemeFile - ? PptxTemplatePackRegistry.Suggest(preparedDeck?.Objective ?? objective, preparedDeck?.Audience ?? audience) + ? PptxTemplatePackRegistry.Suggest(renderDeck?.Objective ?? objective, renderDeck?.Audience ?? audience) : null); var templatePackName = templatePack?.Name; var packTemplateName = templatePack?.PreferredTemplate; + if (renderDeck != null) + { + deckReview = DeckQualityReviewService.ReviewDeck( + presTitle, + renderDeck.Slides, + hasExplicitTemplate || hasThemeFile || !string.IsNullOrWhiteSpace(templatePackName), + renderDeck.AutoRepairCount, + renderDeck.Storyline); + + if (ShouldAutoRefineDeck(deckReview)) + { + refinedDeck = DeckPlanningService.RefineForQuality(renderDeck, deckReview, presTitle); + var refinedReview = DeckQualityReviewService.ReviewDeck( + presTitle, + refinedDeck.Slides, + hasExplicitTemplate || hasThemeFile || !string.IsNullOrWhiteSpace(templatePackName), + refinedDeck.AutoRepairCount, + refinedDeck.Storyline); + + if (IsImprovedDeckReview(deckReview, refinedReview)) + { + renderDeck = refinedDeck; + slidesEl = renderDeck.Slides; + deckReview = refinedReview; + if (!hasExplicitTheme && !hasExplicitTemplate && !hasThemeFile && + !string.IsNullOrWhiteSpace(renderDeck.SuggestedTheme)) + { + theme = renderDeck.SuggestedTheme!; + } + } + else + { + refinedDeck.Dispose(); + refinedDeck = null; + } + } + } + var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder); if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath); if (!fullPath.EndsWith(".pptx", StringComparison.OrdinalIgnoreCase)) @@ -1012,22 +1053,23 @@ public class PptxSkill : IAgentTool : templatePptxPath != null ? $"theme_file:{cloneInfo_srcLabel} (master cloned{(cloneAll ? " + slides cloned" : "")})" : theme; - 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 (renderDeck != null) + outputParts.Add(renderDeck.ToToolSummary()); if (!string.IsNullOrWhiteSpace(templatePackName)) outputParts.Add($"Template pack: {templatePackName}"); + if (deckReview == null && hasSlidesArray) + { + deckReview = DeckQualityReviewService.ReviewDeck( + presTitle, + slidesEl, + !string.IsNullOrWhiteSpace(templateName) || templatePptxPath != null || !string.IsNullOrWhiteSpace(templatePackName), + renderDeck?.AutoRepairCount ?? 0, + renderDeck?.Storyline); + } if (deckReview != null) outputParts.AddRange(ArtifactQualityOutputFormatter.BuildLines(deckReview)); return ToolResult.Ok(string.Join("\n", outputParts), fullPath); @@ -1037,6 +1079,37 @@ public class PptxSkill : IAgentTool if (File.Exists(fullPath)) try { File.Delete(fullPath); } catch { } return ToolResult.Fail($"PPTX 생성 실패: {ex.Message}"); } + finally + { + refinedDeck?.Dispose(); + preparedDeck?.Dispose(); + } + } + + private static bool ShouldAutoRefineDeck(DeckQualityReport review) + { + return review.Score < 80 || + review.Issues.Any(issue => issue.Severity is DeckReviewSeverity.Warning or DeckReviewSeverity.Critical); + } + + private static bool IsImprovedDeckReview(DeckQualityReport baseline, DeckQualityReport refined) + { + if (refined.Score > baseline.Score) + return true; + + var baselineCritical = baseline.Issues.Count(issue => issue.Severity == DeckReviewSeverity.Critical); + var refinedCritical = refined.Issues.Count(issue => issue.Severity == DeckReviewSeverity.Critical); + if (refinedCritical < baselineCritical) + return true; + + var baselineWarning = baseline.Issues.Count(issue => issue.Severity == DeckReviewSeverity.Warning); + var refinedWarning = refined.Issues.Count(issue => issue.Severity == DeckReviewSeverity.Warning); + if (refinedWarning < baselineWarning) + return true; + + var baselineAlerts = baseline.Issues.Count(issue => issue.Message.StartsWith("Slide ", StringComparison.OrdinalIgnoreCase)); + var refinedAlerts = refined.Issues.Count(issue => issue.Message.StartsWith("Slide ", StringComparison.OrdinalIgnoreCase)); + return refinedAlerts < baselineAlerts; } // ══════════════════════════════════════════════════════════════════════════ diff --git a/src/AxCopilot/Views/ChatWindow.SystemPromptBuilder.cs b/src/AxCopilot/Views/ChatWindow.SystemPromptBuilder.cs index 63acf59..a60fbd4 100644 --- a/src/AxCopilot/Views/ChatWindow.SystemPromptBuilder.cs +++ b/src/AxCopilot/Views/ChatWindow.SystemPromptBuilder.cs @@ -68,6 +68,7 @@ public partial class ChatWindow sb.AppendLine("For ordinary Cowork requests where no plan is requested, proceed directly with the work and focus on producing the requested result."); sb.AppendLine("If the user asks for a brand-new report, proposal, analysis, manual, or other document and does not explicitly ask to reference workspace files, do NOT start with glob, grep, document_read, or folder_map."); sb.AppendLine("In that case, go straight to the creation tool. Use document_plan only when multi-section structure work clearly improves the result or when the user explicitly requests a plan."); + sb.AppendLine("If the user asks for a presentation, slide deck, or PPT, prefer calling pptx_create directly. Use document_plan first only when the user explicitly asks for an outline or when the deck structure is genuinely unclear."); sb.AppendLine("When writing a new document, avoid repetitive same-shape sections. Tailor the structure to the purpose and use summaries, findings, comparison tables, timelines, recommendations, appendices, or action items when they improve clarity."); sb.AppendLine("Prefer concrete and useful content over filler. If a section benefits from bullets, tables, or structured comparison, use them instead of flat generic paragraphs."); sb.AppendLine("After creating files, summarize what was created and include the actual output path."); @@ -85,7 +86,7 @@ public partial class ChatWindow sb.AppendLine("IMPORTANT: For reports, proposals, analyses, and manuals with multiple sections:"); sb.AppendLine(" 1. Decide the document structure internally first."); sb.AppendLine(" 2. Use document_plan only when multi-section structure work clearly improves the result or when the user explicitly asks for a plan. When the user requests a plan, present it for approval before proceeding."); - sb.AppendLine(" 3. Then immediately create the real output file with html_create, docx_create, markdown_create, or file_write."); + sb.AppendLine(" 3. Then immediately create the real output file with html_create, docx_create, markdown_create, pptx_create, or file_write."); sb.AppendLine(" 4. Fill every section with real content. Do not stop at an outline or plan only."); sb.AppendLine(" 4-1. Use richer section patterns when they help: summary box, key findings bullets, comparison table, timeline, checklist, action items, appendix."); sb.AppendLine(" 5. The task is complete only after the actual document file has been created or updated.");