Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #146.
This commit is contained in:
+14
-2
@@ -139,7 +139,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
ideasSvc, err := ideas.NewService(grounder, ideaFinder(ai, guard), runner, logger)
|
||||
ideasSvc, err := ideas.NewService(db, grounder, ideaFinder(ai, guard), quickIdeaFinder(ai, guard), runner, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -287,7 +287,7 @@ func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds
|
||||
// starts (dinners.NewService registers dinners.KindGroundDinner and
|
||||
// suggest.NewService suggest.KindSuggestionBatch, plan.NewService
|
||||
// plan.KindDraftWeek, shopping.NewService shopping.KindTitleSupplyRun,
|
||||
// ideas.NewService ideas.KindFromIngredients).
|
||||
// ideas.NewService ideas.KindFromIngredients and ideas.KindQuick).
|
||||
func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
|
||||
builtins, err := jobs.RegisterBuiltins(runner)
|
||||
if err != nil {
|
||||
@@ -356,6 +356,18 @@ func ideaFinder(ai *aisettings.Service, guard *harness.Guard) ideas.Generator {
|
||||
}
|
||||
}
|
||||
|
||||
// quickIdeaFinder runs quick_ideas with the AI settings current at each
|
||||
// run, sharing guard.
|
||||
func quickIdeaFinder(ai *aisettings.Service, guard *harness.Guard) ideas.QuickGenerator {
|
||||
return func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error) {
|
||||
cfg, err := ai.HarnessConfig(ctx, guard)
|
||||
if err != nil {
|
||||
return skills.QuickIdeaList{}, err
|
||||
}
|
||||
return skills.QuickIdeas(ctx, cfg, scope, in)
|
||||
}
|
||||
}
|
||||
|
||||
// logListening logs the startup line. Only the public URL's origin is logged
|
||||
// so no credential-bearing URL component can reach the log.
|
||||
func logListening(logger *slog.Logger, cfg config.Config) {
|
||||
|
||||
@@ -338,6 +338,31 @@ automatic** (D-021).
|
||||
read, however it failed (including `interrupted by restart` and an
|
||||
`internal_error` whose clearing write failed). Before it fails it also
|
||||
publishes its result again with no ideas, to tidy what is stored.
|
||||
- Quick dinners (`ideas.Service`, Feature 18). `POST /api/v1/ideas/quick
|
||||
{kind, exclude[]}` (both required; `exclude` is `[]` on a first page)
|
||||
checks `kind` with `skills.CheckQuickKind` — `crockpot` or `under30`,
|
||||
no default — and `exclude` with `skills.CleanExclude` — at most 60
|
||||
names, each non-blank, at most 120 characters, with a non-empty
|
||||
`harness.Normalize` form no earlier name shares — answering 400
|
||||
`invalid_request` otherwise, then enqueues a `quick_ideas` job
|
||||
(service-only) and answers 202 `{jobId}` without waiting. The job reads
|
||||
the household context (`harness.BuildContext`, budget 16000), runs
|
||||
`quick_ideas` (scope: `skills.QuickIdeasScope`, the kind and the
|
||||
normalized excluded names in order), then grounds its ideas exactly as
|
||||
the ingredients job does (the same `Grounder.GroundEach` rounds, model
|
||||
order and failure codes) until 6 are kept or none are left. The kind is
|
||||
enforced on the grounded recipe, never the model's estimate
|
||||
(`ideas.OfQuickKind`): `crockpot` keeps method `crockpot`
|
||||
(`normalize_recipe` writes a slow cooker as `crockpot`), `under30`
|
||||
keeps a known `minutes_total` ≤ 30; every other idea is dropped. The
|
||||
model is asked for 12, more than a page, so dropped ideas can be
|
||||
replaced; when fewer than 6 survive the page shows only those, never
|
||||
padded with an idea not of the kind. The job publishes `{stage, kind,
|
||||
exclude, ideas: [{name, method, minutes_total, difficulty, recipe_id,
|
||||
photo_url, source_url, site_name}], dropped}` at each stage and as each
|
||||
idea is kept, fails with the same reasons, and a failed job shows no
|
||||
ideas through the same `FailedResult` view. "↻ Show me more" posts
|
||||
again with every name the tab has shown so far in `exclude`.
|
||||
`GET /api/v1/suggestions/deck` returns `{suggestions: [{id, dinner_id,
|
||||
name, photo_url, difficulty, method, minutes_total, cuisine, reason,
|
||||
cited_regular_ids, status, dinner_status, decided_at, created_at}],
|
||||
@@ -516,6 +541,16 @@ automatic** (D-021).
|
||||
all 5 · missing: 1 lime" when every ingredient is used, "Uses 3 of 5 ·
|
||||
missing: …" otherwise, with "nothing missing" in place of the list when
|
||||
nothing is.
|
||||
- **Quick ideas** (`skills.QuickIdeas`, skill `quick_ideas` v1): from the
|
||||
household context, a kind (`crockpot` or `under30`, which picks the
|
||||
prompt's rule) and the names already shown (the "Already shown" context
|
||||
section), asks for 12 ideas `{name, est_minutes, method, difficulty,
|
||||
search_query}` at the household temperature. Malformed replies (a
|
||||
missing field, a bad enum, an empty name, more than 12 ideas) get the
|
||||
one repair turn. An idea repeating an excluded name or an earlier idea
|
||||
(`harness.Similar`, on the normalized names) is dropped into `Dropped`,
|
||||
so an excluded name is never returned. Whether an idea is of the kind
|
||||
is left to the grounded recipe (see Quick dinners above).
|
||||
- **Supply run titles** (`skills.TitleArchive`, skill `archive_title`
|
||||
v1): from the names of the week's planned dinners in day order, writes
|
||||
`{title}`, the Supply run's post title in the Supply archive, at the
|
||||
|
||||
@@ -7,3 +7,4 @@ suggest_side_quests v1 bd54d7bf9aea0980059a346e5f40ff4c866b80ef9bb9f74e7cbd413be
|
||||
draft_week v1 e637419690d9532bab53cb8ca01b73a079ecfe8e3022522c05188d18e9740f32
|
||||
archive_title v1 b4f30562a8614dac09a69d512dc361883b917ee4228a0ce3b4583c7fd3f6101a
|
||||
ideas_from_ingredients v1 49df3e134f07fe2c0dbbda7bd3d7d1364731e5851bf6f6a526ec04b703480924
|
||||
quick_ideas v1 c78d1b9917a150bcf8df1351e0b4c2e76eaa10097819782c2c74695b0ae3cf05
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{{/* quick_ideas drafts Quick dinner ideas of one kind, crock pot or 30 minutes or less, picked from the household's tastes. */}}
|
||||
{{define "instructions" -}}
|
||||
You suggest Quick dinner ideas the household would like, picked from its tastes: the dinners it already makes, its Regulars in the household context, and the dinners on its Quest log.
|
||||
{{- if .Crockpot}}
|
||||
|
||||
Every idea must be a crock pot dinner: cooked mainly in a crock pot (slow cooker), with method "crockpot".
|
||||
{{- else}}
|
||||
|
||||
Every idea must take 30 minutes or less from starting prep to the table.
|
||||
{{- end}}
|
||||
|
||||
Never suggest a dinner listed under "Already shown" in the household context, nor one of those under another name. Vary the ideas: no two may be the same dish.
|
||||
|
||||
Give for each idea:
|
||||
- name: the dinner's usual name, as a recipe site would title it.
|
||||
- est_minutes: your estimate of the whole minutes from starting prep to the table.
|
||||
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
|
||||
- difficulty: "easy", "medium" or "hard" for a home cook.
|
||||
- search_query: a web search query that finds a recipe for this dinner.
|
||||
|
||||
One valid idea (an example only, not about this household):
|
||||
{{- if .Crockpot}}
|
||||
{"name": "Slow Cooker Chicken Tortilla Soup", "est_minutes": 375, "method": "crockpot", "difficulty": "easy", "search_query": "slow cooker chicken tortilla soup recipe"}
|
||||
{{- else}}
|
||||
{"name": "Garlic Butter Shrimp Pasta", "est_minutes": 25, "method": "stovetop", "difficulty": "easy", "search_query": "garlic butter shrimp pasta recipe"}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{define "user" -}}
|
||||
{{- if .Crockpot -}}
|
||||
Suggest {{.Count}} crock pot dinner ideas.
|
||||
{{- else -}}
|
||||
Suggest {{.Count}} dinner ideas ready in 30 minutes or less.
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,297 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
)
|
||||
|
||||
// Kinds of Quick dinner quick_ideas suggests: the two tabs of the Quick
|
||||
// dinners screen.
|
||||
const (
|
||||
// QuickCrockpot is a dinner cooked in a crock pot (slow cooker).
|
||||
QuickCrockpot = "crockpot"
|
||||
// QuickUnder30 is a dinner 30 minutes or less from prep to table.
|
||||
QuickUnder30 = "under30"
|
||||
)
|
||||
|
||||
// QuickKinds are every kind of Quick dinner, in the screen's tab order.
|
||||
var QuickKinds = []string{QuickCrockpot, QuickUnder30}
|
||||
|
||||
const (
|
||||
// QuickIdeasRequested is how many ideas quick_ideas asks the model
|
||||
// for: more than a page holds, since validation, the exclude list,
|
||||
// grounding and the kind's check drop some.
|
||||
QuickIdeasRequested = 12
|
||||
// QuickIdeasKept is the most ideas one page of Quick dinners shows.
|
||||
QuickIdeasKept = 6
|
||||
// MaxExclude is the most names already shown a request may exclude.
|
||||
MaxExclude = 60
|
||||
// ExcludeLimit is the most characters one excluded name may have.
|
||||
ExcludeLimit = 120
|
||||
)
|
||||
|
||||
// QuickIdeasRequest is what quick_ideas reads.
|
||||
type QuickIdeasRequest struct {
|
||||
// Context is the household context (see harness.BuildContext): the
|
||||
// ideas are picked from the household's tastes.
|
||||
Context harness.HouseholdContext
|
||||
// Kind is QuickCrockpot or QuickUnder30. It is a hint to the model:
|
||||
// the caller enforces it after grounding, from the grounded recipe.
|
||||
Kind string
|
||||
// Exclude are the names already shown to the household, as
|
||||
// CleanExclude returns them. No idea repeating one is returned.
|
||||
Exclude []string
|
||||
}
|
||||
|
||||
// QuickIdea is one Quick dinner idea. Method, EstMinutes and Difficulty
|
||||
// are the model's estimates until the idea is grounded in a recipe.
|
||||
type QuickIdea struct {
|
||||
Name string `json:"name"`
|
||||
EstMinutes int `json:"est_minutes"`
|
||||
// Method is one of stovetop, oven, crockpot, grill and other.
|
||||
Method string `json:"method"`
|
||||
// Difficulty is easy, medium or hard.
|
||||
Difficulty string `json:"difficulty"`
|
||||
// SearchQuery is a web search query that finds a recipe for the dinner.
|
||||
SearchQuery string `json:"search_query"`
|
||||
}
|
||||
|
||||
// QuickIdeaList is quick_ideas' output.
|
||||
type QuickIdeaList struct {
|
||||
// Ideas are the ideas that passed every rule, in the model's order.
|
||||
Ideas []QuickIdea `json:"ideas"`
|
||||
// Dropped are the ideas the model gave that broke a rule, in the
|
||||
// model's order. It is never part of the model's reply.
|
||||
Dropped []DroppedIdea `json:"-"`
|
||||
}
|
||||
|
||||
// QuickIdeas runs quick_ideas v1 on in and returns ideas that repeat no
|
||||
// excluded name nor each other (harness.Similar, on the normalized
|
||||
// names); the rest are in Dropped. Whether an idea is of in.Kind is not
|
||||
// decided here: the model's estimates never decide it, the grounded
|
||||
// recipe does. scope names the request (see harness.Run; QuickIdeasScope).
|
||||
// A kind CheckQuickKind refuses, or an exclude list CleanExclude refuses,
|
||||
// fails before any LLM call.
|
||||
func QuickIdeas(ctx context.Context, cfg harness.Config, scope string, in QuickIdeasRequest) (QuickIdeaList, error) {
|
||||
if err := CheckQuickKind(in.Kind); err != nil {
|
||||
return QuickIdeaList{}, fmt.Errorf("skills: quick_ideas: %w", err)
|
||||
}
|
||||
exclude, err := CleanExclude(in.Exclude)
|
||||
if err != nil {
|
||||
return QuickIdeaList{}, fmt.Errorf("skills: quick_ideas: %w", err)
|
||||
}
|
||||
in.Exclude = exclude
|
||||
return harness.Run(ctx, cfg, quickIdeas{in: in}, scope, in)
|
||||
}
|
||||
|
||||
// QuickIdeasScope is the harness scope of a request: its kind and its
|
||||
// normalized excluded names, in order, so two requests share a run only
|
||||
// when they ask for the same page.
|
||||
func QuickIdeasScope(in QuickIdeasRequest) string {
|
||||
parts := make([]string, len(in.Exclude))
|
||||
for i, name := range in.Exclude {
|
||||
parts[i] = harness.Normalize(name)
|
||||
}
|
||||
return "kind=" + in.Kind + ";" + strings.Join(parts, "|")
|
||||
}
|
||||
|
||||
// CheckQuickKind returns why kind is not one of QuickKinds, or nil. There
|
||||
// is no default kind.
|
||||
func CheckQuickKind(kind string) error {
|
||||
if !slices.Contains(QuickKinds, kind) {
|
||||
return fmt.Errorf("kind %q is not one of %s", kind, strings.Join(QuickKinds, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanExclude returns the excluded names with each on one line, never
|
||||
// nil, or why they cannot be used: there are more than MaxExclude, or a
|
||||
// name is blank, longer than ExcludeLimit characters, has nothing left
|
||||
// once normalized (harness.Normalize), or normalizes the same as an
|
||||
// earlier one. An empty list, the first page's, is valid.
|
||||
func CleanExclude(list []string) ([]string, error) {
|
||||
if len(list) > MaxExclude {
|
||||
return nil, fmt.Errorf("%d names excluded; exclude at most %d", len(list), MaxExclude)
|
||||
}
|
||||
out := make([]string, len(list))
|
||||
seen := map[string]string{}
|
||||
for i, name := range list {
|
||||
name = oneLine(name)
|
||||
switch {
|
||||
case name == "":
|
||||
return nil, fmt.Errorf("excluded name %d is blank", i+1)
|
||||
case utf8.RuneCountInString(name) > ExcludeLimit:
|
||||
return nil, fmt.Errorf("excluded name %d is over %d characters", i+1, ExcludeLimit)
|
||||
}
|
||||
n := harness.Normalize(name)
|
||||
if n == "" {
|
||||
return nil, fmt.Errorf("excluded name %d, %q, names no dinner", i+1, name)
|
||||
}
|
||||
if first, ok := seen[n]; ok {
|
||||
return nil, fmt.Errorf("excluded name %d, %q, repeats %q", i+1, name, first)
|
||||
}
|
||||
seen[n] = name
|
||||
out[i] = name
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// quickIdeas is the skill. It holds its input because post-processing
|
||||
// compares the output with it.
|
||||
type quickIdeas struct {
|
||||
in QuickIdeasRequest
|
||||
}
|
||||
|
||||
var _ harness.Skill[QuickIdeasRequest, QuickIdeaList] = quickIdeas{}
|
||||
|
||||
func (quickIdeas) Name() string { return "quick_ideas" }
|
||||
|
||||
func (quickIdeas) Version() int { return 1 }
|
||||
|
||||
func (quickIdeas) Timeout() time.Duration { return harness.DefaultTimeout }
|
||||
|
||||
// quickIdeasPrompt is the template data of quick_ideas.v1.tmpl.
|
||||
type quickIdeasPrompt struct {
|
||||
// Count is how many ideas to ask for.
|
||||
Count int
|
||||
// Crockpot is set for QuickCrockpot, unset for QuickUnder30.
|
||||
Crockpot bool
|
||||
}
|
||||
|
||||
// PromptInput puts the household context, then the names already shown,
|
||||
// in the household context block.
|
||||
func (quickIdeas) PromptInput(in QuickIdeasRequest) (harness.PromptInput, error) {
|
||||
if err := CheckQuickKind(in.Kind); err != nil {
|
||||
return harness.PromptInput{}, err
|
||||
}
|
||||
sections := append(in.Context.Sections(), harness.ContextSection{Title: "Already shown", Lines: in.Exclude})
|
||||
return harness.PromptInput{
|
||||
Data: quickIdeasPrompt{Count: QuickIdeasRequested, Crockpot: in.Kind == QuickCrockpot},
|
||||
Context: sections,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (quickIdeas) Schema() llm.JSONSchema {
|
||||
return llm.JSONSchema{Name: "quick_ideas", Schema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ideas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"est_minutes": {"type": "integer"},
|
||||
"method": {"type": "string", "enum": ["stovetop", "oven", "crockpot", "grill", "other"]},
|
||||
"difficulty": {"type": "string", "enum": ["easy", "medium", "hard"]},
|
||||
"search_query": {"type": "string"}
|
||||
},
|
||||
"required": ["name", "est_minutes", "method", "difficulty", "search_query"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["ideas"],
|
||||
"additionalProperties": false
|
||||
}`)}
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes an idea, requiring each of its fields as the
|
||||
// schema does: encoding/json would otherwise read a missing or null one as
|
||||
// empty.
|
||||
func (i *QuickIdea) UnmarshalJSON(data []byte) error {
|
||||
if err := requireFields(data, nil, "name", "est_minutes", "method", "difficulty", "search_query"); err != nil {
|
||||
return fmt.Errorf("idea %s", err)
|
||||
}
|
||||
type plain QuickIdea
|
||||
return json.Unmarshal(data, (*plain)(i))
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes the output, requiring its one field as the schema
|
||||
// does.
|
||||
func (out *QuickIdeaList) UnmarshalJSON(data []byte) error {
|
||||
if err := requireFields(data, nil, "ideas"); err != nil {
|
||||
return fmt.Errorf("output %s", err)
|
||||
}
|
||||
type plain QuickIdeaList
|
||||
return json.Unmarshal(data, (*plain)(out))
|
||||
}
|
||||
|
||||
// Validate finds what makes the reply unusable as a whole: no ideas, too
|
||||
// many, or one that is malformed. A well-formed idea that breaks a rule is
|
||||
// dropped by PostProcess instead.
|
||||
func (quickIdeas) Validate(out QuickIdeaList) []string {
|
||||
var problems []string
|
||||
switch n := len(out.Ideas); {
|
||||
case n == 0 && len(out.Dropped) == 0:
|
||||
problems = append(problems, fmt.Sprintf("ideas is empty; give %d ideas", QuickIdeasRequested))
|
||||
case n > QuickIdeasRequested:
|
||||
problems = append(problems, fmt.Sprintf("ideas has %d entries; give at most %d ideas", n, QuickIdeasRequested))
|
||||
}
|
||||
for i, idea := range out.Ideas {
|
||||
for _, f := range []struct{ field, value string }{{"name", idea.Name}, {"search_query", idea.SearchQuery}} {
|
||||
if strings.TrimSpace(f.value) == "" {
|
||||
problems = append(problems, fmt.Sprintf("idea %d has an empty %s; give one", i+1, f.field))
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(idea.Name) != "" && harness.Normalize(idea.Name) == "" {
|
||||
problems = append(problems, fmt.Sprintf("idea %d name %q has only filler words; name the dish", i+1, idea.Name))
|
||||
}
|
||||
if !slices.Contains(methods, idea.Method) {
|
||||
problems = append(problems, fmt.Sprintf("idea %d method %q is not one of %s", i+1, idea.Method, strings.Join(methods, ", ")))
|
||||
}
|
||||
if idea.EstMinutes <= 0 {
|
||||
problems = append(problems, fmt.Sprintf("idea %d est_minutes %d is not positive; give the minutes from prep to table", i+1, idea.EstMinutes))
|
||||
}
|
||||
if !slices.Contains(difficulties, idea.Difficulty) {
|
||||
problems = append(problems, fmt.Sprintf("idea %d difficulty %q is not one of %s", i+1, idea.Difficulty, strings.Join(difficulties, ", ")))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// PostProcess puts every idea's text on one line and moves each that
|
||||
// repeats an excluded name or an earlier idea (harness.Similar, which
|
||||
// compares normalized names) to Dropped. The model's method and minutes
|
||||
// are kept as estimates and never decide the kind.
|
||||
func (s quickIdeas) PostProcess(out QuickIdeaList) (QuickIdeaList, error) {
|
||||
result := QuickIdeaList{Ideas: []QuickIdea{}}
|
||||
for _, idea := range out.Ideas {
|
||||
idea = QuickIdea{
|
||||
Name: oneLine(idea.Name),
|
||||
EstMinutes: idea.EstMinutes,
|
||||
Method: idea.Method,
|
||||
Difficulty: idea.Difficulty,
|
||||
SearchQuery: oneLine(idea.SearchQuery),
|
||||
}
|
||||
if why := s.drop(idea, result.Ideas); why != "" {
|
||||
result.Dropped = append(result.Dropped, DroppedIdea{Name: idea.Name, Why: why})
|
||||
continue
|
||||
}
|
||||
result.Ideas = append(result.Ideas, idea)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// drop returns why idea must be dropped, or "" when it may be kept.
|
||||
func (s quickIdeas) drop(idea QuickIdea, kept []QuickIdea) string {
|
||||
for _, name := range s.in.Exclude {
|
||||
if harness.Similar(idea.Name, name) {
|
||||
return fmt.Sprintf("repeats %q, already shown", name)
|
||||
}
|
||||
}
|
||||
for _, k := range kept {
|
||||
if harness.Similar(idea.Name, k.Name) {
|
||||
return fmt.Sprintf("repeats the idea %q", k.Name)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
)
|
||||
|
||||
// quickTastes is the household context of the quick_ideas tests.
|
||||
var quickTastes = harness.HouseholdContext{
|
||||
Today: date(2026, 10, 8),
|
||||
Timezone: "America/New_York",
|
||||
Regulars: []harness.RegularDinner{
|
||||
{Name: "Beef Chili", Cuisine: "Tex-Mex", Method: "crockpot", Minutes: 240, Favorite: true},
|
||||
{Name: "Chicken Fajitas", Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30},
|
||||
},
|
||||
QuestLog: []string{"Pad Thai"},
|
||||
}
|
||||
|
||||
// quickIdea renders one idea of a quick_ideas reply.
|
||||
func quickIdea(name, method string, minutes int) string {
|
||||
return fmt.Sprintf(`{"name":%q,"est_minutes":%d,"method":%q,"difficulty":"easy","search_query":%q}`,
|
||||
name, minutes, method, strings.ToLower(name)+" recipe")
|
||||
}
|
||||
|
||||
// quick runs QuickIdeas on in with f and checks f was called exactly
|
||||
// calls times, each at the household temperature.
|
||||
func quick(t *testing.T, f *llm.Fake, in QuickIdeasRequest, calls int) (QuickIdeaList, []llm.Request, error) {
|
||||
t.Helper()
|
||||
out, err := QuickIdeas(context.Background(), testConfig(t, f), QuickIdeasScope(in), in)
|
||||
reqs := f.Requests()
|
||||
if len(reqs) != calls {
|
||||
t.Fatalf("Generate called %d times, want %d", len(reqs), calls)
|
||||
}
|
||||
if g, _, _ := f.Remaining(); g != 0 {
|
||||
t.Fatalf("%d scripted Generate results unused", g)
|
||||
}
|
||||
for i, req := range reqs {
|
||||
if req.Temperature == nil || *req.Temperature != 0.7 {
|
||||
t.Fatalf("request %d temperature = %v, want 0.7", i+1, req.Temperature)
|
||||
}
|
||||
if req.Schema == nil || req.Schema.Name != "quick_ideas" {
|
||||
t.Fatalf("request %d schema = %v, want quick_ideas", i+1, req.Schema)
|
||||
}
|
||||
}
|
||||
return out, reqs, err
|
||||
}
|
||||
|
||||
func TestQuickIdeasPrompt(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
kind, golden, user, rule string
|
||||
}{
|
||||
{QuickCrockpot, "quick_ideas_crockpot", "Suggest 12 crock pot dinner ideas.",
|
||||
`Every idea must be a crock pot dinner: cooked mainly in a crock pot (slow cooker), with method "crockpot".`},
|
||||
{QuickUnder30, "quick_ideas_under30", "Suggest 12 dinner ideas ready in 30 minutes or less.",
|
||||
"Every idea must take 30 minutes or less from starting prep to the table."},
|
||||
} {
|
||||
t.Run(tc.kind, func(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(ideasReply(quickIdea("Chicken Fried Rice", "stovetop", 25))))
|
||||
in := QuickIdeasRequest{Context: quickTastes, Kind: tc.kind, Exclude: []string{"Beef Stew", "Shrimp Tacos"}}
|
||||
_, reqs, err := quick(t, &f, in, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
system, user := reqs[0].System, reqs[0].Messages[0].Content
|
||||
golden(t, tc.golden+".system.golden", system)
|
||||
golden(t, tc.golden+".user.golden", user)
|
||||
for _, part := range []string{
|
||||
"## Household context",
|
||||
"### Regulars\n- Beef Chili",
|
||||
"### Quest log\n- Pad Thai",
|
||||
"### Already shown\n- Beef Stew\n- Shrimp Tacos",
|
||||
tc.rule,
|
||||
"## Output contract",
|
||||
} {
|
||||
if !strings.Contains(system, part) {
|
||||
t.Errorf("system prompt lacks %q", part)
|
||||
}
|
||||
}
|
||||
if user != tc.user {
|
||||
t.Errorf("user message = %q, want %q", user, tc.user)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickIdeasNeverReturnExcludedNames(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(ideasReply(
|
||||
// The excluded names, spelled another way: the normalized name
|
||||
// decides.
|
||||
quickIdea("Easy Beef Stew!", "crockpot", 300),
|
||||
quickIdea("shrimp-tacos", "stovetop", 20),
|
||||
quickIdea("Pulled Pork", "crockpot", 480),
|
||||
// A repeat of an idea already kept.
|
||||
quickIdea("The Pulled Pork", "crockpot", 420),
|
||||
quickIdea("Chicken Fried Rice", "stovetop", 25),
|
||||
)))
|
||||
in := QuickIdeasRequest{Context: quickTastes, Kind: QuickCrockpot, Exclude: []string{"Beef Stew", "Shrimp Tacos"}}
|
||||
out, _, err := quick(t, &f, in, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var kept []string
|
||||
for _, i := range out.Ideas {
|
||||
kept = append(kept, i.Name)
|
||||
}
|
||||
// Chicken Fried Rice is not a crock pot dinner, but the grounded
|
||||
// recipe, not the model's estimate, decides that later.
|
||||
if want := []string{"Pulled Pork", "Chicken Fried Rice"}; !reflect.DeepEqual(kept, want) {
|
||||
t.Fatalf("ideas = %q, want %q", kept, want)
|
||||
}
|
||||
want := []DroppedIdea{
|
||||
{Name: "Easy Beef Stew!", Why: `repeats "Beef Stew", already shown`},
|
||||
{Name: "shrimp-tacos", Why: `repeats "Shrimp Tacos", already shown`},
|
||||
{Name: "The Pulled Pork", Why: `repeats the idea "Pulled Pork"`},
|
||||
}
|
||||
if !reflect.DeepEqual(out.Dropped, want) {
|
||||
t.Fatalf("dropped = %+v\nwant %+v", out.Dropped, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickIdeasRepairsMalformedReply(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(ideasReply(quickIdea("Pulled Pork", "microwave", 0))))
|
||||
f.QueueGenerate(reply(ideasReply(quickIdea("Pulled Pork", "crockpot", 480))))
|
||||
out, reqs, err := quick(t, &f, QuickIdeasRequest{Context: quickTastes, Kind: QuickCrockpot}, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Ideas) != 1 {
|
||||
t.Fatalf("ideas = %+v", out.Ideas)
|
||||
}
|
||||
repair := reqs[1].Messages[len(reqs[1].Messages)-1].Content
|
||||
for _, part := range []string{`idea 1 method "microwave" is not one of`, "idea 1 est_minutes 0 is not positive"} {
|
||||
if !strings.Contains(repair, part) {
|
||||
t.Errorf("repair turn lacks %q:\n%s", part, repair)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickIdeasRefusesTooManyIdeas(t *testing.T) {
|
||||
var f llm.Fake
|
||||
var many []string
|
||||
for i := range QuickIdeasRequested + 1 {
|
||||
many = append(many, quickIdea(fmt.Sprintf("Dish %d", i+1), "stovetop", 20))
|
||||
}
|
||||
f.QueueGenerate(reply(ideasReply(many...)))
|
||||
f.QueueGenerate(reply(ideasReply(many...)))
|
||||
if _, _, err := quick(t, &f, QuickIdeasRequest{Context: quickTastes, Kind: QuickUnder30}, 2); err == nil || !strings.Contains(err.Error(), "give at most 12 ideas") {
|
||||
t.Fatalf("err = %v, want a bad output naming the limit", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickIdeasRefuseBadRequestBeforeCalling(t *testing.T) {
|
||||
for _, in := range []QuickIdeasRequest{
|
||||
{Context: quickTastes, Kind: ""},
|
||||
{Context: quickTastes, Kind: "quick"},
|
||||
{Context: quickTastes, Kind: QuickUnder30, Exclude: []string{"Beef Stew", "beef-stew"}},
|
||||
} {
|
||||
var f llm.Fake
|
||||
if _, _, err := quick(t, &f, in, 0); err == nil {
|
||||
t.Fatalf("%+v was accepted", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanExclude(t *testing.T) {
|
||||
got, err := CleanExclude([]string{" Beef Stew ", "Shrimp\tTacos"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []string{"Beef Stew", "Shrimp Tacos"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("CleanExclude = %q, want %q", got, want)
|
||||
}
|
||||
if got, err := CleanExclude(nil); err != nil || got == nil || len(got) != 0 {
|
||||
t.Fatalf("CleanExclude(nil) = %q, %v, want an empty list", got, err)
|
||||
}
|
||||
tooMany := make([]string, MaxExclude+1)
|
||||
for i := range tooMany {
|
||||
tooMany[i] = fmt.Sprintf("dish %d", i)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
in []string
|
||||
want string
|
||||
}{
|
||||
{"too many", tooMany, "exclude at most 60"},
|
||||
{"blank", []string{"Beef Stew", " "}, "excluded name 2 is blank"},
|
||||
{"too long", []string{strings.Repeat("a", ExcludeLimit+1)}, "over 120 characters"},
|
||||
{"filler only", []string{"Easy Recipe"}, "names no dinner"},
|
||||
{"repeat", []string{"Beef Stew", "easy beef-stew"}, `repeats "Beef Stew"`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := CleanExclude(tc.in); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err = %v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQuickKind(t *testing.T) {
|
||||
for _, kind := range QuickKinds {
|
||||
if err := CheckQuickKind(kind); err != nil {
|
||||
t.Errorf("CheckQuickKind(%q) = %v", kind, err)
|
||||
}
|
||||
}
|
||||
for _, kind := range []string{"", "Crockpot", "under_30", "quick"} {
|
||||
if err := CheckQuickKind(kind); err == nil || !strings.Contains(err.Error(), "is not one of crockpot, under30") {
|
||||
t.Errorf("CheckQuickKind(%q) = %v, want a refusal naming the kinds", kind, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickIdeasScopeIdentifiesTheRequest(t *testing.T) {
|
||||
a := QuickIdeasScope(QuickIdeasRequest{Kind: QuickUnder30, Exclude: []string{"Beef Stew"}})
|
||||
if b := QuickIdeasScope(QuickIdeasRequest{Kind: QuickUnder30, Exclude: []string{"beef-stew"}}); a != b {
|
||||
t.Errorf("scopes of the same request differ: %q, %q", a, b)
|
||||
}
|
||||
for _, other := range []QuickIdeasRequest{
|
||||
{Kind: QuickCrockpot, Exclude: []string{"Beef Stew"}},
|
||||
{Kind: QuickUnder30},
|
||||
{Kind: QuickUnder30, Exclude: []string{"Beef Stew", "Shrimp Tacos"}},
|
||||
} {
|
||||
if QuickIdeasScope(other) == a {
|
||||
t.Errorf("%+v shares the scope %q", other, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
## Instructions
|
||||
You suggest Quick dinner ideas the household would like, picked from its tastes: the dinners it already makes, its Regulars in the household context, and the dinners on its Quest log.
|
||||
|
||||
Every idea must be a crock pot dinner: cooked mainly in a crock pot (slow cooker), with method "crockpot".
|
||||
|
||||
Never suggest a dinner listed under "Already shown" in the household context, nor one of those under another name. Vary the ideas: no two may be the same dish.
|
||||
|
||||
Give for each idea:
|
||||
- name: the dinner's usual name, as a recipe site would title it.
|
||||
- est_minutes: your estimate of the whole minutes from starting prep to the table.
|
||||
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
|
||||
- difficulty: "easy", "medium" or "hard" for a home cook.
|
||||
- search_query: a web search query that finds a recipe for this dinner.
|
||||
|
||||
One valid idea (an example only, not about this household):
|
||||
{"name": "Slow Cooker Chicken Tortilla Soup", "est_minutes": 375, "method": "crockpot", "difficulty": "easy", "search_query": "slow cooker chicken tortilla soup recipe"}
|
||||
|
||||
## Household context
|
||||
### Today
|
||||
- Thursday 2026-10-08 (America/New_York)
|
||||
|
||||
### Regulars
|
||||
- Beef Chili (Tex-Mex, crockpot, 240 min, favorite)
|
||||
- Chicken Fajitas (Tex-Mex, stovetop, 30 min)
|
||||
|
||||
### Quest log
|
||||
- Pad Thai
|
||||
|
||||
### Passed, oldest first
|
||||
(none)
|
||||
|
||||
### Recent plans
|
||||
(none)
|
||||
|
||||
### Already shown
|
||||
- Beef Stew
|
||||
- Shrimp Tacos
|
||||
|
||||
## Output contract
|
||||
This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "quick_ideas" below, and nothing else: no prose and no Markdown code fences.
|
||||
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ideas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"est_minutes": {
|
||||
"type": "integer"
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"stovetop",
|
||||
"oven",
|
||||
"crockpot",
|
||||
"grill",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"difficulty": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"easy",
|
||||
"medium",
|
||||
"hard"
|
||||
]
|
||||
},
|
||||
"search_query": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"est_minutes",
|
||||
"method",
|
||||
"difficulty",
|
||||
"search_query"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ideas"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Suggest 12 crock pot dinner ideas.
|
||||
@@ -0,0 +1,93 @@
|
||||
## Instructions
|
||||
You suggest Quick dinner ideas the household would like, picked from its tastes: the dinners it already makes, its Regulars in the household context, and the dinners on its Quest log.
|
||||
|
||||
Every idea must take 30 minutes or less from starting prep to the table.
|
||||
|
||||
Never suggest a dinner listed under "Already shown" in the household context, nor one of those under another name. Vary the ideas: no two may be the same dish.
|
||||
|
||||
Give for each idea:
|
||||
- name: the dinner's usual name, as a recipe site would title it.
|
||||
- est_minutes: your estimate of the whole minutes from starting prep to the table.
|
||||
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
|
||||
- difficulty: "easy", "medium" or "hard" for a home cook.
|
||||
- search_query: a web search query that finds a recipe for this dinner.
|
||||
|
||||
One valid idea (an example only, not about this household):
|
||||
{"name": "Garlic Butter Shrimp Pasta", "est_minutes": 25, "method": "stovetop", "difficulty": "easy", "search_query": "garlic butter shrimp pasta recipe"}
|
||||
|
||||
## Household context
|
||||
### Today
|
||||
- Thursday 2026-10-08 (America/New_York)
|
||||
|
||||
### Regulars
|
||||
- Beef Chili (Tex-Mex, crockpot, 240 min, favorite)
|
||||
- Chicken Fajitas (Tex-Mex, stovetop, 30 min)
|
||||
|
||||
### Quest log
|
||||
- Pad Thai
|
||||
|
||||
### Passed, oldest first
|
||||
(none)
|
||||
|
||||
### Recent plans
|
||||
(none)
|
||||
|
||||
### Already shown
|
||||
- Beef Stew
|
||||
- Shrimp Tacos
|
||||
|
||||
## Output contract
|
||||
This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "quick_ideas" below, and nothing else: no prose and no Markdown code fences.
|
||||
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ideas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"est_minutes": {
|
||||
"type": "integer"
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"stovetop",
|
||||
"oven",
|
||||
"crockpot",
|
||||
"grill",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"difficulty": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"easy",
|
||||
"medium",
|
||||
"hard"
|
||||
]
|
||||
},
|
||||
"search_query": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"est_minutes",
|
||||
"method",
|
||||
"difficulty",
|
||||
"search_query"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ideas"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Suggest 12 dinner ideas ready in 30 minutes or less.
|
||||
+332
-93
@@ -1,10 +1,12 @@
|
||||
// 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 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 (
|
||||
@@ -14,15 +16,27 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
// KindFromIngredients is the job that finds ideas from ingredients.
|
||||
const KindFromIngredients = "ideas_from_ingredients"
|
||||
// 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.
|
||||
@@ -38,25 +52,26 @@ const (
|
||||
// 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: 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
|
||||
// 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: 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 the job.
|
||||
// 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.
|
||||
// ingredient list or an unknown kind of Quick dinner.
|
||||
type InvalidError struct {
|
||||
Message string
|
||||
}
|
||||
@@ -68,42 +83,68 @@ func (e *InvalidError) Error() string { return e.Message }
|
||||
// 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
|
||||
generate Generator
|
||||
quick QuickGenerator
|
||||
logger *slog.Logger
|
||||
job *jobs.Kind[FromIngredientsJob]
|
||||
quickJob *jobs.Kind[QuickJob]
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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{grounder: grounder, generate: generate, logger: logger}
|
||||
kind, err := jobs.Register(runner, KindFromIngredients, s.run, Reasons...)
|
||||
s := &Service{st: st, grounder: grounder, 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(withoutIdeas); err != nil {
|
||||
if err := kind.FailedResult(failed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.job = kind
|
||||
return s, nil
|
||||
return kind, nil
|
||||
}
|
||||
|
||||
// FromIngredientsJob is the KindFromIngredients payload: the ingredients
|
||||
@@ -195,31 +236,54 @@ func IsQuick(method string, minutes *int) bool {
|
||||
// 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.
|
||||
// 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)
|
||||
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 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
|
||||
}
|
||||
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
|
||||
@@ -243,34 +307,16 @@ func (s *Service) fill(ctx context.Context, p FromIngredientsJob, publish func(R
|
||||
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
|
||||
}
|
||||
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}
|
||||
@@ -279,20 +325,58 @@ func (s *Service) fill(ctx context.Context, p FromIngredientsJob, publish func(R
|
||||
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 {
|
||||
// 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()
|
||||
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) {
|
||||
g.GroundEach(groundCtx, reqs, func(i int, r recipes.GroundResult) {
|
||||
if failed != nil {
|
||||
return
|
||||
}
|
||||
@@ -309,7 +393,7 @@ func (s *Service) ground(ctx context.Context, ideas []skills.Idea, kept func(i i
|
||||
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)
|
||||
failed = fmt.Errorf("ideas: ground %q: %w", reqs[i].DishName, r.Err)
|
||||
}
|
||||
if failed != nil {
|
||||
cancel()
|
||||
@@ -324,13 +408,12 @@ func (s *Service) ground(ctx context.Context, ideas []skills.Idea, kept func(i i
|
||||
// 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
|
||||
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)
|
||||
}
|
||||
res.Ideas = []Idea{}
|
||||
return res.PublicJSON()
|
||||
return res.cleared().PublicJSON()
|
||||
}
|
||||
|
||||
// ideaOf is idea as grounded by gr.
|
||||
@@ -350,3 +433,159 @@ func ideaOf(idea skills.Idea, gr recipes.Grounded) Idea {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"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/recipes/recipestest"
|
||||
@@ -32,17 +33,21 @@ type env struct {
|
||||
search *recipestest.Search
|
||||
pages *recipestest.Pages
|
||||
images *recipestest.Images
|
||||
// model answers ideas_from_ingredients; normalize answers
|
||||
// normalize_recipe while grounding.
|
||||
// model answers ideas_from_ingredients and quick_ideas; normalize
|
||||
// answers normalize_recipe while grounding.
|
||||
model *llm.Fake
|
||||
normalize *llm.Fake
|
||||
// fail, when set, fails ideas_from_ingredients without a model call.
|
||||
// fail, when set, fails ideas_from_ingredients and quick_ideas without
|
||||
// a model call.
|
||||
fail error
|
||||
st *store.Store
|
||||
}
|
||||
|
||||
// newEnv returns an ideas service over a migrated store and an unstarted
|
||||
// newEnv returns an ideas service over a migrated store with the
|
||||
// household's settings seeded and an unstarted
|
||||
// job runner. It finds ideas through the real harness and
|
||||
// ideas_from_ingredients with the scripted e.model, and grounds with fake
|
||||
// ideas_from_ingredients or quick_ideas with the scripted e.model, and
|
||||
// grounds with fake
|
||||
// search, pages and images and the scripted e.normalize.
|
||||
func newEnv(t *testing.T) *env {
|
||||
t.Helper()
|
||||
@@ -60,12 +65,16 @@ func newEnv(t *testing.T) *env {
|
||||
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := &env{
|
||||
search: &recipestest.Search{Results: map[string][]recipes.Candidate{}},
|
||||
pages: &recipestest.Pages{Bodies: map[string][]byte{}},
|
||||
images: &recipestest.Images{JPEGs: map[string][]byte{}},
|
||||
model: &llm.Fake{},
|
||||
normalize: &llm.Fake{},
|
||||
st: st,
|
||||
}
|
||||
g := recipestest.NewGrounder(t, st, dataDir, e.search, e.pages, e.images, e.normalize)
|
||||
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
||||
@@ -84,12 +93,18 @@ func newEnv(t *testing.T) *env {
|
||||
}
|
||||
return skills.IdeasFromIngredients(ctx, cfg, scope, in)
|
||||
}
|
||||
quick := func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error) {
|
||||
if e.fail != nil {
|
||||
return skills.QuickIdeaList{}, e.fail
|
||||
}
|
||||
return skills.QuickIdeas(ctx, cfg, scope, in)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
e.runner, err = jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.svc, err = NewService(g, generate, e.runner, logger)
|
||||
e.svc, err = NewService(st, g, generate, quick, e.runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
package ideas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
)
|
||||
|
||||
// quickIdea renders one idea of a quick_ideas reply whose search query is
|
||||
// its name.
|
||||
func quickIdea(name, method string, minutes int) string {
|
||||
return fmt.Sprintf(`{"name":%q,"est_minutes":%d,"method":%q,"difficulty":"easy","search_query":%q}`,
|
||||
name, minutes, method, name)
|
||||
}
|
||||
|
||||
// quickRecorder collects every result a quick job publishes.
|
||||
type quickRecorder struct {
|
||||
mu sync.Mutex
|
||||
published []QuickResult
|
||||
}
|
||||
|
||||
func (r *quickRecorder) latest() (QuickResult, bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if len(r.published) == 0 {
|
||||
return QuickResult{}, false
|
||||
}
|
||||
return r.published[len(r.published)-1], true
|
||||
}
|
||||
|
||||
func (r *quickRecorder) publish(res QuickResult) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
res.Ideas = append([]QuickIdea(nil), res.Ideas...)
|
||||
r.published = append(r.published, res)
|
||||
return nil
|
||||
}
|
||||
|
||||
// runQuick runs the quick job's handler directly, recording each publish.
|
||||
func (e *env) runQuick(t *testing.T, kind string, exclude ...string) (QuickResult, []QuickResult, error) {
|
||||
t.Helper()
|
||||
names, err := skills.CleanExclude(exclude)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rec quickRecorder
|
||||
res, err := e.svc.runQuick(t.Context(), QuickJob{Kind: kind, Exclude: names}, rec.publish)
|
||||
return res, rec.published, err
|
||||
}
|
||||
|
||||
func quickNames(list []QuickIdea) []string {
|
||||
out := []string{}
|
||||
for _, i := range list {
|
||||
out = append(out, i.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestUnder30DropsIdeasOverThirtyMinutes(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
// The model estimates every idea under 30 minutes; the grounded
|
||||
// recipes decide.
|
||||
e.recipe(t, "Chicken Fried Rice", "Chicken Fried Rice", 25)
|
||||
e.recipe(t, "Beef Stroganoff", "Beef Stroganoff", 45)
|
||||
e.recipe(t, "Shrimp Scampi", "Shrimp Scampi", 30)
|
||||
e.recipe(t, "Chicken Chili", "Slow Cooker Chicken Chili", 240)
|
||||
e.reply(
|
||||
quickIdea("Chicken Fried Rice", "stovetop", 20),
|
||||
quickIdea("Beef Stroganoff", "stovetop", 25),
|
||||
quickIdea("Shrimp Scampi", "stovetop", 20),
|
||||
quickIdea("Chicken Chili", "crockpot", 25),
|
||||
)
|
||||
res, _, err := e.runQuick(t, skills.QuickUnder30)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := quickNames(res.Ideas), []string{"Chicken Fried Rice", "Shrimp Scampi"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ideas = %q, want %q: the 45-minute and the crock pot ideas dropped", got, want)
|
||||
}
|
||||
if res.Dropped != 2 || res.Stage != StageDone || res.Kind != skills.QuickUnder30 {
|
||||
t.Fatalf("result = %+v, want done with 2 dropped", res)
|
||||
}
|
||||
for _, i := range res.Ideas {
|
||||
if i.MinutesTotal == nil || *i.MinutesTotal > QuickMinutes || i.RecipeID == "" || i.PhotoURL == "" {
|
||||
t.Errorf("idea %+v is not a grounded dinner of 30 minutes or less", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrockpotDropsStovetopIdeas(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
// The model calls every idea a crock pot dinner; only the recipes
|
||||
// cooked in a slow cooker are.
|
||||
e.recipe(t, "Pulled Pork", "Slow Cooker Pulled Pork", 480)
|
||||
e.recipe(t, "Beef Stew", "Beef Stew", 150)
|
||||
e.recipe(t, "Chicken Tortilla Soup", "Crock Pot Chicken Tortilla Soup", 375)
|
||||
e.recipe(t, "Chicken Fried Rice", "Chicken Fried Rice", 25)
|
||||
e.reply(
|
||||
quickIdea("Pulled Pork", "crockpot", 480),
|
||||
quickIdea("Beef Stew", "crockpot", 360),
|
||||
quickIdea("Chicken Tortilla Soup", "crockpot", 375),
|
||||
quickIdea("Chicken Fried Rice", "crockpot", 240),
|
||||
)
|
||||
res, _, err := e.runQuick(t, skills.QuickCrockpot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := quickNames(res.Ideas), []string{"Pulled Pork", "Chicken Tortilla Soup"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ideas = %q, want %q: the stovetop ideas dropped", got, want)
|
||||
}
|
||||
if res.Dropped != 2 {
|
||||
t.Fatalf("dropped %d, want 2", res.Dropped)
|
||||
}
|
||||
for _, i := range res.Ideas {
|
||||
if i.Method != "crockpot" {
|
||||
t.Errorf("idea %q method %s, want the grounded crockpot", i.Name, i.Method)
|
||||
}
|
||||
}
|
||||
if m := res.Ideas[0].MinutesTotal; m == nil || *m != 480 {
|
||||
t.Errorf("Pulled Pork minutes_total = %v, want the recipe's 480", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExcludedNamesAreNeverReturned(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
// Every idea has a recipe that would ground it as a Quick dinner.
|
||||
for _, name := range []string{"Easy Chicken Fried Rice", "shrimp-tacos", "Garlic Shrimp Pasta"} {
|
||||
e.recipe(t, name, name, 20)
|
||||
}
|
||||
e.reply(
|
||||
quickIdea("Easy Chicken Fried Rice", "stovetop", 20),
|
||||
quickIdea("shrimp-tacos", "stovetop", 15),
|
||||
quickIdea("Garlic Shrimp Pasta", "stovetop", 25),
|
||||
)
|
||||
res, _, err := e.runQuick(t, skills.QuickUnder30, "Chicken Fried Rice", "Shrimp Tacos")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := quickNames(res.Ideas); !reflect.DeepEqual(got, []string{"Garlic Shrimp Pasta"}) || res.Dropped != 2 {
|
||||
t.Fatalf("ideas = %q dropped %d, want only Garlic Shrimp Pasta with 2 dropped", got, res.Dropped)
|
||||
}
|
||||
if got := e.search.Queries(); !reflect.DeepEqual(got, []string{"Garlic Shrimp Pasta"}) {
|
||||
t.Errorf("searched %q, want the excluded names never grounded", got)
|
||||
}
|
||||
if !reflect.DeepEqual(res.Exclude, []string{"Chicken Fried Rice", "Shrimp Tacos"}) {
|
||||
t.Errorf("result exclude = %q", res.Exclude)
|
||||
}
|
||||
// The model was told what was already shown.
|
||||
if system := e.model.Requests()[0].System; !strings.Contains(system, "### Already shown\n- Chicken Fried Rice\n- Shrimp Tacos") {
|
||||
t.Errorf("prompt does not list the names already shown:\n%s", system)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickPageStopsAtSixAndShowsFewerHonestly(t *testing.T) {
|
||||
t.Run("twelve ideas, six shown", func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var reply []string
|
||||
for i := range skills.QuickIdeasRequested {
|
||||
name := fmt.Sprintf("Skillet Dish %c", 'A'+i)
|
||||
e.recipe(t, name, name, 20)
|
||||
reply = append(reply, quickIdea(name, "stovetop", 20))
|
||||
}
|
||||
e.reply(reply...)
|
||||
res, _, err := e.runQuick(t, skills.QuickUnder30)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Ideas) != skills.QuickIdeasKept || res.Dropped != 0 {
|
||||
t.Fatalf("ideas = %q dropped %d, want %d", quickNames(res.Ideas), res.Dropped, skills.QuickIdeasKept)
|
||||
}
|
||||
if got := e.search.Queries(); len(got) != skills.QuickIdeasKept {
|
||||
t.Errorf("searched %d ideas, want only the %d a page holds", len(got), skills.QuickIdeasKept)
|
||||
}
|
||||
})
|
||||
t.Run("over-generated ideas replace dropped ones", func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var reply []string
|
||||
// The first six ground too slow, so the next six are grounded to
|
||||
// fill the page; only three of those are quick.
|
||||
for i := range skills.QuickIdeasRequested {
|
||||
name := fmt.Sprintf("Skillet Dish %c", 'A'+i)
|
||||
minutes := 20
|
||||
if i < 6 || i%2 == 1 {
|
||||
minutes = 50
|
||||
}
|
||||
e.recipe(t, name, name, minutes)
|
||||
reply = append(reply, quickIdea(name, "stovetop", 20))
|
||||
}
|
||||
e.reply(reply...)
|
||||
res, _, err := e.runQuick(t, skills.QuickUnder30)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Fewer than six survive: the page shows three, never padded.
|
||||
if got, want := quickNames(res.Ideas), []string{"Skillet Dish G", "Skillet Dish I", "Skillet Dish K"}; !reflect.DeepEqual(got, want) || res.Dropped != 9 || res.Stage != StageDone {
|
||||
t.Fatalf("result %s ideas %q dropped %d, want done with %q and 9 dropped", res.Stage, got, res.Dropped, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestQuickResultFillsAsEachIdeaGrounds(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
for _, name := range []string{"Chicken Fried Rice", "Shrimp Scampi", "Garlic Shrimp Pasta"} {
|
||||
e.recipe(t, name, name, 25)
|
||||
}
|
||||
// The first idea's search waits, so the two after it ground first.
|
||||
gate := make(chan struct{})
|
||||
e.search.Gates = map[string]chan struct{}{"Chicken Fried Rice": gate}
|
||||
e.reply(
|
||||
quickIdea("Chicken Fried Rice", "stovetop", 20),
|
||||
quickIdea("Shrimp Scampi", "stovetop", 20),
|
||||
quickIdea("Garlic Shrimp Pasta", "stovetop", 25),
|
||||
)
|
||||
var rec quickRecorder
|
||||
type outcome struct {
|
||||
res QuickResult
|
||||
err error
|
||||
}
|
||||
done := make(chan outcome, 1)
|
||||
go func() {
|
||||
res, err := e.svc.runQuick(t.Context(), QuickJob{Kind: skills.QuickUnder30, Exclude: []string{}}, rec.publish)
|
||||
done <- outcome{res, err}
|
||||
}()
|
||||
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
if latest, ok := rec.latest(); ok && len(latest.Ideas) == 2 {
|
||||
if got := quickNames(latest.Ideas); !reflect.DeepEqual(got, []string{"Shrimp Scampi", "Garlic Shrimp Pasta"}) {
|
||||
t.Fatalf("while the first idea grounds, ideas = %q, want the two grounded", got)
|
||||
}
|
||||
if latest.Stage != StageGrounding {
|
||||
t.Fatalf("stage = %s, want grounding", latest.Stage)
|
||||
}
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("the ideas that grounded were not shown while the first idea was still grounding")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
close(gate)
|
||||
out := <-done
|
||||
if out.err != nil {
|
||||
t.Fatal(out.err)
|
||||
}
|
||||
want := []string{"Chicken Fried Rice", "Shrimp Scampi", "Garlic Shrimp Pasta"}
|
||||
if got := quickNames(out.res.Ideas); !reflect.DeepEqual(got, want) || out.res.Stage != StageDone {
|
||||
t.Fatalf("result = %s %q, want done with %q in the model's order", out.res.Stage, got, want)
|
||||
}
|
||||
|
||||
var stages []string
|
||||
var counts []int
|
||||
for _, p := range rec.published {
|
||||
stages = append(stages, p.Stage)
|
||||
counts = append(counts, len(p.Ideas))
|
||||
}
|
||||
if !reflect.DeepEqual(stages, []string{StageSuggesting, StageGrounding, StageGrounding, StageGrounding, StageGrounding}) || !reflect.DeepEqual(counts, []int{0, 0, 1, 2, 3}) {
|
||||
t.Fatalf("published stages %q with %v ideas, want suggesting, then grounding with 0, 1, 2, 3", stages, counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickFailures(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
setup func(t *testing.T, e *env)
|
||||
code string
|
||||
}{
|
||||
{"ai fails", func(t *testing.T, e *env) { e.fail = errors.New("provider down") }, ReasonAIFailed},
|
||||
{"no idea is of the kind", func(t *testing.T, e *env) {
|
||||
e.recipe(t, "Beef Stew", "Beef Stew", 150)
|
||||
e.reply(quickIdea("Beef Stew", "crockpot", 360))
|
||||
}, ReasonNoIdeasFound},
|
||||
{"every idea already shown", func(t *testing.T, e *env) {
|
||||
e.reply(quickIdea("Pulled Pork", "crockpot", 480))
|
||||
}, ReasonNoIdeasFound},
|
||||
{"a later round fails after ideas were shown", func(t *testing.T, e *env) {
|
||||
e.recipe(t, "Chicken Chili", "Slow Cooker Chicken Chili", 240)
|
||||
e.recipe(t, "Beef Stew", "Beef Stew", 150)
|
||||
e.search.Fail = map[string]error{"Lentil Soup": errors.New("boom")}
|
||||
reply := []string{quickIdea("Chicken Chili", "crockpot", 240)}
|
||||
for i := range 5 {
|
||||
name := fmt.Sprintf("Mystery Stew %d", i+1)
|
||||
reply = append(reply, quickIdea(name, "crockpot", 300))
|
||||
}
|
||||
e.reply(append(reply, quickIdea("Lentil Soup", "crockpot", 300))...)
|
||||
}, ReasonSearchFailed},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
tc.setup(t, e)
|
||||
_, published, err := e.runQuick(t, skills.QuickCrockpot, "Pulled Pork")
|
||||
var failure *jobs.Failure
|
||||
if !errors.As(err, &failure) || failure.Code != tc.code {
|
||||
t.Fatalf("err = %v, want failure %s", err, tc.code)
|
||||
}
|
||||
if last := published[len(published)-1]; len(last.Ideas) != 0 || last.Stage == StageDone {
|
||||
t.Fatalf("last publish of a failed job = %+v, want no ideas", last)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartQuickRunsTheJob(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.recipe(t, "Pulled Pork", "Slow Cooker Pulled Pork", 480)
|
||||
e.reply(quickIdea("Pulled Pork", "crockpot", 480))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := e.runner.Start(ctx); err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
e.runner.Wait()
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
kind string
|
||||
exclude []string
|
||||
want string
|
||||
}{
|
||||
{"", nil, `kind: kind "" is not one of crockpot, under30`},
|
||||
{"quick", nil, `kind: kind "quick" is not one of crockpot, under30`},
|
||||
{skills.QuickCrockpot, []string{"Beef Stew", "beef stew"}, `exclude: excluded name 2, "beef stew", repeats "Beef Stew"`},
|
||||
} {
|
||||
_, err := e.svc.StartQuick(t.Context(), tc.kind, tc.exclude)
|
||||
var invalid *InvalidError
|
||||
if !errors.As(err, &invalid) || invalid.Message != tc.want {
|
||||
t.Errorf("StartQuick(%q, %q) = %v, want %q", tc.kind, tc.exclude, err, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
started, err := e.svc.StartQuick(t.Context(), skills.QuickCrockpot, []string{" Beef Stew "})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
var job jobs.Job
|
||||
for {
|
||||
job, err = e.runner.Get(t.Context(), started.JobID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("job still %s", job.Status)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if job.Status != jobs.StatusSucceeded || job.Kind != KindQuick {
|
||||
t.Fatalf("job = %+v", job)
|
||||
}
|
||||
var res QuickResult
|
||||
if err := json.Unmarshal(job.Result, &res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Stage != StageDone || res.Kind != skills.QuickCrockpot || !reflect.DeepEqual(res.Exclude, []string{"Beef Stew"}) || len(res.Ideas) != 1 {
|
||||
t.Fatalf("result = %+v", res)
|
||||
}
|
||||
if got := res.Ideas[0]; got.Name != "Pulled Pork" || got.Method != "crockpot" || got.SiteName != "R Kitchen" {
|
||||
t.Errorf("idea = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfQuickKind(t *testing.T) {
|
||||
m := func(n int) *int { return &n }
|
||||
for _, tc := range []struct {
|
||||
kind, method string
|
||||
minutes *int
|
||||
want bool
|
||||
}{
|
||||
{skills.QuickCrockpot, "crockpot", m(480), true},
|
||||
{skills.QuickCrockpot, "crockpot", nil, true},
|
||||
{skills.QuickCrockpot, "stovetop", m(20), false},
|
||||
{skills.QuickUnder30, "stovetop", m(30), true},
|
||||
{skills.QuickUnder30, "oven", m(31), false},
|
||||
{skills.QuickUnder30, "stovetop", nil, false},
|
||||
{skills.QuickUnder30, "crockpot", m(240), false},
|
||||
{"quick", "crockpot", m(20), false},
|
||||
} {
|
||||
if got := OfQuickKind(tc.kind, tc.method, tc.minutes); got != tc.want {
|
||||
t.Errorf("OfQuickKind(%s, %s, %v) = %v, want %v", tc.kind, tc.method, tc.minutes, got, tc.want)
|
||||
}
|
||||
}
|
||||
if !slices.Equal(skills.QuickKinds, []string{skills.QuickCrockpot, skills.QuickUnder30}) {
|
||||
t.Errorf("kinds = %q", skills.QuickKinds)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||
)
|
||||
|
||||
// maxIdeasBody bounds a POST /ideas/from-ingredients body.
|
||||
// maxIdeasBody bounds a POST /ideas/from-ingredients or /ideas/quick body.
|
||||
const maxIdeasBody = 16 << 10
|
||||
|
||||
// ideasFromIngredients is the POST /ideas/from-ingredients body. Both
|
||||
@@ -51,3 +51,45 @@ func postIdeasFromIngredients(logger *slog.Logger, svc *ideas.Service) http.Hand
|
||||
writeJSON(w, r, logger, http.StatusAccepted, started)
|
||||
}
|
||||
}
|
||||
|
||||
// ideasQuick is the POST /ideas/quick body. Both fields are required:
|
||||
// exclude is [] on a first page.
|
||||
type ideasQuick struct {
|
||||
Kind *string `json:"kind"`
|
||||
Exclude []string `json:"exclude"`
|
||||
}
|
||||
|
||||
// postIdeasQuick starts finding a page of Quick dinner ideas of a kind,
|
||||
// crockpot or under30, none named in exclude, and answers 202 with the
|
||||
// jobId that GET /jobs/{id} reports on, without waiting for the job. A
|
||||
// missing or unknown kind, or an exclude list the household must correct,
|
||||
// is 400.
|
||||
func postIdeasQuick(logger *slog.Logger, svc *ideas.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := decodePatch[ideasQuick](http.MaxBytesReader(w, r.Body, maxIdeasBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case body.Kind == nil:
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "kind: required")
|
||||
return
|
||||
case body.Exclude == nil:
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "exclude: required")
|
||||
return
|
||||
}
|
||||
started, err := svc.StartQuick(r.Context(), *body.Kind, body.Exclude)
|
||||
var invalid *ideas.InvalidError
|
||||
switch {
|
||||
case errors.As(err, &invalid):
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", invalid.Message)
|
||||
return
|
||||
case err != nil:
|
||||
logger.ErrorContext(r.Context(), "start quick ideas", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusAccepted, started)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
@@ -28,13 +29,16 @@ import (
|
||||
const ideasPath = "/api/v1/ideas/from-ingredients"
|
||||
|
||||
// newIdeasRouter returns a router whose ideas service runs
|
||||
// ideas_from_ingredients through the real harness with the scripted model
|
||||
// and grounds with fake search, pages and images and the scripted
|
||||
// normalize, with its job runner and store. The runner is started when
|
||||
// start is set.
|
||||
// ideas_from_ingredients and quick_ideas through the real harness with the
|
||||
// scripted model and grounds with fake search, pages and images and the
|
||||
// scripted normalize, with its job runner and store, whose household
|
||||
// settings are seeded. The runner is started when start is set.
|
||||
func newIdeasRouter(t *testing.T, model, normalize *llm.Fake, search *recipestest.Search, pages *recipestest.Pages, images *recipestest.Images, start bool) (http.Handler, *jobs.Runner, *store.Store) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
if err := household.SeedSettings(t.Context(), st, "America/New_York"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
@@ -54,7 +58,10 @@ func newIdeasRouter(t *testing.T, model, normalize *llm.Fake, search *recipestes
|
||||
generate := func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error) {
|
||||
return skills.IdeasFromIngredients(ctx, cfg, scope, in)
|
||||
}
|
||||
svc, err := ideas.NewService(g, generate, runner, logger)
|
||||
quick := func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error) {
|
||||
return skills.QuickIdeas(ctx, cfg, scope, in)
|
||||
}
|
||||
svc, err := ideas.NewService(st, g, generate, quick, runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
)
|
||||
|
||||
const quickPath = "/api/v1/ideas/quick"
|
||||
|
||||
// quickRecipe makes the search for name find a page titled title with a
|
||||
// total time of minutes, and scripts the normalize_recipe reply grounding
|
||||
// it takes. "Slow Cooker" in title grounds the method as crockpot.
|
||||
func quickRecipe(t *testing.T, normalize *llm.Fake, search *recipestest.Search, pages *recipestest.Pages, images *recipestest.Images, name, title string, minutes int) {
|
||||
t.Helper()
|
||||
slug := strings.ReplaceAll(strings.ToLower(name), " ", "-")
|
||||
page, photo := "https://r.example/"+slug, "https://r.example/"+slug+".jpg"
|
||||
search.Results[name] = []recipes.Candidate{{URL: page}}
|
||||
pages.Bodies[page] = recipestest.RecipePage(title, "R Kitchen", photo, minutes)
|
||||
images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(len(images.JPEGs)+1))
|
||||
normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", minutes))
|
||||
}
|
||||
|
||||
// quickReply scripts one quick_ideas reply holding the ideas named, each
|
||||
// searched by its name.
|
||||
func quickReply(model *llm.Fake, ideas ...[3]any) {
|
||||
list := make([]string, len(ideas))
|
||||
for i, idea := range ideas {
|
||||
list[i] = fmt.Sprintf(`{"name":%q,"est_minutes":%d,"method":%q,"difficulty":"medium","search_query":%q}`, idea[0], idea[2], idea[1], idea[0])
|
||||
}
|
||||
js := `{"ideas":[` + strings.Join(list, ",") + `]}`
|
||||
model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
|
||||
}
|
||||
|
||||
func newQuickFakes() (*llm.Fake, *llm.Fake, *recipestest.Search, *recipestest.Pages, *recipestest.Images) {
|
||||
return &llm.Fake{}, &llm.Fake{}, &recipestest.Search{Results: map[string][]recipes.Candidate{}},
|
||||
&recipestest.Pages{Bodies: map[string][]byte{}}, &recipestest.Images{JPEGs: map[string][]byte{}}
|
||||
}
|
||||
|
||||
func TestPostIdeasQuickRefusesBadRequests(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, _, _ := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false)
|
||||
|
||||
for _, tc := range []struct{ body, message string }{
|
||||
{`{"exclude": []}`, "kind: required"},
|
||||
{`{"kind": "crockpot"}`, "exclude: required"},
|
||||
{`{"kind": "", "exclude": []}`, `kind: kind "" is not one of crockpot, under30`},
|
||||
{`{"kind": "quick", "exclude": []}`, `kind: kind "quick" is not one of crockpot, under30`},
|
||||
{`{"kind": "Crockpot", "exclude": []}`, `kind: kind "Crockpot" is not one of crockpot, under30`},
|
||||
{`{"kind": "under30", "exclude": ["Beef Stew", " "]}`, "exclude: excluded name 2 is blank"},
|
||||
{`{"kind": "under30", "exclude": ["Beef Stew", "beef-stew"]}`, `exclude: excluded name 2, "beef-stew", repeats "Beef Stew"`},
|
||||
{`{"kind": 30, "exclude": []}`, "kind: want a JSON string"},
|
||||
{`{"kind": "under30", "exclude": [], "extra": 1}`, `invalid request body: json: unknown field "extra"`},
|
||||
} {
|
||||
rec, body := callDinners(t, h, http.MethodPost, quickPath, tc.body)
|
||||
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_request" {
|
||||
t.Errorf("POST %s = %d %v, want 400 invalid_request", tc.body, rec.Code, body)
|
||||
continue
|
||||
}
|
||||
if msg := body["error"].(map[string]any)["message"]; msg != tc.message {
|
||||
t.Errorf("POST %s message = %q, want %q", tc.body, msg, tc.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The job answers 202 at once and its result fills idea by idea: one
|
||||
// idea is shown while another still grounds. The kind is checked against
|
||||
// the grounded recipe and excluded names never come back.
|
||||
func TestPostIdeasQuickRunsTheJobWithProgressiveResults(t *testing.T) {
|
||||
t.Parallel()
|
||||
model, normalize, search, pages, images := newQuickFakes()
|
||||
quickRecipe(t, normalize, search, pages, images, "Chicken Fried Rice", "Chicken Fried Rice", 25)
|
||||
quickRecipe(t, normalize, search, pages, images, "Beef Stroganoff", "Beef Stroganoff", 45)
|
||||
quickRecipe(t, normalize, search, pages, images, "Garlic Shrimp Pasta", "Garlic Shrimp Pasta", 30)
|
||||
gate := make(chan struct{})
|
||||
search.Gates = map[string]chan struct{}{"Garlic Shrimp Pasta": gate}
|
||||
quickReply(model,
|
||||
[3]any{"Easy Shrimp Tacos", "stovetop", 15},
|
||||
[3]any{"Chicken Fried Rice", "stovetop", 20},
|
||||
[3]any{"Beef Stroganoff", "stovetop", 25},
|
||||
[3]any{"Garlic Shrimp Pasta", "stovetop", 20},
|
||||
)
|
||||
h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
|
||||
|
||||
rec, body := callDinners(t, h, http.MethodPost, quickPath, `{"kind": "under30", "exclude": ["Shrimp Tacos"]}`)
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
id, _ := body["jobId"].(string)
|
||||
if id == "" || len(body) != 1 {
|
||||
t.Fatalf("POST answered %v, want only a jobId", body)
|
||||
}
|
||||
|
||||
// While Garlic Shrimp Pasta still grounds, Chicken Fried Rice shows.
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
_, job := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "")
|
||||
if res, _ := job["result"].(map[string]any); res != nil {
|
||||
if list, _ := res["ideas"].([]any); len(list) == 1 {
|
||||
if job["status"] != "running" || res["stage"] != "grounding" || list[0].(map[string]any)["name"] != "Chicken Fried Rice" {
|
||||
t.Fatalf("partial job = %v", job)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("no idea was shown while another still grounded")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
close(gate)
|
||||
|
||||
job := waitJob(t, h, id)
|
||||
if job["status"] != "succeeded" || job["kind"] != "quick_ideas" {
|
||||
t.Fatalf("GET job = %v", job)
|
||||
}
|
||||
res := job["result"].(map[string]any)
|
||||
if res["stage"] != "done" || res["kind"] != "under30" || res["dropped"] != 2.0 {
|
||||
t.Fatalf("result = %v, want done with the excluded and the 45-minute ideas dropped", res)
|
||||
}
|
||||
if ex, _ := json.Marshal(res["exclude"]); string(ex) != `["Shrimp Tacos"]` {
|
||||
t.Errorf("result exclude = %s", ex)
|
||||
}
|
||||
var names []string
|
||||
for _, i := range res["ideas"].([]any) {
|
||||
names = append(names, i.(map[string]any)["name"].(string))
|
||||
}
|
||||
if want := []string{"Chicken Fried Rice", "Garlic Shrimp Pasta"}; !reflect.DeepEqual(names, want) {
|
||||
t.Fatalf("ideas = %q, want %q", names, want)
|
||||
}
|
||||
got := res["ideas"].([]any)[1].(map[string]any)
|
||||
for key, want := range map[string]any{
|
||||
"method": "stovetop",
|
||||
"minutes_total": 30.0,
|
||||
"site_name": "R Kitchen",
|
||||
"source_url": "https://r.example/garlic-shrimp-pasta",
|
||||
} {
|
||||
if got[key] != want {
|
||||
t.Errorf("idea %s = %v, want %v", key, got[key], want)
|
||||
}
|
||||
}
|
||||
if got["recipe_id"] == "" || got["photo_url"] == "" || got["difficulty"] == nil {
|
||||
t.Errorf("idea = %v, want a recipe, photo and difficulty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedQuickJobShowsNoIdeas(t *testing.T) {
|
||||
t.Parallel()
|
||||
model, normalize, search, pages, images := newQuickFakes()
|
||||
quickRecipe(t, normalize, search, pages, images, "Chicken Chili", "Slow Cooker Chicken Chili", 240)
|
||||
search.Fail = map[string]error{"Lentil Soup": errors.New("boom")}
|
||||
ideas := [][3]any{{"Chicken Chili", "crockpot", 240}}
|
||||
for i := range 5 {
|
||||
ideas = append(ideas, [3]any{fmt.Sprintf("Mystery Stew %d", i+1), "crockpot", 300})
|
||||
}
|
||||
quickReply(model, append(ideas, [3]any{"Lentil Soup", "crockpot", 300})...)
|
||||
h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
|
||||
rec, body := callDinners(t, h, http.MethodPost, quickPath, `{"kind": "crockpot", "exclude": []}`)
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
assertFailedWithoutIdeas(t, waitJob(t, h, body["jobId"].(string)), "search_failed")
|
||||
}
|
||||
|
||||
func TestJobsEndpointRefusesQuickKind(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, _, _ := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false)
|
||||
rec, body := callDinners(t, h, http.MethodPost, "/api/v1/jobs", `{"kind": "quick_ideas", "payload": {"kind": "crockpot", "exclude": []}}`)
|
||||
if rec.Code != http.StatusBadRequest || errorCode(body) != "service_only_kind" {
|
||||
t.Fatalf("POST /jobs = %d %v, want 400 service_only_kind", rec.Code, body)
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ type RouteMounter interface {
|
||||
// /api/v1/integrations/google and its /connect, /callback and /calendars,
|
||||
// PUT /api/v1/integrations/google/calendar and DELETE
|
||||
// /api/v1/integrations/google; ideasSvc serves POST
|
||||
// /api/v1/ideas/from-ingredients; runner serves
|
||||
// /api/v1/ideas/from-ingredients and POST /api/v1/ideas/quick; runner serves
|
||||
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
|
||||
// GET /media/photos/{id}-{size}.jpg; assets is the web build
|
||||
// served for every other GET. /health, /auth and the web build are public.
|
||||
@@ -164,6 +164,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
apiV1.Get("/integrations/google/calendars", getGoogleCalendars(logger, googleSvc))
|
||||
apiV1.Put("/integrations/google/calendar", putGoogleCalendar(logger, googleSvc))
|
||||
apiV1.Post("/ideas/from-ingredients", postIdeasFromIngredients(logger, ideasSvc))
|
||||
apiV1.Post("/ideas/quick", postIdeasQuick(logger, ideasSvc))
|
||||
apiV1.Post("/jobs", postJob(logger, runner))
|
||||
apiV1.Get("/jobs/{id}", getJob(logger, runner))
|
||||
media.Get("/photos/{name}", getPhoto(logger, photos))
|
||||
|
||||
@@ -192,7 +192,7 @@ func newTestSuggest(t *testing.T) *suggest.Service {
|
||||
}
|
||||
|
||||
// newTestIdeas returns an ideas service with its own unstarted job runner
|
||||
// and a generator that always fails.
|
||||
// and generators that always fail.
|
||||
func newTestIdeas(t *testing.T) *ideas.Service {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
@@ -205,7 +205,10 @@ func newTestIdeas(t *testing.T) *ideas.Service {
|
||||
fail := func(context.Context, string, skills.IdeasRequest) (skills.Ideas, error) {
|
||||
return skills.Ideas{}, errors.New("no model in this test")
|
||||
}
|
||||
svc, err := ideas.NewService(g, fail, runner, logger)
|
||||
failQuick := func(context.Context, string, skills.QuickIdeasRequest) (skills.QuickIdeaList, error) {
|
||||
return skills.QuickIdeaList{}, errors.New("no model in this test")
|
||||
}
|
||||
svc, err := ideas.NewService(st, g, fail, failQuick, runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user