문서 생성 고도화 1차: 네이티브 워드·엑셀·HTML 경로 정렬 및 품질 보강

- Word/Excel/HTML 스킬을 Python 우회 중심에서 AX 네이티브 문서 도구 우선 경로로 재작성했습니다.

- DocumentPlannerTool의 보고서·제안서·분석 문서 아웃라인을 Executive Summary, Business Case, Decision Ask, Appendix 중심의 업무형 구조로 확장했습니다.

- DocumentAssemblerTool의 DOCX 조립 경로에서 표·목록·콜아웃·소제목 같은 HTML/Markdown 구조를 더 보존하도록 개선했습니다.

- ExcelSkill에 summary_sheet를 추가해 KPI·핵심 인사이트·후속 과제를 담은 요약 시트를 상세 데이터 시트 앞에 생성할 수 있게 했습니다.

- HtmlSkill에 comparison, roadmap, matrix 구조화 섹션을 추가하고 sections 중심 호출 스키마를 정리했습니다.

- DocumentAssemblerSemanticTests, ExcelSkillSummarySheetTests, HtmlSkillConsultingSectionsTests, DocumentPlannerBusinessDocumentTests를 추가했습니다.

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_doc_phase1\\ -p:IntermediateOutputPath=obj\\verify_doc_phase1\

- 검증: dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter 문서_고도화_테스트_5건 -p:OutputPath=bin\\verify_doc_phase1_tests\\ -p:IntermediateOutputPath=obj\\verify_doc_phase1_tests\
This commit is contained in:
2026-04-14 21:02:08 +09:00
parent 0b6d60e959
commit d9cb02f3c4
14 changed files with 1002 additions and 466 deletions

View File

@@ -17,7 +17,7 @@ public class ExcelSkill : IAgentTool
"Supports: header styling (bold white text on colored background), " +
"striped rows, column auto-width, formulas (=SUM, =AVERAGE, etc), " +
"cell merge, freeze panes (freeze header row), number formatting, " +
"themes (professional/modern/dark/minimal), column alignments, and multi-sheet workbooks. " +
"themes (professional/modern/dark/minimal), column alignments, executive summary sheets, and multi-sheet workbooks. " +
"Inline icons: use {icon:name} in cell text (e.g. '{icon:checkmark} 완료', '{icon:warning} 주의'). " +
"170+ built-in icons: checkmark, warning, star, rocket, chart_up, chart_down, etc.";
@@ -35,6 +35,12 @@ public class ExcelSkill : IAgentTool
["freeze_header"] = new() { Type = "boolean", Description = "Freeze the header row. Default: true for styled." },
["merges"] = new() { Type = "array", Description = "Cell merge ranges. e.g. [\"A1:C1\", \"D5:D8\"]", Items = new() { Type = "string" } },
["summary_row"] = new() { Type = "object", Description = "Auto-generate summary row. {\"label\": \"합계\", \"columns\": {\"B\": \"SUM\", \"C\": \"AVERAGE\"}}. Adds formulas at bottom." },
["summary_sheet"] = new()
{
Type = "object",
Description = "Optional executive summary sheet inserted before detail sheets. " +
"Example: {\"name\":\"Summary\",\"title\":\"2026 운영 리뷰\",\"subtitle\":\"핵심 KPI와 후속 과제\",\"kpis\":[{\"label\":\"Revenue\",\"value\":\"12%\",\"trend\":\"YoY\",\"note\":\"Strong\"}],\"highlights\":[\"핵심 인사이트 1\",\"핵심 인사이트 2\"],\"actions\":[\"즉시 과제 1\",\"즉시 과제 2\"]}"
},
["number_formats"] = new() { Type = "array", Description = "Number format per column index. Supported: 'currency' (#,##0\"원\"), 'percent' (0.00%), 'decimal' (#,##0.00), 'integer' (#,##0), 'date' (yyyy-mm-dd), or any custom Excel format string. e.g. [\"text\",\"integer\",\"currency\",\"percent\"]", Items = new() { Type = "string" } },
["col_alignments"] = new() { Type = "array", Description = "Horizontal alignment per column: 'left', 'center', 'right'. Headers always center-aligned.", Items = new() { Type = "string" } },
["sheets"] = new() { Type = "array", Description = "Multi-sheet mode: array of sheet objects [{name, headers, rows, style?, theme?, col_widths?, freeze_header?, number_formats?, col_alignments?, merges?, summary_row?}]. When present, overrides top-level headers/rows/sheet_name.", Items = new() { Type = "object" } },
@@ -117,13 +123,18 @@ public class ExcelSkill : IAgentTool
var dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
var hasSummarySheet = args.SafeTryGetProperty("summary_sheet", out var summarySheet)
&& summarySheet.ValueKind == JsonValueKind.Object;
// Determine if we are in multi-sheet mode
var multiSheetMode = args.SafeTryGetProperty("sheets", out var sheetsArr)
&& sheetsArr.ValueKind == JsonValueKind.Array
&& sheetsArr.GetArrayLength() > 0;
if (multiSheetMode)
return GenerateMultiSheetWorkbook(args, sheetsArr, fullPath);
return GenerateMultiSheetWorkbook(args, sheetsArr, fullPath, hasSummarySheet ? summarySheet : default);
else if (hasSummarySheet)
return GenerateSingleSheetWorkbookWithSummary(args, summarySheet, fullPath);
else
return GenerateSingleSheetWorkbook(args, fullPath);
}
@@ -186,7 +197,6 @@ public class ExcelSkill : IAgentTool
});
workbookPart.Workbook.Save();
var features = BuildFeatureList(isStyled, freezeHeader,
mergesArg.ValueKind == JsonValueKind.Array,
summaryArg.ValueKind == JsonValueKind.Object,
@@ -201,7 +211,76 @@ public class ExcelSkill : IAgentTool
// Multi-sheet workbook
// ═══════════════════════════════════════════════════
private static ToolResult GenerateMultiSheetWorkbook(JsonElement args, JsonElement sheetsArr, string fullPath)
private static ToolResult GenerateSingleSheetWorkbookWithSummary(JsonElement args, JsonElement summarySheet, string fullPath)
{
if (!args.SafeTryGetProperty("headers", out var headers) || headers.ValueKind != JsonValueKind.Array)
return ToolResult.Fail("?꾩닔 ?뚮씪誘명꽣 ?꾨씫: 'headers'");
if (!args.SafeTryGetProperty("rows", out var rows) || rows.ValueKind != JsonValueKind.Array)
return ToolResult.Fail("?꾩닔 ?뚮씪誘명꽣 ?꾨씫: 'rows'");
var sheetName = args.SafeTryGetProperty("sheet_name", out var sn) ? sn.SafeGetString() ?? "Sheet1" : "Sheet1";
var tableStyle = args.SafeTryGetProperty("style", out var st) ? st.SafeGetString() ?? "styled" : "styled";
var themeName = args.SafeTryGetProperty("theme", out var th) ? th.SafeGetString() : null;
var isStyled = tableStyle != "plain";
var freezeHeader = args.SafeTryGetProperty("freeze_header", out var fh) ? fh.GetBoolean() : isStyled;
var theme = GetTheme(themeName);
var numFmts = ParseNumberFormats(args, "number_formats");
var alignments = ParseAlignments(args, "col_alignments");
var customFmts = CollectCustomFormats(numFmts);
using var spreadsheet = SpreadsheetDocument.Create(fullPath, SpreadsheetDocumentType.Workbook);
var workbookPart = spreadsheet.AddWorkbookPart();
workbookPart.Workbook = new Workbook();
var stylesPart = workbookPart.AddNewPart<WorkbookStylesPart>();
stylesPart.Stylesheet = CreateStylesheet(true, theme, customFmts, numFmts, alignments);
stylesPart.Stylesheet.Save();
var wbSheets = workbookPart.Workbook.AppendChild(new Sheets());
var summaryName = summarySheet.SafeTryGetProperty("name", out var summaryNameEl)
? summaryNameEl.SafeGetString() ?? "Summary"
: "Summary";
var summaryPart = workbookPart.AddNewPart<WorksheetPart>();
WriteExecutiveSummarySheet(summaryPart, summarySheet, [sheetName]);
wbSheets.Append(new Sheet
{
Id = workbookPart.GetIdOfPart(summaryPart),
SheetId = 1,
Name = summaryName,
});
var worksheetPart = workbookPart.AddNewPart<WorksheetPart>();
worksheetPart.Worksheet = new Worksheet();
var colCount = headers.GetArrayLength();
var summaryArg = args.SafeTryGetProperty("summary_row", out var sumEl) ? sumEl : default;
var mergesArg = args.SafeTryGetProperty("merges", out var mergeEl) ? mergeEl : default;
var rowCount = WriteSheetContent(worksheetPart, args, sheetName, headers, rows,
isStyled, freezeHeader, theme, numFmts, alignments, customFmts,
summaryArg, mergesArg, colCount);
wbSheets.Append(new Sheet
{
Id = workbookPart.GetIdOfPart(worksheetPart),
SheetId = 2,
Name = sheetName,
});
workbookPart.Workbook.Save();
var features = BuildFeatureList(isStyled, freezeHeader,
mergesArg.ValueKind == JsonValueKind.Array,
summaryArg.ValueKind == JsonValueKind.Object,
numFmts.Count > 0, alignments.Count > 0, themeName);
return ToolResult.Ok(
$"Excel ?뚯씪 ?앹꽦 ?꾨즺: {fullPath}\n?쒗듃: {summaryName}, {sheetName}, ?? {colCount}, ?? {rowCount}{features} [?붿빟 ?쒗듃]",
fullPath);
}
private static ToolResult GenerateMultiSheetWorkbook(JsonElement args, JsonElement sheetsArr, string fullPath, JsonElement summarySheet)
{
using var spreadsheet = SpreadsheetDocument.Create(fullPath, SpreadsheetDocumentType.Workbook);
var workbookPart = spreadsheet.AddWorkbookPart();
@@ -234,6 +313,30 @@ public class ExcelSkill : IAgentTool
uint sheetId = 1;
var totalSheets = 0;
var totalRows = 0;
var detailSheetNames = new List<string>();
foreach (var sheetDef in sheetsArr.EnumerateArray())
{
var detailSheetName = sheetDef.SafeTryGetProperty("name", out var nameEl)
? nameEl.SafeGetString() ?? $"Sheet{detailSheetNames.Count + 1}"
: $"Sheet{detailSheetNames.Count + 1}";
detailSheetNames.Add(detailSheetName);
}
if (summarySheet.ValueKind == JsonValueKind.Object)
{
var summaryName = summarySheet.SafeTryGetProperty("name", out var summaryNameEl)
? summaryNameEl.SafeGetString() ?? "Summary"
: "Summary";
var summaryPart = workbookPart.AddNewPart<WorksheetPart>();
WriteExecutiveSummarySheet(summaryPart, summarySheet, detailSheetNames);
wbSheets.Append(new Sheet
{
Id = workbookPart.GetIdOfPart(summaryPart),
SheetId = sheetId++,
Name = summaryName,
});
}
foreach (var sheetDef in sheetsArr.EnumerateArray())
{
@@ -283,6 +386,108 @@ public class ExcelSkill : IAgentTool
// Core sheet writer (shared by single + multi)
// ═══════════════════════════════════════════════════
private static void WriteExecutiveSummarySheet(WorksheetPart worksheetPart, JsonElement summarySheet, IReadOnlyList<string> detailSheetNames)
{
worksheetPart.Worksheet = new Worksheet();
var columns = new Columns(
new Column { Min = 1, Max = 1, Width = 22, CustomWidth = true },
new Column { Min = 2, Max = 2, Width = 18, CustomWidth = true },
new Column { Min = 3, Max = 3, Width = 18, CustomWidth = true },
new Column { Min = 4, Max = 4, Width = 28, CustomWidth = true },
new Column { Min = 5, Max = 5, Width = 18, CustomWidth = true },
new Column { Min = 6, Max = 6, Width = 30, CustomWidth = true });
worksheetPart.Worksheet.Append(columns);
var sheetData = new SheetData();
worksheetPart.Worksheet.Append(sheetData);
var merges = new MergeCells();
uint rowIndex = 1;
var title = summarySheet.SafeTryGetProperty("title", out var titleEl)
? titleEl.SafeGetString() ?? "Executive Summary"
: "Executive Summary";
var subtitle = summarySheet.SafeTryGetProperty("subtitle", out var subtitleEl)
? subtitleEl.SafeGetString() ?? ""
: "";
AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", title, 1);
if (!string.IsNullOrWhiteSpace(subtitle))
AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", subtitle, 3);
rowIndex++;
if (summarySheet.SafeTryGetProperty("kpis", out var kpis) && kpis.ValueKind == JsonValueKind.Array && kpis.GetArrayLength() > 0)
{
AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", "Key KPIs", 1);
var headerRow = new Row { RowIndex = rowIndex++ };
headerRow.Append(CreateSummaryCell("A", headerRow.RowIndex!.Value, "Metric", 1));
headerRow.Append(CreateSummaryCell("B", headerRow.RowIndex!.Value, "Value", 1));
headerRow.Append(CreateSummaryCell("C", headerRow.RowIndex!.Value, "Trend", 1));
headerRow.Append(CreateSummaryCell("D", headerRow.RowIndex!.Value, "Note", 1));
sheetData.Append(headerRow);
var kpiIndex = 0;
foreach (var kpi in kpis.EnumerateArray())
{
var styleIndex = kpiIndex % 2 == 0 ? (uint)0 : (uint)2;
var row = new Row { RowIndex = rowIndex++ };
row.Append(CreateSummaryCell("A", row.RowIndex!.Value, kpi.SafeTryGetProperty("label", out var labelEl) ? labelEl.SafeGetString() ?? "" : "", styleIndex));
row.Append(CreateSummaryCell("B", row.RowIndex!.Value, kpi.SafeTryGetProperty("value", out var valueEl) ? valueEl.SafeGetString() ?? "" : "", styleIndex));
row.Append(CreateSummaryCell("C", row.RowIndex!.Value, kpi.SafeTryGetProperty("trend", out var trendEl) ? trendEl.SafeGetString() ?? "" : "", styleIndex));
row.Append(CreateSummaryCell("D", row.RowIndex!.Value, kpi.SafeTryGetProperty("note", out var noteEl) ? noteEl.SafeGetString() ?? "" : "", styleIndex));
sheetData.Append(row);
kpiIndex++;
}
rowIndex++;
}
AppendSummaryTextSection(summarySheet, "highlights", "Key Highlights", sheetData, merges, ref rowIndex);
AppendSummaryTextSection(summarySheet, "actions", "Next Actions", sheetData, merges, ref rowIndex);
if (detailSheetNames.Count > 0)
{
AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", "Detail Sheets", 1);
foreach (var detailSheetName in detailSheetNames)
AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", $"• {detailSheetName}", 0);
}
if (merges.HasChildren)
worksheetPart.Worksheet.InsertAfter(merges, sheetData);
}
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)
return;
AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", heading, 1);
foreach (var item in items.EnumerateArray())
AppendMergedTextRow(sheetData, merges, rowIndex++, "A", "F", $"• {item.SafeGetString() ?? ""}", 0);
rowIndex++;
}
private static void AppendMergedTextRow(SheetData sheetData, MergeCells merges, uint rowIndex, string startColumn, string endColumn, string text, uint styleIndex)
{
var row = new Row { RowIndex = rowIndex };
row.Append(CreateSummaryCell(startColumn, rowIndex, text, styleIndex));
sheetData.Append(row);
merges.Append(new MergeCell { Reference = $"{startColumn}{rowIndex}:{endColumn}{rowIndex}" });
}
private static Cell CreateSummaryCell(string column, uint rowIndex, string text, uint styleIndex)
{
return new Cell
{
CellReference = $"{column}{rowIndex}",
DataType = CellValues.String,
CellValue = new CellValue(text),
StyleIndex = styleIndex,
};
}
private static int WriteSheetContent(
WorksheetPart worksheetPart,
JsonElement args,