Dodaj igru Superheroji (glasovna) + admin tab

Igra: 1-5 igrača, nekoliko rundi (konfigurabilno). Svaku rundu svaki igrač
kaže izmišljenog lika (širok pojam — bilo koji medij), Idriz presudi ko bi
pobijedio i zašto. Pobjednik runde se banuje do kraja partije. Redoslijed se
rotira svaku rundu (zadnji ima prednost). Najviše pobjeda runda = pobjednik partije.

- idriz/gamestore.py: trajno skladište (heroji, Elo rejting, broj rundi), bez
  vanjskih zavisnosti (i admin ga uvozi). clear_favorite() drži borbe dosljedne
  kroz partije (jači heroj ne gubi od znatno slabijeg).
- idriz/game.py: resolve_hero (najjači LLM gpt-5.5 + web search fallback samo kad
  ne zna lika), adjudicate (presuda + obrazloženje na bosanskom), Match (tok partije).
- assistant.py: 4 realtime alata (nova_partija, izbor, presudi, kraj) + upute;
  igra ide odmah nazad modelu, ne kroz pozadinski posao.
- config.py: game_model (default = text_model).
- admin: tab "Superheroji" (broj rundi + rang-lista + reset pamćenja).

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 11:57:43 +02:00
parent 24cbe280d6
commit db0a9b88fd
8 changed files with 731 additions and 1 deletions

View File

@@ -39,3 +39,18 @@ Odštampani PDF-ovi ostaju u `~/.local/share/idriz/out/`.
```
IDRIZ_DRY_RUN=1 .venv/bin/python scripts/test_summary.py "Volcano" "8 godina"
```
## Igra "Superheroji"
Glasovna igra (bez štampanja). Tok: Idriz pita koliko igrača (1–5) i imena, pa se
igra nekoliko rundi. Svaku rundu svaki igrač kaže jednog izmišljenog lika (bilo koji
medij — Supermen, Mario, La Linea, Mad Max...), a Idriz presudi ko bi pobijedio i
zašto. Pobjednik runde se više ne može birati do kraja partije. Sistem pamti
relativne jačine likova (Elo) u `~/.local/share/idriz/heroes.json` da borbe budu
dosljedne kroz partije. Za prepoznavanje likova koristi se najjači tekstualni model
(`game_model`, default `gpt-5.5`) i njegovo znanje; web search (Responses API) samo
kad model ne zna lika.
- `idriz/gamestore.py` — trajno skladište (heroji, Elo, broj rundi), bez vanjskih zavisnosti.
- `idriz/game.py` — prepoznavanje likova, presuđivanje, tok partije (`Match`).
- Broj rundi i rang-lista heroja se podešavaju u admin panelu (tab "Superheroji").

View File

@@ -7,14 +7,20 @@ Prijava ide preko PAM-a (Linux korisnik `idriz`).
import os
import secrets
import subprocess
import sys
from datetime import datetime
from functools import wraps
from pathlib import Path
import pam as pam_module
from flask import (
Flask, render_template, request, redirect, url_for, session, flash
)
# Pristup skladištu igre (bez openai zavisnosti) iz glavnog idriz paketa.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from idriz import gamestore # noqa: E402
# --- Konfiguracija -----------------------------------------------------------
# Samo ovaj Linux korisnik smije da se prijavi.
@@ -195,6 +201,37 @@ def status():
)
@app.route("/superheroji", methods=["GET", "POST"])
@login_required
def superheroji():
if request.method == "POST":
action = request.form.get("action")
if action == "rounds":
try:
n = int(request.form.get("rounds", ""))
saved = gamestore.save_rounds(n)
flash(f"Broj rundi postavljen na {saved}.")
except (ValueError, TypeError):
flash("Neispravan broj rundi.")
elif action == "reset":
store = gamestore.HeroStore()
store.heroes = {}
store.aliases = {}
store.save()
flash("Pamćenje jačina heroja je obrisano.")
return redirect(url_for("superheroji"))
store = gamestore.HeroStore()
return render_template(
"superheroji.html",
rounds=gamestore.load_rounds(),
min_rounds=gamestore.MIN_ROUNDS,
max_rounds=gamestore.MAX_ROUNDS,
heroes=store.leaderboard(),
active_page="superheroji",
)
if __name__ == "__main__":
from waitress import serve
print(f"Idriz admin na http://{LISTEN_HOST}:{LISTEN_PORT}")

View File

@@ -98,6 +98,9 @@
<a href="{{ url_for('status') }}" class="{{ 'active' if active_page == 'status' else '' }}">
<span class="icon">●</span> Status servisa
</a>
<a href="{{ url_for('superheroji') }}" class="{{ 'active' if active_page == 'superheroji' else '' }}">
<span class="icon">★</span> Superheroji
</a>
</nav>
<div class="sidebar-foot">
{% if session.user %}<a href="{{ url_for('logout') }}">↩ Odjava</a>{% endif %}

View File

@@ -0,0 +1,66 @@
{% extends "base.html" %}
{% block title %}Superheroji — Idriz Admin{% endblock %}
{% block extra_css %}
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
padding: 20px 22px; margin-bottom: 22px; max-width: 640px; }
.panel h2 { margin: 0 0 4px; font-size: 16px; }
.panel p.hint { color: var(--muted); font-size: 13px; margin: 0 0 16px; }
.row { display: flex; align-items: center; gap: 12px; }
input[type=number] { width: 90px; padding: 9px 12px; font-size: 15px;
border: 1px solid var(--line); border-radius: 8px; }
button { padding: 9px 18px; font-size: 14px; font-weight: 600; color: #fff;
background: var(--accent); border: none; border-radius: 8px; cursor: pointer; }
button:hover { filter: brightness(1.05); }
button.danger { background: var(--bad); }
table { width: 100%; border-collapse: collapse; background: var(--panel);
border: 1px solid var(--line); border-radius: 10px; overflow: hidden; max-width: 640px; }
th, td { text-align: left; padding: 10px 14px; font-size: 14px; }
th { background: #fafbfc; color: var(--muted); font-weight: 600; border-bottom: 1px solid var(--line); }
tr:not(:last-child) td { border-bottom: 1px solid var(--line); }
td.num { text-align: right; font-variant-numeric: tabular-nums; }
.rank { color: var(--muted); width: 30px; }
.src { font-size: 11px; color: var(--muted); }
.empty { color: var(--muted); font-style: italic; }
{% endblock %}
{% block content %}
<h1>Superheroji</h1>
<p class="sub">Podešavanje igre i pamćenje relativnih jačina likova.</p>
<div class="panel">
<h2>Broj rundi po partiji</h2>
<p class="hint">Koliko rundi traje jedna partija ({{ min_rounds }}–{{ max_rounds }}). Primjenjuje se na sljedeću partiju.</p>
<form method="post" class="row">
<input type="hidden" name="action" value="rounds">
<input type="number" name="rounds" min="{{ min_rounds }}" max="{{ max_rounds }}" value="{{ rounds }}">
<button type="submit">Sačuvaj</button>
</form>
</div>
<h2 style="font-size:16px; margin: 0 0 4px;">Rang-lista heroja</h2>
<p class="sub">Snaga (rejting) raste kad heroj pobjeđuje. Idriz je koristi da borbe budu dosljedne kroz partije.</p>
{% if heroes %}
<table>
<thead>
<tr><th class="rank">#</th><th>Heroj</th><th class="num">Snaga</th><th class="num">Pobjede</th><th class="num">Porazi</th><th class="num">Borbi</th></tr>
</thead>
<tbody>
{% for h in heroes %}
<tr>
<td class="rank">{{ loop.index }}</td>
<td>{{ h.name }} {% if h.source == 'web' %}<span class="src">(web)</span>{% endif %}</td>
<td class="num">{{ h.rating }}</td>
<td class="num">{{ h.wins }}</td>
<td class="num">{{ h.losses }}</td>
<td class="num">{{ h.games }}</td>
</tr>
{% 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 %}
{% endblock %}

View File

@@ -9,7 +9,7 @@ from pathlib import Path
from openai import AsyncOpenAI
from . import audio, coloring, printer, render, textgen, wiki
from . import audio, coloring, game, printer, render, textgen, wiki
from .audio import Mic, Speaker
from .config import Config
@@ -23,6 +23,17 @@ Govoriš toplo, kratko i jasno, kao strpljivi stariji brat. Rečenice su kratke.
1. RAZGOVARATI I ODGOVARATI NA PITANJA. Dijete te može pitati bilo šta: za školu, o svijetu, o životinjama, svemiru, historiji, matematici, riječima... Odgovaraš iz svog znanja, glasom, bez štampanja.
2. Odštampati sažetak o nekoj temi. Tražiš članak na engleskoj Wikipediji, prevedeš ga na bosanski i odštampaš sažetak do dvije stranice. Alat: wikipedia_sazetak.
3. Odštampati bojanku: crno-bijelu sliku sa konturama koju dijete može bojiti. Alat: bojanka.
4. IGRATI IGRU "SUPERHEROJI" (glasom, bez štampanja). Alati: superheroji_nova_partija, superheroji_izbor, superheroji_presudi, superheroji_kraj.
Kako se igra Superheroji:
- NE objašnjavaj pravila na početku. Pretpostavi da djeca znaju igrati. Pravila objasni samo ako te izričito pitaju.
- Kad djeca kažu da žele igrati: prvo pitaj koliko igrača igra (od 1 do 5), pa ih pitaj za imena. Onda pozovi superheroji_nova_partija sa listom imena. Alat ti vrati broj rundi i redoslijed biranja za prvu rundu.
- U svakoj rundi traži od igrača, TAČNO onim redoslijedom koji ti je alat dao, da svaki kaže jednog superheroja. Definicija superheroja je široka: bilo koji izmišljeni lik iz bilo kog medija (Supermen, Mario, La Linea, Mad Max, Gandalf...). Zadnji igrač ima prednost jer bira zadnji, zato se redoslijed mijenja svaku rundu — uvijek koristi redoslijed koji ti alat da.
- Čim igrač kaže heroja, pozovi superheroji_izbor (igrac, heroj). Alat potvrdi ili odbije (npr. ako je taj heroj već pobijedio ranije u partiji, ili je već izabran u rundi). Ako je odbijen, kratko reci zašto i traži od tog igrača drugog heroja. Ako je prihvaćen, kratko potvrdi ("Supermen, zabilježeno!") i pitaj sljedećeg igrača.
- 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.
- Ako djeca žele stati ranije, pozovi superheroji_kraj.
- Za igru ne treba potvrda kao za štampanje — samo lijepo vodi igru.
Kako odgovaraš na pitanja:
- Prvi odgovor je kratak: dvije do četiri rečenice, najvažnije prvo. Zatim možeš pitati "Hoćeš da ti kažem još?" ili "Je li ti jasno?".
@@ -76,8 +87,47 @@ TOOLS = [
"required": ["opis", "opis_bosanski"],
},
},
{
"type": "function",
"name": "superheroji_nova_partija",
"description": "Započinje novu partiju igre Superheroji. Pozvati NAKON što si pitao koliko igrača (1-5) i njihova imena. Vraća broj rundi i redoslijed biranja za prvu rundu.",
"parameters": {
"type": "object",
"properties": {
"igraci": {"type": "array", "items": {"type": "string"}, "description": "Imena igrača redom, od 1 do 5."},
},
"required": ["igraci"],
},
},
{
"type": "function",
"name": "superheroji_izbor",
"description": "Prijavljuje heroja kojeg je jedan igrač izabrao u trenutnoj rundi. Pozvati čim igrač kaže ime lika. Vraća potvrdu ili razlog odbijanja.",
"parameters": {
"type": "object",
"properties": {
"igrac": {"type": "string", "description": "Ime igrača koji bira."},
"heroj": {"type": "string", "description": "Ime lika kako ga je igrač rekao."},
},
"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",
"description": "Prekida trenutnu partiju ranije ako igrači žele stati. Vraća trenutni rezultat.",
"parameters": {"type": "object", "properties": {}},
},
]
GAME_TOOLS = {"superheroji_nova_partija", "superheroji_izbor", "superheroji_presudi", "superheroji_kraj"}
def _slug(s: str) -> str:
s = re.sub(r"[^a-zA-Z0-9]+", "-", s).strip("-").lower()
@@ -96,6 +146,7 @@ class Assistant:
self.current_response = ""
self.response_active = False
self.cancelled_responses: set[str] = set()
self.match: game.Match | None = None
# ---------- alati ----------
async def tool_wikipedia(self, args: dict) -> str:
@@ -144,6 +195,89 @@ class Assistant:
await printer.print_file(out, self.cfg.printer, self.cfg.dry_run, fit=True)
return f"Bojanka '{subject_bs}' je odštampana."
# ---------- igra Superheroji ----------
async def game_nova(self, args: dict) -> str:
players = args.get("igraci") or []
if isinstance(players, str):
players = [players]
self.match = game.Match(self.client, self.cfg.game_model, players)
info = self.match.start_info()
order = ", ".join(info["redoslijed"])
prvi = info["redoslijed"][0]
return (f"Partija je počela. Igrači: {', '.join(info['igraci'])}. Broj rundi: {info['broj_rundi']}. "
f"RUNDA 1 — redoslijed biranja: {order}. "
f"Traži od igrača {prvi} da kaže svog superheroja, pa redom ostale tim redoslijedom. "
f"Kad ti igrač kaže heroja, pozovi superheroji_izbor.")
async def game_izbor(self, args: dict) -> str:
if not self.match:
return "Nema aktivne partije. Prvo pokreni igru sa superheroji_nova_partija."
res = await self.match.record_pick(args.get("igrac", ""), args.get("heroj", ""))
if res.get("ok"):
return (f"{res['poruka']} Kratko potvrdi to djetetu i pitaj sljedećeg igrača po redoslijedu. "
f"Kad SVI igrači u rundi izaberu, pozovi superheroji_presudi.")
return res.get("poruka", "Taj izbor ne može proći, neka igrač izabere drugog heroja.")
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()
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']} "
f"(izabrao ga {res['pobjednik_igrac']}). Obrazloženje: {res['obrazlozenje']} "
f"Objavi ovo djeci veselo — ko je pobijedio i zašto. "
f"Napomeni da se {res['pobjednik_heroj']} više ne može birati u ovoj partiji.")
if res.get("gotovo"):
st = res["konacni_rezultat"]
poredak = ", ".join(f"{r['igrac']} {r['pobjede']}" for r in st["poredak"])
if st["nerijeseno"]:
txt += f" PARTIJA JE GOTOVA! Neriješeno je (poredak: {poredak}). Čestitaj svima."
else:
txt += f" PARTIJA JE GOTOVA! Pobjednik partije je {st['pobjednici'][0]}! Poredak: {poredak}. Čestitaj pobjedniku."
self.match = None
else:
order = ", ".join(res["sljedeci_redoslijed"])
prvi = res["sljedeci_redoslijed"][0]
txt += (f" Zatim kreće RUNDA {res['sljedeca_runda']} — redoslijed: {order}. "
f"Traži od igrača {prvi} novog heroja.")
return txt
async def game_kraj(self, args: dict) -> str:
if not self.match:
return "Trenutno nema aktivne partije."
st = self.match.standings()
poredak = ", ".join(f"{r['igrac']} {r['pobjede']}" for r in st["poredak"])
self.match = None
if st["pobjednici"] and not st["nerijeseno"]:
return f"Partija je prekinuta. Do sada vodi {st['pobjednici'][0]}. Poredak: {poredak}. Reci to djeci."
return f"Partija je prekinuta. Poredak: {poredak}. Reci to djeci."
async def run_game_tool(self, name: str, call_id: str, args: dict) -> None:
"""Igra: izračunaj rezultat pa ga odmah vrati modelu da ga izgovori."""
try:
if name == "superheroji_nova_partija":
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:
result = f"Nepoznat alat {name}."
except Exception as e: # noqa: BLE001
log.exception("igra: alat %s nije uspio", name)
result = f"Došlo je do greške u igri ({type(e).__name__}). Reci djeci da probamo ponovo."
try:
await self.conn.send({
"type": "conversation.item.create",
"item": {"type": "function_call_output", "call_id": call_id, "output": result},
})
await self.conn.send({"type": "response.create"})
except Exception: # noqa: BLE001
log.exception("nisam uspio vratiti rezultat igre modelu")
async def run_tool(self, name: str, call_id: str, args: dict) -> None:
"""Pozadinski posao; kad završi, obavijesti model porukom i zatraži novi odgovor."""
try:
@@ -203,6 +337,12 @@ class Assistant:
except json.JSONDecodeError:
args = {}
log.info("alat %s(%s)", name, args)
if name in GAME_TOOLS:
# igra: brzo, rezultat ide odmah nazad modelu (bez "radim na tome")
task = asyncio.create_task(self.run_game_tool(name, call_id, args))
self.jobs.add(task)
task.add_done_callback(self.jobs.discard)
return
task = asyncio.create_task(self.run_tool(name, call_id, args))
self.jobs.add(task)
task.add_done_callback(self.jobs.discard)

View File

@@ -13,6 +13,7 @@ class Config:
openai_api_key: str = field(default_factory=lambda: _env("OPENAI_API_KEY", ""))
realtime_model: str = field(default_factory=lambda: _env("IDRIZ_REALTIME_MODEL", "gpt-realtime-2.1"))
text_model: str = field(default_factory=lambda: _env("IDRIZ_TEXT_MODEL", "gpt-5.5"))
game_model: str = field(default_factory=lambda: _env("IDRIZ_GAME_MODEL", ""))
image_model: str = field(default_factory=lambda: _env("IDRIZ_IMAGE_MODEL", "gpt-image-2"))
transcription_model: str = field(default_factory=lambda: _env("IDRIZ_TRANSCRIPTION_MODEL", "gpt-4o-mini-transcribe"))
voice: str = field(default_factory=lambda: _env("IDRIZ_VOICE", "cedar"))
@@ -28,6 +29,9 @@ class Config:
def __post_init__(self) -> None:
self.out_dir.mkdir(parents=True, exist_ok=True)
# "najjači LLM koji imamo" — po defaultu isti kao za tekst (gpt-5.5)
if not self.game_model:
self.game_model = self.text_model
def load() -> Config:

290
idriz/game.py Normal file
View File

@@ -0,0 +1,290 @@
"""Igra 'Superheroji': prepoznavanje likova (najjači LLM + web search kad ne zna),
presuđivanje borbe i pamćenje relativnih jačina kroz partije."""
import asyncio
import json
import logging
from openai import AsyncOpenAI
from . import gamestore, wiki
from .gamestore import HeroStore, normalize_key
log = logging.getLogger("idriz.game")
RESOLVE_SYS = """Ti si stručnjak za izmišljene likove iz SVIH medija: stripovi, filmovi, crtani, video igre, \
knjige, mitologija, memovi. Dobiješ ime lika kako ga je izgovorilo dijete (može biti na bosanskom, sa greškom u \
izgovoru ili kao nadimak). Pojam 'superheroj' ovdje je vrlo širok: bilo koji izmišljeni lik (npr. Supermen, Betmen, \
Mario, La Linea, Mad Max, Gandalf, Pikaču, Baba Jaga, Goku).
Ako prepoznaš o kojem se liku radi, vrati JSON:
{"poznat": true,
"ime": "uobičajeno ime lika na bosanskom ako postoji (npr. Supermen, Betmen, Spajdermen), inače originalno ime",
"original": "originalno/englesko ime lika",
"opis": "jedna do dvije kratke rečenice na bosanskom: ko je lik i koje su mu glavne moći ili sposobnosti"}
Ako NISI siguran ko je lik ili ga ne prepoznaješ, vrati {"poznat": false}.
Odgovori ISKLJUČIVO JSON objektom."""
ADJ_SYS = """Ti si pošten i zabavan sudija u dječijoj igri 'Superheroji'. Dobiješ listu izmišljenih likova sa kratkim \
opisima, a gdje postoji i njihovu dosadašnju snagu (rejting iz ranijih borbi; veći broj = pokazao se jačim).
Zamisli borbu svih protiv svih i odluči KO BI POBIJEDIO, te objasni ZAŠTO — kratko, pošteno i djetetu razumljivo, \
na bosanskom jeziku (2 do 4 rečenice, vedro).
VAŽNO ZA DOSLJEDNOST: ako je neki lik u ranijim borbama pokazao veću snagu (veći rejting), on ne smije izgubiti od \
znatno slabijeg lika bez izuzetno jakog razloga. Poštuj ranije ishode da igra bude fer.
Vrati JSON: {"pobjednik": "tačno ime pobjednika iz date liste", "razlog": "obrazloženje na bosanskom"}."""
async def _web_search(client: AsyncOpenAI, model: str, name: str) -> str:
"""Kratak opis lika sa weba — koristi se SAMO kad LLM ne zna lika."""
try:
resp = await client.responses.create(
model=model,
tools=[{"type": "web_search"}],
input=(f"Ko je izmišljeni lik '{name}'? Iz kojeg je djela (film, crtani, strip, igra, knjiga)? "
f"Ukratko opiši lika i njegove moći ili sposobnosti."),
)
text = (resp.output_text or "").strip()
if text:
log.info("web search za %r: %d znakova", name, len(text))
return text
except Exception: # noqa: BLE001
log.exception("web search (responses API) nije uspio za %r", name)
# rezervni izvor: engleska Wikipedia
try:
art = await asyncio.to_thread(wiki.find_article, name)
if art:
return f"{art.title}: {art.text[:800]}"
except Exception: # noqa: BLE001
log.exception("wiki fallback nije uspio za %r", name)
return ""
async def resolve_hero(client: AsyncOpenAI, model: str, store: HeroStore, spoken: str) -> dict | None:
"""Prepoznaj lika. Vrati {key, name, desc, source, known} ili None ako se nikako ne može prepoznati.
Prvo gleda keš (ranije viđena imena). Zatim trenirano znanje LLM-a. Web search tek ako LLM ne zna.
"""
spoken = (spoken or "").strip()
if not spoken:
return None
cached_key = store.resolve_alias(spoken)
if cached_key and store.get(cached_key):
h = store.get(cached_key)
return {"key": cached_key, "name": h["name"], "desc": h.get("desc", ""),
"source": h.get("source", "llm"), "known": True}
async def _ask(extra: str = "") -> dict:
content = f"Ime lika: {spoken}"
if extra:
content += f"\n\nDodatne informacije sa weba (koristi ih):\n{extra}"
resp = await client.chat.completions.create(
model=model,
messages=[{"role": "system", "content": RESOLVE_SYS}, {"role": "user", "content": content}],
response_format={"type": "json_object"},
)
try:
return json.loads(resp.choices[0].message.content or "{}")
except json.JSONDecodeError:
return {}
data = await _ask()
source = "llm"
if not data.get("poznat"):
# LLM ne zna lika -> web search, pa ponovo pitaj sa kontekstom
snippet = await _web_search(client, model, spoken)
if snippet:
data = await _ask(snippet)
source = "web"
if not data.get("poznat") and snippet:
# i dalje "nesiguran", ali imamo web opis — prihvati sa izgovorenim imenom
data = {"poznat": True, "ime": spoken.title(), "original": "",
"opis": snippet.split("\n")[0][:200]}
source = "web"
if not data.get("poznat"):
return None
name = (data.get("ime") or spoken).strip()
original = (data.get("original") or "").strip()
desc = (data.get("opis") or "").strip()
key = normalize_key(original or name)
if not key:
key = normalize_key(spoken)
h = store.upsert(key, name, original, desc, source)
store.add_alias(spoken, key)
store.add_alias(name, key)
if original:
store.add_alias(original, key)
store.save()
return {"key": key, "name": h["name"], "desc": h.get("desc", ""), "source": source, "known": source == "llm"}
async def adjudicate(client: AsyncOpenAI, model: str, store: HeroStore, picks: list[dict]) -> dict:
"""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"],
"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}
favorite = store.clear_favorite(keys)
lines = []
for p in picks:
h = store.get(p["key"]) or {}
rating = round(h.get("rating", gamestore.BASE_RATING))
games = h.get("games", 0)
snaga = f", dosadašnja snaga {rating} iz {games} borbi" if games else ", novi izazivač"
lines.append(f"- {p['name']}: {p.get('desc','(bez opisa)')}{snaga}")
listing = "\n".join(lines)
if favorite:
fav_name = by_key[favorite]["name"]
user = (f"Likovi u borbi:\n{listing}\n\n"
f"Po dosadašnjim borbama najjači je: {fav_name}. On je pobjednik. "
f"Napiši SAMO obrazloženje zašto {fav_name} pobjeđuje ostale, pošteno i djetetu razumljivo.")
else:
user = f"Likovi u borbi:\n{listing}\n\nOdluči ko pobjeđuje i zašto."
resp = await client.chat.completions.create(
model=model,
messages=[{"role": "system", "content": ADJ_SYS}, {"role": "user", "content": user}],
response_format={"type": "json_object"},
)
try:
data = json.loads(resp.choices[0].message.content or "{}")
except json.JSONDecodeError:
data = {}
reason = (data.get("razlog") or "").strip()
if favorite:
winner_key = favorite
else:
# upari ime pobjednika koje je LLM vratio sa našim ključevima
wname = (data.get("pobjednik") or "").strip()
wkey = normalize_key(wname)
winner_key = None
for p in picks:
if p["key"] == wkey or normalize_key(p["name"]) == wkey or wname.lower() in p["name"].lower():
winner_key = p["key"]
break
if winner_key is None:
# fallback: najjači po rejtingu, pa prvi
winner_key = max(keys, key=lambda k: (store.get(k) or {}).get("rating", gamestore.BASE_RATING))
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}
class Match:
"""Stanje jedne partije (u memoriji asistenta)."""
def __init__(self, client: AsyncOpenAI, model: str, players: list[str]) -> None:
self.client = client
self.model = model
self.store = HeroStore()
self.players = [p.strip() for p in players if p and p.strip()][:5] or ["Igrač 1"]
self.rounds = gamestore.load_rounds()
self.round_index = 0 # 0-bazirano, trenutna runda
self.banned: set[str] = set() # ključevi pobjedničkih heroja (ne smiju se ponoviti)
self.picks: dict[str, dict] = {} # igrač -> {key,name,desc} za trenutnu rundu
self.scores: dict[str, int] = {p: 0 for p in self.players}
self.history: list[dict] = [] # [{round, winner, player}]
def order_for(self, round_index: int) -> list[str]:
"""Svaka runda drugačiji redoslijed (rotacija) — svako jednom bude zadnji (prednost)."""
n = len(self.players)
if n == 0:
return []
shift = round_index % n
return self.players[shift:] + self.players[:shift]
@property
def current_order(self) -> list[str]:
return self.order_for(self.round_index)
@property
def finished(self) -> bool:
return self.round_index >= self.rounds
def start_info(self) -> dict:
return {
"igraci": self.players,
"broj_rundi": self.rounds,
"runda": 1,
"redoslijed": self.current_order,
}
async def record_pick(self, player: str, hero: str) -> dict:
player = (player or "").strip()
# upari igrača (tolerantno na sitne razlike)
if player not in self.scores:
match = next((p for p in self.players if p.lower() == player.lower()), None)
player = match or (self.players[0] if len(self.players) == 1 else player)
info = await resolve_hero(self.client, self.model, self.store, hero)
if info is None:
return {"ok": False, "razlog": "nepoznat",
"poruka": f"Ne mogu prepoznati lika '{hero}'. Neka {player} izabere drugog."}
if info["key"] in self.banned:
return {"ok": False, "razlog": "iskorišten",
"poruka": f"{info['name']} je već pobijedio ranije u ovoj partiji i ne može se ponovo koristiti. "
f"Neka {player} izabere drugog heroja."}
if any(pk["key"] == info["key"] for pl, pk in self.picks.items() if pl != player):
return {"ok": False, "razlog": "duplikat",
"poruka": f"{info['name']} je već izabran u ovoj rundi. Neka {player} izabere drugog."}
self.picks[player] = info
return {"ok": True, "igrac": player, "heroj": info["name"], "poruka": f"{info['name']} je zabilježen za {player}."}
async def resolve_round(self) -> dict:
if not self.picks:
return {"ok": False, "poruka": "Još niko nije izabrao heroja u ovoj rundi."}
# heroji poredani po redoslijedu igranja
order = self.current_order
participants = [self.picks[p] for p in order if p in self.picks]
for p, info in self.picks.items():
if info not in participants:
participants.append(info)
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()
self.banned.add(winner_key)
# koji igrač je izabrao pobjednika
winner_player = next((pl for pl, pk in self.picks.items() if pk["key"] == winner_key), None)
if winner_player:
self.scores[winner_player] = self.scores.get(winner_player, 0) + 1
played_round = self.round_index + 1
self.history.append({"runda": played_round, "pobjednik": verdict["winner_name"], "igrac": winner_player})
self.round_index += 1
self.picks = {}
result = {
"ok": True,
"runda": played_round,
"pobjednik_heroj": verdict["winner_name"],
"pobjednik_igrac": winner_player,
"obrazlozenje": verdict["reason"],
"gotovo": self.finished,
}
if self.finished:
result["konacni_rezultat"] = self.standings()
else:
result["sljedeca_runda"] = self.round_index + 1
result["sljedeci_redoslijed"] = self.current_order
return result
def standings(self) -> dict:
table = sorted(self.scores.items(), key=lambda kv: kv[1], reverse=True)
top = table[0][1] if table else 0
winners = [p for p, s in table if s == top and top > 0]
return {
"poredak": [{"igrac": p, "pobjede": s} for p, s in table],
"pobjednici": winners,
"nerijeseno": len(winners) != 1,
}

175
idriz/gamestore.py Normal file
View File

@@ -0,0 +1,175 @@
"""Trajno skladište za igru Superheroji: heroji, njihove relativne jačine (Elo) i konfiguracija.
Namjerno bez vanjskih zavisnosti (samo standardna biblioteka) da ga može uvesti
i admin panel (koji nema instaliran openai). LLM logika je u game.py.
"""
import json
import os
import re
import tempfile
import threading
import unicodedata
from pathlib import Path
HEROES_PATH = Path(os.environ.get("IDRIZ_HEROES_PATH", str(Path.home() / ".local/share/idriz/heroes.json")))
GAME_CONFIG_PATH = Path(os.environ.get("IDRIZ_GAME_CONFIG_PATH", str(Path.home() / ".config/idriz/game.json")))
DEFAULT_ROUNDS = 3
MIN_ROUNDS = 1
MAX_ROUNDS = 15
BASE_RATING = 1000.0
K = 32.0 # Elo K-faktor
CLEAR_LEAD = 80.0 # razlika u rejtingu koja se smatra "jasnom" prednošću iz prošlih borbi
_lock = threading.Lock()
def normalize_key(name: str) -> str:
"""Ime -> ključ za dedup (bez dijakritike, mala slova, samo slova/brojevi)."""
s = unicodedata.normalize("NFKD", name or "").encode("ascii", "ignore").decode()
return re.sub(r"[^a-z0-9]+", "", s.lower())
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")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
os.replace(tmp, path)
finally:
if os.path.exists(tmp):
os.unlink(tmp)
# ---------------- Konfiguracija (broj rundi) ----------------
def load_rounds() -> int:
try:
data = json.loads(GAME_CONFIG_PATH.read_text(encoding="utf-8"))
n = int(data.get("rounds", DEFAULT_ROUNDS))
except (OSError, ValueError, json.JSONDecodeError):
return DEFAULT_ROUNDS
return max(MIN_ROUNDS, min(MAX_ROUNDS, n))
def save_rounds(n: int) -> int:
n = max(MIN_ROUNDS, min(MAX_ROUNDS, int(n)))
with _lock:
data = {}
try:
data = json.loads(GAME_CONFIG_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
data = {}
data["rounds"] = n
_atomic_write(GAME_CONFIG_PATH, data)
return n
# ---------------- Skladište heroja ----------------
class HeroStore:
def __init__(self, path: Path = HEROES_PATH) -> None:
self.path = path
self.heroes: dict[str, dict] = {}
self.aliases: dict[str, str] = {}
self.load()
def load(self) -> None:
try:
data = json.loads(self.path.read_text(encoding="utf-8"))
self.heroes = data.get("heroes", {})
self.aliases = data.get("aliases", {})
except (OSError, json.JSONDecodeError):
self.heroes = {}
self.aliases = {}
def save(self) -> None:
with _lock:
_atomic_write(self.path, {"heroes": self.heroes, "aliases": self.aliases})
def resolve_alias(self, spoken: str) -> str | None:
"""Vrati kanonski ključ za izgovoreno ime ako smo ga ranije već sreli."""
key = normalize_key(spoken)
if key in self.heroes:
return key
return self.aliases.get(key)
def get(self, key: str) -> dict | None:
return self.heroes.get(key)
def upsert(self, key: str, name: str, original: str = "", desc: str = "", source: str = "llm") -> dict:
h = self.heroes.get(key)
if h is None:
h = {
"name": name, "original": original, "desc": desc,
"rating": BASE_RATING, "wins": 0, "losses": 0, "games": 0, "source": source,
}
self.heroes[key] = h
else:
# ne gazi postojeći rejting; osvježi opis ako je bolji
if desc and len(desc) > len(h.get("desc", "")):
h["desc"] = desc
if name:
h["name"] = name
if original:
h["original"] = original
return h
def add_alias(self, spoken: str, canonical_key: str) -> None:
key = normalize_key(spoken)
if key and key != canonical_key:
self.aliases[key] = canonical_key
def record_match(self, winner_key: str, loser_keys: list[str]) -> None:
"""Elo ažuriranje: pobjednik pobjeđuje svakog gubitnika (parno)."""
w = self.heroes.get(winner_key)
if not w:
return
for lk in loser_keys:
l = self.heroes.get(lk)
if not l or lk == winner_key:
continue
rw, rl = w["rating"], l["rating"]
ew = 1.0 / (1.0 + 10 ** ((rl - rw) / 400.0))
w["rating"] = rw + K * (1.0 - ew)
l["rating"] = rl + K * (0.0 - (1.0 - ew))
l["losses"] = l.get("losses", 0) + 1
w["wins"] = w.get("wins", 0) + 1
for lk in set(loser_keys) | {winner_key}:
h = self.heroes.get(lk)
if h:
h["games"] = h.get("games", 0) + 1
def clear_favorite(self, keys: list[str]) -> str | None:
"""Ako među likovima postoji jedan koji je po prošlim borbama JASNO najjači, vrati njegov ključ.
Uslov: ima odigranih borbi i vodi bar CLEAR_LEAD rejtinga ispred sljedećeg.
"""
rated = [(k, self.heroes[k]) for k in keys if k in self.heroes and self.heroes[k].get("games", 0) > 0]
if len(rated) < 1:
return None
rated.sort(key=lambda kv: kv[1]["rating"], reverse=True)
top_key, top = rated[0]
# rejting svih ostalih (uključujući nerangirane koji imaju BASE_RATING)
others = [self.heroes.get(k, {}).get("rating", BASE_RATING) for k in keys if k != top_key]
if not others:
return None
if top["rating"] - max(others) >= CLEAR_LEAD:
return top_key
return None
def leaderboard(self) -> list[dict]:
rows = []
for key, h in self.heroes.items():
rows.append({
"key": key,
"name": h.get("name", key),
"rating": round(h.get("rating", BASE_RATING)),
"wins": h.get("wins", 0),
"losses": h.get("losses", 0),
"games": h.get("games", 0),
"source": h.get("source", "llm"),
})
rows.sort(key=lambda r: r["rating"], reverse=True)
return rows