Initial commit to new repository

This commit is contained in:
2026-04-03 18:22:19 +09:00
commit 4458bb0f52
7672 changed files with 452440 additions and 0 deletions

View 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();
}
}