using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Runtime.InteropServices; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; namespace AxCopilot.Services.Agent; public class DocumentAssemblerTool : IAgentTool { public string Name => "document_assemble"; public string Description => "Assemble multiple individually-written sections into a single complete document. Use this after writing each section separately with document_plan. Supports HTML, DOCX, and Markdown output. Automatically adds table of contents, cover page, and section numbering for HTML. After assembly, the document is auto-validated for quality issues."; public ToolParameterSchema Parameters { get { ToolParameterSchema toolParameterSchema = new ToolParameterSchema(); Dictionary obj = new Dictionary { ["path"] = new ToolProperty { Type = "string", Description = "Output file path. Relative to work folder." }, ["title"] = new ToolProperty { Type = "string", Description = "Document title" }, ["sections"] = new ToolProperty { Type = "array", Description = "Array of section objects: [{\"heading\": \"1. 개요\", \"content\": \"HTML or markdown body...\", \"level\": 1}]. content should be the detailed text for each section.", Items = new ToolProperty { Type = "object" } } }; ToolProperty obj2 = new ToolProperty { Type = "string", Description = "Output format: html, docx, markdown. Default: html" }; int num = 3; List list = new List(num); CollectionsMarshal.SetCount(list, num); Span span = CollectionsMarshal.AsSpan(list); span[0] = "html"; span[1] = "docx"; span[2] = "markdown"; obj2.Enum = list; obj["format"] = obj2; obj["mood"] = new ToolProperty { Type = "string", Description = "Design theme for HTML output: modern, professional, creative, corporate, dashboard, etc. Default: professional" }; obj["toc"] = new ToolProperty { Type = "boolean", Description = "Auto-generate table of contents. Default: true" }; obj["cover_subtitle"] = new ToolProperty { Type = "string", Description = "Subtitle for cover page. If provided, a cover page is added." }; obj["header"] = new ToolProperty { Type = "string", Description = "Header text for DOCX output." }; obj["footer"] = new ToolProperty { Type = "string", Description = "Footer text for DOCX output. Use {page} for page number." }; toolParameterSchema.Properties = obj; num = 3; List list2 = new List(num); CollectionsMarshal.SetCount(list2, num); Span span2 = CollectionsMarshal.AsSpan(list2); span2[0] = "path"; span2[1] = "title"; span2[2] = "sections"; toolParameterSchema.Required = list2; return toolParameterSchema; } } public async Task ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct) { string path = args.GetProperty("path").GetString() ?? ""; string title = args.GetProperty("title").GetString() ?? "Document"; JsonElement fmt; string format = (args.TryGetProperty("format", out fmt) ? (fmt.GetString() ?? "html") : "html"); JsonElement m; string mood = (args.TryGetProperty("mood", out m) ? (m.GetString() ?? "professional") : "professional"); JsonElement tocVal; bool useToc = !args.TryGetProperty("toc", out tocVal) || tocVal.GetBoolean(); JsonElement cs; string coverSubtitle = (args.TryGetProperty("cover_subtitle", out cs) ? cs.GetString() : null); JsonElement hdr; string headerText = (args.TryGetProperty("header", out hdr) ? hdr.GetString() : null); JsonElement ftr; string footerText = (args.TryGetProperty("footer", out ftr) ? ftr.GetString() : null); if (!args.TryGetProperty("sections", out var sectionsEl) || sectionsEl.ValueKind != JsonValueKind.Array) { return ToolResult.Fail("sections 배열이 필요합니다."); } List<(string Heading, string Content, int Level)> sections = new List<(string, string, int)>(); foreach (JsonElement sec in sectionsEl.EnumerateArray()) { JsonElement h; string heading = (sec.TryGetProperty("heading", out h) ? (h.GetString() ?? "") : ""); JsonElement c; string content = (sec.TryGetProperty("content", out c) ? (c.GetString() ?? "") : ""); JsonElement lv; int level = ((!sec.TryGetProperty("level", out lv)) ? 1 : lv.GetInt32()); if (!string.IsNullOrWhiteSpace(heading) || !string.IsNullOrWhiteSpace(content)) { sections.Add((heading, content, level)); } h = default(JsonElement); c = default(JsonElement); lv = default(JsonElement); } if (sections.Count == 0) { return ToolResult.Fail("조립할 섹션이 없습니다."); } string fullPath = FileReadTool.ResolvePath(path, context.WorkFolder); if (context.ActiveTab == "Cowork") { fullPath = AgentContext.EnsureTimestampedPath(fullPath); } if (1 == 0) { } string text = format; string text2 = ((text == "docx") ? ".docx" : ((!(text == "markdown")) ? ".html" : ".md")); if (1 == 0) { } string ext = text2; if (!fullPath.EndsWith(ext, StringComparison.OrdinalIgnoreCase)) { fullPath += ext; } if (!context.IsPathAllowed(fullPath)) { return ToolResult.Fail("경로 접근 차단: " + fullPath); } if (!(await context.CheckWritePermissionAsync(Name, fullPath))) { return ToolResult.Fail("쓰기 권한 거부: " + fullPath); } string dir = Path.GetDirectoryName(fullPath); if (!string.IsNullOrEmpty(dir)) { Directory.CreateDirectory(dir); } try { string text3 = format; text2 = text3; string resultMsg = ((text2 == "docx") ? AssembleDocx(fullPath, title, sections, headerText, footerText) : ((!(text2 == "markdown")) ? AssembleHtml(fullPath, title, sections, mood, useToc, coverSubtitle) : AssembleMarkdown(fullPath, title, sections))); int totalChars = sections.Sum<(string, string, int)>(((string Heading, string Content, int Level) s) => s.Content.Length); int totalWords = sections.Sum<(string, string, int)>(((string Heading, string Content, int Level) s) => EstimateWordCount(s.Content)); int pageEstimate = Math.Max(1, totalWords / 500); return ToolResult.Ok($"✅ 문서 조립 완료: {Path.GetFileName(fullPath)}\n 섹션: {sections.Count}개 | 글자: {totalChars:N0} | 단어: ~{totalWords:N0} | 예상 페이지: ~{pageEstimate}\n{resultMsg}", fullPath); } catch (Exception ex) { return ToolResult.Fail("문서 조립 실패: " + ex.Message); } } private string AssembleHtml(string path, string title, List<(string Heading, string Content, int Level)> sections, string mood, bool toc, string? coverSubtitle) { StringBuilder stringBuilder = new StringBuilder(); string css = TemplateService.GetCss(mood); TemplateMood mood2 = TemplateService.GetMood(mood); stringBuilder.AppendLine(""); stringBuilder.AppendLine(""); stringBuilder.AppendLine(""); stringBuilder.AppendLine(""); StringBuilder stringBuilder2 = stringBuilder; StringBuilder stringBuilder3 = stringBuilder2; StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(15, 1, stringBuilder2); handler.AppendLiteral(""); handler.AppendFormatted(Escape(title)); handler.AppendLiteral(""); stringBuilder3.AppendLine(ref handler); stringBuilder.AppendLine(""); stringBuilder.AppendLine(""); stringBuilder.AppendLine(""); if (!string.IsNullOrWhiteSpace(coverSubtitle)) { stringBuilder.AppendLine("
"); stringBuilder2 = stringBuilder; StringBuilder stringBuilder4 = stringBuilder2; handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2); handler.AppendLiteral("

"); handler.AppendFormatted(Escape(title)); handler.AppendLiteral("

"); stringBuilder4.AppendLine(ref handler); stringBuilder2 = stringBuilder; StringBuilder stringBuilder5 = stringBuilder2; handler = new StringBuilder.AppendInterpolatedStringHandler(28, 1, stringBuilder2); handler.AppendLiteral("
"); handler.AppendFormatted(Escape(coverSubtitle)); handler.AppendLiteral("
"); stringBuilder5.AppendLine(ref handler); stringBuilder2 = stringBuilder; StringBuilder stringBuilder6 = stringBuilder2; handler = new StringBuilder.AppendInterpolatedStringHandler(24, 1, stringBuilder2); handler.AppendLiteral("
"); handler.AppendFormatted(DateTime.Now, "yyyy년 MM월 dd일"); handler.AppendLiteral("
"); stringBuilder6.AppendLine(ref handler); stringBuilder.AppendLine("
"); } stringBuilder.AppendLine("
"); if (string.IsNullOrWhiteSpace(coverSubtitle)) { stringBuilder2 = stringBuilder; StringBuilder stringBuilder7 = stringBuilder2; handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2); handler.AppendLiteral("

"); handler.AppendFormatted(Escape(title)); handler.AppendLiteral("

"); stringBuilder7.AppendLine(ref handler); } if (toc && sections.Count > 1) { stringBuilder.AppendLine("
"); stringBuilder.AppendLine("

\ud83d\udccb 목차

"); stringBuilder.AppendLine("
    "); for (int i = 0; i < sections.Count; i++) { string value = ((sections[i].Level > 1) ? " style=\"padding-left:20px\"" : ""); stringBuilder2 = stringBuilder; StringBuilder stringBuilder8 = stringBuilder2; handler = new StringBuilder.AppendInterpolatedStringHandler(33, 3, stringBuilder2); handler.AppendLiteral(""); handler.AppendFormatted(Escape(sections[i].Heading)); handler.AppendLiteral(""); stringBuilder8.AppendLine(ref handler); } stringBuilder.AppendLine("
"); stringBuilder.AppendLine("
"); } for (int j = 0; j < sections.Count; j++) { (string Heading, string Content, int Level) tuple = sections[j]; string item = tuple.Heading; string item2 = tuple.Content; int item3 = tuple.Level; string value2 = ((item3 <= 1) ? "h2" : "h3"); stringBuilder2 = stringBuilder; StringBuilder stringBuilder9 = stringBuilder2; handler = new StringBuilder.AppendInterpolatedStringHandler(19, 4, stringBuilder2); handler.AppendLiteral("<"); handler.AppendFormatted(value2); handler.AppendLiteral(" id=\"section-"); handler.AppendFormatted(j + 1); handler.AppendLiteral("\">"); handler.AppendFormatted(Escape(item)); handler.AppendLiteral(""); stringBuilder9.AppendLine(ref handler); stringBuilder2 = stringBuilder; StringBuilder stringBuilder10 = stringBuilder2; handler = new StringBuilder.AppendInterpolatedStringHandler(35, 1, stringBuilder2); handler.AppendLiteral("
"); handler.AppendFormatted(item2); handler.AppendLiteral("
"); stringBuilder10.AppendLine(ref handler); } stringBuilder.AppendLine("
"); stringBuilder.AppendLine(""); stringBuilder.AppendLine(""); File.WriteAllText(path, stringBuilder.ToString(), Encoding.UTF8); List list = ValidateBasic(stringBuilder.ToString()); return (list.Count > 0) ? $" ⚠ 품질 검증 이슈 {list.Count}건: {string.Join("; ", list)}" : " ✓ 품질 검증 통과"; } private string AssembleDocx(string path, string title, List<(string Heading, string Content, int Level)> sections, string? headerText, string? footerText) { using WordprocessingDocument wordprocessingDocument = WordprocessingDocument.Create(path, WordprocessingDocumentType.Document); MainDocumentPart mainDocumentPart = wordprocessingDocument.AddMainDocumentPart(); mainDocumentPart.Document = new Document(); Body body = mainDocumentPart.Document.AppendChild(new Body()); Paragraph paragraph = new Paragraph(); Run run = new Run(); run.AppendChild(new RunProperties { Bold = new Bold(), FontSize = new FontSize { Val = "48" } }); run.AppendChild(new Text(title)); paragraph.AppendChild(run); body.AppendChild(paragraph); body.AppendChild(new Paragraph()); foreach (var section in sections) { string item = section.Heading; string item2 = section.Content; int item3 = section.Level; Paragraph paragraph2 = new Paragraph(); Run run2 = new Run(); run2.AppendChild(new RunProperties { Bold = new Bold(), FontSize = new FontSize { Val = ((item3 <= 1) ? "32" : "28") }, Color = new Color { Val = "2B579A" } }); run2.AppendChild(new Text(item)); paragraph2.AppendChild(run2); body.AppendChild(paragraph2); string[] array = StripHtmlTags(item2).Split('\n', StringSplitOptions.RemoveEmptyEntries); string[] array2 = array; foreach (string text in array2) { Paragraph paragraph3 = new Paragraph(); Run run3 = new Run(); run3.AppendChild(new Text(text.Trim()) { Space = SpaceProcessingModeValues.Preserve }); paragraph3.AppendChild(run3); body.AppendChild(paragraph3); } body.AppendChild(new Paragraph()); } return " ✓ DOCX 조립 완료"; } private string AssembleMarkdown(string path, string title, List<(string Heading, string Content, int Level)> sections) { StringBuilder stringBuilder = new StringBuilder(); StringBuilder stringBuilder2 = stringBuilder; StringBuilder stringBuilder3 = stringBuilder2; StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(2, 1, stringBuilder2); handler.AppendLiteral("# "); handler.AppendFormatted(title); stringBuilder3.AppendLine(ref handler); stringBuilder.AppendLine(); stringBuilder2 = stringBuilder; StringBuilder stringBuilder4 = stringBuilder2; handler = new StringBuilder.AppendInterpolatedStringHandler(7, 1, stringBuilder2); handler.AppendLiteral("*작성일: "); handler.AppendFormatted(DateTime.Now, "yyyy-MM-dd"); handler.AppendLiteral("*"); stringBuilder4.AppendLine(ref handler); stringBuilder.AppendLine(); if (sections.Count > 1) { stringBuilder.AppendLine("## 목차"); stringBuilder.AppendLine(); foreach (var section in sections) { string item = section.Heading; string value = item.Replace(" ", "-").ToLowerInvariant(); stringBuilder2 = stringBuilder; StringBuilder stringBuilder5 = stringBuilder2; handler = new StringBuilder.AppendInterpolatedStringHandler(7, 2, stringBuilder2); handler.AppendLiteral("- ["); handler.AppendFormatted(item); handler.AppendLiteral("](#"); handler.AppendFormatted(value); handler.AppendLiteral(")"); stringBuilder5.AppendLine(ref handler); } stringBuilder.AppendLine(); stringBuilder.AppendLine("---"); stringBuilder.AppendLine(); } foreach (var section2 in sections) { string item2 = section2.Heading; string item3 = section2.Content; int item4 = section2.Level; string value2 = ((item4 <= 1) ? "##" : "###"); stringBuilder2 = stringBuilder; StringBuilder stringBuilder6 = stringBuilder2; handler = new StringBuilder.AppendInterpolatedStringHandler(1, 2, stringBuilder2); handler.AppendFormatted(value2); handler.AppendLiteral(" "); handler.AppendFormatted(item2); stringBuilder6.AppendLine(ref handler); stringBuilder.AppendLine(); stringBuilder.AppendLine(StripHtmlTags(item3)); stringBuilder.AppendLine(); } File.WriteAllText(path, stringBuilder.ToString(), Encoding.UTF8); return " ✓ Markdown 조립 완료"; } private static int EstimateWordCount(string text) { if (string.IsNullOrWhiteSpace(text)) { return 0; } string source = StripHtmlTags(text); int num = source.Count((char c) => c == ' '); int num2 = source.Count((char c) => c >= '가' && c <= '힣'); return num + 1 + num2 / 3; } private static string StripHtmlTags(string html) { if (string.IsNullOrEmpty(html)) { return ""; } return Regex.Replace(html, "<[^>]+>", " ").Replace(" ", " ").Replace("&", "&") .Replace("<", "<") .Replace(">", ">") .Replace(" ", " ") .Trim(); } private static List ValidateBasic(string html) { List list = new List(); if (html.Length < 500) { list.Add("문서 내용이 매우 짧습니다 (500자 미만)"); } Regex regex = new Regex("]*>[^<]+\\s*
\\s*
", RegexOptions.IgnoreCase); MatchCollection matchCollection = regex.Matches(html); if (matchCollection.Count > 0) { list.Add($"빈 섹션 {matchCollection.Count}개 발견"); } if (html.Contains("[TODO]", StringComparison.OrdinalIgnoreCase) || html.Contains("[PLACEHOLDER]", StringComparison.OrdinalIgnoreCase) || html.Contains("Lorem ipsum", StringComparison.OrdinalIgnoreCase)) { list.Add("플레이스홀더 텍스트가 남아있습니다"); } return list; } private static string Escape(string text) { return text.Replace("&", "&").Replace("<", "<").Replace(">", ">"); } }