Files
pansy/internal/service/ops.go
T
steveandClaude Opus 4.8 d3b7dd06c9
Build image / build-and-push (push) Successful in 5s
Gadfly review (reusable) / review (pull_request) Successful in 13m34s
Adversarial Review (Gadfly) / review (pull_request) Successful in 13m35s
Add agent seam: bulk ops + majordomo tool wrappers (#19)
Bulk, natural-language-shaped service operations (ACL-enforced like every other
op, so agent tools inherit permissions for free):
- ops.go: Region + NamedRegion (nw/ne/sw/se corners, north/south/east/west and
  top/bottom/left/right halves, "all"; -y is north in the local frame).
  FillRegion hex-packs plops at 2×radius pitch (radius = #15's max(1.5·spacing,
  15cm)) clipped to the region, skipping any candidate that would sit entirely
  inside an existing active plop. ClearObject soft-removes all active plops in one
  UPDATE. DescribeGarden returns a structured summary (dims, objects+version,
  plantings with plant/effective-count/rough compass location).
- store/plantings.go: ListActivePlantingsForObject + ClearObjectPlantings.

Agent toolbox (internal/agent), deliberately isolated:
- doc.go (untagged) keeps the package in the default build; tools.go is behind
  the `majordomo` build tag and NOT in go.mod, so `go build/test ./...` and the
  server binary carry no majordomo/LLM deps. Built + tested locally against real
  majordomo (go test -tags majordomo ./internal/agent/).
- NewToolbox(svc, actorID) exposes list_gardens, describe_garden, create_object,
  move_object, place_planting, fill_region, clear_object as llm.DefineTool
  wrappers — thin typed adapters over the service, each running as the bound
  actor.

Tests: NamedRegion for all names + unknown→ErrInvalidInput; deterministic
hex-packing count (60×60 bed → 4 plops) + re-fill skips covered; rotated bed
fills the correct LOCAL corner; ClearObject; viewer→ErrForbidden on fill/clear
but can describe; and the DESIGN corner/half scenario (garlic NE, basil NW, beans
south) verified via DescribeGarden. A tagged demo drives the same scenario
through the toolbox (JSON args → tool → service) and confirms a viewer is refused.

GOWORK=off go build/vet/test ./internal/... green (majordomo-free).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-19 00:54:03 -04:00

324 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"math"
"strings"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// This file holds pansy's bulk, "natural-language-shaped" operations — the ones
// an agent drives ("fill the NE corner with garlic", "clear the bed"). They live
// on *Service like every other operation, so agent tools (internal/agent) and any
// future REST surface inherit the same ACL enforcement via objectForRole /
// requireGardenRole. Geometry is in each object's LOCAL frame (origin at the
// object's center, +x east, +y south, so -y is NORTH).
// Region is an area in an object's local frame: an axis-aligned rectangle, or a
// circle when Circle is set. NamedRegion produces the common ones.
type Region struct {
MinX, MinY, MaxX, MaxY float64 // rectangle (also the circle's bounding box)
Circle bool
CX, CY, Radius float64 // used when Circle
}
// contains reports whether a local point lies in the region.
func (r Region) contains(x, y float64) bool {
if r.Circle {
dx, dy := x-r.CX, y-r.CY
return dx*dx+dy*dy <= r.Radius*r.Radius
}
return x >= r.MinX && x <= r.MaxX && y >= r.MinY && y <= r.MaxY
}
// rect builds a rectangular region.
func rect(minX, minY, maxX, maxY float64) Region {
return Region{MinX: minX, MinY: minY, MaxX: maxX, MaxY: maxY}
}
// NamedRegion resolves a compass name to a Region in the object's local frame.
// Recognizes the quarter corners "nw|ne|sw|se", the halves
// "north|south|east|west" and their "top|bottom|left|right" synonyms, and "all".
// A trailing "corner"/"half" word is ignored ("NE corner", "south half"). North
// is -y (see the file header). Unknown names return ErrInvalidInput.
func NamedRegion(o *domain.GardenObject, name string) (Region, error) {
hw, hh := o.WidthCM/2, o.HeightCM/2
key := strings.ToLower(strings.TrimSpace(name))
key = strings.TrimSpace(strings.TrimSuffix(key, "corner"))
key = strings.TrimSpace(strings.TrimSuffix(key, "half"))
switch key {
case "all", "":
return rect(-hw, -hh, hw, hh), nil
case "north", "top":
return rect(-hw, -hh, hw, 0), nil
case "south", "bottom":
return rect(-hw, 0, hw, hh), nil
case "east", "right":
return rect(0, -hh, hw, hh), nil
case "west", "left":
return rect(-hw, -hh, 0, hh), nil
case "nw", "northwest":
return rect(-hw, -hh, 0, 0), nil
case "ne", "northeast":
return rect(0, -hh, hw, 0), nil
case "sw", "southwest":
return rect(-hw, 0, 0, hh), nil
case "se", "southeast":
return rect(0, 0, hw, hh), nil
default:
return Region{}, domain.ErrInvalidInput
}
}
// defaultPlopRadius is #15's formula for a placed plop's radius: max(1.5×spacing,
// 15cm). Reused here (don't duplicate the constant elsewhere).
func defaultPlopRadius(spacingCM float64) float64 {
return math.Max(1.5*spacingCM, 15)
}
// FillRegion lays a hex-packed field of plops of one plant across a region of a
// plantable object the actor can edit. Plop radius comes from the plant's spacing
// (or spacingOverride) via defaultPlopRadius; centers sit on a hex lattice at 2×
// radius pitch, kept where the center is inside the region. A candidate is
// skipped when its plop would sit entirely inside an existing active plop (so
// re-filling doesn't stack duplicates). Returns the plops it created.
func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, region Region, plantID int64, spacingOverride *float64) ([]domain.Planting, error) {
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
if err != nil {
return nil, err
}
if !o.Plantable {
return nil, domain.ErrInvalidInput
}
plant, err := s.visiblePlant(ctx, actorID, plantID)
if err != nil {
return nil, err
}
spacing := plant.SpacingCM
if spacingOverride != nil {
if !isFinite(*spacingOverride) || *spacingOverride < minPlantSpacingCM || *spacingOverride > maxPlantSpacingCM {
return nil, domain.ErrInvalidInput
}
spacing = *spacingOverride
}
radius := defaultPlopRadius(spacing)
existing, err := s.store.ListActivePlantingsForObject(ctx, objectID)
if err != nil {
return nil, err
}
today := s.now().UTC().Format(dateLayout)
halfW, halfH := o.WidthCM/2, o.HeightCM/2
created := []domain.Planting{}
for _, c := range hexCenters(region, radius) {
// The center must be inside the object (region ⊆ bounds already, but guard).
if math.Abs(c.x) > halfW || math.Abs(c.y) > halfH {
continue
}
if coveredByExisting(c.x, c.y, radius, existing) {
continue
}
p := &domain.Planting{ObjectID: objectID, PlantID: plantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &today}
stored, err := s.store.CreatePlanting(ctx, p)
if err != nil {
return nil, err
}
stored.DerivedCount = derivedCount(stored.RadiusCM, spacing)
created = append(created, *stored)
// Count what we just placed so later candidates in this same fill don't
// stack on top of it.
existing = append(existing, *stored)
}
return created, nil
}
type localPoint struct{ x, y float64 }
// hexCenters returns hex-packed lattice centers whose center lies in the region.
// Rows are spaced radius·√3 apart and every other row is offset by radius, the
// standard hexagonal packing at a 2×radius pitch. The lattice is anchored one
// radius inside the region's min corner so the first plop sits inside it.
func hexCenters(r Region, radius float64) []localPoint {
if radius <= 0 {
return nil
}
pitch := 2 * radius
rowH := pitch * math.Sqrt(3) / 2
const eps = 1e-6
var pts []localPoint
row := 0
for y := r.MinY + radius; y <= r.MaxY+eps; y += rowH {
xStart := r.MinX + radius
if row%2 == 1 {
xStart += radius
}
for x := xStart; x <= r.MaxX+eps; x += pitch {
if r.contains(x, y) {
pts = append(pts, localPoint{x, y})
}
}
row++
}
return pts
}
// coveredByExisting reports whether a new plop (center, radius) would sit
// entirely inside some existing active plop.
func coveredByExisting(x, y, radius float64, existing []domain.Planting) bool {
for _, e := range existing {
if math.Hypot(x-e.XCM, y-e.YCM)+radius <= e.RadiusCM {
return true
}
}
return false
}
// FillNamedRegion is FillRegion addressed by a compass name ("ne", "south half")
// instead of a resolved Region — the ergonomic form for agent tools, which don't
// hold the object's geometry. It resolves the name against the object, then fills.
func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64, regionName string, plantID int64, spacingOverride *float64) ([]domain.Planting, error) {
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
if err != nil {
return nil, err
}
region, err := NamedRegion(o, regionName)
if err != nil {
return nil, err
}
return s.FillRegion(ctx, actorID, objectID, region, plantID, spacingOverride)
}
// ClearObject soft-removes every active plop in an object the actor can edit (one
// UPDATE), returning how many were cleared. Distinct from deleting the object.
func (s *Service) ClearObject(ctx context.Context, actorID, objectID int64) (int, error) {
if _, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor); err != nil {
return 0, err
}
today := s.now().UTC().Format(dateLayout)
return s.store.ClearObjectPlantings(ctx, objectID, today)
}
// DescribeResult is a structured summary of a garden for prompting an agent.
type DescribeResult struct {
GardenID int64 `json:"gardenId"`
Name string `json:"name"`
WidthCM float64 `json:"widthCm"`
HeightCM float64 `json:"heightCm"`
UnitPref string `json:"unitPref"`
Objects []DescribeObject `json:"objects"`
}
// DescribeObject is one object plus its active plantings, for DescribeResult.
// Version is included so an agent can move/edit the object (the mutation guard).
type DescribeObject struct {
ID int64 `json:"id"`
Kind string `json:"kind"`
Name string `json:"name"`
Shape string `json:"shape"`
WidthCM float64 `json:"widthCm"`
HeightCM float64 `json:"heightCm"`
XCM float64 `json:"xCm"`
YCM float64 `json:"yCm"`
RotationDeg float64 `json:"rotationDeg"`
Plantable bool `json:"plantable"`
Version int64 `json:"version"`
Plantings []DescribePlanting `json:"plantings"`
}
// DescribePlanting is one plop with a rough compass location, for DescribeResult.
type DescribePlanting struct {
PlantID int64 `json:"plantId"`
Plant string `json:"plant"`
Count int `json:"count"`
Location string `json:"location"`
RadiusCM float64 `json:"radiusCm"`
}
// DescribeGarden returns a structured summary — dimensions, objects, and each
// object's active plantings (plant, effective count, rough location) — for a
// garden the actor can view. Built on GardenFull so it inherits the ACL check.
func (s *Service) DescribeGarden(ctx context.Context, actorID, gardenID int64) (*DescribeResult, error) {
full, err := s.GardenFull(ctx, actorID, gardenID)
if err != nil {
return nil, err
}
plantByID := make(map[int64]domain.Plant, len(full.Plants))
for _, p := range full.Plants {
plantByID[p.ID] = p
}
plopsByObject := make(map[int64][]domain.Planting)
for _, pl := range full.Plantings {
plopsByObject[pl.ObjectID] = append(plopsByObject[pl.ObjectID], pl)
}
res := &DescribeResult{
GardenID: full.Garden.ID,
Name: full.Garden.Name,
WidthCM: full.Garden.WidthCM,
HeightCM: full.Garden.HeightCM,
UnitPref: full.Garden.UnitPref,
Objects: make([]DescribeObject, 0, len(full.Objects)),
}
for _, o := range full.Objects {
do := DescribeObject{
ID: o.ID, Kind: o.Kind, Name: o.Name, Shape: o.Shape,
WidthCM: o.WidthCM, HeightCM: o.HeightCM, XCM: o.XCM, YCM: o.YCM,
RotationDeg: o.RotationDeg, Plantable: o.Plantable, Version: o.Version,
Plantings: []DescribePlanting{},
}
for _, pl := range plopsByObject[o.ID] {
count := pl.DerivedCount
if pl.Count != nil {
count = *pl.Count
}
do.Plantings = append(do.Plantings, DescribePlanting{
PlantID: pl.PlantID,
Plant: plantByID[pl.PlantID].Name,
Count: count,
Location: describeLocation(pl.XCM, pl.YCM),
RadiusCM: pl.RadiusCM,
})
}
res.Objects = append(res.Objects, do)
}
return res, nil
}
// describeLocation reverse-maps a local point to a rough compass location — the
// inverse of NamedRegion's quarters/halves ("NE corner", "south", "center").
func describeLocation(x, y float64) string {
const eps = 1e-6
ns := ""
switch {
case y < -eps:
ns = "N"
case y > eps:
ns = "S"
}
ew := ""
switch {
case x < -eps:
ew = "W"
case x > eps:
ew = "E"
}
switch {
case ns == "" && ew == "":
return "center"
case ns != "" && ew != "":
return ns + ew + " corner"
case ns == "N":
return "north"
case ns == "S":
return "south"
case ew == "E":
return "east"
default:
return "west"
}
}