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

1"""Camelot flow: pull cell grids straight from the PDF. 

2 

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""" 

9 

10from __future__ import annotations 

11 

12import asyncio 

13from pathlib import Path 

14from typing import List, Optional 

15 

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 

23 

24 

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. 

31 

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)] 

37 

38 if classifier is None: 

39 return [CamelotTable(candidate=c, classification=None) for c in kept] 

40 

41 semaphore = asyncio.Semaphore(concurrency) 

42 

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) 

47 

48 return list(await asyncio.gather(*(tag(c) for c in kept)))