From 2cb5cdfd426cbad23dd538bd7a3a33aeb85cede6 Mon Sep 17 00:00:00 2001 From: Ray Date: Fri, 11 Sep 2026 17:07:12 +0800 Subject: [PATCH 1/8] Flash: drop the size and landscape bail, fall back to page nodes extract_toc refused any PDF under 300 text weight, or 200 on its densest page, or with mostly-landscape pages, and returned an empty outline. Both rules threw away documents the detector handles: three to five pages of heading plus a line yield every heading, and an ordinary slide deck yields one node per slide. Only the no-alphabetic-script condition stays, and its result now says so with toc_source="unreadable" instead of borrowing "detected"; toc_source is set on every result (detected, bookmarks, pages, unreadable). When detection yields nothing for a document that has text, the pages are the tree: page_index_flash emits one node per text-bearing page, titled by its first block and labelled toc_source="pages". A flat tree larger than FLAT_TREE_MAX_NODES is returned without the optimize and summary passes, since the managed pipelines refuse it: flash_rejection_reason() gives the local client and the CLI one policy, pointing at standard mode for an oversized flat tree, and for an unreadable PDF naming the missing text (standard mode stays the hint there too, since a PDF of nothing but numbers lands in the same bucket and the model can still read it). The nine example PDFs extract byte-identically before and after: the removed rules never fired on ordinary documents. --- pageindex/flash/README.md | 6 ++ pageindex/flash/api.py | 48 ++++++++- pageindex/flash/main.py | 22 ++-- pageindex/local_api.py | 10 +- run_pageindex.py | 8 +- tests/test_client.py | 42 ++++++++ tests/test_flash_extraction.py | 182 +++++++++++++++++++++++++++++++-- 7 files changed, 293 insertions(+), 25 deletions(-) diff --git a/pageindex/flash/README.md b/pageindex/flash/README.md index 0d23a3c35..cfdf6eba8 100644 --- a/pageindex/flash/README.md +++ b/pageindex/flash/README.md @@ -45,9 +45,15 @@ Writes the tree to `results/_structure.json`. "nodes": [...], } ], + "toc_source": str, # "detected" | "bookmarks" | "pages" | "unreadable" } ``` +`toc_source` says where the structure came from. `"pages"` means the layout +yielded no hierarchy, so each page that carries text became one node titled by +its first block; `"unreadable"` means the PDF carries no alphabetic text in any +script (scanned, image-only, or a garbage encoding) and `structure` is empty. + ## Benchmark Nine PDFs, each run end to end with tree optimization: PDF parse, layout diff --git a/pageindex/flash/api.py b/pageindex/flash/api.py index 6f7361e27..7c557698a 100644 --- a/pageindex/flash/api.py +++ b/pageindex/flash/api.py @@ -10,6 +10,9 @@ from .main import extract_toc +# Largest page-node fallback the managed pipelines accept as an index. +FLAT_TREE_MAX_NODES = 10 + def _is_pdfium_password_error(exc: Exception) -> bool: msg = str(exc).lower() @@ -95,11 +98,45 @@ def _optimize(structure, page_texts, do_expand, model): "before": outcome["before"], "after": outcome["after"]} +def _page_nodes(page_texts: list[str]) -> list[dict]: + """One node per page that carries text, titled by the page's first block.""" + from ..utils import write_node_id + nodes = [] + for index, text in enumerate(page_texts, start=1): + first = next((line.strip() for line in text.splitlines() if line.strip()), "") + if first: + nodes.append({"title": first[:80], "node_id": "", "start_index": index, + "end_index": index, "nodes": []}) + write_node_id(nodes) + return nodes + + +def flash_rejection_reason(result: dict, standard_hint: str = "mode='standard'") -> str | None: + """Why a managed pipeline should refuse this flash result, or None to accept it. + + The local client and the CLI share this policy so they refuse the same + documents; ``standard_hint`` is how each spells the standard-mode switch. + """ + structure = result.get("structure") or [] + if result.get("toc_source") == "unreadable": + return ("PageIndex Flash found no alphabetic text in this PDF (scanned, " + "image-only, or a broken text encoding). If it does carry text, " + f"try {standard_hint}, which builds the structure with the model.") + if result.get("toc_source") == "pages" and len(structure) > FLAT_TREE_MAX_NODES: + return (f"PageIndex Flash found no layout structure in this document " + f"({len(structure)} pages of text); try {standard_hint}, which " + "builds the structure with the model.") + if not structure: + return ("PageIndex Flash could not extract a structure from this PDF; " + f"try {standard_hint}, which builds the structure with the model.") + return None + + def page_index_flash(pdf, summary=True, summary_model=None, optimize: str | bool | None = None, optimize_expand=None, optimize_model=None, summary_concurrency=None, use_embedded_toc=True) -> dict: - """Build a PageIndex tree structure from a PDF using layout statistics. The tree extraction itself uses no LLM; by default an LLM writes node summaries and expands the tree (``summary=False, optimize=False`` runs fully LLM-free). Args: pdf: path to a PDF file (``str`` or ``pathlib.Path``) or an in-memory binary stream (``io.BytesIO``). summary: if True, generate LLM summaries for each node (requires ``summary_model``). summary_model: the LLM model identifier to use for summary generation. optimize: ``"full"`` for merge + LLM expand (a model unreachable after the retry ladder — a missing credential included — fails the run loudly from expand itself; a per-prompt rejection leaves just that node collapsed), ``"merge"`` for deterministic merge only, ``False`` to disable. ``True`` is accepted as ``"full"`` for backward compatibility; defaults to ``"full"``. Expand needs readable page text, so a bookmark-only or scanned PDF runs the merge half only (``expands`` reports 0). optimize_expand: deprecated — use ``optimize``. Honored only when ``optimize`` is not passed (or is the legacy ``True``): ``False`` maps to ``"merge"``, ``True`` to ``"full"``. optimize_model: the LLM model for expand (defaults to the summary model). summary_concurrency: maximum simultaneous summary model calls; None uses the library default. use_embedded_toc: if True, consume the PDF's embedded bookmarks when trustworthy: deep bookmarks become the frame and the detected sections they lack are grafted back in after noise filtering, coarse ones become the chapter frame with detected nodes re-hung under them (deeper sparse entries are filled in when the page text confirms them, and garbled extracted titles are repaired from the bookmark strings), garbage ones are ignored; adds a ``toc_source`` key to the result. On by default; pass False for the pure detected structure. Returns: dict with keys ``doc_name``, ``doc_title``, ``structure`` (a list of nested ``{"title", "start_index", "end_index", "nodes"}`` dicts; page indexes are 1-based) and ``has_abstract_or_references_section`` (True when a top-level entry is an abstract or references heading). With ``optimize`` an ``optimize`` key reports merge/expand counts and before/after search-cost metrics. """ + """Build a PageIndex tree structure from a PDF using layout statistics. The tree extraction itself uses no LLM; by default an LLM writes node summaries and expands the tree (``summary=False, optimize=False`` runs fully LLM-free). Args: pdf: path to a PDF file (``str`` or ``pathlib.Path``) or an in-memory binary stream (``io.BytesIO``). summary: if True, generate LLM summaries for each node (requires ``summary_model``). summary_model: the LLM model identifier to use for summary generation. optimize: ``"full"`` for merge + LLM expand (a model unreachable after the retry ladder — a missing credential included — fails the run loudly from expand itself; a per-prompt rejection leaves just that node collapsed), ``"merge"`` for deterministic merge only, ``False`` to disable. ``True`` is accepted as ``"full"`` for backward compatibility; defaults to ``"full"``. Expand needs readable page text, so a bookmark-only or scanned PDF runs the merge half only (``expands`` reports 0). optimize_expand: deprecated — use ``optimize``. Honored only when ``optimize`` is not passed (or is the legacy ``True``): ``False`` maps to ``"merge"``, ``True`` to ``"full"``. optimize_model: the LLM model for expand (defaults to the summary model). summary_concurrency: maximum simultaneous summary model calls; None uses the library default. use_embedded_toc: if True, consume the PDF's embedded bookmarks when trustworthy: deep bookmarks become the frame and the detected sections they lack are grafted back in after noise filtering, coarse ones become the chapter frame with detected nodes re-hung under them (deeper sparse entries are filled in when the page text confirms them, and garbled extracted titles are repaired from the bookmark strings), garbage ones are ignored. On by default; pass False for the pure detected structure. Returns: dict with keys ``doc_name``, ``doc_title``, ``structure`` (a list of nested ``{"title", "start_index", "end_index", "nodes"}`` dicts; page indexes are 1-based) and ``has_abstract_or_references_section`` (True when a top-level entry is an abstract or references heading). ``toc_source`` says where the structure came from: ``"detected"`` (layout), ``"bookmarks"`` (the embedded outline), ``"pages"`` (no hierarchy found, so one node per page that carries text, titled by its first block; left unsummarized and unoptimized when there are more than ``FLAT_TREE_MAX_NODES`` of them, a size the local client and CLI refuse) or ``"unreadable"`` (no alphabetic text in any script: scanned, image-only, or garbage encoding; ``structure`` is empty). With ``optimize`` an ``optimize`` key reports merge/expand counts and before/after search-cost metrics. """ if optimize_expand is not None: import warnings warnings.warn( @@ -118,6 +155,15 @@ def page_index_flash(pdf, summary=True, summary_model=None, f"optimize must be 'full', 'merge', or False, got {optimize!r}") result = extract_toc(_validate_pdf(pdf), use_embedded_toc=use_embedded_toc) structure = result.get("structure", []) + if not structure and result.get("toc_source") != "unreadable": + # the layout yields no hierarchy; the pages themselves are the tree + structure = _page_nodes(result.get("page_texts") or []) + result["structure"] = structure + result["toc_source"] = "pages" if structure else "unreadable" + if result.get("toc_source") == "pages" and len(structure) > FLAT_TREE_MAX_NODES: + # the managed pipelines refuse a flat tree this size; skip the model passes + result.pop("page_texts", None) + return result if optimize and structure: # bookmark-only extractions carry no page_texts and scanned ones # only empty strings; expand needs text diff --git a/pageindex/flash/main.py b/pageindex/flash/main.py index 791874538..a69b7a204 100644 --- a/pageindex/flash/main.py +++ b/pageindex/flash/main.py @@ -21,7 +21,7 @@ from .labels import detect_captions, build_caption_regions, CaptionContext from .model import Rect, numbering_kind, block_text, deaccented_text, Block from .outline_assembly import ( - build_heading_from_block, is_landscape_or_empty, is_outline_valid, is_chapter_outline_valid, mark_outline_block_types, assemble_outline, compute_max_heading_gap, has_table_or_prominent, OutlineNode, outline_to_dict_tree, + build_heading_from_block, is_outline_valid, is_chapter_outline_valid, mark_outline_block_types, assemble_outline, compute_max_heading_gap, has_table_or_prominent, OutlineNode, outline_to_dict_tree, ) from .parser_pdfium_parallel import parse_charlevel_meta_parallel from .phases import assign_reading_order, PageView, process_page @@ -126,7 +126,7 @@ def extract_toc( workers: Optional[int] = None, use_embedded_toc: bool = True, ) -> dict: - """Run the full pipeline. Returns a dict shaped like:: { "doc_name": "...", "doc_title": "...", "structure": [ {"title": "...", "start_index": 1, "end_index": 3, "nodes": [...]}, ... ], "has_abstract_or_references_section": False } ``has_abstract_or_references_section`` is True when any TOP-LEVEL outline entry is an abstract-keyword heading or carries the prominent-heading flag (a references-keyword heading, plain or numbered). The near-empty bail and the valid-outline branch both report False. ``workers`` sets the process count for the per-page parallel parser: None = auto (CPU count - 1), 1 forces the sequential path; output is identical either way. ``use_embedded_toc`` consumes the PDF's embedded bookmarks when trustworthy: deep bookmarks become the frame with the detected sections they lack grafted back in, coarse ones become the chapter frame with detected nodes re-hung under them, garbage ones are ignored; adds ``toc_source`` to the result. On by default; pass False for the pure detected structure. """ + """Run the full pipeline. Returns a dict shaped like:: { "doc_name": "...", "doc_title": "...", "structure": [ {"title": "...", "start_index": 1, "end_index": 3, "nodes": [...]}, ... ], "has_abstract_or_references_section": False } ``has_abstract_or_references_section`` is True when any TOP-LEVEL outline entry is an abstract-keyword heading or carries the prominent-heading flag (a references-keyword heading, plain or numbered). The unreadable-text bail and the valid-outline branch both report False. ``workers`` sets the process count for the per-page parallel parser: None = auto (CPU count - 1), 1 forces the sequential path; output is identical either way. ``use_embedded_toc`` consumes the PDF's embedded bookmarks when trustworthy: deep bookmarks become the frame with the detected sections they lack grafted back in, coarse ones become the chapter frame with detected nodes re-hung under them, garbage ones are ignored. On by default; pass False for the pure detected structure. ``toc_source`` is always present: ``"detected"``, ``"bookmarks"``, or ``"unreadable"`` when the text layer carries no alphabetic script and the outline is left empty. """ # ----- 1) Parse PDF -> flat spans per page -------------------------- # per-page (view box, /Rotate) comes from the same engine (PDFium) that # produced the block coordinates, so the geometry frame is consistent. @@ -157,11 +157,11 @@ def extract_toc( page.blocks = cluster_lines_into_blocks(ctx) assign_reading_order(page, page.blocks) - # ----- Early empty-outline gate ------------------------------------ - # Short, near-empty, unsupported-script, or mostly-landscape documents - # emit an empty outline rather than a fabricated structure. - if (doc.secondary_slot.state_slot <= 300 or doc.secondary_slot.previous_slot <= 200 - or doc.secondary_slot.tertiary_slot in (0, 2, 10) or is_landscape_or_empty(doc)): + # ----- Unreadable-text gate ------------------------------------------ + # A text layer of control characters, unclassified bytes, or astral code + # points is garbage for every later stage: emit an empty outline rather + # than headings made of it. + if doc.secondary_slot.tertiary_slot in (0, 2, 10): if isinstance(doc_handle, (str, Path)): doc_name = Path(str(doc_handle)).name else: @@ -175,14 +175,17 @@ def extract_toc( "page_texts": ["\n".join(block_text(block) for block in (page.secondary_slot or [])) for page in pages], + "toc_source": "unreadable", } # Bookmarks need no extracted text, so they can still structure a - # document this gate wrote off as unreadable. + # document whose text layer is unusable. if use_embedded_toc: from .embedded_toc import apply_embedded_toc - result["structure"], result["toc_source"] = apply_embedded_toc( + structure, source = apply_embedded_toc( [], doc_handle, len(pages), page_texts=result["page_texts"], ) + if structure: + result["structure"], result["toc_source"] = structure, source return result # ----- 5) Classification: header / footer / watermark / TOC pages --- @@ -321,6 +324,7 @@ def extract_toc( "structure": structure, "has_abstract_or_references_section": has_abstract_or_references, "page_texts": page_texts, + "toc_source": "detected", } if use_embedded_toc: from .embedded_toc import apply_embedded_toc diff --git a/pageindex/local_api.py b/pageindex/local_api.py index e60433932..9443c953b 100644 --- a/pageindex/local_api.py +++ b/pageindex/local_api.py @@ -227,6 +227,7 @@ def _index_standard(self, file_path: str, page_texts: list[str]) -> tuple[list, def _index_flash(self, file_path: str) -> tuple[list, str | None]: from .flash import page_index_flash + from .flash.api import flash_rejection_reason from .utils import (create_clean_structure_for_description, generate_doc_description, write_node_id) result = page_index_flash(file_path, summary=True, @@ -234,12 +235,9 @@ def _index_flash(self, file_path: str) -> tuple[list, str | None]: optimize="full", optimize_model=self._summary_model) structure = result.get("structure", []) - if not structure: - raise PageIndexAPIError( - "Failed to submit document: PageIndex Flash could not extract " - "a structure from this PDF. Try mode='standard', which builds " - "the structure with the model." - ) + reason = flash_rejection_reason(result) + if reason: + raise PageIndexAPIError(f"Failed to submit document: {reason}") write_node_id(structure) description = generate_doc_description( create_clean_structure_for_description(structure), diff --git a/run_pageindex.py b/run_pageindex.py index 8e3e1e525..0d515c369 100644 --- a/run_pageindex.py +++ b/run_pageindex.py @@ -90,6 +90,7 @@ if args.mode == 'flash': from pageindex.flash import page_index_flash + from pageindex.flash.api import flash_rejection_reason summary_model = ConfigLoader().load({k: v for k, v in { 'summary_model': args.summary_model, 'index_model': args.index_model, @@ -104,9 +105,10 @@ use_embedded_toc=args.embedded_toc if args.embedded_toc is not None else True, summary=will_summarize, ) - if not toc_with_page_number.get('structure'): - raise ValueError("PageIndex Flash could not extract a structure from this PDF; " - "try --mode standard, which builds the structure with the model") + reason = flash_rejection_reason(toc_with_page_number, + standard_hint="--mode standard") + if reason: + raise ValueError(reason) if 'optimize' in toc_with_page_number: o = toc_with_page_number['optimize'] print(f"Optimize: merges={o['merges']} expands={o['expands']}, " diff --git a/tests/test_client.py b/tests/test_client.py index 4e63223bf..946964d30 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -2186,3 +2186,45 @@ def test_instructions_must_be_a_string(tmp_path): with pytest.raises(PageIndexAPIError, match="instructions must be a str"): PageIndexClient(storage_path=str(tmp_path / "s"), instructions=[{"type": "text", "text": "x"}]) + + +def test_submit_flash_rejects_unreadable_text_layer(local_client, sample_pdf, + monkeypatch): + monkeypatch.setattr( + pageindex.flash, "page_index_flash", + lambda pdf, **kwargs: {"doc_name": "sample.pdf", "structure": [], + "toc_source": "unreadable"}) + with pytest.raises(PageIndexAPIError, match="no alphabetic text"): + local_client.submit_document(sample_pdf, mode="flash") + + +def test_submit_flash_accepts_page_fallback(local_client, sample_pdf, monkeypatch): + """A small flat tree is a valid index.""" + monkeypatch.setattr( + pageindex.flash, "page_index_flash", + lambda pdf, **kwargs: { + "doc_name": "sample.pdf", "toc_source": "pages", + "structure": [{"title": "Hello", "start_index": 1, "end_index": 1, + "summary": "s", "nodes": []}, + {"title": "World", "start_index": 2, "end_index": 2, + "summary": "s", "nodes": []}]}) + monkeypatch.setattr(pageindex.utils, "llm_completion", + lambda model, prompt, **kw: "Flash description.") + doc_id = local_client.submit_document(sample_pdf, mode="flash")["doc_id"] + tree = local_client.get_tree(doc_id)["result"] + assert [node["title"] for node in tree] == ["Hello", "World"] + + +def test_submit_flash_rejects_oversized_flat_tree(local_client, sample_pdf, + monkeypatch): + from pageindex.flash.api import FLAT_TREE_MAX_NODES + + nodes = [{"title": f"Page {n}", "start_index": 1, "end_index": 1, "nodes": []} + for n in range(FLAT_TREE_MAX_NODES + 1)] + monkeypatch.setattr( + pageindex.flash, "page_index_flash", + lambda pdf, **kwargs: {"doc_name": "sample.pdf", "toc_source": "pages", + "structure": nodes}) + with pytest.raises(PageIndexAPIError, + match="no layout structure.*mode='standard'"): + local_client.submit_document(sample_pdf, mode="flash") diff --git a/tests/test_flash_extraction.py b/tests/test_flash_extraction.py index 28c0a963e..e272be6a7 100644 --- a/tests/test_flash_extraction.py +++ b/tests/test_flash_extraction.py @@ -81,16 +81,16 @@ def test_optimize_full_keyless_reports_file_errors_first(tmp_path, monkeypatch): assert "structure" in result -def test_empty_outline_gate_carries_page_texts(tmp_path): - """The gate's bookmark-built trees feed the same summary/expand passes - as detected ones, so its result must carry the per-page text too.""" +def test_no_heading_result_carries_page_texts(tmp_path): + """A document that yields no headings still carries its per-page text, + so the page-node fallback and the summary/expand passes can read it.""" from conftest import build_pdf from pageindex.flash.main import extract_toc pdf = tmp_path / "doc.pdf" pdf.write_bytes(build_pdf(["Alpha body", "Beta body"])) result = extract_toc(str(pdf)) - assert result["structure"] == [] # the short-document gate fired + assert result["structure"] == [] # two body-only pages: nothing to detect assert len(result["page_texts"]) == 2 assert "Alpha" in result["page_texts"][0] @@ -420,7 +420,7 @@ def test_optimize_expand_warning_names_the_behavior_change(tmp_path, SCRIPT = Path(__file__).resolve().parent.parent / "run_pageindex.py" -def _run_flash_cli(monkeypatch, tmp_path, argv, structure): +def _run_flash_cli(monkeypatch, tmp_path, argv, structure, toc_source=None): """Drive run_pageindex.py in-process with a stubbed flash indexer.""" import runpy import sys @@ -433,7 +433,10 @@ def _run_flash_cli(monkeypatch, tmp_path, argv, structure): def fake_flash(path, **kw): captured.update(kw) - return {"structure": structure} + result = {"structure": structure} + if toc_source: + result["toc_source"] = toc_source + return result monkeypatch.setattr(pageindex.flash, "page_index_flash", fake_flash) monkeypatch.setattr(sys, "argv", ["run_pageindex.py", "--pdf_path", str(pdf), *argv]) @@ -467,3 +470,170 @@ def test_flash_cli_rejects_empty_structure(monkeypatch, tmp_path): with pytest.raises(ValueError, match="try --mode standard"): _run_flash_cli(monkeypatch, tmp_path, [], []) assert not (tmp_path / "results").exists() + + +def test_flash_cli_rejects_oversized_flat_tree(monkeypatch, tmp_path): + """The CLI applies the same refusal policy as the local client, worded + for its own flag.""" + from pageindex.flash.api import FLAT_TREE_MAX_NODES + + nodes = [{"title": f"Page {n}", "start_index": n, "end_index": n, "nodes": []} + for n in range(1, FLAT_TREE_MAX_NODES + 2)] + with pytest.raises(ValueError, match="no layout structure.*--mode standard"): + _run_flash_cli(monkeypatch, tmp_path, [], nodes, toc_source="pages") + assert not (tmp_path / "results").exists() + + +def _layout_pdf(pages, landscape=False): + """Uncompressed PDF, per page a 20pt heading line then 11pt body lines: + ``pages`` is a list of ``(heading, [body line, ...])``.""" + width, height = (792, 612) if landscape else (595, 842) + objs = {1: "<>", 3: "<>>>", + 5: "<>"} + kids, nxt = [], 6 + for heading, lines in pages: + ops, y = [(20, height - 80, heading)], height - 120 + for line in lines: + ops.append((11, y, line)) + y -= 16 + stream = "".join(f"BT /F1 {size} Tf 72 {top} Td ({text}) Tj ET\n" + for size, top, text in ops) + objs[nxt] = f"<>\nstream\n{stream}endstream" + objs[nxt + 1] = (f"<>") + kids.append(nxt + 1) + nxt += 2 + objs[2] = (f"<>") + out, offsets = bytearray(b"%PDF-1.7\n"), {} + for num in sorted(objs): + offsets[num] = len(out) + out += f"{num} 0 obj\n{objs[num]}\nendobj\n".encode("latin-1") + xref = len(out) + out += f"xref\n0 {nxt}\n0000000000 65535 f \n".encode() + for num in range(1, nxt): + out += (f"{offsets[num]:010d} 00000 n \n" if num in offsets + else "0000000000 65535 f \n").encode() + out += (f"trailer\n<>\nstartxref\n{xref}\n" + "%%EOF\n").encode() + return bytes(out) + + +def test_landscape_deck_is_structured(tmp_path): + """A text-light landscape deck is an ordinary document: every slide title + becomes a node. The landscape bail used to empty it.""" + from pageindex.flash.main import extract_toc + + titles = ["Revenue Overview", "Operating Expenses", "Customer Growth", + "Product Roadmap", "Regional Performance", "Engineering Metrics", + "Risk Factors", "Outlook and Guidance"] + pdf = tmp_path / "deck.pdf" + pdf.write_bytes(_layout_pdf( + [(title, [f"Detail {n} for slide {slide} with a few more words of body text" + for n in range(1, 6)]) for slide, title in enumerate(titles, 1)], + landscape=True)) + result = extract_toc(str(pdf)) + # slide 1 is spent on the document title, as on any title page + assert [node["title"] for node in result["structure"]] == titles[1:] + assert result["toc_source"] == "detected" + + +def test_short_document_headings_are_detected(tmp_path): + """Three pages of heading-plus-a-line are enough for detection; the + weight thresholds used to bail before it ran.""" + from pageindex.flash.main import extract_toc + + pdf = tmp_path / "memo.pdf" + pdf.write_bytes(_layout_pdf([("Mission", ["The launch is named Skylark."]), + ("Budget", ["The budget is 420 euros."]), + ("Team", ["The lead is Ada."])])) + result = extract_toc(str(pdf)) + assert [node["title"] for node in result["structure"]] == ["Budget", "Team"] + assert result["doc_title"] == "Mission" + + +def test_toc_source_is_always_present(tmp_path): + """The pure-detected path (no bookmark pass) labels its result too, so + callers can rely on the key.""" + from pageindex.flash import page_index_flash + + pdf = tmp_path / "memo.pdf" + pdf.write_bytes(_layout_pdf([("Mission", ["The launch is named Skylark."]), + ("Budget", ["The budget is 420 euros."]), + ("Team", ["The lead is Ada."])])) + result = page_index_flash(str(pdf), summary=False, optimize=False, + use_embedded_toc=False) + assert result["toc_source"] == "detected" + assert [node["title"] for node in result["structure"]] == ["Budget", "Team"] + + +def test_no_hierarchy_falls_back_to_page_nodes(tmp_path): + """Two pages leave one heading after the title claims the other; with no + hierarchy to infer, the pages themselves are the tree, labelled as such.""" + from pageindex.flash import page_index_flash + + pdf = tmp_path / "two.pdf" + pdf.write_bytes(_layout_pdf([("Mission", ["The launch is named Skylark."]), + ("Budget", ["The budget is 420 euros."])])) + result = page_index_flash(str(pdf), summary=False, optimize=False) + assert result["toc_source"] == "pages" + assert result["structure"] == [ + {"title": "Mission", "node_id": "0000", "start_index": 1, + "end_index": 1, "nodes": []}, + {"title": "Budget", "node_id": "0001", "start_index": 2, + "end_index": 2, "nodes": []}, + ] + assert "page_texts" not in result + + +def test_flat_fallback_over_limit_skips_model_passes(tmp_path, monkeypatch): + """A flat tree larger than the managed pipelines accept is returned + unsummarized: neither optimize nor summary may spend model calls on it.""" + from conftest import build_pdf + from pageindex.flash import api as flash_api + + monkeypatch.setattr(flash_api, "FLAT_TREE_MAX_NODES", 2) + monkeypatch.setattr(flash_api, "_optimize", lambda *a, **k: pytest.fail( + "optimize ran on a refused flat tree")) + monkeypatch.setattr(flash_api, "_summarize", lambda *a, **k: pytest.fail( + "summary ran on a refused flat tree")) + pdf = tmp_path / "letter.pdf" + pdf.write_bytes(build_pdf(["Alpha body", "Beta body", "Gamma body"])) + result = flash_api.page_index_flash(str(pdf), summary=True, summary_model="m") + assert result["toc_source"] == "pages" + assert [node["title"] for node in result["structure"]] == [ + "Alpha body", "Beta body", "Gamma body"] + assert "page_texts" not in result + + +def test_textless_pdf_is_unreadable(tmp_path): + """An empty text layer has no alphabetic script, so the gate bails with + an empty structure labelled unreadable and no page nodes are invented.""" + from conftest import build_pdf + from pageindex.flash import page_index_flash + + pdf = tmp_path / "scan.pdf" + pdf.write_bytes(build_pdf(["", ""])) + result = page_index_flash(str(pdf), summary=False, optimize=False) + assert result["structure"] == [] + assert result["toc_source"] == "unreadable" + + +def test_flash_rejection_reason(): + from pageindex.flash.api import FLAT_TREE_MAX_NODES, flash_rejection_reason + + node = {"title": "T", "start_index": 1, "end_index": 1, "nodes": []} + assert flash_rejection_reason( + {"structure": [node], "toc_source": "detected"}) is None + assert flash_rejection_reason( + {"structure": [node] * 2, "toc_source": "pages"}) is None + unreadable = flash_rejection_reason({"structure": [], "toc_source": "unreadable"}) + assert "no alphabetic text" in unreadable and "mode='standard'" in unreadable + oversized = {"structure": [node] * (FLAT_TREE_MAX_NODES + 1), + "toc_source": "pages"} + flat = flash_rejection_reason(oversized) + assert "no layout structure" in flat and "mode='standard'" in flat + assert "--mode standard" in flash_rejection_reason( + oversized, standard_hint="--mode standard") + assert "could not extract" in flash_rejection_reason({"structure": []}) From 3687480fff71cc7fc4952db47b638ca287a3cf9c Mon Sep 17 00:00:00 2001 From: Ray Date: Sat, 12 Sep 2026 22:30:01 +0800 Subject: [PATCH 2/8] Flash: layout decides, never script; the page fallback covers every page extract_toc refused a PDF whose dominant script fell in Scholar's "other" family, which is not a script but a bucket: Arabic, Hebrew, Persian, Urdu, Devanagari, Bengali, Tamil, Thai, Khmer, Georgian, Armenian, Amharic, and any document with no letters at all, each reported as "no alphabetic text". Three more rules keyed on script inside the estimator: an unnumbered heading in a script other than the body's was dropped, so a Chinese report lost its English section titles; a kana-majority Japanese document had every detected heading discarded; a mostly-landscape document picked its title from page one without the body-paragraph check the portrait path applies, so a slide deck's title became slide one's body text. These are Scholar's precision-over-recall scope limits for an index of Latin and CJK papers; on PageIndex's default local mode they were silent refusals and silent losses. They are deleted here, in this repo's copy of the port, together with the Cyrillic-only density threshold; the private scholar/ tree stays a faithful port and the new tests guard the fork. With the rules gone the same layout yields the same three headings in Japanese, Hindi, Arabic, Hebrew, Thai and mixed Chinese/English, and English is unchanged. Language now only decides which cues are available: case, keyword tables, numbering styles. The page fallback emitted a node only for pages carrying text, each spanning one page, so a partly-OCR'd 20-page PDF indexed as six islands with fourteen pages in no node's range and unreachable by retrieval, accepted without an error. Titles came from the page's first raw line, on a real document the running header or the page number. Every page is now a node titled "Page N", FLAT_TREE_MAX_NODES bounds document pages, and toc_source="unreadable" means exactly that no page carries text. The refusal says what was measured, no text layer, run OCR, instead of "no alphabetic text ... try standard mode", which asserted a cause never checked and pointed at a mode fed the same bytes. The non-Latin fixtures are PyMuPDF-generated with open-licensed font subsets embedded (FiraGO, Droid Sans Fallback); tests/data/flash/make_fixtures.py regenerates them byte-identically. --- pageindex/flash/README.md | 11 +- pageindex/flash/api.py | 25 ++--- .../flash/heading_detection/page_scan.py | 8 +- pageindex/flash/main.py | 39 +------ pageindex/flash/outline_assembly/__init__.py | 4 +- pageindex/flash/outline_assembly/assembly.py | 14 --- .../flash/outline_assembly/candidates.py | 24 ---- pageindex/flash/title/detect.py | 15 --- tests/data/flash/ar_report.pdf | Bin 0 -> 35774 bytes tests/data/flash/hi_report.pdf | Bin 0 -> 37355 bytes tests/data/flash/ja_report.pdf | Bin 0 -> 25354 bytes tests/data/flash/make_fixtures.py | 58 ++++++++++ tests/data/flash/zh_body_en_headings.pdf | Bin 0 -> 25343 bytes tests/test_client.py | 2 +- tests/test_flash_extraction.py | 105 ++++++++++++++---- 15 files changed, 169 insertions(+), 136 deletions(-) create mode 100644 tests/data/flash/ar_report.pdf create mode 100644 tests/data/flash/hi_report.pdf create mode 100644 tests/data/flash/ja_report.pdf create mode 100644 tests/data/flash/make_fixtures.py create mode 100644 tests/data/flash/zh_body_en_headings.pdf diff --git a/pageindex/flash/README.md b/pageindex/flash/README.md index cfdf6eba8..a2f58a6e9 100644 --- a/pageindex/flash/README.md +++ b/pageindex/flash/README.md @@ -45,14 +45,15 @@ Writes the tree to `results/_structure.json`. "nodes": [...], } ], - "toc_source": str, # "detected" | "bookmarks" | "pages" | "unreadable" + "toc_source": str, # "detected" | "bookmarks" | "hybrid" | "pages" | "unreadable" } ``` -`toc_source` says where the structure came from. `"pages"` means the layout -yielded no hierarchy, so each page that carries text became one node titled by -its first block; `"unreadable"` means the PDF carries no alphabetic text in any -script (scanned, image-only, or a garbage encoding) and `structure` is empty. +`toc_source` says where the structure came from: `"detected"` from the layout, +`"bookmarks"` from the embedded outline, `"hybrid"` when bookmarks frame the +detected sections. `"pages"` means the layout yielded no hierarchy, so every +page became one node titled `Page N`; `"unreadable"` means no page carries text +and `structure` is empty. ## Benchmark diff --git a/pageindex/flash/api.py b/pageindex/flash/api.py index 7c557698a..0b5ca59d7 100644 --- a/pageindex/flash/api.py +++ b/pageindex/flash/api.py @@ -99,14 +99,12 @@ def _optimize(structure, page_texts, do_expand, model): def _page_nodes(page_texts: list[str]) -> list[dict]: - """One node per page that carries text, titled by the page's first block.""" + """One node per page, so every page is reachable.""" from ..utils import write_node_id - nodes = [] - for index, text in enumerate(page_texts, start=1): - first = next((line.strip() for line in text.splitlines() if line.strip()), "") - if first: - nodes.append({"title": first[:80], "node_id": "", "start_index": index, - "end_index": index, "nodes": []}) + if not any(text.strip() for text in page_texts): + return [] + nodes = [{"title": f"Page {index}", "node_id": "", "start_index": index, + "end_index": index} for index in range(1, len(page_texts) + 1)] write_node_id(nodes) return nodes @@ -119,13 +117,12 @@ def flash_rejection_reason(result: dict, standard_hint: str = "mode='standard'") """ structure = result.get("structure") or [] if result.get("toc_source") == "unreadable": - return ("PageIndex Flash found no alphabetic text in this PDF (scanned, " - "image-only, or a broken text encoding). If it does carry text, " - f"try {standard_hint}, which builds the structure with the model.") + return ("PageIndex Flash found no text layer in this PDF (scanned or " + "image-only); run OCR before indexing it.") if result.get("toc_source") == "pages" and len(structure) > FLAT_TREE_MAX_NODES: return (f"PageIndex Flash found no layout structure in this document " - f"({len(structure)} pages of text); try {standard_hint}, which " - "builds the structure with the model.") + f"({len(structure)} pages); try {standard_hint}, which builds " + "the structure with the model.") if not structure: return ("PageIndex Flash could not extract a structure from this PDF; " f"try {standard_hint}, which builds the structure with the model.") @@ -136,7 +133,7 @@ def page_index_flash(pdf, summary=True, summary_model=None, optimize: str | bool | None = None, optimize_expand=None, optimize_model=None, summary_concurrency=None, use_embedded_toc=True) -> dict: - """Build a PageIndex tree structure from a PDF using layout statistics. The tree extraction itself uses no LLM; by default an LLM writes node summaries and expands the tree (``summary=False, optimize=False`` runs fully LLM-free). Args: pdf: path to a PDF file (``str`` or ``pathlib.Path``) or an in-memory binary stream (``io.BytesIO``). summary: if True, generate LLM summaries for each node (requires ``summary_model``). summary_model: the LLM model identifier to use for summary generation. optimize: ``"full"`` for merge + LLM expand (a model unreachable after the retry ladder — a missing credential included — fails the run loudly from expand itself; a per-prompt rejection leaves just that node collapsed), ``"merge"`` for deterministic merge only, ``False`` to disable. ``True`` is accepted as ``"full"`` for backward compatibility; defaults to ``"full"``. Expand needs readable page text, so a bookmark-only or scanned PDF runs the merge half only (``expands`` reports 0). optimize_expand: deprecated — use ``optimize``. Honored only when ``optimize`` is not passed (or is the legacy ``True``): ``False`` maps to ``"merge"``, ``True`` to ``"full"``. optimize_model: the LLM model for expand (defaults to the summary model). summary_concurrency: maximum simultaneous summary model calls; None uses the library default. use_embedded_toc: if True, consume the PDF's embedded bookmarks when trustworthy: deep bookmarks become the frame and the detected sections they lack are grafted back in after noise filtering, coarse ones become the chapter frame with detected nodes re-hung under them (deeper sparse entries are filled in when the page text confirms them, and garbled extracted titles are repaired from the bookmark strings), garbage ones are ignored. On by default; pass False for the pure detected structure. Returns: dict with keys ``doc_name``, ``doc_title``, ``structure`` (a list of nested ``{"title", "start_index", "end_index", "nodes"}`` dicts; page indexes are 1-based) and ``has_abstract_or_references_section`` (True when a top-level entry is an abstract or references heading). ``toc_source`` says where the structure came from: ``"detected"`` (layout), ``"bookmarks"`` (the embedded outline), ``"pages"`` (no hierarchy found, so one node per page that carries text, titled by its first block; left unsummarized and unoptimized when there are more than ``FLAT_TREE_MAX_NODES`` of them, a size the local client and CLI refuse) or ``"unreadable"`` (no alphabetic text in any script: scanned, image-only, or garbage encoding; ``structure`` is empty). With ``optimize`` an ``optimize`` key reports merge/expand counts and before/after search-cost metrics. """ + """Build a PageIndex tree structure from a PDF using layout statistics. The tree extraction itself uses no LLM; by default an LLM writes node summaries and expands the tree (``summary=False, optimize=False`` runs fully LLM-free). Args: pdf: path to a PDF file (``str`` or ``pathlib.Path``) or an in-memory binary stream (``io.BytesIO``). summary: if True, generate LLM summaries for each node (requires ``summary_model``). summary_model: the LLM model identifier to use for summary generation. optimize: ``"full"`` for merge + LLM expand (a model unreachable after the retry ladder — a missing credential included — fails the run loudly from expand itself; a per-prompt rejection leaves just that node collapsed), ``"merge"`` for deterministic merge only, ``False`` to disable. ``True`` is accepted as ``"full"`` for backward compatibility; defaults to ``"full"``. Expand needs readable page text, so a bookmark-only or scanned PDF runs the merge half only (``expands`` reports 0). optimize_expand: deprecated — use ``optimize``. Honored only when ``optimize`` is not passed (or is the legacy ``True``): ``False`` maps to ``"merge"``, ``True`` to ``"full"``. optimize_model: the LLM model for expand (defaults to the summary model). summary_concurrency: maximum simultaneous summary model calls; None uses the library default. use_embedded_toc: if True, consume the PDF's embedded bookmarks when trustworthy: deep bookmarks become the frame and the detected sections they lack are grafted back in after noise filtering, coarse ones become the chapter frame with detected nodes re-hung under them (deeper sparse entries are filled in when the page text confirms them, and garbled extracted titles are repaired from the bookmark strings), garbage ones are ignored. On by default; pass False for the pure detected structure. Returns: dict with keys ``doc_name``, ``doc_title``, ``structure`` (a list of nested ``{"title", "start_index", "end_index", "nodes"}`` dicts; page indexes are 1-based) and ``has_abstract_or_references_section`` (True when a top-level entry is an abstract or references heading). ``toc_source`` says where the structure came from: ``"detected"`` (layout), ``"bookmarks"`` (the embedded outline), ``"hybrid"`` (bookmarks framing the detected sections), ``"pages"`` (no hierarchy found, so one node per page titled ``Page N``; left unsummarized and unoptimized when there are more than ``FLAT_TREE_MAX_NODES`` pages, a size the local client and CLI refuse) or ``"unreadable"`` (no page carries text; ``structure`` is empty). With ``optimize`` an ``optimize`` key reports merge/expand counts and before/after search-cost metrics; a refused flat tree carries neither it nor node summaries. """ if optimize_expand is not None: import warnings warnings.warn( @@ -155,7 +152,7 @@ def page_index_flash(pdf, summary=True, summary_model=None, f"optimize must be 'full', 'merge', or False, got {optimize!r}") result = extract_toc(_validate_pdf(pdf), use_embedded_toc=use_embedded_toc) structure = result.get("structure", []) - if not structure and result.get("toc_source") != "unreadable": + if not structure: # the layout yields no hierarchy; the pages themselves are the tree structure = _page_nodes(result.get("page_texts") or []) result["structure"] = structure diff --git a/pageindex/flash/heading_detection/page_scan.py b/pageindex/flash/heading_detection/page_scan.py index d6ebe6d8e..fc52c4927 100644 --- a/pageindex/flash/heading_detection/page_scan.py +++ b/pageindex/flash/heading_detection/page_scan.py @@ -295,7 +295,6 @@ def __init__(self, doc, labeled): def filter_page_candidates(doc_collector: DocCandidateCollector, page, page_candidates: list[HeadingCandidate]) -> None: """Per-page candidate filter for noisy pages, title overlap, page headers, and numbering continuity.""" - from ..outline_assembly import is_script_compatible from ..model import intervals_overlap, is_caps_heavy from ..stats import column_index_of @@ -350,9 +349,6 @@ def _ih_key(heading_candidate: HeadingCandidate): active_candidate = page_candidates[index] next_item = page_candidates[index + 1] if index + 1 < count else None - if is_script_compatible(doc_collector.previous_slot.secondary_slot.tertiary_slot, active_candidate): - continue - if not doc_collector.auxiliary_slot and active_candidate.type != 11: bottom = active_candidate.group_slot.top_edge() if (title is not None and bottom > title.top_edge() @@ -428,7 +424,7 @@ def build_doc_heading_candidates(doc, labeled: Optional[list] = None) -> list[He def find_section_openers(doc, start_page_idx: int) -> list: """Find the first valid heading on each page, then clique-filter the result.""" - from ..outline_assembly import is_script_compatible, has_conflict_in_context, OutlineContext, OutlineNode + from ..outline_assembly import has_conflict_in_context, OutlineContext, OutlineNode item_list: list[HeadingCandidate] = [] index = start_page_idx @@ -450,7 +446,7 @@ def find_section_openers(doc, start_page_idx: int) -> list: break if block.line_count() > 2: break - if current_candidate is not None and not is_script_compatible(doc.secondary_slot.tertiary_slot, current_candidate): + if current_candidate is not None: item_list.append(current_candidate) index += 1 diff --git a/pageindex/flash/main.py b/pageindex/flash/main.py index a69b7a204..3f8b3d9c4 100644 --- a/pageindex/flash/main.py +++ b/pageindex/flash/main.py @@ -126,7 +126,7 @@ def extract_toc( workers: Optional[int] = None, use_embedded_toc: bool = True, ) -> dict: - """Run the full pipeline. Returns a dict shaped like:: { "doc_name": "...", "doc_title": "...", "structure": [ {"title": "...", "start_index": 1, "end_index": 3, "nodes": [...]}, ... ], "has_abstract_or_references_section": False } ``has_abstract_or_references_section`` is True when any TOP-LEVEL outline entry is an abstract-keyword heading or carries the prominent-heading flag (a references-keyword heading, plain or numbered). The unreadable-text bail and the valid-outline branch both report False. ``workers`` sets the process count for the per-page parallel parser: None = auto (CPU count - 1), 1 forces the sequential path; output is identical either way. ``use_embedded_toc`` consumes the PDF's embedded bookmarks when trustworthy: deep bookmarks become the frame with the detected sections they lack grafted back in, coarse ones become the chapter frame with detected nodes re-hung under them, garbage ones are ignored. On by default; pass False for the pure detected structure. ``toc_source`` is always present: ``"detected"``, ``"bookmarks"``, or ``"unreadable"`` when the text layer carries no alphabetic script and the outline is left empty. """ + """Run the full pipeline. Returns a dict shaped like:: { "doc_name": "...", "doc_title": "...", "structure": [ {"title": "...", "start_index": 1, "end_index": 3, "nodes": [...]}, ... ], "has_abstract_or_references_section": False } ``has_abstract_or_references_section`` is True when any TOP-LEVEL outline entry is an abstract-keyword heading or carries the prominent-heading flag (a references-keyword heading, plain or numbered). The valid-outline branch reports False. ``workers`` sets the process count for the per-page parallel parser: None = auto (CPU count - 1), 1 forces the sequential path; output is identical either way. ``use_embedded_toc`` consumes the PDF's embedded bookmarks when trustworthy: deep bookmarks become the frame with the detected sections they lack grafted back in, coarse ones become the chapter frame with detected nodes re-hung under them, garbage ones are ignored. On by default; pass False for the pure detected structure. ``toc_source`` is always present: ``"detected"``, ``"bookmarks"``, or ``"hybrid"``. """ # ----- 1) Parse PDF -> flat spans per page -------------------------- # per-page (view box, /Rotate) comes from the same engine (PDFium) that # produced the block coordinates, so the geometry frame is consistent. @@ -157,37 +157,6 @@ def extract_toc( page.blocks = cluster_lines_into_blocks(ctx) assign_reading_order(page, page.blocks) - # ----- Unreadable-text gate ------------------------------------------ - # A text layer of control characters, unclassified bytes, or astral code - # points is garbage for every later stage: emit an empty outline rather - # than headings made of it. - if doc.secondary_slot.tertiary_slot in (0, 2, 10): - if isinstance(doc_handle, (str, Path)): - doc_name = Path(str(doc_handle)).name - else: - doc_name = "document.pdf" - result = { - "doc_name": doc_name, - "doc_title": None, - "structure": [], - "has_abstract_or_references_section": False, - # the summary/expand passes read these like on the normal path - "page_texts": ["\n".join(block_text(block) - for block in (page.secondary_slot or [])) - for page in pages], - "toc_source": "unreadable", - } - # Bookmarks need no extracted text, so they can still structure a - # document whose text layer is unusable. - if use_embedded_toc: - from .embedded_toc import apply_embedded_toc - structure, source = apply_embedded_toc( - [], doc_handle, len(pages), page_texts=result["page_texts"], - ) - if structure: - result["structure"], result["toc_source"] = structure, source - return result - # ----- 5) Classification: header / footer / watermark / TOC pages --- detect_header_footer(HeaderFooterContext(doc, 1)) # HEADER detect_header_footer(HeaderFooterContext(doc, 2)) # FOOTER @@ -283,7 +252,7 @@ def extract_toc( # ----- 11) Outline assembly and validation gate --------------------- outline_nodes = assemble_outline(doc, section_openers) # Validate the assembled outline. Structured outlines must cover enough - # chapters; unstructured outlines are filtered by script and density gap. + # chapters; unstructured outlines are filtered by density gap. # The abstract/references signal rides along with this gate: it is False on # the valid-outline branch, and on the other branch it is read off the # possibly-emptied list once the density filter has run. @@ -294,9 +263,9 @@ def extract_toc( else: mark_outline_block_types(outline_nodes) page_count = len(doc.primary_slot) - if doc.secondary_slot.tertiary_slot == 7 or ( + if ( page_count >= 3 - and compute_max_heading_gap(outline_nodes, 1)["max_gap"] > (0.65 if doc.secondary_slot.tertiary_slot == 4 else 0.85) * page_count + and compute_max_heading_gap(outline_nodes, 1)["max_gap"] > 0.85 * page_count ): outline_nodes = [] has_abstract_or_references = has_table_or_prominent(outline_nodes) diff --git a/pageindex/flash/outline_assembly/__init__.py b/pageindex/flash/outline_assembly/__init__.py index 303c14883..2abc42ef8 100644 --- a/pageindex/flash/outline_assembly/__init__.py +++ b/pageindex/flash/outline_assembly/__init__.py @@ -34,7 +34,6 @@ compare_heading_order, _compare_block_order, heading_order_key, - is_script_compatible, heading_signature, parent_signature, cached_signature, @@ -87,7 +86,6 @@ mark_outline_block_types, compute_max_heading_gap, has_table_or_prominent, - is_landscape_or_empty, build_heading_from_block, assemble_outline, _flatten_outline_nodes, @@ -98,7 +96,7 @@ __all__ = [ "HeadingCandidate", "OutlineNode", "compare_heading_order", "heading_order_key", "compare_heading_depth", - "is_script_compatible", "heading_signature", "parent_signature", "cached_signature", "is_in_oo_range", "has_style_neighbor", "pick_style_bucket", "has_conflict_in_context", "is_compatible_with_context", + "heading_signature", "parent_signature", "cached_signature", "is_in_oo_range", "has_style_neighbor", "pick_style_bucket", "has_conflict_in_context", "is_compatible_with_context", "StyleCluster", "OutlineContext", "NumberingTrie", "insert_numbering", "count_sibling_numberings", "OutlineState", "find_keyword_clique", "detect_body_headings", "CliqueFilterContext", diff --git a/pageindex/flash/outline_assembly/assembly.py b/pageindex/flash/outline_assembly/assembly.py index ea0ff4027..816c5411f 100644 --- a/pageindex/flash/outline_assembly/assembly.py +++ b/pageindex/flash/outline_assembly/assembly.py @@ -64,20 +64,6 @@ def has_table_or_prominent(outline_nodes: list[OutlineNode]) -> bool: return any(secondary_item.heading.type == 5 or secondary_item.heading.is_prominent for secondary_item in outline_nodes) -def is_landscape_or_empty(doc) -> bool: - """Return True for mostly-landscape or near-empty documents with little outline text.""" - if doc.secondary_slot.secondary_slot >= 1e3: - return False - secondary_item = 0 - candidate_item = 0.0 - for page in doc.primary_slot: - if page.bounds.bbox_width() > page.bounds.bbox_height() and page.primary_slot.secondary_slot < 1e3: - secondary_item += 1 - candidate_item += page.primary_slot.secondary_slot - count_item = len(doc.primary_slot) - return secondary_item >= 0.9 * count_item or (secondary_item >= 0.7 * count_item and candidate_item >= 0.5 * doc.secondary_slot.state_slot) - - # --------------------------------------------------------------------------- # # Build a heading candidate from a block # # --------------------------------------------------------------------------- # diff --git a/pageindex/flash/outline_assembly/candidates.py b/pageindex/flash/outline_assembly/candidates.py index 630f4ecc2..6d0ca2846 100644 --- a/pageindex/flash/outline_assembly/candidates.py +++ b/pageindex/flash/outline_assembly/candidates.py @@ -136,30 +136,6 @@ def heading_order_key(heading_candidate: HeadingCandidate) -> tuple: # --------------------------------------------------------------------------- # -def is_script_compatible(number: int, other_heading_candidate: HeadingCandidate) -> bool: - """Return whether candidate script/type is compatible with prior context. Args: a: integer previous script/type context b: heading candidate """ - candidate_item = other_heading_candidate.state_slot - if candidate_item == 0 or candidate_item == 2 or candidate_item == 10: - return True - if number == candidate_item: - return False - if other_heading_candidate.type == 5: - return False - if other_heading_candidate.is_prominent: - return False - if len(other_heading_candidate.numbering) > 0: - return False - if number == 3 and candidate_item == 9: - return False - if number == 9 and candidate_item == 3: - return False - if number == 7 and candidate_item == 5: - return False - if number == 6 and candidate_item == 3: - return False - return True - - def heading_signature(heading_candidate: HeadingCandidate) -> str: """Return a full heading signature including numbering or text.""" if len(heading_candidate.numbering) > 0: diff --git a/pageindex/flash/title/detect.py b/pageindex/flash/title/detect.py index 49a022227..14f561e94 100644 --- a/pageindex/flash/title/detect.py +++ b/pageindex/flash/title/detect.py @@ -61,21 +61,6 @@ def detect_title(doc) -> Optional[TitleCandidate]: has_seen_da = False # "broke into body" flag for page in doc.primary_slot: - # Special branch: landscape cover document - if ( - doc.secondary_slot.style_slot > len(doc.primary_slot) / 2 - and page.page_index <= 1 - and page.bounds.bbox_width() > page.bounds.bbox_height() - and page.primary_slot.secondary_slot < 500 - ): - for idx, block in enumerate(page.secondary_slot): - if ( - is_title_candidate_block(block) and id(block) not in state.primary_slot - and heading_score(block) > page.primary_slot.primary_slot - 0.1 - ): - score_title_candidate(state, page, idx) - break - if ( is_cover_like_page(doc, page) or (page.page_index <= 1 and len(doc.primary_slot) >= 10 and page.primary_slot.secondary_slot < 0.8 * doc.secondary_slot.secondary_slot) diff --git a/tests/data/flash/ar_report.pdf b/tests/data/flash/ar_report.pdf new file mode 100644 index 0000000000000000000000000000000000000000..57dd6f06874b5caccda5d092eeff0b973e4eac6a GIT binary patch literal 35774 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 aERP#Mw0V5sp3BKXrFG}dhxI+Y_5T4w`Fg7W literal 0 HcmV?d00001 diff --git a/tests/data/flash/hi_report.pdf b/tests/data/flash/hi_report.pdf new file mode 100644 index 0000000000000000000000000000000000000000..f191e5c79834d938379bfb6d4f64eac379e5428d GIT binary patch literal 37355 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 literal 0 HcmV?d00001 diff --git a/tests/data/flash/ja_report.pdf b/tests/data/flash/ja_report.pdf new file mode 100644 index 0000000000000000000000000000000000000000..7eab57e7ca283c5b7f441c558c30b0c4732001bf GIT binary patch literal 25354 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�BSYUF(BBSK~yo$)20hk&|c`*f5xJ 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 literal 0 HcmV?d00001 diff --git a/tests/data/flash/make_fixtures.py b/tests/data/flash/make_fixtures.py new file mode 100644 index 000000000..fb29cf2ae --- /dev/null +++ b/tests/data/flash/make_fixtures.py @@ -0,0 +1,58 @@ +"""Regenerate the flash fixtures: python tests/data/flash/make_fixtures.py + +Needs pymupdf and pymupdf-fonts (FiraGO), neither a PageIndex dependency. Each +PDF is a title page and three 20pt headings over 11pt body lines, with the font +subset embedded. Output is byte-stable, so a regeneration leaves git clean. +""" +from pathlib import Path + +import pymupdf + +HERE = Path(__file__).parent + +ZH = ["本公司致力于为客户提供高质量的产品和服务,持续推动技术创新与业务增长。", + "报告期内,公司实现营业收入同比增长,主要得益于核心业务的稳步扩张。", + "管理层将继续优化资源配置,加强风险管理,提升整体运营效率。", + "未来公司将围绕战略目标,深化数字化转型,拓展新的市场机会。", + "董事会对全体员工的辛勤付出表示衷心感谢,并对未来发展充满信心。"] +JA = ["当社は、お客様に高品質な製品とサービスを提供し、技術革新と事業成長を推進しています。", + "当期において、当社の売上高は主力事業の着実な拡大により前年同期比で増加しました。", + "経営陣は引き続き資源配分を最適化し、リスク管理を強化して業務効率を高めていきます。", + "今後は戦略目標を軸にデジタル変革を深め、新たな市場機会の開拓を進めてまいります。", + "取締役会は全従業員の努力に心より感謝し、今後の発展に自信を持っております。"] +HI = ["कंपनी ग्राहकों को उच्च गुणवत्ता वाले उत्पाद और सेवाएँ प्रदान करने के लिए प्रतिबद्ध है।", + "रिपोर्टिंग अवधि में कंपनी की आय में मुख्य व्यवसाय के विस्तार के कारण वृद्धि हुई।", + "प्रबंधन संसाधनों का अनुकूलन और जोखिम प्रबंधन को मजबूत करना जारी रखेगा।", + "भविष्य में कंपनी रणनीतिक लक्ष्यों के अनुरूप डिजिटल परिवर्तन को गहरा करेगी।", + "निदेशक मंडल सभी कर्मचारियों की कड़ी मेहनत के लिए हृदय से आभार व्यक्त करता है।"] +AR = ["تلتزم الشركة بتقديم منتجات وخدمات عالية الجودة لعملائها في جميع الأسواق.", + "خلال فترة التقرير ارتفعت إيرادات الشركة بفضل التوسع المستمر في الأعمال الأساسية.", + "ستواصل الإدارة تحسين توزيع الموارد وتعزيز إدارة المخاطر لرفع الكفاءة التشغيلية.", + "في المستقبل ستعمق الشركة التحول الرقمي وفق أهدافها الاستراتيجية وتستكشف أسواقا جديدة.", + "يتقدم مجلس الإدارة بخالص الشكر لجميع الموظفين على جهودهم ويثق بمستقبل الشركة."] + +FIXTURES = { + "zh_body_en_headings.pdf": ("china-s", ["公司年度报告", "Financial Review", "Risk Factors", "Business Outlook"], ZH), + "ja_report.pdf": ("japan", ["年次報告書", "財務ハイライト", "リスク要因", "今後の見通し"], JA), + "hi_report.pdf": ("figo", ["वार्षिक रिपोर्ट", "वित्तीय समीक्षा", "जोखिम कारक", "भविष्य की दिशा"], HI), + "ar_report.pdf": ("figo", ["التقرير السنوي", "المراجعة المالية", "عوامل المخاطر", "التوقعات المستقبلية"], AR), +} + + +def build(font, headings, body): + doc = pymupdf.Document() + buffer = pymupdf.Font(font).buffer + for heading in headings: + page = doc.new_page(width=595, height=842) + page.insert_font(fontname="f", fontbuffer=buffer) + page.insert_text((72, 90), heading, fontsize=20, fontname="f") + for row, line in enumerate(body): + page.insert_text((72, 130 + 16 * row), line, fontsize=11, fontname="f") + doc.subset_fonts() + return doc.tobytes(garbage=4, deflate=True, no_new_id=True) + + +if __name__ == "__main__": + for name, (font, headings, body) in FIXTURES.items(): + (HERE / name).write_bytes(build(font, headings, body)) + print(name) diff --git a/tests/data/flash/zh_body_en_headings.pdf b/tests/data/flash/zh_body_en_headings.pdf new file mode 100644 index 0000000000000000000000000000000000000000..3e72b2b040cbe477d23c26d93218ec7cf01b7ae5 GIT binary patch literal 25343 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 Date: Sun, 13 Sep 2026 03:11:32 +0800 Subject: [PATCH 3/8] Flash parser: a multi-code-point glyph no longer crashes the RTL sign A ToUnicode value can be several code points: a Devanagari conjunct, a Thai cluster, an Arabic ligature. _rtl_sign passed the whole value to unicodedata.bidirectional, which takes exactly one character, so every real Hindi and Thai PDF raised TypeError in the char merge, before any rule ran. The first code point now decides the direction, as _reverse_if_rtl already does for the same values; the empty string is LTR. --- .../flash/parser_pdfium_charlevel/text_normalize.py | 4 ++-- tests/test_flash_extraction.py | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/pageindex/flash/parser_pdfium_charlevel/text_normalize.py b/pageindex/flash/parser_pdfium_charlevel/text_normalize.py index 9c477e3f5..21a6f7240 100644 --- a/pageindex/flash/parser_pdfium_charlevel/text_normalize.py +++ b/pageindex/flash/parser_pdfium_charlevel/text_normalize.py @@ -260,8 +260,8 @@ def _apply_bidi_reordering(text: str, start_level: int = -1, vertical: bool = Fa def _rtl_sign(char: str) -> int: - """+1 for LTR runs, -1 for a strong right-to-left char (bidi class R/AL, e.g. Hebrew/Arabic). PDFium reports RTL text in logical order with decreasing char origins, so the LTR ``advance = ox - prev_text_x`` model (prev_text_x = ox+glyph_w, a right edge) yields a large negative advance. For RTL chunks the x-axis is signed with ``sign*ox`` so the reading-direction advance is positive and the existing LTR merge logic applies unchanged.""" - return -1 if unicodedata.bidirectional(char) in ("R", "AL") else 1 + """+1 for LTR runs, -1 for a strong right-to-left char (bidi class R/AL, e.g. Hebrew/Arabic). PDFium reports RTL text in logical order with decreasing char origins, so the LTR ``advance = ox - prev_text_x`` model (prev_text_x = ox+glyph_w, a right edge) yields a large negative advance. For RTL chunks the x-axis is signed with ``sign*ox`` so the reading-direction advance is positive and the existing LTR merge logic applies unchanged. A multi-code-point value (a ligature, a Devanagari conjunct, a Thai cluster) takes the class of its first code point, like ``_reverse_if_rtl``.""" + return -1 if char and unicodedata.bidirectional(char[0]) in ("R", "AL") else 1 def _reverse_if_rtl(chars: str) -> str: diff --git a/tests/test_flash_extraction.py b/tests/test_flash_extraction.py index 3545c137e..ceb7d971e 100644 --- a/tests/test_flash_extraction.py +++ b/tests/test_flash_extraction.py @@ -60,6 +60,17 @@ def test_page_mode_walk_uses_merged_surrogate_census(): assert unmapped["ch"] == "β" +def test_rtl_sign_takes_a_multi_code_point_glyph(): + """A ToUnicode value can be several code points (a Devanagari conjunct, a + Thai cluster, an Arabic ligature); the first one decides the direction.""" + from pageindex.flash.parser_pdfium_charlevel.text_normalize import _rtl_sign + + assert _rtl_sign("\u094d\u0924") == 1 # Devanagari conjunct + assert _rtl_sign("\u0e01\u0e34") == 1 # Thai cluster + assert _rtl_sign("\u0626\u062c") == -1 # Arabic ligature + assert _rtl_sign("") == 1 + + def test_optimize_full_keyless_reports_file_errors_first(tmp_path, monkeypatch): """No credential pre-check: a bad path is a FileNotFoundError even keyless (validation runs first), and the LLM-free spellings still run From b1ac493c4faa770bcd6a11f5fe8cb55c7e944c90 Mon Sep 17 00:00:00 2001 From: Ray Date: Sun, 13 Sep 2026 15:48:53 +0800 Subject: [PATCH 4/8] Flash: title scoring stops looking at script; the docs describe page nodes as emitted The title scorer halved any candidate whose dominant script differed from the document's, the last place flash changed a decision on script. Without the factor the nine example PDFs and the four fixture PDFs extract byte-identically. get_leaf_nodes reads `nodes` with .get like its siblings, so a flat page tree, whose nodes carry no `nodes` key, walks instead of raising KeyError. The README output block and the page_index_flash docstring now say what a node actually carries: node_id always, `nodes` only with children, `summary` only when summaries ran, and a flat tree past FLAT_TREE_MAX_NODES pages coming back unsummarized and unoptimized. The local client's page-fallback test feeds that real node shape. --- pageindex/flash/README.md | 10 ++++++---- pageindex/flash/api.py | 4 ++-- pageindex/flash/title/scoring.py | 12 ++---------- pageindex/utils.py | 2 +- tests/test_client.py | 8 ++++---- tests/test_flash_extraction.py | 12 ++++++++++++ 6 files changed, 27 insertions(+), 21 deletions(-) diff --git a/pageindex/flash/README.md b/pageindex/flash/README.md index a2f58a6e9..d9bc0fc51 100644 --- a/pageindex/flash/README.md +++ b/pageindex/flash/README.md @@ -40,9 +40,9 @@ Writes the tree to `results/_structure.json`. "node_id": str, # 4-digit, zero-padded "start_index": int, # 1-based, inclusive "end_index": int, - "summary": str, + "summary": str, # summary=True only "key_items": [str], # optimize only: titles of subsections merged away - "nodes": [...], + "nodes": [...], # entries with children only } ], "toc_source": str, # "detected" | "bookmarks" | "hybrid" | "pages" | "unreadable" @@ -52,8 +52,10 @@ Writes the tree to `results/_structure.json`. `toc_source` says where the structure came from: `"detected"` from the layout, `"bookmarks"` from the embedded outline, `"hybrid"` when bookmarks frame the detected sections. `"pages"` means the layout yielded no hierarchy, so every -page became one node titled `Page N`; `"unreadable"` means no page carries text -and `structure` is empty. +page became one node titled `Page N`; past `FLAT_TREE_MAX_NODES` (10) pages that +flat tree comes back without summaries or optimization, and the local client and +CLI refuse it. `"unreadable"` means no page carries text and `structure` is +empty. ## Benchmark diff --git a/pageindex/flash/api.py b/pageindex/flash/api.py index 0b5ca59d7..f3a6297d2 100644 --- a/pageindex/flash/api.py +++ b/pageindex/flash/api.py @@ -1,4 +1,4 @@ -"""Public API for PageIndex Flash. The only supported entry point is :func:`page_index_flash`. Everything else in this package is internal pipeline machinery.""" +"""Public API for PageIndex Flash: :func:`page_index_flash` builds the tree, :func:`flash_rejection_reason` is the refusal policy the local client and CLI share. Everything else in this package is internal pipeline machinery.""" from __future__ import annotations @@ -133,7 +133,7 @@ def page_index_flash(pdf, summary=True, summary_model=None, optimize: str | bool | None = None, optimize_expand=None, optimize_model=None, summary_concurrency=None, use_embedded_toc=True) -> dict: - """Build a PageIndex tree structure from a PDF using layout statistics. The tree extraction itself uses no LLM; by default an LLM writes node summaries and expands the tree (``summary=False, optimize=False`` runs fully LLM-free). Args: pdf: path to a PDF file (``str`` or ``pathlib.Path``) or an in-memory binary stream (``io.BytesIO``). summary: if True, generate LLM summaries for each node (requires ``summary_model``). summary_model: the LLM model identifier to use for summary generation. optimize: ``"full"`` for merge + LLM expand (a model unreachable after the retry ladder — a missing credential included — fails the run loudly from expand itself; a per-prompt rejection leaves just that node collapsed), ``"merge"`` for deterministic merge only, ``False`` to disable. ``True`` is accepted as ``"full"`` for backward compatibility; defaults to ``"full"``. Expand needs readable page text, so a bookmark-only or scanned PDF runs the merge half only (``expands`` reports 0). optimize_expand: deprecated — use ``optimize``. Honored only when ``optimize`` is not passed (or is the legacy ``True``): ``False`` maps to ``"merge"``, ``True`` to ``"full"``. optimize_model: the LLM model for expand (defaults to the summary model). summary_concurrency: maximum simultaneous summary model calls; None uses the library default. use_embedded_toc: if True, consume the PDF's embedded bookmarks when trustworthy: deep bookmarks become the frame and the detected sections they lack are grafted back in after noise filtering, coarse ones become the chapter frame with detected nodes re-hung under them (deeper sparse entries are filled in when the page text confirms them, and garbled extracted titles are repaired from the bookmark strings), garbage ones are ignored. On by default; pass False for the pure detected structure. Returns: dict with keys ``doc_name``, ``doc_title``, ``structure`` (a list of nested ``{"title", "start_index", "end_index", "nodes"}`` dicts; page indexes are 1-based) and ``has_abstract_or_references_section`` (True when a top-level entry is an abstract or references heading). ``toc_source`` says where the structure came from: ``"detected"`` (layout), ``"bookmarks"`` (the embedded outline), ``"hybrid"`` (bookmarks framing the detected sections), ``"pages"`` (no hierarchy found, so one node per page titled ``Page N``; left unsummarized and unoptimized when there are more than ``FLAT_TREE_MAX_NODES`` pages, a size the local client and CLI refuse) or ``"unreadable"`` (no page carries text; ``structure`` is empty). With ``optimize`` an ``optimize`` key reports merge/expand counts and before/after search-cost metrics; a refused flat tree carries neither it nor node summaries. """ + """Build a PageIndex tree structure from a PDF using layout statistics. The tree extraction itself uses no LLM; by default an LLM writes node summaries and expands the tree (``summary=False, optimize=False`` runs fully LLM-free). Args: pdf: path to a PDF file (``str`` or ``pathlib.Path``) or an in-memory binary stream (``io.BytesIO``). summary: if True, generate LLM summaries for each node (requires ``summary_model``). summary_model: the LLM model identifier to use for summary generation. optimize: ``"full"`` for merge + LLM expand (a model unreachable after the retry ladder — a missing credential included — fails the run loudly from expand itself; a per-prompt rejection leaves just that node collapsed), ``"merge"`` for deterministic merge only, ``False`` to disable. ``True`` is accepted as ``"full"`` for backward compatibility; defaults to ``"full"``. Expand needs readable page text, so a bookmark-only or scanned PDF runs the merge half only (``expands`` reports 0). optimize_expand: deprecated — use ``optimize``. Honored only when ``optimize`` is not passed (or is the legacy ``True``): ``False`` maps to ``"merge"``, ``True`` to ``"full"``. optimize_model: the LLM model for expand (defaults to the summary model). summary_concurrency: maximum simultaneous summary model calls; None uses the library default. use_embedded_toc: if True, consume the PDF's embedded bookmarks when trustworthy: deep bookmarks become the frame and the detected sections they lack are grafted back in after noise filtering, coarse ones become the chapter frame with detected nodes re-hung under them (deeper sparse entries are filled in when the page text confirms them, and garbled extracted titles are repaired from the bookmark strings), garbage ones are ignored. On by default; pass False for the pure detected structure. Returns: dict with keys ``doc_name``, ``doc_title``, ``structure`` (a list of ``{"title", "node_id", "start_index", "end_index"}`` dicts; ``"nodes"`` holds the children where there are any and ``"summary"`` appears when summaries ran; page indexes are 1-based) and ``has_abstract_or_references_section`` (True when a top-level entry is an abstract or references heading). ``toc_source`` says where the structure came from: ``"detected"`` (layout), ``"bookmarks"`` (the embedded outline), ``"hybrid"`` (bookmarks framing the detected sections), ``"pages"`` (no hierarchy found, so one node per page titled ``Page N``; left unsummarized and unoptimized when there are more than ``FLAT_TREE_MAX_NODES`` pages, a size the local client and CLI refuse) or ``"unreadable"`` (no page carries text; ``structure`` is empty). With ``optimize`` an ``optimize`` key reports merge/expand counts and before/after search-cost metrics; a refused flat tree carries neither it nor node summaries. """ if optimize_expand is not None: import warnings warnings.warn( diff --git a/pageindex/flash/title/scoring.py b/pageindex/flash/title/scoring.py index 7c2c47308..89c382875 100644 --- a/pageindex/flash/title/scoring.py +++ b/pageindex/flash/title/scoring.py @@ -15,7 +15,6 @@ Line, last_line_of, first_span_of, - block_text, deaccented_text, letter_count, dominant_style_of, @@ -24,7 +23,7 @@ alignment_code, Block, ) -from ..stats import DocStats, column_index_of, tally_scripts, dominant_script_family, ScriptHistogram +from ..stats import DocStats, column_index_of from ..tokens import is_superscript_adjacent, clamp_value, enumerate_tokens, jenkins_hash, trie_prefix_match, set_case_fold, TrieConfig, build_trie, tokenize_block, _de_norm, BuiltTrie, is_word_token from .dicts import ( @@ -247,17 +246,10 @@ def score_title_candidate(zp_state, page, index: int) -> None: # Email penalty email = 1.0 / ((1 + email_count) ** 2) - # Script-family match: build a script histogram over the candidate group's text and - # compare the candidate script family against the document script family. - script_acc = ScriptHistogram() - for result_value in group: - tally_scripts(script_acc, block_text(result_value)) - script = 1.0 if dominant_script_family(script_acc) == doc_state.secondary_slot.tertiary_slot else 0.5 - score = ( max_heading_score * len_value * width_ratio_sq * right_pen * bracket_factor * page_pos * density_factor * top * factor * recurrence_factor * institution * label - * email * script + * email ) if zp_state.secondary_slot is None or score > zp_state.secondary_slot.score: diff --git a/pageindex/utils.py b/pageindex/utils.py index f23995057..995d60ec1 100644 --- a/pageindex/utils.py +++ b/pageindex/utils.py @@ -317,7 +317,7 @@ def structure_to_list(structure): def get_leaf_nodes(structure): if isinstance(structure, dict): - if not structure['nodes']: + if not structure.get('nodes'): structure_node = copy.deepcopy(structure) structure_node.pop('nodes', None) return [structure_node] diff --git a/tests/test_client.py b/tests/test_client.py index c10551e98..19df6c6e0 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -2204,10 +2204,10 @@ def test_submit_flash_accepts_page_fallback(local_client, sample_pdf, monkeypatc pageindex.flash, "page_index_flash", lambda pdf, **kwargs: { "doc_name": "sample.pdf", "toc_source": "pages", - "structure": [{"title": "Hello", "start_index": 1, "end_index": 1, - "summary": "s", "nodes": []}, - {"title": "World", "start_index": 2, "end_index": 2, - "summary": "s", "nodes": []}]}) + "structure": [{"title": "Hello", "node_id": "0000", + "start_index": 1, "end_index": 1}, + {"title": "World", "node_id": "0001", + "start_index": 2, "end_index": 2}]}) monkeypatch.setattr(pageindex.utils, "llm_completion", lambda model, prompt, **kw: "Flash description.") doc_id = local_client.submit_document(sample_pdf, mode="flash")["doc_id"] diff --git a/tests/test_flash_extraction.py b/tests/test_flash_extraction.py index ceb7d971e..944d4cde2 100644 --- a/tests/test_flash_extraction.py +++ b/tests/test_flash_extraction.py @@ -672,6 +672,18 @@ def test_page_fallback_covers_every_page(tmp_path): assert all("nodes" not in n for n in result["structure"]) +def test_page_nodes_are_leaves(tmp_path): + """``get_leaf_nodes`` takes a flat page tree, whose nodes carry no ``nodes`` key.""" + from conftest import build_pdf + from pageindex import get_leaf_nodes + from pageindex.flash import page_index_flash + + pdf = tmp_path / "flat.pdf" + pdf.write_bytes(build_pdf(["Alpha body", "Beta body"])) + structure = page_index_flash(str(pdf), summary=False, optimize=False)["structure"] + assert [n["title"] for n in get_leaf_nodes(structure)] == ["Page 1", "Page 2"] + + @pytest.mark.parametrize("name", ["hi_report.pdf", "ar_report.pdf"]) def test_non_latin_document_is_indexed(name): """Script never decides whether a document is indexable.""" From 3fedbd65beeb2be3e9ae756ed5036ae78d6902c1 Mon Sep 17 00:00:00 2001 From: Ray Date: Sun, 13 Sep 2026 17:40:44 +0800 Subject: [PATCH 5/8] Flash: a hierarchy that starts after page 1 gets a Preface node, as in standard mode Retrieval reaches a page only through a node range. When the first heading or the first bookmark sits on a later page, everything before it was in no node: a memo's first section once its heading became the document title, a title slide's body, a report's cover, contents and letter to shareholders, the cover pages of a bookmark tree. Standard mode has always inserted a Preface node for exactly this (utils.add_preface_if_needed); flash now does the same, next to the page fallback and before the optimize and summary passes, and renumbers the node ids. Of the nine example PDFs, the two Federal Reserve reports and Four Lectures gain the node (pages 1-4, 1-2 and 1); the other six and every extract_toc result are unchanged. The four fixtures gain a Preface over their title page. --- pageindex/flash/README.md | 3 ++ pageindex/flash/api.py | 12 +++++++- tests/test_flash_extraction.py | 52 +++++++++++++++++++++++++++++++++- 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/pageindex/flash/README.md b/pageindex/flash/README.md index d9bc0fc51..7ecff60f6 100644 --- a/pageindex/flash/README.md +++ b/pageindex/flash/README.md @@ -57,6 +57,9 @@ flat tree comes back without summaries or optimization, and the local client and CLI refuse it. `"unreadable"` means no page carries text and `structure` is empty. +Every page is in some node: a hierarchy that starts after page 1 is preceded by +a `Preface` node covering the pages before it, as in standard mode. + ## Benchmark Nine PDFs, each run end to end with tree optimization: PDF parse, layout diff --git a/pageindex/flash/api.py b/pageindex/flash/api.py index f3a6297d2..02a6eebf1 100644 --- a/pageindex/flash/api.py +++ b/pageindex/flash/api.py @@ -109,6 +109,14 @@ def _page_nodes(page_texts: list[str]) -> list[dict]: return nodes +def _add_preface(structure: list[dict]) -> None: + """The pages before a hierarchy that starts late become a Preface node, as in standard mode.""" + from ..utils import write_node_id + structure.insert(0, {"title": "Preface", "start_index": 1, + "end_index": structure[0]["start_index"] - 1}) + write_node_id(structure) + + def flash_rejection_reason(result: dict, standard_hint: str = "mode='standard'") -> str | None: """Why a managed pipeline should refuse this flash result, or None to accept it. @@ -133,7 +141,7 @@ def page_index_flash(pdf, summary=True, summary_model=None, optimize: str | bool | None = None, optimize_expand=None, optimize_model=None, summary_concurrency=None, use_embedded_toc=True) -> dict: - """Build a PageIndex tree structure from a PDF using layout statistics. The tree extraction itself uses no LLM; by default an LLM writes node summaries and expands the tree (``summary=False, optimize=False`` runs fully LLM-free). Args: pdf: path to a PDF file (``str`` or ``pathlib.Path``) or an in-memory binary stream (``io.BytesIO``). summary: if True, generate LLM summaries for each node (requires ``summary_model``). summary_model: the LLM model identifier to use for summary generation. optimize: ``"full"`` for merge + LLM expand (a model unreachable after the retry ladder — a missing credential included — fails the run loudly from expand itself; a per-prompt rejection leaves just that node collapsed), ``"merge"`` for deterministic merge only, ``False`` to disable. ``True`` is accepted as ``"full"`` for backward compatibility; defaults to ``"full"``. Expand needs readable page text, so a bookmark-only or scanned PDF runs the merge half only (``expands`` reports 0). optimize_expand: deprecated — use ``optimize``. Honored only when ``optimize`` is not passed (or is the legacy ``True``): ``False`` maps to ``"merge"``, ``True`` to ``"full"``. optimize_model: the LLM model for expand (defaults to the summary model). summary_concurrency: maximum simultaneous summary model calls; None uses the library default. use_embedded_toc: if True, consume the PDF's embedded bookmarks when trustworthy: deep bookmarks become the frame and the detected sections they lack are grafted back in after noise filtering, coarse ones become the chapter frame with detected nodes re-hung under them (deeper sparse entries are filled in when the page text confirms them, and garbled extracted titles are repaired from the bookmark strings), garbage ones are ignored. On by default; pass False for the pure detected structure. Returns: dict with keys ``doc_name``, ``doc_title``, ``structure`` (a list of ``{"title", "node_id", "start_index", "end_index"}`` dicts; ``"nodes"`` holds the children where there are any and ``"summary"`` appears when summaries ran; page indexes are 1-based) and ``has_abstract_or_references_section`` (True when a top-level entry is an abstract or references heading). ``toc_source`` says where the structure came from: ``"detected"`` (layout), ``"bookmarks"`` (the embedded outline), ``"hybrid"`` (bookmarks framing the detected sections), ``"pages"`` (no hierarchy found, so one node per page titled ``Page N``; left unsummarized and unoptimized when there are more than ``FLAT_TREE_MAX_NODES`` pages, a size the local client and CLI refuse) or ``"unreadable"`` (no page carries text; ``structure`` is empty). With ``optimize`` an ``optimize`` key reports merge/expand counts and before/after search-cost metrics; a refused flat tree carries neither it nor node summaries. """ + """Build a PageIndex tree structure from a PDF using layout statistics. The tree extraction itself uses no LLM; by default an LLM writes node summaries and expands the tree (``summary=False, optimize=False`` runs fully LLM-free). Args: pdf: path to a PDF file (``str`` or ``pathlib.Path``) or an in-memory binary stream (``io.BytesIO``). summary: if True, generate LLM summaries for each node (requires ``summary_model``). summary_model: the LLM model identifier to use for summary generation. optimize: ``"full"`` for merge + LLM expand (a model unreachable after the retry ladder — a missing credential included — fails the run loudly from expand itself; a per-prompt rejection leaves just that node collapsed), ``"merge"`` for deterministic merge only, ``False`` to disable. ``True`` is accepted as ``"full"`` for backward compatibility; defaults to ``"full"``. Expand needs readable page text, so a bookmark-only or scanned PDF runs the merge half only (``expands`` reports 0). optimize_expand: deprecated — use ``optimize``. Honored only when ``optimize`` is not passed (or is the legacy ``True``): ``False`` maps to ``"merge"``, ``True`` to ``"full"``. optimize_model: the LLM model for expand (defaults to the summary model). summary_concurrency: maximum simultaneous summary model calls; None uses the library default. use_embedded_toc: if True, consume the PDF's embedded bookmarks when trustworthy: deep bookmarks become the frame and the detected sections they lack are grafted back in after noise filtering, coarse ones become the chapter frame with detected nodes re-hung under them (deeper sparse entries are filled in when the page text confirms them, and garbled extracted titles are repaired from the bookmark strings), garbage ones are ignored. On by default; pass False for the pure detected structure. Returns: dict with keys ``doc_name``, ``doc_title``, ``structure`` (a list of ``{"title", "node_id", "start_index", "end_index"}`` dicts; ``"nodes"`` holds the children where there are any and ``"summary"`` appears when summaries ran; page indexes are 1-based; a hierarchy that starts after page 1 is preceded by a ``Preface`` node covering the pages before it, as in standard mode) and ``has_abstract_or_references_section`` (True when a top-level entry is an abstract or references heading). ``toc_source`` says where the structure came from: ``"detected"`` (layout), ``"bookmarks"`` (the embedded outline), ``"hybrid"`` (bookmarks framing the detected sections), ``"pages"`` (no hierarchy found, so one node per page titled ``Page N``; left unsummarized and unoptimized when there are more than ``FLAT_TREE_MAX_NODES`` pages, a size the local client and CLI refuse) or ``"unreadable"`` (no page carries text; ``structure`` is empty). With ``optimize`` an ``optimize`` key reports merge/expand counts and before/after search-cost metrics; a refused flat tree carries neither it nor node summaries. """ if optimize_expand is not None: import warnings warnings.warn( @@ -157,6 +165,8 @@ def page_index_flash(pdf, summary=True, summary_model=None, structure = _page_nodes(result.get("page_texts") or []) result["structure"] = structure result["toc_source"] = "pages" if structure else "unreadable" + elif structure[0]["start_index"] > 1: + _add_preface(structure) if result.get("toc_source") == "pages" and len(structure) > FLAT_TREE_MAX_NODES: # the managed pipelines refuse a flat tree this size; skip the model passes result.pop("page_texts", None) diff --git a/tests/test_flash_extraction.py b/tests/test_flash_extraction.py index 944d4cde2..f799db237 100644 --- a/tests/test_flash_extraction.py +++ b/tests/test_flash_extraction.py @@ -581,7 +581,8 @@ def test_toc_source_is_always_present(tmp_path): result = page_index_flash(str(pdf), summary=False, optimize=False, use_embedded_toc=False) assert result["toc_source"] == "detected" - assert [node["title"] for node in result["structure"]] == ["Budget", "Team"] + assert [node["title"] for node in result["structure"]] == [ + "Preface", "Budget", "Team"] def test_no_hierarchy_falls_back_to_page_nodes(tmp_path): @@ -684,6 +685,55 @@ def test_page_nodes_are_leaves(tmp_path): assert [n["title"] for n in get_leaf_nodes(structure)] == ["Page 1", "Page 2"] +def test_every_page_is_in_a_node(tmp_path): + """Top-level ranges cover the whole document: a hierarchy that starts after + page 1 is preceded by a Preface node, as in standard mode.""" + import pypdfium2 as pdfium + from pageindex.flash import page_index_flash + + memo = tmp_path / "memo.pdf" + memo.write_bytes(_layout_pdf([("Mission", ["The launch is named Skylark."]), + ("Budget", ["The budget is 420 euros."]), + ("Team", ["The lead is Ada."])])) + deck = tmp_path / "deck.pdf" + deck.write_bytes(_deck_pdf()) + for pdf in [memo, deck, *sorted(FLASH_DATA.glob("*.pdf"))]: + document = pdfium.PdfDocument(str(pdf)) + pages = len(document) + document.close() + structure = page_index_flash(str(pdf), summary=False, optimize=False)["structure"] + covered = {page for node in structure + for page in range(node["start_index"], node["end_index"] + 1)} + assert covered == set(range(1, pages + 1)), pdf.name + assert structure[0] == {"title": "Preface", "node_id": "0000", + "start_index": 1, "end_index": 1}, pdf.name + assert structure[1]["node_id"] == "0001", pdf.name + + +def test_preface_page_is_retrievable(tmp_path, monkeypatch): + """The page a late-starting hierarchy skips reaches the client's tree text.""" + import pageindex.utils + from pageindex import PageIndexClient + from pageindex.flash import api as flash_api + + async def no_summary(*args, **kwargs): + return None + monkeypatch.setattr(flash_api, "_optimize", lambda *a, **k: {"merges": 0}) + monkeypatch.setattr(flash_api, "_summarize", no_summary) + monkeypatch.setattr(pageindex.utils, "llm_completion", + lambda model, prompt, **kw: "A memo.") + pdf = tmp_path / "memo.pdf" + pdf.write_bytes(_layout_pdf([("Mission", ["The launch is named Skylark."]), + ("Budget", ["The budget is 420 euros."]), + ("Team", ["The lead is Ada."])])) + client = PageIndexClient(storage_path=str(tmp_path / "store")) + doc_id = client.submit_document(str(pdf), mode="flash")["doc_id"] + tree = client.get_tree(doc_id)["result"] + assert [(node["title"], node["page_index"]) for node in tree] == [ + ("Preface", 1), ("Budget", 2), ("Team", 3)] + assert "Skylark" in tree[0]["text"] + + @pytest.mark.parametrize("name", ["hi_report.pdf", "ar_report.pdf"]) def test_non_latin_document_is_indexed(name): """Script never decides whether a document is indexable.""" From f0aeda5d7f3244206427113ba0111f2989dd0c90 Mon Sep 17 00:00:00 2001 From: Ray Date: Wed, 16 Sep 2026 19:59:57 +0800 Subject: [PATCH 6/8] Align local prompt copies with the chat MCP server The frozen citation prompt carried an invented "Sources" footer and the agent instructions had a pagination tutorial chat never shipped; the persistence protocol was missing the rephrase-and-retry step chat has had since April. --- pageindex/agent_tools.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/pageindex/agent_tools.py b/pageindex/agent_tools.py index afac6b9d5..40b76cf23 100644 --- a/pageindex/agent_tools.py +++ b/pageindex/agent_tools.py @@ -1596,7 +1596,7 @@ def build_agent_tools(client, include_management: bool = False, _DISCOVERY = """\ DOCUMENT DISCOVERY: -- browse_documents() — DEFAULT discovery tool, first choice for any document-related question. It lists your documents newest first with names and descriptions; match them against the user's intent, and page through with `offset: next_offset` while has_more is true.""" +- browse_documents() — DEFAULT discovery tool, first choice for any document-related question. The bare call returns your documents newest first with names and descriptions; match them against the user's intent.""" _DECISION = """\ DECISION: @@ -1612,9 +1612,9 @@ def build_agent_tools(client, include_management: bool = False, PERSISTENCE (before concluding the target document is not in the library): This protocol applies both when results are empty AND when results are returned but none match the user's intent. Do NOT give up after a single discovery attempt. Follow these steps in order: 1. browse_documents() and compare every returned name/description against the user's intent -2. Page through the ENTIRE library with `limit: 50` and `offset: next_offset` until has_more is false — MANDATORY, must be completed before concluding "not found" -3. Re-scan for loose matches: synonyms, abbreviations, and partial titles in names/descriptions can identify the target -Only after ALL three steps have been tried may you conclude the document is not in the library. Do NOT fall back to general knowledge — if the user's question references their own documents, exhaust every discovery path first.""" +2. Rephrase the query with synonyms or alternative terms and browse again +3. Page through the ENTIRE library with `limit: 50` and `offset: next_offset` until has_more is false — MANDATORY, must be completed before concluding "not found" +Only after ALL steps have been tried may you conclude the document is not in the library. Do NOT fall back to general knowledge — if the user's question references their own documents, exhaust every discovery path first.""" AGENT_INSTRUCTIONS = "\n\n".join([ _INSTRUCTIONS_HEADER, @@ -1651,8 +1651,7 @@ def build_agent_tools(client, include_management: bool = False, - Cite only statements supported by tool outputs: or . Place immediately after the claim. - When page content includes block_id values, citations MUST be block-level: copy the exact block_id of the supporting block. Page-only cites are allowed ONLY when the tool output carries no block_id (legacy documents, structure outlines). NEVER invent or alter block_id values. - For a claim drawn from multiple blocks on one page, add one tag per supporting block (at most 3); beyond that, cite the single strongest block. -- Each tag must reference a SINGLE page integer. For multi-page citations, use separate tags. -- Close every answer with a "Sources" section: one plain-text line per cited block, formatted `- , page (block )`. Keep it even when the inline tags are present — a client that strips unknown HTML tags would otherwise leave the answer with no visible citations at all.""", +- Each tag must reference a SINGLE page integer. For multi-page citations, use separate tags.""", "footnote": """\ GROUNDING - Answer only from the user's PageIndex documents. Call get_page_content() and state only what was actually read there. From fa4644c67e13a690dd3057361190f00f261a33b0 Mon Sep 17 00:00:00 2001 From: Ray Date: Wed, 16 Sep 2026 20:17:03 +0800 Subject: [PATCH 7/8] get_document_id(name): look up a document ID by its display name One API call via the new server-side name filter on /docs, so no client-side pagination. Cloud and local both covered. --- pageindex/client.py | 13 +++++++++++++ pageindex/cloud_api.py | 4 +++- pageindex/local_api.py | 3 +++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/pageindex/client.py b/pageindex/client.py index ffac91d02..aaa93a594 100644 --- a/pageindex/client.py +++ b/pageindex/client.py @@ -1653,6 +1653,19 @@ def get_document(self, doc_id: str) -> dict[str, Any]: """ return self._api.get_document(doc_id=doc_id) + def get_document_id(self, name: str) -> str: + """ + Look up a document's ID by its name. Useful for resolving + citation doc names (from ````) to IDs. + + Raises PageIndexAPIError if no document with that name exists. + """ + result = self._api.list_documents(limit=1, name=name) + docs = result.get("documents", []) + if docs: + return docs[0]["id"] + raise PageIndexAPIError(f"No document named {name!r} found.") + def delete_document(self, doc_id: str) -> dict[str, Any]: """ Delete a PageIndex document and all its associated data. diff --git a/pageindex/cloud_api.py b/pageindex/cloud_api.py index 94e2cfd39..a8fc7a7e6 100644 --- a/pageindex/cloud_api.py +++ b/pageindex/cloud_api.py @@ -369,7 +369,7 @@ def delete_document(self, doc_id: str) -> Dict[str, Any]: status_code=response.status_code) return response.json() if response.content else {} - def list_documents(self, limit: int = 50, offset: int = 0, folder_id: Optional[str] = None) -> Dict[str, Any]: + def list_documents(self, limit: int = 50, offset: int = 0, folder_id: Optional[str] = None, name: Optional[str] = None) -> Dict[str, Any]: """ List all documents for the authenticated user with pagination. @@ -394,6 +394,8 @@ def list_documents(self, limit: int = 50, offset: int = 0, folder_id: Optional[s params: Dict[str, Any] = {"limit": limit, "offset": offset} if folder_id is not None: params["folder_id"] = folder_id + if name is not None: + params["name"] = name response = requests.get( f"{self.BASE_URL}/docs/", diff --git a/pageindex/local_api.py b/pageindex/local_api.py index 9443c953b..21a3abcd8 100644 --- a/pageindex/local_api.py +++ b/pageindex/local_api.py @@ -346,6 +346,7 @@ def list_documents( limit: int = 50, offset: int = 0, folder_id: str | None = None, + name: str | None = None, ) -> dict[str, Any]: if limit < 1 or limit > 100: raise ValueError("limit must be between 1 and 100") @@ -357,6 +358,8 @@ def list_documents( ) metas = sorted(self._store.list_metas(), key=lambda m: m.get("id") or "") metas.sort(key=lambda m: m.get("createdAt") or "", reverse=True) + if name is not None: + metas = [m for m in metas if m.get("name") == name] documents = [{ "id": m.get("id"), "name": m.get("name"), From 3b4d0e2f539ff588b9cd62fc2805f6b244b69a5f Mon Sep 17 00:00:00 2001 From: Ray Date: Wed, 16 Sep 2026 20:20:13 +0800 Subject: [PATCH 8/8] Fix citation parity test for the Sources line removal The test asserted the frozen copy matched the cloud exactly minus get_document_image(); the Sources line is now a second deliberate omission in the cite format. --- tests/test_agent_tools.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/test_agent_tools.py b/tests/test_agent_tools.py index 3de8384ab..01d91d664 100644 --- a/tests/test_agent_tools.py +++ b/tests/test_agent_tools.py @@ -2406,14 +2406,19 @@ def test_citation_prompt_local_frozen_copy(client): @pytest.mark.skipif(not LIVE_KEY, reason="PAGEINDEX_API_KEY not set") def test_live_local_citation_prompts_match_cloud(): - """The frozen local copies are the server's texts minus the one bullet - naming get_document_image(); any other server edit fails here.""" + """The frozen local copies are the server's texts minus the bullet + naming get_document_image() and the 'Sources' footer (cite format + only — SDK users who can't render tags use the markdown or + footnote format instead).""" from pageindex.agent_tools import LOCAL_CITATION_PROMPTS cloud = PageIndexCloudClient(api_key=LIVE_KEY) for fmt, frozen in LOCAL_CITATION_PROMPTS.items(): live = cloud.citation_prompt(format=fmt).split("\n") - dropped = [line for line in live if "get_document_image()" in line] - assert len(dropped) == 1, fmt + dropped = [line for line in live + if "get_document_image()" in line + or (fmt == "cite" and "Sources" in line)] + expected_drops = 2 if fmt == "cite" else 1 + assert len(dropped) == expected_drops, f"{fmt}: expected {expected_drops} dropped lines, got {len(dropped)}" assert "\n".join(line for line in live if line not in dropped) == frozen