Update main. #6
+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 \
|
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 \
|
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 \
|
`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 \
|
whenever he describes the guy again. The `name` field is ONLY a real handle (what he'd call \
|
||||||
generic ones — "mid-aged white guy in glasses" identifies no one. When you already have \
|
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, \
|
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-\
|
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 \
|
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
|
_DISTINCT_MIN = 0.30 # below this the description is too generic to match at all
|
||||||
|
|
||||||
|
|
||||||
|
_GENERIC_SET = frozenset(_GENERIC)
|
||||||
|
|
||||||
|
|
||||||
def distinctiveness(text: str) -> float:
|
def distinctiveness(text: str) -> float:
|
||||||
"""How usable a description is as an identity key: ~1.0 for a neck tattoo,
|
"""How usable a description is as an identity key: ~1.0 for 'neck tattoo, Fox
|
||||||
~0.1 for 'mid-aged white guy with glasses'. Generic-only stays near zero."""
|
Racing hat', ~0.1 for 'mid-aged white guy with glasses'. Generic-ONLY stays
|
||||||
t = (text or "").lower()
|
near zero; specific content (named features, brands, a list) reads as high —
|
||||||
dist = sum(1 for w in _DISTINCTIVE if w in t)
|
even if a generic word like 'shirt' is mixed in."""
|
||||||
if dist == 0:
|
t = (text or "").strip()
|
||||||
return 0.10 if any(w in t for w in _GENERIC) else 0.30
|
if not t:
|
||||||
return min(1.0, 0.45 + 0.28 * dist)
|
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):
|
def _embed_vec(text: str):
|
||||||
@@ -1473,6 +1486,27 @@ def scan_merge_candidates(sim_threshold: float = _SIM_HIGH) -> int:
|
|||||||
return filed
|
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,
|
def add_read(note: str, seat: str | None = None, name: str | None = None,
|
||||||
descriptor: str | None = None, session_id: int | None = None,
|
descriptor: str | None = None, session_id: int | None = None,
|
||||||
**player_fields) -> int:
|
**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."""
|
confident, else opens a new one — so reads on unnamed players still accumulate."""
|
||||||
sid = _resolve(session_id)
|
sid = _resolve(session_id)
|
||||||
venue = player_fields.get("venue")
|
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
|
pid = None
|
||||||
if name:
|
if name:
|
||||||
pid = upsert_player(name, **{k: v for k, v in player_fields.items()
|
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:
|
else:
|
||||||
pid = create_descriptor_villain(descriptor, venue=venue,
|
pid = create_descriptor_villain(descriptor, venue=venue,
|
||||||
category=player_fields.get("category"))
|
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()
|
conn = _c()
|
||||||
with conn:
|
with conn:
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
|
|||||||
@@ -46,6 +46,24 @@ def test_descriptor_read_creates_then_reuses_nameless_villain(mods):
|
|||||||
assert reads == 2
|
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):
|
def test_name_villain_tool_attaches_name(mods):
|
||||||
poker, tools = mods
|
poker, tools = mods
|
||||||
poker.start_session(venue="Meadows", buy_in=300)
|
poker.start_session(venue="Meadows", buy_in=300)
|
||||||
|
|||||||
Reference in New Issue
Block a user