""" Soft suggest-on-create helpers (Phase 2 #1 resolution). When an annotator (or, in solo mode, the LLM) is about to add a code — especially via in-vivo coding where the name is derived from a text selection — near-duplicates proliferate fast ("cost", "costs", "cost concerns"). Rather than block or silently merge, we *suggest*: surface existing codes that closely match the proposed name so the annotator can reuse one. Non-destructive and adjudicator-free, so it works in solo mode too. Pure functions, no I/O — unit-testable in isolation. """ from __future__ import annotations import re from difflib import SequenceMatcher, get_close_matches from typing import List _WS = re.compile(r"\s+") # Conservative default: 0.78 catches "cost concern" ~ "cost concerns" # and case/space variants without dragging in merely topical names. DEFAULT_CUTOFF = 0.78 MAX_CODE_NAME = 60 def _norm(s: str) -> str: return _WS.sub(" ", str(s or "")).strip().lower() def derive_code_name(text: str, cap: int = MAX_CODE_NAME) -> str: """Propose a code name from a raw text selection: collapse whitespace, trim, and cap length at a word boundary when possible. Mirrors the client-side derivation (codebook.js) — keep in sync.""" s = _WS.sub(" ", str(text or "")).strip() if len(s) <= cap: return s head = s[:cap].rsplit(" ", 1)[0] return (head or s[:cap]).strip() def similar_code_names( names: List[str], proposed: str, cutoff: float = DEFAULT_CUTOFF, n: int = 5, ) -> List[str]: """Existing code names that closely match `proposed` (normalized), ordered best-first, returned in their ORIGINAL casing. An exact normalized match is always included first.""" p = _norm(proposed) if not p: return [] norm_to_orig = {} for original in names: norm_to_orig.setdefault(_norm(original), original) keys = list(norm_to_orig.keys()) ordered: List[str] = [] if p in norm_to_orig: # exact (normalized) hit ordered.append(norm_to_orig[p]) for key in get_close_matches(p, keys, n=n, cutoff=cutoff): orig = norm_to_orig[key] if orig not in ordered: ordered.append(orig) # Stable best-first ordering by similarity ratio. ordered.sort( key=lambda o: SequenceMatcher(None, p, _norm(o)).ratio(), reverse=True, ) return ordered[:n]