From 5607f6391e478e3e1db69ed7c719906e8c06c3e5 Mon Sep 17 00:00:00 2001 From: lacvet Date: Tue, 14 Apr 2026 22:31:15 +0900 Subject: [PATCH] ?? ??? 3?? DOCX ????XLSX ?? ???HTML ???? ?? ?? - DocumentAssemblerTool? template_path/page_numbers? ??? DOCX ??? ??, ??????????? ?? ??? ?? - ExcelSkill? data_validations? ???? ??/??/?? ?? ??? ?? ??? ?? ?? ?? ?? - HtmlSkill? ArtifactQualityReviewService? decision_summary/evidence_cards ??? ?? ?? ?? ??? ?? - DocumentAssemblerDocxFeaturesTests, HtmlSkillConsultingSectionsTests, ExcelSkillDataValidationTests? ?? ??? ?? - README.md? docs/DEVELOPMENT.md? 2026-04-14 22:28 (KST) ???? ?? ??: - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_doc_phase_next\\ -p:IntermediateOutputPath=obj\\verify_doc_phase_next\\ (?? 0 / ?? 0) - dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "DocumentAssemblerDocxFeaturesTests|DocumentAssemblerSemanticTests|DocumentPlannerWorkbookScaffoldTests|ExcelSkillExecutiveSummaryLinkTests|ExcelSkillSummarySheetTests|ExcelSkillDataValidationTests|HtmlSkillConsultingSectionsTests|DocxSkillTemplateFeaturesTests|DocumentPlannerBusinessDocumentTests" -p:OutputPath=bin\\verify_doc_phase_next_tests\\ -p:IntermediateOutputPath=obj\\verify_doc_phase_next_tests\\ (?? 9) --- README.md | 10 ++ docs/DEVELOPMENT.md | 10 ++ .../DocumentAssemblerDocxFeaturesTests.cs | 17 +++ .../Services/ExcelSkillDataValidationTests.cs | 77 ++++++++++++ .../HtmlSkillConsultingSectionsTests.cs | 33 ++++-- .../Agent/ArtifactQualityReviewService.cs | 6 +- .../Services/Agent/DocumentAssemblerTool.cs | 87 +++++++++++--- src/AxCopilot/Services/Agent/ExcelSkill.cs | 110 +++++++++++++++--- src/AxCopilot/Services/Agent/HtmlSkill.cs | 69 +++++++++++ 9 files changed, 380 insertions(+), 39 deletions(-) create mode 100644 src/AxCopilot.Tests/Services/ExcelSkillDataValidationTests.cs diff --git a/README.md b/README.md index 952bd02..cc53b08 100644 --- a/README.md +++ b/README.md @@ -1802,3 +1802,13 @@ MIT License - PPT 3? ?? ??? ??????. DeckPlanningService, DeckQualityReviewService, PptxSkill? planning/quality summary ??? ??? PPT ?? ?? ??? ???? ?? ??? ?? ?? ??? ??? ?????. - ??: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_ppt_phase3\\ -p:IntermediateOutputPath=obj\\verify_ppt_phase3\\ ?? 0 / ?? 0 - ??: dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "DeckPlanningServiceTests|DeckQualityReviewServiceTests|PptxSkillAutoRepairTests|PptxSkillConsultingDeckTests" -p:OutputPath=bin\\verify_ppt_phase3_tests\\ -p:IntermediateOutputPath=obj\\verify_ppt_phase3_tests\\ ?? 5 + +업데이트: 2026-04-14 22:28 (KST) +- 문서 포맷 고도화 3차를 반영했습니다. `DocumentAssemblerTool`은 DOCX 조립 시 `template_path`와 `page_numbers`를 지원해 기존 사내 Word 템플릿을 복제한 뒤 커버, 목차, 머리글, 바닥글, 페이지 번호를 함께 적용할 수 있습니다. +- 같은 도구는 템플릿 상속 여부를 구조화 문서 품질 리뷰 입력에도 전달해, 결과 메시지에서 템플릿 기반 산출물의 강점과 보완 포인트를 함께 요약합니다. +- `ExcelSkill`은 `data_validations` 파라미터를 지원하도록 확장됐습니다. 단일 시트, summary sheet 포함 워크북, 멀티 시트 워크북 모두에서 OpenXML `DataValidation` 규칙을 생성하고 품질 리뷰에 검증 규칙 수를 반영합니다. +- `HtmlSkill`은 `decision_summary`, `evidence_cards` 섹션을 지원해 경영 보고형 HTML에서 의사결정 요약과 근거 카드 묶음을 구조화 블록으로 렌더링합니다. +- `ArtifactQualityReviewService`의 HTML 리뷰도 새 블록을 인식해 comparison, roadmap, matrix 외에 decision/evidence 구조를 강점으로 계산하도록 보강했습니다. +- 테스트로 `ExcelSkillDataValidationTests`를 추가했고, `DocumentAssemblerDocxFeaturesTests`, `HtmlSkillConsultingSectionsTests`를 확장해 DOCX 템플릿/페이지 번호와 HTML decision/evidence 블록을 회귀 검증했습니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_doc_phase_next\\ -p:IntermediateOutputPath=obj\\verify_doc_phase_next\\` 경고 0 / 오류 0 +- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "DocumentAssemblerDocxFeaturesTests|DocumentAssemblerSemanticTests|DocumentPlannerWorkbookScaffoldTests|ExcelSkillExecutiveSummaryLinkTests|ExcelSkillSummarySheetTests|ExcelSkillDataValidationTests|HtmlSkillConsultingSectionsTests|DocxSkillTemplateFeaturesTests|DocumentPlannerBusinessDocumentTests" -p:OutputPath=bin\\verify_doc_phase_next_tests\\ -p:IntermediateOutputPath=obj\\verify_doc_phase_next_tests\\` 통과 9 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index a63ad0d..84c3eed 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -872,3 +872,13 @@ UI ?붿옄???€洹쒕え 由ы뙥?좊쭅 ???꾪뿕 ?묒뾽 ??湲곕줉???덉쟾 - 테스트로 [DocumentPlannerWorkbookScaffoldTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/DocumentPlannerWorkbookScaffoldTests.cs), [DocumentAssemblerDocxFeaturesTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/DocumentAssemblerDocxFeaturesTests.cs)를 추가했습니다. - 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_doc_planning2\\ -p:IntermediateOutputPath=obj\\verify_doc_planning2\\` 경고 0 / 오류 0 - 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "DocumentPlannerWorkbookScaffoldTests|DocumentAssemblerDocxFeaturesTests|DocumentAssemblerSemanticTests|DocumentPlannerBusinessDocumentTests|ExcelSkillExecutiveSummaryLinkTests|HtmlSkillConsultingSectionsTests|DocxSkillTemplateFeaturesTests" -p:OutputPath=bin\\verify_doc_planning_tests3\\ -p:IntermediateOutputPath=obj\\verify_doc_planning_tests3\\` 통과 7 + +업데이트: 2026-04-14 22:28 (KST) +- 문서 포맷 고도화 3차를 반영했습니다. [DocumentAssemblerTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/DocumentAssemblerTool.cs)는 DOCX 조립 시 `template_path`와 `page_numbers`를 지원해 사내 템플릿 복제 후 커버, 목차, 머리글, 바닥글, 페이지 번호를 함께 적용할 수 있게 했습니다. +- 같은 도구의 DOCX 경로는 템플릿 상속 여부를 [ArtifactQualityReviewService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs)의 구조화 문서 리뷰 입력으로 전달해 템플릿 기반 산출물 강점까지 품질 요약에 반영합니다. +- [ExcelSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ExcelSkill.cs)는 `data_validations`를 지원하도록 확장했습니다. 단일 시트, summary sheet 포함 워크북, 멀티 시트 워크북 모두에서 OpenXML `DataValidation` 규칙을 생성하고 워크북 품질 리뷰에 검증 규칙 수를 포함합니다. +- [HtmlSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/HtmlSkill.cs)는 `decision_summary`, `evidence_cards` 섹션을 지원해 경영 보고형 HTML에서 의사결정 요약과 근거 카드 묶음을 구조화 블록으로 렌더링합니다. +- [ArtifactQualityReviewService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs)의 HTML 리뷰는 새 블록을 인식해 comparison, roadmap, matrix 외에 decision/evidence 구조도 강점으로 점수화하도록 보강했습니다. +- 테스트로 [ExcelSkillDataValidationTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/ExcelSkillDataValidationTests.cs)를 추가했고, [DocumentAssemblerDocxFeaturesTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/DocumentAssemblerDocxFeaturesTests.cs), [HtmlSkillConsultingSectionsTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/HtmlSkillConsultingSectionsTests.cs)를 확장해 DOCX 템플릿/페이지 번호와 HTML decision/evidence 블록을 회귀 검증했습니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_doc_phase_next\\ -p:IntermediateOutputPath=obj\\verify_doc_phase_next\\` 경고 0 / 오류 0 +- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "DocumentAssemblerDocxFeaturesTests|DocumentAssemblerSemanticTests|DocumentPlannerWorkbookScaffoldTests|ExcelSkillExecutiveSummaryLinkTests|ExcelSkillSummarySheetTests|ExcelSkillDataValidationTests|HtmlSkillConsultingSectionsTests|DocxSkillTemplateFeaturesTests|DocumentPlannerBusinessDocumentTests" -p:OutputPath=bin\\verify_doc_phase_next_tests\\ -p:IntermediateOutputPath=obj\\verify_doc_phase_next_tests\\` 통과 9 diff --git a/src/AxCopilot.Tests/Services/DocumentAssemblerDocxFeaturesTests.cs b/src/AxCopilot.Tests/Services/DocumentAssemblerDocxFeaturesTests.cs index f4d2552..b0547f6 100644 --- a/src/AxCopilot.Tests/Services/DocumentAssemblerDocxFeaturesTests.cs +++ b/src/AxCopilot.Tests/Services/DocumentAssemblerDocxFeaturesTests.cs @@ -15,9 +15,21 @@ public class DocumentAssemblerDocxFeaturesTests { var workDir = Path.Combine(Path.GetTempPath(), "ax-doc-assemble-features-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(workDir); + var templatePath = Path.Combine(workDir, "doc-template.docx"); try { + 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 Placeholder"))), + new SectionProperties( + new PageSize { Width = 15840U, Height = 12240U }, + new PageMargin { Top = 1080, Right = 1080, Bottom = 1080, Left = 1080 }))); + mainPart.Document.Save(); + } + var tool = new DocumentAssemblerTool(); var context = new AgentContext { @@ -34,8 +46,10 @@ public class DocumentAssemblerDocxFeaturesTests "format": "docx", "toc": true, "cover_subtitle": "Q2 Steering Committee", + "template_path": "doc-template.docx", "header": "Internal Use Only", "footer": "AX Copilot {page}", + "page_numbers": true, "sections": [ { "heading": "1. Executive Summary", "level": 1, "content": "

Summary.

" }, { "heading": "2. Current State", "level": 1, "content": "" }, @@ -59,6 +73,9 @@ public class DocumentAssemblerDocxFeaturesTests doc.MainDocumentPart.FooterParts.Should().NotBeEmpty(); doc.MainDocumentPart.Document.Body!.InnerText.Should().Contain("Q2 Steering Committee"); doc.MainDocumentPart.Document.Body.Descendants().Should().Contain(field => field.Text.Contains("TOC")); + doc.MainDocumentPart.FooterParts + .SelectMany(part => part.Footer.Descendants()) + .Should().Contain(field => field.Text.Contains("PAGE")); } finally { diff --git a/src/AxCopilot.Tests/Services/ExcelSkillDataValidationTests.cs b/src/AxCopilot.Tests/Services/ExcelSkillDataValidationTests.cs new file mode 100644 index 0000000..2c0927e --- /dev/null +++ b/src/AxCopilot.Tests/Services/ExcelSkillDataValidationTests.cs @@ -0,0 +1,77 @@ +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 ExcelSkillDataValidationTests +{ + [Fact] + public async Task ExecuteAsync_WithDataValidations_ShouldPersistValidationRules() + { + var workDir = Path.Combine(Path.GetTempPath(), "ax-xlsx-validation-" + 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": "validated.xlsx", + "sheet_name": "Tracker", + "headers": ["Item", "Status", "Owner"], + "rows": [ + ["Task 1", "Open", "Kim"], + ["Task 2", "Done", "Lee"] + ], + "data_validations": [ + { + "range": "B2:B100", + "type": "list", + "formula1": "\"Open,In Progress,Done\"", + "allow_blank": false, + "prompt": "Select a valid status" + } + ] + } + """).RootElement; + + var result = await tool.ExecuteAsync(args, context, CancellationToken.None); + + result.Success.Should().BeTrue(); + result.Output.Should().Contain("점수"); + + var outputPath = Path.Combine(workDir, "validated.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().Should().ContainSingle(); + worksheetPart.Worksheet.Descendants().Should().ContainSingle(); + } + finally + { + try + { + if (Directory.Exists(workDir)) + Directory.Delete(workDir, true); + } + catch + { + } + } + } +} diff --git a/src/AxCopilot.Tests/Services/HtmlSkillConsultingSectionsTests.cs b/src/AxCopilot.Tests/Services/HtmlSkillConsultingSectionsTests.cs index 13fdf2f..8a664bc 100644 --- a/src/AxCopilot.Tests/Services/HtmlSkillConsultingSectionsTests.cs +++ b/src/AxCopilot.Tests/Services/HtmlSkillConsultingSectionsTests.cs @@ -28,29 +28,44 @@ public class HtmlSkillConsultingSectionsTests """ { "path": "consulting.html", - "title": "전략 리뷰", + "title": "Executive Report", "body": "", "sections": [ { "type": "comparison", - "title": "옵션 비교", + "title": "Option Comparison", "items": [ - { "name": "Option A", "summary": "빠른 실행", "pros": "도입이 쉽다", "cons": "확장성 제약", "verdict": "Fast" } + { "name": "Option A", "summary": "Fast rollout", "pros": "Low setup effort", "cons": "Limited scale", "verdict": "Fast" } ] }, { "type": "roadmap", - "title": "실행 로드맵", + "title": "Delivery Roadmap", "phases": [ - { "title": "Phase 1", "detail": "진단 및 설계", "timeline": "0-30일", "owner": "PMO" } + { "title": "Phase 1", "detail": "Assess and design", "timeline": "0-30 days", "owner": "PMO" } ] }, { "type": "matrix", - "title": "우선순위 매트릭스", + "title": "Priority Matrix", "quadrants": [ - { "title": "Quick Wins", "items": ["자동화 과제", "리포트 표준화"] }, - { "title": "Strategic Bets", "items": ["플랫폼 재구성"] } + { "title": "Quick Wins", "items": ["Automate reporting", "Standardize templates"] }, + { "title": "Strategic Bets", "items": ["Operating model redesign"] } + ] + }, + { + "type": "decision_summary", + "title": "Decision", + "decision": "Approve pilot expansion", + "rationale": "Retention and margin improved in the pilot region.", + "actions": ["Approve budget", "Launch phase 2"] + }, + { + "type": "evidence_cards", + "title": "Evidence", + "items": [ + { "title": "Margin uplift", "detail": "+4.2p improvement after workflow change", "source": "Finance close", "tag": "KPI" }, + { "title": "Cycle time", "detail": "Lead time reduced from 12 to 8 days", "source": "PMO tracker", "tag": "Ops" } ] } ] @@ -64,6 +79,8 @@ public class HtmlSkillConsultingSectionsTests html.Should().Contain("comparison-grid"); html.Should().Contain("roadmap-block"); html.Should().Contain("matrix-grid"); + html.Should().Contain("decision-summary"); + html.Should().Contain("evidence-cards"); } finally { diff --git a/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs b/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs index f7e8fa9..7d180a5 100644 --- a/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs +++ b/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs @@ -85,6 +85,8 @@ public static class ArtifactQualityReviewService var comparisonCount = Regex.Matches(html, @"comparison-grid|comparison-card", RegexOptions.IgnoreCase).Count; var roadmapCount = Regex.Matches(html, @"roadmap(-block|-phase)?", RegexOptions.IgnoreCase).Count; var matrixCount = Regex.Matches(html, @"matrix-grid|risk-matrix", RegexOptions.IgnoreCase).Count; + var decisionCount = Regex.Matches(html, @"decision-summary", RegexOptions.IgnoreCase).Count; + var evidenceCardCount = Regex.Matches(html, @"evidence-cards|evidence-card", RegexOptions.IgnoreCase).Count; var kpiCount = Regex.Matches(html, @"kpi-card|kpi-grid|metric-card", RegexOptions.IgnoreCase).Count; var placeholderCount = CountPlaceholders(html); @@ -92,7 +94,7 @@ public static class ArtifactQualityReviewService if (hasTableOfContents) strengths.Add("목차 자동 생성"); if (printReady) strengths.Add("인쇄 최적화 CSS 포함"); if (sectionCount >= 5) strengths.Add($"핵심 섹션 {sectionCount}개 구성"); - if (tableCount > 0 || comparisonCount > 0 || roadmapCount > 0 || matrixCount > 0 || kpiCount > 0) + if (tableCount > 0 || comparisonCount > 0 || roadmapCount > 0 || matrixCount > 0 || decisionCount > 0 || evidenceCardCount > 0 || kpiCount > 0) strengths.Add("표/비교/로드맵/KPI 등 구조화 블록 활용"); if (calloutCount > 0) strengths.Add("핵심 메시지 강조 블록 포함"); @@ -102,7 +104,7 @@ public static class ArtifactQualityReviewService issues.Add(new("업무 문서 기준 핵심 섹션 수가 부족합니다", ArtifactReviewSeverity.Warning)); if (paragraphCount < sectionCount * 2) issues.Add(new("일부 섹션의 서술이 충분하지 않습니다", ArtifactReviewSeverity.Warning)); - if (tableCount + comparisonCount + roadmapCount + matrixCount + kpiCount == 0) + if (tableCount + comparisonCount + roadmapCount + matrixCount + decisionCount + evidenceCardCount + kpiCount == 0) issues.Add(new("구조화 시각 요소가 없어 보고서 완성도가 낮을 수 있습니다", ArtifactReviewSeverity.Warning)); if (placeholderCount > 0) issues.Add(new($"플레이스홀더/미완성 표현 {placeholderCount}건이 남아 있습니다", ArtifactReviewSeverity.Critical)); diff --git a/src/AxCopilot/Services/Agent/DocumentAssemblerTool.cs b/src/AxCopilot/Services/Agent/DocumentAssemblerTool.cs index 446e258..f3b8376 100644 --- a/src/AxCopilot/Services/Agent/DocumentAssemblerTool.cs +++ b/src/AxCopilot/Services/Agent/DocumentAssemblerTool.cs @@ -57,8 +57,10 @@ public class DocumentAssemblerTool : IAgentTool }, ["toc"] = new() { Type = "boolean", Description = "Auto-generate table of contents. Default: true" }, ["cover_subtitle"] = new() { Type = "string", Description = "Subtitle for cover page. If provided, a cover page is added." }, + ["template_path"] = new() { Type = "string", Description = "Optional .docx template path for DOCX output. Reuses template styles and section setup." }, ["header"] = new() { Type = "string", Description = "Header text for DOCX output." }, ["footer"] = new() { Type = "string", Description = "Footer text for DOCX output. Use {page} for page number." }, + ["page_numbers"] = new() { Type = "boolean", Description = "Show page numbers in DOCX footer. Default: true when header or footer is set." }, }, Required = ["path", "title", "sections"] }; @@ -71,8 +73,12 @@ public class DocumentAssemblerTool : IAgentTool var requestedMood = args.SafeTryGetProperty("mood", out var m) ? m.SafeGetString() ?? GetDefaultMood() : GetDefaultMood(); var useToc = !args.SafeTryGetProperty("toc", out var tocVal) || tocVal.GetBoolean(); // default true var coverSubtitle = args.SafeTryGetProperty("cover_subtitle", out var cs) ? cs.SafeGetString() : null; + var templatePath = args.SafeTryGetProperty("template_path", out var templateEl) ? templateEl.SafeGetString() : null; var headerText = args.SafeTryGetProperty("header", out var hdr) ? hdr.SafeGetString() : null; var footerText = args.SafeTryGetProperty("footer", out var ftr) ? ftr.SafeGetString() : null; + var showPageNumbers = args.SafeTryGetProperty("page_numbers", out var pageNumbersEl) + ? pageNumbersEl.ValueKind == JsonValueKind.True + : !string.IsNullOrWhiteSpace(headerText) || !string.IsNullOrWhiteSpace(footerText); if (!args.SafeTryGetProperty("sections", out var sectionsEl) || sectionsEl.ValueKind != JsonValueKind.Array) return ToolResult.Fail("sections 배열이 필요합니다."); @@ -114,13 +120,27 @@ public class DocumentAssemblerTool : IAgentTool var dir = Path.GetDirectoryName(fullPath); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + string? templateFullPath = null; + if (!string.IsNullOrWhiteSpace(templatePath)) + { + templateFullPath = FileReadTool.ResolvePath(templatePath, context.WorkFolder); + if (!templateFullPath.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)) + templateFullPath += ".docx"; + if (!context.IsPathAllowed(templateFullPath)) + return ToolResult.Fail($"템플릿 경로 접근 차단: {templateFullPath}"); + if (!File.Exists(templateFullPath)) + return ToolResult.Fail($"DOCX 템플릿을 찾을 수 없습니다: {templateFullPath}"); + if (string.Equals(Path.GetFullPath(templateFullPath), Path.GetFullPath(fullPath), StringComparison.OrdinalIgnoreCase)) + return ToolResult.Fail("template_path와 path는 같은 파일일 수 없습니다."); + } + try { string resultMsg; switch (format) { case "docx": - resultMsg = AssembleDocx(fullPath, title, sections, useToc, coverSubtitle, headerText, footerText); + resultMsg = AssembleDocx(fullPath, title, sections, useToc, coverSubtitle, templateFullPath, headerText, footerText, showPageNumbers); break; case "markdown": resultMsg = AssembleMarkdown(fullPath, title, sections); @@ -239,19 +259,21 @@ public class DocumentAssemblerTool : IAgentTool } private string AssembleDocx(string path, string title, List<(string Heading, string Content, int Level)> sections, - bool useToc, string? coverSubtitle, string? headerText, string? footerText) + bool useToc, string? coverSubtitle, string? templatePath, string? headerText, string? footerText, bool showPageNumbers) { - using var doc = DocumentFormat.OpenXml.Packaging.WordprocessingDocument.Create( - path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document); + var templateApplied = !string.IsNullOrWhiteSpace(templatePath); + if (templateApplied) + File.Copy(templatePath!, path, true); - var mainPart = doc.AddMainDocumentPart(); + using var doc = templateApplied + ? DocumentFormat.OpenXml.Packaging.WordprocessingDocument.Open(path, true) + : DocumentFormat.OpenXml.Packaging.WordprocessingDocument.Create(path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document); - var stylesPart = mainPart.AddNewPart(); - stylesPart.Styles = CreateDefaultDocxStyles(); - stylesPart.Styles.Save(); + var mainPart = doc.MainDocumentPart ?? doc.AddMainDocumentPart(); - mainPart.Document = new DocumentFormat.OpenXml.Wordprocessing.Document(); - var body = mainPart.Document.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Body()); + EnsureAssemblerStyles(mainPart, templateApplied); + + var body = InitializeAssemblerBody(mainPart, templateApplied); DocumentFormat.OpenXml.Wordprocessing.RunFonts KoreanFonts() => new() { @@ -470,8 +492,8 @@ public class DocumentAssemblerTool : IAgentTool Header = 720, Footer = 720, Gutter = 0 } )); - if (!string.IsNullOrWhiteSpace(headerText) || !string.IsNullOrWhiteSpace(footerText)) - AddAssemblerHeaderFooter(mainPart, body, headerText, footerText, showPageNumbers: true); + if (!string.IsNullOrWhiteSpace(headerText) || !string.IsNullOrWhiteSpace(footerText) || showPageNumbers) + AddAssemblerHeaderFooter(mainPart, body, headerText, footerText, showPageNumbers); mainPart.Document.Save(); @@ -486,7 +508,7 @@ public class DocumentAssemblerTool : IAgentTool 0, includeCover, useToc && sections.Count > 3, - false, + templateApplied, !string.IsNullOrWhiteSpace(headerText) || !string.IsNullOrWhiteSpace(footerText), headings.Any(h => ArtifactQualityReviewService.ContainsBusinessKeyword(h, "executive summary", "summary", "요약")), headings.Any(h => ArtifactQualityReviewService.ContainsBusinessKeyword(h, "recommendation", "proposal", "next step", "action", "권고", "실행")), @@ -608,6 +630,45 @@ public class DocumentAssemblerTool : IAgentTool return styles; } + private static DocumentFormat.OpenXml.Wordprocessing.Body InitializeAssemblerBody( + DocumentFormat.OpenXml.Packaging.MainDocumentPart mainPart, + bool preserveSectionSetup) + { + mainPart.Document ??= new DocumentFormat.OpenXml.Wordprocessing.Document(); + + DocumentFormat.OpenXml.Wordprocessing.SectionProperties? preservedSection = null; + if (preserveSectionSetup) + preservedSection = mainPart.Document.Body?.Elements().LastOrDefault()?.CloneNode(true) as DocumentFormat.OpenXml.Wordprocessing.SectionProperties; + + mainPart.Document.Body = new DocumentFormat.OpenXml.Wordprocessing.Body(); + var body = mainPart.Document.Body; + + if (preservedSection != null) + body.Append(preservedSection); + + return body; + } + + private static void EnsureAssemblerStyles( + DocumentFormat.OpenXml.Packaging.MainDocumentPart mainPart, + bool preserveExisting) + { + var stylesPart = mainPart.StyleDefinitionsPart; + if (stylesPart == null) + { + stylesPart = mainPart.AddNewPart(); + stylesPart.Styles = CreateDefaultDocxStyles(); + stylesPart.Styles.Save(); + return; + } + + if (!preserveExisting || stylesPart.Styles == null) + { + stylesPart.Styles = CreateDefaultDocxStyles(); + stylesPart.Styles.Save(); + } + } + private static void AppendAssemblerCoverPage(DocumentFormat.OpenXml.Wordprocessing.Body body, string title, string subtitle) { body.Append(new DocumentFormat.OpenXml.Wordprocessing.Paragraph( diff --git a/src/AxCopilot/Services/Agent/ExcelSkill.cs b/src/AxCopilot/Services/Agent/ExcelSkill.cs index 67644ca..ed8d622 100644 --- a/src/AxCopilot/Services/Agent/ExcelSkill.cs +++ b/src/AxCopilot/Services/Agent/ExcelSkill.cs @@ -42,8 +42,9 @@ public class ExcelSkill : IAgentTool "Example: {\"name\":\"Summary\",\"title\":\"2026 운영 리뷰\",\"subtitle\":\"핵심 KPI와 후속 과제\",\"kpis\":[{\"label\":\"Revenue\",\"value\":\"12%\",\"trend\":\"YoY\",\"note\":\"Strong\"}],\"highlights\":[\"핵심 인사이트 1\",\"핵심 인사이트 2\"],\"actions\":[\"즉시 과제 1\",\"즉시 과제 2\"]}" }, ["number_formats"] = new() { Type = "array", Description = "Number format per column index. Supported: 'currency' (#,##0\"원\"), 'percent' (0.00%), 'decimal' (#,##0.00), 'integer' (#,##0), 'date' (yyyy-mm-dd), or any custom Excel format string. e.g. [\"text\",\"integer\",\"currency\",\"percent\"]", Items = new() { Type = "string" } }, + ["data_validations"] = new() { Type = "array", Description = "Validation rules such as [{\"range\":\"E2:E100\",\"type\":\"list\",\"formula1\":\"\\\"Open,In Progress,Done\\\"\",\"allow_blank\":true,\"prompt\":\"Select status\"}].", Items = new() { Type = "object" } }, ["col_alignments"] = new() { Type = "array", Description = "Horizontal alignment per column: 'left', 'center', 'right'. Headers always center-aligned.", Items = new() { Type = "string" } }, - ["sheets"] = new() { Type = "array", Description = "Multi-sheet mode: array of sheet objects [{name, headers, rows, style?, theme?, col_widths?, freeze_header?, number_formats?, col_alignments?, merges?, summary_row?}]. When present, overrides top-level headers/rows/sheet_name.", Items = new() { Type = "object" } }, + ["sheets"] = new() { Type = "array", Description = "Multi-sheet mode: array of sheet objects [{name, headers, rows, style?, theme?, col_widths?, freeze_header?, number_formats?, col_alignments?, merges?, summary_row?, data_validations?}]. When present, overrides top-level headers/rows/sheet_name.", Items = new() { Type = "object" } }, }, Required = [] }; @@ -183,10 +184,11 @@ public class ExcelSkill : IAgentTool var summaryArg = args.SafeTryGetProperty("summary_row", out var sumEl) ? sumEl : default; var mergesArg = args.SafeTryGetProperty("merges", out var mergeEl) ? mergeEl : default; + var validationsArg = args.SafeTryGetProperty("data_validations", out var validationsEl) ? validationsEl : default; - var rowCount = WriteSheetContent(worksheetPart, args, sheetName, headers, rows, + var (rowCount, validationCount) = WriteSheetContent(worksheetPart, args, sheetName, headers, rows, isStyled, freezeHeader, theme, numFmts, alignments, customFmts, - summaryArg, mergesArg, colCount); + summaryArg, mergesArg, validationsArg, colCount); var wbSheets = workbookPart.Workbook.AppendChild(new Sheets()); wbSheets.Append(new Sheet @@ -209,7 +211,7 @@ public class ExcelSkill : IAgentTool rowCount, CountFormulaCells(rows), 0, - 0, + validationCount, false, false, summaryArg.ValueKind == JsonValueKind.Object)); @@ -270,9 +272,10 @@ public class ExcelSkill : IAgentTool var colCount = headers.GetArrayLength(); var summaryArg = args.SafeTryGetProperty("summary_row", out var sumEl) ? sumEl : default; var mergesArg = args.SafeTryGetProperty("merges", out var mergeEl) ? mergeEl : default; - var rowCount = WriteSheetContent(worksheetPart, args, sheetName, headers, rows, + var validationsArg = args.SafeTryGetProperty("data_validations", out var validationsEl) ? validationsEl : default; + var (rowCount, validationCount) = WriteSheetContent(worksheetPart, args, sheetName, headers, rows, isStyled, freezeHeader, theme, numFmts, alignments, customFmts, - summaryArg, mergesArg, colCount); + summaryArg, mergesArg, validationsArg, colCount); wbSheets.Append(new Sheet { @@ -294,7 +297,7 @@ public class ExcelSkill : IAgentTool rowCount, CountFormulaCells(rows), 1, - 0, + validationCount, true, HasSummaryItems(summarySheet, "highlights"), HasSummaryItems(summarySheet, "actions"))); @@ -339,6 +342,7 @@ public class ExcelSkill : IAgentTool var totalSheets = 0; var totalRows = 0; var totalFormulaCount = 0; + var totalValidationCount = 0; var detailSheetNames = new List(); foreach (var sheetDef in sheetsArr.EnumerateArray()) @@ -381,13 +385,14 @@ public class ExcelSkill : IAgentTool var colCount = headers.GetArrayLength(); var summaryArg = sheetDef.SafeTryGetProperty("summary_row", out var sumEl) ? sumEl : default; var mergesArg = sheetDef.SafeTryGetProperty("merges", out var mergeEl) ? mergeEl : default; + var validationsArg = sheetDef.SafeTryGetProperty("data_validations", out var validationsEl) ? validationsEl : default; var worksheetPart = workbookPart.AddNewPart(); worksheetPart.Worksheet = new Worksheet(); - var rowCount = WriteSheetContent(worksheetPart, sheetDef, sheetName, headers, rows, + var (rowCount, validationCount) = WriteSheetContent(worksheetPart, sheetDef, sheetName, headers, rows, isStyled, freezeHeader, theme, numFmts, alignments, combinedCustomFmts, - summaryArg, mergesArg, colCount); + summaryArg, mergesArg, validationsArg, colCount); wbSheets.Append(new Sheet { @@ -400,11 +405,23 @@ public class ExcelSkill : IAgentTool totalSheets++; totalRows += rowCount; totalFormulaCount += CountFormulaCells(rows); + totalValidationCount += validationCount; } workbookPart.Workbook.Save(); + var review = ArtifactQualityReviewService.ReviewWorkbook(new WorkbookReviewInput( + fullPath, + totalSheets + (summarySheet.ValueKind == JsonValueKind.Object ? 1 : 0), + totalSheets, + totalRows, + totalFormulaCount, + summarySheet.ValueKind == JsonValueKind.Object ? detailSheetNames.Count : 0, + totalValidationCount, + summarySheet.ValueKind == JsonValueKind.Object, + HasSummaryItems(summarySheet, "highlights"), + HasSummaryItems(summarySheet, "actions"))); return ToolResult.Ok( - $"Excel 파일 생성 완료: {fullPath}\n시트: {totalSheets}개, 총 데이터 행: {totalRows}", + $"Excel 파일 생성 완료: {fullPath}\n시트: {totalSheets}개, 총 데이터 행: {totalRows}\n{review.ToToolSummary()}", fullPath); } @@ -534,7 +551,7 @@ public class ExcelSkill : IAgentTool }; } - private static int WriteSheetContent( + private static (int RowCount, int ValidationCount) WriteSheetContent( WorksheetPart worksheetPart, JsonElement args, string sheetName, @@ -548,6 +565,7 @@ public class ExcelSkill : IAgentTool List<(string code, uint id)> customFmtRegistry, JsonElement summaryArg, JsonElement mergesArg, + JsonElement validationsArg, int colCount) { // Column widths @@ -626,19 +644,22 @@ public class ExcelSkill : IAgentTool AddSummaryRow(sheetData, summaryArg, rowNum, colCount, rowCount, isStyled); // Cell merges + MergeCells? mergeCellsSection = null; if (mergesArg.ValueKind == JsonValueKind.Array) { - var mergeCells = new MergeCells(); + mergeCellsSection = new MergeCells(); foreach (var merge in mergesArg.EnumerateArray()) { var range = merge.SafeGetString(); if (!string.IsNullOrEmpty(range)) - mergeCells.Append(new MergeCell { Reference = range }); + mergeCellsSection.Append(new MergeCell { Reference = range }); } - if (mergeCells.HasChildren) - worksheetPart.Worksheet.InsertAfter(mergeCells, sheetData); + if (mergeCellsSection.HasChildren) + worksheetPart.Worksheet.InsertAfter(mergeCellsSection, sheetData); } + var validationCount = AddDataValidations(worksheetPart, sheetData, mergeCellsSection, validationsArg); + // Freeze header if (freezeHeader) { @@ -663,7 +684,64 @@ public class ExcelSkill : IAgentTool worksheetPart.Worksheet.InsertBefore(sheetViews, insertBefore); } - return rowCount; + return (rowCount, validationCount); + } + + private static int AddDataValidations(WorksheetPart worksheetPart, SheetData sheetData, MergeCells? mergeCells, JsonElement validationsArg) + { + if (validationsArg.ValueKind != JsonValueKind.Array || validationsArg.GetArrayLength() == 0) + return 0; + + var dataValidations = new DataValidations(); + var count = 0; + + foreach (var rule in validationsArg.EnumerateArray()) + { + var range = rule.SafeTryGetProperty("range", out var rangeEl) ? rangeEl.SafeGetString() : null; + var typeText = rule.SafeTryGetProperty("type", out var typeEl) ? typeEl.SafeGetString() : null; + var formula1 = rule.SafeTryGetProperty("formula1", out var formulaEl) ? formulaEl.SafeGetString() : null; + if (string.IsNullOrWhiteSpace(range) || string.IsNullOrWhiteSpace(typeText) || string.IsNullOrWhiteSpace(formula1)) + continue; + + var validation = new DataValidation + { + Type = ResolveValidationType(typeText), + AllowBlank = !(rule.SafeTryGetProperty("allow_blank", out var allowBlankEl) && allowBlankEl.ValueKind == JsonValueKind.False), + ShowInputMessage = rule.SafeTryGetProperty("prompt", out _), + SequenceOfReferences = new ListValue { InnerText = range } + }; + + if (rule.SafeTryGetProperty("prompt", out var promptEl) && !string.IsNullOrWhiteSpace(promptEl.SafeGetString())) + { + validation.PromptTitle = "Input"; + validation.Prompt = promptEl.SafeGetString(); + } + + validation.Append(new Formula1(formula1)); + dataValidations.Append(validation); + count++; + } + + if (count == 0) + return 0; + + dataValidations.Count = (uint)count; + var anchor = (OpenXmlElement?)mergeCells ?? sheetData; + worksheetPart.Worksheet.InsertAfter(dataValidations, anchor); + return count; + } + + private static DataValidationValues ResolveValidationType(string? type) + { + return (type ?? "list").Trim().ToLowerInvariant() switch + { + "whole" or "whole_number" => DataValidationValues.Whole, + "decimal" => DataValidationValues.Decimal, + "date" => DataValidationValues.Date, + "text_length" => DataValidationValues.TextLength, + "custom" => DataValidationValues.Custom, + _ => DataValidationValues.List, + }; } // ═══════════════════════════════════════════════════ diff --git a/src/AxCopilot/Services/Agent/HtmlSkill.cs b/src/AxCopilot/Services/Agent/HtmlSkill.cs index 6237cf8..9a663b4 100644 --- a/src/AxCopilot/Services/Agent/HtmlSkill.cs +++ b/src/AxCopilot/Services/Agent/HtmlSkill.cs @@ -52,6 +52,8 @@ public class HtmlSkill : IAgentTool "'table' {headers:[], rows:[[]]}, " + "'chart' {kind:'bar|horizontal_bar', title, data:[{label, value, color}]}, " + "'cards' {items:[{title, body, badge, icon}]}, " + + "'decision_summary' {title, decision, rationale, actions:['...']}, " + + "'evidence_cards' {title, items:[{title, detail, source, tag}]}, " + "'comparison' {title, items:[{name, summary, pros, cons, verdict}]}, " + "'roadmap' {title, phases:[{title, detail, timeline, owner}]}, " + "'matrix' {title, quadrants:[{title, items:['...']}]}, " + @@ -334,6 +336,12 @@ public class HtmlSkill : IAgentTool case "cards": sb.AppendLine(RenderCards(section)); break; + case "decision_summary": + sb.AppendLine(RenderDecisionSummary(section)); + break; + case "evidence_cards": + sb.AppendLine(RenderEvidenceCards(section)); + break; case "comparison": sb.AppendLine(RenderComparison(section)); break; @@ -525,6 +533,67 @@ public class HtmlSkill : IAgentTool return sb.ToString(); } + private static string RenderDecisionSummary(JsonElement s) + { + var title = s.SafeTryGetProperty("title", out var titleEl) ? titleEl.SafeGetString() : "Decision Summary"; + var decision = s.SafeTryGetProperty("decision", out var decisionEl) ? decisionEl.SafeGetString() : null; + var rationale = s.SafeTryGetProperty("rationale", out var rationaleEl) ? rationaleEl.SafeGetString() : null; + + var sb = new StringBuilder(); + sb.AppendLine("
"); + sb.AppendLine($"
{Escape(title ?? "Decision Summary")}
"); + if (!string.IsNullOrWhiteSpace(decision)) + sb.AppendLine($"
Decision: {MarkdownToHtml(decision)}
"); + if (!string.IsNullOrWhiteSpace(rationale)) + sb.AppendLine($"
{MarkdownToHtml(rationale)}
"); + + if (s.SafeTryGetProperty("actions", out var actionsEl) && actionsEl.ValueKind == JsonValueKind.Array && actionsEl.GetArrayLength() > 0) + { + sb.AppendLine("
Immediate Actions
"); + sb.AppendLine("
    "); + foreach (var item in actionsEl.EnumerateArray()) + sb.AppendLine($"
  • {MarkdownToHtml(item.SafeGetString() ?? string.Empty)}
  • "); + sb.AppendLine("
"); + } + + sb.AppendLine("
"); + return sb.ToString(); + } + + private static string RenderEvidenceCards(JsonElement s) + { + if (!s.SafeTryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array) + return string.Empty; + + var title = s.SafeTryGetProperty("title", out var titleEl) ? titleEl.SafeGetString() : null; + var sb = new StringBuilder(); + if (!string.IsNullOrWhiteSpace(title)) + sb.AppendLine($"

{Escape(title)}

"); + + sb.AppendLine("
"); + foreach (var item in items.EnumerateArray()) + { + var cardTitle = item.SafeTryGetProperty("title", out var cardTitleEl) ? cardTitleEl.SafeGetString() : null; + var detail = item.SafeTryGetProperty("detail", out var detailEl) ? detailEl.SafeGetString() : null; + var source = item.SafeTryGetProperty("source", out var sourceEl) ? sourceEl.SafeGetString() : null; + var tag = item.SafeTryGetProperty("tag", out var tagEl) ? tagEl.SafeGetString() : null; + + sb.AppendLine("
"); + sb.Append("
"); + sb.Append($"{Escape(cardTitle ?? "Evidence")}"); + if (!string.IsNullOrWhiteSpace(tag)) + sb.Append($"{Escape(tag)}"); + sb.AppendLine("
"); + if (!string.IsNullOrWhiteSpace(detail)) + sb.AppendLine($"
{MarkdownToHtml(detail)}
"); + if (!string.IsNullOrWhiteSpace(source)) + sb.AppendLine($"
Source: {Escape(source)}
"); + sb.AppendLine("
"); + } + sb.AppendLine("
"); + return sb.ToString(); + } + private static string RenderComparison(JsonElement s) { if (!s.SafeTryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)