Problem 1 (kuromi -> Hulk): model je nejasan zvuk mapirao na poznatog superheroja. Upute pojačane: likovi su iz SVIH medija (Kuromi, Pikaču...), ne zamjenjuj čuveno ime sličnim superherojem, pitaj da se ponovi ako nisi siguran, i ponovo pozovi izbor ako dijete ispravi ime. Problem 2 (ne potvrdi drugog heroja): superheroji_izbor sada zna (preko round_complete/next_picker) da li runda ima još igrača; UVIJEK traži da model prvo naglas potvrdi ime, pa tek onda pita sljedećeg ili pozove presudi. Uz to: benigna greška response_cancel_not_active spuštena na debug. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LzKpYaEsuWDevuNnvSjcFW
493 lines
27 KiB
Python
493 lines
27 KiB
Python
"""Glavna petlja: OpenAI Realtime razgovor + alati (Wikipedia sažetak, bojanka)."""
|
|
import asyncio
|
|
import base64
|
|
import datetime as dt
|
|
import json
|
|
import logging
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from openai import AsyncOpenAI
|
|
|
|
from . import audio, coloring, game, printer, render, textgen, wiki
|
|
from .audio import Mic, Speaker
|
|
from .config import Config
|
|
|
|
log = logging.getLogger("idriz.assistant")
|
|
|
|
INSTRUCTIONS = """Ti si Idriz, prijateljski glasovni pomoćnik za djecu u jednoj porodici u Bosni i Hercegovini.
|
|
Govoriš ISKLJUČIVO bosanskim jezikom (ijekavica). Nikad ne prelaziš na engleski ni na drugi jezik, čak i ako te dijete to traži.
|
|
Govoriš toplo, kratko i jasno, kao strpljivi stariji brat. Rečenice su kratke. Ne držiš predavanja.
|
|
|
|
Šta možeš uraditi:
|
|
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 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 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?".
|
|
- NIVO se podešava kroz razgovor. Ako dijete kaže "naprednije", "detaljnije", "više", "kao za stariji razred", "reci mi sve": sljedeći odgovori su duži (do osam rečenica), sa više činjenica, tačnim stručnim pojmovima (uz kratko objašnjenje pojma) i brojevima. Svaki sljedeći zahtjev "još naprednije" podiže nivo dalje, sve do nivoa srednje škole.
|
|
Ako dijete kaže "pojednostavi", "jednostavnije", "ne razumijem", "kao za male", "lakše": sljedeći odgovori su kraći, sa najjednostavnijim riječima, kratkim rečenicama i poređenjima iz svakodnevnog života (kuhinja, igralište, lopta, voda). Svaki sljedeći zahtjev spušta nivo dalje.
|
|
Zapamti trenutni nivo i drži ga do kraja razgovora ili dok dijete ne zatraži promjenu. Ako znaš uzrast djeteta, kreni od nivoa koji odgovara tom uzrastu.
|
|
- Ako nešto ne znaš ili nisi siguran, reci to iskreno. Ne izmišljaj brojeve ni imena.
|
|
- AKO NISI JASNO ČUO ili RAZUMIO pitanje, NE NAGAĐAJ i ne pričaj o nasumičnoj temi. Reci kratko: "Nisam te dobro čuo, možeš li ponoviti?" i sačekaj.
|
|
- Odgovaraj SAMO na ono što je dijete stvarno pitalo. Ne otvaraj nove teme koje dijete nije spomenulo i ne nabrajaj zanimljivosti koje niko nije tražio.
|
|
- Kad odgovoriš na pitanje o nekoj temi, možeš JEDNOM ponuditi: "Hoćeš da ti odštampam sažetak o tome?" Ne nudi to poslije svakog odgovora.
|
|
- Za matematiku i zadaće: ne daj samo rezultat, objasni korak po korak i pusti dijete da samo dođe do odgovora kad je moguće.
|
|
- Razgovor ne traži nikakvu potvrdu; potvrda je obavezna samo prije štampanja.
|
|
|
|
Pravila za štampanje:
|
|
- Ako nije jasno šta dijete želi ili koja tema, postavi JEDNO kratko pitanje. Kod sažetka je korisno znati koliko dijete ima godina ili koji je razred, ako to već ne znaš; pitaj samo jednom.
|
|
- Prije nego pozoveš alat OBAVEZNO kratko ponovi šta ćeš uraditi i pitaj za potvrdu, npr. "Da odštampam sažetak o vulkanima?" ili "Da odštampam bojanku sa zmajem?". Alat pozovi TEK kad dijete potvrdi (da, može, hajde, ok).
|
|
- Kad pozoveš alat, reci djetetu da radiš na tome i da će trajati minutu do dvije. U međuvremenu možeš nastaviti razgovor.
|
|
- Kad dobiješ poruku da je posao gotov, reci djetetu da uzme papir iz printera. Ako je posao propao, reci to iskreno i ponudi da probate ponovo.
|
|
- Nikad ne čitaj cijeli sažetak naglas; on ide na papir. Možeš reći jednu rečenicu o čemu se radi.
|
|
|
|
Opšta pravila:
|
|
- Ako dijete traži nešto što ne znaš uraditi (npr. puštanje muzike, pozivanje nekoga), reci jednostavno da to još ne umiješ.
|
|
- Sadržaj mora biti prikladan za djecu. Budi ljubazan, ohrabruj radoznalost, pohvali dobro pitanje.
|
|
"""
|
|
|
|
TOOLS = [
|
|
{
|
|
"type": "function",
|
|
"name": "wikipedia_sazetak",
|
|
"description": "Pronađe temu na engleskoj Wikipediji, prevede i sažme na bosanski (do 2 stranice) i odštampa. Pozvati tek nakon potvrde djeteta.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"tema": {"type": "string", "description": "Tema, po mogućnosti na engleskom (npr. 'Volcano', 'Nikola Tesla')."},
|
|
"tema_bosanski": {"type": "string", "description": "Tema onako kako ju je dijete reklo, na bosanskom."},
|
|
"uzrast": {"type": "string", "description": "Uzrast ili razred djeteta, ako je poznat. Inače prazno."},
|
|
},
|
|
"required": ["tema", "tema_bosanski"],
|
|
},
|
|
},
|
|
{
|
|
"type": "function",
|
|
"name": "bojanka",
|
|
"description": "Generiše bojanku (crno-bijele konture) preko cijele stranice i odštampa je. Pozvati tek nakon potvrde djeteta.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"opis": {"type": "string", "description": "Šta treba biti na slici, opisano na engleskom, jednostavno (npr. 'a friendly dragon flying over a castle')."},
|
|
"opis_bosanski": {"type": "string", "description": "Opis onako kako ga je dijete reklo, na bosanskom."},
|
|
},
|
|
"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()
|
|
return s[:40] or "posao"
|
|
|
|
|
|
class Assistant:
|
|
def __init__(self, cfg: Config) -> None:
|
|
self.cfg = cfg
|
|
self.client = AsyncOpenAI(api_key=cfg.openai_api_key)
|
|
self.mic = Mic(cfg.mic_device, cfg.mic_gain)
|
|
self.speaker = Speaker(cfg.speaker_device)
|
|
self.conn = None
|
|
self.jobs: set[asyncio.Task] = set()
|
|
self.greeted = False
|
|
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:
|
|
topic = args.get("tema") or args.get("tema_bosanski") or ""
|
|
topic_bs = args.get("tema_bosanski") or topic
|
|
age = args.get("uzrast", "")
|
|
article = await asyncio.to_thread(wiki.find_article, topic)
|
|
if not article:
|
|
return f"Nisam našao članak o temi '{topic_bs}' na Wikipediji."
|
|
stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
base = self.cfg.out_dir / f"{stamp}-sazetak-{_slug(article.title)}"
|
|
# sažetak i slika sa Wikipedije idu paralelno
|
|
summary_task = asyncio.create_task(textgen.summarize(self.client, self.cfg.text_model, article, topic_bs, age))
|
|
image_task = asyncio.create_task(asyncio.to_thread(self._wiki_image, article.title, base))
|
|
summary = await summary_task
|
|
image = await image_task
|
|
if image is None:
|
|
try:
|
|
raw = await coloring.generate_illustration(self.client, self.cfg.image_model, summary.image_prompt,
|
|
base.with_suffix(".gen.png"))
|
|
image = render.prepare_image(raw, base.with_suffix(".img.png"))
|
|
except Exception: # noqa: BLE001
|
|
log.exception("ilustracija nije uspjela, štampam bez slike")
|
|
out = base.with_suffix(".pdf")
|
|
await asyncio.to_thread(render.render_summary, summary.title, summary.html, summary.facts, summary.caption,
|
|
image, article.title, out, self.cfg.max_pages)
|
|
await printer.print_file(out, self.cfg.printer, self.cfg.dry_run, fit=False)
|
|
return f"Sažetak '{summary.title}' (po članku '{article.title}') je odštampan."
|
|
|
|
@staticmethod
|
|
def _wiki_image(title: str, base):
|
|
url = wiki.page_image_url(title)
|
|
if not url:
|
|
return None
|
|
raw = base.with_suffix(".wiki" + Path(url).suffix.lower())
|
|
if not wiki.download(url, raw):
|
|
return None
|
|
return render.prepare_image(raw, base.with_suffix(".img.png"))
|
|
|
|
async def tool_bojanka(self, args: dict) -> str:
|
|
subject = args.get("opis") or args.get("opis_bosanski") or "a cute animal"
|
|
subject_bs = args.get("opis_bosanski") or subject
|
|
stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
out = self.cfg.out_dir / f"{stamp}-bojanka-{_slug(subject)}.pdf"
|
|
await coloring.generate(self.client, self.cfg.image_model, subject, out)
|
|
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 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:
|
|
return confirm + " Svi igrači su izabrali u ovoj rundi. Zatim ODMAH pozovi superheroji_presudi."
|
|
nxt = self.match.next_picker()
|
|
return confirm + f" Zatim pitaj igrača {nxt} da kaže svog lika."
|
|
|
|
async def game_presudi(self, args: dict) -> str:
|
|
if not self.match:
|
|
return "Nema aktivne partije. Prvo pokreni igru sa superheroji_nova_partija."
|
|
res = await self.match.resolve_round()
|
|
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:
|
|
if name == "wikipedia_sazetak":
|
|
result = await self.tool_wikipedia(args)
|
|
elif name == "bojanka":
|
|
result = await self.tool_bojanka(args)
|
|
else:
|
|
result = f"Nepoznat alat {name}."
|
|
ok = True
|
|
except Exception as e: # noqa: BLE001
|
|
log.exception("alat %s nije uspio", name)
|
|
result = f"Posao nije uspio: {type(e).__name__}: {e}"
|
|
ok = False
|
|
await self.notify_model(("GOTOVO. " if ok else "GREŠKA. ") + result + " Reci to djetetu kratko, na bosanskom.")
|
|
|
|
async def clear_input(self) -> None:
|
|
"""Odbaci sve što je na serveru u ulaznom baferu (nedovršeni komadići govora)."""
|
|
if self.conn:
|
|
try:
|
|
await self.conn.send({"type": "input_audio_buffer.clear"})
|
|
except Exception: # noqa: BLE001
|
|
log.exception("input_audio_buffer.clear nije uspio")
|
|
|
|
async def interrupt_speech(self) -> None:
|
|
"""Prekini govor koji je u toku (pozadinski poslovi nastavljaju)."""
|
|
speaking = self.speaker.is_playing(0.0) or self.response_active
|
|
if self.response_active and self.conn:
|
|
self.cancelled_responses.add(self.current_response)
|
|
try:
|
|
await self.conn.send({"type": "response.cancel"})
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
await self.clear_input()
|
|
if speaking:
|
|
await self.speaker.clear()
|
|
log.info("govor prekinut (mikrofon ugašen)")
|
|
await self.speaker.write(audio.tone_off())
|
|
|
|
async def notify_model(self, text: str) -> None:
|
|
if not self.conn:
|
|
return
|
|
try:
|
|
await self.conn.send({
|
|
"type": "conversation.item.create",
|
|
"item": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": f"[SISTEMSKA PORUKA] {text}"}]},
|
|
})
|
|
await self.conn.send({"type": "response.create"})
|
|
except Exception: # noqa: BLE001
|
|
log.exception("nisam uspio obavijestiti model")
|
|
|
|
async def handle_function_call(self, item: dict) -> None:
|
|
name = item.get("name", "")
|
|
call_id = item.get("call_id", "")
|
|
try:
|
|
args = json.loads(item.get("arguments") or "{}")
|
|
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)
|
|
# odmah vrati rezultat da model može reći "radim na tome"
|
|
await self.conn.send({
|
|
"type": "conversation.item.create",
|
|
"item": {"type": "function_call_output", "call_id": call_id,
|
|
"output": "Posao je pokrenut i trajat će oko pola minute do minutu. Reci djetetu da sačeka; javit ću kad bude gotovo."},
|
|
})
|
|
await self.conn.send({"type": "response.create"})
|
|
|
|
# ---------- audio petlje ----------
|
|
async def mic_loop(self) -> None:
|
|
"""Šalje zvuk modelu. Ako je USB mikrofon isključen prekidačem (nestane iz PipeWire-a), ne sluša ništa."""
|
|
last_check = 0.0
|
|
present = False
|
|
while True:
|
|
now = asyncio.get_event_loop().time()
|
|
if now - last_check > 0.7:
|
|
present = await audio.source_present(self.cfg.mic_device)
|
|
last_check = now
|
|
if not present:
|
|
if self.mic.running:
|
|
await self.mic.stop()
|
|
log.info("mikrofon isključen (prekidač), ne slušam")
|
|
await self.interrupt_speech()
|
|
await asyncio.sleep(0.3)
|
|
continue
|
|
if not self.mic.running:
|
|
await self.mic.start()
|
|
log.info("mikrofon uključen, slušam")
|
|
await self.clear_input()
|
|
await self.speaker.write(audio.tone_on())
|
|
chunk = await self.mic.read()
|
|
if not chunk:
|
|
await self.mic.stop()
|
|
await asyncio.sleep(1)
|
|
continue
|
|
if self.speaker.is_playing():
|
|
continue # half-duplex: ne šalji dok Idriz govori
|
|
await self.conn.send({"type": "input_audio_buffer.append", "audio": base64.b64encode(chunk).decode()})
|
|
|
|
async def event_loop(self) -> None:
|
|
transcript = ""
|
|
async for event in self.conn:
|
|
t = event.type
|
|
if t == "response.output_audio.delta":
|
|
if getattr(event, "response_id", "") in self.cancelled_responses:
|
|
continue
|
|
await self.speaker.write(base64.b64decode(event.delta))
|
|
elif t == "response.created":
|
|
self.current_response = event.response.id or ""
|
|
self.response_active = True
|
|
elif t == "response.done":
|
|
self.response_active = False
|
|
elif t == "response.output_audio_transcript.delta":
|
|
transcript += event.delta
|
|
elif t == "response.output_audio_transcript.done":
|
|
log.info("IDRIZ: %s", transcript.strip())
|
|
transcript = ""
|
|
elif t == "conversation.item.input_audio_transcription.completed":
|
|
log.info("DIJETE: %s", (event.transcript or "").strip())
|
|
elif t == "response.output_item.done":
|
|
item = event.item.model_dump() if hasattr(event.item, "model_dump") else dict(event.item)
|
|
if item.get("type") == "function_call":
|
|
await self.handle_function_call(item)
|
|
elif t == "input_audio_buffer.speech_started":
|
|
log.info("govor počeo")
|
|
if self.speaker.is_playing(0.0):
|
|
await self.speaker.clear()
|
|
elif t == "input_audio_buffer.speech_stopped":
|
|
log.info("govor stao")
|
|
elif t == "error":
|
|
code = getattr(event.error, "code", "") if event.error else ""
|
|
if code == "response_cancel_not_active":
|
|
log.debug("cancel bez aktivnog odgovora (bezopasno)")
|
|
else:
|
|
log.error("realtime greška: %s", event.error)
|
|
elif t == "session.created":
|
|
log.info("sesija: %s", t)
|
|
elif t == "session.updated":
|
|
log.info("sesija: %s", t)
|
|
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."}})
|
|
|
|
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,
|
|
"output_modalities": ["audio"],
|
|
"tools": TOOLS,
|
|
"tool_choice": "auto",
|
|
"audio": {
|
|
"input": {
|
|
"format": {"type": "audio/pcm", "rate": 24000},
|
|
"noise_reduction": {"type": "far_field"},
|
|
"transcription": {"model": self.cfg.transcription_model, "language": "bs"},
|
|
"turn_detection": {
|
|
"type": "server_vad",
|
|
"threshold": self.cfg.vad_threshold,
|
|
"prefix_padding_ms": 300,
|
|
"silence_duration_ms": self.cfg.vad_silence_ms,
|
|
"create_response": True,
|
|
},
|
|
},
|
|
"output": {"format": {"type": "audio/pcm", "rate": 24000}, "voice": self.cfg.voice, "speed": 1.0},
|
|
},
|
|
})
|
|
log.info("povezan na %s", self.cfg.realtime_model)
|
|
mic_task = asyncio.create_task(self.mic_loop())
|
|
try:
|
|
await self.event_loop()
|
|
finally:
|
|
mic_task.cancel()
|
|
self.conn = None
|
|
|
|
async def run(self) -> None:
|
|
await self.speaker.start()
|
|
if not await printer.printer_ok(self.cfg.printer):
|
|
log.warning("printer %s nije spreman", self.cfg.printer)
|
|
backoff = 2
|
|
while True:
|
|
try:
|
|
await self.session()
|
|
backoff = 2
|
|
log.info("sesija zatvorena, ponovo se povezujem")
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception: # noqa: BLE001
|
|
log.exception("sesija pukla, ponovni pokušaj za %ss", backoff)
|
|
await asyncio.sleep(backoff)
|
|
backoff = min(backoff * 2, 60)
|