feat(documents): add text pdf ingestion with heading reconstruction

Why:
- Support Text PDFs in the same DocumentTree/markdown contract as DOCX.

Changes:
- PyMuPDF parser, text-layer gate, shared heading heuristics, upload dispatch for .pdf.

Impact:
- Scanned/image PDFs are rejected at upload; requires pymupdf installed.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-02 16:59:42 +03:30
parent afc20200f7
commit 55907a8dec
8 changed files with 418 additions and 131 deletions

View File

@@ -26,3 +26,6 @@ SEMANTIC_MIN_CHUNK_SIZE=3
# SQLite database path # SQLite database path
DATABASE_URL=sqlite:///./data/chunking_benchmark.db DATABASE_URL=sqlite:///./data/chunking_benchmark.db
# Text PDF gate (reject scanned/image PDFs)
PDF_MIN_TOTAL_CHARS=100
PDF_MIN_MEDIAN_CHARS_PER_PAGE=40

View File

@@ -0,0 +1,16 @@
# PyMuPDF for Text PDF extraction
Text PDF ingestion uses PyMuPDF (`fitz`) so Heading Reconstruction can read font size/weight, text blocks, and outline bookmarks — the same structural signals `python-docx` gives us for DOCX. We rejected flat string extractors (`pypdf`), table-first libraries as the primary path (`pdfplumber`), and LibreOffice PDF→DOCX conversion (lossy styles, slow, fights ADR 0006’s “prefer native structure”).
## Considered Options
- **PyMuPDF** — chosen; best fit for Heading Reconstruction; AGPL acceptable while this stays an internal benchmarker
- **pdfplumber** — strong tables, weaker hierarchy; deferred to backlog if tables are a measured failure
- **pypdf** — too little layout/font signal
- **LibreOffice PDF→DOCX → existing parser** — reuses DOCX path but conversion quality is unreliable
## Consequences
- Add `pymupdf` dependency; keep a single PDF code path in the documents parser seam
- If we later ship the parser as a distributed service, revisit AGPL vs a permissive alternative
- Table-heavy and Scanned PDF work stays out of this ADR (see `backlog/`)

View File

@@ -35,5 +35,9 @@ class Settings(BaseSettings):
# Database # Database
database_url: str = "sqlite:///./data/chunking_benchmark.db" database_url: str = "sqlite:///./data/chunking_benchmark.db"
# Text PDF gate (reject Scanned PDFs with near-empty text layer)
pdf_min_total_chars: int = 100
pdf_min_median_chars_per_page: int = 40
settings = Settings() settings = Settings()

View File

@@ -0,0 +1,79 @@
"""Shared heading detection heuristics for DOCX and Text PDF parsers.
Used when native styles/fonts/outlines are missing or incomplete
(common in table-heavy Farsi documents).
"""
from __future__ import annotations
import re
from typing import Protocol
class StyledBlock(Protocol):
"""Minimal block interface: mutable style_name + text."""
style_name: str
text: str
_HEADING_PATTERNS: list[tuple[re.Pattern[str], int]] = [
(re.compile(r"^بخش\s+"), 1),
(re.compile(r"^\d+[\-\.]\d+"), 2),
(re.compile(r"^\*\s*بخش\s+"), 1),
(re.compile(r"^\*\s*\S"), 2),
]
def heading_level_from_style(style_name: str) -> int | None:
"""Return heading level from a style name like 'Heading 1' or 'Heading1'."""
m = re.match(r"^heading\s*(\d+)$", style_name.strip(), re.IGNORECASE)
if m:
return int(m.group(1))
return None
def is_likely_heading(text: str) -> int | None:
"""Detect heading-like patterns in body text.
Returns heading level (1 or 2) if detected, else None.
"""
text = text.strip()
if len(text) > 150:
return None
for pattern, level in _HEADING_PATTERNS:
if pattern.match(text):
return level
return None
def apply_heading_heuristics(
blocks: list[StyledBlock],
*,
only_when_no_headings: bool = True,
) -> bool:
"""Upgrade Normal blocks to HeadingN via text patterns.
Args:
blocks: Mutable text blocks with style_name / text.
only_when_no_headings: If True (DOCX default), skip when any
Heading styles already exist. If False, only upgrade remaining
Normal blocks (PDF gap-fill after outline/font).
Returns:
True if any blocks were upgraded.
"""
if only_when_no_headings:
if any(heading_level_from_style(b.style_name) is not None for b in blocks):
return False
upgraded = False
for block in blocks:
if block.style_name != "Normal":
continue
level = is_likely_heading(block.text)
if level is not None:
block.style_name = f"Heading{level}"
upgraded = True
return upgraded

View File

@@ -1,18 +1,13 @@
"""DOCX parser: extracts document tree + flat markdown. """Document parser: DOCX/DOC + dispatcher for all supported formats.
Uses python-docx to read paragraph styles and build a hierarchical Produces a hierarchical DocumentTree (Document > Section > Article > Paragraph)
DocumentTree (Document > Section > Article > Paragraph). Also produces and markdown consumed by chunking strategies.
a markdown representation consumed by chunking strategies.
Supports both .docx and .doc formats. .doc files are converted to Supports .docx, .doc (via LibreOffice), and .pdf (via pdf_parser).
.docx via LibreOffice headless mode before parsing.
Handles documents where content is in tables (not just paragraphs).
""" """
from __future__ import annotations from __future__ import annotations
import re
import subprocess import subprocess
import tempfile import tempfile
from pathlib import Path from pathlib import Path
@@ -27,6 +22,10 @@ from src.core.models import (
NodeType, NodeType,
) )
from src.core.exceptions import DocumentProcessingError from src.core.exceptions import DocumentProcessingError
from src.documents.heading_heuristics import (
apply_heading_heuristics,
heading_level_from_style,
)
# ── Heading level → node type mapping ────────────────────────────── # ── Heading level → node type mapping ──────────────────────────────
@@ -34,89 +33,41 @@ from src.core.exceptions import DocumentProcessingError
_HEADING_MAP: dict[int, NodeType] = { _HEADING_MAP: dict[int, NodeType] = {
1: NodeType.SECTION, 1: NodeType.SECTION,
2: NodeType.ARTICLE, 2: NodeType.ARTICLE,
# 3+ also ARTICLE (nesting depth determines hierarchy)
} }
def _heading_level(style_name: str) -> int | None:
"""Return the heading level from a style name, or None if not a heading.
Handles both "Heading 1" (display name) and "Heading1" (style ID from XML).
"""
m = re.match(r"^heading\s*(\d+)$", style_name.strip(), re.IGNORECASE)
if m:
return int(m.group(1))
return None
def _node_type_for_level(level: int) -> NodeType: def _node_type_for_level(level: int) -> NodeType:
return _HEADING_MAP.get(level, NodeType.ARTICLE) return _HEADING_MAP.get(level, NodeType.ARTICLE)
# ── Heuristic heading detection for table-heavy documents ────────── # ── Text block ─────────────────────────────────────────────────────
# Patterns that look like section/article headings in Farsi/English docs class TextBlock:
_HEADING_PATTERNS: list[tuple[str, int]] = [ """A unit of text extracted from a document, preserving reading order."""
# Farsi section markers: "بخش اول", "بخش دوم", etc.
(re.compile(r"^بخش\s+"), 1),
# Numbered sections: "1-1", "2-1", "10-1", "1-1-8"
(re.compile(r"^\d+[\-\.]\d+"), 2),
# Starred sections: "*بخش اول", "*تعریف"
(re.compile(r"^\*\s*بخش\s+"), 1),
(re.compile(r"^\*\s*\S"), 2),
]
__slots__ = ("style_name", "text", "font_size", "bold", "page", "locked")
def _is_likely_heading(text: str) -> int | None: def __init__(
"""Detect heading-like patterns in table-extracted text. self,
style_name: str,
Returns the heading level (1 or 2) if detected, else None. text: str,
Used when the document has no real heading styles (table-only content). *,
""" font_size: float = 0.0,
text = text.strip() bold: bool = False,
if len(text) > 150: # headings are short page: int = 0,
return None locked: bool = False,
for pattern, level in _HEADING_PATTERNS: ) -> None:
if pattern.match(text): self.style_name = style_name
return level self.text = text
return None self.font_size = font_size
self.bold = bold
self.page = page
def _detect_heading_blocks(blocks: list[_TextBlock]) -> bool: self.locked = locked
"""Upgrade paragraph blocks to heading blocks based on content patterns.
Returns True if any blocks were upgraded.
Only activates when no real headings exist in the document.
"""
# Check if there are already real headings
has_headings = any(_heading_level(b.style_name) is not None for b in blocks)
if has_headings:
return False
upgraded = False
for block in blocks:
if block.style_name != "Normal":
continue
level = _is_likely_heading(block.text)
if level is not None:
block.style_name = f"Heading{level}"
upgraded = True
return upgraded
# ── Text block extraction (paragraphs + tables) ─────────────────── # ── Text block extraction (paragraphs + tables) ───────────────────
class _TextBlock: def _extract_text_blocks(doc: DocxDocumentType) -> list[TextBlock]:
"""A unit of text extracted from the document, preserving reading order."""
__slots__ = ("style_name", "text")
def __init__(self, style_name: str, text: str) -> None:
self.style_name = style_name
self.text = text
def _extract_text_blocks(doc: DocxDocumentType) -> list[_TextBlock]:
"""Walk the document body in reading order, extracting paragraphs and tables. """Walk the document body in reading order, extracting paragraphs and tables.
This handles documents where content lives inside table cells This handles documents where content lives inside table cells
@@ -124,20 +75,17 @@ def _extract_text_blocks(doc: DocxDocumentType) -> list[_TextBlock]:
""" """
from docx.oxml.ns import qn from docx.oxml.ns import qn
blocks: list[_TextBlock] = [] blocks: list[TextBlock] = []
for element in doc.element.body: for element in doc.element.body:
tag = element.tag.split("}")[-1] if "}" in element.tag else element.tag tag = element.tag.split("}")[-1] if "}" in element.tag else element.tag
if tag == "p": if tag == "p":
# Paragraph — extract text and style
text = element.text or "" text = element.text or ""
# Also check for runs (text split across formatting)
if not text.strip(): if not text.strip():
runs = element.findall(qn("w:r")) runs = element.findall(qn("w:r"))
text = "".join(r.text or "" for r in runs) text = "".join(r.text or "" for r in runs)
# Get style name
ppr = element.find(qn("w:pPr")) ppr = element.find(qn("w:pPr"))
style_name = "Normal" style_name = "Normal"
if ppr is not None: if ppr is not None:
@@ -146,19 +94,15 @@ def _extract_text_blocks(doc: DocxDocumentType) -> list[_TextBlock]:
style_name = pstyle.get(qn("w:val"), "Normal") style_name = pstyle.get(qn("w:val"), "Normal")
if text.strip(): if text.strip():
blocks.append(_TextBlock(style_name, text.strip())) blocks.append(TextBlock(style_name, text.strip()))
elif tag == "tbl": elif tag == "tbl":
# Table — extract all cell text as paragraph blocks
for row in element.findall(qn("w:tr")): for row in element.findall(qn("w:tr")):
for cell in row.findall(qn("w:tc")): for cell in row.findall(qn("w:tc")):
for para in cell.findall(qn("w:p")): for para in cell.findall(qn("w:p")):
# Get paragraph text
text = ""
runs = para.findall(qn("w:r")) runs = para.findall(qn("w:r"))
text = "".join(r.text or "" for r in runs) text = "".join(r.text or "" for r in runs)
# Get style
ppr = para.find(qn("w:pPr")) ppr = para.find(qn("w:pPr"))
style_name = "Normal" style_name = "Normal"
if ppr is not None: if ppr is not None:
@@ -167,39 +111,36 @@ def _extract_text_blocks(doc: DocxDocumentType) -> list[_TextBlock]:
style_name = pstyle.get(qn("w:val"), "Normal") style_name = pstyle.get(qn("w:val"), "Normal")
if text.strip(): if text.strip():
blocks.append(_TextBlock(style_name, text.strip())) blocks.append(TextBlock(style_name, text.strip()))
return blocks return blocks
# ── Tree builder ─────────────────────────────────────────────────── # ── Tree builder ───────────────────────────────────────────────────
def build_document_tree(paragraphs: list[Paragraph] | list[_TextBlock]) -> DocumentTreeNode: def build_document_tree(paragraphs: list[Paragraph] | list[TextBlock]) -> DocumentTreeNode:
"""Build a DocumentTreeNode tree from a list of text blocks. """Build a DocumentTreeNode tree from a list of text blocks.
Accepts either python-docx Paragraph objects or _TextBlock objects. Accepts either python-docx Paragraph objects or TextBlock objects.
""" """
root = DocumentTreeNode(node_type=NodeType.DOCUMENT, text="", heading=None, heading_level=None) root = DocumentTreeNode(node_type=NodeType.DOCUMENT, text="", heading=None, heading_level=None)
# Stack of (level, node) for current nesting. level 0 = root.
stack: list[tuple[int, DocumentTreeNode]] = [(0, root)] stack: list[tuple[int, DocumentTreeNode]] = [(0, root)]
for block in paragraphs: for block in paragraphs:
# Get style name and text from either type if isinstance(block, TextBlock):
if isinstance(block, _TextBlock):
style_name = block.style_name style_name = block.style_name
text = block.text text = block.text
else: else:
style_name = block.style.name style_name = block.style.name
text = block.text.strip() text = block.text.strip()
level = _heading_level(style_name) level = heading_level_from_style(style_name)
if not text: if not text:
continue # skip blank paragraphs continue
if level is not None: if level is not None:
# Pop back to parent level
while len(stack) > 1 and stack[-1][0] >= level: while len(stack) > 1 and stack[-1][0] >= level:
stack.pop() stack.pop()
@@ -212,7 +153,6 @@ def build_document_tree(paragraphs: list[Paragraph] | list[_TextBlock]) -> Docum
stack[-1][1].children.append(node) stack[-1][1].children.append(node)
stack.append((level, node)) stack.append((level, node))
else: else:
# Body text — add as paragraph child of current heading
node = DocumentTreeNode( node = DocumentTreeNode(
node_type=NodeType.PARAGRAPH, node_type=NodeType.PARAGRAPH,
text=text, text=text,
@@ -244,7 +184,7 @@ def tree_to_markdown(node: DocumentTreeNode, depth: int = 0) -> str:
# ── Public API ───────────────────────────────────────────────────── # ── Public API ─────────────────────────────────────────────────────
class ParseResult: class ParseResult:
"""Output of parse_docx(): tree + markdown + raw text.""" """Output of parse_document() / parse_docx() / parse_pdf()."""
__slots__ = ("tree", "markdown", "plain_text", "paragraph_count") __slots__ = ("tree", "markdown", "plain_text", "paragraph_count")
@@ -261,6 +201,31 @@ class ParseResult:
self.paragraph_count = paragraph_count self.paragraph_count = paragraph_count
SUPPORTED_SUFFIXES = (".docx", ".doc", ".pdf")
def parse_document(file_path: str | Path) -> ParseResult:
"""Parse a supported document into DocumentTree + markdown.
Dispatches by suffix: .pdf → pdf_parser; .docx/.doc → parse_docx.
"""
path = Path(file_path)
if not path.exists():
raise DocumentProcessingError(f"File not found: {path}")
suffix = path.suffix.lower()
if suffix == ".pdf":
from src.documents.pdf_parser import parse_pdf
return parse_pdf(path)
if suffix in (".docx", ".doc"):
return parse_docx(path)
raise DocumentProcessingError(
f"Not a supported file format: {suffix} (expected {', '.join(SUPPORTED_SUFFIXES)})"
)
# ── .doc → .docx conversion ─────────────────────────────────────── # ── .doc → .docx conversion ───────────────────────────────────────
def _convert_doc_to_docx(doc_path: Path) -> Path: def _convert_doc_to_docx(doc_path: Path) -> Path:
@@ -268,9 +233,6 @@ def _convert_doc_to_docx(doc_path: Path) -> Path:
Returns the path to the converted .docx file (in a temp directory). Returns the path to the converted .docx file (in a temp directory).
The caller is responsible for cleanup. The caller is responsible for cleanup.
Raises:
DocumentProcessingError: If conversion fails.
""" """
out_dir = Path(tempfile.mkdtemp(prefix="docconv_")) out_dir = Path(tempfile.mkdtemp(prefix="docconv_"))
try: try:
@@ -291,7 +253,6 @@ def _convert_doc_to_docx(doc_path: Path) -> Path:
f"LibreOffice conversion failed: {result.stderr}" f"LibreOffice conversion failed: {result.stderr}"
) )
# Find the converted file
converted = out_dir / doc_path.with_suffix(".docx").name converted = out_dir / doc_path.with_suffix(".docx").name
if not converted.exists(): if not converted.exists():
raise DocumentProcessingError( raise DocumentProcessingError(
@@ -312,15 +273,6 @@ def parse_docx(file_path: str | Path) -> ParseResult:
"""Parse a .docx or .doc file into a DocumentTree + markdown. """Parse a .docx or .doc file into a DocumentTree + markdown.
.doc files are automatically converted to .docx via LibreOffice. .doc files are automatically converted to .docx via LibreOffice.
Args:
file_path: Path to the .docx or .doc file.
Returns:
ParseResult with tree, markdown, plain_text, and paragraph_count.
Raises:
DocumentProcessingError: If the file cannot be parsed.
""" """
path = Path(file_path) path = Path(file_path)
if not path.exists(): if not path.exists():
@@ -328,9 +280,10 @@ def parse_docx(file_path: str | Path) -> ParseResult:
suffix = path.suffix.lower() suffix = path.suffix.lower()
if suffix not in (".docx", ".doc"): if suffix not in (".docx", ".doc"):
raise DocumentProcessingError(f"Not a supported file format: {suffix} (expected .docx or .doc)") raise DocumentProcessingError(
f"Not a supported file format: {suffix} (expected .docx or .doc)"
)
# Convert .doc to .docx if needed
if suffix == ".doc": if suffix == ".doc":
path = _convert_doc_to_docx(path) path = _convert_doc_to_docx(path)
@@ -339,14 +292,12 @@ def parse_docx(file_path: str | Path) -> ParseResult:
except Exception as exc: except Exception as exc:
raise DocumentProcessingError(f"Failed to open DOCX: {exc}") from exc raise DocumentProcessingError(f"Failed to open DOCX: {exc}") from exc
# Extract text blocks from paragraphs + tables (preserves reading order)
blocks = _extract_text_blocks(doc) blocks = _extract_text_blocks(doc)
if not blocks: if not blocks:
raise DocumentProcessingError("Document contains no text content") raise DocumentProcessingError("Document contains no text content")
# Detect heading patterns in table-only documents apply_heading_heuristics(blocks, only_when_no_headings=True)
_detect_heading_blocks(blocks)
root = build_document_tree(blocks) root = build_document_tree(blocks)
tree = DocumentTree(root=root) tree = DocumentTree(root=root)

228
src/documents/pdf_parser.py Normal file
View File

@@ -0,0 +1,228 @@
"""Text PDF parser via PyMuPDF with Heading Reconstruction.
Pipeline:
1. Text-layer Gate — reject Scanned PDFs (near-empty text)
2. Extract reading-order blocks (Table Flattening via block order)
3. Heading Reconstruction: outline → font size → shared heuristics
4. Build DocumentTree + markdown (same contract as DOCX)
"""
from __future__ import annotations
import statistics
from pathlib import Path
import fitz # PyMuPDF
from src.core.config import settings
from src.core.exceptions import DocumentProcessingError
from src.core.models import DocumentTree
from src.documents.heading_heuristics import apply_heading_heuristics
from src.documents.parser import (
ParseResult,
TextBlock,
build_document_tree,
tree_to_markdown,
)
def _median_chars_per_page(page_char_counts: list[int]) -> float:
if not page_char_counts:
return 0.0
return float(statistics.median(page_char_counts))
def _enforce_text_layer_gate(doc: fitz.Document) -> None:
"""Hard-reject PDFs without enough extractable text (Scanned PDFs)."""
page_counts: list[int] = []
for page in doc:
page_counts.append(len(page.get_text("text").strip()))
total = sum(page_counts)
median = _median_chars_per_page(page_counts)
if total < settings.pdf_min_total_chars or median < settings.pdf_min_median_chars_per_page:
raise DocumentProcessingError(
"PDF has no usable text layer (likely a scanned/image PDF). "
"OCR is not enabled for this benchmarker — only Text PDFs are supported. "
f"(total_chars={total}, median_chars_per_page={median:.0f}; "
f"need total>={settings.pdf_min_total_chars} and "
f"median>={settings.pdf_min_median_chars_per_page})"
)
def _line_font_meta(line: dict) -> tuple[str, float, bool]:
"""Return (text, max_font_size, any_bold) for a dict-line."""
spans = line.get("spans") or []
parts: list[str] = []
max_size = 0.0
any_bold = False
for span in spans:
text = (span.get("text") or "").strip()
if text:
parts.append(span.get("text") or "")
size = float(span.get("size") or 0)
if size > max_size:
max_size = size
# flags bit 4 (16) = bold in PyMuPDF
if int(span.get("flags") or 0) & 2**4:
any_bold = True
return ("".join(parts).strip(), max_size, any_bold)
def _extract_blocks(doc: fitz.Document) -> list[TextBlock]:
"""Extract reading-order text blocks with font metadata (tables flattened)."""
blocks: list[TextBlock] = []
for page_index, page in enumerate(doc):
page_dict = page.get_text("dict", flags=fitz.TEXT_PRESERVE_WHITESPACE)
for block in page_dict.get("blocks") or []:
if block.get("type") != 0: # 0 = text
continue
for line in block.get("lines") or []:
text, font_size, bold = _line_font_meta(line)
if not text:
continue
blocks.append(
TextBlock(
style_name="Normal",
text=text,
font_size=font_size,
bold=bold,
page=page_index + 1,
locked=False,
)
)
return blocks
def _normalize(s: str) -> str:
return " ".join(s.split()).casefold()
def _apply_outline_headings(doc: fitz.Document, blocks: list[TextBlock]) -> int:
"""Mark blocks that match PDF outline/bookmark titles. Returns match count."""
toc = doc.get_toc(simple=True)
if not toc:
return 0
matched = 0
used: set[int] = set()
for level, title, page in toc:
title_norm = _normalize(title)
if not title_norm:
continue
level = max(1, min(int(level), 6))
# Prefer same page; fall back to any unused block with matching title
candidates = [
(i, b)
for i, b in enumerate(blocks)
if i not in used and not b.locked
]
same_page = [(i, b) for i, b in candidates if b.page == page]
search_order = same_page + [(i, b) for i, b in candidates if b.page != page]
for i, block in search_order:
block_norm = _normalize(block.text)
if block_norm == title_norm or title_norm in block_norm or block_norm in title_norm:
block.style_name = f"Heading{level}"
block.locked = True
used.add(i)
matched += 1
break
return matched
def _apply_font_headings(blocks: list[TextBlock]) -> int:
"""Mark unlocked blocks as headings from font size clusters vs body size."""
sizes = [b.font_size for b in blocks if b.font_size > 0]
if len(sizes) < 3:
return 0
body_size = statistics.median(sizes)
if body_size <= 0:
return 0
# Distinct sizes clearly above body text
heading_sizes = sorted(
{round(s, 1) for s in sizes if s >= body_size * 1.2},
reverse=True,
)
if not heading_sizes:
# Bold + short lines at/near body size as weak H2
upgraded = 0
for block in blocks:
if block.locked or block.style_name != "Normal":
continue
if block.bold and len(block.text) <= 150:
block.style_name = "Heading2"
upgraded += 1
return upgraded
size_to_level: dict[float, int] = {}
for idx, size in enumerate(heading_sizes):
size_to_level[size] = 1 if idx == 0 else 2
upgraded = 0
for block in blocks:
if block.locked or block.style_name != "Normal":
continue
if len(block.text) > 150:
continue
rounded = round(block.font_size, 1)
level = size_to_level.get(rounded)
if level is None:
continue
block.style_name = f"Heading{level}"
upgraded += 1
return upgraded
def parse_pdf(file_path: str | Path) -> ParseResult:
"""Parse a Text PDF into DocumentTree + markdown.
Raises DocumentProcessingError for missing files, Scanned PDFs, or empty text.
"""
path = Path(file_path)
if not path.exists():
raise DocumentProcessingError(f"File not found: {path}")
if path.suffix.lower() != ".pdf":
raise DocumentProcessingError(f"Not a PDF: {path.suffix}")
try:
doc = fitz.open(path)
except Exception as exc:
raise DocumentProcessingError(f"Failed to open PDF: {exc}") from exc
try:
if doc.page_count < 1:
raise DocumentProcessingError("PDF has no pages")
_enforce_text_layer_gate(doc)
blocks = _extract_blocks(doc)
if not blocks:
raise DocumentProcessingError("PDF contains no extractable text blocks")
_apply_outline_headings(doc, blocks)
_apply_font_headings(blocks)
# Gap-fill remaining Normal blocks (outline titles stay locked).
# When outline/font found nothing, this is a full heuristic pass.
apply_heading_heuristics(blocks, only_when_no_headings=False)
root = build_document_tree(blocks)
tree = DocumentTree(root=root)
markdown = tree_to_markdown(root)
plain_text = "\n".join(b.text for b in blocks if b.text)
return ParseResult(
tree=tree,
markdown=markdown,
plain_text=plain_text,
paragraph_count=len(blocks),
)
finally:
doc.close()

View File

@@ -1,8 +1,8 @@
"""Document and strategy API routes. """Document and strategy API routes.
Endpoints: Endpoints:
POST /documents Upload a .docx file POST /documents Upload a .docx / .doc / .pdf file
POST /documents/{id}/process Run chunking strategies (stub until Phase 2) POST /documents/{id}/process Run chunking strategies
DELETE /documents/{id} Remove document + vectors DELETE /documents/{id} Remove document + vectors
GET /strategies List available chunking strategies GET /strategies List available chunking strategies
""" """
@@ -10,7 +10,6 @@ Endpoints:
from fastapi import APIRouter, File, UploadFile from fastapi import APIRouter, File, UploadFile
from src.core.exceptions import DocumentProcessingError from src.core.exceptions import DocumentProcessingError
from src.core.models import PaginatedResponse, StrategyName
from src.documents.models import ( from src.documents.models import (
DeleteResponse, DeleteResponse,
DocumentResponse, DocumentResponse,
@@ -20,6 +19,7 @@ from src.documents.models import (
StrategiesResponse, StrategiesResponse,
StrategyInfo, StrategyInfo,
) )
from src.documents.parser import SUPPORTED_SUFFIXES
from src.documents import service from src.documents import service
router = APIRouter() router = APIRouter()
@@ -39,11 +39,15 @@ async def list_documents(offset: int = 0, limit: int = 50):
@router.post("/documents", response_model=DocumentResponse, status_code=201) @router.post("/documents", response_model=DocumentResponse, status_code=201)
async def upload_document(file: UploadFile = File(...)): async def upload_document(file: UploadFile = File(...)):
"""Upload a .docx file. Parses it, stores the document tree in SQLite.""" """Upload a document. Parses it, stores the document tree in SQLite."""
if not file.filename: if not file.filename:
raise DocumentProcessingError("No filename provided") raise DocumentProcessingError("No filename provided")
if not file.filename.lower().endswith((".docx", ".doc")):
raise DocumentProcessingError("Only .docx and .doc files are supported") lower = file.filename.lower()
if not any(lower.endswith(s) for s in SUPPORTED_SUFFIXES):
raise DocumentProcessingError(
f"Only {', '.join(SUPPORTED_SUFFIXES)} files are supported"
)
content = await file.read() content = await file.read()
if not content: if not content:
@@ -61,10 +65,7 @@ async def upload_document(file: UploadFile = File(...)):
@router.post("/documents/{doc_id}/process", response_model=ProcessResponse) @router.post("/documents/{doc_id}/process", response_model=ProcessResponse)
async def process_document(doc_id: str, request: ProcessRequest): async def process_document(doc_id: str, request: ProcessRequest):
"""Run selected chunking strategies on an uploaded document. """Run selected chunking strategies on an uploaded document."""
Currently returns stub results until Phase 2 implements the strategies.
"""
return service.process_document(doc_id, request) return service.process_document(doc_id, request)

View File

@@ -16,7 +16,7 @@ from src.documents.models import (
ProcessResponse, ProcessResponse,
StrategyResult, StrategyResult,
) )
from src.documents.parser import parse_docx from src.documents.parser import parse_document, SUPPORTED_SUFFIXES
from src.storage import sqlite as db from src.storage import sqlite as db
from src.storage import qdrant as qdr from src.storage import qdrant as qdr
@@ -49,17 +49,22 @@ STRATEGY_DESCRIPTIONS: dict[StrategyName, str] = {
# ── Upload ───────────────────────────────────────────────────────── # ── Upload ─────────────────────────────────────────────────────────
def upload_document(filename: str, file_bytes: bytes) -> dict[str, Any]: def upload_document(filename: str, file_bytes: bytes) -> dict[str, Any]:
"""Parse a .docx upload, store in SQLite, return document record.""" """Parse an uploaded document, store in SQLite, return document record."""
import tempfile import tempfile
from pathlib import Path from pathlib import Path
suffix = Path(filename).suffix or ".docx" suffix = Path(filename).suffix.lower()
if suffix not in SUPPORTED_SUFFIXES:
raise DocumentProcessingError(
f"Only {', '.join(SUPPORTED_SUFFIXES)} files are supported"
)
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
tmp.write(file_bytes) tmp.write(file_bytes)
tmp_path = tmp.name tmp_path = tmp.name
try: try:
result = parse_docx(tmp_path) result = parse_document(tmp_path)
finally: finally:
Path(tmp_path).unlink(missing_ok=True) Path(tmp_path).unlink(missing_ok=True)