From 1ad5eea32ec6f3cb1e5af8e1e1a6c6fc3eabc54e Mon Sep 17 00:00:00 2001 From: lacvet Date: Tue, 14 Apr 2026 22:15:50 +0900 Subject: [PATCH] ?? planner/assembler ??? 2? ?? ?? ??: - Word/HTML/Excel ?? ?? ??? PPT ??? planner ?? ??? ? ??? ??? ?? ?? ??? ?????. - ??? ???? document_plan ???? ?? ??? ? ??? xlsx scaffold ??? ?????. ?? ????: - DocumentPlannerTool? format:xlsx ??? ???? summary_sheet + sheets ??? excel_create scaffold? ????? ??????. - DocumentPlannerTool? ?? ?? ??? workbook/tracker/dashboard/scorecard ???? ????? ??????. - DocumentAssemblerTool? DOCX ?? ??? cover_subtitle, TOC, header/footer ??? ???? ?? ???? DOCX ?? ?? ??? ??????. - DocumentAssemblerTool? HTML ?? ??? ArtifactQualityReviewService? ??? score ?? ?? ??? ????? ??????. - kpi-workbook ???? document_plan ??? ??? complex workbook ?? ? planner ??? ??? ? ?? ????. - ?? ?? ???? DocumentPlannerWorkbookScaffoldTests, DocumentAssemblerDocxFeaturesTests? ??????. - README.md? docs/DEVELOPMENT.md? 2026-04-14 22:14 (KST) ?? ?? ??? ??????. ?? ??: - 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 --- README.md | 12 +- docs/DEVELOPMENT.md | 10 + .../DocumentAssemblerDocxFeaturesTests.cs | 75 ++++ .../DocumentPlannerWorkbookScaffoldTests.cs | 41 ++ .../Services/Agent/DocumentAssemblerTool.cs | 362 +++++++++++++++--- .../Services/Agent/DocumentPlannerTool.cs | 191 ++++++++- src/AxCopilot/skills/kpi-workbook.skill.md | 1 + 7 files changed, 633 insertions(+), 59 deletions(-) create mode 100644 src/AxCopilot.Tests/Services/DocumentAssemblerDocxFeaturesTests.cs create mode 100644 src/AxCopilot.Tests/Services/DocumentPlannerWorkbookScaffoldTests.cs diff --git a/README.md b/README.md index 52dff18..952bd02 100644 --- a/README.md +++ b/README.md @@ -1788,8 +1788,16 @@ MIT License - [PptxSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PptxSkill.cs)는 `issue_tree`, `before_after`, `decision_matrix`, `risk_heatmap`, `benefit_waterfall`, `operating_model`, `appendix_evidence` 같은 컨설팅형 레이아웃을 생성 전에 네이티브 레이아웃으로 정규화하고, 결과 메시지에 deck planning/quality review 요약을 함께 반환합니다. - [pptx-creator.skill.md](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/skills/pptx-creator.skill.md)는 `document_plan -> deck brief -> pptx_create` 흐름을 중심으로 재작성했고, [strategy-deck.skill.md](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/skills/strategy-deck.skill.md), [board-update.skill.md](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/skills/board-update.skill.md), [pmo-steering.skill.md](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/skills/pmo-steering.skill.md), [sales-review-deck.skill.md](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/skills/sales-review-deck.skill.md), [operating-model-deck.skill.md](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/skills/operating-model-deck.skill.md)를 추가해 목적형 deck 진입점을 늘렸습니다. - 테스트로 [DeckPlanningServiceTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/DeckPlanningServiceTests.cs), [DeckQualityReviewServiceTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/DeckQualityReviewServiceTests.cs), [PptxSkillAutoRepairTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/PptxSkillAutoRepairTests.cs)를 추가했습니다. - - 검증: `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 +- 검증: `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:14 (KST) +- 문서 생성 고도화 2차 planner/assembler 보강을 반영했습니다. `DocumentPlannerTool`은 이제 `format: xlsx`를 직접 지원하고, 문서 개요뿐 아니라 `excel_create`에 바로 넘길 수 있는 `summary_sheet + sheets` 워크북 scaffold를 생성합니다. +- `DocumentAssemblerTool`은 DOCX 조립 시 `cover_subtitle`, `toc`, `header`, `footer`를 실제 Word 문서에 반영하도록 확장했고, 결과 메시지에 DOCX/HTML 품질 리뷰 요약을 함께 붙여 산출물 완성도를 바로 확인할 수 있게 했습니다. +- `kpi-workbook.skill.md`는 complex workbook 시나리오에서 `document_plan -> excel_create` 경로를 사용할 수 있도록 planner 도구 허용 목록을 확장했습니다. +- 테스트로 `DocumentPlannerWorkbookScaffoldTests`, `DocumentAssemblerDocxFeaturesTests`를 추가했고, 기존 문서/워크북/HTML 회귀 테스트와 함께 검증했습니다. +- 검증: `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:00 (KST) - 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 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index c16af34..a63ad0d 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -862,3 +862,13 @@ UI ?붿옄???€洹쒕え 由ы뙥?좊쭅 ???꾪뿕 ?묒뾽 ??湲곕줉???덉쟾 - 테스트로 `DeckPlanningServiceTests`, `DeckQualityReviewServiceTests`, `PptxSkillAutoRepairTests`를 추가했고, 기존 `PptxSkillConsultingDeckTests`와 함께 검증했습니다. - 검증: `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:14 (KST) +- 문서 planner/assembler 고도화 2차를 반영했습니다. [DocumentPlannerTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/DocumentPlannerTool.cs)는 `format: xlsx`를 지원하고, 분석/제안 시나리오에서 `summary_sheet + sheets` 구조의 `excel_create` scaffold를 직접 생성합니다. +- 같은 파일의 포맷 해석 로직은 `xlsx`, `excel`, `workbook`, `tracker`, `dashboard`, `scorecard` 계열 의도를 먼저 감지해 워크북 경로로 보내도록 보강했습니다. +- [DocumentAssemblerTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/DocumentAssemblerTool.cs)는 DOCX 조립 시 `cover_subtitle`, `toc`, `header`, `footer`를 실제 OpenXML 문서에 반영하고, 구조화 HTML을 Word 블록으로 조립한 뒤 품질 리뷰 점수를 함께 반환합니다. +- 같은 도구의 HTML 조립 경로도 공통 [ArtifactQualityReviewService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs)와 연결해 score/strengths/issues 기준의 요약을 돌려주도록 정리했습니다. +- [kpi-workbook.skill.md](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/skills/kpi-workbook.skill.md)는 complex workbook 생성 시 planner 경로를 열기 위해 `document_plan`을 허용 도구에 추가했습니다. +- 테스트로 [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 diff --git a/src/AxCopilot.Tests/Services/DocumentAssemblerDocxFeaturesTests.cs b/src/AxCopilot.Tests/Services/DocumentAssemblerDocxFeaturesTests.cs new file mode 100644 index 0000000..f4d2552 --- /dev/null +++ b/src/AxCopilot.Tests/Services/DocumentAssemblerDocxFeaturesTests.cs @@ -0,0 +1,75 @@ +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 DocumentAssemblerDocxFeaturesTests +{ + [Fact] + public async Task ExecuteAsync_ForDocx_ShouldIncludeCoverTocHeaderAndFooter() + { + var workDir = Path.Combine(Path.GetTempPath(), "ax-doc-assemble-features-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workDir); + + try + { + var tool = new DocumentAssemblerTool(); + var context = new AgentContext + { + WorkFolder = workDir, + Permission = "Auto", + OperationMode = "external", + }; + + var args = JsonDocument.Parse( + """ + { + "path": "assembled-features.docx", + "title": "Operating Review", + "format": "docx", + "toc": true, + "cover_subtitle": "Q2 Steering Committee", + "header": "Internal Use Only", + "footer": "AX Copilot {page}", + "sections": [ + { "heading": "1. Executive Summary", "level": 1, "content": "

Summary.

" }, + { "heading": "2. Current State", "level": 1, "content": "" }, + { "heading": "3. Recommendation", "level": 1, "content": "
Decision

Approve phase 1.

" }, + { "heading": "4. Appendix", "level": 1, "content": "
MetricValue
NPS61
" } + ] + } + """).RootElement; + + var result = await tool.ExecuteAsync(args, context, CancellationToken.None); + + result.Success.Should().BeTrue(); + result.Output.Should().Contain("DOCX 품질 리뷰"); + + var outputPath = Path.Combine(workDir, "assembled-features.docx"); + File.Exists(outputPath).Should().BeTrue(); + + using var doc = WordprocessingDocument.Open(outputPath, false); + doc.MainDocumentPart.Should().NotBeNull(); + doc.MainDocumentPart!.HeaderParts.Should().NotBeEmpty(); + 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")); + } + finally + { + try + { + if (Directory.Exists(workDir)) + Directory.Delete(workDir, true); + } + catch + { + } + } + } +} diff --git a/src/AxCopilot.Tests/Services/DocumentPlannerWorkbookScaffoldTests.cs b/src/AxCopilot.Tests/Services/DocumentPlannerWorkbookScaffoldTests.cs new file mode 100644 index 0000000..7ca0c50 --- /dev/null +++ b/src/AxCopilot.Tests/Services/DocumentPlannerWorkbookScaffoldTests.cs @@ -0,0 +1,41 @@ +using System.IO; +using System.Text.Json; +using AxCopilot.Services.Agent; +using FluentAssertions; +using Xunit; + +namespace AxCopilot.Tests.Services; + +public class DocumentPlannerWorkbookScaffoldTests +{ + [Fact] + public async Task ExecuteAsync_ForXlsxFormat_ShouldReturnWorkbookScaffold() + { + var tool = new DocumentPlannerTool(); + var context = new AgentContext + { + WorkFolder = Path.GetTempPath(), + Permission = "Auto", + OperationMode = "external", + }; + + var args = JsonDocument.Parse( + """ + { + "topic": "2026 KPI operating review", + "document_type": "analysis", + "format": "xlsx", + "target_pages": 6 + } + """).RootElement; + + var result = await tool.ExecuteAsync(args, context, CancellationToken.None); + + result.Success.Should().BeTrue(); + result.Output.Should().Contain("excel_create"); + result.Output.Should().Contain("\"summary_sheet\""); + result.Output.Should().Contain("\"sheets\""); + result.Output.Should().Contain("Metrics"); + result.Output.Should().Contain("Actions"); + } +} diff --git a/src/AxCopilot/Services/Agent/DocumentAssemblerTool.cs b/src/AxCopilot/Services/Agent/DocumentAssemblerTool.cs index e379c75..446e258 100644 --- a/src/AxCopilot/Services/Agent/DocumentAssemblerTool.cs +++ b/src/AxCopilot/Services/Agent/DocumentAssemblerTool.cs @@ -120,7 +120,7 @@ public class DocumentAssemblerTool : IAgentTool switch (format) { case "docx": - resultMsg = AssembleDocx(fullPath, title, sections, headerText, footerText); + resultMsg = AssembleDocx(fullPath, title, sections, useToc, coverSubtitle, headerText, footerText); break; case "markdown": resultMsg = AssembleMarkdown(fullPath, title, sections); @@ -231,13 +231,15 @@ public class DocumentAssemblerTool : IAgentTool File.WriteAllText(path, sb.ToString(), Encoding.UTF8); var issues = ValidateBasic(sb.ToString()); + var review = ArtifactQualityReviewService.ReviewHtml(title, sb.ToString(), !string.IsNullOrWhiteSpace(coverSubtitle), toc, printReady: true); + var reviewSummary = $" HTML 품질 리뷰: {review.Score}/100 | 강점 {review.Strengths.Count} | 이슈 {review.Issues.Count}"; return issues.Count > 0 - ? $" ⚠ 품질 검증 이슈 {issues.Count}건: {string.Join("; ", issues)}" - : " ✓ 품질 검증 통과"; + ? $"{reviewSummary} | 기본 검토 이슈 {issues.Count}건: {string.Join("; ", issues)}" + : reviewSummary; } private string AssembleDocx(string path, string title, List<(string Heading, string Content, int Level)> sections, - string? headerText, string? footerText) + bool useToc, string? coverSubtitle, string? headerText, string? footerText) { using var doc = DocumentFormat.OpenXml.Packaging.WordprocessingDocument.Create( path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document); @@ -424,6 +426,74 @@ public class DocumentAssemblerTool : IAgentTool } } + var includeCover = !string.IsNullOrWhiteSpace(coverSubtitle); + if (includeCover) + AppendAssemblerCoverPage(body, title, coverSubtitle!); + else + body.AppendChild(CreateParagraph(title, fontSize: "48", bold: true, color: "1F3A5F")); + + if (!includeCover) + body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Paragraph()); + + if (useToc && sections.Count > 3) + AppendAssemblerTableOfContents(body); + + var tableCount = 0; + var listCount = 0; + var calloutCount = 0; + var highlightCount = 0; + var headings = new List(); + + 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); + headings.Add(heading); + + AppendStructuredContent(content); + body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Paragraph()); + } + + body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.SectionProperties( + new DocumentFormat.OpenXml.Wordprocessing.PageSize { Width = 11906, Height = 16838 }, + new DocumentFormat.OpenXml.Wordprocessing.PageMargin { Top = 1440, Right = 1440, Bottom = 1440, Left = 1440, + Header = 720, Footer = 720, Gutter = 0 } + )); + + if (!string.IsNullOrWhiteSpace(headerText) || !string.IsNullOrWhiteSpace(footerText)) + AddAssemblerHeaderFooter(mainPart, body, headerText, footerText, showPageNumbers: true); + + mainPart.Document.Save(); + + var review = ArtifactQualityReviewService.ReviewStructuredDocument(new StructuredDocumentReviewInput( + title, + headings.Count, + sections.Sum(section => StripHtmlTags(section.Content).Length), + tableCount, + listCount, + calloutCount, + highlightCount, + 0, + includeCover, + useToc && sections.Count > 3, + false, + !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", "권고", "실행")), + headings.Any(h => ArtifactQualityReviewService.ContainsBusinessKeyword(h, "appendix", "reference", "supplement", "부록", "참고")))); + + return $" DOCX 품질 리뷰: {review.Score}/100 | 강점 {review.Strengths.Count} | 이슈 {review.Issues.Count}"; + void AppendStructuredContent(string rawContent) { if (string.IsNullOrWhiteSpace(rawContent)) @@ -437,7 +507,7 @@ public class DocumentAssemblerTool : IAgentTool } normalized = Regex.Replace(normalized, @"", "\n", RegexOptions.IgnoreCase); - var blockPattern = @"]*>.*?|]*>.*?|]*>.*?|]*>.*?|]*class=""[^""]*(callout-[^""]*|comparison-grid|roadmap-block|matrix-grid)[^""]*""[^>]*>.*?|]*>.*?|]*>.*?

"; + var blockPattern = @"]*>.*?|]*>.*?|]*>.*?|]*>.*?|]*class=""[^""]*(callout-[^""]*|comparison-grid|roadmap-block|matrix-grid|highlight-box)[^""]*""[^>]*>.*?|]*>.*?|]*>.*?

"; var matches = Regex.Matches(normalized, blockPattern, RegexOptions.IgnoreCase | RegexOptions.Singleline); if (matches.Count == 0) @@ -459,14 +529,17 @@ public class DocumentAssemblerTool : IAgentTool if (Regex.IsMatch(block, @"^DOCX 기본 스타일 정의 생성 (한글 글꼴 기본 설정 포함). @@ -573,6 +608,235 @@ public class DocumentAssemblerTool : IAgentTool return styles; } + private static void AppendAssemblerCoverPage(DocumentFormat.OpenXml.Wordprocessing.Body body, string title, string subtitle) + { + body.Append(new DocumentFormat.OpenXml.Wordprocessing.Paragraph( + new DocumentFormat.OpenXml.Wordprocessing.ParagraphProperties + { + Justification = new DocumentFormat.OpenXml.Wordprocessing.Justification + { + Val = DocumentFormat.OpenXml.Wordprocessing.JustificationValues.Center + }, + SpacingBetweenLines = new DocumentFormat.OpenXml.Wordprocessing.SpacingBetweenLines + { + Before = "1600", + After = "120" + } + }, + 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)))); + + body.Append(new DocumentFormat.OpenXml.Wordprocessing.Paragraph( + new DocumentFormat.OpenXml.Wordprocessing.ParagraphProperties + { + Justification = new DocumentFormat.OpenXml.Wordprocessing.Justification + { + Val = DocumentFormat.OpenXml.Wordprocessing.JustificationValues.Center + }, + SpacingBetweenLines = new DocumentFormat.OpenXml.Wordprocessing.SpacingBetweenLines + { + After = "260" + } + }, + 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)))); + + body.Append(new DocumentFormat.OpenXml.Wordprocessing.Paragraph( + new DocumentFormat.OpenXml.Wordprocessing.ParagraphProperties + { + Justification = new DocumentFormat.OpenXml.Wordprocessing.Justification + { + Val = DocumentFormat.OpenXml.Wordprocessing.JustificationValues.Center + } + }, + new DocumentFormat.OpenXml.Wordprocessing.Run( + new DocumentFormat.OpenXml.Wordprocessing.RunProperties( + new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "18" }, + new DocumentFormat.OpenXml.Wordprocessing.Color { Val = "808080" }, + new DocumentFormat.OpenXml.Wordprocessing.RunFonts { Ascii = "맑은 고딕", HighAnsi = "맑은 고딕", EastAsia = "맑은 고딕" }), + new DocumentFormat.OpenXml.Wordprocessing.Text(DateTime.Now.ToString("yyyy-MM-dd"))))); + + body.Append(new DocumentFormat.OpenXml.Wordprocessing.Paragraph( + new DocumentFormat.OpenXml.Wordprocessing.Run(new DocumentFormat.OpenXml.Wordprocessing.Break + { + Type = DocumentFormat.OpenXml.Wordprocessing.BreakValues.Page + }))); + } + + private static void AppendAssemblerTableOfContents(DocumentFormat.OpenXml.Wordprocessing.Body body) + { + body.Append(new DocumentFormat.OpenXml.Wordprocessing.Paragraph( + new DocumentFormat.OpenXml.Wordprocessing.ParagraphProperties( + new DocumentFormat.OpenXml.Wordprocessing.SpacingBetweenLines { Before = "120", After = "120" }), + new DocumentFormat.OpenXml.Wordprocessing.Run( + new DocumentFormat.OpenXml.Wordprocessing.RunProperties( + new DocumentFormat.OpenXml.Wordprocessing.Bold(), + new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "28" }, + new DocumentFormat.OpenXml.Wordprocessing.RunFonts { Ascii = "맑은 고딕", HighAnsi = "맑은 고딕", EastAsia = "맑은 고딕" }), + new DocumentFormat.OpenXml.Wordprocessing.Text("목차")))); + + var paragraph = new DocumentFormat.OpenXml.Wordprocessing.Paragraph(); + paragraph.Append(new DocumentFormat.OpenXml.Wordprocessing.Run(new DocumentFormat.OpenXml.Wordprocessing.FieldChar + { + FieldCharType = DocumentFormat.OpenXml.Wordprocessing.FieldCharValues.Begin + })); + paragraph.Append(new DocumentFormat.OpenXml.Wordprocessing.Run(new DocumentFormat.OpenXml.Wordprocessing.FieldCode(" TOC \\o \"1-3\" \\h \\z \\u ") + { + Space = DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve + })); + paragraph.Append(new DocumentFormat.OpenXml.Wordprocessing.Run(new DocumentFormat.OpenXml.Wordprocessing.FieldChar + { + FieldCharType = DocumentFormat.OpenXml.Wordprocessing.FieldCharValues.Separate + })); + paragraph.Append(new DocumentFormat.OpenXml.Wordprocessing.Run(new DocumentFormat.OpenXml.Wordprocessing.Text("Word에서 필드 업데이트를 실행하면 목차가 새로 고쳐집니다."))); + paragraph.Append(new DocumentFormat.OpenXml.Wordprocessing.Run(new DocumentFormat.OpenXml.Wordprocessing.FieldChar + { + FieldCharType = DocumentFormat.OpenXml.Wordprocessing.FieldCharValues.End + })); + body.Append(paragraph); + body.Append(new DocumentFormat.OpenXml.Wordprocessing.Paragraph( + new DocumentFormat.OpenXml.Wordprocessing.Run(new DocumentFormat.OpenXml.Wordprocessing.Break + { + Type = DocumentFormat.OpenXml.Wordprocessing.BreakValues.Page + }))); + } + + private static void AddAssemblerHeaderFooter( + DocumentFormat.OpenXml.Packaging.MainDocumentPart mainPart, + DocumentFormat.OpenXml.Wordprocessing.Body body, + string? headerText, + string? footerText, + bool showPageNumbers) + { + if (!string.IsNullOrWhiteSpace(headerText)) + { + var headerPart = mainPart.AddNewPart(); + var header = new DocumentFormat.OpenXml.Wordprocessing.Header(); + var paragraph = new DocumentFormat.OpenXml.Wordprocessing.Paragraph( + new DocumentFormat.OpenXml.Wordprocessing.Run( + new DocumentFormat.OpenXml.Wordprocessing.RunProperties( + new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "18" }, + new DocumentFormat.OpenXml.Wordprocessing.Color { Val = "808080" }, + new DocumentFormat.OpenXml.Wordprocessing.RunFonts { Ascii = "맑은 고딕", HighAnsi = "맑은 고딕", EastAsia = "맑은 고딕" }), + new DocumentFormat.OpenXml.Wordprocessing.Text(headerText))); + paragraph.ParagraphProperties = new DocumentFormat.OpenXml.Wordprocessing.ParagraphProperties + { + Justification = new DocumentFormat.OpenXml.Wordprocessing.Justification + { + Val = DocumentFormat.OpenXml.Wordprocessing.JustificationValues.Right + } + }; + header.Append(paragraph); + headerPart.Header = header; + + var sectionProperties = body.Elements().LastOrDefault() + ?? body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.SectionProperties()); + sectionProperties.Elements().ToList().ForEach(reference => reference.Remove()); + sectionProperties.Append(new DocumentFormat.OpenXml.Wordprocessing.HeaderReference + { + Type = DocumentFormat.OpenXml.Wordprocessing.HeaderFooterValues.Default, + Id = mainPart.GetIdOfPart(headerPart) + }); + } + + if (!string.IsNullOrWhiteSpace(footerText) || showPageNumbers) + { + var footerPart = mainPart.AddNewPart(); + var footer = new DocumentFormat.OpenXml.Wordprocessing.Footer(); + var paragraph = new DocumentFormat.OpenXml.Wordprocessing.Paragraph + { + ParagraphProperties = new DocumentFormat.OpenXml.Wordprocessing.ParagraphProperties + { + Justification = new DocumentFormat.OpenXml.Wordprocessing.Justification + { + Val = DocumentFormat.OpenXml.Wordprocessing.JustificationValues.Center + } + } + }; + + var displayText = string.IsNullOrWhiteSpace(footerText) ? "AX Copilot" : footerText!; + if (showPageNumbers) + { + if (displayText.Contains("{page}", StringComparison.Ordinal)) + { + var parts = displayText.Split("{page}", StringSplitOptions.None); + paragraph.Append(CreateAssemblerFooterRun(parts[0])); + paragraph.Append(CreateAssemblerPageNumberRun()); + if (parts.Length > 1) + paragraph.Append(CreateAssemblerFooterRun(parts[1])); + } + else + { + paragraph.Append(CreateAssemblerFooterRun(displayText + " · ")); + paragraph.Append(CreateAssemblerPageNumberRun()); + } + } + else + { + paragraph.Append(CreateAssemblerFooterRun(displayText)); + } + + footer.Append(paragraph); + footerPart.Footer = footer; + + var sectionProperties = body.Elements().LastOrDefault() + ?? body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.SectionProperties()); + sectionProperties.Elements().ToList().ForEach(reference => reference.Remove()); + sectionProperties.Append(new DocumentFormat.OpenXml.Wordprocessing.FooterReference + { + Type = DocumentFormat.OpenXml.Wordprocessing.HeaderFooterValues.Default, + Id = mainPart.GetIdOfPart(footerPart) + }); + } + } + + private static DocumentFormat.OpenXml.Wordprocessing.Run CreateAssemblerFooterRun(string text) => + new(new DocumentFormat.OpenXml.Wordprocessing.Text(text) { Space = DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve }) + { + RunProperties = new DocumentFormat.OpenXml.Wordprocessing.RunProperties + { + FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "16" }, + Color = new DocumentFormat.OpenXml.Wordprocessing.Color { Val = "999999" }, + RunFonts = new DocumentFormat.OpenXml.Wordprocessing.RunFonts { Ascii = "맑은 고딕", HighAnsi = "맑은 고딕", EastAsia = "맑은 고딕" } + } + }; + + private static DocumentFormat.OpenXml.Wordprocessing.Run CreateAssemblerPageNumberRun() + { + var run = new DocumentFormat.OpenXml.Wordprocessing.Run + { + RunProperties = new DocumentFormat.OpenXml.Wordprocessing.RunProperties + { + FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "16" }, + Color = new DocumentFormat.OpenXml.Wordprocessing.Color { Val = "999999" }, + RunFonts = new DocumentFormat.OpenXml.Wordprocessing.RunFonts { Ascii = "맑은 고딕", HighAnsi = "맑은 고딕", EastAsia = "맑은 고딕" } + } + }; + + run.Append(new DocumentFormat.OpenXml.Wordprocessing.FieldChar + { + FieldCharType = DocumentFormat.OpenXml.Wordprocessing.FieldCharValues.Begin + }); + run.Append(new DocumentFormat.OpenXml.Wordprocessing.FieldCode(" PAGE ") + { + Space = DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve + }); + run.Append(new DocumentFormat.OpenXml.Wordprocessing.FieldChar + { + FieldCharType = DocumentFormat.OpenXml.Wordprocessing.FieldCharValues.End + }); + return run; + } + private string AssembleMarkdown(string path, string title, List<(string Heading, string Content, int Level)> sections) { var sb = new StringBuilder(); diff --git a/src/AxCopilot/Services/Agent/DocumentPlannerTool.cs b/src/AxCopilot/Services/Agent/DocumentPlannerTool.cs index d7302f3..93e6aa7 100644 --- a/src/AxCopilot/Services/Agent/DocumentPlannerTool.cs +++ b/src/AxCopilot/Services/Agent/DocumentPlannerTool.cs @@ -65,8 +65,8 @@ public class DocumentPlannerTool : IAgentTool ["format"] = new() { Type = "string", - Description = "Output document format: auto, html, docx, markdown. Default: auto (resolved from settings and request intent)", - Enum = ["auto", "html", "docx", "markdown"] + Description = "Output document format: auto, html, docx, markdown, xlsx. Default: auto (resolved from settings and request intent)", + Enum = ["auto", "html", "docx", "markdown", "xlsx"] }, ["sections_hint"] = new() { @@ -124,7 +124,7 @@ public class DocumentPlannerTool : IAgentTool int targetPages, int totalWords, List sections, bool highQuality) { var safeTitle = SanitizeFileName(topic); - var ext = format switch { "docx" => ".docx", "markdown" => ".md", _ => ".html" }; + var ext = format switch { "docx" => ".docx", "markdown" => ".md", "xlsx" => ".xlsx", _ => ".html" }; var suggestedFileName = $"{safeTitle}{ext}"; var label = highQuality ? "[고품질]" : "[표준]"; @@ -134,6 +134,8 @@ public class DocumentPlannerTool : IAgentTool return ExecuteWithMarkdownScaffold(topic, suggestedFileName, sections, targetPages, totalWords, label); case "docx": return ExecuteWithDocxScaffold(topic, suggestedFileName, sections, targetPages, totalWords, label); + case "xlsx": + return ExecuteWithExcelScaffold(topic, suggestedFileName, docType, sections, targetPages, totalWords, label); default: // html return ExecuteWithHtmlScaffold(topic, suggestedFileName, sections, targetPages, totalWords, label, mood); } @@ -294,15 +296,54 @@ public class DocumentPlannerTool : IAgentTool return Task.FromResult(ToolResult.Ok(output.ToString())); } + /// excel_create 즉시 호출 가능한 workbook 골격 반환. + private Task ExecuteWithExcelScaffold(string topic, string fileName, string docType, + List sections, int targetPages, int totalWords, string label) + { + var summarySection = sections.FirstOrDefault(); + var recommendationSection = sections.FirstOrDefault(s => + ContainsAny(s.Heading, "Recommendation", "Decision Ask", "Roadmap", "Action", "Ask", "권고", "실행", "요청")); + var summarySheet = new + { + name = "Summary", + title = topic, + subtitle = $"{GetDocTypeLabel(docType)} workbook", + kpis = BuildWorkbookKpis(summarySection), + highlights = (summarySection?.KeyPoints ?? []).Take(3).ToArray(), + actions = (recommendationSection?.KeyPoints ?? summarySection?.KeyPoints ?? []).Take(3).ToArray(), + }; + + var sheets = BuildWorkbookSheets(docType, sections); + var payload = new + { + path = fileName, + theme = "professional", + summary_sheet = summarySheet, + sheets, + }; + + var output = new StringBuilder(); + output.AppendLine($"📋 워크북 개요 생성 완료 {label} ({sheets.Count}개 상세 시트, {targetPages}페이지/{totalWords}단어 기준)"); + output.AppendLine(); + output.AppendLine("## 즉시 실행: excel_create 호출 파라미터"); + output.AppendLine(JsonSerializer.Serialize(payload, _jsonOptions)); + output.AppendLine(); + output.AppendLine("⚠ 아래 placeholder 값과 샘플 행은 실제 수치/일정/담당자로 교체하세요."); + output.AppendLine("⚠ Summary 시트의 highlights/actions는 핵심 메시지 중심으로 유지하고, 상세 시트는 수치/근거 중심으로 채우세요."); + + return Task.FromResult(ToolResult.Ok(output.ToString())); + } + // ─── 싱글패스 + 폴더 데이터 활용: 개요 반환 + LLM이 데이터 읽고 직접 저장 ── private Task ExecuteSinglePassWithData(string topic, string docType, string format, string mood, int targetPages, int totalWords, List sections, string folderDataUsage, string refSummary) { - var ext = format switch { "docx" => ".docx", "markdown" => ".md", _ => ".html" }; - var createTool = format switch { "docx" => "docx_create", "markdown" => "file_write", _ => "html_create" }; + var ext = format switch { "docx" => ".docx", "markdown" => ".md", "xlsx" => ".xlsx", _ => ".html" }; + var createTool = format switch { "docx" => "docx_create", "markdown" => "file_write", "xlsx" => "excel_create", _ => "html_create" }; var safeTitle = SanitizeFileName(topic); var suggestedPath = $"{safeTitle}{ext}"; + var artifactLabel = format == "xlsx" ? "workbook" : "document"; // reference_summary가 이미 있으면 데이터 읽기 단계를 건너뛸 수 있음 var hasRefData = !string.IsNullOrWhiteSpace(refSummary); @@ -327,8 +368,10 @@ public class DocumentPlannerTool : IAgentTool step2 = hasRefData ? "(skipped)" : "Summarize the key findings from the folder documents relevant to the topic.", - step3 = $"Write the COMPLETE document content covering ALL sections above, incorporating folder data.", - step4 = $"Save the document using {createTool} with the path '{suggestedPath}'. " + + step3 = format == "xlsx" + ? "Create the COMPLETE workbook structure using validated data, including summary sheet, detailed sheets, and follow-up actions." + : $"Write the COMPLETE {artifactLabel} content covering ALL sections above, incorporating folder data.", + step4 = $"Save the {artifactLabel} using {createTool} with the path '{suggestedPath}'. " + "Write ALL sections in a SINGLE tool call. Do NOT split into multiple calls.", preferred_mood = format == "html" ? mood : null as string, note = "Minimize LLM calls. Read data → write complete document → save. Maximum 3 iterations." @@ -707,11 +750,13 @@ public class DocumentPlannerTool : IAgentTool private static string ResolveDocumentFormat(string? preferred, string docType, string topic) { var normalized = (preferred ?? "auto").Trim().ToLowerInvariant(); - if (normalized is "html" or "docx" or "markdown") + if (normalized is "html" or "docx" or "markdown" or "xlsx") return normalized; var intent = $"{docType} {topic}".ToLowerInvariant(); + if (ContainsAny(intent, "xlsx", "excel", "workbook", "tracker", "dashboard", "scorecard", "워크북", "엑셀", "대시보드", "추적표")) + return "xlsx"; if (ContainsAny(intent, "docx", "word", "워드")) return "docx"; if (ContainsAny(intent, "markdown", ".md", "md ", "마크다운", "readme", "가이드", "manual", "guide", "회의록", "minutes")) @@ -774,4 +819,134 @@ public class DocumentPlannerTool : IAgentTool public int TargetWords { get; set; } = 300; public List KeyPoints { get; set; } = new(); } + + private sealed class WorkbookSheetPlan + { + public string Name { get; set; } = ""; + public List Headers { get; set; } = new(); + public List> Rows { get; set; } = new(); + } + + private static List BuildWorkbookKpis(SectionPlan? summarySection) + { + var source = summarySection?.KeyPoints ?? []; + return source.Take(3).Select((point, index) => (object)new + { + label = $"KPI {index + 1}", + value = "[수치]", + trend = point, + note = "Replace with validated metric", + }).ToList(); + } + + private static List BuildWorkbookSheets(string docType, List sections) + { + var plans = docType switch + { + "proposal" => new List + { + new() + { + Name = "BusinessCase", + Headers = ["Workstream", "Investment", "Benefit", "Payback", "Owner"], + Rows = + [ + ["Initiative A", "[cost]", "[benefit]", "[months]", "[owner]"], + ["Initiative B", "[cost]", "[benefit]", "[months]", "[owner]"], + ], + }, + new() + { + Name = "Roadmap", + Headers = ["Phase", "Milestone", "Timeline", "Owner", "Status"], + Rows = + [ + ["Phase 1", "[milestone]", "[date]", "[owner]", "Planned"], + ["Phase 2", "[milestone]", "[date]", "[owner]", "Planned"], + ], + }, + new() + { + Name = "Risks", + Headers = ["Risk", "Impact", "Likelihood", "Mitigation", "Owner"], + Rows = + [ + ["[risk]", "High", "Medium", "[mitigation]", "[owner]"], + ["[risk]", "Medium", "Low", "[mitigation]", "[owner]"], + ], + }, + }, + "analysis" => new List + { + new() + { + Name = "Metrics", + Headers = ["Metric", "Current", "Target", "Delta", "Comment"], + Rows = + [ + ["[metric]", "[current]", "[target]", "=[@Current]-[@Target]", "[insight]"], + ["[metric]", "[current]", "[target]", "=[@Current]-[@Target]", "[insight]"], + ], + }, + new() + { + Name = "Findings", + Headers = ["Theme", "Finding", "Impact", "Evidence", "Owner"], + Rows = + [ + ["[theme]", "[finding]", "[impact]", "[source]", "[owner]"], + ["[theme]", "[finding]", "[impact]", "[source]", "[owner]"], + ], + }, + new() + { + Name = "Actions", + Headers = ["Priority", "Action", "Owner", "Due Date", "Status"], + Rows = + [ + ["High", "[action]", "[owner]", "[date]", "Open"], + ["Medium", "[action]", "[owner]", "[date]", "Open"], + ], + }, + }, + _ => new List + { + new() + { + Name = "Detail", + Headers = ["Category", "Item", "Value", "Trend", "Owner"], + Rows = + [ + ["[category]", "[item]", "[value]", "[trend]", "[owner]"], + ["[category]", "[item]", "[value]", "[trend]", "[owner]"], + ], + }, + new() + { + Name = "Actions", + Headers = ["Action", "Owner", "Due Date", "Status", "Note"], + Rows = + [ + ["[action]", "[owner]", "[date]", "Open", "[note]"], + ["[action]", "[owner]", "[date]", "Open", "[note]"], + ], + }, + new() + { + Name = "Evidence", + Headers = ["Section", "Key Point", "Evidence", "Source", "Comment"], + Rows = sections.Take(4).Select(section => new List + { + section.Heading, + section.KeyPoints.FirstOrDefault() ?? "[key point]", + "[evidence]", + "[source]", + "[comment]", + }).ToList(), + }, + }, + }; + + return plans; + } } diff --git a/src/AxCopilot/skills/kpi-workbook.skill.md b/src/AxCopilot/skills/kpi-workbook.skill.md index 4213718..fbb7eb3 100644 --- a/src/AxCopilot/skills/kpi-workbook.skill.md +++ b/src/AxCopilot/skills/kpi-workbook.skill.md @@ -9,6 +9,7 @@ allowed-tools: - folder_map - file_read - data_pivot + - document_plan - excel_create tabs: cowork ---