From df4d15fd0c34f1a65edcff23329e8f12d63fc681 Mon Sep 17 00:00:00 2001 From: woogi Date: Mon, 10 Aug 2026 10:40:10 -0500 Subject: [PATCH] feat: deterministic disputed-value detection for cluster assertions --- backend/agents/disputes.py | 55 +++++++++++++++++++++++++++++++++++ tests/agents/test_disputes.py | 42 ++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 backend/agents/disputes.py create mode 100644 tests/agents/test_disputes.py diff --git a/backend/agents/disputes.py b/backend/agents/disputes.py new file mode 100644 index 0000000..3db7ae9 --- /dev/null +++ b/backend/agents/disputes.py @@ -0,0 +1,55 @@ +"""Deterministic detection of contradictory extracted values within a cluster. + +Extraction is a vision pass: quantities and sizes can be misread ("(2) 2x6" vs +"(5) 2x6"). Cluster members are supposed to describe the same real-world +element, so two members asserting different values for the same attribute are +a probable misread. Flag these so downstream text-only stages treat the value +as unverified instead of reasoning from one reading. +""" + +import re +from typing import Dict, List + + +def _norm(value) -> str: + return re.sub(r"\s+", " ", str(value or "").strip().lower()) + + +def find_disputes(assertions: List[Dict]) -> List[Dict]: + """Same attribute with >= 2 distinct normalized values = disputed.""" + groups: Dict[str, Dict[str, Dict]] = {} + for assertion in assertions: + attribute = _norm(assertion.get("attribute")) + value = _norm(assertion.get("value")) + if not attribute or not value: + continue + # Group on the normalized value, but keep the original (whitespace- + # collapsed) text so disputes read like the sheet, not a lowercase munge. + original = re.sub(r"\s+", " ", str(assertion.get("value")).strip()) + bucket = groups.setdefault(attribute, {}).setdefault( + value, {"original": original, "ids": set()} + ) + bucket["ids"].add(assertion.get("id")) + disputes = [] + for attribute, values in sorted(groups.items()): + if len(values) < 2: + continue + disputes.append({ + "attribute": attribute, + "values": sorted(v["original"] for v in values.values()), + "assertion_ids": sorted( + aid for v in values.values() for aid in v["ids"] if aid + ), + }) + return disputes + + +def annotate_clusters(clusters: List[Dict]) -> int: + """Attach disputed_attributes to each cluster that has any. Returns count.""" + annotated = 0 + for cluster in clusters: + disputes = find_disputes(cluster.get("assertions") or []) + if disputes: + cluster["disputed_attributes"] = disputes + annotated += 1 + return annotated diff --git a/tests/agents/test_disputes.py b/tests/agents/test_disputes.py new file mode 100644 index 0000000..bdcbe8f --- /dev/null +++ b/tests/agents/test_disputes.py @@ -0,0 +1,42 @@ +from backend.agents.disputes import annotate_clusters, find_disputes + + +def _a(id_, attribute, value): + return {"id": id_, "attribute": attribute, "value": value, + "source_text": value} + + +def test_find_disputes_flags_same_attribute_different_values(): + assertions = [ + _a("a1", "stud_pack_size", "(2) 2x6 STUD PACK"), + _a("a2", "stud_pack_size", "(5) 2x6 STUD PACK"), + _a("a3", "beam_size", "HSS16X4X5/8"), + ] + disputes = find_disputes(assertions) + assert len(disputes) == 1 + assert disputes[0]["attribute"] == "stud_pack_size" + assert disputes[0]["values"] == ["(2) 2x6 STUD PACK", "(5) 2x6 STUD PACK"] + assert disputes[0]["assertion_ids"] == ["a1", "a2"] + + +def test_find_disputes_ignores_agreeing_values_and_blanks(): + assertions = [ + _a("a1", "beam_size", "HSS16X4X5/8"), + _a("a2", "beam_size", " hss16x4x5/8 "), # same after normalize + _a("a3", "", "orphan"), # no attribute -> skipped + _a("a4", "beam_size", ""), # no value -> skipped + ] + assert find_disputes(assertions) == [] + + +def test_annotate_clusters_writes_disputed_attributes(): + clusters = [ + {"key": "c1", "assertions": [ + _a("a1", "stud_pack_size", "(2) 2x6"), + _a("a2", "stud_pack_size", "(5) 2x6"), + ]}, + {"key": "c2", "assertions": [_a("a3", "x", "1"), _a("a4", "x", "1")]}, + ] + assert annotate_clusters(clusters) == 1 + assert clusters[0]["disputed_attributes"][0]["attribute"] == "stud_pack_size" + assert "disputed_attributes" not in clusters[1]