Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb47aff609 | ||
|
|
415d26c33b | ||
|
|
c6b3d6c0af | ||
|
|
b3ac884707 | ||
|
|
3a7d796f30 | ||
|
|
cb9a611aaa | ||
|
|
ad6cd4bbfd | ||
|
|
a953b1bcf4 | ||
|
|
2c3867b1c8 | ||
|
|
d600332af4 | ||
|
|
6eed7aaa60 | ||
|
|
fa5dc94293 | ||
|
|
9b1a0a99f2 | ||
|
|
fffbcbe293 | ||
|
|
abbcc377ff | ||
|
|
ea541e4a3e |
No files matched your search
@@ -16,8 +16,29 @@ 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
|
||||
|
||||
# Days to keep photos after a scan is published.
|
||||
IMAGE_RETENTION_DAYS=90
|
||||
|
||||
# 1 adds a "Scan Stats" button to each scan's page (model, tokens, pages, events, scan time), for comparing models. Every scan's time is logged either way.
|
||||
# SHOW_SCAN_STATS=0
|
||||
@@ -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,32 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.0.5] - 2026-11-06
|
||||
|
||||
### Added
|
||||
|
||||
- A published scan's page lists its events, and you can edit or delete them there like on the Calendar tab. Events with no child picked are on no calendar, so they're marked "Not on any calendar" there, with a note to pick a child.
|
||||
|
||||
### Changed
|
||||
|
||||
- A new source starts with every child ticked, like a new calendar. Before, it started with none, and its events showed on no calendar.
|
||||
|
||||
## [0.0.4] - 2026-11-06
|
||||
|
||||
### Added
|
||||
|
||||
- A published scan's photo can still be viewed from its scan page, under "Scanned photo", until it's deleted after `IMAGE_RETENTION_DAYS`.
|
||||
- A log line for every scan with how long it took, the model and what it found. `SHOW_SCAN_STATS=1` adds a "Scan Stats" button next to a scan's status that shows the model, tokens used, pages, events and scan time.
|
||||
- Today stands out on the Lunch screen and in the Calendar list: a filled day number like the month view, a "Today" label and a tinted card.
|
||||
|
||||
## [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 +66,9 @@ 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.5...main
|
||||
[0.0.5]: https://git.dou.bet/iamdoubz/parentcalendar/compare/v0.0.4...v0.0.5
|
||||
[0.0.4]: https://git.dou.bet/iamdoubz/parentcalendar/compare/v0.0.3...v0.0.4
|
||||
[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,10 +96,16 @@ 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` |
|
||||
| `IMAGE_RETENTION_DAYS` | Days to keep images after publish | `90` |
|
||||
| `SHOW_SCAN_STATS` | `1` adds a "Scan Stats" button to each scan's page: model, tokens used, pages, events and scan time. Every scan's time is logged either way | `0` |
|
||||
|
||||
**Deployment on the home server**
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface Source {
|
||||
ignore_labels: string[]; applies_filter: string | null; prompt_notes: string | null;
|
||||
default_calendar_id: number | null; child_ids: number[];
|
||||
}
|
||||
export interface Household { timezone: string; children: Child[]; calendars: Calendar[]; sources: Source[] }
|
||||
export interface Household { timezone: string; show_scan_stats: boolean; children: Child[]; calendars: Calendar[]; sources: Source[] }
|
||||
|
||||
export interface CalEvent {
|
||||
id: number; uid: string; scan_id: number | null; source_id: number | null;
|
||||
@@ -32,7 +32,7 @@ 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[];
|
||||
status: 'queued' | 'parsed' | 'failed' | 'abandoned' | 'reviewed' | 'published';
|
||||
error: string | null; seconds: number | null; created_at: string; published_at: string | null;
|
||||
error: string | null; seconds: number | null; model: string | null; cost_tokens: number | null; created_at: string; published_at: string | null;
|
||||
events: CalEvent[]; menu_items: MenuItem[]; can_undo: boolean;
|
||||
}
|
||||
export interface ScanSummary {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import MapPin from '@lucide/svelte/icons/map-pin';
|
||||
import { api, KINDS, type CalEvent, type EventValues } from '../lib/api';
|
||||
import { app, toast } from '../lib/state.svelte';
|
||||
import { monthEnd, monthLabel, parse, shiftMonth, thisMonth, whenLabel } from '../lib/dates';
|
||||
import { iso, monthEnd, monthLabel, parse, shiftMonth, thisMonth, whenLabel } from '../lib/dates';
|
||||
import { KIND_ICONS } from '../lib/icons';
|
||||
import PageHeader from '../lib/ui/PageHeader.svelte';
|
||||
import EmptyState from '../lib/ui/EmptyState.svelte';
|
||||
@@ -43,6 +43,7 @@
|
||||
}
|
||||
return [...days.entries()].sort();
|
||||
});
|
||||
const today = iso(new Date());
|
||||
const lastDownload = $derived(calendar?.last_exported_at ? new Date(calendar.last_exported_at.replace(' ', 'T') + 'Z') : null);
|
||||
|
||||
async function load() {
|
||||
@@ -143,11 +144,14 @@
|
||||
<ol class="agenda">
|
||||
{#each byDay as [day, list] (day)}
|
||||
{@const d = parse(day)}
|
||||
<li class="day">
|
||||
{@const isToday = day === today}
|
||||
<li class="day" class:today={isToday} aria-current={isToday ? 'date' : undefined}>
|
||||
<div class="date" aria-hidden="true">
|
||||
<span class="dow">{d.toLocaleDateString(undefined, { weekday: 'short' })}</span>
|
||||
<span class="dnum tabular">{d.getDate()}</span>
|
||||
{#if isToday}<span class="today-tag">Today</span>{/if}
|
||||
</div>
|
||||
{#if isToday}<span class="visually-hidden">Today</span>{/if}
|
||||
<ul class="card-list grow">
|
||||
{#each list as e (e.id)}
|
||||
{@const KindIcon = KIND_ICONS[e.kind]}
|
||||
@@ -189,6 +193,10 @@
|
||||
.date { flex: none; width: 52px; display: flex; flex-direction: column; align-items: center; padding-top: var(--s2); }
|
||||
.dow { font-size: var(--fs-xs); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-2); }
|
||||
.dnum { font-size: var(--fs-xl); font-weight: 750; line-height: 1.1; }
|
||||
/* Today: same filled day number and label as the Lunch screen, and its events get the same tint. */
|
||||
.today .dnum { background: var(--primary); color: var(--on-primary); border-radius: var(--r-full); padding: 0 var(--s2); min-width: 2ch; text-align: center; margin-top: 2px; }
|
||||
.today-tag { margin-top: var(--s1); font-size: var(--fs-xs); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--primary-text); }
|
||||
.today .event { background: color-mix(in srgb, var(--primary-soft) 60%, var(--surface)); border-color: color-mix(in srgb, var(--primary) 35%, var(--border)); border-left-color: var(--child); }
|
||||
|
||||
.event { background: var(--surface); border: 1px solid var(--border); border-left: 4px solid var(--child); border-radius: var(--r-lg); padding: var(--s1) var(--s2) var(--s1) var(--s2); box-shadow: var(--shadow-1); }
|
||||
.item { width: 100%; min-height: 56px; justify-content: flex-start; gap: var(--s3); padding: var(--s1); border: 0; background: none; text-align: left; white-space: normal; font-weight: 400; }
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import Utensils from '@lucide/svelte/icons/utensils';
|
||||
import { api, type LunchDay } from '../lib/api';
|
||||
import { app, toast } from '../lib/state.svelte';
|
||||
import { monthLabel, parse, shiftMonth, thisMonth } from '../lib/dates';
|
||||
import { iso, 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';
|
||||
@@ -31,6 +31,7 @@
|
||||
const open = $derived(days?.filter((d) => !d.greyed) ?? []); // days that can be picked
|
||||
const allPicked = $derived(open.length > 0 && open.every((d) => d.picked));
|
||||
let bulkBusy = $state(false);
|
||||
const today = iso(new Date());
|
||||
|
||||
async function load() {
|
||||
if (childId === null) return;
|
||||
@@ -127,13 +128,15 @@
|
||||
<ul class="days">
|
||||
{#each days as d (d.date)}
|
||||
{@const date = parse(d.date)}
|
||||
<li class="day card" class:closed={d.greyed} class:picked={d.picked}>
|
||||
{@const isToday = d.date === today}
|
||||
<li class="day card" class:closed={d.greyed} class:picked={d.picked} class:today={isToday} aria-current={isToday ? 'date' : undefined}>
|
||||
<div class="day-head">
|
||||
<span class="date-tile" aria-hidden="true">
|
||||
<span class="dow">{date.toLocaleDateString(undefined, { weekday: 'short' })}</span>
|
||||
<span class="dnum tabular">{date.getDate()}</span>
|
||||
{#if isToday}<span class="today-tag">Today</span>{/if}
|
||||
</span>
|
||||
<span class="visually-hidden">{date.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })}</span>
|
||||
<span class="visually-hidden">{isToday ? 'Today, ' : ''}{date.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })}</span>
|
||||
<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. -->
|
||||
@@ -177,6 +180,11 @@
|
||||
.day { display: flex; flex-direction: column; gap: var(--s3); transition: border-color var(--dur) var(--ease), box-shadow var(--dur) var(--ease); }
|
||||
.day.picked { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary), var(--shadow-1); }
|
||||
.day.closed { background: var(--day-closed); box-shadow: none; }
|
||||
/* Today: the same filled day number as the month view, plus a label so it isn't color alone. */
|
||||
.day.today:not(.closed) { background: color-mix(in srgb, var(--primary-soft) 60%, var(--surface)); }
|
||||
.today .dnum { background: var(--primary); color: var(--on-primary); border-radius: var(--r-full); padding: 0 var(--s2); min-width: 2ch; text-align: center; }
|
||||
.today .date-tile { align-items: center; }
|
||||
.today-tag { font-size: var(--fs-xs); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--primary-text); }
|
||||
.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; }
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import CalendarDays from '@lucide/svelte/icons/calendar-days';
|
||||
import TriangleAlert from '@lucide/svelte/icons/triangle-alert';
|
||||
import { api, KINDS, type EventValues, type Scan } from '../lib/api';
|
||||
import { app, toast } from '../lib/state.svelte';
|
||||
import { whenLabel } from '../lib/dates';
|
||||
import { KIND_ICONS } from '../lib/icons';
|
||||
import EventEditor from './EventEditor.svelte';
|
||||
|
||||
// A published scan's events, editable like on the Calendar tab. An event with no child is on no calendar,
|
||||
// so this is the only place it can be found and fixed.
|
||||
let { scan = $bindable() }: { scan: Scan } = $props();
|
||||
let editing = $state<number | null>(null);
|
||||
const events = $derived(scan.events.filter((e) => e.status === 'confirmed'));
|
||||
const untagged = $derived(events.filter((e) => !e.child_ids.length).length);
|
||||
const children = $derived(new Map(app.household!.children.map((c) => [c.id, c])));
|
||||
|
||||
async function run(work: () => Promise<unknown>, done: string) {
|
||||
try {
|
||||
await work();
|
||||
editing = null;
|
||||
scan = await api<Scan>(`/scans/${scan.id}`);
|
||||
toast(done);
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
const save = (id: number) => (v: EventValues) => run(() => api(`/events/${id}`, { method: 'PATCH', json: v }), 'Saved');
|
||||
const remove = (id: number) => () => run(() => api(`/events/${id}`, { method: 'DELETE' }), 'Deleted');
|
||||
</script>
|
||||
|
||||
{#if events.length}
|
||||
<section class="section fix">
|
||||
<h2 class="section-title">
|
||||
<CalendarDays size={16} aria-hidden="true" />Fix the events
|
||||
<span class="count">{events.length} {events.length === 1 ? 'event' : 'events'}</span>
|
||||
</h2>
|
||||
{#if untagged}
|
||||
<div class="alert warn" role="status">
|
||||
<TriangleAlert size={18} aria-hidden="true" />
|
||||
<span>{untagged === 1 ? '1 event has' : `${untagged} events have`} no child picked, so {untagged === 1 ? "it isn't" : "they aren't"} on any calendar. Tap one and pick a child.</span>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="hint">Tap an event to change it. Changes show on the Calendar tab as soon as you save.</p>
|
||||
{/if}
|
||||
<ul class="card-list">
|
||||
{#each events as e (e.id)}
|
||||
{@const KindIcon = KIND_ICONS[e.kind]}
|
||||
{@const none = !e.child_ids.length}
|
||||
<li class="event" class:none style:--child={children.get(e.child_ids[0])?.color ?? 'var(--warn)'}>
|
||||
<button class="item" onclick={() => (editing = editing === e.id ? null : e.id)} aria-expanded={editing === e.id}>
|
||||
<span class="kind"><KindIcon size={18} aria-hidden="true" /></span>
|
||||
<span class="grow">
|
||||
<span class="title">{e.title}</span>
|
||||
<span class="meta">{whenLabel(e)} · {KINDS[e.kind]}</span>
|
||||
{#if none}<span class="meta warn"><TriangleAlert size={12} aria-hidden="true" /> Not on any calendar</span>{/if}
|
||||
</span>
|
||||
</button>
|
||||
{#if editing === e.id}
|
||||
<EventEditor event={e} onsave={save(e.id)} oncancel={() => (editing = null)} ondelete={remove(e.id)} />
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.fix { margin-top: var(--s6); }
|
||||
/* Same rows as the Calendar list. */
|
||||
.event { background: var(--surface); border: 1px solid var(--border); border-left: 4px solid var(--child); border-radius: var(--r-lg); padding: var(--s1) var(--s2); box-shadow: var(--shadow-1); }
|
||||
.event.none { border-left-style: dashed; }
|
||||
.item { width: 100%; min-height: 56px; justify-content: flex-start; gap: var(--s3); padding: var(--s1); border: 0; background: none; text-align: left; white-space: normal; font-weight: 400; }
|
||||
.item:hover:not(:disabled) { background: none; }
|
||||
.item:active:not(:disabled) { transform: none; }
|
||||
.kind { display: grid; place-items: center; width: 36px; height: 36px; flex: none; border-radius: var(--r-md); background: var(--surface-2); color: var(--text-2); }
|
||||
.title { display: block; font-weight: 650; color: var(--text); }
|
||||
.meta { display: block; font-size: var(--fs-sm); color: var(--text-2); }
|
||||
.meta.warn { display: flex; align-items: center; gap: var(--s1); color: var(--warn); font-weight: 600; }
|
||||
</style>
|
||||
@@ -1,8 +1,11 @@
|
||||
<script lang="ts">
|
||||
import CalendarDays from '@lucide/svelte/icons/calendar-days';
|
||||
import ChevronDown from '@lucide/svelte/icons/chevron-down';
|
||||
import ChartColumn from '@lucide/svelte/icons/chart-column';
|
||||
import CircleAlert from '@lucide/svelte/icons/circle-alert';
|
||||
import CircleCheck from '@lucide/svelte/icons/circle-check';
|
||||
import Download from '@lucide/svelte/icons/download';
|
||||
import ImageIcon from '@lucide/svelte/icons/image';
|
||||
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
|
||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||
import Sandwich from '@lucide/svelte/icons/sandwich';
|
||||
@@ -13,6 +16,7 @@
|
||||
import PageHeader from '../lib/ui/PageHeader.svelte';
|
||||
import StatusBadge from './StatusBadge.svelte';
|
||||
import MenuDays from './MenuDays.svelte';
|
||||
import ScanEvents from './ScanEvents.svelte';
|
||||
import Review from './Review.svelte';
|
||||
|
||||
let { id }: { id: number } = $props();
|
||||
@@ -20,6 +24,9 @@
|
||||
let error = $state('');
|
||||
let confirmDiscard = $state(false);
|
||||
let retrying = $state(false);
|
||||
let statsOpen = $state(false);
|
||||
// SHOW_SCAN_STATS: model, tokens and time, for comparing models. Shown once the scan has been read.
|
||||
const hasStats = $derived(app.household!.show_scan_stats && scan?.model != null && scan?.seconds != null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
@@ -75,8 +82,26 @@
|
||||
</div>
|
||||
{:else}
|
||||
<PageHeader title={scan.source_name} subtitle={monthLabel(scan.hint_month)} back="#/">
|
||||
{#snippet actions()}<StatusBadge status={scan!.status} />{/snippet}
|
||||
{#snippet actions()}
|
||||
<StatusBadge status={scan!.status} />
|
||||
{#if hasStats}
|
||||
<button class="sm" aria-expanded={statsOpen} aria-controls="scan-stats" onclick={() => (statsOpen = !statsOpen)}>
|
||||
<ChartColumn size={16} aria-hidden="true" />Scan Stats
|
||||
</button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
{#if hasStats && statsOpen}
|
||||
<dl id="scan-stats" class="card stats tabular">
|
||||
<div class="wide"><dt>Model</dt><dd>{scan.model}</dd></div>
|
||||
<div><dt>Tokens used</dt><dd>{scan.cost_tokens?.toLocaleString() ?? '—'}</dd></div>
|
||||
<!-- Photos are deleted after IMAGE_RETENTION_DAYS, and the page count with them. -->
|
||||
<div><dt>Pages</dt><dd>{scan.image_keys.length || '—'}</dd></div>
|
||||
<div><dt>Events</dt><dd>{scan.events.length}</dd></div>
|
||||
{#if scan.menu_items.length}<div><dt>Menu days</dt><dd>{scan.menu_items.length}</dd></div>{/if}
|
||||
<div><dt>Scan time</dt><dd>{scan.seconds!.toFixed(1)} s</dd></div>
|
||||
</dl>
|
||||
{/if}
|
||||
|
||||
{#if scan.status === 'queued'}
|
||||
<section class="card state" aria-live="polite">
|
||||
@@ -119,6 +144,27 @@
|
||||
<a class="btn ghost" href="#/calendar"><CalendarDays size={18} aria-hidden="true" /> See the calendar</a>
|
||||
</div>
|
||||
</section>
|
||||
{#if scan.image_keys.length}
|
||||
<!-- Closed by default; the lazy images only load once it's opened. -->
|
||||
<details class="card photos">
|
||||
<summary>
|
||||
<ImageIcon size={18} aria-hidden="true" />
|
||||
<span class="grow">{scan.image_keys.length === 1 ? 'Scanned photo' : `Scanned photos (${scan.image_keys.length})`}</span>
|
||||
<ChevronDown size={20} class="chev" aria-hidden="true" />
|
||||
</summary>
|
||||
<div class="photo-list">
|
||||
{#each scan.image_keys as _, i}
|
||||
<a href={`/api/scans/${scan.id}/pages/${i + 1}`} target="_blank" rel="noopener">
|
||||
<img loading="lazy" src={`/api/scans/${scan.id}/pages/${i + 1}`} alt={`Page ${i + 1} of the scanned ${scan.source_name}`} />
|
||||
</a>
|
||||
{/each}
|
||||
<p class="hint">Tap the photo to open it full size and zoom in.</p>
|
||||
</div>
|
||||
</details>
|
||||
{:else}
|
||||
<p class="hint photos-gone">The photo was deleted after the retention period.</p>
|
||||
{/if}
|
||||
<ScanEvents bind:scan />
|
||||
{#if scan.menu_items.length}<div class="menu-fix"><MenuDays bind:scan /></div>{/if}
|
||||
{:else}
|
||||
<Review bind:scan />
|
||||
@@ -141,6 +187,19 @@
|
||||
.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); }
|
||||
.photos { margin-top: var(--s4); padding: 0; overflow: hidden; }
|
||||
.photos summary { display: flex; align-items: center; gap: var(--s2); min-height: 48px; padding: 0 var(--s4); cursor: pointer; font-weight: 600; list-style: none; }
|
||||
.photos summary::-webkit-details-marker { display: none; }
|
||||
.photos :global(.chev) { color: var(--text-2); transition: transform var(--dur) var(--ease); }
|
||||
.photos[open] :global(.chev) { transform: rotate(180deg); }
|
||||
.photo-list { display: flex; flex-direction: column; gap: var(--s3); padding: 0 var(--s4) var(--s4); }
|
||||
.photo-list a { display: block; border: 1px solid var(--border); border-radius: var(--r-md); overflow: hidden; background: var(--surface-2); }
|
||||
.photo-list img { display: block; width: 100%; height: auto; }
|
||||
.photos-gone { margin-top: var(--s4); text-align: center; }
|
||||
.menu-fix { margin-top: var(--s6); }
|
||||
.stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: var(--s3) var(--s4); margin: calc(-1 * var(--s2)) 0 var(--s4); }
|
||||
.stats dt { font-size: var(--fs-xs); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-2); }
|
||||
.stats dd { margin: 0; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.stats .wide { grid-column: 1 / -1; }
|
||||
.discard { margin-top: var(--s6); padding-top: var(--s4); border-top: 1px solid var(--border); display: flex; gap: var(--s2); }
|
||||
</style>
|
||||
@@ -56,7 +56,7 @@
|
||||
source = { name: s.name ?? '', layout_type: s.layout_type ?? 'month_grid', double_read: s.double_read ?? false,
|
||||
ignore_text: (s.ignore_labels ?? []).join(', '), applies_filter: s.applies_filter ?? '',
|
||||
prompt_notes: s.prompt_notes ?? '', default_calendar_id: s.default_calendar_id ?? null,
|
||||
child_ids: [...(s.child_ids ?? [])] };
|
||||
child_ids: [...(s.child_ids ?? h.children.map((k) => k.id))] };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ 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 logging
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import sys
|
||||
@@ -35,6 +37,26 @@ 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,
|
||||
"SHOW_SCAN_STATS": int(config.SHOW_SCAN_STATS),
|
||||
"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()))
|
||||
|
||||
# The app's own log lines (scan timings, failures), formatted like uvicorn's. Other libraries stay at warnings.
|
||||
logging.basicConfig(format="%(levelname)s: %(message)s")
|
||||
logging.getLogger("parentcalendar").setLevel(logging.INFO)
|
||||
uvicorn.run("parentcalendar.app:app", host=config.HOST, port=config.PORT, proxy_headers=True)
|
||||
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ CHILD_IDS = "(SELECT json_group_array(child_id) FROM {table} WHERE {key} = {alia
|
||||
def state(conn: DB):
|
||||
return {
|
||||
"timezone": conn.execute("SELECT timezone FROM user WHERE id = 1").fetchone()[0],
|
||||
"show_scan_stats": config.SHOW_SCAN_STATS,
|
||||
"children": db.all_dicts(conn.execute("SELECT * FROM child ORDER BY id")),
|
||||
"calendars": db.all_dicts(conn.execute(
|
||||
f"SELECT c.*, {CHILD_IDS.format(table='calendar_child', key='calendar_id', alias='c')} FROM calendar c ORDER BY id")),
|
||||
@@ -541,6 +542,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 +550,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
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ STATIC_DIR = ROOT / "static"
|
||||
HOST = os.environ.get("HOST", "127.0.0.1")
|
||||
PORT = int(os.environ.get("PORT", "8000"))
|
||||
IMAGE_RETENTION_DAYS = int(os.environ.get("IMAGE_RETENTION_DAYS", "90"))
|
||||
SHOW_SCAN_STATS = os.environ.get("SHOW_SCAN_STATS", "").strip().lower() in ("1", "true", "yes") # model and time on scan pages
|
||||
MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # per file
|
||||
CANCELLED_DAYS = 30 # deleted events stay in exports as STATUS:CANCELLED this long
|
||||
# Stored in each new event's UID; changing PUBLIC_URL later never changes existing UIDs.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -88,6 +88,7 @@ def process_scan(conn, scan_id):
|
||||
hint = {"year": year, "month": month, "layout": source["layout_type"], "filter": source["applies_filter"]}
|
||||
ignore = extraction.IGNORE | {w.strip().lower() for w in source["ignore_labels"] if w.strip()}
|
||||
runs = []
|
||||
started = time.time()
|
||||
for key in scan["image_keys"]:
|
||||
img = extraction.load_image(config.IMAGES_DIR / key)
|
||||
run = extraction.extract_image(img, hint, double_read=source["double_read"], ignore=ignore)
|
||||
@@ -124,6 +125,11 @@ def process_scan(conn, scan_id):
|
||||
(dates[0] if dates else None, dates[-1] if dates else None, runs[0]["model"] if runs else None,
|
||||
tokens, round(sum(r["seconds"] for r in runs), 1), scan_id))
|
||||
match_drafts(conn, scan_id)
|
||||
events = sum(len(r["result"]["events"]) for r in runs)
|
||||
menus = sum(len(r["result"]["menu_items"]) for r in runs)
|
||||
log.info("scan %s (%s, %s) read in %.1fs: %d page(s), %d events, %d menu days, model %s (%s), %d tokens",
|
||||
scan_id, source["name"], scan["hint_month"], time.time() - started, len(runs), events, menus,
|
||||
runs[0]["model"] if runs else "-", ", ".join(dict.fromkeys(r["method"] for r in runs)) or "-", tokens)
|
||||
|
||||
|
||||
def overlaps(a, b):
|
||||
@@ -215,11 +221,12 @@ def run_worker(stop):
|
||||
if job is None:
|
||||
stop.wait(2)
|
||||
continue
|
||||
started = time.time()
|
||||
try:
|
||||
process_scan(conn, job["scan_id"])
|
||||
status, error = "done", None
|
||||
except Exception as e: # any failure marks this scan failed; the worker keeps serving the queue
|
||||
log.exception("scan %s failed", job["scan_id"])
|
||||
log.exception("scan %s failed after %.1fs", job["scan_id"], time.time() - started)
|
||||
status, error = "failed", f"{type(e).__name__}: {e}"
|
||||
with conn:
|
||||
conn.execute("UPDATE job SET status = ?, error = ? WHERE id = ?", (status, error, job["id"]))
|
||||
|
||||
+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