Spaces:
Running on Zero
Running on Zero
| """ | |
| Frox AI — File Analysis Tool | |
| Extracts text (and light structure) from common document formats. | |
| Each format's parser is optional-imported so the tool degrades to a | |
| clear error for a specific format rather than failing to import at | |
| all if one optional dependency is missing. | |
| """ | |
| from __future__ import annotations | |
| from pathlib import Path | |
| from typing import Optional | |
| from tools.registry import tool, ToolContext | |
| MAX_CONTENT_CHARS = 20000 # keep tool results from blowing the model's context budget | |
| class UnsupportedFileType(ValueError): | |
| pass | |
| def _parse_txt(path: Path) -> str: | |
| return path.read_text(encoding="utf-8", errors="replace") | |
| def _parse_pdf(path: Path) -> str: | |
| try: | |
| import pdfplumber | |
| except ImportError: | |
| raise RuntimeError("pdfplumber is required for PDF parsing — pip install pdfplumber") | |
| pages = [] | |
| with pdfplumber.open(str(path)) as pdf: | |
| for i, page in enumerate(pdf.pages): | |
| text = page.extract_text() or "" | |
| if text.strip(): | |
| pages.append(f"[Page {i + 1}]\n{text}") | |
| return "\n\n".join(pages) | |
| def _parse_docx(path: Path) -> str: | |
| try: | |
| import docx | |
| except ImportError: | |
| raise RuntimeError("python-docx is required for DOCX parsing — pip install python-docx") | |
| doc = docx.Document(str(path)) | |
| parts = [p.text for p in doc.paragraphs if p.text.strip()] | |
| for table in doc.tables: | |
| for row in table.rows: | |
| parts.append(" | ".join(cell.text for cell in row.cells)) | |
| return "\n".join(parts) | |
| def _parse_csv(path: Path, max_rows: int = 200) -> str: | |
| import csv | |
| with open(path, newline="", encoding="utf-8", errors="replace") as f: | |
| reader = csv.reader(f) | |
| rows = list(reader) | |
| header = rows[0] if rows else [] | |
| body = rows[1:max_rows + 1] | |
| lines = [", ".join(header)] | |
| lines += [", ".join(row) for row in body] | |
| truncated_note = f"\n[... {len(rows) - 1 - len(body)} more rows not shown]" if len(rows) - 1 > len(body) else "" | |
| return "\n".join(lines) + truncated_note | |
| def _parse_xlsx(path: Path, max_rows: int = 200) -> str: | |
| try: | |
| import openpyxl | |
| except ImportError: | |
| raise RuntimeError("openpyxl is required for XLSX parsing — pip install openpyxl") | |
| wb = openpyxl.load_workbook(str(path), data_only=True, read_only=True) | |
| sheets_out = [] | |
| for sheet in wb.worksheets: | |
| lines = [f"[Sheet: {sheet.title}]"] | |
| for i, row in enumerate(sheet.iter_rows(values_only=True)): | |
| if i >= max_rows: | |
| lines.append(f"[... more rows not shown]") | |
| break | |
| lines.append(", ".join(str(c) if c is not None else "" for c in row)) | |
| sheets_out.append("\n".join(lines)) | |
| return "\n\n".join(sheets_out) | |
| def _parse_pptx(path: Path) -> str: | |
| try: | |
| from pptx import Presentation | |
| except ImportError: | |
| raise RuntimeError("python-pptx is required for PPTX parsing — pip install python-pptx") | |
| prs = Presentation(str(path)) | |
| slides_out = [] | |
| for i, slide in enumerate(prs.slides): | |
| lines = [f"[Slide {i + 1}]"] | |
| for shape in slide.shapes: | |
| if shape.has_text_frame and shape.text_frame.text.strip(): | |
| lines.append(shape.text_frame.text) | |
| if shape.has_notes_frame if hasattr(shape, "has_notes_frame") else False: | |
| lines.append(f"(notes: {shape.notes_frame.text})") | |
| slides_out.append("\n".join(lines)) | |
| return "\n\n".join(slides_out) | |
| def _parse_md(path: Path) -> str: | |
| return path.read_text(encoding="utf-8", errors="replace") | |
| _PARSERS = { | |
| ".txt": _parse_txt, ".md": _parse_md, | |
| ".pdf": _parse_pdf, ".docx": _parse_docx, | |
| ".csv": _parse_csv, ".xlsx": _parse_xlsx, ".xls": _parse_xlsx, | |
| ".pptx": _parse_pptx, | |
| } | |
| def parse_file(path: str) -> dict: | |
| """Parse a file into extracted text, dispatching by extension.""" | |
| p = Path(path) | |
| if not p.exists(): | |
| raise FileNotFoundError(f"File not found: {path}") | |
| ext = p.suffix.lower() | |
| parser = _PARSERS.get(ext) | |
| if parser is None: | |
| raise UnsupportedFileType( | |
| f"Unsupported file type: {ext!r}. Supported: {', '.join(_PARSERS)}" | |
| ) | |
| text = parser(p) | |
| truncated = len(text) > MAX_CONTENT_CHARS | |
| return { | |
| "filename": p.name, | |
| "extension": ext, | |
| "text": text[:MAX_CONTENT_CHARS], | |
| "truncated": truncated, | |
| "char_count": len(text), | |
| } | |
| def file_analysis(ctx: ToolContext, file_path: str) -> dict: | |
| """ | |
| Args: | |
| file_path: Path to the uploaded file on disk. | |
| Plain `def`, not `async def`: file I/O and the parsing libraries | |
| here (pdfplumber, python-docx, openpyxl) are all blocking, so this | |
| is thread-offloaded by the registry. | |
| """ | |
| return parse_file(file_path) | |