Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LzKpYaEsuWDevuNnvSjcFW
288 lines
14 KiB
Python
288 lines
14 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, 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 (samo ovo dvoje za sada):
|
|
1. Odštampati sažetak o nekoj temi za školu ili iz radoznalosti. Tražiš članak na engleskoj Wikipediji, prevedeš ga na bosanski i odštampaš sažetak do dvije stranice. Alat: wikipedia_sazetak.
|
|
2. Odštampati bojanku: crno-bijelu sliku sa konturama koju dijete može bojiti. Alat: bojanka.
|
|
|
|
Pravila razgovora:
|
|
- 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 pola minute do minutu. Ne izmišljaj sadržaj.
|
|
- 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.
|
|
- Ako dijete traži nešto što ne znaš uraditi, reci jednostavno da to još ne umiješ, i da za sada znaš štampati sažetke i bojanke.
|
|
- Sadržaj mora biti prikladan za djecu.
|
|
"""
|
|
|
|
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"],
|
|
},
|
|
},
|
|
]
|
|
|
|
|
|
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
|
|
|
|
# ---------- 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."
|
|
|
|
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 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)
|
|
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 > 1.5:
|
|
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 asyncio.sleep(0.5)
|
|
continue
|
|
if not self.mic.running:
|
|
await self.mic.start()
|
|
log.info("mikrofon uključen, slušam")
|
|
await self.speaker.write(audio.tone())
|
|
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":
|
|
await self.speaker.write(base64.b64decode(event.delta))
|
|
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":
|
|
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, i reci da možeš 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)
|