문서 생성 품질 게이트와 산출물 고도화 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:
216
src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs
Normal file
216
src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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})",
|
||||
|
||||
26
src/AxCopilot/skills/board-report-html.skill.md
Normal file
26
src/AxCopilot/skills/board-report-html.skill.md
Normal 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 블록을 내용에 맞게 섞어 쓰세요.
|
||||
- 섹션마다 최소 두 개 이상의 실질 문단 또는 구조화 블록을 포함하세요.
|
||||
- 단순 꾸밈보다 의사결정에 필요한 근거, 비교, 후속 조치를 분명히 드러내세요.
|
||||
|
||||
결과물은 작업 폴더에 저장하고, 인쇄 가능 여부와 핵심 결론을 함께 요약하세요.
|
||||
26
src/AxCopilot/skills/executive-brief.skill.md
Normal file
26
src/AxCopilot/skills/executive-brief.skill.md
Normal 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`를 포함해 완성도를 높이세요.
|
||||
|
||||
결과물은 작업 폴더에 저장하고, 최종 응답에는 파일 경로와 핵심 섹션을 요약하세요.
|
||||
25
src/AxCopilot/skills/kpi-workbook.skill.md
Normal file
25
src/AxCopilot/skills/kpi-workbook.skill.md
Normal 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를 함께 요약하세요.
|
||||
Reference in New Issue
Block a user