- 코워크·코드 프롬프트, 도구 선택, 문서 생성/검증 흐름을 claude-code 동등 품질 기준으로 재정렬함 - OpenAI/vLLM 경로의 오래된 tool history를 평탄화하고 최근 이력만 구조화해 컨텍스트 직렬화를 경량화함 - AX Agent UI를 테마 기준으로 재구성하고 플랜 승인/오버레이/이벤트 렌더링/명령 입력 상호작용을 개선함 - 파일 후보 제안, 반복 경로 정체 복구, LSP 보강, 문서·PPT 처리 개선, 설정/서비스 인터페이스 정리를 함께 반영함 - README.md 및 docs/DEVELOPMENT.md를 작업 시점별로 갱신함 - 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0, 오류 0)
508 lines
22 KiB
C#
508 lines
22 KiB
C#
using System.IO;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
namespace AxCopilot.Services.Agent;
|
|
|
|
/// <summary>
|
|
/// 여러 섹션의 내용을 하나의 완성된 문서로 조립하는 도구.
|
|
/// 멀티패스 문서 생성의 3단계: 개별 생성된 섹션들을 최종 문서로 결합합니다.
|
|
/// </summary>
|
|
public class DocumentAssemblerTool : IAgentTool
|
|
{
|
|
private static string GetDefaultOutputFormat()
|
|
{
|
|
var app = System.Windows.Application.Current as App;
|
|
return app?.SettingsService?.Settings.Llm.DefaultOutputFormat ?? "auto";
|
|
}
|
|
|
|
private static string GetDefaultMood()
|
|
{
|
|
var app = System.Windows.Application.Current as App;
|
|
return app?.SettingsService?.Settings.Llm.DefaultMood ?? "modern";
|
|
}
|
|
|
|
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: auto, html, docx, markdown. Default: auto (resolved from settings and request intent)",
|
|
Enum = ["auto", "html", "docx", "markdown"]
|
|
},
|
|
["mood"] = new()
|
|
{
|
|
Type = "string",
|
|
Description = "Design theme for HTML output: modern, professional, creative, corporate, dashboard, etc. Default: inferred from settings and document intent"
|
|
},
|
|
["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<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
|
{
|
|
var path = args.GetProperty("path").SafeGetString() ?? "";
|
|
var title = args.GetProperty("title").SafeGetString() ?? "Document";
|
|
var requestedFormat = args.SafeTryGetProperty("format", out var fmt) ? fmt.SafeGetString() ?? "auto" : GetDefaultOutputFormat();
|
|
var requestedMood = args.SafeTryGetProperty("mood", out var m) ? m.SafeGetString() ?? GetDefaultMood() : GetDefaultMood();
|
|
var useToc = !args.SafeTryGetProperty("toc", out var tocVal) || tocVal.GetBoolean(); // default true
|
|
var coverSubtitle = args.SafeTryGetProperty("cover_subtitle", out var cs) ? cs.SafeGetString() : null;
|
|
var headerText = args.SafeTryGetProperty("header", out var hdr) ? hdr.SafeGetString() : null;
|
|
var footerText = args.SafeTryGetProperty("footer", out var ftr) ? ftr.SafeGetString() : null;
|
|
|
|
if (!args.SafeTryGetProperty("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.SafeTryGetProperty("heading", out var h) ? h.SafeGetString() ?? "" : "";
|
|
var content = sec.SafeTryGetProperty("content", out var c) ? c.SafeGetString() ?? "" : "";
|
|
var level = sec.SafeTryGetProperty("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 format = ResolveDocumentFormat(requestedFormat, title, sections);
|
|
var mood = ResolveDocumentMood(requestedMood, title, sections);
|
|
|
|
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(
|
|
$"✅ 문서 조립 완료: {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("<!DOCTYPE html>");
|
|
sb.AppendLine("<html lang=\"ko\">");
|
|
sb.AppendLine("<head>");
|
|
sb.AppendLine("<meta charset=\"utf-8\">");
|
|
sb.AppendLine($"<title>{Escape(title)}</title>");
|
|
sb.AppendLine("<style>");
|
|
sb.AppendLine(style);
|
|
// 추가 조립용 스타일
|
|
sb.AppendLine(@"
|
|
.assembled-doc { max-width: 900px; margin: 0 auto; padding: 40px 30px 60px; }
|
|
.assembled-doc h1 { font-size: 28px; margin-bottom: 8px; }
|
|
.assembled-doc h2 { font-size: 22px; margin-top: 36px; margin-bottom: 12px; border-bottom: 2px solid var(--accent, #4B5EFC); padding-bottom: 6px; }
|
|
.assembled-doc h3 { font-size: 18px; margin-top: 24px; margin-bottom: 8px; }
|
|
.assembled-doc .section-content { line-height: 1.8; margin-bottom: 20px; }
|
|
.assembled-doc .toc { background: #f8f9fa; border-radius: 12px; padding: 20px 28px; margin: 24px 0 32px; }
|
|
.assembled-doc .toc h3 { margin-top: 0; }
|
|
.assembled-doc .toc a { color: inherit; text-decoration: none; }
|
|
.assembled-doc .toc a:hover { text-decoration: underline; }
|
|
.assembled-doc .toc ul { list-style: none; padding-left: 0; }
|
|
.assembled-doc .toc li { padding: 4px 0; }
|
|
.cover-page { text-align: center; padding: 120px 40px 80px; page-break-after: always; }
|
|
.cover-page h1 { font-size: 36px; margin-bottom: 16px; }
|
|
.cover-page .subtitle { font-size: 18px; color: #666; margin-bottom: 40px; }
|
|
.cover-page .date { font-size: 14px; color: #999; }
|
|
@media print { .cover-page { page-break-after: always; } .assembled-doc h2 { page-break-before: auto; } }
|
|
");
|
|
sb.AppendLine("</style>");
|
|
sb.AppendLine("</head>");
|
|
sb.AppendLine("<body>");
|
|
|
|
// 커버 페이지
|
|
if (!string.IsNullOrWhiteSpace(coverSubtitle))
|
|
{
|
|
sb.AppendLine("<div class=\"cover-page\">");
|
|
sb.AppendLine($"<h1>{Escape(title)}</h1>");
|
|
sb.AppendLine($"<div class=\"subtitle\">{Escape(coverSubtitle)}</div>");
|
|
sb.AppendLine($"<div class=\"date\">{DateTime.Now:yyyy년 MM월 dd일}</div>");
|
|
sb.AppendLine("</div>");
|
|
}
|
|
|
|
sb.AppendLine("<div class=\"assembled-doc\">");
|
|
|
|
if (string.IsNullOrWhiteSpace(coverSubtitle))
|
|
sb.AppendLine($"<h1>{Escape(title)}</h1>");
|
|
|
|
// TOC 생성
|
|
if (toc && sections.Count > 1)
|
|
{
|
|
sb.AppendLine("<div class=\"toc\">");
|
|
sb.AppendLine("<h3>📋 목차</h3>");
|
|
sb.AppendLine("<ul>");
|
|
for (int i = 0; i < sections.Count; i++)
|
|
{
|
|
var indent = sections[i].Level > 1 ? " style=\"padding-left:20px\"" : "";
|
|
sb.AppendLine($"<li{indent}><a href=\"#section-{i + 1}\">{Escape(sections[i].Heading)}</a></li>");
|
|
}
|
|
sb.AppendLine("</ul>");
|
|
sb.AppendLine("</div>");
|
|
}
|
|
|
|
// 섹션 본문
|
|
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)}</{tag}>");
|
|
sb.AppendLine($"<div class=\"section-content\">{content}</div>");
|
|
}
|
|
|
|
sb.AppendLine("</div>");
|
|
sb.AppendLine("</body>");
|
|
sb.AppendLine("</html>");
|
|
|
|
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();
|
|
|
|
// 기본 스타일 파트 추가 (styles.xml — 없으면 Word에서 글꼴/서식 깨짐)
|
|
var stylesPart = mainPart.AddNewPart<DocumentFormat.OpenXml.Packaging.StyleDefinitionsPart>();
|
|
stylesPart.Styles = CreateDefaultDocxStyles();
|
|
stylesPart.Styles.Save();
|
|
|
|
mainPart.Document = new DocumentFormat.OpenXml.Wordprocessing.Document();
|
|
var body = mainPart.Document.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Body());
|
|
|
|
// 한글 호환 글꼴 설정 헬퍼
|
|
static DocumentFormat.OpenXml.Wordprocessing.RunFonts KoreanFonts() => new()
|
|
{
|
|
Ascii = "맑은 고딕",
|
|
HighAnsi = "맑은 고딕",
|
|
EastAsia = "맑은 고딕",
|
|
ComplexScript = "맑은 고딕"
|
|
};
|
|
|
|
// 제목
|
|
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);
|
|
|
|
// 섹션 본문 (줄 단위 분할)
|
|
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.RunProperties
|
|
{
|
|
RunFonts = KoreanFonts(),
|
|
FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "22" }
|
|
});
|
|
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());
|
|
}
|
|
|
|
// ★ SectionProperties는 반드시 body의 마지막 자식이어야 함 (OOXML 규격)
|
|
// 첫 번째에 넣으면 Word가 무시하거나 문서가 깨짐
|
|
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>
|
|
private static DocumentFormat.OpenXml.Wordprocessing.Styles CreateDefaultDocxStyles()
|
|
{
|
|
var styles = new DocumentFormat.OpenXml.Wordprocessing.Styles();
|
|
|
|
// 문서 기본 글꼴 설정
|
|
var docDefaults = new DocumentFormat.OpenXml.Wordprocessing.DocDefaults(
|
|
new DocumentFormat.OpenXml.Wordprocessing.RunPropertiesDefault(
|
|
new DocumentFormat.OpenXml.Wordprocessing.RunPropertiesBaseStyle(
|
|
new DocumentFormat.OpenXml.Wordprocessing.RunFonts
|
|
{
|
|
Ascii = "맑은 고딕",
|
|
HighAnsi = "맑은 고딕",
|
|
EastAsia = "맑은 고딕",
|
|
ComplexScript = "맑은 고딕"
|
|
},
|
|
new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "22" },
|
|
new DocumentFormat.OpenXml.Wordprocessing.Languages { Val = "ko-KR", EastAsia = "ko-KR" }
|
|
)
|
|
),
|
|
new DocumentFormat.OpenXml.Wordprocessing.ParagraphPropertiesDefault(
|
|
new DocumentFormat.OpenXml.Wordprocessing.ParagraphPropertiesBaseStyle(
|
|
new DocumentFormat.OpenXml.Wordprocessing.SpacingBetweenLines { After = "160", Line = "259", LineRule = DocumentFormat.OpenXml.Wordprocessing.LineSpacingRuleValues.Auto }
|
|
)
|
|
)
|
|
);
|
|
styles.AppendChild(docDefaults);
|
|
|
|
// Normal 스타일
|
|
var normalStyle = new DocumentFormat.OpenXml.Wordprocessing.Style
|
|
{
|
|
Type = DocumentFormat.OpenXml.Wordprocessing.StyleValues.Paragraph,
|
|
StyleId = "Normal",
|
|
Default = true
|
|
};
|
|
normalStyle.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.StyleName { Val = "Normal" });
|
|
styles.AppendChild(normalStyle);
|
|
|
|
return styles;
|
|
}
|
|
|
|
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 string ResolveDocumentFormat(string? preferred, string title, List<(string Heading, string Content, int Level)> sections)
|
|
{
|
|
var normalized = (preferred ?? "auto").Trim().ToLowerInvariant();
|
|
if (normalized is "html" or "docx" or "markdown")
|
|
return normalized;
|
|
|
|
var intent = BuildIntentText(title, sections);
|
|
if (ContainsAny(intent, "docx", "word", "워드"))
|
|
return "docx";
|
|
if (ContainsAny(intent, "markdown", ".md", "마크다운", "readme", "가이드", "매뉴얼", "회의록"))
|
|
return "markdown";
|
|
if (ContainsAny(intent, "html", "웹 문서", "웹페이지", "대시보드"))
|
|
return "html";
|
|
if (ContainsAny(intent, "proposal", "제안", "제안서", "보고"))
|
|
return "docx";
|
|
if (ContainsAny(intent, "analysis", "분석", "지표", "통계"))
|
|
return "html";
|
|
|
|
return "docx";
|
|
}
|
|
|
|
private static string ResolveDocumentMood(string? preferred, string title, List<(string Heading, string Content, int Level)> sections)
|
|
{
|
|
var normalized = (preferred ?? "modern").Trim().ToLowerInvariant();
|
|
if (!string.IsNullOrWhiteSpace(normalized) && normalized != "modern")
|
|
return normalized;
|
|
|
|
var intent = BuildIntentText(title, sections);
|
|
if (ContainsAny(intent, "dashboard", "분석", "지표", "통계", "kpi"))
|
|
return "dashboard";
|
|
if (ContainsAny(intent, "기획", "아이디어", "브레인스토밍", "creative"))
|
|
return "creative";
|
|
if (ContainsAny(intent, "기업", "공식", "proposal", "제안서", "사내"))
|
|
return "corporate";
|
|
if (ContainsAny(intent, "가이드", "매뉴얼", "manual", "guide"))
|
|
return "minimal";
|
|
if (ContainsAny(intent, "회의록", "minutes"))
|
|
return "professional";
|
|
|
|
return "professional";
|
|
}
|
|
|
|
private static string BuildIntentText(string title, List<(string Heading, string Content, int Level)> sections)
|
|
{
|
|
var headingText = string.Join(" ", sections.Select(s => s.Heading));
|
|
return $"{title} {headingText}".ToLowerInvariant();
|
|
}
|
|
|
|
private static bool ContainsAny(string text, params string[] keywords)
|
|
=> keywords.Any(k => text.Contains(k, StringComparison.OrdinalIgnoreCase));
|
|
|
|
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<string> ValidateBasic(string html)
|
|
{
|
|
var issues = new List<string>();
|
|
if (html.Length < 500)
|
|
issues.Add("문서 내용이 매우 짧습니다 (500자 미만)");
|
|
|
|
// 빈 섹션 검사
|
|
var emptySectionPattern = new System.Text.RegularExpressions.Regex(
|
|
@"<h[23][^>]*>[^<]+</h[23]>\s*<div class=""section-content"">\s*</div>",
|
|
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(">", ">");
|
|
}
|
|
}
|