문서 생성 품질 게이트와 산출물 고도화 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

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