From 990c703bb29bbe5ad73b51fbdbded52e671ae4ac Mon Sep 17 00:00:00 2001 From: fireshaper Date: Wed, 28 Oct 2026 14:29:06 -0400 Subject: [PATCH] AI pedigree scanning, reusable ancestors, rabbit merge and delete Pedigree scanning - Replace the Tesseract OCR pipeline with server-side Claude vision (claude-opus-5-5, structured output) that reads any layout, printed or handwritten, into the subject + 14 ancestors with per-field uncertainty - New POST/GET /api/pedigree-scan; enabled by ANTHROPIC_API_KEY in .env - Add Rabbit step 1: "Scan a pedigree" card, photo review panel, "Check" badges on uncertain ancestors, sire/dam herd matching - Remove vendored Tesseract assets and OCR libs; bump SW cache to v2 - nginx /api read timeout raised to 180s for slow scans Ancestors - Manual ancestors default to 3 lbs and Unknown DOB (legacy 0 lbs read as 3); weight/DOB shown in the tree and on Review - On save, ancestors entered as text become linked rabbit records (status Other), deduped by ear number or name + farm Existing rabbits - Adding a rabbit that already exists tops up the existing record: fills empty fields, adds missing ancestors, promotes reference ancestors; never overwrites. Review step warns beforehand - Delete button on rabbit detail; delete snapshots full ancestor JSON to offspring, clears litter links and timeline events, and is blocked while the rabbit has show entries Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- memory-bank/FEATURE_OCR_Pedigree_Scan.md | 2 + .../IMPLEMENTATION_OCR_Pedigree_Scan.md | 2 + memory-bank/architecture.md | 12 +- memory-bank/progress.md | 8 + mykindlebox/README.md | 15 + mykindlebox/client/nginx.conf | 3 + mykindlebox/client/package-lock.json | 127 +------ mykindlebox/client/package.json | 4 +- mykindlebox/client/public/sw.js | 59 +-- .../client/public/tesseract/eng.traineddata | Bin 5199098 -> 0 bytes .../tesseract/tesseract-core-lstm.wasm.js | 191 ---------- .../tesseract-core-relaxedsimd-lstm.wasm.js | 191 ---------- .../tesseract-core-simd-lstm.wasm.js | 191 ---------- .../client/public/tesseract/worker.min.js | 3 - mykindlebox/client/src/App.jsx | 3 - mykindlebox/client/src/api.js | 13 + .../client/src/components/PedigreeScan.jsx | 198 ++++++++++ .../client/src/components/PedigreeTree.jsx | 65 +++- .../client/src/components/ReviewStep.jsx | 38 +- mykindlebox/client/src/lib/imageResize.js | 25 ++ mykindlebox/client/src/lib/ocrLayout.js | 172 --------- mykindlebox/client/src/lib/ocrLayout.test.js | 92 ----- mykindlebox/client/src/lib/ocrParse.js | 176 --------- mykindlebox/client/src/lib/ocrParse.test.js | 133 ------- mykindlebox/client/src/lib/ocrPipeline.js | 113 ------ mykindlebox/client/src/lib/ocrPreprocess.js | 83 ----- mykindlebox/client/src/lib/ocrRun.js | 70 ---- mykindlebox/client/src/lib/pedigree.js | 80 +++- mykindlebox/client/src/lib/pedigree.test.js | 58 +++ mykindlebox/client/src/pages/AddRabbit.jsx | 132 ++++++- mykindlebox/client/src/pages/OcrDev.jsx | 195 ---------- mykindlebox/client/src/pages/RabbitDetail.jsx | 27 +- mykindlebox/docker-compose.yml | 8 +- mykindlebox/server/index.ts | 2 + mykindlebox/server/lib/ancestors.test.ts | 182 +++++++++ mykindlebox/server/lib/ancestors.ts | 344 ++++++++++++++++++ mykindlebox/server/lib/pedigreeScan.ts | 221 +++++++++++ mykindlebox/server/package-lock.json | 94 ++++- mykindlebox/server/package.json | 4 +- mykindlebox/server/routes/pedigreeScan.ts | 86 +++++ mykindlebox/server/routes/rabbits.ts | 92 ++++- 42 files changed, 1669 insertions(+), 1847 deletions(-) delete mode 100644 mykindlebox/client/public/tesseract/eng.traineddata delete mode 100644 mykindlebox/client/public/tesseract/tesseract-core-lstm.wasm.js delete mode 100644 mykindlebox/client/public/tesseract/tesseract-core-relaxedsimd-lstm.wasm.js delete mode 100644 mykindlebox/client/public/tesseract/tesseract-core-simd-lstm.wasm.js delete mode 100644 mykindlebox/client/public/tesseract/worker.min.js create mode 100644 mykindlebox/client/src/components/PedigreeScan.jsx create mode 100644 mykindlebox/client/src/lib/imageResize.js delete mode 100644 mykindlebox/client/src/lib/ocrLayout.js delete mode 100644 mykindlebox/client/src/lib/ocrLayout.test.js delete mode 100644 mykindlebox/client/src/lib/ocrParse.js delete mode 100644 mykindlebox/client/src/lib/ocrParse.test.js delete mode 100644 mykindlebox/client/src/lib/ocrPipeline.js delete mode 100644 mykindlebox/client/src/lib/ocrPreprocess.js delete mode 100644 mykindlebox/client/src/lib/ocrRun.js delete mode 100644 mykindlebox/client/src/pages/OcrDev.jsx create mode 100644 mykindlebox/server/lib/ancestors.test.ts create mode 100644 mykindlebox/server/lib/ancestors.ts create mode 100644 mykindlebox/server/lib/pedigreeScan.ts create mode 100644 mykindlebox/server/routes/pedigreeScan.ts diff --git a/CLAUDE.md b/CLAUDE.md index 327bd67..c7540b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,7 +65,7 @@ There are only two core tables — no separate "Kit" table: Kits are full Rabbit records from birth, linked to a litter implicitly via matching `sire_id`/`dam_id`/DOB — the Litter detail page derives its kit list by querying Rabbits rather than a join table. When adding features touching litters or kits, preserve this derivation rather than introducing a Kit entity. -Ancestor pedigree nodes that aren't in the DB are stored as freeform text rather than null FKs, so entry is never blocked by incomplete ancestor records. +Ancestor pedigree nodes that aren't in the DB are entered as freeform text (manual-ancestor JSON) rather than null FKs, so entry is never blocked by incomplete ancestor records. On rabbit creation the server then materializes those entries into real, linked Rabbit records (status `other`, deduped by ear number or name + farm — `server/lib/ancestors.ts`) so ancestors are reusable; text columns remain only for branches above an Unknown parent and for legacy data. Weight is stored as a decimal float in lbs internally; lb+oz vs decimal display is a per-user setting applied at the read/write boundary, not stored separately. diff --git a/memory-bank/FEATURE_OCR_Pedigree_Scan.md b/memory-bank/FEATURE_OCR_Pedigree_Scan.md index e0573f6..6e12f09 100644 --- a/memory-bank/FEATURE_OCR_Pedigree_Scan.md +++ b/memory-bank/FEATURE_OCR_Pedigree_Scan.md @@ -1,5 +1,7 @@ # MyKindlebox — OCR Pedigree Scanning Feature +> **Superseded 2026-10-28 — AI vision replaces Tesseract.** Position-based OCR could not handle the real variety of pedigrees (horizontal vs vertical, color vs B&W, handwritten vs printed, every breeder's own format). Per the user's decisions, scanning now sends the photo from the server to the **Claude API** (`claude-opus-5-5`), which returns the subject + all 14 ancestors as schema-validated JSON with per-field "uncertain" flags. Tesseract, the vendored `/tesseract/` assets, the `lib/ocr*` modules, the zone-picker plan, and the "no LLM / fully client-side / offline" constraint are **dropped**. The photo is **not stored**. Scanning needs internet and `ANTHROPIC_API_KEY` on the server; without it the button is hidden. Still in force from this doc: the Add Rabbit Step 1 integration point, Q2/Q3/Q6 (sire/dam herd matching: exact ear number, else unique exact name; a matched branch locks to the herd record; everything else lands as editable manual nodes), and Q4 normalization (decimal lbs, YYYY-MM-DD). See `architecture.md` → `lib/pedigreeScan.ts`, `routes/pedigreeScan.ts`, `PedigreeScan.jsx`. The rest of this document is historical. + **Feature:** OCR-assisted pedigree entry with manual zone correction fallback **Stack:** Node.js / React / SQLite **Packages:** `tesseract.js`, `jimp`, `react-image-crop` diff --git a/memory-bank/IMPLEMENTATION_OCR_Pedigree_Scan.md b/memory-bank/IMPLEMENTATION_OCR_Pedigree_Scan.md index 18d1bf5..05ac2f4 100644 --- a/memory-bank/IMPLEMENTATION_OCR_Pedigree_Scan.md +++ b/memory-bank/IMPLEMENTATION_OCR_Pedigree_Scan.md @@ -1,5 +1,7 @@ # MyKindlebox — OCR Pedigree Scanning: Implementation Plan +> **Superseded 2026-10-28.** This Tesseract plan was abandoned after Phase 3 in favor of server-side Claude vision extraction — see the note at the top of `FEATURE_OCR_Pedigree_Scan.md`. Kept for history only. + **Feature:** OCR-assisted pedigree entry with manual zone correction fallback **Reference:** `FEATURE_OCR_Pedigree_Scan.md`, `architecture.md` **Scope:** Base feature only — image capture, OCR pipeline, form pre-fill, zone picker correction. diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 8db4ea3..219553b 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -35,6 +35,9 @@ docker-compose.yml — orchestrates both services for self-hosted deployment | `routes/settings.ts` | Express `Router` mounted at `/api/settings`. `GET /` flattens the `settings` table into a single object; `PATCH /` rejects any request containing a key outside the `rabbitry_name`/`weight_unit` allowlist. No casing conversion needed — settings keys are stored and read as snake_case directly. | | `routes/stats.ts` | Express `Router` mounted at `/api/stats`. Returns dashboard counts (`herd`, `bucks`, `does`, `kits`, `for_sale`, `on_hold`, `sold`, `archived`) computed live from `rabbits` on every request — no cached counters. `kits` reuses the same 56-day `dob` window as the rabbits list's `sex=kits` filter; `archived` sums `status IN ('other', 'deceased')`. | | `routes/import.ts` | Express `Router` mounted at `/api/import` (behind `requireAuth`). `POST /` accepts a single multipart `file` (multer **memory** storage, 10 MB cap) and **replaces the entire herd**: it wipes `litters` then `rabbits` and inserts the file's rabbits, all inside one `db.transaction` so a parse/insert failure rolls back and leaves existing data untouched. Auto-detects three shapes: **legacy CSV** (the old PHP Kindlebox export — header has `ID`/`GENDER`/`SIRE_ID`), **native CSV** (snake_case headers matching the API), and **JSON** (a rabbit array or `{ rabbits: [...] }`). Legacy mapping: `GENDER` 1→buck/0→doe; numeric `STATUS` via `LEGACY_STATUS_MAP` (0 Herd→active, 1 For Sale→for_sale, 2 Pending Sale→on_hold, 3 Sold→sold, 4 Other→other, 5 Dead→deceased — sourced from the original `editrabbit.php` status ` + ) + + if (scanning) { + return ( + + +
+

Reading the pedigree…

+

This usually takes 20–60 seconds. Keep this page open.

+
+
+ ) + } + + if (!result) { + return ( + + {fileInput} +
+

Have a paper pedigree?

+

+ Take or upload a photo and the tree fills itself in. Printed or handwritten, any layout. +

+ {error && ( +

+ {error} +

+ )} +
+ +
+ ) + } + + const lines = uncertaintyLines(result) + + return ( + + {fileInput} +
+
+
+

Pedigree scanned — check it before saving

+

+ Everything is filled in as editable entries. Compare against the photo and tap any ancestor to fix it. +

+
+ +
+ + {(result.matches?.sire || result.matches?.dam) && ( +

+ Linked to your herd:{' '} + {[result.matches.sire && `Sire ${result.matches.sire.name}`, result.matches.dam && `Dam ${result.matches.dam.name}`] + .filter(Boolean) + .join(' · ')} + . Their ancestors come from your records. +

+ )} + + {lines.length > 0 && ( +
+

Double-check these (marked “Check”):

+
    + {lines.map((line) => ( +
  • {line}
  • + ))} +
+
+ )} + + {result.notes &&

Note: {result.notes}

} + {error && ( +

+ {error} +

+ )} +
+ + {imageUrl && ( +
+ + {photoOpen && ( +
+ + Scanned pedigree + +

Tap the photo to open it full size.

+
+ )} +
+ )} +
+ ) +} + +export default PedigreeScan diff --git a/mykindlebox/client/src/components/PedigreeTree.jsx b/mykindlebox/client/src/components/PedigreeTree.jsx index 5f77eac..9ff5449 100644 --- a/mykindlebox/client/src/components/PedigreeTree.jsx +++ b/mykindlebox/client/src/components/PedigreeTree.jsx @@ -2,6 +2,8 @@ import { useEffect, useRef, useState } from 'react' import { searchRabbits, getRabbitAncestors } from '../api' import { handleResultKeyDown, handleSearchInputKeyDown } from '../lib/keyboardNav' import { useMediaQuery } from '../hooks/useMediaQuery' +import { useWeightUnit } from '../hooks/useWeightUnit' +import { formatWeight } from '../lib/format' import { ALL_SLOTS, GEN1, @@ -14,9 +16,18 @@ import { serializeManualAncestor, parseManualAncestor, formatManualAncestorDisplay, + formatManualAncestorDetails, + MANUAL_ANCESTOR_DEFAULTS, } from '../lib/pedigree' -const EMPTY_MANUAL_FORM = { name: '', farm: '', ear_number: '', weight_lbs: '0', dob: '', dobUnknown: false } +const EMPTY_MANUAL_FORM = { + name: '', + farm: '', + ear_number: '', + weight_lbs: String(MANUAL_ANCESTOR_DEFAULTS.weight_lbs), + dob: '', + dobUnknown: true, +} // Upper bound on the popup's height (its tallest state is the manual-entry // form). Used to decide drop direction synchronously on open, before the popup @@ -57,14 +68,15 @@ function manualFormFromParsed(parsed) { return { ...EMPTY_MANUAL_FORM, ...parsed, - weight_lbs: String(parsed?.weight_lbs ?? '0'), + weight_lbs: String(parsed?.weight_lbs ?? MANUAL_ANCESTOR_DEFAULTS.weight_lbs), dob: isKnownDate ? parsed.dob : '', dobUnknown: !isKnownDate, } } -function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fetchCascade }) { +function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fetchCascade, flagged = false }) { const node = resolveEffectiveNode(path, state, cascadeCache) + const weightUnit = useWeightUnit() const [open, setOpen] = useState(false) const [mode, setMode] = useState('search') // 'search' | 'manual' const [query, setQuery] = useState('') @@ -163,6 +175,8 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe const displayText = node.mode === 'unknown' ? 'Unknown' : node.mode === 'manual' ? formatManualAncestorDisplay(node.name) : node.name + const detailText = + node.mode === 'manual' ? formatManualAncestorDetails(node.name, (w) => formatWeight(w, weightUnit)) : null return (
@@ -180,14 +194,22 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe setManualForm(EMPTY_MANUAL_FORM) } }} - className="flex h-16 w-full min-w-0 flex-col items-start justify-center gap-0.5 overflow-hidden rounded-md border border-border bg-surface-raised px-3 py-2 text-left hover:border-accent focus:outline-none focus:ring-2 focus:ring-accent" + className={`flex min-h-16 w-full min-w-0 flex-col items-start justify-center gap-0.5 overflow-hidden rounded-md border bg-surface-raised px-3 py-2 text-left hover:border-accent focus:outline-none focus:ring-2 focus:ring-accent ${ + flagged ? 'border-accent-warm ring-1 ring-accent-warm' : 'border-border' + }`} > - {label} + + {label} + {flagged && ( + Check + )} + {displayText} + {detailText && {detailText}} {open && ( @@ -278,7 +300,9 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe className="w-full rounded border border-border px-2 py-2 text-sm uppercase md:py-1 focus:outline-none focus:ring-2 focus:ring-accent" />
- + {entries.map(({ path, slot, label }) => ( @@ -349,6 +373,7 @@ function Generation({ entries, state, setSlotState, cascadeCache, fetchCascade } setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} + flagged={flaggedSlots.has(slot)} /> ))}
@@ -366,7 +391,16 @@ function StackedBranch({ path, slotProps }) { return (
- + {childPaths.length > 0 && (
{childPaths.map((childPath) => ( @@ -383,6 +417,9 @@ function PedigreeTree({ initialState, initialCascade, onChange, + // Slots a pedigree scan was unsure about. Each is highlighted until the user + // edits it (tracked locally in `reviewedSlots`). + uncertainSlots, }) { const [state, setState] = useState(() => initialState ?? createInitialPedigreeState()) const [cascadeCache, setCascadeCache] = useState(() => initialCascade ?? new Map()) @@ -392,8 +429,12 @@ function PedigreeTree({ const cascadeFetchStarted = useRef(new Set()) const wide = useMediaQuery(WIDE_TREE_QUERY) + const [reviewedSlots, setReviewedSlots] = useState(() => new Set()) + const flaggedSlots = new Set([...(uncertainSlots ?? [])].filter((slot) => !reviewedSlots.has(slot))) + function setSlotState(slot, value) { setState((prev) => ({ ...prev, [slot]: value })) + setReviewedSlots((prev) => new Set(prev).add(slot)) } function fetchCascade(id) { @@ -421,7 +462,7 @@ function PedigreeTree({ }, []) if (!wide) { - const slotProps = { state, setSlotState, cascadeCache, fetchCascade } + const slotProps = { state, setSlotState, cascadeCache, fetchCascade, flaggedSlots } return (
{GEN1.map(({ path, slot }) => ( @@ -441,9 +482,9 @@ function PedigreeTree({
{subjectName}
- - - + + +
) } diff --git a/mykindlebox/client/src/components/ReviewStep.jsx b/mykindlebox/client/src/components/ReviewStep.jsx index 18119f6..6e7e75d 100644 --- a/mykindlebox/client/src/components/ReviewStep.jsx +++ b/mykindlebox/client/src/components/ReviewStep.jsx @@ -1,6 +1,7 @@ import Card from './Card' import { LOCI, generateColorString } from '../lib/genetics' import { SEX_LABELS, STATUS_LABELS, formatDate, formatWeight } from '../lib/format' +import { formatManualAncestorDetails } from '../lib/pedigree' // Display order + formatting for the Details card. Empty values are skipped // (except Name, which is required), so the review shows what was entered @@ -59,20 +60,53 @@ function Rows({ rows }) { ) } -function ReviewStep({ details, pedigreeSummary, genetics, weightUnit, onEdit }) { +function ReviewStep({ details, pedigreeSummary, genetics, weightUnit, onEdit, existingMatch }) { // Sire and Dam always show (so "Unknown" parents are visible at a glance); // deeper ancestors only when something was actually entered. const pedigreeRows = pedigreeSummary .filter((node) => node.slot === 'sire' || node.slot === 'dam' || (node.display && node.display !== 'Unknown')) - .map((node) => [node.label, node.display]) + .map((node) => [ + node.label, + node.manualText + ? `${node.display} — ${formatManualAncestorDetails(node.manualText, (w) => formatWeight(w, weightUnit))}` + : node.display, + ]) const setLoci = LOCI.filter((locus) => genetics[locus.key]) const colorString = generateColorString(genetics) return (
+ {existingMatch && ( +
+

+ {existingMatch.name} + {existingMatch.ear_number ? ` (${existingMatch.ear_number})` : ''} is already in your herd + {existingMatch.status === 'other' ? ' as a pedigree ancestor' : ''}. +

+

+ Saving will update that rabbit instead of adding a copy: it fills in details and ancestors it’s missing + {existingMatch.status === 'other' ? ' and moves it into your herd' : ''}. Nothing already recorded is + changed.{' '} + + Open it in a new tab + +

+
+ )}
onEdit(1)}> + {pedigreeSummary.some((node) => node.manualText) && ( +

+ Ancestors that aren’t already in your herd will be saved as rabbits (status Other, under Archived) so + you can reuse them. Ones that match an existing rabbit by ear number or name and farm are linked instead. +

+ )}
onEdit(2)}> diff --git a/mykindlebox/client/src/lib/imageResize.js b/mykindlebox/client/src/lib/imageResize.js new file mode 100644 index 0000000..32512f6 --- /dev/null +++ b/mykindlebox/client/src/lib/imageResize.js @@ -0,0 +1,25 @@ +// Shrinks a photo before upload. Phone photos are 4000px+ and several MB; the +// scanning model reads up to ~2576px on the long edge, so 2400px keeps small +// handwriting legible while cutting upload time and per-scan cost. Re-encodes +// as JPEG, which also normalizes PNG/WebP/HEIC-from-Safari into one type. +// `imageOrientation: 'from-image'` applies the camera's EXIF rotation so the +// pedigree isn't sent sideways. +export const SCAN_MAX_LONG_EDGE = 2400 + +export async function downscaleImage(file, maxLongEdge = SCAN_MAX_LONG_EDGE, quality = 0.88) { + const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' }) + const scale = Math.min(1, maxLongEdge / Math.max(bitmap.width, bitmap.height)) + const canvas = document.createElement('canvas') + canvas.width = Math.round(bitmap.width * scale) + canvas.height = Math.round(bitmap.height * scale) + canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height) + bitmap.close() + + return new Promise((resolve, reject) => { + canvas.toBlob( + (blob) => (blob ? resolve(blob) : reject(new Error('Couldn’t process that photo.'))), + 'image/jpeg', + quality + ) + }) +} diff --git a/mykindlebox/client/src/lib/ocrLayout.js b/mykindlebox/client/src/lib/ocrLayout.js deleted file mode 100644 index 155cc47..0000000 --- a/mykindlebox/client/src/lib/ocrLayout.js +++ /dev/null @@ -1,172 +0,0 @@ -// Pedigree layout analysis (OCR Pedigree Scanning, Phase 3, Steps 3.2–3.4). -// -// A printed pedigree lays its ancestor cells out in vertical columns — one per -// generation — that fan left→right: subject, then parents, then grandparents, -// and (on a real 4–5 generation slip) great- and great-great-grandparents -// beyond. Within a column the cells stack vertically and double each generation -// (1 subject, 2 parents, 4 grandparents, …). -// -// These pure helpers take the flat word list from `runOCR` (each word carrying a -// `bbox` of {x0,y0,x1,y1}) and slice it into those columns/cells purely from the -// words' geometry — no OCR is re-run here. Column detection is *adaptive*: it -// finds the real whitespace gutters between generations rather than assuming a -// fixed number of equal-width bands, so it works regardless of how many -// generations the photo happens to contain. The pipeline (ocrPipeline.js) then -// anchors on the subject column and keeps only the first three generations. - -// Two words whose top edges (y0) fall within this many pixels of each other are -// treated as being on the same printed line by `aggregateCellText`. Coordinates -// are in the preprocessed image space (<=1600px wide), so a few px of slop -// absorbs OCR baseline jitter without merging adjacent rows. -export const LINE_GROUP_TOLERANCE = 10 - -/** - * Detect the pedigree's generation columns from the whitespace gutters between - * them. Projects every word onto an x-axis coverage histogram, treats a run of - * empty bins at least `minGutterPx` wide as a column break, and buckets each - * word into the column whose x-range contains the word's horizontal center. - * - * Unlike a fixed 3-band split, this adapts to however many generations are in - * the image — the caller decides which columns are subject/parents/grandparents. - * - * @param {object[]} words words with a `bbox` ({x0,x1}) - * @param {number} imageWidth full image width in px - * @param {object} [opts] - * @param {number} [opts.binSize] x-histogram bin width in px - * @param {number} [opts.minGutterPx] min empty-run width that splits two columns - * @returns {object[][]} non-empty column word arrays, ordered left→right - */ -export function detectColumns(words, imageWidth, opts = {}) { - if (words.length === 0) return [] - - const binSize = opts.binSize ?? Math.max(2, Math.round(imageWidth / 400)) - const minGutterPx = opts.minGutterPx ?? imageWidth * 0.02 - const minGutterBins = Math.max(1, Math.round(minGutterPx / binSize)) - - const binCount = Math.max(1, Math.ceil(imageWidth / binSize)) - const coverage = new Array(binCount).fill(0) - for (const word of words) { - const start = Math.max(0, Math.floor(word.bbox.x0 / binSize)) - const end = Math.min(binCount - 1, Math.floor(word.bbox.x1 / binSize)) - for (let b = start; b <= end; b += 1) coverage[b] += 1 - } - - // Walk the histogram; a covered run is a column, an empty run >= minGutterBins - // closes the current column. - const ranges = [] - let runStart = null - let emptyRun = 0 - for (let b = 0; b < binCount; b += 1) { - if (coverage[b] > 0) { - if (runStart === null) runStart = b - emptyRun = 0 - } else if (runStart !== null) { - emptyRun += 1 - if (emptyRun >= minGutterBins) { - ranges.push({ x0: runStart * binSize, x1: (b - emptyRun + 1) * binSize }) - runStart = null - emptyRun = 0 - } - } - } - if (runStart !== null) ranges.push({ x0: runStart * binSize, x1: binCount * binSize }) - - const columns = ranges.map((r) => ({ ...r, words: [] })) - for (const word of words) { - const cx = (word.bbox.x0 + word.bbox.x1) / 2 - let target = columns.find((c) => cx >= c.x0 && cx < c.x1) - if (!target) { - // Word center landed in a gutter — attach to the nearest column. - let bestDist = Infinity - for (const c of columns) { - const d = Math.abs(cx - (c.x0 + c.x1) / 2) - if (d < bestDist) { - bestDist = d - target = c - } - } - } - if (target) target.words.push(word) - } - - return columns.map((c) => c.words).filter((w) => w.length > 0) -} - -/** - * Split one column's words into `n` vertically-stacked cells by cutting at the - * `n - 1` largest vertical gaps between adjacent words (sorted by top edge). - * If the column has fewer words than cells, the trailing cells come back empty. - * - * @param {object[]} columnWords one column's words - * @param {number} n number of cells (e.g. 2 for parents, 4 for grandparents) - * @returns {object[][]} exactly `n` word arrays, top→bottom - */ -export function splitIntoCells(columnWords, n) { - const sorted = [...columnWords].sort((a, b) => a.bbox.y0 - b.bbox.y0) - if (n <= 1) return [sorted] - - const gaps = [] - for (let i = 1; i < sorted.length; i += 1) { - gaps.push({ index: i, size: sorted[i].bbox.y0 - sorted[i - 1].bbox.y0 }) - } - const cuts = gaps - .sort((a, b) => b.size - a.size) - .slice(0, n - 1) - .map((g) => g.index) - .sort((a, b) => a - b) - - const cells = [] - let start = 0 - for (const cut of cuts) { - cells.push(sorted.slice(start, cut)) - start = cut - } - cells.push(sorted.slice(start)) - while (cells.length < n) cells.push([]) - return cells -} - -/** - * Split a parents column into the sire (upper) and dam (lower) halves — the - * common n=2 case of `splitIntoCells`, named for readability at the call site. - * - * @param {object[]} columnWords parents-column words - * @returns {{ sire: object[], dam: object[] }} - */ -export function splitSireDam(columnWords) { - const [sire, dam] = splitIntoCells(columnWords, 2) - return { sire, dam } -} - -/** - * Reassemble one cell's words into a multiline string. Words are grouped into - * lines by their top edge (within LINE_GROUP_TOLERANCE of the line's first - * word), each line is ordered left-to-right, and lines are joined top-to-bottom - * with newlines — reproducing roughly what is printed in that cell. - * - * @param {object[]} words one cell's words - * @returns {string} - */ -export function aggregateCellText(words) { - if (words.length === 0) return '' - - const byTop = [...words].sort((a, b) => a.bbox.y0 - b.bbox.y0) - const lines = [] - let anchorY = null - for (const word of byTop) { - if (anchorY === null || word.bbox.y0 - anchorY > LINE_GROUP_TOLERANCE) { - lines.push([]) - anchorY = word.bbox.y0 - } - lines[lines.length - 1].push(word) - } - - return lines - .map((line) => - line - .sort((a, b) => a.bbox.x0 - b.bbox.x0) - .map((word) => word.text) - .join(' '), - ) - .join('\n') -} diff --git a/mykindlebox/client/src/lib/ocrLayout.test.js b/mykindlebox/client/src/lib/ocrLayout.test.js deleted file mode 100644 index 2c38864..0000000 --- a/mykindlebox/client/src/lib/ocrLayout.test.js +++ /dev/null @@ -1,92 +0,0 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { detectColumns, splitIntoCells, splitSireDam, aggregateCellText } from './ocrLayout.js' - -// Minimal word factory: only the fields the layout helpers read. -function word(text, x0, y0, x1 = x0 + 50, y1 = y0 + 20) { - return { text, bbox: { x0, y0, x1, y1 }, confidence: 90 } -} - -test('detectColumns splits at whitespace gutters, ignoring how many columns there are', () => { - // Three generation columns with wide gutters between them; the parents and - // grandparents columns each have multiple vertically-stacked words at the - // same x — those must stay in one column, not split. - const words = [ - word('Subject', 40, 500), // col A ~40–90 - word('Sire', 400, 250), // col B ~400–450 - word('Dam', 400, 750), - word('GrandA', 800, 120), // col C ~800–850 - word('GrandB', 800, 380), - word('GrandC', 800, 640), - word('GrandD', 800, 900), - ] - const cols = detectColumns(words, 1600) - assert.equal(cols.length, 3) - assert.deepEqual(cols[0].map((w) => w.text), ['Subject']) - assert.deepEqual(cols[1].map((w) => w.text).sort(), ['Dam', 'Sire']) - assert.deepEqual(cols[2].map((w) => w.text).sort(), ['GrandA', 'GrandB', 'GrandC', 'GrandD']) -}) - -test('detectColumns keeps words of one wide box together (no false split within a column)', () => { - // A single cell spanning x 40–300 across several lines: small inter-word gaps - // must not be mistaken for a column gutter. - const words = [ - word("BASTET'S", 40, 100, 140), - word('SHANGHEI', 150, 100, 300), - word('Sable', 40, 130, 110), - word('Point', 120, 130, 200), - ] - const cols = detectColumns(words, 1600) - assert.equal(cols.length, 1) - assert.equal(cols[0].length, 4) -}) - -test('splitIntoCells cuts a grandparents column into 4 by the largest vertical gaps', () => { - const col = [ - word('SS', 800, 100), - word('SS2', 800, 122), - word('SD', 800, 400), - word('DS', 800, 700), - word('DD', 800, 1000), - ] - const cells = splitIntoCells(col, 4) - assert.equal(cells.length, 4) - assert.deepEqual(cells[0].map((w) => w.text), ['SS', 'SS2']) - assert.deepEqual(cells[1].map((w) => w.text), ['SD']) - assert.deepEqual(cells[2].map((w) => w.text), ['DS']) - assert.deepEqual(cells[3].map((w) => w.text), ['DD']) -}) - -test('splitIntoCells pads with empty cells when there are too few words', () => { - const cells = splitIntoCells([word('only', 0, 0)], 4) - assert.equal(cells.length, 4) - assert.deepEqual(cells[0].map((w) => w.text), ['only']) - assert.deepEqual(cells[1], []) - assert.deepEqual(cells[3], []) -}) - -test('splitSireDam cuts at the largest vertical gap', () => { - const column = [ - word('Sire', 350, 40), - word('line2', 350, 62), - word('Dam', 350, 400), - word('line2', 350, 422), - ] - const { sire, dam } = splitSireDam(column) - assert.deepEqual(sire.map((w) => w.text), ['Sire', 'line2']) - assert.deepEqual(dam.map((w) => w.text), ['Dam', 'line2']) -}) - -test('aggregateCellText groups lines by y0 and orders words left-to-right', () => { - const words = [ - word('Thumper', 120, 50), - word("Belowland's", 40, 52), - word('AB12', 60, 130), - word('Tattoo:', 40, 128), - ] - assert.equal(aggregateCellText(words), "Belowland's Thumper\nTattoo: AB12") -}) - -test('aggregateCellText returns empty string for no words', () => { - assert.equal(aggregateCellText([]), '') -}) diff --git a/mykindlebox/client/src/lib/ocrParse.js b/mykindlebox/client/src/lib/ocrParse.js deleted file mode 100644 index fa9097f..0000000 --- a/mykindlebox/client/src/lib/ocrParse.js +++ /dev/null @@ -1,176 +0,0 @@ -// Pedigree cell field extraction (OCR Pedigree Scanning, Phase 3, Step 3.5). -// -// Turns one cell's reassembled multiline text (from `aggregateCellText`) into -// the five structured fields the Add Rabbit wizard consumes. Everything here is -// pure string parsing with no OCR involved, so it is fully unit-testable -// (ocrParse.test.js). The OCR-side field names (`farmName`/`rabbitName`/ -// `earNumber`/`weight`) are remapped to the serializer's shape later, in the -// wizard integration (plan Phase 5) — never here. - -// "BASTET'S SHANGHEI" → farm "BASTET'S", name "SHANGHEI". Non-greedy first group -// so only the leading possessive token is split off as the farm. Case-insensitive -// so an ALL-CAPS possessive ("BASTET'S") matches its `'S` — real pedigrees are -// frequently printed entirely in caps. -const POSSESSIVE_RE = /^(.+?'s?)\s+(.+)$/i -// A line that is *only* a possessive farm token, with the rabbit's name printed -// on the following line ("BASTET'S" / "SHANGHEI"). -const BARE_POSSESSIVE_RE = /^.+'s?$/i - -// Ear-number / tattoo label, matched case-insensitively up to (and including) -// the separator; the value after it is matched case-sensitively as uppercase + -// digits so trailing lowercase letters from a following word aren't absorbed. -const EAR_LABEL_RE = /(?:tattoo|ear(?:\s*(?:#|no\.?|number))?)\s*[:#]?\s*/i -const EAR_VALUE_RE = /([A-Z0-9]{2,8})\b/ -// A *bare* (unlabelled) ear token must contain at least one digit. Pedigrees are -// often all-caps, so an unlabelled all-letter token like "SHANGHEI"/"SHAMWOW" is -// a rabbit name, not an ear number — requiring a digit keeps names out of the -// ear field. (Labelled values, via EAR_VALUE_RE above, don't need a digit.) -const BARE_EAR_RE = /^(?=[A-Z0-9]*\d)([A-Z0-9]{2,8})$/ - -// MM/DD/YYYY, MM-DD-YYYY, MM/DD/YY, M/D/YY — separators / or -, 2- or 4-digit year. -const DATE_RE = /\b(\d{1,2})[/-](\d{1,2})[/-](\d{2}|\d{4})\b/ - -// "8 lbs 4 oz" (also "8# 4oz") and "8.5 lbs" / "8.5 lb". A bare number with no -// unit is intentionally not treated as a weight. -const WEIGHT_LBS_OZ_RE = /(\d+(?:\.\d+)?)\s*(?:lbs?\.?|#)\s*(\d+(?:\.\d+)?)\s*oz\b/i -const WEIGHT_LBS_RE = /(\d+(?:\.\d+)?)\s*(?:lbs?\.?|#)\b/i - -// Lines beginning with one of these are field labels, not the ancestor's name, -// so they're skipped when picking the name line. -const LABEL_PREFIXES = [ - 'tattoo', 'ear', 'dob', 'd.o.b', 'date', 'born', 'weight', 'wt', - 'color', 'colour', 'variety', 'sex', 'breed', 'reg', 'grand', -] - -function round2(n) { - return Math.round(n * 100) / 100 -} - -// A line is a "label line" if it leads with a known field label, or is itself a -// detectable ear number / date / weight value — anything that clearly isn't the -// printed name of the ancestor. -function isLabelLine(line) { - const lower = line.toLowerCase() - if (LABEL_PREFIXES.some((p) => lower.startsWith(p))) return true - if (BARE_EAR_RE.test(line)) return true - if (DATE_RE.test(line)) return true - if (WEIGHT_LBS_RE.test(line)) return true - return false -} - -function parseName(lines) { - const idx = lines.findIndex((line) => !isLabelLine(line)) - if (idx === -1) return { farmName: null, rabbitName: null } - const nameLine = lines[idx] - - // Farm and name on one line: "BASTET'S SHANGHEI". - const match = nameLine.match(POSSESSIVE_RE) - if (match) { - return { farmName: match[1].trim(), rabbitName: match[2].trim() } - } - // Farm alone on its line, rabbit name on the next non-label line. - if (BARE_POSSESSIVE_RE.test(nameLine)) { - const next = lines.slice(idx + 1).find((line) => !isLabelLine(line)) - if (next) return { farmName: nameLine.trim(), rabbitName: next.trim() } - } - return { farmName: null, rabbitName: nameLine } -} - -function parseEarNumber(lines) { - // Labelled value takes precedence ("Tattoo: AB12"). - for (const line of lines) { - const label = line.match(EAR_LABEL_RE) - if (!label) continue - const after = line.slice(label.index + label[0].length) - const value = after.match(EAR_VALUE_RE) - if (value) return value[1].toUpperCase() - } - // Otherwise a bare alphanumeric token alone on its own line. - for (const line of lines) { - const bare = line.match(BARE_EAR_RE) - if (bare) return bare[1].toUpperCase() - } - return null -} - -function parseDob(lines) { - for (const line of lines) { - const match = line.match(DATE_RE) - if (!match) continue - - const month = parseInt(match[1], 10) - const day = parseInt(match[2], 10) - let year = parseInt(match[3], 10) - // Two-digit years pivot on the current year: a DOB is never in the future - // (+1 slop for a slip printed around New Year), and older slips genuinely - // carry 1990s ancestors — so "24" → 2024 but "98" → 1998. - if (match[3].length === 2) { - const pivot = (new Date().getFullYear() + 1) % 100 - year += year <= pivot ? 2000 : 1900 - } - - // new Date(y, m, 0) is the last day of month m, so this rejects impossible - // dates like 2/31 instead of emitting an invalid "YYYY-02-31" string. - const daysInMonth = new Date(year, month, 0).getDate() - if (month < 1 || month > 12 || day < 1 || day > daysInMonth) continue - - const mm = String(month).padStart(2, '0') - const dd = String(day).padStart(2, '0') - return `${year}-${mm}-${dd}` - } - return null -} - -function parseWeight(lines) { - // Prefer the lbs+oz form (it also partially matches the lbs-only regex). - for (const line of lines) { - const lo = line.match(WEIGHT_LBS_OZ_RE) - if (lo) return round2(parseFloat(lo[1]) + parseFloat(lo[2]) / 16) - } - for (const line of lines) { - const l = line.match(WEIGHT_LBS_RE) - if (l) return parseFloat(l[1]) - } - return null -} - -/** - * Extract the five pedigree fields from one cell's multiline OCR text. - * - * @param {string} cellText output of `aggregateCellText` - * @returns {{ farmName: string|null, rabbitName: string|null, - * earNumber: string|null, dob: string|null, weight: number|null }} - * `dob` is `YYYY-MM-DD` or null; `weight` is decimal lbs or null. Missing - * fields are null — the "Unknown"/0 sentinels are only applied later, at save. - */ -export function parseCellFields(cellText) { - const lines = (cellText || '') - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0) - - return { - ...parseName(lines), - earNumber: parseEarNumber(lines), - dob: parseDob(lines), - weight: parseWeight(lines), - } -} - -// Structural labels every printed ancestor cell carries (even when the value is -// blank): Ear #, Weight, DOB, Sex, Color, Legs, Reg #, GC #, Variety, Tattoo. -const CELL_LABEL_RE = - /\b(?:ear|tattoo|weight|wt|d\.?o\.?b|dob|sex|colou?r|legs?|reg|gc|variety)\b/i - -/** - * Heuristic: does this cell text read like a real ancestor cell rather than the - * form's title/header or stray margin text? A genuine cell always has at least - * one printed field label; the title ("Bastet's Bunnies Holland Lop Pedigree") - * has none. The pipeline uses this to drop header text that lands in a cell slot. - * - * @param {string} cellText - * @returns {boolean} - */ -export function looksLikeAncestorCell(cellText) { - return CELL_LABEL_RE.test(cellText || '') -} diff --git a/mykindlebox/client/src/lib/ocrParse.test.js b/mykindlebox/client/src/lib/ocrParse.test.js deleted file mode 100644 index 4a9a6b3..0000000 --- a/mykindlebox/client/src/lib/ocrParse.test.js +++ /dev/null @@ -1,133 +0,0 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { parseCellFields, looksLikeAncestorCell } from './ocrParse.js' - -// --- The three cases required by plan Step 3.5 --- - -test('case 1: all five fields present and labelled', () => { - const cell = [ - "Belowland's Thumper", - 'Tattoo: AB12', - 'DOB: 03/15/2024', - 'Weight: 8 lbs 4 oz', - ].join('\n') - - assert.deepEqual(parseCellFields(cell), { - farmName: "Belowland's", - rabbitName: 'Thumper', - earNumber: 'AB12', - dob: '2024-03-15', - weight: 8.25, - }) -}) - -test('case 2: only name and ear number (DOB and weight absent)', () => { - const cell = ['Clover', 'RZ7'].join('\n') - - assert.deepEqual(parseCellFields(cell), { - farmName: null, - rabbitName: 'Clover', - earNumber: 'RZ7', - dob: null, - weight: null, - }) -}) - -test('case 3: name has no possessive (rabbit name only, no farm)', () => { - const cell = ['Midnight', 'Ear #: XY99'].join('\n') - - const result = parseCellFields(cell) - assert.equal(result.farmName, null) - assert.equal(result.rabbitName, 'Midnight') - assert.equal(result.earNumber, 'XY99') -}) - -// --- Per-field rule coverage --- - -test('possessive split handles a plain "s without apostrophe-s', () => { - // "Lund Lops' Seabreeze" → farm "Lund Lops'", name "Seabreeze" - const result = parseCellFields("Lund Lops' Seabreeze") - assert.equal(result.farmName, "Lund Lops'") - assert.equal(result.rabbitName, 'Seabreeze') -}) - -test('weight: decimal lbs form', () => { - assert.equal(parseCellFields('Bunny\nWeight 8.5 lbs').weight, 8.5) -}) - -test('weight: bare number with no unit is not a weight', () => { - assert.equal(parseCellFields('Bunny\n8.5').weight, null) -}) - -test('dob: 2-digit year maps to 20xx and slashes/dashes both parse', () => { - assert.equal(parseCellFields('Bunny\nborn 5/9/24').dob, '2024-05-09') - assert.equal(parseCellFields('Bunny\n12-25-2023').dob, '2023-12-25') -}) - -test('dob: 2-digit year beyond the current-year pivot maps to 19xx', () => { - // Older slips carry 1990s ancestors; "98" must not become a future 2098 DOB. - assert.equal(parseCellFields('Bunny\nDOB: 3/15/98').dob, '1998-03-15') -}) - -test('dob: invalid month is rejected', () => { - assert.equal(parseCellFields('Bunny\n13/40/2024').dob, null) -}) - -test('dob: impossible day for the month is rejected', () => { - assert.equal(parseCellFields('Bunny\n2/31/2024').dob, null) - // 2024 is a leap year — 2/29 is real. - assert.equal(parseCellFields('Bunny\n2/29/2024').dob, '2024-02-29') - assert.equal(parseCellFields('Bunny\n2/29/2023').dob, null) -}) - -test('ear number: labelled value wins over a later bare token', () => { - const result = parseCellFields('Rocky\nTattoo: GH42\nREW') - assert.equal(result.earNumber, 'GH42') -}) - -// --- ALL-CAPS pedigrees (real-world: BASTET'S SHANGHEI etc.) --- - -test('all-caps possessive splits farm from name on one line', () => { - const result = parseCellFields('BASTET’S SHANGHEI'.replace('’', "'")) - assert.equal(result.farmName, "BASTET'S") - assert.equal(result.rabbitName, 'SHANGHEI') -}) - -test('farm on its own line, rabbit name on the next line', () => { - const cell = ["BASTET'S", 'SHAMWOW', 'Ear #: BSHAMW', 'Weight: 3 # 15 oz'].join('\n') - const result = parseCellFields(cell) - assert.equal(result.farmName, "BASTET'S") - assert.equal(result.rabbitName, 'SHAMWOW') - assert.equal(result.earNumber, 'BSHAMW') -}) - -test('an all-caps name is not mistaken for a bare ear number', () => { - // "SHANGHEI" is all letters → a name, not an ear. The labelled ear wins. - const cell = ['MIDNIGHT', 'Ear #: XY99'].join('\n') - const result = parseCellFields(cell) - assert.equal(result.rabbitName, 'MIDNIGHT') - assert.equal(result.earNumber, 'XY99') -}) - -test('a bare all-letter token is not treated as an ear number', () => { - // No label, no digit → not an ear; it becomes the name instead. - const result = parseCellFields('SHAMWOW') - assert.equal(result.earNumber, null) - assert.equal(result.rabbitName, 'SHAMWOW') -}) - -test('looksLikeAncestorCell distinguishes a real cell from the form title', () => { - assert.equal(looksLikeAncestorCell("BASTET'S SHAMWOW\nEar #: BSHAMW\nWeight: 3 lbs"), true) - assert.equal(looksLikeAncestorCell("Bastet's Bunnies\nHolland Lop"), false) - assert.equal(looksLikeAncestorCell(''), false) -}) - -test('empty / whitespace cell yields all nulls', () => { - assert.deepEqual(parseCellFields(' \n \n'), { - farmName: null, - rabbitName: null, - earNumber: null, - dob: null, - weight: null, - }) -}) diff --git a/mykindlebox/client/src/lib/ocrPipeline.js b/mykindlebox/client/src/lib/ocrPipeline.js deleted file mode 100644 index 44895b7..0000000 --- a/mykindlebox/client/src/lib/ocrPipeline.js +++ /dev/null @@ -1,113 +0,0 @@ -// Full pedigree OCR pipeline (OCR Pedigree Scanning, Phase 3, Step 3.6). -// -// Orchestrates the Phase 3 pieces end to end: OCR a preprocessed pedigree image, -// figure out which columns are the subject / parents / grandparents (real -// pedigrees carry 4–5 generations plus margin text, so this is done adaptively -// and anchored on the subject rather than by fixed bands), and parse each cell -// into structured fields. The returned object is what the Add Rabbit wizard -// turns into the pedigree tree's initial state (plan Phase 5). - -import { runOCR } from './ocrRun.js' -import { detectColumns, splitIntoCells, aggregateCellText } from './ocrLayout.js' -import { parseCellFields, looksLikeAncestorCell } from './ocrParse.js' - -const EMPTY_CELL = { farmName: null, rabbitName: null, earNumber: null, dob: null, weight: null } - -// Words from the certification/signature block and the printed form title sit -// outside the ancestor tree and would otherwise pollute the subject/sire cells. -// Matched case-insensitively as whole words. Kept deliberately small and -// pedigree-boilerplate-specific so it can't eat a real rabbit/farm name (note: -// "pedigree" is safe — no rabbit is named that — but breed words like "lop" are -// intentionally NOT here, since farms are often named " Lops"). -const BOILERPLATE_RE = - /\b(?:certify|certifies|certified|hereby|signed|knowledge|belief|pedigree|printed|software|registrar|phone)\b/i - -// Positive signal that a column's text is an actual rabbit cell (vs. the cert -// block or stray margin text): a labelled ear/tattoo/weight/DOB, or a possessive -// farm token. Used to find the subject column and skip the left margin. -const RABBIT_CELL_RE = /(?:ear\s*#|tattoo|weight|d\.?o\.?b|\bsex\b|'s?\b)/i - -function isBoilerplateWord(text) { - return BOILERPLATE_RE.test(text) -} - -// Parse a cell, but reject text that doesn't read like a real ancestor cell -// (the form title/header has no field labels) so it can't masquerade as a rabbit. -function parseCell(words) { - const text = aggregateCellText(words) - if (!looksLikeAncestorCell(text)) return { ...EMPTY_CELL } - return parseCellFields(text) -} - -// The subject is the leftmost column that reads like a rabbit cell — anything to -// its left (a certification/signature margin) is skipped. Falls back to column 0 -// if nothing matches, so a poorly-OCR'd subject still yields a best effort. -function findSubjectColumnIndex(columns) { - for (let i = 0; i < columns.length; i += 1) { - if (RABBIT_CELL_RE.test(aggregateCellText(columns[i]))) return i - } - return 0 -} - -/** - * Synchronous core of the pipeline: from a flat word list + image width, produce - * the seven-cell pedigree. Split out from `extractPedigree` so it can be unit- - * tested and reused by the dev harness without re-running OCR. - * - * Real pedigrees stack the four grandparents vertically in a single column - * (top→bottom: sire_sire, sire_dam, dam_sire, dam_dam), so they're split by the - * three largest vertical gaps — not into a 2×2 quadrant grid. - * - * @param {object[]} words flat words from `runOCR` - * @param {number} imageWidth full image width in px - * @returns {object} seven keys, each a `parseCellFields` result - */ -export function extractFromWords(words, imageWidth) { - const cleaned = words.filter((w) => !isBoilerplateWord(w.text)) - const columns = detectColumns(cleaned, imageWidth) - - const subjectIdx = findSubjectColumnIndex(columns) - const subjectWords = columns[subjectIdx] ?? [] - const parentWords = columns[subjectIdx + 1] ?? [] - const grandparentWords = columns[subjectIdx + 2] ?? [] - - const [sireWords, damWords] = splitIntoCells(parentWords, 2) - const [ssWords, sdWords, dsWords, ddWords] = splitIntoCells(grandparentWords, 4) - - return { - subject: parseCell(subjectWords), - sire: parseCell(sireWords), - dam: parseCell(damWords), - sire_sire: parseCell(ssWords), - sire_dam: parseCell(sdWords), - dam_sire: parseCell(dsWords), - dam_dam: parseCell(ddWords), - } -} - -// Decode a blob just to read its pixel width — `detectColumns` needs it and the -// Tesseract `Page` doesn't expose it. createImageBitmap is available in every -// browser this app targets. -async function imageWidthOf(blob) { - const bitmap = await createImageBitmap(blob) - const { width } = bitmap - bitmap.close?.() - return width -} - -/** - * Extract a full pedigree from a preprocessed image blob. - * - * @param {Blob} imageBlob preprocessed (binarized, <=1600px) pedigree image - * @returns {Promise} seven keys — `subject`, `sire`, `dam`, - * `sire_sire`, `sire_dam`, `dam_sire`, `dam_dam` — each the - * `{ farmName, rabbitName, earNumber, dob, weight }` shape from - * `parseCellFields`. Sparse cells yield null fields rather than throwing. - */ -export async function extractPedigree(imageBlob) { - const [{ words }, imageWidth] = await Promise.all([ - runOCR(imageBlob), - imageWidthOf(imageBlob), - ]) - return extractFromWords(words, imageWidth) -} diff --git a/mykindlebox/client/src/lib/ocrPreprocess.js b/mykindlebox/client/src/lib/ocrPreprocess.js deleted file mode 100644 index 3d2e634..0000000 --- a/mykindlebox/client/src/lib/ocrPreprocess.js +++ /dev/null @@ -1,83 +0,0 @@ -// Client-side image preprocessing for OCR (OCR Pedigree Scanning, Phase 2). -// -// Tesseract reads a printed pedigree far more reliably from a downscaled, -// high-contrast black-and-white image than from a raw phone photo. This module -// does that normalization entirely with the browser canvas API — no image -// library (jimp was deliberately not added) — and returns a PNG Blob ready to -// hand to the OCR pipeline (Phase 3). - -// Tesseract gains nothing from images wider than this and slows down on them, -// so anything wider is scaled down proportionally. Narrower photos are left at -// their native size (never upscaled). -export const MAX_WIDTH = 1600 - -// Luminance cutoff for the black/white binarization pass: pixels at or above it -// become white (255), below become black (0). Tuned down from the 128 starting -// point to 103 during OCR Phase 2 Step 2.2 validation — at 128, a real photographed -// pedigree's uneven lighting / shadowed paper edges binarized into large black -// blobs that swallowed the bottom-row cells; 103 keeps those cells legible. -// Lower it further if blobs reappear, raise it if faint text drops out. -export const BINARIZE_THRESHOLD = 103 - -// Decode a File/Blob into a loaded HTMLImageElement, cleaning up the object URL. -function decodeImage(file) { - return new Promise((resolve, reject) => { - const url = URL.createObjectURL(file) - const img = new Image() - img.onload = () => { - URL.revokeObjectURL(url) - resolve(img) - } - img.onerror = () => { - URL.revokeObjectURL(url) - reject(new Error('Could not decode the selected image.')) - } - img.src = url - }) -} - -// Promisified canvas.toBlob (PNG). -function canvasToBlob(canvas) { - return new Promise((resolve, reject) => { - canvas.toBlob((blob) => { - if (blob) resolve(blob) - else reject(new Error('Failed to encode the processed image.')) - }, 'image/png') - }) -} - -/** - * Preprocess an image for OCR: downscale to <= MAX_WIDTH, convert to grayscale, - * then binarize to pure black/white. Accepts a File or Blob; resolves to a PNG - * Blob. The original is not retained — only this processed blob is used by the - * scanner and the zone picker (their bounding boxes live in this coordinate space). - */ -export async function preprocessImage(file) { - const img = await decodeImage(file) - - // Scale down proportionally only when wider than MAX_WIDTH; never upscale. - const scale = img.width > MAX_WIDTH ? MAX_WIDTH / img.width : 1 - const width = Math.round(img.width * scale) - const height = Math.round(img.height * scale) - - const canvas = document.createElement('canvas') - canvas.width = width - canvas.height = height - const ctx = canvas.getContext('2d') - ctx.drawImage(img, 0, 0, width, height) - - // One pass over the pixels: grayscale (standard luminance) then threshold. - const imageData = ctx.getImageData(0, 0, width, height) - const px = imageData.data - for (let i = 0; i < px.length; i += 4) { - const luma = 0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2] - const bw = luma >= BINARIZE_THRESHOLD ? 255 : 0 - px[i] = bw - px[i + 1] = bw - px[i + 2] = bw - // alpha (px[i + 3]) is left untouched - } - ctx.putImageData(imageData, 0, 0) - - return canvasToBlob(canvas) -} diff --git a/mykindlebox/client/src/lib/ocrRun.js b/mykindlebox/client/src/lib/ocrRun.js deleted file mode 100644 index e7a2cd9..0000000 --- a/mykindlebox/client/src/lib/ocrRun.js +++ /dev/null @@ -1,70 +0,0 @@ -// Tesseract OCR runner (OCR Pedigree Scanning, Phase 3, Step 3.1). -// -// Wraps tesseract.js with the locally-vendored runtime assets (OCR Phase 1) so -// nothing is fetched from a CDN at runtime — the app is self-hosted and must -// work offline. Accepts a preprocessed image blob (OCR Phase 2) and returns a -// flat list of recognized words, each with its bounding box and confidence, for -// the layout/parse stages (Steps 3.2–3.6) to slice into pedigree cells. - -import { createWorker } from 'tesseract.js' - -// Vendored under client/public/tesseract/ — served from the site root in both -// Vite dev and the nginx/dist production build. corePath is a *directory* so -// tesseract.js v7 auto-selects the right LSTM core variant for the browser -// (plain / simd / relaxedsimd); langPath + gzip:false are required because the -// vendored eng.traineddata is uncompressed (default gzip:true 404s on .gz). -const TESSERACT_BASE = '/tesseract/' - -// Words below this Tesseract confidence are dropped as OCR noise before any -// layout analysis runs, so a stray speck never lands in a pedigree cell. -export const MIN_WORD_CONFIDENCE = 60 - -// A single worker is reused across scans (loading the WASM core + traineddata -// is expensive, ~9MB). Created lazily on the first runOCR call. -let workerPromise = null - -function getWorker() { - if (!workerPromise) { - workerPromise = createWorker('eng', undefined, { - workerPath: `${TESSERACT_BASE}worker.min.js`, - corePath: TESSERACT_BASE, - langPath: TESSERACT_BASE, - gzip: false, - }) - } - return workerPromise -} - -/** - * Run Tesseract on a preprocessed image blob. - * - * tesseract.js v7 nests recognized text as blocks → paragraphs → lines → words - * and only populates that structure when `blocks: true` is requested (the - * default output is text-only). We flatten it to plain `words`/`lines` arrays — - * the shape the layout stage expects — and drop low-confidence words. - * - * @param {Blob} imageBlob preprocessed (binarized, <=1600px) image - * @returns {Promise<{ words: object[], lines: object[] }>} flattened result; - * each word has `text`, `bbox` ({x0,y0,x1,y1}), and `confidence`. - */ -export async function runOCR(imageBlob) { - const worker = await getWorker() - const { data } = await worker.recognize(imageBlob, {}, { blocks: true }) - - const words = [] - const lines = [] - for (const block of data.blocks ?? []) { - for (const paragraph of block.paragraphs ?? []) { - for (const line of paragraph.lines ?? []) { - lines.push(line) - for (const word of line.words ?? []) { - if (word.confidence >= MIN_WORD_CONFIDENCE) { - words.push(word) - } - } - } - } - } - - return { words, lines } -} diff --git a/mykindlebox/client/src/lib/pedigree.js b/mykindlebox/client/src/lib/pedigree.js index 1003c97..456ccd8 100644 --- a/mykindlebox/client/src/lib/pedigree.js +++ b/mykindlebox/client/src/lib/pedigree.js @@ -48,20 +48,26 @@ const TEXT_COLUMN_BY_SLOT = { // Manually-entered ancestors capture more than a bare name (Farm, Ear Number, // Weight, DOB), but each `_name_text` column is still a single text field, so -// the extra fields are persisted as a JSON string in that same column. Only -// Name/Farm/Ear Number are meaningful for the user to fill in; Weight and DOB -// default to placeholders ancestor records never had real values for. -const MANUAL_ANCESTOR_DEFAULTS = { weight_lbs: 0, dob: 'Unknown' } +// the extra fields are persisted as a JSON string in that same column. +// Name/Farm/Ear Number are required on entry; Weight and DOB are always stored +// too, falling back to these defaults (user rule: no weight → 3 lbs, no DOB → +// "Unknown"). Entries saved before the 3 lb default stored weight 0 to mean +// "not entered", so a non-positive weight is read back as the default as well. +export const MANUAL_ANCESTOR_DEFAULTS = { weight_lbs: 3, dob: 'Unknown' } + +function normalizeAncestorWeight(weight) { + const value = Number(weight) + return weight === '' || weight === null || weight === undefined || !Number.isFinite(value) || value <= 0 + ? MANUAL_ANCESTOR_DEFAULTS.weight_lbs + : value +} export function serializeManualAncestor({ name, farm, ear_number, weight_lbs, dob }) { return JSON.stringify({ name: name?.trim() || '', farm: farm?.trim() || '', ear_number: ear_number?.trim() || '', - weight_lbs: - weight_lbs === '' || weight_lbs === null || weight_lbs === undefined - ? MANUAL_ANCESTOR_DEFAULTS.weight_lbs - : Number(weight_lbs), + weight_lbs: normalizeAncestorWeight(weight_lbs), dob: dob?.trim() || MANUAL_ANCESTOR_DEFAULTS.dob, }) } @@ -78,8 +84,8 @@ export function parseManualAncestor(text) { name: parsed.name, farm: parsed.farm ?? '', ear_number: parsed.ear_number ?? '', - weight_lbs: parsed.weight_lbs ?? MANUAL_ANCESTOR_DEFAULTS.weight_lbs, - dob: parsed.dob ?? MANUAL_ANCESTOR_DEFAULTS.dob, + weight_lbs: normalizeAncestorWeight(parsed.weight_lbs), + dob: parsed.dob || MANUAL_ANCESTOR_DEFAULTS.dob, } } } catch { @@ -94,6 +100,16 @@ export function parseManualAncestor(text) { } } +// Second line for a manual ancestor: "3.00 lbs · DOB Unknown". `formatWeight` +// is passed in (lib/format.js) so callers apply the user's weight unit. +export function formatManualAncestorDetails(text, formatWeight) { + const parsed = parseManualAncestor(text) + if (!parsed) return null + const date = /^\d{4}-\d{2}-\d{2}$/.test(parsed.dob) ? new Date(`${parsed.dob}T00:00:00`) : null + const dob = date && !Number.isNaN(date.getTime()) ? date.toLocaleDateString('en-US') : parsed.dob + return `${formatWeight(parsed.weight_lbs)} · DOB ${dob}` +} + export function formatManualAncestorDisplay(text) { const parsed = parseManualAncestor(text) if (!parsed) return null @@ -204,7 +220,9 @@ export function buildPedigreeSummary(state, cascadeCache) { : eff.mode === 'manual' ? formatManualAncestorDisplay(eff.name) : eff.name - return { slot, label, display } + // `manualText` lets the Review step add the weight/DOB line in the user's + // weight unit (this lib has no access to settings). + return { slot, label, display, manualText: eff.mode === 'manual' ? eff.name : null } }) } @@ -241,3 +259,43 @@ export function buildPedigreePayload(state, cascadeCache) { return payload } + +// --- Pedigree scan → tree state ------------------------------------------- +// A scan result (POST /api/pedigree-scan) carries `ancestors` keyed by slot +// and `matches` for confident herd matches on sire/dam. Matched parents become +// linked nodes (their branch then locks to the herd record's own pedigree, per +// FEATURE_OCR_Pedigree_Scan.md Q6); every other named ancestor becomes a +// manual node the user can review and edit like hand entry. +export function pedigreeStateFromScan({ ancestors = {}, matches = {} } = {}) { + const state = createInitialPedigreeState() + for (const { slot } of ALL_SLOTS) { + const match = matches[slot] + const scanned = ancestors[slot] + if (match) { + state[slot] = { mode: 'linked', id: match.id, name: match.name } + } else if (scanned?.name) { + state[slot] = { + mode: 'manual', + id: null, + name: serializeManualAncestor({ + name: scanned.name, + farm: scanned.farm ?? '', + ear_number: scanned.ear_number ?? '', + weight_lbs: scanned.weight_lbs ?? '', + dob: scanned.dob ?? '', + }), + } + } + } + return state +} + +// Slots the scan wasn't sure about, for highlighting in the tree. A matched +// parent is excluded: it's linked to a herd record, not the scanned text. +export function uncertainScanSlots({ ancestors = {}, matches = {} } = {}) { + return new Set( + ALL_SLOTS.filter(({ slot }) => !matches[slot] && ancestors[slot]?.uncertain?.length > 0).map( + ({ slot }) => slot + ) + ) +} diff --git a/mykindlebox/client/src/lib/pedigree.test.js b/mykindlebox/client/src/lib/pedigree.test.js index 6db1d16..811caeb 100644 --- a/mykindlebox/client/src/lib/pedigree.test.js +++ b/mykindlebox/client/src/lib/pedigree.test.js @@ -6,6 +6,9 @@ import { serializeManualAncestor, parseManualAncestor, formatManualAncestorDisplay, + formatManualAncestorDetails, + pedigreeStateFromScan, + uncertainScanSlots, } from './pedigree.js' test('manual ancestor serialize/parse round-trips', () => { @@ -69,3 +72,58 @@ test('buildPedigreePayload: a manual parent leaves deeper slots editable and per assert.equal(payload.sire_name_text, 'Bob') assert.equal(payload.paternal_grandsire_name_text, 'Old Bob') }) + +test('pedigreeStateFromScan links matched parents and makes named ancestors manual', () => { + const state = pedigreeStateFromScan({ + ancestors: { + sire: { name: 'Hoppin-pop', farm: "Peachy Buns'", ear_number: 'HP1', dob: null, weight_lbs: null, uncertain: [] }, + dam: { name: 'Black Betty', farm: "Cody's", ear_number: 'BB2', dob: '2024-03-04', weight_lbs: 4.25, uncertain: ['dob'] }, + paternal_grandsire: { name: null, farm: null, ear_number: null, dob: null, weight_lbs: null, uncertain: [] }, + }, + matches: { sire: { id: 7, name: 'Hoppin-pop' }, dam: null }, + }) + assert.deepEqual(state.sire, { mode: 'linked', id: 7, name: 'Hoppin-pop' }) + assert.equal(state.dam.mode, 'manual') + const dam = parseManualAncestor(state.dam.name) + assert.equal(dam.name, 'Black Betty') + assert.equal(dam.farm, "Cody's") + assert.equal(dam.dob, '2024-03-04') + assert.equal(dam.weight_lbs, 4.25) + // Unnamed or missing ancestors stay at the empty default. + assert.equal(state.paternal_grandsire.mode, 'unknown') + assert.equal(state.mm_granddam.mode, 'unknown') +}) + +test('uncertainScanSlots skips matched parents and certain ancestors', () => { + const slots = uncertainScanSlots({ + ancestors: { + sire: { name: 'A', uncertain: ['ear_number'] }, + dam: { name: 'B', uncertain: ['dob'] }, + maternal_grandsire: { name: 'C', uncertain: [] }, + }, + matches: { sire: { id: 1, name: 'A' } }, + }) + assert.deepEqual([...slots], ['dam']) +}) + +test('manual ancestors default to 3 lbs and Unknown DOB, including legacy 0-weight entries', () => { + const fresh = parseManualAncestor(serializeManualAncestor({ name: 'No Info', farm: 'X', ear_number: 'A1' })) + assert.equal(fresh.weight_lbs, 3) + assert.equal(fresh.dob, 'Unknown') + const legacy = parseManualAncestor(JSON.stringify({ name: 'Old', farm: '', ear_number: '', weight_lbs: 0, dob: '' })) + assert.equal(legacy.weight_lbs, 3) + assert.equal(legacy.dob, 'Unknown') + assert.equal(parseManualAncestor('Plain Name').weight_lbs, 3) + const real = parseManualAncestor(serializeManualAncestor({ name: 'Real', weight_lbs: '4.5', dob: '2024-03-04' })) + assert.equal(real.weight_lbs, 4.5) + assert.equal(real.dob, '2024-03-04') +}) + +test('formatManualAncestorDetails shows weight and DOB', () => { + const fmt = (w) => `${w} lbs` + assert.equal(formatManualAncestorDetails(serializeManualAncestor({ name: 'A' }), fmt), '3 lbs · DOB Unknown') + assert.equal( + formatManualAncestorDetails(serializeManualAncestor({ name: 'B', weight_lbs: 5, dob: '2024-03-04' }), fmt), + '5 lbs · DOB 3/4/2024' + ) +}) diff --git a/mykindlebox/client/src/pages/AddRabbit.jsx b/mykindlebox/client/src/pages/AddRabbit.jsx index a0e4d16..193421d 100644 --- a/mykindlebox/client/src/pages/AddRabbit.jsx +++ b/mykindlebox/client/src/pages/AddRabbit.jsx @@ -7,9 +7,17 @@ import RabbitDetailsForm from '../components/RabbitDetailsForm' import GeneticsForm from '../components/GeneticsForm' import ReviewStep from '../components/ReviewStep' import StickyActionBar from '../components/StickyActionBar' +import PedigreeScan from '../components/PedigreeScan' import Button from '../components/Button' -import { getSettings, getRabbit, createRabbit, uploadRabbitPhoto } from '../api' -import { createInitialPedigreeState } from '../lib/pedigree' +import { + getSettings, + getRabbit, + createRabbit, + uploadRabbitPhoto, + getPedigreeScanStatus, + findExistingRabbit, +} from '../api' +import { createInitialPedigreeState, pedigreeStateFromScan, uncertainScanSlots } from '../lib/pedigree' const STEP_LABELS = ['Pedigree', 'Details', 'Review'] const NAME_INPUT_ID = 'add-rabbit-name' @@ -37,6 +45,45 @@ const INITIAL_GENETICS = { locus_v: null, } +// The scanned subject's fields that should overwrite the form. Only values the +// scan actually found replace what's there (e.g. the settings-default farm +// survives a pedigree that doesn't print one). +function detailsFromScan(subject) { + const out = {} + for (const key of ['name', 'farm', 'ear_number', 'dob', 'breed', 'color_name']) { + if (subject[key]) out[key] = subject[key] + } + if (subject.weight_lbs != null) out.weight_lbs = subject.weight_lbs + if (subject.sex && subject.sex !== 'unknown') out.sex = subject.sex + return out +} + +const FIELD_LABELS = { + color_name: 'color', + weight_lbs: 'weight', + ear_number: 'ear number', + dob: 'date of birth', + sire_id: 'sire', + dam_id: 'dam', + acquired_date: 'acquired date', + death_date: 'death date', +} + +// Summary shown on the rabbit's page after "adding" a rabbit that was already +// in the herd (the server topped up that record instead of duplicating it). +function mergeNotice(rabbit) { + const { filled = [], ancestors_added: added = 0, ancestors_updated: updated = 0, conflicts = [] } = + rabbit.merged + const parts = [] + if (filled.length) parts.push(`filled in ${filled.map((f) => FIELD_LABELS[f] ?? f.replace(/_/g, ' ')).join(', ')}`) + if (added) parts.push(`added ${added} ancestor${added === 1 ? '' : 's'}`) + if (updated) parts.push(`completed ${updated} existing ancestor${updated === 1 ? '' : 's'}`) + let text = `${rabbit.name} was already in your herd, so it was updated instead of added again` + text += parts.length ? `: ${parts.join('; ')}.` : ' (nothing new to add).' + if (conflicts.length) text += ` Kept the existing record where the pedigree differed — ${conflicts.join('; ')}.` + return text +} + function Stepper({ step, canReach, onSelect }) { return (
    @@ -99,6 +146,13 @@ function AddRabbit() { // The name error only shows once the user has tried to move past step 2, // not the moment they arrive on an empty form. const [triedNext, setTriedNext] = useState(false) + // Pedigree scan: the result drives highlighting/notes, and `pedigreeSeed` + + // a bumped `pedigreeKey` remount PedigreeTree with the scanned ancestors + // (it only reads `initialState` at mount). + const [scanResult, setScanResult] = useState(null) + const [scanImageUrl, setScanImageUrl] = useState(null) + const [pedigreeSeed, setPedigreeSeed] = useState(null) + const [pedigreeKey, setPedigreeKey] = useState(0) const step2Valid = details.name.trim().length > 0 @@ -110,6 +164,7 @@ function AddRabbit() { const step = requestedStep === 3 && !step2Valid ? 2 : requestedStep const isDirty = + Boolean(scanResult) || Boolean(details.name.trim()) || Boolean(photoFile) || Object.values(genetics).some(Boolean) || @@ -126,7 +181,33 @@ function AddRabbit() { return () => window.removeEventListener('beforeunload', onBeforeUnload) }, [isDirty, saving]) + // The photo is only held as an in-memory object URL; free it when replaced + // or when leaving the page. + useEffect(() => { + if (!scanImageUrl) return + return () => URL.revokeObjectURL(scanImageUrl) + }, [scanImageUrl]) + const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: getSettings }) + // On Review, check whether saving would top up an existing rabbit so the + // user knows before pressing Save. + const { data: existingMatch } = useQuery({ + queryKey: ['find-existing', details.name, details.farm, details.ear_number, details.sex, details.dob], + queryFn: () => + findExistingRabbit({ + name: details.name, + farm: details.farm, + ear_number: details.ear_number, + sex: details.sex, + dob: details.dob, + }), + enabled: requestedStep === 3 && step2Valid, + }) + const { data: scanStatus } = useQuery({ + queryKey: ['pedigree-scan-status'], + queryFn: getPedigreeScanStatus, + staleTime: Infinity, + }) const { data: sireRabbit, isLoading: sireLoading } = useQuery({ queryKey: ['rabbit', sireId], queryFn: () => getRabbit(sireId), @@ -156,8 +237,9 @@ function AddRabbit() { return } - const pedigreeInitialState = - sireId || damId + const pedigreeInitialState = pedigreeSeed + ? pedigreeSeed + : sireId || damId ? { ...createInitialPedigreeState(), ...(sireRabbit ? { sire: { mode: 'linked', id: sireRabbit.id, name: sireRabbit.name } } : {}), @@ -173,6 +255,20 @@ function AddRabbit() { setGenetics((prev) => ({ ...prev, ...partial })) } + function handleScanned(result, imageUrl) { + setPedigreeSeed(pedigreeStateFromScan(result)) + setPedigreeKey((k) => k + 1) + setDetails((prev) => ({ ...prev, ...detailsFromScan(result.subject ?? {}) })) + setScanResult(result) + setScanImageUrl(imageUrl) + } + + // Scanning replaces the tree wholesale, so confirm before discarding entries. + function confirmReplaceWithScan() { + const hasEntries = pedigreeSummary.some((node) => node.display && node.display !== 'Unknown') + return !hasEntries || window.confirm('Replace the ancestors you’ve entered with the scanned pedigree?') + } + function goToStep(n) { const next = new URLSearchParams(searchParams) if (n === 1) next.delete('step') @@ -201,21 +297,25 @@ function AddRabbit() { setSaving(false) return } + const notice = created.merged ? mergeNotice(created) : null // The rabbit exists from here on. A failed photo upload must not leave the // user on Review, where pressing Save again would create a duplicate — - // go to the new rabbit and say what happened instead. - if (photoFile) { + // go to the new rabbit and say what happened instead. A topped-up rabbit + // keeps its existing photo (merges never overwrite). + if (photoFile && !created.merged?.had_photo) { try { await uploadRabbitPhoto(created.id, photoFile) } catch { navigate(`/herd/${created.id}`, { replace: true, - state: { notice: 'Rabbit saved, but the photo didn’t upload. You can add it again from Edit.' }, + state: { + notice: [notice, 'The photo didn’t upload. You can add it again from Edit.'].filter(Boolean).join(' '), + }, }) return } } - navigate(`/herd/${created.id}`, { replace: true }) + navigate(`/herd/${created.id}`, { replace: true, state: notice ? { notice } : undefined }) } return ( @@ -224,6 +324,17 @@ function AddRabbit() { n < 3 || step2Valid} onSelect={goToStep} /> + {/* Shown on Pedigree and, once there's a photo, on Details too so the + subject's fields can be checked against it. */} + {scanStatus?.enabled && (step === 1 || (step === 2 && scanResult)) && ( + + )} + {/* All three steps stay mounted and are hidden with CSS, so going back (or jumping from Review via "Edit") keeps the pedigree tree's internal state, the photo preview, and any open sections intact. */} @@ -246,6 +357,8 @@ function AddRabbit() { Picking a rabbit from your herd fills in its ancestors automatically.

    { @@ -286,6 +399,7 @@ function AddRabbit() { genetics={genetics} weightUnit={settings?.weight_unit} onEdit={goToStep} + existingMatch={existingMatch} /> )} @@ -308,7 +422,7 @@ function AddRabbit() { )} {step === 3 && ( )} diff --git a/mykindlebox/client/src/pages/OcrDev.jsx b/mykindlebox/client/src/pages/OcrDev.jsx deleted file mode 100644 index 7b40d2b..0000000 --- a/mykindlebox/client/src/pages/OcrDev.jsx +++ /dev/null @@ -1,195 +0,0 @@ -// TEMPORARY dev harness for validating OCR Pedigree Scanning, Phase 3. -// Not part of the app — wired to a throwaway /ocr-dev route. Delete this file -// and its route in App.jsx once Phase 3 is validated (same convention as the -// Phase 2 harness). -// -// Drops a real pedigree photo through the full Phase 3 pipeline in ONE OCR pass -// and shows every intermediate stage so you can eyeball where extraction works -// or breaks. The cells are reconstructed here from the exported layout/parse -// helpers in exactly the order extractPedigree() calls them, so the "Parsed -// cells" grid is byte-for-byte what extractPedigree() returns. - -import { useState } from 'react' -import { preprocessImage } from '../lib/ocrPreprocess' -import { runOCR } from '../lib/ocrRun' -import { detectColumns, aggregateCellText } from '../lib/ocrLayout' -import { extractFromWords } from '../lib/ocrPipeline' - -const CELL_ORDER = ['subject', 'sire', 'dam', 'sire_sire', 'sire_dam', 'dam_sire', 'dam_dam'] - -async function imageWidthOf(blob) { - const bitmap = await createImageBitmap(blob) - const { width, height } = bitmap - bitmap.close?.() - return { width, height } -} - -export default function OcrDev() { - const [status, setStatus] = useState('idle') - const [error, setError] = useState(null) - const [origUrl, setOrigUrl] = useState(null) - const [procUrl, setProcUrl] = useState(null) - const [dims, setDims] = useState(null) - const [words, setWords] = useState([]) - const [columns, setColumns] = useState(null) - const [cells, setCells] = useState(null) - const [elapsed, setElapsed] = useState(null) - - async function handleFile(e) { - const file = e.target.files?.[0] - if (!file) return - - setError(null) - setWords([]) - setCells(null) - setColumns(null) - setOrigUrl(URL.createObjectURL(file)) - const start = performance.now() - - try { - setStatus('preprocessing') - const processed = await preprocessImage(file) - setProcUrl(URL.createObjectURL(processed)) - const size = await imageWidthOf(processed) - setDims(size) - - setStatus('scanning') - const { words: w } = await runOCR(processed) - setWords(w) - - // Debug view: the raw adaptive columns (before subject-anchoring), so we - // can see where the gutters fell. The "Parsed cells" grid below uses the - // real pipeline core (extractFromWords) — same code extractPedigree runs. - setColumns(detectColumns(w, size.width).map((col) => aggregateCellText(col))) - setCells(extractFromWords(w, size.width)) - - setElapsed(Math.round(performance.now() - start)) - setStatus('done') - } catch (err) { - console.error(err) - setError(err?.message || String(err)) - setStatus('error') - } - } - - return ( -
    -

    OCR Phase 3 — dev harness

    -

    - Temporary. Pick a pedigree photo; it runs preprocess → runOCR → - layout/parse in one OCR pass. Open DevTools → Network and confirm{' '} - no requests to any CDN (jsdelivr/unpkg) — all Tesseract - assets load from /tesseract/. For a true offline check, go - offline after the first load and re-run. -

    - - - -

    - Status: {status} - {status === 'scanning' && ' (first scan loads ~9MB of WASM — can take 10–30s)'} - {dims && ` · preprocessed ${dims.width}×${dims.height}`} - {elapsed != null && status === 'done' && ` · ${elapsed}ms total`} - {words.length > 0 && ` · ${words.length} words (conf ≥ 60)`} -

    - - {error && ( -

    - Error: {error} -

    - )} - - {(origUrl || procUrl) && ( -
    - {origUrl && ( -
    -
    Original
    - original -
    - )} - {procUrl && ( -
    -
    - Preprocessed (binarized, ≤1600px) — this is what OCR sees -
    - preprocessed -
    - )} -
    - )} - - {cells && ( - <> -

    - Parsed cells (== extractPedigree() output) -

    -
    - {CELL_ORDER.map((key) => ( -
    -
    {key}
    -
    -                  {JSON.stringify(cells[key], null, 2)}
    -                
    -
    - ))} -
    - - )} - - {columns && ( -
    - - Detected columns ({columns.length}) — adaptive gutter split, left→right - -

    - Each box is one detected generation column (before boilerplate filtering - / subject-anchoring). If these don't line up with subject / parents / - grandparents, the gutter thresholds need tuning. -

    -
    - {columns.map((text, i) => ( -
    -
    column {i}
    -
    -                  {text || '(empty)'}
    -                
    -
    - ))} -
    -
    - )} - - {words.length > 0 && ( -
    - - Raw word list ({words.length}) - - - - - - - - - - - - - - {words.map((w, i) => ( - - - - - - - - - ))} - -
    textconfx0y0x1y1
    {w.text}{Math.round(w.confidence)}{w.bbox.x0}{w.bbox.y0}{w.bbox.x1}{w.bbox.y1}
    -
    - )} -
    - ) -} diff --git a/mykindlebox/client/src/pages/RabbitDetail.jsx b/mykindlebox/client/src/pages/RabbitDetail.jsx index 5c5bd37..5b233ff 100644 --- a/mykindlebox/client/src/pages/RabbitDetail.jsx +++ b/mykindlebox/client/src/pages/RabbitDetail.jsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { Link, useLocation, useParams } from 'react-router-dom' +import { Link, useLocation, useNavigate, useParams } from 'react-router-dom' import { useQuery, useQueryClient } from '@tanstack/react-query' import PageHeader from '../components/PageHeader' import Card from '../components/Card' @@ -8,7 +8,7 @@ import Button from '../components/Button' import Silhouette from '../components/Silhouette' import PedigreeTreeReadOnly from '../components/PedigreeTreeReadOnly' import RabbitTimeline from '../components/RabbitTimeline' -import { getRabbit, getRabbitAncestors, getLitters, updateRabbit, SERVER_BASE } from '../api' +import { getRabbit, getRabbitAncestors, getLitters, updateRabbit, deleteRabbit, SERVER_BASE } from '../api' import { SEX_LABELS, formatAge, formatWeight, formatDate } from '../lib/format' import { useWeightUnit } from '../hooks/useWeightUnit' import { LOCI } from '../lib/genetics' @@ -144,6 +144,7 @@ function RabbitDetail() { // Rabbit when the photo upload failed after the rabbit was created). const notice = useLocation().state?.notice const queryClient = useQueryClient() + const navigate = useNavigate() const [updating, setUpdating] = useState(false) const [error, setError] = useState(null) @@ -174,6 +175,25 @@ function RabbitDetail() { } } + async function handleDelete() { + const confirmed = window.confirm( + `Permanently delete ${rabbit.name}? This can’t be undone.\n\n` + + 'Its kits keep a written copy of it in their pedigrees, and litters it was part of show that parent as unknown. ' + + 'Its timeline events are deleted. To keep its history instead, use Archive.' + ) + if (!confirmed) return + setUpdating(true) + setError(null) + try { + await deleteRabbit(id) + queryClient.invalidateQueries() + navigate('/herd', { replace: true }) + } catch (err) { + setError(err.message) + setUpdating(false) + } + } + function handleArchive() { if (window.confirm('Archive this rabbit? This marks it as deceased.')) { patchStatus('deceased') @@ -229,6 +249,9 @@ function RabbitDetail() { + } /> diff --git a/mykindlebox/docker-compose.yml b/mykindlebox/docker-compose.yml index 3fa560e..6884119 100644 --- a/mykindlebox/docker-compose.yml +++ b/mykindlebox/docker-compose.yml @@ -7,6 +7,10 @@ services: # Settings. The old MYKINDLEBOX_PASSWORD/AUTH_PASSWORD env var is no # longer used. DATA_DIR: /app/data + # Pedigree scanning (Add Rabbit → "Scan a pedigree") sends the photo to + # the Claude API. Set ANTHROPIC_API_KEY in .env to enable it; without it + # the scan button is hidden and everything else works as before. + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} volumes: - server-data:/app/data restart: unless-stopped @@ -24,8 +28,8 @@ services: client: build: ./client ports: - # Host port is configurable: set MYKINDLEBOX_PORT (e.g. in the same .env - # as MYKINDLEBOX_PASSWORD) to serve on a different port. Defaults to 8080. + # Host port is configurable: set MYKINDLEBOX_PORT (e.g. in .env) to serve + # on a different port. Defaults to 8080. - "${MYKINDLEBOX_PORT:-8080}:80" # Wait for the API to answer /health before starting nginx, so the first # page load after `docker compose up` can't hit a 502 from a still-booting diff --git a/mykindlebox/server/index.ts b/mykindlebox/server/index.ts index 7c8af28..27a2955 100644 --- a/mykindlebox/server/index.ts +++ b/mykindlebox/server/index.ts @@ -13,6 +13,7 @@ import settingsRouter from './routes/settings'; import statsRouter from './routes/stats'; import exportRouter from './routes/export'; import importRouter from './routes/import'; +import pedigreeScanRouter from './routes/pedigreeScan'; const app = express(); const PORT = Number(process.env.PORT) || 3001; @@ -50,6 +51,7 @@ app.use('/api/settings', requireAuth, settingsRouter); app.use('/api/stats', requireAuth, statsRouter); app.use('/api/export', requireAuth, exportRouter); app.use('/api/import', requireAuth, importRouter); +app.use('/api/pedigree-scan', requireAuth, pedigreeScanRouter); app.use((req: Request, res: Response, next: NextFunction) => { res.status(404).json({ error: 'Not found' }); diff --git a/mykindlebox/server/lib/ancestors.test.ts b/mykindlebox/server/lib/ancestors.test.ts new file mode 100644 index 0000000..f7d5c36 --- /dev/null +++ b/mykindlebox/server/lib/ancestors.test.ts @@ -0,0 +1,182 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +// db/client opens (and migrates) the SQLite file under DATA_DIR at import +// time, so point it at a throwaway directory before loading anything that +// imports it. +process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'mkb-ancestors-')); +const { db } = require('../db/client') as typeof import('../db/client'); +const { rabbits } = require('../db/schema') as typeof import('../db/schema'); +const { materializeAncestors, findExistingSubject, mergeIntoExisting } = require('./ancestors') as typeof import('./ancestors'); +const { eq } = require('drizzle-orm') as typeof import('drizzle-orm'); + +type RabbitInsert = typeof rabbits.$inferInsert; + +const manual = (name: string, extra: Record = {}) => + JSON.stringify({ name, farm: 'Test Farm', ear_number: '', weight_lbs: 3, dob: 'Unknown', ...extra }); + +function save(values: RabbitInsert) { + return db.transaction((tx) => { + materializeAncestors(tx, values); + return tx.insert(rabbits).values(values).returning().get(); + }); +} + +// Mirrors POST /api/rabbits: top up an existing rabbit, else create. +function submit(values: RabbitInsert) { + return db.transaction((tx) => { + const existing = findExistingSubject(tx, values); + if (existing) return { id: existing.id, report: mergeIntoExisting(tx, existing, values) }; + materializeAncestors(tx, values); + return { id: tx.insert(rabbits).values(values).returning().get().id, report: null }; + }); +} + +const byId = (id: number) => db.select().from(rabbits).where(eq(rabbits.id, id)).get()!; + +test('typed ancestors become linked reference rabbits', () => { + const subject = save({ + name: 'Kit One', + sireNameText: manual('Big Buck', { ear_number: 'bb1', weight_lbs: 9.5, dob: '2022-01-02' }), + damNameText: manual('Nice Doe', { ear_number: 'ND1' }), + paternalGrandsireNameText: manual('Grandpa', { ear_number: 'GP1' }), + ppGranddamNameText: manual('Great Gran', { ear_number: 'GG1' }), + }); + + assert.ok(subject.sireId && subject.damId); + assert.equal(subject.sireNameText, null); + assert.equal(subject.paternalGrandsireNameText, null); + assert.equal(subject.ppGranddamNameText, null); + + const sire = byId(subject.sireId!); + assert.equal(sire.name, 'Big Buck'); + assert.equal(sire.sex, 'buck'); + assert.equal(sire.status, 'other'); + assert.equal(sire.earNumber, 'BB1'); + assert.equal(sire.weightLbs, 9.5); + assert.equal(sire.dob, '2022-01-02'); + assert.equal(sire.damId, null); + + const grandpa = byId(sire.sireId!); + assert.equal(grandpa.name, 'Grandpa'); + assert.equal(byId(grandpa.damId!).name, 'Great Gran'); + assert.equal(byId(grandpa.damId!).sex, 'doe'); + + const dam = byId(subject.damId!); + assert.equal(dam.weightLbs, 3); + assert.equal(dam.dob, null); // "Unknown" isn't a date +}); + +test('a sibling reuses the ancestors already created', () => { + const before = db.select().from(rabbits).all().length; + const sibling = save({ + name: 'Kit Two', + sireNameText: manual('Big Buck', { ear_number: 'BB1' }), + damNameText: manual('Nice Doe', { ear_number: 'nd1' }), + }); + assert.equal(db.select().from(rabbits).all().length, before + 1); // only the sibling itself + const first = db.select().from(rabbits).where(eq(rabbits.name, 'Kit One')).get()!; + assert.equal(sibling.sireId, first.sireId); + assert.equal(sibling.damId, first.damId); +}); + +test('a line-bred pedigree repeating an animal creates it once', () => { + const subject = save({ + name: 'Line Bred', + sireNameText: manual('Son', { ear_number: 'S1' }), + damNameText: manual('Daughter', { ear_number: 'D1' }), + paternalGrandsireNameText: manual('Patriarch', { ear_number: 'PAT' }), + maternalGrandsireNameText: manual('Patriarch', { ear_number: 'PAT' }), + }); + const sire = byId(subject.sireId!); + const dam = byId(subject.damId!); + assert.equal(sire.sireId, dam.sireId); +}); + +test('name + farm matches only without a conflicting ear number; linked parents are untouched', () => { + const sameName = save({ name: 'Child A', sireNameText: manual('Common Name', { ear_number: 'X1' }) }); + const otherEar = save({ name: 'Child B', sireNameText: manual('Common Name', { ear_number: 'X2' }) }); + assert.notEqual(sameName.sireId, otherEar.sireId); + + const linked = save({ name: 'Child C', sireId: sameName.sireId, sireNameText: manual('Ignored') }); + assert.equal(linked.sireId, sameName.sireId); + assert.equal(db.select().from(rabbits).where(eq(rabbits.name, 'Ignored')).all().length, 0); +}); + +test('an Unknown parent keeps grandparent text on the rabbit', () => { + const subject = save({ name: 'Partial', paternalGrandsireNameText: manual('Lonely Grandsire') }); + assert.equal(subject.sireId ?? null, null); + assert.ok(subject.paternalGrandsireNameText); +}); + +test('adding an existing rabbit again fills gaps and adds missing ancestors, never overwrites', () => { + const first = submit({ name: 'Repeat', farm: 'Home', earNumber: 'RP1', breed: 'Holland Lop', sex: 'doe' }); + assert.equal(first.report, null); + + const again = submit({ + name: 'Repeat', + farm: 'Home', + earNumber: 'rp1', + breed: 'Mini Rex', // already set: must not change + colorName: 'Broken Black', + weightLbs: 3.5, + sireNameText: manual('Repeat Sire', { ear_number: 'RS1' }), + paternalGrandsireNameText: manual('Repeat Grandsire', { ear_number: 'RG1' }), + }); + assert.equal(again.id, first.id); + const row = byId(first.id); + assert.equal(row.breed, 'Holland Lop'); + assert.equal(row.colorName, 'Broken Black'); + assert.equal(row.weightLbs, 3.5); + assert.equal(byId(row.sireId!).name, 'Repeat Sire'); + assert.equal(byId(byId(row.sireId!).sireId!).name, 'Repeat Grandsire'); + assert.equal(again.report!.ancestorsAdded, 2); + assert.deepEqual(again.report!.filled.sort(), ['colorName', 'weightLbs']); + + // A third pass adds the sire's dam to the existing sire record. + const third = submit({ + name: 'Repeat', + earNumber: 'RP1', + farm: 'Home', + sireNameText: manual('Repeat Sire', { ear_number: 'RS1', dob: '2021-05-06' }), + paternalGranddamNameText: manual('Repeat Granddam', { ear_number: 'RD1' }), + }); + const sire = byId(byId(first.id).sireId!); + assert.equal(sire.dob, '2021-05-06'); + assert.equal(byId(sire.damId!).name, 'Repeat Granddam'); + assert.equal(third.report!.ancestorsUpdated, 1); +}); + +test('adding a rabbit that exists only as a reference ancestor promotes it', () => { + const kit = submit({ name: 'Promo Kit', damNameText: manual('Promo Doe', { ear_number: 'PD1' }) }); + const damId = byId(kit.id).damId!; + assert.equal(byId(damId).status, 'other'); + + const added = submit({ name: 'Promo Doe', farm: 'Test Farm', earNumber: 'PD1', status: 'active', sex: 'doe' }); + assert.equal(added.id, damId); + assert.equal(byId(damId).status, 'active'); +}); + +test('a different existing parent is kept and reported as a conflict', () => { + submit({ name: 'Conflicted', earNumber: 'CF1', sireNameText: manual('Real Sire', { ear_number: 'RSX' }) }); + const again = submit({ name: 'Conflicted', earNumber: 'CF1', sireNameText: manual('Other Sire', { ear_number: 'OSX' }) }); + assert.equal(byId(byId(again.id).sireId!).name, 'Real Sire'); + assert.equal(again.report!.conflicts.length, 1); +}); + +test('when an ear number is shared, the herd rabbit wins over a reference record', () => { + const ref = db.insert(rabbits).values({ name: 'Dup', earNumber: 'DUP1', status: 'other' }).returning().get(); + const herd = db.insert(rabbits).values({ name: 'Dup', earNumber: 'DUP1', status: 'active' }).returning().get(); + const kit = submit({ name: 'Dup Kit', sireNameText: manual('Dup', { ear_number: 'DUP1' }) }); + assert.equal(byId(kit.id).sireId, herd.id); + assert.notEqual(byId(kit.id).sireId, ref.id); +}); + +test('same name and farm but a different DOB is a different rabbit', () => { + const a = submit({ name: 'Doe 1', farm: 'Kits', dob: '2025-01-01' }); + const b = submit({ name: 'Doe 1', farm: 'Kits', dob: '2025-06-01' }); + assert.notEqual(a.id, b.id); +}); diff --git a/mykindlebox/server/lib/ancestors.ts b/mykindlebox/server/lib/ancestors.ts new file mode 100644 index 0000000..422cd96 --- /dev/null +++ b/mykindlebox/server/lib/ancestors.ts @@ -0,0 +1,344 @@ +import { and, asc, eq, isNull, or, sql } from 'drizzle-orm'; +import { rabbits } from '../db/schema'; + +// Ancestor materialization and "add again = top up" merging. +// +// When a rabbit is saved from Add Rabbit, every ancestor entered as text +// (typed in or scanned) becomes a real rabbit record linked through +// sire_id/dam_id, so it can be reused. Created ancestors get status 'other' +// (they show under Archived, not the active herd or dashboard counts). +// +// Matching (reuse instead of duplicate) — `findExisting`: a rabbit with the +// same ear number, else the same name + farm, provided the ear number, sex and +// DOB don't contradict each other. When several share an ear number, the +// active herd rabbit wins over a reference record. +// +// Merging never overwrites: a matched rabbit only gets its *empty* fields +// filled, missing parents linked/created, and the same applied up the tree. +// If a matched rabbit already has a parent that is a different animal from the +// one entered, the existing record wins and that branch is reported as a +// conflict. + +type RabbitRow = typeof rabbits.$inferSelect; +type RabbitInsert = typeof rabbits.$inferInsert; +type Direction = 'sire' | 'dam'; +type TextKey = + | 'sireNameText' + | 'damNameText' + | 'paternalGrandsireNameText' + | 'paternalGranddamNameText' + | 'maternalGrandsireNameText' + | 'maternalGranddamNameText' + | 'ppGrandsireNameText' + | 'ppGranddamNameText' + | 'pmGrandsireNameText' + | 'pmGranddamNameText' + | 'mpGrandsireNameText' + | 'mpGranddamNameText' + | 'mmGrandsireNameText' + | 'mmGranddamNameText'; + +const TEXT_KEY_BY_PATH: Record = { + sire: 'sireNameText', + dam: 'damNameText', + 'sire,sire': 'paternalGrandsireNameText', + 'sire,dam': 'paternalGranddamNameText', + 'dam,sire': 'maternalGrandsireNameText', + 'dam,dam': 'maternalGranddamNameText', + 'sire,sire,sire': 'ppGrandsireNameText', + 'sire,sire,dam': 'ppGranddamNameText', + 'sire,dam,sire': 'pmGrandsireNameText', + 'sire,dam,dam': 'pmGranddamNameText', + 'dam,sire,sire': 'mpGrandsireNameText', + 'dam,sire,dam': 'mpGranddamNameText', + 'dam,dam,sire': 'mmGrandsireNameText', + 'dam,dam,dam': 'mmGranddamNameText', +}; +const TEXT_KEYS = new Set(Object.values(TEXT_KEY_BY_PATH)); + +const MAX_DEPTH = 3; // great-grandparents; their parents aren't captured +const DEFAULT_WEIGHT_LBS = 3; // user rule, mirrors client MANUAL_ANCESTOR_DEFAULTS + +// Subject fields that a merge never fills from the form: identity/links and +// status are handled explicitly, the rest are server-managed. +const NON_FILLABLE = new Set(['id', 'sireId', 'damId', 'status', 'createdAt', 'updatedAt', 'photoPath']); + +interface ParsedAncestor { + name: string; + farm: string | null; + earNumber: string | null; + weightLbs: number; + dob: string | null; +} + +/** What a merge or materialization did, returned to the client as `merged`. */ +export interface MergeReport { + filled: string[]; + ancestorsAdded: number; + ancestorsUpdated: number; + conflicts: string[]; + hadPhoto: boolean; +} + +function emptyReport(): MergeReport { + return { filled: [], ancestorsAdded: 0, ancestorsUpdated: 0, conflicts: [], hadPhoto: false }; +} + +function blankToNull(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function isEmpty(value: unknown): boolean { + return value === null || value === undefined || (typeof value === 'string' && value.trim() === ''); +} + +function pathLabel(path: Direction[]): string { + return path.map((d) => (d === 'sire' ? 'Sire' : 'Dam')).join("'s "); +} + +// Manual ancestors are stored as JSON ({name, farm, ear_number, weight_lbs, +// dob}, see client lib/pedigree.js serializeManualAncestor); older entries +// may be a plain name. +function parseAncestorText(text: string | null | undefined): ParsedAncestor | null { + if (!text?.trim()) return null; + let raw: Record = { name: text }; + try { + const parsed = JSON.parse(text); + if (parsed && typeof parsed === 'object' && typeof parsed.name === 'string') raw = parsed; + } catch { + // plain-text legacy entry + } + const name = blankToNull(raw.name); + if (!name) return null; + const weight = Number(raw.weight_lbs); + const dob = blankToNull(raw.dob); + return { + name, + farm: blankToNull(raw.farm), + earNumber: blankToNull(raw.ear_number)?.toUpperCase() ?? null, + weightLbs: Number.isFinite(weight) && weight > 0 ? weight : DEFAULT_WEIGHT_LBS, + dob: dob && /^\d{4}-\d{2}-\d{2}$/.test(dob) ? dob : null, + }; +} + +// Anything with select/insert/update works — the module's `db` or a transaction. +type Db = Pick; + +interface Identity { + name: string; + farm: string | null; + earNumber: string | null; + sex?: string | null; + dob?: string | null; +} + +function compatible(row: RabbitRow, who: Identity): boolean { + const ear = blankToNull(row.earNumber)?.toUpperCase() ?? null; + if (ear && who.earNumber && ear !== who.earNumber) return false; + if (who.sex && who.sex !== 'unknown' && row.sex !== 'unknown' && row.sex !== who.sex) return false; + if (who.dob && row.dob && row.dob !== who.dob) return false; + return true; +} + +export function findExisting(tx: Db, who: Identity): RabbitRow | null { + if (who.earNumber) { + const byEar = tx + .select() + .from(rabbits) + .where(sql`upper(trim(${rabbits.earNumber})) = ${who.earNumber}`) + // Prefer a real herd rabbit over a reference record, then the oldest. + .orderBy(sql`${rabbits.status} = 'other'`, asc(rabbits.id)) + .all() + .filter((row) => compatible(row, who)); + if (byEar.length > 0) return byEar[0]; + } + const farmMatch = who.farm + ? sql`lower(trim(${rabbits.farm})) = ${who.farm.toLowerCase()}` + : or(isNull(rabbits.farm), sql`trim(${rabbits.farm}) = ''`); + const byName = tx + .select() + .from(rabbits) + .where(and(sql`lower(trim(${rabbits.name})) = ${who.name.toLowerCase()}`, farmMatch)) + .all() + .filter((row) => compatible(row, who)); + return byName.length === 1 ? byName[0] : null; // several same-name rabbits: don't guess +} + +function fetchRow(tx: Db, id: number): RabbitRow | undefined { + return tx.select().from(rabbits).where(eq(rabbits.id, id)).get(); +} + +// Fill only the empty columns of `row` from `patch`; returns the filled keys. +function fillMissing(tx: Db, row: RabbitRow, patch: Partial): string[] { + const updates: Record = {}; + for (const [key, value] of Object.entries(patch)) { + if (isEmpty(value)) continue; + const current = (row as Record)[key]; + const missing = isEmpty(current) || (key === 'sex' && current === 'unknown' && value !== 'unknown'); + if (missing) updates[key] = value; + } + const keys = Object.keys(updates); + if (keys.length > 0) { + tx.update(rabbits) + .set({ ...updates, updatedAt: new Date().toISOString() }) + .where(eq(rabbits.id, row.id)) + .run(); + Object.assign(row, updates); + } + return keys; +} + +function ancestorPatch(a: ParsedAncestor): Partial { + return { farm: a.farm, earNumber: a.earNumber, dob: a.dob, weightLbs: a.weightLbs }; +} + +function sexForPath(path: Direction[]): 'buck' | 'doe' { + return path[path.length - 1] === 'sire' ? 'buck' : 'doe'; +} + +// Resolve the ancestor entered at `path` to a rabbit id: reuse (and top up) a +// matching rabbit, or create one with its own parents. +function resolveAncestor(tx: Db, values: RabbitInsert, path: Direction[], report: MergeReport): number | null { + const ancestor = parseAncestorText(values[TEXT_KEY_BY_PATH[path.join(',')]]); + if (!ancestor) return null; + const sex = sexForPath(path); + + const existing = findExisting(tx, { ...ancestor, sex, dob: ancestor.dob }); + if (existing) { + if (fillMissing(tx, existing, ancestorPatch(ancestor)).length > 0) report.ancestorsUpdated += 1; + attachMissingParents(tx, values, existing, path, report); + return existing.id; + } + + // Parents first, so an animal repeated higher up the tree (line breeding) + // is created once and then found by findExisting on its second appearance. + const deeper = path.length < MAX_DEPTH; + const sireId = deeper ? resolveAncestor(tx, values, [...path, 'sire'], report) : null; + const damId = deeper ? resolveAncestor(tx, values, [...path, 'dam'], report) : null; + + const created = tx + .insert(rabbits) + .values({ + name: ancestor.name, + farm: ancestor.farm, + earNumber: ancestor.earNumber, + sex, + dob: ancestor.dob, + weightLbs: ancestor.weightLbs, + status: 'other', + sireId, + damId, + }) + .returning({ id: rabbits.id }) + .get(); + report.ancestorsAdded += 1; + return created.id; +} + +// For a rabbit already in the DB sitting at `path` in the entered pedigree, +// link/create parents it's missing and top up the ones it has (when they're +// the same animal as entered). +function attachMissingParents( + tx: Db, + values: RabbitInsert, + row: RabbitRow, + path: Direction[], + report: MergeReport, +): void { + if (path.length >= MAX_DEPTH) return; + for (const side of ['sire', 'dam'] as const) { + const childPath = [...path, side]; + const entered = parseAncestorText(values[TEXT_KEY_BY_PATH[childPath.join(',')]]); + if (!entered) continue; + const fkKey = side === 'sire' ? 'sireId' : 'damId'; + const linkedId = row[fkKey]; + + if (linkedId == null) { + const id = resolveAncestor(tx, values, childPath, report); + if (id != null && id !== row.id) { + tx.update(rabbits).set({ [fkKey]: id, updatedAt: new Date().toISOString() }).where(eq(rabbits.id, row.id)).run(); + row[fkKey] = id; + } + continue; + } + + const linked = fetchRow(tx, linkedId); + if (!linked) continue; + const sameAnimal = + linked.name.trim().toLowerCase() === entered.name.toLowerCase() || + (entered.earNumber != null && blankToNull(linked.earNumber)?.toUpperCase() === entered.earNumber); + if (!sameAnimal) { + report.conflicts.push(`${pathLabel(childPath)}: kept ${linked.name} (you entered ${entered.name})`); + continue; + } + if (fillMissing(tx, linked, ancestorPatch(entered)).length > 0) report.ancestorsUpdated += 1; + attachMissingParents(tx, values, linked, childPath, report); + } +} + +// New rabbit: replace each parent branch entered as text with a sire_id/dam_id +// link and clear that branch's now-redundant text columns. Mutates `values`. +// A parent left Unknown keeps any grandparent text as-is. +export function materializeAncestors(tx: Db, values: RabbitInsert): MergeReport { + const report = emptyReport(); + for (const side of ['sire', 'dam'] as const) { + const fkKey = side === 'sire' ? 'sireId' : 'damId'; + if (values[fkKey] != null) continue; // already linked to a herd rabbit + + const id = resolveAncestor(tx, values, [side], report); + if (id == null) continue; + + values[fkKey] = id; + for (const [pathKey, textKey] of Object.entries(TEXT_KEY_BY_PATH)) { + if (pathKey === side || pathKey.startsWith(`${side},`)) values[textKey] = null; + } + } + return report; +} + +/** The existing rabbit an Add Rabbit submission refers to, if any. */ +export function findExistingSubject(tx: Db, values: RabbitInsert): RabbitRow | null { + const name = blankToNull(values.name); + if (!name) return null; + return findExisting(tx, { + name, + farm: blankToNull(values.farm), + earNumber: blankToNull(values.earNumber)?.toUpperCase() ?? null, + sex: values.sex ?? null, + dob: blankToNull(values.dob), + }); +} + +// "Add again" on an existing rabbit: fill its empty fields, adopt the entered +// status if it was only a reference ancestor, link a parent picked from the +// herd if it had none, and add/top up ancestors up the tree. +export function mergeIntoExisting(tx: Db, row: RabbitRow, values: RabbitInsert): MergeReport { + const report = emptyReport(); + report.hadPhoto = Boolean(row.photoPath); + + const patch: Record = {}; + for (const [key, value] of Object.entries(values)) { + if (!NON_FILLABLE.has(key) && !TEXT_KEYS.has(key)) patch[key] = value; + } + if (typeof patch.earNumber === 'string') patch.earNumber = patch.earNumber.trim().toUpperCase(); + report.filled = fillMissing(tx, row, patch as Partial); + + if (row.status === 'other' && values.status && values.status !== 'other') { + tx.update(rabbits).set({ status: values.status }).where(eq(rabbits.id, row.id)).run(); + row.status = values.status; + report.filled.push('status'); + } + + for (const side of ['sire', 'dam'] as const) { + const fkKey = side === 'sire' ? 'sireId' : 'damId'; + const picked = values[fkKey]; + if (picked != null && row[fkKey] == null && picked !== row.id) { + tx.update(rabbits).set({ [fkKey]: picked }).where(eq(rabbits.id, row.id)).run(); + row[fkKey] = picked; + report.filled.push(fkKey); + } + } + + attachMissingParents(tx, values, row, [], report); + return report; +} diff --git a/mykindlebox/server/lib/pedigreeScan.ts b/mykindlebox/server/lib/pedigreeScan.ts new file mode 100644 index 0000000..61a9775 --- /dev/null +++ b/mykindlebox/server/lib/pedigreeScan.ts @@ -0,0 +1,221 @@ +import Anthropic from '@anthropic-ai/sdk'; +import { betaZodOutputFormat } from '@anthropic-ai/sdk/helpers/beta/zod'; +import { z } from 'zod'; + +// Pedigree scanning: a photo of any pedigree layout (horizontal, vertical, +// printed, handwritten, color or B&W) goes to a Claude vision model, which +// returns the subject + 14 ancestors as schema-validated JSON. Position-based +// OCR (the earlier Tesseract pipeline) couldn't cope with layout variety; the +// model works out relationships from labels and tree structure instead. +// +// Enabled only when ANTHROPIC_API_KEY is set (docker-compose passes it through +// from .env). The image is sent to the API and never stored by the app. + +const MODEL = 'claude-opus-5-5'; + +const FIELD_KEYS = ['name', 'farm', 'ear_number', 'dob', 'weight_lbs'] as const; +export type ScanField = (typeof FIELD_KEYS)[number]; + +const animalFields = { + name: z.string().nullable().describe('Rabbit name without the farm prefix, exactly as written'), + farm: z.string().nullable().describe('Rabbitry / farm prefix, e.g. "Cody\'s" or "Peachy Buns\'"'), + ear_number: z.string().nullable().describe('Tattoo / ear number'), + dob: z.string().nullable().describe('Date of birth as YYYY-MM-DD, only if a full date is legible'), + weight_lbs: z.number().nullable().describe('Weight in decimal pounds'), + uncertain: z + .array(z.enum(FIELD_KEYS)) + .describe('Fields whose value is a best guess, or that look filled in but are illegible'), +}; + +// Nested exactly like a pedigree so the model fills relationships, not slots. +const GreatGrandparent = z.object(animalFields); +const Grandparent = z.object({ ...animalFields, sire: GreatGrandparent, dam: GreatGrandparent }); +const Parent = z.object({ ...animalFields, sire: Grandparent, dam: Grandparent }); +const Subject = z.object({ + ...animalFields, + sex: z.enum(['buck', 'doe', 'unknown']), + breed: z.string().nullable(), + color: z.string().nullable().describe('Color / variety of the subject rabbit'), + sire: Parent, + dam: Parent, +}); +const ScanOutput = z.object({ + is_pedigree: z.boolean().describe('False if the image is not a rabbit pedigree'), + subject: Subject, + notes: z + .string() + .nullable() + .describe('One short sentence on anything that limited the reading (cut off, blurry, glare), else null'), +}); + +const SYSTEM_PROMPT = `You transcribe photographed rabbit pedigrees into structured data for a rabbitry management app. + +A pedigree shows one rabbit (the subject) and its ancestors: sire (father) and dam (mother), their sires and dams (grandparents), and usually the great-grandparents. Breeders format pedigrees very differently: +- Horizontal trees (generations left to right, sire's branch above the dam's), vertical trees (generations top to bottom or bottom to top, sire's branch on the left), boxed grids, or plain lists. +- Printed forms, software printouts, or handwriting; color-coded boxes (often blue for sires, pink for dams) or black and white. +- Labels such as "Sire", "Dam", "Sire's Sire", "Dam's Dam", "GS"/"GD" (grand sire/dam), "GGS"/"GGD", or "S"/"D". + +Work out each animal's place from the labels and the connecting structure, not from page position alone. The subject is the rabbit the pedigree is for: usually the entry labelled with the rabbit's name at the root of the tree or at the top of the form. A sire is always a buck and a dam always a doe; each animal's sire and dam are the two entries directly linked to it in the next generation. + +For each animal capture only: +- name and farm: many names start with a rabbitry prefix, often possessive ("Cody's Black Betty" → farm "Cody's", name "Black Betty"; "Peachy Buns' Hoppin-pop" → farm "Peachy Buns'", name "Hoppin-pop"). Split the prefix into farm. With no prefix, farm is null unless a breeder or rabbitry is written for that animal. +- ear_number: the tattoo or ear number ("Tattoo", "Ear #", "T:"). +- dob: return YYYY-MM-DD only when the full date is legible. Read numeric dates month-first (US) unless the document clearly uses another order. Otherwise null. +- weight_lbs: convert to decimal pounds ("8 lb 4 oz" → 8.25, "142 oz" → 8.875). A number with no unit is a weight only if it sits in a weight column or after a weight label. +For the subject also capture sex (from "Buck"/"Doe"/"Sex"), breed, and color/variety. Ignore everything else (registration and grand champion numbers, ancestor colors, breeder addresses, signatures). + +Accuracy rules: +- Transcribe exactly as written. Keep the breeder's spelling and capitalization; do not correct or complete names. +- Never invent or infer values, and never copy a value from one animal to another. Blank boxes and unknown ancestors get null fields. +- For handwriting or poor image quality, give your best reading and list the field in "uncertain". If a field is clearly filled in but unreadable, leave it null and still list it in "uncertain". +- If the image is not a rabbit pedigree, set is_pedigree to false and leave every field null.`; + +export class PedigreeScanError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + } +} + +export function isPedigreeScanEnabled(): boolean { + return Boolean(process.env.ANTHROPIC_API_KEY?.trim()); +} + +let client: Anthropic | null = null; +function getClient(): Anthropic { + // A slow high-effort read can take a minute; nginx allows 180s (nginx.conf). + client ??= new Anthropic({ timeout: 150_000, maxRetries: 1 }); + return client; +} + +type RawAnimal = z.infer; + +export interface ScannedAnimal { + name: string | null; + farm: string | null; + ear_number: string | null; + dob: string | null; + weight_lbs: number | null; + uncertain: ScanField[]; +} + +export interface PedigreeScanResult { + subject: ScannedAnimal & { sex: 'buck' | 'doe' | 'unknown'; breed: string | null; color_name: string | null }; + /** Keyed by the client's pedigree slot names (client/src/lib/pedigree.js). */ + ancestors: Record; + notes: string | null; +} + +function cleanText(value: string | null): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +function cleanDate(value: string | null): string | null { + const text = cleanText(value); + if (!text || !/^\d{4}-\d{2}-\d{2}$/.test(text)) return null; + const date = new Date(`${text}T00:00:00Z`); + return Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== text ? null : text; +} + +function cleanAnimal(raw: RawAnimal): ScannedAnimal { + const weight = raw.weight_lbs; + return { + name: cleanText(raw.name), + farm: cleanText(raw.farm), + ear_number: cleanText(raw.ear_number)?.toUpperCase() ?? null, + dob: cleanDate(raw.dob), + // Guard against a misread (a year or ear number taken for a weight). + weight_lbs: weight != null && Number.isFinite(weight) && weight > 0 && weight < 40 ? weight : null, + uncertain: [...new Set(raw.uncertain)], + }; +} + +// Nested model output → the flat slot map PedigreeTree uses. +function flattenAncestors(subject: z.infer): Record { + const { sire, dam } = subject; + return { + sire: cleanAnimal(sire), + dam: cleanAnimal(dam), + paternal_grandsire: cleanAnimal(sire.sire), + paternal_granddam: cleanAnimal(sire.dam), + maternal_grandsire: cleanAnimal(dam.sire), + maternal_granddam: cleanAnimal(dam.dam), + pp_grandsire: cleanAnimal(sire.sire.sire), + pp_granddam: cleanAnimal(sire.sire.dam), + pm_grandsire: cleanAnimal(sire.dam.sire), + pm_granddam: cleanAnimal(sire.dam.dam), + mp_grandsire: cleanAnimal(dam.sire.sire), + mp_granddam: cleanAnimal(dam.sire.dam), + mm_grandsire: cleanAnimal(dam.dam.sire), + mm_granddam: cleanAnimal(dam.dam.dam), + }; +} + +export async function scanPedigreeImage( + image: Buffer, + mediaType: 'image/jpeg' | 'image/png' | 'image/webp', +): Promise { + let response; + try { + response = await getClient().beta.messages.parse({ + model: MODEL, + max_tokens: 16000, + // On a policy decline, re-run on Anthropic's recommended fallback model + // instead of failing the scan. + betas: ['server-side-fallback-2026-07-01'], + fallbacks: 'default', + output_config: { effort: 'high', format: betaZodOutputFormat(ScanOutput) }, + system: SYSTEM_PROMPT, + messages: [ + { + role: 'user', + content: [ + { type: 'image', source: { type: 'base64', media_type: mediaType, data: image.toString('base64') } }, + { type: 'text', text: 'Transcribe this pedigree.' }, + ], + }, + ], + }); + } catch (err) { + if (err instanceof Anthropic.AuthenticationError || err instanceof Anthropic.PermissionDeniedError) { + throw new PedigreeScanError('The scanning API key was rejected. Check ANTHROPIC_API_KEY on the server.', 502); + } + if (err instanceof Anthropic.RateLimitError) { + throw new PedigreeScanError('Scanning is busy right now. Try again in a minute.', 429); + } + if (err instanceof Anthropic.APIConnectionError) { + throw new PedigreeScanError('Couldn’t reach the scanning service. Check the server’s internet connection.', 502); + } + if (err instanceof Anthropic.APIError) { + console.error('Pedigree scan API error', err.status, err.message); + throw new PedigreeScanError('The scanning service returned an error. Try again.', 502); + } + throw err; + } + + if (response.stop_reason === 'refusal') { + throw new PedigreeScanError('This image couldn’t be scanned. Try a clearer photo of just the pedigree.', 422); + } + const parsed = response.parsed_output; + if (response.stop_reason === 'max_tokens' || !parsed) { + throw new PedigreeScanError('The scan didn’t finish. Try again.', 502); + } + if (!parsed.is_pedigree) { + throw new PedigreeScanError('That doesn’t look like a rabbit pedigree. Try another photo.', 422); + } + + const subject = parsed.subject; + return { + subject: { + ...cleanAnimal(subject), + sex: subject.sex, + breed: cleanText(subject.breed), + color_name: cleanText(subject.color), + }, + ancestors: flattenAncestors(subject), + notes: cleanText(parsed.notes), + }; +} diff --git a/mykindlebox/server/package-lock.json b/mykindlebox/server/package-lock.json index 82a5a32..6b71d7f 100644 --- a/mykindlebox/server/package-lock.json +++ b/mykindlebox/server/package-lock.json @@ -9,12 +9,14 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@anthropic-ai/sdk": "^1.3.0", "better-sqlite3": "^12.11.1", "cheerio": "^1.2.0", "cors": "^2.8.6", "drizzle-orm": "^0.45.2", "express": "^5.2.1", - "multer": "^2.2.0" + "multer": "^2.2.0", + "zod": "^4.6.7" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", @@ -27,6 +29,37 @@ "typescript": "^6.0.3" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-1.3.0.tgz", + "integrity": "sha512-k7z9BlLLPxfZH+ov7dS6EnaN/wiZeV8YNT1LZfrpAHIy7oY7pHr4UzirF9f2OlHtFEnRBOX+AZQ91Y5fK+TPTw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.4.1", + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@drizzle-team/brocli": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", @@ -912,6 +945,21 @@ "node": ">=18" } }, + "node_modules/@opentelemetry/api": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.10.0.tgz", + "integrity": "sha512-lMkyes8iHrG+mk6WH7X70MdCLI2RS0FwNBY1PyQ8UemaijE2m5mv5A0Niy3HtbYTFrgkUEQjFMXmSAk2hQDuPQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@types/better-sqlite3": { "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", @@ -2336,6 +2384,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -2617,6 +2671,19 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3287,6 +3354,16 @@ "source-map": "^0.6.0" } }, + "node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -3359,6 +3436,12 @@ "node": ">=0.6" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/tsx": { "version": "4.22.4", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", @@ -3520,6 +3603,15 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" + }, + "node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/mykindlebox/server/package.json b/mykindlebox/server/package.json index d36635a..6e97a9c 100644 --- a/mykindlebox/server/package.json +++ b/mykindlebox/server/package.json @@ -14,12 +14,14 @@ "license": "ISC", "type": "commonjs", "dependencies": { + "@anthropic-ai/sdk": "^1.3.0", "better-sqlite3": "^12.11.1", "cheerio": "^1.2.0", "cors": "^2.8.6", "drizzle-orm": "^0.45.2", "express": "^5.2.1", - "multer": "^2.2.0" + "multer": "^2.2.0", + "zod": "^4.6.7" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", diff --git a/mykindlebox/server/routes/pedigreeScan.ts b/mykindlebox/server/routes/pedigreeScan.ts new file mode 100644 index 0000000..26b0776 --- /dev/null +++ b/mykindlebox/server/routes/pedigreeScan.ts @@ -0,0 +1,86 @@ +import { Router } from 'express'; +import { sql } from 'drizzle-orm'; +import multer from 'multer'; +import { db } from '../db/client'; +import { rabbits } from '../db/schema'; +import { isPedigreeScanEnabled, PedigreeScanError, scanPedigreeImage } from '../lib/pedigreeScan'; + +const router = Router(); + +const MEDIA_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp'] as const); +type MediaType = 'image/jpeg' | 'image/png' | 'image/webp'; + +// In memory: the image is forwarded to the API and discarded, never written to +// disk. The client downsizes to ~2400px JPEG first, so real uploads are well +// under the limit. +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 10 * 1024 * 1024 }, +}).single('image'); + +// Lets the client decide whether to show the "Scan a pedigree" button. +router.get('/', (req, res) => { + res.json({ enabled: isPedigreeScanEnabled() }); +}); + +// Confident herd match for a scanned sire/dam (FEATURE_OCR_Pedigree_Scan.md +// Q6): an exact ear number wins; otherwise exactly one exact-name match. +// Anything looser stays a manual entry the user can link by hand. +function findHerdMatch(name: string | null, earNumber: string | null) { + const columns = { id: rabbits.id, name: rabbits.name }; + if (earNumber) { + const byEar = db + .select(columns) + .from(rabbits) + .where(sql`upper(${rabbits.earNumber}) = ${earNumber.toUpperCase()}`) + .limit(2) + .all(); + if (byEar.length === 1) return byEar[0]; + } + if (name) { + const byName = db + .select(columns) + .from(rabbits) + .where(sql`lower(${rabbits.name}) = ${name.toLowerCase()}`) + .limit(2) + .all(); + if (byName.length === 1) return byName[0]; + } + return null; +} + +router.post('/', (req, res, next) => { + if (!isPedigreeScanEnabled()) { + res.status(503).json({ error: 'Pedigree scanning isn’t set up on this server.' }); + return; + } + + upload(req, res, async (uploadErr) => { + if (uploadErr) { + res.status(400).json({ error: uploadErr.message }); + return; + } + const file = req.file; + if (!file || !MEDIA_TYPES.has(file.mimetype as MediaType)) { + res.status(400).json({ error: 'Upload a JPEG, PNG, or WebP photo of the pedigree.' }); + return; + } + + try { + const result = await scanPedigreeImage(file.buffer, file.mimetype as MediaType); + const matches = { + sire: findHerdMatch(result.ancestors.sire.name, result.ancestors.sire.ear_number), + dam: findHerdMatch(result.ancestors.dam.name, result.ancestors.dam.ear_number), + }; + res.json({ ...result, matches }); + } catch (err) { + if (err instanceof PedigreeScanError) { + res.status(err.status).json({ error: err.message }); + return; + } + next(err); + } + }); +}); + +export default router; diff --git a/mykindlebox/server/routes/rabbits.ts b/mykindlebox/server/routes/rabbits.ts index 67a7000..ca5fe26 100644 --- a/mykindlebox/server/routes/rabbits.ts +++ b/mykindlebox/server/routes/rabbits.ts @@ -3,8 +3,14 @@ import { and, or, asc, gte, eq, sql, SQL, AnyColumn, getTableColumns } from 'dri import multer from 'multer'; import path from 'path'; import { db, uploadsDir } from '../db/client'; -import { rabbits } from '../db/schema'; +import { rabbits, rabbitEvents, litters, showEventEntries } from '../db/schema'; import { toCamelCase, toSnakeCase, pick } from '../utils/case'; +import { + findExistingSubject, + materializeAncestors, + mergeIntoExisting, + MergeReport, +} from '../lib/ancestors'; const router = Router(); @@ -105,6 +111,20 @@ function fetchRabbitById(id: number): RabbitRow | undefined { return db.select().from(rabbits).where(eq(rabbits.id, id)).get(); } +// Manual-ancestor JSON for a resolved node (see client lib/pedigree.js). +// Freeform nodes already hold their stored text. +function snapshotText(node: AncestorNode | null): string | null { + if (!node) return null; + if (node.id == null) return node.name; + return JSON.stringify({ + name: node.name ?? '', + farm: node.farm ?? '', + ear_number: node.ear_number ?? '', + weight_lbs: node.weight_lbs ?? 3, + dob: node.dob ?? 'Unknown', + }); +} + function dbAncestorNode(ancestor: RabbitRow): AncestorNode { return { id: ancestor.id, @@ -409,9 +429,46 @@ router.post('/', (req, res) => { return; } - const created = db.insert(rabbits).values(values).returning().get(); + // Adding a rabbit that already exists (same ear number, or same name + farm) + // tops up that record instead of duplicating it; otherwise ancestors entered + // as text become linked reference rabbits. See lib/ancestors.ts. One + // transaction so a failure can't leave half-made ancestor records behind. + const outcome = db.transaction((tx) => { + const existing = findExistingSubject(tx, values); + if (existing) { + const report = mergeIntoExisting(tx, existing, values); + const row = tx.select().from(rabbits).where(eq(rabbits.id, existing.id)).get()!; + return { row, merged: report }; + } + materializeAncestors(tx, values); + return { row: tx.insert(rabbits).values(values).returning().get(), merged: null }; + }); - res.status(201).json(toSnakeCase(created)); + res + .status(outcome.merged ? 200 : 201) + .json({ ...toSnakeCase(outcome.row), merged: outcome.merged && mergeReportJson(outcome.merged) }); +}); + +function mergeReportJson(report: MergeReport) { + return { + filled: Object.keys(toSnakeCase(Object.fromEntries(report.filled.map((key) => [key, true])))), + ancestors_added: report.ancestorsAdded, + ancestors_updated: report.ancestorsUpdated, + conflicts: report.conflicts, + had_photo: report.hadPhoto, + }; +} + +// Lets the Add Rabbit review step say up front that saving will top up an +// existing rabbit. Same matching as POST / uses. +router.post('/find-existing', (req, res) => { + const values = pick(toCamelCase(req.body), RABBIT_WRITABLE) as typeof rabbits.$inferInsert; + const existing = findExistingSubject(db, values); + res.json( + existing + ? toSnakeCase({ id: existing.id, name: existing.name, farm: existing.farm, earNumber: existing.earNumber, status: existing.status }) + : null, + ); }); router.patch('/:id', (req, res) => { @@ -450,6 +507,20 @@ router.delete('/:id', (req, res) => { return; } + // Show entries are history the user would lose silently — make them remove + // those first (the UI suggests Archive instead). + const showEntryCount = db + .select({ id: showEventEntries.id }) + .from(showEventEntries) + .where(eq(showEventEntries.rabbitId, id)) + .all().length; + if (showEntryCount > 0) { + res.status(409).json({ + error: `${existing.name} is entered in ${showEntryCount} show${showEntryCount === 1 ? '' : 's'}. Remove those entries first, or archive the rabbit instead.`, + }); + return; + } + // Chain-authority: when a rabbit is deleted, its offspring's link to it is // broken, so snapshot the deleted rabbit (and the two generations above it // that the offspring used to see through it) into the offspring's freeform @@ -458,11 +529,14 @@ router.delete('/:id', (req, res) => { // text. All offspring of a given rabbit share the same snapshot, so it's // computed once. Wrapped in a transaction with the delete so a partial failure // can't leave dangling FKs. - const nameAt = (path: Direction[]) => resolveAncestorNode(existing, path)?.name ?? null; + // Snapshots keep the full manual-ancestor JSON (name, farm, ear #, weight, + // DOB), not just a name, so the entry can be matched/re-linked later. + const nameAt = (path: Direction[]) => snapshotText(resolveAncestorNode(existing, path)); + const self = snapshotText(dbAncestorNode(existing)); const sireSnapshot = { sireId: null, - sireNameText: existing.name, + sireNameText: self, paternalGrandsireNameText: nameAt(['sire']), paternalGranddamNameText: nameAt(['dam']), ppGrandsireNameText: nameAt(['sire', 'sire']), @@ -473,7 +547,7 @@ router.delete('/:id', (req, res) => { const damSnapshot = { damId: null, - damNameText: existing.name, + damNameText: self, maternalGrandsireNameText: nameAt(['sire']), maternalGranddamNameText: nameAt(['dam']), mpGrandsireNameText: nameAt(['sire', 'sire']), @@ -485,6 +559,12 @@ router.delete('/:id', (req, res) => { db.transaction((tx) => { tx.update(rabbits).set(sireSnapshot).where(eq(rabbits.sireId, id)).run(); tx.update(rabbits).set(damSnapshot).where(eq(rabbits.damId, id)).run(); + // SQLite FK enforcement is off, so nothing cascades on its own: litters + // keep their dates/kit counts with that parent shown as unknown, and the + // rabbit's own timeline events go with it. + tx.update(litters).set({ sireId: null }).where(eq(litters.sireId, id)).run(); + tx.update(litters).set({ damId: null }).where(eq(litters.damId, id)).run(); + tx.delete(rabbitEvents).where(eq(rabbitEvents.rabbitId, id)).run(); tx.delete(rabbits).where(eq(rabbits.id, id)).run(); });