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]>
309 lines
12 KiB
Go
309 lines
12 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
|
)
|
|
|
|
// plantingColumns lists plantings columns in the order scanPlanting expects.
|
|
// Used unqualified for direct selects; the /full read below qualifies with pl.
|
|
const plantingColumns = `id, object_id, plant_id, x_cm, y_cm, radius_cm, count, label,
|
|
planted_at, removed_at, seed_lot_id, version, created_at, updated_at`
|
|
|
|
func scanPlanting(s scanner) (*domain.Planting, error) {
|
|
var p domain.Planting
|
|
if err := s.Scan(
|
|
&p.ID, &p.ObjectID, &p.PlantID, &p.XCM, &p.YCM, &p.RadiusCM,
|
|
&p.Count, &p.Label, &p.PlantedAt, &p.RemovedAt, &p.SeedLotID,
|
|
&p.Version, &p.CreatedAt, &p.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
return &p, nil
|
|
}
|
|
|
|
// ListActivePlantingsForGarden returns every currently-planted plop (removed_at
|
|
// IS NULL) across all objects in a garden — the editor's one-shot load. Always a
|
|
// non-nil slice. The service fills each row's DerivedCount; this is the raw read.
|
|
func (d *DB) ListActivePlantingsForGarden(ctx context.Context, gardenID int64) ([]domain.Planting, error) {
|
|
return queryPlantings(ctx, d.sql,
|
|
`SELECT `+qualifyColumns("pl", plantingColumns)+` FROM plantings pl
|
|
JOIN garden_objects o ON o.id = pl.object_id
|
|
WHERE o.garden_id = ? AND pl.removed_at IS NULL
|
|
ORDER BY pl.id`,
|
|
gardenID)
|
|
}
|
|
|
|
// queryPlantings runs a planting query and scans every row. Like queryObjects it
|
|
// drains and closes the cursor before returning, so a transaction caller may
|
|
// write afterwards. Always a non-nil slice.
|
|
func queryPlantings(ctx context.Context, q queryer, query string, args ...any) ([]domain.Planting, error) {
|
|
rows, err := q.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: list plantings: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
plantings := []domain.Planting{}
|
|
for rows.Next() {
|
|
p, err := scanPlanting(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: scan planting: %w", err)
|
|
}
|
|
plantings = append(plantings, *p)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("store: iterate plantings: %w", err)
|
|
}
|
|
return plantings, nil
|
|
}
|
|
|
|
// ListPlantingsForGardenYear returns every plop in a garden whose time in the
|
|
// ground overlaps the given calendar year — the season view (#54).
|
|
//
|
|
// The interval is [planted_at, removed_at]: planted before the year ended, and
|
|
// either still in the ground or removed after the year began. Garlic planted in
|
|
// October and pulled the following July therefore appears in BOTH years, which
|
|
// is what actually happened.
|
|
//
|
|
// Undated plantings are always included. Every planting that predates this
|
|
// feature has a null planted_at, and a rule that excluded them would empty every
|
|
// existing garden the moment a year was selected — an unhelpful way to be
|
|
// technically correct.
|
|
func (d *DB) ListPlantingsForGardenYear(ctx context.Context, gardenID int64, year int) ([]domain.Planting, error) {
|
|
start := fmt.Sprintf("%04d-01-01", year)
|
|
end := fmt.Sprintf("%04d-12-31", year)
|
|
return queryPlantings(ctx, d.sql,
|
|
`SELECT `+qualifyColumns("pl", plantingColumns)+` FROM plantings pl
|
|
JOIN garden_objects o ON o.id = pl.object_id
|
|
WHERE o.garden_id = ?
|
|
AND (pl.planted_at IS NULL OR pl.planted_at <= ?)
|
|
AND (pl.removed_at IS NULL OR pl.removed_at >= ?)
|
|
ORDER BY pl.id`,
|
|
gardenID, end, start)
|
|
}
|
|
|
|
// GardenPlantingYears lists the calendar years a garden has planting data for,
|
|
// newest first — every year touched by a [planted_at, removed_at] interval.
|
|
//
|
|
// Undated plantings contribute no year, deliberately: they belong to every year
|
|
// the selector offers, so adding one here would be noise.
|
|
func (d *DB) GardenPlantingYears(ctx context.Context, gardenID int64) ([]int, error) {
|
|
rows, err := d.sql.QueryContext(ctx,
|
|
`SELECT DISTINCT CAST(substr(y, 1, 4) AS INTEGER) AS year FROM (
|
|
SELECT pl.planted_at AS y FROM plantings pl
|
|
JOIN garden_objects o ON o.id = pl.object_id
|
|
WHERE o.garden_id = ? AND pl.planted_at IS NOT NULL
|
|
UNION
|
|
SELECT pl.removed_at AS y FROM plantings pl
|
|
JOIN garden_objects o ON o.id = pl.object_id
|
|
WHERE o.garden_id = ? AND pl.removed_at IS NOT NULL
|
|
)
|
|
ORDER BY year DESC`,
|
|
gardenID, gardenID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: garden planting years: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
years := []int{}
|
|
for rows.Next() {
|
|
var y int
|
|
if err := rows.Scan(&y); err != nil {
|
|
return nil, fmt.Errorf("store: scan planting year: %w", err)
|
|
}
|
|
years = append(years, y)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("store: iterate planting years: %w", err)
|
|
}
|
|
return years, nil
|
|
}
|
|
|
|
// ListActivePlantingsForObject returns an object's currently-planted plops
|
|
// (removed_at IS NULL). Always a non-nil slice. Used by FillRegion to avoid
|
|
// stacking new plops inside existing ones.
|
|
func (d *DB) ListActivePlantingsForObject(ctx context.Context, objectID int64) ([]domain.Planting, error) {
|
|
return queryPlantings(ctx, d.sql,
|
|
`SELECT `+plantingColumns+` FROM plantings WHERE object_id = ? AND removed_at IS NULL ORDER BY id`,
|
|
objectID)
|
|
}
|
|
|
|
// ListPlantingsForObject returns every plop in an object, removed ones included.
|
|
// Used when an object is deleted: the FK cascades its plantings away without the
|
|
// service seeing them, so they are snapshotted first or the delete would not be
|
|
// revertible. Always a non-nil slice.
|
|
func (d *DB) ListPlantingsForObject(ctx context.Context, objectID int64) ([]domain.Planting, error) {
|
|
return queryPlantings(ctx, d.sql,
|
|
`SELECT `+plantingColumns+` FROM plantings WHERE object_id = ? ORDER BY id`,
|
|
objectID)
|
|
}
|
|
|
|
// RestorePlanting re-inserts a deleted plop under its ORIGINAL id, preserving
|
|
// version and timestamps — the plop counterpart of RestoreObject, and subject to
|
|
// the same reasoning. Its parent object must exist again first, or the FK
|
|
// rejects it; the revert orders object restores ahead of planting restores.
|
|
func (d *DB) RestorePlanting(ctx context.Context, p *domain.Planting) (*domain.Planting, error) {
|
|
restored, err := scanPlanting(d.sql.QueryRowContext(ctx,
|
|
`INSERT INTO plantings
|
|
(id, object_id, plant_id, x_cm, y_cm, radius_cm, count, label, planted_at, removed_at,
|
|
seed_lot_id, version, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
|
RETURNING `+plantingColumns,
|
|
p.ID, p.ObjectID, p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label,
|
|
p.PlantedAt, p.RemovedAt, p.SeedLotID, p.Version, p.CreatedAt,
|
|
))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: restore planting: %w", err)
|
|
}
|
|
return restored, nil
|
|
}
|
|
|
|
// ClearObjectPlantings soft-removes the given plops of an object in one UPDATE
|
|
// (sets removed_at=date, bumps version) and returns how many rows it affected.
|
|
//
|
|
// It takes explicit ids rather than clearing "every active plop" so the caller
|
|
// can snapshot exactly the rows it is about to change. Clearing by predicate
|
|
// would let a plop created between the caller's read and this UPDATE be removed
|
|
// without a revision — cleared, but with no way to undo it.
|
|
func (d *DB) ClearObjectPlantings(ctx context.Context, objectID int64, date string, ids []int64) (int, error) {
|
|
if len(ids) == 0 {
|
|
return 0, nil
|
|
}
|
|
args := make([]any, 0, len(ids)+2)
|
|
args = append(args, date, objectID)
|
|
for _, id := range ids {
|
|
args = append(args, id)
|
|
}
|
|
res, err := d.sql.ExecContext(ctx,
|
|
`UPDATE plantings
|
|
SET removed_at = ?, version = version + 1,
|
|
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
|
WHERE object_id = ? AND removed_at IS NULL AND id IN (`+placeholders(len(ids))+`)`,
|
|
args...,
|
|
)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("store: clear object plantings: %w", err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("store: clear plantings rows: %w", err)
|
|
}
|
|
return int(n), nil
|
|
}
|
|
|
|
// GetPlanting returns the planting with the given id, or domain.ErrNotFound.
|
|
func (d *DB) GetPlanting(ctx context.Context, id int64) (*domain.Planting, error) {
|
|
p, err := scanPlanting(d.sql.QueryRowContext(ctx,
|
|
`SELECT `+plantingColumns+` FROM plantings WHERE id = ?`, id))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, domain.ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: get planting: %w", err)
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// plantingInsert inserts one plantings row and returns it. Shared by
|
|
// CreatePlanting, the CreatePlantings batch and CopyGarden's in-transaction copy
|
|
// — with plantingInsertArgs supplying its parameters — so all three stay in step
|
|
// with the table. removed_at is never set here: a new plop is active, and "clear
|
|
// bed" sets removed_at later via UpdatePlanting.
|
|
const plantingInsert = `INSERT INTO plantings (object_id, plant_id, x_cm, y_cm, radius_cm, count, label, planted_at, seed_lot_id)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
RETURNING ` + plantingColumns
|
|
|
|
// plantingInsertArgs builds plantingInsert's parameters, parenting p to objectID
|
|
// (p's own object normally, and the copied object when copying a garden).
|
|
func plantingInsertArgs(objectID int64, p *domain.Planting) []any {
|
|
return []any{objectID, p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label, p.PlantedAt, p.SeedLotID}
|
|
}
|
|
|
|
// CreatePlanting inserts a plop (fields already validated by the service) and
|
|
// returns the stored row.
|
|
func (d *DB) CreatePlanting(ctx context.Context, p *domain.Planting) (*domain.Planting, error) {
|
|
created, err := scanPlanting(d.sql.QueryRowContext(ctx, plantingInsert, plantingInsertArgs(p.ObjectID, p)...))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: insert planting: %w", err)
|
|
}
|
|
return created, nil
|
|
}
|
|
|
|
// CreatePlantings inserts many plops in a single transaction (one commit), for
|
|
// bulk fills. Returns the stored rows in order. An empty input is a no-op.
|
|
func (d *DB) CreatePlantings(ctx context.Context, plantings []*domain.Planting) ([]domain.Planting, error) {
|
|
if len(plantings) == 0 {
|
|
return []domain.Planting{}, nil
|
|
}
|
|
tx, err := d.sql.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: begin plantings tx: %w", err)
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck // no-op after a successful commit
|
|
|
|
out := make([]domain.Planting, 0, len(plantings))
|
|
for _, p := range plantings {
|
|
created, err := scanPlanting(tx.QueryRowContext(ctx, plantingInsert, plantingInsertArgs(p.ObjectID, p)...))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: insert planting (batch): %w", err)
|
|
}
|
|
out = append(out, *created)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("store: commit plantings: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// UpdatePlanting applies a version-guarded update of all mutable columns (the
|
|
// service merges partial patches first). Returns the updated row, or
|
|
// (current row, ErrVersionConflict) / ErrNotFound — the same contract as the
|
|
// other mutable resources.
|
|
func (d *DB) UpdatePlanting(ctx context.Context, p *domain.Planting) (*domain.Planting, error) {
|
|
updated, err := scanPlanting(d.sql.QueryRowContext(ctx,
|
|
`UPDATE plantings
|
|
SET object_id = ?, plant_id = ?, x_cm = ?, y_cm = ?, radius_cm = ?, count = ?, label = ?,
|
|
planted_at = ?, removed_at = ?, seed_lot_id = ?,
|
|
version = version + 1,
|
|
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
|
WHERE id = ? AND version = ?
|
|
RETURNING `+plantingColumns,
|
|
p.ObjectID, p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label, p.PlantedAt, p.RemovedAt, p.SeedLotID,
|
|
p.ID, p.Version,
|
|
))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
current, gerr := d.GetPlanting(ctx, p.ID)
|
|
if gerr != nil {
|
|
return nil, gerr
|
|
}
|
|
return current, domain.ErrVersionConflict
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: update planting: %w", err)
|
|
}
|
|
return updated, nil
|
|
}
|
|
|
|
// DeletePlanting hard-deletes a plop (for mistakes; "removed/harvested" flows set
|
|
// removed_at instead). Returns domain.ErrNotFound if no row was deleted.
|
|
func (d *DB) DeletePlanting(ctx context.Context, id int64) error {
|
|
res, err := d.sql.ExecContext(ctx, `DELETE FROM plantings WHERE id = ?`, id)
|
|
if err != nil {
|
|
return fmt.Errorf("store: delete planting: %w", err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return fmt.Errorf("store: planting delete rows: %w", err)
|
|
}
|
|
if n == 0 {
|
|
return domain.ErrNotFound
|
|
}
|
|
return nil
|
|
}
|