AX Agent 창 제목 고정 및 PPT 품질 재루프 고도화

- 채팅창 Windows 제목을 AX Agent로 고정해 작업 표시줄/Alt+Tab에서 대화 제목이 섞이지 않도록 조정

- Assets/ppt manifest와 템플릿 카탈로그를 추가해 basic100/core100 등 고품질 PPT 자산을 실행 폴더, 소스 루트, AppData 템플릿 폴더에서 안정적으로 탐색하도록 개선

- pptx_create 결과에서 asset missing과 color fallback을 구분해 진단 메시지를 남기고 Cowork에서는 PPT quality/slide alerts/Needs work 기준으로 최대 2회 재생성 루프를 타도록 품질 게이트 추가

- PPT 시스템 프롬프트와 pptx-creator 스킬 지시를 document_plan -> pptx_create 중심으로 정렬

- 검증: dotnet build ...verify_ppt_quality_gate 경고 0/오류 0, dotnet test ...PptxSkillTemplatePackTests|PptxSkillAutoRepairTests|PptxSkillGoldenDeckTests|PptQualityGatePolicyTests|PptxTemplateManifestCatalogTests 통과 12
This commit is contained in:
2026-04-15 21:49:09 +09:00
parent 2c48ca211a
commit f8067a1f9b
16 changed files with 666 additions and 68 deletions

View File

@@ -2269,3 +2269,13 @@ MIT License
업데이트: 2026-04-15 21:23 (KST)
- AX Agent 라이브 진행 문구와 시간·토큰 표시가 서로 분리돼 보이던 입력 영역 레이아웃을 다시 다듬었습니다. src/AxCopilot/Views/ChatWindow.xaml에서 StreamMetricsLabel을 독립 줄로 두지 않고 라이브 진행 Grid의 우측 하단에 다시 배치해, 진행 문구와 입력창이 한 덩어리처럼 붙어 보이면서도 시간·토큰 표시는 입력창 바로 위 우측에 유지되도록 조정했습니다.
- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_stream_metrics_compact\\ -p:IntermediateOutputPath=obj\\verify_stream_metrics_compact\\ 경고 0 / 오류 0
업데이트: 2026-04-15 21:48 (KST)
- AX Agent 채팅창의 Windows 제목을 대화명과 분리해 `AX Agent`로 고정했습니다. `src/AxCopilot/Views/ChatWindow.xaml`의 창 제목이 더 이상 현재 대화 제목과 섞여 `AX Copilot — ...` 형태로 보이지 않습니다.
- PPT 템플릿 해상도 경로를 `Assets/ppt` manifest 기반으로 다시 정리했습니다. `src/AxCopilot/Assets/ppt/templates.manifest.json`, `src/AxCopilot/Services/Agent/PptxTemplateManifestCatalog.cs`, `src/AxCopilot/Services/Agent/PptxSkill.cs`가 이제 템플릿 키/별칭/기본 테마를 manifest에서 읽고, 실행 폴더·소스 루트·%APPDATA% 템플릿 폴더를 상향 탐색해 실제 `.pptx`를 찾습니다. 기존 개발 출력 경로에서 `src/AxCopilot/Assets/ppt`를 제대로 못 찾아 `color fallback`으로 빠지던 원인을 줄였습니다.
- PPT 생성 결과도 `asset missing``color fallback`을 구분해 표시하도록 바꿨습니다. 템플릿 파일 자체를 못 찾은 경우에는 `Template diagnostics` 라인을 남겨 경로 문제와 마스터 복제 실패를 바로 구분할 수 있습니다.
- Cowork의 `pptx_create`는 이제 성공 직후 바로 종료되지 않고, `src/AxCopilot/Services/Agent/PptQualityGatePolicy.cs``src/AxCopilot/Services/Agent/AgentLoopTransitions.Documents.cs`에서 품질 점수/슬라이드 경고/Needs work를 읽어 기준 미만이면 최대 2회까지 재생성을 요구합니다. 저품질 덱이 한 번 생성됐다는 이유만으로 루프가 끝나던 흐름을 막았습니다.
- `src/AxCopilot/Views/ChatWindow.SystemPromptBuilder.cs``src/AxCopilot/skills/pptx-creator.skill.md`도 PPT는 품질이 중요한 업무형 덱일수록 `document_plan -> pptx_create`를 기본 경로로 쓰고, 낮은 품질 요약이 나오면 바로 종료하지 말고 재생성하라고 맞췄습니다.
- 검증:
- `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_ppt_quality_gate\\ -p:IntermediateOutputPath=obj\\verify_ppt_quality_gate\\` 경고 0 / 오류 0
- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "PptxSkillTemplatePackTests|PptxSkillAutoRepairTests|PptxSkillGoldenDeckTests|PptQualityGatePolicyTests|PptxTemplateManifestCatalogTests" -p:OutputPath=bin\\verify_ppt_quality_gate_tests\\ -p:IntermediateOutputPath=obj\\verify_ppt_quality_gate_tests\\` 통과 12

View File

@@ -1588,3 +1588,17 @@ UI ?遺우쁽????域뱀뮆???귐뗫솯?醫딆춦 ???袁る퓮 ?臾믩씜 ??疫
- AX Agent 입력 영역 상단 레이아웃을 다시 정리했습니다. src/AxCopilot/Views/ChatWindow.xaml에서 StreamMetricsLabel을 독립 줄로 분리했던 배치 때문에 라이브 진행 문구와 입력창 사이가 넓게 벌어져 보였고, 우측 시간·토큰 표시만 아래로 내려간 상태였습니다.
- 같은 파일의 라이브 진행 Grid를 2열 구조로 바꾸고 StreamMetricsLabel을 우측 하단에 붙여, 진행 문구는 입력창과 시각적으로 붙은 상태를 유지하면서도 시간·토큰은 항상 입력창 바로 위 우측에 고정되도록 조정했습니다.
- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_stream_metrics_compact\\ -p:IntermediateOutputPath=obj\\verify_stream_metrics_compact\\ 경고 0 / 오류 0
업데이트: 2026-04-15 21:48 (KST)
- AX Agent 채팅창의 Windows 제목을 `AX Agent`로 고정했습니다. `src/AxCopilot/Views/ChatWindow.xaml`이 더 이상 대화 제목을 창 제목에 섞어 보여주지 않아, 작업 표시줄/Alt+Tab/미리보기에서 제품명만 안정적으로 보입니다.
- PPT 템플릿 해상도 경로를 manifest 기반으로 재구성했습니다. `src/AxCopilot/Assets/ppt/templates.manifest.json`에 템플릿 키, 파일명, fallback theme, 태그/pack hint를 선언하고 `src/AxCopilot/Services/Agent/PptxTemplateManifestCatalog.cs`가 실행 폴더, 현재 작업 폴더, `%APPDATA%\\AXCopilot\\templates\\ppt`, 소스 루트의 `src/AxCopilot/Assets/ppt`를 상향 탐색해 실제 템플릿 파일을 찾도록 바꿨습니다. 이전 `ResolveTemplatePath(...)``bin\\...\\Assets\\ppt`처럼 잘못된 상대 경로에 의존해 개발 빌드 출력에서 `basic100/core100`을 놓칠 수 있었습니다.
- `src/AxCopilot/Services/Agent/PptxSkill.cs`는 manifest 해상도 결과를 받아 명시적 템플릿/pack 템플릿의 fallback theme를 더 정확히 고르고, 결과 요약에서 `color fallback``asset missing -> built-in fallback`을 구분해 남깁니다. 템플릿 파일을 못 찾은 경우에는 `Template diagnostics` 라인에 후보 디렉터리 수를 함께 남겨 원인 추적이 쉬워졌습니다.
- Cowork PPT 품질 게이트를 추가했습니다. `src/AxCopilot/Services/Agent/PptQualityGatePolicy.cs``pptx_create` 출력에서 `PPT quality`, `Slide alerts`, `Needs work`, `color fallback`을 파싱하고, `src/AxCopilot/Services/Agent/AgentLoopTransitions.Documents.cs`는 Cowork에서 `pptx_create` 성공 직후 품질이 기준 미만이면 즉시 종료하지 않고 최대 2회까지 `document_plan`/`pptx_create` 재생성을 요구합니다. 그동안 `AgentLoopService`의 terminal document completion 때문에 저품질 PPT도 첫 성공 후 바로 끝나던 문제가 있었습니다.
- Cowork 시스템 프롬프트와 PPT 스킬 지시도 품질 기준으로 정렬했습니다. `src/AxCopilot/Views/ChatWindow.SystemPromptBuilder.cs`는 전략/제안/경영진용 PPT에서 `document_plan`을 먼저 쓰게 유도하고, `src/AxCopilot/skills/pptx-creator.skill.md``document_plan + pptx_create`를 기본 경로로 명시하며 낮은 품질 요약이 나오면 바로 종료하지 말고 재생성하도록 안내합니다.
- 테스트:
- `src/AxCopilot.Tests/Services/PptQualityGatePolicyTests.cs`
- `src/AxCopilot.Tests/Services/PptxTemplateManifestCatalogTests.cs`
- `src/AxCopilot.Tests/Services/PptxSkillTemplatePackTests.cs`
- 검증:
- `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_ppt_quality_gate\\ -p:IntermediateOutputPath=obj\\verify_ppt_quality_gate\\` 경고 0 / 오류 0
- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "PptxSkillTemplatePackTests|PptxSkillAutoRepairTests|PptxSkillGoldenDeckTests|PptQualityGatePolicyTests|PptxTemplateManifestCatalogTests" -p:OutputPath=bin\\verify_ppt_quality_gate_tests\\ -p:IntermediateOutputPath=obj\\verify_ppt_quality_gate_tests\\` 통과 12

View File

@@ -0,0 +1,56 @@
using AxCopilot.Services.Agent;
using FluentAssertions;
using Xunit;
namespace AxCopilot.Tests.Services;
public class PptQualityGatePolicyTests
{
[Fact]
public void TryAssess_ShouldRequestRetry_ForLowScoreDeck()
{
var result = ToolResult.Ok(
"PPTX created: E:\\docu\\deck.pptx (10 slides, template:basic100 (color fallback), 16:9)\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);
parsed.Should().BeTrue();
assessment.Score.Should().Be(64);
assessment.SlideAlertCount.Should().Be(4);
assessment.HasColorFallback.Should().BeTrue();
assessment.RequiresRetry.Should().BeTrue();
}
[Fact]
public void TryAssess_ShouldPass_ForCleanDeck()
{
var result = ToolResult.Ok(
"PPTX created: E:\\docu\\deck.pptx (6 slides, template:basic100 (master cloned), 16:9)\n" +
"PPT quality 88/100 | Strengths: Includes title slide | Needs work: none");
var parsed = PptQualityGatePolicy.TryAssess(result, out var assessment);
parsed.Should().BeTrue();
assessment.Score.Should().Be(88);
assessment.RequiresRetry.Should().BeFalse();
}
[Fact]
public void BuildRetryPrompt_ShouldRecommendDocumentPlan_WhenStorylineWasNotPrepared()
{
var assessment = new PptQualityGateAssessment(
64,
3,
HasColorFallback: false,
"Too many slides are text-heavy.",
RequiresRetry: true,
Summary: "점수 64/100 (기준 80), 슬라이드 경고 3건");
var prompt = PptQualityGatePolicy.BuildRetryPrompt(assessment, "E:\\docu\\deck.pptx", documentPlanWasCalled: false);
prompt.Should().Contain("document_plan");
prompt.Should().Contain("pptx_create");
prompt.Should().Contain("E:\\docu\\deck.pptx");
}
}

View File

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

View File

@@ -0,0 +1,43 @@
using System.IO;
using AxCopilot.Services.Agent;
using FluentAssertions;
using Xunit;
namespace AxCopilot.Tests.Services;
public class PptxTemplateManifestCatalogTests
{
[Fact]
public void Resolve_ShouldReturnManifestEntry_ForKnownTemplate()
{
var entry = PptxTemplateManifestCatalog.Resolve("basic100");
entry.Should().NotBeNull();
entry!.FileName.Should().Be("BASIC100 기준 템플릿 V1.pptx");
entry.FallbackTheme.Should().Be("professional");
}
[Fact]
public void ResolveTemplate_ShouldFindBundledSourceAsset_FromTestRuntime()
{
var resolution = PptxTemplateManifestCatalog.ResolveTemplate("basic100");
resolution.Entry.Should().NotBeNull();
resolution.IsResolved.Should().BeTrue();
resolution.Status.Should().Be("resolved");
File.Exists(resolution.ResolvedPath).Should().BeTrue();
Path.GetFileName(resolution.ResolvedPath!).Should().Be(resolution.Entry!.FileName);
}
[Fact]
public void EnumerateCandidateDirectories_ShouldIncludeProjectSourceAssets()
{
var directories = PptxTemplateManifestCatalog.EnumerateCandidateDirectoriesForTesting(
AppContext.BaseDirectory,
Environment.CurrentDirectory,
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData));
directories.Should().Contain(path =>
path.EndsWith(Path.Combine("src", "AxCopilot", "Assets", "ppt"), StringComparison.OrdinalIgnoreCase));
}
}

View File

@@ -0,0 +1,76 @@
{
"templates": [
{
"key": "basic100",
"fileName": "BASIC100 기준 템플릿 V1.pptx",
"displayName": "BASIC100 기준 템플릿 V1",
"fallbackTheme": "professional",
"aliases": ["basic", "consulting_basic"],
"tags": ["executive", "strategy", "consulting", "brand"],
"packHints": ["strategy", "pmo", "sales", "operating_model"]
},
{
"key": "core100",
"fileName": "CORE100 기준템플릿 V1.pptx",
"displayName": "CORE100 기준 템플릿 V1",
"fallbackTheme": "corporate",
"aliases": ["core", "board_core"],
"tags": ["board", "governance", "finance", "corporate"],
"packHints": ["board", "finance"]
},
{
"key": "frame_blue",
"fileName": "P01_01_프레임디자인_PPT템플릿_블루(PPT_편집용).pptx",
"displayName": "프레임디자인 블루",
"fallbackTheme": "modern",
"aliases": ["blue_frame"],
"tags": ["frame", "blue", "report", "proposal"],
"packHints": ["sales", "operating_model"]
},
{
"key": "mr_ppt_01",
"fileName": "미스터 피피티 템플릿 01_원본.pptx",
"displayName": "미스터 피피티 템플릿 01",
"fallbackTheme": "modern",
"aliases": ["mrppt01"],
"tags": ["creative", "proposal", "sales"],
"packHints": ["sales"]
},
{
"key": "mr_ppt_02",
"fileName": "미스터 피피티 템플릿 02_원본.pptx",
"displayName": "미스터 피피티 템플릿 02",
"fallbackTheme": "vibrant",
"aliases": ["mrppt02"],
"tags": ["creative", "marketing", "proposal"],
"packHints": ["sales"]
},
{
"key": "mr_ppt_03",
"fileName": "미스터 피피티 03_원본.pptx",
"displayName": "미스터 피피티 템플릿 03",
"fallbackTheme": "minimal",
"aliases": ["mrppt03"],
"tags": ["minimal", "analysis", "report"],
"packHints": ["strategy", "finance"]
},
{
"key": "mr_ppt_04",
"fileName": "미스터 피피티 템플릿 04_원본.pptx",
"displayName": "미스터 피피티 템플릿 04",
"fallbackTheme": "slate",
"aliases": ["mrppt04"],
"tags": ["dark", "executive", "review"],
"packHints": ["board", "finance"]
},
{
"key": "mr_ppt_05",
"fileName": "미스터 피피티 템플릿_05_원본.pptx",
"displayName": "미스터 피피티 템플릿 05",
"fallbackTheme": "ocean",
"aliases": ["mrppt05"],
"tags": ["blue", "modern", "report"],
"packHints": ["pmo", "operating_model"]
}
]
}

View File

@@ -121,6 +121,9 @@
<Resource Include="Assets\mascot.webp" Condition="Exists('Assets\mascot.webp')" />
<Resource Include="Assets\foldy_qdy.png" Condition="Exists('Assets\foldy_qdy.png')" />
<Resource Include="Assets\pixel_art.png" Condition="Exists('Assets\pixel_art.png')" />
<Content Include="Assets\ppt\templates.manifest.json" Condition="Exists('Assets\ppt\templates.manifest.json')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<!-- PPT 고품질 템플릿: 빌드에 포함하지 않음 (297MB).
런타임에 Assets/ppt/ 또는 %APPDATA%/AXCopilot/templates/ppt/ 에서 검색.
개발 환경에서는 소스의 Assets/ppt/를 자동으로 찾음. -->

View File

@@ -1756,7 +1756,8 @@ public partial class AgentLoopService
executionPolicy,
context,
ct,
documentPlanCalled);
documentPlanCalled,
runState);
if (terminalCompleted)
{
if (consumedExtraIteration)

View File

@@ -94,11 +94,15 @@ public partial class AgentLoopService
ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy,
AgentContext context,
CancellationToken ct,
bool documentPlanWasCalled = false)
bool documentPlanWasCalled = false,
RunState? runState = null)
{
if (!result.Success || !IsTerminalDocumentTool(call.ToolName) || toolCalls.Count != 1)
return (false, false);
if (TryApplyPptQualityGateTransition(call, result, messages, documentPlanWasCalled, runState))
return (false, false);
if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase))
{
EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료");
@@ -124,6 +128,53 @@ public partial class AgentLoopService
return (true, consumedExtraIteration);
}
private bool TryApplyPptQualityGateTransition(
ContentBlock call,
ToolResult result,
List<ChatMessage> messages,
bool documentPlanWasCalled,
RunState? runState)
{
if (!string.Equals(ActiveTab, "Cowork", StringComparison.OrdinalIgnoreCase))
return false;
if (!string.Equals(call.ToolName, "pptx_create", StringComparison.OrdinalIgnoreCase))
return false;
if (!PptQualityGatePolicy.TryAssess(result, out var assessment))
return false;
if (!assessment.RequiresRetry)
return false;
if (runState == null)
return false;
if (runState.PptQualityGateRetry >= PptQualityGatePolicy.DefaultMaxRetries)
{
EmitEvent(
AgentEventType.Thinking,
"",
$"PPT 품질 재생성 한도에 도달해 현재 결과로 종료합니다 ({assessment.Summary})");
return false;
}
runState.PptQualityGateRetry++;
messages.Add(new ChatMessage
{
Role = "user",
Content = PptQualityGatePolicy.BuildRetryPrompt(
assessment,
result.FilePath,
documentPlanWasCalled)
});
EmitEvent(
AgentEventType.Thinking,
"",
$"PPT 품질이 기준 미만이라 재생성을 요청합니다 ({runState.PptQualityGateRetry}/{PptQualityGatePolicy.DefaultMaxRetries})");
return true;
}
private async Task<bool> TryApplyPostToolVerificationTransitionAsync(
ContentBlock call,
ToolResult result,

View File

@@ -1242,6 +1242,7 @@ public partial class AgentLoopService
public int TransientLlmErrorRetries;
public int DocumentArtifactGateRetry;
public int DocumentVerificationGateRetry;
public int PptQualityGateRetry;
public int NoProgressRecoveryRetry;
public int TerminalEvidenceGateRetry;
public bool WorkspaceAppearsEmpty;

View File

@@ -0,0 +1,92 @@
using System.Text.RegularExpressions;
namespace AxCopilot.Services.Agent;
public sealed record PptQualityGateAssessment(
int Score,
int SlideAlertCount,
bool HasColorFallback,
string NeedsWork,
bool RequiresRetry,
string Summary);
public static class PptQualityGatePolicy
{
public const int DefaultMinScore = 80;
public const int DefaultMaxRetries = 2;
public static bool TryAssess(ToolResult result, out PptQualityGateAssessment assessment, int minScore = DefaultMinScore)
{
assessment = new PptQualityGateAssessment(0, 0, false, "", false, "");
if (!result.Success || string.IsNullOrWhiteSpace(result.Output))
return false;
var output = result.Output;
var scoreMatch = Regex.Match(output, @"PPT quality\s+(?<score>\d{1,3})/100", RegexOptions.IgnoreCase);
if (!scoreMatch.Success || !int.TryParse(scoreMatch.Groups["score"].Value, out var score))
return false;
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 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 requiresRetry = score < minScore
|| slideAlerts > 0
|| (!string.IsNullOrWhiteSpace(needsWork)
&& !string.Equals(needsWork, "none", StringComparison.OrdinalIgnoreCase));
var summary = BuildSummary(score, slideAlerts, hasColorFallback, needsWork, minScore);
assessment = new PptQualityGateAssessment(
score,
slideAlerts,
hasColorFallback,
needsWork,
requiresRetry,
summary);
return true;
}
public static string BuildRetryPrompt(
PptQualityGateAssessment assessment,
string? filePath,
bool documentPlanWasCalled)
{
var target = string.IsNullOrWhiteSpace(filePath) ? "방금 생성한 PPT 파일" : $"'{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";
return "[System:PptQualityGate] 방금 생성한 PPT 품질이 기준 미만입니다.\n" +
$"- 대상 파일: {target}\n" +
$"- 품질 요약: {assessment.Summary}\n" +
needsWorkLine +
"- 필수 개선 방향:\n" +
" 1. 한 슬라이드 한 메시지 원칙으로 텍스트 밀도를 낮추세요.\n" +
" 2. Executive Summary → Options/Comparison → Recommendation → Roadmap → Appendix/Evidence 흐름을 분명히 하세요.\n" +
" 3. 표/차트/비교 슬라이드로 정량 근거를 늘리고, 헤드라인을 더 결론형 문장으로 바꾸세요.\n" +
$" 4. {templateStep}\n" +
$" 5. {planStep}\n" +
"개선된 버전을 만든 뒤 다시 품질 요약이 좋아졌는지 확인하세요.";
}
private static string BuildSummary(int score, int slideAlerts, bool hasColorFallback, string needsWork, int minScore)
{
var parts = new List<string> { $"점수 {score}/100 (기준 {minScore})" };
if (slideAlerts > 0)
parts.Add($"슬라이드 경고 {slideAlerts}건");
if (hasColorFallback)
parts.Add("template color fallback 발생");
if (!string.IsNullOrWhiteSpace(needsWork) && !string.Equals(needsWork, "none", StringComparison.OrdinalIgnoreCase))
parts.Add($"Needs work: {needsWork}");
return string.Join(", ", parts);
}
}

View File

@@ -220,58 +220,21 @@ public class PptxSkill : IAgentTool
// ── 통합 테마 레코드 ──────────────────────────────────────────────────────
private record FullTheme(ThemeColors Colors, ThemeLayout Layout);
// ── 고품질 템플릿 레지스트리 ────────────────────────────────────────────────
// 이름 → Assets/ppt/ 아래 파일명 매핑. 실행 시 디스크에서 열어 마스터 복제.
// 토큰 컨텍스트 영향 0 — 바이너리 파일은 LLM에 전달되지 않음.
private static readonly Dictionary<string, string> TemplateRegistry = new(StringComparer.OrdinalIgnoreCase)
private static FullTheme ResolveTemplateFallbackTheme(
string templateName,
PptxTemplateResolution? resolution,
FullTheme defaultTheme)
{
["basic100"] = "BASIC100 기준 템플릿 V1.pptx",
["core100"] = "CORE100 기준템플릿 V1.pptx",
["frame_blue"] = "P01_01_프레임디자인_PPT템플릿_블루(PPT_편집용).pptx",
["mr_ppt_01"] = "미스터 피피티 템플릿 01_원본.pptx",
["mr_ppt_02"] = "미스터 피피티 템플릿 02_원본.pptx",
["mr_ppt_03"] = "미스터 피피티 03_원본.pptx",
["mr_ppt_04"] = "미스터 피피티 템플릿 04_원본.pptx",
["mr_ppt_05"] = "미스터 피피티 템플릿_05_원본.pptx",
};
if (FullThemes.TryGetValue(templateName, out var namedTheme))
return namedTheme;
/// <summary>
/// 템플릿 이름으로 .pptx 파일 전체 경로를 반환합니다.
/// 설치파일 용량 문제로 템플릿은 빌드에 포함하지 않으며,
/// 여러 알려진 경로를 순서대로 탐색합니다.
/// </summary>
private static string? ResolveTemplatePath(string templateName)
{
if (!TemplateRegistry.TryGetValue(templateName, out var fileName))
return null;
var candidates = new List<string>();
var exeDir = AppContext.BaseDirectory;
// 1) 실행 파일 옆 Assets/ppt/ (수동 배치)
candidates.Add(Path.Combine(exeDir, "Assets", "ppt", fileName));
// 2) %APPDATA%/AXCopilot/templates/ppt/ (사용자 설치)
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
if (!string.IsNullOrEmpty(appData))
candidates.Add(Path.Combine(appData, "AXCopilot", "templates", "ppt", fileName));
// 3) 소스 프로젝트 디렉토리 (개발 환경 — bin/Release/net8.0-windows... 기준)
candidates.Add(Path.GetFullPath(Path.Combine(exeDir, "..", "..", "..", "Assets", "ppt", fileName)));
// 4) 워킹 디렉토리/src/AxCopilot/Assets/ppt/ (개발 환경 루트에서 실행)
candidates.Add(Path.Combine("src", "AxCopilot", "Assets", "ppt", fileName));
// 5) 프로젝트 루트 추정 (E:\AX Copilot - Claude 등)
candidates.Add(Path.GetFullPath(Path.Combine(exeDir, "..", "..", "..", "..", "..", "Assets", "ppt", fileName)));
foreach (var path in candidates)
if (resolution?.Entry != null &&
FullThemes.TryGetValue(resolution.Entry.FallbackTheme, out var manifestTheme))
{
if (File.Exists(path))
return Path.GetFullPath(path);
return manifestTheme;
}
return null;
return defaultTheme;
}
// ── 20가지 테마 사전 ──────────────────────────────────────────────────────
@@ -588,6 +551,8 @@ public class PptxSkill : IAgentTool
: null);
var templatePackName = templatePack?.Name;
var packTemplateName = templatePack?.PreferredTemplate;
PptxTemplateResolution? explicitTemplateResolution = null;
PptxTemplateResolution? packTemplateResolution = null;
if (renderDeck != null)
{
@@ -647,24 +612,24 @@ public class PptxSkill : IAgentTool
if (!string.IsNullOrWhiteSpace(templateName))
{
// 고품질 템플릿: 마스터 복제 시도 → 실패 시 내장 메타데이터 폴백
templatePptxPath = ResolveTemplatePath(templateName!);
explicitTemplateResolution = PptxTemplateManifestCatalog.ResolveTemplate(templateName!);
templatePptxPath = explicitTemplateResolution.ResolvedPath;
var templateFallbackTheme = ResolveTemplateFallbackTheme(
templateName!,
explicitTemplateResolution,
FullThemes["professional"]);
// 내장 테마에 템플릿 이름이 등록되어 있으면 해당 색상/레이아웃 사용
if (FullThemes.TryGetValue(templateName!, out var templateTheme))
fullTheme = templateTheme;
else if (templatePptxPath != null)
if (templatePptxPath != null)
{
// 미등록 템플릿이지만 파일이 있으면 색상 추출
var extracted = ExtractThemeFromPptx(templatePptxPath);
var baseLayout = FullThemes["professional"].Layout;
var baseLayout = templateFallbackTheme.Layout;
fullTheme = extracted != null
? new FullTheme(extracted, baseLayout)
: FullThemes["professional"];
: templateFallbackTheme;
}
else
{
fullTheme = FullThemes["professional"];
fullTheme = templateFallbackTheme;
}
}
else if (args.SafeTryGetProperty("theme_file", out var tfEl) && !string.IsNullOrEmpty(tfEl.SafeGetString()))
@@ -683,7 +648,10 @@ public class PptxSkill : IAgentTool
else if (templatePack != null)
{
if (!string.IsNullOrWhiteSpace(packTemplateName))
templatePptxPath = ResolveTemplatePath(packTemplateName!);
{
packTemplateResolution = PptxTemplateManifestCatalog.ResolveTemplate(packTemplateName!);
templatePptxPath = packTemplateResolution.ResolvedPath;
}
if (!FullThemes.TryGetValue(templatePack.FallbackTheme, out var packTheme))
packTheme = FullThemes["professional"];
@@ -1047,16 +1015,32 @@ public class PptxSkill : IAgentTool
? $"theme_file:{cloneInfo_srcLabel} (master cloned{(cloneAll ? " + slides cloned" : "")})"
: theme
: !string.IsNullOrWhiteSpace(templateName)
? $"template:{templateName} (color fallback)"
? templatePptxPath == null
? $"template:{templateName} (asset missing -> built-in fallback)"
: $"template:{templateName} (color fallback)"
: templatePptxPath != null && !string.IsNullOrWhiteSpace(packTemplateName)
? $"template:{packTemplateName} (color fallback)"
: templatePptxPath != null
? $"theme_file:{cloneInfo_srcLabel} (master cloned{(cloneAll ? " + slides cloned" : "")})"
: theme;
: !string.IsNullOrWhiteSpace(packTemplateName)
? $"template:{packTemplateName} (asset missing -> pack fallback)"
: templatePptxPath != null
? $"theme_file:{cloneInfo_srcLabel} (color fallback)"
: 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.");
}
if (renderDeck != null)
outputParts.Add(renderDeck.ToToolSummary());
if (!string.IsNullOrWhiteSpace(templatePackName))

View File

@@ -0,0 +1,262 @@
using System.IO;
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 PptxTemplateManifest
{
public List<PptxTemplateManifestEntry> Templates { get; init; } = new();
}
public sealed record PptxTemplateResolution(
string RequestedName,
PptxTemplateManifestEntry? Entry,
string? ResolvedPath,
string Status,
IReadOnlyList<string> CandidateDirectories)
{
public bool IsResolved => !string.IsNullOrWhiteSpace(ResolvedPath);
}
public static class PptxTemplateManifestCatalog
{
private const string ManifestFileName = "templates.manifest.json";
private static readonly JsonSerializerOptions s_jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
private static readonly object s_manifestLock = new();
private static string? s_cachedManifestPath;
private static DateTime s_cachedManifestWriteTimeUtc;
private static PptxTemplateManifest? s_cachedManifest;
public static IReadOnlyList<PptxTemplateManifestEntry> GetAll()
=> LoadManifest().Templates;
public static PptxTemplateManifestEntry? Resolve(string? requestedName)
{
if (string.IsNullOrWhiteSpace(requestedName))
return null;
var normalized = requestedName.Trim();
return LoadManifest().Templates.FirstOrDefault(entry =>
string.Equals(entry.Key, normalized, StringComparison.OrdinalIgnoreCase) ||
entry.Aliases.Any(alias => string.Equals(alias, normalized, StringComparison.OrdinalIgnoreCase)));
}
public static PptxTemplateResolution ResolveTemplate(string? requestedName)
{
if (string.IsNullOrWhiteSpace(requestedName))
return new PptxTemplateResolution("", null, null, "empty", Array.Empty<string>());
var trimmed = requestedName.Trim();
if (LooksLikePath(trimmed))
{
var explicitPath = ResolveExplicitPath(trimmed);
return new PptxTemplateResolution(
trimmed,
null,
explicitPath,
explicitPath != null ? "explicit_path" : "explicit_path_missing",
explicitPath != null ? new[] { Path.GetDirectoryName(explicitPath)! } : Array.Empty<string>());
}
var entry = Resolve(trimmed);
var candidateDirectories = EnumerateCandidateDirectories(
AppContext.BaseDirectory,
Environment.CurrentDirectory,
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData));
if (entry == null)
return new PptxTemplateResolution(trimmed, null, null, "unknown_template", candidateDirectories);
foreach (var directory in candidateDirectories)
{
var path = Path.Combine(directory, entry.FileName);
if (!File.Exists(path))
continue;
return new PptxTemplateResolution(
trimmed,
entry,
Path.GetFullPath(path),
"resolved",
candidateDirectories);
}
return new PptxTemplateResolution(trimmed, entry, null, "asset_missing", candidateDirectories);
}
internal static IReadOnlyList<string> EnumerateCandidateDirectoriesForTesting(
string baseDirectory,
string currentDirectory,
string? appDataPath)
=> EnumerateCandidateDirectories(baseDirectory, currentDirectory, appDataPath);
private static PptxTemplateManifest LoadManifest()
{
var manifestPath = FindManifestPath();
if (string.IsNullOrWhiteSpace(manifestPath) || !File.Exists(manifestPath))
return CreateFallbackManifest();
var fullPath = Path.GetFullPath(manifestPath);
var writeTimeUtc = File.GetLastWriteTimeUtc(fullPath);
lock (s_manifestLock)
{
if (s_cachedManifest != null &&
string.Equals(s_cachedManifestPath, fullPath, StringComparison.OrdinalIgnoreCase) &&
s_cachedManifestWriteTimeUtc == writeTimeUtc)
{
return s_cachedManifest;
}
try
{
var json = File.ReadAllText(fullPath);
var manifest = JsonSerializer.Deserialize<PptxTemplateManifest>(json, s_jsonOptions);
if (manifest?.Templates.Count > 0)
{
s_cachedManifestPath = fullPath;
s_cachedManifestWriteTimeUtc = writeTimeUtc;
s_cachedManifest = manifest;
return manifest;
}
}
catch
{
// fall through to the bundled fallback manifest
}
s_cachedManifestPath = null;
s_cachedManifestWriteTimeUtc = DateTime.MinValue;
s_cachedManifest = CreateFallbackManifest();
return s_cachedManifest;
}
}
private static string? FindManifestPath()
{
var candidates = EnumerateCandidateDirectories(
AppContext.BaseDirectory,
Environment.CurrentDirectory,
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData));
foreach (var directory in candidates)
{
var manifestPath = Path.Combine(directory, ManifestFileName);
if (File.Exists(manifestPath))
return Path.GetFullPath(manifestPath);
}
return null;
}
private static IReadOnlyList<string> EnumerateCandidateDirectories(
string baseDirectory,
string currentDirectory,
string? appDataPath)
{
var directories = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
void Add(string? candidate)
{
if (string.IsNullOrWhiteSpace(candidate))
return;
string normalized;
try
{
normalized = Path.GetFullPath(candidate);
}
catch
{
return;
}
if (seen.Add(normalized))
directories.Add(normalized);
}
if (!string.IsNullOrWhiteSpace(appDataPath))
Add(Path.Combine(appDataPath, "AXCopilot", "templates", "ppt"));
Add(Path.Combine(baseDirectory, "Assets", "ppt"));
Add(Path.Combine(currentDirectory, "Assets", "ppt"));
Add(Path.Combine(currentDirectory, "src", "AxCopilot", "Assets", "ppt"));
foreach (var anchor in new[] { baseDirectory, currentDirectory })
{
var current = SafeDirectoryInfo(anchor);
for (var depth = 0; current != null && depth < 10; depth++, current = current.Parent)
{
Add(Path.Combine(current.FullName, "Assets", "ppt"));
Add(Path.Combine(current.FullName, "src", "AxCopilot", "Assets", "ppt"));
}
}
return directories;
}
private static DirectoryInfo? SafeDirectoryInfo(string? path)
{
if (string.IsNullOrWhiteSpace(path))
return null;
try
{
return new DirectoryInfo(Path.GetFullPath(path));
}
catch
{
return null;
}
}
private static bool LooksLikePath(string value)
=> value.Contains('\\', StringComparison.Ordinal)
|| value.Contains('/', StringComparison.Ordinal)
|| Path.IsPathRooted(value);
private static string? ResolveExplicitPath(string path)
{
try
{
var fullPath = Path.GetFullPath(path);
return File.Exists(fullPath) ? fullPath : null;
}
catch
{
return null;
}
}
private static PptxTemplateManifest CreateFallbackManifest()
{
return new PptxTemplateManifest
{
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"]),
]
};
}
}

View File

@@ -68,7 +68,8 @@ public partial class ChatWindow
sb.AppendLine("For ordinary Cowork requests where no plan is requested, proceed directly with the work and focus on producing the requested result.");
sb.AppendLine("If the user asks for a brand-new report, proposal, analysis, manual, or other document and does not explicitly ask to reference workspace files, do NOT start with glob, grep, document_read, or folder_map.");
sb.AppendLine("In that case, go straight to the creation tool. Use document_plan only when multi-section structure work clearly improves the result or when the user explicitly requests a plan.");
sb.AppendLine("If the user asks for a presentation, slide deck, or PPT, prefer calling pptx_create directly. Use document_plan first only when the user explicitly asks for an outline or when the deck structure is genuinely unclear.");
sb.AppendLine("If the user asks for a presentation, slide deck, or PPT, prefer document_plan first for executive, business, strategy, review, proposal, or board-style decks, and especially when the deck is more than a few slides.");
sb.AppendLine("Use pptx_create directly only for clearly simple decks, explicit slide-by-slide edits, or when the user already provided a strong structure.");
sb.AppendLine("When writing a new document, avoid repetitive same-shape sections. Tailor the structure to the purpose and use summaries, findings, comparison tables, timelines, recommendations, appendices, or action items when they improve clarity.");
sb.AppendLine("Prefer concrete and useful content over filler. If a section benefits from bullets, tables, or structured comparison, use them instead of flat generic paragraphs.");
sb.AppendLine("After creating files, summarize what was created and include the actual output path.");
@@ -91,6 +92,7 @@ public partial class ChatWindow
sb.AppendLine(" 4. Fill every section with real content. Do not stop at an outline or plan only.");
sb.AppendLine(" 4-1. Use richer section patterns when they help: summary box, key findings bullets, comparison table, timeline, checklist, action items, appendix.");
sb.AppendLine(" 5. The task is complete only after the actual document file has been created or updated.");
sb.AppendLine(" 6. For PPT work, if the quality summary mentions low score, slide alerts, or needs work, revise the storyline and regenerate instead of stopping after the first pptx_create call.");
// 문서 품질 검증 루프
sb.AppendLine("\n## Document Quality Review");

View File

@@ -5,7 +5,7 @@
xmlns:local="clr-namespace:AxCopilot.Views"
xmlns:conv="clr-namespace:AxCopilot.Themes"
xmlns:vm="clr-namespace:AxCopilot.ViewModels"
Title="AX Copilot — AX Agent"
Title="AX Agent"
Width="1180" Height="880"
MinWidth="780" MinHeight="560"
WindowStyle="None"

View File

@@ -14,14 +14,16 @@ allowed-tools:
tabs: cowork
---
Use `pptx_create` as the default path. Do not start with ad-hoc slide arrays unless the user already gave a very detailed deck structure.
Use `document_plan` plus `pptx_create` as the default path for business decks. Do not start with ad-hoc slide arrays unless the user already gave a very detailed deck structure.
## Default workflow
1. Review the workspace for existing presentation files, reports, or templates.
2. Use `document_plan` with `document_type: presentation` to define the storyline first.
3. Build a deck brief with `audience`, `objective`, `decision_ask`, and optional `storyline`.
4. Generate the deck with `pptx_create`.
5. Return the file path together with the storyline and quality summary.
5. Prefer `template_pack` or a bundled template (`basic100`, `core100`, etc.) when the deck needs branded polish.
6. If the returned quality summary is weak, revise the storyline and regenerate the same file instead of stopping.
7. Return the file path together with the storyline and quality summary.
## Quality rules
- Keep one message per slide.