GET /jobs/{id} served a succeeded idea job's stored result as it was, so
a result stored before sources were made safe still showed a
token-bearing source_url and a site_name taken from the link's host
(BASE-01-R1). jobs.Kind.FailedResult becomes ShownResult, applied to
every stored result with the job's status: ideas_from_ingredients and
quick_ideas results show no ideas when failed and, otherwise, each
idea's source through dinners.SourceOf, the one rule, so no job old or
new serves an unsafe source. Other kinds' results are untouched.
dinners.SourceOf now parses the link before reading the site name, so a
link that does not parse is an error with or without a stored name, and
an idea job grounded in one fails instead of succeeding (BASE-01-R2).
Task: c15ed4b5-25ae-43c6-8d36-f4a6dd51e5af
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
748 lines
28 KiB
Go
748 lines
28 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"
|
|
"database/sql"
|
|
"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, shownJobResult[Result]); err != nil {
|
|
return nil, err
|
|
}
|
|
if s.quickJob, err = register(runner, KindQuick, s.runQuick, shownJobResult[QuickResult]); err != nil {
|
|
return nil, err
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// register registers the service-only job name, run by run, whose jobs
|
|
// show their result through show.
|
|
func register[P any, R jobs.Public](runner *jobs.Runner, name string, run jobs.Handler[P, R], show func(jobs.Status, 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.ShownResult(show); 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 and SiteName are the grounded recipe's source as
|
|
// dinners.SourceOf gives it: each null when it could leak a secret.
|
|
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
|
|
// withSafeSources is the result with each idea's source as
|
|
// shownSource gives it.
|
|
withSafeSources() (R, error)
|
|
}
|
|
|
|
func (r Result) shown() int { return len(r.Ideas) }
|
|
|
|
func (r Result) cleared() Result {
|
|
r.Ideas = []Idea{}
|
|
return r
|
|
}
|
|
|
|
func (r Result) withSafeSources() (Result, error) {
|
|
ideas := make([]Idea, len(r.Ideas))
|
|
for i, idea := range r.Ideas {
|
|
src, err := shownSource(idea.SourceURL, idea.SiteName)
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("ideas: idea %d: %w", i, err)
|
|
}
|
|
idea.SourceURL, idea.SiteName = src.URL, src.SiteName
|
|
ideas[i] = idea
|
|
}
|
|
r.Ideas = ideas
|
|
return r, nil
|
|
}
|
|
|
|
// 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:
|
|
// shownJobResult, 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, error) {
|
|
idea, err := ideaOf(out.Ideas[i], gr)
|
|
return idea, !p.QuickOnly || idea.Quick, err
|
|
}, 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 keep 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, error), 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, err := keep(first+i, *gr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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
|
|
}
|
|
|
|
// shownJobResult is a job's stored result as the job shows it. A failed
|
|
// job's shows no ideas, however the job failed, so a failed job never
|
|
// shows ideas kept before the failure. Any other job's shows each idea's
|
|
// source as shownSource gives it, so a result stored before sources were
|
|
// made safe never shows a link or name that could leak a secret.
|
|
func shownJobResult[R shownResult[R]](status jobs.Status, stored json.RawMessage) (json.RawMessage, error) {
|
|
var res R
|
|
if err := json.Unmarshal(stored, &res); err != nil {
|
|
return nil, fmt.Errorf("ideas: decode %s job's result: %w", status, err)
|
|
}
|
|
if status == jobs.StatusFailed {
|
|
return res.cleared().PublicJSON()
|
|
}
|
|
safe, err := res.withSafeSources()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return safe.PublicJSON()
|
|
}
|
|
|
|
// ideaOf is idea as grounded by gr.
|
|
func ideaOf(idea skills.Idea, gr recipes.Grounded) (Idea, error) {
|
|
src, err := sourceOf(gr)
|
|
if err != nil {
|
|
return Idea{}, err
|
|
}
|
|
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: src.URL,
|
|
SiteName: src.SiteName,
|
|
}, nil
|
|
}
|
|
|
|
// sourceOf is gr's recipe source as a client may see it (see
|
|
// dinners.SourceOf): a grounded recipe's site name is stored only when
|
|
// not empty.
|
|
func sourceOf(gr recipes.Grounded) (dinners.Source, error) {
|
|
src, err := dinners.SourceOf(sql.NullString{String: gr.SiteName, Valid: gr.SiteName != ""}, gr.SourceURL)
|
|
if err != nil {
|
|
return dinners.Source{}, fmt.Errorf("ideas: recipe %s: %w", gr.RecipeID, err)
|
|
}
|
|
return src, nil
|
|
}
|
|
|
|
// shownSource is the source of an idea as stored in a job's result, with
|
|
// link sourceURL and site name siteName, as a client may see it (see
|
|
// dinners.SourceOf). A result stored now already holds it, and one stored
|
|
// before sources were made safe holds the grounded recipe's own, with ""
|
|
// for no site name, as sourceOf reads it; a null link, which only a result
|
|
// stored now holds, is checked as "".
|
|
func shownSource(sourceURL, siteName *string) (dinners.Source, error) {
|
|
var link string
|
|
if sourceURL != nil {
|
|
link = *sourceURL
|
|
}
|
|
var site sql.NullString
|
|
if siteName != nil {
|
|
site = sql.NullString{String: *siteName, Valid: *siteName != ""}
|
|
}
|
|
return dinners.SourceOf(site, link)
|
|
}
|
|
|
|
// 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 and SiteName are as in Idea.
|
|
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
|
|
}
|
|
|
|
func (r QuickResult) withSafeSources() (QuickResult, error) {
|
|
ideas := make([]QuickIdea, len(r.Ideas))
|
|
for i, idea := range r.Ideas {
|
|
src, err := shownSource(idea.SourceURL, idea.SiteName)
|
|
if err != nil {
|
|
return QuickResult{}, fmt.Errorf("ideas: idea %d: %w", i, err)
|
|
}
|
|
idea.SourceURL, idea.SiteName = src.URL, src.SiteName
|
|
ideas[i] = idea
|
|
}
|
|
r.Ideas = ideas
|
|
return r, nil
|
|
}
|
|
|
|
// 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, error) {
|
|
idea, err := quickIdeaOf(out.Ideas[i], gr)
|
|
return idea, OfQuickKind(p.Kind, gr.Method, gr.MinutesTotal), err
|
|
}, 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, error) {
|
|
src, err := sourceOf(gr)
|
|
if err != nil {
|
|
return QuickIdea{}, err
|
|
}
|
|
return QuickIdea{
|
|
Name: idea.Name,
|
|
Method: gr.Method,
|
|
MinutesTotal: gr.MinutesTotal,
|
|
Difficulty: gr.Difficulty,
|
|
RecipeID: gr.RecipeID,
|
|
PhotoURL: dinners.PhotoURL(gr.PhotoID),
|
|
SourceURL: src.URL,
|
|
SiteName: src.SiteName,
|
|
}, nil
|
|
}
|
|
|
|
// 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
|
|
}
|