Files
MealQuest/internal/ideas/ideas.go
T

298 lines
10 KiB
Go
Raw Normal View History

2026-10-08 19:01:03 -04:00
// 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. Ideas are not stored as dinners: the household
// acts on one later.
package ideas
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"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"
)
// Reasons are every failure reason of the job.
var Reasons = []string{ReasonAIFailed, ReasonSearchNotConfigured, ReasonSearchFailed}
// 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
}
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,
// 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;
// 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. The job may end done with no ideas, when
// every one was dropped.
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 {
var failure *jobs.Failure
if ctx.Err() == nil && 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)
}
return Result{}, err
}
return res, nil
}
// fill does run's work, returning a *jobs.Failure for a failure the
// household may see.
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 Result{}, 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 Result{}, ctx.Err()
}
return Result{}, &jobs.Failure{Code: ReasonAIFailed}
}
res.Stage = StageGrounding
res.Dropped = len(out.Dropped)
if err := publish(res); err != nil {
return Result{}, err
}
left := out.Ideas
for len(res.Ideas) < skills.IdeasKept && len(left) > 0 {
round := left[:min(skills.IdeasKept-len(res.Ideas), len(left))]
left = left[len(round):]
results, err := s.ground(ctx, round)
if err != nil {
return Result{}, err
}
for i, r := range results {
if r == nil {
res.Dropped++
continue
}
idea := ideaOf(round[i], *r)
if p.QuickOnly && !idea.Quick {
res.Dropped++
continue
}
res.Ideas = append(res.Ideas, idea)
if err := publish(res); err != nil {
return Result{}, err
}
}
}
res.Stage = StageDone
return res, nil
}
// ground grounds ideas by their search queries and returns one result per
// idea, in order: its grounding, or nil when no recipe grounds it. Any
// other failure fails the job.
func (s *Service) ground(ctx context.Context, ideas []skills.Idea) ([]*recipes.Grounded, error) {
reqs := make([]recipes.GroundRequest, len(ideas))
for i, idea := range ideas {
reqs[i] = recipes.GroundRequest{DishName: idea.Name, Query: idea.SearchQuery}
}
results := s.grounder.GroundMany(ctx, reqs)
if err := ctx.Err(); err != nil {
return nil, err
}
out := make([]*recipes.Grounded, len(results))
for i, r := range results {
var ungroundable *recipes.ErrUngroundable
switch {
case r.Err == nil:
out[i] = &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("ideas: ground %q: %w", ideas[i].Name, r.Err)
}
}
return out, nil
}
// 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,
}
}