문서 생성 품질 게이트와 산출물 고도화 2차 반영

공통 ArtifactQualityReviewService를 추가해 HTML, DOCX, XLSX 결과물에 로컬 품질 점수와 보완 포인트를 부여했습니다.

DocxSkill에 template_path, cover_subtitle, cover_meta, toc 흐름을 붙여 템플릿 기반 문서와 커버/목차 생성을 강화했고, Excel summary sheet에는 detail sheet 링크와 workbook review를 연결했습니다. HtmlSkill도 결과 요약에 품질 리뷰를 포함하도록 보강했습니다.

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)
This commit is contained in:
2026-04-14 21:26:58 +09:00
parent d9cb02f3c4
commit 6c7fba9dff
12 changed files with 836 additions and 10 deletions

View File

@@ -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해 같은 `/명령`이 겹칠 때 더 안정적으로 하나만 노출합니다.

View File

@@ -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)와 코드 탭 기본 지원 언어를 명시적으로 표시합니다.

View File

@@ -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 =
"""
<h2>Executive Summary</h2><p>Summary text.</p><p>Another supporting paragraph.</p>
<h2>Current State</h2><p>Current state detail.</p><p>Evidence paragraph.</p>
<div class="callout-info">Important message</div>
<table><tr><th>Metric</th><th>Value</th></tr><tr><td>NPS</td><td>61</td></tr></table>
<div class="comparison-grid"></div>
<div class="roadmap-block"></div>
<h2>Recommendation</h2><p>Recommendation text.</p><p>Action support text.</p>
<h2>Appendix</h2><p>Reference detail.</p><p>More detail.</p>
""";
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);
}
}

View File

@@ -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<FieldCode>().Should().Contain(f => f.Text.Contains("TOC"));
}
finally
{
try
{
if (Directory.Exists(workDir))
Directory.Delete(workDir, true);
}
catch
{
}
}
}
}

View File

@@ -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<Sheet>().First(s => s.Name == "Summary");
var summaryPart = (WorksheetPart)workbookPart.GetPartById(summarySheet.Id!);
summaryPart.Worksheet.Elements<Hyperlinks>().Should().ContainSingle();
summaryPart.Worksheet.Descendants<Hyperlink>().Count().Should().BeGreaterOrEqualTo(2);
}
finally
{
try
{
if (Directory.Exists(workDir))
Directory.Delete(workDir, true);
}
catch
{
}
}
}
}

View File

@@ -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<string> Strengths,
IReadOnlyList<ArtifactReviewIssue> 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<string> { $"품질 점수 {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<string>();
var issues = new List<ArtifactReviewIssue>();
var sectionCount = Regex.Matches(html, @"<h2\b", RegexOptions.IgnoreCase).Count;
var subSectionCount = Regex.Matches(html, @"<h3\b", RegexOptions.IgnoreCase).Count;
var paragraphCount = Regex.Matches(html, @"<p\b", RegexOptions.IgnoreCase).Count;
var tableCount = Regex.Matches(html, @"<table\b", RegexOptions.IgnoreCase).Count;
var calloutCount = Regex.Matches(html, @"callout-(info|warning|tip|danger)", RegexOptions.IgnoreCase).Count;
var comparisonCount = Regex.Matches(html, @"comparison-grid|comparison-card", RegexOptions.IgnoreCase).Count;
var roadmapCount = Regex.Matches(html, @"roadmap(-block|-phase)?", RegexOptions.IgnoreCase).Count;
var matrixCount = Regex.Matches(html, @"matrix-grid|risk-matrix", RegexOptions.IgnoreCase).Count;
var kpiCount = Regex.Matches(html, @"kpi-card|kpi-grid|metric-card", RegexOptions.IgnoreCase).Count;
var placeholderCount = CountPlaceholders(html);
if (hasCover) strengths.Add("커버 페이지 포함");
if (hasTableOfContents) strengths.Add("목차 자동 생성");
if (printReady) strengths.Add("인쇄 최적화 CSS 포함");
if (sectionCount >= 5) strengths.Add($"핵심 섹션 {sectionCount}개 구성");
if (tableCount > 0 || comparisonCount > 0 || roadmapCount > 0 || matrixCount > 0 || kpiCount > 0)
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<string>();
var issues = new List<ArtifactReviewIssue>();
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<string>();
var issues = new List<ArtifactReviewIssue>();
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<string> strengths,
List<ArtifactReviewIssue> 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<string> keywords)
{
return keywords.Any(keyword => text.Contains(keyword, StringComparison.OrdinalIgnoreCase));
}
}

View File

@@ -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<StyleDefinitionsPart>();
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<string>();
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<string>();
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<string> ReadStringArray(JsonElement args, string propertyName)
{
var items = new List<string>();
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<SectionProperties>().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<StyleDefinitionsPart>();
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<string> 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<SectionProperties>() ?? body.AppendChild(new SectionProperties());
secProps.Elements<HeaderReference>().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<SectionProperties>() ?? body.AppendChild(new SectionProperties());
secProps.Elements<FooterReference>().ToList().ForEach(r => r.Remove());
secProps.Append(new FooterReference
{
Type = HeaderFooterValues.Default,

View File

@@ -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<string>();
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)
{

View File

@@ -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})",

View File

@@ -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 블록을 내용에 맞게 섞어 쓰세요.
- 섹션마다 최소 두 개 이상의 실질 문단 또는 구조화 블록을 포함하세요.
- 단순 꾸밈보다 의사결정에 필요한 근거, 비교, 후속 조치를 분명히 드러내세요.
결과물은 작업 폴더에 저장하고, 인쇄 가능 여부와 핵심 결론을 함께 요약하세요.

View File

@@ -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`를 포함해 완성도를 높이세요.
결과물은 작업 폴더에 저장하고, 최종 응답에는 파일 경로와 핵심 섹션을 요약하세요.

View File

@@ -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를 함께 요약하세요.