"""Per-sheet Drawing Integrity QA agent. Reads ONE sheet's own extracted objects + sheet image + deterministic text layer and flags defects internal to that single sheet: dangling detail/ callout/keynote references, schedule-vs-plan/legend disagreements on the same sheet, dimension strings that do not sum, missing title-block/scale/north essentials, and duplicate/inconsistent tags. This is the drawing-focused pass that complements the cross-sheet conflict critic; it never does code/ADA or cross-sheet coordination. """ from typing import Dict, List from backend import config from backend.agents.base import AgentResult, AgentScope, AgentUsage, failure from backend.agents.prompts import ( DRAWING_INTEGRITY_SYSTEM_PROMPT, DRAWING_INTEGRITY_USER_PROMPT, ) from backend.llm import call_json from backend.pipeline._serialize import dumps from backend.pipeline._stage import collect_list, validate_issue def _sheet_meta(sheet: Dict) -> Dict: """Compact title-block-ish descriptor of the sheet (no raw assertions).""" return { "sheet_number": sheet.get("sheet_number"), "sheet_title": sheet.get("sheet_title"), "discipline": sheet.get("discipline"), "drawing_type": sheet.get("drawing_type"), "level": sheet.get("level"), "scale": sheet.get("scale"), } def build_integrity_scopes( sheets: List[Dict], page_to_b64: Dict, page_to_text: Dict ) -> List[AgentScope]: """One scope per sheet that carries enough objects to judge internal consistency. Sheets below INTEGRITY_MIN_ASSERTIONS are skipped as too sparse for a meaningful single-sheet back-check.""" scopes: List[AgentScope] = [] for sheet in sheets: assertions = sheet.get("assertions") or [] if len(assertions) < config.INTEGRITY_MIN_ASSERTIONS: continue page_number = sheet.get("page_number") scopes.append(AgentScope( scope_id=f"integrity:{page_number}", payload={ "sheet": sheet, "page_number": page_number, "image_b64": page_to_b64.get(page_number), "text_layer": page_to_text.get(page_number) or "", }, )) return scopes class DrawingIntegrityAgent: name = "drawing_integrity" def __init__(self, usage: AgentUsage) -> None: self.usage = usage def run(self, scope: AgentScope) -> AgentResult: try: sheet = dict(scope.payload["sheet"]) assertions = ( sheet.get("assertions") or [] )[:config.AGENT_INTEGRITY_MAX_ASSERTIONS] text_layer = (scope.payload.get("text_layer") or "")[ :config.TEXT_LAYER_MAX_CHARS ] image_b64 = scope.payload.get("image_b64") images = [image_b64] if image_b64 else [] images = images[:config.AGENT_INTEGRITY_MAX_IMAGES] instruction = DRAWING_INTEGRITY_USER_PROMPT for key, value in { "sheet_meta": dumps(_sheet_meta(sheet)), "assertions": dumps(assertions), "text_layer": text_layer, }.items(): instruction = instruction.replace("{" + key + "}", value) parsed = call_json( system_prompt=DRAWING_INTEGRITY_SYSTEM_PROMPT, user_text=instruction, images_b64=images, max_tokens=config.INTEGRITY_MAX_TOKENS, model=config.AGENT_INTEGRITY_MODEL, usage_tracker=self.usage, usage_stage="agent.drawing_integrity", reasoning_effort=config.EXTRACT_REASONING_EFFORT or None, reasoning_max_tokens=config.EXTRACT_REASONING_MAX_TOKENS or None, ) findings = collect_list( parsed, "issues", lambda item: validate_issue(item, "drawing_integrity"), ) sheet_number = sheet.get("sheet_number") for finding in findings: finding.update(agent=self.name, scope_id=scope.scope_id) # Anchor the finding to this sheet if the model left it blank. if not finding.get("sheets") and sheet_number: finding["sheets"] = [sheet_number] return AgentResult(scope_id=scope.scope_id, artifacts=findings) except Exception as exc: return failure(scope, exc)