?? planner/assembler ??? 2? ??

?? ??:
- Word/HTML/Excel ?? ?? ??? PPT ??? planner ?? ??? ? ??? ??? ?? ?? ??? ?????.
- ??? ???? document_plan ???? ?? ??? ? ??? xlsx scaffold ??? ?????.

?? ????:
- DocumentPlannerTool? format:xlsx ??? ???? summary_sheet + sheets ??? excel_create scaffold? ????? ??????.
- DocumentPlannerTool? ?? ?? ??? workbook/tracker/dashboard/scorecard ???? ????? ??????.
- DocumentAssemblerTool? DOCX ?? ??? cover_subtitle, TOC, header/footer ??? ???? ?? ???? DOCX ?? ?? ??? ??????.
- DocumentAssemblerTool? HTML ?? ??? ArtifactQualityReviewService? ??? score ?? ?? ??? ????? ??????.
- kpi-workbook ???? document_plan ??? ??? complex workbook ?? ? planner ??? ??? ? ?? ????.
- ?? ?? ???? DocumentPlannerWorkbookScaffoldTests, DocumentAssemblerDocxFeaturesTests? ??????.
- README.md? docs/DEVELOPMENT.md? 2026-04-14 22:14 (KST) ?? ?? ??? ??????.

?? ??:
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_doc_planning2\\ -p:IntermediateOutputPath=obj\\verify_doc_planning2\\ : ?? 0 / ?? 0
- dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "DocumentPlannerWorkbookScaffoldTests|DocumentAssemblerDocxFeaturesTests|DocumentAssemblerSemanticTests|DocumentPlannerBusinessDocumentTests|ExcelSkillExecutiveSummaryLinkTests|HtmlSkillConsultingSectionsTests|DocxSkillTemplateFeaturesTests" -p:OutputPath=bin\\verify_doc_planning_tests3\\ -p:IntermediateOutputPath=obj\\verify_doc_planning_tests3\\ : ?? 7
This commit is contained in:
2026-04-14 22:15:50 +09:00
parent 8571a83ed0
commit 1ad5eea32e
7 changed files with 633 additions and 59 deletions

View File

@@ -120,7 +120,7 @@ public class DocumentAssemblerTool : IAgentTool
switch (format)
{
case "docx":
resultMsg = AssembleDocx(fullPath, title, sections, headerText, footerText);
resultMsg = AssembleDocx(fullPath, title, sections, useToc, coverSubtitle, headerText, footerText);
break;
case "markdown":
resultMsg = AssembleMarkdown(fullPath, title, sections);
@@ -231,13 +231,15 @@ public class DocumentAssemblerTool : IAgentTool
File.WriteAllText(path, sb.ToString(), Encoding.UTF8);
var issues = ValidateBasic(sb.ToString());
var review = ArtifactQualityReviewService.ReviewHtml(title, sb.ToString(), !string.IsNullOrWhiteSpace(coverSubtitle), toc, printReady: true);
var reviewSummary = $" HTML 품질 리뷰: {review.Score}/100 | 강점 {review.Strengths.Count} | 이슈 {review.Issues.Count}";
return issues.Count > 0
? $" ⚠ 품질 이슈 {issues.Count}건: {string.Join("; ", issues)}"
: " ✓ 품질 검증 통과";
? $"{reviewSummary} | 기본 이슈 {issues.Count}건: {string.Join("; ", issues)}"
: reviewSummary;
}
private string AssembleDocx(string path, string title, List<(string Heading, string Content, int Level)> sections,
string? headerText, string? footerText)
bool useToc, string? coverSubtitle, string? headerText, string? footerText)
{
using var doc = DocumentFormat.OpenXml.Packaging.WordprocessingDocument.Create(
path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document);
@@ -424,6 +426,74 @@ public class DocumentAssemblerTool : IAgentTool
}
}
var includeCover = !string.IsNullOrWhiteSpace(coverSubtitle);
if (includeCover)
AppendAssemblerCoverPage(body, title, coverSubtitle!);
else
body.AppendChild(CreateParagraph(title, fontSize: "48", bold: true, color: "1F3A5F"));
if (!includeCover)
body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Paragraph());
if (useToc && sections.Count > 3)
AppendAssemblerTableOfContents(body);
var tableCount = 0;
var listCount = 0;
var calloutCount = 0;
var highlightCount = 0;
var headings = new List<string>();
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);
headings.Add(heading);
AppendStructuredContent(content);
body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Paragraph());
}
body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.SectionProperties(
new DocumentFormat.OpenXml.Wordprocessing.PageSize { Width = 11906, Height = 16838 },
new DocumentFormat.OpenXml.Wordprocessing.PageMargin { Top = 1440, Right = 1440, Bottom = 1440, Left = 1440,
Header = 720, Footer = 720, Gutter = 0 }
));
if (!string.IsNullOrWhiteSpace(headerText) || !string.IsNullOrWhiteSpace(footerText))
AddAssemblerHeaderFooter(mainPart, body, headerText, footerText, showPageNumbers: true);
mainPart.Document.Save();
var review = ArtifactQualityReviewService.ReviewStructuredDocument(new StructuredDocumentReviewInput(
title,
headings.Count,
sections.Sum(section => StripHtmlTags(section.Content).Length),
tableCount,
listCount,
calloutCount,
highlightCount,
0,
includeCover,
useToc && sections.Count > 3,
false,
!string.IsNullOrWhiteSpace(headerText) || !string.IsNullOrWhiteSpace(footerText),
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", "부록", "참고"))));
return $" DOCX 품질 리뷰: {review.Score}/100 | 강점 {review.Strengths.Count} | 이슈 {review.Issues.Count}";
void AppendStructuredContent(string rawContent)
{
if (string.IsNullOrWhiteSpace(rawContent))
@@ -437,7 +507,7 @@ public class DocumentAssemblerTool : IAgentTool
}
normalized = Regex.Replace(normalized, @"<br\s*/?>", "\n", RegexOptions.IgnoreCase);
var blockPattern = @"<table\b[^>]*>.*?</table>|<ul\b[^>]*>.*?</ul>|<ol\b[^>]*>.*?</ol>|<blockquote\b[^>]*>.*?</blockquote>|<div\b[^>]*class=""[^""]*(callout-[^""]*|comparison-grid|roadmap-block|matrix-grid)[^""]*""[^>]*>.*?</div>|<h[2-6]\b[^>]*>.*?</h[2-6]>|<p\b[^>]*>.*?</p>";
var blockPattern = @"<table\b[^>]*>.*?</table>|<ul\b[^>]*>.*?</ul>|<ol\b[^>]*>.*?</ol>|<blockquote\b[^>]*>.*?</blockquote>|<div\b[^>]*class=""[^""]*(callout-[^""]*|comparison-grid|roadmap-block|matrix-grid|highlight-box)[^""]*""[^>]*>.*?</div>|<h[2-6]\b[^>]*>.*?</h[2-6]>|<p\b[^>]*>.*?</p>";
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, @"^<table", RegexOptions.IgnoreCase))
{
body.AppendChild(CreateTableFromHtml(block));
tableCount++;
}
else if (Regex.IsMatch(block, @"^<ul", RegexOptions.IgnoreCase))
{
AppendListBlock(block, ordered: false);
listCount++;
}
else if (Regex.IsMatch(block, @"^<ol", RegexOptions.IgnoreCase))
{
AppendListBlock(block, ordered: true);
listCount++;
}
else if (Regex.IsMatch(block, @"^<h", RegexOptions.IgnoreCase))
{
@@ -474,7 +547,12 @@ public class DocumentAssemblerTool : IAgentTool
}
else if (Regex.IsMatch(block, @"^<(blockquote|div)", RegexOptions.IgnoreCase))
{
body.AppendChild(CreateParagraph(ExtractStructuredText(block), fontSize: "21", bold: true, color: "1F3A5F", fill: "EDF4FF"));
var fill = Regex.IsMatch(block, "highlight-box", RegexOptions.IgnoreCase) ? "FFF7DA" : "EDF4FF";
body.AppendChild(CreateParagraph(ExtractStructuredText(block), fontSize: "21", bold: true, color: "1F3A5F", fill: fill));
if (Regex.IsMatch(block, "highlight-box", RegexOptions.IgnoreCase))
highlightCount++;
else
calloutCount++;
}
else
{
@@ -487,49 +565,6 @@ public class DocumentAssemblerTool : IAgentTool
if (cursor < normalized.Length)
AppendPlainText(ExtractStructuredText(normalized[cursor..]));
}
var titlePara = new DocumentFormat.OpenXml.Wordprocessing.Paragraph();
var titleRun = new DocumentFormat.OpenXml.Wordprocessing.Run();
titleRun.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.RunProperties
{
RunFonts = KoreanFonts(),
Bold = new DocumentFormat.OpenXml.Wordprocessing.Bold(),
FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "48" }
});
titleRun.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Text(title));
titlePara.AppendChild(titleRun);
body.AppendChild(titlePara);
// 빈 줄
body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Paragraph());
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);
AppendStructuredContent(content);
body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Paragraph());
}
body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.SectionProperties(
new DocumentFormat.OpenXml.Wordprocessing.PageSize { Width = 11906, Height = 16838 },
new DocumentFormat.OpenXml.Wordprocessing.PageMargin { Top = 1440, Right = 1440, Bottom = 1440, Left = 1440,
Header = 720, Footer = 720, Gutter = 0 }
));
mainPart.Document.Save();
return " ✓ DOCX 조립 완료";
}
/// <summary>DOCX 기본 스타일 정의 생성 (한글 글꼴 기본 설정 포함).</summary>
@@ -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<DocumentFormat.OpenXml.Packaging.HeaderPart>();
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<DocumentFormat.OpenXml.Wordprocessing.SectionProperties>().LastOrDefault()
?? body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.SectionProperties());
sectionProperties.Elements<DocumentFormat.OpenXml.Wordprocessing.HeaderReference>().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<DocumentFormat.OpenXml.Packaging.FooterPart>();
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<DocumentFormat.OpenXml.Wordprocessing.SectionProperties>().LastOrDefault()
?? body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.SectionProperties());
sectionProperties.Elements<DocumentFormat.OpenXml.Wordprocessing.FooterReference>().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();

View File

@@ -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<SectionPlan> 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()));
}
/// <summary>excel_create 즉시 호출 가능한 workbook 골격 반환.</summary>
private Task<ToolResult> ExecuteWithExcelScaffold(string topic, string fileName, string docType,
List<SectionPlan> 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<ToolResult> ExecuteSinglePassWithData(string topic, string docType, string format, string mood,
int targetPages, int totalWords, List<SectionPlan> 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<string> KeyPoints { get; set; } = new();
}
private sealed class WorkbookSheetPlan
{
public string Name { get; set; } = "";
public List<string> Headers { get; set; } = new();
public List<List<string>> Rows { get; set; } = new();
}
private static List<object> BuildWorkbookKpis(SectionPlan? summarySection)
{
var source = summarySection?.KeyPoints ?? [];
return source.Take(3).Select((point, index) => (object)new
{
label = $"KPI {index + 1}",
value = "[수치]",
trend = point,
note = "Replace with validated metric",
}).ToList();
}
private static List<WorkbookSheetPlan> BuildWorkbookSheets(string docType, List<SectionPlan> sections)
{
var plans = docType switch
{
"proposal" => new List<WorkbookSheetPlan>
{
new()
{
Name = "BusinessCase",
Headers = ["Workstream", "Investment", "Benefit", "Payback", "Owner"],
Rows =
[
["Initiative A", "[cost]", "[benefit]", "[months]", "[owner]"],
["Initiative B", "[cost]", "[benefit]", "[months]", "[owner]"],
],
},
new()
{
Name = "Roadmap",
Headers = ["Phase", "Milestone", "Timeline", "Owner", "Status"],
Rows =
[
["Phase 1", "[milestone]", "[date]", "[owner]", "Planned"],
["Phase 2", "[milestone]", "[date]", "[owner]", "Planned"],
],
},
new()
{
Name = "Risks",
Headers = ["Risk", "Impact", "Likelihood", "Mitigation", "Owner"],
Rows =
[
["[risk]", "High", "Medium", "[mitigation]", "[owner]"],
["[risk]", "Medium", "Low", "[mitigation]", "[owner]"],
],
},
},
"analysis" => new List<WorkbookSheetPlan>
{
new()
{
Name = "Metrics",
Headers = ["Metric", "Current", "Target", "Delta", "Comment"],
Rows =
[
["[metric]", "[current]", "[target]", "=[@Current]-[@Target]", "[insight]"],
["[metric]", "[current]", "[target]", "=[@Current]-[@Target]", "[insight]"],
],
},
new()
{
Name = "Findings",
Headers = ["Theme", "Finding", "Impact", "Evidence", "Owner"],
Rows =
[
["[theme]", "[finding]", "[impact]", "[source]", "[owner]"],
["[theme]", "[finding]", "[impact]", "[source]", "[owner]"],
],
},
new()
{
Name = "Actions",
Headers = ["Priority", "Action", "Owner", "Due Date", "Status"],
Rows =
[
["High", "[action]", "[owner]", "[date]", "Open"],
["Medium", "[action]", "[owner]", "[date]", "Open"],
],
},
},
_ => new List<WorkbookSheetPlan>
{
new()
{
Name = "Detail",
Headers = ["Category", "Item", "Value", "Trend", "Owner"],
Rows =
[
["[category]", "[item]", "[value]", "[trend]", "[owner]"],
["[category]", "[item]", "[value]", "[trend]", "[owner]"],
],
},
new()
{
Name = "Actions",
Headers = ["Action", "Owner", "Due Date", "Status", "Note"],
Rows =
[
["[action]", "[owner]", "[date]", "Open", "[note]"],
["[action]", "[owner]", "[date]", "Open", "[note]"],
],
},
new()
{
Name = "Evidence",
Headers = ["Section", "Key Point", "Evidence", "Source", "Comment"],
Rows = sections.Take(4).Select(section => new List<string>
{
section.Heading,
section.KeyPoints.FirstOrDefault() ?? "[key point]",
"[evidence]",
"[source]",
"[comment]",
}).ToList(),
},
},
};
return plans;
}
}