Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

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