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:
@@ -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 -----------------------------------------------------------
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
0
idriz/apps/obroci/__init__.py
Normal file
0
idriz/apps/obroci/__init__.py
Normal file
27
idriz/apps/obroci/admin.py
Normal file
27
idriz/apps/obroci/admin.py
Normal 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",
|
||||
)
|
||||
117
idriz/apps/obroci/manifest.py
Normal file
117
idriz/apps/obroci/manifest.py
Normal 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
133
idriz/apps/obroci/store.py
Normal 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
|
||||
56
idriz/apps/obroci/templates/obroci.html
Normal file
56
idriz/apps/obroci/templates/obroci.html
Normal 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. Jaja Musli 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. Grah Sarma 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 %}
|
||||
Reference in New Issue
Block a user