Nova aplikacija "Šta za ručak?" (obroci)

Glasovni prijedlozi doručka/ručka iz porodičnih lista (ne izmišlja, ne pretražuje web).
- idriz/apps/obroci/store.py: liste jela po obroku + istorija pojedenog (bez openai).
- manifest.py: alati obroci_prijedlog (nasumično sa liste; ponovni poziv za isti
  obrok daje drugo jelo jer se prethodno tretira kao odbijeno; izbjegava jela
  pojedena zadnja 3 dana) i obroci_dodaj (dodavanje jela glasom).
- Pamćenje pojedenog: zadnji NE-odbijeni prijedlog za dan/obrok se bilježi kao
  pojedeno (svaki prijedlog prepiše današnji zapis).
- admin.py + templates/obroci.html: dvije textarea (doručak/ručak) + istorija zadnjih dana.
- Registrovano u load_apps() i ADMIN_APPS.

Prvi app napravljen po CLAUDE.md obrascu.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LzKpYaEsuWDevuNnvSjcFW
This commit is contained in:
idriz
2026-09-11 14:35:51 +02:00
parent 00b4525588
commit fd30ff2b8a
7 changed files with 337 additions and 2 deletions

View File

@@ -19,10 +19,11 @@ from flask import (
# Admin stranice mini-app-ova (lagani moduli iz glavnog paketa, bez openai zavisnosti). # Admin stranice mini-app-ova (lagani moduli iz glavnog paketa, bez openai zavisnosti).
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) 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 from idriz.apps.superheroji import admin as _superheroji_admin # noqa: E402
# Svaki app-admin modul ima BP (Blueprint) i NAV (stavka lijevog menija). # Svaki app-admin modul ima BP (Blueprint) i NAV (stavka lijevog menija).
ADMIN_APPS = [_superheroji_admin] ADMIN_APPS = [_superheroji_admin, _obroci_admin]
# --- Konfiguracija ----------------------------------------------------------- # --- Konfiguracija -----------------------------------------------------------

View File

@@ -9,6 +9,7 @@ from .base import AppContext, AppSpec, AppTool # noqa: F401
def load_apps() -> list[AppSpec]: def load_apps() -> list[AppSpec]:
from .bojanka import manifest as bojanka from .bojanka import manifest as bojanka
from .obroci import manifest as obroci
from .sazetak import manifest as sazetak from .sazetak import manifest as sazetak
from .superheroji import manifest as superheroji from .superheroji import manifest as superheroji
return [sazetak.SPEC, bojanka.SPEC, superheroji.SPEC] return [sazetak.SPEC, bojanka.SPEC, superheroji.SPEC, obroci.SPEC]

View File

View File

@@ -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",
)

View File

@@ -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"),
],
)

133
idriz/apps/obroci/store.py Normal file
View File

@@ -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

View File

@@ -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 %}
<h1>Šta za ručak?</h1>
<p class="sub">Jela koja porodica jede. Jedna linija = jedno jelo. Idriz nasumično bira sa liste.</p>
<form method="post">
<div class="grid">
<div class="panel">
<h2>Doručak</h2>
<p class="hint">Jedno jelo po liniji.</p>
<textarea name="dorucak" placeholder="Npr.&#10;Jaja&#10;Musli&#10;Palačinke">{{ dorucak }}</textarea>
</div>
<div class="panel">
<h2>Ručak</h2>
<p class="hint">Jedno jelo po liniji.</p>
<textarea name="rucak" placeholder="Npr.&#10;Grah&#10;Sarma&#10;Pileći file">{{ rucak }}</textarea>
</div>
</div>
<button type="submit">Sačuvaj</button>
</form>
<h2 style="font-size:15px; margin: 26px 0 4px;">Zadnji dani</h2>
<p class="sub">Šta je Idriz zadnje predložio (pretpostavljeno pojedeno) — ovo izbjegava pri novim prijedlozima.</p>
{% if istorija %}
<table>
<thead><tr><th>Datum</th><th>Doručak</th><th>Ručak</th></tr></thead>
<tbody>
{% for r in istorija %}
<tr><td>{{ r.datum }}</td><td>{{ r.dorucak or "—" }}</td><td>{{ r.rucak or "—" }}</td></tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty">Još nema istorije.</p>
{% endif %}
{% endblock %}