";
var matches = Regex.Matches(normalized, blockPattern, RegexOptions.IgnoreCase | RegexOptions.Singleline);
if (matches.Count == 0)
@@ -459,14 +529,17 @@ public class DocumentAssemblerTool : IAgentTool
if (Regex.IsMatch(block, @"^
DOCX 기본 스타일 정의 생성 (한글 글꼴 기본 설정 포함).
@@ -573,6 +608,235 @@ public class DocumentAssemblerTool : IAgentTool
return styles;
}
+ private static void AppendAssemblerCoverPage(DocumentFormat.OpenXml.Wordprocessing.Body body, string title, string subtitle)
+ {
+ body.Append(new DocumentFormat.OpenXml.Wordprocessing.Paragraph(
+ new DocumentFormat.OpenXml.Wordprocessing.ParagraphProperties
+ {
+ Justification = new DocumentFormat.OpenXml.Wordprocessing.Justification
+ {
+ Val = DocumentFormat.OpenXml.Wordprocessing.JustificationValues.Center
+ },
+ SpacingBetweenLines = new DocumentFormat.OpenXml.Wordprocessing.SpacingBetweenLines
+ {
+ Before = "1600",
+ After = "120"
+ }
+ },
+ 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))));
+
+ body.Append(new DocumentFormat.OpenXml.Wordprocessing.Paragraph(
+ new DocumentFormat.OpenXml.Wordprocessing.ParagraphProperties
+ {
+ Justification = new DocumentFormat.OpenXml.Wordprocessing.Justification
+ {
+ Val = DocumentFormat.OpenXml.Wordprocessing.JustificationValues.Center
+ },
+ SpacingBetweenLines = new DocumentFormat.OpenXml.Wordprocessing.SpacingBetweenLines
+ {
+ After = "260"
+ }
+ },
+ new DocumentFormat.OpenXml.Wordprocessing.Run(
+ new DocumentFormat.OpenXml.Wordprocessing.RunProperties(
+ new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "26" },
+ new DocumentFormat.OpenXml.Wordprocessing.Color { Val = "5B6472" },
+ new DocumentFormat.OpenXml.Wordprocessing.RunFonts { Ascii = "맑은 고딕", HighAnsi = "맑은 고딕", EastAsia = "맑은 고딕" }),
+ new DocumentFormat.OpenXml.Wordprocessing.Text(subtitle))));
+
+ body.Append(new DocumentFormat.OpenXml.Wordprocessing.Paragraph(
+ new DocumentFormat.OpenXml.Wordprocessing.ParagraphProperties
+ {
+ Justification = new DocumentFormat.OpenXml.Wordprocessing.Justification
+ {
+ Val = DocumentFormat.OpenXml.Wordprocessing.JustificationValues.Center
+ }
+ },
+ new DocumentFormat.OpenXml.Wordprocessing.Run(
+ new DocumentFormat.OpenXml.Wordprocessing.RunProperties(
+ new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "18" },
+ new DocumentFormat.OpenXml.Wordprocessing.Color { Val = "808080" },
+ new DocumentFormat.OpenXml.Wordprocessing.RunFonts { Ascii = "맑은 고딕", HighAnsi = "맑은 고딕", EastAsia = "맑은 고딕" }),
+ new DocumentFormat.OpenXml.Wordprocessing.Text(DateTime.Now.ToString("yyyy-MM-dd")))));
+
+ body.Append(new DocumentFormat.OpenXml.Wordprocessing.Paragraph(
+ new DocumentFormat.OpenXml.Wordprocessing.Run(new DocumentFormat.OpenXml.Wordprocessing.Break
+ {
+ Type = DocumentFormat.OpenXml.Wordprocessing.BreakValues.Page
+ })));
+ }
+
+ private static void AppendAssemblerTableOfContents(DocumentFormat.OpenXml.Wordprocessing.Body body)
+ {
+ body.Append(new DocumentFormat.OpenXml.Wordprocessing.Paragraph(
+ new DocumentFormat.OpenXml.Wordprocessing.ParagraphProperties(
+ new DocumentFormat.OpenXml.Wordprocessing.SpacingBetweenLines { Before = "120", After = "120" }),
+ new DocumentFormat.OpenXml.Wordprocessing.Run(
+ new DocumentFormat.OpenXml.Wordprocessing.RunProperties(
+ new DocumentFormat.OpenXml.Wordprocessing.Bold(),
+ new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "28" },
+ new DocumentFormat.OpenXml.Wordprocessing.RunFonts { Ascii = "맑은 고딕", HighAnsi = "맑은 고딕", EastAsia = "맑은 고딕" }),
+ new DocumentFormat.OpenXml.Wordprocessing.Text("목차"))));
+
+ var paragraph = new DocumentFormat.OpenXml.Wordprocessing.Paragraph();
+ paragraph.Append(new DocumentFormat.OpenXml.Wordprocessing.Run(new DocumentFormat.OpenXml.Wordprocessing.FieldChar
+ {
+ FieldCharType = DocumentFormat.OpenXml.Wordprocessing.FieldCharValues.Begin
+ }));
+ paragraph.Append(new DocumentFormat.OpenXml.Wordprocessing.Run(new DocumentFormat.OpenXml.Wordprocessing.FieldCode(" TOC \\o \"1-3\" \\h \\z \\u ")
+ {
+ Space = DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve
+ }));
+ paragraph.Append(new DocumentFormat.OpenXml.Wordprocessing.Run(new DocumentFormat.OpenXml.Wordprocessing.FieldChar
+ {
+ FieldCharType = DocumentFormat.OpenXml.Wordprocessing.FieldCharValues.Separate
+ }));
+ paragraph.Append(new DocumentFormat.OpenXml.Wordprocessing.Run(new DocumentFormat.OpenXml.Wordprocessing.Text("Word에서 필드 업데이트를 실행하면 목차가 새로 고쳐집니다.")));
+ paragraph.Append(new DocumentFormat.OpenXml.Wordprocessing.Run(new DocumentFormat.OpenXml.Wordprocessing.FieldChar
+ {
+ FieldCharType = DocumentFormat.OpenXml.Wordprocessing.FieldCharValues.End
+ }));
+ body.Append(paragraph);
+ body.Append(new DocumentFormat.OpenXml.Wordprocessing.Paragraph(
+ new DocumentFormat.OpenXml.Wordprocessing.Run(new DocumentFormat.OpenXml.Wordprocessing.Break
+ {
+ Type = DocumentFormat.OpenXml.Wordprocessing.BreakValues.Page
+ })));
+ }
+
+ private static void AddAssemblerHeaderFooter(
+ DocumentFormat.OpenXml.Packaging.MainDocumentPart mainPart,
+ DocumentFormat.OpenXml.Wordprocessing.Body body,
+ string? headerText,
+ string? footerText,
+ bool showPageNumbers)
+ {
+ if (!string.IsNullOrWhiteSpace(headerText))
+ {
+ var headerPart = mainPart.AddNewPart();
+ var header = new DocumentFormat.OpenXml.Wordprocessing.Header();
+ var paragraph = new DocumentFormat.OpenXml.Wordprocessing.Paragraph(
+ new DocumentFormat.OpenXml.Wordprocessing.Run(
+ new DocumentFormat.OpenXml.Wordprocessing.RunProperties(
+ new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "18" },
+ new DocumentFormat.OpenXml.Wordprocessing.Color { Val = "808080" },
+ new DocumentFormat.OpenXml.Wordprocessing.RunFonts { Ascii = "맑은 고딕", HighAnsi = "맑은 고딕", EastAsia = "맑은 고딕" }),
+ new DocumentFormat.OpenXml.Wordprocessing.Text(headerText)));
+ paragraph.ParagraphProperties = new DocumentFormat.OpenXml.Wordprocessing.ParagraphProperties
+ {
+ Justification = new DocumentFormat.OpenXml.Wordprocessing.Justification
+ {
+ Val = DocumentFormat.OpenXml.Wordprocessing.JustificationValues.Right
+ }
+ };
+ header.Append(paragraph);
+ headerPart.Header = header;
+
+ var sectionProperties = body.Elements().LastOrDefault()
+ ?? body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.SectionProperties());
+ sectionProperties.Elements().ToList().ForEach(reference => reference.Remove());
+ sectionProperties.Append(new DocumentFormat.OpenXml.Wordprocessing.HeaderReference
+ {
+ Type = DocumentFormat.OpenXml.Wordprocessing.HeaderFooterValues.Default,
+ Id = mainPart.GetIdOfPart(headerPart)
+ });
+ }
+
+ if (!string.IsNullOrWhiteSpace(footerText) || showPageNumbers)
+ {
+ var footerPart = mainPart.AddNewPart();
+ var footer = new DocumentFormat.OpenXml.Wordprocessing.Footer();
+ var paragraph = new DocumentFormat.OpenXml.Wordprocessing.Paragraph
+ {
+ ParagraphProperties = new DocumentFormat.OpenXml.Wordprocessing.ParagraphProperties
+ {
+ Justification = new DocumentFormat.OpenXml.Wordprocessing.Justification
+ {
+ Val = DocumentFormat.OpenXml.Wordprocessing.JustificationValues.Center
+ }
+ }
+ };
+
+ var displayText = string.IsNullOrWhiteSpace(footerText) ? "AX Copilot" : footerText!;
+ if (showPageNumbers)
+ {
+ if (displayText.Contains("{page}", StringComparison.Ordinal))
+ {
+ var parts = displayText.Split("{page}", StringSplitOptions.None);
+ paragraph.Append(CreateAssemblerFooterRun(parts[0]));
+ paragraph.Append(CreateAssemblerPageNumberRun());
+ if (parts.Length > 1)
+ paragraph.Append(CreateAssemblerFooterRun(parts[1]));
+ }
+ else
+ {
+ paragraph.Append(CreateAssemblerFooterRun(displayText + " · "));
+ paragraph.Append(CreateAssemblerPageNumberRun());
+ }
+ }
+ else
+ {
+ paragraph.Append(CreateAssemblerFooterRun(displayText));
+ }
+
+ footer.Append(paragraph);
+ footerPart.Footer = footer;
+
+ var sectionProperties = body.Elements().LastOrDefault()
+ ?? body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.SectionProperties());
+ sectionProperties.Elements().ToList().ForEach(reference => reference.Remove());
+ sectionProperties.Append(new DocumentFormat.OpenXml.Wordprocessing.FooterReference
+ {
+ Type = DocumentFormat.OpenXml.Wordprocessing.HeaderFooterValues.Default,
+ Id = mainPart.GetIdOfPart(footerPart)
+ });
+ }
+ }
+
+ private static DocumentFormat.OpenXml.Wordprocessing.Run CreateAssemblerFooterRun(string text) =>
+ new(new DocumentFormat.OpenXml.Wordprocessing.Text(text) { Space = DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve })
+ {
+ RunProperties = new DocumentFormat.OpenXml.Wordprocessing.RunProperties
+ {
+ FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "16" },
+ Color = new DocumentFormat.OpenXml.Wordprocessing.Color { Val = "999999" },
+ RunFonts = new DocumentFormat.OpenXml.Wordprocessing.RunFonts { Ascii = "맑은 고딕", HighAnsi = "맑은 고딕", EastAsia = "맑은 고딕" }
+ }
+ };
+
+ private static DocumentFormat.OpenXml.Wordprocessing.Run CreateAssemblerPageNumberRun()
+ {
+ var run = new DocumentFormat.OpenXml.Wordprocessing.Run
+ {
+ RunProperties = new DocumentFormat.OpenXml.Wordprocessing.RunProperties
+ {
+ FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "16" },
+ Color = new DocumentFormat.OpenXml.Wordprocessing.Color { Val = "999999" },
+ RunFonts = new DocumentFormat.OpenXml.Wordprocessing.RunFonts { Ascii = "맑은 고딕", HighAnsi = "맑은 고딕", EastAsia = "맑은 고딕" }
+ }
+ };
+
+ run.Append(new DocumentFormat.OpenXml.Wordprocessing.FieldChar
+ {
+ FieldCharType = DocumentFormat.OpenXml.Wordprocessing.FieldCharValues.Begin
+ });
+ run.Append(new DocumentFormat.OpenXml.Wordprocessing.FieldCode(" PAGE ")
+ {
+ Space = DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve
+ });
+ run.Append(new DocumentFormat.OpenXml.Wordprocessing.FieldChar
+ {
+ FieldCharType = DocumentFormat.OpenXml.Wordprocessing.FieldCharValues.End
+ });
+ return run;
+ }
+
private string AssembleMarkdown(string path, string title, List<(string Heading, string Content, int Level)> sections)
{
var sb = new StringBuilder();
diff --git a/src/AxCopilot/Services/Agent/DocumentPlannerTool.cs b/src/AxCopilot/Services/Agent/DocumentPlannerTool.cs
index d7302f3..93e6aa7 100644
--- a/src/AxCopilot/Services/Agent/DocumentPlannerTool.cs
+++ b/src/AxCopilot/Services/Agent/DocumentPlannerTool.cs
@@ -65,8 +65,8 @@ public class DocumentPlannerTool : IAgentTool
["format"] = new()
{
Type = "string",
- Description = "Output document format: auto, html, docx, markdown. Default: auto (resolved from settings and request intent)",
- Enum = ["auto", "html", "docx", "markdown"]
+ Description = "Output document format: auto, html, docx, markdown, xlsx. Default: auto (resolved from settings and request intent)",
+ Enum = ["auto", "html", "docx", "markdown", "xlsx"]
},
["sections_hint"] = new()
{
@@ -124,7 +124,7 @@ public class DocumentPlannerTool : IAgentTool
int targetPages, int totalWords, List sections, bool highQuality)
{
var safeTitle = SanitizeFileName(topic);
- var ext = format switch { "docx" => ".docx", "markdown" => ".md", _ => ".html" };
+ var ext = format switch { "docx" => ".docx", "markdown" => ".md", "xlsx" => ".xlsx", _ => ".html" };
var suggestedFileName = $"{safeTitle}{ext}";
var label = highQuality ? "[고품질]" : "[표준]";
@@ -134,6 +134,8 @@ public class DocumentPlannerTool : IAgentTool
return ExecuteWithMarkdownScaffold(topic, suggestedFileName, sections, targetPages, totalWords, label);
case "docx":
return ExecuteWithDocxScaffold(topic, suggestedFileName, sections, targetPages, totalWords, label);
+ case "xlsx":
+ return ExecuteWithExcelScaffold(topic, suggestedFileName, docType, sections, targetPages, totalWords, label);
default: // html
return ExecuteWithHtmlScaffold(topic, suggestedFileName, sections, targetPages, totalWords, label, mood);
}
@@ -294,15 +296,54 @@ public class DocumentPlannerTool : IAgentTool
return Task.FromResult(ToolResult.Ok(output.ToString()));
}
+ /// excel_create 즉시 호출 가능한 workbook 골격 반환.
+ private Task ExecuteWithExcelScaffold(string topic, string fileName, string docType,
+ List sections, int targetPages, int totalWords, string label)
+ {
+ var summarySection = sections.FirstOrDefault();
+ var recommendationSection = sections.FirstOrDefault(s =>
+ ContainsAny(s.Heading, "Recommendation", "Decision Ask", "Roadmap", "Action", "Ask", "권고", "실행", "요청"));
+ var summarySheet = new
+ {
+ name = "Summary",
+ title = topic,
+ subtitle = $"{GetDocTypeLabel(docType)} workbook",
+ kpis = BuildWorkbookKpis(summarySection),
+ highlights = (summarySection?.KeyPoints ?? []).Take(3).ToArray(),
+ actions = (recommendationSection?.KeyPoints ?? summarySection?.KeyPoints ?? []).Take(3).ToArray(),
+ };
+
+ var sheets = BuildWorkbookSheets(docType, sections);
+ var payload = new
+ {
+ path = fileName,
+ theme = "professional",
+ summary_sheet = summarySheet,
+ sheets,
+ };
+
+ var output = new StringBuilder();
+ output.AppendLine($"📋 워크북 개요 생성 완료 {label} ({sheets.Count}개 상세 시트, {targetPages}페이지/{totalWords}단어 기준)");
+ output.AppendLine();
+ output.AppendLine("## 즉시 실행: excel_create 호출 파라미터");
+ output.AppendLine(JsonSerializer.Serialize(payload, _jsonOptions));
+ output.AppendLine();
+ output.AppendLine("⚠ 아래 placeholder 값과 샘플 행은 실제 수치/일정/담당자로 교체하세요.");
+ output.AppendLine("⚠ Summary 시트의 highlights/actions는 핵심 메시지 중심으로 유지하고, 상세 시트는 수치/근거 중심으로 채우세요.");
+
+ return Task.FromResult(ToolResult.Ok(output.ToString()));
+ }
+
// ─── 싱글패스 + 폴더 데이터 활용: 개요 반환 + LLM이 데이터 읽고 직접 저장 ──
private Task ExecuteSinglePassWithData(string topic, string docType, string format, string mood,
int targetPages, int totalWords, List sections, string folderDataUsage, string refSummary)
{
- var ext = format switch { "docx" => ".docx", "markdown" => ".md", _ => ".html" };
- var createTool = format switch { "docx" => "docx_create", "markdown" => "file_write", _ => "html_create" };
+ var ext = format switch { "docx" => ".docx", "markdown" => ".md", "xlsx" => ".xlsx", _ => ".html" };
+ var createTool = format switch { "docx" => "docx_create", "markdown" => "file_write", "xlsx" => "excel_create", _ => "html_create" };
var safeTitle = SanitizeFileName(topic);
var suggestedPath = $"{safeTitle}{ext}";
+ var artifactLabel = format == "xlsx" ? "workbook" : "document";
// reference_summary가 이미 있으면 데이터 읽기 단계를 건너뛸 수 있음
var hasRefData = !string.IsNullOrWhiteSpace(refSummary);
@@ -327,8 +368,10 @@ public class DocumentPlannerTool : IAgentTool
step2 = hasRefData
? "(skipped)"
: "Summarize the key findings from the folder documents relevant to the topic.",
- step3 = $"Write the COMPLETE document content covering ALL sections above, incorporating folder data.",
- step4 = $"Save the document using {createTool} with the path '{suggestedPath}'. " +
+ step3 = format == "xlsx"
+ ? "Create the COMPLETE workbook structure using validated data, including summary sheet, detailed sheets, and follow-up actions."
+ : $"Write the COMPLETE {artifactLabel} content covering ALL sections above, incorporating folder data.",
+ step4 = $"Save the {artifactLabel} using {createTool} with the path '{suggestedPath}'. " +
"Write ALL sections in a SINGLE tool call. Do NOT split into multiple calls.",
preferred_mood = format == "html" ? mood : null as string,
note = "Minimize LLM calls. Read data → write complete document → save. Maximum 3 iterations."
@@ -707,11 +750,13 @@ public class DocumentPlannerTool : IAgentTool
private static string ResolveDocumentFormat(string? preferred, string docType, string topic)
{
var normalized = (preferred ?? "auto").Trim().ToLowerInvariant();
- if (normalized is "html" or "docx" or "markdown")
+ if (normalized is "html" or "docx" or "markdown" or "xlsx")
return normalized;
var intent = $"{docType} {topic}".ToLowerInvariant();
+ if (ContainsAny(intent, "xlsx", "excel", "workbook", "tracker", "dashboard", "scorecard", "워크북", "엑셀", "대시보드", "추적표"))
+ return "xlsx";
if (ContainsAny(intent, "docx", "word", "워드"))
return "docx";
if (ContainsAny(intent, "markdown", ".md", "md ", "마크다운", "readme", "가이드", "manual", "guide", "회의록", "minutes"))
@@ -774,4 +819,134 @@ public class DocumentPlannerTool : IAgentTool
public int TargetWords { get; set; } = 300;
public List KeyPoints { get; set; } = new();
}
+
+ private sealed class WorkbookSheetPlan
+ {
+ public string Name { get; set; } = "";
+ public List Headers { get; set; } = new();
+ public List> Rows { get; set; } = new();
+ }
+
+ private static List