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)
|
regulars.add(r.Name)
|
||||||
names = append(names, r.Name)
|
names = append(names, r.Name)
|
||||||
}
|
}
|
||||||
|
regulars.all = names
|
||||||
known := harness.NewDeduper(harness.DedupeSources{
|
known := harness.NewDeduper(harness.DedupeSources{
|
||||||
Regulars: names,
|
Regulars: names,
|
||||||
QuestLog: s.in.Context.QuestLog,
|
QuestLog: s.in.Context.QuestLog,
|
||||||
@@ -245,6 +246,8 @@ type regularNames struct {
|
|||||||
exact map[string][]string
|
exact map[string][]string
|
||||||
// normalized is keyed by harness.Normalize of the name.
|
// normalized is keyed by harness.Normalize of the name.
|
||||||
normalized map[string][]string
|
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 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, ""
|
return cited, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// named returns the Regulars text names, each found by find. Reading from
|
// named returns the Regulars text names, by one rule. A mention of a
|
||||||
// the left, the longest run of words find takes for one Regular, its outer
|
// Regular is a place where text spells its name, ignoring case and spacing,
|
||||||
// punctuation trimmed, is the Regular named there; a run naming several
|
// with the name's own outer punctuation optional ("Easy Beef Chili!" is
|
||||||
// Regulars names none. So "Your Easy Beef Chili." names "Easy Beef Chili"
|
// mentioned by "Easy Beef Chili." too) and no letter or digit just outside
|
||||||
// and not "Beef Chili".
|
// 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 {
|
func (r regularNames) named(text string) []string {
|
||||||
words := strings.Fields(text)
|
type mention struct {
|
||||||
var names []string
|
name string
|
||||||
for i := 0; i < len(words); i++ {
|
start, end int
|
||||||
for j := len(words); j > i; j-- {
|
}
|
||||||
run := strings.TrimFunc(strings.Join(words[i:j], " "), func(c rune) bool {
|
text = exactName(text)
|
||||||
return !unicode.IsLetter(c) && !unicode.IsDigit(c)
|
var mentions []mention
|
||||||
})
|
for _, name := range r.all {
|
||||||
if name, why := r.find(run); why == "" {
|
full := exactName(name)
|
||||||
names = append(names, name)
|
core := strings.TrimFunc(full, isNotWord)
|
||||||
i = j - 1
|
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
|
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
|
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) {
|
func TestSuggestSideQuestsDuplicatesDropped(t *testing.T) {
|
||||||
declined := sideQuest("The Best Homemade Chicken Tikka-Masala", "Spiced like your Chicken Fajitas.", "Chicken Fajitas")
|
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")
|
oldDeclined := sideQuest("Fish Tacos!", "Tortillas like your Chicken Fajitas.", "Chicken Fajitas")
|
||||||
|
|||||||
Reference in New Issue
Block a user