From 1edeffa206f02d6b2ae6a2e91efd7b5d8f79e104 Mon Sep 17 00:00:00 2001 From: lacvet Date: Tue, 14 Apr 2026 23:06:53 +0900 Subject: [PATCH] =?UTF-8?q?=EF=BB=BF=EB=AC=B8=EC=84=9C=20=EA=B3=A0?= =?UTF-8?q?=EB=8F=84=ED=99=94=20=EB=8B=A4=EC=9D=8C=20=EB=8B=A8=EA=B3=84?= =?UTF-8?q?=EB=A5=BC=20=EB=B0=98=EC=98=81=ED=95=B4=20=EC=97=91=EC=85=80=20?= =?UTF-8?q?summary=EC=99=80=20DOCX=20=EC=A1=B0=EB=A6=BD=20=ED=92=88?= =?UTF-8?q?=EC=A7=88=EC=9D=84=20=EB=81=8C=EC=96=B4=EC=98=AC=EB=A0=B8?= =?UTF-8?q?=EC=8A=B5=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 핵심 수정사항: - ExcelSkill summary_sheet에 decision_summary, scorecards, sheet_summaries를 추가해 dashboard형 summary 시트를 생성하도록 확장했습니다. - ArtifactQualityReviewService의 workbook 리뷰 입력을 확장해 KPI/decision/detail summary 존재 여부를 품질 점수와 보완 포인트에 반영했습니다. - DocumentAssemblerTool에 style_map 파라미터를 추가해 template 기반 DOCX 조립에서 title/heading/body 문단 스타일을 실제 Word 스타일로 매핑하도록 개선했습니다. - DocumentAssemblerStyleMapTests, ExcelSkillDashboardSummaryTests를 추가하고 기존 ArtifactQualityReviewServiceTests를 갱신했습니다. 검증 결과: - 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 --- README.md | 8 + docs/DEVELOPMENT.md | 8 + .../ArtifactQualityReviewServiceTests.cs | 3 + .../DocumentAssemblerStyleMapTests.cs | 91 +++++++++ .../ExcelSkillDashboardSummaryTests.cs | 91 +++++++++ .../Agent/ArtifactQualityReviewService.cs | 10 +- .../Services/Agent/DocumentAssemblerTool.cs | 174 ++++++++++++------ src/AxCopilot/Services/Agent/ExcelSkill.cs | 142 +++++++++++++- 8 files changed, 471 insertions(+), 56 deletions(-) create mode 100644 src/AxCopilot.Tests/Services/DocumentAssemblerStyleMapTests.cs create mode 100644 src/AxCopilot.Tests/Services/ExcelSkillDashboardSummaryTests.cs diff --git a/README.md b/README.md index 5735b5c..c1a9800 100644 --- a/README.md +++ b/README.md @@ -1822,3 +1822,11 @@ MIT License - 테스트로 `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 + +업데이트: 2026-04-14 23:05 (KST) +- 문서 포맷 고도화 다음 단계를 반영했습니다. [ExcelSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ExcelSkill.cs)는 `summary_sheet`에 `decision_summary`, `scorecards`, `sheet_summaries`를 받을 수 있게 확장되어, 단순 KPI 표를 넘어 의사결정 요약과 상세 시트별 상태를 함께 보여주는 dashboard형 summary 시트를 생성합니다. +- 같은 파일의 워크북 품질 리뷰 입력도 확장해 summary sheet가 KPI/scorecard, decision summary, detail sheet summary를 실제로 담고 있는지 점수화하도록 [ArtifactQualityReviewService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs)를 보강했습니다. +- [DocumentAssemblerTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/DocumentAssemblerTool.cs)는 `style_map` 파라미터를 지원합니다. 템플릿 기반 DOCX 조립에서 `title`, `heading1`, `heading2`, `body` 문단 스타일을 실제 Word 문단에 연결해 cover title, 섹션 헤딩, 본문 문단이 사내 템플릿 스타일을 더 잘 따르도록 개선했습니다. +- 테스트로 [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 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 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 10b2ec7..a9c7cd4 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -891,3 +891,11 @@ UI ?붿옄???€洹쒕え 由ы뙥?좊쭅 ???꾪뿕 ?묒뾽 ??湲곕줉???덉쟾 - 테스트로 [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 + +업데이트: 2026-04-14 23:05 (KST) +- 문서 고도화 다음 단계를 반영했습니다. [ExcelSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ExcelSkill.cs)는 `summary_sheet`에 `decision_summary`, `scorecards`, `sheet_summaries`를 추가로 받을 수 있게 확장됐고, executive summary sheet에서 의사결정 요청, 핵심 scorecard, 상세 시트별 상태를 순서대로 렌더링합니다. +- 워크북 품질 리뷰 입력도 같은 구조를 인식하도록 [ArtifactQualityReviewService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs)의 `WorkbookReviewInput`과 `ReviewWorkbook()`를 확장했습니다. 이제 summary sheet가 KPI/decision/detail summary를 충분히 담고 있는지 강점과 보완 포인트로 함께 표시합니다. +- [DocumentAssemblerTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/DocumentAssemblerTool.cs)는 `style_map` 파라미터를 받아 template-based DOCX assembly에서 `title`, `heading1`, `heading2`, `body` 문단 스타일을 실제 Word 문단에 매핑합니다. cover title, 섹션 헤딩, 본문 문단이 사내 템플릿 스타일을 더 자연스럽게 따라가도록 정리했습니다. +- 새 회귀 테스트 [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 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 diff --git a/src/AxCopilot.Tests/Services/ArtifactQualityReviewServiceTests.cs b/src/AxCopilot.Tests/Services/ArtifactQualityReviewServiceTests.cs index 2f70fc6..2ee7391 100644 --- a/src/AxCopilot.Tests/Services/ArtifactQualityReviewServiceTests.cs +++ b/src/AxCopilot.Tests/Services/ArtifactQualityReviewServiceTests.cs @@ -68,6 +68,9 @@ public class ArtifactQualityReviewServiceTests 0, false, false, + false, + false, + false, false)); review.Issues.Should().Contain(issue => issue.Message.Contains("summary sheet", StringComparison.OrdinalIgnoreCase)); diff --git a/src/AxCopilot.Tests/Services/DocumentAssemblerStyleMapTests.cs b/src/AxCopilot.Tests/Services/DocumentAssemblerStyleMapTests.cs new file mode 100644 index 0000000..3babf75 --- /dev/null +++ b/src/AxCopilot.Tests/Services/DocumentAssemblerStyleMapTests.cs @@ -0,0 +1,91 @@ +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 DocumentAssemblerStyleMapTests +{ + [Fact] + public async Task ExecuteAsync_WithStyleMap_ShouldApplyTemplateParagraphStyles() + { + var workDir = Path.Combine(Path.GetTempPath(), "ax-doc-assemble-stylemap-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workDir); + var templatePath = Path.Combine(workDir, "style-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"))))); + + var stylePart = mainPart.AddNewPart(); + stylePart.Styles = new Styles( + new Style { Type = StyleValues.Paragraph, StyleId = "DeckTitle", CustomStyle = true }, + new Style { Type = StyleValues.Paragraph, StyleId = "DeckHeading1", CustomStyle = true }, + new Style { Type = StyleValues.Paragraph, StyleId = "DeckBody", CustomStyle = true }); + stylePart.Styles.Save(); + mainPart.Document.Save(); + } + + var tool = new DocumentAssemblerTool(); + var context = new AgentContext + { + WorkFolder = workDir, + Permission = "Auto", + OperationMode = "external", + }; + + var args = JsonDocument.Parse( + """ + { + "path": "assembled-stylemap.docx", + "title": "Board Update", + "format": "docx", + "template_path": "style-template.docx", + "style_map": { + "title": "DeckTitle", + "heading1": "DeckHeading1", + "body": "DeckBody" + }, + "sections": [ + { "heading": "1. Executive Summary", "level": 1, "content": "

Margins improved across key accounts.

" } + ] + } + """).RootElement; + + var result = await tool.ExecuteAsync(args, context, CancellationToken.None); + + result.Success.Should().BeTrue(); + + using var doc = WordprocessingDocument.Open(Path.Combine(workDir, "assembled-stylemap.docx"), false); + var paragraphs = doc.MainDocumentPart!.Document.Body!.Elements().ToList(); + + paragraphs.First(paragraph => paragraph.InnerText == "Board Update") + .ParagraphProperties?.ParagraphStyleId?.Val?.Value + .Should().Be("DeckTitle"); + paragraphs.First(paragraph => paragraph.InnerText == "1. Executive Summary") + .ParagraphProperties?.ParagraphStyleId?.Val?.Value + .Should().Be("DeckHeading1"); + paragraphs.First(paragraph => paragraph.InnerText.Contains("Margins improved")) + .ParagraphProperties?.ParagraphStyleId?.Val?.Value + .Should().Be("DeckBody"); + } + finally + { + try + { + if (Directory.Exists(workDir)) + Directory.Delete(workDir, true); + } + catch + { + } + } + } +} diff --git a/src/AxCopilot.Tests/Services/ExcelSkillDashboardSummaryTests.cs b/src/AxCopilot.Tests/Services/ExcelSkillDashboardSummaryTests.cs new file mode 100644 index 0000000..ba49095 --- /dev/null +++ b/src/AxCopilot.Tests/Services/ExcelSkillDashboardSummaryTests.cs @@ -0,0 +1,91 @@ +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 ExcelSkillDashboardSummaryTests +{ + [Fact] + public async Task ExecuteAsync_WithDashboardSummary_ShouldRenderDecisionAndSheetSummaryBlocks() + { + var workDir = Path.Combine(Path.GetTempPath(), "ax-xlsx-dashboard-" + 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": "dashboard-review.xlsx", + "summary_sheet": { + "name": "Summary", + "title": "Operating Review", + "decision_summary": [ + { "label": "Decision Ask", "value": "Approve regional rollout", "owner": "COO" } + ], + "scorecards": [ + { "label": "Run-rate", "value": "96%", "status": "On Track", "note": "Ahead of plan" } + ], + "sheet_summaries": [ + { "sheet": "Revenue", "status": "Watch", "summary": "SMB margin pressure", "owner": "Sales Ops" } + ], + "highlights": ["Expansion pipeline improved"], + "actions": ["Lock next-quarter target"] + }, + "sheets": [ + { + "name": "Revenue", + "headers": ["Metric", "Value"], + "rows": [["Revenue", 120], ["Margin", 18]] + } + ] + } + """).RootElement; + + var result = await tool.ExecuteAsync(args, context, CancellationToken.None); + + result.Success.Should().BeTrue(); + result.Output.Should().Contain("Quality score"); + + using var doc = SpreadsheetDocument.Open(Path.Combine(workDir, "dashboard-review.xlsx"), false); + var workbookPart = doc.WorkbookPart!; + var summarySheet = workbookPart.Workbook.Sheets!.Elements().First(sheet => sheet.Name == "Summary"); + var summaryPart = (WorksheetPart)workbookPart.GetPartById(summarySheet.Id!); + var texts = summaryPart.Worksheet.Descendants() + .Select(cell => cell.CellValue?.Text) + .Where(text => !string.IsNullOrWhiteSpace(text)) + .ToList(); + + texts.Should().Contain("Decision Summary"); + texts.Should().Contain("Approve regional rollout"); + texts.Should().Contain("Scorecards"); + texts.Should().Contain("Run-rate"); + texts.Should().Contain("Revenue"); + texts.Should().Contain("SMB margin pressure"); + } + 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 36a3be3..4e8718c 100644 --- a/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs +++ b/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs @@ -66,7 +66,10 @@ public sealed record WorkbookReviewInput( int ConditionalFormattingCount, bool HasSummarySheet, bool HasHighlightSection, - bool HasActionSection); + bool HasActionSection, + bool HasScorecardSection, + bool HasDecisionSection, + bool HasSheetSummarySection); public static class ArtifactQualityReviewService { @@ -192,6 +195,9 @@ public static class ArtifactQualityReviewService 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.HasScorecardSection) strengths.Add("Includes KPI or scorecard section"); + if (input.HasDecisionSection) strengths.Add("Includes decision summary"); + if (input.HasSheetSummarySection) strengths.Add("Includes detail sheet summaries"); if (input.SheetCount > 1 && !input.HasSummarySheet) issues.Add(new("Workbook has multiple sheets but no summary sheet.", ArtifactReviewSeverity.Warning)); @@ -203,6 +209,8 @@ public static class ArtifactQualityReviewService 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)); + if (input.HasSummarySheet && !input.HasScorecardSection && !input.HasDecisionSection && !input.HasHighlightSection) + issues.Add(new("Summary sheet could better surface KPIs, decisions, or highlights.", ArtifactReviewSeverity.Info)); return BuildReport("xlsx", strengths, issues); } diff --git a/src/AxCopilot/Services/Agent/DocumentAssemblerTool.cs b/src/AxCopilot/Services/Agent/DocumentAssemblerTool.cs index f3b8376..aa8642b 100644 --- a/src/AxCopilot/Services/Agent/DocumentAssemblerTool.cs +++ b/src/AxCopilot/Services/Agent/DocumentAssemblerTool.cs @@ -11,6 +11,12 @@ namespace AxCopilot.Services.Agent; /// public class DocumentAssemblerTool : IAgentTool { + private sealed record AssemblerDocxStyleMap( + string? TitleStyle, + string? Heading1Style, + string? Heading2Style, + string? BodyStyle); + private static string GetDefaultOutputFormat() { var app = System.Windows.Application.Current as App; @@ -61,6 +67,7 @@ public class DocumentAssemblerTool : IAgentTool ["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." }, + ["style_map"] = new() { Type = "object", Description = "Optional paragraph style map for template-based DOCX assembly. Example: {\"title\":\"Title\",\"heading1\":\"Heading1\",\"heading2\":\"Heading2\",\"body\":\"Normal\"}." }, }, Required = ["path", "title", "sections"] }; @@ -76,6 +83,7 @@ public class DocumentAssemblerTool : IAgentTool 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 styleMapArg = args.SafeTryGetProperty("style_map", out var styleMapEl) ? styleMapEl : default; var showPageNumbers = args.SafeTryGetProperty("page_numbers", out var pageNumbersEl) ? pageNumbersEl.ValueKind == JsonValueKind.True : !string.IsNullOrWhiteSpace(headerText) || !string.IsNullOrWhiteSpace(footerText); @@ -140,7 +148,7 @@ public class DocumentAssemblerTool : IAgentTool switch (format) { case "docx": - resultMsg = AssembleDocx(fullPath, title, sections, useToc, coverSubtitle, templateFullPath, headerText, footerText, showPageNumbers); + resultMsg = AssembleDocx(fullPath, title, sections, useToc, coverSubtitle, templateFullPath, headerText, footerText, showPageNumbers, styleMapArg); break; case "markdown": resultMsg = AssembleMarkdown(fullPath, title, sections); @@ -259,7 +267,7 @@ public class DocumentAssemblerTool : IAgentTool } private string AssembleDocx(string path, string title, List<(string Heading, string Content, int Level)> sections, - bool useToc, string? coverSubtitle, string? templatePath, string? headerText, string? footerText, bool showPageNumbers) + bool useToc, string? coverSubtitle, string? templatePath, string? headerText, string? footerText, bool showPageNumbers, JsonElement styleMapArg) { var templateApplied = !string.IsNullOrWhiteSpace(templatePath); if (templateApplied) @@ -272,6 +280,7 @@ public class DocumentAssemblerTool : IAgentTool var mainPart = doc.MainDocumentPart ?? doc.AddMainDocumentPart(); EnsureAssemblerStyles(mainPart, templateApplied); + var styleMap = ResolveAssemblerStyleMap(mainPart, styleMapArg); var body = InitializeAssemblerBody(mainPart, templateApplied); @@ -308,10 +317,13 @@ public class DocumentAssemblerTool : IAgentTool string fontSize = "22", bool bold = false, string? color = null, - string? fill = null) + string? fill = null, + string? styleId = null) { var para = new DocumentFormat.OpenXml.Wordprocessing.Paragraph(); var props = new DocumentFormat.OpenXml.Wordprocessing.ParagraphProperties(); + if (!string.IsNullOrWhiteSpace(styleId)) + props.ParagraphStyleId = new DocumentFormat.OpenXml.Wordprocessing.ParagraphStyleId { Val = styleId }; props.SpacingBetweenLines = new DocumentFormat.OpenXml.Wordprocessing.SpacingBetweenLines { After = "160" @@ -336,18 +348,26 @@ public class DocumentAssemblerTool : IAgentTool para.AppendChild(props); var run = new DocumentFormat.OpenXml.Wordprocessing.Run(); - var runProps = new DocumentFormat.OpenXml.Wordprocessing.RunProperties + var applyInlineFormatting = string.IsNullOrWhiteSpace(styleId) + || !string.IsNullOrWhiteSpace(fill) + || bold + || !string.IsNullOrWhiteSpace(color) + || fontSize != "22"; + if (applyInlineFormatting) { - RunFonts = KoreanFonts(), - FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = fontSize } - }; + var runProps = new DocumentFormat.OpenXml.Wordprocessing.RunProperties + { + RunFonts = KoreanFonts(), + FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = fontSize } + }; - if (bold) - runProps.Bold = new DocumentFormat.OpenXml.Wordprocessing.Bold(); - if (!string.IsNullOrWhiteSpace(color)) - runProps.Color = new DocumentFormat.OpenXml.Wordprocessing.Color { Val = color }; + if (bold) + runProps.Bold = new DocumentFormat.OpenXml.Wordprocessing.Bold(); + if (!string.IsNullOrWhiteSpace(color)) + runProps.Color = new DocumentFormat.OpenXml.Wordprocessing.Color { Val = color }; - run.AppendChild(runProps); + run.AppendChild(runProps); + } run.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Text(text) { Space = DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve @@ -356,6 +376,24 @@ public class DocumentAssemblerTool : IAgentTool return para; } + DocumentFormat.OpenXml.Wordprocessing.Paragraph CreateTitleParagraph(string text) + => !string.IsNullOrWhiteSpace(styleMap.TitleStyle) + ? CreateParagraph(text, styleId: styleMap.TitleStyle) + : CreateParagraph(text, fontSize: "48", bold: true, color: "1F3A5F"); + + DocumentFormat.OpenXml.Wordprocessing.Paragraph CreateSectionHeadingParagraph(string text, int level) + { + var headingStyleId = level <= 1 ? styleMap.Heading1Style : styleMap.Heading2Style; + return !string.IsNullOrWhiteSpace(headingStyleId) + ? CreateParagraph(text, styleId: headingStyleId) + : CreateParagraph(text, fontSize: level <= 1 ? "32" : "28", bold: true, color: "2B579A"); + } + + DocumentFormat.OpenXml.Wordprocessing.Paragraph CreateBodyParagraph(string text) + => !string.IsNullOrWhiteSpace(styleMap.BodyStyle) + ? CreateParagraph(text, styleId: styleMap.BodyStyle) + : CreateParagraph(text); + DocumentFormat.OpenXml.Wordprocessing.Table CreateTableFromHtml(string tableHtml) { var table = new DocumentFormat.OpenXml.Wordprocessing.Table(); @@ -387,7 +425,7 @@ public class DocumentAssemblerTool : IAgentTool } else { - cell.AppendChild(CreateParagraph(cellText)); + cell.AppendChild(CreateBodyParagraph(cellText)); } cell.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.TableCellProperties( new DocumentFormat.OpenXml.Wordprocessing.TableCellWidth @@ -415,23 +453,23 @@ public class DocumentAssemblerTool : IAgentTool if (Regex.IsMatch(line, @"^#{2,6}\s+")) { - body.AppendChild(CreateParagraph(Regex.Replace(line, @"^#{2,6}\s+", ""), fontSize: "26", bold: true, color: "2B579A")); + body.AppendChild(CreateSectionHeadingParagraph(Regex.Replace(line, @"^#{2,6}\s+", ""), 2)); continue; } if (Regex.IsMatch(line, @"^[-*]\s+")) { - body.AppendChild(CreateParagraph($"• {Regex.Replace(line, @"^[-*]\s+", "")}")); + body.AppendChild(CreateBodyParagraph($"• {Regex.Replace(line, @"^[-*]\s+", "")}")); continue; } if (Regex.IsMatch(line, @"^\d+\.\s+")) { - body.AppendChild(CreateParagraph(line)); + body.AppendChild(CreateBodyParagraph(line)); continue; } - body.AppendChild(CreateParagraph(line)); + body.AppendChild(CreateBodyParagraph(line)); } } @@ -443,16 +481,16 @@ public class DocumentAssemblerTool : IAgentTool { var text = ExtractStructuredText(match.Groups[1].Value); var prefix = ordered ? $"{number}. " : "• "; - body.AppendChild(CreateParagraph(prefix + text)); + body.AppendChild(CreateBodyParagraph(prefix + text)); number++; } } var includeCover = !string.IsNullOrWhiteSpace(coverSubtitle); if (includeCover) - AppendAssemblerCoverPage(body, title, coverSubtitle!); + AppendAssemblerCoverPage(body, title, coverSubtitle!, styleMap.TitleStyle); else - body.AppendChild(CreateParagraph(title, fontSize: "48", bold: true, color: "1F3A5F")); + body.AppendChild(CreateTitleParagraph(title)); if (!includeCover) body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Paragraph()); @@ -468,18 +506,7 @@ public class DocumentAssemblerTool : IAgentTool foreach (var (heading, content, level) in sections) { - var headPara = new DocumentFormat.OpenXml.Wordprocessing.Paragraph(); - var headRun = new DocumentFormat.OpenXml.Wordprocessing.Run(); - headRun.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.RunProperties - { - RunFonts = KoreanFonts(), - Bold = new DocumentFormat.OpenXml.Wordprocessing.Bold(), - FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = level <= 1 ? "32" : "28" }, - Color = new DocumentFormat.OpenXml.Wordprocessing.Color { Val = "2B579A" }, - }); - headRun.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Text(heading)); - headPara.AppendChild(headRun); - body.AppendChild(headPara); + body.AppendChild(CreateSectionHeadingParagraph(heading, level)); headings.Add(heading); AppendStructuredContent(content); @@ -565,7 +592,11 @@ public class DocumentAssemblerTool : IAgentTool } else if (Regex.IsMatch(block, @"^() + .Any(style => string.Equals(style.StyleId?.Value, styleId, StringComparison.OrdinalIgnoreCase)) + ? styleId + : null; + } + + private static void AppendAssemblerCoverPage(DocumentFormat.OpenXml.Wordprocessing.Body body, string title, string subtitle, string? titleStyleId) + { + var titleParagraphProperties = new DocumentFormat.OpenXml.Wordprocessing.ParagraphProperties + { + Justification = new DocumentFormat.OpenXml.Wordprocessing.Justification { - Justification = new DocumentFormat.OpenXml.Wordprocessing.Justification - { - Val = DocumentFormat.OpenXml.Wordprocessing.JustificationValues.Center - }, - SpacingBetweenLines = new DocumentFormat.OpenXml.Wordprocessing.SpacingBetweenLines - { - Before = "1600", - After = "120" - } + Val = DocumentFormat.OpenXml.Wordprocessing.JustificationValues.Center }, - new DocumentFormat.OpenXml.Wordprocessing.Run( - new DocumentFormat.OpenXml.Wordprocessing.RunProperties( - new DocumentFormat.OpenXml.Wordprocessing.Bold(), - new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "44" }, - new DocumentFormat.OpenXml.Wordprocessing.Color { Val = "1F3A5F" }, - new DocumentFormat.OpenXml.Wordprocessing.RunFonts { Ascii = "맑은 고딕", HighAnsi = "맑은 고딕", EastAsia = "맑은 고딕" }), - new DocumentFormat.OpenXml.Wordprocessing.Text(title)))); + SpacingBetweenLines = new DocumentFormat.OpenXml.Wordprocessing.SpacingBetweenLines + { + Before = "1600", + After = "120" + } + }; + if (!string.IsNullOrWhiteSpace(titleStyleId)) + titleParagraphProperties.ParagraphStyleId = new DocumentFormat.OpenXml.Wordprocessing.ParagraphStyleId { Val = titleStyleId }; + + var titleRun = new DocumentFormat.OpenXml.Wordprocessing.Run(); + if (string.IsNullOrWhiteSpace(titleStyleId)) + { + titleRun.Append(new DocumentFormat.OpenXml.Wordprocessing.RunProperties( + new DocumentFormat.OpenXml.Wordprocessing.Bold(), + new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "44" }, + new DocumentFormat.OpenXml.Wordprocessing.Color { Val = "1F3A5F" }, + new DocumentFormat.OpenXml.Wordprocessing.RunFonts { Ascii = "맑은 고딕", HighAnsi = "맑은 고딕", EastAsia = "맑은 고딕" })); + } + + titleRun.Append(new DocumentFormat.OpenXml.Wordprocessing.Text(title)); + body.Append(new DocumentFormat.OpenXml.Wordprocessing.Paragraph(titleParagraphProperties, titleRun)); body.Append(new DocumentFormat.OpenXml.Wordprocessing.Paragraph( new DocumentFormat.OpenXml.Wordprocessing.ParagraphProperties diff --git a/src/AxCopilot/Services/Agent/ExcelSkill.cs b/src/AxCopilot/Services/Agent/ExcelSkill.cs index 662c50a..1a9f556 100644 --- a/src/AxCopilot/Services/Agent/ExcelSkill.cs +++ b/src/AxCopilot/Services/Agent/ExcelSkill.cs @@ -217,7 +217,10 @@ public class ExcelSkill : IAgentTool conditionalFormattingCount, false, false, - summaryArg.ValueKind == JsonValueKind.Object)); + summaryArg.ValueKind == JsonValueKind.Object, + false, + false, + false)); features += $"\n{review.ToToolSummary()}"; return ToolResult.Ok( @@ -305,7 +308,10 @@ public class ExcelSkill : IAgentTool conditionalFormattingCount, true, HasSummaryItems(summarySheet, "highlights"), - HasSummaryItems(summarySheet, "actions"))); + HasSummaryItems(summarySheet, "actions"), + HasSummaryItems(summarySheet, "scorecards") || HasSummaryItems(summarySheet, "cards") || HasSummaryItems(summarySheet, "kpis"), + HasStructuredSummaryContent(summarySheet, "decision_summary"), + HasSummaryItems(summarySheet, "sheet_summaries"))); features += $"\n{review.ToToolSummary()}"; return ToolResult.Ok( @@ -428,7 +434,10 @@ public class ExcelSkill : IAgentTool totalConditionalFormattingCount, summarySheet.ValueKind == JsonValueKind.Object, HasSummaryItems(summarySheet, "highlights"), - HasSummaryItems(summarySheet, "actions"))); + HasSummaryItems(summarySheet, "actions"), + HasSummaryItems(summarySheet, "scorecards") || HasSummaryItems(summarySheet, "cards") || HasSummaryItems(summarySheet, "kpis"), + HasStructuredSummaryContent(summarySheet, "decision_summary"), + HasSummaryItems(summarySheet, "sheet_summaries"))); return ToolResult.Ok( $"Excel 파일 생성 완료: {fullPath}\n시트: {totalSheets}개, 총 데이터 행: {totalRows}\n{review.ToToolSummary()}", fullPath); @@ -471,6 +480,9 @@ public class ExcelSkill : IAgentTool rowIndex++; + AppendDecisionSummarySection(summarySheet, sheetData, merges, ref rowIndex); + AppendScorecardSection(summarySheet, sheetData, ref rowIndex); + if (summarySheet.SafeTryGetProperty("kpis", out var kpis) && kpis.ValueKind == JsonValueKind.Array && kpis.GetArrayLength() > 0) { AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", "Key KPIs", 1); @@ -497,6 +509,7 @@ public class ExcelSkill : IAgentTool rowIndex++; } + AppendSheetSummarySection(summarySheet, sheetData, ref rowIndex); AppendSummaryTextSection(summarySheet, "highlights", "Key Highlights", sheetData, merges, ref rowIndex); AppendSummaryTextSection(summarySheet, "actions", "Next Actions", sheetData, merges, ref rowIndex); @@ -530,6 +543,115 @@ public class ExcelSkill : IAgentTool } } + private static void AppendDecisionSummarySection(JsonElement summarySheet, SheetData sheetData, MergeCells merges, ref uint rowIndex) + { + if (!summarySheet.SafeTryGetProperty("decision_summary", out var decisionSummary)) + return; + + var appended = false; + if (decisionSummary.ValueKind == JsonValueKind.Array && decisionSummary.GetArrayLength() > 0) + { + AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", "Decision Summary", 1); + foreach (var item in decisionSummary.EnumerateArray()) + { + var label = item.SafeTryGetProperty("label", out var labelEl) ? labelEl.SafeGetString() ?? "" : ""; + var value = item.SafeTryGetProperty("value", out var valueEl) ? valueEl.SafeGetString() ?? "" : ""; + var owner = item.SafeTryGetProperty("owner", out var ownerEl) ? ownerEl.SafeGetString() ?? "" : ""; + + var row = new Row { RowIndex = rowIndex++ }; + row.Append(CreateSummaryCell("A", row.RowIndex!.Value, label, 1)); + row.Append(CreateSummaryCell("B", row.RowIndex!.Value, value, 3)); + row.Append(CreateSummaryCell("C", row.RowIndex!.Value, owner, 0)); + sheetData.Append(row); + appended = true; + } + } + else if (decisionSummary.ValueKind == JsonValueKind.Object) + { + AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", "Decision Summary", 1); + foreach (var property in decisionSummary.EnumerateObject()) + { + var row = new Row { RowIndex = rowIndex++ }; + row.Append(CreateSummaryCell("A", row.RowIndex!.Value, property.Name, 1)); + row.Append(CreateSummaryCell("B", row.RowIndex!.Value, property.Value.SafeGetString() ?? property.Value.ToString(), 3)); + sheetData.Append(row); + appended = true; + } + } + + if (appended) + rowIndex++; + } + + private static void AppendScorecardSection(JsonElement summarySheet, SheetData sheetData, ref uint rowIndex) + { + JsonElement scorecards; + var hasScorecards = summarySheet.SafeTryGetProperty("scorecards", out scorecards) + && scorecards.ValueKind == JsonValueKind.Array + && scorecards.GetArrayLength() > 0; + if (!hasScorecards) + { + hasScorecards = summarySheet.SafeTryGetProperty("cards", out scorecards) + && scorecards.ValueKind == JsonValueKind.Array + && scorecards.GetArrayLength() > 0; + } + + if (!hasScorecards) + return; + + var headerRow = new Row { RowIndex = rowIndex++ }; + headerRow.Append(CreateSummaryCell("A", headerRow.RowIndex!.Value, "Scorecards", 1)); + headerRow.Append(CreateSummaryCell("B", headerRow.RowIndex!.Value, "Value", 1)); + headerRow.Append(CreateSummaryCell("C", headerRow.RowIndex!.Value, "Status", 1)); + headerRow.Append(CreateSummaryCell("D", headerRow.RowIndex!.Value, "Note", 1)); + sheetData.Append(headerRow); + + var cardIndex = 0; + foreach (var card in scorecards.EnumerateArray()) + { + var row = new Row { RowIndex = rowIndex++ }; + var stripeStyle = cardIndex % 2 == 0 ? (uint)0 : (uint)2; + row.Append(CreateSummaryCell("A", row.RowIndex!.Value, card.SafeTryGetProperty("label", out var labelEl) ? labelEl.SafeGetString() ?? "" : "", 1)); + row.Append(CreateSummaryCell("B", row.RowIndex!.Value, card.SafeTryGetProperty("value", out var valueEl) ? valueEl.SafeGetString() ?? "" : "", 3)); + row.Append(CreateSummaryCell("C", row.RowIndex!.Value, card.SafeTryGetProperty("status", out var statusEl) ? statusEl.SafeGetString() ?? "" : "", stripeStyle)); + row.Append(CreateSummaryCell("D", row.RowIndex!.Value, card.SafeTryGetProperty("note", out var noteEl) ? noteEl.SafeGetString() ?? "" : "", stripeStyle)); + sheetData.Append(row); + cardIndex++; + } + + rowIndex++; + } + + private static void AppendSheetSummarySection(JsonElement summarySheet, SheetData sheetData, ref uint rowIndex) + { + if (!summarySheet.SafeTryGetProperty("sheet_summaries", out var sheetSummaries) + || sheetSummaries.ValueKind != JsonValueKind.Array + || sheetSummaries.GetArrayLength() == 0) + return; + + var headerRow = new Row { RowIndex = rowIndex++ }; + headerRow.Append(CreateSummaryCell("A", headerRow.RowIndex!.Value, "Sheet", 1)); + headerRow.Append(CreateSummaryCell("B", headerRow.RowIndex!.Value, "Status", 1)); + headerRow.Append(CreateSummaryCell("C", headerRow.RowIndex!.Value, "Summary", 1)); + headerRow.Append(CreateSummaryCell("D", headerRow.RowIndex!.Value, "Owner", 1)); + sheetData.Append(headerRow); + + var summaryIndex = 0; + foreach (var sheetSummary in sheetSummaries.EnumerateArray()) + { + var row = new Row { RowIndex = rowIndex++ }; + var stripeStyle = summaryIndex % 2 == 0 ? (uint)0 : (uint)2; + row.Append(CreateSummaryCell("A", row.RowIndex!.Value, sheetSummary.SafeTryGetProperty("sheet", out var sheetEl) ? sheetEl.SafeGetString() ?? "" : "", 1)); + row.Append(CreateSummaryCell("B", row.RowIndex!.Value, sheetSummary.SafeTryGetProperty("status", out var statusEl) ? statusEl.SafeGetString() ?? "" : "", 3)); + row.Append(CreateSummaryCell("C", row.RowIndex!.Value, sheetSummary.SafeTryGetProperty("summary", out var summaryEl) ? summaryEl.SafeGetString() ?? "" : "", stripeStyle)); + row.Append(CreateSummaryCell("D", row.RowIndex!.Value, sheetSummary.SafeTryGetProperty("owner", out var ownerEl) ? ownerEl.SafeGetString() ?? "" : "", stripeStyle)); + sheetData.Append(row); + summaryIndex++; + } + + 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) @@ -1235,6 +1357,20 @@ public class ExcelSkill : IAgentTool && items.GetArrayLength() > 0; } + private static bool HasStructuredSummaryContent(JsonElement summarySheet, string propertyName) + { + if (summarySheet.ValueKind != JsonValueKind.Object + || !summarySheet.SafeTryGetProperty(propertyName, out var value)) + return false; + + return value.ValueKind switch + { + JsonValueKind.Array => value.GetArrayLength() > 0, + JsonValueKind.Object => value.EnumerateObject().Any(), + _ => false, + }; + } + private static string BuildFeatureList(bool isStyled, bool freezeHeader, bool hasMerges, bool hasSummary, bool hasNumFmts, bool hasAlignments, string? theme) {