From db4ccd5df4533f46af8b2eaec4aa91965afd8101 Mon Sep 17 00:00:00 2001 From: lacvet Date: Thu, 16 Apr 2026 00:16:36 +0900 Subject: [PATCH] =?UTF-8?q?PPT=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=ED=92=88?= =?UTF-8?q?=EC=A7=88=20=EA=B2=8C=EC=9D=B4=ED=8A=B8=EC=99=80=20manifest=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=ED=9D=90=EB=A6=84=EC=9D=84=20=EA=B3=A0?= =?UTF-8?q?=EB=8F=84=ED=99=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PptxSkill에 template master clone 진단 코드와 Template diagnostics 출력 라인을 추가해 color fallback 원인을 asset missing / clone failure로 구분한다. - PptQualityGatePolicy와 templates.manifest.json, PptxTemplateManifestCatalog를 확장해 manifest 메타데이터 기반 추천과 원인별 재루프 프롬프트를 연결한다. - PptQualityGatePolicyTests, PptxTemplateManifestCatalogTests, PptxSkillTemplatePackTests, PptxSkillTemplateDiagnosticsTests를 보강하고 README.md 및 docs/DEVELOPMENT.md 이력을 2026-04-16 00:15 (KST) 기준으로 갱신한다. 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_ppt_manifest_quality\\ -p:IntermediateOutputPath=obj\\verify_ppt_manifest_quality\\ / dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter PptQualityGatePolicyTests -p:OutputPath=bin\\verify_ppt_manifest_quality_tests\\ -p:IntermediateOutputPath=obj\\verify_ppt_manifest_quality_tests\\ 포함 15건 통과 --- README.md | 12 + docs/DEVELOPMENT.md | 28 ++ .../Services/PptQualityGatePolicyTests.cs | 36 ++- .../PptxSkillTemplateDiagnosticsTests.cs | 65 +++++ .../Services/PptxSkillTemplatePackTests.cs | 1 + .../PptxTemplateManifestCatalogTests.cs | 18 +- .../Assets/ppt/templates.manifest.json | 104 +++++-- .../Services/Agent/PptQualityGatePolicy.cs | 108 +++++-- src/AxCopilot/Services/Agent/PptxSkill.cs | 244 ++++++++++++++-- .../Agent/PptxTemplateManifestCatalog.cs | 267 ++++++++++++++++-- 10 files changed, 781 insertions(+), 102 deletions(-) create mode 100644 src/AxCopilot.Tests/Services/PptxSkillTemplateDiagnosticsTests.cs diff --git a/README.md b/README.md index 7ca7986..8321f38 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,18 @@ - 검증: - `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_chat_width_wrap\\ -p:IntermediateOutputPath=obj\\verify_chat_width_wrap\\` 경고 0 / 오류 0 - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ChatStreamingUiPolicyTests|ChatWindowSlashPolicyTests" -p:OutputPath=bin\\verify_chat_width_wrap_tests\\ -p:IntermediateOutputPath=obj\\verify_chat_width_wrap_tests\\` 통과 74 +업데이트: 2026-04-16 00:15 (KST) +- PPT 템플릿 품질 게이트와 manifest 기반 추천 흐름을 함께 보강했습니다. `src/AxCopilot/Services/Agent/PptQualityGatePolicy.cs`는 이제 `PPT quality` 점수뿐 아니라 `asset missing`, `master clone failed`, `color fallback`을 각각 구분해서 재루프 여부를 판단하고, 원인에 맞는 재생성 프롬프트를 만들도록 확장했습니다. +- `src/AxCopilot/Services/Agent/PptxSkill.cs`는 템플릿 마스터 복제 실패를 `openxml_package_error`, `missing_slide_master`, `missing_slide_layout` 같은 진단 코드로 남기고, 결과 문자열에 `Template diagnostics:` 라인을 추가합니다. 이로써 이전처럼 모든 실패가 단순 `color fallback`으로만 보이지 않고, 실제 실패 원인을 로그와 품질 게이트가 함께 사용합니다. +- `src/AxCopilot/Services/Agent/PptxTemplateManifestCatalog.cs`와 `src/AxCopilot/Assets/ppt/templates.manifest.json`은 `tone`, `density`, `fidelityTier`, `objectiveKeywords`, `audienceKeywords`, `supportsMasterClone` 메타데이터를 갖는 구조로 확장했습니다. 이제 템플릿 팩 선택 시 단순 고정 `PreferredTemplate`만 보는 대신 manifest 점수화로 추천 템플릿을 고르고, 결과에도 `Template recommendation:`을 남깁니다. +- 테스트: + - `src/AxCopilot.Tests/Services/PptQualityGatePolicyTests.cs` + - `src/AxCopilot.Tests/Services/PptxTemplateManifestCatalogTests.cs` + - `src/AxCopilot.Tests/Services/PptxSkillTemplatePackTests.cs` + - `src/AxCopilot.Tests/Services/PptxSkillTemplateDiagnosticsTests.cs` +- 검증: + - `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_ppt_manifest_quality\\ -p:IntermediateOutputPath=obj\\verify_ppt_manifest_quality\\` 경고 0 / 오류 0 + - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "PptQualityGatePolicyTests|PptxTemplateManifestCatalogTests|PptxSkillTemplatePackTests|PptxSkillTemplateDiagnosticsTests|PptxSkillAutoRepairTests|PptxSkillGoldenDeckTests" -p:OutputPath=bin\\verify_ppt_manifest_quality_tests\\ -p:IntermediateOutputPath=obj\\verify_ppt_manifest_quality_tests\\` 통과 15 - 업데이트: 2026-04-15 22:45 (KST) - HTML 보고서 뒤쪽으로 갈수록 폰트 크기와 카드 레이아웃이 흔들리던 raw body 호환 문제를 보강했습니다. `src/AxCopilot/Services/Agent/TemplateService.cs`에 `h4`, `dl`, `matrix`, `comparison`, `decision_matrix`, `board_report`, `metrics`, `roadmap` 같은 레거시 블록 전용 CSS를 추가해, 구조화 섹션이 아닌 자유 본문 HTML로 생성된 보고서도 앞부분과 같은 문서 톤을 유지하도록 맞췄습니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index c4a302d..514099f 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1711,3 +1711,31 @@ UI ?遺우쁽????域뱀뮆???귐뗫솯?醫딆춦 ???袁る퓮 ?臾믩씜 ??疫 - 검증: - `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_chat_width_wrap\\ -p:IntermediateOutputPath=obj\\verify_chat_width_wrap\\` 경고 0 / 오류 0 - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ChatStreamingUiPolicyTests|ChatWindowSlashPolicyTests" -p:OutputPath=bin\\verify_chat_width_wrap_tests\\ -p:IntermediateOutputPath=obj\\verify_chat_width_wrap_tests\\` 통과 74 +업데이트: 2026-04-16 00:15 (KST) +- PPT 템플릿 품질 보정 흐름을 manifest 중심으로 재구성했다. + - `src/AxCopilot/Services/Agent/PptQualityGatePolicy.cs` + - `PPT quality`, `Slide alerts`, `Needs work` 외에 `Template diagnostics:`와 `color fallback` 문구를 함께 파싱한다. + - `asset missing`, `master clone failed`, `color fallback` 중 하나라도 남으면 재생성 후보로 판단한다. + - 재생성 프롬프트를 원인별로 분기해 `document_plan` 선행 여부, manifest 템플릿/`template_pack` 재선택, `master cloned` 결과 강제 유도를 포함한다. + - `src/AxCopilot/Services/Agent/PptxSkill.cs` + - `TryCloneMasterFromTemplate(...)`를 `TemplateMasterCloneResult` 기반으로 바꿔 `openxml_package_error`, `missing_presentation_part`, `missing_slide_master`, `missing_slide_layout`, `clone_exception` 같은 진단 코드를 결과 문자열에 남긴다. + - 템플릿 복제 실패 시 `Template diagnostics:` 라인을 추가하고, `themeLabel`도 `master clone failed: -> color fallback` 형태로 출력한다. + - `template_pack` 사용 시 manifest 추천 결과를 먼저 계산하고, 추천된 템플릿 key와 `tone/density/fidelityTier/reason`를 출력에 포함한다. + - `src/AxCopilot/Services/Agent/PptxTemplateManifestCatalog.cs` + - manifest 엔트리를 `tone`, `density`, `aspectHint`, `fidelityTier`, `supportsMasterClone`, `objectiveKeywords`, `audienceKeywords` 메타데이터를 갖는 클래스로 확장했다. + - `RecommendTemplate(packName, objective, audience)`를 추가해 `packHints + objectiveKeywords + audienceKeywords + tags + fidelityTier` 점수로 가장 적합한 템플릿을 고른다. + - `src/AxCopilot/Assets/ppt/templates.manifest.json` + - 실제 자산 파일을 Unicode escape 기반 JSON으로 정리하고, 위 메타데이터를 모두 반영했다. + - 이 manifest가 이제 단순 파일 매핑이 아니라 내장 템플릿 pack 추천 테이블 역할까지 담당한다. +- 테스트: + - `src/AxCopilot.Tests/Services/PptQualityGatePolicyTests.cs` + - 저품질 + clone failure, asset missing 고득점 케이스, clean deck 통과, retry prompt 분기 검증을 추가했다. + - `src/AxCopilot.Tests/Services/PptxTemplateManifestCatalogTests.cs` + - board/finance 신호에서 `core100`이 추천되는지와 manifest 메타데이터 로드를 검증한다. + - `src/AxCopilot.Tests/Services/PptxSkillTemplatePackTests.cs` + - `Template recommendation: core100`이 실제 출력에 포함되는지 확인한다. + - `src/AxCopilot.Tests/Services/PptxSkillTemplateDiagnosticsTests.cs` + - 손상된 `theme_file` 입력에서 deck 생성은 유지하면서 `master clone failed` 진단을 남기는 회귀를 고정했다. +- 검증: + - `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_ppt_manifest_quality\\ -p:IntermediateOutputPath=obj\\verify_ppt_manifest_quality\\` 경고 0 / 오류 0 + - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "PptQualityGatePolicyTests|PptxTemplateManifestCatalogTests|PptxSkillTemplatePackTests|PptxSkillTemplateDiagnosticsTests|PptxSkillAutoRepairTests|PptxSkillGoldenDeckTests" -p:OutputPath=bin\\verify_ppt_manifest_quality_tests\\ -p:IntermediateOutputPath=obj\\verify_ppt_manifest_quality_tests\\` 통과 15 diff --git a/src/AxCopilot.Tests/Services/PptQualityGatePolicyTests.cs b/src/AxCopilot.Tests/Services/PptQualityGatePolicyTests.cs index bc175bb..ab34546 100644 --- a/src/AxCopilot.Tests/Services/PptQualityGatePolicyTests.cs +++ b/src/AxCopilot.Tests/Services/PptQualityGatePolicyTests.cs @@ -10,7 +10,8 @@ public class PptQualityGatePolicyTests public void TryAssess_ShouldRequestRetry_ForLowScoreDeck() { var result = ToolResult.Ok( - "PPTX created: E:\\docu\\deck.pptx (10 slides, template:basic100 (color fallback), 16:9)\n" + + "PPTX created: E:\\docu\\deck.pptx (10 slides, template:basic100 (master clone failed:openxml_package_error -> color fallback), 16:9)\n" + + "Template diagnostics: template 'basic100' master clone failed (openxml_package_error). File contains corrupted data.\n" + "PPT quality 64/100 | Strengths: Includes title slide | Slide alerts: 4 | Needs work: Too many slides are text-heavy."); var parsed = PptQualityGatePolicy.TryAssess(result, out var assessment); @@ -19,6 +20,22 @@ public class PptQualityGatePolicyTests assessment.Score.Should().Be(64); assessment.SlideAlertCount.Should().Be(4); assessment.HasColorFallback.Should().BeTrue(); + assessment.HasMasterCloneFailure.Should().BeTrue(); + assessment.RequiresRetry.Should().BeTrue(); + } + + [Fact] + public void TryAssess_ShouldRequestRetry_ForTemplateAssetMissingEvenWhenScoreIsHigh() + { + var result = ToolResult.Ok( + "PPTX created: E:\\docu\\deck.pptx (6 slides, template:core100 (asset missing -> built-in fallback), 16:9)\n" + + "Template diagnostics: 'core100' asset not found. Searched 3 candidate PPT directories.\n" + + "PPT quality 86/100 | Strengths: Clear structure | Needs work: none"); + + var parsed = PptQualityGatePolicy.TryAssess(result, out var assessment); + + parsed.Should().BeTrue(); + assessment.HasTemplateAssetMissing.Should().BeTrue(); assessment.RequiresRetry.Should().BeTrue(); } @@ -37,20 +54,27 @@ public class PptQualityGatePolicyTests } [Fact] - public void BuildRetryPrompt_ShouldRecommendDocumentPlan_WhenStorylineWasNotPrepared() + public void BuildRetryPrompt_ShouldRecommendDocumentPlan_AndManifestTemplateRecovery() { var assessment = new PptQualityGateAssessment( 64, 3, - HasColorFallback: false, - "Too many slides are text-heavy.", + HasColorFallback: true, + HasTemplateAssetMissing: false, + HasMasterCloneFailure: true, + NeedsWork: "Too many slides are text-heavy.", + TemplateDiagnostics: "template 'basic100' master clone failed (openxml_package_error).", RequiresRetry: true, - Summary: "점수 64/100 (기준 80), 슬라이드 경고 3건"); + Summary: "score 64/100 (target 80), 3 slide alerts, color fallback triggered, master clone failed"); - var prompt = PptQualityGatePolicy.BuildRetryPrompt(assessment, "E:\\docu\\deck.pptx", documentPlanWasCalled: false); + var prompt = PptQualityGatePolicy.BuildRetryPrompt( + assessment, + "E:\\docu\\deck.pptx", + documentPlanWasCalled: false); prompt.Should().Contain("document_plan"); prompt.Should().Contain("pptx_create"); + prompt.Should().Contain("master cloned"); prompt.Should().Contain("E:\\docu\\deck.pptx"); } } diff --git a/src/AxCopilot.Tests/Services/PptxSkillTemplateDiagnosticsTests.cs b/src/AxCopilot.Tests/Services/PptxSkillTemplateDiagnosticsTests.cs new file mode 100644 index 0000000..1474ad4 --- /dev/null +++ b/src/AxCopilot.Tests/Services/PptxSkillTemplateDiagnosticsTests.cs @@ -0,0 +1,65 @@ +using System.IO; +using System.Text.Json; +using AxCopilot.Services.Agent; +using FluentAssertions; +using Xunit; + +namespace AxCopilot.Tests.Services; + +public class PptxSkillTemplateDiagnosticsTests +{ + [Fact] + public async Task ExecuteAsync_WithCorruptedThemeFile_ShouldReportCloneFailureDiagnostics() + { + var workDir = Path.Combine(Path.GetTempPath(), "ax-pptx-diagnostics-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workDir); + + try + { + var brokenTemplatePath = Path.Combine(workDir, "broken-theme.pptx"); + await File.WriteAllTextAsync(brokenTemplatePath, "not a real pptx"); + + var context = new AgentContext + { + WorkFolder = workDir, + Permission = "Auto", + OperationMode = "external", + }; + + var tool = new PptxSkill(); + var args = JsonDocument.Parse( + """ + { + "path": "diagnostic-deck.pptx", + "theme_file": "broken-theme.pptx", + "slides": [ + { + "layout": "content", + "title": "Current State", + "body": "Issue summary\nConstraint\nNext action" + } + ] + } + """).RootElement; + + var result = await tool.ExecuteAsync(args, context, CancellationToken.None); + + result.Success.Should().BeTrue(); + result.Output.Should().Contain("master clone failed"); + result.Output.Should().Contain("Template diagnostics:"); + result.Output.Should().Contain("theme_file"); + File.Exists(Path.Combine(workDir, "diagnostic-deck.pptx")).Should().BeTrue(); + } + finally + { + try + { + if (Directory.Exists(workDir)) + Directory.Delete(workDir, true); + } + catch + { + } + } + } +} diff --git a/src/AxCopilot.Tests/Services/PptxSkillTemplatePackTests.cs b/src/AxCopilot.Tests/Services/PptxSkillTemplatePackTests.cs index 0ef5c22..d17fc51 100644 --- a/src/AxCopilot.Tests/Services/PptxSkillTemplatePackTests.cs +++ b/src/AxCopilot.Tests/Services/PptxSkillTemplatePackTests.cs @@ -49,6 +49,7 @@ public class PptxSkillTemplatePackTests result.Success.Should().BeTrue(); result.Output.Should().Contain("Template pack: board"); + result.Output.Should().Contain("Template recommendation: core100"); result.Output.Should().NotContain("asset missing"); File.Exists(Path.Combine(workDir, "board-pack-deck.pptx")).Should().BeTrue(); } diff --git a/src/AxCopilot.Tests/Services/PptxTemplateManifestCatalogTests.cs b/src/AxCopilot.Tests/Services/PptxTemplateManifestCatalogTests.cs index db35885..a47a2f5 100644 --- a/src/AxCopilot.Tests/Services/PptxTemplateManifestCatalogTests.cs +++ b/src/AxCopilot.Tests/Services/PptxTemplateManifestCatalogTests.cs @@ -13,8 +13,24 @@ public class PptxTemplateManifestCatalogTests var entry = PptxTemplateManifestCatalog.Resolve("basic100"); entry.Should().NotBeNull(); - entry!.FileName.Should().Be("BASIC100 기준 템플릿 V1.pptx"); + entry!.Key.Should().Be("basic100"); entry.FallbackTheme.Should().Be("professional"); + entry.FidelityTier.Should().Be("premium"); + entry.ObjectiveKeywords.Should().Contain("strategy"); + } + + [Fact] + public void RecommendTemplate_ShouldPreferBoardTemplate_ForBoardFinanceSignals() + { + var recommendation = PptxTemplateManifestCatalog.RecommendTemplate( + "board", + "board governance and finance review", + "executive committee"); + + recommendation.Should().NotBeNull(); + recommendation!.Entry.Key.Should().Be("core100"); + recommendation.Entry.FidelityTier.Should().Be("premium"); + recommendation.Reason.Should().Contain("pack:board"); } [Fact] diff --git a/src/AxCopilot/Assets/ppt/templates.manifest.json b/src/AxCopilot/Assets/ppt/templates.manifest.json index 2b2321a..4925c23 100644 --- a/src/AxCopilot/Assets/ppt/templates.manifest.json +++ b/src/AxCopilot/Assets/ppt/templates.manifest.json @@ -2,75 +2,131 @@ "templates": [ { "key": "basic100", - "fileName": "BASIC100 기준 템플릿 V1.pptx", - "displayName": "BASIC100 기준 템플릿 V1", + "fileName": "BASIC100 \uae30\uc900 \ud15c\ud50c\ub9bf V1.pptx", + "displayName": "BASIC100 template", "fallbackTheme": "professional", "aliases": ["basic", "consulting_basic"], "tags": ["executive", "strategy", "consulting", "brand"], - "packHints": ["strategy", "pmo", "sales", "operating_model"] + "packHints": ["strategy", "pmo", "sales", "operating_model"], + "tone": "consulting", + "density": "balanced", + "aspectHint": "widescreen", + "fidelityTier": "premium", + "supportsMasterClone": true, + "objectiveKeywords": ["strategy", "transformation", "operating model", "growth", "roadmap"], + "audienceKeywords": ["executive", "leadership", "steering", "pmo", "business"] }, { "key": "core100", - "fileName": "CORE100 기준템플릿 V1.pptx", - "displayName": "CORE100 기준 템플릿 V1", + "fileName": "CORE100 \uae30\uc900\ud15c\ud50c\ub9bf V1.pptx", + "displayName": "CORE100 template", "fallbackTheme": "corporate", "aliases": ["core", "board_core"], "tags": ["board", "governance", "finance", "corporate"], - "packHints": ["board", "finance"] + "packHints": ["board", "finance"], + "tone": "corporate", + "density": "concise", + "aspectHint": "widescreen", + "fidelityTier": "premium", + "supportsMasterClone": true, + "objectiveKeywords": ["board", "governance", "finance", "budget", "investment"], + "audienceKeywords": ["board", "executive", "committee", "cfo", "directors"] }, { "key": "frame_blue", - "fileName": "P01_01_프레임디자인_PPT템플릿_블루(PPT_편집용).pptx", - "displayName": "프레임디자인 블루", + "fileName": "P01_01_\ud504\ub808\uc784\ub514\uc790\uc778_PPT\ud15c\ud50c\ub9bf_\ube14\ub8e8(PPT_\ud3b8\uc9d1\uc6a9).pptx", + "displayName": "Blue frame template", "fallbackTheme": "modern", "aliases": ["blue_frame"], "tags": ["frame", "blue", "report", "proposal"], - "packHints": ["sales", "operating_model"] + "packHints": ["sales", "operating_model"], + "tone": "proposal", + "density": "balanced", + "aspectHint": "widescreen", + "fidelityTier": "high", + "supportsMasterClone": true, + "objectiveKeywords": ["proposal", "operating model", "sales", "status", "report"], + "audienceKeywords": ["client", "sales", "project", "leadership"] }, { "key": "mr_ppt_01", - "fileName": "미스터 피피티 템플릿 01_원본.pptx", - "displayName": "미스터 피피티 템플릿 01", + "fileName": "\ubbf8\uc2a4\ud130 \ud53c\ud53c\ud2f0 \ud15c\ud50c\ub9bf 01_\uc6d0\ubcf8.pptx", + "displayName": "Mr PPT 01", "fallbackTheme": "modern", "aliases": ["mrppt01"], "tags": ["creative", "proposal", "sales"], - "packHints": ["sales"] + "packHints": ["sales"], + "tone": "creative", + "density": "balanced", + "aspectHint": "widescreen", + "fidelityTier": "standard", + "supportsMasterClone": true, + "objectiveKeywords": ["proposal", "pitch", "sales", "commercial"], + "audienceKeywords": ["client", "sales", "commercial"] }, { "key": "mr_ppt_02", - "fileName": "미스터 피피티 템플릿 02_원본.pptx", - "displayName": "미스터 피피티 템플릿 02", + "fileName": "\ubbf8\uc2a4\ud130 \ud53c\ud53c\ud2f0 \ud15c\ud50c\ub9bf 02_\uc6d0\ubcf8.pptx", + "displayName": "Mr PPT 02", "fallbackTheme": "vibrant", "aliases": ["mrppt02"], "tags": ["creative", "marketing", "proposal"], - "packHints": ["sales"] + "packHints": ["sales"], + "tone": "bold", + "density": "condensed", + "aspectHint": "widescreen", + "fidelityTier": "standard", + "supportsMasterClone": true, + "objectiveKeywords": ["marketing", "launch", "brand", "proposal"], + "audienceKeywords": ["marketing", "sales", "brand"] }, { "key": "mr_ppt_03", - "fileName": "미스터 피피티 03_원본.pptx", - "displayName": "미스터 피피티 템플릿 03", + "fileName": "\ubbf8\uc2a4\ud130 \ud53c\ud53c\ud2f0 03_\uc6d0\ubcf8.pptx", + "displayName": "Mr PPT 03", "fallbackTheme": "minimal", "aliases": ["mrppt03"], "tags": ["minimal", "analysis", "report"], - "packHints": ["strategy", "finance"] + "packHints": ["strategy", "finance"], + "tone": "analytical", + "density": "concise", + "aspectHint": "widescreen", + "fidelityTier": "high", + "supportsMasterClone": true, + "objectiveKeywords": ["analysis", "review", "report", "diagnostic"], + "audienceKeywords": ["strategy", "finance", "leadership"] }, { "key": "mr_ppt_04", - "fileName": "미스터 피피티 템플릿 04_원본.pptx", - "displayName": "미스터 피피티 템플릿 04", + "fileName": "\ubbf8\uc2a4\ud130 \ud53c\ud53c\ud2f0 \ud15c\ud50c\ub9bf 04_\uc6d0\ubcf8.pptx", + "displayName": "Mr PPT 04", "fallbackTheme": "slate", "aliases": ["mrppt04"], "tags": ["dark", "executive", "review"], - "packHints": ["board", "finance"] + "packHints": ["board", "finance"], + "tone": "executive", + "density": "condensed", + "aspectHint": "widescreen", + "fidelityTier": "high", + "supportsMasterClone": true, + "objectiveKeywords": ["board review", "risk", "finance", "committee"], + "audienceKeywords": ["board", "executive", "cfo"] }, { "key": "mr_ppt_05", - "fileName": "미스터 피피티 템플릿_05_원본.pptx", - "displayName": "미스터 피피티 템플릿 05", + "fileName": "\ubbf8\uc2a4\ud130 \ud53c\ud53c\ud2f0 \ud15c\ud50c\ub9bf_05_\uc6d0\ubcf8.pptx", + "displayName": "Mr PPT 05", "fallbackTheme": "ocean", "aliases": ["mrppt05"], "tags": ["blue", "modern", "report"], - "packHints": ["pmo", "operating_model"] + "packHints": ["pmo", "operating_model"], + "tone": "operational", + "density": "balanced", + "aspectHint": "widescreen", + "fidelityTier": "high", + "supportsMasterClone": true, + "objectiveKeywords": ["pmo", "roadmap", "status", "operating model"], + "audienceKeywords": ["pmo", "operations", "delivery", "steering"] } ] } diff --git a/src/AxCopilot/Services/Agent/PptQualityGatePolicy.cs b/src/AxCopilot/Services/Agent/PptQualityGatePolicy.cs index a92db90..fe45d15 100644 --- a/src/AxCopilot/Services/Agent/PptQualityGatePolicy.cs +++ b/src/AxCopilot/Services/Agent/PptQualityGatePolicy.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; +using System.Linq; using System.Text.RegularExpressions; namespace AxCopilot.Services.Agent; @@ -6,7 +8,10 @@ public sealed record PptQualityGateAssessment( int Score, int SlideAlertCount, bool HasColorFallback, + bool HasTemplateAssetMissing, + bool HasMasterCloneFailure, string NeedsWork, + string TemplateDiagnostics, bool RequiresRetry, string Summary); @@ -17,7 +22,7 @@ public static class PptQualityGatePolicy public static bool TryAssess(ToolResult result, out PptQualityGateAssessment assessment, int minScore = DefaultMinScore) { - assessment = new PptQualityGateAssessment(0, 0, false, "", false, ""); + assessment = new PptQualityGateAssessment(0, 0, false, false, false, "", "", false, ""); if (!result.Success || string.IsNullOrWhiteSpace(result.Output)) return false; @@ -28,22 +33,41 @@ public static class PptQualityGatePolicy var alertMatch = Regex.Match(output, @"Slide alerts:\s*(?\d+)", RegexOptions.IgnoreCase); var needsWorkMatch = Regex.Match(output, @"Needs work:\s*(?.+?)(?:\||\r?\n|$)", RegexOptions.IgnoreCase); + var templateDiagnostics = ReadTemplateDiagnostics(output); + var slideAlerts = alertMatch.Success && int.TryParse(alertMatch.Groups["count"].Value, out var parsedAlerts) ? parsedAlerts : 0; var needsWork = needsWorkMatch.Success ? needsWorkMatch.Groups["value"].Value.Trim() : ""; var hasColorFallback = output.Contains("color fallback", StringComparison.OrdinalIgnoreCase); + var hasTemplateAssetMissing = output.Contains("asset missing", StringComparison.OrdinalIgnoreCase) || + templateDiagnostics.Contains("asset not found", StringComparison.OrdinalIgnoreCase); + var hasMasterCloneFailure = output.Contains("master clone failed", StringComparison.OrdinalIgnoreCase) || + templateDiagnostics.Contains("master clone failed", StringComparison.OrdinalIgnoreCase); var requiresRetry = score < minScore || slideAlerts > 0 + || hasColorFallback + || hasTemplateAssetMissing + || hasMasterCloneFailure || (!string.IsNullOrWhiteSpace(needsWork) && !string.Equals(needsWork, "none", StringComparison.OrdinalIgnoreCase)); - var summary = BuildSummary(score, slideAlerts, hasColorFallback, needsWork, minScore); + var summary = BuildSummary( + score, + slideAlerts, + hasColorFallback, + hasTemplateAssetMissing, + hasMasterCloneFailure, + needsWork, + minScore); assessment = new PptQualityGateAssessment( score, slideAlerts, hasColorFallback, + hasTemplateAssetMissing, + hasMasterCloneFailure, needsWork, + templateDiagnostics, requiresRetry, summary); return true; @@ -54,39 +78,75 @@ public static class PptQualityGatePolicy string? filePath, bool documentPlanWasCalled) { - var target = string.IsNullOrWhiteSpace(filePath) ? "방금 생성한 PPT 파일" : $"'{filePath}'"; + var target = string.IsNullOrWhiteSpace(filePath) ? "the previous PPT output" : $"'{filePath}'"; var planStep = documentPlanWasCalled - ? "기존 스토리라인을 더 날카롭게 다듬고 같은 파일 경로로 다시 생성하세요." - : "먼저 document_plan으로 스토리라인을 재정리한 뒤, 같은 파일 경로로 pptx_create를 다시 호출하세요."; - var templateStep = assessment.HasColorFallback - ? "템플릿 마스터 복제가 끝까지 적용되지 않았으니 template/template_pack 선택을 다시 점검하고 브랜드 템플릿을 우선 사용하세요." - : "template_pack(strategy/board/pmo/finance/sales/operating_model) 또는 적절한 브랜드 템플릿을 적극 사용하세요."; - var needsWorkLine = string.IsNullOrWhiteSpace(assessment.NeedsWork) - ? "- 주요 보완: 슬라이드당 메시지를 하나로 줄이고, 근거 슬라이드와 결론 슬라이드의 대비를 분명히 하세요.\n" - : $"- 주요 보완: {assessment.NeedsWork}\n"; + ? "Reuse the current storyline, but tighten the slide narrative and regenerate to the same output path." + : "Call document_plan first to rebuild the storyline, then regenerate with pptx_create to the same output path."; + var templateStep = assessment.HasTemplateAssetMissing + ? "Use only bundled manifest templates or template_pack values. Do not reference a missing external PPT asset." + : assessment.HasMasterCloneFailure + ? "Switch to a bundled manifest template or template_pack that can finish with 'master cloned'. Do not stop on color fallback." + : assessment.HasColorFallback + ? "Prefer a bundled manifest template or template_pack and regenerate until the result reports 'master cloned' instead of 'color fallback'." + : "Use the most suitable template_pack (strategy, board, pmo, finance, sales, operating_model) to strengthen layout fidelity."; + var needsWorkLine = string.IsNullOrWhiteSpace(assessment.NeedsWork) || + string.Equals(assessment.NeedsWork, "none", StringComparison.OrdinalIgnoreCase) + ? "- Main repair goal: reduce text density, sharpen the headline, and add evidence or takeaway slides where needed.\n" + : $"- Main repair goal: {assessment.NeedsWork}\n"; + var diagnosticsLine = string.IsNullOrWhiteSpace(assessment.TemplateDiagnostics) + ? string.Empty + : $"- Template diagnostics: {assessment.TemplateDiagnostics}\n"; - return "[System:PptQualityGate] 방금 생성한 PPT 품질이 기준 미만입니다.\n" + - $"- 대상 파일: {target}\n" + - $"- 품질 요약: {assessment.Summary}\n" + + return "[System:PptQualityGate] The previous PPT did not meet the minimum quality bar.\n" + + $"- Target file: {target}\n" + + $"- Quality summary: {assessment.Summary}\n" + + diagnosticsLine + needsWorkLine + - "- 필수 개선 방향:\n" + - " 1. 한 슬라이드 한 메시지 원칙으로 텍스트 밀도를 낮추세요.\n" + - " 2. Executive Summary → Options/Comparison → Recommendation → Roadmap → Appendix/Evidence 흐름을 분명히 하세요.\n" + - " 3. 표/차트/비교 슬라이드로 정량 근거를 늘리고, 헤드라인을 더 결론형 문장으로 바꾸세요.\n" + + "- Required improvements:\n" + + " 1. Keep one headline per slide and compress overly dense text into short evidence-backed bullets.\n" + + " 2. Make the flow explicit across Executive Summary, Options/Comparison, Recommendation, Roadmap, and Appendix/Evidence.\n" + + " 3. Turn long narrative sections into comparison, KPI, chart, or roadmap layouts when possible.\n" + $" 4. {templateStep}\n" + $" 5. {planStep}\n" + - "개선된 버전을 만든 뒤 다시 품질 요약이 좋아졌는지 확인하세요."; + "Regenerate the deck and verify that the reported quality summary improves."; } - private static string BuildSummary(int score, int slideAlerts, bool hasColorFallback, string needsWork, int minScore) + private static string ReadTemplateDiagnostics(string output) { - var parts = new List { $"점수 {score}/100 (기준 {minScore})" }; + var matches = Regex.Matches( + output, + @"^Template diagnostics:\s*(?.+)$", + RegexOptions.IgnoreCase | RegexOptions.Multiline); + if (matches.Count == 0) + return string.Empty; + + return string.Join( + " | ", + matches + .Select(match => match.Groups["value"].Value.Trim()) + .Where(value => !string.IsNullOrWhiteSpace(value))); + } + + private static string BuildSummary( + int score, + int slideAlerts, + bool hasColorFallback, + bool hasTemplateAssetMissing, + bool hasMasterCloneFailure, + string needsWork, + int minScore) + { + var parts = new List { $"score {score}/100 (target {minScore})" }; if (slideAlerts > 0) - parts.Add($"슬라이드 경고 {slideAlerts}건"); + parts.Add($"{slideAlerts} slide alerts"); if (hasColorFallback) - parts.Add("template color fallback 발생"); + parts.Add("color fallback triggered"); + if (hasTemplateAssetMissing) + parts.Add("template asset missing"); + if (hasMasterCloneFailure) + parts.Add("master clone failed"); if (!string.IsNullOrWhiteSpace(needsWork) && !string.Equals(needsWork, "none", StringComparison.OrdinalIgnoreCase)) - parts.Add($"Needs work: {needsWork}"); + parts.Add($"needs work: {needsWork}"); return string.Join(", ", parts); } } diff --git a/src/AxCopilot/Services/Agent/PptxSkill.cs b/src/AxCopilot/Services/Agent/PptxSkill.cs index 178f881..e39c0d8 100644 --- a/src/AxCopilot/Services/Agent/PptxSkill.cs +++ b/src/AxCopilot/Services/Agent/PptxSkill.cs @@ -220,6 +220,19 @@ public class PptxSkill : IAgentTool // ── 통합 테마 레코드 ────────────────────────────────────────────────────── private record FullTheme(ThemeColors Colors, ThemeLayout Layout); + private sealed record TemplateMasterCloneResult( + bool Success, + SlideLayoutPart? LayoutPart, + string Status, + string Detail) + { + public static TemplateMasterCloneResult Succeeded(SlideLayoutPart layoutPart) + => new(true, layoutPart, "ok", ""); + + public static TemplateMasterCloneResult Failed(string status, string detail) + => new(false, null, status, detail); + } + private static FullTheme ResolveTemplateFallbackTheme( string templateName, PptxTemplateResolution? resolution, @@ -549,8 +562,14 @@ public class PptxSkill : IAgentTool : (!hasExplicitTheme && !hasExplicitTemplate && !hasThemeFile ? PptxTemplatePackRegistry.Suggest(renderDeck?.Objective ?? objective, renderDeck?.Audience ?? audience) : null); + var templatePackRecommendation = templatePack != null + ? PptxTemplateManifestCatalog.RecommendTemplate( + templatePack.Name, + renderDeck?.Objective ?? objective, + renderDeck?.Audience ?? audience) + : null; var templatePackName = templatePack?.Name; - var packTemplateName = templatePack?.PreferredTemplate; + var packTemplateName = templatePackRecommendation?.Entry.Key ?? templatePack?.PreferredTemplate; PptxTemplateResolution? explicitTemplateResolution = null; PptxTemplateResolution? packTemplateResolution = null; @@ -703,6 +722,7 @@ public class PptxSkill : IAgentTool { var cloneInfo_srcLabel = templatePptxPath != null ? Path.GetFileName(templatePptxPath) : null; var cloneInfo_cloned = false; + TemplateMasterCloneResult? templateCloneResult = null; int slideCount_final = 0; using (var pres = PresentationDocument.Create(fullPath, PresentationDocumentType.Presentation)) @@ -714,9 +734,69 @@ public class PptxSkill : IAgentTool var clonedFromTemplate = false; // ── 마스터 복제 또는 자체 생성 ───────────────────────────────── - if (templatePptxPath != null && TryCloneMasterFromTemplate(templatePptxPath, presPart, out layoutPart!)) + if (templatePptxPath != null) { - clonedFromTemplate = true; + templateCloneResult = TryCloneMasterFromTemplate(templatePptxPath, presPart); + if (templateCloneResult.Success && templateCloneResult.LayoutPart != null) + { + layoutPart = templateCloneResult.LayoutPart; + clonedFromTemplate = true; + } + else + { + // 자체 마스터 생성 (기존 방식) + var masterPart = presPart.AddNewPart(); + masterPart.SlideMaster = new SlideMaster( + new CommonSlideData(new ShapeTree( + new P.NonVisualGroupShapeProperties( + new P.NonVisualDrawingProperties { Id = 1, Name = "" }, + new P.NonVisualGroupShapeDrawingProperties(), + new ApplicationNonVisualDrawingProperties()), + new GroupShapeProperties(new A.TransformGroup()))), + new P.ColorMap + { + Background1 = A.ColorSchemeIndexValues.Light1, + Text1 = A.ColorSchemeIndexValues.Dark1, + Background2 = A.ColorSchemeIndexValues.Light2, + Text2 = A.ColorSchemeIndexValues.Dark2, + Accent1 = A.ColorSchemeIndexValues.Accent1, + Accent2 = A.ColorSchemeIndexValues.Accent2, + Accent3 = A.ColorSchemeIndexValues.Accent3, + Accent4 = A.ColorSchemeIndexValues.Accent4, + Accent5 = A.ColorSchemeIndexValues.Accent5, + Accent6 = A.ColorSchemeIndexValues.Accent6, + Hyperlink = A.ColorSchemeIndexValues.Hyperlink, + FollowedHyperlink = A.ColorSchemeIndexValues.FollowedHyperlink, + }, + new SlideLayoutIdList()); + + layoutPart = masterPart.AddNewPart(); + layoutPart.SlideLayout = new SlideLayout( + new CommonSlideData(new ShapeTree( + new P.NonVisualGroupShapeProperties( + new P.NonVisualDrawingProperties { Id = 1, Name = "" }, + new P.NonVisualGroupShapeDrawingProperties(), + new ApplicationNonVisualDrawingProperties()), + new GroupShapeProperties(new A.TransformGroup())))) + { Type = SlideLayoutValues.Blank, Preserve = true }; + layoutPart.AddPart(masterPart); + + masterPart.SlideMaster.SlideLayoutIdList!.AppendChild(new SlideLayoutId + { + Id = 256U, + RelationshipId = masterPart.GetIdOfPart(layoutPart) + }); + + // ThemePart 생성 + var themePart = masterPart.AddNewPart(); + themePart.Theme = CreateOfficeTheme(fullTheme.Colors, "AX Theme"); + + presPart.Presentation.Append(new SlideMasterIdList(new SlideMasterId + { + Id = 2147483648U, + RelationshipId = presPart.GetIdOfPart(masterPart) + })); + } } else { @@ -1008,6 +1088,7 @@ public class PptxSkill : IAgentTool RepairContentTypes(fullPath); var templateSourceName = templateName ?? packTemplateName; + var fallbackLabel = FormatTemplateFallbackLabel(templateCloneResult); var themeLabel = cloneInfo_cloned ? templateSourceName != null ? $"template:{templateSourceName} (master cloned{(cloneAll ? " + slides cloned" : "")})" @@ -1017,34 +1098,36 @@ public class PptxSkill : IAgentTool : !string.IsNullOrWhiteSpace(templateName) ? templatePptxPath == null ? $"template:{templateName} (asset missing -> built-in fallback)" - : $"template:{templateName} (color fallback)" + : $"template:{templateName} ({fallbackLabel})" : templatePptxPath != null && !string.IsNullOrWhiteSpace(packTemplateName) - ? $"template:{packTemplateName} (color fallback)" + ? $"template:{packTemplateName} ({fallbackLabel})" : !string.IsNullOrWhiteSpace(packTemplateName) ? $"template:{packTemplateName} (asset missing -> pack fallback)" : templatePptxPath != null - ? $"theme_file:{cloneInfo_srcLabel} (color fallback)" + ? $"theme_file:{cloneInfo_srcLabel} ({fallbackLabel})" : theme; var outputParts = new List { $"PPTX created: {fullPath} ({slideCount_final} slides, {themeLabel}, {(isWide ? "16:9" : "4:3")})" }; - if (explicitTemplateResolution != null && explicitTemplateResolution.Status == "asset_missing") - { - outputParts.Add( - $"Template diagnostics: '{explicitTemplateResolution.RequestedName}' asset not found. " + - $"Searched {explicitTemplateResolution.CandidateDirectories.Count} candidate PPT directories."); - } - else if (packTemplateResolution != null && packTemplateResolution.Status == "asset_missing") - { - outputParts.Add( - $"Template diagnostics: pack template '{packTemplateResolution.RequestedName}' asset not found. " + - $"Searched {packTemplateResolution.CandidateDirectories.Count} candidate PPT directories."); - } + outputParts.AddRange(BuildTemplateDiagnosticsLines( + explicitTemplateResolution, + packTemplateResolution, + templateCloneResult, + templateName, + packTemplateName, + cloneInfo_srcLabel)); if (renderDeck != null) outputParts.Add(renderDeck.ToToolSummary()); if (!string.IsNullOrWhiteSpace(templatePackName)) outputParts.Add($"Template pack: {templatePackName}"); + if (templatePackRecommendation != null) + { + outputParts.Add( + $"Template recommendation: {templatePackRecommendation.Entry.Key} " + + $"(tone={templatePackRecommendation.Entry.Tone}, density={templatePackRecommendation.Entry.Density}, " + + $"fidelity={templatePackRecommendation.Entry.FidelityTier}, reason={templatePackRecommendation.Reason})"); + } if (deckReview == null && hasSlidesArray) { deckReview = DeckQualityReviewService.ReviewDeck( @@ -2499,28 +2582,80 @@ public class PptxSkill : IAgentTool notesPart.NotesSlide.Save(); } + private static A.Theme CreateOfficeTheme(ThemeColors colors, string themeName) + { + var palette = colors; + return new A.Theme( + new A.ThemeElements( + new A.ColorScheme( + new A.Dark1Color(new A.SystemColor { Val = A.SystemColorValues.WindowText, LastColor = "000000" }), + new A.Light1Color(new A.SystemColor { Val = A.SystemColorValues.Window, LastColor = "FFFFFF" }), + new A.Dark2Color(new A.RgbColorModelHex { Val = palette.TextDark.TrimStart('#') }), + new A.Light2Color(new A.RgbColorModelHex { Val = palette.BgAlt.TrimStart('#') }), + new A.Accent1Color(new A.RgbColorModelHex { Val = palette.Primary.TrimStart('#') }), + new A.Accent2Color(new A.RgbColorModelHex { Val = palette.Accent.TrimStart('#') }), + new A.Accent3Color(new A.RgbColorModelHex { Val = palette.HeaderBg.TrimStart('#') }), + new A.Accent4Color(new A.RgbColorModelHex { Val = palette.Accent.TrimStart('#') }), + new A.Accent5Color(new A.RgbColorModelHex { Val = palette.Primary.TrimStart('#') }), + new A.Accent6Color(new A.RgbColorModelHex { Val = palette.Accent.TrimStart('#') }), + new A.Hyperlink(new A.RgbColorModelHex { Val = "0563C1" }), + new A.FollowedHyperlinkColor(new A.RgbColorModelHex { Val = "954F72" }) + ) { Name = themeName }, + new A.FontScheme( + new A.MajorFont(new A.LatinFont { Typeface = "Noto Sans KR" }, + new A.EastAsianFont { Typeface = "Noto Sans KR" }, + new A.ComplexScriptFont { Typeface = "Noto Sans KR" }), + new A.MinorFont(new A.LatinFont { Typeface = "Noto Sans KR" }, + new A.EastAsianFont { Typeface = "Noto Sans KR" }, + new A.ComplexScriptFont { Typeface = "Noto Sans KR" }) + ) { Name = themeName }, + new A.FormatScheme( + new A.FillStyleList( + new A.SolidFill(new A.SchemeColor { Val = A.SchemeColorValues.PhColor }), + new A.SolidFill(new A.SchemeColor { Val = A.SchemeColorValues.PhColor }), + new A.SolidFill(new A.SchemeColor { Val = A.SchemeColorValues.PhColor })), + new A.LineStyleList( + new A.Outline(new A.SolidFill(new A.SchemeColor { Val = A.SchemeColorValues.PhColor })) { Width = 9525 }, + new A.Outline(new A.SolidFill(new A.SchemeColor { Val = A.SchemeColorValues.PhColor })) { Width = 9525 }, + new A.Outline(new A.SolidFill(new A.SchemeColor { Val = A.SchemeColorValues.PhColor })) { Width = 9525 }), + new A.EffectStyleList( + new A.EffectStyle(new A.EffectList()), + new A.EffectStyle(new A.EffectList()), + new A.EffectStyle(new A.EffectList())), + new A.BackgroundFillStyleList( + new A.SolidFill(new A.SchemeColor { Val = A.SchemeColorValues.PhColor }), + new A.SolidFill(new A.SchemeColor { Val = A.SchemeColorValues.PhColor }), + new A.SolidFill(new A.SchemeColor { Val = A.SchemeColorValues.PhColor })) + ) { Name = themeName } + ), + new A.ObjectDefaults(), + new A.ExtraColorSchemeList()) + { Name = themeName }; + } + // ── 고품질 템플릿에서 마스터/레이아웃/테마 복제 ────────────────────────────── /// /// 기존 .pptx 템플릿에서 SlideMaster, SlideLayout, ThemePart를 통째로 복제합니다. /// 복제 성공 시 layoutPart에 첫 번째 레이아웃을 반환하고 true. /// 실패 시 false → 호출부에서 자체 마스터를 생성합니다 (색상 추출 폴백). /// - private static bool TryCloneMasterFromTemplate( + private static TemplateMasterCloneResult TryCloneMasterFromTemplate( string templatePath, - PresentationPart targetPresPart, - out SlideLayoutPart? layoutPart) + PresentationPart targetPresPart) { - layoutPart = null; - if (!File.Exists(templatePath)) return false; + if (!File.Exists(templatePath)) + return TemplateMasterCloneResult.Failed("file_missing", "The template file does not exist."); try { using var srcDoc = PresentationDocument.Open(templatePath, isEditable: false); var srcPresPart = srcDoc.PresentationPart; - if (srcPresPart == null) return false; + if (srcPresPart == null) + return TemplateMasterCloneResult.Failed("missing_presentation_part", "The source PPTX has no presentation part."); var srcMaster = srcPresPart.SlideMasterParts.FirstOrDefault(); - if (srcMaster == null) return false; + if (srcMaster == null) + return TemplateMasterCloneResult.Failed("missing_slide_master", "The source PPTX has no slide master."); // 1. SlideMasterPart 통째로 복제 (ThemePart, 이미지, 레이아웃 포함) var clonedMaster = targetPresPart.AddPart(srcMaster); @@ -2545,20 +2680,71 @@ public class PptxSkill : IAgentTool break; } } - layoutPart = blankLayout ?? firstLayout; - if (layoutPart == null) return false; + + var layoutPart = blankLayout ?? firstLayout; + if (layoutPart == null) + return TemplateMasterCloneResult.Failed("missing_slide_layout", "The source PPTX has no reusable slide layout."); // 4. 마스터 저장 clonedMaster.SlideMaster.Save(); - return true; + return TemplateMasterCloneResult.Succeeded(layoutPart); + } + catch (OpenXmlPackageException ex) + { + Services.LogService.Warn($"템플릿 마스터 복제 실패 ({Path.GetFileName(templatePath)}): {ex.Message}"); + return TemplateMasterCloneResult.Failed("openxml_package_error", ex.Message); } catch (Exception ex) { Services.LogService.Warn($"템플릿 마스터 복제 실패 ({Path.GetFileName(templatePath)}): {ex.Message}"); - return false; + return TemplateMasterCloneResult.Failed("clone_exception", ex.Message); } } + private static string FormatTemplateFallbackLabel(TemplateMasterCloneResult? cloneResult) + { + if (cloneResult == null || cloneResult.Success) + return "color fallback"; + + return $"master clone failed:{cloneResult.Status} -> color fallback"; + } + + private static IReadOnlyList BuildTemplateDiagnosticsLines( + PptxTemplateResolution? explicitTemplateResolution, + PptxTemplateResolution? packTemplateResolution, + TemplateMasterCloneResult? cloneResult, + string? templateName, + string? packTemplateName, + string? themeFileLabel) + { + var lines = new List(); + if (explicitTemplateResolution != null && explicitTemplateResolution.Status == "asset_missing") + { + lines.Add( + $"Template diagnostics: '{explicitTemplateResolution.RequestedName}' asset not found. " + + $"Searched {explicitTemplateResolution.CandidateDirectories.Count} candidate PPT directories."); + } + else if (packTemplateResolution != null && packTemplateResolution.Status == "asset_missing") + { + lines.Add( + $"Template diagnostics: pack template '{packTemplateResolution.RequestedName}' asset not found. " + + $"Searched {packTemplateResolution.CandidateDirectories.Count} candidate PPT directories."); + } + + if (cloneResult != null && !cloneResult.Success) + { + var sourceLabel = !string.IsNullOrWhiteSpace(templateName) + ? $"template '{templateName}'" + : !string.IsNullOrWhiteSpace(packTemplateName) + ? $"pack template '{packTemplateName}'" + : $"theme_file '{themeFileLabel}'"; + lines.Add( + $"Template diagnostics: {sourceLabel} master clone failed ({cloneResult.Status}). {cloneResult.Detail}"); + } + + return lines; + } + // ══════════════════════════════════════════════════════════════════════════ // 슬라이드 페이지 복제 (방법 1 + 방법 2) // ══════════════════════════════════════════════════════════════════════════ diff --git a/src/AxCopilot/Services/Agent/PptxTemplateManifestCatalog.cs b/src/AxCopilot/Services/Agent/PptxTemplateManifestCatalog.cs index efef15a..ed3a214 100644 --- a/src/AxCopilot/Services/Agent/PptxTemplateManifestCatalog.cs +++ b/src/AxCopilot/Services/Agent/PptxTemplateManifestCatalog.cs @@ -1,20 +1,31 @@ +using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text.Json; namespace AxCopilot.Services.Agent; -public sealed record PptxTemplateManifestEntry( - string Key, - string FileName, - string DisplayName, - string FallbackTheme, - IReadOnlyList Aliases, - IReadOnlyList Tags, - IReadOnlyList PackHints); +public sealed class PptxTemplateManifestEntry +{ + public string Key { get; init; } = ""; + public string FileName { get; init; } = ""; + public string DisplayName { get; init; } = ""; + public string FallbackTheme { get; init; } = "professional"; + public List Aliases { get; init; } = []; + public List Tags { get; init; } = []; + public List PackHints { get; init; } = []; + public string Tone { get; init; } = "balanced"; + public string Density { get; init; } = "balanced"; + public string AspectHint { get; init; } = "widescreen"; + public string FidelityTier { get; init; } = "standard"; + public bool SupportsMasterClone { get; init; } = true; + public List ObjectiveKeywords { get; init; } = []; + public List AudienceKeywords { get; init; } = []; +} public sealed class PptxTemplateManifest { - public List Templates { get; init; } = new(); + public List Templates { get; init; } = []; } public sealed record PptxTemplateResolution( @@ -27,6 +38,11 @@ public sealed record PptxTemplateResolution( public bool IsResolved => !string.IsNullOrWhiteSpace(ResolvedPath); } +public sealed record PptxTemplateRecommendation( + PptxTemplateManifestEntry Entry, + int Score, + string Reason); + public static class PptxTemplateManifestCatalog { private const string ManifestFileName = "templates.manifest.json"; @@ -54,6 +70,82 @@ public static class PptxTemplateManifestCatalog entry.Aliases.Any(alias => string.Equals(alias, normalized, StringComparison.OrdinalIgnoreCase))); } + public static PptxTemplateRecommendation? RecommendTemplate(string? packName, string? objective, string? audience) + { + var normalizedPack = packName?.Trim(); + var normalizedObjective = NormalizeText(objective); + var normalizedAudience = NormalizeText(audience); + var combinedText = $"{normalizedObjective} {normalizedAudience}".Trim(); + + if (string.IsNullOrWhiteSpace(normalizedPack) && + string.IsNullOrWhiteSpace(normalizedObjective) && + string.IsNullOrWhiteSpace(normalizedAudience)) + { + return null; + } + + var bestScore = 0; + PptxTemplateRecommendation? bestRecommendation = null; + foreach (var entry in LoadManifest().Templates) + { + var score = 0; + var reasons = new List(); + + if (!string.IsNullOrWhiteSpace(normalizedPack) && + entry.PackHints.Any(hint => string.Equals(hint, normalizedPack, StringComparison.OrdinalIgnoreCase))) + { + score += 40; + reasons.Add($"pack:{normalizedPack}"); + } + + var objectiveMatches = CountMatches(normalizedObjective, entry.ObjectiveKeywords); + if (objectiveMatches > 0) + { + score += objectiveMatches * 10; + reasons.Add($"objective:{objectiveMatches}"); + } + + var audienceMatches = CountMatches(normalizedAudience, entry.AudienceKeywords); + if (audienceMatches > 0) + { + score += audienceMatches * 8; + reasons.Add($"audience:{audienceMatches}"); + } + + var tagMatches = CountMatches(combinedText, entry.Tags); + if (tagMatches > 0) + { + score += tagMatches * 4; + reasons.Add($"tags:{tagMatches}"); + } + + if (entry.SupportsMasterClone) + { + score += 3; + reasons.Add("master-clone"); + } + + score += entry.FidelityTier.ToLowerInvariant() switch + { + "premium" => 6, + "high" => 4, + "standard" => 2, + _ => 0, + }; + + if (score <= bestScore) + continue; + + bestScore = score; + bestRecommendation = new PptxTemplateRecommendation( + entry, + score, + string.Join(", ", reasons)); + } + + return bestRecommendation; + } + public static PptxTemplateResolution ResolveTemplate(string? requestedName) { if (string.IsNullOrWhiteSpace(requestedName)) @@ -68,7 +160,7 @@ public static class PptxTemplateManifestCatalog null, explicitPath, explicitPath != null ? "explicit_path" : "explicit_path_missing", - explicitPath != null ? new[] { Path.GetDirectoryName(explicitPath)! } : Array.Empty()); + explicitPath != null ? [Path.GetDirectoryName(explicitPath)!] : Array.Empty()); } var entry = Resolve(trimmed); @@ -224,6 +316,17 @@ public static class PptxTemplateManifestCatalog } } + private static string NormalizeText(string? value) + => string.IsNullOrWhiteSpace(value) ? "" : value.Trim().ToLowerInvariant(); + + private static int CountMatches(string normalizedText, IEnumerable keywords) + { + if (string.IsNullOrWhiteSpace(normalizedText)) + return 0; + + return keywords.Count(keyword => normalizedText.Contains(keyword, StringComparison.OrdinalIgnoreCase)); + } + private static bool LooksLikePath(string value) => value.Contains('\\', StringComparison.Ordinal) || value.Contains('/', StringComparison.Ordinal) @@ -248,14 +351,142 @@ public static class PptxTemplateManifestCatalog { Templates = [ - new("basic100", "BASIC100 기준 템플릿 V1.pptx", "BASIC100 기준 템플릿 V1", "professional", ["basic", "consulting_basic"], ["executive", "strategy", "consulting", "brand"], ["strategy", "pmo", "sales", "operating_model"]), - new("core100", "CORE100 기준템플릿 V1.pptx", "CORE100 기준 템플릿 V1", "corporate", ["core", "board_core"], ["board", "governance", "finance", "corporate"], ["board", "finance"]), - new("frame_blue", "P01_01_프레임디자인_PPT템플릿_블루(PPT_편집용).pptx", "프레임디자인 블루", "modern", ["blue_frame"], ["frame", "blue", "report", "proposal"], ["sales", "operating_model"]), - new("mr_ppt_01", "미스터 피피티 템플릿 01_원본.pptx", "미스터 피피티 템플릿 01", "modern", ["mrppt01"], ["creative", "proposal", "sales"], ["sales"]), - new("mr_ppt_02", "미스터 피피티 템플릿 02_원본.pptx", "미스터 피피티 템플릿 02", "vibrant", ["mrppt02"], ["creative", "marketing", "proposal"], ["sales"]), - new("mr_ppt_03", "미스터 피피티 03_원본.pptx", "미스터 피피티 템플릿 03", "minimal", ["mrppt03"], ["minimal", "analysis", "report"], ["strategy", "finance"]), - new("mr_ppt_04", "미스터 피피티 템플릿 04_원본.pptx", "미스터 피피티 템플릿 04", "slate", ["mrppt04"], ["dark", "executive", "review"], ["board", "finance"]), - new("mr_ppt_05", "미스터 피피티 템플릿_05_원본.pptx", "미스터 피피티 템플릿 05", "ocean", ["mrppt05"], ["blue", "modern", "report"], ["pmo", "operating_model"]), + new PptxTemplateManifestEntry + { + Key = "basic100", + FileName = "BASIC100 \uae30\uc900 \ud15c\ud50c\ub9bf V1.pptx", + DisplayName = "BASIC100 template", + FallbackTheme = "professional", + Aliases = ["basic", "consulting_basic"], + Tags = ["executive", "strategy", "consulting", "brand"], + PackHints = ["strategy", "pmo", "sales", "operating_model"], + Tone = "consulting", + Density = "balanced", + AspectHint = "widescreen", + FidelityTier = "premium", + SupportsMasterClone = true, + ObjectiveKeywords = ["strategy", "transformation", "operating model", "growth", "roadmap"], + AudienceKeywords = ["executive", "leadership", "steering", "pmo", "business"] + }, + new PptxTemplateManifestEntry + { + Key = "core100", + FileName = "CORE100 \uae30\uc900\ud15c\ud50c\ub9bf V1.pptx", + DisplayName = "CORE100 template", + FallbackTheme = "corporate", + Aliases = ["core", "board_core"], + Tags = ["board", "governance", "finance", "corporate"], + PackHints = ["board", "finance"], + Tone = "corporate", + Density = "concise", + AspectHint = "widescreen", + FidelityTier = "premium", + SupportsMasterClone = true, + ObjectiveKeywords = ["board", "governance", "finance", "budget", "investment"], + AudienceKeywords = ["board", "executive", "committee", "cfo", "directors"] + }, + new PptxTemplateManifestEntry + { + Key = "frame_blue", + FileName = "P01_01_\ud504\ub808\uc784\ub514\uc790\uc778_PPT\ud15c\ud50c\ub9bf_\ube14\ub8e8(PPT_\ud3b8\uc9d1\uc6a9).pptx", + DisplayName = "Blue frame template", + FallbackTheme = "modern", + Aliases = ["blue_frame"], + Tags = ["frame", "blue", "report", "proposal"], + PackHints = ["sales", "operating_model"], + Tone = "proposal", + Density = "balanced", + AspectHint = "widescreen", + FidelityTier = "high", + SupportsMasterClone = true, + ObjectiveKeywords = ["proposal", "operating model", "sales", "status", "report"], + AudienceKeywords = ["client", "sales", "project", "leadership"] + }, + new PptxTemplateManifestEntry + { + Key = "mr_ppt_01", + FileName = "\ubbf8\uc2a4\ud130 \ud53c\ud53c\ud2f0 \ud15c\ud50c\ub9bf 01_\uc6d0\ubcf8.pptx", + DisplayName = "Mr PPT 01", + FallbackTheme = "modern", + Aliases = ["mrppt01"], + Tags = ["creative", "proposal", "sales"], + PackHints = ["sales"], + Tone = "creative", + Density = "balanced", + AspectHint = "widescreen", + FidelityTier = "standard", + SupportsMasterClone = true, + ObjectiveKeywords = ["proposal", "pitch", "sales", "commercial"], + AudienceKeywords = ["client", "sales", "commercial"] + }, + new PptxTemplateManifestEntry + { + Key = "mr_ppt_02", + FileName = "\ubbf8\uc2a4\ud130 \ud53c\ud53c\ud2f0 \ud15c\ud50c\ub9bf 02_\uc6d0\ubcf8.pptx", + DisplayName = "Mr PPT 02", + FallbackTheme = "vibrant", + Aliases = ["mrppt02"], + Tags = ["creative", "marketing", "proposal"], + PackHints = ["sales"], + Tone = "bold", + Density = "condensed", + AspectHint = "widescreen", + FidelityTier = "standard", + SupportsMasterClone = true, + ObjectiveKeywords = ["marketing", "launch", "brand", "proposal"], + AudienceKeywords = ["marketing", "sales", "brand"] + }, + new PptxTemplateManifestEntry + { + Key = "mr_ppt_03", + FileName = "\ubbf8\uc2a4\ud130 \ud53c\ud53c\ud2f0 03_\uc6d0\ubcf8.pptx", + DisplayName = "Mr PPT 03", + FallbackTheme = "minimal", + Aliases = ["mrppt03"], + Tags = ["minimal", "analysis", "report"], + PackHints = ["strategy", "finance"], + Tone = "analytical", + Density = "concise", + AspectHint = "widescreen", + FidelityTier = "high", + SupportsMasterClone = true, + ObjectiveKeywords = ["analysis", "review", "report", "diagnostic"], + AudienceKeywords = ["strategy", "finance", "leadership"] + }, + new PptxTemplateManifestEntry + { + Key = "mr_ppt_04", + FileName = "\ubbf8\uc2a4\ud130 \ud53c\ud53c\ud2f0 \ud15c\ud50c\ub9bf 04_\uc6d0\ubcf8.pptx", + DisplayName = "Mr PPT 04", + FallbackTheme = "slate", + Aliases = ["mrppt04"], + Tags = ["dark", "executive", "review"], + PackHints = ["board", "finance"], + Tone = "executive", + Density = "condensed", + AspectHint = "widescreen", + FidelityTier = "high", + SupportsMasterClone = true, + ObjectiveKeywords = ["board review", "risk", "finance", "committee"], + AudienceKeywords = ["board", "executive", "cfo"] + }, + new PptxTemplateManifestEntry + { + Key = "mr_ppt_05", + FileName = "\ubbf8\uc2a4\ud130 \ud53c\ud53c\ud2f0 \ud15c\ud50c\ub9bf_05_\uc6d0\ubcf8.pptx", + DisplayName = "Mr PPT 05", + FallbackTheme = "ocean", + Aliases = ["mrppt05"], + Tags = ["blue", "modern", "report"], + PackHints = ["pmo", "operating_model"], + Tone = "operational", + Density = "balanced", + AspectHint = "widescreen", + FidelityTier = "high", + SupportsMasterClone = true, + ObjectiveKeywords = ["pmo", "roadmap", "status", "operating model"], + AudienceKeywords = ["pmo", "operations", "delivery", "steering"] + }, ] }; }