diff --git a/idriz/apps/__init__.py b/idriz/apps/__init__.py index bd1423d..c039899 100644 --- a/idriz/apps/__init__.py +++ b/idriz/apps/__init__.py @@ -8,5 +8,7 @@ from .base import AppContext, AppSpec, AppTool # noqa: F401 def load_apps() -> list[AppSpec]: + from .bojanka import manifest as bojanka + from .sazetak import manifest as sazetak from .superheroji import manifest as superheroji - return [superheroji.SPEC] + return [sazetak.SPEC, bojanka.SPEC, superheroji.SPEC] diff --git a/idriz/apps/bojanka/__init__.py b/idriz/apps/bojanka/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/idriz/apps/bojanka/manifest.py b/idriz/apps/bojanka/manifest.py new file mode 100644 index 0000000..58385b7 --- /dev/null +++ b/idriz/apps/bojanka/manifest.py @@ -0,0 +1,49 @@ +"""Mini-app 'Bojanka': generiše crno-bijelu bojanku preko cijele stranice i štampa. + +Prompt je u jezgru (temeljna funkcija); ovdje su shema alata i handler. Background. +""" +import datetime as dt + +from idriz import coloring, printer +from idriz.util import slug + +from ..base import AppContext, AppSpec, AppTool + +SCHEMA = { + "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"], + }, +} + + +async def bojanka(ctx: AppContext, args: dict) -> str: + cfg = ctx.cfg + 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 = cfg.out_dir / f"{stamp}-bojanka-{slug(subject)}.pdf" + await coloring.generate(ctx.client, cfg.image_model, subject, out) + await printer.print_file(out, cfg.printer, cfg.dry_run, fit=True) + return f"Bojanka '{subject_bs}' je odštampana." + + +INSTRUCTIONS = """ODŠTAMPATI BOJANKU (alat: bojanka): crno-bijela slika sa konturama koju dijete može bojiti, preko cijele stranice. +- Prije štampanja potvrdi, npr. "Da odštampam bojanku sa zmajem?". +- Kad bojanka bude odštampana, reci djetetu da uzme papir iz printera.""" + + +SPEC = AppSpec( + name="bojanka", + title="Bojanka", + instructions=INSTRUCTIONS, + greeting="odštampati bojanku za bojenje", + tools=[AppTool(SCHEMA, bojanka, "background")], +) diff --git a/idriz/apps/sazetak/__init__.py b/idriz/apps/sazetak/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/idriz/apps/sazetak/manifest.py b/idriz/apps/sazetak/manifest.py new file mode 100644 index 0000000..45ee059 --- /dev/null +++ b/idriz/apps/sazetak/manifest.py @@ -0,0 +1,86 @@ +"""Mini-app 'Sažetak': Wikipedia članak -> bosanski sažetak -> štampa. + +Prompt za ovu (temeljnu) funkciju je u jezgru (assistant INSTRUCTIONS); ovdje su +samo shema alata i handler. Način rada: background (dug posao, javi kad je gotovo). +""" +import asyncio +import datetime as dt +import logging +from pathlib import Path + +from idriz import coloring, printer, render, textgen, wiki +from idriz.util import slug + +from ..base import AppContext, AppSpec, AppTool + +log = logging.getLogger("idriz.sazetak") + +SCHEMA = { + "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"], + }, +} + + +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 sazetak(ctx: AppContext, args: dict) -> str: + cfg = ctx.cfg + 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 = cfg.out_dir / f"{stamp}-sazetak-{slug(article.title)}" + summary_task = asyncio.create_task(textgen.summarize(ctx.client, cfg.text_model, article, topic_bs, age)) + image_task = asyncio.create_task(asyncio.to_thread(_wiki_image, article.title, base)) + summary = await summary_task + image = await image_task + if image is None: + try: + raw = await coloring.generate_illustration(ctx.client, 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, cfg.max_pages) + await printer.print_file(out, cfg.printer, cfg.dry_run, fit=False) + return f"Sažetak '{summary.title}' (po članku '{article.title}') je odštampan." + + +INSTRUCTIONS = """ODŠTAMPATI SAŽETAK o nekoj temi (alat: wikipedia_sazetak): tražiš članak na engleskoj Wikipediji, prevedeš ga na bosanski i odštampaš sažetak do dvije stranice sa slikom. +- Prije štampanja potvrdi, npr. "Da odštampam sažetak o vulkanima?". +- Korisno je znati koliko dijete ima godina ili koji je razred (ako to već ne znaš) — pitaj samo jednom. +- Nikad ne čitaj cijeli sažetak naglas; on ide na papir. Možeš reći jednu rečenicu o čemu se radi. +- 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. +- Kad sažetak bude odštampan, reci djetetu da uzme papir iz printera.""" + + +SPEC = AppSpec( + name="sazetak", + title="Sažetak", + instructions=INSTRUCTIONS, + greeting="odštampati sažetak o nekoj temi sa Wikipedije", + tools=[AppTool(SCHEMA, sazetak, "background")], +) diff --git a/idriz/apps/superheroji/manifest.py b/idriz/apps/superheroji/manifest.py index 89c9fd4..0e332df 100644 --- a/idriz/apps/superheroji/manifest.py +++ b/idriz/apps/superheroji/manifest.py @@ -165,7 +165,7 @@ SPEC = AppSpec( name="superheroji", title="Superheroji", instructions=INSTRUCTIONS, - greeting="možete zajedno igrati igru Superheroji", + greeting="igrati igru Superheroji", tools=[ AppTool(NOVA_SCHEMA, nova, "inline"), AppTool(IZBOR_SCHEMA, izbor, "inline"), diff --git a/idriz/assistant.py b/idriz/assistant.py index 132d127..19dde86 100644 --- a/idriz/assistant.py +++ b/idriz/assistant.py @@ -1,15 +1,12 @@ """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 . import audio, printer from .apps import load_apps from .apps.base import AppContext from .audio import Mic, Speaker @@ -22,11 +19,8 @@ INSTRUCTIONS = """Ti si Idriz, prijateljski glasovni pomoćnik za djecu u jednoj 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. -Osim toga, možeš voditi i posebne igre i aktivnosti opisane niže (samo ako ih dijete zatraži). +Osnovno što radiš je RAZGOVOR: odgovaraš na pitanja glasom. Dijete te može pitati bilo šta — za školu, o svijetu, životinjama, svemiru, historiji, matematici, riječima... Odgovaraš iz svog znanja. +Osim razgovora imaš i posebne mogućnosti (npr. štampanje, igre). Svaka je opisana niže, sa svojim alatom. Koristi ih samo kad 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?". @@ -36,56 +30,18 @@ Kako odgovaraš na pitanja: - 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. +- Razgovor ne traži nikakvu potvrdu. -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. +Kako koristiš alate: +- Prije nego pozoveš alat koji pravi fizički rezultat (npr. nešto štampa), OBAVEZNO kratko ponovi šta ćeš uraditi i pitaj za potvrdu; alat pozovi TEK kad dijete potvrdi (da, može, hajde, ok). Za igre i sličan razgovor potvrda nije potrebna. +- Neki poslovi traju (npr. štampanje). Kad pokreneš takav alat, reci djetetu da radiš na tome i da traje minutu do dvije; u međuvremenu možeš nastaviti razgovor. Kad dobiješ poruku da je gotovo, reci to djetetu; ako je posao propao, reci iskreno i ponudi da probate ponovo. 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"], - }, - }, -] - -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: @@ -105,25 +61,21 @@ class Assistant: self.ctx = AppContext(self) self.apps = load_apps() - _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(at.schema, self._bind(at.handler), at.mode) + for app in self.apps for at in app.tools ] - 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) + caps = [app.greeting for app in self.apps if app.greeting] + moze = (", a možeš i: " + ", ".join(caps)) if caps else "" 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) + "prijateljski pomoćnik za djecu. Dijete te može nešto pitati" + moze + ".") def _bind(self, handler): """Veže AppContext za app-handler (ctx, args) -> handler(args) za registar.""" @@ -131,53 +83,6 @@ class Assistant: 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 "" - 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." - PLACEHOLDER = ("Posao je pokrenut i trajat će oko pola minute do minutu. " "Reci djetetu da sačeka; javit ću kad bude gotovo.") diff --git a/idriz/util.py b/idriz/util.py new file mode 100644 index 0000000..909582a --- /dev/null +++ b/idriz/util.py @@ -0,0 +1,7 @@ +"""Sitne dijeljene pomoćne funkcije.""" +import re + + +def slug(s: str) -> str: + s = re.sub(r"[^a-zA-Z0-9]+", "-", s).strip("-").lower() + return s[:40] or "posao"