diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index 708ad27..09dc518 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -4261,6 +4261,34 @@ async def case_number_collides(case_number: str, exclude_id: UUID) -> bool: )) +# Subject-tag convention for case_law topic hubs (powers /graph topic nodes). +# A simple multi-word Hebrew phrase is stored with underscores between words +# ("היטל_השבחה") — the documented extraction contract (precedent_library tool +# examples: קווי_בניין, מועד_קביעת_שומה) that ~99% of the corpus already follows. +# The same phrase arriving with spaces ("היטל השבחה") is the same topic in +# disguise and spawns a duplicate graph hub. Normalize at the single case_law +# write chokepoint (G1) so no path can re-introduce the split. Tags carrying +# punctuation/digits/dashes (e.g. "פטור מותנה — סעיף 19(ג)") are left untouched — +# the convention covers only plain Hebrew word phrases. +_PURE_HEBREW_WORDS = re.compile(r"^[א-ת]+(?: [א-ת]+)+$") + + +def _normalize_subject_tags(tags) -> list[str]: + """Strip, apply the underscore convention to plain Hebrew phrases, dedup.""" + out: list[str] = [] + seen: set[str] = set() + for raw in tags or []: + t = str(raw).strip() + if not t: + continue + if _PURE_HEBREW_WORDS.match(t): + t = t.replace(" ", "_") + if t not in seen: + seen.add(t) + out.append(t) + return out + + async def create_external_case_law( case_number: str, case_name: str, @@ -4291,7 +4319,7 @@ async def create_external_case_law( if source_type == "appeals_committee": is_binding = False pool = await get_pool() - tags_json = json.dumps(subject_tags or [], ensure_ascii=False) + tags_json = json.dumps(_normalize_subject_tags(subject_tags), ensure_ascii=False) async with pool.acquire() as conn: # Atomic upsert on the V15 partial unique index # uq_case_law_external_number (case_number) WHERE source_kind <> 'internal_committee'. @@ -4374,7 +4402,7 @@ async def create_internal_committee_decision( is_binding = False pool = await get_pool() case_number = _canonical_case_number(case_number) - tags_json = json.dumps(subject_tags or [], ensure_ascii=False) + tags_json = json.dumps(_normalize_subject_tags(subject_tags), ensure_ascii=False) async with pool.acquire() as conn: # Atomic upsert on V15 partial unique index # uq_case_law_internal_number_proc (case_number, proceeding_type) @@ -4529,7 +4557,7 @@ async def update_case_law(case_law_id: UUID, **fields) -> dict | None: params: list = [case_law_id] for i, (k, v) in enumerate(updates.items(), start=2): if k == "subject_tags": - v = json.dumps(v or [], ensure_ascii=False) + v = json.dumps(_normalize_subject_tags(v), ensure_ascii=False) set_parts.append(f"{k} = ${i}") params.append(v) sql = f"UPDATE case_law SET {', '.join(set_parts)} WHERE id = $1 RETURNING *"