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
82 lines
2.8 KiB
Go
82 lines
2.8 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
|
)
|
|
|
|
// The instance_settings row is seeded by migration 0010 and there is exactly one
|
|
// (CHECK id = 1), so reads never branch on existence and writes never insert.
|
|
|
|
const instanceSettingsColumns = `agent_model, agent_enabled, vision_model, version, updated_at`
|
|
|
|
// scanInstanceSettings reads the single settings row. agent_enabled is a nullable
|
|
// INTEGER (NULL = inherit env), so it is scanned through sql.NullInt64.
|
|
func scanInstanceSettings(s scanner) (*domain.InstanceSettings, error) {
|
|
var (
|
|
out domain.InstanceSettings
|
|
enabled sql.NullInt64
|
|
)
|
|
if err := s.Scan(&out.AgentModel, &enabled, &out.VisionModel, &out.Version, &out.UpdatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
if enabled.Valid {
|
|
b := enabled.Int64 != 0
|
|
out.AgentEnabled = &b
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// GetInstanceSettings returns the single settings row.
|
|
func (d *DB) GetInstanceSettings(ctx context.Context) (*domain.InstanceSettings, error) {
|
|
s, err := scanInstanceSettings(d.sql.QueryRowContext(ctx,
|
|
`SELECT `+instanceSettingsColumns+` FROM instance_settings WHERE id = 1`))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
// The migration seeds this row, so its absence is a broken database, not a
|
|
// normal "not found" the caller should paper over.
|
|
return nil, fmt.Errorf("store: instance_settings row missing (migration 0010 not applied?)")
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: get instance settings: %w", err)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// UpdateInstanceSettings applies a version-guarded update to the single row,
|
|
// following the same optimistic-concurrency contract as every mutable resource:
|
|
// the updated row on success, (current row, ErrVersionConflict) on a version
|
|
// mismatch. There is no ErrNotFound path — the row always exists.
|
|
//
|
|
// agentEnabled is nil to store SQL NULL (inherit env), or a pointer to store an
|
|
// explicit 0/1.
|
|
func (d *DB) UpdateInstanceSettings(ctx context.Context, s *domain.InstanceSettings) (*domain.InstanceSettings, error) {
|
|
var enabled any
|
|
if s.AgentEnabled != nil {
|
|
enabled = boolToInt(*s.AgentEnabled)
|
|
}
|
|
updated, err := scanInstanceSettings(d.sql.QueryRowContext(ctx,
|
|
`UPDATE instance_settings
|
|
SET agent_model = ?, agent_enabled = ?, vision_model = ?,
|
|
version = version + 1,
|
|
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
|
WHERE id = 1 AND version = ?
|
|
RETURNING `+instanceSettingsColumns,
|
|
s.AgentModel, enabled, s.VisionModel, s.Version,
|
|
))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
current, gerr := d.GetInstanceSettings(ctx)
|
|
if gerr != nil {
|
|
return nil, gerr
|
|
}
|
|
return current, domain.ErrVersionConflict
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: update instance settings: %w", err)
|
|
}
|
|
return updated, nil
|
|
}
|