From 62d47d28ac5ecff8cd24c64ca4677e32bba56e54 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 31 Jul 2026 14:25:23 +0400 Subject: [PATCH 1/2] Add an eval check for formal and third-person address (#384) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phrasing run produced two persona breaks that scored clean: "Приходите… Жду вас" (formal plural) and "Он не ел 11 дней" (talks about him instead of to him). She is feminine, he is male, and she speaks to him informally, one to one. The new `address` check flags the "вы" family, plural imperative endings, and a third-person "он" with no other subject named earlier in the message. Like `hisgender` it is a keyword/suffix heuristic, not a parser, and it prints the word it tripped on so a false alarm is easy to dismiss. Limits are written out in the comment. Both recorded strings are pinned as unit tests. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ --- internal/phraser/eval/checks.go | 150 ++++++++++++++++++++++++++++- internal/phraser/eval/eval_test.go | 35 +++++++ 2 files changed, 184 insertions(+), 1 deletion(-) diff --git a/internal/phraser/eval/checks.go b/internal/phraser/eval/checks.go index 6c4c7dc..3a90ffe 100644 --- a/internal/phraser/eval/checks.go +++ b/internal/phraser/eval/checks.go @@ -21,10 +21,14 @@ const ( // CheckHisGender — the other half of the persona rule: SHE is feminine, HE // is male. "ты давно не отдыхала" addresses the operator as a woman. CheckHisGender = "hisgender" + + // CheckAddress — she talks TO him, informally, one to one. Not "вы", not + // "он". See the comment block above checkAddress. + CheckAddress = "address" ) // CheckNames — report order. -var CheckNames = []string{CheckMood, CheckLang, CheckLength, CheckFeminine, CheckHisGender, CheckCringe, CheckOnTopic} +var CheckNames = []string{CheckMood, CheckLang, CheckLength, CheckFeminine, CheckHisGender, CheckAddress, CheckCringe, CheckOnTopic} // Result — one check on one message. type Result struct { @@ -57,6 +61,7 @@ func RunChecks(c Case, body, mood string) []Result { checkLength(body), checkFeminine(body), checkHisGender(body), + checkAddress(body), checkCringe(body), checkOnTopic(c, body), } @@ -302,6 +307,149 @@ func prevWord(words []string, i int) string { return "" } +// --- how she addresses him ------------------------------------------------ +// +// Persona hard constraint: Maven speaks TO him, informally, one to one. The +// phrasing eval produced two breaks of it, and both scored clean: +// +// - "Приходите… Жду вас" — the formal plural. Correct is ты/тебя/тебе and a +// singular imperative ("приходи", "жду тебя"). +// - "Он не ел 11 дней" — she talks ABOUT him, in the third person, as if +// reporting to somebody else. Correct is "ты не ел 11 дней". +// +// Like checkHisGender this is a keyword + suffix heuristic, NOT a parser. Every +// hit prints the word it tripped on, so a false alarm is obvious at a glance and +// can be dismissed. +// +// Part 1, formal address. Two signals: +// - the "вы" pronoun family, matched as whole words, so there is nothing to +// exclude — "вы" and "вас" are never anything else. +// - a plural verb ending: -ите/-ете/-йте/-ьте ("приходите", "выпейте", +// "не забудьте", "хотите"). Nouns in the prepositional case share those +// endings ("в интернете", "в свете"), so a word right after a preposition is +// skipped. That is the whole exclusion list, on purpose: a bigger one would +// start swallowing real imperatives. +// +// Part 2, third person. "он" is perfectly fine when the message really is about +// somebody or something else ("сервис упал, он не отвечает"). The way to tell +// them apart: a legitimate third person has an ANTECEDENT — the thing it refers +// to was named earlier in the message. So "он" is only flagged when nothing +// before it in the message could be that thing. +// +// Where this gives up, plainly: +// - it only looks BACKWARD. "Он не отвечает, сервис упал" names the subject +// after the pronoun and is flagged wrongly. +// - any noun earlier in the message counts as an antecedent, even when it is +// not one ("после обеда он не ел" reads as legitimate and is missed). +// - a message that opens with "ты" and only later slips into "он" is missed, +// because "ты" itself is skipped but the words around it are not. +// - formal address outside these endings (short adjectives, "вашими" style +// forms not listed) is missed. + +// addressWordRE also takes Latin words, because "him"/"he" is the same break in +// English. +var addressWordRE = regexp.MustCompile(`[\p{Cyrillic}]+|[a-zA-Z]+|[,.;:!?…—-]`) + +// formalPronouns — the "вы" family. Whole-word match, so no false hits. +var formalPronouns = map[string]bool{ + "вы": true, "вас": true, "вам": true, "вами": true, + "ваш": true, "ваша": true, "ваше": true, "ваши": true, + "вашего": true, "вашей": true, "вашему": true, "вашим": true, + "вашими": true, "вашу": true, +} + +// prepositions — used twice: to skip prepositional-case nouns that look like +// plural verbs, and as words that cannot be what "он" refers to. +var prepositions = map[string]bool{ + "в": true, "во": true, "на": true, "о": true, "об": true, "обо": true, + "при": true, "по": true, "за": true, "из": true, "с": true, "со": true, + "к": true, "ко": true, "до": true, "от": true, "у": true, "над": true, + "под": true, "про": true, "без": true, "для": true, "через": true, +} + +// pluralVerb reports whether a word looks like a plural/formal verb form: +// "приходите", "выпейте", "забудьте", "хотите". +func pluralVerb(w string) bool { + if len([]rune(w)) < 5 { + return false + } + return strings.HasSuffix(w, "ите") || strings.HasSuffix(w, "ете") || + strings.HasSuffix(w, "йте") || strings.HasSuffix(w, "ьте") +} + +// thirdPersonHim — pronouns that would be talking about him instead of to him. +var thirdPersonHim = map[string]bool{ + "он": true, "его": true, "ему": true, "него": true, "нему": true, "ним": true, + "he": true, "him": true, "his": true, +} + +// notAnAntecedent — words that cannot be the thing "он" refers to: pronouns, +// particles, conjunctions, adverbs of time. If only these come before "он", the +// message never named a third party and "он" is him. +var notAnAntecedent = map[string]bool{ + "не": true, "ни": true, "и": true, "а": true, "но": true, "да": true, + "же": true, "бы": true, "ли": true, "вот": true, "уже": true, + "ещё": true, "еще": true, "тоже": true, "там": true, "тут": true, + "здесь": true, "это": true, "что": true, "как": true, "когда": true, + "чтобы": true, "потому": true, "сейчас": true, "потом": true, + "я": true, "мне": true, "меня": true, "мной": true, "мы": true, "нас": true, + "ты": true, "тебя": true, "тебе": true, "тобой": true, + "твой": true, "твоя": true, "твоё": true, "твое": true, "твои": true, "твою": true, +} + +// looksPastVerb — a past-tense verb needs a subject of its own, so it is not an +// antecedent either. Keeps "сервис упал, он не отвечает" working off "сервис". +func looksPastVerb(w string) bool { + if len([]rune(w)) < 3 { + return false + } + return strings.HasSuffix(w, "л") || strings.HasSuffix(w, "ла") || + strings.HasSuffix(w, "ло") || strings.HasSuffix(w, "ли") +} + +func checkAddress(body string) Result { + words := addressWordRE.FindAllString(strings.ToLower(body), -1) + + for i, w := range words { + if formalPronouns[w] { + return Result{CheckAddress, false, + fmt.Sprintf("formal %q — she says ты/тебя/тебе", w)} + } + if pluralVerb(w) && !(i > 0 && prepositions[words[i-1]]) { + return Result{CheckAddress, false, + fmt.Sprintf("plural imperative %q — she uses the singular", w)} + } + } + + for i, w := range words { + if !thirdPersonHim[w] { + continue + } + named := false + for j := 0; j < i; j++ { + p := words[j] + if !unicode.Is(unicode.Cyrillic, []rune(p)[0]) && !isLatinWord(p) { + continue // punctuation + } + if notAnAntecedent[p] || prepositions[p] || thirdPersonHim[p] || looksPastVerb(p) { + continue + } + named = true + break + } + if !named { + return Result{CheckAddress, false, + fmt.Sprintf("third person %q with nobody else named — she talks to him, not about him", w)} + } + } + return Result{CheckAddress, true, ""} +} + +func isLatinWord(w string) bool { + r := []rune(w)[0] + return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') +} + // --- the cringe checks --------------------------------------------------- // // "Think Jarvis without the cringe part". DESIGN.md § Non-goals: "Not a diff --git a/internal/phraser/eval/eval_test.go b/internal/phraser/eval/eval_test.go index 72932bd..3342793 100644 --- a/internal/phraser/eval/eval_test.go +++ b/internal/phraser/eval/eval_test.go @@ -75,6 +75,7 @@ func TestStubBaseline(t *testing.T) { CheckLength: 12, CheckFeminine: 15, CheckHisGender: 15, + CheckAddress: 15, CheckCringe: 15, CheckOnTopic: 12, } @@ -123,6 +124,10 @@ func TestChecksCatchWhatTheyClaim(t *testing.T) { {"asks how he feels", "как ты себя чувствуешь? попей воды.", CheckCringe}, {"praise", "молодец! теперь попей воды.", CheckCringe}, {"off topic", "пора бы уже что-то сделать.", CheckOnTopic}, + // The two recorded persona breaks from the phrasing eval run. Pinned as + // unit tests because an eval run is sampled and may not reproduce them. + {"formal plural", "Приходите… Жду вас", CheckAddress}, + {"third person about him", "Он не ел 11 дней", CheckAddress}, } for _, tc := range cases { @@ -144,6 +149,36 @@ func TestChecksCatchWhatTheyClaim(t *testing.T) { } } +// TestAddressCheck — the address check on its own, so the messages that must NOT +// trip it can be written without also having to satisfy the on-topic check. +func TestAddressCheck(t *testing.T) { + bad := []string{ + "Приходите… Жду вас", // the recorded formal-plural break + "Он не ел 11 дней", // the recorded third-person break + "Выпейте воды, пожалуйста.", // plural imperative on its own + "Ваш обед был давно.", // formal possessive + } + for _, body := range bad { + if r := checkAddress(body); r.Pass { + t.Errorf("persona break not caught: %q", body) + } else { + t.Logf("%q -> %s", body, r.Detail) + } + } + + good := []string{ + "ты не пил воду четыре часа — попей.", // correct informal address + "сервис netdata упал, он не отвечает.", // legitimately about a third party + "я заметила, что зарядка была утром.", // no address at all + "в интернете опять тихо, всё работает.", // "интернете" is a noun, not an imperative + } + for _, body := range good { + if r := checkAddress(body); !r.Pass { + t.Errorf("clean message flagged: %q -> %s", body, r.Detail) + } + } +} + func TestMoodCheckUsesTheEnum(t *testing.T) { if r := checkMood("cheerful"); r.Pass { t.Error("mood outside the enum passed") From 9949b309b1c575f152a72c1e7f04ac7ad4cad8e8 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 31 Jul 2026 14:27:08 +0400 Subject: [PATCH 2/2] Don't let a time word blind the third-person check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The check asks whether anyone else was named before "он". Time words were not stoplisted, so "сегодня он не ел" read "сегодня" as the person being talked about and passed — which is the recorded break with a word in front of it, and nudges open with those words constantly. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ --- internal/phraser/eval/address_time_test.go | 26 ++++++++++++++++++++++ internal/phraser/eval/checks.go | 12 +++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 internal/phraser/eval/address_time_test.go diff --git a/internal/phraser/eval/address_time_test.go b/internal/phraser/eval/address_time_test.go new file mode 100644 index 0000000..894a05c --- /dev/null +++ b/internal/phraser/eval/address_time_test.go @@ -0,0 +1,26 @@ +package eval + +import "testing" + +func TestAddressTimeWordDoesNotBlind(t *testing.T) { + // A nudge that opens with a time word must still be caught. Without the + // time words in the stoplist, "сегодня" was read as the third party. + for _, s := range []string{ + "сегодня он не ел 11 дней", + "вчера он не пил воду", + "опять он забыл про таблетки", + } { + if r := checkAddress(s); r.Pass { + t.Errorf("checkAddress(%q) passed, want a third-person failure", s) + } + } + // Still must not fire when a third party really is named. + for _, s := range []string{ + "сегодня сервис упал, он не отвечает", + "ты не пил воду четыре часа", + } { + if r := checkAddress(s); !r.Pass { + t.Errorf("checkAddress(%q) failed: %s", s, r.Detail) + } + } +} diff --git a/internal/phraser/eval/checks.go b/internal/phraser/eval/checks.go index 3a90ffe..838cbd3 100644 --- a/internal/phraser/eval/checks.go +++ b/internal/phraser/eval/checks.go @@ -340,7 +340,11 @@ func prevWord(words []string, i int) string { // - it only looks BACKWARD. "Он не отвечает, сервис упал" names the subject // after the pronoun and is flagged wrongly. // - any noun earlier in the message counts as an antecedent, even when it is -// not one ("после обеда он не ел" reads as legitimate and is missed). +// not one ("после обеда он не ел" reads as legitimate and is missed). The +// common time words are stoplisted so the usual nudge opening does not +// blind it, but a message with any other noun in front still slips through. +// This is the check's real hole; widening it further would start flagging +// legitimate third-party messages, so it stops here. // - a message that opens with "ты" and only later slips into "он" is missed, // because "ты" itself is skipped but the words around it are not. // - formal address outside these endings (short adjectives, "вашими" style @@ -392,6 +396,12 @@ var notAnAntecedent = map[string]bool{ "ещё": true, "еще": true, "тоже": true, "там": true, "тут": true, "здесь": true, "это": true, "что": true, "как": true, "когда": true, "чтобы": true, "потому": true, "сейчас": true, "потом": true, + // Time words. A nudge almost always opens with one ("сегодня он не ел"), + // and without them the very next word is read as the person being talked + // about, so the check misses the exact break it was written for. + "сегодня": true, "вчера": true, "завтра": true, "послезавтра": true, + "утром": true, "днём": true, "днем": true, "вечером": true, "ночью": true, + "опять": true, "снова": true, "весь": true, "всю": true, "целый": true, "я": true, "мне": true, "меня": true, "мной": true, "мы": true, "нас": true, "ты": true, "тебя": true, "тебе": true, "тобой": true, "твой": true, "твоя": true, "твоё": true, "твое": true, "твои": true, "твою": true,