Idriz v0.1: Realtime voice assistant with Wikipedia summaries and coloring pages

- OpenAI Realtime session (server VAD, half-duplex audio via pacat/PipeWire)
- Tools: wikipedia_sazetak (en.wikipedia -> Bosnian summary, max 2 A4 pages via WeasyPrint)
  and bojanka (image generation -> pure line art on A4)
- CUPS printing, systemd user service, install script, offline test script

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LzKpYaEsuWDevuNnvSjcFW
This commit is contained in:
idriz
2026-09-02 18:37:39 +02:00
parent cc1babe26d
commit 78967e15f9
16 changed files with 729 additions and 2 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.venv/
__pycache__/
*.pyc
out/
*.pdf
*.png
*.wav

View File

@@ -1,3 +1,40 @@
# idriz
# Idriz
Source code for the idriz autonomous agent. Deployed only on the `idriz` machine; this repository exists for incremental backup of changes.
Glasovni pomoćnik za djecu, alternativa ekranu. Sluša preko mikrofona, govori na zvučnik, a rezultate štampa na printer.
Radi samo na mašini `idriz` (Debian 13); ovaj repozitorij služi za sigurnosne kopije koda.
## Šta zna (v0.1)
- **Sažetak sa Wikipedije:** nađe članak na engleskoj Wikipediji, prevede ga i sažme na bosanski, prilagodi uzrastu i odštampa najviše 2 stranice.
- **Bojanka:** generiše crno-bijelu sliku sa konturama preko cijele A4 stranice i odštampa je.
Razgovor ide preko OpenAI Realtime API-ja (govor u govor). Tišina znači kraj komande. Idriz prije štampanja uvijek pita za potvrdu.
## Struktura
- `idriz/assistant.py` glavna petlja (Realtime sesija, alati, half-duplex audio)
- `idriz/audio.py` mikrofon i zvučnik preko `pacat` (PipeWire), PCM16 24 kHz
- `idriz/wiki.py` pretraga i preuzimanje članka
- `idriz/textgen.py` prevod i sažetak (chat model, JSON)
- `idriz/render.py` HTML → PDF (WeasyPrint), automatsko uklapanje u 2 stranice
- `idriz/coloring.py` generisanje slike i pretvaranje u čiste konture, A4 PDF
- `idriz/printer.py` `lp` na CUPS printer `idriz-printer`
- `systemd/idriz.service` korisnički servis
## Instalacija
```
scripts/install.sh
# upiši OPENAI_API_KEY u ~/.config/idriz/env
systemctl --user start idriz
journalctl --user -u idriz -f
```
## Konfiguracija (okolina)
`OPENAI_API_KEY`, `IDRIZ_REALTIME_MODEL` (gpt-realtime-2.1), `IDRIZ_TEXT_MODEL` (gpt-5.4-mini), `IDRIZ_IMAGE_MODEL` (gpt-image-2),
`IDRIZ_VOICE` (cedar), `IDRIZ_PRINTER` (idriz-printer), `IDRIZ_DRY_RUN=1` (ne štampa), `IDRIZ_MAX_PAGES` (2),
`IDRIZ_VAD_SILENCE_MS` (900), `IDRIZ_MIC_DEVICE`, `IDRIZ_SPEAKER_DEVICE`.
Odštampani PDF-ovi ostaju u `~/.local/share/idriz/out/`.
## Test bez štampanja
```
IDRIZ_DRY_RUN=1 .venv/bin/python scripts/test_summary.py "Volcano" "8 godina"
```

2
idriz/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""Idriz: glasovni asistent za djecu, izlaz na printer."""
__version__ = "0.1.0"

24
idriz/__main__.py Normal file
View File

@@ -0,0 +1,24 @@
import asyncio
import logging
import sys
from .assistant import Assistant
from .config import load
def main() -> int:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s", stream=sys.stderr)
logging.getLogger("httpx").setLevel(logging.WARNING)
cfg = load()
if not cfg.openai_api_key:
logging.error("OPENAI_API_KEY nije postavljen (očekujem ga u ~/.config/idriz/env)")
return 2
try:
asyncio.run(Assistant(cfg).run())
except KeyboardInterrupt:
pass
return 0
if __name__ == "__main__":
sys.exit(main())

238
idriz/assistant.py Normal file
View File

@@ -0,0 +1,238 @@
"""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 openai import AsyncOpenAI
from . import 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)
self.speaker = Speaker(cfg.speaker_device)
self.conn = None
self.jobs: set[asyncio.Task] = set()
# ---------- 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."
title, body = await textgen.summarize(self.client, self.cfg.text_model, article, topic_bs, age)
stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
out = self.cfg.out_dir / f"{stamp}-sazetak-{_slug(article.title)}.pdf"
await asyncio.to_thread(render.render_summary, title, body, 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 '{title}' (po članku '{article.title}') je odštampan."
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:
while True:
chunk = await self.mic.read()
if not chunk:
log.error("mikrofon je prestao davati podatke")
await asyncio.sleep(1)
await self.mic.start()
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":
if self.speaker.is_playing(0.0):
await self.speaker.clear()
elif t == "error":
log.error("realtime greška: %s", event.error)
elif t in ("session.created", "session.updated"):
log.info("sesija: %s", t)
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.mic.start()
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)

90
idriz/audio.py Normal file
View File

@@ -0,0 +1,90 @@
"""Mikrofon i zvučnik preko PipeWire-a (pacat), PCM16 mono 24 kHz kako traži Realtime API."""
import asyncio
import logging
import time
log = logging.getLogger("idriz.audio")
RATE = 24000
BYTES_PER_SEC = RATE * 2 # s16le mono
CHUNK = 2400 # 50 ms
def _pacat_args(mode: str, device: str) -> list[str]:
args = ["pacat", mode, "--raw", "--format=s16le", f"--rate={RATE}", "--channels=1", "--latency-msec=60"]
if device:
args.append(f"--device={device}")
return args
class Mic:
def __init__(self, device: str = "") -> None:
self.device = device
self.proc: asyncio.subprocess.Process | None = None
async def start(self) -> None:
self.proc = await asyncio.create_subprocess_exec(
*_pacat_args("--record", self.device),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
log.info("mikrofon pokrenut (pid %s)", self.proc.pid)
async def read(self) -> bytes:
assert self.proc and self.proc.stdout
return await self.proc.stdout.read(CHUNK)
async def stop(self) -> None:
if self.proc and self.proc.returncode is None:
self.proc.kill()
await self.proc.wait()
class Speaker:
"""Reproducira PCM; pamti do kada traje reprodukcija da bi se mikrofon utišao (half-duplex)."""
def __init__(self, device: str = "") -> None:
self.device = device
self.proc: asyncio.subprocess.Process | None = None
self.playing_until = 0.0
self._lock = asyncio.Lock()
async def start(self) -> None:
self.proc = await asyncio.create_subprocess_exec(
*_pacat_args("--playback", self.device),
stdin=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
log.info("zvučnik pokrenut (pid %s)", self.proc.pid)
async def write(self, pcm: bytes) -> None:
async with self._lock:
if not self.proc or self.proc.returncode is not None:
await self.start()
assert self.proc and self.proc.stdin
now = time.monotonic()
base = max(now, self.playing_until)
self.playing_until = base + len(pcm) / BYTES_PER_SEC
try:
self.proc.stdin.write(pcm)
await self.proc.stdin.drain()
except (BrokenPipeError, ConnectionResetError):
log.warning("zvučnik prekinut, restart")
await self.start()
def is_playing(self, tail: float = 0.35) -> bool:
return time.monotonic() < self.playing_until + tail
async def clear(self) -> None:
"""Odbaci sve što još nije pušteno (prekid govora)."""
async with self._lock:
if self.proc and self.proc.returncode is None:
self.proc.kill()
await self.proc.wait()
self.playing_until = 0.0
await self.start()
async def stop(self) -> None:
if self.proc and self.proc.returncode is None:
self.proc.kill()
await self.proc.wait()

37
idriz/coloring.py Normal file
View File

@@ -0,0 +1,37 @@
"""Generisanje bojanke (crno-bijele konture) i priprema za štampu na cijeloj A4 stranici."""
import base64
import io
import logging
from pathlib import Path
from openai import AsyncOpenAI
from PIL import Image, ImageOps
log = logging.getLogger("idriz.coloring")
PROMPT = (
"A children's coloring book page: {subject}. "
"Pure black line art on a plain white background, thick clean outlines, no shading, no gray, "
"no color, no text, no watermark, large simple shapes that are easy to color, fills the whole page, portrait orientation."
)
A4_PX = (2480, 3508) # 300 dpi
async def generate(client: AsyncOpenAI, model: str, subject: str, out: Path) -> Path:
prompt = PROMPT.format(subject=subject)
log.info("generišem bojanku: %r", subject)
resp = await client.images.generate(model=model, prompt=prompt, size="1024x1536", quality="medium", n=1)
b64 = resp.data[0].b64_json
if not b64:
raise RuntimeError("model nije vratio sliku")
img = Image.open(io.BytesIO(base64.b64decode(b64))).convert("L")
img = ImageOps.autocontrast(img)
bw = img.point(lambda v: 255 if v > 165 else 0, mode="1") # čiste konture bez sivih tonova
page = Image.new("1", A4_PX, 1)
fitted = ImageOps.contain(bw.convert("L"), (A4_PX[0] - 200, A4_PX[1] - 200)).convert("1")
page.paste(fitted, ((A4_PX[0] - fitted.width) // 2, (A4_PX[1] - fitted.height) // 2))
page.save(out, "PDF", resolution=300.0)
page.save(out.with_suffix(".png"))
log.info("bojanka spremljena: %s", out)
return out

33
idriz/config.py Normal file
View File

@@ -0,0 +1,33 @@
"""Konfiguracija iz okoline (~/.config/idriz/env se učitava preko systemd EnvironmentFile)."""
import os
from dataclasses import dataclass, field
from pathlib import Path
def _env(name: str, default: str) -> str:
return os.environ.get(name, default)
@dataclass
class Config:
openai_api_key: str = field(default_factory=lambda: _env("OPENAI_API_KEY", ""))
realtime_model: str = field(default_factory=lambda: _env("IDRIZ_REALTIME_MODEL", "gpt-realtime-2.1"))
text_model: str = field(default_factory=lambda: _env("IDRIZ_TEXT_MODEL", "gpt-5.4-mini"))
image_model: str = field(default_factory=lambda: _env("IDRIZ_IMAGE_MODEL", "gpt-image-2"))
transcription_model: str = field(default_factory=lambda: _env("IDRIZ_TRANSCRIPTION_MODEL", "gpt-4o-mini-transcribe"))
voice: str = field(default_factory=lambda: _env("IDRIZ_VOICE", "cedar"))
printer: str = field(default_factory=lambda: _env("IDRIZ_PRINTER", "idriz-printer"))
mic_device: str = field(default_factory=lambda: _env("IDRIZ_MIC_DEVICE", ""))
speaker_device: str = field(default_factory=lambda: _env("IDRIZ_SPEAKER_DEVICE", ""))
out_dir: Path = field(default_factory=lambda: Path(_env("IDRIZ_OUT_DIR", str(Path.home() / ".local/share/idriz/out"))))
max_pages: int = field(default_factory=lambda: int(_env("IDRIZ_MAX_PAGES", "2")))
dry_run: bool = field(default_factory=lambda: _env("IDRIZ_DRY_RUN", "0") == "1")
vad_silence_ms: int = field(default_factory=lambda: int(_env("IDRIZ_VAD_SILENCE_MS", "900")))
vad_threshold: float = field(default_factory=lambda: float(_env("IDRIZ_VAD_THRESHOLD", "0.6")))
def __post_init__(self) -> None:
self.out_dir.mkdir(parents=True, exist_ok=True)
def load() -> Config:
return Config()

29
idriz/printer.py Normal file
View File

@@ -0,0 +1,29 @@
"""Štampanje preko CUPS-a (lp)."""
import asyncio
import logging
from pathlib import Path
log = logging.getLogger("idriz.printer")
async def print_file(path: Path, printer: str, dry_run: bool = False, fit: bool = True) -> str:
if dry_run:
log.info("DRY RUN: ne štampam %s", path)
return "dry-run"
args = ["lp", "-d", printer, "-o", "media=A4"]
if fit:
args += ["-o", "fit-to-page"]
args.append(str(path))
proc = await asyncio.create_subprocess_exec(*args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
out, err = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(f"lp greška: {err.decode().strip()}")
job = out.decode().strip()
log.info("poslano na printer: %s", job)
return job
async def printer_ok(printer: str) -> bool:
proc = await asyncio.create_subprocess_exec("lpstat", "-p", printer, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
out, _ = await proc.communicate()
return proc.returncode == 0 and b"disabled" not in out

55
idriz/render.py Normal file
View File

@@ -0,0 +1,55 @@
"""Pravljenje PDF-a od HTML sažetka (WeasyPrint) uz ograničenje broja stranica."""
import datetime as dt
import html
import logging
from pathlib import Path
from weasyprint import HTML
log = logging.getLogger("idriz.render")
CSS = """
@page {{ size: A4; margin: 16mm 18mm 18mm 18mm;
@bottom-center {{ content: "Idriz • Izvor: engleska Wikipedija ({source}) • {date} • strana " counter(page) " / " counter(pages);
font-family: "DejaVu Sans", sans-serif; font-size: 8pt; color: #666; }} }}
body {{ font-family: "DejaVu Sans", "Noto Sans", sans-serif; font-size: {size}pt; line-height: 1.38; color: #111; }}
h1 {{ font-size: {h1}pt; margin: 0 0 6pt 0; border-bottom: 2pt solid #333; padding-bottom: 4pt; }}
h2 {{ font-size: {h2}pt; margin: 10pt 0 3pt 0; }}
p {{ margin: 0 0 6pt 0; text-align: justify; }}
ul {{ margin: 0 0 6pt 0; padding-left: 16pt; }}
li {{ margin-bottom: 2pt; }}
.meta {{ font-size: {meta}pt; color: #555; margin-bottom: 8pt; }}
"""
def _document(title: str, body_html: str, source: str, size: float) -> str:
date = dt.date.today().strftime("%d.%m.%Y.")
css = CSS.format(source=html.escape(source), date=date, size=size, h1=size * 1.8, h2=size * 1.25, meta=size * 0.85)
return (
f"<html><head><meta charset='utf-8'><style>{css}</style></head><body>"
f"<h1>{html.escape(title)}</h1>"
f"<div class='meta'>Sažetak za tebe pripremio Idriz</div>"
f"{body_html}</body></html>"
)
def render_summary(title: str, body_html: str, source: str, out: Path, max_pages: int = 2) -> Path:
"""Renderuje PDF; ako ne stane na max_pages, smanjuje font, a na kraju skraćuje tekst."""
body = body_html
for attempt in range(8):
for size in (12, 11.5, 11, 10.5, 10):
doc = HTML(string=_document(title, body, source, size)).render()
pages = len(doc.pages)
if pages <= max_pages:
doc.write_pdf(out)
log.info("PDF %s: %d stranica, font %spt", out.name, pages, size)
return out
# skrati: izbaci zadnji paragraf/stavku
cut = max(body.rfind("<p>"), body.rfind("<li>"))
if cut <= 0:
break
body = body[:cut]
log.info("skraćujem tekst (pokušaj %d)", attempt + 1)
doc = HTML(string=_document(title, body, source, 10)).render()
doc.write_pdf(out)
return out

40
idriz/textgen.py Normal file
View File

@@ -0,0 +1,40 @@
"""Prevod i sažimanje članka na bosanski, prilagođeno djetetu."""
import json
import logging
from openai import AsyncOpenAI
from .wiki import Article
log = logging.getLogger("idriz.textgen")
SYSTEM = """Ti si Idriz, pomoćnik za djecu u Bosni i Hercegovini. Dobit ćeš članak sa engleske Wikipedije.
Napiši sažetak na BOSANSKOM jeziku (ijekavica, latinica) koji dijete može razumjeti.
Pravila:
- Prilagodi jezik uzrastu djeteta koji ti je dat. Za mlađe: kratke rečenice, jednostavne riječi. Za starije: više činjenica.
- Dužina: između 450 i 650 riječi. Nikako više, jer mora stati na dvije stranice A4.
- Struktura: naslov, uvod (2-3 rečenice), zatim 3 do 5 kratkih poglavlja sa podnaslovima, i na kraju "Zanimljivosti" sa 3 crtice.
- Piši tačno, prema članku. Ne izmišljaj podatke. Brojeve i godine zadrži.
- Bez engleskih riječi osim imena koja se ne prevode.
Odgovori ISKLJUČIVO JSON objektom oblika:
{"naslov": "...", "html": "<p>uvod</p><h2>Podnaslov</h2><p>...</p>...<h2>Zanimljivosti</h2><ul><li>...</li></ul>"}
U "html" koristi samo oznake p, h2, ul, li, strong."""
async def summarize(client: AsyncOpenAI, model: str, article: Article, topic: str, age: str) -> tuple[str, str]:
user = (
f"Tema koju je dijete tražilo: {topic}\n"
f"Uzrast djeteta: {age or 'nepoznat, pretpostavi 10 godina'}\n\n"
f"Naslov članka: {article.title}\n\n{article.text}"
)
resp = await client.chat.completions.create(
model=model,
messages=[{"role": "system", "content": SYSTEM}, {"role": "user", "content": user}],
response_format={"type": "json_object"},
)
content = resp.choices[0].message.content or "{}"
data = json.loads(content)
title = data.get("naslov") or article.title
html = data.get("html") or "<p>Nažalost, nisam uspio napisati sažetak.</p>"
log.info("sažetak %r: %d znakova", title, len(html))
return title, html

69
idriz/wiki.py Normal file
View File

@@ -0,0 +1,69 @@
"""Pretraga i preuzimanje članaka sa engleske Wikipedije."""
import logging
from dataclasses import dataclass
import requests
log = logging.getLogger("idriz.wiki")
API = "https://en.wikipedia.org/w/api.php"
HEADERS = {"User-Agent": "Idriz/0.1 (kucni asistent za djecu; kontakt: idriz@uka.life)"}
MAX_CHARS = 16000
@dataclass
class Article:
title: str
url: str
text: str
def search(topic: str, limit: int = 5) -> list[str]:
r = requests.get(
API,
params={"action": "query", "list": "search", "srsearch": topic, "srlimit": limit, "format": "json"},
headers=HEADERS,
timeout=20,
)
r.raise_for_status()
return [hit["title"] for hit in r.json().get("query", {}).get("search", [])]
def fetch(title: str) -> Article | None:
r = requests.get(
API,
params={
"action": "query",
"prop": "extracts|info",
"explaintext": 1,
"redirects": 1,
"inprop": "url",
"titles": title,
"format": "json",
},
headers=HEADERS,
timeout=30,
)
r.raise_for_status()
pages = r.json().get("query", {}).get("pages", {})
for page in pages.values():
if "missing" in page or not page.get("extract"):
continue
text = page["extract"]
# skrati na najzanimljiviji dio i izbaci "See also"/"References" repove
for marker in ("\n\n== See also ==", "\n\n== References ==", "\n\n== Notes ==", "\n\n== External links =="):
idx = text.find(marker)
if idx > 0:
text = text[:idx]
return Article(title=page["title"], url=page.get("fullurl", ""), text=text[:MAX_CHARS])
return None
def find_article(topic: str) -> Article | None:
titles = search(topic)
log.info("wikipedia pretraga %r -> %s", topic, titles)
for t in titles:
art = fetch(t)
if art and len(art.text) > 400:
return art
return None

5
requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
openai>=3.7
websockets>=17
weasyprint>=69
pillow>=11
requests>=2.32

16
scripts/install.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/bin/sh
# Postavlja venv, systemd korisnički servis i konfiguracioni fajl. Pokretati kao korisnik idriz.
set -e
cd "$(dirname "$0")/.."
[ -d .venv ] || python3 -m venv .venv
.venv/bin/pip install -q -r requirements.txt
mkdir -p ~/.config/idriz ~/.config/systemd/user
if [ ! -f ~/.config/idriz/env ]; then
printf 'OPENAI_API_KEY=\n' > ~/.config/idriz/env
chmod 600 ~/.config/idriz/env
echo "Upiši OPENAI_API_KEY u ~/.config/idriz/env"
fi
cp systemd/idriz.service ~/.config/systemd/user/idriz.service
systemctl --user daemon-reload
systemctl --user enable idriz.service
echo "Gotovo. Pokreni sa: systemctl --user start idriz"

30
scripts/test_summary.py Normal file
View File

@@ -0,0 +1,30 @@
"""Ručni test: Wikipedia -> (opciono) sažetak -> PDF. Bez ključa radi samo sa sirovim tekstom članka."""
import asyncio
import html
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from idriz import render, textgen, wiki # noqa: E402
from idriz.config import load # noqa: E402
async def main(topic: str, age: str) -> None:
cfg = load()
art = wiki.find_article(topic)
if not art:
print("nema članka"); return
print("članak:", art.title, art.url, len(art.text), "znakova")
out = Path("out"); out.mkdir(exist_ok=True)
if cfg.openai_api_key:
from openai import AsyncOpenAI
title, body = await textgen.summarize(AsyncOpenAI(api_key=cfg.openai_api_key), cfg.text_model, art, topic, age)
else:
title = art.title
body = "".join(f"<p>{html.escape(p)}</p>" for p in art.text.split("\n\n")[:12])
pdf = render.render_summary(title, body, art.title, out / "test-sazetak.pdf", cfg.max_pages)
print("PDF:", pdf)
if __name__ == "__main__":
asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else "Volcano", sys.argv[2] if len(sys.argv) > 2 else "10"))

15
systemd/idriz.service Normal file
View File

@@ -0,0 +1,15 @@
[Unit]
Description=Idriz - glasovni pomoćnik za djecu
After=pipewire.service wireplumber.service network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=%h/src/idriz
EnvironmentFile=%h/.config/idriz/env
ExecStart=%h/src/idriz/.venv/bin/python -m idriz
Restart=always
RestartSec=5
[Install]
WantedBy=default.target