diff --git a/README.md b/README.md index bc99635..7fc7f44 100644 --- a/README.md +++ b/README.md @@ -1838,3 +1838,12 @@ MIT License - 테스트로 [ExcelSkillDashboardSummaryTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/ExcelSkillDashboardSummaryTests.cs), [HtmlSkillPrintFrameTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/HtmlSkillPrintFrameTests.cs), [DeckQualityReviewServiceTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/DeckQualityReviewServiceTests.cs)를 확장해 trend dashboard, default print frame, slide-level alert를 회귀 검증했습니다. - 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_doc_next3\\ -p:IntermediateOutputPath=obj\\verify_doc_next3\\` 경고 0 / 오류 0 - 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "DeckQualityReviewServiceTests|PptxSkillAutoRepairTests|PptxSkillConsultingDeckTests|ExcelSkillDashboardSummaryTests|ExcelSkillSummarySheetTests|HtmlSkillPrintFrameTests|HtmlSkillConsultingSectionsTests|ArtifactQualityReviewServiceTests" -p:OutputPath=bin\\verify_doc_next3_tests\\ -p:IntermediateOutputPath=obj\\verify_doc_next3_tests\\` 통과 13 + +업데이트: 2026-04-14 23:25 (KST) +- 문서 고도화 다음 단계를 반영했습니다. [ExcelSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ExcelSkill.cs)는 `summary_sheet.dashboard_sheet_name`과 `trend_series`를 기반으로 summary 시트 앞뒤에 별도 `Dashboard` 시트를 생성합니다. decision summary, scorecards, trend dashboard, detail-sheet links를 실제 워크북 시트로 분리해 multi-sheet 보고서형 workbook 완성도를 높였습니다. +- 같은 파일의 workbook review 입력과 [ArtifactQualityReviewService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs)는 `HasDashboardSheet`를 인식하도록 확장됐습니다. summary sheet만 있고 dashboard가 없는 multi-sheet workbook은 개선 포인트로 잡고, dashboard가 있으면 강점으로 점수화합니다. +- [DocumentAssemblerTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/DocumentAssemblerTool.cs)는 `style_map`을 `cover_subtitle`, `callout`, `table_header`까지 확장했습니다. 이제 템플릿 기반 DOCX 조립에서 cover subtitle, 강조 블록, 표 헤더 문단도 사내 Word 스타일을 직접 따를 수 있습니다. +- 테스트로 [DocumentAssemblerStyleMapTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/DocumentAssemblerStyleMapTests.cs)를 확장해 subtitle/callout/table header style 적용을 검증했고, [ExcelSkillDashboardSummaryTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/ExcelSkillDashboardSummaryTests.cs)는 실제 `Dashboard` 시트 생성과 링크 텍스트까지 확인하도록 보강했습니다. +- [PptxSkillGoldenDeckTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/PptxSkillGoldenDeckTests.cs)를 추가해 strong board deck이 `PPT quality` 요약을 안정적으로 반환하고 불필요한 `Slide alerts` 없이 통과하는지 golden 회귀로 고정했습니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_doc_next4\\ -p:IntermediateOutputPath=obj\\verify_doc_next4\\` 경고 0 / 오류 0 +- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ArtifactQualityReviewServiceTests|DocumentAssemblerStyleMapTests|DocumentAssemblerDocxFeaturesTests|DocumentAssemblerSemanticTests|ExcelSkillDashboardSummaryTests|ExcelSkillSummarySheetTests|ExcelSkillExecutiveSummaryLinkTests|ExcelSkillDataValidationTests|ExcelSkillConditionalFormattingTests|HtmlSkillPrintFrameTests|HtmlSkillConsultingSectionsTests|DeckQualityReviewServiceTests|PptxSkillAutoRepairTests|PptxSkillConsultingDeckTests|PptxSkillGoldenDeckTests" -p:OutputPath=bin\\verify_doc_next4_tests\\ -p:IntermediateOutputPath=obj\\verify_doc_next4_tests\\` 통과 20 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 8743087..7093613 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -910,3 +910,13 @@ UI ?붿옄???€洹쒕え 由ы뙥?좊쭅 ???꾪뿕 ?묒뾽 ??湲곕줉???덉쟾 - 테스트로 [ExcelSkillDashboardSummaryTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/ExcelSkillDashboardSummaryTests.cs), [HtmlSkillPrintFrameTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/HtmlSkillPrintFrameTests.cs), [DeckQualityReviewServiceTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/DeckQualityReviewServiceTests.cs)를 확장했습니다. - 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_doc_next3\\ -p:IntermediateOutputPath=obj\\verify_doc_next3\\` 경고 0 / 오류 0 - 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "DeckQualityReviewServiceTests|PptxSkillAutoRepairTests|PptxSkillConsultingDeckTests|ExcelSkillDashboardSummaryTests|ExcelSkillSummarySheetTests|HtmlSkillPrintFrameTests|HtmlSkillConsultingSectionsTests|ArtifactQualityReviewServiceTests" -p:OutputPath=bin\\verify_doc_next3_tests\\ -p:IntermediateOutputPath=obj\\verify_doc_next3_tests\\` 통과 13 + +업데이트: 2026-04-14 23:25 (KST) +- [ExcelSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ExcelSkill.cs)는 `summary_sheet.dashboard_sheet_name`과 `trend_series`를 바탕으로 별도 `Dashboard` worksheet를 생성합니다. summary sheet에서 decision summary, scorecards, trend dashboard, detail sheet links를 요약하고, dashboard sheet에서는 이를 한 장의 workbook dashboard로 다시 정리해 summary-only workbook보다 분석/보고 밀도를 높입니다. +- 같은 파일의 single-sheet / multi-sheet workbook 생성 경로는 dashboard sheet가 있으면 시트 순서를 `Summary -> Dashboard -> Detail...`로 정렬하고, summary hyperlink 수와 total sheet count를 품질 리뷰 입력에 정확히 전달합니다. +- [ArtifactQualityReviewService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs)의 `WorkbookReviewInput`은 `HasDashboardSheet`를 새로 받습니다. dashboard가 있는 workbook은 강점으로 계산하고, detail sheet가 여러 장인데 dashboard가 없는 경우는 보완 포인트로 돌려줍니다. +- [DocumentAssemblerTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/DocumentAssemblerTool.cs)는 `style_map` 지원 범위를 `cover_subtitle`, `callout`, `table_header`까지 넓혔습니다. cover subtitle 문단, 강조 블록 paragraph, 표 header cell paragraph가 사내 DOCX 템플릿 스타일을 실제로 타도록 조립 경로를 연결했습니다. +- [DocumentAssemblerStyleMapTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/DocumentAssemblerStyleMapTests.cs)는 title/heading/body뿐 아니라 subtitle/callout/table header 스타일까지 회귀 검증하도록 보강했습니다. +- [PptxSkillGoldenDeckTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/PptxSkillGoldenDeckTests.cs)를 추가해 strong board deck이 `PPT quality` 요약을 안정적으로 반환하고 불필요한 `Slide alerts` 없이 통과하는지 golden regression으로 고정했습니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_doc_next4\\ -p:IntermediateOutputPath=obj\\verify_doc_next4\\` 경고 0 / 오류 0 +- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ArtifactQualityReviewServiceTests|DocumentAssemblerStyleMapTests|DocumentAssemblerDocxFeaturesTests|DocumentAssemblerSemanticTests|ExcelSkillDashboardSummaryTests|ExcelSkillSummarySheetTests|ExcelSkillExecutiveSummaryLinkTests|ExcelSkillDataValidationTests|ExcelSkillConditionalFormattingTests|HtmlSkillPrintFrameTests|HtmlSkillConsultingSectionsTests|DeckQualityReviewServiceTests|PptxSkillAutoRepairTests|PptxSkillConsultingDeckTests|PptxSkillGoldenDeckTests" -p:OutputPath=bin\\verify_doc_next4_tests\\ -p:IntermediateOutputPath=obj\\verify_doc_next4_tests\\` 통과 20 diff --git a/src/AxCopilot.Tests/Services/ArtifactQualityReviewServiceTests.cs b/src/AxCopilot.Tests/Services/ArtifactQualityReviewServiceTests.cs index 2ee7391..f17a3bf 100644 --- a/src/AxCopilot.Tests/Services/ArtifactQualityReviewServiceTests.cs +++ b/src/AxCopilot.Tests/Services/ArtifactQualityReviewServiceTests.cs @@ -71,6 +71,7 @@ public class ArtifactQualityReviewServiceTests 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 index 3babf75..613fddd 100644 --- a/src/AxCopilot.Tests/Services/DocumentAssemblerStyleMapTests.cs +++ b/src/AxCopilot.Tests/Services/DocumentAssemblerStyleMapTests.cs @@ -28,7 +28,10 @@ public class DocumentAssemblerStyleMapTests 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 }); + new Style { Type = StyleValues.Paragraph, StyleId = "DeckBody", CustomStyle = true }, + new Style { Type = StyleValues.Paragraph, StyleId = "DeckSubtitle", CustomStyle = true }, + new Style { Type = StyleValues.Paragraph, StyleId = "DeckCallout", CustomStyle = true }, + new Style { Type = StyleValues.Paragraph, StyleId = "DeckTableHeader", CustomStyle = true }); stylePart.Styles.Save(); mainPart.Document.Save(); } @@ -48,13 +51,17 @@ public class DocumentAssemblerStyleMapTests "title": "Board Update", "format": "docx", "template_path": "style-template.docx", + "cover_subtitle": "Quarterly Steering Pack", "style_map": { "title": "DeckTitle", "heading1": "DeckHeading1", - "body": "DeckBody" + "body": "DeckBody", + "cover_subtitle": "DeckSubtitle", + "callout": "DeckCallout", + "table_header": "DeckTableHeader" }, "sections": [ - { "heading": "1. Executive Summary", "level": 1, "content": "

Margins improved across key accounts.

" } + { "heading": "1. Executive Summary", "level": 1, "content": "

Margins improved across key accounts.

Focus on margin recovery
MetricValue
Margin18%
" } ] } """).RootElement; @@ -69,12 +76,28 @@ public class DocumentAssemblerStyleMapTests paragraphs.First(paragraph => paragraph.InnerText == "Board Update") .ParagraphProperties?.ParagraphStyleId?.Val?.Value .Should().Be("DeckTitle"); + paragraphs.First(paragraph => paragraph.InnerText == "Quarterly Steering Pack") + .ParagraphProperties?.ParagraphStyleId?.Val?.Value + .Should().Be("DeckSubtitle"); 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"); + paragraphs.First(paragraph => paragraph.InnerText.Contains("Focus on margin recovery")) + .ParagraphProperties?.ParagraphStyleId?.Val?.Value + .Should().Be("DeckCallout"); + + var firstHeaderParagraph = doc.MainDocumentPart.Document.Body! + .Descendants() + .First() + .Descendants() + .First() + .Descendants() + .First(); + firstHeaderParagraph.ParagraphProperties?.ParagraphStyleId?.Val?.Value + .Should().Be("DeckTableHeader"); } finally { diff --git a/src/AxCopilot.Tests/Services/ExcelSkillDashboardSummaryTests.cs b/src/AxCopilot.Tests/Services/ExcelSkillDashboardSummaryTests.cs index be41645..f170caf 100644 --- a/src/AxCopilot.Tests/Services/ExcelSkillDashboardSummaryTests.cs +++ b/src/AxCopilot.Tests/Services/ExcelSkillDashboardSummaryTests.cs @@ -32,6 +32,7 @@ public class ExcelSkillDashboardSummaryTests "path": "dashboard-review.xlsx", "summary_sheet": { "name": "Summary", + "dashboard_sheet_name": "Dashboard", "title": "Operating Review", "decision_summary": [ { "label": "Decision Ask", "value": "Approve regional rollout", "owner": "COO" } @@ -65,6 +66,9 @@ public class ExcelSkillDashboardSummaryTests using var doc = SpreadsheetDocument.Open(Path.Combine(workDir, "dashboard-review.xlsx"), false); var workbookPart = doc.WorkbookPart!; + workbookPart.Workbook.Sheets!.Elements().Select(sheet => sheet.Name!.Value) + .Should().Contain(["Summary", "Dashboard", "Revenue"]); + var summarySheet = workbookPart.Workbook.Sheets!.Elements().First(sheet => sheet.Name == "Summary"); var summaryPart = (WorksheetPart)workbookPart.GetPartById(summarySheet.Id!); var texts = summaryPart.Worksheet.Descendants() @@ -80,6 +84,16 @@ public class ExcelSkillDashboardSummaryTests texts.Should().Contain("-10"); texts.Should().Contain("Revenue"); texts.Should().Contain("SMB margin pressure"); + + var dashboardSheet = workbookPart.Workbook.Sheets!.Elements().First(sheet => sheet.Name == "Dashboard"); + var dashboardPart = (WorksheetPart)workbookPart.GetPartById(dashboardSheet.Id!); + var dashboardTexts = dashboardPart.Worksheet.Descendants() + .Select(cell => cell.CellValue?.Text) + .Where(text => !string.IsNullOrWhiteSpace(text)) + .ToList(); + dashboardTexts.Should().Contain("Operating Review Dashboard"); + dashboardTexts.Should().Contain("Linked Detail Sheets"); + dashboardTexts.Should().Contain("Open: Revenue"); } finally { diff --git a/src/AxCopilot.Tests/Services/PptxSkillGoldenDeckTests.cs b/src/AxCopilot.Tests/Services/PptxSkillGoldenDeckTests.cs new file mode 100644 index 0000000..caf86f2 --- /dev/null +++ b/src/AxCopilot.Tests/Services/PptxSkillGoldenDeckTests.cs @@ -0,0 +1,124 @@ +using System.IO; +using System.Text.Json; +using AxCopilot.Services.Agent; +using FluentAssertions; +using Xunit; + +namespace AxCopilot.Tests.Services; + +public class PptxSkillGoldenDeckTests +{ + [Fact] + public async Task ExecuteAsync_WithStrongBoardDeck_ShouldReturnStableQualitySummary() + { + var workDir = Path.Combine(Path.GetTempPath(), "ax-pptx-golden-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workDir); + + try + { + var tool = new PptxSkill(); + var context = new AgentContext + { + WorkFolder = workDir, + Permission = "Auto", + OperationMode = "external", + }; + + var args = JsonDocument.Parse( + """ + { + "path": "board-golden.pptx", + "theme": "professional", + "audience": "Executive committee", + "objective": "Approve the phase-1 operating model rollout", + "decision_ask": "Approve the phase-1 rollout and funding release", + "slides": [ + { + "layout": "title", + "title": "Operating Model Refresh" + }, + { + "layout": "executive_summary", + "title": "Executive Summary", + "headline": "Phase-1 rollout can improve service consistency while protecting margin.", + "summary_points": [ + "Three pilot functions account for most process variation and rework.", + "Phase-1 can be delivered without slowing current-quarter commitments.", + "The operating model change is expected to reduce handoff delay by 18%." + ], + "recommendation": "Approve the phase-1 rollout and lock the pilot governance model.", + "kpis": [ + { "label": "Cycle Time", "value": "-18%", "trend": "target", "note": "handoff simplification" }, + { "label": "Margin", "value": "+2.4pt", "trend": "run-rate", "note": "mix and rework" }, + { "label": "On-time Delivery", "value": "96%", "trend": "pilot", "note": "stable" } + ] + }, + { + "layout": "comparison", + "title": "Options", + "headline": "A phased rollout balances speed, control, and adoption risk.", + "options": [ + { "name": "Pilot only", "pros": "Lowest delivery risk", "cons": "Value capture is delayed", "verdict": "Too cautious" }, + { "name": "Phased rollout", "pros": "Balanced speed and control", "cons": "Requires PMO discipline", "verdict": "Recommended" }, + { "name": "Full rollout", "pros": "Fastest value capture", "cons": "High adoption risk", "verdict": "Too disruptive" } + ] + }, + { + "layout": "roadmap", + "title": "Roadmap", + "headline": "Roll out in three waves with explicit governance checkpoints.", + "phases": [ + { "title": "Design", "detail": "Finalize scope, KPIs, and governance", "timeline": "Weeks 1-3", "owner": "PMO" }, + { "title": "Pilot", "detail": "Launch pilot functions and refine playbooks", "timeline": "Weeks 4-8", "owner": "Operations" }, + { "title": "Scale", "detail": "Expand to the remaining regions", "timeline": "Weeks 9-14", "owner": "Business" } + ] + }, + { + "layout": "recommendation", + "title": "Recommendation", + "recommendation": "Approve phase-1 rollout, assign PMO governance, and release pilot funding.", + "summary_points": [ + "The phased path protects execution quality while capturing measurable value in-quarter.", + "The pilot governance model can be reused for the scale phase.", + "The decision can be taken now because the key delivery risks are already bounded." + ], + "next_steps": [ + "Confirm phase-1 scope this week", + "Launch pilot governance cadence next week", + "Review pilot performance at week 8" + ] + }, + { + "layout": "table", + "title": "Appendix & Evidence", + "headers": ["Evidence", "Detail"], + "rows": [ + ["Process review", "Pilot teams show the highest rework and handoff load."], + ["Financial impact", "Run-rate margin lift is driven by lower rework and better prioritization."] + ] + } + ] + } + """).RootElement; + + var result = await tool.ExecuteAsync(args, context, CancellationToken.None); + + result.Success.Should().BeTrue(); + File.Exists(Path.Combine(workDir, "board-golden.pptx")).Should().BeTrue(); + result.Output.Should().Contain("PPT quality"); + result.Output.Should().NotContain("Slide alerts:"); + result.Output.Should().Contain("Needs work: none"); + } + 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 ce5df48..38a7437 100644 --- a/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs +++ b/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs @@ -68,6 +68,7 @@ public sealed record WorkbookReviewInput( int DataValidationCount, int ConditionalFormattingCount, bool HasSummarySheet, + bool HasDashboardSheet, bool HasHighlightSection, bool HasActionSection, bool HasScorecardSection, @@ -197,6 +198,7 @@ public static class ArtifactQualityReviewService var issues = new List(); if (input.HasSummarySheet) strengths.Add("Includes summary sheet"); + if (input.HasDashboardSheet) strengths.Add("Includes dashboard 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"); @@ -220,6 +222,8 @@ public static class ArtifactQualityReviewService 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)); + if (input.HasSummarySheet && input.DetailSheetCount >= 2 && !input.HasDashboardSheet) + issues.Add(new("Workbook could benefit from a dashboard sheet to summarize multi-sheet trends.", ArtifactReviewSeverity.Info)); return BuildReport("xlsx", strengths, issues); } diff --git a/src/AxCopilot/Services/Agent/DocumentAssemblerTool.cs b/src/AxCopilot/Services/Agent/DocumentAssemblerTool.cs index aa8642b..d631b4c 100644 --- a/src/AxCopilot/Services/Agent/DocumentAssemblerTool.cs +++ b/src/AxCopilot/Services/Agent/DocumentAssemblerTool.cs @@ -15,7 +15,10 @@ public class DocumentAssemblerTool : IAgentTool string? TitleStyle, string? Heading1Style, string? Heading2Style, - string? BodyStyle); + string? BodyStyle, + string? SubtitleStyle, + string? CalloutStyle, + string? TableHeaderStyle); private static string GetDefaultOutputFormat() { @@ -67,7 +70,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\"}." }, + ["style_map"] = new() { Type = "object", Description = "Optional paragraph style map for template-based DOCX assembly. Example: {\"title\":\"Title\",\"heading1\":\"Heading1\",\"heading2\":\"Heading2\",\"body\":\"Normal\",\"cover_subtitle\":\"Subtitle\",\"callout\":\"Quote\",\"table_header\":\"TableHeader\"}." }, }, Required = ["path", "title", "sections"] }; @@ -421,7 +424,9 @@ public class DocumentAssemblerTool : IAgentTool var cell = new DocumentFormat.OpenXml.Wordprocessing.TableCell(); if (isHeader) { - cell.AppendChild(CreateParagraph(cellText, fontSize: "21", bold: true, color: "1F3A5F", fill: "E8EEF8")); + cell.AppendChild(!string.IsNullOrWhiteSpace(styleMap.TableHeaderStyle) + ? CreateParagraph(cellText, fontSize: "21", bold: true, color: "1F3A5F", fill: "E8EEF8", styleId: styleMap.TableHeaderStyle) + : CreateParagraph(cellText, fontSize: "21", bold: true, color: "1F3A5F", fill: "E8EEF8")); } else { @@ -488,7 +493,7 @@ public class DocumentAssemblerTool : IAgentTool var includeCover = !string.IsNullOrWhiteSpace(coverSubtitle); if (includeCover) - AppendAssemblerCoverPage(body, title, coverSubtitle!, styleMap.TitleStyle); + AppendAssemblerCoverPage(body, title, coverSubtitle!, styleMap.TitleStyle, styleMap.SubtitleStyle); else body.AppendChild(CreateTitleParagraph(title)); @@ -601,7 +606,13 @@ public class DocumentAssemblerTool : IAgentTool else if (Regex.IsMatch(block, @"^<(blockquote|div)", RegexOptions.IgnoreCase)) { var fill = Regex.IsMatch(block, "highlight-box", RegexOptions.IgnoreCase) ? "FFF7DA" : "EDF4FF"; - body.AppendChild(CreateParagraph(ExtractStructuredText(block), fontSize: "21", bold: true, color: "1F3A5F", fill: fill)); + body.AppendChild(CreateParagraph( + ExtractStructuredText(block), + fontSize: "21", + bold: true, + color: "1F3A5F", + fill: fill, + styleId: styleMap.CalloutStyle)); if (Regex.IsMatch(block, "highlight-box", RegexOptions.IgnoreCase)) highlightCount++; else @@ -705,13 +716,16 @@ public class DocumentAssemblerTool : IAgentTool JsonElement styleMapArg) { if (styleMapArg.ValueKind != JsonValueKind.Object) - return new AssemblerDocxStyleMap(null, null, null, null); + return new AssemblerDocxStyleMap(null, null, null, null, null, null, null); return new AssemblerDocxStyleMap( FindAssemblerStyle(mainPart, styleMapArg.SafeTryGetProperty("title", out var titleEl) ? titleEl.SafeGetString() : null), FindAssemblerStyle(mainPart, styleMapArg.SafeTryGetProperty("heading1", out var heading1El) ? heading1El.SafeGetString() : null), FindAssemblerStyle(mainPart, styleMapArg.SafeTryGetProperty("heading2", out var heading2El) ? heading2El.SafeGetString() : null), - FindAssemblerStyle(mainPart, styleMapArg.SafeTryGetProperty("body", out var bodyEl) ? bodyEl.SafeGetString() : null)); + FindAssemblerStyle(mainPart, styleMapArg.SafeTryGetProperty("body", out var bodyEl) ? bodyEl.SafeGetString() : null), + FindAssemblerStyle(mainPart, styleMapArg.SafeTryGetProperty("cover_subtitle", out var subtitleEl) ? subtitleEl.SafeGetString() : null), + FindAssemblerStyle(mainPart, styleMapArg.SafeTryGetProperty("callout", out var calloutEl) ? calloutEl.SafeGetString() : null), + FindAssemblerStyle(mainPart, styleMapArg.SafeTryGetProperty("table_header", out var tableHeaderEl) ? tableHeaderEl.SafeGetString() : null)); } private static string? FindAssemblerStyle( @@ -732,7 +746,7 @@ public class DocumentAssemblerTool : IAgentTool : null; } - private static void AppendAssemblerCoverPage(DocumentFormat.OpenXml.Wordprocessing.Body body, string title, string subtitle, string? titleStyleId) + private static void AppendAssemblerCoverPage(DocumentFormat.OpenXml.Wordprocessing.Body body, string title, string subtitle, string? titleStyleId, string? subtitleStyleId) { var titleParagraphProperties = new DocumentFormat.OpenXml.Wordprocessing.ParagraphProperties { @@ -762,24 +776,30 @@ public class DocumentAssemblerTool : IAgentTool 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 + var subtitleParagraphProperties = 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 - { - After = "260" - } + Val = DocumentFormat.OpenXml.Wordprocessing.JustificationValues.Center }, - new DocumentFormat.OpenXml.Wordprocessing.Run( - new DocumentFormat.OpenXml.Wordprocessing.RunProperties( - new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "26" }, - new DocumentFormat.OpenXml.Wordprocessing.Color { Val = "5B6472" }, - new DocumentFormat.OpenXml.Wordprocessing.RunFonts { Ascii = "맑은 고딕", HighAnsi = "맑은 고딕", EastAsia = "맑은 고딕" }), - new DocumentFormat.OpenXml.Wordprocessing.Text(subtitle)))); + SpacingBetweenLines = new DocumentFormat.OpenXml.Wordprocessing.SpacingBetweenLines + { + After = "260" + } + }; + if (!string.IsNullOrWhiteSpace(subtitleStyleId)) + subtitleParagraphProperties.ParagraphStyleId = new DocumentFormat.OpenXml.Wordprocessing.ParagraphStyleId { Val = subtitleStyleId }; + + var subtitleRun = new DocumentFormat.OpenXml.Wordprocessing.Run(); + if (string.IsNullOrWhiteSpace(subtitleStyleId)) + { + subtitleRun.Append(new DocumentFormat.OpenXml.Wordprocessing.RunProperties( + new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "26" }, + new DocumentFormat.OpenXml.Wordprocessing.Color { Val = "5B6472" }, + new DocumentFormat.OpenXml.Wordprocessing.RunFonts { Ascii = "맑은 고딕", HighAnsi = "맑은 고딕", EastAsia = "맑은 고딕" })); + } + subtitleRun.Append(new DocumentFormat.OpenXml.Wordprocessing.Text(subtitle)); + body.Append(new DocumentFormat.OpenXml.Wordprocessing.Paragraph(subtitleParagraphProperties, subtitleRun)); 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 7264a10..2c2fb65 100644 --- a/src/AxCopilot/Services/Agent/ExcelSkill.cs +++ b/src/AxCopilot/Services/Agent/ExcelSkill.cs @@ -217,7 +217,8 @@ public class ExcelSkill : IAgentTool conditionalFormattingCount, false, false, - summaryArg.ValueKind == JsonValueKind.Object, + false, + false, false, false, false)); @@ -259,12 +260,17 @@ public class ExcelSkill : IAgentTool stylesPart.Stylesheet.Save(); var wbSheets = workbookPart.Workbook.AppendChild(new Sheets()); + var dashboardSheetName = GetDashboardSheetName(summarySheet); + var hasDashboardSheet = HasDashboardSheet(summarySheet); + var summaryLinkedSheets = hasDashboardSheet + ? new List { dashboardSheetName, sheetName } + : [sheetName]; var summaryName = summarySheet.SafeTryGetProperty("name", out var summaryNameEl) ? summaryNameEl.SafeGetString() ?? "Summary" : "Summary"; var summaryPart = workbookPart.AddNewPart(); - WriteExecutiveSummarySheet(summaryPart, summarySheet, [sheetName]); + WriteExecutiveSummarySheet(summaryPart, summarySheet, summaryLinkedSheets); wbSheets.Append(new Sheet { Id = workbookPart.GetIdOfPart(summaryPart), @@ -272,6 +278,19 @@ public class ExcelSkill : IAgentTool Name = summaryName, }); + uint nextSheetId = 2; + if (hasDashboardSheet) + { + var dashboardPart = workbookPart.AddNewPart(); + WriteDashboardSheet(dashboardPart, summarySheet, [sheetName]); + wbSheets.Append(new Sheet + { + Id = workbookPart.GetIdOfPart(dashboardPart), + SheetId = nextSheetId++, + Name = dashboardSheetName, + }); + } + var worksheetPart = workbookPart.AddNewPart(); worksheetPart.Worksheet = new Worksheet(); @@ -287,7 +306,7 @@ public class ExcelSkill : IAgentTool wbSheets.Append(new Sheet { Id = workbookPart.GetIdOfPart(worksheetPart), - SheetId = 2, + SheetId = nextSheetId, Name = sheetName, }); @@ -299,14 +318,15 @@ public class ExcelSkill : IAgentTool numFmts.Count > 0, alignments.Count > 0, themeName); var review = ArtifactQualityReviewService.ReviewWorkbook(new WorkbookReviewInput( summaryName, - 2, + hasDashboardSheet ? 3 : 2, 1, rowCount, CountFormulaCells(rows), - 1, + summaryLinkedSheets.Count, validationCount, conditionalFormattingCount, true, + hasDashboardSheet, HasSummaryItems(summarySheet, "highlights"), HasSummaryItems(summarySheet, "actions"), HasSummaryItems(summarySheet, "scorecards") || HasSummaryItems(summarySheet, "cards") || HasSummaryItems(summarySheet, "kpis") || HasSummaryItems(summarySheet, "trend_series"), @@ -356,6 +376,8 @@ public class ExcelSkill : IAgentTool var totalValidationCount = 0; var totalConditionalFormattingCount = 0; var detailSheetNames = new List(); + var dashboardSheetName = GetDashboardSheetName(summarySheet); + var hasDashboardSheet = HasDashboardSheet(summarySheet); foreach (var sheetDef in sheetsArr.EnumerateArray()) { @@ -371,13 +393,28 @@ public class ExcelSkill : IAgentTool ? summaryNameEl.SafeGetString() ?? "Summary" : "Summary"; var summaryPart = workbookPart.AddNewPart(); - WriteExecutiveSummarySheet(summaryPart, summarySheet, detailSheetNames); + var summaryLinkedSheets = hasDashboardSheet + ? new[] { dashboardSheetName }.Concat(detailSheetNames).ToList() + : detailSheetNames; + WriteExecutiveSummarySheet(summaryPart, summarySheet, summaryLinkedSheets); wbSheets.Append(new Sheet { Id = workbookPart.GetIdOfPart(summaryPart), SheetId = sheetId++, Name = summaryName, }); + + if (hasDashboardSheet) + { + var dashboardPart = workbookPart.AddNewPart(); + WriteDashboardSheet(dashboardPart, summarySheet, detailSheetNames); + wbSheets.Append(new Sheet + { + Id = workbookPart.GetIdOfPart(dashboardPart), + SheetId = sheetId++, + Name = dashboardSheetName, + }); + } } foreach (var sheetDef in sheetsArr.EnumerateArray()) @@ -425,14 +462,15 @@ public class ExcelSkill : IAgentTool workbookPart.Workbook.Save(); var review = ArtifactQualityReviewService.ReviewWorkbook(new WorkbookReviewInput( fullPath, - totalSheets + (summarySheet.ValueKind == JsonValueKind.Object ? 1 : 0), + totalSheets + (summarySheet.ValueKind == JsonValueKind.Object ? 1 : 0) + (hasDashboardSheet ? 1 : 0), totalSheets, totalRows, totalFormulaCount, - summarySheet.ValueKind == JsonValueKind.Object ? detailSheetNames.Count : 0, + summarySheet.ValueKind == JsonValueKind.Object ? detailSheetNames.Count + (hasDashboardSheet ? 1 : 0) : 0, totalValidationCount, totalConditionalFormattingCount, summarySheet.ValueKind == JsonValueKind.Object, + hasDashboardSheet, HasSummaryItems(summarySheet, "highlights"), HasSummaryItems(summarySheet, "actions"), HasSummaryItems(summarySheet, "scorecards") || HasSummaryItems(summarySheet, "cards") || HasSummaryItems(summarySheet, "kpis") || HasSummaryItems(summarySheet, "trend_series"), @@ -544,6 +582,72 @@ public class ExcelSkill : IAgentTool } } + private static void WriteDashboardSheet(WorksheetPart worksheetPart, JsonElement summarySheet, IReadOnlyList detailSheetNames) + { + worksheetPart.Worksheet = new Worksheet(); + + var columns = new Columns( + new Column { Min = 1, Max = 1, Width = 22, CustomWidth = true }, + new Column { Min = 2, Max = 2, Width = 18, CustomWidth = true }, + new Column { Min = 3, Max = 3, Width = 18, CustomWidth = true }, + new Column { Min = 4, Max = 4, Width = 22, CustomWidth = true }, + new Column { Min = 5, Max = 5, Width = 18, CustomWidth = true }, + new Column { Min = 6, Max = 6, Width = 30, CustomWidth = true }); + worksheetPart.Worksheet.Append(columns); + + var sheetData = new SheetData(); + worksheetPart.Worksheet.Append(sheetData); + + var merges = new MergeCells(); + var hyperlinks = new Hyperlinks(); + uint rowIndex = 1; + + var baseTitle = summarySheet.SafeTryGetProperty("title", out var titleEl) + ? titleEl.SafeGetString() ?? "Executive Summary" + : "Executive Summary"; + var subtitle = summarySheet.SafeTryGetProperty("subtitle", out var subtitleEl) + ? subtitleEl.SafeGetString() ?? string.Empty + : string.Empty; + + AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", $"{baseTitle} Dashboard", 1); + if (!string.IsNullOrWhiteSpace(subtitle)) + AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", subtitle, 3); + + rowIndex++; + + AppendDecisionSummarySection(summarySheet, sheetData, merges, ref rowIndex); + AppendScorecardSection(summarySheet, sheetData, ref rowIndex); + AppendTrendSection(summarySheet, sheetData, ref rowIndex); + AppendSheetSummarySection(summarySheet, sheetData, ref rowIndex); + AppendSummaryTextSection(summarySheet, "actions", "Priority Actions", sheetData, merges, ref rowIndex); + + if (detailSheetNames.Count > 0) + { + AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", "Linked Detail Sheets", 1); + var startRow = rowIndex; + foreach (var detailSheetName in detailSheetNames) + AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", $"Open: {detailSheetName}", 0); + + for (var i = 0; i < detailSheetNames.Count; i++) + { + hyperlinks.Append(new Hyperlink + { + Reference = $"A{startRow + (uint)i}", + Location = $"'{detailSheetNames[i]}'!A1", + Display = detailSheetNames[i] + }); + } + } + + if (merges.HasChildren) + worksheetPart.Worksheet.InsertAfter(merges, sheetData); + if (hyperlinks.HasChildren) + { + var anchor = merges.HasChildren ? (OpenXmlElement)merges : sheetData; + worksheetPart.Worksheet.InsertAfter(hyperlinks, anchor); + } + } + private static void AppendDecisionSummarySection(JsonElement summarySheet, SheetData sheetData, MergeCells merges, ref uint rowIndex) { if (!summarySheet.SafeTryGetProperty("decision_summary", out var decisionSummary)) @@ -685,6 +789,31 @@ public class ExcelSkill : IAgentTool rowIndex++; } + private static bool HasDashboardSheet(JsonElement summarySheet) + { + if (summarySheet.ValueKind != JsonValueKind.Object) + return false; + + if (summarySheet.SafeTryGetProperty("dashboard_sheet_name", out var dashboardNameEl) + && dashboardNameEl.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(dashboardNameEl.SafeGetString())) + return true; + + return summarySheet.SafeTryGetProperty("trend_series", out var trendSeries) + && trendSeries.ValueKind == JsonValueKind.Array + && trendSeries.GetArrayLength() > 0; + } + + private static string GetDashboardSheetName(JsonElement summarySheet) + { + if (summarySheet.SafeTryGetProperty("dashboard_sheet_name", out var dashboardNameEl) + && dashboardNameEl.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(dashboardNameEl.SafeGetString())) + return dashboardNameEl.SafeGetString()!; + + return "Dashboard"; + } + 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)