Build image / build-and-push (push) Successful in 6s
Gadfly findings on #94, the real ones: - scanSeedPacket extends only the READ deadline; a slow upload + a live vision call runs past the server's absolute 30s WriteTimeout and the successful response is silently dropped (the #78 failure mode). Extend the write deadline too (scanWriteTimeout). - An oversized upload tripping MaxBytesReader was mapped to 400; it's 413. Detect *http.MaxBytesError and report IMAGE_TOO_LARGE. - Split imagenorm error mapping: ErrTooLarge->413, ErrUnsupported->400, genuine read/encode faults (and a failed file.Open)->500, not 400. - CreateFromPacket discarded the plant it created when the lot then failed, contradicting its own doc. Roll the new plant back instead so the confirm is all-or-nothing (a fresh plant has no lots/plantings, so the delete is safe; log-and-continue on cleanup failure). - Dedup: packetLotRequest and seedLotCreateRequest shared every lot field. Extract a seedLotFields base both use. validCategory now reuses plantCategories. EffectiveConfig resolves agent+vision from one settings-row read instead of two. - capabilities swallowed an EffectiveVision error silently; log it. - vision test hand-copied Extract's body (drift risk). Split generate() out of Extract so the hermetic test drives the real request builder. Tests: rollback-on-lot-failure (service), oversized->413 (api). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
228 lines
8.0 KiB
Go
228 lines
8.0 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"sort"
|
|
"strings"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/vision"
|
|
)
|
|
|
|
// Seed-packet capture (#81): read a photographed packet, propose a plant + lot,
|
|
// let the user confirm. The two halves are deliberately separate operations:
|
|
// extraction only READS (a picture in, a proposal out — it can't touch the
|
|
// garden), and creation happens later, from the confirmed proposal, so a
|
|
// misread never writes anything on its own.
|
|
|
|
// PacketPlantMatch is a candidate existing plant the packet might be, with why it
|
|
// matched, so the UI can pre-select the likely one and let the user override.
|
|
type PacketPlantMatch struct {
|
|
Plant domain.Plant `json:"plant"`
|
|
// Reason is a short human tag: "exact name", "variety in name", "same species".
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
// PacketProposal is what a scan returns: the fields read off the packet, plus
|
|
// candidate existing plants it might already be. Nothing is created yet.
|
|
type PacketProposal struct {
|
|
Packet vision.SeedPacket `json:"packet"`
|
|
// Candidates are existing plants the packet may match, best first. Empty means
|
|
// "probably a new variety" — the UI then offers to create one.
|
|
Candidates []PacketPlantMatch `json:"candidates"`
|
|
// SuggestedName is the variety (or species) to prefill a new-plant name with.
|
|
SuggestedName string `json:"suggestedName"`
|
|
// SuggestedCategory is the packet's category if it's a valid one, for prefill.
|
|
SuggestedCategory string `json:"suggestedCategory"`
|
|
}
|
|
|
|
// ExtractSeedPacket reads a (JPEG) packet photo and proposes a plant + lot for
|
|
// the actor to confirm. It needs a configured vision model; with none it returns
|
|
// ErrInvalidInput (the API layer turns "not configured" into a clear message and
|
|
// never offers the feature in the first place).
|
|
//
|
|
// The extraction runs as the actor only in the sense that the catalog match is
|
|
// scoped to what they can see; the model call itself has no ACL — it just reads
|
|
// a picture the actor uploaded.
|
|
func (s *Service) ExtractSeedPacket(ctx context.Context, actorID int64, jpeg []byte) (*PacketProposal, error) {
|
|
if len(jpeg) == 0 {
|
|
return nil, domain.ErrInvalidInput
|
|
}
|
|
vis, err := s.EffectiveVision(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !vis.Ready() {
|
|
// No vision model configured — the feature isn't available.
|
|
return nil, domain.ErrInvalidInput
|
|
}
|
|
|
|
packet, err := s.extractPacket(ctx, vis.APIKey, vis.Model, jpeg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
plants, err := s.store.ListPlantsForActor(ctx, actorID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &PacketProposal{
|
|
Packet: packet,
|
|
Candidates: matchPlants(packet, plants),
|
|
SuggestedName: suggestedName(packet),
|
|
SuggestedCategory: validCategory(packet.Category),
|
|
}, nil
|
|
}
|
|
|
|
// suggestedName is the variety if the packet named one, else the species — what
|
|
// to prefill a new plant's name with. "Music" beats "garlic" when both are read.
|
|
func suggestedName(p vision.SeedPacket) string {
|
|
if v := strings.TrimSpace(p.Variety); v != "" {
|
|
return v
|
|
}
|
|
return strings.TrimSpace(p.Species)
|
|
}
|
|
|
|
// validCategory returns the packet's category if it's one pansy knows, else "".
|
|
// It reuses plantCategories — the same set CreatePlant validates against — so a
|
|
// new category can't be accepted by one path and rejected by the other.
|
|
func validCategory(c string) string {
|
|
if _, ok := plantCategories[c]; ok {
|
|
return c
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// matchPlants ranks existing plants the packet might already be, best first.
|
|
//
|
|
// This is the crux of "create both, linked" (#81): getting it wrong makes a
|
|
// duplicate catalog entry that then splits a variety's seed-lot history across
|
|
// two rows. So it NEVER decides — it only surfaces candidates for the user to
|
|
// confirm. Matching is deliberately conservative and name-based (no fuzzy
|
|
// scoring that could confidently mis-rank): exact variety name, variety appearing
|
|
// within a plant's name, then same species word. Case-insensitive.
|
|
func matchPlants(p vision.SeedPacket, plants []domain.Plant) []PacketPlantMatch {
|
|
variety := strings.ToLower(strings.TrimSpace(p.Variety))
|
|
species := strings.ToLower(strings.TrimSpace(p.Species))
|
|
|
|
// rank: lower is better; keep only matched plants.
|
|
type scored struct {
|
|
match PacketPlantMatch
|
|
rank int
|
|
}
|
|
var out []scored
|
|
seen := map[int64]bool{}
|
|
add := func(pl domain.Plant, rank int, reason string) {
|
|
if seen[pl.ID] {
|
|
return
|
|
}
|
|
seen[pl.ID] = true
|
|
out = append(out, scored{PacketPlantMatch{Plant: pl, Reason: reason}, rank})
|
|
}
|
|
|
|
for _, pl := range plants {
|
|
name := strings.ToLower(pl.Name)
|
|
switch {
|
|
case variety != "" && name == variety:
|
|
add(pl, 0, "exact name")
|
|
case variety != "" && strings.Contains(name, variety):
|
|
add(pl, 1, "variety in name")
|
|
case species != "" && wordIn(name, species):
|
|
add(pl, 2, "same species")
|
|
}
|
|
}
|
|
sort.SliceStable(out, func(i, j int) bool { return out[i].rank < out[j].rank })
|
|
|
|
matches := make([]PacketPlantMatch, len(out))
|
|
for i, s := range out {
|
|
matches[i] = s.match
|
|
}
|
|
return matches
|
|
}
|
|
|
|
// wordIn reports whether word appears as a whole space-delimited token in name,
|
|
// so "garlic" matches "German Garlic" but not "garlicky-thing".
|
|
func wordIn(name, word string) bool {
|
|
for _, tok := range strings.Fields(name) {
|
|
if tok == word {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// PacketConfirm is a user-confirmed proposal to turn into rows.
|
|
//
|
|
// Plant selection is explicit: either PlantID names an existing plant to attach
|
|
// the lot to, or NewPlant carries the fields to create one. Exactly one — the
|
|
// service refuses both or neither, so an ambiguous confirm can't silently pick.
|
|
type PacketConfirm struct {
|
|
// PlantID attaches the lot to an existing plant. Set this XOR NewPlant.
|
|
PlantID *int64
|
|
// NewPlant creates a variety. Set this XOR PlantID.
|
|
NewPlant *PlantInput
|
|
// Lot is the purchase to record against whichever plant results.
|
|
Lot SeedLotInput
|
|
}
|
|
|
|
// PacketResult is what a confirm produced.
|
|
type PacketResult struct {
|
|
Plant *domain.Plant `json:"plant"`
|
|
Lot *domain.SeedLot `json:"lot"`
|
|
PlantIsNew bool `json:"plantIsNew"`
|
|
}
|
|
|
|
// CreateFromPacket turns a confirmed proposal into a plant (new or existing) plus
|
|
// a seed lot attributed to it. Unlike garden edits these rows aren't in the undo
|
|
// history — plants and lots are catalog/inventory, created directly — so there is
|
|
// no change set to wrap; the two creations just happen in sequence.
|
|
//
|
|
// If a new plant is created but the lot then fails, the plant is rolled back so
|
|
// the confirm is all-or-nothing. Otherwise a bad lot (say, an invalid unit) would
|
|
// strand a half-made catalog entry the user never asked for on its own, and the
|
|
// error return gives the HTTP caller no handle to it. A just-created plant has no
|
|
// plantings or lots yet, so the delete is safe; if it somehow can't be undone we
|
|
// log and still surface the original lot error, not the cleanup one.
|
|
func (s *Service) CreateFromPacket(ctx context.Context, actorID int64, in PacketConfirm) (*PacketResult, error) {
|
|
hasID, hasNew := in.PlantID != nil, in.NewPlant != nil
|
|
if hasID == hasNew {
|
|
return nil, domain.ErrInvalidInput // exactly one of existing / new
|
|
}
|
|
|
|
res := &PacketResult{}
|
|
if hasNew {
|
|
plant, err := s.CreatePlant(ctx, actorID, *in.NewPlant)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
res.Plant = plant
|
|
res.PlantIsNew = true
|
|
} else {
|
|
// Attach to an existing plant the actor can see. visiblePlant enforces the
|
|
// ACL (built-ins + their own); a plant they can't see is ErrNotFound.
|
|
plant, err := s.visiblePlant(ctx, actorID, *in.PlantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
res.Plant = plant
|
|
}
|
|
|
|
lotIn := in.Lot
|
|
lotIn.PlantID = res.Plant.ID
|
|
lot, err := s.CreateSeedLot(ctx, actorID, lotIn)
|
|
if err != nil {
|
|
if res.PlantIsNew {
|
|
// Roll back the plant we just made for this confirm; keep the lot error.
|
|
if delErr := s.DeletePlant(ctx, actorID, res.Plant.ID); delErr != nil {
|
|
slog.Error("service: could not roll back plant after packet lot failed",
|
|
"error", delErr, "plant", res.Plant.ID)
|
|
}
|
|
}
|
|
return nil, err
|
|
}
|
|
res.Lot = lot
|
|
return res, nil
|
|
}
|