문서 고도화 다음 단계를 반영해 엑셀 summary와 DOCX 조립 품질을 끌어올렸습니다

핵심 수정사항:
- ExcelSkill summary_sheet에 decision_summary, scorecards, sheet_summaries를 추가해 dashboard형 summary 시트를 생성하도록 확장했습니다.
- ArtifactQualityReviewService의 workbook 리뷰 입력을 확장해 KPI/decision/detail summary 존재 여부를 품질 점수와 보완 포인트에 반영했습니다.
- DocumentAssemblerTool에 style_map 파라미터를 추가해 template 기반 DOCX 조립에서 title/heading/body 문단 스타일을 실제 Word 스타일로 매핑하도록 개선했습니다.
- DocumentAssemblerStyleMapTests, ExcelSkillDashboardSummaryTests를 추가하고 기존 ArtifactQualityReviewServiceTests를 갱신했습니다.

검증 결과:
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_doc_next2\\ -p:IntermediateOutputPath=obj\\verify_doc_next2\\ : 경고 0 / 오류 0
- dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ArtifactQualityReviewServiceTests|DocumentAssemblerStyleMapTests|DocumentAssemblerDocxFeaturesTests|DocumentAssemblerSemanticTests|ExcelSkillDashboardSummaryTests|ExcelSkillSummarySheetTests|ExcelSkillExecutiveSummaryLinkTests|ExcelSkillDataValidationTests|ExcelSkillConditionalFormattingTests" -p:OutputPath=bin\\verify_doc_next2_tests\\ -p:IntermediateOutputPath=obj\\verify_doc_next2_tests\\ : 통과 11
This commit is contained in:
2026-04-14 23:06:53 +09:00
parent 3232db1b12
commit 1edeffa206
8 changed files with 471 additions and 56 deletions

View File

@@ -1822,3 +1822,11 @@ MIT License
- 테스트로 `ExcelSkillDataValidationTests`를 추가했고, `DocumentAssemblerDocxFeaturesTests`, `HtmlSkillConsultingSectionsTests`를 확장해 DOCX 템플릿/페이지 번호와 HTML decision/evidence 블록을 회귀 검증했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_doc_phase_next\\ -p:IntermediateOutputPath=obj\\verify_doc_phase_next\\` 경고 0 / 오류 0
- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "DocumentAssemblerDocxFeaturesTests|DocumentAssemblerSemanticTests|DocumentPlannerWorkbookScaffoldTests|ExcelSkillExecutiveSummaryLinkTests|ExcelSkillSummarySheetTests|ExcelSkillDataValidationTests|HtmlSkillConsultingSectionsTests|DocxSkillTemplateFeaturesTests|DocumentPlannerBusinessDocumentTests" -p:OutputPath=bin\\verify_doc_phase_next_tests\\ -p:IntermediateOutputPath=obj\\verify_doc_phase_next_tests\\` 통과 9
업데이트: 2026-04-14 23:05 (KST)
- 문서 포맷 고도화 다음 단계를 반영했습니다. [ExcelSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ExcelSkill.cs)는 `summary_sheet``decision_summary`, `scorecards`, `sheet_summaries`를 받을 수 있게 확장되어, 단순 KPI 표를 넘어 의사결정 요약과 상세 시트별 상태를 함께 보여주는 dashboard형 summary 시트를 생성합니다.
- 같은 파일의 워크북 품질 리뷰 입력도 확장해 summary sheet가 KPI/scorecard, decision summary, detail sheet summary를 실제로 담고 있는지 점수화하도록 [ArtifactQualityReviewService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs)를 보강했습니다.
- [DocumentAssemblerTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/DocumentAssemblerTool.cs)는 `style_map` 파라미터를 지원합니다. 템플릿 기반 DOCX 조립에서 `title`, `heading1`, `heading2`, `body` 문단 스타일을 실제 Word 문단에 연결해 cover title, 섹션 헤딩, 본문 문단이 사내 템플릿 스타일을 더 잘 따르도록 개선했습니다.
- 테스트로 [ExcelSkillDashboardSummaryTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/ExcelSkillDashboardSummaryTests.cs), [DocumentAssemblerStyleMapTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/DocumentAssemblerStyleMapTests.cs)를 추가했고, [ArtifactQualityReviewServiceTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/ArtifactQualityReviewServiceTests.cs)도 새 workbook review 입력 형식에 맞춰 갱신했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_doc_next2\\ -p:IntermediateOutputPath=obj\\verify_doc_next2\\` 경고 0 / 오류 0
- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ArtifactQualityReviewServiceTests|DocumentAssemblerStyleMapTests|DocumentAssemblerDocxFeaturesTests|DocumentAssemblerSemanticTests|ExcelSkillDashboardSummaryTests|ExcelSkillSummarySheetTests|ExcelSkillExecutiveSummaryLinkTests|ExcelSkillDataValidationTests|ExcelSkillConditionalFormattingTests" -p:OutputPath=bin\\verify_doc_next2_tests\\ -p:IntermediateOutputPath=obj\\verify_doc_next2_tests\\` 통과 11

View File

@@ -891,3 +891,11 @@ UI ?붿옄???€洹쒕え 由ы뙥?좊쭅 ???꾪뿕 ?묒뾽 ??湲곕줉???덉쟾
- 테스트로 [ExcelSkillDataValidationTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/ExcelSkillDataValidationTests.cs)를 추가했고, [DocumentAssemblerDocxFeaturesTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/DocumentAssemblerDocxFeaturesTests.cs), [HtmlSkillConsultingSectionsTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/HtmlSkillConsultingSectionsTests.cs)를 확장해 DOCX 템플릿/페이지 번호와 HTML decision/evidence 블록을 회귀 검증했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_doc_phase_next\\ -p:IntermediateOutputPath=obj\\verify_doc_phase_next\\` 경고 0 / 오류 0
- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "DocumentAssemblerDocxFeaturesTests|DocumentAssemblerSemanticTests|DocumentPlannerWorkbookScaffoldTests|ExcelSkillExecutiveSummaryLinkTests|ExcelSkillSummarySheetTests|ExcelSkillDataValidationTests|HtmlSkillConsultingSectionsTests|DocxSkillTemplateFeaturesTests|DocumentPlannerBusinessDocumentTests" -p:OutputPath=bin\\verify_doc_phase_next_tests\\ -p:IntermediateOutputPath=obj\\verify_doc_phase_next_tests\\` 통과 9
업데이트: 2026-04-14 23:05 (KST)
- 문서 고도화 다음 단계를 반영했습니다. [ExcelSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ExcelSkill.cs)는 `summary_sheet`에 `decision_summary`, `scorecards`, `sheet_summaries`를 추가로 받을 수 있게 확장됐고, executive summary sheet에서 의사결정 요청, 핵심 scorecard, 상세 시트별 상태를 순서대로 렌더링합니다.
- 워크북 품질 리뷰 입력도 같은 구조를 인식하도록 [ArtifactQualityReviewService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ArtifactQualityReviewService.cs)의 `WorkbookReviewInput`과 `ReviewWorkbook()`를 확장했습니다. 이제 summary sheet가 KPI/decision/detail summary를 충분히 담고 있는지 강점과 보완 포인트로 함께 표시합니다.
- [DocumentAssemblerTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/DocumentAssemblerTool.cs)는 `style_map` 파라미터를 받아 template-based DOCX assembly에서 `title`, `heading1`, `heading2`, `body` 문단 스타일을 실제 Word 문단에 매핑합니다. cover title, 섹션 헤딩, 본문 문단이 사내 템플릿 스타일을 더 자연스럽게 따라가도록 정리했습니다.
- 새 회귀 테스트 [ExcelSkillDashboardSummaryTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/ExcelSkillDashboardSummaryTests.cs), [DocumentAssemblerStyleMapTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/DocumentAssemblerStyleMapTests.cs)를 추가했고, [ArtifactQualityReviewServiceTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/ArtifactQualityReviewServiceTests.cs)도 새 record 시그니처에 맞춰 갱신했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_doc_next2\\ -p:IntermediateOutputPath=obj\\verify_doc_next2\\` 경고 0 / 오류 0
- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ArtifactQualityReviewServiceTests|DocumentAssemblerStyleMapTests|DocumentAssemblerDocxFeaturesTests|DocumentAssemblerSemanticTests|ExcelSkillDashboardSummaryTests|ExcelSkillSummarySheetTests|ExcelSkillExecutiveSummaryLinkTests|ExcelSkillDataValidationTests|ExcelSkillConditionalFormattingTests" -p:OutputPath=bin\\verify_doc_next2_tests\\ -p:IntermediateOutputPath=obj\\verify_doc_next2_tests\\` 통과 11

View File

@@ -68,6 +68,9 @@ public class ArtifactQualityReviewServiceTests
0,
false,
false,
false,
false,
false,
false));
review.Issues.Should().Contain(issue => issue.Message.Contains("summary sheet", StringComparison.OrdinalIgnoreCase));

View File

@@ -0,0 +1,91 @@
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 DocumentAssemblerStyleMapTests
{
[Fact]
public async Task ExecuteAsync_WithStyleMap_ShouldApplyTemplateParagraphStyles()
{
var workDir = Path.Combine(Path.GetTempPath(), "ax-doc-assemble-stylemap-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(workDir);
var templatePath = Path.Combine(workDir, "style-template.docx");
try
{
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 Placeholder")))));
var stylePart = mainPart.AddNewPart<StyleDefinitionsPart>();
stylePart.Styles = new Styles(
new Style { Type = StyleValues.Paragraph, StyleId = "DeckTitle", CustomStyle = true },
new Style { Type = StyleValues.Paragraph, StyleId = "DeckHeading1", CustomStyle = true },
new Style { Type = StyleValues.Paragraph, StyleId = "DeckBody", CustomStyle = true });
stylePart.Styles.Save();
mainPart.Document.Save();
}
var tool = new DocumentAssemblerTool();
var context = new AgentContext
{
WorkFolder = workDir,
Permission = "Auto",
OperationMode = "external",
};
var args = JsonDocument.Parse(
"""
{
"path": "assembled-stylemap.docx",
"title": "Board Update",
"format": "docx",
"template_path": "style-template.docx",
"style_map": {
"title": "DeckTitle",
"heading1": "DeckHeading1",
"body": "DeckBody"
},
"sections": [
{ "heading": "1. Executive Summary", "level": 1, "content": "<p>Margins improved across key accounts.</p>" }
]
}
""").RootElement;
var result = await tool.ExecuteAsync(args, context, CancellationToken.None);
result.Success.Should().BeTrue();
using var doc = WordprocessingDocument.Open(Path.Combine(workDir, "assembled-stylemap.docx"), false);
var paragraphs = doc.MainDocumentPart!.Document.Body!.Elements<Paragraph>().ToList();
paragraphs.First(paragraph => paragraph.InnerText == "Board Update")
.ParagraphProperties?.ParagraphStyleId?.Val?.Value
.Should().Be("DeckTitle");
paragraphs.First(paragraph => paragraph.InnerText == "1. Executive Summary")
.ParagraphProperties?.ParagraphStyleId?.Val?.Value
.Should().Be("DeckHeading1");
paragraphs.First(paragraph => paragraph.InnerText.Contains("Margins improved"))
.ParagraphProperties?.ParagraphStyleId?.Val?.Value
.Should().Be("DeckBody");
}
finally
{
try
{
if (Directory.Exists(workDir))
Directory.Delete(workDir, true);
}
catch
{
}
}
}
}

View File

@@ -0,0 +1,91 @@
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 ExcelSkillDashboardSummaryTests
{
[Fact]
public async Task ExecuteAsync_WithDashboardSummary_ShouldRenderDecisionAndSheetSummaryBlocks()
{
var workDir = Path.Combine(Path.GetTempPath(), "ax-xlsx-dashboard-" + 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": "dashboard-review.xlsx",
"summary_sheet": {
"name": "Summary",
"title": "Operating Review",
"decision_summary": [
{ "label": "Decision Ask", "value": "Approve regional rollout", "owner": "COO" }
],
"scorecards": [
{ "label": "Run-rate", "value": "96%", "status": "On Track", "note": "Ahead of plan" }
],
"sheet_summaries": [
{ "sheet": "Revenue", "status": "Watch", "summary": "SMB margin pressure", "owner": "Sales Ops" }
],
"highlights": ["Expansion pipeline improved"],
"actions": ["Lock next-quarter target"]
},
"sheets": [
{
"name": "Revenue",
"headers": ["Metric", "Value"],
"rows": [["Revenue", 120], ["Margin", 18]]
}
]
}
""").RootElement;
var result = await tool.ExecuteAsync(args, context, CancellationToken.None);
result.Success.Should().BeTrue();
result.Output.Should().Contain("Quality score");
using var doc = SpreadsheetDocument.Open(Path.Combine(workDir, "dashboard-review.xlsx"), false);
var workbookPart = doc.WorkbookPart!;
var summarySheet = workbookPart.Workbook.Sheets!.Elements<Sheet>().First(sheet => sheet.Name == "Summary");
var summaryPart = (WorksheetPart)workbookPart.GetPartById(summarySheet.Id!);
var texts = summaryPart.Worksheet.Descendants<Cell>()
.Select(cell => cell.CellValue?.Text)
.Where(text => !string.IsNullOrWhiteSpace(text))
.ToList();
texts.Should().Contain("Decision Summary");
texts.Should().Contain("Approve regional rollout");
texts.Should().Contain("Scorecards");
texts.Should().Contain("Run-rate");
texts.Should().Contain("Revenue");
texts.Should().Contain("SMB margin pressure");
}
finally
{
try
{
if (Directory.Exists(workDir))
Directory.Delete(workDir, true);
}
catch
{
}
}
}
}

View File

@@ -66,7 +66,10 @@ public sealed record WorkbookReviewInput(
int ConditionalFormattingCount,
bool HasSummarySheet,
bool HasHighlightSection,
bool HasActionSection);
bool HasActionSection,
bool HasScorecardSection,
bool HasDecisionSection,
bool HasSheetSummarySection);
public static class ArtifactQualityReviewService
{
@@ -192,6 +195,9 @@ public static class ArtifactQualityReviewService
if (input.ConditionalFormattingCount > 0) strengths.Add("Includes conditional formatting");
if (input.HasHighlightSection) strengths.Add("Includes highlight section");
if (input.HasActionSection) strengths.Add("Includes action section");
if (input.HasScorecardSection) strengths.Add("Includes KPI or scorecard section");
if (input.HasDecisionSection) strengths.Add("Includes decision summary");
if (input.HasSheetSummarySection) strengths.Add("Includes detail sheet summaries");
if (input.SheetCount > 1 && !input.HasSummarySheet)
issues.Add(new("Workbook has multiple sheets but no summary sheet.", ArtifactReviewSeverity.Warning));
@@ -203,6 +209,8 @@ public static class ArtifactQualityReviewService
issues.Add(new("Input controls are limited for a workbook with editable data.", ArtifactReviewSeverity.Info));
if (input.ConditionalFormattingCount == 0 && input.DataRowCount >= 8)
issues.Add(new("Conditional formatting is limited for a workbook with enough data to prioritize or flag.", ArtifactReviewSeverity.Info));
if (input.HasSummarySheet && !input.HasScorecardSection && !input.HasDecisionSection && !input.HasHighlightSection)
issues.Add(new("Summary sheet could better surface KPIs, decisions, or highlights.", ArtifactReviewSeverity.Info));
return BuildReport("xlsx", strengths, issues);
}

View File

@@ -11,6 +11,12 @@ namespace AxCopilot.Services.Agent;
/// </summary>
public class DocumentAssemblerTool : IAgentTool
{
private sealed record AssemblerDocxStyleMap(
string? TitleStyle,
string? Heading1Style,
string? Heading2Style,
string? BodyStyle);
private static string GetDefaultOutputFormat()
{
var app = System.Windows.Application.Current as App;
@@ -61,6 +67,7 @@ public class DocumentAssemblerTool : IAgentTool
["header"] = new() { Type = "string", Description = "Header text for DOCX output." },
["footer"] = new() { Type = "string", Description = "Footer text for DOCX output. Use {page} for page number." },
["page_numbers"] = new() { Type = "boolean", Description = "Show page numbers in DOCX footer. Default: true when header or footer is set." },
["style_map"] = new() { Type = "object", Description = "Optional paragraph style map for template-based DOCX assembly. Example: {\"title\":\"Title\",\"heading1\":\"Heading1\",\"heading2\":\"Heading2\",\"body\":\"Normal\"}." },
},
Required = ["path", "title", "sections"]
};
@@ -76,6 +83,7 @@ public class DocumentAssemblerTool : IAgentTool
var templatePath = args.SafeTryGetProperty("template_path", out var templateEl) ? templateEl.SafeGetString() : null;
var headerText = args.SafeTryGetProperty("header", out var hdr) ? hdr.SafeGetString() : null;
var footerText = args.SafeTryGetProperty("footer", out var ftr) ? ftr.SafeGetString() : null;
var styleMapArg = args.SafeTryGetProperty("style_map", out var styleMapEl) ? styleMapEl : default;
var showPageNumbers = args.SafeTryGetProperty("page_numbers", out var pageNumbersEl)
? pageNumbersEl.ValueKind == JsonValueKind.True
: !string.IsNullOrWhiteSpace(headerText) || !string.IsNullOrWhiteSpace(footerText);
@@ -140,7 +148,7 @@ public class DocumentAssemblerTool : IAgentTool
switch (format)
{
case "docx":
resultMsg = AssembleDocx(fullPath, title, sections, useToc, coverSubtitle, templateFullPath, headerText, footerText, showPageNumbers);
resultMsg = AssembleDocx(fullPath, title, sections, useToc, coverSubtitle, templateFullPath, headerText, footerText, showPageNumbers, styleMapArg);
break;
case "markdown":
resultMsg = AssembleMarkdown(fullPath, title, sections);
@@ -259,7 +267,7 @@ public class DocumentAssemblerTool : IAgentTool
}
private string AssembleDocx(string path, string title, List<(string Heading, string Content, int Level)> sections,
bool useToc, string? coverSubtitle, string? templatePath, string? headerText, string? footerText, bool showPageNumbers)
bool useToc, string? coverSubtitle, string? templatePath, string? headerText, string? footerText, bool showPageNumbers, JsonElement styleMapArg)
{
var templateApplied = !string.IsNullOrWhiteSpace(templatePath);
if (templateApplied)
@@ -272,6 +280,7 @@ public class DocumentAssemblerTool : IAgentTool
var mainPart = doc.MainDocumentPart ?? doc.AddMainDocumentPart();
EnsureAssemblerStyles(mainPart, templateApplied);
var styleMap = ResolveAssemblerStyleMap(mainPart, styleMapArg);
var body = InitializeAssemblerBody(mainPart, templateApplied);
@@ -308,10 +317,13 @@ public class DocumentAssemblerTool : IAgentTool
string fontSize = "22",
bool bold = false,
string? color = null,
string? fill = null)
string? fill = null,
string? styleId = null)
{
var para = new DocumentFormat.OpenXml.Wordprocessing.Paragraph();
var props = new DocumentFormat.OpenXml.Wordprocessing.ParagraphProperties();
if (!string.IsNullOrWhiteSpace(styleId))
props.ParagraphStyleId = new DocumentFormat.OpenXml.Wordprocessing.ParagraphStyleId { Val = styleId };
props.SpacingBetweenLines = new DocumentFormat.OpenXml.Wordprocessing.SpacingBetweenLines
{
After = "160"
@@ -336,18 +348,26 @@ public class DocumentAssemblerTool : IAgentTool
para.AppendChild(props);
var run = new DocumentFormat.OpenXml.Wordprocessing.Run();
var runProps = new DocumentFormat.OpenXml.Wordprocessing.RunProperties
var applyInlineFormatting = string.IsNullOrWhiteSpace(styleId)
|| !string.IsNullOrWhiteSpace(fill)
|| bold
|| !string.IsNullOrWhiteSpace(color)
|| fontSize != "22";
if (applyInlineFormatting)
{
RunFonts = KoreanFonts(),
FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = fontSize }
};
var runProps = new DocumentFormat.OpenXml.Wordprocessing.RunProperties
{
RunFonts = KoreanFonts(),
FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = fontSize }
};
if (bold)
runProps.Bold = new DocumentFormat.OpenXml.Wordprocessing.Bold();
if (!string.IsNullOrWhiteSpace(color))
runProps.Color = new DocumentFormat.OpenXml.Wordprocessing.Color { Val = color };
if (bold)
runProps.Bold = new DocumentFormat.OpenXml.Wordprocessing.Bold();
if (!string.IsNullOrWhiteSpace(color))
runProps.Color = new DocumentFormat.OpenXml.Wordprocessing.Color { Val = color };
run.AppendChild(runProps);
run.AppendChild(runProps);
}
run.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Text(text)
{
Space = DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve
@@ -356,6 +376,24 @@ public class DocumentAssemblerTool : IAgentTool
return para;
}
DocumentFormat.OpenXml.Wordprocessing.Paragraph CreateTitleParagraph(string text)
=> !string.IsNullOrWhiteSpace(styleMap.TitleStyle)
? CreateParagraph(text, styleId: styleMap.TitleStyle)
: CreateParagraph(text, fontSize: "48", bold: true, color: "1F3A5F");
DocumentFormat.OpenXml.Wordprocessing.Paragraph CreateSectionHeadingParagraph(string text, int level)
{
var headingStyleId = level <= 1 ? styleMap.Heading1Style : styleMap.Heading2Style;
return !string.IsNullOrWhiteSpace(headingStyleId)
? CreateParagraph(text, styleId: headingStyleId)
: CreateParagraph(text, fontSize: level <= 1 ? "32" : "28", bold: true, color: "2B579A");
}
DocumentFormat.OpenXml.Wordprocessing.Paragraph CreateBodyParagraph(string text)
=> !string.IsNullOrWhiteSpace(styleMap.BodyStyle)
? CreateParagraph(text, styleId: styleMap.BodyStyle)
: CreateParagraph(text);
DocumentFormat.OpenXml.Wordprocessing.Table CreateTableFromHtml(string tableHtml)
{
var table = new DocumentFormat.OpenXml.Wordprocessing.Table();
@@ -387,7 +425,7 @@ public class DocumentAssemblerTool : IAgentTool
}
else
{
cell.AppendChild(CreateParagraph(cellText));
cell.AppendChild(CreateBodyParagraph(cellText));
}
cell.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.TableCellProperties(
new DocumentFormat.OpenXml.Wordprocessing.TableCellWidth
@@ -415,23 +453,23 @@ public class DocumentAssemblerTool : IAgentTool
if (Regex.IsMatch(line, @"^#{2,6}\s+"))
{
body.AppendChild(CreateParagraph(Regex.Replace(line, @"^#{2,6}\s+", ""), fontSize: "26", bold: true, color: "2B579A"));
body.AppendChild(CreateSectionHeadingParagraph(Regex.Replace(line, @"^#{2,6}\s+", ""), 2));
continue;
}
if (Regex.IsMatch(line, @"^[-*]\s+"))
{
body.AppendChild(CreateParagraph($"• {Regex.Replace(line, @"^[-*]\s+", "")}"));
body.AppendChild(CreateBodyParagraph($"• {Regex.Replace(line, @"^[-*]\s+", "")}"));
continue;
}
if (Regex.IsMatch(line, @"^\d+\.\s+"))
{
body.AppendChild(CreateParagraph(line));
body.AppendChild(CreateBodyParagraph(line));
continue;
}
body.AppendChild(CreateParagraph(line));
body.AppendChild(CreateBodyParagraph(line));
}
}
@@ -443,16 +481,16 @@ public class DocumentAssemblerTool : IAgentTool
{
var text = ExtractStructuredText(match.Groups[1].Value);
var prefix = ordered ? $"{number}. " : "• ";
body.AppendChild(CreateParagraph(prefix + text));
body.AppendChild(CreateBodyParagraph(prefix + text));
number++;
}
}
var includeCover = !string.IsNullOrWhiteSpace(coverSubtitle);
if (includeCover)
AppendAssemblerCoverPage(body, title, coverSubtitle!);
AppendAssemblerCoverPage(body, title, coverSubtitle!, styleMap.TitleStyle);
else
body.AppendChild(CreateParagraph(title, fontSize: "48", bold: true, color: "1F3A5F"));
body.AppendChild(CreateTitleParagraph(title));
if (!includeCover)
body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Paragraph());
@@ -468,18 +506,7 @@ public class DocumentAssemblerTool : IAgentTool
foreach (var (heading, content, level) in sections)
{
var headPara = new DocumentFormat.OpenXml.Wordprocessing.Paragraph();
var headRun = new DocumentFormat.OpenXml.Wordprocessing.Run();
headRun.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.RunProperties
{
RunFonts = KoreanFonts(),
Bold = new DocumentFormat.OpenXml.Wordprocessing.Bold(),
FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = level <= 1 ? "32" : "28" },
Color = new DocumentFormat.OpenXml.Wordprocessing.Color { Val = "2B579A" },
});
headRun.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Text(heading));
headPara.AppendChild(headRun);
body.AppendChild(headPara);
body.AppendChild(CreateSectionHeadingParagraph(heading, level));
headings.Add(heading);
AppendStructuredContent(content);
@@ -565,7 +592,11 @@ public class DocumentAssemblerTool : IAgentTool
}
else if (Regex.IsMatch(block, @"^<h", RegexOptions.IgnoreCase))
{
body.AppendChild(CreateParagraph(ExtractStructuredText(block), fontSize: "26", bold: true, color: "2B579A"));
var headingMatch = Regex.Match(block, @"^<h([2-6])", RegexOptions.IgnoreCase);
var headingLevel = headingMatch.Success && int.TryParse(headingMatch.Groups[1].Value, out var parsedLevel)
? Math.Max(1, parsedLevel - 1)
: 2;
body.AppendChild(CreateSectionHeadingParagraph(ExtractStructuredText(block), headingLevel));
}
else if (Regex.IsMatch(block, @"^<(blockquote|div)", RegexOptions.IgnoreCase))
{
@@ -669,28 +700,67 @@ public class DocumentAssemblerTool : IAgentTool
}
}
private static void AppendAssemblerCoverPage(DocumentFormat.OpenXml.Wordprocessing.Body body, string title, string subtitle)
private static AssemblerDocxStyleMap ResolveAssemblerStyleMap(
DocumentFormat.OpenXml.Packaging.MainDocumentPart mainPart,
JsonElement styleMapArg)
{
body.Append(new DocumentFormat.OpenXml.Wordprocessing.Paragraph(
new DocumentFormat.OpenXml.Wordprocessing.ParagraphProperties
if (styleMapArg.ValueKind != JsonValueKind.Object)
return new AssemblerDocxStyleMap(null, null, null, null);
return new AssemblerDocxStyleMap(
FindAssemblerStyle(mainPart, styleMapArg.SafeTryGetProperty("title", out var titleEl) ? titleEl.SafeGetString() : null),
FindAssemblerStyle(mainPart, styleMapArg.SafeTryGetProperty("heading1", out var heading1El) ? heading1El.SafeGetString() : null),
FindAssemblerStyle(mainPart, styleMapArg.SafeTryGetProperty("heading2", out var heading2El) ? heading2El.SafeGetString() : null),
FindAssemblerStyle(mainPart, styleMapArg.SafeTryGetProperty("body", out var bodyEl) ? bodyEl.SafeGetString() : null));
}
private static string? FindAssemblerStyle(
DocumentFormat.OpenXml.Packaging.MainDocumentPart mainPart,
string? styleId)
{
if (string.IsNullOrWhiteSpace(styleId))
return null;
var styles = mainPart.StyleDefinitionsPart?.Styles;
if (styles == null)
return null;
return styles
.Elements<DocumentFormat.OpenXml.Wordprocessing.Style>()
.Any(style => string.Equals(style.StyleId?.Value, styleId, StringComparison.OrdinalIgnoreCase))
? styleId
: null;
}
private static void AppendAssemblerCoverPage(DocumentFormat.OpenXml.Wordprocessing.Body body, string title, string subtitle, string? titleStyleId)
{
var titleParagraphProperties = new DocumentFormat.OpenXml.Wordprocessing.ParagraphProperties
{
Justification = new DocumentFormat.OpenXml.Wordprocessing.Justification
{
Justification = new DocumentFormat.OpenXml.Wordprocessing.Justification
{
Val = DocumentFormat.OpenXml.Wordprocessing.JustificationValues.Center
},
SpacingBetweenLines = new DocumentFormat.OpenXml.Wordprocessing.SpacingBetweenLines
{
Before = "1600",
After = "120"
}
Val = DocumentFormat.OpenXml.Wordprocessing.JustificationValues.Center
},
new DocumentFormat.OpenXml.Wordprocessing.Run(
new DocumentFormat.OpenXml.Wordprocessing.RunProperties(
new DocumentFormat.OpenXml.Wordprocessing.Bold(),
new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "44" },
new DocumentFormat.OpenXml.Wordprocessing.Color { Val = "1F3A5F" },
new DocumentFormat.OpenXml.Wordprocessing.RunFonts { Ascii = "맑은 고딕", HighAnsi = "맑은 고딕", EastAsia = "맑은 고딕" }),
new DocumentFormat.OpenXml.Wordprocessing.Text(title))));
SpacingBetweenLines = new DocumentFormat.OpenXml.Wordprocessing.SpacingBetweenLines
{
Before = "1600",
After = "120"
}
};
if (!string.IsNullOrWhiteSpace(titleStyleId))
titleParagraphProperties.ParagraphStyleId = new DocumentFormat.OpenXml.Wordprocessing.ParagraphStyleId { Val = titleStyleId };
var titleRun = new DocumentFormat.OpenXml.Wordprocessing.Run();
if (string.IsNullOrWhiteSpace(titleStyleId))
{
titleRun.Append(new DocumentFormat.OpenXml.Wordprocessing.RunProperties(
new DocumentFormat.OpenXml.Wordprocessing.Bold(),
new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "44" },
new DocumentFormat.OpenXml.Wordprocessing.Color { Val = "1F3A5F" },
new DocumentFormat.OpenXml.Wordprocessing.RunFonts { Ascii = "맑은 고딕", HighAnsi = "맑은 고딕", EastAsia = "맑은 고딕" }));
}
titleRun.Append(new DocumentFormat.OpenXml.Wordprocessing.Text(title));
body.Append(new DocumentFormat.OpenXml.Wordprocessing.Paragraph(titleParagraphProperties, titleRun));
body.Append(new DocumentFormat.OpenXml.Wordprocessing.Paragraph(
new DocumentFormat.OpenXml.Wordprocessing.ParagraphProperties

View File

@@ -217,7 +217,10 @@ public class ExcelSkill : IAgentTool
conditionalFormattingCount,
false,
false,
summaryArg.ValueKind == JsonValueKind.Object));
summaryArg.ValueKind == JsonValueKind.Object,
false,
false,
false));
features += $"\n{review.ToToolSummary()}";
return ToolResult.Ok(
@@ -305,7 +308,10 @@ public class ExcelSkill : IAgentTool
conditionalFormattingCount,
true,
HasSummaryItems(summarySheet, "highlights"),
HasSummaryItems(summarySheet, "actions")));
HasSummaryItems(summarySheet, "actions"),
HasSummaryItems(summarySheet, "scorecards") || HasSummaryItems(summarySheet, "cards") || HasSummaryItems(summarySheet, "kpis"),
HasStructuredSummaryContent(summarySheet, "decision_summary"),
HasSummaryItems(summarySheet, "sheet_summaries")));
features += $"\n{review.ToToolSummary()}";
return ToolResult.Ok(
@@ -428,7 +434,10 @@ public class ExcelSkill : IAgentTool
totalConditionalFormattingCount,
summarySheet.ValueKind == JsonValueKind.Object,
HasSummaryItems(summarySheet, "highlights"),
HasSummaryItems(summarySheet, "actions")));
HasSummaryItems(summarySheet, "actions"),
HasSummaryItems(summarySheet, "scorecards") || HasSummaryItems(summarySheet, "cards") || HasSummaryItems(summarySheet, "kpis"),
HasStructuredSummaryContent(summarySheet, "decision_summary"),
HasSummaryItems(summarySheet, "sheet_summaries")));
return ToolResult.Ok(
$"Excel 파일 생성 완료: {fullPath}\n시트: {totalSheets}개, 총 데이터 행: {totalRows}\n{review.ToToolSummary()}",
fullPath);
@@ -471,6 +480,9 @@ public class ExcelSkill : IAgentTool
rowIndex++;
AppendDecisionSummarySection(summarySheet, sheetData, merges, ref rowIndex);
AppendScorecardSection(summarySheet, sheetData, ref rowIndex);
if (summarySheet.SafeTryGetProperty("kpis", out var kpis) && kpis.ValueKind == JsonValueKind.Array && kpis.GetArrayLength() > 0)
{
AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", "Key KPIs", 1);
@@ -497,6 +509,7 @@ public class ExcelSkill : IAgentTool
rowIndex++;
}
AppendSheetSummarySection(summarySheet, sheetData, ref rowIndex);
AppendSummaryTextSection(summarySheet, "highlights", "Key Highlights", sheetData, merges, ref rowIndex);
AppendSummaryTextSection(summarySheet, "actions", "Next Actions", sheetData, merges, ref rowIndex);
@@ -530,6 +543,115 @@ public class ExcelSkill : IAgentTool
}
}
private static void AppendDecisionSummarySection(JsonElement summarySheet, SheetData sheetData, MergeCells merges, ref uint rowIndex)
{
if (!summarySheet.SafeTryGetProperty("decision_summary", out var decisionSummary))
return;
var appended = false;
if (decisionSummary.ValueKind == JsonValueKind.Array && decisionSummary.GetArrayLength() > 0)
{
AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", "Decision Summary", 1);
foreach (var item in decisionSummary.EnumerateArray())
{
var label = item.SafeTryGetProperty("label", out var labelEl) ? labelEl.SafeGetString() ?? "" : "";
var value = item.SafeTryGetProperty("value", out var valueEl) ? valueEl.SafeGetString() ?? "" : "";
var owner = item.SafeTryGetProperty("owner", out var ownerEl) ? ownerEl.SafeGetString() ?? "" : "";
var row = new Row { RowIndex = rowIndex++ };
row.Append(CreateSummaryCell("A", row.RowIndex!.Value, label, 1));
row.Append(CreateSummaryCell("B", row.RowIndex!.Value, value, 3));
row.Append(CreateSummaryCell("C", row.RowIndex!.Value, owner, 0));
sheetData.Append(row);
appended = true;
}
}
else if (decisionSummary.ValueKind == JsonValueKind.Object)
{
AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", "Decision Summary", 1);
foreach (var property in decisionSummary.EnumerateObject())
{
var row = new Row { RowIndex = rowIndex++ };
row.Append(CreateSummaryCell("A", row.RowIndex!.Value, property.Name, 1));
row.Append(CreateSummaryCell("B", row.RowIndex!.Value, property.Value.SafeGetString() ?? property.Value.ToString(), 3));
sheetData.Append(row);
appended = true;
}
}
if (appended)
rowIndex++;
}
private static void AppendScorecardSection(JsonElement summarySheet, SheetData sheetData, ref uint rowIndex)
{
JsonElement scorecards;
var hasScorecards = summarySheet.SafeTryGetProperty("scorecards", out scorecards)
&& scorecards.ValueKind == JsonValueKind.Array
&& scorecards.GetArrayLength() > 0;
if (!hasScorecards)
{
hasScorecards = summarySheet.SafeTryGetProperty("cards", out scorecards)
&& scorecards.ValueKind == JsonValueKind.Array
&& scorecards.GetArrayLength() > 0;
}
if (!hasScorecards)
return;
var headerRow = new Row { RowIndex = rowIndex++ };
headerRow.Append(CreateSummaryCell("A", headerRow.RowIndex!.Value, "Scorecards", 1));
headerRow.Append(CreateSummaryCell("B", headerRow.RowIndex!.Value, "Value", 1));
headerRow.Append(CreateSummaryCell("C", headerRow.RowIndex!.Value, "Status", 1));
headerRow.Append(CreateSummaryCell("D", headerRow.RowIndex!.Value, "Note", 1));
sheetData.Append(headerRow);
var cardIndex = 0;
foreach (var card in scorecards.EnumerateArray())
{
var row = new Row { RowIndex = rowIndex++ };
var stripeStyle = cardIndex % 2 == 0 ? (uint)0 : (uint)2;
row.Append(CreateSummaryCell("A", row.RowIndex!.Value, card.SafeTryGetProperty("label", out var labelEl) ? labelEl.SafeGetString() ?? "" : "", 1));
row.Append(CreateSummaryCell("B", row.RowIndex!.Value, card.SafeTryGetProperty("value", out var valueEl) ? valueEl.SafeGetString() ?? "" : "", 3));
row.Append(CreateSummaryCell("C", row.RowIndex!.Value, card.SafeTryGetProperty("status", out var statusEl) ? statusEl.SafeGetString() ?? "" : "", stripeStyle));
row.Append(CreateSummaryCell("D", row.RowIndex!.Value, card.SafeTryGetProperty("note", out var noteEl) ? noteEl.SafeGetString() ?? "" : "", stripeStyle));
sheetData.Append(row);
cardIndex++;
}
rowIndex++;
}
private static void AppendSheetSummarySection(JsonElement summarySheet, SheetData sheetData, ref uint rowIndex)
{
if (!summarySheet.SafeTryGetProperty("sheet_summaries", out var sheetSummaries)
|| sheetSummaries.ValueKind != JsonValueKind.Array
|| sheetSummaries.GetArrayLength() == 0)
return;
var headerRow = new Row { RowIndex = rowIndex++ };
headerRow.Append(CreateSummaryCell("A", headerRow.RowIndex!.Value, "Sheet", 1));
headerRow.Append(CreateSummaryCell("B", headerRow.RowIndex!.Value, "Status", 1));
headerRow.Append(CreateSummaryCell("C", headerRow.RowIndex!.Value, "Summary", 1));
headerRow.Append(CreateSummaryCell("D", headerRow.RowIndex!.Value, "Owner", 1));
sheetData.Append(headerRow);
var summaryIndex = 0;
foreach (var sheetSummary in sheetSummaries.EnumerateArray())
{
var row = new Row { RowIndex = rowIndex++ };
var stripeStyle = summaryIndex % 2 == 0 ? (uint)0 : (uint)2;
row.Append(CreateSummaryCell("A", row.RowIndex!.Value, sheetSummary.SafeTryGetProperty("sheet", out var sheetEl) ? sheetEl.SafeGetString() ?? "" : "", 1));
row.Append(CreateSummaryCell("B", row.RowIndex!.Value, sheetSummary.SafeTryGetProperty("status", out var statusEl) ? statusEl.SafeGetString() ?? "" : "", 3));
row.Append(CreateSummaryCell("C", row.RowIndex!.Value, sheetSummary.SafeTryGetProperty("summary", out var summaryEl) ? summaryEl.SafeGetString() ?? "" : "", stripeStyle));
row.Append(CreateSummaryCell("D", row.RowIndex!.Value, sheetSummary.SafeTryGetProperty("owner", out var ownerEl) ? ownerEl.SafeGetString() ?? "" : "", stripeStyle));
sheetData.Append(row);
summaryIndex++;
}
rowIndex++;
}
private static void AppendSummaryTextSection(JsonElement summarySheet, string key, string heading, SheetData sheetData, MergeCells merges, ref uint rowIndex)
{
if (!summarySheet.SafeTryGetProperty(key, out var items) || items.ValueKind != JsonValueKind.Array || items.GetArrayLength() == 0)
@@ -1235,6 +1357,20 @@ public class ExcelSkill : IAgentTool
&& items.GetArrayLength() > 0;
}
private static bool HasStructuredSummaryContent(JsonElement summarySheet, string propertyName)
{
if (summarySheet.ValueKind != JsonValueKind.Object
|| !summarySheet.SafeTryGetProperty(propertyName, out var value))
return false;
return value.ValueKind switch
{
JsonValueKind.Array => value.GetArrayLength() > 0,
JsonValueKind.Object => value.EnumerateObject().Any(),
_ => false,
};
}
private static string BuildFeatureList(bool isStyled, bool freezeHeader,
bool hasMerges, bool hasSummary, bool hasNumFmts, bool hasAlignments, string? theme)
{