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:
10
README.md
10
README.md
@@ -2269,3 +2269,13 @@ MIT License
|
|||||||
업데이트: 2026-04-15 21:23 (KST)
|
업데이트: 2026-04-15 21:23 (KST)
|
||||||
- AX Agent 라이브 진행 문구와 시간·토큰 표시가 서로 분리돼 보이던 입력 영역 레이아웃을 다시 다듬었습니다. src/AxCopilot/Views/ChatWindow.xaml에서 StreamMetricsLabel을 독립 줄로 두지 않고 라이브 진행 Grid의 우측 하단에 다시 배치해, 진행 문구와 입력창이 한 덩어리처럼 붙어 보이면서도 시간·토큰 표시는 입력창 바로 위 우측에 유지되도록 조정했습니다.
|
- 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
|
- 검증: 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
|
||||||
|
|||||||
@@ -1588,3 +1588,17 @@ UI ?遺우쁽????域뱀뮆???귐뗫솯?醫딆춦 ???袁る퓮 ?臾믩씜 ??疫
|
|||||||
- AX Agent 입력 영역 상단 레이아웃을 다시 정리했습니다. src/AxCopilot/Views/ChatWindow.xaml에서 StreamMetricsLabel을 독립 줄로 분리했던 배치 때문에 라이브 진행 문구와 입력창 사이가 넓게 벌어져 보였고, 우측 시간·토큰 표시만 아래로 내려간 상태였습니다.
|
- AX Agent 입력 영역 상단 레이아웃을 다시 정리했습니다. src/AxCopilot/Views/ChatWindow.xaml에서 StreamMetricsLabel을 독립 줄로 분리했던 배치 때문에 라이브 진행 문구와 입력창 사이가 넓게 벌어져 보였고, 우측 시간·토큰 표시만 아래로 내려간 상태였습니다.
|
||||||
- 같은 파일의 라이브 진행 Grid를 2열 구조로 바꾸고 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
|
- 검증: 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
|
||||||
|
|||||||
56
src/AxCopilot.Tests/Services/PptQualityGatePolicyTests.cs
Normal file
56
src/AxCopilot.Tests/Services/PptQualityGatePolicyTests.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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().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();
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/AxCopilot/Assets/ppt/templates.manifest.json
Normal file
76
src/AxCopilot/Assets/ppt/templates.manifest.json
Normal 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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -121,6 +121,9 @@
|
|||||||
<Resource Include="Assets\mascot.webp" Condition="Exists('Assets\mascot.webp')" />
|
<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\foldy_qdy.png" Condition="Exists('Assets\foldy_qdy.png')" />
|
||||||
<Resource Include="Assets\pixel_art.png" Condition="Exists('Assets\pixel_art.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).
|
<!-- PPT 고품질 템플릿: 빌드에 포함하지 않음 (297MB).
|
||||||
런타임에 Assets/ppt/ 또는 %APPDATA%/AXCopilot/templates/ppt/ 에서 검색.
|
런타임에 Assets/ppt/ 또는 %APPDATA%/AXCopilot/templates/ppt/ 에서 검색.
|
||||||
개발 환경에서는 소스의 Assets/ppt/를 자동으로 찾음. -->
|
개발 환경에서는 소스의 Assets/ppt/를 자동으로 찾음. -->
|
||||||
|
|||||||
@@ -1756,7 +1756,8 @@ public partial class AgentLoopService
|
|||||||
executionPolicy,
|
executionPolicy,
|
||||||
context,
|
context,
|
||||||
ct,
|
ct,
|
||||||
documentPlanCalled);
|
documentPlanCalled,
|
||||||
|
runState);
|
||||||
if (terminalCompleted)
|
if (terminalCompleted)
|
||||||
{
|
{
|
||||||
if (consumedExtraIteration)
|
if (consumedExtraIteration)
|
||||||
|
|||||||
@@ -94,11 +94,15 @@ public partial class AgentLoopService
|
|||||||
ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy,
|
ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy,
|
||||||
AgentContext context,
|
AgentContext context,
|
||||||
CancellationToken ct,
|
CancellationToken ct,
|
||||||
bool documentPlanWasCalled = false)
|
bool documentPlanWasCalled = false,
|
||||||
|
RunState? runState = null)
|
||||||
{
|
{
|
||||||
if (!result.Success || !IsTerminalDocumentTool(call.ToolName) || toolCalls.Count != 1)
|
if (!result.Success || !IsTerminalDocumentTool(call.ToolName) || toolCalls.Count != 1)
|
||||||
return (false, false);
|
return (false, false);
|
||||||
|
|
||||||
|
if (TryApplyPptQualityGateTransition(call, result, messages, documentPlanWasCalled, runState))
|
||||||
|
return (false, false);
|
||||||
|
|
||||||
if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase))
|
if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료");
|
EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료");
|
||||||
@@ -124,6 +128,53 @@ public partial class AgentLoopService
|
|||||||
return (true, consumedExtraIteration);
|
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(
|
private async Task<bool> TryApplyPostToolVerificationTransitionAsync(
|
||||||
ContentBlock call,
|
ContentBlock call,
|
||||||
ToolResult result,
|
ToolResult result,
|
||||||
|
|||||||
@@ -1242,6 +1242,7 @@ public partial class AgentLoopService
|
|||||||
public int TransientLlmErrorRetries;
|
public int TransientLlmErrorRetries;
|
||||||
public int DocumentArtifactGateRetry;
|
public int DocumentArtifactGateRetry;
|
||||||
public int DocumentVerificationGateRetry;
|
public int DocumentVerificationGateRetry;
|
||||||
|
public int PptQualityGateRetry;
|
||||||
public int NoProgressRecoveryRetry;
|
public int NoProgressRecoveryRetry;
|
||||||
public int TerminalEvidenceGateRetry;
|
public int TerminalEvidenceGateRetry;
|
||||||
public bool WorkspaceAppearsEmpty;
|
public bool WorkspaceAppearsEmpty;
|
||||||
|
|||||||
92
src/AxCopilot/Services/Agent/PptQualityGatePolicy.cs
Normal file
92
src/AxCopilot/Services/Agent/PptQualityGatePolicy.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -220,58 +220,21 @@ public class PptxSkill : IAgentTool
|
|||||||
// ── 통합 테마 레코드 ──────────────────────────────────────────────────────
|
// ── 통합 테마 레코드 ──────────────────────────────────────────────────────
|
||||||
private record FullTheme(ThemeColors Colors, ThemeLayout Layout);
|
private record FullTheme(ThemeColors Colors, ThemeLayout Layout);
|
||||||
|
|
||||||
// ── 고품질 템플릿 레지스트리 ────────────────────────────────────────────────
|
private static FullTheme ResolveTemplateFallbackTheme(
|
||||||
// 이름 → Assets/ppt/ 아래 파일명 매핑. 실행 시 디스크에서 열어 마스터 복제.
|
string templateName,
|
||||||
// 토큰 컨텍스트 영향 0 — 바이너리 파일은 LLM에 전달되지 않음.
|
PptxTemplateResolution? resolution,
|
||||||
private static readonly Dictionary<string, string> TemplateRegistry = new(StringComparer.OrdinalIgnoreCase)
|
FullTheme defaultTheme)
|
||||||
{
|
{
|
||||||
["basic100"] = "BASIC100 기준 템플릿 V1.pptx",
|
if (FullThemes.TryGetValue(templateName, out var namedTheme))
|
||||||
["core100"] = "CORE100 기준템플릿 V1.pptx",
|
return namedTheme;
|
||||||
["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",
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>
|
if (resolution?.Entry != null &&
|
||||||
/// 템플릿 이름으로 .pptx 파일 전체 경로를 반환합니다.
|
FullThemes.TryGetValue(resolution.Entry.FallbackTheme, out var manifestTheme))
|
||||||
/// 설치파일 용량 문제로 템플릿은 빌드에 포함하지 않으며,
|
|
||||||
/// 여러 알려진 경로를 순서대로 탐색합니다.
|
|
||||||
/// </summary>
|
|
||||||
private static string? ResolveTemplatePath(string templateName)
|
|
||||||
{
|
{
|
||||||
if (!TemplateRegistry.TryGetValue(templateName, out var fileName))
|
return manifestTheme;
|
||||||
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 (File.Exists(path))
|
|
||||||
return Path.GetFullPath(path);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return defaultTheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 20가지 테마 사전 ──────────────────────────────────────────────────────
|
// ── 20가지 테마 사전 ──────────────────────────────────────────────────────
|
||||||
@@ -588,6 +551,8 @@ public class PptxSkill : IAgentTool
|
|||||||
: null);
|
: null);
|
||||||
var templatePackName = templatePack?.Name;
|
var templatePackName = templatePack?.Name;
|
||||||
var packTemplateName = templatePack?.PreferredTemplate;
|
var packTemplateName = templatePack?.PreferredTemplate;
|
||||||
|
PptxTemplateResolution? explicitTemplateResolution = null;
|
||||||
|
PptxTemplateResolution? packTemplateResolution = null;
|
||||||
|
|
||||||
if (renderDeck != null)
|
if (renderDeck != null)
|
||||||
{
|
{
|
||||||
@@ -647,24 +612,24 @@ public class PptxSkill : IAgentTool
|
|||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(templateName))
|
if (!string.IsNullOrWhiteSpace(templateName))
|
||||||
{
|
{
|
||||||
// 고품질 템플릿: 마스터 복제 시도 → 실패 시 내장 메타데이터 폴백
|
explicitTemplateResolution = PptxTemplateManifestCatalog.ResolveTemplate(templateName!);
|
||||||
templatePptxPath = ResolveTemplatePath(templateName!);
|
templatePptxPath = explicitTemplateResolution.ResolvedPath;
|
||||||
|
var templateFallbackTheme = ResolveTemplateFallbackTheme(
|
||||||
|
templateName!,
|
||||||
|
explicitTemplateResolution,
|
||||||
|
FullThemes["professional"]);
|
||||||
|
|
||||||
// 내장 테마에 템플릿 이름이 등록되어 있으면 해당 색상/레이아웃 사용
|
if (templatePptxPath != null)
|
||||||
if (FullThemes.TryGetValue(templateName!, out var templateTheme))
|
|
||||||
fullTheme = templateTheme;
|
|
||||||
else if (templatePptxPath != null)
|
|
||||||
{
|
{
|
||||||
// 미등록 템플릿이지만 파일이 있으면 색상 추출
|
|
||||||
var extracted = ExtractThemeFromPptx(templatePptxPath);
|
var extracted = ExtractThemeFromPptx(templatePptxPath);
|
||||||
var baseLayout = FullThemes["professional"].Layout;
|
var baseLayout = templateFallbackTheme.Layout;
|
||||||
fullTheme = extracted != null
|
fullTheme = extracted != null
|
||||||
? new FullTheme(extracted, baseLayout)
|
? new FullTheme(extracted, baseLayout)
|
||||||
: FullThemes["professional"];
|
: templateFallbackTheme;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
fullTheme = FullThemes["professional"];
|
fullTheme = templateFallbackTheme;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (args.SafeTryGetProperty("theme_file", out var tfEl) && !string.IsNullOrEmpty(tfEl.SafeGetString()))
|
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)
|
else if (templatePack != null)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(packTemplateName))
|
if (!string.IsNullOrWhiteSpace(packTemplateName))
|
||||||
templatePptxPath = ResolveTemplatePath(packTemplateName!);
|
{
|
||||||
|
packTemplateResolution = PptxTemplateManifestCatalog.ResolveTemplate(packTemplateName!);
|
||||||
|
templatePptxPath = packTemplateResolution.ResolvedPath;
|
||||||
|
}
|
||||||
|
|
||||||
if (!FullThemes.TryGetValue(templatePack.FallbackTheme, out var packTheme))
|
if (!FullThemes.TryGetValue(templatePack.FallbackTheme, out var packTheme))
|
||||||
packTheme = FullThemes["professional"];
|
packTheme = FullThemes["professional"];
|
||||||
@@ -1047,16 +1015,32 @@ public class PptxSkill : IAgentTool
|
|||||||
? $"theme_file:{cloneInfo_srcLabel} (master cloned{(cloneAll ? " + slides cloned" : "")})"
|
? $"theme_file:{cloneInfo_srcLabel} (master cloned{(cloneAll ? " + slides cloned" : "")})"
|
||||||
: theme
|
: theme
|
||||||
: !string.IsNullOrWhiteSpace(templateName)
|
: !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)
|
: templatePptxPath != null && !string.IsNullOrWhiteSpace(packTemplateName)
|
||||||
? $"template:{packTemplateName} (color fallback)"
|
? $"template:{packTemplateName} (color fallback)"
|
||||||
|
: !string.IsNullOrWhiteSpace(packTemplateName)
|
||||||
|
? $"template:{packTemplateName} (asset missing -> pack fallback)"
|
||||||
: templatePptxPath != null
|
: templatePptxPath != null
|
||||||
? $"theme_file:{cloneInfo_srcLabel} (master cloned{(cloneAll ? " + slides cloned" : "")})"
|
? $"theme_file:{cloneInfo_srcLabel} (color fallback)"
|
||||||
: 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.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)
|
if (renderDeck != null)
|
||||||
outputParts.Add(renderDeck.ToToolSummary());
|
outputParts.Add(renderDeck.ToToolSummary());
|
||||||
if (!string.IsNullOrWhiteSpace(templatePackName))
|
if (!string.IsNullOrWhiteSpace(templatePackName))
|
||||||
|
|||||||
262
src/AxCopilot/Services/Agent/PptxTemplateManifestCatalog.cs
Normal file
262
src/AxCopilot/Services/Agent/PptxTemplateManifestCatalog.cs
Normal 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"]),
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("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("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("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("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("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.");
|
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. 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(" 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(" 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");
|
sb.AppendLine("\n## Document Quality Review");
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
xmlns:local="clr-namespace:AxCopilot.Views"
|
xmlns:local="clr-namespace:AxCopilot.Views"
|
||||||
xmlns:conv="clr-namespace:AxCopilot.Themes"
|
xmlns:conv="clr-namespace:AxCopilot.Themes"
|
||||||
xmlns:vm="clr-namespace:AxCopilot.ViewModels"
|
xmlns:vm="clr-namespace:AxCopilot.ViewModels"
|
||||||
Title="AX Copilot — AX Agent"
|
Title="AX Agent"
|
||||||
Width="1180" Height="880"
|
Width="1180" Height="880"
|
||||||
MinWidth="780" MinHeight="560"
|
MinWidth="780" MinHeight="560"
|
||||||
WindowStyle="None"
|
WindowStyle="None"
|
||||||
|
|||||||
@@ -14,14 +14,16 @@ allowed-tools:
|
|||||||
tabs: cowork
|
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
|
## Default workflow
|
||||||
1. Review the workspace for existing presentation files, reports, or templates.
|
1. Review the workspace for existing presentation files, reports, or templates.
|
||||||
2. Use `document_plan` with `document_type: presentation` to define the storyline first.
|
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`.
|
3. Build a deck brief with `audience`, `objective`, `decision_ask`, and optional `storyline`.
|
||||||
4. Generate the deck with `pptx_create`.
|
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
|
## Quality rules
|
||||||
- Keep one message per slide.
|
- Keep one message per slide.
|
||||||
|
|||||||
Reference in New Issue
Block a user