feat: coverage-driven extraction retry ladder (agent path)
Docker Release / build-and-push (push) Successful in 1m14s
Docker Release / release (push) Skipped

This commit is contained in:
2026-08-18 13:38:30 -05:00
parent 48fefa4007
commit fe09e4a66b
4 changed files with 195 additions and 7 deletions
+101 -5
View File
@@ -6,7 +6,11 @@ from typing import Dict
from backend import config
from backend.agents.base import AgentResult, AgentScope, AgentUsage, failure
from backend.llm import call_json
from backend.pipeline.extractor import _normalize_sheet, _text_layer_block
from backend.pipeline.extractor import (
_normalize_sheet,
_text_layer_block,
discipline_from_sheet_number,
)
from backend.pipeline.sheet_index import _index_input
from backend.prompts import (
EXTRACTOR_SYSTEM_PROMPT,
@@ -60,12 +64,39 @@ class SheetExtractorAgent:
reasoning_max_tokens=config.EXTRACT_REASONING_MAX_TOKENS or None,
)
def _text_structuring_call(self, instruction_page: Dict, sheet_hint: str):
"""Rung 2: text-only structuring pass over the page's text layer
(no image). Recovers text content the vision pass missed."""
from backend.prompts import (TEXT_STRUCTURING_SYSTEM_PROMPT,
TEXT_STRUCTURING_USER_INSTRUCTION)
instruction = (TEXT_STRUCTURING_USER_INSTRUCTION
.replace("{sheet_hint}", str(sheet_hint or ""))
.replace("{text_layer}",
(instruction_page.get("text_layer") or "")
[:config.TEXT_LAYER_MAX_CHARS]))
return call_json(
system_prompt=TEXT_STRUCTURING_SYSTEM_PROMPT,
user_text=instruction,
images_b64=None,
max_tokens=config.EXTRACT_MAX_TOKENS,
model=config.AGENT_EXTRACT_MODEL,
usage_tracker=self.usage,
usage_stage="agent.extract_text",
reasoning_effort=config.EXTRACT_REASONING_EFFORT or None,
reasoning_max_tokens=config.EXTRACT_REASONING_MAX_TOKENS or None,
)
def run(self, scope: AgentScope) -> AgentResult:
from backend.text_coverage import (fallback_objects, merge_objects,
recover_sheet_number, text_coverage)
try:
page = scope.payload["page"]
hint = scope.payload.get("sheet_hint") or ""
page_text = page.get("text_layer")
instruction = EXTRACTOR_USER_INSTRUCTION.replace(
"{sheet_hint}", str(scope.payload.get("sheet_hint") or "")
) + _text_layer_block(page)
"{sheet_hint}", str(hint)) + _text_layer_block(page)
# Rung 1: vision pass (unchanged behaviour, incl. compact retry)
parsed = _wrap_bare_list(self._call(instruction, page),
page["page_number"])
if not isinstance(parsed, dict):
@@ -78,9 +109,74 @@ class SheetExtractorAgent:
page["page_number"],
)
if not isinstance(parsed, dict):
raise ValueError("no structured extraction returned")
# Don't give up on the page - the ladder below can still
# rescue it from the text layer.
parsed = {"sheet": {}, "objects": []}
sheet = _normalize_sheet(parsed, page["page_number"],
page_text=page.get("text_layer"))
page_text=page_text)
cov = text_coverage(page_text or "", sheet["assertions"])
sheet["coverage"] = cov
# Rung 2: text-only structuring when coverage is below floor.
# MERGE, never replace: vision keeps every object it found
# (graphical_basis content exists only in the image); the text
# pass fills in the text content the vision pass missed.
if (page_text and config.EXTRACT_TEXT_RETRY_ENABLED
and cov["ratio"] < config.EXTRACT_COVERAGE_FLOOR):
print(f"[Extract] Page {page['page_number']}: coverage "
f"{cov['ratio']:.0%} < floor - text-only structuring pass")
parsed2 = _wrap_bare_list(
self._text_structuring_call(page, hint), page["page_number"])
if isinstance(parsed2, dict):
sheet2 = _normalize_sheet(parsed2, page["page_number"],
page_text=page_text)
before = len(sheet["assertions"])
sheet["assertions"] = merge_objects(sheet["assertions"],
sheet2["assertions"])
# Fill header gaps the vision pass left null
for key in ("sheet_number", "sheet_title", "discipline",
"level", "scale", "drawing_type"):
if not sheet.get(key) and sheet2.get(key):
sheet[key] = sheet2[key]
cov = text_coverage(page_text, sheet["assertions"])
sheet["coverage"] = cov
print(f"[Extract] Page {page['page_number']}: merged "
f"{len(sheet['assertions']) - before} text-structured "
f"object(s), coverage now {cov['ratio']:.0%}")
# Rung 3: deterministic fallback - dark sheets are impossible.
# Also merged (deduped) so stub notes never double up with
# objects the earlier rungs already captured.
if (page_text and config.EXTRACT_FALLBACK_ENABLED
and cov["ratio"] < config.EXTRACT_COVERAGE_FLOOR):
stubs = fallback_objects(page_text, page["page_number"],
config.EXTRACT_FALLBACK_MAX_OBJECTS)
stubs = _normalize_sheet({"sheet": {}, "objects": stubs},
page["page_number"],
page_text=page_text)["assertions"]
# _normalize_sheet only stamps its own "text_layer" rescue
# grounding; restore the explicit fallback provenance.
for stub in stubs:
stub["grounding"] = "text_layer_fallback"
before = len(sheet["assertions"])
sheet["assertions"] = merge_objects(sheet["assertions"], stubs)
print(f"[Extract] Page {page['page_number']}: fallback merged "
f"{len(sheet['assertions']) - before} text-layer stub(s)")
sheet["coverage"] = text_coverage(page_text,
sheet["assertions"])
# Identity recovery: never leave a text-bearing page sheet-less
if not sheet.get("sheet_number") and page_text:
recovered = recover_sheet_number(page_text)
if recovered:
sheet["sheet_number"] = recovered
sheet["discipline"] = (
discipline_from_sheet_number(recovered)
or sheet.get("discipline") or "Unknown")
print(f"[Extract] Page {page['page_number']}: sheet number "
f"recovered from text layer -> {recovered}")
return AgentResult(scope_id=scope.scope_id, artifacts=[sheet])
except Exception as exc:
return failure(scope, exc)