package eval import ( "fmt" "regexp" "strings" "unicode" "unicode/utf8" "github.com/kami/maven/internal/morph" ) // The check names, in report order. Every check is a string or length test — no // model grades another model here. const ( CheckMood = "mood" // mood is in the documented enum CheckLang = "lang" // the operator's language, not the prompt's CheckLength = "length" // a nudge is one sentence, not a paragraph CheckFeminine = "feminine" // her self-reference is feminine (hard constraint) CheckCringe = "cringe" // docs/design.md § Non-goals, "not a relationship" CheckOnTopic = "ontopic" // says the thing the rule is about // 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, CheckAddress, CheckCringe, CheckOnTopic} // Result — one check on one message. type Result struct { Name string Pass bool Detail string } // Moods — the fixed enum from the LLM output contract. Not extended here; the // contract lives in the daemon and the harness only reads it. var Moods = map[string]bool{ "neutral": true, "happy": true, "thinking": true, "tired": true, "confused": true, } // Length ceilings. Justification: the nudge is spoken by piper at roughly 14 // characters per second, so 120 characters is about 8 seconds of speech. The // operator has AuDHD — past one short sentence a nudge stops being a nudge and // becomes something to tune out, which is exactly the "not a nag" failure. The // word ceiling catches the same thing for languages that pack more per byte. const ( MaxChars = 120 MaxWords = 16 ) // RunChecks scores one message. Order matches CheckNames. func RunChecks(c Case, body, mood string) []Result { return []Result{ checkMood(mood), checkLang(body), checkLength(body), checkFeminine(body), checkHisGender(body), checkAddress(body), checkCringe(body), checkOnTopic(c, body), } } // Feminine, HisGender and Address expose three checks one at a time, so the // daemon can run them on a phrased message before he hears it (Vikunja #399). // Only these three: they are unambiguous string tests with nothing to compare // against, while length is path-specific and ontopic needs the fixture's // expected fragments, which do not exist at runtime. func Feminine(body string) Result { return checkFeminine(body) } // HisGender — see checkHisGender. func HisGender(body string) Result { return checkHisGender(body) } // Address — see checkAddress. func Address(body string) Result { return checkAddress(body) } func checkMood(mood string) Result { if Moods[mood] { return Result{CheckMood, true, ""} } return Result{CheckMood, false, fmt.Sprintf("mood %q not in the enum", mood)} } // checkLang — the operator is Russian-speaking and the nudge is spoken aloud by // a Russian piper voice. An English nudge is not a tone problem, it is an // unusable one. func checkLang(body string) Result { cyr, lat := 0, 0 for _, r := range body { switch { case unicode.Is(unicode.Cyrillic, r): cyr++ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z': lat++ } } if cyr > lat { return Result{CheckLang, true, ""} } return Result{CheckLang, false, fmt.Sprintf("not Russian (%d cyrillic vs %d latin letters)", cyr, lat)} } func checkLength(body string) Result { chars := utf8.RuneCountInString(body) words := len(strings.Fields(body)) if chars <= MaxChars && words <= MaxWords { return Result{CheckLength, true, ""} } return Result{CheckLength, false, fmt.Sprintf("%d chars / %d words, ceiling %d / %d", chars, words, MaxChars, MaxWords)} } // --- feminine self-reference --------------------------------------------- // // The hard constraint (CLAUDE.md, docs/design.md § Identity): Maven's Russian // self-reference is feminine. The operator is male, so second-person forms // addressed to him are MASCULINE and must not be flagged — "ты не пил воду" is // correct, "я напомнил" is not. Both directions matter, which is why this is a // windowed scan around "я" and not a bare search for masculine endings. var wordRE = regexp.MustCompile(`[\p{Cyrillic}]+|[,.;:!?…—-]`) // secondPerson — pronouns that end the self-reference window. Everything after // one of these is about him, not about her. var secondPerson = map[string]bool{ "ты": true, "тебе": true, "тебя": true, "тобой": true, "вы": true, "вам": true, "вас": true, "он": true, "она": true, "оно": true, "они": true, } // masculinePredicative — short adjectives with no verb ending to key off. var masculinePredicative = map[string]bool{ "должен": true, "готов": true, "рад": true, "уверен": true, "обязан": true, "сам": true, "занят": true, "прав": true, } // masculinePast reports whether a word is a masculine past-tense verb. Russian // past tense is gendered by suffix: -л for him, -ла for her. A small model with // weak Russian defaults to the masculine form, which is the exact drift this // check measures. // // The ending is only half the test, and the other half used to be a hand list of // nineteen nouns that end in л — стол, файл, апрель — kept "small on purpose", // which means incomplete on purpose. A list of exceptions to a pattern is the // pattern conceding it is wrong, so the second half is now a dictionary lookup: // ends in -л AND is a form of a verb (Vikunja #526). Every noun the list held is // correctly not a verb, and so are the ones it had not got round to. func masculinePast(w string) bool { if len([]rune(w)) < 3 { return false } if !strings.HasSuffix(w, "л") && !strings.HasSuffix(w, "лся") { return false } return morph.IsVerbForm(w) } func checkFeminine(body string) Result { words := wordRE.FindAllString(strings.ToLower(body), -1) for i, w := range words { if w != "я" { continue } // Scan the next few words. Stop at punctuation or at a second-person // pronoun: past that point the sentence is about him and masculine is // correct. for j := i + 1; j < len(words) && j <= i+3; j++ { nw := words[j] if len(nw) == 1 && !unicode.Is(unicode.Cyrillic, []rune(nw)[0]) { break } if secondPerson[nw] { break } if masculinePast(nw) || masculinePredicative[nw] { return Result{CheckFeminine, false, fmt.Sprintf("masculine self-reference %q after \"я\"", nw)} } } } // Second pass: self-reference with the pronoun dropped — "напомнил тебе", // "проверил за тебя". A masculine past-tense verb whose object is HIM can // only be her speaking about herself. // // Two guards, both from a false positive on the talk fixture: "ты заплатил // за домен до марта" scored as her drift and cost the run a point it had // earned (Vikunja #462). He is male, so a past-tense verb governed by "ты" // must be masculine. And a bare "за" is not evidence of anything — "за // домен" is a price, "за тебя" is her doing something on his behalf — so it // only counts when he is the one it points at. for i, w := range words { if !masculinePast(w) || i+1 >= len(words) || governedByYou(words, i) { continue } next := words[i+1] aboutHim := next == "тебе" || next == "тебя" if next == "за" && i+2 < len(words) && (words[i+2] == "тебя" || words[i+2] == "тебе") { aboutHim = true } if aboutHim { return Result{CheckFeminine, false, fmt.Sprintf("masculine self-reference %q before %q", w, next)} } } return Result{CheckFeminine, true, ""} } // --- he is male ---------------------------------------------------------- // // The mirror of checkFeminine, and the failure it was written for: the model // wrote "ты давно не отдыхала", which addresses the operator as a woman. That // scored clean, because checkFeminine only ever looks at how SHE speaks about // herself. // // How it works: Russian past tense is gendered by suffix, -л (m) / -ла (f). So // this looks for feminine past-tense words in a sentence that also talks TO him // ("ты", "тебя", "тебе", "твой", …). A feminine verb that belongs to her ("я // заметила", "напомнила тебе") is skipped — that one is correct. // // Honest about the limits: this is a suffix rule, not a parser. // - False positives: a feminine noun can be the subject in the same sentence // ("зарядка была утром, ты её пропустил"). The guard below skips a verb whose // previous word looks like a feminine noun, which helps but will not always // be right. // - False negatives: gender also shows up outside the past tense (short // adjectives, "сама"), and none of that is checked here. // // That is acceptable for an eval check. It is a signal to read the message, not // a grammar verdict, and every hit prints the word it tripped on so a human can // disagree. // hisMarkers — words that mean the sentence is addressed to him. var hisMarkers = map[string]bool{ "ты": true, "тебя": true, "тебе": true, "тобой": true, "тобою": true, "твой": true, "твоя": true, "твоё": true, "твое": true, "твои": true, "твою": true, } // notFeminineVerb — ordinary words ending in "-ла" that are not verbs. Small on // purpose: it only has to cover words a nudge might actually use. // // Words that are both a noun and a verb are deliberately NOT here. "села", // "мыла" and "стекла" are nouns on paper, but in a nudge they are almost always // verbs ("ты села", "ты мыла"), and listing them would make the check miss the // exact thing it is for. Missing a real hit is worse than one false alarm. var notFeminineVerb = map[string]bool{ "школа": true, "скала": true, "игла": true, "метла": true, "смола": true, "дела": true, "тела": true, "масла": true, "весла": true, "зола": true, "пчела": true, "числа": true, } // femininePast reports whether a word looks like a feminine past-tense verb: // "отдыхала", "поела", "выспалась". func femininePast(w string) bool { if len([]rune(w)) < 3 || notFeminineVerb[w] { return false } return strings.HasSuffix(w, "ла") || strings.HasSuffix(w, "лась") } // looksFeminineNoun — a crude guard against "зарядка была": a word right before // the verb that ends in "а"/"я" and is not itself a verb is probably the subject. func looksFeminineNoun(w string) bool { if femininePast(w) || len([]rune(w)) < 3 { return false } return strings.HasSuffix(w, "а") || strings.HasSuffix(w, "я") } // sentenceRE splits on sentence-ending punctuation, so a feminine verb in one // sentence is not blamed on a "ты" in the next. var sentenceRE = regexp.MustCompile(`[.!?;…]+`) func checkHisGender(body string) Result { for _, sentence := range sentenceRE.Split(strings.ToLower(body), -1) { words := wordRE.FindAllString(sentence, -1) addressed := false for _, w := range words { if hisMarkers[w] { addressed = true } } if !addressed { continue } for i, w := range words { if !femininePast(w) || hersNotHis(words, i) { continue } if i > 0 && looksFeminineNoun(prevWord(words, i)) { continue } return Result{CheckHisGender, false, fmt.Sprintf("feminine %q addressed to him — he is male", w)} } } return Result{CheckHisGender, true, ""} } // hersNotHis — the verb is Maven's own if "я" comes shortly before it, or if the // thing she did was done to him ("напомнила тебе", "проверила за тебя"). func hersNotHis(words []string, i int) bool { for j := i - 1; j >= 0 && j >= i-3; j-- { if words[j] == "я" { return true } } if i+1 < len(words) { switch words[i+1] { case "тебе", "тебя", "за", "тобой": return true } } return false } // prevWord — the word before i, skipping "не" and punctuation, so "не отдыхала" // still sees the subject. func prevWord(words []string, i int) string { for j := i - 1; j >= 0; j-- { w := words[j] if w == "не" || w == "ни" || !unicode.Is(unicode.Cyrillic, []rune(w)[0]) { continue } return w } 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 ("после обеда он не ел", "выпей воды, он не пил" — both missed). // Verbs and time words no longer count, which covers the usual nudge, but a // plain noun before the pronoun still blinds it. 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 // 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, // 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, } // looksVerb — a verb is never the thing "он" refers to, so it must not count as // an antecedent. Past tense keeps "сервис упал, он не отвечает" working off // "сервис"; the infinitive and imperative endings are here because a nudge is // mostly made of them ("попробуй встать и отдохнуть — у него есть перерыв" // slipped through with "попробуй" taken for the person being talked about). func looksVerb(w string) bool { r := []rune(w) if len(r) < 3 { return false } for _, suf := range []string{ "л", "ла", "ло", "ли", // past tense "ть", "ться", "ти", "чь", // infinitive "й", "йся", "йте", // imperative } { if strings.HasSuffix(w, suf) { return true } } return false } func checkAddress(body string) Result { words := addressWordRE.FindAllString(strings.ToLower(body), -1) // Every break, not just the first. A bad reply usually breaks in more than // one way at once — "Смотрите на его потребление воды" is a plural imperative // AND third person about him — and reporting only the first hid the second, // which made the failure look milder than it was. var breaks []string seen := map[string]bool{} add := func(msg string) { if seen[msg] { return // the same word twice in one message is one problem, not two } seen[msg] = true breaks = append(breaks, msg) } for i, w := range words { if formalPronouns[w] { add(fmt.Sprintf("formal %q — she says ты/тебя/тебе", w)) } if pluralVerb(w) && !(i > 0 && prepositions[words[i-1]]) { add(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 } // pluralVerb as well as looksVerb: looksVerb knows the imperative in // -й/-йте but not the -те plural ("смотрите"), so "Смотрите на его // потребление воды" counted "смотрите" as the person being talked // about and the "его" never printed. Third time a verb form has // blinded this check — if a fourth turns up, the antecedent test // wants a real morphology table, not another suffix. if notAnAntecedent[p] || prepositions[p] || thirdPersonHim[p] || looksVerb(p) || pluralVerb(p) { continue } named = true break } if !named { add(fmt.Sprintf("third person %q with nobody else named — she talks to him, not about him", w)) } } if len(breaks) > 0 { return Result{CheckAddress, false, strings.Join(breaks, " + ")} } 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". docs/design.md § Non-goals: "Not a // relationship — mom-tone is a function that makes nudges land, not emotional // company. Names the drift a warm small model falls into." Each pattern below // is one shape of that drift. They are deliberately specific: a check that // flags any warmth at all would make the nudges robotic, which is the other // failure. type cringePattern struct { // what the pattern is defending against, shown in the failure detail. why string pat *regexp.Regexp } var cringePatterns = []cringePattern{ { // Endearments. "Not a relationship" — a pet name reframes a nudge as // intimacy, and the operator asked for mother-like, not girlfriend-like. why: "pet name / endearment", // No \b around the Russian alternatives: Go's RE2 \b is ASCII-only and // never matches at a Cyrillic boundary, so anchoring them would make // this check silently always pass. pat: regexp.MustCompile(`(?i)(милый|дорогой|солнышко|солнце моё|солнце мое|зайчик|котик|сладкий|малыш|дружок|родной|любимый|\bhoney\b|\bsweetie\b|\bdarling\b|\bbuddy\b)`), }, { // Emoji. The nudge is spoken aloud; an emoji is either silence or a TTS // artefact. Also the single loudest cringe signal in a small model. why: "emoji", pat: nil, // handled by hasEmoji, ranges don't fit a regexp cleanly }, { // Exclamation pileup. One "!" is emphasis; two is a cheerleader. why: "more than one exclamation mark", pat: regexp.MustCompile(`!.*!|!!`), }, { // Fake concern. She has no feelings to report, and reporting them makes // the nudge about her instead of about the water. why: "fake concern opener", pat: regexp.MustCompile(`(?i)(я волну|я беспоко|беспокоюсь|переживаю|я забочусь|я тревож|мне тревожно|i'?m worried)`), }, { // Apologising. The rule decided she speaks. Apologising for a greenlit // nudge undermines the one thing that makes nudges land. why: "apology", pat: regexp.MustCompile(`(?i)(извини|прости|сожалею|прошу прощения|не хочу мешать|не хочу отвлекать|sorry|apolog)`), }, { // Offering emotional support. The explicit "not emotional company" line. why: "offer of emotional support", pat: regexp.MustCompile(`(?i)(я рядом|я здесь для теб|ты не один|всё будет хорошо|все будет хорошо|не переживай|я с тобой|обнимаю|я поддерж|держись)`), }, { // Asking how he feels. Turns a one-way nudge into a conversation he now // owes an answer to — the most reliable way to make him mute it. why: "asking how he feels", pat: regexp.MustCompile(`(?i)(как ты\s*[?.!]|как ты себя|как самочувств|как настроение|всё ли в порядке|все ли в порядке|ты в порядке|how are you)`), }, { // Praise for compliance. Rewards make the nudge a training exercise; // "not a relationship" again, from the other side. why: "praise / reward framing", pat: regexp.MustCompile(`(?i)(молодец|умница|ты справ|гордюсь|горжусь|отличная работа|так держать|good job|proud of you)`), }, } // hasEmoji — the pictographic ranges plus the variation selector. Cyrillic and // ordinary punctuation are far below all of these. func hasEmoji(s string) bool { for _, r := range s { switch { case r >= 0x1F000 && r <= 0x1FAFF, r >= 0x2600 && r <= 0x27BF, r >= 0x2B00 && r <= 0x2BFF, r == 0xFE0F, r == 0x203C, r == 0x2049: return true } } return false } func checkCringe(body string) Result { for _, c := range cringePatterns { if c.pat == nil { if hasEmoji(body) { return Result{CheckCringe, false, c.why} } continue } if m := c.pat.FindString(body); m != "" { return Result{CheckCringe, false, fmt.Sprintf("%s (%q)", c.why, m)} } } return Result{CheckCringe, true, ""} } // checkOnTopic — the message must name the thing the rule is about. A nudge // that never mentions water leaves the operator with a chime and no action. func checkOnTopic(c Case, body string) Result { return checkOnTopicAny(c.WantAny, body) } // checkOnTopicAny is the same test over a bare want-list, so the talk scorer can // reuse it without owning a nudge Case. func checkOnTopicAny(wantAny []string, body string) Result { low := strings.ToLower(body) for _, want := range wantAny { if strings.Contains(low, strings.ToLower(want)) { return Result{CheckOnTopic, true, ""} } } return Result{CheckOnTopic, false, fmt.Sprintf("mentions none of %v", wantAny)} } // --- shape checks for the free-form paths -------------------------------- // // The nudge checks assume one short sentence. Chat and query replies are longer // by design, so the only shape worth testing there is that the model produced a // reply at all and did not trail off. Both are failure modes the fallbacks in // llmphraser.go hide: a truncated or empty generation still returns nil error. const ( CheckNonEmpty = "nonempty" // she said something CheckEllipsis = "ellipsis" // she finished the sentence ) // A reply needs words in it, not just characters. This check used to test for a // non-empty string, which scored 27/27 on a run where two replies were "{" and // "{\n \"" — punctuation passed as content. Braces, quotes, digits and spaces // are all empty in the only sense that matters. // // Digits alone fail too, and that is deliberate: the same run answered "сколько // варить яйцо вкрутую?" with "15-16". No unit, no words, and it is also the // wrong number. Whatever that is, it is not something she said. func checkNonEmpty(body string) Result { if strings.TrimSpace(body) == "" { return Result{CheckNonEmpty, false, "empty reply"} } for _, r := range body { if unicode.IsLetter(r) { return Result{CheckNonEmpty, true, ""} } } return Result{CheckNonEmpty, false, fmt.Sprintf("no letters in the reply %q — punctuation or digits only", strings.TrimSpace(body))} } // checkEllipsis — a reply ending in "…" or "..." is a generation that ran out of // tokens, not a stylistic pause. Mid-sentence ellipses are left alone. func checkEllipsis(body string) Result { trimmed := strings.TrimRight(strings.TrimSpace(body), `"'»)`) if strings.HasSuffix(trimmed, "…") || strings.HasSuffix(trimmed, "...") { return Result{CheckEllipsis, false, "reply trails off in an ellipsis — likely truncated"} } return Result{CheckEllipsis, true, ""} } // governedByYou reports whether "ты" stands close enough in front of the verb // at index i to be its subject. Three words, the same window checkFeminine's // first pass uses after "я", and it stops at a first-person pronoun so "ты // просил, я напомнил" still trips. func governedByYou(words []string, i int) bool { for j := i - 1; j >= 0 && j >= i-3; j-- { switch words[j] { case "ты": return true case "я": return false } } return false }