using System.IO; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Spreadsheet; using UglyToad.PdfPig; namespace AxCopilot.Services.Agent; /// /// 문서 파일을 읽어 텍스트로 반환하는 도구. /// PDF, DOCX, XLSX, CSV, TXT, BibTeX, RIS 등 다양한 형식을 지원합니다. /// public class DocumentReaderTool : IAgentTool { public string Name => "document_read"; public string Description => "Read a document file and extract its text content. " + "Supports: PDF (.pdf), Word (.docx), Excel (.xlsx), CSV (.csv), text (.txt/.log/.json/.xml/.md), " + "BibTeX (.bib), RIS (.ris). " + "For large files, use 'offset' to read from a specific character position (chunked reading). " + "For large PDFs, use 'pages' parameter to read specific page ranges (e.g., '1-5', '10-20'). " + "Use 'section' parameter with value 'references' to extract only the references/bibliography section from a PDF."; public ToolParameterSchema Parameters => new() { Properties = new() { ["path"] = new() { Type = "string", Description = "Document file path (absolute or relative to work folder)" }, ["max_chars"] = new() { Type = "integer", Description = "Maximum characters to extract per chunk. Default: 8000. Use smaller values for summaries." }, ["offset"] = new() { Type = "integer", Description = "Character offset to start reading from. Default: 0. Use this to read the next chunk of a large file (value from 'next_offset' in previous response)." }, ["sheet"] = new() { Type = "string", Description = "For Excel files: sheet name or 1-based index. Default: first sheet." }, ["pages"] = new() { Type = "string", Description = "For PDF files: page range to read (e.g., '1-5', '3', '10-20'). Default: all pages." }, ["section"] = new() { Type = "string", Description = "Extract specific section. 'references' = extract references/bibliography from PDF. 'abstract' = extract abstract." }, }, Required = ["path"] }; private static readonly HashSet TextExtensions = new(StringComparer.OrdinalIgnoreCase) { ".txt", ".log", ".json", ".xml", ".md", ".csv", ".tsv", ".yaml", ".yml", ".ini", ".cfg", ".conf", ".properties", ".html", ".htm", ".css", ".js", ".ts", ".py", ".cs", ".java", ".sql", ".sh", ".bat", ".ps1", ".r", ".m", }; private const int DefaultMaxChars = 8000; public async Task ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct) { if (!args.TryGetProperty("path", out var pathEl)) return ToolResult.Fail("path가 필요합니다."); var path = pathEl.GetString() ?? ""; var maxChars = args.TryGetProperty("max_chars", out var mc) ? GetIntValue(mc, DefaultMaxChars) : DefaultMaxChars; var offset = args.TryGetProperty("offset", out var off) ? GetIntValue(off, 0) : 0; var sheetParam = args.TryGetProperty("sheet", out var sh) ? sh.GetString() ?? "" : ""; var pagesParam = args.TryGetProperty("pages", out var pg) ? pg.GetString() ?? "" : ""; var sectionParam = args.TryGetProperty("section", out var sec) ? sec.GetString() ?? "" : ""; if (maxChars < 100) maxChars = DefaultMaxChars; if (offset < 0) offset = 0; var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder); if (!context.IsPathAllowed(fullPath)) return ToolResult.Fail($"경로 접근 차단: {fullPath}"); if (!File.Exists(fullPath)) return ToolResult.Fail($"파일이 존재하지 않습니다: {fullPath}"); var ext = Path.GetExtension(fullPath).ToLowerInvariant(); try { // 전체 텍스트 추출 (offset > 0이면 전체를 추출해서 잘라야 함) var extractMax = offset > 0 ? offset + maxChars + 100 : maxChars; var text = ext switch { ".pdf" => await Task.Run(() => ReadPdf(fullPath, extractMax, pagesParam, sectionParam), ct), ".docx" => await Task.Run(() => ReadDocx(fullPath, extractMax), ct), ".xlsx" => await Task.Run(() => ReadXlsx(fullPath, sheetParam, extractMax), ct), ".bib" => await Task.Run(() => ReadBibTeX(fullPath, extractMax), ct), ".ris" => await Task.Run(() => ReadRis(fullPath, extractMax), ct), _ when TextExtensions.Contains(ext) => await ReadTextFile(fullPath, extractMax, ct), _ => null, }; if (text == null) return ToolResult.Fail($"지원하지 않는 파일 형식: {ext}"); var totalExtracted = text.Length; // offset 적용 — 청크 분할 읽기 if (offset > 0) { if (offset >= text.Length) return ToolResult.Ok($"[{Path.GetFileName(fullPath)}] offset {offset}은 문서 끝을 초과합니다 (전체 {text.Length}자).", fullPath); text = text[offset..]; } // maxChars 자르기 var hasMore = text.Length > maxChars; if (hasMore) text = text[..maxChars]; var fileInfo = new FileInfo(fullPath); var header = $"[{Path.GetFileName(fullPath)}] ({ext.TrimStart('.')}, {FormatSize(fileInfo.Length)})"; if (offset > 0) header += $" — offset {offset}부터 {maxChars}자 읽음"; if (hasMore) { var nextOffset = offset + maxChars; header += $"\n⚡ 추가 내용이 있습니다. 다음 청크를 읽으려면 offset={nextOffset}을 사용하세요."; } else if (offset > 0) { header += " — 문서 끝까지 읽음 ✓"; } else if (totalExtracted >= maxChars) { header += $" — 처음 {maxChars}자만 추출됨. 계속 읽으려면 offset={maxChars}을 사용하거나, pages 파라미터로 특정 페이지를 지정하세요."; } return ToolResult.Ok($"{header}\n\n{text}", fullPath); } catch (Exception ex) { return ToolResult.Fail($"문서 읽기 실패: {ex.Message}"); } } // ─── PDF ──────────────────────────────────────────────────────────────── private static string ReadPdf(string path, int maxChars, string pagesParam, string sectionParam) { var sb = new StringBuilder(); using var doc = PdfDocument.Open(path); var totalPages = doc.NumberOfPages; sb.AppendLine($"PDF: {totalPages}페이지"); sb.AppendLine(); // 페이지 범위 파싱 var (startPage, endPage) = ParsePageRange(pagesParam, totalPages); // 섹션 추출 모드 if (string.Equals(sectionParam, "references", StringComparison.OrdinalIgnoreCase)) return ExtractReferences(doc, totalPages, maxChars); if (string.Equals(sectionParam, "abstract", StringComparison.OrdinalIgnoreCase)) return ExtractAbstract(doc, totalPages, maxChars); sb.AppendLine($"읽는 범위: {startPage}-{endPage} / {totalPages} 페이지"); sb.AppendLine(); for (int i = startPage; i <= endPage && sb.Length < maxChars; i++) { var page = doc.GetPage(i); var pageText = page.Text; if (!string.IsNullOrWhiteSpace(pageText)) { sb.AppendLine($"--- Page {i} ---"); sb.AppendLine(pageText.Trim()); sb.AppendLine(); } } return Truncate(sb.ToString(), maxChars); } private static (int start, int end) ParsePageRange(string pagesParam, int totalPages) { if (string.IsNullOrWhiteSpace(pagesParam)) return (1, totalPages); // "5" → page 5 only if (int.TryParse(pagesParam.Trim(), out var single)) return (Math.Max(1, single), Math.Min(single, totalPages)); // "3-10" → pages 3 to 10 var parts = pagesParam.Split('-', StringSplitOptions.TrimEntries); if (parts.Length == 2 && int.TryParse(parts[0], out var s) && int.TryParse(parts[1], out var e)) { return (Math.Max(1, s), Math.Min(e, totalPages)); } return (1, totalPages); } /// PDF에서 References/Bibliography 섹션을 추출합니다. private static string ExtractReferences(PdfDocument doc, int totalPages, int maxChars) { var sb = new StringBuilder(); sb.AppendLine("=== References / Bibliography ==="); sb.AppendLine(); // 뒤에서부터 References 섹션 시작점을 찾습니다 var refPatterns = new[] { @"(?i)^\s*(References|Bibliography|Works\s+Cited|Literature\s+Cited|참고\s*문헌|참조|인용\s*문헌)\s*$", @"(?i)^(References|Bibliography|참고문헌)\s*\n", }; bool found = false; for (int i = totalPages; i >= Math.Max(1, totalPages - 10) && !found; i--) { var pageText = doc.GetPage(i).Text; if (string.IsNullOrWhiteSpace(pageText)) continue; foreach (var pattern in refPatterns) { var match = Regex.Match(pageText, pattern, RegexOptions.Multiline); if (match.Success) { // References 시작 지점부터 끝까지 추출 var refStart = match.Index; sb.AppendLine($"(Page {i}부터 시작)"); sb.AppendLine(pageText[refStart..].Trim()); // 이후 페이지도 포함 for (int j = i + 1; j <= totalPages && sb.Length < maxChars; j++) { var nextText = doc.GetPage(j).Text; if (!string.IsNullOrWhiteSpace(nextText)) sb.AppendLine(nextText.Trim()); } found = true; break; } } } if (!found) { // References 헤더를 못 찾으면 마지막 3페이지를 반환 sb.AppendLine("(References 섹션 헤더를 찾지 못했습니다. 마지막 3페이지를 반환합니다.)"); sb.AppendLine(); for (int i = Math.Max(1, totalPages - 2); i <= totalPages && sb.Length < maxChars; i++) { var pageText = doc.GetPage(i).Text; if (!string.IsNullOrWhiteSpace(pageText)) { sb.AppendLine($"--- Page {i} ---"); sb.AppendLine(pageText.Trim()); sb.AppendLine(); } } } // 개별 참고문헌 항목 파싱 시도 var rawRefs = sb.ToString(); var parsed = ParseReferenceEntries(rawRefs); if (parsed.Count > 0) { var result = new StringBuilder(); result.AppendLine($"=== References ({parsed.Count}개 항목) ===\n"); for (int i = 0; i < parsed.Count; i++) { result.AppendLine($"[{i + 1}] {parsed[i]}"); } return Truncate(result.ToString(), maxChars); } return Truncate(rawRefs, maxChars); } /// PDF에서 Abstract 섹션을 추출합니다. private static string ExtractAbstract(PdfDocument doc, int totalPages, int maxChars) { var sb = new StringBuilder(); sb.AppendLine("=== Abstract ==="); sb.AppendLine(); // 첫 3페이지에서 Abstract 찾기 for (int i = 1; i <= Math.Min(3, totalPages); i++) { var pageText = doc.GetPage(i).Text; if (string.IsNullOrWhiteSpace(pageText)) continue; var match = Regex.Match(pageText, @"(?i)(Abstract|초록|요약)\s*\n(.*?)(?=\n\s*(Keywords|Introduction|1\.|서론|키워드|핵심어)\s*[\n:])", RegexOptions.Singleline); if (match.Success) { sb.AppendLine(match.Groups[2].Value.Trim()); return Truncate(sb.ToString(), maxChars); } } // 찾지 못하면 첫 페이지 반환 sb.AppendLine("(Abstract 섹션을 찾지 못했습니다. 첫 페이지를 반환합니다.)"); var firstPage = doc.GetPage(1).Text; if (!string.IsNullOrWhiteSpace(firstPage)) sb.AppendLine(firstPage.Trim()); return Truncate(sb.ToString(), maxChars); } /// 참고문헌 텍스트에서 개별 항목을 파싱합니다. private static List ParseReferenceEntries(string text) { var entries = new List(); // [1], [2] 형태의 번호 매기기 var numbered = Regex.Split(text, @"\n\s*\[(\d+)\]\s*"); if (numbered.Length > 3) { for (int i = 2; i < numbered.Length; i += 2) { var entry = numbered[i].Trim().Replace("\n", " ").Replace(" ", " "); if (entry.Length > 10) entries.Add(entry); } return entries; } // 1. 2. 3. 형태 var dotNumbered = Regex.Split(text, @"\n\s*(\d+)\.\s+"); if (dotNumbered.Length > 5) { for (int i = 2; i < dotNumbered.Length; i += 2) { var entry = dotNumbered[i].Trim().Replace("\n", " ").Replace(" ", " "); if (entry.Length > 10) entries.Add(entry); } return entries; } return entries; } // ─── BibTeX ───────────────────────────────────────────────────────────── private static string ReadBibTeX(string path, int maxChars) { var content = TextFileCodec.ReadAllText(path).Text; var sb = new StringBuilder(); var entryPattern = new Regex( @"@(\w+)\s*\{\s*([^,\s]+)\s*,\s*(.*?)\n\s*\}", RegexOptions.Singleline); var fieldPattern = new Regex( @"(\w+)\s*=\s*[\{""](.*?)[\}""]", RegexOptions.Singleline); var matches = entryPattern.Matches(content); sb.AppendLine($"BibTeX: {matches.Count}개 항목"); sb.AppendLine(); int idx = 0; foreach (Match m in matches) { if (sb.Length >= maxChars) break; idx++; var entryType = m.Groups[1].Value; var citeKey = m.Groups[2].Value; var body = m.Groups[3].Value; sb.AppendLine($"[{idx}] @{entryType}{{{citeKey}}}"); var fields = fieldPattern.Matches(body); foreach (Match f in fields) { var fieldName = f.Groups[1].Value.ToLower(); var fieldValue = f.Groups[2].Value.Trim(); // 핵심 필드만 표시 if (fieldName is "author" or "title" or "journal" or "booktitle" or "year" or "volume" or "number" or "pages" or "doi" or "publisher" or "url") { sb.AppendLine($" {fieldName}: {fieldValue}"); } } sb.AppendLine(); } if (matches.Count == 0) { sb.AppendLine("(BibTeX 항목을 파싱하지 못했습니다. 원문을 반환합니다.)"); sb.AppendLine(Truncate(content, maxChars - sb.Length)); } return Truncate(sb.ToString(), maxChars); } // ─── RIS ──────────────────────────────────────────────────────────────── private static string ReadRis(string path, int maxChars) { var lines = TextFileCodec.SplitLines(TextFileCodec.ReadAllText(path).Text); var sb = new StringBuilder(); var entries = new List>>(); Dictionary>? current = null; foreach (var line in lines) { if (line.StartsWith("TY -")) { current = new Dictionary>(); entries.Add(current); } else if (line.StartsWith("ER -")) { current = null; continue; } if (current != null && line.Length >= 6 && line[2] == ' ' && line[3] == ' ' && line[4] == '-' && line[5] == ' ') { var tag = line[..2].Trim(); var value = line[6..].Trim(); if (!current.ContainsKey(tag)) current[tag] = new List(); current[tag].Add(value); } } sb.AppendLine($"RIS: {entries.Count}개 항목"); sb.AppendLine(); // RIS 태그 → 사람이 읽을 수 있는 이름 var tagNames = new Dictionary { ["TY"] = "Type", ["AU"] = "Author", ["TI"] = "Title", ["T1"] = "Title", ["JO"] = "Journal", ["JF"] = "Journal", ["PY"] = "Year", ["Y1"] = "Year", ["VL"] = "Volume", ["IS"] = "Issue", ["SP"] = "Start Page", ["EP"] = "End Page", ["DO"] = "DOI", ["UR"] = "URL", ["PB"] = "Publisher", ["AB"] = "Abstract", ["KW"] = "Keyword", ["SN"] = "ISSN/ISBN", }; for (int i = 0; i < entries.Count && sb.Length < maxChars; i++) { sb.AppendLine($"[{i + 1}]"); var entry = entries[i]; foreach (var (tag, values) in entry) { var label = tagNames.GetValueOrDefault(tag, tag); if (tag is "AU" or "KW") sb.AppendLine($" {label}: {string.Join("; ", values)}"); else sb.AppendLine($" {label}: {string.Join(" ", values)}"); } sb.AppendLine(); } return Truncate(sb.ToString(), maxChars); } // ─── DOCX ─────────────────────────────────────────────────────────────── private static string ReadDocx(string path, int maxChars) { var sb = new StringBuilder(); using var doc = WordprocessingDocument.Open(path, false); var body = doc.MainDocumentPart?.Document.Body; if (body == null) return "(빈 문서)"; foreach (var para in body.Elements()) { var text = para.InnerText; if (!string.IsNullOrWhiteSpace(text)) { sb.AppendLine(text); if (sb.Length >= maxChars) break; } } return Truncate(sb.ToString(), maxChars); } // ─── XLSX ─────────────────────────────────────────────────────────────── private static string ReadXlsx(string path, string sheetParam, int maxChars) { var sb = new StringBuilder(); using var doc = SpreadsheetDocument.Open(path, false); var workbook = doc.WorkbookPart; if (workbook == null) return "(빈 스프레드시트)"; var sheets = workbook.Workbook.Sheets?.Elements().ToList() ?? []; if (sheets.Count == 0) return "(시트 없음)"; sb.AppendLine($"Excel: {sheets.Count}개 시트 ({string.Join(", ", sheets.Select(s => s.Name?.Value))})"); sb.AppendLine(); Sheet? targetSheet = null; if (!string.IsNullOrEmpty(sheetParam)) { if (int.TryParse(sheetParam, out var idx) && idx >= 1 && idx <= sheets.Count) targetSheet = sheets[idx - 1]; else targetSheet = sheets.FirstOrDefault(s => string.Equals(s.Name?.Value, sheetParam, StringComparison.OrdinalIgnoreCase)); } targetSheet ??= sheets[0]; var sheetId = targetSheet.Id?.Value; if (sheetId == null) return "(시트 ID 없음)"; var wsPart = (WorksheetPart)workbook.GetPartById(sheetId); var sharedStrings = workbook.SharedStringTablePart?.SharedStringTable .Elements().ToList() ?? []; var rows = wsPart.Worksheet.Descendants().ToList(); sb.AppendLine($"[{targetSheet.Name?.Value}] ({rows.Count} rows)"); foreach (var row in rows) { var cells = row.Elements().ToList(); var values = new List(); foreach (var cell in cells) values.Add(GetCellValue(cell, sharedStrings)); sb.AppendLine(string.Join("\t", values)); if (sb.Length >= maxChars) break; } return Truncate(sb.ToString(), maxChars); } private static string GetCellValue(Cell cell, List sharedStrings) { var value = cell.CellValue?.Text ?? ""; if (cell.DataType?.Value == CellValues.SharedString) { if (int.TryParse(value, out var idx) && idx >= 0 && idx < sharedStrings.Count) return sharedStrings[idx].InnerText; } return value; } // ─── Text ─────────────────────────────────────────────────────────────── private static async Task ReadTextFile(string path, int maxChars, CancellationToken ct) { var text = (await TextFileCodec.ReadAllTextAsync(path, ct)).Text; return Truncate(text, maxChars); } // ─── Helpers ──────────────────────────────────────────────────────────── private static string Truncate(string text, int maxChars) { if (text.Length <= maxChars) return text; return text[..maxChars] + "\n\n... (내용 잘림 — pages 또는 section 파라미터로 특정 부분을 읽을 수 있습니다)"; } private static string FormatSize(long bytes) => bytes switch { < 1024 => $"{bytes} B", < 1024 * 1024 => $"{bytes / 1024.0:F1} KB", _ => $"{bytes / (1024.0 * 1024.0):F1} MB", }; /// JsonElement에서 int를 안전하게 추출합니다. string/integer 양쪽 호환. private static int GetIntValue(JsonElement el, int defaultValue) { if (el.ValueKind == JsonValueKind.Number) return el.GetInt32(); if (el.ValueKind == JsonValueKind.String && int.TryParse(el.GetString(), out var v)) return v; return defaultValue; } }