문서 고도화 다음 단계를 반영해 XLSX·HTML·PPT 품질 게이트를 강화했습니다

핵심 수정사항:
- ExcelSkill summary_sheet에 trend_series를 추가해 Trend Dashboard 블록을 렌더링하고, workbook quality review가 dashboard형 summary 구성을 더 정확히 평가하도록 확장했습니다.
- HtmlSkill은 print=true인데 print_header/print_footer가 없는 경우 기본 print frame을 자동 생성하도록 보강했고, ArtifactQualityReviewService는 print-ready 문서의 frame/decision/evidence/cover 부족을 추가 경고로 반환합니다.
- DeckPlanningService는 comparison, roadmap, executive_summary, kpi_dashboard 슬라이드의 최소 구조를 자동 보정하고, DeckQualityReviewService는 slide-level quality gate를 추가해 긴 headline, 과밀 슬라이드, 옵션 부족, 표/차트 데이터 누락을 Slide N 경고로 요약합니다.
- DeckQualityReviewServiceTests, ExcelSkillDashboardSummaryTests, HtmlSkillPrintFrameTests를 확장해 회귀 검증을 강화했습니다.

검증 결과:
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_doc_next3\\ -p:IntermediateOutputPath=obj\\verify_doc_next3\\ : 경고 0 / 오류 0
- dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "DeckQualityReviewServiceTests|PptxSkillAutoRepairTests|PptxSkillConsultingDeckTests|ExcelSkillDashboardSummaryTests|ExcelSkillSummarySheetTests|HtmlSkillPrintFrameTests|HtmlSkillConsultingSectionsTests|ArtifactQualityReviewServiceTests" -p:OutputPath=bin\\verify_doc_next3_tests\\ -p:IntermediateOutputPath=obj\\verify_doc_next3_tests\\ : 통과 13
This commit is contained in:
2026-04-14 23:16:00 +09:00
parent 1edeffa206
commit 2e36f2fef1
10 changed files with 350 additions and 5 deletions

View File

@@ -1830,3 +1830,11 @@ MIT License
- 테스트로 [ExcelSkillDashboardSummaryTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/ExcelSkillDashboardSummaryTests.cs), [DocumentAssemblerStyleMapTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/DocumentAssemblerStyleMapTests.cs)를 추가했고, [ArtifactQualityReviewServiceTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/ArtifactQualityReviewServiceTests.cs)도 새 workbook review 입력 형식에 맞춰 갱신했습니다. - 테스트로 [ExcelSkillDashboardSummaryTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/ExcelSkillDashboardSummaryTests.cs), [DocumentAssemblerStyleMapTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/DocumentAssemblerStyleMapTests.cs)를 추가했고, [ArtifactQualityReviewServiceTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/ArtifactQualityReviewServiceTests.cs)도 새 workbook review 입력 형식에 맞춰 갱신했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_doc_next2\\ -p:IntermediateOutputPath=obj\\verify_doc_next2\\` 경고 0 / 오류 0 - 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_doc_next2\\ -p:IntermediateOutputPath=obj\\verify_doc_next2\\` 경고 0 / 오류 0
- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ArtifactQualityReviewServiceTests|DocumentAssemblerStyleMapTests|DocumentAssemblerDocxFeaturesTests|DocumentAssemblerSemanticTests|ExcelSkillDashboardSummaryTests|ExcelSkillSummarySheetTests|ExcelSkillExecutiveSummaryLinkTests|ExcelSkillDataValidationTests|ExcelSkillConditionalFormattingTests" -p:OutputPath=bin\\verify_doc_next2_tests\\ -p:IntermediateOutputPath=obj\\verify_doc_next2_tests\\` 통과 11 - 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ArtifactQualityReviewServiceTests|DocumentAssemblerStyleMapTests|DocumentAssemblerDocxFeaturesTests|DocumentAssemblerSemanticTests|ExcelSkillDashboardSummaryTests|ExcelSkillSummarySheetTests|ExcelSkillExecutiveSummaryLinkTests|ExcelSkillDataValidationTests|ExcelSkillConditionalFormattingTests" -p:OutputPath=bin\\verify_doc_next2_tests\\ -p:IntermediateOutputPath=obj\\verify_doc_next2_tests\\` 통과 11
업데이트: 2026-04-14 23:15 (KST)
- 문서 고도화 다음 단계를 반영했습니다. [ExcelSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ExcelSkill.cs)는 `summary_sheet.trend_series`를 지원해 `Trend Dashboard` 블록을 추가로 렌더링하고, 기존 `decision_summary`/`scorecards`/`sheet_summaries`와 함께 보고서형 workbook summary 밀도를 높였습니다.
- [HtmlSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/HtmlSkill.cs)는 `print=true`인데 `print_header`/`print_footer`가 없는 경우 기본 print frame을 자동 생성하도록 보강했습니다. 같은 흐름에서 [ArtifactQualityReviewService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs)는 print-ready HTML이 frame, decision summary, evidence card 없이 끝나는 경우를 품질 경고로 잡아냅니다.
- PPT 쪽은 [DeckPlanningService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/DeckPlanningService.cs)가 `comparison`, `roadmap`, `executive_summary`, `kpi_dashboard` 슬라이드의 최소 구조를 더 적극적으로 자동 보정하고, [DeckQualityReviewService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/DeckQualityReviewService.cs)는 slide-level quality gate를 추가해 긴 headline, 과밀 슬라이드, 비교 옵션 부족, 차트/표 데이터 누락을 슬라이드 번호와 함께 경고합니다.
- 테스트로 [ExcelSkillDashboardSummaryTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/ExcelSkillDashboardSummaryTests.cs), [HtmlSkillPrintFrameTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/HtmlSkillPrintFrameTests.cs), [DeckQualityReviewServiceTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/DeckQualityReviewServiceTests.cs)를 확장해 trend dashboard, default print frame, slide-level alert를 회귀 검증했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_doc_next3\\ -p:IntermediateOutputPath=obj\\verify_doc_next3\\` 경고 0 / 오류 0
- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "DeckQualityReviewServiceTests|PptxSkillAutoRepairTests|PptxSkillConsultingDeckTests|ExcelSkillDashboardSummaryTests|ExcelSkillSummarySheetTests|HtmlSkillPrintFrameTests|HtmlSkillConsultingSectionsTests|ArtifactQualityReviewServiceTests" -p:OutputPath=bin\\verify_doc_next3_tests\\ -p:IntermediateOutputPath=obj\\verify_doc_next3_tests\\` 통과 13

View File

@@ -899,3 +899,14 @@ UI ?붿옄???€洹쒕え 由ы뙥?좊쭅 ???꾪뿕 ?묒뾽 ??湲곕줉???덉쟾
- 새 회귀 테스트 [ExcelSkillDashboardSummaryTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/ExcelSkillDashboardSummaryTests.cs), [DocumentAssemblerStyleMapTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/DocumentAssemblerStyleMapTests.cs)를 추가했고, [ArtifactQualityReviewServiceTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/ArtifactQualityReviewServiceTests.cs)도 새 record 시그니처에 맞춰 갱신했습니다. - 새 회귀 테스트 [ExcelSkillDashboardSummaryTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/ExcelSkillDashboardSummaryTests.cs), [DocumentAssemblerStyleMapTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/DocumentAssemblerStyleMapTests.cs)를 추가했고, [ArtifactQualityReviewServiceTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/ArtifactQualityReviewServiceTests.cs)도 새 record 시그니처에 맞춰 갱신했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_doc_next2\\ -p:IntermediateOutputPath=obj\\verify_doc_next2\\` 경고 0 / 오류 0 - 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_doc_next2\\ -p:IntermediateOutputPath=obj\\verify_doc_next2\\` 경고 0 / 오류 0
- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ArtifactQualityReviewServiceTests|DocumentAssemblerStyleMapTests|DocumentAssemblerDocxFeaturesTests|DocumentAssemblerSemanticTests|ExcelSkillDashboardSummaryTests|ExcelSkillSummarySheetTests|ExcelSkillExecutiveSummaryLinkTests|ExcelSkillDataValidationTests|ExcelSkillConditionalFormattingTests" -p:OutputPath=bin\\verify_doc_next2_tests\\ -p:IntermediateOutputPath=obj\\verify_doc_next2_tests\\` 통과 11 - 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ArtifactQualityReviewServiceTests|DocumentAssemblerStyleMapTests|DocumentAssemblerDocxFeaturesTests|DocumentAssemblerSemanticTests|ExcelSkillDashboardSummaryTests|ExcelSkillSummarySheetTests|ExcelSkillExecutiveSummaryLinkTests|ExcelSkillDataValidationTests|ExcelSkillConditionalFormattingTests" -p:OutputPath=bin\\verify_doc_next2_tests\\ -p:IntermediateOutputPath=obj\\verify_doc_next2_tests\\` 통과 11
업데이트: 2026-04-14 23:15 (KST)
- [ExcelSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ExcelSkill.cs)는 `summary_sheet.trend_series`를 새로 지원합니다. summary sheet에서 `Trend Dashboard` 섹션을 추가로 만들고 `label/current/target/delta/status`를 열 기반으로 렌더링해 workbook summary가 KPI 표 수준을 넘어 상태 대시보드 역할까지 하도록 확장했습니다.
- 같은 파일의 workbook review 입력 계산은 `trend_series`도 summary quality 강점으로 인정하도록 업데이트했습니다. [ArtifactQualityReviewService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs)는 workbook summary가 KPI/decision/highlight 없이 끝나는 경우 보완 포인트를 추가로 반환합니다.
- [HtmlSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/HtmlSkill.cs)는 `print=true`에서 명시적 `print_header`/`print_footer`가 없는 경우 기본 frame(`title`, `date | AX Copilot`)을 자동 생성합니다. print-ready HTML이 최소 배포형 header/footer를 갖도록 내부 기본값을 넣은 것입니다.
- [ArtifactQualityReviewService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs)의 HTML 리뷰는 print-ready 문서에 frame이 없거나, decision/evidence block이 부족하거나, 장문 보고서인데 cover가 없는 경우를 추가로 경고합니다.
- [DeckPlanningService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/DeckPlanningService.cs)는 `comparison`, `roadmap`, `executive_summary`, `kpi_dashboard` 슬라이드의 최소 구조를 자동 보정하고, 긴 headline은 내부 기준 길이로 압축합니다.
- [DeckQualityReviewService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/DeckQualityReviewService.cs)는 slide-level quality gate를 추가해 긴 headline, 과밀 슬라이드, 옵션 부족, 표/차트 데이터 누락을 `Slide N:` 경고로 품질 요약에 포함합니다.
- 테스트로 [ExcelSkillDashboardSummaryTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/ExcelSkillDashboardSummaryTests.cs), [HtmlSkillPrintFrameTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/HtmlSkillPrintFrameTests.cs), [DeckQualityReviewServiceTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/DeckQualityReviewServiceTests.cs)를 확장했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_doc_next3\\ -p:IntermediateOutputPath=obj\\verify_doc_next3\\` 경고 0 / 오류 0
- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "DeckQualityReviewServiceTests|PptxSkillAutoRepairTests|PptxSkillConsultingDeckTests|ExcelSkillDashboardSummaryTests|ExcelSkillSummarySheetTests|HtmlSkillPrintFrameTests|HtmlSkillConsultingSectionsTests|ArtifactQualityReviewServiceTests" -p:OutputPath=bin\\verify_doc_next3_tests\\ -p:IntermediateOutputPath=obj\\verify_doc_next3_tests\\` 통과 13

View File

@@ -54,4 +54,33 @@ public class DeckQualityReviewServiceTests
review.Strengths.Should().Contain(strength => strength.Contains("Executive Summary")); review.Strengths.Should().Contain(strength => strength.Contains("Executive Summary"));
review.Issues.Should().NotContain(issue => issue.Severity == DeckReviewSeverity.Critical); review.Issues.Should().NotContain(issue => issue.Severity == DeckReviewSeverity.Critical);
} }
[Fact]
public void ReviewDeck_ShouldSurfaceSlideLevelQualityAlerts()
{
using var slides = JsonDocument.Parse(
"""
[
{ "layout": "title", "title": "Growth Strategy" },
{
"layout": "executive_summary",
"title": "Executive Summary",
"headline": "This headline is intentionally very long so that it exceeds the preferred consulting slide headline length and triggers the slide quality gate.",
"summary_points": ["Only one point"]
},
{
"layout": "comparison",
"title": "Options",
"headline": "Compare options",
"options": [{ "name": "A", "pros": "Fast", "cons": "Shallow", "verdict": "Only option" }]
}
]
""");
var review = DeckQualityReviewService.ReviewDeck("Alert Deck", slides.RootElement, hasTemplate: false, autoRepairCount: 0);
review.Issues.Should().Contain(issue => issue.Message.Contains("Slide 2"));
review.Issues.Should().Contain(issue => issue.Message.Contains("headline is too long"));
review.Issues.Should().Contain(issue => issue.Message.Contains("comparison slide needs at least two options"));
}
} }

View File

@@ -39,6 +39,9 @@ public class ExcelSkillDashboardSummaryTests
"scorecards": [ "scorecards": [
{ "label": "Run-rate", "value": "96%", "status": "On Track", "note": "Ahead of plan" } { "label": "Run-rate", "value": "96%", "status": "On Track", "note": "Ahead of plan" }
], ],
"trend_series": [
{ "label": "Revenue", "current": "120", "target": "130", "delta": "-10", "status": "Watch" }
],
"sheet_summaries": [ "sheet_summaries": [
{ "sheet": "Revenue", "status": "Watch", "summary": "SMB margin pressure", "owner": "Sales Ops" } { "sheet": "Revenue", "status": "Watch", "summary": "SMB margin pressure", "owner": "Sales Ops" }
], ],
@@ -73,6 +76,8 @@ public class ExcelSkillDashboardSummaryTests
texts.Should().Contain("Approve regional rollout"); texts.Should().Contain("Approve regional rollout");
texts.Should().Contain("Scorecards"); texts.Should().Contain("Scorecards");
texts.Should().Contain("Run-rate"); texts.Should().Contain("Run-rate");
texts.Should().Contain("Trend Dashboard");
texts.Should().Contain("-10");
texts.Should().Contain("Revenue"); texts.Should().Contain("Revenue");
texts.Should().Contain("SMB margin pressure"); texts.Should().Contain("SMB margin pressure");
} }

View File

@@ -59,4 +59,52 @@ public class HtmlSkillPrintFrameTests
} }
} }
} }
[Fact]
public async Task ExecuteAsync_WithPrintEnabled_ShouldGenerateDefaultPrintFrame()
{
var workDir = Path.Combine(Path.GetTempPath(), "ax-html-printframe-auto-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(workDir);
try
{
var tool = new HtmlSkill();
var context = new AgentContext
{
WorkFolder = workDir,
Permission = "Auto",
OperationMode = "external",
};
var args = JsonDocument.Parse(
"""
{
"path": "auto-print-frame.html",
"title": "Strategy Review",
"print": true,
"body": "<h2>Executive Summary</h2><p>Summary text with enough detail to qualify as a printable executive report.</p><h2>Decision</h2><p>Approve the phase-1 roadmap.</p><h2>Evidence</h2><p>Supporting evidence.</p><h2>Appendix</h2><p>Reference detail.</p>"
}
""").RootElement;
var result = await tool.ExecuteAsync(args, context, CancellationToken.None);
result.Success.Should().BeTrue();
var html = File.ReadAllText(Path.Combine(workDir, "auto-print-frame.html"));
html.Should().Contain("print-header");
html.Should().Contain("print-footer");
html.Should().Contain("Strategy Review");
html.Should().Contain("AX Copilot");
}
finally
{
try
{
if (Directory.Exists(workDir))
Directory.Delete(workDir, true);
}
catch
{
}
}
}
} }

View File

@@ -29,6 +29,9 @@ public sealed record ArtifactQualityReport(
var parts = new List<string> { $"Quality score {Score}/100" }; var parts = new List<string> { $"Quality score {Score}/100" };
if (strengths.Count > 0) if (strengths.Count > 0)
parts.Add("Strengths: " + string.Join(", ", strengths)); parts.Add("Strengths: " + string.Join(", ", strengths));
var slideAlertCount = Issues.Count(issue => issue.Message.StartsWith("Slide ", StringComparison.OrdinalIgnoreCase));
if (slideAlertCount > 0)
parts.Add($"Slide alerts: {slideAlertCount}");
if (issues.Count > 0) if (issues.Count > 0)
parts.Add("Needs work: " + string.Join(", ", issues)); parts.Add("Needs work: " + string.Join(", ", issues));
else else
@@ -148,6 +151,12 @@ public static class ArtifactQualityReviewService
issues.Add(new($"Found {placeholderCount} placeholder or unfinished marker(s).", ArtifactReviewSeverity.Critical)); issues.Add(new($"Found {placeholderCount} placeholder or unfinished marker(s).", ArtifactReviewSeverity.Critical));
if (!ContainsAny(html, ExecutiveKeywords)) if (!ContainsAny(html, ExecutiveKeywords))
issues.Add(new("Executive summary or summary section is missing.", ArtifactReviewSeverity.Warning)); issues.Add(new("Executive summary or summary section is missing.", ArtifactReviewSeverity.Warning));
if (printReady && !hasPrintFrame)
issues.Add(new("Print-ready export is missing a print header/footer frame.", ArtifactReviewSeverity.Info));
if (printReady && sectionCount >= 4 && decisionCount + evidenceCardCount == 0)
issues.Add(new("Print-ready business report would benefit from decision summary or evidence cards.", ArtifactReviewSeverity.Warning));
if (printReady && !hasCover && sectionCount >= 5)
issues.Add(new("Print-ready report could benefit from a cover page.", ArtifactReviewSeverity.Info));
return BuildReport("html", strengths, issues); return BuildReport("html", strengths, issues);
} }

View File

@@ -249,6 +249,21 @@ public static class DeckPlanningService
repairs++; repairs++;
} }
if (normalizedLayout == "comparison" && EnsureMinimumOptions(slide))
repairs++;
if (normalizedLayout == "roadmap" && EnsureRoadmapPhases(slide))
repairs++;
if (normalizedLayout == "executive_summary")
{
repairs += EnsureExecutiveSummaryContent(slide, objective, decisionAsk);
repairs += EnsureKpiContent(slide);
}
if (normalizedLayout == "kpi_dashboard")
repairs += EnsureKpiContent(slide);
if (string.IsNullOrWhiteSpace(ReadString(slide, "headline")) && if (string.IsNullOrWhiteSpace(ReadString(slide, "headline")) &&
normalizedLayout is "executive_summary" or "comparison" or "recommendation" or "roadmap" or "kpi_dashboard") normalizedLayout is "executive_summary" or "comparison" or "recommendation" or "roadmap" or "kpi_dashboard")
{ {
@@ -256,6 +271,9 @@ public static class DeckPlanningService
repairs++; repairs++;
} }
if (TrimLongHeadline(slide))
repairs++;
if (normalizedLayout == "recommendation" && string.IsNullOrWhiteSpace(ReadString(slide, "recommendation"))) if (normalizedLayout == "recommendation" && string.IsNullOrWhiteSpace(ReadString(slide, "recommendation")))
{ {
slide["recommendation"] = Coalesce(decisionAsk, objective, "利됱떆 ?ㅽ뻾??沅뚭퀬?덉쓣 ??臾몄옣?쇰줈 ?쒖떆?⑸땲??"); slide["recommendation"] = Coalesce(decisionAsk, objective, "利됱떆 ?ㅽ뻾??沅뚭퀬?덉쓣 ??臾몄옣?쇰줈 ?쒖떆?⑸땲??");
@@ -497,6 +515,80 @@ public static class DeckPlanningService
}; };
} }
private static bool EnsureMinimumOptions(JsonObject slide)
{
if (!slide.TryGetPropertyValue("options", out var optionsNode) || optionsNode is not JsonArray options)
{
EnsureFallbackOptions(slide);
return true;
}
if (options.Count >= 2)
return false;
var existing = options.OfType<JsonObject>().ToList();
EnsureFallbackOptions(slide);
if (existing.Count > 0 && slide["options"] is JsonArray fallbackOptions)
fallbackOptions.Insert(0, existing[0]);
return true;
}
private static bool EnsureRoadmapPhases(JsonObject slide)
{
if (slide.TryGetPropertyValue("phases", out var phasesNode) && phasesNode is JsonArray phases && phases.Count > 0)
return false;
slide["phases"] = BuildRoadmapSlide(ReadString(slide, "headline")).TryGetPropertyValue("phases", out var defaultPhases)
? defaultPhases?.DeepClone()
: new JsonArray();
return true;
}
private static int EnsureExecutiveSummaryContent(JsonObject slide, string? objective, string? decisionAsk)
{
var repairs = 0;
if (ReadTextLines(slide, "summary_points").Count == 0)
{
slide["summary_points"] = new JsonArray(
Coalesce(decisionAsk, objective, "Key decision and expected business impact are summarized here."),
"Immediate execution priorities are separated from medium-term actions.",
"Supporting evidence is translated into a concise executive message.");
repairs++;
}
if (string.IsNullOrWhiteSpace(ReadString(slide, "recommendation")))
{
slide["recommendation"] = Coalesce(decisionAsk, objective, "Confirm the top-priority initiative and move to execution.");
repairs++;
}
return repairs;
}
private static int EnsureKpiContent(JsonObject slide)
{
if (slide.TryGetPropertyValue("kpis", out var kpisNode) && kpisNode is JsonArray kpis && kpis.Count > 0)
return 0;
slide["kpis"] = new JsonArray
{
new JsonObject { ["label"] = "Impact", ["value"] = "High", ["trend"] = "Near term", ["note"] = "Primary KPI" },
new JsonObject { ["label"] = "Risk", ["value"] = "Managed", ["trend"] = "Tracked", ["note"] = "Watch item" },
new JsonObject { ["label"] = "Owner", ["value"] = "Confirmed", ["trend"] = "Ready", ["note"] = "Execution aligned" }
};
return 1;
}
private static bool TrimLongHeadline(JsonObject slide)
{
var headline = ReadString(slide, "headline");
if (string.IsNullOrWhiteSpace(headline) || headline.Length <= 88)
return false;
slide["headline"] = headline[..88].TrimEnd() + "...";
return true;
}
private static bool HasChartData(JsonObject slide) private static bool HasChartData(JsonObject slide)
{ {
return slide.TryGetPropertyValue("chart_labels", out var labelsNode) && return slide.TryGetPropertyValue("chart_labels", out var labelsNode) &&

View File

@@ -29,6 +29,9 @@ public sealed record DeckQualityReport(
var parts = new List<string> { $"PPT quality {Score}/100" }; var parts = new List<string> { $"PPT quality {Score}/100" };
if (strengths.Count > 0) if (strengths.Count > 0)
parts.Add("Strengths: " + string.Join(", ", strengths)); parts.Add("Strengths: " + string.Join(", ", strengths));
var slideAlertCount = Issues.Count(issue => issue.Message.StartsWith("Slide ", StringComparison.OrdinalIgnoreCase));
if (slideAlertCount > 0)
parts.Add($"Slide alerts: {slideAlertCount}");
if (issues.Count > 0) if (issues.Count > 0)
parts.Add("Needs work: " + string.Join(", ", issues)); parts.Add("Needs work: " + string.Join(", ", issues));
else else
@@ -63,12 +66,14 @@ public static class DeckQualityReviewService
var tableSlides = 0; var tableSlides = 0;
var comparisonSlides = 0; var comparisonSlides = 0;
var textHeavySlides = 0; var textHeavySlides = 0;
var slideAlertCount = 0;
var duplicateHeadlines = 0; var duplicateHeadlines = 0;
var placeholderCount = 0; var placeholderCount = 0;
var headlines = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var headlines = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var layoutCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase); var layoutCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var slide in slidesList) for (var slideIndex = 0; slideIndex < slidesList.Count; slideIndex++)
{ {
var slide = slidesList[slideIndex];
var layout = ReadString(slide, "layout", "content"); var layout = ReadString(slide, "layout", "content");
var normalizedLayout = layout.ToLowerInvariant(); var normalizedLayout = layout.ToLowerInvariant();
layoutCounts[normalizedLayout] = layoutCounts.GetValueOrDefault(normalizedLayout) + 1; layoutCounts[normalizedLayout] = layoutCounts.GetValueOrDefault(normalizedLayout) + 1;
@@ -118,6 +123,7 @@ public static class DeckQualityReviewService
ReadString(slide, "right").Length; ReadString(slide, "right").Length;
if (bulletCount >= 7 || textLength >= 420) if (bulletCount >= 7 || textLength >= 420)
textHeavySlides++; textHeavySlides++;
slideAlertCount += AddSlideSpecificIssues(issues, slide, slideIndex + 1, normalizedLayout, headline, bulletCount, textLength);
placeholderCount += CountPlaceholders(slide.GetRawText()); placeholderCount += CountPlaceholders(slide.GetRawText());
} }
if (hasTemplate) strengths.Add("Uses template or branded theme"); if (hasTemplate) strengths.Add("Uses template or branded theme");
@@ -131,6 +137,8 @@ public static class DeckQualityReviewService
strengths.Add($"Uses {layoutCounts.Count} distinct layouts"); strengths.Add($"Uses {layoutCounts.Count} distinct layouts");
if (autoRepairCount > 0) if (autoRepairCount > 0)
strengths.Add($"Auto-repair applied {autoRepairCount} time(s)"); strengths.Add($"Auto-repair applied {autoRepairCount} time(s)");
if (slideAlertCount == 0)
strengths.Add("Slides pass quality gate checks");
if (slideCount < 4) if (slideCount < 4)
issues.Add(new("Slide count may be too low for an executive deck.", DeckReviewSeverity.Warning)); issues.Add(new("Slide count may be too low for an executive deck.", DeckReviewSeverity.Warning));
if (titleSlides == 0) if (titleSlides == 0)
@@ -162,6 +170,77 @@ public static class DeckQualityReviewService
score = Math.Clamp(score, 30, 98); score = Math.Clamp(score, 30, 98);
return new DeckQualityReport(score, strengths, issues, storylineSteps); return new DeckQualityReport(score, strengths, issues, storylineSteps);
} }
private static int AddSlideSpecificIssues(
List<DeckReviewIssue> issues,
JsonElement slide,
int slideNumber,
string normalizedLayout,
string headline,
int bulletCount,
int textLength)
{
var added = 0;
if (!string.IsNullOrWhiteSpace(headline) && headline.Length > 90)
{
issues.Add(new($"Slide {slideNumber}: headline is too long and may blur the core message.", DeckReviewSeverity.Info));
added++;
}
if (bulletCount >= 8 || textLength >= 520)
{
issues.Add(new($"Slide {slideNumber}: content density is high and should be simplified.", DeckReviewSeverity.Warning));
added++;
}
switch (normalizedLayout)
{
case "executive_summary":
if (ReadStringList(slide, "summary_points").Count < 2)
{
issues.Add(new($"Slide {slideNumber}: Executive Summary needs more supporting points.", DeckReviewSeverity.Warning));
added++;
}
break;
case "recommendation":
if (string.IsNullOrWhiteSpace(ReadString(slide, "recommendation")))
{
issues.Add(new($"Slide {slideNumber}: recommendation text is missing.", DeckReviewSeverity.Warning));
added++;
}
break;
case "comparison":
if (ReadJsonArrayCount(slide, "options") < 2)
{
issues.Add(new($"Slide {slideNumber}: comparison slide needs at least two options.", DeckReviewSeverity.Warning));
added++;
}
break;
case "roadmap":
if (ReadJsonArrayCount(slide, "phases") == 0)
{
issues.Add(new($"Slide {slideNumber}: roadmap slide is missing phases.", DeckReviewSeverity.Warning));
added++;
}
break;
case "chart":
if (ReadJsonArrayCount(slide, "chart_labels") == 0 || ReadJsonArrayCount(slide, "chart_values") == 0)
{
issues.Add(new($"Slide {slideNumber}: chart slide is missing chart data.", DeckReviewSeverity.Critical));
added++;
}
break;
case "table":
if (ReadJsonArrayCount(slide, "headers") == 0 || ReadJsonArrayCount(slide, "rows") == 0)
{
issues.Add(new($"Slide {slideNumber}: table slide is missing headers or rows.", DeckReviewSeverity.Warning));
added++;
}
break;
}
return added;
}
private static string ReadString(JsonElement element, string propertyName, string fallback = "") private static string ReadString(JsonElement element, string propertyName, string fallback = "")
{ {
if (!element.SafeTryGetProperty(propertyName, out var value)) if (!element.SafeTryGetProperty(propertyName, out var value))
@@ -199,6 +278,13 @@ public static class DeckQualityReviewService
}; };
} }
private static int ReadJsonArrayCount(JsonElement element, string propertyName)
{
return element.SafeTryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.Array
? value.GetArrayLength()
: 0;
}
private static int CountPlaceholders(string text) private static int CountPlaceholders(string text)
{ {
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text))

View File

@@ -309,7 +309,7 @@ public class ExcelSkill : IAgentTool
true, true,
HasSummaryItems(summarySheet, "highlights"), HasSummaryItems(summarySheet, "highlights"),
HasSummaryItems(summarySheet, "actions"), HasSummaryItems(summarySheet, "actions"),
HasSummaryItems(summarySheet, "scorecards") || HasSummaryItems(summarySheet, "cards") || HasSummaryItems(summarySheet, "kpis"), HasSummaryItems(summarySheet, "scorecards") || HasSummaryItems(summarySheet, "cards") || HasSummaryItems(summarySheet, "kpis") || HasSummaryItems(summarySheet, "trend_series"),
HasStructuredSummaryContent(summarySheet, "decision_summary"), HasStructuredSummaryContent(summarySheet, "decision_summary"),
HasSummaryItems(summarySheet, "sheet_summaries"))); HasSummaryItems(summarySheet, "sheet_summaries")));
features += $"\n{review.ToToolSummary()}"; features += $"\n{review.ToToolSummary()}";
@@ -435,7 +435,7 @@ public class ExcelSkill : IAgentTool
summarySheet.ValueKind == JsonValueKind.Object, summarySheet.ValueKind == JsonValueKind.Object,
HasSummaryItems(summarySheet, "highlights"), HasSummaryItems(summarySheet, "highlights"),
HasSummaryItems(summarySheet, "actions"), HasSummaryItems(summarySheet, "actions"),
HasSummaryItems(summarySheet, "scorecards") || HasSummaryItems(summarySheet, "cards") || HasSummaryItems(summarySheet, "kpis"), HasSummaryItems(summarySheet, "scorecards") || HasSummaryItems(summarySheet, "cards") || HasSummaryItems(summarySheet, "kpis") || HasSummaryItems(summarySheet, "trend_series"),
HasStructuredSummaryContent(summarySheet, "decision_summary"), HasStructuredSummaryContent(summarySheet, "decision_summary"),
HasSummaryItems(summarySheet, "sheet_summaries"))); HasSummaryItems(summarySheet, "sheet_summaries")));
return ToolResult.Ok( return ToolResult.Ok(
@@ -482,6 +482,7 @@ public class ExcelSkill : IAgentTool
AppendDecisionSummarySection(summarySheet, sheetData, merges, ref rowIndex); AppendDecisionSummarySection(summarySheet, sheetData, merges, ref rowIndex);
AppendScorecardSection(summarySheet, sheetData, ref rowIndex); AppendScorecardSection(summarySheet, sheetData, ref rowIndex);
AppendTrendSection(summarySheet, sheetData, ref rowIndex);
if (summarySheet.SafeTryGetProperty("kpis", out var kpis) && kpis.ValueKind == JsonValueKind.Array && kpis.GetArrayLength() > 0) if (summarySheet.SafeTryGetProperty("kpis", out var kpis) && kpis.ValueKind == JsonValueKind.Array && kpis.GetArrayLength() > 0)
{ {
@@ -652,6 +653,38 @@ public class ExcelSkill : IAgentTool
rowIndex++; rowIndex++;
} }
private static void AppendTrendSection(JsonElement summarySheet, SheetData sheetData, ref uint rowIndex)
{
if (!summarySheet.SafeTryGetProperty("trend_series", out var trendSeries)
|| trendSeries.ValueKind != JsonValueKind.Array
|| trendSeries.GetArrayLength() == 0)
return;
var headerRow = new Row { RowIndex = rowIndex++ };
headerRow.Append(CreateSummaryCell("A", headerRow.RowIndex!.Value, "Trend Dashboard", 1));
headerRow.Append(CreateSummaryCell("B", headerRow.RowIndex!.Value, "Current", 1));
headerRow.Append(CreateSummaryCell("C", headerRow.RowIndex!.Value, "Target", 1));
headerRow.Append(CreateSummaryCell("D", headerRow.RowIndex!.Value, "Delta", 1));
headerRow.Append(CreateSummaryCell("E", headerRow.RowIndex!.Value, "Status", 1));
sheetData.Append(headerRow);
var trendIndex = 0;
foreach (var trend in trendSeries.EnumerateArray())
{
var row = new Row { RowIndex = rowIndex++ };
var stripeStyle = trendIndex % 2 == 0 ? (uint)0 : (uint)2;
row.Append(CreateSummaryCell("A", row.RowIndex!.Value, trend.SafeTryGetProperty("label", out var labelEl) ? labelEl.SafeGetString() ?? "" : "", 1));
row.Append(CreateSummaryCell("B", row.RowIndex!.Value, trend.SafeTryGetProperty("current", out var currentEl) ? currentEl.SafeGetString() ?? currentEl.ToString() : "", 3));
row.Append(CreateSummaryCell("C", row.RowIndex!.Value, trend.SafeTryGetProperty("target", out var targetEl) ? targetEl.SafeGetString() ?? targetEl.ToString() : "", stripeStyle));
row.Append(CreateSummaryCell("D", row.RowIndex!.Value, trend.SafeTryGetProperty("delta", out var deltaEl) ? deltaEl.SafeGetString() ?? deltaEl.ToString() : "", stripeStyle));
row.Append(CreateSummaryCell("E", row.RowIndex!.Value, trend.SafeTryGetProperty("status", out var statusEl) ? statusEl.SafeGetString() ?? "" : "", stripeStyle));
sheetData.Append(row);
trendIndex++;
}
rowIndex++;
}
private static void AppendSummaryTextSection(JsonElement summarySheet, string key, string heading, SheetData sheetData, MergeCells merges, ref uint rowIndex) private static void AppendSummaryTextSection(JsonElement summarySheet, string key, string heading, SheetData sheetData, MergeCells merges, ref uint rowIndex)
{ {
if (!summarySheet.SafeTryGetProperty(key, out var items) || items.ValueKind != JsonValueKind.Array || items.GetArrayLength() == 0) if (!summarySheet.SafeTryGetProperty(key, out var items) || items.ValueKind != JsonValueKind.Array || items.GetArrayLength() == 0)

View File

@@ -133,10 +133,11 @@ public class HtmlSkill : IAgentTool
var useToc = hasTocArg && tocVal.ValueKind == JsonValueKind.True; var useToc = hasTocArg && tocVal.ValueKind == JsonValueKind.True;
var useNumbered = args.SafeTryGetProperty("numbered", out var numVal) && numVal.ValueKind == JsonValueKind.True; var useNumbered = args.SafeTryGetProperty("numbered", out var numVal) && numVal.ValueKind == JsonValueKind.True;
var usePrint = args.SafeTryGetProperty("print", out var printVal) && printVal.ValueKind == JsonValueKind.True; var usePrint = args.SafeTryGetProperty("print", out var printVal) && printVal.ValueKind == JsonValueKind.True;
var printHeader = args.SafeTryGetProperty("print_header", out var printHeaderEl) ? printHeaderEl.SafeGetString() : null; var requestedPrintHeader = args.SafeTryGetProperty("print_header", out var printHeaderEl) ? printHeaderEl.SafeGetString() : null;
var printFooter = args.SafeTryGetProperty("print_footer", out var printFooterEl) ? printFooterEl.SafeGetString() : null; var requestedPrintFooter = args.SafeTryGetProperty("print_footer", out var printFooterEl) ? printFooterEl.SafeGetString() : null;
var hasCover = args.SafeTryGetProperty("cover", out var coverVal) && coverVal.ValueKind == JsonValueKind.Object; var hasCover = args.SafeTryGetProperty("cover", out var coverVal) && coverVal.ValueKind == JsonValueKind.Object;
var accentColor = args.SafeTryGetProperty("accent_color", out var accentEl) ? accentEl.SafeGetString() : null; var accentColor = args.SafeTryGetProperty("accent_color", out var accentEl) ? accentEl.SafeGetString() : null;
var (printHeader, printFooter) = ResolvePrintFrame(title, usePrint, requestedPrintHeader, requestedPrintFooter, hasCover);
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder); var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath); if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
@@ -295,6 +296,8 @@ public class HtmlSkill : IAgentTool
if (hasSections) features.Add("구조화섹션"); if (hasSections) features.Add("구조화섹션");
if (!string.IsNullOrEmpty(accentColor)) features.Add($"색상:{accentColor}"); if (!string.IsNullOrEmpty(accentColor)) features.Add($"색상:{accentColor}");
if (usePrint) features.Add("인쇄최적화"); if (usePrint) features.Add("인쇄최적화");
if (!string.IsNullOrWhiteSpace(printHeader) || !string.IsNullOrWhiteSpace(printFooter))
features.Add("print-frame");
var featureStr = features.Count > 0 ? $" [{string.Join(", ", features)}]" : ""; var featureStr = features.Count > 0 ? $" [{string.Join(", ", features)}]" : "";
var review = ArtifactQualityReviewService.ReviewHtml( var review = ArtifactQualityReviewService.ReviewHtml(
title, title,
@@ -383,6 +386,27 @@ public class HtmlSkill : IAgentTool
return sb.ToString(); return sb.ToString();
} }
private static (string? Header, string? Footer) ResolvePrintFrame(
string title,
bool usePrint,
string? requestedHeader,
string? requestedFooter,
bool hasCover)
{
if (!usePrint)
return (requestedHeader, requestedFooter);
var header = requestedHeader;
var footer = requestedFooter;
if (string.IsNullOrWhiteSpace(header))
header = hasCover ? $"{title} | Executive Pack" : title;
if (string.IsNullOrWhiteSpace(footer))
footer = $"{DateTime.Now:yyyy-MM-dd} | AX Copilot";
return (header, footer);
}
private static string RenderHeading(JsonElement s) private static string RenderHeading(JsonElement s)
{ {
var level = s.SafeTryGetProperty("level", out var lv) ? Math.Clamp(lv.GetInt32(), 1, 4) : 2; var level = s.SafeTryGetProperty("level", out var lv) ? Math.Clamp(lv.GetInt32(), 1, 4) : 2;