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:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
out/
|
||||
*.pdf
|
||||
*.png
|
||||
*.wav
|
||||
41
README.md
41
README.md
@@ -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
2
idriz/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Idriz: glasovni asistent za djecu, izlaz na printer."""
|
||||
__version__ = "0.1.0"
|
||||
24
idriz/__main__.py
Normal file
24
idriz/__main__.py
Normal 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
238
idriz/assistant.py
Normal 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
90
idriz/audio.py
Normal 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
37
idriz/coloring.py
Normal 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
33
idriz/config.py
Normal 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
29
idriz/printer.py
Normal 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
55
idriz/render.py
Normal 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
40
idriz/textgen.py
Normal 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
69
idriz/wiki.py
Normal 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
5
requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
openai>=3.7
|
||||
websockets>=17
|
||||
weasyprint>=69
|
||||
pillow>=11
|
||||
requests>=2.32
|
||||
16
scripts/install.sh
Executable file
16
scripts/install.sh
Executable 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
30
scripts/test_summary.py
Normal 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
15
systemd/idriz.service
Normal 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
|
||||
Reference in New Issue
Block a user