Initial commit to new repository
This commit is contained in:
161
src/AxCopilot/Services/Agent/DocumentReviewTool.cs
Normal file
161
src/AxCopilot/Services/Agent/DocumentReviewTool.cs
Normal file
@@ -0,0 +1,161 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user