Files
MealQuest/internal/ideas/ideas.go
T
dev_agentandClaude Opus 5.5 e93c9c63f6
CI / ci (push) Successful in 5m42s
CI / ci (pull_request) Successful in 5m44s
Add idea actions: add to Quest log and cook tonight (Feature 18)
POST /api/v1/ideas/{jobId}/items/{i}/quest-log adds idea i of a
succeeded ideas job to the Quest log as a dinner grounded in the idea's
own recipe, with origin ingredients or quick, or answers the dinner it
repeats as it is (the Regular dedupe rule), so repeating the call is
idempotent. POST .../cook-tonight does the same, then puts the dinner on
today (household timezone) of this week's plan through plan.Service.Swap;
with no plan for the week it answers 409 no_plan and changes nothing.

Maestro task 54dcd487-1597-4a91-8493-97f3a7635e2d.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:58:20 -04:00

663 lines
25 KiB
Go

// Package ideas finds dinner ideas for Cook from what I have and Quick
// dinners: from a list of ingredients on hand, ideas_from_ingredients
// proposes dinners, and for a kind of Quick dinner, quick_ideas does; each
// is grounded with the recipe grounding pipeline (dropping those no recipe
// grounds, and Quick dinners whose grounded recipe is not of the kind), and
// the ideas kept are reported as a job's result, which fills as each idea
// grounds. A job that keeps no idea, or fails, shows no idea: nothing is
// made up instead (D-017). Ideas are not stored as dinners: the household
// acts on one later.
package ideas
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"slices"
"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/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"
)
// Job kinds.
const (
// KindFromIngredients is the job that finds ideas from ingredients.
KindFromIngredients = "ideas_from_ingredients"
// KindQuick is the job that finds a page of Quick dinner ideas.
KindQuick = "quick_ideas"
)
// ContextBudget is the character budget of the household context
// quick_ideas is given (see harness.BuildContext).
const ContextBudget = 16000
// QuickMinutes is the most minutes from prep to table of a Quick dinner
// not cooked in a crock pot.
const QuickMinutes = 30
// Job stages, as the job's result reports them.
const (
StageSuggesting = "suggesting"
StageGrounding = "grounding"
StageDone = "done"
)
// Reasons the job failed, stored as its failure code. Any other failure,
// such as the database's, is the runner's internal_error.
const (
// ReasonAIFailed: ideas_from_ingredients or quick_ideas, or
// normalize_recipe while grounding, failed; no idea 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"
// ReasonNoIdeasFound: every idea was dropped, by the skill's rules,
// because no recipe grounds it, or because its grounded recipe is not
// a Quick dinner (a Quick only request) or not of the kind asked for (a
// Quick dinners request).
ReasonNoIdeasFound = "no_ideas_found"
)
// Reasons are every failure reason of both jobs.
var Reasons = []string{ReasonAIFailed, ReasonSearchNotConfigured, ReasonSearchFailed, ReasonNoIdeasFound}
// InvalidError is a request the household must correct, such as an empty
// ingredient list or an unknown kind of Quick dinner.
type InvalidError struct {
Message string
}
func (e *InvalidError) Error() string { return e.Message }
// Generator runs ideas_from_ingredients on in; scope is
// skills.IdeasScope(in). In production it is skills.IdeasFromIngredients
// with the household's harness.Config.
type Generator func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error)
// QuickGenerator runs quick_ideas on in; scope is skills.QuickIdeasScope(in).
// In production it is skills.QuickIdeas with the household's
// harness.Config.
type QuickGenerator func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error)
// Service starts and runs idea jobs.
type Service struct {
st *store.Store
grounder *recipes.Grounder
runner *jobs.Runner
generate Generator
quick QuickGenerator
logger *slog.Logger
job *jobs.Kind[FromIngredientsJob]
quickJob *jobs.Kind[QuickJob]
}
// NewService returns a service that reads the household context from st,
// finds ideas with generate and Quick dinner ideas with quick, grounds
// them with grounder and registers the KindFromIngredients and KindQuick
// jobs on runner, which must not have started yet. The jobs are started
// only by StartFromIngredients and StartQuick, which check the request
// first.
func NewService(st *store.Store, grounder *recipes.Grounder, generate Generator, quick QuickGenerator, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("ideas: nil store")
case grounder == nil:
return nil, errors.New("ideas: nil grounder")
case generate == nil:
return nil, errors.New("ideas: nil generator")
case quick == nil:
return nil, errors.New("ideas: nil quick generator")
case runner == nil:
return nil, errors.New("ideas: nil job runner")
case logger == nil:
return nil, errors.New("ideas: nil logger")
}
s := &Service{st: st, grounder: grounder, runner: runner, generate: generate, quick: quick, logger: logger}
var err error
if s.job, err = register(runner, KindFromIngredients, s.run, withoutIdeas[Result]); err != nil {
return nil, err
}
if s.quickJob, err = register(runner, KindQuick, s.runQuick, withoutIdeas[QuickResult]); err != nil {
return nil, err
}
return s, nil
}
// register registers the service-only job name, run by run, whose failed
// jobs show their result through failed.
func register[P any, R jobs.Public](runner *jobs.Runner, name string, run jobs.Handler[P, R], failed func(json.RawMessage) (json.RawMessage, error)) (*jobs.Kind[P], error) {
kind, err := jobs.Register(runner, name, run, Reasons...)
if err != nil {
return nil, err
}
if err := kind.ServiceOnly(); err != nil {
return nil, err
}
if err := kind.FailedResult(failed); err != nil {
return nil, err
}
return kind, nil
}
// FromIngredientsJob is the KindFromIngredients payload: the ingredients
// on hand, as skills.CleanIngredients returns them, and whether only
// Quick dinners are wanted.
type FromIngredientsJob struct {
Ingredients []string `json:"ingredients"`
QuickOnly bool `json:"quick_only"`
}
// Started is the job a request started.
type Started struct {
JobID string `json:"jobId"`
}
// StartFromIngredients enqueues a KindFromIngredients job for ingredients
// and returns its id. An ingredient list skills.CleanIngredients refuses
// is an *InvalidError, and starts nothing.
func (s *Service) StartFromIngredients(ctx context.Context, ingredients []string, quickOnly bool) (Started, error) {
ings, err := skills.CleanIngredients(ingredients)
if err != nil {
return Started{}, &InvalidError{Message: "ingredients: " + err.Error()}
}
id, err := s.job.Enqueue(ctx, FromIngredientsJob{Ingredients: ings, QuickOnly: quickOnly})
if err != nil {
return Started{}, fmt.Errorf("ideas: enqueue: %w", err)
}
return Started{JobID: id}, nil
}
// Idea is a grounded idea as the job's result shows it. Method,
// MinutesTotal and Difficulty are the grounded recipe's, never the
// model's estimates.
type Idea struct {
Name string `json:"name"`
// Uses are the household's ingredients the idea uses, as the household
// wrote them and in its order.
Uses []string `json:"uses"`
// Missing are the ingredients the idea needs that the household does
// not have.
Missing []string `json:"missing"`
// Summary is the line shown under the idea, e.g. "Uses 3 of 5 ·
// missing: 1 lime" (see skills.IdeaSummary).
Summary string `json:"summary"`
// Method is stovetop, oven, crockpot, grill or other.
Method string `json:"method"`
// MinutesTotal is prep to table, null when the recipe does not say.
MinutesTotal *int `json:"minutes_total"`
// Difficulty is easy, medium or hard.
Difficulty string `json:"difficulty"`
// Quick says whether the idea is a Quick dinner (see IsQuick).
Quick bool `json:"quick"`
RecipeID string `json:"recipe_id"`
PhotoURL string `json:"photo_url"`
SourceURL string `json:"source_url"`
SiteName string `json:"site_name"`
}
// Result is the KindFromIngredients result, published as the job goes:
// its stage, the request, the ideas grounded so far in the model's order
// (an idea that grounds sooner than one before it is shown first, and the
// earlier one is put back in its place when it grounds),
// and how many ideas were dropped, by ideas_from_ingredients' rules,
// because no recipe grounds them, or, for a Quick only request, because
// the grounded recipe is not a Quick dinner.
type Result struct {
Stage string `json:"stage"`
Ingredients []string `json:"ingredients"`
QuickOnly bool `json:"quick_only"`
Ideas []Idea `json:"ideas"`
Dropped int `json:"dropped"`
}
func (r Result) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// IsQuick reports whether a recipe cooked by method in minutes (nil when
// unknown) is a Quick dinner: cooked in a crock pot, or 30 minutes or less
// from prep to table.
func IsQuick(method string, minutes *int) bool {
return method == "crockpot" || (minutes != nil && *minutes <= QuickMinutes)
}
// run runs a KindFromIngredients job: it asks ideas_from_ingredients for
// skills.IdeasRequested ideas, then grounds them in the model's order, at
// most the number still wanted at a time, until skills.IdeasKept are kept
// or none are left. An idea no recipe grounds is dropped, and so, for a
// Quick only request, is one whose grounded recipe is not a Quick dinner;
// when every idea is dropped the job fails with ReasonNoIdeasFound. Any
// other grounding failure fails the job, as does ideas_from_ingredients
// failing, with no idea made up instead. The result is published at each
// stage and again as each idea is kept, so it fills as the ideas ground.
// Before the job fails, its result is published again with no ideas (see
// finish).
func (s *Service) run(ctx context.Context, p FromIngredientsJob, publish func(Result) error) (Result, error) {
res, err := s.fill(ctx, p, publish)
return finish(ctx, s.logger, "ideas from ingredients", res, err, publish)
}
// shownResult is a job result that shows ideas.
type shownResult[R any] interface {
jobs.Public
// shown is how many ideas the result shows.
shown() int
// cleared is the result showing no ideas.
cleared() R
}
func (r Result) shown() int { return len(r.Ideas) }
func (r Result) cleared() Result {
r.Ideas = []Idea{}
return r
}
// finish ends a job whose work returned res and err. A job that failed
// has its result published again with no ideas, to tidy the stored
// result. That write is not what keeps a failed job from showing ideas,
// since it may fail and a job interrupted by restart never makes it:
// withoutIdeas, applied whenever a failed job is read, is.
func finish[R shownResult[R]](ctx context.Context, logger *slog.Logger, what string, res R, err error, publish func(R) error) (R, error) {
var zero R
if err == nil {
return res, nil
}
if ctx.Err() != nil {
return zero, 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.
logger.InfoContext(ctx, what+" failed", "reason", failure.Code)
}
if res.shown() > 0 {
if perr := publish(res.cleared()); perr != nil {
return zero, fmt.Errorf("ideas: clear failed job's ideas: %w", perr)
}
}
return zero, err
}
// fill does run's work, returning a *jobs.Failure for a failure the
// household may see. On failure it returns the result last published with
// the error, so run can clear its ideas.
func (s *Service) fill(ctx context.Context, p FromIngredientsJob, publish func(Result) error) (Result, error) {
res := Result{Stage: StageSuggesting, Ingredients: p.Ingredients, QuickOnly: p.QuickOnly, Ideas: []Idea{}}
if err := publish(res); err != nil {
return res, err
}
in := skills.IdeasRequest{Ingredients: p.Ingredients, QuickOnly: p.QuickOnly}
out, err := s.generate(ctx, skills.IdeasScope(in), in)
if err != nil {
if ctx.Err() != nil {
return res, ctx.Err()
}
return res, &jobs.Failure{Code: ReasonAIFailed}
}
res.Stage = StageGrounding
res.Dropped = len(out.Dropped)
if err := publish(res); err != nil {
return res, err
}
reqs := make([]recipes.GroundRequest, len(out.Ideas))
for i, idea := range out.Ideas {
reqs[i] = recipes.GroundRequest{DishName: idea.Name, Query: idea.SearchQuery}
}
err = keepGrounded(ctx, s.grounder, reqs, skills.IdeasKept, &res.Ideas, &res.Dropped, func(i int, gr recipes.Grounded) (Idea, bool) {
idea := ideaOf(out.Ideas[i], gr)
return idea, !p.QuickOnly || idea.Quick
}, func() error { return publish(res) })
if err != nil {
return res, err
}
if len(res.Ideas) == 0 {
return res, &jobs.Failure{Code: ReasonNoIdeasFound}
}
res.Stage = StageDone
return res, nil
}
// keepGrounded grounds reqs with g in their order, at most the number
// still wanted at a time, until want ideas are shown or none are left. As
// each grounds, keep makes its idea from request i's grounding and says
// whether it is kept: a kept idea is put in *shown in its place in reqs'
// order (an idea that grounds sooner than one before it is shown first,
// and the earlier one is put back in its place when it grounds) and
// publish is called. An idea no recipe grounds, or that keep refuses,
// counts in *dropped. Any other grounding failure, or publish failing, is
// returned (see ground).
func keepGrounded[T any](ctx context.Context, g *recipes.Grounder, reqs []recipes.GroundRequest, want int, shown *[]T, dropped *int, keep func(i int, gr recipes.Grounded) (T, bool), publish func() error) error {
// order[k] is the index in reqs of (*shown)[k], so each idea is put in
// its place in reqs' order as it grounds.
var order []int
for next := 0; len(*shown) < want && next < len(reqs); {
first := next
round := reqs[first:min(first+want-len(*shown), len(reqs))]
next += len(round)
err := ground(ctx, g, round, func(i int, gr *recipes.Grounded) error {
if gr == nil {
*dropped++
return nil
}
idea, ok := keep(first+i, *gr)
if !ok {
*dropped++
return nil
}
at := len(order)
for at > 0 && order[at-1] > first+i {
at--
}
order = slices.Insert(order, at, first+i)
*shown = slices.Insert(*shown, at, idea)
return publish()
})
if err != nil {
return err
}
}
return nil
}
// ground grounds reqs with g and calls kept, one call at a time, as each
// request grounds, with its index in reqs and its grounding, or nil when
// no recipe grounds it. Any other grounding failure, or kept failing,
// stops the groundings still running and is returned, as a *jobs.Failure
// when the household may see it.
func ground(ctx context.Context, g *recipes.Grounder, reqs []recipes.GroundRequest, kept func(i int, gr *recipes.Grounded) error) error {
groundCtx, cancel := context.WithCancel(ctx)
defer cancel()
var failed error
g.GroundEach(groundCtx, reqs, func(i int, r recipes.GroundResult) {
if failed != nil {
return
}
var ungroundable *recipes.ErrUngroundable
switch {
case r.Err == nil:
failed = kept(i, &r.Grounded)
case errors.As(r.Err, &ungroundable):
failed = kept(i, nil)
case errors.Is(r.Err, recipes.ErrSearchNotConfigured):
failed = &jobs.Failure{Code: ReasonSearchNotConfigured}
case errors.Is(r.Err, recipes.ErrSearchFailed):
failed = &jobs.Failure{Code: ReasonSearchFailed}
case errors.Is(r.Err, recipes.ErrNormalizeFailed):
failed = &jobs.Failure{Code: ReasonAIFailed}
default:
failed = fmt.Errorf("ideas: ground %q: %w", reqs[i].DishName, r.Err)
}
if failed != nil {
cancel()
}
})
if err := ctx.Err(); err != nil {
return err
}
return failed
}
// withoutIdeas is a failed job's stored result as the job shows it: with no
// ideas, however the job failed, so a failed job never shows ideas kept
// before the failure.
func withoutIdeas[R shownResult[R]](stored json.RawMessage) (json.RawMessage, error) {
var res R
if err := json.Unmarshal(stored, &res); err != nil {
return nil, fmt.Errorf("ideas: decode failed job's result: %w", err)
}
return res.cleared().PublicJSON()
}
// ideaOf is idea as grounded by gr.
func ideaOf(idea skills.Idea, gr recipes.Grounded) Idea {
return Idea{
Name: idea.Name,
Uses: idea.Uses,
Missing: idea.Missing,
Summary: idea.Summary,
Method: gr.Method,
MinutesTotal: gr.MinutesTotal,
Difficulty: gr.Difficulty,
Quick: IsQuick(gr.Method, gr.MinutesTotal),
RecipeID: gr.RecipeID,
PhotoURL: dinners.PhotoURL(gr.PhotoID),
SourceURL: gr.SourceURL,
SiteName: gr.SiteName,
}
}
// QuickJob is the KindQuick payload: the kind of Quick dinner wanted,
// skills.QuickCrockpot or skills.QuickUnder30, and the names already shown
// to the household, as skills.CleanExclude returns them.
type QuickJob struct {
Kind string `json:"kind"`
Exclude []string `json:"exclude"`
}
// StartQuick enqueues a KindQuick job for a page of Quick dinner ideas of
// kind, none repeating a name in exclude, and returns its id. A kind
// skills.CheckQuickKind refuses, with no default, or an exclude list
// skills.CleanExclude refuses, is an *InvalidError, and starts nothing.
func (s *Service) StartQuick(ctx context.Context, kind string, exclude []string) (Started, error) {
if err := skills.CheckQuickKind(kind); err != nil {
return Started{}, &InvalidError{Message: "kind: " + err.Error()}
}
names, err := skills.CleanExclude(exclude)
if err != nil {
return Started{}, &InvalidError{Message: "exclude: " + err.Error()}
}
id, err := s.quickJob.Enqueue(ctx, QuickJob{Kind: kind, Exclude: names})
if err != nil {
return Started{}, fmt.Errorf("ideas: enqueue: %w", err)
}
return Started{JobID: id}, nil
}
// QuickIdea is a grounded Quick dinner idea as the job's result shows it.
// Method, MinutesTotal and Difficulty are the grounded recipe's, never the
// model's estimates, and always make it of the kind asked for (see
// OfQuickKind).
type QuickIdea struct {
Name string `json:"name"`
// Method is stovetop, oven, crockpot, grill or other: crockpot for
// skills.QuickCrockpot.
Method string `json:"method"`
// MinutesTotal is prep to table, null when the recipe does not say:
// never null, and at most QuickMinutes, for skills.QuickUnder30.
MinutesTotal *int `json:"minutes_total"`
// Difficulty is easy, medium or hard.
Difficulty string `json:"difficulty"`
RecipeID string `json:"recipe_id"`
PhotoURL string `json:"photo_url"`
SourceURL string `json:"source_url"`
SiteName string `json:"site_name"`
}
// QuickResult is the KindQuick result, published as the job goes: its
// stage, the request, the ideas grounded so far in the model's order (as
// in Result), and how many ideas were dropped, by quick_ideas' rules
// (repeating a name already shown or an earlier idea), because no recipe
// grounds them, or because the grounded recipe is not of the kind.
type QuickResult struct {
Stage string `json:"stage"`
Kind string `json:"kind"`
Exclude []string `json:"exclude"`
Ideas []QuickIdea `json:"ideas"`
Dropped int `json:"dropped"`
}
func (r QuickResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
func (r QuickResult) shown() int { return len(r.Ideas) }
func (r QuickResult) cleared() QuickResult {
r.Ideas = []QuickIdea{}
return r
}
// OfQuickKind reports whether a recipe cooked by method in minutes (nil
// when unknown) is a Quick dinner of kind: for skills.QuickCrockpot,
// cooked in a crock pot (normalize_recipe writes a slow cooker as
// crockpot); for skills.QuickUnder30, known to be 30 minutes or less from
// prep to table. Any other kind is none.
func OfQuickKind(kind, method string, minutes *int) bool {
switch kind {
case skills.QuickCrockpot:
return method == "crockpot"
case skills.QuickUnder30:
return minutes != nil && *minutes <= QuickMinutes
}
return false
}
// runQuick runs a KindQuick job: from the household context, it asks
// quick_ideas for skills.QuickIdeasRequested ideas of the kind, more than
// a page holds, then grounds them in the model's order, at most the number
// still wanted at a time, until skills.QuickIdeasKept are kept or none are
// left. An idea no recipe grounds is dropped, and so is one whose grounded
// recipe is not of the kind (OfQuickKind): the model's estimate never
// keeps an idea. When fewer than skills.QuickIdeasKept survive, the page
// shows only those: fewer is honest, and it is never padded with an idea
// not of the kind. When every idea is dropped the job fails with
// ReasonNoIdeasFound. Any other grounding failure fails the job, as does
// quick_ideas failing, with no idea made up instead. The result is
// published at each stage and again as each idea is kept, and a failed job
// shows no ideas, as for KindFromIngredients (see finish).
func (s *Service) runQuick(ctx context.Context, p QuickJob, publish func(QuickResult) error) (QuickResult, error) {
res, err := s.fillQuick(ctx, p, publish)
return finish(ctx, s.logger, "quick ideas", res, err, publish)
}
// fillQuick does runQuick's work as fill does run's.
func (s *Service) fillQuick(ctx context.Context, p QuickJob, publish func(QuickResult) error) (QuickResult, error) {
res := QuickResult{Stage: StageSuggesting, Kind: p.Kind, Exclude: p.Exclude, Ideas: []QuickIdea{}}
if err := publish(res); err != nil {
return res, err
}
hctx, err := harness.BuildContext(ctx, s.st, time.Now(), ContextBudget)
if err != nil {
return res, fmt.Errorf("ideas: %w", err)
}
in := skills.QuickIdeasRequest{Context: hctx, Kind: p.Kind, Exclude: p.Exclude}
out, err := s.quick(ctx, skills.QuickIdeasScope(in), in)
if err != nil {
if ctx.Err() != nil {
return res, ctx.Err()
}
return res, &jobs.Failure{Code: ReasonAIFailed}
}
res.Stage = StageGrounding
res.Dropped = len(out.Dropped)
if err := publish(res); err != nil {
return res, err
}
reqs := make([]recipes.GroundRequest, len(out.Ideas))
for i, idea := range out.Ideas {
reqs[i] = recipes.GroundRequest{DishName: idea.Name, Query: idea.SearchQuery}
}
err = keepGrounded(ctx, s.grounder, reqs, skills.QuickIdeasKept, &res.Ideas, &res.Dropped, func(i int, gr recipes.Grounded) (QuickIdea, bool) {
return quickIdeaOf(out.Ideas[i], gr), OfQuickKind(p.Kind, gr.Method, gr.MinutesTotal)
}, func() error { return publish(res) })
if err != nil {
return res, err
}
if len(res.Ideas) == 0 {
return res, &jobs.Failure{Code: ReasonNoIdeasFound}
}
res.Stage = StageDone
return res, nil
}
// quickIdeaOf is idea as grounded by gr.
func quickIdeaOf(idea skills.QuickIdea, gr recipes.Grounded) QuickIdea {
return QuickIdea{
Name: idea.Name,
Method: gr.Method,
MinutesTotal: gr.MinutesTotal,
Difficulty: gr.Difficulty,
RecipeID: gr.RecipeID,
PhotoURL: dinners.PhotoURL(gr.PhotoID),
SourceURL: gr.SourceURL,
SiteName: gr.SiteName,
}
}
// Origins of a dinner made from an idea (the dinners.origin column).
const (
// OriginIngredients is an idea from Cook from what I have.
OriginIngredients = "ingredients"
// OriginQuick is an idea from Quick dinners.
OriginQuick = "quick"
)
// ErrIdeaNotFound is an idea the household cannot act on: an unknown job,
// a job that does not find ideas, a failed job (which shows no ideas), or
// an index past the job's ideas.
var ErrIdeaNotFound = errors.New("idea not found")
// ErrIdeasNotReady is an idea of a job still queued or running. Its ideas
// may still move: an idea that grounds later is put back in its place
// before those shown already, so an index names an idea only once the job
// has succeeded.
var ErrIdeasNotReady = errors.New("the ideas are still being found; act on one when the job has succeeded")
// Chosen is the idea the household acts on: its name, the recipe it was
// grounded in, and the origin of a dinner made from it.
type Chosen struct {
Name string
RecipeID string
Origin string
}
// Idea returns idea i, counted from 0, of the succeeded job jobID, as its
// stored result shows it: a dinner made from it uses the recipe it was
// grounded in, never grounding it again. A job still queued or running is
// ErrIdeasNotReady; any idea not shown by a succeeded KindFromIngredients
// or KindQuick job is ErrIdeaNotFound.
func (s *Service) Idea(ctx context.Context, jobID string, i int) (Chosen, error) {
job, err := s.runner.Get(ctx, jobID)
if errors.Is(err, jobs.ErrNotFound) {
return Chosen{}, ErrIdeaNotFound
}
if err != nil {
return Chosen{}, fmt.Errorf("ideas: read job: %w", err)
}
if job.Kind != KindFromIngredients && job.Kind != KindQuick {
return Chosen{}, ErrIdeaNotFound
}
switch job.Status {
case jobs.StatusQueued, jobs.StatusRunning:
return Chosen{}, ErrIdeasNotReady
case jobs.StatusSucceeded:
default:
return Chosen{}, ErrIdeaNotFound
}
if job.Kind == KindQuick {
var res QuickResult
if err := json.Unmarshal(job.Result, &res); err != nil {
return Chosen{}, fmt.Errorf("ideas: decode %s result: %w", job.Kind, err)
}
if i < 0 || i >= len(res.Ideas) {
return Chosen{}, ErrIdeaNotFound
}
return Chosen{Name: res.Ideas[i].Name, RecipeID: res.Ideas[i].RecipeID, Origin: OriginQuick}, nil
}
var res Result
if err := json.Unmarshal(job.Result, &res); err != nil {
return Chosen{}, fmt.Errorf("ideas: decode %s result: %w", job.Kind, err)
}
if i < 0 || i >= len(res.Ideas) {
return Chosen{}, ErrIdeaNotFound
}
return Chosen{Name: res.Ideas[i].Name, RecipeID: res.Ideas[i].RecipeID, Origin: OriginIngredients}, nil
}