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")