From 3232db1b126df78ef8b86ea2c23495e929568872 Mon Sep 17 00:00:00 2001 From: lacvet Date: Tue, 14 Apr 2026 22:54:24 +0900 Subject: [PATCH] ?? ?? ?? ???? PPTX ??? ? ?? ?? ?? ?? ??? PPTX/DOCX/XLSX/HTML ???? ? ?????, PPTX? ???? ??? ? ???? ?? ???? ? ??? ?? ?? ???? ????. ?? ????: - ExcelSkill? conditional_formats? ??? ?? ???? ??? ? ?????? OpenXML? ?? ???? workbook quality review? ?? - DocxSkill? style_map? ??? ???? ??/??/?? ???? ?? ?? ParagraphStyleId? ?? - HtmlSkill? print_header/print_footer ?? ?? ???? ???? ArtifactQualityReviewService? ?? ?? ?? ???? ?? - PptxTemplatePackRegistry? PptxSkill template_pack ????? ??? strategy/board/pmo/finance/sales/operating_model ??? ??? ?? ?? ?? ??? ?? - ?????, ????, ?? ???, ??? ?? ?? ?? ???? ???? ?? ?? ?? ???? ???? ???? ?? ?? ??: - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_next_doc_ppt\\ -p:IntermediateOutputPath=obj\\verify_next_doc_ppt\\ => ?? 0 / ?? 0 - dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ArtifactQualityReviewServiceTests|ExcelSkillDataValidationTests|ExcelSkillConditionalFormattingTests|ExcelSkillExecutiveSummaryLinkTests|ExcelSkillSummarySheetTests|DocxSkillTemplateFeaturesTests|DocxSkillStyleMapTests|HtmlSkillConsultingSectionsTests|HtmlSkillPrintFrameTests|DocumentAssemblerDocxFeaturesTests|PptxSkillConsultingDeckTests|PptxSkillAutoRepairTests|PptxSkillTemplatePackTests" -p:OutputPath=bin\\verify_next_doc_ppt_tests\\ -p:IntermediateOutputPath=obj\\verify_next_doc_ppt_tests\\ => ?? 15 --- README.md | 10 ++ docs/DEVELOPMENT.md | 9 ++ .../ArtifactQualityReviewServiceTests.cs | 13 +- .../Services/DocxSkillStyleMapTests.cs | 106 +++++++++++++ .../ExcelSkillConditionalFormattingTests.cs | 78 ++++++++++ .../Services/ExcelSkillDataValidationTests.cs | 2 +- .../Services/HtmlSkillPrintFrameTests.cs | 62 ++++++++ .../Services/PptxSkillTemplatePackTests.cs | 66 ++++++++ .../Agent/ArtifactQualityReviewService.cs | 144 +++++++++++------- src/AxCopilot/Services/Agent/DocxSkill.cs | 90 ++++++++--- src/AxCopilot/Services/Agent/ExcelSkill.cs | 120 +++++++++++++-- src/AxCopilot/Services/Agent/HtmlSkill.cs | 36 ++++- src/AxCopilot/Services/Agent/PptxSkill.cs | 58 ++++++- .../Agent/PptxTemplatePackRegistry.cs | 93 +++++++++++ 14 files changed, 790 insertions(+), 97 deletions(-) create mode 100644 src/AxCopilot.Tests/Services/DocxSkillStyleMapTests.cs create mode 100644 src/AxCopilot.Tests/Services/ExcelSkillConditionalFormattingTests.cs create mode 100644 src/AxCopilot.Tests/Services/HtmlSkillPrintFrameTests.cs create mode 100644 src/AxCopilot.Tests/Services/PptxSkillTemplatePackTests.cs create mode 100644 src/AxCopilot/Services/Agent/PptxTemplatePackRegistry.cs diff --git a/README.md b/README.md index cc53b08..5735b5c 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,16 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저 개발 참고: Claw Code 동등성 작업 추적 문서 `docs/claw-code-parity-plan.md` +- 업데이트: 2026-04-14 21:25 (KST) +- 업데이트: 2026-04-14 22:52 (KST) +- 문서 포맷 고도화를 한 단계 더 진행했습니다. [ExcelSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ExcelSkill.cs)에 `conditional_formats`를 추가해 색상 스케일과 데이터 바 조건부서식을 네이티브 OpenXML로 생성하고, 품질 리뷰에도 반영되도록 했습니다. +- [DocxSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/DocxSkill.cs)는 `style_map`을 지원해 템플릿에 정의된 제목/헤딩/본문 스타일을 실제 문단에 매핑할 수 있게 했습니다. 이제 사내 Word 템플릿을 사용할 때 스타일 상속 품질이 더 높아집니다. +- [HtmlSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/HtmlSkill.cs)는 `print_header`, `print_footer`를 받아 인쇄용 헤더/푸터 프레임을 추가하고, [ArtifactQualityReviewService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs)는 HTML/DOCX/XLSX 산출물의 품질 요약을 안정적인 공통 형식으로 반환하도록 정리했습니다. +- PPTX도 계속 고도화할 수 있게 확장 포인트를 넣었습니다. [PptxTemplatePackRegistry.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PptxTemplatePackRegistry.cs)를 추가해 `strategy`, `board`, `pmo`, `finance`, `sales`, `operating_model` 템플릿 팩을 정의했고, [PptxSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PptxSkill.cs)는 `template_pack` 파라미터와 objective/audience 기반 자동 pack suggestion을 지원합니다. +- 테스트: [ExcelSkillConditionalFormattingTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/ExcelSkillConditionalFormattingTests.cs), [DocxSkillStyleMapTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/DocxSkillStyleMapTests.cs), [HtmlSkillPrintFrameTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/HtmlSkillPrintFrameTests.cs), [PptxSkillTemplatePackTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/PptxSkillTemplatePackTests.cs) 추가 +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_next_doc_ppt\\ -p:IntermediateOutputPath=obj\\verify_next_doc_ppt\\` 경고 0 / 오류 0 +- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ArtifactQualityReviewServiceTests|ExcelSkillDataValidationTests|ExcelSkillConditionalFormattingTests|ExcelSkillExecutiveSummaryLinkTests|ExcelSkillSummarySheetTests|DocxSkillTemplateFeaturesTests|DocxSkillStyleMapTests|HtmlSkillConsultingSectionsTests|HtmlSkillPrintFrameTests|DocumentAssemblerDocxFeaturesTests|PptxSkillConsultingDeckTests|PptxSkillAutoRepairTests|PptxSkillTemplatePackTests" -p:OutputPath=bin\\verify_next_doc_ppt_tests\\ -p:IntermediateOutputPath=obj\\verify_next_doc_ppt_tests\\` 통과 15 + - 업데이트: 2026-04-14 21:25 (KST) - 문서 생성 고도화 2차를 반영했습니다. [ArtifactQualityReviewService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs)를 추가해 HTML/DOCX/XLSX 결과물에 공통 품질 점수와 보완 포인트를 부여하고, [HtmlSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/HtmlSkill.cs), [DocxSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/DocxSkill.cs), [ExcelSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ExcelSkill.cs)에 연결했습니다. - Word 쪽은 템플릿·커버·목차를 강화했습니다. `docx_create`는 `template_path`, `cover_subtitle`, `cover_meta`, `toc`를 받아 템플릿 기반 문서와 커버 페이지, TOC 필드를 함께 만들 수 있게 되었고, 결과 요약에도 문서 품질 리뷰가 같이 남습니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 84c3eed..10b2ec7 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -8,6 +8,15 @@ - 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_doc_phase2\\ -p:IntermediateOutputPath=obj\\verify_doc_phase2\\` 경고 0 / 오류 0 - 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ArtifactQualityReviewServiceTests|DocxSkillTemplateFeaturesTests|ExcelSkillExecutiveSummaryLinkTests|DocumentAssemblerSemanticTests|DocumentPlannerBusinessDocumentTests|HtmlSkillConsultingSectionsTests|ExcelSkillSummarySheetTests" -p:OutputPath=bin\\verify_doc_phase2_tests\\ -p:IntermediateOutputPath=obj\\verify_doc_phase2_tests\\` 통과 9 +업데이트: 2026-04-14 22:52 (KST) +- 문서 포맷 고도화와 PPTX 확장 포인트를 함께 반영했습니다. `src/AxCopilot/Services/Agent/ExcelSkill.cs`는 `conditional_formats`를 지원해 색상 스케일과 데이터 바 조건부서식을 OpenXML로 직접 생성하고, workbook quality review에도 조건부서식 개수를 반영합니다. +- `src/AxCopilot/Services/Agent/DocxSkill.cs`는 `style_map`을 지원하도록 확장했습니다. 템플릿에 정의된 제목/헤딩/본문 스타일을 실제 문단의 `ParagraphStyleId`로 연결해 사내 템플릿 기반 문서 품질을 높였습니다. +- `src/AxCopilot/Services/Agent/HtmlSkill.cs`는 `print_header`, `print_footer`를 받아 인쇄용 헤더/푸터 프레임을 렌더링하고, `src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs`는 HTML/DOCX/XLSX 품질 리뷰 문자열과 판정 로직을 공통 형식으로 정리했습니다. +- PPTX는 `src/AxCopilot/Services/Agent/PptxTemplatePackRegistry.cs`를 새로 추가해 `strategy`, `board`, `pmo`, `finance`, `sales`, `operating_model` 템플릿 팩을 정의했습니다. `src/AxCopilot/Services/Agent/PptxSkill.cs`는 `template_pack` 파라미터와 objective/audience 기반 pack suggestion을 받아 이후 목적형 deck 고도화를 이어가기 쉬운 구조가 됐습니다. +- 테스트: `ArtifactQualityReviewServiceTests`, `ExcelSkillDataValidationTests`, `ExcelSkillConditionalFormattingTests`, `DocxSkillStyleMapTests`, `HtmlSkillPrintFrameTests`, `PptxSkillTemplatePackTests` +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_next_doc_ppt\\ -p:IntermediateOutputPath=obj\\verify_next_doc_ppt\\` 경고 0 / 오류 0 +- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ArtifactQualityReviewServiceTests|ExcelSkillDataValidationTests|ExcelSkillConditionalFormattingTests|ExcelSkillExecutiveSummaryLinkTests|ExcelSkillSummarySheetTests|DocxSkillTemplateFeaturesTests|DocxSkillStyleMapTests|HtmlSkillConsultingSectionsTests|HtmlSkillPrintFrameTests|DocumentAssemblerDocxFeaturesTests|PptxSkillConsultingDeckTests|PptxSkillAutoRepairTests|PptxSkillTemplatePackTests" -p:OutputPath=bin\\verify_next_doc_ppt_tests\\ -p:IntermediateOutputPath=obj\\verify_next_doc_ppt_tests\\` 통과 15 + 업데이트: 2026-04-14 19:50 (KST) - Agent loop/queue/context 품질을 보강했습니다. `src/AxCopilot/Services/Agent/AgentCommandQueue.cs`로 실행 중 추가 입력을 우선순위와 interrupt 여부까지 포함해 관리하고, `AgentLoopService`는 이를 안전하게 반영합니다. - `AgentToolResultBudget`, `AgentQueryContextBuilder`, `ChatModels`는 tool result preview를 메시지에 캐시해 긴 세션과 재질문에서도 같은 축약 결과를 재사용하도록 정리했습니다. diff --git a/src/AxCopilot.Tests/Services/ArtifactQualityReviewServiceTests.cs b/src/AxCopilot.Tests/Services/ArtifactQualityReviewServiceTests.cs index c9c99e6..2f70fc6 100644 --- a/src/AxCopilot.Tests/Services/ArtifactQualityReviewServiceTests.cs +++ b/src/AxCopilot.Tests/Services/ArtifactQualityReviewServiceTests.cs @@ -24,8 +24,10 @@ public class ArtifactQualityReviewServiceTests var review = ArtifactQualityReviewService.ReviewHtml("Board Report", html, hasCover: true, hasTableOfContents: true, printReady: true); review.Score.Should().BeGreaterThan(75); - review.Strengths.Should().Contain(s => s.Contains("커버") || s.Contains("목차")); - review.Issues.Should().NotContain(i => i.Severity == ArtifactReviewSeverity.Critical); + review.Strengths.Should().Contain(strength => + strength.Contains("cover", StringComparison.OrdinalIgnoreCase) || + strength.Contains("contents", StringComparison.OrdinalIgnoreCase)); + review.Issues.Should().NotContain(issue => issue.Severity == ArtifactReviewSeverity.Critical); } [Fact] @@ -48,8 +50,8 @@ public class ArtifactQualityReviewServiceTests false, false)); - review.Issues.Should().Contain(i => i.Message.Contains("Executive Summary")); - review.Issues.Should().Contain(i => i.Message.Contains("권고안")); + review.Issues.Should().Contain(issue => issue.Message.Contains("Executive Summary", StringComparison.OrdinalIgnoreCase)); + review.Issues.Should().Contain(issue => issue.Message.Contains("Recommendation", StringComparison.OrdinalIgnoreCase)); } [Fact] @@ -63,11 +65,12 @@ public class ArtifactQualityReviewServiceTests 2, 0, 0, + 0, false, false, false)); - review.Issues.Should().Contain(i => i.Message.Contains("요약 시트")); + review.Issues.Should().Contain(issue => issue.Message.Contains("summary sheet", StringComparison.OrdinalIgnoreCase)); review.Score.Should().BeLessThan(80); } } diff --git a/src/AxCopilot.Tests/Services/DocxSkillStyleMapTests.cs b/src/AxCopilot.Tests/Services/DocxSkillStyleMapTests.cs new file mode 100644 index 0000000..a15116d --- /dev/null +++ b/src/AxCopilot.Tests/Services/DocxSkillStyleMapTests.cs @@ -0,0 +1,106 @@ +using System.IO; +using System.Text.Json; +using AxCopilot.Services.Agent; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; +using FluentAssertions; +using Xunit; + +namespace AxCopilot.Tests.Services; + +public class DocxSkillStyleMapTests +{ + [Fact] + public async Task ExecuteAsync_WithStyleMap_ShouldApplyTemplateParagraphStyles() + { + var workDir = Path.Combine(Path.GetTempPath(), "ax-docx-stylemap-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workDir); + + try + { + var templatePath = Path.Combine(workDir, "style-template.docx"); + using (var template = WordprocessingDocument.Create(templatePath, DocumentFormat.OpenXml.WordprocessingDocumentType.Document)) + { + var mainPart = template.AddMainDocumentPart(); + mainPart.Document = new Document(new Body(new Paragraph(new Run(new Text("Template Root"))))); + + var stylePart = mainPart.AddNewPart(); + stylePart.Styles = new Styles( + CreateParagraphStyle("CustomTitle"), + CreateParagraphStyle("CustomHeading1"), + CreateParagraphStyle("CustomHeading2"), + CreateParagraphStyle("CustomBody")); + stylePart.Styles.Save(); + + mainPart.Document.Save(); + } + + var tool = new DocxSkill(); + var context = new AgentContext + { + WorkFolder = workDir, + Permission = "Auto", + OperationMode = "external", + }; + + var args = JsonDocument.Parse( + """ + { + "path": "styled-brief.docx", + "title": "Styled Brief", + "template_path": "style-template.docx", + "style_map": { + "title": "CustomTitle", + "heading1": "CustomHeading1", + "heading2": "CustomHeading2", + "body": "CustomBody" + }, + "sections": [ + { "heading": "Executive Summary", "body": "Body line one.\nBody line two.", "level": 1 }, + { "heading": "Detail", "body": "Detail text.", "level": 2 } + ] + } + """).RootElement; + + var result = await tool.ExecuteAsync(args, context, CancellationToken.None); + + result.Success.Should().BeTrue(); + var outputPath = Path.Combine(workDir, "styled-brief.docx"); + File.Exists(outputPath).Should().BeTrue(); + + using var doc = WordprocessingDocument.Open(outputPath, false); + var paragraphs = doc.MainDocumentPart!.Document.Body!.Descendants().ToList(); + + paragraphs.First(paragraph => paragraph.InnerText == "Styled Brief") + .ParagraphProperties!.ParagraphStyleId!.Val!.Value.Should().Be("CustomTitle"); + paragraphs.First(paragraph => paragraph.InnerText == "Executive Summary") + .ParagraphProperties!.ParagraphStyleId!.Val!.Value.Should().Be("CustomHeading1"); + paragraphs.First(paragraph => paragraph.InnerText == "Detail") + .ParagraphProperties!.ParagraphStyleId!.Val!.Value.Should().Be("CustomHeading2"); + paragraphs.First(paragraph => paragraph.InnerText == "Body line one.") + .ParagraphProperties!.ParagraphStyleId!.Val!.Value.Should().Be("CustomBody"); + } + finally + { + try + { + if (Directory.Exists(workDir)) + Directory.Delete(workDir, true); + } + catch + { + } + } + } + + private static Style CreateParagraphStyle(string styleId) + { + return new Style + { + Type = StyleValues.Paragraph, + StyleId = styleId, + CustomStyle = true, + StyleName = new StyleName { Val = styleId }, + }; + } +} diff --git a/src/AxCopilot.Tests/Services/ExcelSkillConditionalFormattingTests.cs b/src/AxCopilot.Tests/Services/ExcelSkillConditionalFormattingTests.cs new file mode 100644 index 0000000..4d6f2f8 --- /dev/null +++ b/src/AxCopilot.Tests/Services/ExcelSkillConditionalFormattingTests.cs @@ -0,0 +1,78 @@ +using System.IO; +using System.Text.Json; +using AxCopilot.Services.Agent; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Spreadsheet; +using FluentAssertions; +using Xunit; + +namespace AxCopilot.Tests.Services; + +public class ExcelSkillConditionalFormattingTests +{ + [Fact] + public async Task ExecuteAsync_WithConditionalFormats_ShouldPersistFormattingRules() + { + var workDir = Path.Combine(Path.GetTempPath(), "ax-xlsx-conditional-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workDir); + + try + { + var tool = new ExcelSkill(); + var context = new AgentContext + { + WorkFolder = workDir, + Permission = "Auto", + OperationMode = "external", + }; + + var args = JsonDocument.Parse( + """ + { + "path": "conditional.xlsx", + "sheet_name": "Tracker", + "headers": ["Metric", "Score", "Trend"], + "rows": [ + ["Margin", 42, 12], + ["Retention", 61, 8], + ["Cycle Time", 19, -3] + ], + "conditional_formats": [ + { "range": "B2:B10", "type": "color_scale", "low": "FEE2E2", "mid": "FEF3C7", "high": "DCFCE7" }, + { "range": "C2:C10", "type": "data_bar", "color": "2563EB" } + ] + } + """).RootElement; + + var result = await tool.ExecuteAsync(args, context, CancellationToken.None); + + result.Success.Should().BeTrue(); + result.Output.Should().Contain("Quality score"); + + var outputPath = Path.Combine(workDir, "conditional.xlsx"); + File.Exists(outputPath).Should().BeTrue(); + + using var doc = SpreadsheetDocument.Open(outputPath, false); + var firstSheet = doc.WorkbookPart!.Workbook.Sheets!.Elements().First(); + var worksheetPart = (WorksheetPart)doc.WorkbookPart.GetPartById(firstSheet.Id!); + + worksheetPart.Worksheet.Elements().Count().Should().Be(2); + var ruleTypes = worksheetPart.Worksheet.Descendants() + .Select(rule => rule.Type?.Value) + .ToList(); + ruleTypes.Should().Contain(ConditionalFormatValues.ColorScale); + ruleTypes.Should().Contain(ConditionalFormatValues.DataBar); + } + finally + { + try + { + if (Directory.Exists(workDir)) + Directory.Delete(workDir, true); + } + catch + { + } + } + } +} diff --git a/src/AxCopilot.Tests/Services/ExcelSkillDataValidationTests.cs b/src/AxCopilot.Tests/Services/ExcelSkillDataValidationTests.cs index 2c0927e..ec7b8dc 100644 --- a/src/AxCopilot.Tests/Services/ExcelSkillDataValidationTests.cs +++ b/src/AxCopilot.Tests/Services/ExcelSkillDataValidationTests.cs @@ -51,7 +51,7 @@ public class ExcelSkillDataValidationTests var result = await tool.ExecuteAsync(args, context, CancellationToken.None); result.Success.Should().BeTrue(); - result.Output.Should().Contain("점수"); + result.Output.Should().Contain("Quality score"); var outputPath = Path.Combine(workDir, "validated.xlsx"); File.Exists(outputPath).Should().BeTrue(); diff --git a/src/AxCopilot.Tests/Services/HtmlSkillPrintFrameTests.cs b/src/AxCopilot.Tests/Services/HtmlSkillPrintFrameTests.cs new file mode 100644 index 0000000..65164f2 --- /dev/null +++ b/src/AxCopilot.Tests/Services/HtmlSkillPrintFrameTests.cs @@ -0,0 +1,62 @@ +using System.IO; +using System.Text.Json; +using AxCopilot.Services.Agent; +using FluentAssertions; +using Xunit; + +namespace AxCopilot.Tests.Services; + +public class HtmlSkillPrintFrameTests +{ + [Fact] + public async Task ExecuteAsync_WithPrintHeaderAndFooter_ShouldRenderPrintFrame() + { + var workDir = Path.Combine(Path.GetTempPath(), "ax-html-printframe-" + 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": "board-report.html", + "title": "Board Report", + "print": true, + "print_header": "Board Review", + "print_footer": "Confidential", + "body": "

Executive Summary

Summary text with enough detail to qualify as a printable executive report.

Recommendation

Approve phase 1.

Appendix

Evidence notes.

" + } + """).RootElement; + + var result = await tool.ExecuteAsync(args, context, CancellationToken.None); + + result.Success.Should().BeTrue(); + result.Output.Should().Contain("Quality score"); + + var html = File.ReadAllText(Path.Combine(workDir, "board-report.html")); + html.Should().Contain("print-header"); + html.Should().Contain("print-footer"); + html.Should().Contain("Board Review"); + html.Should().Contain("Confidential"); + } + finally + { + try + { + if (Directory.Exists(workDir)) + Directory.Delete(workDir, true); + } + catch + { + } + } + } +} diff --git a/src/AxCopilot.Tests/Services/PptxSkillTemplatePackTests.cs b/src/AxCopilot.Tests/Services/PptxSkillTemplatePackTests.cs new file mode 100644 index 0000000..a719d62 --- /dev/null +++ b/src/AxCopilot.Tests/Services/PptxSkillTemplatePackTests.cs @@ -0,0 +1,66 @@ +using System.IO; +using System.Text.Json; +using AxCopilot.Services.Agent; +using FluentAssertions; +using Xunit; + +namespace AxCopilot.Tests.Services; + +public class PptxSkillTemplatePackTests +{ + [Fact] + public async Task ExecuteAsync_WithTemplatePack_ShouldReportSelectedPack() + { + var workDir = Path.Combine(Path.GetTempPath(), "ax-pptx-pack-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workDir); + + try + { + var context = new AgentContext + { + WorkFolder = workDir, + Permission = "Auto", + OperationMode = "external", + }; + + var tool = new PptxSkill(); + var args = JsonDocument.Parse( + """ + { + "path": "board-pack-deck.pptx", + "title": "Board Update", + "template_pack": "board", + "slides": [ + { + "layout": "content", + "title": "Current State", + "body": "Margin improved\nDelivery risk is stable\nWorking capital is within plan" + }, + { + "layout": "recommendation", + "title": "Recommendation", + "recommendation": "Approve the next operating phase" + } + ] + } + """).RootElement; + + var result = await tool.ExecuteAsync(args, context, CancellationToken.None); + + result.Success.Should().BeTrue(); + result.Output.Should().Contain("Template pack: board"); + File.Exists(Path.Combine(workDir, "board-pack-deck.pptx")).Should().BeTrue(); + } + finally + { + try + { + if (Directory.Exists(workDir)) + Directory.Delete(workDir, true); + } + catch + { + } + } + } +} diff --git a/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs b/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs index 7d180a5..36a3be3 100644 --- a/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs +++ b/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs @@ -20,15 +20,19 @@ public sealed record ArtifactQualityReport( public string ToToolSummary() { var strengths = Strengths.Take(3).ToList(); - var issues = Issues.OrderByDescending(i => i.Severity).Take(3).Select(i => i.Message).ToList(); + var issues = Issues + .OrderByDescending(issue => issue.Severity) + .Take(3) + .Select(issue => issue.Message) + .ToList(); - var parts = new List { $"품질 점수 {Score}/100" }; + var parts = new List { $"Quality score {Score}/100" }; if (strengths.Count > 0) - parts.Add("강점: " + string.Join(", ", strengths)); + parts.Add("Strengths: " + string.Join(", ", strengths)); if (issues.Count > 0) - parts.Add("보완: " + string.Join(", ", issues)); + parts.Add("Needs work: " + string.Join(", ", issues)); else - parts.Add("보완 필요 사항 없음"); + parts.Add("Needs work: none"); return string.Join(" | ", parts); } @@ -59,17 +63,48 @@ public sealed record WorkbookReviewInput( int FormulaCount, int HyperlinkCount, int DataValidationCount, + int ConditionalFormattingCount, bool HasSummarySheet, bool HasHighlightSection, bool HasActionSection); public static class ArtifactQualityReviewService { - private static readonly string[] ExecutiveKeywords = ["executive summary", "요약", "핵심 요약", "summary"]; - private static readonly string[] RecommendationKeywords = ["recommendation", "권고", "제안", "next steps", "실행 과제"]; - private static readonly string[] AppendixKeywords = ["appendix", "부록", "참고", "reference"]; + private static readonly string[] ExecutiveKeywords = + [ + "executive summary", + "summary", + "\uC694\uC57D", + "\uD575\uC2EC \uC694\uC57D", + ]; - public static ArtifactQualityReport ReviewHtml(string title, string html, bool hasCover, bool hasTableOfContents, bool printReady) + private static readonly string[] RecommendationKeywords = + [ + "recommendation", + "proposal", + "next steps", + "action plan", + "\uAD8C\uACE0", + "\uC81C\uC548", + "\uC2E4\uD589", + ]; + + private static readonly string[] AppendixKeywords = + [ + "appendix", + "reference", + "supplement", + "\uBD80\uB85D", + "\uCC38\uACE0", + ]; + + public static ArtifactQualityReport ReviewHtml( + string title, + string html, + bool hasCover, + bool hasTableOfContents, + bool printReady, + bool hasPrintFrame = false) { title ??= string.Empty; html ??= string.Empty; @@ -78,7 +113,6 @@ public static class ArtifactQualityReviewService var issues = new List(); var sectionCount = Regex.Matches(html, @"= 5) strengths.Add($"핵심 섹션 {sectionCount}개 구성"); + if (hasCover) strengths.Add("Includes cover page"); + if (hasTableOfContents) strengths.Add("Includes table of contents"); + if (printReady) strengths.Add("Includes print-ready CSS"); + if (hasPrintFrame) strengths.Add("Includes print header/footer frame"); + if (sectionCount >= 5) strengths.Add($"Contains {sectionCount} major sections"); if (tableCount > 0 || comparisonCount > 0 || roadmapCount > 0 || matrixCount > 0 || decisionCount > 0 || evidenceCardCount > 0 || kpiCount > 0) - strengths.Add("표/비교/로드맵/KPI 등 구조화 블록 활용"); - if (calloutCount > 0) strengths.Add("핵심 메시지 강조 블록 포함"); + strengths.Add("Uses structured business blocks"); + if (calloutCount > 0) strengths.Add("Uses callout blocks for emphasis"); if (html.Length < 1800) - issues.Add(new("문서 본문이 짧아 메시지와 근거 밀도가 낮을 수 있습니다", ArtifactReviewSeverity.Warning)); + issues.Add(new("Body content may be too short for an executive-quality document.", ArtifactReviewSeverity.Warning)); if (sectionCount < 4) - issues.Add(new("업무 문서 기준 핵심 섹션 수가 부족합니다", ArtifactReviewSeverity.Warning)); + issues.Add(new("Major section count is low for a business report.", ArtifactReviewSeverity.Warning)); if (paragraphCount < sectionCount * 2) - issues.Add(new("일부 섹션의 서술이 충분하지 않습니다", ArtifactReviewSeverity.Warning)); + issues.Add(new("Several sections may need more supporting paragraphs.", ArtifactReviewSeverity.Warning)); if (tableCount + comparisonCount + roadmapCount + matrixCount + decisionCount + evidenceCardCount + kpiCount == 0) - issues.Add(new("구조화 시각 요소가 없어 보고서 완성도가 낮을 수 있습니다", ArtifactReviewSeverity.Warning)); + issues.Add(new("Structured visual blocks are limited.", ArtifactReviewSeverity.Warning)); if (placeholderCount > 0) - issues.Add(new($"플레이스홀더/미완성 표현 {placeholderCount}건이 남아 있습니다", ArtifactReviewSeverity.Critical)); + issues.Add(new($"Found {placeholderCount} placeholder or unfinished marker(s).", ArtifactReviewSeverity.Critical)); if (!ContainsAny(html, ExecutiveKeywords)) - issues.Add(new("요약 또는 핵심 메시지 섹션이 명확하지 않습니다", ArtifactReviewSeverity.Warning)); + issues.Add(new("Executive summary or summary section is missing.", ArtifactReviewSeverity.Warning)); return BuildReport("html", strengths, issues); } @@ -119,27 +154,27 @@ public static class ArtifactQualityReviewService var strengths = new List(); var issues = new List(); - if (input.HasTemplate) strengths.Add("템플릿 기반 스타일 상속"); - if (input.HasCoverPage) strengths.Add("커버 페이지 포함"); - if (input.HasTableOfContents) strengths.Add("목차 포함"); - if (input.HasHeaderFooter) strengths.Add("머리글/바닥글 적용"); - if (input.SectionCount >= 5) strengths.Add($"핵심 섹션 {input.SectionCount}개 구성"); - if (input.TableCount > 0) strengths.Add($"테이블 {input.TableCount}개 포함"); - if (input.CalloutCount + input.HighlightCount > 0) strengths.Add("강조 블록 활용"); - if (input.ListCount > 0) strengths.Add("실행 항목/목록 구조 포함"); + if (input.HasTemplate) strengths.Add("Uses template-based styling"); + if (input.HasCoverPage) strengths.Add("Includes cover page"); + if (input.HasTableOfContents) strengths.Add("Includes table of contents"); + if (input.HasHeaderFooter) strengths.Add("Includes header/footer"); + if (input.SectionCount >= 5) strengths.Add($"Contains {input.SectionCount} major sections"); + if (input.TableCount > 0) strengths.Add($"Includes {input.TableCount} table(s)"); + if (input.CalloutCount + input.HighlightCount > 0) strengths.Add("Uses emphasis blocks"); + if (input.ListCount > 0) strengths.Add("Uses structured list sections"); if (input.BodyCharacterCount < 1400) - issues.Add(new("문서 분량이 짧아 경영 보고용 밀도가 부족할 수 있습니다", ArtifactReviewSeverity.Warning)); + issues.Add(new("Body content may be too short for an executive document.", ArtifactReviewSeverity.Warning)); if (input.SectionCount < 4) - issues.Add(new("업무 문서 기준 핵심 섹션 수가 부족합니다", ArtifactReviewSeverity.Warning)); + issues.Add(new("Major section count is low for a business document.", ArtifactReviewSeverity.Warning)); if (!input.HasExecutiveSummarySection) - issues.Add(new("Executive Summary 또는 요약 섹션이 없습니다", ArtifactReviewSeverity.Warning)); + issues.Add(new("Executive Summary section is missing.", ArtifactReviewSeverity.Warning)); if (!input.HasRecommendationSection) - issues.Add(new("권고안 또는 다음 단계 섹션이 없습니다", ArtifactReviewSeverity.Warning)); + issues.Add(new("Recommendation or next-step section is missing.", ArtifactReviewSeverity.Warning)); if (input.SectionCount >= 6 && !input.HasAppendixSection) - issues.Add(new("긴 문서인데 부록/참고 섹션이 없습니다", ArtifactReviewSeverity.Info)); + issues.Add(new("Appendix or reference section is limited for a long document.", ArtifactReviewSeverity.Info)); if (input.TableCount + input.ListCount + input.CalloutCount + input.HighlightCount == 0) - issues.Add(new("표, 목록, 강조 블록이 없어 문서가 단조로울 수 있습니다", ArtifactReviewSeverity.Warning)); + issues.Add(new("Document structure is mostly plain paragraphs.", ArtifactReviewSeverity.Warning)); return BuildReport("docx", strengths, issues); } @@ -149,22 +184,25 @@ public static class ArtifactQualityReviewService var strengths = new List(); var issues = new List(); - if (input.HasSummarySheet) strengths.Add("요약 시트 포함"); - if (input.DetailSheetCount > 1) strengths.Add($"상세 시트 {input.DetailSheetCount}개 구성"); - if (input.FormulaCount > 0) strengths.Add($"수식 {input.FormulaCount}개 포함"); - if (input.HyperlinkCount > 0) strengths.Add("시트 간 빠른 이동 링크 포함"); - if (input.DataValidationCount > 0) strengths.Add("데이터 검증 규칙 포함"); - if (input.HasHighlightSection) strengths.Add("핵심 인사이트 영역 포함"); - if (input.HasActionSection) strengths.Add("후속 액션 영역 포함"); + if (input.HasSummarySheet) strengths.Add("Includes summary sheet"); + if (input.DetailSheetCount > 1) strengths.Add($"Contains {input.DetailSheetCount} detail sheets"); + if (input.FormulaCount > 0) strengths.Add($"Includes {input.FormulaCount} formula cell(s)"); + if (input.HyperlinkCount > 0) strengths.Add("Includes navigation links"); + if (input.DataValidationCount > 0) strengths.Add("Includes data validation rules"); + if (input.ConditionalFormattingCount > 0) strengths.Add("Includes conditional formatting"); + if (input.HasHighlightSection) strengths.Add("Includes highlight section"); + if (input.HasActionSection) strengths.Add("Includes action section"); if (input.SheetCount > 1 && !input.HasSummarySheet) - issues.Add(new("멀티시트 워크북인데 요약 시트가 없습니다", ArtifactReviewSeverity.Warning)); + issues.Add(new("Workbook has multiple sheets but no summary sheet.", ArtifactReviewSeverity.Warning)); if (input.DataRowCount >= 10 && input.FormulaCount == 0) - issues.Add(new("데이터 행 수 대비 수식/집계가 부족합니다", ArtifactReviewSeverity.Warning)); + issues.Add(new("Workbook has enough data to benefit from more formulas or rollups.", ArtifactReviewSeverity.Warning)); if (input.HasSummarySheet && input.HyperlinkCount == 0) - issues.Add(new("요약 시트에서 상세 시트로 이동하는 링크가 없습니다", ArtifactReviewSeverity.Warning)); + issues.Add(new("Summary sheet does not link to detail sheets.", ArtifactReviewSeverity.Warning)); if (input.DataValidationCount == 0 && input.DataRowCount >= 5) - issues.Add(new("입력 통제를 위한 데이터 검증 규칙이 없습니다", ArtifactReviewSeverity.Info)); + issues.Add(new("Input controls are limited for a workbook with editable data.", ArtifactReviewSeverity.Info)); + if (input.ConditionalFormattingCount == 0 && input.DataRowCount >= 8) + issues.Add(new("Conditional formatting is limited for a workbook with enough data to prioritize or flag.", ArtifactReviewSeverity.Info)); return BuildReport("xlsx", strengths, issues); } @@ -194,21 +232,17 @@ public static class ArtifactQualityReviewService private static int CountPlaceholders(string text) { - var count = 0; if (string.IsNullOrWhiteSpace(text)) - return count; + return 0; var patterns = new[] { - @"\[(todo|placeholder|내용.*작성|fill me)\]", + @"\[(todo|placeholder|fill me)\]", @"lorem ipsum", - @"tbd", + @"\bTBD\b", }; - foreach (var pattern in patterns) - count += Regex.Matches(text, pattern, RegexOptions.IgnoreCase).Count; - - return count; + return patterns.Sum(pattern => Regex.Matches(text, pattern, RegexOptions.IgnoreCase).Count); } private static bool ContainsAny(string text, IEnumerable keywords) diff --git a/src/AxCopilot/Services/Agent/DocxSkill.cs b/src/AxCopilot/Services/Agent/DocxSkill.cs index 6aaee0c..823efd6 100644 --- a/src/AxCopilot/Services/Agent/DocxSkill.cs +++ b/src/AxCopilot/Services/Agent/DocxSkill.cs @@ -55,6 +55,7 @@ public class DocxSkill : IAgentTool ["header"] = new() { Type = "string", Description = "Header text shown at top of every page (optional)." }, ["footer"] = new() { Type = "string", Description = "Footer text. Use {page} for page number. Default: 'AX Copilot · {page}' if header is set." }, ["page_numbers"] = new() { Type = "boolean", Description = "Show page numbers in footer. Default: true if header or footer is set." }, + ["style_map"] = new() { Type = "object", Description = "Optional paragraph style map for template-based documents. Example: {\"title\":\"Title\",\"heading1\":\"Heading1\",\"heading2\":\"Heading2\",\"body\":\"Normal\"}." }, }, Required = ["sections"] }; @@ -70,6 +71,12 @@ public class DocxSkill : IAgentTool string TableHeader, string BorderColor); + private sealed record DocxStyleMap( + string? TitleStyle, + string? Heading1Style, + string? Heading2Style, + string? BodyStyle); + private static readonly Dictionary Themes = new(StringComparer.OrdinalIgnoreCase) { ["professional"] = new("1F3864", "2E74B5", "404040", "2E74B5", "B4C6E7"), @@ -176,6 +183,7 @@ public class DocxSkill : IAgentTool // 기본 스타일 파트 추가 (styles.xml) EnsureStyles(mainPart, templateApplied); + var styleMap = ResolveStyleMap(mainPart, args); var body = InitializeDocumentBody(mainPart, templateApplied); @@ -184,7 +192,7 @@ public class DocxSkill : IAgentTool AddHeaderFooter(mainPart, body, headerText, footerText, showPageNumbers); if (!string.IsNullOrWhiteSpace(title) || !string.IsNullOrWhiteSpace(coverSubtitle) || coverMeta.Count > 0) - AppendCoverPage(body, title, coverSubtitle, coverMeta, theme); + AppendCoverPage(body, title, coverSubtitle, coverMeta, theme, styleMap); if (useToc) AppendTableOfContents(body); @@ -192,7 +200,7 @@ public class DocxSkill : IAgentTool // 제목 if (!string.IsNullOrEmpty(title)) { - body.Append(CreateTitleParagraph(title, theme)); + body.Append(CreateTitleParagraph(title, theme, styleMap.TitleStyle)); // 제목 아래 구분선 body.Append(new Paragraph( new ParagraphProperties @@ -262,7 +270,7 @@ public class DocxSkill : IAgentTool if (!string.IsNullOrEmpty(heading)) { - body.Append(CreateHeadingParagraph(heading, level, theme)); + body.Append(CreateHeadingParagraph(heading, level, theme, level <= 1 ? styleMap.Heading1Style : styleMap.Heading2Style)); headings.Add(heading); } @@ -270,7 +278,7 @@ public class DocxSkill : IAgentTool { foreach (var line in bodyText.Split('\n')) { - body.Append(CreateBodyParagraph(line)); + body.Append(CreateBodyParagraph(line, styleMap.BodyStyle)); } } sectionCount++; @@ -396,10 +404,36 @@ public class DocxSkill : IAgentTool } } - private static void AppendCoverPage(Body body, string title, string? subtitle, IReadOnlyList metaLines, ThemeColors theme) + private static DocxStyleMap ResolveStyleMap(MainDocumentPart mainPart, JsonElement args) + { + if (!args.SafeTryGetProperty("style_map", out var styleMapEl) || styleMapEl.ValueKind != JsonValueKind.Object) + return new DocxStyleMap(null, null, null, null); + + return new DocxStyleMap( + FindAvailableStyle(mainPart, styleMapEl.SafeTryGetProperty("title", out var titleStyleEl) ? titleStyleEl.SafeGetString() : null), + FindAvailableStyle(mainPart, styleMapEl.SafeTryGetProperty("heading1", out var heading1StyleEl) ? heading1StyleEl.SafeGetString() : null), + FindAvailableStyle(mainPart, styleMapEl.SafeTryGetProperty("heading2", out var heading2StyleEl) ? heading2StyleEl.SafeGetString() : null), + FindAvailableStyle(mainPart, styleMapEl.SafeTryGetProperty("body", out var bodyStyleEl) ? bodyStyleEl.SafeGetString() : null)); + } + + private static string? FindAvailableStyle(MainDocumentPart mainPart, string? styleId) + { + if (string.IsNullOrWhiteSpace(styleId)) + return null; + + var styles = mainPart.StyleDefinitionsPart?.Styles; + if (styles == null) + return null; + + return styles.Elements