From 1ca246b715ce826c6e90f923379f253b2cfd3b79 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Tue, 6 Oct 2026 21:48:12 -0400 Subject: [PATCH] Match cited Regulars unambiguously and reject filler-only names 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 --- internal/ai/skills/suggest_side_quests.go | 57 +++++++++++++++---- .../ai/skills/suggest_side_quests_test.go | 48 ++++++++++++++++ 2 files changed, 95 insertions(+), 10 deletions(-) diff --git a/internal/ai/skills/suggest_side_quests.go b/internal/ai/skills/suggest_side_quests.go index b83ef61..00a85cb 100644 --- a/internal/ai/skills/suggest_side_quests.go +++ b/internal/ai/skills/suggest_side_quests.go @@ -182,6 +182,9 @@ func (s suggestSideQuests) Validate(out SideQuests) []string { 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, ", "))) } @@ -199,12 +202,10 @@ func (s suggestSideQuests) Validate(out SideQuests) []string { // the household does, and moves each one that breaks a rule to Dropped // (see drop). func (s suggestSideQuests) PostProcess(out SideQuests) (SideQuests, error) { - regulars := map[string]string{} // normalized name -> name + regulars := regularNames{exact: map[string][]string{}, normalized: map[string][]string{}} var names []string for _, r := range s.in.Context.Regulars { - if n := harness.Normalize(r.Name); n != "" { - regulars[n] = r.Name - } + regulars.add(r.Name) names = append(names, r.Name) } known := harness.NewDeduper(harness.DedupeSources{ @@ -236,12 +237,48 @@ func (s suggestSideQuests) PostProcess(out SideQuests) (SideQuests, error) { 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 +} + +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 not -// in regulars (keyed by normalized name), or has a reason over ReasonLimit +// 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 map[string]string, known *harness.Deduper, kept []SideQuest) ([]string, string) { +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" } @@ -255,9 +292,9 @@ func drop(q SideQuest, regulars map[string]string, known *harness.Deduper, kept } var cited []string for _, c := range q.CitedRegulars { - name, ok := regulars[harness.Normalize(c)] - if !ok { - return nil, fmt.Sprintf("cites %q, which is not a Regular", c) + name, why := regulars.find(c) + if why != "" { + return nil, why } cited = append(cited, name) } diff --git a/internal/ai/skills/suggest_side_quests_test.go b/internal/ai/skills/suggest_side_quests_test.go index ed1df36..d1f20fc 100644 --- a/internal/ai/skills/suggest_side_quests_test.go +++ b/internal/ai/skills/suggest_side_quests_test.go @@ -176,6 +176,30 @@ func TestSuggestSideQuestsInventedRegularDropped(t *testing.T) { }) } +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 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") @@ -276,6 +300,30 @@ func TestSuggestSideQuestsMalformedRepairedOnce(t *testing.T) { } } +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":[]}`))