Refaktor 2/4: superheroji izdvojen u plugin (apps/superheroji) + AppContext

Prvi pravi mini-app kao samostalan folder:
- idriz/apps/base.py: AppSpec, AppTool, AppContext (uski most ka runtime-u).
- idriz/apps/__init__.py: load_apps() (lijeni uvoz manifesta).
- idriz/apps/superheroji/: logic.py (bivši game.py), store.py (bivši gamestore.py),
  manifest.py (prompt fragment + sheme + handleri preko ctx).

Assistant više NEMA game_* metode ni self.match: prompt/tools/pozdrav/dispatch se
grade iz registra app-ova + jezgra. Admin uvozi apps.superheroji.store (bez openai).
Ponašanje isto (testirano: mehanika, handleri kroz AppContext, admin bez openai).

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-11 12:20:52 +02:00
parent 0e6d8947ce
commit f4a99beaeb
8 changed files with 287 additions and 155 deletions

View File

@@ -19,7 +19,7 @@ from flask import (
# 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
from idriz.apps.superheroji import store as gamestore # noqa: E402
# --- Konfiguracija -----------------------------------------------------------

12
idriz/apps/__init__.py Normal file
View File

@@ -0,0 +1,12 @@
"""Registar mini-app-ova.
load_apps() LIJENO uvozi manifeste, da proces koji nema teške zavisnosti (npr.
admin bez openai-a) može uvesti lagane dijelove app-a (npr. apps.superheroji.store)
bez povlačenja svega.
"""
from .base import AppContext, AppSpec, AppTool # noqa: F401
def load_apps() -> list[AppSpec]:
from .superheroji import manifest as superheroji
return [superheroji.SPEC]

65
idriz/apps/base.py Normal file
View File

@@ -0,0 +1,65 @@
"""Osnovni tipovi za mini-app-ove (plugine).
Svaki app se opisuje jednim AppSpec-om: doprinos promptu, pozdravu, i listu alata.
AppContext je NAMJERNO uzak most prema runtime-u (Assistant-u) — samo par metoda,
da ne postane novi "god object".
"""
import asyncio
from dataclasses import dataclass, field
from typing import Awaitable, Callable
# Handler app-alata: prima kontekst i argumente, vraća tekst za model.
AppHandler = Callable[["AppContext", dict], Awaitable[str]]
@dataclass
class AppTool:
schema: dict # OpenAI function schema (ima "name")
handler: AppHandler # async (ctx, args) -> str
mode: str = "inline" # "inline" (brzo) ili "background" (dug posao)
@dataclass
class AppSpec:
name: str
title: str
instructions: str = "" # doprinos sistemskom promptu
greeting: str = "" # kratka fraza za pozdrav (opciono)
tools: list[AppTool] = field(default_factory=list)
class AppContext:
"""Most između app-a i runtime-a. Drži ga uskim (par metoda)."""
def __init__(self, runtime) -> None:
self._rt = runtime
@property
def client(self):
return self._rt.client
@property
def cfg(self):
return self._rt.cfg
# per-razgovor stanje app-a (npr. trenutna partija)
def get_state(self, key: str):
return self._rt.app_state.get(key)
def set_state(self, key: str, value) -> None:
self._rt.app_state[key] = value
# reci nešto modelu (kontekst-poruka + novi odgovor), pazi na jedan-odgovor-u-vremenu
async def notify(self, text: str) -> None:
await self._rt.notify_model(text)
# kratko podstakni model (npr. filler "još razmišljam")
async def nudge(self, instructions: str) -> None:
await self._rt._nudge(instructions)
# pokreni pozadinski posao (praćen, da se ne izgubi)
def background(self, coro) -> asyncio.Task:
task = asyncio.create_task(coro)
self._rt.jobs.add(task)
task.add_done_callback(self._rt.jobs.discard)
return task

View File

View File

@@ -6,10 +6,11 @@ import logging
from openai import AsyncOpenAI
from . import gamestore, wiki
from .gamestore import HeroStore, normalize_key
from idriz import wiki
from . import store as gamestore
from .store import HeroStore, normalize_key
log = logging.getLogger("idriz.game")
log = logging.getLogger("idriz.superheroji")
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 \

View File

@@ -0,0 +1,174 @@
"""Mini-app 'Superheroji': doprinos promptu, alati i handleri.
Sva logika partije je u logic.py; ovdje je samo spoj na runtime preko AppContext-a.
"""
import asyncio
import logging
from ..base import AppContext, AppSpec, AppTool
from . import logic
log = logging.getLogger("idriz.superheroji")
STATE = "superheroji_match" # ključ per-razgovor stanja (trenutna partija)
INSTRUCTIONS = """IGRA "SUPERHEROJI" (glasom, bez štampanja). Alati: superheroji_nova_partija, superheroji_izbor, 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 2 do 5, mora biti najmanje dvoje), 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 lika. Definicija je široka: bilo koji izmišljeni lik iz bilo kog medija — ne samo klasični superheroji. To su i crtani likovi, likovi iz video igara, animea, filmova, knjiga, memova (npr. Kuromi, Pikaču, Goku, La Linea, Mario, Gandalf, Baba Jaga, Mad Max). Zadnji igrač ima prednost jer bira zadnji, zato se redoslijed mijenja svaku rundu — uvijek koristi redoslijed koji ti alat da.
- VRLO VAŽNO ZA IMENA: djeca često biraju likove neobičnih ili stranih imena. Slušaj TAČNO ime koje dijete kaže i NE zamjenjuj ga poznatim superherojem koji zvuči slično (ako dijete kaže "Kuromi", ne smiješ čuti "Hulk"). Ako ime nisi jasno čuo ili ga ne razumiješ, NE POGAĐAJ — reci "Nisam dobro čuo ime, možeš li ponoviti?" i sačekaj. Tek kad si siguran u ime, pozovi alat.
- Č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 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."""
NOVA_SCHEMA = {
"type": "function",
"name": "superheroji_nova_partija",
"description": "Započinje novu partiju igre Superheroji. Pozvati NAKON što si pitao koliko igrača (2-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 2 do 5 (najmanje dvoje)."},
},
"required": ["igraci"],
},
}
IZBOR_SCHEMA = {
"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"],
},
}
KRAJ_SCHEMA = {
"type": "function",
"name": "superheroji_kraj",
"description": "Prekida trenutnu partiju ranije ako igrači žele stati. Vraća trenutni rezultat.",
"parameters": {"type": "object", "properties": {}},
}
_FILLERS = [
"Reci kratko i veselo, u jednoj rečenici, da još razmišljaš ko bi pobijedio. Ne otkrivaj pobjednika.",
"Reci u jednoj rečenici da je borba tijesna i da još vagaš ko je jači. Ne otkrivaj pobjednika.",
"Reci kratko da samo što nisi odlučio, još malo. Ne otkrivaj pobjednika.",
]
async def nova(ctx: AppContext, args: dict) -> str:
players = args.get("igraci") or []
if isinstance(players, str):
players = [players]
players = [p.strip() for p in players if p and p.strip()]
if len(players) < 2:
return ("Za igru Superheroji treba najmanje dvoje igrača. Reci djeci da moraju biti bar dvoje, "
"pitaj koliko će igrati (2 do 5) i njihova imena, pa ponovo pozovi superheroji_nova_partija.")
match = logic.Match(ctx.client, ctx.cfg.game_model, players)
ctx.set_state(STATE, match)
info = 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 izbor(ctx: AppContext, args: dict) -> str:
match = ctx.get_state(STATE)
if not match:
return "Nema aktivne partije. Prvo pokreni igru sa superheroji_nova_partija."
res = await match.record_pick(args.get("igrac", ""), args.get("heroj", ""))
if not res.get("ok"):
return (res.get("poruka", "Taj izbor ne može proći.") +
" Kad ti dijete kaže drugog lika, ponovo pozovi superheroji_izbor.")
name = res["heroj"]
confirm = (f'OBAVEZNO prvo reci djetetu tačno ovo ime naglas: "{name}, zabilježeno!" — koristi baš to ime '
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 match.round_complete:
ctx.background(_adjudicate_and_announce(ctx, match))
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 = match.next_picker()
return confirm + f" Zatim pitaj igrača {nxt} da kaže svog lika."
async def kraj(ctx: AppContext, args: dict) -> str:
match = ctx.get_state(STATE)
if not match:
return "Trenutno nema aktivne partije."
st = match.standings()
poredak = ", ".join(f"{r['igrac']} {r['pobjede']}" for r in st["poredak"])
ctx.set_state(STATE, 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 _adjudicate_and_announce(ctx: AppContext, match) -> None:
"""Presudi rundu (može potrajati zbog prepoznavanja/web searcha). Ubacuj filler dok traje."""
adj = asyncio.create_task(match.resolve_round())
i = 0
try:
while True:
try:
res = await asyncio.wait_for(asyncio.shield(adj), timeout=9)
break
except asyncio.TimeoutError:
await ctx.nudge(_FILLERS[min(i, len(_FILLERS) - 1)])
i += 1
except Exception: # noqa: BLE001
log.exception("presuda runde nije uspjela")
await ctx.notify("Došlo je do greške pri presudi runde. Reci djeci da još jednom kažu svoje likove.")
return
await ctx.notify(_format_verdict(ctx, res))
def _format_verdict(ctx: AppContext, 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']} "
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."
ctx.set_state(STATE, 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
SPEC = AppSpec(
name="superheroji",
title="Superheroji",
instructions=INSTRUCTIONS,
greeting="možete zajedno igrati igru Superheroji",
tools=[
AppTool(NOVA_SCHEMA, nova, "inline"),
AppTool(IZBOR_SCHEMA, izbor, "inline"),
AppTool(KRAJ_SCHEMA, kraj, "inline"),
],
)

View File

@@ -9,7 +9,9 @@ from pathlib import Path
from openai import AsyncOpenAI
from . import audio, coloring, game, printer, render, textgen, wiki
from . import audio, coloring, printer, render, textgen, wiki
from .apps import load_apps
from .apps.base import AppContext
from .audio import Mic, Speaker
from .config import Config
from .tooling import Tool
@@ -24,20 +26,7 @@ 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_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 2 do 5, mora biti najmanje dvoje), 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 lika. Definicija je široka: bilo koji izmišljeni lik iz bilo kog medija — ne samo klasični superheroji. To su i crtani likovi, likovi iz video igara, animea, filmova, knjiga, memova (npr. Kuromi, Pikaču, Goku, La Linea, Mario, Gandalf, Baba Jaga, Mad Max). Zadnji igrač ima prednost jer bira zadnji, zato se redoslijed mijenja svaku rundu — uvijek koristi redoslijed koji ti alat da.
- VRLO VAŽNO ZA IMENA: djeca često biraju likove neobičnih ili stranih imena. Slušaj TAČNO ime koje dijete kaže i NE zamjenjuj ga poznatim superherojem koji zvuči slično (ako dijete kaže "Kuromi", ne smiješ čuti "Hulk"). Ako ime nisi jasno čuo ili ga ne razumiješ, NE POGAĐAJ — reci "Nisam dobro čuo ime, možeš li ponoviti?" i sačekaj. Tek kad si siguran u ime, pozovi alat.
- Č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 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.
Osim toga, možeš voditi i posebne igre i aktivnosti opisane niže (samo ako ih dijete zatraži).
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?".
@@ -91,37 +80,6 @@ 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 (2-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 2 do 5 (najmanje dvoje)."},
},
"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_kraj",
"description": "Prekida trenutnu partiju ranije ako igrači žele stati. Vraća trenutni rezultat.",
"parameters": {"type": "object", "properties": {}},
},
]
def _slug(s: str) -> str:
@@ -141,19 +99,38 @@ class Assistant:
self.current_response = ""
self.response_active = False
self.cancelled_responses: set[str] = set()
self.match: game.Match | None = None
self.app_state: dict = {} # per-razgovor stanje mini-app-ova
# --- Sastavljanje iz jezgra + mini-app-ova (plugina) ---
self.ctx = AppContext(self)
self.apps = load_apps()
# Registar alata: shema + handler + način izvršavanja (umjesto elif lanaca).
_schema = {t["name"]: t for t in TOOLS}
self.tools: list[Tool] = [
Tool(_schema["wikipedia_sazetak"], self.tool_wikipedia, "background"),
Tool(_schema["bojanka"], self.tool_bojanka, "background"),
Tool(_schema["superheroji_nova_partija"], self.game_nova, "inline"),
Tool(_schema["superheroji_izbor"], self.game_izbor, "inline"),
Tool(_schema["superheroji_kraj"], self.game_kraj, "inline"),
]
for app in self.apps:
for at in app.tools:
self.tools.append(Tool(at.schema, self._bind(at.handler), at.mode))
self.dispatch: dict[str, Tool] = {t.name: t for t in self.tools}
# prompt = jezgro + doprinosi app-ova; pozdrav = jezgro + fraze app-ova
self.instructions = INSTRUCTIONS + "".join(
"\n\n" + app.instructions for app in self.apps if app.instructions)
self.tool_schemas = [t.schema for t in self.tools]
greetings = "".join(f" {app.greeting}." for app in self.apps if app.greeting)
self.greeting_instr = (
"Kratko se predstavi na bosanskom u jednoj ili dvije rečenice: ti si Idriz, "
"dijete te može nešto pitati, a možeš i odštampati sažetak o nekoj temi ili bojanku."
+ greetings)
def _bind(self, handler):
"""Veže AppContext za app-handler (ctx, args) -> handler(args) za registar."""
async def _run(args: dict) -> str:
return await handler(self.ctx, args)
return _run
# ---------- alati ----------
async def tool_wikipedia(self, args: dict) -> str:
topic = args.get("tema") or args.get("tema_bosanski") or ""
@@ -201,103 +178,6 @@ 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]
players = [p.strip() for p in players if p and p.strip()]
if len(players) < 2:
return ("Za igru Superheroji treba najmanje dvoje igrača. Reci djeci da moraju biti bar dvoje, "
"pitaj koliko će igrati (2 do 5) i njihova imena, pa ponovo pozovi superheroji_nova_partija.")
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 not res.get("ok"):
return (res.get("poruka", "Taj izbor ne može proći.") +
" Kad ti dijete kaže drugog lika, ponovo pozovi superheroji_izbor.")
name = res["heroj"]
# UVIJEK prvo potvrdi ime (i za zadnjeg igrača), da dijete čuje jesi li dobro razumio
confirm = (f'OBAVEZNO prvo reci djetetu tačno ovo ime naglas: "{name}, zabilježeno!" — koristi baš to ime '
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:
# 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."
_FILLERS = [
"Reci kratko i veselo, u jednoj rečenici, da još razmišljaš ko bi pobijedio. Ne otkrivaj pobjednika.",
"Reci u jednoj rečenici da je borba tijesna i da još vagaš ko je jači. Ne otkrivaj pobjednika.",
"Reci kratko da samo što nisi odlučio, još malo. Ne otkrivaj pobjednika.",
]
async def _adjudicate_and_announce(self, match: "game.Match") -> None:
"""Presudi rundu (može potrajati zbog prepoznavanja/web searcha). Ubacuj filler dok traje."""
adj = asyncio.create_task(match.resolve_round())
i = 0
try:
while True:
try:
res = await asyncio.wait_for(asyncio.shield(adj), timeout=9)
break
except asyncio.TimeoutError:
await self._nudge(self._FILLERS[min(i, len(self._FILLERS) - 1)])
i += 1
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']} "
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."
PLACEHOLDER = ("Posao je pokrenut i trajat će oko pola minute do minutu. "
"Reci djetetu da sačeka; javit ću kad bude gotovo.")
@@ -481,16 +361,16 @@ class Assistant:
if not self.greeted:
self.greeted = True
await self.conn.send({"type": "response.create", "response": {
"instructions": "Kratko se predstavi na bosanskom u jednoj ili dvije rečenice: ti si Idriz, dijete te može nešto pitati, a možeš i odštampati sažetak o nekoj temi ili bojanku."}})
"instructions": self.greeting_instr}})
async def session(self) -> None:
async with self.client.realtime.connect(model=self.cfg.realtime_model) as conn:
self.conn = conn
await conn.session.update(session={
"type": "realtime",
"instructions": INSTRUCTIONS,
"instructions": self.instructions,
"output_modalities": ["audio"],
"tools": TOOLS,
"tools": self.tool_schemas,
"tool_choice": "auto",
"audio": {
"input": {