diff --git a/admin/app.py b/admin/app.py index 756ec0e..dc4e1a9 100644 --- a/admin/app.py +++ b/admin/app.py @@ -19,10 +19,11 @@ from flask import ( # Admin stranice mini-app-ova (lagani moduli iz glavnog paketa, bez openai zavisnosti). sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from idriz.apps.obroci import admin as _obroci_admin # noqa: E402 from idriz.apps.superheroji import admin as _superheroji_admin # noqa: E402 # Svaki app-admin modul ima BP (Blueprint) i NAV (stavka lijevog menija). -ADMIN_APPS = [_superheroji_admin] +ADMIN_APPS = [_superheroji_admin, _obroci_admin] # --- Konfiguracija ----------------------------------------------------------- diff --git a/idriz/apps/__init__.py b/idriz/apps/__init__.py index c039899..d406157 100644 --- a/idriz/apps/__init__.py +++ b/idriz/apps/__init__.py @@ -9,6 +9,7 @@ from .base import AppContext, AppSpec, AppTool # noqa: F401 def load_apps() -> list[AppSpec]: from .bojanka import manifest as bojanka + from .obroci import manifest as obroci from .sazetak import manifest as sazetak from .superheroji import manifest as superheroji - return [sazetak.SPEC, bojanka.SPEC, superheroji.SPEC] + return [sazetak.SPEC, bojanka.SPEC, superheroji.SPEC, obroci.SPEC] diff --git a/idriz/apps/obroci/__init__.py b/idriz/apps/obroci/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/idriz/apps/obroci/admin.py b/idriz/apps/obroci/admin.py new file mode 100644 index 0000000..7aa5b53 --- /dev/null +++ b/idriz/apps/obroci/admin.py @@ -0,0 +1,27 @@ +"""Admin stranica za 'Šta za ručak?': dvije textarea (doručak/ručak) + istorija. + +Flask Blueprint, bez openai zavisnosti (uvozi ga admin proces). +""" +from flask import Blueprint, flash, redirect, render_template, request, url_for + +from . import store + +BP = Blueprint("obroci", __name__, template_folder="templates", url_prefix="/obroci") +NAV = {"endpoint": "obroci.index", "page": "obroci", "title": "Šta za ručak?", "icon": "🍽"} + + +@BP.route("/", methods=["GET", "POST"]) +def index(): + if request.method == "POST": + d = store.set_list("dorucak", request.form.get("dorucak", "")) + r = store.set_list("rucak", request.form.get("rucak", "")) + flash(f"Sačuvano: {len(d)} jela za doručak, {len(r)} za ručak.") + return redirect(url_for("obroci.index")) + + return render_template( + "obroci.html", + dorucak="\n".join(store.get_list("dorucak")), + rucak="\n".join(store.get_list("rucak")), + istorija=store.history_recent(store.HISTORY_DAYS), + active_page="obroci", + ) diff --git a/idriz/apps/obroci/manifest.py b/idriz/apps/obroci/manifest.py new file mode 100644 index 0000000..6106242 --- /dev/null +++ b/idriz/apps/obroci/manifest.py @@ -0,0 +1,117 @@ +"""Mini-app 'Šta za ručak?': nasumičan prijedlog doručka/ručka iz porodičnih lista. + +Ne izmišlja jela i ne pretražuje internet — bira samo sa lista koje se uređuju u +admin panelu ili se dopunjavaju glasom. Pamti šta je pojedeno zadnjih dana tako što +pretpostavi da je zadnji NE-odbijeni prijedlog za taj dan/obrok ono što je pojedeno. +""" +import datetime as dt +import logging +import random + +from ..base import AppContext, AppSpec, AppTool +from . import store + +log = logging.getLogger("idriz.obroci") + +INSTRUCTIONS = """ŠTA ZA RUČAK (alati: obroci_prijedlog, obroci_dodaj): predlažeš šta jesti za doručak ili ručak. +- NE izmišljaj jela i ne pretražuj internet. Prijedlog uvijek daje alat sa porodične liste. +- Kad dijete pita šta da jede za doručak (ili ručak), pozovi obroci_prijedlog sa obrok="dorucak" (ili "rucak"). Reci mu koje jelo je alat predložio. +- Ako dijete kaže da NEĆE to jelo (ili traži nešto drugo), ponovo pozovi obroci_prijedlog za ISTI obrok — alat će dati drugo jelo (neće ponoviti odbijeno). +- Ako alat kaže da je lista prazna, reci djetetu da doda jelo (npr. "dodaj jaja u doručak") ili da se doda u admin panelu. Ne izmišljaj jelo sam. +- Ako dijete traži da doda/zapamti jelo (npr. "dodaj sarmu u ručak", "zapamti da jedemo palačinke za doručak"), pozovi obroci_dodaj sa obrok i jelo, pa kratko potvrdi. +- Za ovaj alat ne treba posebna potvrda kao za štampanje — samo vodi razgovor prirodno.""" + +PRIJEDLOG_SCHEMA = { + "type": "function", + "name": "obroci_prijedlog", + "description": "Nasumično predloži jelo za doručak ili ručak sa porodične liste. Pozovi ponovo za isti obrok ako dijete odbije prijedlog (dat će drugo jelo).", + "parameters": { + "type": "object", + "properties": { + "obrok": {"type": "string", "enum": ["dorucak", "rucak"], "description": "Za koji obrok: 'dorucak' ili 'rucak'."}, + }, + "required": ["obrok"], + }, +} + +DODAJ_SCHEMA = { + "type": "function", + "name": "obroci_dodaj", + "description": "Dodaje jelo u porodičnu listu za doručak ili ručak (kad dijete kaže da ga zapamti/doda).", + "parameters": { + "type": "object", + "properties": { + "obrok": {"type": "string", "enum": ["dorucak", "rucak"], "description": "Za koji obrok: 'dorucak' ili 'rucak'."}, + "jelo": {"type": "string", "description": "Naziv jela kako ga je dijete reklo."}, + }, + "required": ["obrok", "jelo"], + }, +} + + +def _state(ctx: AppContext) -> dict: + """Per-razgovor stanje za danas: odbijena jela i zadnji prijedlog po obroku.""" + s = ctx.get_state("obroci") + today = dt.date.today().isoformat() + if not s or s.get("date") != today: + s = {"date": today, "rejected": {"dorucak": set(), "rucak": set()}, + "last": {"dorucak": None, "rucak": None}} + ctx.set_state("obroci", s) + return s + + +async def prijedlog(ctx: AppContext, args: dict) -> str: + meal = (args.get("obrok") or "").strip().lower() + if not store.valid_meal(meal): + return "Nisam siguran je li za doručak ili ručak — pitaj dijete da pojasni." + label = store.MEALS[meal] + lst = store.get_list(meal) + if not lst: + return (f"Lista za {label} je prazna. Reci djetetu da doda jelo (npr. \"dodaj palačinke u {label}\") " + f"ili da se doda u admin panelu. NE izmišljaj jelo.") + s = _state(ctx) + # ponovni poziv za isti obrok = prethodni prijedlog je odbijen + last = s["last"][meal] + if last: + s["rejected"][meal].add(last) + rejected = s["rejected"][meal] + recent = store.recent_eaten(meal, store.HISTORY_DAYS) + + def pool(exclude): + return [d for d in lst if d not in exclude] + + cand = pool(rejected | recent) or pool(rejected) or lst + pick = random.choice(cand) + s["last"][meal] = pick + ctx.set_state("obroci", s) + # posljednji prijedlog danas = pretpostavljeno pojedeno + store.record_eaten(s["date"], meal, pick) + log.info("prijedlog %s: %r (odbijeno danas: %s)", meal, pick, sorted(rejected)) + uvod = "Onda" if last else f"Za {label} danas" + return (f"{uvod}: {pick}. Reci to djetetu veselo. " + f'Ako kaže da neće, ponovo pozovi obroci_prijedlog za "{meal}".') + + +async def dodaj(ctx: AppContext, args: dict) -> str: + meal = (args.get("obrok") or "").strip().lower() + jelo = (args.get("jelo") or "").strip() + if not store.valid_meal(meal): + return "Nije jasno je li za doručak ili ručak — pitaj dijete da pojasni." + if not jelo: + return "Nisam čuo koje jelo, pitaj dijete da ponovi." + label = store.MEALS[meal] + if store.add_dish(meal, jelo): + return f'Dodao sam "{jelo}" u listu za {label}. Potvrdi to djetetu kratko.' + return f'"{jelo}" je već na listi za {label}. Reci to djetetu.' + + +SPEC = AppSpec( + name="obroci", + title="Šta za ručak?", + instructions=INSTRUCTIONS, + greeting="predložiti šta jesti za doručak ili ručak", + tools=[ + AppTool(PRIJEDLOG_SCHEMA, prijedlog, "inline"), + AppTool(DODAJ_SCHEMA, dodaj, "inline"), + ], +) diff --git a/idriz/apps/obroci/store.py b/idriz/apps/obroci/store.py new file mode 100644 index 0000000..a0b02ed --- /dev/null +++ b/idriz/apps/obroci/store.py @@ -0,0 +1,133 @@ +"""Skladište za app 'Šta za ručak?': liste jela (doručak/ručak) + istorija pojedenog. + +Bez vanjskih zavisnosti (uvozi ga i admin). Podaci u ~/.local/share/idriz/obroci.json. +""" +import datetime as dt +import json +import os +import tempfile +import threading +from pathlib import Path + +OBROCI_PATH = Path(os.environ.get("IDRIZ_OBROCI_PATH", str(Path.home() / ".local/share/idriz/obroci.json"))) + +MEALS = {"dorucak": "doručak", "rucak": "ručak"} # ključ -> lijep naziv +HISTORY_DAYS = 3 # koliko zadnjih dana pamtimo/izbjegavamo + +_lock = threading.Lock() + + +def _atomic_write(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + os.replace(tmp, path) + finally: + if os.path.exists(tmp): + os.unlink(tmp) + + +def _load() -> dict: + try: + data = json.loads(OBROCI_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + data = {} + data.setdefault("dorucak", []) + data.setdefault("rucak", []) + data.setdefault("istorija", {}) # "YYYY-MM-DD" -> {"dorucak": jelo, "rucak": jelo} + return data + + +def _save(data: dict) -> None: + with _lock: + _atomic_write(OBROCI_PATH, data) + + +def _clean_list(items) -> list[str]: + """Normalizuj listu jela: strip, izbaci prazne, ukloni duplikate (case-insensitive), zadrži redoslijed.""" + if isinstance(items, str): + items = items.splitlines() + out, seen = [], set() + for it in items: + s = (it or "").strip() + if s and s.lower() not in seen: + seen.add(s.lower()) + out.append(s) + return out + + +def valid_meal(meal: str) -> bool: + return meal in MEALS + + +def get_list(meal: str) -> list[str]: + return list(_load().get(meal, [])) + + +def set_list(meal: str, items) -> list[str]: + if not valid_meal(meal): + return [] + data = _load() + data[meal] = _clean_list(items) + _save(data) + return data[meal] + + +def add_dish(meal: str, dish: str) -> bool: + """Dodaj jelo ako ga nema. Vrati True ako je dodano, False ako je već postojalo.""" + if not valid_meal(meal): + return False + dish = (dish or "").strip() + if not dish: + return False + data = _load() + lst = data.get(meal, []) + if any(dish.lower() == d.lower() for d in lst): + return False + lst.append(dish) + data[meal] = lst + _save(data) + return True + + +def record_eaten(date_str: str, meal: str, dish: str) -> None: + """Zabilježi šta je pojedeno za dati dan/obrok (posljednji prijedlog = pojedeno).""" + if not valid_meal(meal): + return + data = _load() + day = data["istorija"].setdefault(date_str, {}) + day[meal] = dish + # očisti staru istoriju (drži ~30 dana) + keys = sorted(data["istorija"].keys()) + for k in keys[:-30]: + del data["istorija"][k] + _save(data) + + +def recent_eaten(meal: str, days: int = HISTORY_DAYS) -> set[str]: + """Skup jela pojedenih za dati obrok u zadnjih `days` kalendarskih dana (uključivo danas).""" + if not valid_meal(meal): + return set() + today = dt.date.today() + cutoff = today - dt.timedelta(days=days - 1) + out = set() + for date_str, day in _load().get("istorija", {}).items(): + try: + d = dt.date.fromisoformat(date_str) + except ValueError: + continue + if cutoff <= d <= today and day.get(meal): + out.add(day[meal]) + return out + + +def history_recent(n: int = HISTORY_DAYS) -> list[dict]: + """Za admin: zadnjih n dana istorije, najnoviji prvo.""" + data = _load() + rows = [] + for date_str in sorted(data.get("istorija", {}).keys(), reverse=True)[:n]: + day = data["istorija"][date_str] + rows.append({"datum": date_str, "dorucak": day.get("dorucak", ""), "rucak": day.get("rucak", "")}) + return rows diff --git a/idriz/apps/obroci/templates/obroci.html b/idriz/apps/obroci/templates/obroci.html new file mode 100644 index 0000000..408de2d --- /dev/null +++ b/idriz/apps/obroci/templates/obroci.html @@ -0,0 +1,56 @@ +{% extends "base.html" %} +{% block title %}Šta za ručak? — Idriz Admin{% endblock %} +{% block extra_css %} + .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; max-width: 720px; } + @media (max-width: 640px) { .grid { grid-template-columns: 1fr; } } + .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 16px 18px; } + .panel h2 { margin: 0 0 4px; font-size: 15px; } + .panel p.hint { color: var(--muted); font-size: 12.5px; margin: 0 0 12px; } + textarea { width: 100%; min-height: 220px; padding: 10px 12px; font-size: 14px; line-height: 1.5; + border: 1px solid var(--line); border-radius: 8px; resize: vertical; font-family: inherit; } + textarea:focus { outline: none; border-color: var(--accent); } + button { margin-top: 16px; padding: 10px 20px; font-size: 14px; font-weight: 600; color: #fff; + background: var(--accent); border: none; border-radius: 8px; cursor: pointer; } + button:hover { filter: brightness(1.05); } + table { width: 100%; border-collapse: collapse; background: var(--panel); max-width: 720px; + border: 1px solid var(--line); border-radius: 10px; overflow: hidden; margin-top: 8px; } + th, td { text-align: left; padding: 9px 14px; font-size: 14px; } + th { background: #fafbfc; color: var(--muted); font-weight: 600; border-bottom: 1px solid var(--line); } + tr:not(:last-child) td { border-bottom: 1px solid var(--line); } + .empty { color: var(--muted); font-style: italic; } +{% endblock %} +{% block content %} +
Jela koja porodica jede. Jedna linija = jedno jelo. Idriz nasumično bira sa liste.
+ + + +Šta je Idriz zadnje predložio (pretpostavljeno pojedeno) — ovo izbjegava pri novim prijedlozima.
+ {% if istorija %} +| Datum | Doručak | Ručak |
|---|---|---|
| {{ r.datum }} | {{ r.dorucak or "—" }} | {{ r.rucak or "—" }} |
Još nema istorije.
+ {% endif %} +{% endblock %}