diff --git a/backend/agents/linker.py b/backend/agents/linker.py index 184c8e0..0eb6635 100644 --- a/backend/agents/linker.py +++ b/backend/agents/linker.py @@ -30,9 +30,23 @@ def _family(assertion: Dict) -> str: ) +def _xref_keys(assertion: Dict) -> List[str]: + """Cross-level join keys: detail references and member tags.""" + location = assertion.get("location_key") or {} + keys = [] + ref = re.sub(r"\s+", "", str(location.get("detail_reference") or "")).upper() + if ref: + keys.append(f"detail:{ref}") + tag = re.sub(r"\s+", "", str(location.get("tag") or "")).upper() + if re.match(r"^[A-Z]{2,}\d", tag): # member marks: HSS16X4X5/8, W12X26, ... + keys.append(f"tag:{tag}") + return keys + + def build_link_scopes(sheets: List[Dict]) -> List[AgentScope]: """Partition facts by level and object/tag family, then enforce a hard cap.""" buckets: Dict[Tuple[str, str], List[Dict]] = defaultdict(list) + xref: Dict[str, List[Dict]] = defaultdict(list) for sheet in sheets: for assertion in sheet.get("assertions", []): enriched = { @@ -44,6 +58,8 @@ def build_link_scopes(sheets: List[Dict]) -> List[AgentScope]: level = str((assertion.get("location_key") or {}).get("level") or sheet.get("level") or "unknown").lower() buckets[(level, _family(assertion))].append(enriched) + for key in _xref_keys(assertion): + xref[key].append(enriched) scopes: List[AgentScope] = [] cap = max(2, config.AGENT_LINK_MAX_ASSERTIONS) @@ -56,6 +72,15 @@ def build_link_scopes(sheets: List[Dict]) -> List[AgentScope]: scope_id=f"{level}:{family}:{offset // cap + 1}", payload={"assertions": chunk, "level": level, "family": family}, )) + for key, assertions in sorted(xref.items()): + sheets_present = {a.get("sheet_number") for a in assertions} + if len(assertions) < 2 or len(sheets_present) < 2: + continue + scopes.append(AgentScope( + scope_id=f"xref:{key}", + payload={"assertions": assertions[:cap], + "level": "xref", "family": key}, + )) return scopes diff --git a/tests/agents/test_linker_xref.py b/tests/agents/test_linker_xref.py new file mode 100644 index 0000000..a236934 --- /dev/null +++ b/tests/agents/test_linker_xref.py @@ -0,0 +1,47 @@ +from backend.agents.base import AgentScope +from backend.agents.linker import build_link_scopes + + +def _sheet(number, page, level, assertions): + return {"sheet_number": number, "page_number": page, + "discipline": "Structural", "level": level, + "assertions": assertions} + + +def _assertion(id_, ref=None, tag=None, level=None): + return {"id": id_, "attribute": "stud_pack_size", "value": "(5) 2x6", + "source_text": "(5) 2x6 STUD PACK", + "location_key": {"detail_reference": ref, "tag": tag, + "level": level}} + + +def test_xref_scope_joins_same_detail_reference_across_levels(): + sheets = [ + _sheet("S101", 10, "foundation", [_assertion("a1", ref="A/S205")]), + _sheet("S205", 20, "roof", [_assertion("a2", ref="A/S205")]), + _sheet("S401", 30, "roof", [_assertion("a3", ref="A/S205")]), + ] + scopes = build_link_scopes(sheets) + xref = [s for s in scopes if s.scope_id.startswith("xref:")] + assert xref, "expected a cross-level detail-reference scope" + ids = {a["id"] for s in xref for a in s.payload["assertions"]} + assert ids == {"a1", "a2", "a3"} + + +def test_xref_scope_requires_two_distinct_sheets(): + sheets = [ + _sheet("S401", 30, "roof", [_assertion("a1", ref="A/S205"), + _assertion("a2", ref="A/S205")]), + ] + scopes = build_link_scopes(sheets) + assert not [s for s in scopes if s.scope_id.startswith("xref:")] + + +def test_xref_scope_joins_shared_member_tag(): + sheets = [ + _sheet("S102", 5, "roof", [_assertion("a1", tag="HSS16X4X5/8")]), + _sheet("S401", 30, "unknown", [_assertion("a2", tag="HSS16X4X5/8")]), + ] + scopes = build_link_scopes(sheets) + xref = [s for s in scopes if s.scope_id.startswith("xref:")] + assert xref