6 Commits
Author SHA1 Message Date
iamdoubz 6eed7aaa60 Merge pull request 'Ollama settings' (#5) from ollama-settings into main
Release image / image (release) Successful in 3s
Reviewed-on: #5
2026-11-06 11:02:23 -06:00
iamdoubz fa5dc94293 Update changelog for 0.0.3 2026-11-06 11:01:25 -06:00
iamdoubz 9b1a0a99f2 Add Ollama top P, top K, thinking level, context size and keep-alive settings 2026-11-06 11:00:45 -06:00
iamdoubz fffbcbe293 Merge pull request 'Put the day's first menu line in Pack lunch notes' (#4) from pack-lunch-notes into main
Reviewed-on: #4
2026-11-06 09:31:15 -06:00
iamdoubz abbcc377ff Put the day's first menu line in Pack lunch notes 2026-11-06 09:30:49 -06:00
iamdoubz ea541e4a3e Print version and settings when the server starts 2026-11-06 09:23:59 -06:00
10 changed files with 88 additions and 11 deletions

No files matched your search

+18
View File
@@ -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
+3 -1
View File
@@ -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
View File
@@ -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
+3
View File
@@ -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"]
+5
View File
@@ -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` |
+17
View File
@@ -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)
+8 -4
View File
@@ -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
+22 -3
View File
@@ -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
View File
@@ -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:
+1 -1
View File
@@ -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"]