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).
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 -----------------------------------------------------------

View File

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

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 %}