162 lines
7.1 KiB
C#
162 lines
7.1 KiB
C#
using System.IO;
|
|
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace AxCopilot.Services.Agent;
|
|
|
|
/// <summary>
|
|
/// 생성된 문서를 자동 검증하는 도구.
|
|
/// HTML/Markdown/텍스트 파일의 구조적 완성도, 날짜 정합성, 빈 섹션 등을 점검합니다.
|
|
/// </summary>
|
|
public class DocumentReviewTool : IAgentTool
|
|
{
|
|
public string Name => "document_review";
|
|
public string Description =>
|
|
"Review a generated document for quality issues. " +
|
|
"Checks: empty sections, placeholder text, date consistency, missing headings, broken HTML tags, " +
|
|
"content completeness. Returns a structured review report with issues found and suggestions.";
|
|
|
|
public ToolParameterSchema Parameters => new()
|
|
{
|
|
Properties = new()
|
|
{
|
|
["path"] = new() { Type = "string", Description = "Path to the document to review" },
|
|
["expected_sections"] = new()
|
|
{
|
|
Type = "array",
|
|
Description = "Optional list of expected section titles to verify presence",
|
|
Items = new() { Type = "string" },
|
|
},
|
|
},
|
|
Required = ["path"]
|
|
};
|
|
|
|
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
|
{
|
|
if (!args.TryGetProperty("path", out var pathEl))
|
|
return Task.FromResult(ToolResult.Fail("path가 필요합니다."));
|
|
var path = pathEl.GetString() ?? "";
|
|
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
|
|
|
if (!context.IsPathAllowed(fullPath))
|
|
return Task.FromResult(ToolResult.Fail($"경로 접근 차단: {fullPath}"));
|
|
if (!File.Exists(fullPath))
|
|
return Task.FromResult(ToolResult.Fail($"파일 없음: {fullPath}"));
|
|
|
|
var content = TextFileCodec.ReadAllText(fullPath).Text;
|
|
var ext = Path.GetExtension(fullPath).ToLowerInvariant();
|
|
var issues = new List<string>();
|
|
var stats = new List<string>();
|
|
|
|
// 기본 통계
|
|
var lineCount = content.Split('\n').Length;
|
|
var charCount = content.Length;
|
|
stats.Add($"파일: {Path.GetFileName(fullPath)} ({charCount:N0}자, {lineCount}줄)");
|
|
|
|
// 1. 빈 콘텐츠 검사
|
|
if (string.IsNullOrWhiteSpace(content))
|
|
{
|
|
issues.Add("[CRITICAL] 파일 내용이 비어있습니다");
|
|
return Task.FromResult(ToolResult.Ok(FormatReport(stats, issues, []), fullPath));
|
|
}
|
|
|
|
// 2. 플레이스홀더 텍스트 검사
|
|
var placeholders = new[] { "TODO", "TBD", "FIXME", "Lorem ipsum", "[여기에", "[INSERT", "placeholder", "예시 텍스트" };
|
|
foreach (var ph in placeholders)
|
|
{
|
|
if (content.Contains(ph, StringComparison.OrdinalIgnoreCase))
|
|
issues.Add($"[WARNING] 플레이스홀더 텍스트 발견: \"{ph}\"");
|
|
}
|
|
|
|
// 3. 날짜 정합성 (미래 날짜, 너무 오래된 날짜)
|
|
var datePattern = new Regex(@"\d{4}[-년.]\s*\d{1,2}[-월.]\s*\d{1,2}[일]?");
|
|
foreach (Match m in datePattern.Matches(content))
|
|
{
|
|
var cleaned = Regex.Replace(m.Value, @"[년월일\s]", "-").TrimEnd('-');
|
|
if (DateTime.TryParse(cleaned, out var dt))
|
|
{
|
|
if (dt > DateTime.Now.AddDays(365))
|
|
issues.Add($"[WARNING] 미래 날짜 감지: {m.Value}");
|
|
else if (dt < DateTime.Now.AddYears(-50))
|
|
issues.Add($"[INFO] 매우 오래된 날짜: {m.Value}");
|
|
}
|
|
}
|
|
|
|
// 4. HTML 전용 검사
|
|
if (ext is ".html" or ".htm")
|
|
{
|
|
// 빈 섹션 (h2/h3 뒤에 내용 없이 바로 다음 h2/h3)
|
|
var emptySection = Regex.Matches(content, @"<h[23][^>]*>.*?</h[23]>\s*<h[23]");
|
|
if (emptySection.Count > 0)
|
|
issues.Add($"[WARNING] 빈 섹션 {emptySection.Count}개 감지 (헤딩 뒤 내용 없음)");
|
|
|
|
// 닫히지 않은 태그
|
|
var openTags = Regex.Matches(content, @"<(table|div|section|article)\b[^/]*>").Count;
|
|
var closeTags = Regex.Matches(content, @"</(table|div|section|article)>").Count;
|
|
if (openTags != closeTags)
|
|
issues.Add($"[WARNING] HTML 태그 불균형: 열림 {openTags}개, 닫힘 {closeTags}개");
|
|
|
|
// 이미지 alt 텍스트 누락
|
|
var imgNoAlt = Regex.Matches(content, @"<img\b(?![^>]*\balt\s*=)[^>]*>");
|
|
if (imgNoAlt.Count > 0)
|
|
issues.Add($"[INFO] alt 속성 없는 이미지 {imgNoAlt.Count}개");
|
|
|
|
// 제목 태그 수
|
|
var h1Count = Regex.Matches(content, @"<h1\b").Count;
|
|
var h2Count = Regex.Matches(content, @"<h2\b").Count;
|
|
stats.Add($"구조: h1={h1Count}, h2={h2Count}개 섹션");
|
|
}
|
|
|
|
// 5. 기대 섹션 검사
|
|
if (args.TryGetProperty("expected_sections", out var sections) && sections.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (var sec in sections.EnumerateArray())
|
|
{
|
|
var title = sec.GetString() ?? "";
|
|
if (!string.IsNullOrEmpty(title) && !content.Contains(title, StringComparison.OrdinalIgnoreCase))
|
|
issues.Add($"[MISSING] 기대 섹션 누락: \"{title}\"");
|
|
}
|
|
}
|
|
|
|
// 6. 반복 텍스트 검사 (같은 문장 3회 이상 반복)
|
|
var sentences = Regex.Split(content, @"[.!?。]\s+")
|
|
.Where(s => s.Length > 20)
|
|
.GroupBy(s => s.Trim())
|
|
.Where(g => g.Count() >= 3);
|
|
foreach (var dup in sentences.Take(3))
|
|
issues.Add($"[WARNING] 반복 텍스트 ({dup.Count()}회): \"{dup.Key[..Math.Min(50, dup.Key.Length)]}...\"");
|
|
|
|
var suggestions = new List<string>();
|
|
if (issues.Count == 0)
|
|
suggestions.Add("문서 검증 통과 — 구조적 이슈가 발견되지 않았습니다.");
|
|
else
|
|
{
|
|
suggestions.Add($"총 {issues.Count}개 이슈 발견. 수정 후 다시 검증하세요.");
|
|
if (issues.Any(i => i.Contains("플레이스홀더")))
|
|
suggestions.Add("플레이스홀더를 실제 내용으로 교체하세요.");
|
|
if (issues.Any(i => i.Contains("빈 섹션")))
|
|
suggestions.Add("빈 섹션에 내용을 추가하거나 불필요한 헤딩을 제거하세요.");
|
|
}
|
|
|
|
return Task.FromResult(ToolResult.Ok(FormatReport(stats, issues, suggestions), fullPath));
|
|
}
|
|
|
|
private static string FormatReport(List<string> stats, List<string> issues, List<string> suggestions)
|
|
{
|
|
var sb = new System.Text.StringBuilder();
|
|
sb.AppendLine("=== 문서 검증 보고서 ===\n");
|
|
foreach (var s in stats) sb.AppendLine($"📊 {s}");
|
|
sb.AppendLine();
|
|
if (issues.Count == 0)
|
|
sb.AppendLine("✅ 이슈 없음 — 문서가 정상입니다.");
|
|
else
|
|
{
|
|
sb.AppendLine($"⚠ 발견된 이슈 ({issues.Count}건):");
|
|
foreach (var i in issues) sb.AppendLine($" {i}");
|
|
}
|
|
sb.AppendLine();
|
|
foreach (var s in suggestions) sb.AppendLine($"💡 {s}");
|
|
return sb.ToString();
|
|
}
|
|
}
|