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.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",
)

View File

@@ -56,11 +56,36 @@
{% endfor %}
</tbody>
</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 %}
<p class="empty">Još nema odigranih borbi. Rang-lista se puni kako djeca igraju.</p>
{% 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 %}

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.
- 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")

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}."""
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

View File

@@ -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():