From 97eb97582dde33a1af1fe8b4b1a8f95e927c3e59 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Tue, 6 Oct 2026 22:21:31 -0400 Subject: [PATCH] Define one boundary rule for Regulars a Side quest's reason names 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 --- internal/ai/skills/suggest_side_quests.go | 71 +++++++++++++++---- .../ai/skills/suggest_side_quests_test.go | 48 +++++++++++++ 2 files changed, 104 insertions(+), 15 deletions(-) diff --git a/internal/ai/skills/suggest_side_quests.go b/internal/ai/skills/suggest_side_quests.go index ff8a6c9..1883461 100644 --- a/internal/ai/skills/suggest_side_quests.go +++ b/internal/ai/skills/suggest_side_quests.go @@ -209,6 +209,7 @@ func (s suggestSideQuests) PostProcess(out SideQuests) (SideQuests, error) { regulars.add(r.Name) names = append(names, r.Name) } + regulars.all = names known := harness.NewDeduper(harness.DedupeSources{ Regulars: names, QuestLog: s.in.Context.QuestLog, @@ -245,6 +246,8 @@ type regularNames struct { 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)) } @@ -309,25 +312,63 @@ func drop(q SideQuest, regulars regularNames, known *harness.Deduper, kept []Sid return cited, "" } -// named returns the Regulars text names, each found by find. Reading from -// the left, the longest run of words find takes for one Regular, its outer -// punctuation trimmed, is the Regular named there; a run naming several -// Regulars names none. So "Your Easy Beef Chili." names "Easy Beef Chili" -// and not "Beef Chili". +// 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 the name's own outer punctuation optional ("Easy Beef Chili!" is +// mentioned by "Easy Beef Chili." too) and no letter or digit just outside +// it: the start or end of text, white space or punctuation must border it, +// so "Beef Chili-inspired" and "Beef Chili's" mention "Beef Chili" while +// "Beef Chilis" does not. 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 { - words := strings.Fields(text) - var names []string - for i := 0; i < len(words); i++ { - for j := len(words); j > i; j-- { - run := strings.TrimFunc(strings.Join(words[i:j], " "), func(c rune) bool { - return !unicode.IsLetter(c) && !unicode.IsDigit(c) - }) - if name, why := r.find(run); why == "" { - names = append(names, name) - i = j - 1 + type mention struct { + name string + start, end int + } + text = exactName(text) + var mentions []mention + for _, name := range r.all { + full := exactName(name) + core := strings.TrimFunc(full, isNotWord) + if core == "" { + continue + } + lead := full[:len(full)-len(strings.TrimLeftFunc(full, isNotWord))] + 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 + before, _ := utf8.DecodeLastRuneInString(text[:start]) + after, _ := utf8.DecodeRuneInString(text[end:]) + if (start > 0 && !isNotWord(before)) || (end < len(text) && !isNotWord(after)) { + continue + } + if strings.HasSuffix(text[:start], lead) { + start -= len(lead) + } + if strings.HasPrefix(text[end:], trail) { + end += len(trail) + } + 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 } + +// isNotWord reports whether c is neither a letter nor a digit. +func isNotWord(c rune) bool { return !unicode.IsLetter(c) && !unicode.IsDigit(c) } diff --git a/internal/ai/skills/suggest_side_quests_test.go b/internal/ai/skills/suggest_side_quests_test.go index 2c2ab2f..a4e23ec 100644 --- a/internal/ai/skills/suggest_side_quests_test.go +++ b/internal/ai/skills/suggest_side_quests_test.go @@ -226,6 +226,54 @@ func TestSuggestSideQuestsReasonNamesResolvedRegular(t *testing.T) { }) } +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}, + } { + 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")