Coverage for src / quber / core / extractors / dual / camelot.py: 100%
18 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-09-23 22:14 -0400
« prev ^ index » next coverage.py v7.14.0, created at 2026-09-23 22:14 -0400
1"""Camelot flow: pull cell grids straight from the PDF.
3Runs both Camelot flavors over the whole document, drops only the
4content-empty shells lattice leaves on whitespace-aligned pages, and tags
5each surviving grid with an is-table verdict. The verdict is METADATA: it
6is recorded for later cross-reference, never used to drop a candidate —
7deciding what is a real table is the visual flow's job, not Camelot's.
8"""
10from __future__ import annotations
12import asyncio
13from pathlib import Path
14from typing import List, Optional
16from quber.agents.classifier import TableClassifier
17from quber.core.extractors.camelot.acquire import (
18 CamelotCandidate,
19 is_content_empty,
20 run_camelot_flavors_parallel,
21)
22from quber.core.extractors.dual.models import CamelotTable
25async def run_camelot_flow(
26 source: Path,
27 classifier: Optional[TableClassifier] = None,
28 concurrency: int = 8,
29) -> List[CamelotTable]:
30 """Extract Camelot grids from `source`, tagging each with an is-table verdict.
32 With no classifier, the grids are returned untagged (`classification`
33 None). The content-empty shells are always dropped; nothing else is.
34 """
35 candidates = await asyncio.to_thread(run_camelot_flavors_parallel, source)
36 kept = [c for c in candidates if not is_content_empty(c.markdown)]
38 if classifier is None:
39 return [CamelotTable(candidate=c, classification=None) for c in kept]
41 semaphore = asyncio.Semaphore(concurrency)
43 async def tag(candidate: CamelotCandidate) -> CamelotTable:
44 async with semaphore:
45 verdict = await classifier.classify(candidate.markdown)
46 return CamelotTable(candidate=candidate, classification=verdict)
48 return list(await asyncio.gather(*(tag(c) for c in kept)))