Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6eed7aaa60 | ||
|
|
fa5dc94293 | ||
|
|
9b1a0a99f2 | ||
|
|
fffbcbe293 | ||
|
|
abbcc377ff | ||
|
|
ea541e4a3e | ||
|
|
588133ee5a | ||
|
|
1d7d422a80 | ||
|
|
ce1eb8b201 | ||
|
|
1a61a72527 | ||
|
|
c8d7a7aa80 | ||
|
|
4268e17dea | ||
|
|
98ef76bc1e | ||
|
|
42d5be9dfb | ||
|
|
975913315e | ||
|
|
69d6f5731c | ||
|
|
92553ebd88 |
No files matched your search
@@ -10,10 +10,30 @@ HOST=127.0.0.1
|
||||
PORT=8000
|
||||
|
||||
# Extraction model: an Ollama model, or a claude-* model (then set ANTHROPIC_API_KEY).
|
||||
# In Docker, localhost is the container. Use the Ollama machine's address, or http://host.docker.internal:11434
|
||||
# when Ollama runs on the Docker host (start Ollama with OLLAMA_HOST=0.0.0.0 so it accepts the connection).
|
||||
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
|
||||
+30
-1
@@ -5,6 +5,33 @@ 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
|
||||
|
||||
- Food emoji in each lunch day's header, picked from the day's menu (🐔🌽🍐 for chicken, corn and pears).
|
||||
- Read 1 / Read 2 in review: when a double read disagrees on a menu day, both readings are kept so you can switch between them before saving. Needs database migration 002, which runs on start.
|
||||
- Menus can be fixed after publishing, from the scan page. Changes show on the Lunch tab right away.
|
||||
- `npm test` in `frontend/` checks the food emoji rules.
|
||||
|
||||
### Changed
|
||||
|
||||
- Marking a menu day "No lunch this day" keeps its food, so unticking it brings the text back.
|
||||
- A failed scan that can't reach Ollama now says so, and how to point `OLLAMA_URL` at it from Docker. The example settings and Compose file explain `host.docker.internal`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Week-row crops no longer include the top of the next week's text, which the model misread, for example as an extra "End of Q1" event.
|
||||
- Words in a stray alphabet are dropped from what the model reads. A Sept/Oct menu came back with "выда" after the last food of every day.
|
||||
|
||||
## [0.0.1] - 2026-11-05
|
||||
|
||||
First test release: the Phase 1 MVP.
|
||||
@@ -21,5 +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.1...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"]
|
||||
@@ -7,6 +7,9 @@ services:
|
||||
environment:
|
||||
DATA_DIR: /data
|
||||
HOST: 0.0.0.0
|
||||
# Lets OLLAMA_URL=http://host.docker.internal:11434 reach an Ollama running on this machine (Linux too).
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
# Only this machine can reach it; put nginx (HTTPS) in front.
|
||||
ports:
|
||||
- "127.0.0.1:8000:8000"
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build"
|
||||
"build": "vite build",
|
||||
"test": "node src/lib/foodEmoji.check.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "7.3.1",
|
||||
|
||||
@@ -25,7 +25,9 @@ export interface CalEvent {
|
||||
export interface MenuItem {
|
||||
id: number; scan_id: number; source_id: number; date: string; items: string[]; no_lunch: boolean;
|
||||
page: number | null; cell_bbox: number[] | null; flags: string[]; source_name?: string;
|
||||
reads: MenuRead[] | null; // both readings, when a double read disagreed
|
||||
}
|
||||
export interface MenuRead { items: string[]; no_lunch: boolean }
|
||||
export interface Scan {
|
||||
id: number; source_id: number; source_name: string; layout_type: Source['layout_type']; hint_month: string;
|
||||
date_start: string | null; date_end: string | null; image_keys: string[];
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Run: npm test (Node 22.18+ runs TypeScript directly)
|
||||
import assert from 'node:assert/strict';
|
||||
import { foodEmoji } from './foodEmoji.ts';
|
||||
|
||||
const one = (food: string) => foodEmoji([food])[0];
|
||||
|
||||
assert.deepEqual(foodEmoji(['Chicken', 'Corn', 'Pears']), ['🐔', '🌽', '🍐']);
|
||||
// Dishes win over the ingredients in their names
|
||||
assert.equal(one('Corn Dog'), '🌭');
|
||||
assert.equal(one('Mini Corn Dogs'), '🌭');
|
||||
assert.equal(one('Popcorn Chicken'), '🐔');
|
||||
assert.equal(one('Orange Chicken'), '🐔');
|
||||
assert.equal(one('Chicken Alfredo'), '🍝');
|
||||
assert.equal(one('Pepperoni Pizza'), '🍕');
|
||||
assert.equal(one('Cheeseburger on WG Bun'), '🍔');
|
||||
assert.equal(one('Mac-n-Cheese'), '🧀');
|
||||
assert.equal(one('Broccoli and Cheese'), '🥦');
|
||||
assert.equal(one('Chx&Veg WG Egg Roll'), '🥟');
|
||||
assert.equal(one('Beef Burrito'), '🌯');
|
||||
// Similar words don't collide
|
||||
assert.equal(one('Pineapple Tidbits'), '🍍');
|
||||
assert.equal(one('Whole Red Apple'), '🍎');
|
||||
assert.equal(one('Watermelon'), '🍉');
|
||||
assert.equal(one('Potato Sticks'), '🍟');
|
||||
assert.equal(one('Tater-Tots'), '🍟');
|
||||
assert.equal(one("Seas'd Potato Cubes"), '🥔');
|
||||
assert.equal(one('Green Beans'), '🫛');
|
||||
assert.equal(one('Refried Beans'), '🫘');
|
||||
assert.equal(one('Bell Pepper strips'), '🫑');
|
||||
assert.equal(one('Mandarin Oranges'), '🍊');
|
||||
// Foods with no rule add nothing; repeats and overflow are dropped
|
||||
assert.deepEqual(foodEmoji(['WG bun', 'Ranch', 'Marinara Cup']), []);
|
||||
assert.deepEqual(foodEmoji(['Diced Pears', 'Whole Pear']), ['🍐']);
|
||||
assert.equal(foodEmoji(['Pizza', 'Corn', 'Peas', 'Apple', 'Banana', 'Grapes']).length, 5);
|
||||
console.log('foodEmoji check ok');
|
||||
@@ -0,0 +1,77 @@
|
||||
// Emoji for a day's lunch menu, picked by keyword. Each food takes its first matching rule, so dishes come
|
||||
// before the ingredients in their names: "Corn Dog" is a hot dog, "Orange Chicken" is chicken, "Chicken Alfredo" is pasta.
|
||||
// ponytail: keyword list tuned on our school's menus; add a rule when a food shows up with no emoji.
|
||||
const RULES: [RegExp, string][] = [
|
||||
// Dishes
|
||||
[/pizza|fiestada/, '🍕'],
|
||||
[/corn ?dog|hot ?dog|sausage/, '🌭'],
|
||||
[/burger/, '🍔'],
|
||||
[/rib(let)?s?\b/, '🍖'],
|
||||
[/burrito|crispito/, '🌯'],
|
||||
[/taco|chalupa|nacho/, '🌮'],
|
||||
[/egg ?roll|dumpling|pot ?sticker/, '🥟'],
|
||||
[/spaghetti|pasta|alfredo|lasagn|noodle|ravioli/, '🍝'],
|
||||
[/mac\b|mac-n|macaroni/, '🧀'],
|
||||
[/\bsub\b|sandwich|wrap\b/, '🥪'],
|
||||
[/french toast|pancake/, '🥞'],
|
||||
[/waffle/, '🧇'],
|
||||
[/\brice\b/, '🍚'],
|
||||
[/soup|stew/, '🍲'],
|
||||
[/breadstick|bosco|garlic knot|\brolls?\b|biscuit/, '🥖'],
|
||||
[/fries|\btots?\b|tater|totcho|wedges|potato stick|hash ?brown/, '🍟'],
|
||||
[/potato/, '🥔'],
|
||||
[/salad/, '🥗'],
|
||||
// Meat and fish
|
||||
[/chicken|\bchx\b|drummies|nugget/, '🐔'],
|
||||
[/turkey/, '🦃'],
|
||||
[/pork|\bham\b|bacon/, '🐷'],
|
||||
[/beef|steak|meatball/, '🐮'],
|
||||
[/fish|tuna|salmon/, '🐟'],
|
||||
[/shrimp/, '🍤'],
|
||||
[/\beggs?\b/, '🥚'],
|
||||
// Vegetables
|
||||
[/\bcorn\b/, '🌽'],
|
||||
[/carrot/, '🥕'],
|
||||
[/broccoli|vegetable|veggie/, '🥦'],
|
||||
[/cucumber|pickle/, '🥒'],
|
||||
[/bell pepper|\bpeppers?\b/, '🫑'],
|
||||
[/tomato/, '🍅'],
|
||||
[/\bpeas\b|green beans?/, '🫛'],
|
||||
[/beans/, '🫘'],
|
||||
// Fruit
|
||||
[/pineapple/, '🍍'],
|
||||
[/watermelon/, '🍉'],
|
||||
[/melon|cantaloupe/, '🍈'],
|
||||
[/apple/, '🍎'],
|
||||
[/pears?\b/, '🍐'],
|
||||
[/peach/, '🍑'],
|
||||
[/orange|mandarin|clementine/, '🍊'],
|
||||
[/banana/, '🍌'],
|
||||
[/grape/, '🍇'],
|
||||
[/strawberr/, '🍓'],
|
||||
[/blueberr/, '🫐'],
|
||||
[/cherr|mixed fruit|fruit cup/, '🍒'],
|
||||
[/kiwi/, '🥝'],
|
||||
[/mango/, '🥭'],
|
||||
// Everything else
|
||||
[/cheese/, '🧀'],
|
||||
[/popcorn/, '🍿'],
|
||||
[/pretzel/, '🥨'],
|
||||
[/cookie/, '🍪'],
|
||||
[/muffin|cupcake/, '🧁'],
|
||||
[/donut|doughnut/, '🍩'],
|
||||
[/ice cream/, '🍨'],
|
||||
[/milk/, '🥛'],
|
||||
[/juice/, '🧃'],
|
||||
];
|
||||
|
||||
/** Up to `max` distinct emoji for a day's foods, in menu order. Foods with no rule add nothing. */
|
||||
export function foodEmoji(items: string[], max = 5): string[] {
|
||||
const out: string[] = [];
|
||||
for (const item of items) {
|
||||
const food = item.toLowerCase();
|
||||
const hit = RULES.find(([re]) => re.test(food));
|
||||
if (hit && !out.includes(hit[1])) out.push(hit[1]);
|
||||
}
|
||||
return out.slice(0, max);
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
import { api, type LunchDay } from '../lib/api';
|
||||
import { app, toast } from '../lib/state.svelte';
|
||||
import { monthLabel, parse, shiftMonth, thisMonth } from '../lib/dates';
|
||||
import { foodEmoji } from '../lib/foodEmoji';
|
||||
import PageHeader from '../lib/ui/PageHeader.svelte';
|
||||
import EmptyState from '../lib/ui/EmptyState.svelte';
|
||||
|
||||
@@ -133,7 +134,11 @@
|
||||
<span class="dnum tabular">{date.getDate()}</span>
|
||||
</span>
|
||||
<span class="visually-hidden">{date.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })}</span>
|
||||
{#if d.warning}<span class="badge warn"><TriangleAlert size={12} aria-hidden="true" />{d.warning}</span>{/if}
|
||||
<span class="head-end">
|
||||
{#if d.warning}<span class="badge warn"><TriangleAlert size={12} aria-hidden="true" />{d.warning}</span>{/if}
|
||||
<!-- Decoration only: the foods are listed below, so screen readers skip this. -->
|
||||
{#if !d.greyed}<span class="emoji" aria-hidden="true">{foodEmoji(d.menus.flatMap((m) => m.items)).join('')}</span>{/if}
|
||||
</span>
|
||||
</div>
|
||||
{#if d.greyed}
|
||||
<p class="closed-note"><CalendarX size={18} aria-hidden="true" />{d.closed ?? 'No lunch this day'}</p>
|
||||
@@ -174,6 +179,8 @@
|
||||
.day.closed { background: var(--day-closed); box-shadow: none; }
|
||||
.day-head { display: flex; align-items: center; justify-content: space-between; gap: var(--s2); }
|
||||
.date-tile { display: flex; align-items: baseline; gap: 6px; }
|
||||
.head-end { display: flex; align-items: center; gap: var(--s2); min-width: 0; }
|
||||
.emoji { font-size: var(--fs-lg); line-height: 1; letter-spacing: 2px; white-space: nowrap; }
|
||||
.dow { font-size: var(--fs-sm); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-2); }
|
||||
.dnum { font-size: var(--fs-xl); font-weight: 750; }
|
||||
.food { display: flex; flex-direction: column; gap: 2px; font-size: var(--fs-sm); color: var(--text); }
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import Check from '@lucide/svelte/icons/check';
|
||||
import TriangleAlert from '@lucide/svelte/icons/triangle-alert';
|
||||
import { untrack } from 'svelte';
|
||||
import type { MenuItem, MenuRead } from '../lib/api';
|
||||
import { dayLabel } from '../lib/dates';
|
||||
|
||||
let { item, onsave }: { item: MenuItem; onsave: (items: string[], noLunch: boolean) => Promise<void> } = $props();
|
||||
|
||||
// The form edits its own copy; picking a reading replaces the copy with that reading.
|
||||
let text = $state(untrack(() => item.items.join('\n')));
|
||||
let noLunch = $state(untrack(() => item.no_lunch));
|
||||
let saving = $state(false);
|
||||
const lines = $derived(text.split('\n').map((s) => s.trim()).filter(Boolean));
|
||||
const shows = (r: MenuRead) => r.no_lunch === noLunch && r.items.join('\n') === lines.join('\n');
|
||||
|
||||
function pick(r: MenuRead) {
|
||||
text = r.items.join('\n');
|
||||
noLunch = r.no_lunch;
|
||||
}
|
||||
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
saving = true;
|
||||
try {
|
||||
await onsave(lines, noLunch);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<li class="card day">
|
||||
<form class="stack" onsubmit={submit}>
|
||||
<div class="row spread">
|
||||
<span class="strong">{dayLabel(item.date)}</span>
|
||||
{#each item.flags as flag}<span class="badge warn"><TriangleAlert size={12} aria-hidden="true" />{flag}</span>{/each}
|
||||
</div>
|
||||
{#if item.reads}
|
||||
<!-- The double read disagreed: both readings are kept, so you can compare them before saving. -->
|
||||
<div class="reads" role="group" aria-label={`Readings of ${dayLabel(item.date)}`}>
|
||||
{#each item.reads as r, i}
|
||||
<button type="button" class="sm" aria-pressed={shows(r)} onclick={() => pick(r)}>
|
||||
{#if shows(r)}<Check size={16} aria-hidden="true" />{/if}Read {i + 1}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<textarea bind:value={text} aria-label={`Menu for ${dayLabel(item.date)}`} rows={Math.max(2, lines.length)}></textarea>
|
||||
<div class="row spread">
|
||||
<label class="check small"><input type="checkbox" bind:checked={noLunch} /> No lunch this day</label>
|
||||
<button class="sm" type="submit" disabled={saving}>Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</li>
|
||||
|
||||
<style>
|
||||
textarea { min-height: 72px; }
|
||||
.reads { display: flex; gap: var(--s2); }
|
||||
.reads button { flex: 1; }
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import Utensils from '@lucide/svelte/icons/utensils';
|
||||
import { api, type MenuItem, type Scan } from '../lib/api';
|
||||
import { toast } from '../lib/state.svelte';
|
||||
import { dayLabel } from '../lib/dates';
|
||||
import MenuDay from './MenuDay.svelte';
|
||||
|
||||
// A scan's menu days, editable in review and after publishing.
|
||||
let { scan = $bindable() }: { scan: Scan } = $props();
|
||||
const flagged = $derived(scan.menu_items.filter((m) => m.flags.length).length);
|
||||
const published = $derived(scan.status === 'published');
|
||||
|
||||
async function save(m: MenuItem, items: string[], noLunch: boolean) {
|
||||
try {
|
||||
await api(`/menu_items/${m.id}`, { method: 'PATCH', json: { items, no_lunch: noLunch } });
|
||||
scan = await api<Scan>(`/scans/${scan.id}`);
|
||||
toast(`Saved ${dayLabel(m.date)}`);
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="section">
|
||||
<h2 class="section-title">
|
||||
<Utensils size={16} aria-hidden="true" />{published ? 'Fix the menu' : 'Menu'}
|
||||
<span class="count">{scan.menu_items.length} days{flagged ? `, ${flagged} need a look` : ''}</span>
|
||||
</h2>
|
||||
<p class="hint">
|
||||
One food per line.
|
||||
{published ? 'Changes show on the Lunch tab as soon as you save.' : 'Your kids pick lunch days on the Lunch tab after you publish.'}
|
||||
</p>
|
||||
<ul class="days">
|
||||
{#each scan.menu_items as m (m.id)}
|
||||
<MenuDay item={m} onsave={(items, noLunch) => save(m, items, noLunch)} />
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.days { display: grid; gap: var(--s2); grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
|
||||
</style>
|
||||
@@ -13,17 +13,17 @@
|
||||
import Sparkles from '@lucide/svelte/icons/sparkles';
|
||||
import TriangleAlert from '@lucide/svelte/icons/triangle-alert';
|
||||
import Undo2 from '@lucide/svelte/icons/undo-2';
|
||||
import Utensils from '@lucide/svelte/icons/utensils';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import ZoomIn from '@lucide/svelte/icons/zoom-in';
|
||||
import ZoomOut from '@lucide/svelte/icons/zoom-out';
|
||||
import type { Component } from 'svelte';
|
||||
import { api, KINDS, type CalEvent, type EventValues as Values, type Kind, type MenuItem, type Scan } from '../lib/api';
|
||||
import { api, KINDS, type CalEvent, type EventValues as Values, type Kind, type Scan } from '../lib/api';
|
||||
import { app, toast } from '../lib/state.svelte';
|
||||
import { dayLabel, monthsBetween, whenLabel } from '../lib/dates';
|
||||
import { monthsBetween, whenLabel } from '../lib/dates';
|
||||
import { KIND_ICONS } from '../lib/icons';
|
||||
import Segmented from '../lib/ui/Segmented.svelte';
|
||||
import EventEditor from './EventEditor.svelte';
|
||||
import MenuDays from './MenuDays.svelte';
|
||||
import MonthGrid from './MonthGrid.svelte';
|
||||
|
||||
let { scan = $bindable() }: { scan: Scan } = $props();
|
||||
@@ -59,7 +59,6 @@
|
||||
);
|
||||
const children = $derived(new Map(app.household!.children.map((c) => [c.id, c])));
|
||||
const colorFor = (e: CalEvent) => children.get(e.child_ids[0])?.color ?? 'var(--primary)';
|
||||
const flaggedMenu = $derived(scan.menu_items.filter((m) => m.flags.length).length);
|
||||
|
||||
async function refresh() {
|
||||
scan = await api<Scan>(`/scans/${scan.id}`);
|
||||
@@ -113,12 +112,6 @@
|
||||
view = 'list';
|
||||
}
|
||||
|
||||
async function saveMenu(m: MenuItem, text: string, noLunch: boolean) {
|
||||
const items = text.split('\n').map((s) => s.trim()).filter(Boolean);
|
||||
await run(() => api(`/menu_items/${m.id}`, { method: 'PATCH', json: { items, no_lunch: noLunch } }));
|
||||
toast(`Saved ${dayLabel(m.date)}`);
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
busy = true;
|
||||
try {
|
||||
@@ -289,29 +282,7 @@
|
||||
{@render collapsed('unchanged', 'Unchanged', CheckCheck, unchanged)}
|
||||
{@render collapsed('excluded', 'Not for this child, or unchecked', EyeOff, excluded)}
|
||||
|
||||
{#if scan.menu_items.length}
|
||||
<section class="section">
|
||||
<h2 class="section-title"><Utensils size={16} aria-hidden="true" />Menu <span class="count">{scan.menu_items.length} days{flaggedMenu ? `, ${flaggedMenu} need a look` : ''}</span></h2>
|
||||
<p class="hint">One food per line. Your kids pick lunch days on the Lunch tab after you publish.</p>
|
||||
<ul class="menu-days">
|
||||
{#each scan.menu_items as m (m.id)}
|
||||
<li class="card menu-day">
|
||||
<form class="stack" onsubmit={(ev) => { ev.preventDefault(); const fd = new FormData(ev.currentTarget as HTMLFormElement); saveMenu(m, String(fd.get('items') ?? ''), fd.get('no_lunch') === 'on'); }}>
|
||||
<div class="row spread">
|
||||
<span class="strong">{dayLabel(m.date)}</span>
|
||||
{#each m.flags as flag}<span class="badge warn"><TriangleAlert size={12} aria-hidden="true" />{flag}</span>{/each}
|
||||
</div>
|
||||
<textarea name="items" aria-label={`Menu for ${dayLabel(m.date)}`} rows={Math.max(2, m.items.length)}>{m.items.join('\n')}</textarea>
|
||||
<div class="row spread">
|
||||
<label class="check small"><input type="checkbox" name="no_lunch" checked={m.no_lunch} /> No lunch this day</label>
|
||||
<button class="sm" type="submit">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
{#if scan.menu_items.length}<MenuDays bind:scan />{/if}
|
||||
{/if}
|
||||
|
||||
<div class="publish-bar">
|
||||
@@ -380,8 +351,6 @@
|
||||
.disclosure :global(.chev) { transition: transform var(--dur) var(--ease); transform: rotate(-90deg); }
|
||||
.disclosure[aria-expanded='true'] :global(.chev) { transform: none; }
|
||||
|
||||
.menu-days { display: grid; gap: var(--s2); grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
|
||||
.menu-day textarea { min-height: 72px; }
|
||||
|
||||
.publish-bar {
|
||||
position: sticky; bottom: calc(var(--nav-h) + var(--s2) + env(safe-area-inset-bottom)); z-index: var(--z-sticky);
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import { monthLabel } from '../lib/dates';
|
||||
import PageHeader from '../lib/ui/PageHeader.svelte';
|
||||
import StatusBadge from './StatusBadge.svelte';
|
||||
import MenuDays from './MenuDays.svelte';
|
||||
import Review from './Review.svelte';
|
||||
|
||||
let { id }: { id: number } = $props();
|
||||
@@ -118,6 +119,7 @@
|
||||
<a class="btn ghost" href="#/calendar"><CalendarDays size={18} aria-hidden="true" /> See the calendar</a>
|
||||
</div>
|
||||
</section>
|
||||
{#if scan.menu_items.length}<div class="menu-fix"><MenuDays bind:scan /></div>{/if}
|
||||
{:else}
|
||||
<Review bind:scan />
|
||||
<div class="discard">
|
||||
@@ -139,5 +141,6 @@
|
||||
.state-icon.danger { background: var(--danger-soft); color: var(--danger); }
|
||||
.state p { max-width: 48ch; }
|
||||
.actions { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--s2); margin-top: var(--s2); }
|
||||
.menu-fix { margin-top: var(--s6); }
|
||||
.discard { margin-top: var(--s6); padding-top: var(--s4); border-top: 1px solid var(--border); display: flex; gap: var(--s2); }
|
||||
</style>
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
+11
-6
@@ -476,8 +476,9 @@ class MenuItemIn(BaseModel):
|
||||
|
||||
@app.patch("/api/menu_items/{item_id}")
|
||||
def edit_menu_item(item_id: int, body: MenuItemIn, conn: DB):
|
||||
"""Fix a misread menu day, in review or later."""
|
||||
items = [] if body.no_lunch else [i.strip() for i in body.items if i.strip()]
|
||||
"""Fix a misread menu day, in review or after publishing: menus never reach a calendar file, so the
|
||||
lunch screen shows the change right away. The food is kept when no_lunch is set, so unticking it brings it back."""
|
||||
items = [i.strip() for i in body.items if i.strip()]
|
||||
with conn:
|
||||
found(conn.execute("UPDATE menu_item SET items = ?, no_lunch = ?, flags = '[]' WHERE id = ? RETURNING id",
|
||||
(json.dumps(items), body.no_lunch, item_id)).fetchone(), "menu item")
|
||||
@@ -540,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"]):
|
||||
@@ -547,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
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from parentcalendar import config
|
||||
|
||||
MIGRATIONS = Path(__file__).parent / "migrations"
|
||||
JSON_COLUMNS = {"source_ids", "ignore_labels", "image_keys", "items", "flags", "cell_bbox", "before", "after",
|
||||
"child_ids"} # child_ids comes from json_group_array() subqueries
|
||||
"child_ids", "reads"} # child_ids comes from json_group_array() subqueries
|
||||
BOOL_COLUMNS = {"double_read", "all_day", "applies", "include", "no_lunch"}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -20,6 +25,8 @@ import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import unicodedata
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections import Counter
|
||||
from datetime import date, datetime, timedelta
|
||||
@@ -38,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
|
||||
@@ -192,14 +209,38 @@ def iso(s):
|
||||
return s
|
||||
|
||||
|
||||
def script(word):
|
||||
"""The alphabet of a word's letters (LATIN, CYRILLIC, CJK, ...), or None for numbers, symbols and mixes."""
|
||||
names = {unicodedata.name(c, "").split(" ")[0] for c in word if c.isalpha()}
|
||||
return names.pop() if len(names) == 1 else None
|
||||
|
||||
|
||||
def drop_stray_scripts(result):
|
||||
"""A small model sometimes invents a word in another alphabet and repeats it down a row (Cyrillic
|
||||
"выда" after the last food of every day). Drop words in an alphabet that is under 10% of the page's words."""
|
||||
# ponytail: share-of-words heuristic; a short flyer with one real title in a second alphabet loses it
|
||||
words = [w for e in result["events"] for w in e["title"].split()] + [w for m in result["menu_items"] for i in m["items"] for w in i.split()]
|
||||
counts = Counter(script(w) for w in words)
|
||||
counts.pop(None, None)
|
||||
stray = {s for s, n in counts.items() if n < 0.1 * sum(counts.values())}
|
||||
if not stray:
|
||||
return
|
||||
keep = lambda text: " ".join(w for w in text.split() if script(w) not in stray)
|
||||
for e in result["events"]:
|
||||
e["title"] = keep(e["title"])
|
||||
for m in result["menu_items"]:
|
||||
m["items"] = [k for k in map(keep, m["items"]) if k]
|
||||
|
||||
|
||||
def cleanup(result, ignore=None):
|
||||
"""Deterministic tidy-up before validation: pad dates, drop events with no real title,
|
||||
and drop labels (grade ranges and the ignore list) from event titles and menu items."""
|
||||
"""Deterministic tidy-up before validation: pad dates, drop words in a stray alphabet, drop events
|
||||
with no real title, and drop labels (grade ranges and the ignore list) from event titles and menu items."""
|
||||
ignore = IGNORE if ignore is None else ignore
|
||||
for item in [result["date_range"]] + result["events"] + result["menu_items"]:
|
||||
for key in ("date", "end_date", "start", "end"):
|
||||
if item.get(key):
|
||||
item[key] = iso(item[key])
|
||||
drop_stray_scripts(result)
|
||||
result["events"] = [e for e in result["events"] if re.sub(r"[^a-z]", "", e["title"].lower()) not in ("", "event")]
|
||||
result["events"] = [e for e in result["events"] if not ignored(e["title"], ignore)]
|
||||
for m in result["menu_items"]:
|
||||
@@ -257,14 +298,22 @@ 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]}],
|
||||
}
|
||||
req = urllib.request.Request(f"{OLLAMA_URL}/api/chat", json.dumps(body).encode(),
|
||||
{"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=1800) as r:
|
||||
try:
|
||||
r = urllib.request.urlopen(req, timeout=1800)
|
||||
except urllib.error.HTTPError:
|
||||
raise
|
||||
except urllib.error.URLError as e: # shown on the scan page, so say what to fix
|
||||
raise RuntimeError(f"Can't reach Ollama at {OLLAMA_URL} ({e.reason}). In Docker, localhost is the container "
|
||||
"itself: set OLLAMA_URL to the Ollama machine's address.") from e
|
||||
with r:
|
||||
d = json.load(r)
|
||||
if d.get("done_reason") != "stop":
|
||||
raise RuntimeError(f"done_reason={d.get('done_reason')} (output may be cut off; raise num_ctx)")
|
||||
@@ -299,9 +348,9 @@ def vary(img):
|
||||
|
||||
def read_row(call, img, header, row, number, total, hint, second=False):
|
||||
"""One model call for one week row. Returns (raw output, usage, model)."""
|
||||
if second: # a little more of the row above and below, and smaller
|
||||
if second: # a little more of the row above (not below: that's the next week's text), and smaller
|
||||
pad = (row[1] - row[0]) * 4 // 100
|
||||
row = (max(0, row[0] - pad), min(img.height, row[1] + pad))
|
||||
row = (max(0, row[0] - pad), row[1])
|
||||
strip = grid.strip(img, header, row)
|
||||
if second:
|
||||
strip = vary(strip)
|
||||
@@ -431,6 +480,7 @@ def merge_reads(a, b):
|
||||
m.setdefault("read_flags", []).append("second read did not find this")
|
||||
elif norm("".join(m["items"])) != norm("".join(o["items"])) or m["no_lunch"] != o["no_lunch"]:
|
||||
m.setdefault("read_flags", []).append("second read differs: items")
|
||||
m["reads"] = [{"items": r["items"], "no_lunch": r["no_lunch"]} for r in (m, o)] # review can switch
|
||||
for o in menu_b.values():
|
||||
o.setdefault("read_flags", []).append("only the second read found this")
|
||||
a["menu_items"].append(o)
|
||||
@@ -455,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,
|
||||
@@ -501,6 +554,15 @@ def selftest():
|
||||
"No School": ["second read did not find this"],
|
||||
"Happy Halloween": ["only the second read found this"]}, flags
|
||||
assert m["menu_items"][0]["read_flags"] == ["second read differs: items"]
|
||||
assert [r["items"] for r in m["menu_items"][0]["reads"]] == [["Corn"], ["Corn", "Peas"]]
|
||||
# A word in a stray alphabet is dropped; a page that is really bilingual keeps both
|
||||
page = {"events": [], "menu_items": [{"items": ["Chicken Patty", "Corn", "Whole Orange выда"]},
|
||||
{"items": ["Pizza", "Garden Salad", "Mixed Fruit", "Peas", "Milk"]}]}
|
||||
drop_stray_scripts(page)
|
||||
assert page["menu_items"][0]["items"] == ["Chicken Patty", "Corn", "Whole Orange"], page
|
||||
page = {"events": [{"title": "Book Fair 书展"}, {"title": "Picture Day 拍照日"}], "menu_items": []}
|
||||
drop_stray_scripts(page)
|
||||
assert page["events"][0]["title"] == "Book Fair 书展", page
|
||||
print("selftest ok")
|
||||
|
||||
|
||||
|
||||
@@ -129,7 +129,9 @@ def find_rows(img):
|
||||
header = above[0] # boxed heading row
|
||||
else:
|
||||
header = tighten_header(img, max(0, rows[0][0] - median // 2), rows[0][0]) # headings printed above the grid
|
||||
padded = [(max(0, a - median * 3 // 100), min(h, b + median * 8 // 100)) for a, b in rows]
|
||||
# Below a row's line is the next week's first line of text; a sliver of it gets read as garbage
|
||||
# (half-cut letters came back as Cyrillic), so the bottom pad stays as small as the top one.
|
||||
padded = [(max(0, a - median * 3 // 100), min(h, b + median * 3 // 100)) for a, b in rows]
|
||||
a, _ = padded[-1]
|
||||
padded[-1] = (a, min(h, rows[-1][1] + median // 2)) # text often spills below the last row
|
||||
return padded, header
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Both readings of a menu day when a double read disagreed: [{"items": [...], "no_lunch": bool}, {...}].
|
||||
-- NULL when the reads agreed or the source reads once. Review lets you switch between them.
|
||||
ALTER TABLE menu_item ADD COLUMN reads TEXT;
|
||||
@@ -113,9 +113,10 @@ def process_scan(conn, scan_id):
|
||||
for m in r["menu_items"]:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO menu_item (scan_id, source_id, date, items, no_lunch, page, cell_bbox,"
|
||||
" confidence, flags) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
" confidence, flags, reads) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(scan_id, source["id"], m["date"], json.dumps(m["items"]), m["no_lunch"], page,
|
||||
json.dumps(m.get("cell_bbox")), m.get("confidence"), json.dumps(m.get("flags", []))))
|
||||
json.dumps(m.get("cell_bbox")), m.get("confidence"), json.dumps(m.get("flags", [])),
|
||||
json.dumps(m["reads"]) if m.get("reads") else None))
|
||||
dates = sorted(d for d in dates if d)
|
||||
tokens = sum(r["usage"]["input_tokens"] + r["usage"]["output_tokens"] for r in runs)
|
||||
conn.execute("UPDATE scan SET status = 'parsed', error = NULL, date_start = ?, date_end = ?, model = ?,"
|
||||
|
||||
+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:
|
||||
|
||||
+13
-2
@@ -137,9 +137,20 @@ def test():
|
||||
|
||||
# Lunch: menu days, greyed closures, picks as events
|
||||
menu = upload(c, menu_src, png("yellow"), {"events": [], "menu_items": [
|
||||
{"date": "2026-10-05", "items": ["Pork Chopette", "Corn"], "no_lunch": False, "flags": []},
|
||||
{"date": "2026-10-05", "items": ["Pork Chopette", "Corn"], "no_lunch": False, "flags": [],
|
||||
"reads": [{"items": ["Pork Chopette", "Corn"], "no_lunch": False},
|
||||
{"items": ["Pork Chop", "Corn"], "no_lunch": False}]},
|
||||
{"date": "2026-10-30", "items": [], "no_lunch": True, "flags": []}]})
|
||||
assert [r["items"][0] for r in menu["menu_items"][0]["reads"]] == ["Pork Chopette", "Pork Chop"] # both reads kept
|
||||
assert menu["menu_items"][1]["reads"] is None
|
||||
c.post(f"/api/scans/{menu['id']}/publish")
|
||||
# After publishing, a mistaken "no lunch" keeps the food, and unticking it brings the day back
|
||||
item = menu["menu_items"][0]["id"]
|
||||
r = c.patch(f"/api/menu_items/{item}", json={"items": ["Pork Chopette", "Corn"], "no_lunch": True}).json()
|
||||
assert r["no_lunch"] is True and r["items"] == ["Pork Chopette", "Corn"], r
|
||||
days = {d["date"]: d for d in c.get("/api/lunch", params={"child_id": sam, "month": "2026-10"}).json()}
|
||||
assert days["2026-10-05"]["greyed"] is True
|
||||
c.patch(f"/api/menu_items/{item}", json={"items": ["Pork Chopette", "Corn"], "no_lunch": False})
|
||||
days = {d["date"]: d for d in c.get("/api/lunch", params={"child_id": sam, "month": "2026-10"}).json()}
|
||||
assert days["2026-10-05"]["greyed"] is False and days["2026-10-30"]["greyed"] is True
|
||||
assert days["2026-10-30"]["closed"] == "No School"
|
||||
@@ -147,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