Feature 13: Discover - Side Quest Suggestions (#110)
CI / ci (push) Successful in 2m49s

Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #110.
This commit is contained in:
2026-10-07 05:53:22 -04:00
committed by approver_agent
parent ac7453f576
commit 5291457214
59 changed files with 8959 additions and 128 deletions
+309
View File
@@ -0,0 +1,309 @@
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. When
// recording the batch's end fails, so does the job; when it times out at
// shutdown, the job is left running and the batch with it, for the next
// start to fail.
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)
if err == nil {
if err := s.finishBatch(ctx, p.BatchID, BatchDone); err != nil {
return batchResult{}, err
}
return res, nil
}
finishErr := s.finishBatch(ctx, p.BatchID, BatchFailed)
if ctx.Err() != nil {
return batchResult{}, errors.Join(ctx.Err(), finishErr)
}
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{}, errors.Join(err, finishErr)
}
// 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, a suggestion it
// repeats passed, or a Regular it cites renamed or archived. Declined
// names are read in the same transaction as the store, so a Pass committed
// before it is never missed.
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)
}
names, err := q.ListDeclinedSuggestionNames(ctx)
if err != nil {
return fmt.Errorf("suggest: list declined names for dedupe: %w", err)
}
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,
Name: d.Name,
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}
}
+482
View File
@@ -0,0 +1,482 @@
// 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"
"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]
}
// 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)
}
var b Batch
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
var err error
b, err = s.startBatch(ctx, tx, trigger)
return err
})
if err != nil {
return Batch{}, err
}
if b.Created {
s.batchJob.Wake()
}
return b, nil
}
// activeBatch returns the batch pending or running in q's transaction, or
// nil when there is none. A batch still pending or running whose job is no
// longer queued or running is failed first, so a batch whose job ended
// without recording its end (the process stopped, or the write failed) is
// never reported as active and never blocks a later one.
func (s *Service) activeBatch(ctx context.Context, q *db.Queries) (*Batch, error) {
n, err := q.FailOrphanedSuggestionBatches(ctx, db.FailOrphanedSuggestionBatchesParams{
UpdatedAt: store.Now().String(),
Kind: KindSuggestionBatch,
})
if err != nil {
return nil, fmt.Errorf("suggest: fail orphaned batches: %w", err)
}
if n > 0 {
s.logger.WarnContext(ctx, "failed suggestion batches whose job ended", "count", n)
}
cur, err := q.GetActiveSuggestionBatch(ctx)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("suggest: read active batch: %w", err)
}
jobID, err := q.GetSuggestionBatchJobID(ctx, db.GetSuggestionBatchJobIDParams{Kind: KindSuggestionBatch, BatchID: cur.ID})
if err != nil {
return nil, fmt.Errorf("suggest: read job of batch %s: %w", cur.ID, err)
}
return &Batch{ID: cur.ID, JobID: jobID, Trigger: cur.Trigger, Status: cur.Status}, nil
}
// failedBatch returns the newest batch in q's transaction when it failed,
// or nil when it did not or there is none: a later batch, done or under
// way, supersedes an earlier failure. Run after activeBatch, so a batch
// whose job ended without recording its end reads as failed.
func failedBatch(ctx context.Context, q *db.Queries) (*Batch, error) {
cur, err := q.GetLatestSuggestionBatch(ctx)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("suggest: read latest batch: %w", err)
}
if cur.Status != BatchFailed {
return nil, nil
}
jobID, err := q.GetSuggestionBatchJobID(ctx, db.GetSuggestionBatchJobIDParams{Kind: KindSuggestionBatch, BatchID: cur.ID})
if err != nil {
return nil, fmt.Errorf("suggest: read job of batch %s: %w", cur.ID, err)
}
return &Batch{ID: cur.ID, JobID: jobID, Trigger: cur.Trigger, Status: cur.Status}, nil
}
// startBatch does StartBatch's work in tx: the batch and the job that runs
// it are committed together, so a batch is never seen without its job. The
// caller wakes the job runner once tx has committed and Created is true.
func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (Batch, error) {
q := db.New(tx)
cur, err := s.activeBatch(ctx, q)
if err != nil {
return Batch{}, err
}
if cur != nil {
return *cur, nil
}
now := store.Now().String()
row, err := q.CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{
ID: store.NewID(),
Trigger: trigger,
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return Batch{}, fmt.Errorf("suggest: create batch: %w", err)
}
jobID, err := s.batchJob.EnqueueTx(ctx, tx, BatchJob{BatchID: row.ID})
if err != nil {
return Batch{}, fmt.Errorf("suggest: enqueue batch %s: %w", row.ID, err)
}
return Batch{ID: row.ID, JobID: jobID, Trigger: row.Trigger, Status: row.Status, Created: true}, nil
}
// finishBatch records running batch id as status, with
// jobs.FinalWriteContext: the write is made even when ctx has been
// cancelled, but a writer that stays busy cannot hold its worker past
// jobs.FinalWriteTimeout. Its job's handler returns any error, so the job
// never ends succeeded with the batch still running; the runner keeps only
// a code, so the error is logged too.
func (s *Service) finishBatch(ctx context.Context, id, status string) error {
write, cancel := jobs.FinalWriteContext(ctx)
defer cancel()
n, err := db.New(s.st.Writer).FinishSuggestionBatch(write, db.FinishSuggestionBatchParams{
Status: status,
UpdatedAt: store.Now().String(),
ID: id,
})
if err == nil && n != 1 {
err = errors.New("batch is not running")
}
if err != nil {
s.logger.ErrorContext(ctx, "record suggestion batch status", "batch_id", id, "status", status, "error", err)
return fmt.Errorf("suggest: record batch %s %s: %w", id, status, err)
}
return 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 is the household's deck: the pending suggestions whose dinner is
// still suggested, oldest first; the batch pending or running, if any, so
// a client can follow the job producing more; and the newest batch when it
// failed, so a client that was not following its job still learns of the
// failure. At most one of ActiveBatch and FailedBatch is set: a batch is
// only started when none is pending or running, so the active batch is
// always the newest.
type Deck struct {
Suggestions []Suggestion `json:"suggestions"`
ActiveBatch *Batch `json:"active_batch"`
FailedBatch *Batch `json:"failed_batch"`
}
// Deck reads the deck and its active and failed batches in one
// transaction, so all come from the same snapshot. It is a write
// transaction because reading the active batch first fails any batch whose
// job has ended (see activeBatch), which is then the failed batch.
func (s *Service) Deck(ctx context.Context) (Deck, error) {
var deck Deck
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
rows, err := q.ListDeck(ctx)
if err != nil {
return fmt.Errorf("suggest: list deck: %w", err)
}
deck.Suggestions = make([]Suggestion, len(rows))
for i, row := range rows {
deck.Suggestions[i], err = fromRow(row.Suggestion, row.Dinner)
if err != nil {
return err
}
}
deck.ActiveBatch, err = s.activeBatch(ctx, q)
if err != nil || deck.ActiveBatch != nil {
return err
}
deck.FailedBatch, err = failedBatch(ctx, q)
return err
})
if err != nil {
return Deck{}, err
}
return deck, nil
}
// Decision is a suggestion once decided, with the id of the replenish
// batch's job when the decision called for one: the batch it started, or
// the one already pending or running that it joined.
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 suggestion keeps the name its dish was
// suggested under, whatever the dinner is later renamed, and every later
// batch reads it as declined, 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, or joins the one already pending or running, in the decision's own
// transaction, and returns its job id either way: the decision is never recorded without the replenish it
// calls for, and a failure to start the batch records neither.
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
var replenish *Batch
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)
}
left, err := q.CountDeck(ctx)
if err != nil {
return fmt.Errorf("suggest: count deck: %w", err)
}
if left >= ReplenishBelow {
return nil
}
b, err := s.startBatch(ctx, tx, TriggerReplenish)
if err != nil {
return err
}
replenish = &b
return nil
})
if err != nil {
return Decision{}, err
}
if replenish != nil && replenish.Created {
s.batchJob.Wake()
}
sug, err := fromRow(row.Suggestion, row.Dinner)
if err != nil {
return Decision{}, err
}
d := Decision{Suggestion: sug}
if replenish != nil {
d.ReplenishJobID = &replenish.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
}
File diff suppressed because it is too large Load Diff