diff --git a/README.md b/README.md index 4310a37..5d82f94 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,15 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저 개발 참고: Claw Code 동등성 작업 추적 문서 `docs/claw-code-parity-plan.md` +- 업데이트: 2026-04-14 21:25 (KST) +- 문서 생성 고도화 2차를 반영했습니다. [ArtifactQualityReviewService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs)를 추가해 HTML/DOCX/XLSX 결과물에 공통 품질 점수와 보완 포인트를 부여하고, [HtmlSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/HtmlSkill.cs), [DocxSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/DocxSkill.cs), [ExcelSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ExcelSkill.cs)에 연결했습니다. +- Word 쪽은 템플릿·커버·목차를 강화했습니다. `docx_create`는 `template_path`, `cover_subtitle`, `cover_meta`, `toc`를 받아 템플릿 기반 문서와 커버 페이지, TOC 필드를 함께 만들 수 있게 되었고, 결과 요약에도 문서 품질 리뷰가 같이 남습니다. +- Excel 쪽은 요약 시트 품질을 높였습니다. summary sheet가 detail sheet 링크를 자동으로 넣고, KPI/하이라이트/액션 구조를 가진 워크북에 대해 품질 점수를 함께 반환합니다. +- HTML 쪽은 결과 요약에 업무 문서 기준의 로컬 품질 리뷰를 붙였습니다. 커버, 목차, 인쇄 최적화, 구조화 블록 사용 여부를 함께 평가해 후속 보정 근거를 더 쉽게 확인할 수 있습니다. +- 번들 스킬도 확장했습니다. [executive-brief.skill.md](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/skills/executive-brief.skill.md), [kpi-workbook.skill.md](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/skills/kpi-workbook.skill.md), [board-report-html.skill.md](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/skills/board-report-html.skill.md)을 추가해 임원 보고 문서, KPI 워크북, HTML 이사회 보고서 생성을 바로 유도할 수 있게 했습니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_doc_phase2\\ -p:IntermediateOutputPath=obj\\verify_doc_phase2\\` 경고 0 / 오류 0 +- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ArtifactQualityReviewServiceTests|DocxSkillTemplateFeaturesTests|ExcelSkillExecutiveSummaryLinkTests|DocumentAssemblerSemanticTests|DocumentPlannerBusinessDocumentTests|HtmlSkillConsultingSectionsTests|ExcelSkillSummarySheetTests" -p:OutputPath=bin\\verify_doc_phase2_tests\\ -p:IntermediateOutputPath=obj\\verify_doc_phase2_tests\\` 통과 9 + - 업데이트: 2026-04-14 19:13 (KST) - `claude-code` 기준 Phase 4 범위를 이어서 반영했습니다. MCP 서버 메타데이터를 `mcp` 스코프의 synthetic skill로 노출하는 [McpSkillCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/McpSkillCatalog.cs)를 추가했고, 스킬 source 정책은 `managed/user/additional/project/plugin/mcp/legacy` 단위로 켜고 끌 수 있게 확장했습니다. - 슬래시 명령 합성도 정리했습니다. [SlashCommandCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SlashCommandCatalog.cs)와 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)가 이제 builtin command와 skill을 우선순위 기반으로 dedupe해 같은 `/명령`이 겹칠 때 더 안정적으로 하나만 노출합니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 2c6b880..abe884b 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,4 +1,14 @@ 업데이트: 2026-04-14 19:50 (KST) +업데이트: 2026-04-14 21:25 (KST) +- 문서 생성 고도화 2차를 반영했습니다. `src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs`를 추가해 HTML/DOCX/XLSX 산출물에 대해 로컬 품질 점수와 보완 포인트를 계산하고, `HtmlSkill`, `DocxSkill`, `ExcelSkill`이 같은 리뷰 모델을 공유하도록 맞췄습니다. +- `src/AxCopilot/Services/Agent/DocxSkill.cs`는 `template_path`, `cover_subtitle`, `cover_meta`, `toc`를 지원하도록 확장했습니다. DOCX 템플릿 복제 후 본문을 재구성하고, 커버 페이지와 TOC 필드를 삽입한 뒤 structured review 결과를 함께 반환합니다. +- `src/AxCopilot/Services/Agent/ExcelSkill.cs`는 executive summary sheet에 detail sheet 링크를 자동 생성하고, KPI/highlights/actions 구조를 workbook quality review와 연결하도록 보강했습니다. +- `src/AxCopilot/Services/Agent/HtmlSkill.cs`는 로컬 품질 리뷰를 결과 요약에 포함하도록 바꿨고, 문서형 번들 스킬로 `executive-brief`, `kpi-workbook`, `board-report-html`을 추가했습니다. +- 테스트: `ArtifactQualityReviewServiceTests`, `DocxSkillTemplateFeaturesTests`, `ExcelSkillExecutiveSummaryLinkTests` 추가 +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_doc_phase2\\ -p:IntermediateOutputPath=obj\\verify_doc_phase2\\` 경고 0 / 오류 0 +- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ArtifactQualityReviewServiceTests|DocxSkillTemplateFeaturesTests|ExcelSkillExecutiveSummaryLinkTests|DocumentAssemblerSemanticTests|DocumentPlannerBusinessDocumentTests|HtmlSkillConsultingSectionsTests|ExcelSkillSummarySheetTests" -p:OutputPath=bin\\verify_doc_phase2_tests\\ -p:IntermediateOutputPath=obj\\verify_doc_phase2_tests\\` 통과 9 + +업데이트: 2026-04-14 19:50 (KST) - Agent loop/queue/context 품질을 보강했습니다. `src/AxCopilot/Services/Agent/AgentCommandQueue.cs`로 실행 중 추가 입력을 우선순위와 interrupt 여부까지 포함해 관리하고, `AgentLoopService`는 이를 안전하게 반영합니다. - `AgentToolResultBudget`, `AgentQueryContextBuilder`, `ChatModels`는 tool result preview를 메시지에 캐시해 긴 세션과 재질문에서도 같은 축약 결과를 재사용하도록 정리했습니다. - 코드 탭의 내장 언어 지원을 `src/AxCopilot/Services/CodeLanguageCatalog.cs`로 통합했고, 설정의 코드 탭에 지원 언어(LSP)와 코드 탭 기본 지원 언어를 명시적으로 표시합니다. diff --git a/src/AxCopilot.Tests/Services/ArtifactQualityReviewServiceTests.cs b/src/AxCopilot.Tests/Services/ArtifactQualityReviewServiceTests.cs new file mode 100644 index 0000000..c9c99e6 --- /dev/null +++ b/src/AxCopilot.Tests/Services/ArtifactQualityReviewServiceTests.cs @@ -0,0 +1,73 @@ +using AxCopilot.Services.Agent; +using FluentAssertions; +using Xunit; + +namespace AxCopilot.Tests.Services; + +public class ArtifactQualityReviewServiceTests +{ + [Fact] + public void ReviewHtml_ShouldDetectRichBusinessStructure() + { + var html = + """ +

Executive Summary

Summary text.

Another supporting paragraph.

+

Current State

Current state detail.

Evidence paragraph.

+
Important message
+
MetricValue
NPS61
+
+
+

Recommendation

Recommendation text.

Action support text.

+

Appendix

Reference detail.

More detail.

+ """; + + var review = ArtifactQualityReviewService.ReviewHtml("Board Report", html, hasCover: true, hasTableOfContents: true, printReady: true); + + review.Score.Should().BeGreaterThan(75); + review.Strengths.Should().Contain(s => s.Contains("커버") || s.Contains("목차")); + review.Issues.Should().NotContain(i => i.Severity == ArtifactReviewSeverity.Critical); + } + + [Fact] + public void ReviewStructuredDocument_ShouldFlagMissingExecutiveSections() + { + var review = ArtifactQualityReviewService.ReviewStructuredDocument(new StructuredDocumentReviewInput( + "Ops Memo", + 2, + 600, + 0, + 0, + 0, + 0, + 0, + false, + false, + false, + false, + false, + false, + false)); + + review.Issues.Should().Contain(i => i.Message.Contains("Executive Summary")); + review.Issues.Should().Contain(i => i.Message.Contains("권고안")); + } + + [Fact] + public void ReviewWorkbook_ShouldFlagMissingSummaryForMultiSheet() + { + var review = ArtifactQualityReviewService.ReviewWorkbook(new WorkbookReviewInput( + "PMO Tracker", + 3, + 3, + 40, + 2, + 0, + 0, + false, + false, + false)); + + review.Issues.Should().Contain(i => i.Message.Contains("요약 시트")); + review.Score.Should().BeLessThan(80); + } +} diff --git a/src/AxCopilot.Tests/Services/DocxSkillTemplateFeaturesTests.cs b/src/AxCopilot.Tests/Services/DocxSkillTemplateFeaturesTests.cs new file mode 100644 index 0000000..880ec14 --- /dev/null +++ b/src/AxCopilot.Tests/Services/DocxSkillTemplateFeaturesTests.cs @@ -0,0 +1,76 @@ +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 DocxSkillTemplateFeaturesTests +{ + [Fact] + public async Task ExecuteAsync_ShouldCreateDocx_WithTemplateCoverAndToc() + { + var workDir = Path.Combine(Path.GetTempPath(), "ax-docx-template-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workDir); + + try + { + var templatePath = Path.Combine(workDir, "template.docx"); + using (var template = WordprocessingDocument.Create(templatePath, DocumentFormat.OpenXml.WordprocessingDocumentType.Document)) + { + var mainPart = template.AddMainDocumentPart(); + mainPart.Document = new Document(new Body(new Paragraph(new Run(new Text("Template Root"))))); + mainPart.Document.Save(); + } + + var tool = new DocxSkill(); + var context = new AgentContext + { + WorkFolder = workDir, + Permission = "Auto", + OperationMode = "external", + }; + + var args = JsonDocument.Parse( + """ + { + "path": "executive-brief.docx", + "title": "Executive Brief", + "template_path": "template.docx", + "cover_subtitle": "Q2 Operating Review", + "cover_meta": ["Prepared for Steering Committee", "Confidential"], + "toc": true, + "sections": [ + { "heading": "Executive Summary", "body": "Summary line one.\nSummary line two.", "level": 1 }, + { "heading": "Recommendation", "body": "Recommendation details.", "level": 1 } + ] + } + """).RootElement; + + var result = await tool.ExecuteAsync(args, context, CancellationToken.None); + + result.Success.Should().BeTrue(); + var outputPath = Path.Combine(workDir, "executive-brief.docx"); + File.Exists(outputPath).Should().BeTrue(); + + using var doc = WordprocessingDocument.Open(outputPath, false); + doc.MainDocumentPart!.Document.Body!.InnerText.Should().Contain("Q2 Operating Review"); + doc.MainDocumentPart.Document.Body.InnerText.Should().Contain("Prepared for Steering Committee"); + doc.MainDocumentPart.Document.Body.Descendants().Should().Contain(f => f.Text.Contains("TOC")); + } + finally + { + try + { + if (Directory.Exists(workDir)) + Directory.Delete(workDir, true); + } + catch + { + } + } + } +} diff --git a/src/AxCopilot.Tests/Services/ExcelSkillExecutiveSummaryLinkTests.cs b/src/AxCopilot.Tests/Services/ExcelSkillExecutiveSummaryLinkTests.cs new file mode 100644 index 0000000..2bf2afe --- /dev/null +++ b/src/AxCopilot.Tests/Services/ExcelSkillExecutiveSummaryLinkTests.cs @@ -0,0 +1,80 @@ +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 ExcelSkillExecutiveSummaryLinkTests +{ + [Fact] + public async Task ExecuteAsync_ShouldCreateSummarySheetWithDetailLinks() + { + var workDir = Path.Combine(Path.GetTempPath(), "ax-xlsx-summary-" + 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": "ops-review.xlsx", + "summary_sheet": { + "name": "Summary", + "title": "Ops Review", + "highlights": ["Margin improved", "Backlog stable"], + "actions": ["Expand automation", "Tighten forecast"] + }, + "sheets": [ + { + "name": "Revenue", + "headers": ["Metric", "Value"], + "rows": [["Revenue", 120], ["Margin", 18], ["Delta", "=B2-B3"]] + }, + { + "name": "Pipeline", + "headers": ["Stage", "Count"], + "rows": [["Qualified", 14], ["Proposal", 8]] + } + ] + } + """).RootElement; + + var result = await tool.ExecuteAsync(args, context, CancellationToken.None); + + result.Success.Should().BeTrue(); + var outputPath = Path.Combine(workDir, "ops-review.xlsx"); + File.Exists(outputPath).Should().BeTrue(); + + using var doc = SpreadsheetDocument.Open(outputPath, false); + var workbookPart = doc.WorkbookPart; + workbookPart.Should().NotBeNull(); + var summarySheet = workbookPart!.Workbook.Sheets!.Elements().First(s => s.Name == "Summary"); + var summaryPart = (WorksheetPart)workbookPart.GetPartById(summarySheet.Id!); + summaryPart.Worksheet.Elements().Should().ContainSingle(); + summaryPart.Worksheet.Descendants().Count().Should().BeGreaterOrEqualTo(2); + } + 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 new file mode 100644 index 0000000..f7e8fa9 --- /dev/null +++ b/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs @@ -0,0 +1,216 @@ +using System.Text.RegularExpressions; + +namespace AxCopilot.Services.Agent; + +public enum ArtifactReviewSeverity +{ + Info, + Warning, + Critical, +} + +public sealed record ArtifactReviewIssue(string Message, ArtifactReviewSeverity Severity); + +public sealed record ArtifactQualityReport( + string ArtifactType, + int Score, + IReadOnlyList Strengths, + IReadOnlyList Issues) +{ + public string ToToolSummary() + { + var strengths = Strengths.Take(3).ToList(); + var issues = Issues.OrderByDescending(i => i.Severity).Take(3).Select(i => i.Message).ToList(); + + var parts = new List { $"품질 점수 {Score}/100" }; + if (strengths.Count > 0) + parts.Add("강점: " + string.Join(", ", strengths)); + if (issues.Count > 0) + parts.Add("보완: " + string.Join(", ", issues)); + else + parts.Add("보완 필요 사항 없음"); + + return string.Join(" | ", parts); + } +} + +public sealed record StructuredDocumentReviewInput( + string Title, + int SectionCount, + int BodyCharacterCount, + int TableCount, + int ListCount, + int CalloutCount, + int HighlightCount, + int IconCount, + bool HasCoverPage, + bool HasTableOfContents, + bool HasTemplate, + bool HasHeaderFooter, + bool HasExecutiveSummarySection, + bool HasRecommendationSection, + bool HasAppendixSection); + +public sealed record WorkbookReviewInput( + string Title, + int SheetCount, + int DetailSheetCount, + int DataRowCount, + int FormulaCount, + int HyperlinkCount, + int DataValidationCount, + bool HasSummarySheet, + bool HasHighlightSection, + bool HasActionSection); + +public static class ArtifactQualityReviewService +{ + private static readonly string[] ExecutiveKeywords = ["executive summary", "요약", "핵심 요약", "summary"]; + private static readonly string[] RecommendationKeywords = ["recommendation", "권고", "제안", "next steps", "실행 과제"]; + private static readonly string[] AppendixKeywords = ["appendix", "부록", "참고", "reference"]; + + public static ArtifactQualityReport ReviewHtml(string title, string html, bool hasCover, bool hasTableOfContents, bool printReady) + { + title ??= string.Empty; + html ??= string.Empty; + + var strengths = new List(); + var issues = new List(); + + var sectionCount = Regex.Matches(html, @"= 5) strengths.Add($"핵심 섹션 {sectionCount}개 구성"); + if (tableCount > 0 || comparisonCount > 0 || roadmapCount > 0 || matrixCount > 0 || kpiCount > 0) + strengths.Add("표/비교/로드맵/KPI 등 구조화 블록 활용"); + if (calloutCount > 0) strengths.Add("핵심 메시지 강조 블록 포함"); + + if (html.Length < 1800) + issues.Add(new("문서 본문이 짧아 메시지와 근거 밀도가 낮을 수 있습니다", ArtifactReviewSeverity.Warning)); + if (sectionCount < 4) + issues.Add(new("업무 문서 기준 핵심 섹션 수가 부족합니다", ArtifactReviewSeverity.Warning)); + if (paragraphCount < sectionCount * 2) + issues.Add(new("일부 섹션의 서술이 충분하지 않습니다", ArtifactReviewSeverity.Warning)); + if (tableCount + comparisonCount + roadmapCount + matrixCount + kpiCount == 0) + issues.Add(new("구조화 시각 요소가 없어 보고서 완성도가 낮을 수 있습니다", ArtifactReviewSeverity.Warning)); + if (placeholderCount > 0) + issues.Add(new($"플레이스홀더/미완성 표현 {placeholderCount}건이 남아 있습니다", ArtifactReviewSeverity.Critical)); + if (!ContainsAny(html, ExecutiveKeywords)) + issues.Add(new("요약 또는 핵심 메시지 섹션이 명확하지 않습니다", ArtifactReviewSeverity.Warning)); + + return BuildReport("html", strengths, issues); + } + + public static ArtifactQualityReport ReviewStructuredDocument(StructuredDocumentReviewInput input) + { + var strengths = new List(); + var issues = new List(); + + if (input.HasTemplate) strengths.Add("템플릿 기반 스타일 상속"); + if (input.HasCoverPage) strengths.Add("커버 페이지 포함"); + if (input.HasTableOfContents) strengths.Add("목차 포함"); + if (input.HasHeaderFooter) strengths.Add("머리글/바닥글 적용"); + if (input.SectionCount >= 5) strengths.Add($"핵심 섹션 {input.SectionCount}개 구성"); + if (input.TableCount > 0) strengths.Add($"테이블 {input.TableCount}개 포함"); + if (input.CalloutCount + input.HighlightCount > 0) strengths.Add("강조 블록 활용"); + if (input.ListCount > 0) strengths.Add("실행 항목/목록 구조 포함"); + + if (input.BodyCharacterCount < 1400) + issues.Add(new("문서 분량이 짧아 경영 보고용 밀도가 부족할 수 있습니다", ArtifactReviewSeverity.Warning)); + if (input.SectionCount < 4) + issues.Add(new("업무 문서 기준 핵심 섹션 수가 부족합니다", ArtifactReviewSeverity.Warning)); + if (!input.HasExecutiveSummarySection) + issues.Add(new("Executive Summary 또는 요약 섹션이 없습니다", ArtifactReviewSeverity.Warning)); + if (!input.HasRecommendationSection) + issues.Add(new("권고안 또는 다음 단계 섹션이 없습니다", ArtifactReviewSeverity.Warning)); + if (input.SectionCount >= 6 && !input.HasAppendixSection) + issues.Add(new("긴 문서인데 부록/참고 섹션이 없습니다", ArtifactReviewSeverity.Info)); + if (input.TableCount + input.ListCount + input.CalloutCount + input.HighlightCount == 0) + issues.Add(new("표, 목록, 강조 블록이 없어 문서가 단조로울 수 있습니다", ArtifactReviewSeverity.Warning)); + + return BuildReport("docx", strengths, issues); + } + + public static ArtifactQualityReport ReviewWorkbook(WorkbookReviewInput input) + { + var strengths = new List(); + var issues = new List(); + + if (input.HasSummarySheet) strengths.Add("요약 시트 포함"); + if (input.DetailSheetCount > 1) strengths.Add($"상세 시트 {input.DetailSheetCount}개 구성"); + if (input.FormulaCount > 0) strengths.Add($"수식 {input.FormulaCount}개 포함"); + if (input.HyperlinkCount > 0) strengths.Add("시트 간 빠른 이동 링크 포함"); + if (input.DataValidationCount > 0) strengths.Add("데이터 검증 규칙 포함"); + if (input.HasHighlightSection) strengths.Add("핵심 인사이트 영역 포함"); + if (input.HasActionSection) strengths.Add("후속 액션 영역 포함"); + + if (input.SheetCount > 1 && !input.HasSummarySheet) + issues.Add(new("멀티시트 워크북인데 요약 시트가 없습니다", ArtifactReviewSeverity.Warning)); + if (input.DataRowCount >= 10 && input.FormulaCount == 0) + issues.Add(new("데이터 행 수 대비 수식/집계가 부족합니다", ArtifactReviewSeverity.Warning)); + if (input.HasSummarySheet && input.HyperlinkCount == 0) + issues.Add(new("요약 시트에서 상세 시트로 이동하는 링크가 없습니다", ArtifactReviewSeverity.Warning)); + if (input.DataValidationCount == 0 && input.DataRowCount >= 5) + issues.Add(new("입력 통제를 위한 데이터 검증 규칙이 없습니다", ArtifactReviewSeverity.Info)); + + return BuildReport("xlsx", strengths, issues); + } + + public static bool ContainsBusinessKeyword(string text, params string[] keywords) + { + return ContainsAny(text, keywords); + } + + private static ArtifactQualityReport BuildReport( + string artifactType, + List strengths, + List issues) + { + var score = 72; + score += Math.Min(18, strengths.Count * 4); + score -= issues.Sum(issue => issue.Severity switch + { + ArtifactReviewSeverity.Critical => 12, + ArtifactReviewSeverity.Warning => 6, + _ => 2, + }); + score = Math.Clamp(score, 35, 98); + + return new ArtifactQualityReport(artifactType, score, strengths, issues); + } + + private static int CountPlaceholders(string text) + { + var count = 0; + if (string.IsNullOrWhiteSpace(text)) + return count; + + var patterns = new[] + { + @"\[(todo|placeholder|내용.*작성|fill me)\]", + @"lorem ipsum", + @"tbd", + }; + + foreach (var pattern in patterns) + count += Regex.Matches(text, pattern, RegexOptions.IgnoreCase).Count; + + return count; + } + + private static bool ContainsAny(string text, IEnumerable keywords) + { + return keywords.Any(keyword => text.Contains(keyword, StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/src/AxCopilot/Services/Agent/DocxSkill.cs b/src/AxCopilot/Services/Agent/DocxSkill.cs index 8b6ef12..6aaee0c 100644 --- a/src/AxCopilot/Services/Agent/DocxSkill.cs +++ b/src/AxCopilot/Services/Agent/DocxSkill.cs @@ -28,6 +28,15 @@ public class DocxSkill : IAgentTool ["path"] = new() { Type = "string", Description = "Output file path (.docx). Relative to work folder." }, ["title"] = new() { Type = "string", Description = "Document title (optional)." }, ["theme"] = new() { Type = "string", Description = "Visual theme: professional (default), modern, dark, minimal, creative." }, + ["template_path"] = new() { Type = "string", Description = "Optional .docx template path. Reuses template styles and section setup." }, + ["cover_subtitle"] = new() { Type = "string", Description = "Optional subtitle shown on the cover page." }, + ["cover_meta"] = new() + { + Type = "array", + Description = "Optional cover page meta lines such as author, organization, review window, or distribution note.", + Items = new() { Type = "string" } + }, + ["toc"] = new() { Type = "boolean", Description = "Include a table of contents field. Default: true when there are 4+ sections." }, ["sections"] = new() { Type = "array", @@ -114,6 +123,13 @@ public class DocxSkill : IAgentTool var footerText = args.SafeTryGetProperty("footer", out var ftr) ? ftr.SafeGetString() : null; var showPageNumbers = args.SafeTryGetProperty("page_numbers", out var pn) ? pn.GetBoolean() : (headerText != null || footerText != null); + var coverSubtitle = args.SafeTryGetProperty("cover_subtitle", out var subtitleEl) ? subtitleEl.SafeGetString() : null; + var templatePath = args.SafeTryGetProperty("template_path", out var templateEl) ? templateEl.SafeGetString() : null; + var coverMeta = ReadStringArray(args, "cover_meta"); + var sections = args.GetProperty("sections"); + var useToc = args.SafeTryGetProperty("toc", out var tocEl) + ? tocEl.ValueKind == JsonValueKind.True + : sections.GetArrayLength() >= 4; var themeName = args.SafeTryGetProperty("theme", out var th) ? th.SafeGetString() ?? "professional" : "professional"; var theme = Themes.TryGetValue(themeName, out var tc) ? tc : Themes["professional"]; @@ -131,26 +147,48 @@ public class DocxSkill : IAgentTool try { - var sections = args.GetProperty("sections"); - var dir = Path.GetDirectoryName(fullPath); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + string? templateFullPath = null; + var templateApplied = false; - using var doc = WordprocessingDocument.Create(fullPath, WordprocessingDocumentType.Document); - var mainPart = doc.AddMainDocumentPart(); + if (!string.IsNullOrWhiteSpace(templatePath)) + { + templateFullPath = FileReadTool.ResolvePath(templatePath, context.WorkFolder); + if (!templateFullPath.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)) + templateFullPath += ".docx"; + + if (string.Equals(Path.GetFullPath(templateFullPath), Path.GetFullPath(fullPath), StringComparison.OrdinalIgnoreCase)) + return ToolResult.Fail("template_path와 path는 같은 파일일 수 없습니다."); + if (!context.IsPathAllowed(templateFullPath)) + return ToolResult.Fail($"템플릿 경로 접근 차단: {templateFullPath}"); + if (!File.Exists(templateFullPath)) + return ToolResult.Fail($"DOCX 템플릿을 찾을 수 없습니다: {templateFullPath}"); + + File.Copy(templateFullPath, fullPath, true); + templateApplied = true; + } + + using var doc = templateApplied + ? WordprocessingDocument.Open(fullPath, true) + : WordprocessingDocument.Create(fullPath, WordprocessingDocumentType.Document); + var mainPart = doc.MainDocumentPart ?? doc.AddMainDocumentPart(); // 기본 스타일 파트 추가 (styles.xml) - var stylesPart = mainPart.AddNewPart(); - stylesPart.Styles = CreateDefaultDocxStyles(); - stylesPart.Styles.Save(); + EnsureStyles(mainPart, templateApplied); - mainPart.Document = new Document(); - var body = mainPart.Document.AppendChild(new Body()); + var body = InitializeDocumentBody(mainPart, templateApplied); // 머리글/바닥글 설정 if (headerText != null || footerText != null || showPageNumbers) AddHeaderFooter(mainPart, body, headerText, footerText, showPageNumbers); + if (!string.IsNullOrWhiteSpace(title) || !string.IsNullOrWhiteSpace(coverSubtitle) || coverMeta.Count > 0) + AppendCoverPage(body, title, coverSubtitle, coverMeta, theme); + + if (useToc) + AppendTableOfContents(body); + // 제목 if (!string.IsNullOrEmpty(title)) { @@ -167,6 +205,11 @@ public class DocxSkill : IAgentTool int sectionCount = 0; int tableCount = 0; + int listCount = 0; + int calloutCount = 0; + int highlightCount = 0; + int iconCount = 0; + var headings = new List(); foreach (var section in sections.EnumerateArray()) { var blockType = section.SafeTryGetProperty("type", out var bt) ? bt.SafeGetString()?.ToLower() : null; @@ -187,24 +230,28 @@ public class DocxSkill : IAgentTool if (blockType == "list") { AppendList(body, section); + listCount++; continue; } if (blockType == "callout") { AppendCallout(body, section); + calloutCount++; continue; } if (blockType == "highlight_box") { body.Append(CreateHighlightBox(section)); + highlightCount++; continue; } if (blockType == "icon") { body.Append(CreateIconParagraph(section)); + iconCount++; continue; } @@ -214,7 +261,10 @@ public class DocxSkill : IAgentTool var level = section.SafeTryGetProperty("level", out var lv) ? lv.GetInt32() : 1; if (!string.IsNullOrEmpty(heading)) + { body.Append(CreateHeadingParagraph(heading, level, theme)); + headings.Add(heading); + } if (!string.IsNullOrEmpty(bodyText)) { @@ -231,6 +281,9 @@ public class DocxSkill : IAgentTool mainPart.Document.Save(); var parts = new List(); + if (templateApplied) parts.Add("템플릿"); + if (useToc) parts.Add("목차"); + if (!string.IsNullOrWhiteSpace(coverSubtitle) || coverMeta.Count > 0) parts.Add("커버"); if (!string.IsNullOrEmpty(title)) parts.Add($"제목: {title}"); if (sectionCount > 0) parts.Add($"섹션: {sectionCount}개"); if (tableCount > 0) parts.Add($"테이블: {tableCount}개"); @@ -238,6 +291,46 @@ public class DocxSkill : IAgentTool if (showPageNumbers) parts.Add("페이지번호"); parts.Add($"테마: {themeName}"); + #if false + var review = ArtifactQualityReviewService.ReviewStructuredDocument(new StructuredDocumentReviewInput( + title, + sectionCount, + sections.ToString().Length, + tableCount, + listCount, + calloutCount, + highlightCount, + iconCount, + !string.IsNullOrWhiteSpace(coverSubtitle) || coverMeta.Count > 0, + useToc, + templateApplied, + headerText != null || footerText != null || showPageNumbers, + headings.Any(h => ArtifactQualityReviewService.ContainsBusinessKeyword(h, "executive summary", "summary", "요약")), + headings.Any(h => ArtifactQualityReviewService.ContainsBusinessKeyword(h, "recommendation", "권고", "제안", "next step", "실행")), + headings.Any(h => ArtifactQualityReviewService.ContainsBusinessKeyword(h, "appendix", "부록", "참고")) + )); + + parts.Add(review.ToToolSummary()); + #endif + var review = ArtifactQualityReviewService.ReviewStructuredDocument(new StructuredDocumentReviewInput( + title, + sectionCount, + sections.ToString().Length, + tableCount, + listCount, + calloutCount, + highlightCount, + iconCount, + !string.IsNullOrWhiteSpace(coverSubtitle) || coverMeta.Count > 0, + useToc, + templateApplied, + headerText != null || footerText != null || showPageNumbers, + 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")) + )); + + parts.Add(review.ToToolSummary()); return ToolResult.Ok( $"Word 문서 생성 완료: {fullPath}\n{string.Join(", ", parts)}", fullPath); @@ -252,6 +345,118 @@ public class DocxSkill : IAgentTool // 제목/소제목/본문 단락 생성 // ═══════════════════════════════════════════════════ + private static List ReadStringArray(JsonElement args, string propertyName) + { + var items = new List(); + if (!args.SafeTryGetProperty(propertyName, out var arr) || arr.ValueKind != JsonValueKind.Array) + return items; + + foreach (var item in arr.EnumerateArray()) + { + var text = item.SafeGetString(); + if (!string.IsNullOrWhiteSpace(text)) + items.Add(text); + } + + return items; + } + + private static Body InitializeDocumentBody(MainDocumentPart mainPart, bool preserveSectionSetup) + { + mainPart.Document ??= new Document(); + + SectionProperties? preservedSection = null; + if (preserveSectionSetup) + preservedSection = mainPart.Document.Body?.Elements().LastOrDefault()?.CloneNode(true) as SectionProperties; + + mainPart.Document.Body = new Body(); + var body = mainPart.Document.Body; + + if (preservedSection != null) + body.Append(preservedSection); + + return body; + } + + private static void EnsureStyles(MainDocumentPart mainPart, bool preserveExisting) + { + var stylesPart = mainPart.StyleDefinitionsPart; + if (stylesPart == null) + { + stylesPart = mainPart.AddNewPart(); + stylesPart.Styles = CreateDefaultDocxStyles(); + stylesPart.Styles.Save(); + return; + } + + if (!preserveExisting || stylesPart.Styles == null) + { + stylesPart.Styles = CreateDefaultDocxStyles(); + stylesPart.Styles.Save(); + } + } + + private static void AppendCoverPage(Body body, string title, string? subtitle, IReadOnlyList metaLines, ThemeColors theme) + { + if (!string.IsNullOrWhiteSpace(title)) + body.Append(CreateTitleParagraph(title, theme)); + + if (!string.IsNullOrWhiteSpace(subtitle)) + { + body.Append(new Paragraph( + new ParagraphProperties + { + Justification = new Justification { Val = JustificationValues.Center }, + SpacingBetweenLines = new SpacingBetweenLines { After = "220" }, + }, + new Run( + new RunProperties( + new FontSize { Val = "26" }, + new Color { Val = theme.H2 }, + new RunFonts { Ascii = "Noto Sans KR", HighAnsi = "Noto Sans KR", EastAsia = "Noto Sans KR" }), + new Text(subtitle)))); + } + + foreach (var line in metaLines) + { + body.Append(new Paragraph( + new ParagraphProperties + { + Justification = new Justification { Val = JustificationValues.Center }, + SpacingBetweenLines = new SpacingBetweenLines { After = "80" }, + }, + new Run( + new RunProperties( + new FontSize { Val = "20" }, + new Color { Val = "666666" }, + new RunFonts { Ascii = "Noto Sans KR", HighAnsi = "Noto Sans KR", EastAsia = "Noto Sans KR" }), + new Text(line)))); + } + + body.Append(CreatePageBreak()); + } + + private static void AppendTableOfContents(Body body) + { + body.Append(new Paragraph( + new ParagraphProperties(new SpacingBetweenLines { Before = "120", After = "120" }), + new Run( + new RunProperties( + new Bold(), + new FontSize { Val = "28" }, + new RunFonts { Ascii = "Noto Sans KR", HighAnsi = "Noto Sans KR", EastAsia = "Noto Sans KR" }), + new Text("목차")))); + + var paragraph = new Paragraph(); + paragraph.Append(new Run(new FieldChar { FieldCharType = FieldCharValues.Begin })); + paragraph.Append(new Run(new FieldCode(" TOC \\o \"1-3\" \\h \\z \\u ") { Space = SpaceProcessingModeValues.Preserve })); + paragraph.Append(new Run(new FieldChar { FieldCharType = FieldCharValues.Separate })); + paragraph.Append(new Run(new Text("목차를 업데이트하려면 Word에서 필드를 새로 고치세요."))); + paragraph.Append(new Run(new FieldChar { FieldCharType = FieldCharValues.End })); + body.Append(paragraph); + body.Append(CreatePageBreak()); + } + private static Paragraph CreateTitleParagraph(string text, ThemeColors theme) { var para = new Paragraph(); @@ -779,6 +984,7 @@ public class DocxSkill : IAgentTool // SectionProperties에 머리글 연결 var secProps = body.GetFirstChild() ?? body.AppendChild(new SectionProperties()); + secProps.Elements().ToList().ForEach(r => r.Remove()); secProps.Append(new HeaderReference { Type = HeaderFooterValues.Default, @@ -825,6 +1031,7 @@ public class DocxSkill : IAgentTool footerPart.Footer = footer; var secProps = body.GetFirstChild() ?? body.AppendChild(new SectionProperties()); + secProps.Elements().ToList().ForEach(r => r.Remove()); secProps.Append(new FooterReference { Type = HeaderFooterValues.Default, diff --git a/src/AxCopilot/Services/Agent/ExcelSkill.cs b/src/AxCopilot/Services/Agent/ExcelSkill.cs index 240c0c7..67644ca 100644 --- a/src/AxCopilot/Services/Agent/ExcelSkill.cs +++ b/src/AxCopilot/Services/Agent/ExcelSkill.cs @@ -202,6 +202,19 @@ public class ExcelSkill : IAgentTool summaryArg.ValueKind == JsonValueKind.Object, numFmts.Count > 0, alignments.Count > 0, themeName); + var review = ArtifactQualityReviewService.ReviewWorkbook(new WorkbookReviewInput( + sheetName, + 1, + 1, + rowCount, + CountFormulaCells(rows), + 0, + 0, + false, + false, + summaryArg.ValueKind == JsonValueKind.Object)); + features += $"\n{review.ToToolSummary()}"; + return ToolResult.Ok( $"Excel 파일 생성 완료: {fullPath}\n시트: {sheetName}, 열: {colCount}, 행: {rowCount}{features}", fullPath); @@ -274,6 +287,18 @@ public class ExcelSkill : IAgentTool mergesArg.ValueKind == JsonValueKind.Array, summaryArg.ValueKind == JsonValueKind.Object, numFmts.Count > 0, alignments.Count > 0, themeName); + var review = ArtifactQualityReviewService.ReviewWorkbook(new WorkbookReviewInput( + summaryName, + 2, + 1, + rowCount, + CountFormulaCells(rows), + 1, + 0, + true, + HasSummaryItems(summarySheet, "highlights"), + HasSummaryItems(summarySheet, "actions"))); + features += $"\n{review.ToToolSummary()}"; return ToolResult.Ok( $"Excel ?뚯씪 ?앹꽦 ?꾨즺: {fullPath}\n?쒗듃: {summaryName}, {sheetName}, ?? {colCount}, ?? {rowCount}{features} [?붿빟 ?쒗듃]", @@ -313,6 +338,7 @@ public class ExcelSkill : IAgentTool uint sheetId = 1; var totalSheets = 0; var totalRows = 0; + var totalFormulaCount = 0; var detailSheetNames = new List(); foreach (var sheetDef in sheetsArr.EnumerateArray()) @@ -373,10 +399,10 @@ public class ExcelSkill : IAgentTool sheetId++; totalSheets++; totalRows += rowCount; + totalFormulaCount += CountFormulaCells(rows); } workbookPart.Workbook.Save(); - return ToolResult.Ok( $"Excel 파일 생성 완료: {fullPath}\n시트: {totalSheets}개, 총 데이터 행: {totalRows}", fullPath); @@ -403,6 +429,7 @@ public class ExcelSkill : IAgentTool worksheetPart.Worksheet.Append(sheetData); var merges = new MergeCells(); + var hyperlinks = new Hyperlinks(); uint rowIndex = 1; var title = summarySheet.SafeTryGetProperty("title", out var titleEl) @@ -454,8 +481,27 @@ public class ExcelSkill : IAgentTool AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", $"• {detailSheetName}", 0); } + if (detailSheetNames.Count > 0) + { + var hyperlinkStartRow = rowIndex - (uint)detailSheetNames.Count; + for (var i = 0; i < detailSheetNames.Count; i++) + { + hyperlinks.Append(new Hyperlink + { + Reference = $"A{hyperlinkStartRow + (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 AppendSummaryTextSection(JsonElement summarySheet, string key, string heading, SheetData sheetData, MergeCells merges, ref uint rowIndex) @@ -979,6 +1025,36 @@ public class ExcelSkill : IAgentTool return result; } + private static int CountFormulaCells(JsonElement rows) + { + if (rows.ValueKind != JsonValueKind.Array) + return 0; + + var count = 0; + foreach (var row in rows.EnumerateArray()) + { + if (row.ValueKind != JsonValueKind.Array) + continue; + + foreach (var cell in row.EnumerateArray()) + { + var text = cell.ToString(); + if (!string.IsNullOrWhiteSpace(text) && text.StartsWith('=')) + count++; + } + } + + return count; + } + + private static bool HasSummaryItems(JsonElement summarySheet, string propertyName) + { + return summarySheet.ValueKind == JsonValueKind.Object + && summarySheet.SafeTryGetProperty(propertyName, out var items) + && items.ValueKind == JsonValueKind.Array + && items.GetArrayLength() > 0; + } + private static string BuildFeatureList(bool isStyled, bool freezeHeader, bool hasMerges, bool hasSummary, bool hasNumFmts, bool hasAlignments, string? theme) { diff --git a/src/AxCopilot/Services/Agent/HtmlSkill.cs b/src/AxCopilot/Services/Agent/HtmlSkill.cs index d8231a2..6237cf8 100644 --- a/src/AxCopilot/Services/Agent/HtmlSkill.cs +++ b/src/AxCopilot/Services/Agent/HtmlSkill.cs @@ -288,6 +288,8 @@ public class HtmlSkill : IAgentTool if (!string.IsNullOrEmpty(accentColor)) features.Add($"색상:{accentColor}"); if (usePrint) features.Add("인쇄최적화"); var featureStr = features.Count > 0 ? $" [{string.Join(", ", features)}]" : ""; + var review = ArtifactQualityReviewService.ReviewHtml(title, sb.ToString(), hasCover, useToc, usePrint); + featureStr += $"\n{review.ToToolSummary()}"; return ToolResult.Ok( $"HTML 문서 생성 완료: {fullPath} (디자인: {mood}{featureStr})", diff --git a/src/AxCopilot/skills/board-report-html.skill.md b/src/AxCopilot/skills/board-report-html.skill.md new file mode 100644 index 0000000..0138d03 --- /dev/null +++ b/src/AxCopilot/skills/board-report-html.skill.md @@ -0,0 +1,26 @@ +--- +name: board-report-html +label: HTML 보고서 +description: 이사회/운영위원회용 HTML 보고서를 구조화된 섹션과 인쇄형 레이아웃으로 생성합니다. +icon: \uE8A7 +when_to_use: 웹 공유와 인쇄를 모두 고려한 HTML 경영 보고서가 필요할 때 +argument-hint: <보고 목적과 청중> +allowed-tools: + - folder_map + - file_read + - document_plan + - document_assemble + - html_create +tabs: cowork +--- + +HTML 업무 보고서를 작성하세요. + +작성 원칙: +- `html_create`를 우선 사용하고 `toc`, `print`, `cover`, `sections`를 적극 활용하세요. +- 문서는 `Executive Summary -> Current State -> Key Issues -> Options -> Recommendation -> Roadmap -> Appendix` 순서를 기본으로 잡으세요. +- comparison, roadmap, matrix, KPI, callout 블록을 내용에 맞게 섞어 쓰세요. +- 섹션마다 최소 두 개 이상의 실질 문단 또는 구조화 블록을 포함하세요. +- 단순 꾸밈보다 의사결정에 필요한 근거, 비교, 후속 조치를 분명히 드러내세요. + +결과물은 작업 폴더에 저장하고, 인쇄 가능 여부와 핵심 결론을 함께 요약하세요. diff --git a/src/AxCopilot/skills/executive-brief.skill.md b/src/AxCopilot/skills/executive-brief.skill.md new file mode 100644 index 0000000..ca2d826 --- /dev/null +++ b/src/AxCopilot/skills/executive-brief.skill.md @@ -0,0 +1,26 @@ +--- +name: executive-brief +label: 임원 보고 문서 +description: 경영진 보고용 Word 문서를 AX 네이티브 도구 중심으로 구성합니다. +icon: \uE9D9 +when_to_use: 경영진, 임원진, steering committee, 의사결정 회의용 Word 보고 문서를 빠르게 만들 때 +argument-hint: <보고 주제와 의사결정 포인트> +allowed-tools: + - folder_map + - file_read + - document_plan + - document_assemble + - docx_create +tabs: cowork +--- + +임원 보고 문서를 작성하세요. + +핵심 원칙: +- Python 우회 대신 `document_plan`, `document_assemble`, `docx_create`를 우선 사용하세요. +- 문서는 `Executive Summary -> Situation -> Key Findings -> Recommendation -> Next Steps -> Appendix` 구조를 기본으로 잡으세요. +- 각 섹션 제목은 메시지형으로 쓰고, 표와 강조 블록을 적극 활용하세요. +- 긴 문서는 `document_plan`으로 구조를 먼저 만들고, 최종 산출은 DOCX로 조립하세요. +- 사내 템플릿이 있다면 `template_path`, `cover_subtitle`, `toc`를 포함해 완성도를 높이세요. + +결과물은 작업 폴더에 저장하고, 최종 응답에는 파일 경로와 핵심 섹션을 요약하세요. diff --git a/src/AxCopilot/skills/kpi-workbook.skill.md b/src/AxCopilot/skills/kpi-workbook.skill.md new file mode 100644 index 0000000..4213718 --- /dev/null +++ b/src/AxCopilot/skills/kpi-workbook.skill.md @@ -0,0 +1,25 @@ +--- +name: kpi-workbook +label: KPI 워크북 +description: 요약 시트와 상세 시트를 갖춘 KPI 분석용 Excel 워크북을 생성합니다. +icon: \uE9D2 +when_to_use: KPI 대시보드, 운영 리뷰, 예산 대비 실적, PMO 추적용 Excel 워크북이 필요할 때 +argument-hint: <분석 주제와 포함할 핵심 지표> +allowed-tools: + - folder_map + - file_read + - data_pivot + - excel_create +tabs: cowork +--- + +요약 시트가 있는 KPI 워크북을 만드세요. + +작성 원칙: +- 단일 표보다 `Summary + Detail` 구조의 워크북을 우선 구성하세요. +- 요약 시트에는 KPI, highlights, actions를 넣고 상세 시트로 이어지는 구조를 만드세요. +- 수치 데이터가 있으면 가능한 범위에서 수식과 집계 행을 포함하세요. +- 시트 이름은 이해하기 쉽게 짓고, 운영 회의에서 바로 쓰일 수 있도록 핵심 지표와 후속 액션을 함께 정리하세요. +- 원본 데이터가 여러 개면 먼저 `data_pivot`으로 정리한 뒤 `excel_create`를 호출하세요. + +결과물은 작업 폴더에 저장하고, 시트 구성과 핵심 KPI를 함께 요약하세요.