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 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로 생성된 보고서도 앞부분과 같은 문서 톤을 유지하도록 맞췄습니다.

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 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()
{
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");
}
}

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.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();
}

View File

@@ -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]

View File

@@ -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"]
}
]
}

View File

@@ -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*(?<count>\d+)", 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)
? 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<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)
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);
}
}

View File

@@ -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<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
{
@@ -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<string>
{
$"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 };
}
// ── 고품질 템플릿에서 마스터/레이아웃/테마 복제 ──────────────────────────────
/// <summary>
/// 기존 .pptx 템플릿에서 SlideMaster, SlideLayout, ThemePart를 통째로 복제합니다.
/// 복제 성공 시 layoutPart에 첫 번째 레이아웃을 반환하고 true.
/// 실패 시 false → 호출부에서 자체 마스터를 생성합니다 (색상 추출 폴백).
/// </summary>
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<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)
// ══════════════════════════════════════════════════════════════════════════

View File

@@ -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<string> Aliases,
IReadOnlyList<string> Tags,
IReadOnlyList<string> 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<string> Aliases { get; init; } = [];
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 List<PptxTemplateManifestEntry> Templates { get; init; } = new();
public List<PptxTemplateManifestEntry> 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<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)
{
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<string>());
explicitPath != null ? [Path.GetDirectoryName(explicitPath)!] : Array.Empty<string>());
}
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)
=> 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"]
},
]
};
}