Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6eed7aaa60 | ||
|
|
fa5dc94293 | ||
|
|
9b1a0a99f2 | ||
|
|
fffbcbe293 | ||
|
|
abbcc377ff | ||
|
|
ea541e4a3e |
No files matched your search
@@ -16,6 +16,24 @@ OLLAMA_URL=http://localhost:11434
|
||||
EXTRACT_MODEL=nemotron-3.5-bolt:e4b
|
||||
# ANTHROPIC_API_KEY=
|
||||
|
||||
# Ollama settings. Commented-out lines show the default.
|
||||
# Sampling: leave top P and top K unset to use the model's own.
|
||||
# EXTRACT_TEMPERATURE=0
|
||||
# EXTRACT_TOP_P=0.9
|
||||
# EXTRACT_TOP_K=40
|
||||
# Context size in tokens; raise it if a dense page or thinking runs out of room.
|
||||
# EXTRACT_NUM_CTX=16384
|
||||
# How long Ollama keeps the model loaded after a scan: 10m, 1h, 0 to unload right away, -1 forever.
|
||||
# EXTRACT_KEEP_ALIVE=10m
|
||||
# 1 lets the model reason before answering (slower, needs more context).
|
||||
# EXTRACT_THINK=0
|
||||
# Only with EXTRACT_THINK=1, for models that have levels: low, medium or high.
|
||||
# EXTRACT_THINK_LEVEL=medium
|
||||
|
||||
# 1 reads every page twice and flags where the reads disagree, for the command-line eval in phase0/.
|
||||
# In the app, each source's "double read" setting decides instead.
|
||||
# EXTRACT_DOUBLE_READ=0
|
||||
|
||||
# Labels that are never food or events, for every source (each source can add its own).
|
||||
# EXTRACT_IGNORE=BHS
|
||||
|
||||
|
||||
@@ -38,5 +38,7 @@ jobs:
|
||||
image="$registry/${REPO,,}" # registry paths must be lowercase
|
||||
echo "$REGISTRY_TOKEN" | docker login "$registry" -u "$OWNER" --password-stdin
|
||||
|
||||
docker build --label "org.opencontainers.image.version=$version" "${tags[@]/#/--tag=$image:}" .
|
||||
docker build --label "org.opencontainers.image.version=$version" \
|
||||
--build-arg "VERSION=$version" --build-arg "COMMIT=$(git rev-parse --short HEAD)" \
|
||||
--build-arg "COMMIT_DATE=$(git log -1 --format=%cs)" "${tags[@]/#/--tag=$image:}" .
|
||||
for t in "${tags[@]}"; do docker push "$image:$t"; done
|
||||
+10
-1
@@ -5,6 +5,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.0.3] - 2026-11-06
|
||||
|
||||
### Added
|
||||
|
||||
- The server prints its version, commit and commit date, then its settings, when it starts (the API key only as set or unset).
|
||||
- Ollama settings `EXTRACT_TOP_P`, `EXTRACT_TOP_K`, `EXTRACT_THINK_LEVEL` (low, medium or high, with `EXTRACT_THINK=1`), `EXTRACT_NUM_CTX` (default 16384) and `EXTRACT_KEEP_ALIVE` (default 10m).
|
||||
- A "Pack lunch" event's notes are the day's first menu line, for example "Orange Chicken".
|
||||
|
||||
## [0.0.2] - 2026-11-06
|
||||
|
||||
### Added
|
||||
@@ -40,6 +48,7 @@ First test release: the Phase 1 MVP.
|
||||
- Runs with plain Python or as a Docker image, with SQLite and a `backup` command.
|
||||
- Release workflow that pushes the image to the Gitea container registry: stable releases tag `X.Y.Z`, `X.Y`, `X` and `latest`; pre-releases tag `X.Y.Z` and `dev`.
|
||||
|
||||
[Unreleased]: https://git.dou.bet/iamdoubz/parentcalendar/compare/v0.0.2...main
|
||||
[Unreleased]: https://git.dou.bet/iamdoubz/parentcalendar/compare/v0.0.3...main
|
||||
[0.0.3]: https://git.dou.bet/iamdoubz/parentcalendar/compare/v0.0.2...v0.0.3
|
||||
[0.0.2]: https://git.dou.bet/iamdoubz/parentcalendar/compare/v0.0.1...v0.0.2
|
||||
[0.0.1]: https://git.dou.bet/iamdoubz/parentcalendar/src/tag/v0.0.1
|
||||
@@ -19,4 +19,7 @@ RUN useradd --system --uid 1000 parentcalendar && mkdir -p /data && chown parent
|
||||
USER parentcalendar
|
||||
VOLUME /data
|
||||
EXPOSE 8000
|
||||
# Set by the release workflow; printed when the server starts. Last so a new version reuses the cached layers.
|
||||
ARG VERSION=dev COMMIT=unknown COMMIT_DATE=unknown
|
||||
ENV APP_VERSION=$VERSION APP_COMMIT=$COMMIT APP_COMMIT_DATE=$COMMIT_DATE
|
||||
CMD ["python", "main.py"]
|
||||
@@ -96,6 +96,11 @@ Both include `python main.py backup <destination>`, which runs SQLite's online b
|
||||
| `DATA_DIR` | Database and images; must be a local disk | `./data` when run directly, `/data` in Docker |
|
||||
| `OLLAMA_URL` | Ollama server for the extraction model | `http://localhost:11434` |
|
||||
| `EXTRACT_MODEL` | Ollama model name, or a `claude-*` model to use the Anthropic API instead | `nemotron-3.5-bolt:e4b` |
|
||||
| `EXTRACT_TEMPERATURE`, `EXTRACT_TOP_P`, `EXTRACT_TOP_K` | Ollama sampling settings. Top P and top K fall back to the model's own when not set | `0`, not set, not set |
|
||||
| `EXTRACT_NUM_CTX` | Ollama context size in tokens. Raise it if a dense page or thinking runs out of room | `16384` |
|
||||
| `EXTRACT_KEEP_ALIVE` | How long Ollama keeps the model loaded after a call: `10m`, `1h`, `0` to unload, `-1` forever | `10m` |
|
||||
| `EXTRACT_THINK` | `1` to let an Ollama model reason before answering | `0` |
|
||||
| `EXTRACT_THINK_LEVEL` | `low`, `medium` or `high`, for models with thinking levels. Only used with `EXTRACT_THINK=1` | Not set |
|
||||
| `ANTHROPIC_API_KEY` | Only needed for a `claude-*` model | Not set |
|
||||
| `EXTRACT_IGNORE` | Comma-separated labels dropped from every source's menu items and event titles. Each source can add its own in `ignore_labels` | Not set; for example `BHS,Milk` |
|
||||
| `HOST`, `PORT` | Bind address | `127.0.0.1`, `8000` |
|
||||
|
||||
@@ -4,6 +4,7 @@ Usage:
|
||||
python main.py start the server (settings: see .env.example)
|
||||
python main.py backup <destination> copy the database (SQLite online backup) and the photos
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import sys
|
||||
@@ -35,6 +36,22 @@ def serve():
|
||||
sys.exit("The front end isn't built yet. Run: cd frontend && npm ci && npm run build")
|
||||
import uvicorn
|
||||
|
||||
from parentcalendar import extraction
|
||||
|
||||
env = os.environ.get
|
||||
print(f"ParentCalendar {env('APP_VERSION', 'dev')} ({env('APP_COMMIT', 'unknown')}, {env('APP_COMMIT_DATE', 'unknown')})")
|
||||
settings = { # what the app actually uses, defaults included
|
||||
"PUBLIC_URL": config.PUBLIC_URL, "DATA_DIR": config.DATA_DIR, "HOST": config.HOST, "PORT": config.PORT,
|
||||
"OLLAMA_URL": extraction.OLLAMA_URL, "EXTRACT_MODEL": extraction.MODEL, "EXTRACT_THINK": int(extraction.THINK),
|
||||
"EXTRACT_THINK_LEVEL": extraction.THINK_LEVEL or "", "EXTRACT_TEMPERATURE": extraction.TEMPERATURE,
|
||||
"EXTRACT_TOP_P": "" if extraction.TOP_P is None else extraction.TOP_P,
|
||||
"EXTRACT_TOP_K": "" if extraction.TOP_K is None else extraction.TOP_K,
|
||||
"EXTRACT_NUM_CTX": extraction.NUM_CTX, "EXTRACT_KEEP_ALIVE": extraction.KEEP_ALIVE, "EXTRACT_DOUBLE_READ": int(extraction.DOUBLE_READ),
|
||||
"EXTRACT_IGNORE": ",".join(sorted(extraction.IGNORE)), "IMAGE_RETENTION_DAYS": config.IMAGE_RETENTION_DAYS,
|
||||
"ANTHROPIC_API_KEY": "set" if env("ANTHROPIC_API_KEY") else "unset", # never print the key
|
||||
}
|
||||
print("Settings: " + " ".join(f"{k}={v}" for k, v in settings.items()))
|
||||
|
||||
uvicorn.run("parentcalendar.app:app", host=config.HOST, port=config.PORT, proxy_headers=True)
|
||||
|
||||
|
||||
|
||||
@@ -541,6 +541,7 @@ class PicksIn(BaseModel):
|
||||
|
||||
def set_pick(conn, child, day, picked):
|
||||
"""A pick is a 'Pack lunch' event for that child: picking creates or restores it (same UID), unpicking deletes it.
|
||||
The event's notes are the menu's first line (the main dish), so the calendar shows what's being skipped.
|
||||
Returns False when picking a day with no school lunch (no menu, no school, or no lunch)."""
|
||||
entry = next(iter(lunch_days(conn, child["id"], day, day)), None)
|
||||
if picked and (entry is None or entry["greyed"]):
|
||||
@@ -548,13 +549,16 @@ def set_pick(conn, child, day, picked):
|
||||
existing = db.one(conn, "SELECT e.* FROM event e JOIN event_child ec ON ec.event_id = e.id WHERE ec.child_id = ?"
|
||||
" AND e.kind = 'pack_lunch' AND e.date = ? ORDER BY e.id LIMIT 1", (child["id"], day.isoformat()))
|
||||
status = "confirmed" if picked else "deleted"
|
||||
notes = next((m["items"][0] for m in entry["menus"] if m["items"]), None) if entry else None
|
||||
if existing and existing["status"] != status:
|
||||
conn.execute("UPDATE event SET status = ?, sequence = sequence + 1, updated_at = strftime('%Y-%m-%d %H:%M:%f', 'now')"
|
||||
" WHERE id = ?", (status, existing["id"]))
|
||||
# Re-picking refreshes the notes from the menu, in case it was fixed since.
|
||||
conn.execute("UPDATE event SET status = ?, notes = COALESCE(?, notes), sequence = sequence + 1,"
|
||||
" updated_at = strftime('%Y-%m-%d %H:%M:%f', 'now') WHERE id = ?", (status, notes if picked else None, existing["id"]))
|
||||
elif not existing and picked:
|
||||
event_id = conn.execute(
|
||||
"INSERT INTO event (uid, source_id, date, all_day, title, kind, status) VALUES (?, ?, ?, 1, ?, 'pack_lunch', 'confirmed')",
|
||||
(scans.new_uid(), entry["menus"][0]["source_id"], day.isoformat(), f"Pack lunch — {child['display_name']}")).lastrowid
|
||||
"INSERT INTO event (uid, source_id, date, all_day, title, notes, kind, status)"
|
||||
" VALUES (?, ?, ?, 1, ?, ?, 'pack_lunch', 'confirmed')",
|
||||
(scans.new_uid(), entry["menus"][0]["source_id"], day.isoformat(), f"Pack lunch — {child['display_name']}", notes)).lastrowid
|
||||
conn.execute("INSERT INTO event_child VALUES (?, ?)", (event_id, child["id"]))
|
||||
return True
|
||||
|
||||
|
||||
@@ -6,7 +6,12 @@ Settings (environment variables):
|
||||
EXTRACT_MODEL Ollama model, or a claude-* model for the Anthropic API (default nemotron-3.5-bolt:e4b)
|
||||
OLLAMA_URL Ollama server (default http://localhost:11434)
|
||||
EXTRACT_THINK 1 to let an Ollama model reason before answering (default 0)
|
||||
EXTRACT_THINK_LEVEL low, medium or high, for models with thinking levels; only used with EXTRACT_THINK=1
|
||||
(default not set: plain on)
|
||||
EXTRACT_TEMPERATURE Ollama sampling temperature (default 0)
|
||||
EXTRACT_TOP_P, EXTRACT_TOP_K Ollama sampling top_p (0-1) and top_k (whole number) (default not set: the model's own)
|
||||
EXTRACT_NUM_CTX Ollama context size in tokens; raise it if a dense page or thinking runs out of room (default 16384)
|
||||
EXTRACT_KEEP_ALIVE how long Ollama keeps the model loaded after a call: 10m, 1h, 0 to unload, -1 forever (default 10m)
|
||||
EXTRACT_DOUBLE_READ 1 to read every page twice and flag where the reads disagree (default 0;
|
||||
in the app each source's double_read setting decides)
|
||||
EXTRACT_IGNORE comma-separated labels to drop from menu items and event titles, e.g. "BHS,Milk" (default none)
|
||||
@@ -40,6 +45,16 @@ MODEL = os.environ.get("EXTRACT_MODEL", "nemotron-3.5-bolt:e4b")
|
||||
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434").rstrip("/")
|
||||
THINK = os.environ.get("EXTRACT_THINK") == "1"
|
||||
TEMPERATURE = float(os.environ.get("EXTRACT_TEMPERATURE", "0"))
|
||||
TOP_P = float(os.environ["EXTRACT_TOP_P"]) if os.environ.get("EXTRACT_TOP_P") else None
|
||||
TOP_K = int(os.environ["EXTRACT_TOP_K"]) if os.environ.get("EXTRACT_TOP_K") else None
|
||||
NUM_CTX = int(os.environ.get("EXTRACT_NUM_CTX") or 16384)
|
||||
KEEP_ALIVE = os.environ.get("EXTRACT_KEEP_ALIVE", "").strip() or "10m"
|
||||
THINK_LEVEL = os.environ.get("EXTRACT_THINK_LEVEL", "").strip().lower() or None
|
||||
if THINK_LEVEL not in (None, "low", "medium", "high"):
|
||||
raise SystemExit(f"EXTRACT_THINK_LEVEL must be low, medium or high, not {THINK_LEVEL!r}")
|
||||
OLLAMA_THINK = THINK_LEVEL if THINK and THINK_LEVEL else THINK # what Ollama's "think" field gets
|
||||
OLLAMA_OPTIONS = {"num_ctx": NUM_CTX, "temperature": TEMPERATURE,
|
||||
**({"top_p": TOP_P} if TOP_P is not None else {}), **({"top_k": TOP_K} if TOP_K is not None else {})}
|
||||
DOUBLE_READ = os.environ.get("EXTRACT_DOUBLE_READ") == "1"
|
||||
IGNORE = {w.strip().lower() for w in os.environ.get("EXTRACT_IGNORE", "").split(",") if w.strip()}
|
||||
EFFORT = "high" # Claude only
|
||||
@@ -283,8 +298,9 @@ def call_claude(image, message, system, schema):
|
||||
|
||||
def call_ollama(image, message, system, schema):
|
||||
body = {
|
||||
"model": MODEL, "stream": False, "think": THINK, "format": schema,
|
||||
"options": {"num_ctx": 16384, "temperature": TEMPERATURE}, # ponytail: raise num_ctx if a dense page or thinking runs out of room
|
||||
"model": MODEL, "stream": False, "think": OLLAMA_THINK, "format": schema, "options": OLLAMA_OPTIONS,
|
||||
# Ollama wants a number for bare seconds ("300" isn't a duration), a string for "10m".
|
||||
"keep_alive": int(KEEP_ALIVE) if KEEP_ALIVE.lstrip("-").isdigit() else KEEP_ALIVE,
|
||||
"messages": [{"role": "system", "content": system},
|
||||
{"role": "user", "content": message, "images": [image]}],
|
||||
}
|
||||
@@ -489,8 +505,11 @@ def extract_image(img, hint, double_read=None, ignore=None):
|
||||
return {
|
||||
"model": model,
|
||||
"method": method + (" x2" if double_read else ""),
|
||||
"think": THINK if call is call_ollama else None,
|
||||
"think": OLLAMA_THINK if call is call_ollama else None,
|
||||
"temperature": TEMPERATURE if call is call_ollama else None,
|
||||
"top_p": TOP_P if call is call_ollama else None,
|
||||
"top_k": TOP_K if call is call_ollama else None,
|
||||
"num_ctx": NUM_CTX if call is call_ollama else None,
|
||||
"seconds": round(time.time() - started, 1),
|
||||
"usage": {"input_tokens": tokens[0], "output_tokens": tokens[1]},
|
||||
"result": result,
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ def main(names):
|
||||
cases = [GOLDEN / f"{n}.json" for n in names] or sorted(GOLDEN.glob("*.json"))
|
||||
out = RUNS / datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
out.mkdir(parents=True)
|
||||
ollama = "" if x.MODEL.startswith("claude") else f" at {x.OLLAMA_URL}, think={x.THINK}, temperature={x.TEMPERATURE}"
|
||||
ollama = "" if x.MODEL.startswith("claude") else f" at {x.OLLAMA_URL}, think={x.OLLAMA_THINK}, temperature={x.TEMPERATURE}, top_p={x.TOP_P}, top_k={x.TOP_K}, num_ctx={x.NUM_CTX}, keep_alive={x.KEEP_ALIVE}"
|
||||
print(f"Model: {x.MODEL}{ollama}")
|
||||
tokens_in = tokens_out = 0
|
||||
for path in cases:
|
||||
|
||||
@@ -158,7 +158,7 @@ def test():
|
||||
days = c.post("/api/lunch", json={"child_id": sam, "date": "2026-10-05", "picked": True}).json()
|
||||
assert next(d for d in days if d["date"] == "2026-10-05")["picked"] is True
|
||||
pack = [e for e in c.get("/api/events", params={"calendar_id": cal}).json() if e["kind"] == "pack_lunch"]
|
||||
assert len(pack) == 1 and pack[0]["title"] == "Pack lunch — Sam" and pack[0]["all_day"] is True
|
||||
assert len(pack) == 1 and pack[0]["title"] == "Pack lunch — Sam" and pack[0]["all_day"] is True and pack[0]["notes"] == "Pork Chopette"
|
||||
c.post("/api/lunch", json={"child_id": sam, "date": "2026-10-05", "picked": False})
|
||||
c.post("/api/lunch", json={"child_id": sam, "date": "2026-10-05", "picked": True})
|
||||
pack2 = [e for e in c.get("/api/events", params={"calendar_id": cal}).json() if e["kind"] == "pack_lunch"]
|
||||
|
||||
Reference in new issue
Block a user