diff --git a/idriz/assistant.py b/idriz/assistant.py index 9c83205..4489202 100644 --- a/idriz/assistant.py +++ b/idriz/assistant.py @@ -5,6 +5,7 @@ import datetime as dt import json import logging import re +from pathlib import Path from openai import AsyncOpenAI @@ -85,12 +86,35 @@ class Assistant: 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) + 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 '{title}' (po članku '{article.title}') je odštampan." + 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" diff --git a/idriz/coloring.py b/idriz/coloring.py index 7507ca6..e67ad3d 100644 --- a/idriz/coloring.py +++ b/idriz/coloring.py @@ -35,3 +35,22 @@ async def generate(client: AsyncOpenAI, model: str, subject: str, out: Path) -> page.save(out.with_suffix(".png")) log.info("bojanka spremljena: %s", out) return out + + +ILLUSTRATION_PROMPT = ( + "A simple, friendly black and white line drawing for a children's encyclopedia page: {subject}. " + "Clean black outlines on white, light hatching allowed, no text, no watermark, landscape orientation." +) + + +async def generate_illustration(client: AsyncOpenAI, model: str, subject: str, out: Path) -> Path: + """Crtež za sažetak kad Wikipedija nema sliku (PNG, vodoravno).""" + log.info("generišem ilustraciju: %r", subject) + resp = await client.images.generate(model=model, prompt=ILLUSTRATION_PROMPT.format(subject=subject), + size="1536x1024", 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") + ImageOps.autocontrast(img).save(out, "PNG") + return out diff --git a/idriz/render.py b/idriz/render.py index bbe01ba..13cf93f 100644 --- a/idriz/render.py +++ b/idriz/render.py @@ -1,55 +1,85 @@ -"""Pravljenje PDF-a od HTML sažetka (WeasyPrint) uz ograničenje broja stranica.""" +"""Pravljenje PDF-a od sažetka (WeasyPrint): slika, prozračan tekst, okvir sa zanimljivostima, najviše 2 stranice.""" import datetime as dt import html import logging from pathlib import Path +from PIL import Image, ImageOps from weasyprint import HTML log = logging.getLogger("idriz.render") CSS = """ -@page {{ size: A4; margin: 16mm 18mm 18mm 18mm; +@page {{ size: A4; margin: 15mm 17mm 17mm 17mm; @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; }} + font-family: "DejaVu Sans", sans-serif; font-size: 8pt; color: #777; }} }} +body {{ font-family: "DejaVu Sans", "Noto Sans", sans-serif; font-size: {size}pt; line-height: 1.5; color: #111; }} +h1 {{ font-size: {h1}pt; line-height: 1.15; margin: 0 0 4pt 0; }} +.meta {{ font-size: {meta}pt; color: #666; margin: 0 0 8pt 0; }} +h2 {{ font-size: {h2}pt; margin: 12pt 0 3pt 0; color: #222; }} +p {{ margin: 0 0 7pt 0; }} +figure {{ margin: 4pt 0 10pt 0; text-align: center; page-break-inside: avoid; }} +figure img {{ max-width: 100%; max-height: {imgh}mm; border: 1.5pt solid #333; }} +figcaption {{ font-size: {meta}pt; color: #555; margin-top: 3pt; font-style: italic; }} +.facts {{ border: 2pt dashed #444; border-radius: 8pt; padding: 8pt 12pt; margin: 12pt 0 0 0; page-break-inside: avoid; }} +.facts h2 {{ margin: 0 0 4pt 0; }} +.facts ul {{ margin: 0; padding-left: 16pt; }} +.facts li {{ margin-bottom: 3pt; }} """ -def _document(title: str, body_html: str, source: str, size: float) -> str: +def prepare_image(src: Path, dst: Path, max_w: int = 1400) -> Path | None: + """Pretvori sliku u sivu, pojačaj kontrast (mono laser) i smanji.""" + try: + img = Image.open(src) + img.seek(0) + img = ImageOps.exif_transpose(img).convert("L") + img = ImageOps.autocontrast(img, cutoff=1) + if img.width > max_w: + img = img.resize((max_w, int(img.height * max_w / img.width))) + img.save(dst, "PNG") + return dst + except Exception: # noqa: BLE001 + log.exception("slika %s se ne može obraditi", src) + return None + + +def _document(title: str, body_html: str, facts: list[str], caption: str, image: Path | None, + source: str, size: float, imgh: int) -> 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) + css = CSS.format(source=html.escape(source), date=date, size=size, h1=size * 2.1, h2=size * 1.3, + meta=size * 0.85, imgh=imgh) + fig = "" + if image: + fig = f"
{html.escape(caption)}
" + facts_html = "" + if facts: + items = "".join(f"
  • {html.escape(f)}
  • " for f in facts) + facts_html = f"

    Da li si znao?

    " return ( f"" f"

    {html.escape(title)}

    " f"
    Sažetak za tebe pripremio Idriz
    " - f"{body_html}" + f"{fig}{body_html}{facts_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.""" +def render_summary(title: str, body_html: str, facts: list[str], caption: str, image: Path | None, + source: str, out: Path, max_pages: int = 2) -> Path: + """Renderuje PDF; ako ne stane, prvo smanjuje sliku i font, 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() + for size, imgh in ((13, 110), (12.5, 100), (12, 90), (11.5, 80), (11, 70)): + doc = HTML(string=_document(title, body, facts, caption, image, source, size, imgh)).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("

    "), body.rfind("

  • ")) + cut = body.rfind("

    ") 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) + HTML(string=_document(title, body, facts, caption, image, source, 11, 70)).write_pdf(out) return out diff --git a/idriz/textgen.py b/idriz/textgen.py index 32cd122..e082929 100644 --- a/idriz/textgen.py +++ b/idriz/textgen.py @@ -1,6 +1,7 @@ """Prevod i sažimanje članka na bosanski, prilagođeno djetetu.""" import json import logging +from dataclasses import dataclass from openai import AsyncOpenAI @@ -9,19 +10,32 @@ 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. +Napiši kratak, zanimljiv sažetak na BOSANSKOM jeziku (ijekavica, latinica) koji dijete rado čita. 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. +- Prilagodi jezik uzrastu djeteta. Za mlađe: vrlo kratke rečenice, jednostavne riječi, obraćaj se djetetu sa "ti". Za starije: više činjenica, ali i dalje živo. +- Dužina: između 280 i 420 riječi. Nikako više. Ovo ide na papir sa slikom, mora biti prozračno. +- Struktura: naslov (kratak, može i sa uzvikom), uvod od 2-3 rečenice koji odmah privuče pažnju, zatim 3 kratka poglavlja sa podnaslovima u obliku pitanja ("Kako nastaje?", "Gdje ih ima?"), po 2-4 rečenice, i na kraju "Da li si znao?" sa tačno 3 kratke 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": "

    uvod

    Podnaslov

    ...

    ...

    Zanimljivosti

    "} -U "html" koristi samo oznake p, h2, ul, li, strong.""" +{"naslov": "...", + "html": "

    uvod

    Pitanje?

    ...

    ...", + "zanimljivosti": ["...", "...", "..."], + "slika_opis": "kratak opis slike na bosanskom, jedna rečenica (potpis ispod slike)", + "slika_prompt": "simple English description of one illustration for this topic, for a children's drawing"} +U "html" koristi samo oznake p, h2, strong. Ne stavljaj zanimljivosti u html, one idu posebno.""" -async def summarize(client: AsyncOpenAI, model: str, article: Article, topic: str, age: str) -> tuple[str, str]: +@dataclass +class Summary: + title: str + html: str + facts: list[str] + caption: str + image_prompt: str + + +async def summarize(client: AsyncOpenAI, model: str, article: Article, topic: str, age: str) -> Summary: user = ( f"Tema koju je dijete tražilo: {topic}\n" f"Uzrast djeteta: {age or 'nepoznat, pretpostavi 10 godina'}\n\n" @@ -32,9 +46,13 @@ async def summarize(client: AsyncOpenAI, model: str, article: Article, topic: st 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 "

    Nažalost, nisam uspio napisati sažetak.

    " - log.info("sažetak %r: %d znakova", title, len(html)) - return title, html + data = json.loads(resp.choices[0].message.content or "{}") + s = Summary( + title=data.get("naslov") or article.title, + html=data.get("html") or "

    Nažalost, nisam uspio napisati sažetak.

    ", + facts=[str(f) for f in data.get("zanimljivosti", [])][:3], + caption=data.get("slika_opis") or "", + image_prompt=data.get("slika_prompt") or article.title, + ) + log.info("sažetak %r: %d znakova, %d zanimljivosti", s.title, len(s.html), len(s.facts)) + return s diff --git a/idriz/wiki.py b/idriz/wiki.py index 2ed83bf..fa13899 100644 --- a/idriz/wiki.py +++ b/idriz/wiki.py @@ -67,3 +67,31 @@ def find_article(topic: str) -> Article | None: if art and len(art.text) > 400: return art return None + + +def page_image_url(title: str, width: int = 1400) -> str | None: + """Glavna slika članka (umanjena na `width` px), ako postoji.""" + r = requests.get( + API, + params={"action": "query", "prop": "pageimages", "piprop": "thumbnail|original", "pithumbsize": width, + "pilicense": "any", "redirects": 1, "titles": title, "format": "json"}, + headers=HEADERS, + timeout=20, + ) + r.raise_for_status() + for page in r.json().get("query", {}).get("pages", {}).values(): + for key in ("thumbnail", "original"): + src = (page.get(key) or {}).get("source", "") + clean = src.split("?", 1)[0] + if clean.lower().endswith((".jpg", ".jpeg", ".png", ".gif", ".webp")): + return clean + return None + + +def download(url: str, path) -> bool: + r = requests.get(url, headers=HEADERS, timeout=60) + if r.status_code != 200 or len(r.content) < 2000: + log.warning("slika nije preuzeta (%s): %s", r.status_code, url) + return False + path.write_bytes(r.content) + return True diff --git a/scripts/test_summary.py b/scripts/test_summary.py index c6c2b35..bc4a6bf 100644 --- a/scripts/test_summary.py +++ b/scripts/test_summary.py @@ -1,4 +1,4 @@ -"""Ručni test: Wikipedia -> (opciono) sažetak -> PDF. Bez ključa radi samo sa sirovim tekstom članka.""" +"""Ručni test: Wikipedia -> (opciono) sažetak -> PDF. Bez ključa radi sa sirovim tekstom članka i slikom sa Wikipedije.""" import asyncio import html import sys @@ -16,13 +16,23 @@ async def main(topic: str, age: str) -> None: print("nema članka"); return print("članak:", art.title, art.url, len(art.text), "znakova") out = Path("out"); out.mkdir(exist_ok=True) + url = wiki.page_image_url(art.title) + image = None + if url: + raw = out / ("test-wiki" + Path(url).suffix.lower()) + if wiki.download(url, raw): + image = render.prepare_image(raw, out / "test-img.png") + print("slika:", url, "->", image) 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) + s = await textgen.summarize(AsyncOpenAI(api_key=cfg.openai_api_key), cfg.text_model, art, topic, age) + title, body, facts, caption = s.title, s.html, s.facts, s.caption 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) + paras = [p for p in art.text.split("\n\n") if p and not p.startswith("==")][:5] + title, body = art.title, "".join(f"

    {html.escape(p[:600])}

    " for p in paras) + facts = ["Ovo je probna zanimljivost broj jedan.", "Druga zanimljivost, kratka.", "Treća, još kraća."] + caption = "Probni potpis ispod slike." + pdf = render.render_summary(title, body, facts, caption, image, art.title, out / "test-sazetak.pdf", cfg.max_pages) print("PDF:", pdf)