Files
Conflict_Checker/backend/agents/extractors.py
T
woogi 570300324f
Docker Release / build-and-push (push) Successful in 1m25s
Docker Release / release (push) Skipped
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
2026-08-12 14:27:00 -05:00

146 lines
5.9 KiB
Python

"""Scoped extraction and orientation agents."""
import json
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.sheet_index import _index_input
from backend.prompts import (
EXTRACTOR_SYSTEM_PROMPT,
EXTRACTOR_USER_INSTRUCTION,
JURISDICTION_SYSTEM_PROMPT,
JURISDICTION_USER_INSTRUCTION,
SHEET_INDEX_SYSTEM_PROMPT,
SHEET_INDEX_USER_INSTRUCTION,
)
# Appended to the extractor instruction on the second-chance retry. Dense plan
# sheets blow the output budget on the full schema; compact mode trades
# per-object verbosity for actually finishing the page.
_COMPACT_RETRY_SUFFIX = """
IMPORTANT - COMPACT RETRY: the first pass did not complete. Keep the SAME JSON
schema, but extract at most 40 objects, prioritizing coordination-relevant
items (equipment, fixtures, devices, keynotes, dimensions, markers/callouts,
schedule rows). Keep descriptions/attributes short; skip review_uses entries
you are unsure about. Finish the JSON - a smaller complete answer beats a
larger truncated one."""
def _wrap_bare_list(parsed, page_number: int):
"""Models sometimes skip the {sheet, objects} wrapper and return a bare
objects array (especially after truncation repair). Accept it - the sheet
header falls back to title-block deduction downstream."""
if isinstance(parsed, list):
print(f"[Extract] Page {page_number}: wrapping bare objects array "
f"({len(parsed)} items, no sheet header)")
return {"sheet": {}, "objects": parsed}
return parsed
class SheetExtractorAgent:
name = "sheet_extractor"
def __init__(self, usage: AgentUsage) -> None:
self.usage = usage
def _call(self, instruction: str, page: Dict):
return call_json(
system_prompt=EXTRACTOR_SYSTEM_PROMPT,
user_text=instruction,
images_b64=[page["base64"]],
max_tokens=config.EXTRACT_MAX_TOKENS,
model=config.AGENT_EXTRACT_MODEL,
usage_tracker=self.usage,
usage_stage="agent.extract",
reasoning_effort=config.EXTRACT_REASONING_EFFORT or None,
reasoning_max_tokens=config.EXTRACT_REASONING_MAX_TOKENS or None,
)
def run(self, scope: AgentScope) -> AgentResult:
try:
page = scope.payload["page"]
instruction = EXTRACTOR_USER_INSTRUCTION.replace(
"{sheet_hint}", str(scope.payload.get("sheet_hint") or "")
) + _text_layer_block(page)
parsed = _wrap_bare_list(self._call(instruction, page),
page["page_number"])
if not isinstance(parsed, dict):
# Second chance: same page, compact instructions. Runs only
# when the full-schema pass returned nothing usable.
print(f"[Extract] Page {page['page_number']}: full extraction "
f"failed, retrying compact")
parsed = _wrap_bare_list(
self._call(instruction + _COMPACT_RETRY_SUFFIX, page),
page["page_number"],
)
if not isinstance(parsed, dict):
raise ValueError("no structured extraction returned")
sheet = _normalize_sheet(parsed, page["page_number"],
page_text=page.get("text_layer"))
return AgentResult(scope_id=scope.scope_id, artifacts=[sheet])
except Exception as exc:
return failure(scope, exc)
class SheetIndexAgent:
name = "sheet_index"
def __init__(self, usage: AgentUsage) -> None:
self.usage = usage
def run(self, scope: AgentScope) -> AgentResult:
try:
sheets = scope.payload.get("sheets") or []
instruction = SHEET_INDEX_USER_INSTRUCTION.replace(
"{sheet_index_input}",
json.dumps(_index_input(sheets), ensure_ascii=True),
)
parsed = call_json(
system_prompt=SHEET_INDEX_SYSTEM_PROMPT,
user_text=instruction,
max_tokens=config.SHEET_INDEX_MAX_TOKENS,
model=config.AGENT_INDEX_MODEL,
usage_tracker=self.usage,
usage_stage="agent.sheet_index",
)
if isinstance(parsed, list):
parsed = {"sheet_index": parsed, "missing_expected_sheets": []}
if not isinstance(parsed, dict):
raise ValueError("no sheet index returned")
return AgentResult(scope_id=scope.scope_id, artifacts=[parsed])
except Exception as exc:
return failure(scope, exc)
class JurisdictionAgent:
name = "jurisdiction"
def __init__(self, usage: AgentUsage) -> None:
self.usage = usage
def run(self, scope: AgentScope) -> AgentResult:
try:
project_input: Dict = scope.payload.get("project_input") or {}
instruction = JURISDICTION_USER_INSTRUCTION.replace(
"{project_input}", json.dumps(project_input, ensure_ascii=True)
)
parsed = call_json(
system_prompt=JURISDICTION_SYSTEM_PROMPT,
user_text=instruction,
max_tokens=config.JURISDICTION_MAX_TOKENS,
model=config.AGENT_JURISDICTION_MODEL,
usage_tracker=self.usage,
usage_stage="agent.jurisdiction",
)
if not isinstance(parsed, dict):
raise ValueError("no jurisdiction profile returned")
profile = parsed.get("project_code_profile")
artifact = profile if isinstance(profile, dict) else parsed
return AgentResult(scope_id=scope.scope_id, artifacts=[artifact])
except Exception as exc:
return failure(scope, exc)