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 <noreply@anthropic.com>
This commit is contained in:
@@ -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))
|
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) {
|
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, ", ")))
|
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
|
// the household does, and moves each one that breaks a rule to Dropped
|
||||||
// (see drop).
|
// (see drop).
|
||||||
func (s suggestSideQuests) PostProcess(out SideQuests) (SideQuests, error) {
|
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
|
var names []string
|
||||||
for _, r := range s.in.Context.Regulars {
|
for _, r := range s.in.Context.Regulars {
|
||||||
if n := harness.Normalize(r.Name); n != "" {
|
regulars.add(r.Name)
|
||||||
regulars[n] = r.Name
|
|
||||||
}
|
|
||||||
names = append(names, r.Name)
|
names = append(names, r.Name)
|
||||||
}
|
}
|
||||||
known := harness.NewDeduper(harness.DedupeSources{
|
known := harness.NewDeduper(harness.DedupeSources{
|
||||||
@@ -236,12 +237,48 @@ func (s suggestSideQuests) PostProcess(out SideQuests) (SideQuests, error) {
|
|||||||
return result, nil
|
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
|
// 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
|
// 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
|
// a known dinner or a Side quest already kept, cites no Regular or one
|
||||||
// in regulars (keyed by normalized name), or has a reason over ReasonLimit
|
// regulars finds no single Regular for, or has a reason over ReasonLimit
|
||||||
// characters or naming none of its cited Regulars.
|
// 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) {
|
if known.IsDuplicate(q.Name) {
|
||||||
return nil, "repeats a Regular, Quest log dinner, pending or passed Side quest"
|
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
|
var cited []string
|
||||||
for _, c := range q.CitedRegulars {
|
for _, c := range q.CitedRegulars {
|
||||||
name, ok := regulars[harness.Normalize(c)]
|
name, why := regulars.find(c)
|
||||||
if !ok {
|
if why != "" {
|
||||||
return nil, fmt.Sprintf("cites %q, which is not a Regular", c)
|
return nil, why
|
||||||
}
|
}
|
||||||
cited = append(cited, name)
|
cited = append(cited, name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
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")
|
||||||
@@ -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) {
|
func TestSuggestSideQuestsMalformedTwiceFails(t *testing.T) {
|
||||||
var f llm.Fake
|
var f llm.Fake
|
||||||
f.QueueGenerate(reply(`{"suggestions":`), reply(`{"suggestions":[]}`))
|
f.QueueGenerate(reply(`{"suggestions":`), reply(`{"suggestions":[]}`))
|
||||||
|
|||||||
Reference in New Issue
Block a user