e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
639 lines
22 KiB
Python
639 lines
22 KiB
Python
"""Document text extraction for chat attachments.
|
|
|
|
Bytes-in, text-out. Used by the chat turn runtime to inline the text of
|
|
user-dropped files into the ``effective_user_message`` sent to the LLM.
|
|
|
|
Two format families:
|
|
* **Binary Office** (.pdf / .docx / .xlsx / .pptx) — parsed with pymupdf /
|
|
python-docx / openpyxl / python-pptx.
|
|
* **Text-like** (plain text, Markdown, source code, JSON, XML, CSV, …) —
|
|
the extension set is imported from ``FileTypeRouter.TEXT_EXTENSIONS`` so
|
|
the chat composer accepts every format the knowledge-base pipeline
|
|
already ingests. Decoded with the same multi-encoding fallback chain.
|
|
|
|
Design mirrors ``nanobot/nanobot/utils/document.py`` but works on bytes
|
|
instead of file paths so the server never touches disk.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
from collections.abc import Iterable
|
|
import io
|
|
import logging
|
|
from pathlib import Path, PurePosixPath
|
|
import re
|
|
from typing import Any
|
|
import zipfile
|
|
|
|
from defusedxml import ElementTree as DefusedElementTree
|
|
from defusedxml.common import DefusedXmlException
|
|
|
|
from deeptutor.services.rag.file_routing import FileTypeRouter
|
|
|
|
try:
|
|
import fitz # pymupdf
|
|
except ImportError: # pragma: no cover
|
|
fitz = None # type: ignore[assignment]
|
|
|
|
try:
|
|
from pypdf import PdfReader
|
|
from pypdf.errors import FileNotDecryptedError as _PypdfNotDecryptedError
|
|
except ImportError: # pragma: no cover
|
|
PdfReader = None # type: ignore[assignment]
|
|
_PypdfNotDecryptedError = Exception # type: ignore[assignment,misc]
|
|
|
|
try:
|
|
from docx import Document as DocxDocument
|
|
except ImportError: # pragma: no cover
|
|
DocxDocument = None # type: ignore[assignment]
|
|
|
|
try:
|
|
from openpyxl import load_workbook
|
|
except ImportError: # pragma: no cover
|
|
load_workbook = None # type: ignore[assignment]
|
|
|
|
try:
|
|
from pptx import Presentation as PptxPresentation
|
|
except ImportError: # pragma: no cover
|
|
PptxPresentation = None # type: ignore[assignment]
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
_OFFICE_EXTENSIONS: frozenset[str] = frozenset(FileTypeRouter.PARSER_EXTENSIONS)
|
|
# Text-like formats are sourced from the KB file router so chat and KB stay
|
|
# in sync. Adding a new code / config extension in one place propagates here.
|
|
TEXT_LIKE_EXTENSIONS: frozenset[str] = frozenset(FileTypeRouter.TEXT_EXTENSIONS)
|
|
SUPPORTED_DOC_EXTENSIONS: frozenset[str] = _OFFICE_EXTENSIONS | TEXT_LIKE_EXTENSIONS
|
|
|
|
MAX_DOC_BYTES = 10 * 1024 * 1024
|
|
MAX_TOTAL_DOC_BYTES = 25 * 1024 * 1024
|
|
MAX_EXTRACTED_CHARS_PER_DOC = 200_000
|
|
MAX_EXTRACTED_CHARS_TOTAL = 150_000
|
|
|
|
_PDF_MAGIC = b"%PDF-"
|
|
_OOXML_MAGIC = b"PK\x03\x04"
|
|
|
|
|
|
class DocumentExtractionError(Exception):
|
|
"""Base class for extraction failures. ``str(exc)`` is user-friendly."""
|
|
|
|
def __init__(self, message: str, filename: str = "") -> None:
|
|
super().__init__(message)
|
|
self.filename = filename
|
|
|
|
|
|
class UnsupportedDocumentError(DocumentExtractionError):
|
|
pass
|
|
|
|
|
|
class CorruptDocumentError(DocumentExtractionError):
|
|
pass
|
|
|
|
|
|
class EmptyDocumentError(DocumentExtractionError):
|
|
pass
|
|
|
|
|
|
class DocumentTooLargeError(DocumentExtractionError):
|
|
pass
|
|
|
|
|
|
def is_document_extension(filename: str) -> bool:
|
|
return _ext(filename) in SUPPORTED_DOC_EXTENSIONS
|
|
|
|
|
|
def _ext(filename: str) -> str:
|
|
return PurePosixPath(filename or "").suffix.lower()
|
|
|
|
|
|
def _truncate(text: str, max_length: int) -> str:
|
|
if len(text) <= max_length:
|
|
return text
|
|
return text[:max_length] + f"... (truncated, {len(text)} chars total)"
|
|
|
|
|
|
def _check_magic(ext: str, data: bytes, filename: str) -> None:
|
|
"""Validate file header to catch extension spoofing.
|
|
|
|
Only binary formats have well-known magic prefixes. Text-like extensions
|
|
(code, markup, config, …) are decoded directly; a mislabeled binary blob
|
|
either decodes as garbage or fails at decode time, which is fine.
|
|
"""
|
|
if ext == ".pdf":
|
|
if not data.startswith(_PDF_MAGIC):
|
|
raise CorruptDocumentError(
|
|
f"{filename} does not look like a PDF (bad header)", filename=filename
|
|
)
|
|
elif ext in {".docx", ".xlsx", ".pptx"}:
|
|
if not data.startswith(_OOXML_MAGIC):
|
|
raise CorruptDocumentError(
|
|
f"{filename} does not look like a valid Office file (bad header)",
|
|
filename=filename,
|
|
)
|
|
|
|
|
|
def extract_text_from_bytes(
|
|
filename: str,
|
|
data: bytes,
|
|
*,
|
|
max_bytes: int | None = MAX_DOC_BYTES,
|
|
max_chars: int | None = MAX_EXTRACTED_CHARS_PER_DOC,
|
|
) -> str:
|
|
"""Extract text from a single document's raw bytes.
|
|
|
|
Raises a ``DocumentExtractionError`` subclass on failure. Successful
|
|
output is truncated to ``max_chars`` with a notice when ``max_chars`` is
|
|
not ``None``. ``max_bytes`` is configurable so the KB indexer can reuse
|
|
the same parsers with its larger upload policy while chat keeps the
|
|
stricter per-turn limit.
|
|
"""
|
|
if not data:
|
|
raise EmptyDocumentError(f"{filename} is empty", filename=filename)
|
|
if max_bytes is not None and len(data) > max_bytes:
|
|
raise DocumentTooLargeError(
|
|
f"{filename} exceeds the {max_bytes // (1024 * 1024)} MB per-file limit",
|
|
filename=filename,
|
|
)
|
|
|
|
ext = _ext(filename)
|
|
if ext not in SUPPORTED_DOC_EXTENSIONS:
|
|
raise UnsupportedDocumentError(
|
|
f"{filename} has unsupported extension '{ext}'", filename=filename
|
|
)
|
|
|
|
_check_magic(ext, data, filename)
|
|
|
|
if ext == ".pdf":
|
|
text = _extract_pdf(data, filename)
|
|
elif ext == ".docx":
|
|
text = _extract_docx(data, filename)
|
|
elif ext == ".xlsx":
|
|
text = _extract_xlsx(data, filename)
|
|
elif ext == ".pptx":
|
|
text = _extract_pptx(data, filename)
|
|
elif ext in TEXT_LIKE_EXTENSIONS:
|
|
text = _extract_text_like(data, filename)
|
|
else: # pragma: no cover - guarded above
|
|
raise UnsupportedDocumentError(f"{filename}: unreachable", filename=filename)
|
|
|
|
if not text.strip():
|
|
raise EmptyDocumentError(f"{filename}: no extractable text", filename=filename)
|
|
return _truncate(text, max_chars) if max_chars is not None else text
|
|
|
|
|
|
def extract_text_from_path(
|
|
file_path: str | Path,
|
|
*,
|
|
max_bytes: int | None = MAX_DOC_BYTES,
|
|
max_chars: int | None = MAX_EXTRACTED_CHARS_PER_DOC,
|
|
) -> str:
|
|
"""Extract text from a file path using the same bytes-based parsers."""
|
|
path = Path(file_path)
|
|
return extract_text_from_bytes(
|
|
path.name,
|
|
path.read_bytes(),
|
|
max_bytes=max_bytes,
|
|
max_chars=max_chars,
|
|
)
|
|
|
|
|
|
def _extract_pdf(data: bytes, filename: str) -> str:
|
|
if fitz is not None:
|
|
try:
|
|
with fitz.open(stream=data, filetype="pdf") as doc:
|
|
if doc.is_encrypted and not doc.authenticate(""):
|
|
raise CorruptDocumentError(
|
|
f"{filename} is encrypted and cannot be read", filename=filename
|
|
)
|
|
pages = [
|
|
f"--- Page {i} ---\n{page.get_text() or ''}" for i, page in enumerate(doc, 1)
|
|
]
|
|
return "\n\n".join(pages)
|
|
except CorruptDocumentError:
|
|
raise
|
|
except Exception as exc:
|
|
logger.warning("pymupdf failed on %s: %s — falling back to pypdf", filename, exc)
|
|
|
|
if PdfReader is None:
|
|
raise CorruptDocumentError(
|
|
f"{filename}: no PDF reader available (install pymupdf or pypdf)",
|
|
filename=filename,
|
|
)
|
|
try:
|
|
reader = PdfReader(io.BytesIO(data))
|
|
if getattr(reader, "is_encrypted", False):
|
|
raise CorruptDocumentError(
|
|
f"{filename} is encrypted and cannot be read", filename=filename
|
|
)
|
|
pages = [
|
|
f"--- Page {i} ---\n{page.extract_text() or ''}"
|
|
for i, page in enumerate(reader.pages, 1)
|
|
]
|
|
return "\n\n".join(pages)
|
|
except CorruptDocumentError:
|
|
raise
|
|
except _PypdfNotDecryptedError as exc:
|
|
raise CorruptDocumentError(
|
|
f"{filename} is encrypted and cannot be read", filename=filename
|
|
) from exc
|
|
except Exception as exc:
|
|
raise CorruptDocumentError(
|
|
f"{filename}: failed to read PDF ({exc})", filename=filename
|
|
) from exc
|
|
|
|
|
|
def _extract_docx(data: bytes, filename: str) -> str:
|
|
primary_error: Exception | None = None
|
|
primary_text = ""
|
|
if DocxDocument is not None:
|
|
try:
|
|
doc = DocxDocument(io.BytesIO(data))
|
|
paragraphs = [p.text for p in doc.paragraphs if p.text and p.text.strip()]
|
|
primary_text = "\n\n".join(paragraphs)
|
|
except Exception as exc:
|
|
primary_error = exc
|
|
logger.info("python-docx failed on %s; falling back to raw OOXML: %s", filename, exc)
|
|
|
|
fallback = _extract_docx_ooxml(data, filename)
|
|
if fallback.strip() and (not primary_text.strip() or len(fallback) > len(primary_text) * 1.2):
|
|
return fallback
|
|
if primary_text.strip():
|
|
return primary_text
|
|
|
|
if DocxDocument is None:
|
|
raise CorruptDocumentError(
|
|
f"{filename}: python-docx not installed and OOXML fallback found no text",
|
|
filename=filename,
|
|
)
|
|
if primary_error is not None:
|
|
raise CorruptDocumentError(
|
|
f"{filename}: failed to open DOCX ({primary_error})", filename=filename
|
|
) from primary_error
|
|
return ""
|
|
|
|
|
|
def _extract_xlsx(data: bytes, filename: str) -> str:
|
|
if load_workbook is None:
|
|
return _extract_xlsx_ooxml(data, filename)
|
|
try:
|
|
wb = load_workbook(io.BytesIO(data), read_only=True, data_only=True)
|
|
except Exception as exc:
|
|
logger.info("openpyxl failed on %s; falling back to raw OOXML: %s", filename, exc)
|
|
fallback = _extract_xlsx_ooxml(data, filename)
|
|
if fallback.strip():
|
|
return fallback
|
|
raise CorruptDocumentError(
|
|
f"{filename}: failed to open XLSX ({exc})", filename=filename
|
|
) from exc
|
|
try:
|
|
sheets: list[str] = []
|
|
for sheet_name in wb.sheetnames:
|
|
ws = wb[sheet_name]
|
|
rows: list[str] = []
|
|
for row in ws.iter_rows(values_only=True):
|
|
row_text = "\t".join(str(cell) if cell is not None else "" for cell in row)
|
|
if row_text.strip():
|
|
rows.append(row_text)
|
|
if rows:
|
|
sheets.append(f"--- Sheet: {sheet_name} ---\n" + "\n".join(rows))
|
|
return "\n\n".join(sheets)
|
|
finally:
|
|
wb.close()
|
|
|
|
|
|
def _extract_pptx(data: bytes, filename: str) -> str:
|
|
if PptxPresentation is None:
|
|
return _extract_pptx_ooxml(data, filename)
|
|
try:
|
|
prs = PptxPresentation(io.BytesIO(data))
|
|
except Exception as exc:
|
|
logger.info("python-pptx failed on %s; falling back to raw OOXML: %s", filename, exc)
|
|
fallback = _extract_pptx_ooxml(data, filename)
|
|
if fallback.strip():
|
|
return fallback
|
|
raise CorruptDocumentError(
|
|
f"{filename}: failed to open PPTX ({exc})", filename=filename
|
|
) from exc
|
|
slides: list[str] = []
|
|
for i, slide in enumerate(prs.slides, 1):
|
|
slide_text: list[str] = []
|
|
for shape in slide.shapes:
|
|
_collect_pptx_shape_text(shape, slide_text)
|
|
if slide_text:
|
|
slides.append(f"--- Slide {i} ---\n" + "\n".join(slide_text))
|
|
return "\n\n".join(slides)
|
|
|
|
|
|
def _extract_text_like(data: bytes, filename: str) -> str:
|
|
"""Decode a plain-text / code / config / markup file.
|
|
|
|
Uses the same encoding fallback chain as the KB pipeline
|
|
(``FileTypeRouter.decode_bytes``) so a GBK-encoded Python file or a
|
|
UTF-8-BOM Markdown works the same way in both places.
|
|
"""
|
|
try:
|
|
return FileTypeRouter.decode_bytes(data)
|
|
except Exception as exc: # pragma: no cover - decode_bytes never raises
|
|
raise CorruptDocumentError(
|
|
f"{filename}: failed to decode text ({exc})", filename=filename
|
|
) from exc
|
|
|
|
|
|
def _open_ooxml(data: bytes, filename: str) -> zipfile.ZipFile:
|
|
try:
|
|
return zipfile.ZipFile(io.BytesIO(data))
|
|
except zipfile.BadZipFile as exc:
|
|
raise CorruptDocumentError(
|
|
f"{filename}: failed to open Office ZIP package ({exc})", filename=filename
|
|
) from exc
|
|
|
|
|
|
def _local_name(tag: str) -> str:
|
|
return tag.rsplit("}", 1)[-1] if "}" in tag else tag
|
|
|
|
|
|
def _parse_xml_member(zf: zipfile.ZipFile, member: str, filename: str) -> Any | None:
|
|
try:
|
|
raw = zf.read(member)
|
|
except KeyError:
|
|
return None
|
|
try:
|
|
return DefusedElementTree.fromstring(raw)
|
|
except (DefusedElementTree.ParseError, DefusedXmlException) as exc:
|
|
raise CorruptDocumentError(
|
|
f"{filename}: failed to parse {member} ({exc})", filename=filename
|
|
) from exc
|
|
|
|
|
|
def _collect_ooxml_text(node: Any) -> str:
|
|
parts: list[str] = []
|
|
for child in node.iter():
|
|
name = _local_name(child.tag)
|
|
if name == "t" and child.text:
|
|
parts.append(child.text)
|
|
elif name == "tab":
|
|
parts.append("\t")
|
|
elif name in {"br", "cr"}:
|
|
parts.append("\n")
|
|
return "".join(parts).strip()
|
|
|
|
|
|
def _extract_paragraph_text(root: Any) -> list[str]:
|
|
paragraphs: list[str] = []
|
|
for node in root.iter():
|
|
if _local_name(node.tag) != "p":
|
|
continue
|
|
text = _collect_ooxml_text(node)
|
|
if text:
|
|
paragraphs.append(text)
|
|
if paragraphs:
|
|
return paragraphs
|
|
text = _collect_ooxml_text(root)
|
|
return [text] if text else []
|
|
|
|
|
|
def _extract_docx_ooxml(data: bytes, filename: str) -> str:
|
|
with _open_ooxml(data, filename) as zf:
|
|
names = zf.namelist()
|
|
content_members = ["word/document.xml"]
|
|
content_members.extend(
|
|
sorted(
|
|
name
|
|
for name in names
|
|
if re.match(r"word/(header|footer|footnotes|endnotes|comments)\d*\.xml$", name)
|
|
)
|
|
)
|
|
|
|
chunks: list[str] = []
|
|
for member in content_members:
|
|
root = _parse_xml_member(zf, member, filename)
|
|
if root is None:
|
|
continue
|
|
chunks.extend(_extract_paragraph_text(root))
|
|
return "\n\n".join(chunks)
|
|
|
|
|
|
def _xlsx_shared_strings(zf: zipfile.ZipFile, filename: str) -> list[str]:
|
|
root = _parse_xml_member(zf, "xl/sharedStrings.xml", filename)
|
|
if root is None:
|
|
return []
|
|
strings: list[str] = []
|
|
for node in root:
|
|
if _local_name(node.tag) != "si":
|
|
continue
|
|
strings.append(_collect_ooxml_text(node))
|
|
return strings
|
|
|
|
|
|
def _xlsx_sheet_names(zf: zipfile.ZipFile, filename: str) -> dict[str, str]:
|
|
root = _parse_xml_member(zf, "xl/workbook.xml", filename)
|
|
if root is None:
|
|
return {}
|
|
out: dict[str, str] = {}
|
|
index = 1
|
|
for node in root.iter():
|
|
if _local_name(node.tag) != "sheet":
|
|
continue
|
|
sheet_name = node.attrib.get("name") or f"sheet{index}"
|
|
sheet_id = node.attrib.get("sheetId") or str(index)
|
|
out[f"xl/worksheets/sheet{sheet_id}.xml"] = sheet_name
|
|
index += 1
|
|
return out
|
|
|
|
|
|
def _xlsx_cell_text(cell: Any, shared_strings: list[str]) -> str:
|
|
cell_type = cell.attrib.get("t", "")
|
|
if cell_type == "inlineStr":
|
|
return _collect_ooxml_text(cell)
|
|
|
|
value = ""
|
|
for child in cell:
|
|
if _local_name(child.tag) == "v":
|
|
value = child.text or ""
|
|
break
|
|
|
|
if cell_type == "s":
|
|
try:
|
|
return shared_strings[int(value)]
|
|
except (ValueError, IndexError):
|
|
return value
|
|
return value
|
|
|
|
|
|
def _extract_xlsx_ooxml(data: bytes, filename: str) -> str:
|
|
with _open_ooxml(data, filename) as zf:
|
|
shared_strings = _xlsx_shared_strings(zf, filename)
|
|
sheet_names = _xlsx_sheet_names(zf, filename)
|
|
sheet_members = sorted(
|
|
(name for name in zf.namelist() if re.match(r"xl/worksheets/sheet\d+\.xml$", name)),
|
|
key=lambda name: [
|
|
int(part) if part.isdigit() else part for part in re.split(r"(\d+)", name)
|
|
],
|
|
)
|
|
|
|
sheets: list[str] = []
|
|
for index, member in enumerate(sheet_members, 1):
|
|
root = _parse_xml_member(zf, member, filename)
|
|
if root is None:
|
|
continue
|
|
rows: list[str] = []
|
|
for row in root.iter():
|
|
if _local_name(row.tag) != "row":
|
|
continue
|
|
cells = [
|
|
_xlsx_cell_text(cell, shared_strings)
|
|
for cell in row
|
|
if _local_name(cell.tag) == "c"
|
|
]
|
|
row_text = "\t".join(cells)
|
|
if row_text.strip():
|
|
rows.append(row_text)
|
|
if rows:
|
|
sheet_name = sheet_names.get(member, f"sheet{index}")
|
|
sheets.append(f"--- Sheet: {sheet_name} ---\n" + "\n".join(rows))
|
|
return "\n\n".join(sheets)
|
|
|
|
|
|
def _extract_pptx_ooxml(data: bytes, filename: str) -> str:
|
|
with _open_ooxml(data, filename) as zf:
|
|
slide_members = sorted(
|
|
(name for name in zf.namelist() if re.match(r"ppt/slides/slide\d+\.xml$", name)),
|
|
key=lambda name: [
|
|
int(part) if part.isdigit() else part for part in re.split(r"(\d+)", name)
|
|
],
|
|
)
|
|
slides: list[str] = []
|
|
for index, member in enumerate(slide_members, 1):
|
|
root = _parse_xml_member(zf, member, filename)
|
|
if root is None:
|
|
continue
|
|
paragraphs = _extract_paragraph_text(root)
|
|
if paragraphs:
|
|
slides.append(f"--- Slide {index} ---\n" + "\n".join(paragraphs))
|
|
return "\n\n".join(slides)
|
|
|
|
|
|
def _collect_pptx_shape_text(shape, out: list[str]) -> None:
|
|
"""Recurse into groups + tables, same semantics as nanobot's version."""
|
|
sub_shapes = getattr(shape, "shapes", None)
|
|
if sub_shapes is not None:
|
|
for sub in sub_shapes:
|
|
_collect_pptx_shape_text(sub, out)
|
|
return
|
|
|
|
if getattr(shape, "has_table", False):
|
|
for row in shape.table.rows:
|
|
cells = [cell.text.strip() for cell in row.cells]
|
|
line = "\t".join(cell for cell in cells if cell)
|
|
if line:
|
|
out.append(line)
|
|
return
|
|
|
|
text = getattr(shape, "text", "")
|
|
if text:
|
|
out.append(text)
|
|
|
|
|
|
def extract_documents_from_records(
|
|
records: Iterable[dict],
|
|
) -> tuple[list[str], list[dict]]:
|
|
"""Process a list of attachment records from the WS payload.
|
|
|
|
Parameters
|
|
----------
|
|
records:
|
|
Raw attachment records as parsed by the turn runtime
|
|
(``{"type", "url", "base64", "filename", "mime_type"}``).
|
|
|
|
Returns
|
|
-------
|
|
(doc_texts, updated_records)
|
|
``doc_texts`` is a list of strings formatted as
|
|
``"[File: <name>]\\n<text>"`` (one per processed or skipped doc).
|
|
``updated_records`` is the input list with the ``base64`` field
|
|
cleared on successfully-extracted docs (to save DB space), an
|
|
``extracted_chars`` field added, and the extracted plain text
|
|
stored under ``extracted_text`` so the chat UI can preview office
|
|
documents without re-running the parser. Image / non-document
|
|
records are returned unchanged.
|
|
"""
|
|
doc_texts: list[str] = []
|
|
updated: list[dict] = []
|
|
total_bytes = 0
|
|
total_chars = 0
|
|
over_quota = False
|
|
|
|
for raw in records:
|
|
record = dict(raw)
|
|
filename = str(record.get("filename") or "")
|
|
if not is_document_extension(filename):
|
|
updated.append(record)
|
|
continue
|
|
|
|
b64 = record.get("base64") or ""
|
|
if not b64:
|
|
updated.append(record)
|
|
continue
|
|
|
|
if over_quota:
|
|
doc_texts.append(f"[File: {filename} — skipped: total attachment quota exceeded]")
|
|
record["base64"] = ""
|
|
record["extracted_chars"] = 0
|
|
updated.append(record)
|
|
continue
|
|
|
|
try:
|
|
data = base64.b64decode(b64, validate=False)
|
|
except Exception as exc:
|
|
doc_texts.append(f"[File: {filename} — could not be read: invalid base64 ({exc})]")
|
|
record["base64"] = ""
|
|
record["extracted_chars"] = 0
|
|
updated.append(record)
|
|
continue
|
|
|
|
if total_bytes + len(data) > MAX_TOTAL_DOC_BYTES:
|
|
over_quota = True
|
|
doc_texts.append(f"[File: {filename} — skipped: total attachment quota exceeded]")
|
|
record["base64"] = ""
|
|
record["extracted_chars"] = 0
|
|
updated.append(record)
|
|
continue
|
|
|
|
total_bytes += len(data)
|
|
|
|
try:
|
|
text = extract_text_from_bytes(filename, data)
|
|
except DocumentExtractionError as exc:
|
|
logger.info("Document extraction failed for %s: %s", filename, exc)
|
|
doc_texts.append(f"[File: {filename} — could not be read: {exc}]")
|
|
record["base64"] = ""
|
|
record["extracted_chars"] = 0
|
|
updated.append(record)
|
|
continue
|
|
|
|
remaining_budget = MAX_EXTRACTED_CHARS_TOTAL - total_chars
|
|
if remaining_budget <= 0:
|
|
doc_texts.append(f"[File: {filename} — skipped: total extracted-text quota exceeded]")
|
|
record["base64"] = ""
|
|
record["extracted_chars"] = 0
|
|
updated.append(record)
|
|
continue
|
|
|
|
if len(text) > remaining_budget:
|
|
text = (
|
|
text[:remaining_budget]
|
|
+ f"... (truncated, {len(text)} chars total; turn quota hit)"
|
|
)
|
|
|
|
total_chars += len(text)
|
|
doc_texts.append(f"[File: {filename}]\n{text}")
|
|
record["base64"] = ""
|
|
record["extracted_chars"] = len(text)
|
|
record["extracted_text"] = text
|
|
updated.append(record)
|
|
|
|
return doc_texts, updated
|