Add Side quest suggestion batches, deck and Try it / Pass API
CI / ci (push) Successful in 2m52s
CI / ci (pull_request) Successful in 2m46s

POST /api/v1/suggestions/batches enqueues a suggestion_batch job that runs
suggest_side_quests through the harness, grounds the survivors with
recipes.Grounder.GroundMany (dropping those no recipe grounds) and stores
each as a dinner (status suggested, origin side_quest) with a pending
suggestion holding its reason and cited_regular_ids. Only one batch is
pending or running at a time; a POST while one is active returns it.

GET /api/v1/suggestions/deck lists pending suggestions oldest first with
the dinner's name, photo, difficulty and the reason. POST
/api/v1/suggestions/{id}/accept moves the dinner to the Quest log; /decline
records the Pass and archives the dinner, whose name every later batch
reads as declined. Both are idempotent and 409 on the opposite decision.
A decision leaving fewer than 5 in the deck starts a replenish batch
unless one is already pending or running. Batches left unfinished by a
restart are failed at boot so they never block the next.

Task: b4f2558c-227f-4627-852b-be5aa190242a

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 23:28:11 -04:00
co-authored by Claude Opus 5.5
parent 7d1e983fc8
commit 50399b6329
19 changed files with 2420 additions and 18 deletions
+299
View File
@@ -0,0 +1,299 @@
package suggest
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// BatchJob is the KindSuggestionBatch payload: the batch to run.
type BatchJob struct {
BatchID string `json:"batch_id"`
}
// Batch stages, as the job's result reports them.
const (
StageSuggesting = "suggesting"
StageGrounding = "grounding"
StageDone = "done"
)
// batchResult is the KindSuggestionBatch result, published as the batch
// goes: its stage, the suggestions stored so far, and how many Side
// quests were dropped, by suggest_side_quests' rules, because no recipe
// grounds them, or because they repeat a dinner added meanwhile.
type batchResult struct {
BatchID string `json:"batch_id"`
Stage string `json:"stage"`
SuggestionIDs []string `json:"suggestion_ids"`
Dropped int `json:"dropped"`
}
func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// runBatch runs batch p.BatchID: it asks suggest_side_quests for BatchSize
// Side quests given the household context, the deck and every declined
// name, then grounds them in the model's order, at most the number still
// wanted at a time, until BatchSize are kept or none are left. A Side quest
// no recipe grounds is dropped; any other grounding failure fails the
// batch, as does suggest_side_quests failing, with no Side quest made up
// instead. Each round's grounded Side quests are stored before the next
// round starts, so the deck fills as the batch goes. The batch ends done
// or failed; Side quests stored before a failure stay in the deck.
func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchResult) error) (batchResult, error) {
n, err := db.New(s.st.Writer).StartSuggestionBatch(ctx, db.StartSuggestionBatchParams{
UpdatedAt: store.Now().String(),
ID: p.BatchID,
})
if err != nil {
return batchResult{}, fmt.Errorf("suggest: start batch %s: %w", p.BatchID, err)
}
if n == 0 {
return batchResult{}, fmt.Errorf("suggest: batch %s is not pending", p.BatchID)
}
res, err := s.fill(ctx, p.BatchID, publish)
write := context.WithoutCancel(ctx)
if err == nil {
s.finishBatch(write, p.BatchID, BatchDone)
return res, nil
}
s.finishBatch(write, p.BatchID, BatchFailed)
if ctx.Err() != nil {
return batchResult{}, ctx.Err()
}
var failure *jobs.Failure
if errors.As(err, &failure) {
// Only the code is logged: the error may quote a model's or
// search provider's response.
s.logger.InfoContext(ctx, "suggestion batch failed", "batch_id", p.BatchID, "reason", failure.Code)
}
return batchResult{}, err
}
// fill does runBatch's work, returning a *jobs.Failure for a failure the
// household may see.
func (s *Service) fill(ctx context.Context, batchID string, publish func(batchResult) error) (batchResult, error) {
res := batchResult{BatchID: batchID, Stage: StageSuggesting, SuggestionIDs: []string{}}
if err := publish(res); err != nil {
return batchResult{}, err
}
in, err := s.request(ctx)
if err != nil {
return batchResult{}, err
}
out, err := s.suggest(ctx, batchID, in)
if err != nil {
if ctx.Err() != nil {
return batchResult{}, ctx.Err()
}
return batchResult{}, &jobs.Failure{Code: ReasonAIFailed}
}
res.Stage = StageGrounding
res.Dropped = len(out.Dropped)
if err := publish(res); err != nil {
return batchResult{}, err
}
left := out.Suggestions
for len(res.SuggestionIDs) < BatchSize && len(left) > 0 {
round := left[:min(BatchSize-len(res.SuggestionIDs), len(left))]
left = left[len(round):]
grounded, err := s.ground(ctx, round)
if err != nil {
return batchResult{}, err
}
res.Dropped += len(round) - len(grounded)
ids, err := s.store(ctx, batchID, grounded)
if err != nil {
return batchResult{}, err
}
res.Dropped += len(grounded) - len(ids)
res.SuggestionIDs = append(res.SuggestionIDs, ids...)
if err := publish(res); err != nil {
return batchResult{}, err
}
}
res.Stage = StageDone
return res, nil
}
// request reads what suggest_side_quests is given: the household context,
// the deck's names and every declined name. A household with no Regulars
// fails with ReasonNoRegulars.
func (s *Service) request(ctx context.Context) (skills.SideQuestsRequest, error) {
hctx, err := harness.BuildContext(ctx, s.st, time.Now(), ContextBudget)
if err != nil {
return skills.SideQuestsRequest{}, fmt.Errorf("suggest: %w", err)
}
if len(hctx.Regulars) == 0 {
return skills.SideQuestsRequest{}, &jobs.Failure{Code: ReasonNoRegulars}
}
q := db.New(s.st.Reader)
deck, err := q.ListDeck(ctx)
if err != nil {
return skills.SideQuestsRequest{}, fmt.Errorf("suggest: list deck: %w", err)
}
pending := []string{}
for _, row := range deck {
pending = append(pending, row.Dinner.Name)
}
declined, err := q.ListDeclinedSuggestionNames(ctx)
if err != nil {
return skills.SideQuestsRequest{}, fmt.Errorf("suggest: list declined names: %w", err)
}
return skills.SideQuestsRequest{Context: hctx, Needed: BatchSize, Pending: pending, Declined: declined}, nil
}
// groundedQuest is a Side quest with the recipe that grounds it.
type groundedQuest struct {
quest skills.SideQuest
grounded recipes.Grounded
}
// ground grounds quests by their search queries and returns those a
// recipe grounds, in order. A Side quest no recipe grounds is left out;
// any other failure fails the batch.
func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]groundedQuest, error) {
reqs := make([]recipes.GroundRequest, len(quests))
for i, q := range quests {
reqs[i] = recipes.GroundRequest{DishName: q.Name, Query: q.SearchQuery}
}
results := s.grounder.GroundMany(ctx, reqs)
if err := ctx.Err(); err != nil {
return nil, err
}
var out []groundedQuest
for i, r := range results {
var ungroundable *recipes.ErrUngroundable
switch {
case r.Err == nil:
out = append(out, groundedQuest{quest: quests[i], grounded: r.Grounded})
case errors.As(r.Err, &ungroundable):
case errors.Is(r.Err, recipes.ErrSearchNotConfigured):
return nil, &jobs.Failure{Code: ReasonSearchNotConfigured}
case errors.Is(r.Err, recipes.ErrSearchFailed):
return nil, &jobs.Failure{Code: ReasonSearchFailed}
case errors.Is(r.Err, recipes.ErrNormalizeFailed):
return nil, &jobs.Failure{Code: ReasonAIFailed}
default:
return nil, fmt.Errorf("suggest: ground %q: %w", quests[i].Name, r.Err)
}
}
return out, nil
}
// store stores each grounded Side quest of batchID as a dinner with status
// suggested and origin side_quest, grounded in its recipe, and a pending
// suggestion citing its Regulars by id, all in one transaction, and
// returns the suggestion ids. A Side quest is left out when, since
// suggest_side_quests ran, a dinner it repeats was added or a Regular it
// cites renamed or archived.
func (s *Service) store(ctx context.Context, batchID string, quests []groundedQuest) ([]string, error) {
ids := []string{}
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
existing, err := q.ListUnarchivedDinners(ctx)
if err != nil {
return fmt.Errorf("suggest: list dinners for dedupe: %w", err)
}
var names []string
regulars := map[string][]string{}
for _, row := range existing {
names = append(names, row.Dinner.Name)
if row.Dinner.Status == dinners.StatusRegular {
regulars[row.Dinner.Name] = append(regulars[row.Dinner.Name], row.Dinner.ID)
}
}
for _, gq := range quests {
cited, ok := citedIDs(gq.quest.CitedRegulars, regulars)
if !ok || repeats(gq.quest.Name, names) {
s.logger.InfoContext(ctx, "suggested dinner dropped: changed while grounding", "batch_id", batchID)
continue
}
citedJSON, err := json.Marshal(cited)
if err != nil {
return fmt.Errorf("suggest: encode cited Regulars: %w", err)
}
now := store.Now().String()
gr := gq.grounded
d, err := q.CreateDinner(ctx, db.CreateDinnerParams{
ID: store.NewID(),
Name: gq.quest.Name,
NormalizedName: harness.Normalize(gq.quest.Name),
Status: dinners.StatusSuggested,
Origin: "side_quest",
Method: sql.NullString{String: gr.Method, Valid: true},
MinutesTotal: nullInt(gr.MinutesTotal),
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
Cuisine: sql.NullString{String: gq.quest.Cuisine, Valid: true},
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
GroundingStatus: dinners.GroundingGrounded,
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return fmt.Errorf("suggest: create dinner: %w", err)
}
id := store.NewID()
if err := q.CreateSuggestion(ctx, db.CreateSuggestionParams{
ID: id,
DinnerID: d.ID,
BatchID: batchID,
Reason: gq.quest.Reason,
CitedRegularIds: string(citedJSON),
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
return fmt.Errorf("suggest: create suggestion: %w", err)
}
names = append(names, d.Name)
ids = append(ids, id)
}
return nil
})
if err != nil {
return nil, err
}
return ids, nil
}
// citedIDs returns the ids of the Regulars named cited, false when one
// names no Regular or more than one.
func citedIDs(cited []string, regulars map[string][]string) ([]string, bool) {
ids := make([]string, len(cited))
for i, name := range cited {
if len(regulars[name]) != 1 {
return nil, false
}
ids[i] = regulars[name][0]
}
return ids, true
}
// repeats reports whether name is harness.Similar to any of names.
func repeats(name string, names []string) bool {
for _, n := range names {
if harness.Similar(name, n) {
return true
}
}
return false
}
func nullInt(v *int) sql.NullInt64 {
if v == nil {
return sql.NullInt64{}
}
return sql.NullInt64{Int64: int64(*v), Valid: true}
}
+415
View File
@@ -0,0 +1,415 @@
// Package suggest keeps the household's Side quest deck: batches of new
// dinners the AI suggests, each grounded in a recipe, and the household's
// Try it / Pass decision on each. A batch runs as a job: suggest_side_quests
// proposes Side quests, each is grounded with the recipe grounding pipeline
// (dropping those no recipe grounds), and every one kept is stored as a
// dinner with status suggested and origin side_quest and a pending
// suggestion. Only one batch is pending or running at a time.
package suggest
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"slices"
"sync"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// KindSuggestionBatch is the job that runs a suggestion batch.
const KindSuggestionBatch = "suggestion_batch"
const (
// BatchSize is how many Side quests a batch keeps at most.
BatchSize = 5
// ReplenishBelow is the deck size under which a decision starts a
// replenish batch.
ReplenishBelow = 5
// ContextBudget is the character budget of the household context
// suggest_side_quests is given (see harness.BuildContext).
ContextBudget = 16000
)
// Batch triggers (the suggestion_batches.trigger column).
const (
TriggerScheduled = "scheduled"
TriggerManual = "manual"
TriggerReplenish = "replenish"
)
// Batch statuses (the suggestion_batches.status column).
const (
BatchPending = "pending"
BatchRunning = "running"
BatchDone = "done"
BatchFailed = "failed"
)
// Suggestion statuses (the suggestions.status column).
const (
StatusPending = "pending"
StatusAccepted = "accepted"
StatusDeclined = "declined"
)
// Reasons a batch failed, stored as the KindSuggestionBatch job's failure
// code. Any other failure, such as the database's, is the runner's
// internal_error.
const (
// ReasonNoRegulars: the household has no Regulars for Side quests to
// be based on.
ReasonNoRegulars = "no_regulars"
// ReasonAIFailed: suggest_side_quests, or normalize_recipe while
// grounding, failed; no Side quest is made up instead (D-017).
ReasonAIFailed = "ai_failed"
// ReasonSearchNotConfigured: recipe search is not set up.
ReasonSearchNotConfigured = "search_not_configured"
// ReasonSearchFailed: the search provider failed.
ReasonSearchFailed = "search_failed"
)
// Reasons are every batch failure reason.
var Reasons = []string{ReasonNoRegulars, ReasonAIFailed, ReasonSearchNotConfigured, ReasonSearchFailed}
var triggers = []string{TriggerScheduled, TriggerManual, TriggerReplenish}
// ErrNotFound is returned for an unknown suggestion id.
var ErrNotFound = errors.New("suggestion not found")
// ErrDinnerNotSuggested is a decision on a pending suggestion whose dinner
// was archived or moved out of the deck some other way.
var ErrDinnerNotSuggested = errors.New("the suggestion's dinner is no longer suggested")
// DecidedError is a decision on a suggestion already decided the other
// way.
type DecidedError struct {
// Status is the suggestion's decision: accepted or declined.
Status string
}
func (e *DecidedError) Error() string {
return "the suggestion was already " + e.Status
}
// Suggester runs suggest_side_quests on in; scope names the batch. In
// production it is skills.SuggestSideQuests with the household's
// harness.Config.
type Suggester func(ctx context.Context, scope string, in skills.SideQuestsRequest) (skills.SideQuests, error)
// Service runs suggestion batches and records decisions.
type Service struct {
st *store.Store
grounder *recipes.Grounder
suggest Suggester
logger *slog.Logger
batchJob *jobs.Kind[BatchJob]
// batchMu makes finding or creating the active batch and enqueuing its
// job one step, so a batch is never seen without its job.
batchMu sync.Mutex
}
// NewService returns a service over st that suggests with suggest, grounds
// with grounder and registers the KindSuggestionBatch job on runner, which
// must not have started yet.
func NewService(st *store.Store, grounder *recipes.Grounder, suggest Suggester, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("suggest: nil store")
case grounder == nil:
return nil, errors.New("suggest: nil grounder")
case suggest == nil:
return nil, errors.New("suggest: nil suggester")
case runner == nil:
return nil, errors.New("suggest: nil job runner")
case logger == nil:
return nil, errors.New("suggest: nil logger")
}
s := &Service{st: st, grounder: grounder, suggest: suggest, logger: logger}
kind, err := jobs.Register(runner, KindSuggestionBatch, s.runBatch, Reasons...)
if err != nil {
return nil, err
}
s.batchJob = kind
return s, nil
}
// Batch is a suggestion batch with the id of the job running it.
type Batch struct {
ID string `json:"batch_id"`
JobID string `json:"job_id"`
Trigger string `json:"trigger"`
Status string `json:"status"`
// Created says whether this call created the batch rather than found
// it already pending or running.
Created bool `json:"-"`
}
// StartBatch creates a batch with trigger and enqueues the
// KindSuggestionBatch job that runs it, unless a batch is already pending
// or running, which it returns instead with Created false.
func (s *Service) StartBatch(ctx context.Context, trigger string) (Batch, error) {
if !slices.Contains(triggers, trigger) {
return Batch{}, fmt.Errorf("suggest: unknown batch trigger %q", trigger)
}
s.batchMu.Lock()
defer s.batchMu.Unlock()
var row db.SuggestionBatch
created := false
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
cur, err := q.GetActiveSuggestionBatch(ctx)
if err == nil {
row = cur
return nil
}
if !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("suggest: read active batch: %w", err)
}
now := store.Now().String()
row, err = q.CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{
ID: store.NewID(),
Trigger: trigger,
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return fmt.Errorf("suggest: create batch: %w", err)
}
created = true
return nil
})
if err != nil {
return Batch{}, err
}
b := Batch{ID: row.ID, Trigger: row.Trigger, Status: row.Status, Created: created}
if !created {
b.JobID, err = db.New(s.st.Reader).GetSuggestionBatchJobID(ctx, db.GetSuggestionBatchJobIDParams{Kind: KindSuggestionBatch, BatchID: row.ID})
if err != nil {
return Batch{}, fmt.Errorf("suggest: read job of batch %s: %w", row.ID, err)
}
return b, nil
}
b.JobID, err = s.batchJob.Enqueue(ctx, BatchJob{BatchID: row.ID})
if err != nil {
// The batch has no job to run it, so it must not block the next.
s.finishBatch(context.WithoutCancel(ctx), row.ID, BatchFailed)
return Batch{}, fmt.Errorf("suggest: enqueue batch %s: %w", row.ID, err)
}
return b, nil
}
// finishBatch records batch id as status, logging a failure to do so.
func (s *Service) finishBatch(ctx context.Context, id, status string) {
if _, err := db.New(s.st.Writer).FinishSuggestionBatch(ctx, db.FinishSuggestionBatchParams{
Status: status,
UpdatedAt: store.Now().String(),
ID: id,
}); err != nil {
s.logger.ErrorContext(ctx, "record suggestion batch status", "batch_id", id, "status", status, "error", err)
}
}
// FailInterruptedBatches fails every batch still pending or running whose
// KindSuggestionBatch job is no longer queued or running, so a batch a
// previous process left unfinished does not block every later one. Call it
// once at boot, after the job runner's Start has failed the jobs that
// process left running. It returns how many it failed.
func (s *Service) FailInterruptedBatches(ctx context.Context) (int64, error) {
n, err := db.New(s.st.Writer).FailOrphanedSuggestionBatches(ctx, db.FailOrphanedSuggestionBatchesParams{
UpdatedAt: store.Now().String(),
Kind: KindSuggestionBatch,
})
if err != nil {
return 0, fmt.Errorf("suggest: fail interrupted batches: %w", err)
}
if n > 0 {
s.logger.WarnContext(ctx, "failed suggestion batches interrupted by restart", "count", n)
}
return n, nil
}
// Suggestion is a Side quest suggestion as the API shows it, with its
// dinner.
type Suggestion struct {
ID string `json:"id"`
DinnerID string `json:"dinner_id"`
Name string `json:"name"`
PhotoURL *string `json:"photo_url"`
Difficulty *string `json:"difficulty"`
Method *string `json:"method"`
MinutesTotal *int64 `json:"minutes_total"`
Cuisine *string `json:"cuisine"`
// Reason is the "Why this?".
Reason string `json:"reason"`
CitedRegularIDs []string `json:"cited_regular_ids"`
Status string `json:"status"`
DinnerStatus string `json:"dinner_status"`
DecidedAt *string `json:"decided_at"`
CreatedAt string `json:"created_at"`
}
// Deck returns the pending suggestions whose dinner is still suggested,
// oldest first.
func (s *Service) Deck(ctx context.Context) ([]Suggestion, error) {
rows, err := db.New(s.st.Reader).ListDeck(ctx)
if err != nil {
return nil, fmt.Errorf("suggest: list deck: %w", err)
}
out := make([]Suggestion, len(rows))
for i, row := range rows {
out[i], err = fromRow(row.Suggestion, row.Dinner)
if err != nil {
return nil, err
}
}
return out, nil
}
// Decision is a suggestion once decided, with the id of the replenish
// batch's job when the decision started one.
type Decision struct {
Suggestion Suggestion `json:"suggestion"`
ReplenishJobID *string `json:"replenish_job_id"`
}
// Accept records Try it on suggestion id, moving its dinner to the Quest
// log and recording updatedBy on it.
func (s *Service) Accept(ctx context.Context, id, updatedBy string) (Decision, error) {
return s.decide(ctx, id, StatusAccepted, dinners.StatusQuestLog, updatedBy)
}
// Decline records Pass on suggestion id, archiving its dinner and
// recording updatedBy on it. The archived dinner keeps its name, and the
// declined suggestion is read into every later batch, so the dish is never
// suggested again.
func (s *Service) Decline(ctx context.Context, id, updatedBy string) (Decision, error) {
return s.decide(ctx, id, StatusDeclined, dinners.StatusArchived, updatedBy)
}
// decide records decision on suggestion id and moves its dinner to
// dinnerStatus. Repeating a decision changes nothing and returns the
// suggestion as it is; the opposite decision is a *DecidedError. A
// decision that leaves the deck under ReplenishBelow starts a replenish
// batch unless one is already pending or running. The decision is
// committed before the batch starts, so a failure to start it is returned
// with the decision already made.
func (s *Service) decide(ctx context.Context, id, decision, dinnerStatus, updatedBy string) (Decision, error) {
if updatedBy == "" {
return Decision{}, errors.New("suggest: decide: no member to record as updated_by")
}
var row db.GetSuggestionRow
changed := false
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
cur, err := q.GetSuggestion(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("suggest: read suggestion %s: %w", id, err)
}
switch cur.Suggestion.Status {
case decision:
row = cur
return nil
case StatusPending:
default:
return &DecidedError{Status: cur.Suggestion.Status}
}
if cur.Dinner.Status != dinners.StatusSuggested {
return ErrDinnerNotSuggested
}
now := store.Now().String()
if _, err := q.DecideSuggestion(ctx, db.DecideSuggestionParams{
Status: decision,
Now: sql.NullString{String: now, Valid: true},
ID: id,
}); err != nil {
return fmt.Errorf("suggest: decide %s: %w", id, err)
}
if _, err := q.MoveSuggestedDinner(ctx, db.MoveSuggestedDinnerParams{
Status: dinnerStatus,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: now,
ID: cur.Dinner.ID,
}); err != nil {
return fmt.Errorf("suggest: move dinner %s: %w", cur.Dinner.ID, err)
}
row, err = q.GetSuggestion(ctx, id)
if err != nil {
return fmt.Errorf("suggest: read suggestion %s: %w", id, err)
}
changed = true
return nil
})
if err != nil {
return Decision{}, err
}
sug, err := fromRow(row.Suggestion, row.Dinner)
if err != nil {
return Decision{}, err
}
d := Decision{Suggestion: sug}
if !changed {
return d, nil
}
left, err := db.New(s.st.Reader).CountDeck(ctx)
if err != nil {
return Decision{}, fmt.Errorf("suggest: count deck: %w", err)
}
if left >= ReplenishBelow {
return d, nil
}
b, err := s.StartBatch(ctx, TriggerReplenish)
if err != nil {
return Decision{}, err
}
if b.Created {
d.ReplenishJobID = &b.JobID
}
return d, nil
}
func fromRow(sg db.Suggestion, d db.Dinner) (Suggestion, error) {
out := Suggestion{
ID: sg.ID,
DinnerID: d.ID,
Name: d.Name,
Difficulty: nullString(d.Difficulty),
Method: nullString(d.Method),
Cuisine: nullString(d.Cuisine),
Reason: sg.Reason,
Status: sg.Status,
DinnerStatus: d.Status,
DecidedAt: nullString(sg.DecidedAt),
CreatedAt: sg.CreatedAt,
}
if err := json.Unmarshal([]byte(sg.CitedRegularIds), &out.CitedRegularIDs); err != nil || out.CitedRegularIDs == nil {
return Suggestion{}, fmt.Errorf("suggest: suggestion %s cited_regular_ids is not a JSON array: %q", sg.ID, sg.CitedRegularIds)
}
if d.MinutesTotal.Valid {
out.MinutesTotal = &d.MinutesTotal.Int64
}
if d.PhotoID.Valid {
u := dinners.PhotoURL(d.PhotoID.String)
out.PhotoURL = &u
}
return out, nil
}
func nullString(s sql.NullString) *string {
if !s.Valid {
return nil
}
return &s.String
}
+769
View File
@@ -0,0 +1,769 @@
package suggest_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"slices"
"strings"
"sync"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
const member = "member-1"
type env struct {
svc *suggest.Service
st *store.Store
runner *jobs.Runner
search *recipestest.Search
pages *recipestest.Pages
images *recipestest.Images
// model answers normalize_recipe while grounding; side answers
// suggest_side_quests.
model *llm.Fake
side *llm.Fake
// regulars are the Regulars' ids by name.
regulars map[string]string
mu sync.Mutex
inputs []skills.SideQuestsRequest
}
// newEnv returns a suggestions service over a migrated store with the
// Regulars Beef Chili and Chicken Fajitas and an unstarted job runner. It
// suggests through the real harness and suggest_side_quests with the
// scripted e.side, and grounds with fake search, pages and images and the
// scripted e.model.
func newEnv(t *testing.T) *env {
t.Helper()
ctx := t.Context()
dataDir := t.TempDir()
st, err := store.Open(ctx, dataDir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := st.Close(); err != nil {
t.Error(err)
}
})
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil {
t.Fatal(err)
}
e := &env{
st: st,
search: &recipestest.Search{Results: map[string][]recipes.Candidate{}},
pages: &recipestest.Pages{Bodies: map[string][]byte{}},
images: &recipestest.Images{JPEGs: map[string][]byte{}},
model: &llm.Fake{},
side: &llm.Fake{},
regulars: map[string]string{},
}
for _, name := range []string{"Beef Chili", "Chicken Fajitas"} {
e.regulars[name] = e.dinner(t, name, "regular")
}
g := recipestest.NewGrounder(t, st, dataDir, e.search, e.pages, e.images, e.model)
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
if err != nil {
t.Fatal(err)
}
cfg := harness.Config{
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return e.side, nil },
Store: st,
Guard: guard,
}
suggester := func(ctx context.Context, scope string, in skills.SideQuestsRequest) (skills.SideQuests, error) {
e.mu.Lock()
e.inputs = append(e.inputs, in)
e.mu.Unlock()
return skills.SuggestSideQuests(ctx, cfg, scope, in)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
e.runner, err = jobs.NewRunner(st, logger, 1)
if err != nil {
t.Fatal(err)
}
e.svc, err = suggest.NewService(st, g, suggester, e.runner, logger)
if err != nil {
t.Fatal(err)
}
return e
}
// start starts the env's job runner, stopping it when the test ends.
func (e *env) start(t *testing.T) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
if err := e.runner.Start(ctx); err != nil {
cancel()
t.Fatal(err)
}
t.Cleanup(func() {
cancel()
e.runner.Wait()
})
}
// dinner stores a dinner named name with status and returns its id.
func (e *env) dinner(t *testing.T, name, status string) string {
t.Helper()
now := store.Now().String()
origin := "regular"
if status == "suggested" {
origin = "side_quest"
}
d, err := db.New(e.st.Writer).CreateDinner(t.Context(), db.CreateDinnerParams{
ID: store.NewID(),
Name: name,
NormalizedName: harness.Normalize(name),
Status: status,
Origin: origin,
GroundingStatus: "manual",
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
return d.ID
}
// seedDeck stores a finished batch holding pending suggestions of
// dinners named names, in order, and returns their ids.
func (e *env) seedDeck(t *testing.T, names ...string) []string {
t.Helper()
q := db.New(e.st.Writer)
now := store.Now().String()
b, err := q.CreateSuggestionBatch(t.Context(), db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now})
if err != nil {
t.Fatal(err)
}
if _, err := q.FinishSuggestionBatch(t.Context(), db.FinishSuggestionBatchParams{Status: "done", UpdatedAt: now, ID: b.ID}); err != nil {
t.Fatal(err)
}
var ids []string
for _, name := range names {
id := store.NewID()
if err := q.CreateSuggestion(t.Context(), db.CreateSuggestionParams{
ID: id,
DinnerID: e.dinner(t, name, "suggested"),
BatchID: b.ID,
Reason: "Like your Beef Chili.",
CitedRegularIds: fmt.Sprintf("[%q]", e.regulars["Beef Chili"]),
CreatedAt: store.Now().String(),
UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
ids = append(ids, id)
}
return ids
}
// recipe makes the search for name find one page that grounds it, and
// scripts the normalize_recipe reply grounding it takes.
func (e *env) recipe(t *testing.T, name string) {
t.Helper()
slug := strings.ReplaceAll(strings.ToLower(name), " ", "-")
page, photo := "https://r.example/"+slug, "https://r.example/"+slug+".jpg"
e.search.Results[name] = []recipes.Candidate{{URL: page}}
e.pages.Bodies[page] = recipestest.RecipePage(name, "R Kitchen", photo, 35)
e.images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(len(e.images.JPEGs)+1))
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 35))
}
// sideQuest renders one Side quest of a suggest_side_quests reply whose
// search query is its name.
func sideQuest(name, reason string, cited ...string) string {
c, _ := json.Marshal(cited)
r, _ := json.Marshal(reason)
return fmt.Sprintf(`{"name":%q,"cuisine":"Fusion","method":"stovetop","est_minutes":35,"difficulty":"easy","reason":%s,"cited_regulars":%s,"search_query":%q}`,
name, r, c, name)
}
// reply scripts one suggest_side_quests reply holding quests.
func (e *env) reply(quests ...string) {
js := `{"suggestions":[` + strings.Join(quests, ",") + `]}`
e.side.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
}
// waitJob waits for job id to finish and returns it.
func (e *env) waitJob(t *testing.T, id string) jobs.Job {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for {
job, err := e.runner.Get(t.Context(), id)
if err != nil {
t.Fatal(err)
}
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
return job
}
if time.Now().After(deadline) {
t.Fatalf("job %s still %s", id, job.Status)
}
time.Sleep(5 * time.Millisecond)
}
}
type batchResult struct {
BatchID string `json:"batch_id"`
Stage string `json:"stage"`
SuggestionIDs []string `json:"suggestion_ids"`
Dropped int `json:"dropped"`
}
// runBatch starts a manual batch, waits for it to succeed and returns its
// result.
func (e *env) runBatch(t *testing.T) batchResult {
t.Helper()
b, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual)
if err != nil {
t.Fatal(err)
}
if !b.Created {
t.Fatalf("batch %s was already active", b.ID)
}
job := e.waitJob(t, b.JobID)
if job.Status != jobs.StatusSucceeded {
t.Fatalf("batch job %s, error %v", job.Status, *job.Error)
}
var res batchResult
if err := json.Unmarshal(job.Result, &res); err != nil {
t.Fatal(err)
}
if res.BatchID != b.ID || res.Stage != suggest.StageDone {
t.Fatalf("batch result = %+v, want batch %s done", res, b.ID)
}
if got := e.batchStatus(t, b.ID); got != suggest.BatchDone {
t.Fatalf("batch status = %s, want done", got)
}
return res
}
func (e *env) batchStatus(t *testing.T, id string) string {
t.Helper()
var status string
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT status FROM suggestion_batches WHERE id = ?`, id).Scan(&status); err != nil {
t.Fatal(err)
}
return status
}
func (e *env) activeBatches(t *testing.T) int {
t.Helper()
var n int
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM suggestion_batches WHERE status IN ('pending', 'running')`).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
func (e *env) dinnerStatus(t *testing.T, id string) string {
t.Helper()
row, err := db.New(e.st.Reader).GetDinner(t.Context(), id)
if err != nil {
t.Fatal(err)
}
return row.Status
}
// deckNames returns the names Deck lists, in order.
func (e *env) deckNames(t *testing.T) []string {
t.Helper()
deck, err := e.svc.Deck(t.Context())
if err != nil {
t.Fatal(err)
}
names := []string{}
for _, s := range deck {
names = append(names, s.Name)
}
return names
}
func TestBatchStoresGroundedSideQuests(t *testing.T) {
e := newEnv(t)
e.start(t)
e.recipe(t, "Chicken Shawarma Bowls")
e.recipe(t, "Chili Verde")
e.reply(
sideQuest("Chicken Shawarma Bowls", "Spiced chicken, like your Chicken Fajitas.", "Chicken Fajitas"),
sideQuest("Mystery Stew", "Hearty like your Beef Chili.", "Beef Chili"), // no recipe grounds it
sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili"),
sideQuest("Lamb Tagine", "Slow-cooked like your Pot Roast.", "Pot Roast"), // cites no Regular
)
res := e.runBatch(t)
if len(res.SuggestionIDs) != 2 || res.Dropped != 2 {
t.Fatalf("result = %+v, want 2 suggestions and 2 dropped", res)
}
if g, _, _ := e.model.Remaining(); g != 0 {
t.Errorf("%d normalize_recipe replies unused", g)
}
if q := e.search.Queries(); slices.Contains(q, "Lamb Tagine") {
t.Errorf("searched %q for a Side quest the skill dropped", q)
}
deck, err := e.svc.Deck(t.Context())
if err != nil {
t.Fatal(err)
}
if len(deck) != 2 {
t.Fatalf("deck has %d suggestions, want 2", len(deck))
}
want := []struct{ name, reason, regular string }{
{"Chicken Shawarma Bowls", "Spiced chicken, like your Chicken Fajitas.", "Chicken Fajitas"},
{"Chili Verde", "Your Beef Chili gone green.", "Beef Chili"},
}
for i, w := range want {
s := deck[i]
if s.ID != res.SuggestionIDs[i] || s.Name != w.name || s.Reason != w.reason || s.Status != suggest.StatusPending {
t.Errorf("deck[%d] = %+v, want %s pending with reason %q", i, s, w.name, w.reason)
}
if !slices.Equal(s.CitedRegularIDs, []string{e.regulars[w.regular]}) {
t.Errorf("deck[%d] cites %v, want %s's id", i, s.CitedRegularIDs, w.regular)
}
if s.PhotoURL == nil || !strings.HasPrefix(*s.PhotoURL, "/media/photos/") || s.Difficulty == nil {
t.Errorf("deck[%d] photo %v, difficulty %v; want both", i, s.PhotoURL, s.Difficulty)
}
row, err := db.New(e.st.Reader).GetDinner(t.Context(), s.DinnerID)
if err != nil {
t.Fatal(err)
}
if row.Status != "suggested" || row.Origin != "side_quest" || row.GroundingStatus != "grounded" || !row.RecipeID.Valid || row.Cuisine.String != "Fusion" {
t.Errorf("dinner %s = %+v, want suggested side_quest grounded in a recipe", w.name, row)
}
}
var dinners int
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM dinners WHERE name IN ('Mystery Stew', 'Lamb Tagine')`).Scan(&dinners); err != nil {
t.Fatal(err)
}
if dinners != 0 {
t.Errorf("%d dropped Side quests stored as dinners, want none", dinners)
}
}
func TestBatchKeepsAtMostBatchSize(t *testing.T) {
e := newEnv(t)
e.start(t)
var quests []string
for i := range suggest.BatchSize + 2 {
name := fmt.Sprintf("Fajita Dish %c", 'A'+i)
e.search.Results[name] = nil // ungroundable unless recipe is called
quests = append(quests, sideQuest(name, "Like your Chicken Fajitas.", "Chicken Fajitas"))
}
// The first round grounds BatchSize Side quests; one fails, so the
// second round grounds one more, and the last is never grounded.
for i := range suggest.BatchSize + 1 {
if i != 1 {
e.recipe(t, fmt.Sprintf("Fajita Dish %c", 'A'+i))
}
}
e.reply(quests...)
res := e.runBatch(t)
if len(res.SuggestionIDs) != suggest.BatchSize {
t.Fatalf("kept %d, want %d", len(res.SuggestionIDs), suggest.BatchSize)
}
if slices.Contains(e.search.Queries(), fmt.Sprintf("Fajita Dish %c", 'A'+suggest.BatchSize+1)) {
t.Error("grounded a Side quest after the batch was full")
}
names := e.deckNames(t)
if names[0] != "Fajita Dish A" || names[1] != "Fajita Dish C" || names[len(names)-1] != fmt.Sprintf("Fajita Dish %c", 'A'+suggest.BatchSize) {
t.Errorf("deck = %v, want the model's order without B", names)
}
}
func TestDeckIsOldestFirst(t *testing.T) {
e := newEnv(t)
e.start(t)
e.recipe(t, "Chicken Shawarma Bowls")
e.recipe(t, "Chili Verde")
e.reply(
sideQuest("Chicken Shawarma Bowls", "Spiced chicken, like your Chicken Fajitas.", "Chicken Fajitas"),
sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili"),
)
e.runBatch(t)
e.recipe(t, "Beef Bulgogi")
e.reply(sideQuest("Beef Bulgogi", "Sweet beef for Beef Chili fans.", "Beef Chili"))
e.runBatch(t)
want := []string{"Chicken Shawarma Bowls", "Chili Verde", "Beef Bulgogi"}
if got := e.deckNames(t); !slices.Equal(got, want) {
t.Errorf("deck = %v, want %v", got, want)
}
// The second batch was told the first one's Side quests are pending.
if got := e.inputs[1].Pending; !slices.Equal(got, want[:2]) {
t.Errorf("second batch pending = %v, want %v", got, want[:2])
}
}
func TestDeckLeavesOutDinnersNoLongerSuggested(t *testing.T) {
e := newEnv(t)
ids := e.seedDeck(t, "Pho", "Ramen")
sug, err := e.svc.Deck(t.Context())
if err != nil {
t.Fatal(err)
}
if _, err := db.New(e.st.Writer).ArchiveDinner(t.Context(), db.ArchiveDinnerParams{ID: sug[0].DinnerID, UpdatedAt: store.Now().String()}); err != nil {
t.Fatal(err)
}
if got := e.deckNames(t); !slices.Equal(got, []string{"Ramen"}) {
t.Errorf("deck = %v, want [Ramen]", got)
}
if _, err := e.svc.Accept(t.Context(), ids[0], member); !errors.Is(err, suggest.ErrDinnerNotSuggested) {
t.Errorf("accept a suggestion whose dinner was archived: %v, want ErrDinnerNotSuggested", err)
}
}
func TestAcceptIsIdempotent(t *testing.T) {
e := newEnv(t)
ids := e.seedDeck(t, "Pho", "Ramen", "Laksa", "Bibimbap", "Katsu", "Tonkatsu")
first, err := e.svc.Accept(t.Context(), ids[0], member)
if err != nil {
t.Fatal(err)
}
s := first.Suggestion
if s.Status != suggest.StatusAccepted || s.DinnerStatus != "quest_log" || s.DecidedAt == nil {
t.Fatalf("accepted = %+v, want accepted with its dinner in the Quest log", s)
}
row, err := db.New(e.st.Reader).GetDinner(t.Context(), s.DinnerID)
if err != nil {
t.Fatal(err)
}
if row.Status != "quest_log" || row.UpdatedBy.String != member {
t.Errorf("dinner status %s, updated_by %q; want quest_log by %s", row.Status, row.UpdatedBy.String, member)
}
again, err := e.svc.Accept(t.Context(), ids[0], "member-2")
if err != nil {
t.Fatal(err)
}
if *again.Suggestion.DecidedAt != *s.DecidedAt || again.ReplenishJobID != nil {
t.Errorf("repeat accept = %+v, want the first decision unchanged", again)
}
if row, _ := db.New(e.st.Reader).GetDinner(t.Context(), s.DinnerID); row.UpdatedBy.String != member {
t.Errorf("repeat accept recorded updated_by %q", row.UpdatedBy.String)
}
var decided *suggest.DecidedError
if _, err := e.svc.Decline(t.Context(), ids[0], member); !errors.As(err, &decided) || decided.Status != suggest.StatusAccepted {
t.Fatalf("decline an accepted suggestion: %v, want *DecidedError accepted", err)
}
if got := e.dinnerStatus(t, s.DinnerID); got != "quest_log" {
t.Errorf("dinner status after refused decline = %s, want quest_log", got)
}
if got := e.deckNames(t); slices.Contains(got, "Pho") {
t.Errorf("deck %v still holds the accepted suggestion", got)
}
}
func TestDeclineIsIdempotent(t *testing.T) {
e := newEnv(t)
ids := e.seedDeck(t, "Pho", "Ramen", "Laksa", "Bibimbap", "Katsu", "Tonkatsu")
first, err := e.svc.Decline(t.Context(), ids[1], member)
if err != nil {
t.Fatal(err)
}
s := first.Suggestion
if s.Status != suggest.StatusDeclined || s.DinnerStatus != "archived" || s.DecidedAt == nil {
t.Fatalf("declined = %+v, want declined with its dinner archived", s)
}
again, err := e.svc.Decline(t.Context(), ids[1], member)
if err != nil {
t.Fatal(err)
}
if *again.Suggestion.DecidedAt != *s.DecidedAt || again.ReplenishJobID != nil {
t.Errorf("repeat decline = %+v, want the first decision unchanged", again)
}
var decided *suggest.DecidedError
if _, err := e.svc.Accept(t.Context(), ids[1], member); !errors.As(err, &decided) || decided.Status != suggest.StatusDeclined {
t.Fatalf("accept a declined suggestion: %v, want *DecidedError declined", err)
}
if got := e.dinnerStatus(t, s.DinnerID); got != "archived" {
t.Errorf("dinner status after refused accept = %s, want archived", got)
}
names, err := db.New(e.st.Reader).ListDeclinedSuggestionNames(t.Context())
if err != nil {
t.Fatal(err)
}
if !slices.Equal(names, []string{"Ramen"}) {
t.Errorf("declined names = %v, want [Ramen]", names)
}
}
func TestDecideUnknownSuggestion(t *testing.T) {
e := newEnv(t)
if _, err := e.svc.Accept(t.Context(), "nope", member); !errors.Is(err, suggest.ErrNotFound) {
t.Errorf("accept unknown: %v, want ErrNotFound", err)
}
if _, err := e.svc.Decline(t.Context(), "nope", member); !errors.Is(err, suggest.ErrNotFound) {
t.Errorf("decline unknown: %v, want ErrNotFound", err)
}
}
func TestReplenishWhenDeckFallsBelowFive(t *testing.T) {
e := newEnv(t) // runner not started: a replenish batch stays pending
ids := e.seedDeck(t, "Pho", "Ramen", "Laksa", "Bibimbap", "Katsu", "Tonkatsu")
d, err := e.svc.Accept(t.Context(), ids[0], member) // 5 left
if err != nil {
t.Fatal(err)
}
if d.ReplenishJobID != nil || e.activeBatches(t) != 0 {
t.Fatalf("replenished with 5 left: job %v, %d active batches", d.ReplenishJobID, e.activeBatches(t))
}
d, err = e.svc.Decline(t.Context(), ids[1], member) // 4 left
if err != nil {
t.Fatal(err)
}
if d.ReplenishJobID == nil {
t.Fatal("no replenish batch with 4 left")
}
job, err := e.runner.Get(t.Context(), *d.ReplenishJobID)
if err != nil {
t.Fatal(err)
}
if job.Kind != suggest.KindSuggestionBatch || job.Status != jobs.StatusQueued {
t.Errorf("replenish job = %+v, want a queued %s", job, suggest.KindSuggestionBatch)
}
var trigger string
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT trigger FROM suggestion_batches WHERE status = 'pending'`).Scan(&trigger); err != nil {
t.Fatal(err)
}
if trigger != suggest.TriggerReplenish {
t.Errorf("batch trigger = %s, want replenish", trigger)
}
}
func TestReplenishIsSingleFlight(t *testing.T) {
e := newEnv(t) // runner not started: the replenish batch stays pending
ids := e.seedDeck(t, "Pho", "Ramen", "Laksa")
first, err := e.svc.Decline(t.Context(), ids[0], member)
if err != nil {
t.Fatal(err)
}
if first.ReplenishJobID == nil {
t.Fatal("no replenish batch with 2 left")
}
second, err := e.svc.Accept(t.Context(), ids[1], member)
if err != nil {
t.Fatal(err)
}
if second.ReplenishJobID != nil {
t.Errorf("second decision enqueued replenish job %s while one is pending", *second.ReplenishJobID)
}
manual, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual)
if err != nil {
t.Fatal(err)
}
if manual.Created || manual.JobID != *first.ReplenishJobID || manual.Trigger != suggest.TriggerReplenish {
t.Errorf("manual batch = %+v, want the pending replenish batch with job %s", manual, *first.ReplenishJobID)
}
if n := e.activeBatches(t); n != 1 {
t.Errorf("%d active batches, want 1", n)
}
var jobsQueued int
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, suggest.KindSuggestionBatch).Scan(&jobsQueued); err != nil {
t.Fatal(err)
}
if jobsQueued != 1 {
t.Errorf("%d batch jobs, want 1", jobsQueued)
}
}
func TestStartBatchIsSingleFlightUnderConcurrency(t *testing.T) {
e := newEnv(t)
var wg sync.WaitGroup
batches := make([]suggest.Batch, 8)
for i := range batches {
wg.Add(1)
go func() {
defer wg.Done()
b, err := e.svc.StartBatch(context.Background(), suggest.TriggerManual)
if err != nil {
t.Error(err)
}
batches[i] = b
}()
}
wg.Wait()
created := 0
for _, b := range batches {
if b.Created {
created++
}
if b.ID != batches[0].ID || b.JobID != batches[0].JobID || b.JobID == "" {
t.Errorf("batch %+v, want %s with job %s", b, batches[0].ID, batches[0].JobID)
}
}
if created != 1 {
t.Errorf("%d calls created a batch, want 1", created)
}
}
func TestLaterBatchNeverResuggestsDeclined(t *testing.T) {
e := newEnv(t)
e.start(t)
shawarma := sideQuest("Chicken Shawarma Bowls", "Spiced chicken, like your Chicken Fajitas.", "Chicken Fajitas")
e.recipe(t, "Chicken Shawarma Bowls")
e.reply(shawarma)
res := e.runBatch(t)
// Passing it leaves the deck empty, so a replenish batch runs; the
// model suggests the passed dish again, respelled, with a new one.
e.recipe(t, "Beef Ragu")
e.reply(
sideQuest("chicken shawarma bowls!", "Spiced chicken, like your Chicken Fajitas.", "Chicken Fajitas"),
sideQuest("Beef Ragu", "Slow beef like your Beef Chili.", "Beef Chili"),
)
d, err := e.svc.Decline(t.Context(), res.SuggestionIDs[0], member)
if err != nil {
t.Fatal(err)
}
if d.ReplenishJobID == nil {
t.Fatal("passing the last Side quest started no replenish batch")
}
job := e.waitJob(t, *d.ReplenishJobID)
if job.Status != jobs.StatusSucceeded {
t.Fatalf("replenish job %s: %v", job.Status, *job.Error)
}
var replenished batchResult
if err := json.Unmarshal(job.Result, &replenished); err != nil {
t.Fatal(err)
}
if len(replenished.SuggestionIDs) != 1 || replenished.Dropped != 1 {
t.Errorf("replenish result = %+v, want 1 kept and the passed dish dropped", replenished)
}
if got := e.deckNames(t); !slices.Equal(got, []string{"Beef Ragu"}) {
t.Errorf("deck = %v, want [Beef Ragu]", got)
}
e.mu.Lock()
in := e.inputs[1]
e.mu.Unlock()
if !slices.Equal(in.Declined, []string{"Chicken Shawarma Bowls"}) || !slices.Equal(in.Context.Passed, []string{"Chicken Shawarma Bowls"}) {
t.Errorf("replenish batch declined %v, passed %v; want the passed dish in both", in.Declined, in.Context.Passed)
}
}
func TestBatchFailsWhenAIFails(t *testing.T) {
e := newEnv(t)
e.start(t)
e.side.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")})
b, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual)
if err != nil {
t.Fatal(err)
}
job := e.waitJob(t, b.JobID)
if job.Status != jobs.StatusFailed || *job.Error != suggest.ReasonAIFailed {
t.Fatalf("job = %s %v, want failed %s", job.Status, job.Error, suggest.ReasonAIFailed)
}
if got := e.batchStatus(t, b.ID); got != suggest.BatchFailed {
t.Errorf("batch status = %s, want failed", got)
}
if got := e.deckNames(t); len(got) != 0 {
t.Errorf("deck = %v after a failed batch, want empty", got)
}
// A failed batch does not block the next one.
next, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual)
if err != nil {
t.Fatal(err)
}
if !next.Created || next.ID == b.ID {
t.Errorf("next batch = %+v, want a new one", next)
}
}
func TestBatchFailsWhenSearchFails(t *testing.T) {
e := newEnv(t)
e.start(t)
e.search.Err = errors.New("search down")
e.reply(sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili"))
b, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual)
if err != nil {
t.Fatal(err)
}
job := e.waitJob(t, b.JobID)
if job.Status != jobs.StatusFailed || *job.Error != suggest.ReasonSearchFailed {
t.Fatalf("job = %s %v, want failed %s", job.Status, job.Error, suggest.ReasonSearchFailed)
}
if got := e.deckNames(t); len(got) != 0 {
t.Errorf("deck = %v, want empty", got)
}
}
func TestBatchFailsWithNoRegulars(t *testing.T) {
e := newEnv(t)
e.start(t)
for _, id := range e.regulars {
if _, err := db.New(e.st.Writer).ArchiveDinner(t.Context(), db.ArchiveDinnerParams{ID: id, UpdatedAt: store.Now().String()}); err != nil {
t.Fatal(err)
}
}
b, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual)
if err != nil {
t.Fatal(err)
}
job := e.waitJob(t, b.JobID)
if job.Status != jobs.StatusFailed || *job.Error != suggest.ReasonNoRegulars {
t.Fatalf("job = %s %v, want failed %s", job.Status, job.Error, suggest.ReasonNoRegulars)
}
if reqs := e.side.Requests(); len(reqs) != 0 {
t.Errorf("model called %d times with no Regulars", len(reqs))
}
}
func TestFailInterruptedBatches(t *testing.T) {
e := newEnv(t)
queued, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual) // its job is still queued
if err != nil {
t.Fatal(err)
}
now := store.Now().String()
orphan, err := db.New(e.st.Writer).CreateSuggestionBatch(t.Context(), db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now})
if err != nil {
t.Fatal(err)
}
n, err := e.svc.FailInterruptedBatches(t.Context())
if err != nil {
t.Fatal(err)
}
if n != 1 || e.batchStatus(t, orphan.ID) != suggest.BatchFailed || e.batchStatus(t, queued.ID) != suggest.BatchPending {
t.Errorf("failed %d; orphan %s, queued %s; want only the orphan failed", n, e.batchStatus(t, orphan.ID), e.batchStatus(t, queued.ID))
}
}
func TestStartBatchRefusesUnknownTrigger(t *testing.T) {
e := newEnv(t)
if _, err := e.svc.StartBatch(t.Context(), "whenever"); err == nil {
t.Fatal("unknown trigger accepted")
}
}