392c46d8bf
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>
104 lines
4.4 KiB
Python
104 lines
4.4 KiB
Python
"""Confirm-loop tools: descriptor reads, name attach, merge/mark-distinct."""
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
|
|
def _fake_embed(texts):
|
|
out = []
|
|
for t in texts:
|
|
v = np.zeros(64, dtype=np.float32)
|
|
for w in t.lower().split():
|
|
v[hash(w) % 64] += 1.0
|
|
out.append((v if v.any() else np.full(64, 1e-6, dtype=np.float32)).tolist())
|
|
return out
|
|
|
|
|
|
@pytest.fixture
|
|
def mods(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
|
|
from lyra import llm
|
|
monkeypatch.setattr(llm, "embed", _fake_embed)
|
|
import lyra.memory as memory
|
|
importlib.reload(memory)
|
|
import lyra.poker as poker
|
|
importlib.reload(poker)
|
|
import lyra.tools as tools
|
|
importlib.reload(tools)
|
|
return poker, tools
|
|
|
|
|
|
def test_descriptor_read_creates_then_reuses_nameless_villain(mods):
|
|
poker, tools = mods
|
|
poker.start_session(venue="Meadows", stakes="1/3", buy_in=300)
|
|
tools.dispatch("add_read", {"note": "opened UTG light",
|
|
"descriptor": "neck tattoo sleeve arm"}, {})
|
|
tools.dispatch("add_read", {"note": "showed a bluff",
|
|
"descriptor": "neck tattoo sleeve"}, {}) # rephrase → same guy
|
|
players = [p for p in poker.get_villain_file() if not p["named"]]
|
|
assert len(players) == 1 # one nameless villain, not two
|
|
reads = poker._c().execute(
|
|
"SELECT COUNT(*) n FROM player_reads WHERE player_id = ?", (players[0]["id"],)
|
|
).fetchone()["n"]
|
|
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)
|
|
poker.create_descriptor_villain("neck tattoo sleeve arm", venue="Meadows")
|
|
out = tools.dispatch("name_villain", {"descriptor": "neck tattoo sleeve arm",
|
|
"name": "Danny"}, {})
|
|
assert "Danny" in out
|
|
assert poker.resolve_villain("Danny")["band"] == "name"
|
|
|
|
|
|
def test_link_villains_merge_and_distinct(mods):
|
|
poker, tools = mods
|
|
poker.start_session(venue="Meadows", buy_in=300)
|
|
poker.upsert_player("Danny", venue="Meadows")
|
|
poker.upsert_player("Donny", venue="Meadows")
|
|
# same=false → recorded distinct
|
|
tools.dispatch("link_villains", {"player_a": "Danny", "player_b": "Donny",
|
|
"same": False, "note": "different builds"}, {})
|
|
a = poker.resolve_villain("Danny")["match_id"]
|
|
b = poker.resolve_villain("Donny")["match_id"]
|
|
assert poker.are_distinct(a, b)
|
|
# same=true on a fresh pair → merged
|
|
poker.upsert_player("Mike", venue="Meadows")
|
|
poker.upsert_player("Michael", venue="Meadows")
|
|
tools.dispatch("link_villains", {"player_a": "Mike", "player_b": "Michael",
|
|
"same": True}, {})
|
|
names = [p["name"] for p in poker.get_villain_file()]
|
|
assert ("Mike" in names) ^ ("Michael" in names) # one absorbed the other
|
|
|
|
|
|
def test_link_villains_refuses_when_reference_is_vague(mods):
|
|
poker, tools = mods
|
|
poker.start_session(venue="Meadows", buy_in=300)
|
|
poker.upsert_player("Danny", venue="Meadows")
|
|
out = tools.dispatch("link_villains", {"player_a": "Danny",
|
|
"player_b": "some guy", "same": True}, {})
|
|
assert "didn't merge" in out.lower() or "couldn't" in out.lower()
|