diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a2b5119 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.venv/ +__pycache__/ +*.pyc +out/ +*.pdf +*.png +*.wav diff --git a/README.md b/README.md index 25c5047..3310137 100644 --- a/README.md +++ b/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" +``` diff --git a/idriz/__init__.py b/idriz/__init__.py new file mode 100644 index 0000000..8ff353d --- /dev/null +++ b/idriz/__init__.py @@ -0,0 +1,2 @@ +"""Idriz: glasovni asistent za djecu, izlaz na printer.""" +__version__ = "0.1.0" diff --git a/idriz/__main__.py b/idriz/__main__.py new file mode 100644 index 0000000..05e7523 --- /dev/null +++ b/idriz/__main__.py @@ -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()) diff --git a/idriz/assistant.py b/idriz/assistant.py new file mode 100644 index 0000000..9c83205 --- /dev/null +++ b/idriz/assistant.py @@ -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) diff --git a/idriz/audio.py b/idriz/audio.py new file mode 100644 index 0000000..9659738 --- /dev/null +++ b/idriz/audio.py @@ -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() diff --git a/idriz/coloring.py b/idriz/coloring.py new file mode 100644 index 0000000..7507ca6 --- /dev/null +++ b/idriz/coloring.py @@ -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 diff --git a/idriz/config.py b/idriz/config.py new file mode 100644 index 0000000..24794db --- /dev/null +++ b/idriz/config.py @@ -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() diff --git a/idriz/printer.py b/idriz/printer.py new file mode 100644 index 0000000..acc9dc4 --- /dev/null +++ b/idriz/printer.py @@ -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 diff --git a/idriz/render.py b/idriz/render.py new file mode 100644 index 0000000..bbe01ba --- /dev/null +++ b/idriz/render.py @@ -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"
" + f""), body.rfind("
uvod
...
...Nažalost, nisam uspio napisati sažetak.
" + log.info("sažetak %r: %d znakova", title, len(html)) + return title, html diff --git a/idriz/wiki.py b/idriz/wiki.py new file mode 100644 index 0000000..2ed83bf --- /dev/null +++ b/idriz/wiki.py @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f053a31 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +openai>=3.7 +websockets>=17 +weasyprint>=69 +pillow>=11 +requests>=2.32 diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..3119664 --- /dev/null +++ b/scripts/install.sh @@ -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" diff --git a/scripts/test_summary.py b/scripts/test_summary.py new file mode 100644 index 0000000..c6c2b35 --- /dev/null +++ b/scripts/test_summary.py @@ -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"{html.escape(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")) diff --git a/systemd/idriz.service b/systemd/idriz.service new file mode 100644 index 0000000..b73ed46 --- /dev/null +++ b/systemd/idriz.service @@ -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