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 <noreply@anthropic.com>
This commit is contained in:
@@ -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) }
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user