Implement the ideas_from_ingredients v1 skill on the AI harness: it asks
for 8 ideas {name, uses[], missing[], est_minutes, method, difficulty,
search_query}, keeps only "uses" entries whose harness.Normalize form is
one of the household's listed ingredients (written as the household wrote
them, in its order), drops ideas using none or repeating another, and
computes the summary line from the corrected lists.
Add internal/ideas: an ideas_from_ingredients job that grounds the ideas
with Grounder.GroundMany in the model's order until 5 are kept, drops the
ungroundable ones and, for quick_only, those whose grounded recipe is not
a Quick dinner (crockpot or minutes_total <= 30), publishing the result
as each idea is kept. POST /api/v1/ideas/from-ingredients answers 202
{jobId}.
Maestro task c3c5f98e-7f7d-4c6b-a1ab-d45bb8e497f9.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
298 lines
10 KiB
Go
298 lines
10 KiB
Go
// 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,
|
|
}
|
|
}
|