PPT 템플릿 품질 게이트와 manifest 추천 흐름을 고도화한다

- 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건 통과
This commit is contained in:
2026-04-16 00:16:36 +09:00
parent 13061fa3ca
commit db4ccd5df4
10 changed files with 781 additions and 102 deletions

View File

@@ -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 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 - `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) - 업데이트: 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로 생성된 보고서도 앞부분과 같은 문서 톤을 유지하도록 맞췄습니다. - HTML 보고서 뒤쪽으로 갈수록 폰트 크기와 카드 레이아웃이 흔들리던 raw body 호환 문제를 보강했습니다. `src/AxCopilot/Services/Agent/TemplateService.cs``h4`, `dl`, `matrix`, `comparison`, `decision_matrix`, `board_report`, `metrics`, `roadmap` 같은 레거시 블록 전용 CSS를 추가해, 구조화 섹션이 아닌 자유 본문 HTML로 생성된 보고서도 앞부분과 같은 문서 톤을 유지하도록 맞췄습니다.

View File

@@ -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 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 - `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:<reason> -> 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

View File

@@ -10,7 +10,8 @@ public class PptQualityGatePolicyTests
public void TryAssess_ShouldRequestRetry_ForLowScoreDeck() public void TryAssess_ShouldRequestRetry_ForLowScoreDeck()
{ {
var result = ToolResult.Ok( 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."); "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); var parsed = PptQualityGatePolicy.TryAssess(result, out var assessment);
@@ -19,6 +20,22 @@ public class PptQualityGatePolicyTests
assessment.Score.Should().Be(64); assessment.Score.Should().Be(64);
assessment.SlideAlertCount.Should().Be(4); assessment.SlideAlertCount.Should().Be(4);
assessment.HasColorFallback.Should().BeTrue(); 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(); assessment.RequiresRetry.Should().BeTrue();
} }
@@ -37,20 +54,27 @@ public class PptQualityGatePolicyTests
} }
[Fact] [Fact]
public void BuildRetryPrompt_ShouldRecommendDocumentPlan_WhenStorylineWasNotPrepared() public void BuildRetryPrompt_ShouldRecommendDocumentPlan_AndManifestTemplateRecovery()
{ {
var assessment = new PptQualityGateAssessment( var assessment = new PptQualityGateAssessment(
64, 64,
3, 3,
HasColorFallback: false, HasColorFallback: true,
"Too many slides are text-heavy.", HasTemplateAssetMissing: false,
HasMasterCloneFailure: true,
NeedsWork: "Too many slides are text-heavy.",
TemplateDiagnostics: "template 'basic100' master clone failed (openxml_package_error).",
RequiresRetry: true, 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("document_plan");
prompt.Should().Contain("pptx_create"); prompt.Should().Contain("pptx_create");
prompt.Should().Contain("master cloned");
prompt.Should().Contain("E:\\docu\\deck.pptx"); prompt.Should().Contain("E:\\docu\\deck.pptx");
} }
} }

View File

@@ -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
{
}
}
}
}

View File

@@ -49,6 +49,7 @@ public class PptxSkillTemplatePackTests
result.Success.Should().BeTrue(); result.Success.Should().BeTrue();
result.Output.Should().Contain("Template pack: board"); result.Output.Should().Contain("Template pack: board");
result.Output.Should().Contain("Template recommendation: core100");
result.Output.Should().NotContain("asset missing"); result.Output.Should().NotContain("asset missing");
File.Exists(Path.Combine(workDir, "board-pack-deck.pptx")).Should().BeTrue(); File.Exists(Path.Combine(workDir, "board-pack-deck.pptx")).Should().BeTrue();
} }

View File

@@ -13,8 +13,24 @@ public class PptxTemplateManifestCatalogTests
var entry = PptxTemplateManifestCatalog.Resolve("basic100"); var entry = PptxTemplateManifestCatalog.Resolve("basic100");
entry.Should().NotBeNull(); entry.Should().NotBeNull();
entry!.FileName.Should().Be("BASIC100 기준 템플릿 V1.pptx"); entry!.Key.Should().Be("basic100");
entry.FallbackTheme.Should().Be("professional"); 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] [Fact]

View File

@@ -2,75 +2,131 @@
"templates": [ "templates": [
{ {
"key": "basic100", "key": "basic100",
"fileName": "BASIC100 기준 템플릿 V1.pptx", "fileName": "BASIC100 \uae30\uc900 \ud15c\ud50c\ub9bf V1.pptx",
"displayName": "BASIC100 기준 템플릿 V1", "displayName": "BASIC100 template",
"fallbackTheme": "professional", "fallbackTheme": "professional",
"aliases": ["basic", "consulting_basic"], "aliases": ["basic", "consulting_basic"],
"tags": ["executive", "strategy", "consulting", "brand"], "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", "key": "core100",
"fileName": "CORE100 기준템플릿 V1.pptx", "fileName": "CORE100 \uae30\uc900\ud15c\ud50c\ub9bf V1.pptx",
"displayName": "CORE100 기준 템플릿 V1", "displayName": "CORE100 template",
"fallbackTheme": "corporate", "fallbackTheme": "corporate",
"aliases": ["core", "board_core"], "aliases": ["core", "board_core"],
"tags": ["board", "governance", "finance", "corporate"], "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", "key": "frame_blue",
"fileName": "P01_01_프레임디자인_PPT템플릿_블루(PPT_편집용).pptx", "fileName": "P01_01_\ud504\ub808\uc784\ub514\uc790\uc778_PPT\ud15c\ud50c\ub9bf_\ube14\ub8e8(PPT_\ud3b8\uc9d1\uc6a9).pptx",
"displayName": "프레임디자인 블루", "displayName": "Blue frame template",
"fallbackTheme": "modern", "fallbackTheme": "modern",
"aliases": ["blue_frame"], "aliases": ["blue_frame"],
"tags": ["frame", "blue", "report", "proposal"], "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", "key": "mr_ppt_01",
"fileName": "미스터 피피티 템플릿 01_원본.pptx", "fileName": "\ubbf8\uc2a4\ud130 \ud53c\ud53c\ud2f0 \ud15c\ud50c\ub9bf 01_\uc6d0\ubcf8.pptx",
"displayName": "미스터 피피티 템플릿 01", "displayName": "Mr PPT 01",
"fallbackTheme": "modern", "fallbackTheme": "modern",
"aliases": ["mrppt01"], "aliases": ["mrppt01"],
"tags": ["creative", "proposal", "sales"], "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", "key": "mr_ppt_02",
"fileName": "미스터 피피티 템플릿 02_원본.pptx", "fileName": "\ubbf8\uc2a4\ud130 \ud53c\ud53c\ud2f0 \ud15c\ud50c\ub9bf 02_\uc6d0\ubcf8.pptx",
"displayName": "미스터 피피티 템플릿 02", "displayName": "Mr PPT 02",
"fallbackTheme": "vibrant", "fallbackTheme": "vibrant",
"aliases": ["mrppt02"], "aliases": ["mrppt02"],
"tags": ["creative", "marketing", "proposal"], "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", "key": "mr_ppt_03",
"fileName": "미스터 피피티 03_원본.pptx", "fileName": "\ubbf8\uc2a4\ud130 \ud53c\ud53c\ud2f0 03_\uc6d0\ubcf8.pptx",
"displayName": "미스터 피피티 템플릿 03", "displayName": "Mr PPT 03",
"fallbackTheme": "minimal", "fallbackTheme": "minimal",
"aliases": ["mrppt03"], "aliases": ["mrppt03"],
"tags": ["minimal", "analysis", "report"], "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", "key": "mr_ppt_04",
"fileName": "미스터 피피티 템플릿 04_원본.pptx", "fileName": "\ubbf8\uc2a4\ud130 \ud53c\ud53c\ud2f0 \ud15c\ud50c\ub9bf 04_\uc6d0\ubcf8.pptx",
"displayName": "미스터 피피티 템플릿 04", "displayName": "Mr PPT 04",
"fallbackTheme": "slate", "fallbackTheme": "slate",
"aliases": ["mrppt04"], "aliases": ["mrppt04"],
"tags": ["dark", "executive", "review"], "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", "key": "mr_ppt_05",
"fileName": "미스터 피피티 템플릿_05_원본.pptx", "fileName": "\ubbf8\uc2a4\ud130 \ud53c\ud53c\ud2f0 \ud15c\ud50c\ub9bf_05_\uc6d0\ubcf8.pptx",
"displayName": "미스터 피피티 템플릿 05", "displayName": "Mr PPT 05",
"fallbackTheme": "ocean", "fallbackTheme": "ocean",
"aliases": ["mrppt05"], "aliases": ["mrppt05"],
"tags": ["blue", "modern", "report"], "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"]
} }
] ]
} }

View File

@@ -1,3 +1,5 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
namespace AxCopilot.Services.Agent; namespace AxCopilot.Services.Agent;
@@ -6,7 +8,10 @@ public sealed record PptQualityGateAssessment(
int Score, int Score,
int SlideAlertCount, int SlideAlertCount,
bool HasColorFallback, bool HasColorFallback,
bool HasTemplateAssetMissing,
bool HasMasterCloneFailure,
string NeedsWork, string NeedsWork,
string TemplateDiagnostics,
bool RequiresRetry, bool RequiresRetry,
string Summary); string Summary);
@@ -17,7 +22,7 @@ public static class PptQualityGatePolicy
public static bool TryAssess(ToolResult result, out PptQualityGateAssessment assessment, int minScore = DefaultMinScore) 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)) if (!result.Success || string.IsNullOrWhiteSpace(result.Output))
return false; return false;
@@ -28,22 +33,41 @@ public static class PptQualityGatePolicy
var alertMatch = Regex.Match(output, @"Slide alerts:\s*(?<count>\d+)", RegexOptions.IgnoreCase); var alertMatch = Regex.Match(output, @"Slide alerts:\s*(?<count>\d+)", RegexOptions.IgnoreCase);
var needsWorkMatch = Regex.Match(output, @"Needs work:\s*(?<value>.+?)(?:\||\r?\n|$)", RegexOptions.IgnoreCase); var needsWorkMatch = Regex.Match(output, @"Needs work:\s*(?<value>.+?)(?:\||\r?\n|$)", RegexOptions.IgnoreCase);
var templateDiagnostics = ReadTemplateDiagnostics(output);
var slideAlerts = alertMatch.Success && int.TryParse(alertMatch.Groups["count"].Value, out var parsedAlerts) var slideAlerts = alertMatch.Success && int.TryParse(alertMatch.Groups["count"].Value, out var parsedAlerts)
? parsedAlerts ? parsedAlerts
: 0; : 0;
var needsWork = needsWorkMatch.Success ? needsWorkMatch.Groups["value"].Value.Trim() : ""; var needsWork = needsWorkMatch.Success ? needsWorkMatch.Groups["value"].Value.Trim() : "";
var hasColorFallback = output.Contains("color fallback", StringComparison.OrdinalIgnoreCase); 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 var requiresRetry = score < minScore
|| slideAlerts > 0 || slideAlerts > 0
|| hasColorFallback
|| hasTemplateAssetMissing
|| hasMasterCloneFailure
|| (!string.IsNullOrWhiteSpace(needsWork) || (!string.IsNullOrWhiteSpace(needsWork)
&& !string.Equals(needsWork, "none", StringComparison.OrdinalIgnoreCase)); && !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( assessment = new PptQualityGateAssessment(
score, score,
slideAlerts, slideAlerts,
hasColorFallback, hasColorFallback,
hasTemplateAssetMissing,
hasMasterCloneFailure,
needsWork, needsWork,
templateDiagnostics,
requiresRetry, requiresRetry,
summary); summary);
return true; return true;
@@ -54,39 +78,75 @@ public static class PptQualityGatePolicy
string? filePath, string? filePath,
bool documentPlanWasCalled) bool documentPlanWasCalled)
{ {
var target = string.IsNullOrWhiteSpace(filePath) ? "방금 생성한 PPT 파일" : $"'{filePath}'"; var target = string.IsNullOrWhiteSpace(filePath) ? "the previous PPT output" : $"'{filePath}'";
var planStep = documentPlanWasCalled var planStep = documentPlanWasCalled
? "기존 스토리라인을 더 날카롭게 다듬고 같은 파일 경로로 다시 생성하세요." ? "Reuse the current storyline, but tighten the slide narrative and regenerate to the same output path."
: "먼저 document_plan으로 스토리라인을 재정리한 뒤, 같은 파일 경로로 pptx_create를 다시 호출하세요."; : "Call document_plan first to rebuild the storyline, then regenerate with pptx_create to the same output path.";
var templateStep = assessment.HasColorFallback var templateStep = assessment.HasTemplateAssetMissing
? "템플릿 마스터 복제가 끝까지 적용되지 않았으니 template/template_pack 선택을 다시 점검하고 브랜드 템플릿을 우선 사용하세요." ? "Use only bundled manifest templates or template_pack values. Do not reference a missing external PPT asset."
: "template_pack(strategy/board/pmo/finance/sales/operating_model) 또는 적절한 브랜드 템플릿을 적극 사용하세요."; : assessment.HasMasterCloneFailure
var needsWorkLine = string.IsNullOrWhiteSpace(assessment.NeedsWork) ? "Switch to a bundled manifest template or template_pack that can finish with 'master cloned'. Do not stop on color fallback."
? "- 주요 보완: 슬라이드당 메시지를 하나로 줄이고, 근거 슬라이드와 결론 슬라이드의 대비를 분명히 하세요.\n" : assessment.HasColorFallback
: $"- 주요 보완: {assessment.NeedsWork}\n"; ? "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" + return "[System:PptQualityGate] The previous PPT did not meet the minimum quality bar.\n" +
$"- 대상 파일: {target}\n" + $"- Target file: {target}\n" +
$"- 품질 요약: {assessment.Summary}\n" + $"- Quality summary: {assessment.Summary}\n" +
diagnosticsLine +
needsWorkLine + needsWorkLine +
"- 필수 개선 방향:\n" + "- Required improvements:\n" +
" 1. 한 슬라이드 한 메시지 원칙으로 텍스트 밀도를 낮추세요.\n" + " 1. Keep one headline per slide and compress overly dense text into short evidence-backed bullets.\n" +
" 2. Executive Summary Options/Comparison Recommendation Roadmap Appendix/Evidence 흐름을 분명히 하세요.\n" + " 2. Make the flow explicit across Executive Summary, Options/Comparison, Recommendation, Roadmap, and Appendix/Evidence.\n" +
" 3. 표/차트/비교 슬라이드로 정량 근거를 늘리고, 헤드라인을 더 결론형 문장으로 바꾸세요.\n" + " 3. Turn long narrative sections into comparison, KPI, chart, or roadmap layouts when possible.\n" +
$" 4. {templateStep}\n" + $" 4. {templateStep}\n" +
$" 5. {planStep}\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<string> { $"점수 {score}/100 (기준 {minScore})" }; var matches = Regex.Matches(
output,
@"^Template diagnostics:\s*(?<value>.+)$",
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<string> { $"score {score}/100 (target {minScore})" };
if (slideAlerts > 0) if (slideAlerts > 0)
parts.Add($"슬라이드 경고 {slideAlerts}"); parts.Add($"{slideAlerts} slide alerts");
if (hasColorFallback) 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)) if (!string.IsNullOrWhiteSpace(needsWork) && !string.Equals(needsWork, "none", StringComparison.OrdinalIgnoreCase))
parts.Add($"Needs work: {needsWork}"); parts.Add($"needs work: {needsWork}");
return string.Join(", ", parts); return string.Join(", ", parts);
} }
} }

View File

@@ -220,6 +220,19 @@ public class PptxSkill : IAgentTool
// ── 통합 테마 레코드 ────────────────────────────────────────────────────── // ── 통합 테마 레코드 ──────────────────────────────────────────────────────
private record FullTheme(ThemeColors Colors, ThemeLayout Layout); 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( private static FullTheme ResolveTemplateFallbackTheme(
string templateName, string templateName,
PptxTemplateResolution? resolution, PptxTemplateResolution? resolution,
@@ -549,8 +562,14 @@ public class PptxSkill : IAgentTool
: (!hasExplicitTheme && !hasExplicitTemplate && !hasThemeFile : (!hasExplicitTheme && !hasExplicitTemplate && !hasThemeFile
? PptxTemplatePackRegistry.Suggest(renderDeck?.Objective ?? objective, renderDeck?.Audience ?? audience) ? PptxTemplatePackRegistry.Suggest(renderDeck?.Objective ?? objective, renderDeck?.Audience ?? audience)
: null); : null);
var templatePackRecommendation = templatePack != null
? PptxTemplateManifestCatalog.RecommendTemplate(
templatePack.Name,
renderDeck?.Objective ?? objective,
renderDeck?.Audience ?? audience)
: null;
var templatePackName = templatePack?.Name; var templatePackName = templatePack?.Name;
var packTemplateName = templatePack?.PreferredTemplate; var packTemplateName = templatePackRecommendation?.Entry.Key ?? templatePack?.PreferredTemplate;
PptxTemplateResolution? explicitTemplateResolution = null; PptxTemplateResolution? explicitTemplateResolution = null;
PptxTemplateResolution? packTemplateResolution = null; PptxTemplateResolution? packTemplateResolution = null;
@@ -703,6 +722,7 @@ public class PptxSkill : IAgentTool
{ {
var cloneInfo_srcLabel = templatePptxPath != null ? Path.GetFileName(templatePptxPath) : null; var cloneInfo_srcLabel = templatePptxPath != null ? Path.GetFileName(templatePptxPath) : null;
var cloneInfo_cloned = false; var cloneInfo_cloned = false;
TemplateMasterCloneResult? templateCloneResult = null;
int slideCount_final = 0; int slideCount_final = 0;
using (var pres = PresentationDocument.Create(fullPath, PresentationDocumentType.Presentation)) using (var pres = PresentationDocument.Create(fullPath, PresentationDocumentType.Presentation))
@@ -714,9 +734,69 @@ public class PptxSkill : IAgentTool
var clonedFromTemplate = false; 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<SlideMasterPart>();
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<SlideLayoutPart>();
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>();
themePart.Theme = CreateOfficeTheme(fullTheme.Colors, "AX Theme");
presPart.Presentation.Append(new SlideMasterIdList(new SlideMasterId
{
Id = 2147483648U,
RelationshipId = presPart.GetIdOfPart(masterPart)
}));
}
} }
else else
{ {
@@ -1008,6 +1088,7 @@ public class PptxSkill : IAgentTool
RepairContentTypes(fullPath); RepairContentTypes(fullPath);
var templateSourceName = templateName ?? packTemplateName; var templateSourceName = templateName ?? packTemplateName;
var fallbackLabel = FormatTemplateFallbackLabel(templateCloneResult);
var themeLabel = cloneInfo_cloned var themeLabel = cloneInfo_cloned
? templateSourceName != null ? templateSourceName != null
? $"template:{templateSourceName} (master cloned{(cloneAll ? " + slides cloned" : "")})" ? $"template:{templateSourceName} (master cloned{(cloneAll ? " + slides cloned" : "")})"
@@ -1017,34 +1098,36 @@ public class PptxSkill : IAgentTool
: !string.IsNullOrWhiteSpace(templateName) : !string.IsNullOrWhiteSpace(templateName)
? templatePptxPath == null ? templatePptxPath == null
? $"template:{templateName} (asset missing -> built-in fallback)" ? $"template:{templateName} (asset missing -> built-in fallback)"
: $"template:{templateName} (color fallback)" : $"template:{templateName} ({fallbackLabel})"
: templatePptxPath != null && !string.IsNullOrWhiteSpace(packTemplateName) : templatePptxPath != null && !string.IsNullOrWhiteSpace(packTemplateName)
? $"template:{packTemplateName} (color fallback)" ? $"template:{packTemplateName} ({fallbackLabel})"
: !string.IsNullOrWhiteSpace(packTemplateName) : !string.IsNullOrWhiteSpace(packTemplateName)
? $"template:{packTemplateName} (asset missing -> pack fallback)" ? $"template:{packTemplateName} (asset missing -> pack fallback)"
: templatePptxPath != null : templatePptxPath != null
? $"theme_file:{cloneInfo_srcLabel} (color fallback)" ? $"theme_file:{cloneInfo_srcLabel} ({fallbackLabel})"
: theme; : theme;
var outputParts = new List<string> var outputParts = new List<string>
{ {
$"PPTX created: {fullPath} ({slideCount_final} slides, {themeLabel}, {(isWide ? "16:9" : "4:3")})" $"PPTX created: {fullPath} ({slideCount_final} slides, {themeLabel}, {(isWide ? "16:9" : "4:3")})"
}; };
if (explicitTemplateResolution != null && explicitTemplateResolution.Status == "asset_missing") outputParts.AddRange(BuildTemplateDiagnosticsLines(
{ explicitTemplateResolution,
outputParts.Add( packTemplateResolution,
$"Template diagnostics: '{explicitTemplateResolution.RequestedName}' asset not found. " + templateCloneResult,
$"Searched {explicitTemplateResolution.CandidateDirectories.Count} candidate PPT directories."); templateName,
} packTemplateName,
else if (packTemplateResolution != null && packTemplateResolution.Status == "asset_missing") cloneInfo_srcLabel));
{
outputParts.Add(
$"Template diagnostics: pack template '{packTemplateResolution.RequestedName}' asset not found. " +
$"Searched {packTemplateResolution.CandidateDirectories.Count} candidate PPT directories.");
}
if (renderDeck != null) if (renderDeck != null)
outputParts.Add(renderDeck.ToToolSummary()); outputParts.Add(renderDeck.ToToolSummary());
if (!string.IsNullOrWhiteSpace(templatePackName)) if (!string.IsNullOrWhiteSpace(templatePackName))
outputParts.Add($"Template pack: {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) if (deckReview == null && hasSlidesArray)
{ {
deckReview = DeckQualityReviewService.ReviewDeck( deckReview = DeckQualityReviewService.ReviewDeck(
@@ -2499,28 +2582,80 @@ public class PptxSkill : IAgentTool
notesPart.NotesSlide.Save(); 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 };
}
// ── 고품질 템플릿에서 마스터/레이아웃/테마 복제 ────────────────────────────── // ── 고품질 템플릿에서 마스터/레이아웃/테마 복제 ──────────────────────────────
/// <summary> /// <summary>
/// 기존 .pptx 템플릿에서 SlideMaster, SlideLayout, ThemePart를 통째로 복제합니다. /// 기존 .pptx 템플릿에서 SlideMaster, SlideLayout, ThemePart를 통째로 복제합니다.
/// 복제 성공 시 layoutPart에 첫 번째 레이아웃을 반환하고 true. /// 복제 성공 시 layoutPart에 첫 번째 레이아웃을 반환하고 true.
/// 실패 시 false → 호출부에서 자체 마스터를 생성합니다 (색상 추출 폴백). /// 실패 시 false → 호출부에서 자체 마스터를 생성합니다 (색상 추출 폴백).
/// </summary> /// </summary>
private static bool TryCloneMasterFromTemplate( private static TemplateMasterCloneResult TryCloneMasterFromTemplate(
string templatePath, string templatePath,
PresentationPart targetPresPart, PresentationPart targetPresPart)
out SlideLayoutPart? layoutPart)
{ {
layoutPart = null; if (!File.Exists(templatePath))
if (!File.Exists(templatePath)) return false; return TemplateMasterCloneResult.Failed("file_missing", "The template file does not exist.");
try try
{ {
using var srcDoc = PresentationDocument.Open(templatePath, isEditable: false); using var srcDoc = PresentationDocument.Open(templatePath, isEditable: false);
var srcPresPart = srcDoc.PresentationPart; 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(); 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, 이미지, 레이아웃 포함) // 1. SlideMasterPart 통째로 복제 (ThemePart, 이미지, 레이아웃 포함)
var clonedMaster = targetPresPart.AddPart(srcMaster); var clonedMaster = targetPresPart.AddPart(srcMaster);
@@ -2545,20 +2680,71 @@ public class PptxSkill : IAgentTool
break; 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. 마스터 저장 // 4. 마스터 저장
clonedMaster.SlideMaster.Save(); 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) catch (Exception ex)
{ {
Services.LogService.Warn($"템플릿 마스터 복제 실패 ({Path.GetFileName(templatePath)}): {ex.Message}"); 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<string> BuildTemplateDiagnosticsLines(
PptxTemplateResolution? explicitTemplateResolution,
PptxTemplateResolution? packTemplateResolution,
TemplateMasterCloneResult? cloneResult,
string? templateName,
string? packTemplateName,
string? themeFileLabel)
{
var lines = new List<string>();
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) // 슬라이드 페이지 복제 (방법 1 + 방법 2)
// ══════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════

View File

@@ -1,20 +1,31 @@
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq;
using System.Text.Json; using System.Text.Json;
namespace AxCopilot.Services.Agent; namespace AxCopilot.Services.Agent;
public sealed record PptxTemplateManifestEntry( public sealed class PptxTemplateManifestEntry
string Key, {
string FileName, public string Key { get; init; } = "";
string DisplayName, public string FileName { get; init; } = "";
string FallbackTheme, public string DisplayName { get; init; } = "";
IReadOnlyList<string> Aliases, public string FallbackTheme { get; init; } = "professional";
IReadOnlyList<string> Tags, public List<string> Aliases { get; init; } = [];
IReadOnlyList<string> PackHints); public List<string> Tags { get; init; } = [];
public List<string> 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<string> ObjectiveKeywords { get; init; } = [];
public List<string> AudienceKeywords { get; init; } = [];
}
public sealed class PptxTemplateManifest public sealed class PptxTemplateManifest
{ {
public List<PptxTemplateManifestEntry> Templates { get; init; } = new(); public List<PptxTemplateManifestEntry> Templates { get; init; } = [];
} }
public sealed record PptxTemplateResolution( public sealed record PptxTemplateResolution(
@@ -27,6 +38,11 @@ public sealed record PptxTemplateResolution(
public bool IsResolved => !string.IsNullOrWhiteSpace(ResolvedPath); public bool IsResolved => !string.IsNullOrWhiteSpace(ResolvedPath);
} }
public sealed record PptxTemplateRecommendation(
PptxTemplateManifestEntry Entry,
int Score,
string Reason);
public static class PptxTemplateManifestCatalog public static class PptxTemplateManifestCatalog
{ {
private const string ManifestFileName = "templates.manifest.json"; 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))); 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<string>();
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) public static PptxTemplateResolution ResolveTemplate(string? requestedName)
{ {
if (string.IsNullOrWhiteSpace(requestedName)) if (string.IsNullOrWhiteSpace(requestedName))
@@ -68,7 +160,7 @@ public static class PptxTemplateManifestCatalog
null, null,
explicitPath, explicitPath,
explicitPath != null ? "explicit_path" : "explicit_path_missing", explicitPath != null ? "explicit_path" : "explicit_path_missing",
explicitPath != null ? new[] { Path.GetDirectoryName(explicitPath)! } : Array.Empty<string>()); explicitPath != null ? [Path.GetDirectoryName(explicitPath)!] : Array.Empty<string>());
} }
var entry = Resolve(trimmed); 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<string> keywords)
{
if (string.IsNullOrWhiteSpace(normalizedText))
return 0;
return keywords.Count(keyword => normalizedText.Contains(keyword, StringComparison.OrdinalIgnoreCase));
}
private static bool LooksLikePath(string value) private static bool LooksLikePath(string value)
=> value.Contains('\\', StringComparison.Ordinal) => value.Contains('\\', StringComparison.Ordinal)
|| value.Contains('/', StringComparison.Ordinal) || value.Contains('/', StringComparison.Ordinal)
@@ -248,14 +351,142 @@ public static class PptxTemplateManifestCatalog
{ {
Templates = Templates =
[ [
new("basic100", "BASIC100 기준 템플릿 V1.pptx", "BASIC100 기준 템플릿 V1", "professional", ["basic", "consulting_basic"], ["executive", "strategy", "consulting", "brand"], ["strategy", "pmo", "sales", "operating_model"]), new PptxTemplateManifestEntry
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"]), Key = "basic100",
new("mr_ppt_01", "미스터 피피티 템플릿 01_원본.pptx", "미스터 피피티 템플릿 01", "modern", ["mrppt01"], ["creative", "proposal", "sales"], ["sales"]), FileName = "BASIC100 \uae30\uc900 \ud15c\ud50c\ub9bf V1.pptx",
new("mr_ppt_02", "미스터 피피티 템플릿 02_원본.pptx", "미스터 피피티 템플릿 02", "vibrant", ["mrppt02"], ["creative", "marketing", "proposal"], ["sales"]), DisplayName = "BASIC100 template",
new("mr_ppt_03", "미스터 피피티 03_원본.pptx", "미스터 피피티 템플릿 03", "minimal", ["mrppt03"], ["minimal", "analysis", "report"], ["strategy", "finance"]), FallbackTheme = "professional",
new("mr_ppt_04", "미스터 피피티 템플릿 04_원본.pptx", "미스터 피피티 템플릿 04", "slate", ["mrppt04"], ["dark", "executive", "review"], ["board", "finance"]), Aliases = ["basic", "consulting_basic"],
new("mr_ppt_05", "미스터 피피티 템플릿_05_원본.pptx", "미스터 피피티 템플릿 05", "ocean", ["mrppt05"], ["blue", "modern", "report"], ["pmo", "operating_model"]), 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"]
},
] ]
}; };
} }