From 70a4e540aaeaaeea687c2bb2458bbbb5a71456a0 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Tue, 6 Oct 2026 22:50:36 -0400 Subject: [PATCH] Ignore spacing beside a Regular's outer punctuation in a mention A mention took in a stored name's leading or trailing punctuation only where the text repeated the stored spacing exactly, although the rule says spacing is ignored. With Regulars "Beef Chili !" and "Chili!", the reason "Like your Beef Chili!" left the first mention at "Beef Chili", so a citation of "Chili!" passed. Match the punctuation with any spacing beside it, then apply the same border check to the extended mention. Co-Authored-By: Claude Opus 5.5 --- internal/ai/skills/suggest_side_quests.go | 40 ++++++++++++++++--- .../ai/skills/suggest_side_quests_test.go | 4 ++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/internal/ai/skills/suggest_side_quests.go b/internal/ai/skills/suggest_side_quests.go index ffcd33a..7f65d79 100644 --- a/internal/ai/skills/suggest_side_quests.go +++ b/internal/ai/skills/suggest_side_quests.go @@ -320,8 +320,9 @@ func drop(q SideQuest, regulars regularNames, known *harness.Deduper, kept []Sid // Chilis" does not, nor does "Cafe" with a combining accent after it // mention "Cafe". Punctuation at either end of the name, with any spacing // beside it, is optional, and a mention takes it in only where text spells -// it and the border still holds after it: "Easy Beef Chili!" is mentioned -// by "Easy Beef Chili." too, and by "Easy Beef Chili!x" without its "!". A +// it, spaced in any way, and the border still holds after it: "Easy Beef +// Chili!" is mentioned by "Easy Beef Chili." too, by "Easy Beef Chili !" +// with its "!", and by "Easy Beef Chili!x" without its "!". A // combining mark is part of its letter, never punctuation. 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 @@ -356,11 +357,11 @@ func (r regularNames) named(text string) []string { if !bordered(start, end) { continue } - if strings.HasSuffix(text[:start], lead) && bordered(start-len(lead), end) { - start -= len(lead) + if s := spelledBefore(text, start, lead); s >= 0 && bordered(s, end) { + start = s } - if strings.HasPrefix(text[end:], trail) && bordered(start, end+len(trail)) { - end += len(trail) + if e := spelledAfter(text, end, trail); e >= 0 && bordered(start, e) { + end = e } mentions = append(mentions, mention{name, start, end}) } @@ -377,6 +378,33 @@ func (r regularNames) named(text string) []string { return names } +// spelledAfter returns where outer ends when text spells it from at, +// ignoring spacing, or -1 when text does not. +func spelledAfter(text string, at int, outer string) int { + for _, c := range strings.Join(strings.Fields(outer), "") { + at = len(text) - len(strings.TrimLeftFunc(text[at:], unicode.IsSpace)) + if !strings.HasPrefix(text[at:], string(c)) { + return -1 + } + at += utf8.RuneLen(c) + } + return at +} + +// spelledBefore returns where outer starts when text spells it up to at, +// ignoring spacing, or -1 when text does not. +func spelledBefore(text string, at int, outer string) int { + punct := []rune(strings.Join(strings.Fields(outer), "")) + for i := len(punct) - 1; i >= 0; i-- { + at = len(strings.TrimRightFunc(text[:at], unicode.IsSpace)) + if !strings.HasSuffix(text[:at], string(punct[i])) { + return -1 + } + at -= utf8.RuneLen(punct[i]) + } + return at +} + // isWord reports whether c is a letter, a mark on one or a digit. func isWord(c rune) bool { return unicode.IsLetter(c) || unicode.IsMark(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 1b505f4..0a6076c 100644 --- a/internal/ai/skills/suggest_side_quests_test.go +++ b/internal/ai/skills/suggest_side_quests_test.go @@ -254,6 +254,10 @@ func TestSuggestSideQuestsReasonMentionRule(t *testing.T) { "spelled alike by both": {[]string{"Beef Chili!"}, "Corn chips under your Beef Chili.", "Beef Chili", none}, "punctuation then a letter": {[]string{"Beef Chili!"}, "Your Beef Chili!X is our inspiration.", "Beef Chili!", none}, "letter before punctuation": {[]string{"¡Beef Chili"}, "Your X¡Beef Chili is our inspiration.", "¡Beef Chili", none}, + "spacing before punctuation": {[]string{"Beef Chili!"}, "Your Beef Chili ! over corn chips.", "Beef Chili!", ""}, + "spacing after punctuation": {[]string{"¡Beef Chili"}, "Your ¡ Beef Chili is our inspiration.", "¡Beef Chili", ""}, + "stored spacing left off": {[]string{"Beef Chili !", "Chili!"}, "Like your Beef Chili!", "Beef Chili !", ""}, + "inside unspaced punctuation": {[]string{"Beef Chili !", "Chili!"}, "Like your Beef Chili!", "Chili!", none}, "stored symbol left off": {[]string{"Green Chili 🌶"}, "Like your Green Chili, gone green.", "Green Chili 🌶", none}, "stored accent left off": {[]string{"Chicken Cafe\u0301"}, "Like your Chicken Cafe, gone green.", "Chicken Cafe\u0301", none}, "accent written after name": {[]string{"Chicken Cafe"}, "Like your Chicken Cafe\u0301, gone green.", "Chicken Cafe", none},