using System.IO; using System.Text; using System.Text.Json; namespace AxCopilot.Services.Agent; /// /// 여러 섹션의 내용을 하나의 완성된 문서로 조립하는 도구. /// 멀티패스 문서 생성의 3단계: 개별 생성된 섹션들을 최종 문서로 결합합니다. /// 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 => new() { Properties = new() { ["path"] = new() { Type = "string", Description = "Output file path. Relative to work folder." }, ["title"] = new() { Type = "string", Description = "Document title" }, ["sections"] = new() { 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() { Type = "object" } }, ["format"] = new() { Type = "string", Description = "Output format: html, docx, markdown. Default: html", Enum = ["html", "docx", "markdown"] }, ["mood"] = new() { Type = "string", Description = "Design theme for HTML output: modern, professional, creative, corporate, dashboard, etc. Default: professional" }, ["toc"] = new() { Type = "boolean", Description = "Auto-generate table of contents. Default: true" }, ["cover_subtitle"] = new() { Type = "string", Description = "Subtitle for cover page. If provided, a cover page is added." }, ["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." }, }, Required = ["path", "title", "sections"] }; public async Task ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct) { var path = args.GetProperty("path").GetString() ?? ""; var title = args.GetProperty("title").GetString() ?? "Document"; var format = args.TryGetProperty("format", out var fmt) ? fmt.GetString() ?? "html" : "html"; var mood = args.TryGetProperty("mood", out var m) ? m.GetString() ?? "professional" : "professional"; var useToc = !args.TryGetProperty("toc", out var tocVal) || tocVal.GetBoolean(); // default true var coverSubtitle = args.TryGetProperty("cover_subtitle", out var cs) ? cs.GetString() : null; var headerText = args.TryGetProperty("header", out var hdr) ? hdr.GetString() : null; var footerText = args.TryGetProperty("footer", out var ftr) ? ftr.GetString() : null; if (!args.TryGetProperty("sections", out var sectionsEl) || sectionsEl.ValueKind != JsonValueKind.Array) return ToolResult.Fail("sections 배열이 필요합니다."); var sections = new List<(string Heading, string Content, int Level)>(); foreach (var sec in sectionsEl.EnumerateArray()) { var heading = sec.TryGetProperty("heading", out var h) ? h.GetString() ?? "" : ""; var content = sec.TryGetProperty("content", out var c) ? c.GetString() ?? "" : ""; var level = sec.TryGetProperty("level", out var lv) ? lv.GetInt32() : 1; if (!string.IsNullOrWhiteSpace(heading) || !string.IsNullOrWhiteSpace(content)) sections.Add((heading, content, level)); } if (sections.Count == 0) return ToolResult.Fail("조립할 섹션이 없습니다."); var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder); if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath); // 확장자 자동 추가 var ext = format switch { "docx" => ".docx", "markdown" => ".md", _ => ".html" }; 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}"); var dir = Path.GetDirectoryName(fullPath); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); try { string resultMsg; switch (format) { case "docx": resultMsg = AssembleDocx(fullPath, title, sections, headerText, footerText); break; case "markdown": resultMsg = AssembleMarkdown(fullPath, title, sections); break; default: resultMsg = AssembleHtml(fullPath, title, sections, mood, useToc, coverSubtitle); break; } // 품질 요약 통계 var totalChars = sections.Sum(s => s.Content.Length); var totalWords = sections.Sum(s => EstimateWordCount(s.Content)); var 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) { var sb = new StringBuilder(); var style = TemplateService.GetCss(mood); var moodInfo = TemplateService.GetMood(mood); sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine($"{Escape(title)}"); sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine(""); // 커버 페이지 if (!string.IsNullOrWhiteSpace(coverSubtitle)) { sb.AppendLine("
"); sb.AppendLine($"

{Escape(title)}

"); sb.AppendLine($"
{Escape(coverSubtitle)}
"); sb.AppendLine($"
{DateTime.Now:yyyy년 MM월 dd일}
"); sb.AppendLine("
"); } sb.AppendLine("
"); if (string.IsNullOrWhiteSpace(coverSubtitle)) sb.AppendLine($"

{Escape(title)}

"); // TOC 생성 if (toc && sections.Count > 1) { sb.AppendLine("
"); sb.AppendLine("

📋 목차

"); sb.AppendLine("
    "); for (int i = 0; i < sections.Count; i++) { var indent = sections[i].Level > 1 ? " style=\"padding-left:20px\"" : ""; sb.AppendLine($"{Escape(sections[i].Heading)}"); } sb.AppendLine("
"); sb.AppendLine("
"); } // 섹션 본문 for (int i = 0; i < sections.Count; i++) { var (heading, content, level) = sections[i]; var tag = level <= 1 ? "h2" : "h3"; sb.AppendLine($"<{tag} id=\"section-{i + 1}\">{Escape(heading)}"); sb.AppendLine($"
{content}
"); } sb.AppendLine("
"); sb.AppendLine(""); sb.AppendLine(""); File.WriteAllText(path, sb.ToString(), Encoding.UTF8); var issues = ValidateBasic(sb.ToString()); return issues.Count > 0 ? $" ⚠ 품질 검증 이슈 {issues.Count}건: {string.Join("; ", issues)}" : " ✓ 품질 검증 통과"; } private string AssembleDocx(string path, string title, List<(string Heading, string Content, int Level)> sections, string? headerText, string? footerText) { // DOCX 조립: DocxSkill의 sections 형식으로 변환하여 OpenXML 사용 using var doc = DocumentFormat.OpenXml.Packaging.WordprocessingDocument.Create( path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document); var mainPart = doc.AddMainDocumentPart(); mainPart.Document = new DocumentFormat.OpenXml.Wordprocessing.Document(); var body = mainPart.Document.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Body()); // 제목 var titlePara = new DocumentFormat.OpenXml.Wordprocessing.Paragraph(); var titleRun = new DocumentFormat.OpenXml.Wordprocessing.Run(); titleRun.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.RunProperties { 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 { 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); // 섹션 본문 (줄 단위 분할) var lines = StripHtmlTags(content).Split('\n', StringSplitOptions.RemoveEmptyEntries); foreach (var line in lines) { var para = new DocumentFormat.OpenXml.Wordprocessing.Paragraph(); var run = new DocumentFormat.OpenXml.Wordprocessing.Run(); run.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Text(line.Trim()) { Space = DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve }); para.AppendChild(run); body.AppendChild(para); } // 섹션 간 빈 줄 body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Paragraph()); } return " ✓ DOCX 조립 완료"; } private string AssembleMarkdown(string path, string title, List<(string Heading, string Content, int Level)> sections) { var sb = new StringBuilder(); sb.AppendLine($"# {title}"); sb.AppendLine(); sb.AppendLine($"*작성일: {DateTime.Now:yyyy-MM-dd}*"); sb.AppendLine(); // TOC if (sections.Count > 1) { sb.AppendLine("## 목차"); sb.AppendLine(); foreach (var (heading, _, _) in sections) { var anchor = heading.Replace(" ", "-").ToLowerInvariant(); sb.AppendLine($"- [{heading}](#{anchor})"); } sb.AppendLine(); sb.AppendLine("---"); sb.AppendLine(); } foreach (var (heading, content, level) in sections) { var prefix = level <= 1 ? "##" : "###"; sb.AppendLine($"{prefix} {heading}"); sb.AppendLine(); sb.AppendLine(StripHtmlTags(content)); sb.AppendLine(); } File.WriteAllText(path, sb.ToString(), Encoding.UTF8); return " ✓ Markdown 조립 완료"; } private static int EstimateWordCount(string text) { if (string.IsNullOrWhiteSpace(text)) return 0; var plain = StripHtmlTags(text); // 한국어: 글자 수 / 3 ≈ 단어 수, 영어: 공백 분리 var spaces = plain.Count(c => c == ' '); var koreanChars = plain.Count(c => c >= 0xAC00 && c <= 0xD7A3); return spaces + 1 + koreanChars / 3; } private static string StripHtmlTags(string html) { if (string.IsNullOrEmpty(html)) return ""; return System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", " ") .Replace(" ", " ") .Replace("&", "&") .Replace("<", "<") .Replace(">", ">") .Replace(" ", " ") .Trim(); } private static List ValidateBasic(string html) { var issues = new List(); if (html.Length < 500) issues.Add("문서 내용이 매우 짧습니다 (500자 미만)"); // 빈 섹션 검사 var emptySectionPattern = new System.Text.RegularExpressions.Regex( @"]*>[^<]+\s*
\s*
", System.Text.RegularExpressions.RegexOptions.IgnoreCase); var emptyMatches = emptySectionPattern.Matches(html); if (emptyMatches.Count > 0) issues.Add($"빈 섹션 {emptyMatches.Count}개 발견"); // 플레이스홀더 검사 if (html.Contains("[TODO]", StringComparison.OrdinalIgnoreCase) || html.Contains("[PLACEHOLDER]", StringComparison.OrdinalIgnoreCase) || html.Contains("Lorem ipsum", StringComparison.OrdinalIgnoreCase)) issues.Add("플레이스홀더 텍스트가 남아있습니다"); return issues; } private static string Escape(string text) { return text.Replace("&", "&").Replace("<", "<").Replace(">", ">"); } }