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
137 lines
5.3 KiB
Go
137 lines
5.3 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/imagenorm"
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
|
)
|
|
|
|
// Seed-packet capture (#81). Two steps, deliberately separate:
|
|
// POST /seed-lots/scan multipart image → a proposal (reads only)
|
|
// POST /seed-lots/from-packet confirmed proposal → a plant + lot
|
|
// The scan never writes; creation happens only from an explicit confirm, so a
|
|
// misread can't add anything to the catalog on its own.
|
|
|
|
// scanUploadLimit bounds the multipart body. imagenorm caps the decoded image at
|
|
// 25 MiB; this is a little over that for the multipart envelope. A phone photo is
|
|
// a few MB, so this is generous.
|
|
const scanUploadLimit = 30 << 20
|
|
|
|
// scanReadTimeout is how long we allow the image upload to take. The server's
|
|
// default ReadTimeout (15s) is fine for JSON but tight for a multi-megabyte photo
|
|
// on a slow phone connection, so this endpoint extends it — the same
|
|
// ResponseController mechanism the SSE path uses for writes (#78).
|
|
const scanReadTimeout = 60 * time.Second
|
|
|
|
// scanSeedPacket reads an uploaded packet photo and returns a proposal.
|
|
func (h *handlers) scanSeedPacket(c *gin.Context) {
|
|
// Extend the read deadline for the (potentially large, potentially slow)
|
|
// upload. Best-effort: if the writer doesn't support it, the default applies.
|
|
_ = http.NewResponseController(c.Writer).SetReadDeadline(time.Now().Add(scanReadTimeout))
|
|
|
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, scanUploadLimit)
|
|
file, err := c.FormFile("image")
|
|
if err != nil {
|
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "attach an image as the \"image\" field")
|
|
return
|
|
}
|
|
f, err := file.Open()
|
|
if err != nil {
|
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "could not read the uploaded image")
|
|
return
|
|
}
|
|
defer f.Close()
|
|
|
|
// Normalize to JPEG (decodes HEIC/webp/png/jpeg, downscales, re-encodes) so
|
|
// everything downstream — including the vision model — only sees a format it
|
|
// can read. This is where an iPhone HEIC becomes usable.
|
|
jpeg, format, err := imagenorm.Normalize(f, imagenorm.Options{})
|
|
if err != nil {
|
|
if errors.Is(err, imagenorm.ErrTooLarge) {
|
|
writeAPIError(c, http.StatusRequestEntityTooLarge, "IMAGE_TOO_LARGE", "that image is too large — try a smaller photo")
|
|
return
|
|
}
|
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "that doesn't look like an image we can read (JPEG, PNG, HEIC or WebP)")
|
|
return
|
|
}
|
|
_ = format // available for logging if wanted
|
|
|
|
prop, err := h.svc.ExtractSeedPacket(c.Request.Context(), mustActor(c).ID, jpeg)
|
|
if err != nil {
|
|
// A missing vision model surfaces as ErrInvalidInput from the service; give
|
|
// it a clearer message than the generic 400, since the UI shouldn't have
|
|
// offered the button at all in that case.
|
|
if errors.Is(err, domain.ErrInvalidInput) {
|
|
writeAPIError(c, http.StatusServiceUnavailable, "VISION_DISABLED", "packet scanning isn't set up on this instance")
|
|
return
|
|
}
|
|
writeServiceError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, prop)
|
|
}
|
|
|
|
// packetLotRequest is the lot half of a confirm. It's seedLotCreateRequest
|
|
// WITHOUT plantId — the plant comes from the confirm's plantId/newPlant choice,
|
|
// not the lot body, and reusing seedLotCreateRequest would wrongly require one.
|
|
type packetLotRequest struct {
|
|
Vendor string `json:"vendor"`
|
|
SourceURL string `json:"sourceUrl"`
|
|
SKU string `json:"sku"`
|
|
LotCode string `json:"lotCode"`
|
|
PurchasedAt *string `json:"purchasedAt"`
|
|
PackedForYear *int `json:"packedForYear"`
|
|
Quantity float64 `json:"quantity"`
|
|
Unit string `json:"unit" binding:"required"`
|
|
CostCents *int `json:"costCents"`
|
|
GerminationPct *float64 `json:"germinationPct"`
|
|
Notes string `json:"notes"`
|
|
}
|
|
|
|
func (r packetLotRequest) toInput() service.SeedLotInput {
|
|
return service.SeedLotInput{
|
|
Vendor: r.Vendor, SourceURL: r.SourceURL, SKU: r.SKU, LotCode: r.LotCode,
|
|
PurchasedAt: r.PurchasedAt, PackedForYear: r.PackedForYear, Quantity: r.Quantity,
|
|
Unit: r.Unit, CostCents: r.CostCents, GerminationPct: r.GerminationPct, Notes: r.Notes,
|
|
}
|
|
}
|
|
|
|
// fromPacketRequest confirms a proposal: exactly one of plantId (attach to an
|
|
// existing plant) or newPlant (create a variety), plus the lot to record.
|
|
type fromPacketRequest struct {
|
|
PlantID *int64 `json:"plantId"`
|
|
NewPlant *plantCreateRequest `json:"newPlant"`
|
|
Lot packetLotRequest `json:"lot"`
|
|
}
|
|
|
|
// createFromPacket turns a confirmed proposal into a plant + lot.
|
|
func (h *handlers) createFromPacket(c *gin.Context) {
|
|
var req fromPacketRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a lot and exactly one of plantId or newPlant are required")
|
|
return
|
|
}
|
|
|
|
confirm := service.PacketConfirm{
|
|
PlantID: req.PlantID,
|
|
Lot: req.Lot.toInput(), // no plantId in the lot body; the service attributes it
|
|
}
|
|
if req.NewPlant != nil {
|
|
in := req.NewPlant.toInput()
|
|
confirm.NewPlant = &in
|
|
}
|
|
|
|
res, err := h.svc.CreateFromPacket(c.Request.Context(), mustActor(c).ID, confirm)
|
|
if err != nil {
|
|
writeServiceError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, res)
|
|
}
|