fix: stop spawning duplicate villains from descriptions in the name field
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) <noreply@anthropic.com>
This commit is contained in:
+7
-2
@@ -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 \
|
||||
|
||||
+53
-7
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user