From 392c46d8bf73a8871fb6cbeeda68ef6ac1c2999a Mon Sep 17 00:00:00 2001 From: serversdown Date: Sat, 4 Jul 2026 03:16:50 +0000 Subject: [PATCH] fix: stop spawning duplicate villains from descriptions in the name field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of "4 entries for the same person": physical descriptions were being passed as `name`, creating a new *named* player each time the wording drifted (exact-name match can't dedupe near-identical sentences, and the merge scan only looks at descriptor embeddings). - add_read: a `name` that looks like a description (comma-listed / long / has appearance words) is rerouted to the descriptor path so it dedupes. - descriptor reads that are ambiguously close to an existing villain now file a merge_candidate to the review queue instead of leaving a silent duplicate. - distinctiveness() reworked: recognizes specific content (proper nouns/brands, feature lists) as distinctive even when a generic word like "shirt" is present — the old list-only heuristic scored "Filipino, Fox Racing hat, DKNY shirt" as generic and gated it out. - Cash card: name = real handle ONLY; the look goes in descriptor as a few distinctive tags, and use name_villain to fuse a name onto a described player. Full suite 157 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- lyra/modes.py | 9 ++++-- lyra/poker.py | 60 ++++++++++++++++++++++++++++++++----- tests/test_villain_tools.py | 18 +++++++++++ 3 files changed, 78 insertions(+), 9 deletions(-) diff --git a/lyra/modes.py b/lyra/modes.py index 513db9a..8d910f0 100644 --- a/lyra/modes.py +++ b/lyra/modes.py @@ -102,8 +102,13 @@ stays off the table. At the table you're logging the session, not processing you PLAYERS — names AND nameless. Most villains don't come with a name; Brian knows them by a \ look ("neck tattoo guy", "the bald reg two to my left"). Log reads on them anyway: give \ `add_read` a `descriptor` instead of a name and it attaches to that unnamed player, reused \ -whenever he describes the guy again. Prefer DISTINCTIVE features (tattoos, build, a hat) over \ -generic ones — "mid-aged white guy in glasses" identifies no one. When you already have \ +whenever he describes the guy again. The `name` field is ONLY a real handle (what he'd call \ +him — "Jonathan", "Sleepy John"); a physical description NEVER goes in `name` — that spawns a \ +new duplicate player every time the wording drifts. Put the look in `descriptor`, and keep it \ +to a few DISTINCTIVE tags ("Filipino, Fox Racing hat, DKNY shirt"), not a paragraph and not \ +generic filler — "mid-aged white guy in glasses" identifies no one. If he tells you the same \ +guy's name after you'd been describing him, use name_villain to fuse them — don't create a \ +second record. When you already have \ history on someone he names or describes, a SCOUTING DESK note will appear with it — cite it, \ don't invent. If you're not sure the guy he's describing is one you know, ASK ("same neck-\ tattoo reg from last week?") rather than assume — a wrong callback is worse than none. On his \ diff --git a/lyra/poker.py b/lyra/poker.py index 6987813..5a63f27 100644 --- a/lyra/poker.py +++ b/lyra/poker.py @@ -1189,14 +1189,27 @@ _SIM_AMBIGUOUS = 0.58 # plausible — don't guess live, route to review _DISTINCT_MIN = 0.30 # below this the description is too generic to match at all +_GENERIC_SET = frozenset(_GENERIC) + + def distinctiveness(text: str) -> float: - """How usable a description is as an identity key: ~1.0 for a neck tattoo, - ~0.1 for 'mid-aged white guy with glasses'. Generic-only stays near zero.""" - t = (text or "").lower() - dist = sum(1 for w in _DISTINCTIVE if w in t) - if dist == 0: - return 0.10 if any(w in t for w in _GENERIC) else 0.30 - return min(1.0, 0.45 + 0.28 * dist) + """How usable a description is as an identity key: ~1.0 for 'neck tattoo, Fox + Racing hat', ~0.1 for 'mid-aged white guy with glasses'. Generic-ONLY stays + near zero; specific content (named features, brands, a list) reads as high — + even if a generic word like 'shirt' is mixed in.""" + t = (text or "").strip() + if not t: + return 0.0 + low = t.lower() + tokens = re.findall(r"[a-z0-9']+", low) + dist = sum(1 for w in _DISTINCTIVE if w in low) + proper = len(re.findall(r"\b[A-Z][a-z]{2,}", text)) # brands/proper nouns: Fox, DKNY-ish + non_generic = sum(1 for w in tokens if w not in _GENERIC_SET) + # Only bland filler (age/race/build/gender) and nothing concrete → not usable. + specific = dist + proper + (1 if "," in t else 0) + if specific == 0 and non_generic <= 1: + return 0.10 + return min(1.0, 0.40 + 0.14 * specific + 0.05 * non_generic) def _embed_vec(text: str): @@ -1473,6 +1486,27 @@ def scan_merge_candidates(sim_threshold: float = _SIM_HIGH) -> int: return filed +# Words that mark a "name" as really a physical description (misused name field). +_DESC_MARKERS = ( + "shirt", "hat", "cap", "hair", "beard", "glasses", "sunglasses", "tattoo", + "bracelet", "watch", "descent", "jersey", "hoodie", "jacket", "build", + "bald", "goatee", "chain", "necklace", "piercing", "mustache", "ponytail", + "sleeve", "skin", "wearing", "heavyset", "tall guy", "older", "younger", +) + + +def _looks_like_description(text: str | None) -> bool: + """A physical description mistakenly passed as a name — should be a descriptor. + Real handles are short (1-3 words, no commas); descriptions are longer / listy.""" + t = (text or "").strip() + if not t: + return False + low = t.lower() + if "," in t or len(t.split()) > 4: + return True + return any(m in low for m in _DESC_MARKERS) + + def add_read(note: str, seat: str | None = None, name: str | None = None, descriptor: str | None = None, session_id: int | None = None, **player_fields) -> int: @@ -1481,6 +1515,11 @@ def add_read(note: str, seat: str | None = None, name: str | None = None, confident, else opens a new one — so reads on unnamed players still accumulate.""" sid = _resolve(session_id) venue = player_fields.get("venue") + # A description passed as a name (e.g. "Filipino, Fox Racing hat, DKNY shirt") + # is really a descriptor — route it so it dedupes instead of spawning a new + # named player each time the wording drifts. + if name and not descriptor and _looks_like_description(name): + descriptor, name = name, None pid = None if name: pid = upsert_player(name, **{k: v for k, v in player_fields.items() @@ -1494,6 +1533,13 @@ def add_read(note: str, seat: str | None = None, name: str | None = None, else: pid = create_descriptor_villain(descriptor, venue=venue, category=player_fields.get("category")) + # Plausibly the same guy as an existing villain, but not confident — + # surface it for a one-click merge instead of leaving a silent dup. + if res["band"] == "ambiguous" and res["match_id"]: + queue_identity_task("merge_candidate", [pid, res["match_id"]], + descriptor=descriptor, + context="similar description logged live", + confidence=res["confidence"]) conn = _c() with conn: cur = conn.execute( diff --git a/tests/test_villain_tools.py b/tests/test_villain_tools.py index 95856e2..eb2085c 100644 --- a/tests/test_villain_tools.py +++ b/tests/test_villain_tools.py @@ -46,6 +46,24 @@ def test_descriptor_read_creates_then_reuses_nameless_villain(mods): assert reads == 2 +def test_description_as_name_routes_to_descriptor_and_dedupes(mods): + poker, tools = mods + poker.start_session(venue="Meadows", buy_in=300) + # She (wrongly) puts a physical description in the name field, twice, worded + # slightly differently — must resolve to ONE nameless villain, not two named. + tools.dispatch("add_read", {"note": "limp 3bet A3o", + "name": "Filipino, Fox Racing hat, DKNY shirt, two bracelets"}, {}) + tools.dispatch("add_read", {"note": "called a 4bet light", + "name": "Filipino, Fox Racing hat, DKNY shirt, watch on left"}, {}) + named = [p for p in poker.get_villain_file() if p["named"]] + assert named == [] # no sentence-named players spawned (the bug) + # Either they merged, or the near-dup is surfaced for a one-click merge — never + # a silent duplicate the way sentence-names were. + q = poker.list_identity_queue() + nameless = [p for p in poker.get_villain_file() if not p["named"]] + assert len(nameless) == 1 or any(t["kind"] == "merge_candidate" for t in q) + + def test_name_villain_tool_attaches_name(mods): poker, tools = mods poker.start_session(venue="Meadows", buy_in=300)