Twenty-one prompts against the live assistant found one fabricated success,
a model that believed it was 2025, and a describe_garden that was ~450 plop
entries per turn. This is the set of fixes, each traceable to a finding:
- The gardener's LOCAL day travels with the turn (`today` on POST /agent/chat,
sent by the UI like plantedAt) into the system prompt and every dated tool
default. Left to guess, the model dated journal entries a year back; left to
the server, a 9 pm fill landed on UTC's tomorrow.
- describe_garden groups plops by plant — count, where, planted date, days to
maturity — and lists ids only for groups of ≤ 8; list_plantings spells a big
group out on demand and remove_plantings acts on one plant in a bed ("take
the beets out, leave the garlic"), which used to mean 116 single removals.
- New tools: move_planting (keeps the planting date; across beds via the new
MovePlanting, which is why the store's UPDATE now writes object_id),
update_plant, read_history, copy_garden (the "<garden> — <year>" plan
convention). fill_region takes an explicit local rectangle and a seedLotId;
place_planting's radius defaults to one plant (spacing/2) instead of a guess.
- The system prompt states the date and the gardener's units, forbids claiming
a change no tool made, says it cannot undo and points at the Undo button,
asks before clearing beds on an ambiguous sentence, and stops narrating its
own plantings into the journal.
- A mutation aimed at ANOTHER garden inside a turn is recorded under that
garden as its own change set, not filed into the open scope.
- UI: the thread scrolls inside the Assistant panel so the composer stays
put; every tool has a step label; wide tables stay inside the bubble.
Co-Authored-By: Claude Fable 5 <[email protected]>
418 lines
14 KiB
Go
418 lines
14 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"math"
|
||
"strings"
|
||
"time"
|
||
|
||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||
)
|
||
|
||
// dateLayout is the 'YYYY-MM-DD' format plantings store planted_at/removed_at in.
|
||
const dateLayout = "2006-01-02"
|
||
|
||
const (
|
||
maxPlantingLabelLen = 200
|
||
maxRadiusCM = 10_000 // 100 m — a generous plop ceiling
|
||
maxExplicitCount = 1_000_000 // sanity cap on a manual count override
|
||
)
|
||
|
||
// derivedCount is the plant count implied by a plop's area and the plant's
|
||
// mature spacing: max(1, round(π·r² / spacing²)). It is the single source of the
|
||
// formula (also feeds #15's display and #19's FillRegion). A non-positive or
|
||
// non-finite radius/spacing collapses to the floor of 1; the result is capped at
|
||
// maxExplicitCount so a huge radius / tiny spacing can't yield an absurd (or, on
|
||
// a 32-bit int, overflowing) value — the same ceiling a manual override honors.
|
||
func derivedCount(radiusCM, spacingCM float64) int {
|
||
if radiusCM <= 0 || spacingCM <= 0 || !isFinite(radiusCM) || !isFinite(spacingCM) {
|
||
return 1
|
||
}
|
||
area := math.Pi * radiusCM * radiusCM
|
||
n := int(math.Round(area / (spacingCM * spacingCM)))
|
||
if n < 1 {
|
||
return 1
|
||
}
|
||
if n > maxExplicitCount {
|
||
return maxExplicitCount
|
||
}
|
||
return n
|
||
}
|
||
|
||
// PlantingInput is the payload for placing a plop. Count nil = derived; PlantedAt
|
||
// nil defaults to today.
|
||
type PlantingInput struct {
|
||
PlantID int64
|
||
XCM float64
|
||
YCM float64
|
||
RadiusCM float64
|
||
Count *int
|
||
Label *string
|
||
PlantedAt *string
|
||
// SeedLotID attributes this planting to a purchase, so the lot can report
|
||
// what's left. Optional.
|
||
SeedLotID *int64
|
||
}
|
||
|
||
// PlantingPatch is a partial update. The nullable fields (Count/Label/PlantedAt/
|
||
// RemovedAt) use a Set* flag to tell "clear to NULL" from "leave unchanged".
|
||
// Setting RemovedAt is how "clear bed"/soft-remove works; clearing it un-removes.
|
||
type PlantingPatch struct {
|
||
PlantID *int64
|
||
XCM *float64
|
||
YCM *float64
|
||
RadiusCM *float64
|
||
SetCount bool
|
||
Count *int
|
||
SetLabel bool
|
||
Label *string
|
||
SetPlantedAt bool
|
||
PlantedAt *string
|
||
SetRemovedAt bool
|
||
RemovedAt *string
|
||
SetSeedLotID bool
|
||
SeedLotID *int64
|
||
}
|
||
|
||
// CreatePlanting places a plop in a plantable object the actor can edit.
|
||
func (s *Service) CreatePlanting(ctx context.Context, actorID, objectID int64, in PlantingInput) (*domain.Planting, error) {
|
||
o, g, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !o.Plantable {
|
||
return nil, domain.ErrInvalidInput // trees/paths/structures can't hold plants
|
||
}
|
||
plant, err := s.visiblePlant(ctx, actorID, in.PlantID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
radius := in.RadiusCM
|
||
if radius == 0 {
|
||
// Unspecified means ONE plant: the editor's tap-to-place radius, half the
|
||
// spacing. A clump (1.5× spacing) is what a fill makes, not a placement.
|
||
radius = plant.SpacingCM / 2
|
||
}
|
||
p := &domain.Planting{
|
||
ObjectID: objectID,
|
||
PlantID: in.PlantID,
|
||
XCM: in.XCM,
|
||
YCM: in.YCM,
|
||
RadiusCM: radius,
|
||
Count: in.Count,
|
||
Label: trimStringPtr(in.Label),
|
||
PlantedAt: in.PlantedAt,
|
||
SeedLotID: in.SeedLotID,
|
||
}
|
||
if p.PlantedAt == nil {
|
||
today := s.now().UTC().Format(dateLayout)
|
||
p.PlantedAt = &today
|
||
}
|
||
if err := finalizePlanting(p, o, true); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.checkSeedLotForPlanting(ctx, actorID, p.SeedLotID, p.PlantID); err != nil {
|
||
return nil, err
|
||
}
|
||
created, err := s.store.CreatePlanting(ctx, p)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
s.record(ctx, g.ID, actorID, "Planted "+plant.Name+" in "+objectLabel(o),
|
||
changeCreate(domain.EntityPlanting, created.ID, created))
|
||
created.DerivedCount = derivedCount(created.RadiusCM, plant.SpacingCM)
|
||
return created, nil
|
||
}
|
||
|
||
// UpdatePlanting applies a partial, version-guarded patch to a plop in an object
|
||
// the actor can edit. On a version mismatch it returns (current, ErrVersionConflict).
|
||
func (s *Service) UpdatePlanting(ctx context.Context, actorID, plantingID int64, patch PlantingPatch, version int64) (*domain.Planting, error) {
|
||
pl, err := s.store.GetPlanting(ctx, plantingID)
|
||
if err != nil {
|
||
return nil, err // ErrNotFound
|
||
}
|
||
o, g, err := s.objectForRole(ctx, actorID, pl.ObjectID, roleEditor)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
before := *pl // GetPlanting returns the current row, and the patch mutates it
|
||
originalPlantID := pl.PlantID
|
||
applyPlantingPatch(pl, patch)
|
||
// Fetch the plop's plant for the derived count. Only when the actor is
|
||
// actually CHANGING the plant (new id ≠ old) is the new plant gated on
|
||
// visibility — an existing plop may reference a plant the actor can't see
|
||
// (e.g. a shared editor in the owner's garden using the owner's private
|
||
// plant), and a no-op plantId resend must not break editing it.
|
||
var plant *domain.Plant
|
||
if pl.PlantID != originalPlantID {
|
||
plant, err = s.visiblePlant(ctx, actorID, pl.PlantID)
|
||
} else {
|
||
plant, err = s.store.GetPlant(ctx, pl.PlantID)
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// Only re-check the object bounds when the position is actually being moved.
|
||
// A plop left outside its object's bounds by a later object resize must still
|
||
// be editable (radius, dates, soft-remove) — otherwise it becomes a row you
|
||
// can neither fix nor remove.
|
||
checkBounds := patch.XCM != nil || patch.YCM != nil
|
||
if err := finalizePlanting(pl, o, checkBounds); err != nil {
|
||
return nil, err
|
||
}
|
||
// Re-checked on every update, not just when the lot changes: a plant swap can
|
||
// leave a previously-valid attribution pointing at a lot of the wrong variety.
|
||
if err := s.checkSeedLotForPlanting(ctx, actorID, pl.SeedLotID, pl.PlantID); err != nil {
|
||
return nil, err
|
||
}
|
||
pl.Version = version
|
||
|
||
updated, err := s.store.UpdatePlanting(ctx, pl)
|
||
if err != nil {
|
||
if errors.Is(err, domain.ErrVersionConflict) && updated != nil {
|
||
// Enrich the current row with its own plant's derived count.
|
||
s.enrichDerived(ctx, updated)
|
||
}
|
||
return updated, err
|
||
}
|
||
s.record(ctx, g.ID, actorID, plantingEditSummary(&before, updated, plant, o),
|
||
changeUpdate(domain.EntityPlanting, updated.ID, &before, updated))
|
||
updated.DerivedCount = derivedCount(updated.RadiusCM, plant.SpacingCM)
|
||
return updated, nil
|
||
}
|
||
|
||
// RemovePlanting soft-removes a single plop — the one-plop counterpart to
|
||
// ClearObject. It stamps removed_at from the service clock (s.now()), the same
|
||
// UTC day ClearObject and the fill path default to; RemovePlantingOn is the
|
||
// form for a caller that knows the gardener's local day. Both delegate to
|
||
// UpdatePlanting for the editor-role check, version guard and history record.
|
||
func (s *Service) RemovePlanting(ctx context.Context, actorID, plantingID, version int64) (*domain.Planting, error) {
|
||
return s.RemovePlantingOn(ctx, actorID, plantingID, version, nil)
|
||
}
|
||
|
||
// RemovePlantingOn is RemovePlanting with the removal date supplied (YYYY-MM-DD)
|
||
// by a caller that knows the person's local day. nil keeps the service clock's
|
||
// UTC today.
|
||
func (s *Service) RemovePlantingOn(ctx context.Context, actorID, plantingID, version int64, removedAt *string) (*domain.Planting, error) {
|
||
if !validDatePtr(removedAt) {
|
||
return nil, fmt.Errorf("%w: removedAt must be a YYYY-MM-DD date", domain.ErrInvalidInput)
|
||
}
|
||
on := s.now().UTC().Format(dateLayout)
|
||
if removedAt != nil {
|
||
on = *removedAt
|
||
}
|
||
return s.UpdatePlanting(ctx, actorID, plantingID,
|
||
PlantingPatch{SetRemovedAt: true, RemovedAt: &on}, version)
|
||
}
|
||
|
||
// MoveInput says where a plop goes: a position in the local frame of ToObjectID,
|
||
// or of the plop's current object when ToObjectID is nil.
|
||
type MoveInput struct {
|
||
ToObjectID *int64
|
||
XCM, YCM float64
|
||
}
|
||
|
||
// MovePlanting relocates one plop — within its object, or into another plantable
|
||
// object of the same garden — keeping its plant, size, count and planting date.
|
||
// Removing and re-placing is not the same thing: "move the tomatoes to the other
|
||
// bed" is not "pull them up and plant new ones today", and the live assistant
|
||
// did exactly that for want of this. Version-guarded like UpdatePlanting; a
|
||
// within-object move IS an UpdatePlanting of the position.
|
||
func (s *Service) MovePlanting(ctx context.Context, actorID, plantingID int64, in MoveInput, version int64) (*domain.Planting, error) {
|
||
pl, err := s.store.GetPlanting(ctx, plantingID)
|
||
if err != nil {
|
||
return nil, err // ErrNotFound
|
||
}
|
||
if in.ToObjectID == nil || *in.ToObjectID == pl.ObjectID {
|
||
return s.UpdatePlanting(ctx, actorID, plantingID, PlantingPatch{XCM: &in.XCM, YCM: &in.YCM}, version)
|
||
}
|
||
from, g, err := s.objectForRole(ctx, actorID, pl.ObjectID, roleEditor)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
to, toGarden, err := s.objectForRole(ctx, actorID, *in.ToObjectID, roleEditor)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if toGarden.ID != g.ID {
|
||
return nil, fmt.Errorf("%w: a planting can only move within its own garden", domain.ErrInvalidInput)
|
||
}
|
||
if !to.Plantable {
|
||
return nil, fmt.Errorf("%w: %s can't hold plants", domain.ErrInvalidInput, objectLabel(to))
|
||
}
|
||
// By id, not through the actor's catalog: the plop may be of a variety the
|
||
// actor can't see (a shared editor, the owner's private plant), and moving it
|
||
// isn't choosing it.
|
||
plant, err := s.store.GetPlant(ctx, pl.PlantID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
before := *pl
|
||
pl.ObjectID = to.ID
|
||
pl.XCM, pl.YCM = in.XCM, in.YCM
|
||
if err := finalizePlanting(pl, to, true); err != nil {
|
||
return nil, err
|
||
}
|
||
pl.Version = version
|
||
updated, err := s.store.UpdatePlanting(ctx, pl)
|
||
if err != nil {
|
||
if errors.Is(err, domain.ErrVersionConflict) && updated != nil {
|
||
s.enrichDerived(ctx, updated)
|
||
}
|
||
return updated, err
|
||
}
|
||
s.record(ctx, g.ID, actorID, "Moved "+plant.Name+" from "+objectLabel(from)+" to "+objectLabel(to),
|
||
changeUpdate(domain.EntityPlanting, updated.ID, &before, updated))
|
||
updated.DerivedCount = derivedCount(updated.RadiusCM, plant.SpacingCM)
|
||
return updated, nil
|
||
}
|
||
|
||
// plantingEditSummary describes a plop edit for the history list. Soft-removal
|
||
// ("clear bed", harvested) is the one edit worth naming specifically — it reads
|
||
// as a removal to the person who did it, not as an edit.
|
||
func plantingEditSummary(before, after *domain.Planting, plant *domain.Plant, o *domain.GardenObject) string {
|
||
switch {
|
||
case before.RemovedAt == nil && after.RemovedAt != nil:
|
||
return "Removed " + plant.Name + " from " + objectLabel(o)
|
||
case before.RemovedAt != nil && after.RemovedAt == nil:
|
||
return "Restored " + plant.Name + " in " + objectLabel(o)
|
||
default:
|
||
return "Edited " + plant.Name + " in " + objectLabel(o)
|
||
}
|
||
}
|
||
|
||
// DeletePlanting hard-deletes a plop in an object the actor can edit. (Soft
|
||
// removal — "clear bed" / harvested — sets removed_at via UpdatePlanting.)
|
||
func (s *Service) DeletePlanting(ctx context.Context, actorID, plantingID int64) error {
|
||
pl, err := s.store.GetPlanting(ctx, plantingID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
o, g, err := s.objectForRole(ctx, actorID, pl.ObjectID, roleEditor)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := s.store.DeletePlanting(ctx, plantingID); err != nil {
|
||
return err
|
||
}
|
||
s.record(ctx, g.ID, actorID, "Deleted a planting from "+objectLabel(o),
|
||
changeDelete(domain.EntityPlanting, pl.ID, pl))
|
||
return nil
|
||
}
|
||
|
||
// visiblePlant loads a plant the actor may reference in a planting: a built-in or
|
||
// one the actor owns. An unknown plant or another user's plant is an invalid
|
||
// reference (ErrInvalidInput) rather than leaking existence.
|
||
func (s *Service) visiblePlant(ctx context.Context, actorID, plantID int64) (*domain.Plant, error) {
|
||
p, err := s.store.GetPlant(ctx, plantID)
|
||
if errors.Is(err, domain.ErrNotFound) {
|
||
return nil, domain.ErrInvalidInput
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if p.OwnerID != nil && *p.OwnerID != actorID {
|
||
return nil, domain.ErrInvalidInput
|
||
}
|
||
return p, nil
|
||
}
|
||
|
||
// enrichDerived best-effort sets p.DerivedCount from its plant's spacing (used
|
||
// when we don't already hold the plant, e.g. a conflict's current row).
|
||
func (s *Service) enrichDerived(ctx context.Context, p *domain.Planting) {
|
||
plant, err := s.store.GetPlant(ctx, p.PlantID)
|
||
if err == nil {
|
||
p.DerivedCount = derivedCount(p.RadiusCM, plant.SpacingCM)
|
||
}
|
||
}
|
||
|
||
// applyPlantingPatch mutates pl with each provided patch field.
|
||
func applyPlantingPatch(pl *domain.Planting, p PlantingPatch) {
|
||
if p.PlantID != nil {
|
||
pl.PlantID = *p.PlantID
|
||
}
|
||
if p.XCM != nil {
|
||
pl.XCM = *p.XCM
|
||
}
|
||
if p.YCM != nil {
|
||
pl.YCM = *p.YCM
|
||
}
|
||
if p.RadiusCM != nil {
|
||
pl.RadiusCM = *p.RadiusCM
|
||
}
|
||
if p.SetCount {
|
||
pl.Count = p.Count
|
||
}
|
||
if p.SetLabel {
|
||
pl.Label = trimStringPtr(p.Label)
|
||
}
|
||
if p.SetPlantedAt {
|
||
pl.PlantedAt = p.PlantedAt
|
||
}
|
||
if p.SetRemovedAt {
|
||
pl.RemovedAt = p.RemovedAt
|
||
}
|
||
if p.SetSeedLotID {
|
||
pl.SeedLotID = p.SeedLotID
|
||
}
|
||
}
|
||
|
||
// finalizePlanting validates a built/merged plop against its parent object.
|
||
// Shared by create and update so both enforce the same invariants. checkBounds
|
||
// gates the center-in-object-bounds test: create always checks it, update only
|
||
// when the position is being moved (so a resize that orphans a plop outside the
|
||
// shrunken object doesn't block editing/removing it).
|
||
func finalizePlanting(p *domain.Planting, o *domain.GardenObject, checkBounds bool) error {
|
||
if !isFinite(p.RadiusCM) || p.RadiusCM <= 0 || p.RadiusCM > maxRadiusCM {
|
||
return domain.ErrInvalidInput
|
||
}
|
||
if !isFinite(p.XCM) || !isFinite(p.YCM) {
|
||
return domain.ErrInvalidInput
|
||
}
|
||
// Loose bounds: the plop's CENTER must sit within the object's unrotated
|
||
// local bounds (origin at object center); the radius may overhang.
|
||
if checkBounds && (math.Abs(p.XCM) > o.WidthCM/2 || math.Abs(p.YCM) > o.HeightCM/2) {
|
||
return domain.ErrInvalidInput
|
||
}
|
||
if p.Count != nil && (*p.Count < 1 || *p.Count > maxExplicitCount) {
|
||
return domain.ErrInvalidInput
|
||
}
|
||
if p.Label != nil && len(*p.Label) > maxPlantingLabelLen {
|
||
return domain.ErrInvalidInput
|
||
}
|
||
if !validDatePtr(p.PlantedAt) || !validDatePtr(p.RemovedAt) {
|
||
return domain.ErrInvalidInput
|
||
}
|
||
// A plop can't be removed before it was planted (nonsensical interval).
|
||
if p.PlantedAt != nil && p.RemovedAt != nil {
|
||
planted, _ := time.Parse(dateLayout, *p.PlantedAt)
|
||
removed, _ := time.Parse(dateLayout, *p.RemovedAt)
|
||
if removed.Before(planted) {
|
||
return domain.ErrInvalidInput
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// validDatePtr reports whether a nil-or-'YYYY-MM-DD' date pointer is acceptable.
|
||
func validDatePtr(s *string) bool {
|
||
if s == nil {
|
||
return true
|
||
}
|
||
_, err := time.Parse(dateLayout, *s)
|
||
return err == nil
|
||
}
|
||
|
||
// trimStringPtr trims a *string, preserving nil (distinct from empty).
|
||
func trimStringPtr(s *string) *string {
|
||
if s == nil {
|
||
return nil
|
||
}
|
||
t := strings.TrimSpace(*s)
|
||
return &t
|
||
}
|