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