Igra v2: auto-presuda + filler, pamćenje dvoboja sa razlogom (reuse bez LLM)

Problem: potvrda zadnjeg heroja se čula tek nakon "sad ću odlučiti" jer je
model grupisao potvrdu i poziv presudi. Rješenje: presuda runde je sada
AUTOMATSKA (uklonjen alat superheroji_presudi). Kad zadnji igrač izabere,
Python pokrene resolve_round u pozadini; model prvo brzo potvrdi ime i kaže
da razmišlja, rezultat stigne preko notify_model. Ako traje >8s -> filler.

Pamćenje dvoboja: pored Ela sada se u heroes.json (matchups) bilježe konkretni
ishodi sa razlogom, po cijelom skupu likova i po parovima. Kad se isti dvoboj
ponovi -> čita se iz baze BEZ poziva LLM-u (dosljedno + brzo). Admin tab
prikazuje "Zabilježene dvoboje"; reset briše i njih.

Sekvenciranje odgovora (_wait_idle) da se izbjegne
conversation_already_has_active_response.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LzKpYaEsuWDevuNnvSjcFW
This commit is contained in:
idriz
2026-09-04 14:29:04 +02:00
parent d0fddcf985
commit c3706e67e5
5 changed files with 167 additions and 26 deletions

View File

@@ -217,8 +217,9 @@ def superheroji():
store = gamestore.HeroStore() store = gamestore.HeroStore()
store.heroes = {} store.heroes = {}
store.aliases = {} store.aliases = {}
store.matchups = {}
store.save() store.save()
flash("Pamćenje jačina heroja je obrisano.") flash("Pamćenje jačina i dvoboja je obrisano.")
return redirect(url_for("superheroji")) return redirect(url_for("superheroji"))
store = gamestore.HeroStore() store = gamestore.HeroStore()
@@ -228,6 +229,7 @@ def superheroji():
min_rounds=gamestore.MIN_ROUNDS, min_rounds=gamestore.MIN_ROUNDS,
max_rounds=gamestore.MAX_ROUNDS, max_rounds=gamestore.MAX_ROUNDS,
heroes=store.leaderboard(), heroes=store.leaderboard(),
matchups=store.matchup_list(),
active_page="superheroji", active_page="superheroji",
) )

View File

@@ -56,11 +56,36 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
<form method="post" onsubmit="return confirm('Obrisati sve zapamćene jačine heroja? Ovo se ne može poništiti.');" style="margin-top:16px;">
<input type="hidden" name="action" value="reset">
<button type="submit" class="danger">Obriši pamćenje</button>
</form>
{% else %} {% else %}
<p class="empty">Još nema odigranih borbi. Rang-lista se puni kako djeca igraju.</p> <p class="empty">Još nema odigranih borbi. Rang-lista se puni kako djeca igraju.</p>
{% endif %} {% endif %}
<h2 style="font-size:16px; margin: 26px 0 4px;">Zabilježeni dvoboji</h2>
<p class="sub">Konkretni ishodi sa razlogom. Kad se isti dvoboj ponovi, Idriz čita odavde i ne pita LLM ponovo.</p>
{% if matchups %}
<table>
<thead>
<tr><th>Pobjednik</th><th>Gubitnik</th><th>Razlog</th><th class="num">Puta</th></tr>
</thead>
<tbody>
{% for m in matchups %}
<tr>
<td><strong>{{ m.winner }}</strong></td>
<td>{{ m.loser }}</td>
<td>{{ m.reason }}</td>
<td class="num">{{ m.count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty">Još nema zabilježenih dvoboja.</p>
{% endif %}
{% if heroes or matchups %}
<form method="post" onsubmit="return confirm('Obrisati sve zapamćene jačine i dvoboje? Ovo se ne može poništiti.');" style="margin-top:18px;">
<input type="hidden" name="action" value="reset">
<button type="submit" class="danger">Obriši pamćenje</button>
</form>
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -33,8 +33,8 @@ Kako se igra Superheroji:
- Čim igrač jasno kaže lika, pozovi superheroji_izbor (igrac, heroj) sa imenom TAČNO kako ga je dijete reklo. Alat ti onda kaže šta dalje. UVIJEK prvo naglas potvrdiš ime tako što ga izgovoriš ("Kuromi, zabilježeno!"), da dijete čuje jesi li dobro razumio — to radiš za SVAKOG igrača, i za zadnjeg u rundi. - Čim igrač jasno kaže lika, pozovi superheroji_izbor (igrac, heroj) sa imenom TAČNO kako ga je dijete reklo. Alat ti onda kaže šta dalje. UVIJEK prvo naglas potvrdiš ime tako što ga izgovoriš ("Kuromi, zabilježeno!"), da dijete čuje jesi li dobro razumio — to radiš za SVAKOG igrača, i za zadnjeg u rundi.
- Ako dijete kaže da si pogriješio ime (npr. "nisam rekao Hulk, rekao sam Kuromi"), ODMAH ponovo pozovi superheroji_izbor sa ispravnim imenom za istog igrača. Ne prelazi dalje dok ime nije tačno. - Ako dijete kaže da si pogriješio ime (npr. "nisam rekao Hulk, rekao sam Kuromi"), ODMAH ponovo pozovi superheroji_izbor sa ispravnim imenom za istog igrača. Ne prelazi dalje dok ime nije tačno.
- Ako alat odbije izbor (lik je već pobijedio ranije, ili je već izabran u rundi), kratko reci zašto i traži od tog igrača drugog lika. - Ako alat odbije izbor (lik je već pobijedio ranije, ili je već izabran u rundi), kratko reci zašto i traži od tog igrača drugog lika.
- Kad su SVI igrači u rundi izabrali, pozovi superheroji_presudi. Alat ti da pobjednika runde i obrazloženje. Objavi to djeci veselo: reci ko je pobijedio i zašto. Pobjednički heroj se više NE može koristiti do kraja te partije (o tome brine alat). - Kad ZADNJI igrač u rundi izabere, presuda kreće SAMA — ne zoveš nikakav alat za nju. Ti tada samo: (1) potvrdiš ime zadnjeg lika naglas i (2) veselo kažeš da sada gledaš ko pobjeđuje ("Da vidim ko je jači..."). Zatim SAČEKAJ — rezultat runde ću ti ja poslati porukom, pa ga tek onda objavi djeci: ko je pobijedio i zašto, veselo. Pobjednički lik se više NE može birati do kraja partije.
- Alat ti kaže da li slijedi nova runda (i s kojim redoslijedom) ili je partija gotova. Kad je gotova, objavi konačnog pobjednika partije i čestitaj. - Poruku sa rezultatom (počinje sa REZULTAT RUNDE) obavezno objavi djeci. Ona ti kaže i da li slijedi nova runda (s kojim redoslijedom) ili je partija gotova. Kad je gotova, objavi konačnog pobjednika partije i čestitaj.
- Ako djeca žele stati ranije, pozovi superheroji_kraj. - Ako djeca žele stati ranije, pozovi superheroji_kraj.
- Za igru ne treba potvrda kao za štampanje — samo lijepo vodi igru. - Za igru ne treba potvrda kao za štampanje — samo lijepo vodi igru.
@@ -115,12 +115,6 @@ TOOLS = [
"required": ["igrac", "heroj"], "required": ["igrac", "heroj"],
}, },
}, },
{
"type": "function",
"name": "superheroji_presudi",
"description": "Kad su SVI igrači u rundi izabrali heroja, presuđuje ko pobjeđuje i zašto. Vraća pobjednika, obrazloženje i šta dalje (nova runda ili kraj partije).",
"parameters": {"type": "object", "properties": {}},
},
{ {
"type": "function", "type": "function",
"name": "superheroji_kraj", "name": "superheroji_kraj",
@@ -129,7 +123,7 @@ TOOLS = [
}, },
] ]
GAME_TOOLS = {"superheroji_nova_partija", "superheroji_izbor", "superheroji_presudi", "superheroji_kraj"} GAME_TOOLS = {"superheroji_nova_partija", "superheroji_izbor", "superheroji_kraj"}
def _slug(s: str) -> str: def _slug(s: str) -> str:
@@ -225,14 +219,33 @@ class Assistant:
f'da dijete čuje kako si ga razumio. Ako dijete kaže da to nije taj lik, ponovo pozovi ' f'da dijete čuje kako si ga razumio. Ako dijete kaže da to nije taj lik, ponovo pozovi '
f'superheroji_izbor sa ispravnim imenom za istog igrača.') f'superheroji_izbor sa ispravnim imenom za istog igrača.')
if self.match.round_complete: if self.match.round_complete:
return confirm + " Svi igrači su izabrali u ovoj rundi. Zatim ODMAH pozovi superheroji_presudi." # zadnji igrač je izabrao — presuda kreće sama u pozadini
task = asyncio.create_task(self._adjudicate_and_announce(self.match))
self.jobs.add(task)
task.add_done_callback(self.jobs.discard)
return (confirm + ' Svi su izabrali. Zatim veselo reci da SADA gledaš ko pobjeđuje '
'(npr. "Da vidim ko je jači, samo tren..."). NE zovi nijedan alat — '
'rezultat runde ću ti poslati porukom, pa ga onda objavi.')
nxt = self.match.next_picker() nxt = self.match.next_picker()
return confirm + f" Zatim pitaj igrača {nxt} da kaže svog lika." return confirm + f" Zatim pitaj igrača {nxt} da kaže svog lika."
async def game_presudi(self, args: dict) -> str: async def _adjudicate_and_announce(self, match: "game.Match") -> None:
if not self.match: """Presudi rundu (može potrajati). Ako traje, ubaci filler; onda pošalji rezultat modelu."""
return "Nema aktivne partije. Prvo pokreni igru sa superheroji_nova_partija." try:
res = await self.match.resolve_round() adj = asyncio.create_task(match.resolve_round())
try:
res = await asyncio.wait_for(asyncio.shield(adj), timeout=8)
except asyncio.TimeoutError:
await self._nudge("Reci kratko, veselo i u jednoj rečenici da još razmišljaš ko bi pobijedio "
"(npr. 'Hmm, još vagam ko je jači...'). Ne otkrivaj pobjednika.")
res = await adj
except Exception: # noqa: BLE001
log.exception("presuda runde nije uspjela")
await self.notify_model("Došlo je do greške pri presudi runde. Reci djeci da još jednom kažu svoje likove.")
return
await self.notify_model(self._format_verdict(res))
def _format_verdict(self, res: dict) -> str:
if not res.get("ok"): if not res.get("ok"):
return res.get("poruka", "Runda još nije spremna za presudu.") return res.get("poruka", "Runda još nije spremna za presudu.")
txt = (f"REZULTAT RUNDE {res['runda']}: pobjeđuje {res['pobjednik_heroj']} " txt = (f"REZULTAT RUNDE {res['runda']}: pobjeđuje {res['pobjednik_heroj']} "
@@ -271,8 +284,6 @@ class Assistant:
result = await self.game_nova(args) result = await self.game_nova(args)
elif name == "superheroji_izbor": elif name == "superheroji_izbor":
result = await self.game_izbor(args) result = await self.game_izbor(args)
elif name == "superheroji_presudi":
result = await self.game_presudi(args)
elif name == "superheroji_kraj": elif name == "superheroji_kraj":
result = await self.game_kraj(args) result = await self.game_kraj(args)
else: else:
@@ -328,6 +339,23 @@ class Assistant:
log.info("govor prekinut (mikrofon ugašen)") log.info("govor prekinut (mikrofon ugašen)")
await self.speaker.write(audio.tone_off()) await self.speaker.write(audio.tone_off())
async def _wait_idle(self, timeout: float = 30.0) -> None:
"""Sačekaj da se trenutni odgovor završi prije nego kreiramo novi (jedan odgovor u isto vrijeme)."""
loop = asyncio.get_event_loop()
deadline = loop.time() + timeout
while self.response_active and loop.time() < deadline:
await asyncio.sleep(0.1)
async def _nudge(self, instructions: str) -> None:
"""Kratko podstakni model da nešto kaže (npr. filler dok razmišlja), bez nove kontekst-poruke."""
if not self.conn:
return
await self._wait_idle()
try:
await self.conn.send({"type": "response.create", "response": {"instructions": instructions}})
except Exception: # noqa: BLE001
log.exception("nudge nije uspio")
async def notify_model(self, text: str) -> None: async def notify_model(self, text: str) -> None:
if not self.conn: if not self.conn:
return return
@@ -336,6 +364,7 @@ class Assistant:
"type": "conversation.item.create", "type": "conversation.item.create",
"item": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": f"[SISTEMSKA PORUKA] {text}"}]}, "item": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": f"[SISTEMSKA PORUKA] {text}"}]},
}) })
await self._wait_idle()
await self.conn.send({"type": "response.create"}) await self.conn.send({"type": "response.create"})
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
log.exception("nisam uspio obavijestiti model") log.exception("nisam uspio obavijestiti model")

View File

@@ -122,11 +122,20 @@ async def adjudicate(client: AsyncOpenAI, model: str, store: HeroStore, picks: l
"""picks: lista {key, name, desc}. Vrati {winner_key, winner_name, reason}.""" """picks: lista {key, name, desc}. Vrati {winner_key, winner_name, reason}."""
if len(picks) == 1: if len(picks) == 1:
p = picks[0] p = picks[0]
return {"winner_key": p["key"], "winner_name": p["name"], return {"winner_key": p["key"], "winner_name": p["name"], "cached": True,
"reason": f"{p['name']} je jedini u ovoj rundi, pa pobjeđuje bez borbe!"} "reason": f"{p['name']} je jedini u ovoj rundi, pa pobjeđuje bez borbe!"}
keys = [p["key"] for p in picks] keys = [p["key"] for p in picks]
by_key = {p["key"]: p for p in picks} by_key = {p["key"]: p for p in picks}
# 1) Ako smo ovaj dvoboj već vidjeli — pročitaj iz baze, BEZ poziva LLM-u.
cached = store.get_matchup(keys)
if cached and cached.get("winner") in by_key:
wk = cached["winner"]
reason = cached.get("reason") or f"{by_key[wk]['name']} ponovo pobjeđuje, kao i prošli put."
log.info("dvoboj iz baze (bez LLM): pobjednik %s", by_key[wk]["name"])
return {"winner_key": wk, "winner_name": by_key[wk]["name"], "reason": reason, "cached": True}
favorite = store.clear_favorite(keys) favorite = store.clear_favorite(keys)
lines = [] lines = []
@@ -175,7 +184,7 @@ async def adjudicate(client: AsyncOpenAI, model: str, store: HeroStore, picks: l
winner_name = by_key[winner_key]["name"] winner_name = by_key[winner_key]["name"]
if not reason: if not reason:
reason = f"{winner_name} pobjeđuje u ovoj rundi!" reason = f"{winner_name} pobjeđuje u ovoj rundi!"
return {"winner_key": winner_key, "winner_name": winner_name, "reason": reason} return {"winner_key": winner_key, "winner_name": winner_name, "reason": reason, "cached": False}
class Match: class Match:
@@ -260,8 +269,14 @@ class Match:
verdict = await adjudicate(self.client, self.model, self.store, participants) verdict = await adjudicate(self.client, self.model, self.store, participants)
winner_key = verdict["winner_key"] winner_key = verdict["winner_key"]
loser_keys = [pk["key"] for pk in participants if pk["key"] != winner_key] loser_keys = [pk["key"] for pk in participants if pk["key"] != winner_key]
self.store.record_match(winner_key, loser_keys) if len(participants) >= 2:
self.store.save() if verdict.get("cached"):
# ishod pročitan iz baze — samo osvježi brojače, bez Elo i bez novog LLM-a
self.store.bump_counts(winner_key, loser_keys)
else:
self.store.record_match(winner_key, loser_keys) # Elo
self.store.record_matchup([pk["key"] for pk in participants], winner_key, verdict["reason"])
self.store.save()
self.banned.add(winner_key) self.banned.add(winner_key)
# koji igrač je izabrao pobjednika # koji igrač je izabrao pobjednika

View File

@@ -30,6 +30,11 @@ def normalize_key(name: str) -> str:
return re.sub(r"[^a-z0-9]+", "", s.lower()) return re.sub(r"[^a-z0-9]+", "", s.lower())
def matchup_key(keys: list[str]) -> str:
"""Ključ za jedan dvoboj/borbu — skup likova, neovisno o redoslijedu."""
return "|".join(sorted(set(keys)))
def _atomic_write(path: Path, data: dict) -> None: def _atomic_write(path: Path, data: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp") fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
@@ -73,6 +78,7 @@ class HeroStore:
self.path = path self.path = path
self.heroes: dict[str, dict] = {} self.heroes: dict[str, dict] = {}
self.aliases: dict[str, str] = {} self.aliases: dict[str, str] = {}
self.matchups: dict[str, dict] = {}
self.load() self.load()
def load(self) -> None: def load(self) -> None:
@@ -80,13 +86,15 @@ class HeroStore:
data = json.loads(self.path.read_text(encoding="utf-8")) data = json.loads(self.path.read_text(encoding="utf-8"))
self.heroes = data.get("heroes", {}) self.heroes = data.get("heroes", {})
self.aliases = data.get("aliases", {}) self.aliases = data.get("aliases", {})
self.matchups = data.get("matchups", {})
except (OSError, json.JSONDecodeError): except (OSError, json.JSONDecodeError):
self.heroes = {} self.heroes = {}
self.aliases = {} self.aliases = {}
self.matchups = {}
def save(self) -> None: def save(self) -> None:
with _lock: with _lock:
_atomic_write(self.path, {"heroes": self.heroes, "aliases": self.aliases}) _atomic_write(self.path, {"heroes": self.heroes, "aliases": self.aliases, "matchups": self.matchups})
def resolve_alias(self, spoken: str) -> str | None: def resolve_alias(self, spoken: str) -> str | None:
"""Vrati kanonski ključ za izgovoreno ime ako smo ga ranije već sreli.""" """Vrati kanonski ključ za izgovoreno ime ako smo ga ranije već sreli."""
@@ -159,6 +167,68 @@ class HeroStore:
return top_key return top_key
return None return None
# ---- pamćenje konkretnih dvoboja (uz razlog), da se ne zove LLM ponovo ----
def get_matchup(self, keys: list[str]) -> dict | None:
"""Vrati zabilježen ishod za tačno taj skup likova, ili za par ako ih je dvoje."""
rec = self.matchups.get(matchup_key(keys))
if rec:
return rec
if len(set(keys)) == 2:
return self.matchups.get(matchup_key(keys))
return None
def record_matchup(self, participant_keys: list[str], winner_key: str, reason: str) -> None:
"""Zabilježi ishod (pobjednik + razlog) za cijeli skup i za svaki par pobjednik>gubitnik."""
parts = sorted(set(participant_keys))
full = matchup_key(parts)
prev = self.matchups.get(full, {})
self.matchups[full] = {"winner": winner_key, "reason": reason,
"participants": parts, "count": prev.get("count", 0) + 1}
# parovi pobjednik vs gubitnik — ne gazi raniji ishod para (dosljednost)
for lk in parts:
if lk == winner_key:
continue
pk = matchup_key([winner_key, lk])
if pk not in self.matchups:
self.matchups[pk] = {"winner": winner_key, "reason": reason,
"participants": sorted([winner_key, lk]), "count": 1}
else:
self.matchups[pk]["count"] = self.matchups[pk].get("count", 0) + 1
def bump_counts(self, winner_key: str, loser_keys: list[str]) -> None:
"""Kad se ishod čita iz baze (bez LLM/Elo), samo osvježi brojače pobjeda/poraza."""
w = self.heroes.get(winner_key)
if w:
w["wins"] = w.get("wins", 0) + 1
w["games"] = w.get("games", 0) + 1
for lk in loser_keys:
l = self.heroes.get(lk)
if l:
l["losses"] = l.get("losses", 0) + 1
l["games"] = l.get("games", 0) + 1
def _name(self, key: str) -> str:
return (self.heroes.get(key) or {}).get("name", key)
def matchup_list(self) -> list[dict]:
"""Za admin: zabilježeni parovi (pobjednik > gubitnik : razlog)."""
rows = []
for rec in self.matchups.values():
parts = rec.get("participants", [])
if len(parts) != 2:
continue
w = rec.get("winner", "")
losers = [p for p in parts if p != w]
rows.append({
"winner": self._name(w),
"loser": self._name(losers[0]) if losers else "",
"reason": rec.get("reason", ""),
"count": rec.get("count", 1),
})
rows.sort(key=lambda r: r["count"], reverse=True)
return rows
def leaderboard(self) -> list[dict]: def leaderboard(self) -> list[dict]:
rows = [] rows = []
for key, h in self.heroes.items(): for key, h in self.heroes.items():