feat: text-layer grounding (extractor authority, guard rescue tier, verifier oracle + hi-DPI crops)
- backend/text_layer.py: PyMuPDF text-layer extraction, fuzzy evidence
bbox matching, 300-DPI crop rendering, coverage-gap signal
- extractor (classic + agent): TEXT LAYER block appended at call sites;
grounding guard gains text-layer rescue tier (grounding=text_layer stamp)
- verifier: {text_layer} oracle excerpt + evidence-located hi-DPI crops
replacing full-page images (fallback preserved, I2 guard intact)
- coverage gaps: text-bearing pages with zero extraction -> failed-scope
gap findings (agent) / log-only (classic)
- config knobs: TEXT_LAYER_ENABLED/MIN_CHARS/MAX_CHARS, VERIFY_TEXT_MAX_CHARS,
VERIFY_HI_DPI_CROPS, VERIFY_CROP_DPI, VERIFY_CROP_MARGIN_PTS
- tests: 22 new (text_layer unit, grounding/render, runner-level flow)
Spec: docs/superpowers/specs/2026-08-12-text-layer-grounding-design.md
This commit is contained in:
@@ -64,7 +64,8 @@ def discipline_from_sheet_number(sheet_number: Optional[str]) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _is_grounded(value: str, source_text: str, graphical_basis: str = "") -> bool:
|
||||
def _is_grounded(value: str, source_text: str, graphical_basis: str = "",
|
||||
page_text: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Keep an object only if its primary value is supported by its source_text,
|
||||
OR it is a graphical object (has graphical_basis with no text to quote).
|
||||
@@ -72,6 +73,9 @@ def _is_grounded(value: str, source_text: str, graphical_basis: str = "") -> boo
|
||||
- If graphical_basis is set and source_text is absent, the object is valid.
|
||||
- If the value contains digits, every distinct digit-run must appear in
|
||||
source_text (catches invented dimensions/counts/elevations).
|
||||
- Rescue tier: when page_text (the deterministic text layer) is given,
|
||||
digit-runs absent from source_text but present in the page text are
|
||||
still grounded - vision quoted imperfectly but the value is real.
|
||||
- If the value has no digits, require some alphabetic-token overlap.
|
||||
"""
|
||||
# Graphical objects (no readable text on sheet) are always allowed through.
|
||||
@@ -85,7 +89,11 @@ def _is_grounded(value: str, source_text: str, graphical_basis: str = "") -> boo
|
||||
val_digits = set(_DIGITS_RE.findall(value))
|
||||
if val_digits:
|
||||
src_digits = set(_DIGITS_RE.findall(source_text))
|
||||
return val_digits.issubset(src_digits)
|
||||
if val_digits.issubset(src_digits):
|
||||
return True
|
||||
if page_text:
|
||||
return val_digits.issubset(set(_DIGITS_RE.findall(page_text)))
|
||||
return False
|
||||
|
||||
# No digits: text-based grounding.
|
||||
val_norm = re.sub(r"[^a-z0-9]+", " ", value.lower()).strip()
|
||||
@@ -109,7 +117,24 @@ def _primary_value(obj: Dict) -> str:
|
||||
or obj.get("name") or obj.get("tag") or "")
|
||||
|
||||
|
||||
def _normalize_sheet(parsed: Dict, page_number: int) -> Dict:
|
||||
def _grounding_stamp(value: str, source_text: str,
|
||||
page_text: Optional[str]) -> Optional[str]:
|
||||
"""\"text_layer\" when the object survived only via the text-layer rescue
|
||||
tier (digits absent from source_text but present in the page text)."""
|
||||
if not page_text:
|
||||
return None
|
||||
val_digits = set(_DIGITS_RE.findall(str(value)))
|
||||
if not val_digits:
|
||||
return None
|
||||
if val_digits.issubset(set(_DIGITS_RE.findall(source_text))):
|
||||
return None
|
||||
if val_digits.issubset(set(_DIGITS_RE.findall(page_text))):
|
||||
return "text_layer"
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_sheet(parsed: Dict, page_number: int,
|
||||
page_text: Optional[str] = None) -> Dict:
|
||||
"""
|
||||
Validate + clean one parsed sheet result, attaching page_number and ids.
|
||||
|
||||
@@ -138,6 +163,7 @@ def _normalize_sheet(parsed: Dict, page_number: int) -> Dict:
|
||||
raw_objects = parsed.get("objects") or parsed.get("assertions") or []
|
||||
clean: List[Dict] = []
|
||||
dropped = 0
|
||||
rescued = 0
|
||||
|
||||
for idx, obj in enumerate(raw_objects):
|
||||
if not isinstance(obj, dict):
|
||||
@@ -149,9 +175,13 @@ def _normalize_sheet(parsed: Dict, page_number: int) -> Dict:
|
||||
# Derive a primary value for the grounding check
|
||||
primary_val = _primary_value(obj)
|
||||
|
||||
if not _is_grounded(primary_val, source_text, graphical_basis):
|
||||
if not _is_grounded(primary_val, source_text, graphical_basis,
|
||||
page_text=page_text):
|
||||
dropped += 1
|
||||
continue
|
||||
grounding = _grounding_stamp(primary_val, source_text, page_text)
|
||||
if grounding:
|
||||
rescued += 1
|
||||
|
||||
# --- location_key: new schema is richer; map to legacy shape + extras ---
|
||||
lk = obj.get("location_key")
|
||||
@@ -203,10 +233,13 @@ def _normalize_sheet(parsed: Dict, page_number: int) -> Dict:
|
||||
"object_attributes": attrs,
|
||||
"graphical_basis": graphical_basis or None,
|
||||
"review_uses": obj.get("review_uses") or [],
|
||||
**({"grounding": grounding} if grounding else {}),
|
||||
})
|
||||
|
||||
if dropped:
|
||||
print(f"[Extract] Page {page_number} ({sheet_number}): dropped {dropped} ungrounded object(s)")
|
||||
if dropped or rescued:
|
||||
print(f"[Extract] Page {page_number} ({sheet_number}): "
|
||||
f"dropped {dropped} ungrounded object(s)"
|
||||
+ (f", rescued {rescued} via text layer" if rescued else ""))
|
||||
|
||||
unresolved = parsed.get("unresolved_items") or []
|
||||
|
||||
@@ -223,8 +256,23 @@ def _normalize_sheet(parsed: Dict, page_number: int) -> Dict:
|
||||
}
|
||||
|
||||
|
||||
def _text_layer_block(page: Dict) -> str:
|
||||
"""
|
||||
The TEXT LAYER block appended to the extractor instruction at call sites
|
||||
(NOT a template placeholder - render() silently leaves missing keys as
|
||||
literals). Empty string when the page has no usable text layer.
|
||||
"""
|
||||
text = (page.get("text_layer") or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
return ("\n\nTEXT LAYER (authoritative for alphanumeric content — trust it "
|
||||
"over the image for numbers, tags, and note text):\n"
|
||||
+ text[:config.TEXT_LAYER_MAX_CHARS])
|
||||
|
||||
|
||||
def _extract_one(page: Dict, sheet_hint: str = "") -> Dict:
|
||||
user_text = EXTRACTOR_USER_INSTRUCTION.replace("{sheet_hint}", sheet_hint)
|
||||
user_text = (EXTRACTOR_USER_INSTRUCTION.replace("{sheet_hint}", sheet_hint)
|
||||
+ _text_layer_block(page))
|
||||
parsed = call_json(
|
||||
system_prompt=EXTRACTOR_SYSTEM_PROMPT,
|
||||
user_text=user_text,
|
||||
@@ -241,7 +289,8 @@ def _extract_one(page: Dict, sheet_hint: str = "") -> Dict:
|
||||
"scale": None,
|
||||
"assertions": [],
|
||||
}
|
||||
return _normalize_sheet(parsed, page["page_number"])
|
||||
return _normalize_sheet(parsed, page["page_number"],
|
||||
page_text=page.get("text_layer"))
|
||||
|
||||
|
||||
def extract_assertions(pages: List[Dict], on_progress=None) -> List[Dict]:
|
||||
|
||||
Reference in New Issue
Block a user