Seed-packet capture: vision model, extraction, catalog match, create (backend) (#81)
Photograph a seed packet → it fills in the plant and the purchase. This is the backend; the scan UI is a follow-up PR. Vision model config (mirrors the agent model from #79): - Migration 0011 adds instance_settings.vision_model; PANSY_VISION_MODEL is the env default. Precedence Settings → env → empty; the KEY stays in the env. - EffectiveVision resolves it; /capabilities advertises "vision" only when a model + key are configured, so the UI offers the scan button only when it works. Extraction is one-shot, NOT an agent loop (internal/vision): - majordomo.Generate[SeedPacket] derives a JSON schema from the struct tags and hands the image to the vision model; it can't call a tool, so it can't touch the garden — it only reads a picture and returns data. Numeric fields are pointers, so a field the packet doesn't print comes back nil, not a made-up 0. - Hermetic test: majordomo's fake provider returns canned packet JSON and Generate unmarshals it, image + derived schema included. No live model. The image is normalized to JPEG at the upload boundary (imagenorm from #80), which is where an iPhone HEIC becomes readable — majordomo's media path can't decode HEIC. imagenorm now links into the binary (~7 MB, the cost #80 deferred). The hard part is catalog matching, not OCR (internal/service/seed_packet.go): - A wrong auto-match splits a variety's seed-lot history across duplicate rows, so the service NEVER auto-creates. matchPlants surfaces RANKED candidates (exact name → variety-in-name → same species, conservative and name-based), the user confirms, and CreateFromPacket makes the plant (new or existing) + the lot. Exactly one of plantId/newPlant, refused otherwise. - Plants/lots aren't in the undo history (catalog/inventory), so no change set. - The extractor is injectable (service.WithPacketExtractor) so ExtractSeedPacket and the /scan endpoint test end to end against a fake, no live model. Endpoints: POST /seed-lots/scan (multipart image → proposal, reads only; extends the read deadline for a slow phone upload, caps the body, maps too-large/unreadable to clear statuses) and POST /seed-lots/from-packet (confirmed proposal → 201). Docs: README (PANSY_VISION_MODEL), DESIGN (routes + the decision and why the model can't touch the garden). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
// Package vision reads a photographed seed packet into structured fields (#81).
|
||||
//
|
||||
// It is one-shot structured extraction, NOT an agent loop: majordomo.Generate[T]
|
||||
// derives a JSON schema from the SeedPacket struct, hands the image to a vision
|
||||
// model, and unmarshals the reply into SeedPacket. Because it can't call a tool,
|
||||
// it can't touch the garden — it only reads a picture and returns data, which the
|
||||
// service then turns into a plant + lot after the user confirms.
|
||||
package vision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agentmodel"
|
||||
)
|
||||
|
||||
// SeedPacket is what a vision model reads off a packet. The json/description/enum
|
||||
// tags drive the schema majordomo.Generate derives; pointer fields are nullable,
|
||||
// so a field the packet doesn't print comes back nil rather than a made-up zero.
|
||||
//
|
||||
// These are the packet's PRINTED facts. Mapping them onto a pansy Plant + SeedLot
|
||||
// (and deciding whether the variety is one already in the catalog) is the
|
||||
// service's job, not the model's.
|
||||
type SeedPacket struct {
|
||||
Species string `json:"species" description:"the plant species in plain words, e.g. tomato, garlic, basil"`
|
||||
Variety string `json:"variety" description:"the cultivar or variety name, e.g. Cherokee Purple; empty if the packet only names a species"`
|
||||
Category string `json:"category" enum:"vegetable,herb,flower,fruit,tree_shrub,cover" description:"the single best-fit category"`
|
||||
Vendor string `json:"vendor" description:"the seed company, e.g. Johnny's Selected Seeds"`
|
||||
SKU string `json:"sku" description:"the vendor's item/product number, if printed"`
|
||||
LotCode string `json:"lotCode" description:"the lot or batch code, if printed"`
|
||||
PackedForYear *int `json:"packedForYear" description:"the 'packed for' or 'sell by' year, if printed"`
|
||||
DaysToMaturity *int `json:"daysToMaturity" description:"days to maturity/harvest, if printed"`
|
||||
SpacingCM *float64 `json:"spacingCm" description:"recommended in-row spacing in CENTIMETERS; convert if the packet uses inches"`
|
||||
SeedCount *int `json:"seedCount" description:"approximate seed count in the packet, if printed"`
|
||||
}
|
||||
|
||||
// extractPrompt tells the model the conventions it can't guess: centimeters, and
|
||||
// that a missing field must be left empty rather than invented.
|
||||
const extractPrompt = `You are reading a photograph of a seed packet. Extract only what is actually printed on it.
|
||||
Rules:
|
||||
- Spacing must be in CENTIMETERS. If the packet gives inches, convert (1 in = 2.54 cm).
|
||||
- If a field is not printed on the packet, leave it empty or null. Do not guess or fill from general knowledge.
|
||||
- "variety" is the cultivar name (e.g. "Cherokee Purple"); "species" is the plain plant name (e.g. "tomato").`
|
||||
|
||||
// Extract runs one vision extraction: it resolves the model spec against pansy's
|
||||
// registry, sends the JPEG with the prompt, and returns the parsed SeedPacket.
|
||||
// The image bytes should already be normalized to JPEG (see internal/imagenorm).
|
||||
//
|
||||
// It makes a live model call, so callers give it a bounded context.
|
||||
func Extract(ctx context.Context, apiKey, modelSpec string, jpeg []byte) (SeedPacket, error) {
|
||||
if len(jpeg) == 0 {
|
||||
return SeedPacket{}, fmt.Errorf("vision: empty image")
|
||||
}
|
||||
model, err := agentmodel.Resolve(apiKey, modelSpec)
|
||||
if err != nil {
|
||||
return SeedPacket{}, err
|
||||
}
|
||||
return majordomo.Generate[SeedPacket](ctx, model, majordomo.Request{
|
||||
Messages: []majordomo.Message{
|
||||
majordomo.UserParts(
|
||||
majordomo.Text(extractPrompt),
|
||||
majordomo.Image("image/jpeg", jpeg),
|
||||
),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package vision
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"testing"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
|
||||
)
|
||||
|
||||
// tinyJPEG returns a real, sniffable JPEG. The chain runs media.Normalize before
|
||||
// the provider, which checks the image's magic bytes, so a string literal won't
|
||||
// do — the bytes must actually be a JPEG.
|
||||
func tinyJPEG(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
var b bytes.Buffer
|
||||
if err := jpeg.Encode(&b, image.NewRGBA(image.Rect(0, 0, 8, 8)), nil); err != nil {
|
||||
t.Fatalf("encode jpeg: %v", err)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
// extractVia is Extract with a caller-supplied registry, so the test can point it
|
||||
// at a fake provider instead of resolving a live model. It mirrors Extract's body
|
||||
// exactly apart from where the model comes from.
|
||||
func extractVia(t *testing.T, m llm.Model, jpeg []byte) (SeedPacket, error) {
|
||||
t.Helper()
|
||||
return majordomo.Generate[SeedPacket](context.Background(), m, majordomo.Request{
|
||||
Messages: []majordomo.Message{
|
||||
majordomo.UserParts(majordomo.Text(extractPrompt), majordomo.Image("image/jpeg", jpeg)),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// TestExtractParsesModelJSON is the hermetic proof that the extraction path works
|
||||
// end to end without a live model: a fake vision model returns canned packet JSON
|
||||
// and Generate[SeedPacket] unmarshals it into the struct, image and schema
|
||||
// included.
|
||||
func TestExtractParsesModelJSON(t *testing.T) {
|
||||
reg := majordomo.New(majordomo.WithoutEnvProviders())
|
||||
fp := fake.New("fp") // default caps advertise structured output + images
|
||||
reg.RegisterProvider(fp)
|
||||
fp.Enqueue("vision", fake.Reply(`{
|
||||
"species": "garlic",
|
||||
"variety": "Music",
|
||||
"category": "vegetable",
|
||||
"vendor": "Johnny's",
|
||||
"sku": "2761",
|
||||
"lotCode": "L-42",
|
||||
"packedForYear": 2026,
|
||||
"daysToMaturity": 240,
|
||||
"spacingCm": 15,
|
||||
"seedCount": 8
|
||||
}`))
|
||||
|
||||
m, err := reg.Parse("fp/vision")
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
got, err := extractVia(t, m, tinyJPEG(t))
|
||||
if err != nil {
|
||||
t.Fatalf("extract: %v", err)
|
||||
}
|
||||
if got.Variety != "Music" || got.Species != "garlic" || got.Category != "vegetable" {
|
||||
t.Errorf("unexpected packet: %+v", got)
|
||||
}
|
||||
if got.SpacingCM == nil || *got.SpacingCM != 15 {
|
||||
t.Errorf("spacingCm = %v, want 15", got.SpacingCM)
|
||||
}
|
||||
if got.PackedForYear == nil || *got.PackedForYear != 2026 {
|
||||
t.Errorf("packedForYear = %v, want 2026", got.PackedForYear)
|
||||
}
|
||||
|
||||
// The image and the derived schema really reached the model.
|
||||
call := fp.Calls()[0]
|
||||
if call.Request.SchemaName != "seedpacket" {
|
||||
t.Errorf("schema name = %q, want seedpacket", call.Request.SchemaName)
|
||||
}
|
||||
var sawImage bool
|
||||
for _, p := range call.Request.Messages[0].Parts {
|
||||
if _, ok := p.(llm.ImagePart); ok {
|
||||
sawImage = true
|
||||
}
|
||||
}
|
||||
if !sawImage {
|
||||
t.Error("the image part didn't reach the model")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractLeavesMissingFieldsNil: a packet that only prints a species comes
|
||||
// back with nil pointers for the numbers, not invented zeros — the whole reason
|
||||
// the numeric fields are pointers.
|
||||
func TestExtractLeavesMissingFieldsNil(t *testing.T) {
|
||||
reg := majordomo.New(majordomo.WithoutEnvProviders())
|
||||
fp := fake.New("fp")
|
||||
reg.RegisterProvider(fp)
|
||||
fp.Enqueue("vision", fake.Reply(`{"species":"basil","category":"herb"}`))
|
||||
m, _ := reg.Parse("fp/vision")
|
||||
|
||||
got, err := extractVia(t, m, tinyJPEG(t))
|
||||
if err != nil {
|
||||
t.Fatalf("extract: %v", err)
|
||||
}
|
||||
if got.SpacingCM != nil || got.DaysToMaturity != nil || got.PackedForYear != nil || got.SeedCount != nil {
|
||||
t.Errorf("missing numeric fields should be nil, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractRejectsEmptyImage: no bytes, no call.
|
||||
func TestExtractRejectsEmptyImage(t *testing.T) {
|
||||
if _, err := Extract(context.Background(), "k", "fp/vision", nil); err == nil {
|
||||
t.Error("Extract accepted an empty image")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user