MyKindlebox/mykindlebox/server/lib/pedigreeScan.ts

222 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<typeof GreatGrandparent>;
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<string, ScannedAnimal>;
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<typeof Subject>): Record<string, ScannedAnimal> {
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<PedigreeScanResult> {
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),
};
}