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" 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 os
import secrets import secrets
import subprocess import subprocess
import sys
from datetime import datetime from datetime import datetime
from functools import wraps from functools import wraps
from pathlib import Path
import pam as pam_module import pam as pam_module
from flask import ( from flask import (
Flask, render_template, request, redirect, url_for, session, flash 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 ----------------------------------------------------------- # --- Konfiguracija -----------------------------------------------------------
# Samo ovaj Linux korisnik smije da se prijavi. # 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__": if __name__ == "__main__":
from waitress import serve from waitress import serve
print(f"Idriz admin na http://{LISTEN_HOST}:{LISTEN_PORT}") 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 '' }}"> <a href="{{ url_for('status') }}" class="{{ 'active' if active_page == 'status' else '' }}">
<span class="icon">●</span> Status servisa <span class="icon">●</span> Status servisa
</a> </a>
<a href="{{ url_for('superheroji') }}" class="{{ 'active' if active_page == 'superheroji' else '' }}">
<span class="icon">★</span> Superheroji
</a>
</nav> </nav>
<div class="sidebar-foot"> <div class="sidebar-foot">
{% if session.user %}<a href="{{ url_for('logout') }}">↩ Odjava</a>{% endif %} {% 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 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 .audio import Mic, Speaker
from .config import Config 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. 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. 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. 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: 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?". - 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"], "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: def _slug(s: str) -> str:
s = re.sub(r"[^a-zA-Z0-9]+", "-", s).strip("-").lower() s = re.sub(r"[^a-zA-Z0-9]+", "-", s).strip("-").lower()
@@ -96,6 +146,7 @@ class Assistant:
self.current_response = "" self.current_response = ""
self.response_active = False self.response_active = False
self.cancelled_responses: set[str] = set() self.cancelled_responses: set[str] = set()
self.match: game.Match | None = None
# ---------- alati ---------- # ---------- alati ----------
async def tool_wikipedia(self, args: dict) -> str: 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) await printer.print_file(out, self.cfg.printer, self.cfg.dry_run, fit=True)
return f"Bojanka '{subject_bs}' je odštampana." 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: 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.""" """Pozadinski posao; kad završi, obavijesti model porukom i zatraži novi odgovor."""
try: try:
@@ -203,6 +337,12 @@ class Assistant:
except json.JSONDecodeError: except json.JSONDecodeError:
args = {} args = {}
log.info("alat %s(%s)", name, 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)) task = asyncio.create_task(self.run_tool(name, call_id, args))
self.jobs.add(task) self.jobs.add(task)
task.add_done_callback(self.jobs.discard) 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", "")) 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")) 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")) 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")) 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")) 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")) voice: str = field(default_factory=lambda: _env("IDRIZ_VOICE", "cedar"))
@@ -28,6 +29,9 @@ class Config:
def __post_init__(self) -> None: def __post_init__(self) -> None:
self.out_dir.mkdir(parents=True, exist_ok=True) 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: 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