// Package ideas finds dinner ideas for Cook from what I have: from a list // of ingredients on hand, ideas_from_ingredients proposes dinners, each is // grounded with the recipe grounding pipeline (dropping those no recipe // grounds), 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" "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" ) // KindFromIngredients is the job that finds ideas from ingredients. const KindFromIngredients = "ideas_from_ingredients" // 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 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 // ideas_from_ingredients' rules, because no recipe grounds it, or, for // a Quick only request, because its grounded recipe is not a Quick // dinner. ReasonNoIdeasFound = "no_ideas_found" ) // Reasons are every failure reason of the job. var Reasons = []string{ReasonAIFailed, ReasonSearchNotConfigured, ReasonSearchFailed, ReasonNoIdeasFound} // InvalidError is a request the household must correct, such as an empty // ingredient list. 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) // Service starts and runs idea jobs. type Service struct { grounder *recipes.Grounder generate Generator logger *slog.Logger job *jobs.Kind[FromIngredientsJob] } // NewService returns a service that finds ideas with generate, grounds // them with grounder and registers the KindFromIngredients job on runner, // which must not have started yet. The job is started only by // StartFromIngredients, which checks the ingredient list first. func NewService(grounder *recipes.Grounder, generate Generator, runner *jobs.Runner, logger *slog.Logger) (*Service, error) { switch { case grounder == nil: return nil, errors.New("ideas: nil grounder") case generate == nil: return nil, errors.New("ideas: nil generator") case runner == nil: return nil, errors.New("ideas: nil job runner") case logger == nil: return nil, errors.New("ideas: nil logger") } s := &Service{grounder: grounder, generate: generate, logger: logger} kind, err := jobs.Register(runner, KindFromIngredients, s.run, Reasons...) if err != nil { return nil, err } if err := kind.ServiceOnly(); err != nil { return nil, err } if err := kind.FailedResult(withoutIdeas); err != nil { return nil, err } s.job = kind return s, 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, 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 (s *Service) run(ctx context.Context, p FromIngredientsJob, publish func(Result) error) (Result, error) { res, err := s.fill(ctx, p, publish) if err != nil { if ctx.Err() != nil { return Result{}, 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, "ideas from ingredients failed", "reason", failure.Code) } if len(res.Ideas) > 0 { res.Ideas = []Idea{} if perr := publish(res); perr != nil { return Result{}, fmt.Errorf("ideas: clear failed job's ideas: %w", perr) } } return Result{}, err } return res, nil } // 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 } // order[k] is the index in out.Ideas of res.Ideas[k], so each idea is // put in its place in the model's order as it grounds. var order []int for next := 0; len(res.Ideas) < skills.IdeasKept && next < len(out.Ideas); { first := next round := out.Ideas[first:min(first+skills.IdeasKept-len(res.Ideas), len(out.Ideas))] next += len(round) err := s.ground(ctx, round, func(i int, gr *recipes.Grounded) error { if gr == nil { res.Dropped++ return nil } idea := ideaOf(round[i], *gr) if p.QuickOnly && !idea.Quick { res.Dropped++ return nil } at := len(order) for at > 0 && order[at-1] > first+i { at-- } order = slices.Insert(order, at, first+i) res.Ideas = slices.Insert(res.Ideas, at, idea) 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 } // ground grounds ideas by their search queries and calls kept, one call at // a time, as each idea grounds, with its index in ideas 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 (s *Service) ground(ctx context.Context, ideas []skills.Idea, kept func(i int, gr *recipes.Grounded) error) error { groundCtx, cancel := context.WithCancel(ctx) defer cancel() reqs := make([]recipes.GroundRequest, len(ideas)) for i, idea := range ideas { reqs[i] = recipes.GroundRequest{DishName: idea.Name, Query: idea.SearchQuery} } var failed error s.grounder.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", ideas[i].Name, 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(stored json.RawMessage) (json.RawMessage, error) { var res Result if err := json.Unmarshal(stored, &res); err != nil { return nil, fmt.Errorf("ideas: decode failed job's result: %w", err) } res.Ideas = []Idea{} return res.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, } }