Menu reading fixes #3

Merged
iamdoubz merged 9 commits from menu-reading-fixes into main 2026-11-06 00:36:58 -06:00
17 changed files with 319 additions and 49 deletions

No files matched your search

+21 -1
View File
@@ -5,6 +5,25 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
## [Unreleased]
## [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 +40,6 @@ 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.2...main
[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
+2 -1
View File
@@ -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",
+2
View File
@@ -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[];
+35
View File
@@ -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');
+77
View File
@@ -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);
}
+8 -1
View File
@@ -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); }
+61
View File
@@ -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>
+42
View File
@@ -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>
+4 -35
View File
@@ -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);
+3
View File
@@ -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>
+3 -2
View File
@@ -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")
+1 -1
View File
@@ -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"}
+39 -4
View File
@@ -20,6 +20,7 @@ import os
import re
import sys
import time
import unicodedata
import urllib.error
import urllib.request
from collections import Counter
@@ -193,14 +194,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"]:
@@ -307,9 +332,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)
@@ -439,6 +464,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)
@@ -509,6 +535,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")
+3 -1
View File
@@ -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;
+3 -2
View File
@@ -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 = ?,"
+12 -1
View File
@@ -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"