Add an eval check for formal and third-person address (#384)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
This commit is contained in:
kami
2026-07-31 14:25:23 +04:00
parent 3dbf67f8f9
commit 62d47d28ac
2 changed files with 184 additions and 1 deletions
+149 -1
View File
@@ -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
+35
View File
@@ -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")