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