Files
idriz/idriz/assistant.py

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)