Compare commits

..
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 d532254bc6 Take in as much of a Regular's outer punctuation as a reason spells
CI / ci (push) Successful in 2m28s
CI / ci (pull_request) Successful in 2m28s
A mention extended over a stored name's optional outer punctuation only
when the reason spelled all of it, so "Like your Beef Chili!" left the
mention of "Beef Chili!!" at "Beef Chili" while "Chili!" extended through
its "!", and neither covered the other. Extend through the longest portion
of the punctuation the reason spells after which the border still holds,
at either end of the name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:00:30 -04:00
dev_agentandClaude Opus 5.5 70a4e540aa Ignore spacing beside a Regular's outer punctuation in a mention
CI / ci (push) Successful in 2m53s
CI / ci (pull_request) Successful in 2m26s
A mention took in a stored name's leading or trailing punctuation only
where the text repeated the stored spacing exactly, although the rule
says spacing is ignored. With Regulars "Beef Chili !" and "Chili!", the
reason "Like your Beef Chili!" left the first mention at "Beef Chili",
so a citation of "Chili!" passed. Match the punctuation with any spacing
beside it, then apply the same border check to the extended mention.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:50:36 -04:00
dev_agentandClaude Opus 5.5 e0e934d8a8 Recheck the border after stored punctuation in a Regular's mention
CI / ci (pull_request) Successful in 2m25s
CI / ci (push) Successful in 2m41s
A mention took in the name's trailing or leading punctuation without
checking the border beyond it, so with Regulars "Beef Chili" and "Beef
Chili!" the text "Beef Chili!X" named "Beef Chili!" alone, though the
shared bare mention is ambiguous (MQ-100-6). Punctuation is now taken in
only where the border still holds after it; otherwise the mention stays
the bare name, and stays shared.

Only punctuation and the spacing beside it may be left off a name's ends,
and a combining mark counts as part of its letter, so a stored "Chicken
Cafe" with a decomposed accent is no longer mentioned by "Chicken Cafe",
nor plain "Chicken Cafe" by the accented spelling (MQ-100-7). Symbols are
no longer trimmed either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:41:46 -04:00
dev_agentandClaude Opus 5.5 97eb97582d Define one boundary rule for Regulars a Side quest's reason names
CI / ci (push) Successful in 2m38s
CI / ci (pull_request) Successful in 2m25s
The reason check split on white space and trimmed punctuation before
looking a phrase up, so a stored name ending in punctuation could be
trimmed into a different Regular (MQ-100-4) and a name joined to the
next word by punctuation was never seen (MQ-100-5).

A mention of a Regular is now where the reason spells its name, ignoring
case and spacing, with the name's own outer punctuation optional and no
letter or digit just outside it. A mention names its Regular unless a
different Regular's mention covers the same text or more, which keeps a
mention of "Easy Beef Chili" from naming "Beef Chili" (MQ-100-3).
One table-driven test covers the whole matrix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:21:31 -04:00
dev_agentandClaude Opus 5.5 622f5359e8 Check a Side quest's reason names the Regular its citation resolved to
CI / ci (pull_request) Successful in 2m39s
CI / ci (push) Successful in 2m30s
The reason check compared normalized names, so a reason naming only
"Beef Chili" passed for a citation of "Easy Beef Chili". Find the
Regulars the reason names with the same find the citations use, taking
the longest run of words at each point, and require a cited Regular
among them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:03:40 -04:00
dev_agentandClaude Opus 5.5 1ca246b715 Match cited Regulars unambiguously and reject filler-only names
CI / ci (push) Successful in 2m25s
CI / ci (pull_request) Successful in 2m26s
A cited Regular now resolves by exact name (ignoring case and spacing)
first, then by normalized name only when that names a single Regular;
an ambiguous citation drops the Side quest instead of rewriting it to
whichever Regular shared the key (MQ-100-1).

A Side quest name with no words left after normalizing is malformed and
goes through the repair turn, so it can no longer evade the dedupe or
the dinner-name rule (MQ-100-2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:48:12 -04:00
dev_agentandClaude Opus 5.5 fad1aee319 Add suggest_side_quests skill v1
CI / ci (push) Successful in 2m38s
CI / ci (pull_request) Successful in 2m43s
The skill asks for twice the Side quests needed, using the household
context plus the pending Side quests, through an embedded, checksummed
prompt template. A malformed reply gets the harness's one repair turn.
A well-formed Side quest is dropped (and listed in Dropped) when it
repeats a Regular, Quest log dinner, pending or passed name or an
earlier Side quest in the batch, cites no Regular or one that is not a
Regular, or has a reason over 180 characters or naming none of its
cited Regulars. Golden tests use llm.Fake.

Maestro task 85a898e1-f1f5-4414-92f3-3277d86afe90.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:34:58 -04:00
dev_agent 2b0e5ff01a Feature 12: Regular dinners and onboarding (#94)
CI / ci (pull_request) Successful in 2m30s
CI / ci (push) Successful in 2m25s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-06 21:28:23 -04:00
30 changed files with 1919 additions and 221 deletions
+28 -7
View File
@@ -179,12 +179,12 @@ automatic** (D-021).
response holds the key. `Service.HarnessConfig` reads the settings for
each skill run, so a saved change applies to the next run.
- Dinners (`dinners.Service`). `POST /api/v1/dinners` adds a Regular from
exactly one of `{name}` or `{recipe_url}` (an https:// link, kept in
`recipe_url` for grounding) with status and origin `regular` and
grounding_status `pending`, answering 201 with the dinner. A link's
dinner is named from its last path segment (separators become spaces,
an extension and all-digit words are dropped: `…/12345/beef-stew/` is
"Beef stew"); a link that names nothing is 400 — add it by name. A name
`{name}` or `{name, recipe_url}` (an https:// link, kept in
`recipe_url` for grounding and never returned in a dinner response, as
it may carry a personal token) with status and origin `regular` and
grounding_status `pending`, answering 201 with the dinner. The name is
always the household's: no part of a link is ever used as a dinner's
name, and a link without a name is 400. A name
is trimmed, at most 120 characters and must keep a word after
`harness.Normalize`, which sets `normalized_name`. A name
`harness.Similar` to an unarchived dinner's, or a link one already has
@@ -250,7 +250,16 @@ automatic** (D-021).
`search_failed` / `ai_failed` (only the reason code is logged, never the
provider's text), and grounding a candidate or link that runs past 90
seconds (fetch, photo and `normalize_recipe`, all in the request) 504
`ground_timeout`, leaving the dinner unchanged.
`ground_timeout`, leaving the dinner unchanged. An archived dinner is 409
`dinner_archived`. A candidate or link is written only if the dinner is
still as it was before grounding began: one renamed or grounded some
other way meanwhile is 409 `ground_conflict`, and one archived meanwhile
409 `dinner_archived`, either leaving the dinner unchanged. A method,
minutes or difficulty a member edits meanwhile is kept over the recipe's.
Any other failure grounding a candidate or link, or recording it on the
dinner, is 500, logged with a fixed reason and the dinner id only —
never the link, which can carry a personal token, or dependency error
text.
- `/health` — liveness.
- Everything else serves the embedded PWA with SPA fallback.
@@ -313,6 +322,18 @@ automatic** (D-021).
dinner when the token-set (Jaccard) similarity of their words is at least
0.85. A `Deduper` over the Regulars, Quest log, pending suggestions and
passed names rejects a suggestion that repeats any of them.
- **Side quest suggestions** (`skills.SuggestSideQuests`, skill
`suggest_side_quests` v1): asks for twice the Side quests needed, at the
household temperature, with the household context plus the pending Side
quests. Malformed replies (bad JSON, a missing field, a bad enum, an
empty name) get the one repair turn. Well-formed Side quests that break a
rule are dropped, not repaired, and listed in `Dropped` with the rule:
one that repeats a Regular, Quest log dinner, pending Side quest, any
passed name (the whole history, not only the 60 in the context) or an
earlier Side quest of the batch; one citing no Regular or a name that is
not a Regular; and one whose reason is over 180 characters or names none
of its cited Regulars. Cited names are rewritten as the household writes
them. Grounding each survivor in a recipe is the caller's next step.
## Recipe grounding
+1
View File
@@ -3,3 +3,4 @@
# version, rename its template to match and add a line for the new version.
echo v1 ab5d7884f5c7a2261c06b0c9353bb9597070c001420bd4cbb6ec6272c093fdce
normalize_recipe v1 7174e5479fc020f3b2b39953d85411381aea26defb9943145e7864c5b752d7ee
suggest_side_quests v1 bd54d7bf9aea0980059a346e5f40ff4c866b80ef9bb9f74e7cbd413be0911c8a
@@ -0,0 +1,22 @@
{{/* suggest_side_quests drafts Side quests: new dinners for the household to try, each based on its Regulars. */}}
{{define "instructions" -}}
You suggest Side quests: new dinners for the household to try, based on the dinners it already makes.
Every Side quest must be a dinner the household has not had: never one of its Regulars, its Quest log, its pending Side quests or the dinners it has passed, and never one of those under another name. Base every Side quest on one or more of the household's Regulars, such as a familiar protein in a new cuisine or familiar flavours cooked a new way. Prefer the favorite Regulars, and vary the cuisines across the batch.
Give for each Side quest:
- name: the dinner's usual name, as a recipe site would title it.
- cuisine: its cuisine, e.g. "Thai" or "Tex-Mex".
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
- est_minutes: your estimate of the whole minutes from starting prep to the table.
- difficulty: "easy", "medium" or "hard" for a home cook.
- reason: one sentence of at most 180 characters saying why the household would like it. It must name at least one of the Regulars in cited_regulars.
- cited_regulars: the Regulars the reason relies on, at least one, each written exactly as its name appears in the Regulars list of the household context, without the details in parentheses. Cite only Regulars from that list.
- search_query: a web search query that finds a recipe for this dinner.
One valid Side quest, for a household with a Regular named "Chicken Fajitas" (an example only, not about this household):
{"name": "Chicken Shawarma Bowls", "cuisine": "Middle Eastern", "method": "oven", "est_minutes": 40, "difficulty": "easy", "reason": "Spiced roast chicken and crisp toppings, like your Chicken Fajitas with a Middle Eastern twist.", "cited_regulars": ["Chicken Fajitas"], "search_query": "chicken shawarma bowls recipe"}
{{- end}}
{{define "user" -}}
Suggest {{.Count}} Side quests.
{{- end}}
+420
View File
@@ -0,0 +1,420 @@
package skills
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"time"
"unicode"
"unicode/utf8"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
)
// ReasonLimit is the most characters a Side quest's reason, its "Why
// this?", may have.
const ReasonLimit = 180
// SideQuestsRequest is what suggest_side_quests reads.
type SideQuestsRequest struct {
// Context is the household context (see harness.BuildContext). It must
// hold at least one Regular, since every Side quest cites one.
Context harness.HouseholdContext
// Needed is how many Side quests the caller wants to keep. The skill
// asks the model for twice as many, since validation and grounding
// drop some. It must be positive.
Needed int
// Pending holds the names of the Side quests not yet tried or passed.
Pending []string
// Declined holds the name of every passed Side quest, not only the
// recent ones Context holds.
Declined []string
}
// SideQuest is one suggested new dinner. Method, EstMinutes and Difficulty
// are the model's estimates until the dinner is grounded in a recipe.
type SideQuest struct {
Name string `json:"name"`
Cuisine string `json:"cuisine"`
// Method is one of stovetop, oven, crockpot, grill and other.
Method string `json:"method"`
EstMinutes int `json:"est_minutes"`
// Difficulty is easy, medium or hard.
Difficulty string `json:"difficulty"`
// Reason is the "Why this?": at most ReasonLimit characters, naming at
// least one of CitedRegulars.
Reason string `json:"reason"`
// CitedRegulars are names of the household's Regulars, as the
// household writes them.
CitedRegulars []string `json:"cited_regulars"`
// SearchQuery is a web search query that finds a recipe for the dinner.
SearchQuery string `json:"search_query"`
}
// SideQuests is suggest_side_quests' output.
type SideQuests struct {
// Suggestions are the Side quests that passed every rule, in the
// model's order. There may be more or fewer than Needed.
Suggestions []SideQuest `json:"suggestions"`
// Dropped are the Side quests the model gave that broke a rule, in the
// model's order. It is never part of the model's reply.
Dropped []DroppedSideQuest `json:"-"`
}
// DroppedSideQuest is a Side quest dropped by PostProcess.
type DroppedSideQuest struct {
Name string
// Why says which rule it broke.
Why string
}
// SuggestSideQuests runs suggest_side_quests v1 on in and returns the Side
// quests that cite only real Regulars, give a short reason naming one of
// them, and repeat no Regular, Quest log dinner, pending or passed Side
// quest, nor each other. The rest are in Dropped. scope names the batch
// (see harness.Run).
func SuggestSideQuests(ctx context.Context, cfg harness.Config, scope string, in SideQuestsRequest) (SideQuests, error) {
return harness.Run(ctx, cfg, suggestSideQuests{in: in}, scope, in)
}
// suggestSideQuests is the skill. It holds its input because validation
// and post-processing compare the output with it.
type suggestSideQuests struct {
in SideQuestsRequest
}
var _ harness.Skill[SideQuestsRequest, SideQuests] = suggestSideQuests{}
func (suggestSideQuests) Name() string { return "suggest_side_quests" }
func (suggestSideQuests) Version() int { return 1 }
func (suggestSideQuests) Timeout() time.Duration { return harness.DefaultTimeout }
// sideQuestsPrompt is the template data of suggest_side_quests.v1.tmpl.
type sideQuestsPrompt struct {
// Count is how many Side quests to ask for.
Count int
}
// requested is how many Side quests the model is asked for.
func (s suggestSideQuests) requested() int { return 2 * s.in.Needed }
func (suggestSideQuests) PromptInput(in SideQuestsRequest) (harness.PromptInput, error) {
if in.Needed <= 0 {
return harness.PromptInput{}, fmt.Errorf("%d Side quests needed; the number must be positive", in.Needed)
}
if len(in.Context.Regulars) == 0 {
return harness.PromptInput{}, errors.New("household has no Regulars for Side quests to be based on")
}
sections := append(in.Context.Sections(), harness.ContextSection{Title: "Pending Side quests", Lines: in.Pending})
return harness.PromptInput{Data: sideQuestsPrompt{Count: suggestSideQuests{in: in}.requested()}, Context: sections}, nil
}
func (suggestSideQuests) Schema() llm.JSONSchema {
return llm.JSONSchema{Name: "suggest_side_quests", Schema: json.RawMessage(`{
"type": "object",
"properties": {
"suggestions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"cuisine": {"type": "string"},
"method": {"type": "string", "enum": ["stovetop", "oven", "crockpot", "grill", "other"]},
"est_minutes": {"type": "integer"},
"difficulty": {"type": "string", "enum": ["easy", "medium", "hard"]},
"reason": {"type": "string"},
"cited_regulars": {"type": "array", "items": {"type": "string"}},
"search_query": {"type": "string"}
},
"required": ["name", "cuisine", "method", "est_minutes", "difficulty", "reason", "cited_regulars", "search_query"],
"additionalProperties": false
}
}
},
"required": ["suggestions"],
"additionalProperties": false
}`)}
}
// UnmarshalJSON decodes a Side quest, requiring each of its fields as the
// schema does: encoding/json would otherwise read a missing or null one as
// empty.
func (q *SideQuest) UnmarshalJSON(data []byte) error {
if err := requireFields(data, nil, "name", "cuisine", "method", "est_minutes", "difficulty", "reason", "cited_regulars", "search_query"); err != nil {
return fmt.Errorf("suggestion %s", err)
}
type plain SideQuest
return json.Unmarshal(data, (*plain)(q))
}
// UnmarshalJSON decodes the output, requiring its one field as the schema
// does.
func (out *SideQuests) UnmarshalJSON(data []byte) error {
if err := requireFields(data, nil, "suggestions"); err != nil {
return fmt.Errorf("output %s", err)
}
type plain SideQuests
return json.Unmarshal(data, (*plain)(out))
}
// Validate finds what makes the reply unusable as a whole: a wrong number
// of Side quests or one that is malformed. A well-formed Side quest that
// breaks a rule is dropped by PostProcess instead.
func (s suggestSideQuests) Validate(out SideQuests) []string {
var problems []string
switch n := len(out.Suggestions); {
case n == 0 && len(out.Dropped) == 0:
problems = append(problems, fmt.Sprintf("suggestions is empty; give %d Side quests", s.requested()))
case n > s.requested():
problems = append(problems, fmt.Sprintf("suggestions has %d entries; give at most %d Side quests", n, s.requested()))
}
for i, q := range out.Suggestions {
for _, f := range []struct{ field, value string }{
{"name", q.Name}, {"cuisine", q.Cuisine}, {"reason", q.Reason}, {"search_query", q.SearchQuery},
} {
if strings.TrimSpace(f.value) == "" {
problems = append(problems, fmt.Sprintf("suggestion %d has an empty %s; give one", i+1, f.field))
}
}
if strings.TrimSpace(q.Name) != "" && harness.Normalize(q.Name) == "" {
problems = append(problems, fmt.Sprintf("suggestion %d name %q has only filler words; name the dish", i+1, q.Name))
}
if !slices.Contains(methods, q.Method) {
problems = append(problems, fmt.Sprintf("suggestion %d method %q is not one of %s", i+1, q.Method, strings.Join(methods, ", ")))
}
if q.EstMinutes <= 0 {
problems = append(problems, fmt.Sprintf("suggestion %d est_minutes %d is not positive; give the minutes from prep to table", i+1, q.EstMinutes))
}
if !slices.Contains(difficulties, q.Difficulty) {
problems = append(problems, fmt.Sprintf("suggestion %d difficulty %q is not one of %s", i+1, q.Difficulty, strings.Join(difficulties, ", ")))
}
}
return problems
}
// PostProcess tidies every Side quest's text, writes its cited Regulars as
// the household does, and moves each one that breaks a rule to Dropped
// (see drop).
func (s suggestSideQuests) PostProcess(out SideQuests) (SideQuests, error) {
regulars := regularNames{exact: map[string][]string{}, normalized: map[string][]string{}}
var names []string
for _, r := range s.in.Context.Regulars {
regulars.add(r.Name)
names = append(names, r.Name)
}
regulars.all = names
known := harness.NewDeduper(harness.DedupeSources{
Regulars: names,
QuestLog: s.in.Context.QuestLog,
Pending: s.in.Pending,
Declined: s.in.Declined,
})
result := SideQuests{Suggestions: []SideQuest{}}
for _, q := range out.Suggestions {
q = SideQuest{
Name: oneLine(q.Name),
Cuisine: oneLine(q.Cuisine),
Method: q.Method,
EstMinutes: q.EstMinutes,
Difficulty: q.Difficulty,
Reason: oneLine(q.Reason),
CitedRegulars: q.CitedRegulars,
SearchQuery: oneLine(q.SearchQuery),
}
cited, why := drop(q, regulars, known, result.Suggestions)
if why != "" {
result.Dropped = append(result.Dropped, DroppedSideQuest{Name: q.Name, Why: why})
continue
}
q.CitedRegulars = cited
result.Suggestions = append(result.Suggestions, q)
}
return result, nil
}
// regularNames finds the Regular a cited name means. Names need not be
// unique, even ignoring case, so each key holds every Regular with it.
type regularNames struct {
// exact is keyed by the name in lower case on one line.
exact map[string][]string
// normalized is keyed by harness.Normalize of the name.
normalized map[string][]string
// all holds every Regular's name, in the household's order.
all []string
}
func exactName(name string) string { return strings.ToLower(oneLine(name)) }
func (r regularNames) add(name string) {
r.exact[exactName(name)] = append(r.exact[exactName(name)], name)
if n := harness.Normalize(name); n != "" {
r.normalized[n] = append(r.normalized[n], name)
}
}
// find returns the one Regular cited means, or "" and why there is none.
// A Regular named exactly cited, ignoring case and spacing, comes first;
// failing that, cited may name a Regular by its normalized name.
func (r regularNames) find(cited string) (string, string) {
matches := r.exact[exactName(cited)]
if len(matches) == 0 {
matches = r.normalized[harness.Normalize(cited)]
}
switch len(matches) {
case 0:
return "", fmt.Sprintf("cites %q, which is not a Regular", cited)
case 1:
return matches[0], ""
default:
return "", fmt.Sprintf("cites %q, which could be any of the Regulars %q", cited, matches)
}
}
// drop returns why q must be dropped, or "" and its cited Regulars as the
// household writes them when it may be kept. q is dropped when it repeats
// a known dinner or a Side quest already kept, cites no Regular or one
// regulars finds no single Regular for, or has a reason over ReasonLimit
// characters or naming none of its cited Regulars.
func drop(q SideQuest, regulars regularNames, known *harness.Deduper, kept []SideQuest) ([]string, string) {
if known.IsDuplicate(q.Name) {
return nil, "repeats a Regular, Quest log dinner, pending or passed Side quest"
}
for _, k := range kept {
if harness.Similar(q.Name, k.Name) {
return nil, fmt.Sprintf("repeats the Side quest %q in the same batch", k.Name)
}
}
if len(q.CitedRegulars) == 0 {
return nil, "cites no Regular"
}
var cited []string
for _, c := range q.CitedRegulars {
name, why := regulars.find(c)
if why != "" {
return nil, why
}
cited = append(cited, name)
}
if n := utf8.RuneCountInString(q.Reason); n > ReasonLimit {
return nil, fmt.Sprintf("reason has %d characters, over %d", n, ReasonLimit)
}
named := regulars.named(q.Reason)
if !slices.ContainsFunc(cited, func(name string) bool { return slices.Contains(named, name) }) {
return nil, "reason names none of its cited Regulars"
}
return cited, ""
}
// named returns the Regulars text names, by one rule. A mention of a
// Regular is a place where text spells its name, ignoring case and spacing,
// with no letter, mark or digit just outside it: the start or end of text,
// white space, punctuation or a symbol must border it, so "Beef
// Chili-inspired" and "Beef Chili's" mention "Beef Chili" while "Beef
// Chilis" does not, nor does "Cafe" with a combining accent after it
// mention "Cafe". Punctuation at either end of the name, with any spacing
// beside it, is optional, and a mention takes in as much of it as text
// spells, spaced in any way, with the border still holding after it: "Easy
// Beef Chili!" is mentioned by "Easy Beef Chili." too, by "Easy Beef Chili
// !" with its "!", and by "Easy Beef Chili!x" without its "!", and "Beef
// Chili!!" by "Beef Chili!" with one "!". A
// combining mark is part of its letter, never punctuation. A mention names
// its Regular unless a mention of a different Regular covers the same text
// or more, so "Your Easy Beef Chili." names "Easy Beef Chili" and not "Beef
// Chili", and text that two Regulars spell alike names neither there.
func (r regularNames) named(text string) []string {
type mention struct {
name string
start, end int
}
text = exactName(text)
bordered := func(start, end int) bool {
before, _ := utf8.DecodeLastRuneInString(text[:start])
after, _ := utf8.DecodeRuneInString(text[end:])
return (start == 0 || !isWord(before)) && (end == len(text) || !isWord(after))
}
var mentions []mention
for _, name := range r.all {
full := exactName(name)
core := strings.TrimFunc(full, isOuter)
if core == "" {
continue
}
lead := full[:len(full)-len(strings.TrimLeftFunc(full, isOuter))]
trail := full[len(lead)+len(core):]
for at := 0; ; {
i := strings.Index(text[at:], core)
if i < 0 {
break
}
start, end := at+i, at+i+len(core)
at = start + 1
if !bordered(start, end) {
continue
}
start = spelledBefore(text, start, lead, func(s int) bool { return bordered(s, end) })
end = spelledAfter(text, end, trail, func(e int) bool { return bordered(start, e) })
mentions = append(mentions, mention{name, start, end})
}
}
var names []string
for _, m := range mentions {
covered := slices.ContainsFunc(mentions, func(o mention) bool {
return o.name != m.name && o.start <= m.start && o.end >= m.end
})
if !covered && !slices.Contains(names, m.name) {
names = append(names, m.name)
}
}
return names
}
// spelledAfter returns where the longest start of outer that text spells
// from at ends, ignoring spacing, among those after which bordered holds,
// or at when there are none.
func spelledAfter(text string, at int, outer string, bordered func(int) bool) int {
end := at
for _, c := range strings.Join(strings.Fields(outer), "") {
at = len(text) - len(strings.TrimLeftFunc(text[at:], unicode.IsSpace))
if !strings.HasPrefix(text[at:], string(c)) {
break
}
at += utf8.RuneLen(c)
if bordered(at) {
end = at
}
}
return end
}
// spelledBefore returns where the longest end of outer that text spells up
// to at starts, ignoring spacing, among those before which bordered holds,
// or at when there are none.
func spelledBefore(text string, at int, outer string, bordered func(int) bool) int {
start := at
punct := []rune(strings.Join(strings.Fields(outer), ""))
for i := len(punct) - 1; i >= 0; i-- {
at = len(strings.TrimRightFunc(text[:at], unicode.IsSpace))
if !strings.HasSuffix(text[:at], string(punct[i])) {
break
}
at -= utf8.RuneLen(punct[i])
if bordered(at) {
start = at
}
}
return start
}
// isWord reports whether c is a letter, a mark on one or a digit.
func isWord(c rune) bool { return unicode.IsLetter(c) || unicode.IsMark(c) || unicode.IsDigit(c) }
// isOuter reports whether c may be left off the end of a name: punctuation
// or white space.
func isOuter(c rune) bool { return unicode.IsPunct(c) || unicode.IsSpace(c) }
@@ -0,0 +1,488 @@
package skills
import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
"slices"
"strings"
"testing"
"time"
"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/store"
)
func date(y int, m time.Month, d int) store.LocalDate {
return store.LocalDateOf(time.Date(y, m, d, 0, 0, 0, 0, time.UTC))
}
// household asks for two Side quests, so four are requested.
var household = SideQuestsRequest{
Context: harness.HouseholdContext{
Today: date(2026, 10, 6),
Timezone: "America/New_York",
Regulars: []harness.RegularDinner{
{Name: "Beef Chili", Cuisine: "Tex-Mex", Method: "crockpot", Minutes: 240},
{Name: "Chicken Fajitas", Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30, Favorite: true},
{Name: "Spaghetti Bolognese", Cuisine: "Italian", Method: "stovetop", Minutes: 45},
},
QuestLog: []string{"Pad Thai"},
Passed: []string{"Chicken Tikka Masala"},
Plans: []harness.PlannedDinner{
{Date: date(2026, 10, 5), Name: "Chicken Fajitas"},
{Date: date(2026, 10, 6), Name: "Beef Chili"},
},
},
Needed: 2,
Pending: []string{"Shrimp Scampi"},
Declined: []string{"Chicken Tikka Masala", "Fish Tacos"},
}
// sideQuest renders one Side quest of a reply.
func sideQuest(name, reason string, cited ...string) string {
c, _ := json.Marshal(append([]string{}, cited...))
r, _ := json.Marshal(reason)
return fmt.Sprintf(`{"name":%q,"cuisine":"Fusion","method":"stovetop","est_minutes":35,"difficulty":"easy","reason":%s,"cited_regulars":%s,"search_query":%q}`,
name, r, c, strings.ToLower(name)+" recipe")
}
// batch renders a reply holding the Side quests given.
func batch(quests ...string) string {
return `{"suggestions":[` + strings.Join(quests, ",") + `]}`
}
var (
shawarma = sideQuest("Chicken Shawarma Bowls", "Spiced chicken and crisp toppings, like your Chicken Fajitas.", "Chicken Fajitas")
ragu = sideQuest("Beef Ragu Pappardelle", "A slow beef sauce like your Spaghetti Bolognese, on wide noodles.", "Spaghetti Bolognese")
verde = sideQuest("Chili Verde", "Your Beef Chili gone green, with pork and tomatillos.", "beef chili")
bulgogi = sideQuest("Beef Bulgogi", "Sweet, quick-seared beef for fans of Chicken Fajitas and Beef Chili.", "Chicken Fajitas", "Beef Chili")
)
// want returns the Side quest that sideQuest renders, with cited as the
// household writes them.
func want(name, reason string, cited ...string) SideQuest {
return SideQuest{
Name: name, Cuisine: "Fusion", Method: "stovetop", EstMinutes: 35, Difficulty: "easy",
Reason: reason, CitedRegulars: cited, SearchQuery: strings.ToLower(name) + " recipe",
}
}
var (
wantShawarma = want("Chicken Shawarma Bowls", "Spiced chicken and crisp toppings, like your Chicken Fajitas.", "Chicken Fajitas")
wantRagu = want("Beef Ragu Pappardelle", "A slow beef sauce like your Spaghetti Bolognese, on wide noodles.", "Spaghetti Bolognese")
wantVerde = want("Chili Verde", "Your Beef Chili gone green, with pork and tomatillos.", "Beef Chili")
wantBulgogi = want("Beef Bulgogi", "Sweet, quick-seared beef for fans of Chicken Fajitas and Beef Chili.", "Chicken Fajitas", "Beef Chili")
)
// suggest runs SuggestSideQuests on household with f and checks f was
// called exactly calls times, each at the household temperature.
func suggest(t *testing.T, f *llm.Fake, in SideQuestsRequest, calls int) (SideQuests, []llm.Request, error) {
t.Helper()
out, err := SuggestSideQuests(context.Background(), testConfig(t, f), "2026-W41", 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 != "suggest_side_quests" {
t.Fatalf("request %d schema = %v, want suggest_side_quests", i+1, req.Schema)
}
}
return out, reqs, err
}
func assertSideQuests(t *testing.T, got, want SideQuests) {
t.Helper()
if !reflect.DeepEqual(got, want) {
t.Fatalf("output = %+v\nwant %+v", got, want)
}
}
func TestSuggestSideQuestsCleanBatch(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(batch(shawarma, ragu, verde, bulgogi)))
out, reqs, err := suggest(t, &f, household, 1)
if err != nil {
t.Fatal(err)
}
assertSideQuests(t, out, SideQuests{Suggestions: []SideQuest{wantShawarma, wantRagu, wantVerde, wantBulgogi}})
system, user := reqs[0].System, reqs[0].Messages[0].Content
golden(t, "suggest_side_quests_clean.system.golden", system)
golden(t, "suggest_side_quests_clean.user.golden", user)
for _, part := range []string{
"## Household context",
"- Chicken Fajitas (Tex-Mex, stovetop, 30 min, favorite)",
"### Quest log\n- Pad Thai",
"### Passed, oldest first\n- Chicken Tikka Masala",
"- 2026-10-06 Tue: Beef Chili",
"### Pending Side quests\n- Shrimp Scampi",
"## Output contract",
`"cited_regulars"`,
`"search_query"`,
} {
if !strings.Contains(system, part) {
t.Errorf("system prompt lacks %q", part)
}
}
if strings.Index(system, "## Household context") > strings.Index(system, "## Output contract") {
t.Error("the output contract is not after the household context")
}
if user != "Suggest 4 Side quests." {
t.Errorf("user message = %q, want twice the 2 needed", user)
}
}
func TestSuggestSideQuestsPersonaFirst(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(batch(shawarma)))
cfg := testConfig(t, &f)
cfg.Persona = "You are the household's cheerful kitchen helper."
if _, err := SuggestSideQuests(context.Background(), cfg, "2026-W41", household); err != nil {
t.Fatal(err)
}
if system := f.Requests()[0].System; !strings.HasPrefix(system, cfg.Persona+"\n\n## Instructions\n") {
t.Fatalf("system prompt does not start with the persona then the instructions:\n%s", system)
}
}
func TestSuggestSideQuestsInventedRegularDropped(t *testing.T) {
invented := sideQuest("Meatloaf Wellington", "Your Grandma's Meatloaf, wrapped in pastry.", "Grandma's Meatloaf")
partly := sideQuest("Fajita Meatballs", "Chicken Fajitas meets your Grandma's Meatloaf.", "Chicken Fajitas", "Grandma's Meatloaf")
none := sideQuest("Mushroom Risotto", "Creamy and comforting.")
var f llm.Fake
f.QueueGenerate(reply(batch(invented, shawarma, partly, none)))
out, _, err := suggest(t, &f, household, 1)
if err != nil {
t.Fatal(err)
}
assertSideQuests(t, out, SideQuests{
Suggestions: []SideQuest{wantShawarma},
Dropped: []DroppedSideQuest{
{Name: "Meatloaf Wellington", Why: `cites "Grandma's Meatloaf", which is not a Regular`},
{Name: "Fajita Meatballs", Why: `cites "Grandma's Meatloaf", which is not a Regular`},
{Name: "Mushroom Risotto", Why: "cites no Regular"},
},
})
}
func TestSuggestSideQuestsSharedNormalizedRegularNotRewritten(t *testing.T) {
in := household
in.Context.Regulars = append(slices.Clone(household.Context.Regulars),
harness.RegularDinner{Name: "Easy Beef Chili", Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30})
plain := sideQuest("Chili Verde", "Your Beef Chili gone green, with pork and tomatillos.", "beef chili")
easy := sideQuest("Frito Pie", "Your Easy Beef Chili over corn chips.", "EASY beef chili")
either := sideQuest("Tamale Bake", "Your Beef Chili under a cornbread crust.", "Beef Chili!")
var f llm.Fake
f.QueueGenerate(reply(batch(plain, easy, either)))
out, _, err := suggest(t, &f, in, 1)
if err != nil {
t.Fatal(err)
}
assertSideQuests(t, out, SideQuests{
Suggestions: []SideQuest{
want("Chili Verde", "Your Beef Chili gone green, with pork and tomatillos.", "Beef Chili"),
want("Frito Pie", "Your Easy Beef Chili over corn chips.", "Easy Beef Chili"),
},
Dropped: []DroppedSideQuest{
{Name: "Tamale Bake", Why: `cites "Beef Chili!", which could be any of the Regulars ["Beef Chili" "Easy Beef Chili"]`},
},
})
}
func TestSuggestSideQuestsReasonNamesResolvedRegular(t *testing.T) {
in := household
in.Context.Regulars = append(slices.Clone(household.Context.Regulars),
harness.RegularDinner{Name: "Easy Beef Chili", Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30})
other := sideQuest("Chili Verde", "Your Beef Chili gone green, with pork and tomatillos.", "Easy Beef Chili")
easy := sideQuest("Frito Pie", "Your Easy Beef Chili over corn chips.", "Easy Beef Chili")
plain := sideQuest("Chili Mac", "The Beef Chili, not the Easy Beef Chili, over macaroni.", "Beef Chili")
inside := sideQuest("Tamale Bake", "Your Easy Beef Chili under a cornbread crust.", "Beef Chili")
var f llm.Fake
f.QueueGenerate(reply(batch(other, easy, plain, inside)))
out, _, err := suggest(t, &f, in, 1)
if err != nil {
t.Fatal(err)
}
assertSideQuests(t, out, SideQuests{
Suggestions: []SideQuest{
want("Frito Pie", "Your Easy Beef Chili over corn chips.", "Easy Beef Chili"),
want("Chili Mac", "The Beef Chili, not the Easy Beef Chili, over macaroni.", "Beef Chili"),
},
Dropped: []DroppedSideQuest{
{Name: "Chili Verde", Why: "reason names none of its cited Regulars"},
{Name: "Tamale Bake", Why: "reason names none of its cited Regulars"},
},
})
}
func TestSuggestSideQuestsReasonMentionRule(t *testing.T) {
const none = "reason names none of its cited Regulars"
for name, tc := range map[string]struct {
extra []string // Regulars added to household's
reason string
cited string
why string // "" when the Side quest is kept
}{
"exact name": {nil, "Like your Beef Chili, gone green.", "Beef Chili", ""},
"other case and spacing": {nil, "Like your beef CHILI, gone green.", "Beef Chili", ""},
"sentence punctuation": {nil, `Green, like "Beef Chili" (yours).`, "Beef Chili", ""},
"possessive": {nil, "Beef Chili's spices, gone green.", "Beef Chili", ""},
"joined by punctuation": {nil, "A Beef Chili-inspired stew.", "Beef Chili", ""},
"no mention": {nil, "Grilled skewers with a peanut sauce.", "Beef Chili", none},
"letters run on": {[]string{"Ham"}, "Like your Hamburgers, glazed.", "Ham", none},
"stored punctuation written": {[]string{"Easy Beef Chili!"}, "Your Easy Beef Chili! over corn chips.", "Easy Beef Chili!", ""},
"stored punctuation as period": {[]string{"Easy Beef Chili!"}, "Corn chips under your Easy Beef Chili.", "Easy Beef Chili!", ""},
"inside stored punctuation": {[]string{"Easy Beef Chili!"}, "Your Easy Beef Chili! over corn chips.", "Beef Chili", none},
"inside before period": {[]string{"Easy Beef Chili!"}, "Corn chips under your Easy Beef Chili.", "Beef Chili", none},
"longer name cited": {[]string{"Easy Beef Chili"}, "Your Easy Beef Chili over corn chips.", "Easy Beef Chili", ""},
"shorter inside longer": {[]string{"Easy Beef Chili"}, "Your Easy Beef Chili over corn chips.", "Beef Chili", none},
"longer not mentioned": {[]string{"Easy Beef Chili"}, "Your Beef Chili over corn chips.", "Easy Beef Chili", none},
"shorter also on its own": {[]string{"Easy Beef Chili"}, "The Beef Chili, not the Easy Beef Chili.", "Beef Chili", ""},
"punctuation-only difference": {[]string{"Beef Chili!"}, "Your Beef Chili! over corn chips.", "Beef Chili!", ""},
"bare name inside punctuated": {[]string{"Beef Chili!"}, "Your Beef Chili! over corn chips.", "Beef Chili", none},
"spelled alike by both": {[]string{"Beef Chili!"}, "Corn chips under your Beef Chili.", "Beef Chili", none},
"punctuation then a letter": {[]string{"Beef Chili!"}, "Your Beef Chili!X is our inspiration.", "Beef Chili!", none},
"letter before punctuation": {[]string{"¡Beef Chili"}, "Your X¡Beef Chili is our inspiration.", "¡Beef Chili", none},
"spacing before punctuation": {[]string{"Beef Chili!"}, "Your Beef Chili ! over corn chips.", "Beef Chili!", ""},
"spacing after punctuation": {[]string{"¡Beef Chili"}, "Your ¡ Beef Chili is our inspiration.", "¡Beef Chili", ""},
"stored spacing left off": {[]string{"Beef Chili !", "Chili!"}, "Like your Beef Chili!", "Beef Chili !", ""},
"inside unspaced punctuation": {[]string{"Beef Chili !", "Chili!"}, "Like your Beef Chili!", "Chili!", none},
"stored punctuation partly": {[]string{"Beef Chili!!", "Chili!"}, "Like your Beef Chili!", "Beef Chili!!", ""},
"inside partial punctuation": {[]string{"Beef Chili!!", "Chili!"}, "Like your Beef Chili!", "Chili!", none},
"partly then a letter": {[]string{"Beef Chili!!", "Chili!"}, "Your Beef Chili!!X is our inspiration.", "Chili!", none},
"leading punctuation partly": {[]string{"¡¡Beef Chili", "¡Beef"}, "Like your ¡Beef Chili.", "¡¡Beef Chili", ""},
"inside partial leading": {[]string{"¡¡Beef Chili", "¡Beef"}, "Like your ¡Beef Chili.", "¡Beef", none},
"stored symbol left off": {[]string{"Green Chili 🌶"}, "Like your Green Chili, gone green.", "Green Chili 🌶", none},
"stored accent left off": {[]string{"Chicken Cafe\u0301"}, "Like your Chicken Cafe, gone green.", "Chicken Cafe\u0301", none},
"accent written after name": {[]string{"Chicken Cafe"}, "Like your Chicken Cafe\u0301, gone green.", "Chicken Cafe", none},
} {
t.Run(name, func(t *testing.T) {
in := household
in.Context.Regulars = slices.Clone(household.Context.Regulars)
for _, r := range tc.extra {
in.Context.Regulars = append(in.Context.Regulars, harness.RegularDinner{Name: r, Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30})
}
var f llm.Fake
f.QueueGenerate(reply(batch(sideQuest("Frito Pie", tc.reason, tc.cited))))
out, _, err := suggest(t, &f, in, 1)
if err != nil {
t.Fatal(err)
}
result := SideQuests{Suggestions: []SideQuest{want("Frito Pie", oneLine(tc.reason), tc.cited)}}
if tc.why != "" {
result = SideQuests{Suggestions: []SideQuest{}, Dropped: []DroppedSideQuest{{Name: "Frito Pie", Why: tc.why}}}
}
assertSideQuests(t, out, result)
})
}
}
func TestSuggestSideQuestsDuplicatesDropped(t *testing.T) {
declined := sideQuest("The Best Homemade Chicken Tikka-Masala", "Spiced like your Chicken Fajitas.", "Chicken Fajitas")
oldDeclined := sideQuest("Fish Tacos!", "Tortillas like your Chicken Fajitas.", "Chicken Fajitas")
regular := sideQuest("Easy Beef Chili", "Just like your Beef Chili.", "Beef Chili")
questLog := sideQuest("Pad Thai", "Noodles like your Spaghetti Bolognese.", "Spaghetti Bolognese")
pending := sideQuest("Shrimp Scampi", "Pasta like your Spaghetti Bolognese.", "Spaghetti Bolognese")
again := sideQuest("Chicken Shawarma Bowls", "Chicken Fajitas, but Middle Eastern.", "Chicken Fajitas")
var f llm.Fake
f.QueueGenerate(reply(batch(declined, oldDeclined, regular, questLog, pending, shawarma, again)))
in := household
in.Needed = 4
out, _, err := suggest(t, &f, in, 1)
if err != nil {
t.Fatal(err)
}
known := "repeats a Regular, Quest log dinner, pending or passed Side quest"
assertSideQuests(t, out, SideQuests{
Suggestions: []SideQuest{wantShawarma},
Dropped: []DroppedSideQuest{
{Name: "The Best Homemade Chicken Tikka-Masala", Why: known},
{Name: "Fish Tacos!", Why: known},
{Name: "Easy Beef Chili", Why: known},
{Name: "Pad Thai", Why: known},
{Name: "Shrimp Scampi", Why: known},
{Name: "Chicken Shawarma Bowls", Why: `repeats the Side quest "Chicken Shawarma Bowls" in the same batch`},
},
})
}
func TestSuggestSideQuestsReasonRulesDropped(t *testing.T) {
long := sideQuest("Beef Stroganoff", "Your Beef Chili"+strings.Repeat(" and more", 20)+".", "Beef Chili")
unnamed := sideQuest("Chicken Satay", "Grilled skewers with a peanut sauce.", "Chicken Fajitas")
wrongOne := sideQuest("Beef Tacos", "Like your Beef Chili, in a shell.", "Chicken Fajitas")
var f llm.Fake
f.QueueGenerate(reply(batch(long, unnamed, ragu, wrongOne)))
out, _, err := suggest(t, &f, household, 1)
if err != nil {
t.Fatal(err)
}
assertSideQuests(t, out, SideQuests{
Suggestions: []SideQuest{wantRagu},
Dropped: []DroppedSideQuest{
{Name: "Beef Stroganoff", Why: "reason has 196 characters, over 180"},
{Name: "Chicken Satay", Why: "reason names none of its cited Regulars"},
{Name: "Beef Tacos", Why: "reason names none of its cited Regulars"},
},
})
}
func TestSuggestSideQuestsAllDroppedIsNotRepaired(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(batch(sideQuest("Pad Thai", "Like your Beef Chili.", "Beef Chili"))))
out, _, err := suggest(t, &f, household, 1)
if err != nil {
t.Fatal(err)
}
assertSideQuests(t, out, SideQuests{
Suggestions: []SideQuest{},
Dropped: []DroppedSideQuest{{Name: "Pad Thai", Why: "repeats a Regular, Quest log dinner, pending or passed Side quest"}},
})
}
func TestSuggestSideQuestsMalformedRepairedOnce(t *testing.T) {
for name, tc := range map[string]struct{ first, want string }{
"truncated JSON": {
`{"suggestions":[` + shawarma + `,{"name":"Beef`,
"reply does not decode: unexpected EOF",
},
"omitted field": {
batch(strings.Replace(shawarma, `"cuisine":"Fusion",`, ``, 1)),
`suggestion has no "cuisine" field; give every field the schema requires`,
},
"unknown field": {
batch(strings.Replace(shawarma, `"easy",`, `"easy","spice":"mild",`, 1)),
`suggestion has unknown field "spice"`,
},
"bad method": {
batch(strings.Replace(shawarma, `"stovetop"`, `"air fryer"`, 1)),
`suggestion 1 method "air fryer" is not one of stovetop, oven, crockpot, grill, other`,
},
} {
t.Run(name, func(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(tc.first), reply(batch(shawarma, ragu)))
out, reqs, err := suggest(t, &f, household, 2)
if err != nil {
t.Fatal(err)
}
assertSideQuests(t, out, SideQuests{Suggestions: []SideQuest{wantShawarma, wantRagu}})
repair := reqs[1].Messages
if len(repair) != 3 || repair[1].Content != tc.first {
t.Fatalf("repair turn does not quote the first reply: %+v", repair)
}
if !strings.Contains(repair[2].Content, tc.want) {
t.Fatalf("repair message = %q, want it to contain %q", repair[2].Content, tc.want)
}
})
}
}
func TestSuggestSideQuestsFillerNameRepaired(t *testing.T) {
filler := sideQuest("The.", "Like your Beef Chili.", "Beef Chili")
first := batch(filler, filler)
var f llm.Fake
f.QueueGenerate(reply(first), reply(batch(shawarma, ragu)))
out, reqs, err := suggest(t, &f, household, 2)
if err != nil {
t.Fatal(err)
}
assertSideQuests(t, out, SideQuests{Suggestions: []SideQuest{wantShawarma, wantRagu}})
repair := reqs[1].Messages
if len(repair) != 3 || repair[1].Content != first {
t.Fatalf("repair turn does not quote the first reply: %+v", repair)
}
for _, want := range []string{
`suggestion 1 name "The." has only filler words; name the dish`,
`suggestion 2 name "The." has only filler words; name the dish`,
} {
if !strings.Contains(repair[2].Content, want) {
t.Fatalf("repair message = %q, want it to contain %q", repair[2].Content, want)
}
}
}
func TestSuggestSideQuestsMalformedTwiceFails(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(`{"suggestions":`), reply(`{"suggestions":[]}`))
out, _, err := suggest(t, &f, household, 2)
var bad *harness.BadOutputError
if !errors.As(err, &bad) || !errors.Is(err, llm.ErrBadOutput) {
t.Fatalf("err = %v, want a BadOutputError", err)
}
if !slices.Equal(bad.Repair, []string{"suggestions is empty; give 4 Side quests"}) {
t.Fatalf("repair problems = %q", bad.Repair)
}
if out.Suggestions != nil {
t.Fatalf("output = %+v, want none", out)
}
}
func TestSideQuestsValidate(t *testing.T) {
s := suggestSideQuests{in: household}
if p := s.Validate(SideQuests{Suggestions: []SideQuest{wantShawarma}}); len(p) != 0 {
t.Fatalf("valid output has problems: %q", p)
}
if p := s.Validate(SideQuests{Suggestions: []SideQuest{}, Dropped: []DroppedSideQuest{{Name: "x", Why: "y"}}}); len(p) != 0 {
t.Fatalf("output whose Side quests were all dropped has problems: %q", p)
}
for name, tc := range map[string]struct {
out SideQuests
want []string
}{
"empty": {SideQuests{}, []string{"suggestions is empty; give 4 Side quests"}},
"too many": {
SideQuests{Suggestions: []SideQuest{wantShawarma, wantRagu, wantVerde, wantBulgogi, wantShawarma}},
[]string{"suggestions has 5 entries; give at most 4 Side quests"},
},
"malformed": {
SideQuests{Suggestions: []SideQuest{{Name: " ", Method: "air fryer", EstMinutes: 0, Difficulty: "trivial", CitedRegulars: []string{}}}},
[]string{
"suggestion 1 has an empty name; give one",
"suggestion 1 has an empty cuisine; give one",
"suggestion 1 has an empty reason; give one",
"suggestion 1 has an empty search_query; give one",
`suggestion 1 method "air fryer" is not one of stovetop, oven, crockpot, grill, other`,
"suggestion 1 est_minutes 0 is not positive; give the minutes from prep to table",
`suggestion 1 difficulty "trivial" is not one of easy, medium, hard`,
},
},
} {
t.Run(name, func(t *testing.T) {
if got := s.Validate(tc.out); !slices.Equal(got, tc.want) {
t.Fatalf("problems =\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(tc.want, "\n"))
}
})
}
}
func TestSideQuestsPromptInputRejects(t *testing.T) {
noRegulars := household
noRegulars.Context.Regulars = nil
none := household
none.Needed = 0
for name, tc := range map[string]struct {
in SideQuestsRequest
want string
}{
"no Regulars": {noRegulars, "household has no Regulars for Side quests to be based on"},
"none needed": {none, "0 Side quests needed; the number must be positive"},
} {
t.Run(name, func(t *testing.T) {
_, err := suggestSideQuests{in: tc.in}.PromptInput(tc.in)
if err == nil || err.Error() != tc.want {
t.Fatalf("err = %v, want %q", err, tc.want)
}
})
}
}
@@ -0,0 +1,110 @@
## Instructions
You suggest Side quests: new dinners for the household to try, based on the dinners it already makes.
Every Side quest must be a dinner the household has not had: never one of its Regulars, its Quest log, its pending Side quests or the dinners it has passed, and never one of those under another name. Base every Side quest on one or more of the household's Regulars, such as a familiar protein in a new cuisine or familiar flavours cooked a new way. Prefer the favorite Regulars, and vary the cuisines across the batch.
Give for each Side quest:
- name: the dinner's usual name, as a recipe site would title it.
- cuisine: its cuisine, e.g. "Thai" or "Tex-Mex".
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
- est_minutes: your estimate of the whole minutes from starting prep to the table.
- difficulty: "easy", "medium" or "hard" for a home cook.
- reason: one sentence of at most 180 characters saying why the household would like it. It must name at least one of the Regulars in cited_regulars.
- cited_regulars: the Regulars the reason relies on, at least one, each written exactly as its name appears in the Regulars list of the household context, without the details in parentheses. Cite only Regulars from that list.
- search_query: a web search query that finds a recipe for this dinner.
One valid Side quest, for a household with a Regular named "Chicken Fajitas" (an example only, not about this household):
{"name": "Chicken Shawarma Bowls", "cuisine": "Middle Eastern", "method": "oven", "est_minutes": 40, "difficulty": "easy", "reason": "Spiced roast chicken and crisp toppings, like your Chicken Fajitas with a Middle Eastern twist.", "cited_regulars": ["Chicken Fajitas"], "search_query": "chicken shawarma bowls recipe"}
## Household context
### Today
- Tuesday 2026-10-06 (America/New_York)
### Regulars
- Beef Chili (Tex-Mex, crockpot, 240 min)
- Chicken Fajitas (Tex-Mex, stovetop, 30 min, favorite)
- Spaghetti Bolognese (Italian, stovetop, 45 min)
### Quest log
- Pad Thai
### Passed, oldest first
- Chicken Tikka Masala
### Recent plans
- 2026-10-05 Mon: Chicken Fajitas
- 2026-10-06 Tue: Beef Chili
### Pending Side quests
- Shrimp Scampi
## Output contract
This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "suggest_side_quests" below, and nothing else: no prose and no Markdown code fences.
{
"type": "object",
"properties": {
"suggestions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"cuisine": {
"type": "string"
},
"method": {
"type": "string",
"enum": [
"stovetop",
"oven",
"crockpot",
"grill",
"other"
]
},
"est_minutes": {
"type": "integer"
},
"difficulty": {
"type": "string",
"enum": [
"easy",
"medium",
"hard"
]
},
"reason": {
"type": "string"
},
"cited_regulars": {
"type": "array",
"items": {
"type": "string"
}
},
"search_query": {
"type": "string"
}
},
"required": [
"name",
"cuisine",
"method",
"est_minutes",
"difficulty",
"reason",
"cited_regulars",
"search_query"
],
"additionalProperties": false
}
}
},
"required": [
"suggestions"
],
"additionalProperties": false
}
@@ -0,0 +1 @@
Suggest 4 Side quests.
+49 -19
View File
@@ -248,46 +248,76 @@ func (q *Queries) GetDinner(ctx context.Context, id string) (Dinner, error) {
const groundDinner = `-- name: GroundDinner :one
UPDATE dinners
SET recipe_id = ?,
SET recipe_id = ?1,
recipe_url = NULL,
photo_id = ?,
method = ?,
minutes_total = ?,
difficulty = ?,
grounding_status = ?,
photo_id = ?2,
method = CASE WHEN method IS ?3
THEN ?4 ELSE method END,
minutes_total = CASE WHEN minutes_total IS ?5
THEN ?6 ELSE minutes_total END,
difficulty = CASE WHEN difficulty IS ?7
THEN ?8 ELSE difficulty END,
grounding_status = ?9,
grounding_error = NULL,
updated_by = ?,
updated_at = ?
WHERE id = ?
updated_by = ?10,
updated_at = ?11
WHERE id = ?12
AND status <> 'archived'
AND name = ?13
AND normalized_name = ?14
AND grounding_status = ?15
AND grounding_revision = ?16
AND recipe_id IS ?17
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
`
type GroundDinnerParams struct {
RecipeID sql.NullString
PhotoID sql.NullString
Method sql.NullString
MinutesTotal sql.NullInt64
Difficulty sql.NullString
GroundingStatus string
UpdatedBy sql.NullString
UpdatedAt string
ID string
RecipeID sql.NullString
PhotoID sql.NullString
ObservedMethod sql.NullString
Method sql.NullString
ObservedMinutesTotal sql.NullInt64
MinutesTotal sql.NullInt64
ObservedDifficulty sql.NullString
Difficulty sql.NullString
GroundingStatus string
UpdatedBy sql.NullString
UpdatedAt string
ID string
ObservedName string
ObservedNormalizedName string
ObservedGroundingStatus string
ObservedGroundingRevision int64
ObservedRecipeID sql.NullString
}
// Records a dinner's grounding by hand (grounded or manual) and clears any
// grounding_error and pasted recipe_url: the recipe chosen replaces the
// link. An unknown id returns sql.ErrNoRows.
// link. It writes only an unarchived dinner still as it was observed
// before grounding (same name, normalized_name, grounding_status,
// grounding_revision and recipe_id), so a dinner renamed, archived or
// grounded some other way meanwhile is left as it is. That dinner, or an
// unknown id, returns sql.ErrNoRows. A method, minutes_total or difficulty
// no longer as observed is kept: a member edited it while it was grounding.
func (q *Queries) GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error) {
row := q.db.QueryRowContext(ctx, groundDinner,
arg.RecipeID,
arg.PhotoID,
arg.ObservedMethod,
arg.Method,
arg.ObservedMinutesTotal,
arg.MinutesTotal,
arg.ObservedDifficulty,
arg.Difficulty,
arg.GroundingStatus,
arg.UpdatedBy,
arg.UpdatedAt,
arg.ID,
arg.ObservedName,
arg.ObservedNormalizedName,
arg.ObservedGroundingStatus,
arg.ObservedGroundingRevision,
arg.ObservedRecipeID,
)
var i Dinner
err := row.Scan(
+6 -1
View File
@@ -69,7 +69,12 @@ type Querier interface {
GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error)
// Records a dinner's grounding by hand (grounded or manual) and clears any
// grounding_error and pasted recipe_url: the recipe chosen replaces the
// link. An unknown id returns sql.ErrNoRows.
// link. It writes only an unarchived dinner still as it was observed
// before grounding (same name, normalized_name, grounding_status,
// grounding_revision and recipe_id), so a dinner renamed, archived or
// grounded some other way meanwhile is left as it is. That dinner, or an
// unknown id, returns sql.ErrNoRows. A method, minutes_total or difficulty
// no longer as observed is kept: a member edited it while it was grounding.
GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error)
// Records the background grounding of an unarchived dinner still pending at
// the grounding_revision it was grounded for; a dinner grounded some other
+19 -58
View File
@@ -16,7 +16,6 @@ import (
"slices"
"strings"
"sync"
"unicode"
"unicode/utf8"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
@@ -55,7 +54,9 @@ var listable = []string{StatusRegular, StatusQuestLog}
// ErrNotFound is returned for an unknown dinner id.
var ErrNotFound = errors.New("dinner not found")
// Dinner is a dinner as the API shows it.
// Dinner is a dinner as the API shows it. It has no recipe_url: a pasted
// link may carry a personal token, so it stays in the database for
// grounding and never leaves the server.
type Dinner struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -67,7 +68,6 @@ type Dinner struct {
Cuisine *string `json:"cuisine"`
IsFavorite bool `json:"is_favorite"`
RecipeID *string `json:"recipe_id"`
RecipeURL *string `json:"recipe_url"`
PhotoURL *string `json:"photo_url"`
GroundingStatus string `json:"grounding_status"`
// GroundingError is why grounding failed, one of the Reason codes;
@@ -78,7 +78,10 @@ type Dinner struct {
UpdatedAt string `json:"updated_at"`
}
// NewRegular is a Regular to add: exactly one of Name and RecipeURL.
// NewRegular is a Regular to add: its Name, which the household always
// gives, and optionally the RecipeURL to ground it in. The name is never
// taken from the link: a pasted link may carry a personal token anywhere in
// it, and the name is shown in every dinner response.
type NewRegular struct {
Name *string
RecipeURL *string
@@ -170,10 +173,9 @@ type Created struct {
GroundingJobID *string `json:"grounding_job_id"`
}
// CreateRegular adds a Regular named by r.Name, or by the last path segment
// of r.RecipeURL (whose recipe grounding later replaces), with status and
// origin regular and grounding_status pending, recording updatedBy, and
// enqueues the KindGroundDinner job that grounds it. It returns without
// CreateRegular adds a Regular named r.Name, keeping r.RecipeURL for
// grounding when given, with status and origin regular and grounding_status
// pending, recording updatedBy, and enqueues the KindGroundDinner job that grounds it. It returns without
// waiting for the job. A name repeating an unarchived dinner is a
// *DuplicateError. When the job cannot be enqueued, the stored dinner is
// returned with grounding_status failed (ReasonGroundingError) and no job
@@ -223,34 +225,24 @@ func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy str
if updatedBy == "" {
return db.Dinner{}, errors.New("dinners: create: no member to record as updated_by")
}
var name string
if r.Name == nil {
return db.Dinner{}, invalid("name", "give the dinner's name, with or without a recipe_url")
}
name, err := checkName(*r.Name)
if err != nil {
return db.Dinner{}, err
}
var recipeURL sql.NullString
switch {
case r.Name != nil && r.RecipeURL != nil:
return db.Dinner{}, invalid("name", "give a name or a recipe_url, not both")
case r.Name != nil:
n, err := checkName(*r.Name)
if err != nil {
return db.Dinner{}, err
}
name = n
case r.RecipeURL != nil:
if r.RecipeURL != nil {
u, err := checkRecipeURL(*r.RecipeURL)
if err != nil {
return db.Dinner{}, err
}
n, ok := nameFromURL(u)
if !ok {
return db.Dinner{}, invalid("recipe_url", "the link does not name a dinner; add it by name instead")
}
name = n
recipeURL = sql.NullString{String: u.String(), Valid: true}
default:
return db.Dinner{}, invalid("name", "give a name or a recipe_url")
}
var row db.Dinner
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
if err := checkUnique(ctx, q, "", name, recipeURL); err != nil {
return err
@@ -495,36 +487,6 @@ func checkRecipeURL(raw string) (*url.URL, error) {
return u, nil
}
// nameFromURL names a dinner from the last path segment of u: separators
// become spaces, a file extension and words of only digits are dropped and
// the first letter is capitalized, so
// https://example.com/recipes/12345/chicken-tikka-masala/ is
// "Chicken tikka masala". ok is false when no usable name is left.
func nameFromURL(u *url.URL) (name string, ok bool) {
segments := strings.FieldsFunc(u.Path, func(r rune) bool { return r == '/' })
if len(segments) == 0 {
return "", false
}
slug := segments[len(segments)-1]
if i := strings.LastIndexByte(slug, '.'); i > 0 {
slug = slug[:i]
}
var words []string
for _, w := range strings.FieldsFunc(slug, func(r rune) bool { return r == '-' || r == '_' || r == '+' || unicode.IsSpace(r) }) {
if strings.IndexFunc(w, func(r rune) bool { return !unicode.IsDigit(r) }) >= 0 {
words = append(words, strings.ToLower(w))
}
}
name = strings.Join(words, " ")
if r, size := utf8.DecodeRuneInString(name); r != utf8.RuneError {
name = string(unicode.ToUpper(r)) + name[size:]
}
if _, err := checkName(name); err != nil {
return "", false
}
return name, true
}
// checkUnique returns a *DuplicateError when name is harness.Similar to an
// unarchived dinner other than self, or recipeURL is one's recipe link: the
// source_url of the recipe it is grounded in, else the recipe_url still
@@ -565,7 +527,6 @@ func fromRow(r db.Dinner) Dinner {
Cuisine: nullString(r.Cuisine),
IsFavorite: r.IsFavorite == 1,
RecipeID: nullString(r.RecipeID),
RecipeURL: nullString(r.RecipeUrl),
GroundingStatus: r.GroundingStatus,
GroundingError: nullString(r.GroundingError),
UpdatedBy: nullString(r.UpdatedBy),
+65 -24
View File
@@ -1,33 +1,74 @@
package dinners
package dinners_test
import (
"net/url"
"encoding/json"
"errors"
"strings"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
)
func TestNameFromURL(t *testing.T) {
// TestCreateRegularNeverNamesFromLink: a Regular added with a recipe link
// is named only by the household, so no part of the link — host, path,
// query or fragment — is stored as its name or shown in a dinner or a
// duplicate error, even a token of plain letters in the path.
func TestCreateRegularNeverNamesFromLink(t *testing.T) {
t.Parallel()
tests := []struct {
link string
want string // "" means no usable name
}{
{"https://example.com/recipes/12345/chicken-tikka-masala/", "Chicken tikka masala"},
{"https://example.com/recipe/beef_stew.html", "Beef stew"},
{"https://example.com/2024/05/best-ever-chili-123", "Best ever chili"},
{"https://example.com/crème-brûlée", "Crème brûlée"},
{"https://example.com/recipes/chili?utm=1#notes", "Chili"},
{"https://example.com/", ""},
{"https://example.com/recipe/98765", ""},
{"https://example.com/the-best-easy-recipe", ""},
}
for _, tt := range tests {
u, err := url.Parse(tt.link)
if err != nil {
t.Fatal(err)
}
got, ok := nameFromURL(u)
if got != tt.want || ok != (tt.want != "") {
t.Errorf("nameFromURL(%s) = %q, %v; want %q", tt.link, got, ok, tt.want)
e := newUnstartedEnv(t)
const link = "https://c.example/stew-key-abcdefghijklmno?sig=zyxwvut#frag"
leaks := []string{"c.example", "key", "abcdefghijklmno", "sig", "zyxwvut", "frag"}
check := func(what, text string) {
t.Helper()
lower := strings.ToLower(text)
for _, leak := range leaks {
if strings.Contains(lower, leak) {
t.Errorf("%s contains %q: %s", what, leak, text)
}
}
}
_, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{RecipeURL: ptr(link)}, member)
var verr *dinners.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("link without a name: err = %v, want a *ValidationError", err)
}
check("validation error", verr.Error())
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr(" Sunday stew "), RecipeURL: ptr(link)}, member)
if err != nil {
t.Fatal(err)
}
if c.Name != "Sunday stew" {
t.Errorf("name = %q, want the household's %q", c.Name, "Sunday stew")
}
created, err := json.Marshal(c)
if err != nil {
t.Fatal(err)
}
check("create response", string(created))
row := e.dinner(t, c.ID)
check("stored name", row.Name+" "+row.NormalizedName)
if row.RecipeUrl.String != link {
t.Errorf("stored recipe_url = %q, want %q kept for grounding", row.RecipeUrl.String, link)
}
list, err := e.svc.List(t.Context(), dinners.StatusRegular)
if err != nil {
t.Fatal(err)
}
listed, err := json.Marshal(list)
if err != nil {
t.Fatal(err)
}
check("list response", string(listed))
// The same link under another name is still a duplicate, and its error
// names the existing dinner by the household's name.
_, err = e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Weeknight chili"), RecipeURL: ptr(link)}, member)
var dup *dinners.DuplicateError
if !errors.As(err, &dup) || dup.Existing.ID != c.ID {
t.Fatalf("same link again: err = %v, want a duplicate of %s", err, c.ID)
}
check("duplicate error", dup.Error())
}
+94 -28
View File
@@ -76,6 +76,18 @@ var groundTimeout = GroundTimeout
// runs past GroundTimeout; the dinner is left as it was.
var ErrGroundTimeout = errors.New("dinners: grounding the recipe took too long")
// ErrGroundFailed wraps any unexpected failure grounding a dinner by hand.
// Its text may quote the link and dependency errors, so it is never
// logged.
var ErrGroundFailed = errors.New("dinners: grounding the recipe failed")
// ErrArchived is grounding an archived dinner by hand.
var ErrArchived = errors.New("dinner archived")
// ErrGroundConflict is a candidate or link grounded for a dinner that was
// renamed or grounded some other way while it was grounding.
var ErrGroundConflict = errors.New("dinner changed while its recipe was grounding")
// GroundJob is the KindGroundDinner payload: the dinner to ground and its
// grounding_revision when the job was enqueued.
type GroundJob struct {
@@ -323,11 +335,17 @@ type ManualRecipe struct {
// Ground grounds dinner id as f says, recording updatedBy. A candidate or
// link is grounded with recipes.Grounder.GroundURL and sets
// grounding_status grounded, with the recipe's photo, method, minutes and
// difficulty. A manual recipe is stored as typed — no model runs — and
// sets grounding_status manual with its method and minutes; it has no
// photo, and the dinner keeps its difficulty. An invalid f is a
// *ValidationError; a page that cannot ground the dinner is a
// *recipes.ErrUngroundable and changes nothing.
// difficulty; one a member changed while it was grounding is kept. A
// manual recipe is stored as typed — no model runs — and sets
// grounding_status manual with its method and minutes; it has no photo,
// and the dinner keeps its difficulty. An invalid f is a *ValidationError
// and an archived dinner ErrArchived. A failure grounding a candidate or
// link, or recording it on the dinner, wraps ErrGroundFailed; a page that
// cannot ground the dinner is also a *recipes.ErrUngroundable. A dinner
// renamed, archived or grounded some other way while a candidate or link
// was grounding is ErrGroundConflict or ErrArchived. A missing dinner is
// ErrNotFound; any other failure, by candidate, link or manual recipe,
// wraps ErrGroundFailed. None of these changes the dinner.
func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string) (Dinner, error) {
if updatedBy == "" {
return Dinner{}, errors.New("dinners: ground: no member to record as updated_by")
@@ -339,9 +357,16 @@ func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string
if manual != nil {
return s.groundManual(ctx, id, *manual, updatedBy)
}
if _, err := s.get(ctx, id); err != nil {
cur, err := s.get(ctx, id)
if errors.Is(err, ErrNotFound) {
return Dinner{}, err
}
if err != nil {
return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
}
if cur.Status == StatusArchived {
return Dinner{}, ErrArchived
}
groundCtx, cancel := context.WithTimeout(ctx, groundTimeout)
defer cancel()
gr, err := s.grounder.GroundURL(groundCtx, link)
@@ -349,30 +374,57 @@ func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string
if ctx.Err() == nil && errors.Is(groundCtx.Err(), context.DeadlineExceeded) {
return Dinner{}, ErrGroundTimeout
}
return Dinner{}, fmt.Errorf("dinners: ground %s: %w", id, err)
return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
}
row, err := db.New(s.st.Writer).GroundDinner(ctx, db.GroundDinnerParams{
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
Method: sql.NullString{String: gr.Method, Valid: true},
MinutesTotal: nullInt(gr.MinutesTotal),
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
GroundingStatus: GroundingGrounded,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: store.Now().String(),
ID: id,
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
Method: sql.NullString{String: gr.Method, Valid: true},
MinutesTotal: nullInt(gr.MinutesTotal),
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
GroundingStatus: GroundingGrounded,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: store.Now().String(),
ID: cur.ID,
ObservedName: cur.Name,
ObservedNormalizedName: cur.NormalizedName,
ObservedGroundingStatus: cur.GroundingStatus,
ObservedGroundingRevision: cur.GroundingRevision,
ObservedRecipeID: cur.RecipeID,
ObservedMethod: cur.Method,
ObservedMinutesTotal: cur.MinutesTotal,
ObservedDifficulty: cur.Difficulty,
})
if errors.Is(err, sql.ErrNoRows) {
return Dinner{}, ErrNotFound
return Dinner{}, s.groundRejected(ctx, id)
}
if err != nil {
return Dinner{}, fmt.Errorf("dinners: ground %s: %w", id, err)
return Dinner{}, fmt.Errorf("%w: dinner %s: record: %w", ErrGroundFailed, id, err)
}
return fromRow(row), nil
}
// groundRejected is the error for dinner id when it was not as observed
// before grounding: ErrNotFound, ErrArchived or ErrGroundConflict. Failing
// to read it wraps ErrGroundFailed.
func (s *Service) groundRejected(ctx context.Context, id string) error {
cur, err := s.get(ctx, id)
if errors.Is(err, ErrNotFound) {
return err
}
if err != nil {
return fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
}
if cur.Status == StatusArchived {
return ErrArchived
}
return ErrGroundConflict
}
// groundManual stores m as dinner id's recipe, with its ingredients and
// steps, and records it on the dinner, all in one transaction.
// steps, and records it on the dinner, all in one transaction. A missing
// or archived dinner is ErrNotFound or ErrArchived; any other failure
// wraps ErrGroundFailed.
func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, updatedBy string) (Dinner, error) {
var row db.Dinner
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
@@ -384,6 +436,9 @@ func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, u
if err != nil {
return fmt.Errorf("dinners: read %s: %w", id, err)
}
if cur.Status == StatusArchived {
return ErrArchived
}
ld, err := json.Marshal(manualJSONLD{
Context: "https://schema.org",
Type: "Recipe",
@@ -439,23 +494,34 @@ func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, u
}
}
row, err = q.GroundDinner(ctx, db.GroundDinnerParams{
RecipeID: sql.NullString{String: rec.ID, Valid: true},
Method: method,
MinutesTotal: minutes,
Difficulty: cur.Difficulty,
GroundingStatus: GroundingManual,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: now,
ID: cur.ID,
RecipeID: sql.NullString{String: rec.ID, Valid: true},
Method: method,
MinutesTotal: minutes,
Difficulty: cur.Difficulty,
GroundingStatus: GroundingManual,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: now,
ID: cur.ID,
ObservedName: cur.Name,
ObservedNormalizedName: cur.NormalizedName,
ObservedGroundingStatus: cur.GroundingStatus,
ObservedGroundingRevision: cur.GroundingRevision,
ObservedRecipeID: cur.RecipeID,
ObservedMethod: cur.Method,
ObservedMinutesTotal: cur.MinutesTotal,
ObservedDifficulty: cur.Difficulty,
})
if err != nil {
return fmt.Errorf("dinners: ground %s: %w", id, err)
}
return nil
})
if err != nil {
if errors.Is(err, ErrNotFound) || errors.Is(err, ErrArchived) {
return Dinner{}, err
}
if err != nil {
return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
}
return fromRow(row), nil
}
+218 -11
View File
@@ -194,7 +194,7 @@ func TestCreateByRecipeURLGroundsThatURL(t *testing.T) {
e := newEnv(t)
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{RecipeURL: ptr(stew)}, member)
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Beef stew"), RecipeURL: ptr(stew)}, member)
if err != nil {
t.Fatal(err)
}
@@ -223,7 +223,7 @@ func TestCreateGroundingFailureRecordsReason(t *testing.T) {
reason string
}{
{"no candidates", dinners.NewRegular{Name: ptr("Unknown dish")}, nil, dinners.ReasonNoRecipe},
{"unusable link", dinners.NewRegular{RecipeURL: ptr("https://gone.example/recipes/lost-soup")}, nil, dinners.ReasonRecipeUnusable},
{"unusable link", dinners.NewRegular{Name: ptr("Lost soup"), RecipeURL: ptr("https://gone.example/recipes/lost-soup")}, nil, dinners.ReasonRecipeUnusable},
{"search fails", dinners.NewRegular{Name: ptr("Chicken curry")}, func(e *env) { e.search.Err = recipes.ErrSearchRateLimited }, dinners.ReasonSearchFailed},
{"model fails", dinners.NewRegular{Name: ptr("Chicken curry")}, func(e *env) {
// One provider failure per candidate tried.
@@ -375,7 +375,7 @@ func TestRenameLinkWhilePendingGroundsNewName(t *testing.T) {
e.search.Results["Beef stew"] = []recipes.Candidate{{URL: stew}}
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{RecipeURL: ptr(curryA)}, member)
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry"), RecipeURL: ptr(curryA)}, member)
if err != nil {
t.Fatal(err)
}
@@ -383,8 +383,11 @@ func TestRenameLinkWhilePendingGroundsNewName(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if d.GroundingStatus != dinners.GroundingPending || d.RecipeURL != nil {
t.Fatalf("renamed = %+v, want still pending with no recipe_url", d)
if d.GroundingStatus != dinners.GroundingPending {
t.Fatalf("renamed = %+v, want still pending", d)
}
if row, err := db.New(e.st.Reader).GetDinner(t.Context(), c.ID); err != nil || row.RecipeUrl.Valid {
t.Fatalf("renamed row = %+v (err %v), want no recipe_url", row, err)
}
e.start(t)
@@ -737,7 +740,7 @@ func TestFixReplacesPendingLink(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
e := newEnv(t)
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{RecipeURL: ptr(lost)}, member)
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Lamb roast"), RecipeURL: ptr(lost)}, member)
if err != nil {
t.Fatal(err)
}
@@ -749,19 +752,22 @@ func TestFixReplacesPendingLink(t *testing.T) {
t.Fatal(err)
}
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
d, err := e.svc.Ground(t.Context(), c.ID, tt.fix, member)
if _, err := e.svc.Ground(t.Context(), c.ID, tt.fix, member); err != nil {
t.Fatal(err)
}
row, err := db.New(e.st.Reader).GetDinner(t.Context(), c.ID)
if err != nil {
t.Fatal(err)
}
if d.RecipeURL != nil {
t.Errorf("recipe_url = %q after the fix, want the unusable link dropped", *d.RecipeURL)
if row.RecipeUrl.Valid {
t.Errorf("recipe_url = %q after the fix, want the unusable link dropped", row.RecipeUrl.String)
}
if _, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{RecipeURL: ptr(lost)}, member); err != nil {
if _, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Lentil soup"), RecipeURL: ptr(lost)}, member); err != nil {
t.Errorf("adding the dropped link again: %v, want added", err)
}
if tt.linkTaken != "" {
_, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{RecipeURL: ptr(tt.linkTaken)}, member)
_, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Curry night"), RecipeURL: ptr(tt.linkTaken)}, member)
var dup *dinners.DuplicateError
if !errors.As(err, &dup) || dup.Existing.ID != c.ID {
t.Errorf("adding the fixed dinner's recipe link: err = %v, want a duplicate of %s", err, c.ID)
@@ -1106,3 +1112,204 @@ func TestGroundTimesOut(t *testing.T) {
t.Errorf("grounding_status = %s, want failed as before", row.GroundingStatus)
}
}
// TestFixInFlightWhenDinnerChanges: a link still grounding when its dinner
// is renamed, archived or grounded some other way writes nothing; the
// change made meanwhile is kept.
func TestFixInFlightWhenDinnerChanges(t *testing.T) {
t.Parallel()
tests := []struct {
name string
change func(t *testing.T, e *env, id string)
want error
// kept checks the dinner shows the change, not the link's recipe.
kept func(row db.Dinner) bool
}{
{"renamed", func(t *testing.T, e *env, id string) {
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Chicken curry")}, member); err != nil {
t.Fatal(err)
}
}, dinners.ErrGroundConflict, func(row db.Dinner) bool {
return row.Name == "Chicken curry" && row.GroundingStatus == dinners.GroundingFailed && !row.RecipeID.Valid
}},
{"renamed to the same normalized name", func(t *testing.T, e *env, id string) {
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Easy Grandma's stew")}, member); err != nil {
t.Fatal(err)
}
}, dinners.ErrGroundConflict, func(row db.Dinner) bool {
return row.Name == "Easy Grandma's stew" && row.GroundingStatus == dinners.GroundingFailed && !row.RecipeID.Valid
}},
{"archived", func(t *testing.T, e *env, id string) {
if _, err := e.svc.Archive(t.Context(), id, member); err != nil {
t.Fatal(err)
}
}, dinners.ErrArchived, func(row db.Dinner) bool {
return row.Status == dinners.StatusArchived && row.GroundingStatus == dinners.GroundingFailed && !row.RecipeID.Valid
}},
{"grounded by hand", func(t *testing.T, e *env, id string) {
if _, err := e.svc.Ground(t.Context(), id, dinners.Fix{Manual: &dinners.ManualRecipe{
Ingredients: []string{"1 lb beef"}, Minutes: ptr(240), Method: ptr("crockpot"),
}}, member); err != nil {
t.Fatal(err)
}
}, dinners.ErrGroundConflict, func(row db.Dinner) bool {
return row.GroundingStatus == dinners.GroundingManual && row.Method.String == "crockpot"
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Grandma's stew")
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
e.pages.Gate = make(chan struct{})
done := make(chan error, 1)
go func() {
_, err := e.svc.Ground(context.Background(), id, dinners.Fix{RecipeURL: ptr(stew)}, member)
done <- err
}()
e.waitFetched(t, stew)
tt.change(t, e, id)
close(e.pages.Gate)
if err := <-done; !errors.Is(err, tt.want) {
t.Fatalf("err = %v, want %v", err, tt.want)
}
if row := e.dinner(t, id); !tt.kept(row) {
t.Errorf("dinner = %+v, want the %s change kept and the link's recipe not written", row, tt.name)
}
})
}
}
// TestFixInFlightKeepsMemberEdits: a method, minutes or difficulty a
// member sets while a link is grounding is kept; a field they left alone
// takes the link's recipe, even one already set before grounding began.
func TestFixInFlightKeepsMemberEdits(t *testing.T) {
t.Parallel()
// Every field is set before grounding, then one is changed in flight.
before := dinners.Patch{Method: ptr("stovetop"), MinutesTotal: ptr(45), Difficulty: ptr("easy")}
tests := []struct {
name string
edit dinners.Patch
field string
}{
{"method", dinners.Patch{Method: ptr("grill")}, "method"},
{"minutes", dinners.Patch{MinutesTotal: ptr(20)}, "minutes_total"},
{"difficulty", dinners.Patch{Difficulty: ptr("medium")}, "difficulty"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Grandma's stew")
if _, err := e.svc.Update(t.Context(), id, before, member); err != nil {
t.Fatal(err)
}
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
e.pages.Gate = make(chan struct{})
done := make(chan error, 1)
go func() {
_, err := e.svc.Ground(context.Background(), id, dinners.Fix{RecipeURL: ptr(stew)}, member)
done <- err
}()
e.waitFetched(t, stew)
if _, err := e.svc.Update(t.Context(), id, tt.edit, member); err != nil {
t.Fatal(err)
}
close(e.pages.Gate)
if err := <-done; err != nil {
t.Fatal(err)
}
row := e.dinner(t, id)
if row.GroundingStatus != dinners.GroundingGrounded || !row.RecipeID.Valid {
t.Fatalf("dinner = %+v, want grounded in the link's recipe", row)
}
var recipe fields
if err := e.st.Reader.QueryRowContext(t.Context(),
`SELECT method, minutes_total, difficulty FROM recipes WHERE id = ?`, row.RecipeID.String,
).Scan(&recipe.method, &recipe.minutes, &recipe.difficulty); err != nil {
t.Fatal(err)
}
old, edited := patchFields(before), patchFields(tt.edit)
want := recipe
switch tt.field {
case "method":
want.method = edited.method
case "minutes_total":
want.minutes = edited.minutes
case "difficulty":
want.difficulty = edited.difficulty
}
if recipe.method == old.method || recipe.minutes == old.minutes || recipe.difficulty == old.difficulty {
t.Fatalf("recipe %+v shares a value with the dinner before grounding %+v", recipe, old)
}
got := fields{row.Method.String, row.MinutesTotal.Int64, row.Difficulty.String}
if got != want {
t.Errorf("method, minutes, difficulty = %+v, want %+v: the %s edit kept, the rest from the recipe", got, want, tt.field)
}
})
}
}
// fields are a dinner's or recipe's method, minutes_total and difficulty.
type fields struct {
method string
minutes int64
difficulty string
}
// patchFields returns the fields p sets, zero where it sets none.
func patchFields(p dinners.Patch) fields {
var f fields
if p.Method != nil {
f.method = *p.Method
}
if p.MinutesTotal != nil {
f.minutes = int64(*p.MinutesTotal)
}
if p.Difficulty != nil {
f.difficulty = *p.Difficulty
}
return f
}
// TestFixArchivedDinner: an archived dinner cannot be grounded by hand.
func TestFixArchivedDinner(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Grandma's stew")
if _, err := e.svc.Archive(t.Context(), id, member); err != nil {
t.Fatal(err)
}
before := e.dinner(t, id)
for _, f := range []dinners.Fix{
{RecipeURL: ptr(stew)},
{Manual: &dinners.ManualRecipe{Ingredients: []string{"1 lb beef"}, Minutes: ptr(240), Method: ptr("crockpot")}},
} {
if _, err := e.svc.Ground(t.Context(), id, f, member); !errors.Is(err, dinners.ErrArchived) {
t.Errorf("fix %+v: err = %v, want ErrArchived", f, err)
}
}
if got := e.pages.Fetched(); len(got) != 0 {
t.Errorf("fetched %q for an archived dinner", got)
}
if after := e.dinner(t, id); after != before {
t.Errorf("archived dinner changed: %+v, was %+v", after, before)
}
}
// waitFetched waits until rawURL has been fetched.
func (e *env) waitFetched(t *testing.T, rawURL string) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for !slices.Contains(e.pages.Fetched(), rawURL) {
if time.Now().After(deadline) {
t.Fatalf("%s never fetched", rawURL)
}
time.Sleep(5 * time.Millisecond)
}
}
+1 -1
View File
@@ -266,7 +266,7 @@ func (g *Grounder) GroundURL(ctx context.Context, rawURL string) (Grounded, erro
}
gr, reason, err := g.ground(ctx, rawURL, r)
if err != nil {
return Grounded{}, fmt.Errorf("recipes: ground %s: %w", rawURL, err)
return Grounded{}, fmt.Errorf("recipes: ground pasted link: %w", err)
}
if reason != nil {
return ungroundable(reason)
+17 -2
View File
@@ -19,7 +19,8 @@ const maxDinnerBody = 16 << 10
// manual recipe of dinners.MaxManualLines lines of each kind.
const maxGroundBody = 512 << 10
// dinnerCreate is the POST /dinners body: a name or a recipe_url.
// dinnerCreate is the POST /dinners body: a name, with or without a
// recipe_url.
type dinnerCreate struct {
Name *string `json:"name"`
RecipeURL *string `json:"recipe_url"`
@@ -200,7 +201,11 @@ func actorOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger) (a
// writeDinnerError maps a dinners.Service error to its API error. A page
// that cannot ground the dinner is 422 ungroundable; recipe search not set
// up is 409 not_configured; a search or model failure is 502; grounding by
// hand that runs past dinners.GroundTimeout is 504 ground_timeout.
// hand that runs past dinners.GroundTimeout is 504 ground_timeout; grounding
// an archived dinner is 409 dinner_archived and one changed while grounding
// 409 ground_conflict. Any other grounding failure, by candidate, link or
// manual recipe, wraps dinners.ErrGroundFailed and is 500, logged by reason
// and dinner id only.
func writeDinnerError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
var invalid *dinners.ValidationError
var dup *dinners.DuplicateError
@@ -220,6 +225,16 @@ func writeDinnerError(w http.ResponseWriter, r *http.Request, logger *slog.Logge
case errors.Is(err, recipes.ErrNormalizeFailed):
logger.WarnContext(r.Context(), action, "reason", "normalize_recipe failed")
writeError(w, r, logger, http.StatusBadGateway, "ai_failed", "the AI provider failed to read the recipe")
case errors.Is(err, dinners.ErrGroundFailed):
// The error may quote the pasted link, which can carry a personal
// token, and dependency errors, so only the reason and dinner are
// logged.
logger.ErrorContext(r.Context(), action, "reason", "ground_failed", "dinner_id", chi.URLParam(r, "id"))
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
case errors.Is(err, dinners.ErrArchived):
writeError(w, r, logger, http.StatusConflict, "dinner_archived", "the dinner is archived")
case errors.Is(err, dinners.ErrGroundConflict):
writeError(w, r, logger, http.StatusConflict, "ground_conflict", "the dinner changed while its recipe was grounding; reload and try again")
case errors.Is(err, dinners.ErrGroundTimeout):
logger.WarnContext(r.Context(), action, "reason", "ground_timeout")
writeError(w, r, logger, http.StatusGatewayTimeout, "ground_timeout", "grounding the recipe took too long; try again")
+89 -14
View File
@@ -159,7 +159,6 @@ func TestCreateDinnerByName(t *testing.T) {
"origin": "regular",
"grounding_status": "pending",
"is_favorite": false,
"recipe_url": nil,
"photo_url": nil,
"method": nil,
"minutes_total": nil,
@@ -170,6 +169,9 @@ func TestCreateDinnerByName(t *testing.T) {
t.Errorf("%s = %#v (present %v), want %#v", k, got, ok, v)
}
}
if got, ok := body["recipe_url"]; ok {
t.Errorf("recipe_url = %#v, want no recipe_url in a dinner response", got)
}
var normalized, updatedBy string
if err := st.Reader.QueryRow(`SELECT normalized_name, updated_by FROM dinners WHERE id = ?`, body["id"]).Scan(&normalized, &updatedBy); err != nil {
t.Fatal(err)
@@ -184,13 +186,12 @@ func TestCreateDinnerByURL(t *testing.T) {
router, _ := newDinnersRouter(t)
link := "https://www.example.com/recipes/12345/beef-and-broccoli-stir-fry/"
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"recipe_url":"`+link+`"}`)
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Stir fry night","recipe_url":"`+link+`"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body)
}
for k, v := range map[string]any{
"name": "Beef and broccoli stir fry",
"recipe_url": link,
"name": "Stir fry night",
"status": "regular",
"origin": "regular",
"grounding_status": "pending",
@@ -200,24 +201,27 @@ func TestCreateDinnerByURL(t *testing.T) {
t.Errorf("%s = %#v, want %#v", k, body[k], v)
}
}
// The link stays on the server for grounding; responses never carry it.
if got, ok := body["recipe_url"]; ok {
t.Errorf("recipe_url = %#v, want no recipe_url in a dinner response", got)
}
// The same link again is a duplicate even though its name would differ.
rec, body = callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Something else entirely"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("unrelated name = %d: %s", rec.Code, rec.Body)
}
rec, body = callDinners(t, router, http.MethodPost, dinnersPath, `{"recipe_url":"`+link+`"}`)
rec, body = callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Beef and broccoli","recipe_url":"`+link+`"}`)
if rec.Code != http.StatusConflict || errorCode(body) != "duplicate_dinner" {
t.Errorf("same link again = %d %v, want 409 duplicate_dinner", rec.Code, body)
}
for _, tc := range []struct{ name, body string }{
{"plain http", `{"recipe_url":"http://example.com/recipes/chili"}`},
{"not a URL", `{"recipe_url":"chili"}`},
{"credentials", `{"recipe_url":"https://me:pw@example.com/recipes/chili"}`},
{"no name in path", `{"recipe_url":"https://example.com/"}`},
{"numeric slug", `{"recipe_url":"https://example.com/recipe/98765"}`},
{"both fields", `{"name":"Chili","recipe_url":"https://example.com/recipes/chili"}`},
{"plain http", `{"name":"Chili","recipe_url":"http://example.com/recipes/chili"}`},
{"not a URL", `{"name":"Chili","recipe_url":"chili"}`},
{"credentials", `{"name":"Chili","recipe_url":"https://me:pw@example.com/recipes/chili"}`},
{"link without a name", `{"recipe_url":"https://example.com/recipes/chili"}`},
{"filler name with a link", `{"name":"The Best Easy Recipe","recipe_url":"https://example.com/recipes/chili"}`},
{"neither field", `{}`},
} {
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, tc.body)
@@ -227,6 +231,77 @@ func TestCreateDinnerByURL(t *testing.T) {
}
}
// TestDinnerResponsesHideRecipeURL: a pasted link may carry a personal
// token, so neither the create response nor a later list read shows any
// part of it, while the stored link still awaits grounding.
func TestDinnerResponsesHideRecipeURL(t *testing.T) {
t.Parallel()
router, st := newDinnersRouter(t)
const link = "https://c.example/recipes/stew?access_token=personal-token-abc123"
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Stew","recipe_url":"`+link+`"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body)
}
created := rec.Body.String()
rec, _ = callDinners(t, router, http.MethodGet, dinnersPath+"?status=regular", "")
if rec.Code != http.StatusOK {
t.Fatalf("GET status=regular = %d: %s", rec.Code, rec.Body)
}
for _, resp := range []struct{ name, body string }{{"create", created}, {"list", rec.Body.String()}} {
for _, secret := range []string{link, "c.example", "access_token", "personal-token-abc123", "recipe_url"} {
if strings.Contains(resp.body, secret) {
t.Errorf("%s response contains %q: %s", resp.name, secret, resp.body)
}
}
}
var stored string
if err := st.Reader.QueryRow(`SELECT recipe_url FROM dinners WHERE id = ?`, body["id"]).Scan(&stored); err != nil {
t.Fatal(err)
}
if stored != link {
t.Errorf("stored recipe_url = %q, want %q kept for grounding", stored, link)
}
}
// TestDinnerResponsesHideTokenInLinkPath: a personal token can sit
// anywhere in a link's path, even as plain letters, so a dinner added from
// a link is named only by the household and neither the create response, a
// later list read nor the duplicate error for the same link shows any of
// the link.
func TestDinnerResponsesHideTokenInLinkPath(t *testing.T) {
t.Parallel()
router, _ := newDinnersRouter(t)
const link = "https://c.example/recipes/stew-key-abcdefghijklmno"
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Sunday stew","recipe_url":"`+link+`"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body)
}
if body["name"] != "Sunday stew" {
t.Errorf("name = %v, want Sunday stew", body["name"])
}
created := rec.Body.String()
rec, _ = callDinners(t, router, http.MethodGet, dinnersPath+"?status=regular", "")
if rec.Code != http.StatusOK {
t.Fatalf("GET status=regular = %d: %s", rec.Code, rec.Body)
}
listed := rec.Body.String()
rec, _ = callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Beef stew","recipe_url":"`+link+`"}`)
if rec.Code != http.StatusConflict {
t.Fatalf("same link again = %d, want 409: %s", rec.Code, rec.Body)
}
for _, resp := range []struct{ name, body string }{{"create", created}, {"list", listed}, {"duplicate", rec.Body.String()}} {
lower := strings.ToLower(resp.body)
for _, secret := range []string{"c.example", "recipes", "key", "abcdefghijklmno"} {
if strings.Contains(lower, secret) {
t.Errorf("%s response contains %q: %s", resp.name, secret, resp.body)
}
}
}
}
func TestCreateDinnerDuplicate(t *testing.T) {
t.Parallel()
router, st := newDinnersRouter(t)
@@ -242,10 +317,10 @@ func TestCreateDinnerDuplicate(t *testing.T) {
t.Errorf("%q: %d %v, want 409 duplicate_dinner", name, rec.Code, body)
}
}
// A slug naming the same dish is a duplicate too.
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"recipe_url":"https://example.com/easy-chicken-tikka-masala.html"}`)
// A name repeating the dish is a duplicate with a link too.
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Chicken tikka masala","recipe_url":"https://example.com/other-curry.html"}`)
if rec.Code != http.StatusConflict {
t.Errorf("URL slug duplicate = %d %v, want 409", rec.Code, body)
t.Errorf("named duplicate with a link = %d %v, want 409", rec.Code, body)
}
var n int
+109
View File
@@ -224,6 +224,115 @@ func TestPostGroundViaRecipeURL(t *testing.T) {
}
}
// TestPostGroundViaRecipeURLStorageFailure: a pasted link that fails for a
// reason other than its recipe is a 500 logged by reason and dinner id,
// never with the link, which can carry a personal token, or the dependency
// error.
func TestPostGroundViaRecipeURLStorageFailure(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
const token = "personal-token-abc123"
link := stewPage + "?access_token=" + token
e.pages.Bodies[link] = e.pages.Bodies[stewPage]
id := createDinner(t, e.h, `{"name":"Sunday stew"}`)
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
if _, err := e.st.Writer.ExecContext(t.Context(),
`CREATE TRIGGER refuse_recipes BEFORE INSERT ON recipes BEGIN SELECT RAISE(ABORT, 'disk full'); END`); err != nil {
t.Fatal(err)
}
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+link+`"}`)
if rec.Code != http.StatusInternalServerError || errorCode(body) != "internal" || strings.Contains(rec.Body.String(), token) {
t.Errorf("POST = %d %s, want 500 internal without the link", rec.Code, rec.Body)
}
logs := e.logs.String()
if strings.Contains(logs, token) || strings.Contains(logs, "c.example") || strings.Contains(logs, "disk full") ||
!strings.Contains(logs, "reason=ground_failed") || !strings.Contains(logs, "dinner_id="+id) {
t.Errorf("logs = %q, want reason=ground_failed and the dinner id, without the link or dependency error", logs)
}
}
// TestPostGroundViaRecipeURLDinnerWriteFailure: a pasted link whose recipe
// is stored but whose dinner cannot be written is a 500 logged by reason
// and dinner id, never with the dependency error.
func TestPostGroundViaRecipeURLDinnerWriteFailure(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
id := createDinner(t, e.h, `{"name":"Sunday stew"}`)
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
if _, err := e.st.Writer.ExecContext(t.Context(),
`CREATE TRIGGER refuse_grounding BEFORE UPDATE OF recipe_id ON dinners WHEN NEW.recipe_id IS NOT NULL BEGIN SELECT RAISE(ABORT, 'disk full'); END`); err != nil {
t.Fatal(err)
}
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+stewPage+`"}`)
if rec.Code != http.StatusInternalServerError || errorCode(body) != "internal" {
t.Errorf("POST = %d %s, want 500 internal", rec.Code, rec.Body)
}
var stored int
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM recipes WHERE source_url = ?`, stewPage).Scan(&stored); err != nil || stored != 1 {
t.Fatalf("recipes stored for the link = %d (%v), want 1: the failure must be the dinner write", stored, err)
}
logs := e.logs.String()
if strings.Contains(logs, "disk full") || !strings.Contains(logs, "reason=ground_failed") || !strings.Contains(logs, "dinner_id="+id) {
t.Errorf("logs = %q, want reason=ground_failed and the dinner id, without the dependency error", logs)
}
}
// TestPostGroundUnexpectedFailures: grounding by hand that fails reading
// the dinner, or storing a manual recipe, is a 500 logged by reason and
// dinner id, never with the database error.
func TestPostGroundUnexpectedFailures(t *testing.T) {
t.Parallel()
const (
// Renaming a column every dinner read selects fails the read.
breakRead = `ALTER TABLE dinners RENAME COLUMN cuisine TO cuisine_gone`
// The trigger's message stands in for the database error.
breakStore = `CREATE TRIGGER refuse_recipes BEFORE INSERT ON recipes BEGIN SELECT RAISE(ABORT, 'disk full'); END`
manual = `{"manual":{"ingredients":["1 lb beef"],"steps":["Brown."],"minutes":240,"method":"crockpot"}}`
)
for _, tc := range []struct {
name, sabotage, fix, leak string
}{
{"link read", breakRead, `{"recipe_url":"` + stewPage + `"}`, "cuisine"},
{"manual read", breakRead, manual, "cuisine"},
{"manual store", breakStore, manual, "disk full"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
id := createDinner(t, e.h, `{"name":"Sunday stew"}`)
if _, err := e.st.Writer.ExecContext(t.Context(), tc.sabotage); err != nil {
t.Fatal(err)
}
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", tc.fix)
if rec.Code != http.StatusInternalServerError || errorCode(body) != "internal" {
t.Errorf("POST = %d %s, want 500 internal", rec.Code, rec.Body)
}
logs := e.logs.String()
if strings.Contains(logs, tc.leak) || !strings.Contains(logs, "reason=ground_failed") || !strings.Contains(logs, "dinner_id="+id) {
t.Errorf("logs = %q, want reason=ground_failed and the dinner id, without the database error", logs)
}
})
}
}
// TestPostGroundArchived: grounding an archived dinner by hand is a 409.
func TestPostGroundArchived(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
id := createDinner(t, e.h, `{"name":"Family chili"}`)
if rec, _ := callDinners(t, e.h, http.MethodDelete, dinnersPath+"/"+id, ""); rec.Code != http.StatusOK {
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body)
}
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground",
`{"manual":{"ingredients":["1 lb beef"],"minutes":240,"method":"crockpot"}}`)
if rec.Code != http.StatusConflict || errorCode(body) != "dinner_archived" {
t.Errorf("POST = %d %v, want 409 dinner_archived", rec.Code, body)
}
}
func TestPostGroundViaManual(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
+24 -10
View File
@@ -84,19 +84,33 @@ RETURNING *;
-- name: GroundDinner :one
-- Records a dinner's grounding by hand (grounded or manual) and clears any
-- grounding_error and pasted recipe_url: the recipe chosen replaces the
-- link. An unknown id returns sql.ErrNoRows.
-- link. It writes only an unarchived dinner still as it was observed
-- before grounding (same name, normalized_name, grounding_status,
-- grounding_revision and recipe_id), so a dinner renamed, archived or
-- grounded some other way meanwhile is left as it is. That dinner, or an
-- unknown id, returns sql.ErrNoRows. A method, minutes_total or difficulty
-- no longer as observed is kept: a member edited it while it was grounding.
UPDATE dinners
SET recipe_id = ?,
SET recipe_id = sqlc.arg(recipe_id),
recipe_url = NULL,
photo_id = ?,
method = ?,
minutes_total = ?,
difficulty = ?,
grounding_status = ?,
photo_id = sqlc.arg(photo_id),
method = CASE WHEN method IS sqlc.narg(observed_method)
THEN sqlc.arg(method) ELSE method END,
minutes_total = CASE WHEN minutes_total IS sqlc.narg(observed_minutes_total)
THEN sqlc.arg(minutes_total) ELSE minutes_total END,
difficulty = CASE WHEN difficulty IS sqlc.narg(observed_difficulty)
THEN sqlc.arg(difficulty) ELSE difficulty END,
grounding_status = sqlc.arg(grounding_status),
grounding_error = NULL,
updated_by = ?,
updated_at = ?
WHERE id = ?
updated_by = sqlc.arg(updated_by),
updated_at = sqlc.arg(updated_at)
WHERE id = sqlc.arg(id)
AND status <> 'archived'
AND name = sqlc.arg(observed_name)
AND normalized_name = sqlc.arg(observed_normalized_name)
AND grounding_status = sqlc.arg(observed_grounding_status)
AND grounding_revision = sqlc.arg(observed_grounding_revision)
AND recipe_id IS sqlc.narg(observed_recipe_id)
RETURNING *;
-- name: GroundPendingDinner :execrows
+8 -7
View File
@@ -33,8 +33,10 @@ export type Regular = {
pending?: true
}
// NewRegular is a POST /api/v1/dinners body: a name or a recipe link.
export type NewRegular = { name: string } | { recipe_url: string }
// NewRegular is a POST /api/v1/dinners body: the household's name for the
// dinner, with the recipe link to ground it in when one was pasted. A
// dinner is never named from its link, which may carry private details.
export type NewRegular = { name: string; recipe_url?: string }
// RegularPatch is a PATCH /api/v1/dinners/{id} body; every field is optional.
export type RegularPatch = Partial<{ name: string; method: Method; minutes_total: number; difficulty: Difficulty }>
@@ -195,10 +197,9 @@ function settle(queryClient: QueryClient) {
let pendingIds = 0
// useAddRegular adds a Regular by name or recipe link (POST
// /api/v1/dinners). It shows in the list at once — a link under the link
// itself until the server names it — and is taken out again if the server
// refuses it.
// useAddRegular adds a Regular by name, with or without a recipe link (POST
// /api/v1/dinners). It shows in the list at once under its name and is
// taken out again if the server refuses it.
export function useAddRegular() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
@@ -210,7 +211,7 @@ export function useAddRegular() {
const id = `pending-${++pendingIds}`
const optimistic: CachedRegular = {
id,
name: 'name' in add ? add.name : add.recipe_url,
name: add.name,
method: null,
minutes_total: null,
difficulty: null,
@@ -43,6 +43,13 @@
font-weight: 700;
}
/* The add form: the add row, then the name field a pasted link needs. */
.addForm {
display: flex;
flex-direction: column;
gap: 8px;
}
/* The add field: the input fills the row beside the grape + button. */
.add {
display: flex;
+20 -8
View File
@@ -192,21 +192,33 @@ describe('RegularsSettingsPage', () => {
expect(screen.getByRole('heading', { level: 2, name: 'On rotation · 2' })).toBeInTheDocument()
})
it('adds a Regular from a pasted recipe link', async () => {
const link = 'https://example.com/recipes/12345/beef-stew/'
it('adds a Regular from a pasted recipe link under the name the household gives it', async () => {
const link = 'https://example.com/recipes/stew-key-abcdefghijklmno'
const server = stubServer([], {
'POST /api/v1/dinners': () => {
const created = dinnerBody('d9', 'Beef stew', { grounding_status: 'pending', recipe_url: link })
'POST /api/v1/dinners': (body) => {
const created = dinnerBody('d9', (body as { name: string }).name, { grounding_status: 'pending' })
server.regulars = [created]
return jsonResponse(201, { ...created, grounding_job_id: 'job-1' })
},
})
await renderPage()
add(link)
expect(await screen.findByText('Beef stew')).toBeInTheDocument()
expect(sent(server.fetchMock)).toEqual([{ request: 'POST /api/v1/dinners', body: { recipe_url: link } }])
expect(within(row('Beef stew')).getByText('Finding recipe…')).toBeInTheDocument()
fireEvent.change(addField(), { target: { value: ` ${link} ` } })
// A link alone cannot be added: it never names a dinner.
expect(screen.getByRole('button', { name: 'Add Regular' })).toBeDisabled()
const name = screen.getByRole('textbox', { name: 'Dinner name' })
expect(name).toHaveAccessibleDescription(/never names a dinner/)
fireEvent.change(name, { target: { value: ' Sunday stew ' } })
fireEvent.click(screen.getByRole('button', { name: 'Add Regular' }))
expect(addField()).toHaveValue('')
expect(screen.queryByRole('textbox', { name: 'Dinner name' })).not.toBeInTheDocument()
expect(await screen.findByText('Sunday stew')).toBeInTheDocument()
expect(sent(server.fetchMock)).toEqual([
{ request: 'POST /api/v1/dinners', body: { name: 'Sunday stew', recipe_url: link } },
])
expect(within(row('Sunday stew')).getByText('Finding recipe…')).toBeInTheDocument()
expect(document.body).not.toHaveTextContent(/example\.com|abcdefghijklmno|key/i)
})
it('takes back an add the server refuses and says why', async () => {
+44 -19
View File
@@ -49,12 +49,14 @@ export const methodLabels: Record<Method, string> = {
const difficultyLabels: Record<Difficulty, string> = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }
// A pasted link is added as a recipe link; anything else is a dinner name.
// A pasted link is added as a recipe link under a name the household gives;
// anything else is a dinner name.
const LINK = /^https?:\/\//i
// Settings · Regular dinners: the household's Regulars, each with its photo,
// method and minutes, favorite heart and whether its recipe was found. The
// field at the top adds one by name or pasted recipe link; tapping one
// field at the top adds one by name, or by pasted recipe link with a name
// the household gives it; tapping one
// edits or removes it, or fixes its recipe when none was found.
export function RegularsSettingsPage() {
const navigate = useNavigate()
@@ -85,19 +87,24 @@ function RegularsManager() {
const favorite = useToggleFavorite()
const { refusals, track } = useRefusals()
const [draft, setDraft] = useState('')
const [linkName, setLinkName] = useState('')
const [open, setOpen] = useState<Open>(null)
const headingId = useId()
const linkHintId = useId()
const isLink = LINK.test(draft.trim())
const ready = draft.trim() !== '' && (!isLink || linkName.trim() !== '')
const list = regulars.data ?? []
// A Regular removed meanwhile closes its sheet.
const opened = open && list.find((regular) => regular.id === open.id && !regular.pending)
function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!ready) return
const text = draft.trim()
if (text === '') return
const body: NewRegular = LINK.test(text) ? { recipe_url: text } : { name: text }
track(`add:${text.toLowerCase()}`, `add ${text}`, add.mutateAsync(body))
const body: NewRegular = isLink ? { name: linkName.trim(), recipe_url: text } : { name: text }
track(`add:${body.name.toLowerCase()}`, `add ${body.name}`, add.mutateAsync(body))
setDraft('')
setLinkName('')
}
function onRemove(regular: Regular) {
@@ -111,20 +118,38 @@ function RegularsManager() {
return (
<>
<form className={styles.add} onSubmit={onSubmit}>
<TextInput
label="Add a Regular"
labelHidden
className={styles.addInput}
placeholder="Dinner name or recipe link"
autoComplete="off"
enterKeyHint="done"
value={draft}
onChange={(event) => setDraft(event.target.value)}
/>
<IconButton type="submit" label="Add Regular" tone="accent" disabled={draft.trim() === ''}>
<Icon icon={Plus} size="nav" />
</IconButton>
<form className={styles.addForm} onSubmit={onSubmit}>
<div className={styles.add}>
<TextInput
label="Add a Regular"
labelHidden
className={styles.addInput}
placeholder="Dinner name or recipe link"
autoComplete="off"
enterKeyHint="done"
value={draft}
onChange={(event) => setDraft(event.target.value)}
/>
<IconButton type="submit" label="Add Regular" tone="accent" disabled={!ready}>
<Icon icon={Plus} size="nav" />
</IconButton>
</div>
{isLink && (
<>
<TextInput
label="Dinner name"
placeholder="What do you call this dinner?"
autoComplete="off"
enterKeyHint="done"
aria-describedby={linkHintId}
value={linkName}
onChange={(event) => setLinkName(event.target.value)}
/>
<p id={linkHintId} className={styles.hint}>
Name it yourself: a link can carry private details, so it never names a dinner.
</p>
</>
)}
</form>
{refusals.map((refusal) => (
<p key={refusal.key} role="alert" className={styles.problem}>
+47
View File
@@ -0,0 +1,47 @@
// Runs the service worker's activate handler against mocked Cache Storage,
// with the Workbox precache and router stubbed out. tsconfig.sw.json checks
// this file with the worker's types, like src/sw.ts itself.
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('workbox-precaching', () => ({
PrecacheFallbackPlugin: class {},
matchPrecache: vi.fn(),
precacheAndRoute: vi.fn(),
}))
vi.mock('workbox-routing', () => ({ registerRoute: vi.fn() }))
describe('service worker activation', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('deletes the v1 API cache and keeps the precache, photo and font caches', async () => {
// A household on the v1 worker: its API cache held dinners with the
// pasted recipe_url; its photos and fonts are not in the precache.
const names = [
'mealquest-precache-v1',
'mealquest-api-v1',
'mealquest-media-v1',
'mealquest-fonts-v1',
'other-app-cache',
]
const cacheStorage = {
keys: vi.fn(async () => names),
delete: vi.fn(async () => true),
}
vi.stubGlobal('caches', cacheStorage)
vi.stubGlobal('__WB_MANIFEST', [])
await import('./sw')
const pending: Promise<unknown>[] = []
const activate = Object.assign(new Event('activate'), {
waitUntil: (promise: Promise<unknown>) => pending.push(promise),
})
self.dispatchEvent(activate)
await Promise.all(pending)
expect(pending).toHaveLength(1)
expect(cacheStorage.delete).toHaveBeenCalledWith('mealquest-api-v1')
expect(cacheStorage.delete).toHaveBeenCalledTimes(1)
})
})
+2 -2
View File
@@ -18,7 +18,7 @@ import { CacheFirst, NetworkOnly, type Strategy } from 'workbox-strategies'
import { createApiStrategy } from './sw/apiStrategy'
import {
cacheNames,
cacheVersion,
cacheVersions,
routeFor,
staleCaches,
type Route,
@@ -29,7 +29,7 @@ declare const self: ServiceWorkerGlobalScope & {
}
// The precache becomes mealquest-precache-v1.
setCacheNameDetails({ prefix: 'mealquest', precache: 'precache', suffix: cacheVersion })
setCacheNameDetails({ prefix: 'mealquest', precache: 'precache', suffix: cacheVersions.precache })
const day = 24 * 60 * 60
+13 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { cacheFallbackHeader, cacheNames, markCacheFallback, routeFor, staleCaches, type Route } from './routes'
import { cacheFallbackHeader, cacheNames, cacheVersions, markCacheFallback, routeFor, staleCaches, type Route } from './routes'
const origin = 'https://mealquest.test'
@@ -146,6 +146,18 @@ describe('cache names', () => {
it('all start with mealquest-', () => {
for (const name of Object.values(cacheNames)) expect(name).toMatch(/^mealquest-/)
})
it('retire only the v1 API cache', () => {
// v1 API responses held dinners with the pasted recipe_url, which can
// carry a personal token; offline, the worker would still serve them.
// The precache, photos and fonts keep their v1 caches.
expect(cacheNames).toEqual({
api: 'mealquest-api-v2',
media: 'mealquest-media-v1',
fonts: 'mealquest-fonts-v1',
})
expect(cacheVersions.precache).toBe('v1')
})
})
describe('staleCaches', () => {
+14 -5
View File
@@ -24,14 +24,23 @@ export interface RouteRequest {
mode: RequestMode
}
// Bumping the version renames every cache; activate then deletes the old ones.
export const cacheVersion = 'v1'
// Each cache carries its own version. Bumping one renames only that cache;
// activate then deletes its old name and keeps every other cache, so saved
// photos and fonts stay available offline.
export const cacheVersions = {
precache: 'v1',
// v2 retires the v1 API cache, whose dinner responses still carried the
// pasted recipe_url.
api: 'v2',
media: 'v1',
fonts: 'v1',
} as const
export const cachePrefix = 'mealquest-'
export const cacheNames = {
api: `${cachePrefix}api-${cacheVersion}`,
media: `${cachePrefix}media-${cacheVersion}`,
fonts: `${cachePrefix}fonts-${cacheVersion}`,
api: `${cachePrefix}api-${cacheVersions.api}`,
media: `${cachePrefix}media-${cacheVersions.media}`,
fonts: `${cachePrefix}fonts-${cacheVersions.fonts}`,
} as const
export const networkTimeoutSeconds = 3
+1
View File
@@ -2,6 +2,7 @@
// source: they pin the parts routes.ts cannot express — the API cache's
// strategy, the font strategy's expiration, the font route's place ahead of
// the precache route and network-only requests left to the browser.
// src/sw.test.ts runs the worker's activation against mocked Cache Storage.
import { describe, expect, it } from 'vitest'
import source from '../sw.ts?raw'
-2
View File
@@ -10,7 +10,6 @@ export type DinnerBody = {
cuisine: string | null
is_favorite: boolean
recipe_id: string | null
recipe_url: string | null
photo_url: string | null
grounding_status: string
grounding_error: string | null
@@ -33,7 +32,6 @@ export function dinnerBody(id: string, name: string, overrides: Partial<DinnerBo
cuisine: null,
is_favorite: false,
recipe_id: null,
recipe_url: null,
photo_url: null,
grounding_status: 'grounded',
grounding_error: null,
+1 -1
View File
@@ -19,5 +19,5 @@
"noEmit": true
},
"include": ["src", "vite.config.ts", "manifest.json"],
"exclude": ["src/sw.ts"]
"exclude": ["src/sw.ts", "src/sw.test.ts"]
}
+1 -1
View File
@@ -4,6 +4,6 @@
"lib": ["ES2022", "WebWorker"],
"types": []
},
"include": ["src/sw.ts"],
"include": ["src/sw.ts", "src/sw.test.ts"],
"exclude": []
}