diff --git a/admin/app.py b/admin/app.py index 3d64c35..96e5a71 100644 --- a/admin/app.py +++ b/admin/app.py @@ -217,8 +217,9 @@ def superheroji(): store = gamestore.HeroStore() store.heroes = {} store.aliases = {} + store.matchups = {} store.save() - flash("Pamćenje jačina heroja je obrisano.") + flash("Pamćenje jačina i dvoboja je obrisano.") return redirect(url_for("superheroji")) store = gamestore.HeroStore() @@ -228,6 +229,7 @@ def superheroji(): min_rounds=gamestore.MIN_ROUNDS, max_rounds=gamestore.MAX_ROUNDS, heroes=store.leaderboard(), + matchups=store.matchup_list(), active_page="superheroji", ) diff --git a/admin/templates/superheroji.html b/admin/templates/superheroji.html index cc44fc8..0e9d81a 100644 --- a/admin/templates/superheroji.html +++ b/admin/templates/superheroji.html @@ -56,11 +56,36 @@ {% endfor %} -
{% else %}Još nema odigranih borbi. Rang-lista se puni kako djeca igraju.
{% endif %} + +Konkretni ishodi sa razlogom. Kad se isti dvoboj ponovi, Idriz čita odavde i ne pita LLM ponovo.
+ {% if matchups %} +| Pobjednik | Gubitnik | Razlog | Puta |
|---|---|---|---|
| {{ m.winner }} | +{{ m.loser }} | +{{ m.reason }} | +{{ m.count }} | +
Još nema zabilježenih dvoboja.
+ {% endif %} + + {% if heroes or matchups %} + + {% endif %} {% endblock %} diff --git a/idriz/assistant.py b/idriz/assistant.py index 6a9ae27..995fb93 100644 --- a/idriz/assistant.py +++ b/idriz/assistant.py @@ -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. - 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. -- 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). -- 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. +- 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. +- 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. - Za igru ne treba potvrda kao za štampanje — samo lijepo vodi igru. @@ -115,12 +115,6 @@ TOOLS = [ "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", "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: @@ -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'superheroji_izbor sa ispravnim imenom za istog igrača.') 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() return confirm + f" Zatim pitaj igrača {nxt} da kaže svog lika." - async def game_presudi(self, args: dict) -> str: - if not self.match: - return "Nema aktivne partije. Prvo pokreni igru sa superheroji_nova_partija." - res = await self.match.resolve_round() + async def _adjudicate_and_announce(self, match: "game.Match") -> None: + """Presudi rundu (može potrajati). Ako traje, ubaci filler; onda pošalji rezultat modelu.""" + try: + 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"): return res.get("poruka", "Runda još nije spremna za presudu.") txt = (f"REZULTAT RUNDE {res['runda']}: pobjeđuje {res['pobjednik_heroj']} " @@ -271,8 +284,6 @@ class Assistant: result = await self.game_nova(args) elif name == "superheroji_izbor": result = await self.game_izbor(args) - elif name == "superheroji_presudi": - result = await self.game_presudi(args) elif name == "superheroji_kraj": result = await self.game_kraj(args) else: @@ -328,6 +339,23 @@ class Assistant: log.info("govor prekinut (mikrofon ugašen)") 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: if not self.conn: return @@ -336,6 +364,7 @@ class Assistant: "type": "conversation.item.create", "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"}) except Exception: # noqa: BLE001 log.exception("nisam uspio obavijestiti model") diff --git a/idriz/game.py b/idriz/game.py index a1bca08..c7da108 100644 --- a/idriz/game.py +++ b/idriz/game.py @@ -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}.""" if len(picks) == 1: 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!"} keys = [p["key"] 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) lines = [] @@ -175,7 +184,7 @@ async def adjudicate(client: AsyncOpenAI, model: str, store: HeroStore, picks: l winner_name = by_key[winner_key]["name"] if not reason: 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: @@ -260,8 +269,14 @@ class Match: verdict = await adjudicate(self.client, self.model, self.store, participants) winner_key = verdict["winner_key"] loser_keys = [pk["key"] for pk in participants if pk["key"] != winner_key] - self.store.record_match(winner_key, loser_keys) - self.store.save() + if len(participants) >= 2: + 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) # koji igrač je izabrao pobjednika diff --git a/idriz/gamestore.py b/idriz/gamestore.py index 53fd406..7c2d732 100644 --- a/idriz/gamestore.py +++ b/idriz/gamestore.py @@ -30,6 +30,11 @@ def normalize_key(name: str) -> str: 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: path.parent.mkdir(parents=True, exist_ok=True) fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp") @@ -73,6 +78,7 @@ class HeroStore: self.path = path self.heroes: dict[str, dict] = {} self.aliases: dict[str, str] = {} + self.matchups: dict[str, dict] = {} self.load() def load(self) -> None: @@ -80,13 +86,15 @@ class HeroStore: data = json.loads(self.path.read_text(encoding="utf-8")) self.heroes = data.get("heroes", {}) self.aliases = data.get("aliases", {}) + self.matchups = data.get("matchups", {}) except (OSError, json.JSONDecodeError): self.heroes = {} self.aliases = {} + self.matchups = {} def save(self) -> None: 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: """Vrati kanonski ključ za izgovoreno ime ako smo ga ranije već sreli.""" @@ -159,6 +167,68 @@ class HeroStore: return top_key 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]: rows = [] for key, h in self.heroes.items():