package suggestions import "strings" // What language is this DOCUMENT in — as opposed to this span. // // `readsAsPairLang` next door answers a span-level question for the purpose of // labelling one card, and it is written to under-claim: two marker words, or // nothing. A whole document needs the opposite temperament. A proportion, not a // presence — one Portuguese quotation inside an English essay must not flip the // entire pass into Portuguese, and one English sentence at the end of a // Portuguese journal must not keep it in English. // // Three properties, in the order they bite: // // - Decided over the WHOLE document, never a chunk. The grammar checkpoint // sends only the sentences that changed, so a verdict computed from what it // asked about would put an English card in a Portuguese journal the moment // she edits its one English line. The caller passes content_text, always. // // - Hysteresis. A bilingual paragraph sits near whatever single threshold we // pick, and a document crossing it every few keystrokes would alternate card // languages between passes — the same instability the mascot needed a band // for. Flip to the pair at 70% and back only below 40%; in between, whatever // it was last time stands. The band is the feature, not a rounding // tolerance. // // - Plain code, no model call. The house rule is that the LLM is garnish, // never a gatekeeper: a document must not become uncheckable because the // inference box is down. const ( docLangEnglish = "en" docLangPair = "pair" ) // The band. Deliberately wide: the cost of an unnecessary flip is every card in // the document changing language, which is far more startling than a paragraph // of mixed writing being read as whichever language it was a minute ago. const ( flipToPairAt = 0.70 flipToEnglishBelow = 0.40 ) // documentLang returns the language verdict for a document, given the verdict it // carried before. `prev` is "" for a document that has never been read. // // The result is one of docLangEnglish / docLangPair — not a language code. Which // language "pair" means is the writer's `pair_lang`, and keeping the stored // verdict relative to her pair means changing her pair doesn't strand a stale // language name on every document she owns. func documentLang(contentText, pairLang, prev string) string { if prev != docLangPair { prev = docLangEnglish } p := normalizePairLang(pairLang) if !hasLangTest(p) { // No test for this pair: say English, which is what every surface did // before this phase. A wrong flip is louder than a missing one. return docLangEnglish } var pair, english int for _, c := range splitChunks(contentText, "") { switch sentenceLang(c.text, p) { case docLangPair: pair++ case docLangEnglish: english++ } } decided := pair + english if decided == 0 { // Nothing to go on — an empty document, a list of numbers, a title. Hold // the previous verdict rather than resetting a Portuguese journal to // English because she cleared it to start again. return prev } ratio := float64(pair) / float64(decided) switch { case ratio >= flipToPairAt && corroborated(contentText, p): return docLangPair case ratio < flipToEnglishBelow: return docLangEnglish default: return prev } } // sentenceLang classifies one sentence as pair-language, English, or neither. // // Neither is a real answer and carries weight: a sentence with no evidence // either way ("Bom dia.", "OK.", a heading) is left out of the ratio entirely // rather than counted for the language it isn't. Counting the undecided as // English is what would keep a Portuguese document in English forever, since // short sentences carry no markers. func sentenceLang(s, p string) string { if p == "zh" { han, latin := scriptCounts(s) switch { case han >= 2 && han > latin: return docLangPair case latin >= 3 && latin > han: return docLangEnglish } return "" } // A Latin pair shares its alphabet with English, so both sides are counted // the same way and the larger pile of evidence wins. A tie — including no // evidence at all — is no answer, which is why the English list below is // curated as carefully against the pair languages as theirs is against // English. pair := distinctMarkers(s, latinMarkers[p]) eng := distinctMarkers(s, englishMarkers) switch { case pair > eng: return docLangPair case eng > pair: return docLangEnglish } return "" } // corroborated requires the document as a whole to carry real evidence of the // pair language before the pass flips into it. A two-sentence document of // "Sim." / "Não." would otherwise reach 100% on almost nothing; a flip changes // every card in the document, so it has to be earned document-wide and not only // in proportion. func corroborated(contentText, p string) bool { if p == "zh" { han, _ := scriptCounts(contentText) return han >= 8 } return distinctMarkers(contentText, latinMarkers[p]) >= 3 } // hasLangTest reports whether readsAsPairLang / sentenceLang know how to test // this pair at all. Kept beside the tests it describes so a new pair that adds // markers without adding itself here fails loudly in review rather than quietly // at runtime. func hasLangTest(p string) bool { if p == "zh" { return true } return len(latinMarkers[p]) > 0 } // English function words, curated against the pair languages exactly as // `latinMarkers` is curated against English. // // Every word here is one a Portuguese, French or Spanish sentence has no reason // to contain. Deliberately absent, each a false English vote waiting to happen // in someone's own language: "on" and "son" (French), "as", "a", "o", "e", "no", // "os" (Portuguese), "no", "para", "sin" (Spanish), and "is"-alikes that are // really other languages' words. The list is short on purpose — it does not need // coverage, only a reliable vote in the sentences where the pair list is silent. var englishMarkers = words( "the", "and", "is", "are", "was", "were", "be", "been", "being", "of", "to", "that", "this", "these", "those", "with", "from", "for", "have", "has", "had", "they", "them", "their", "there", "then", "than", "what", "which", "when", "where", "why", "how", "who", "will", "would", "should", "could", "can", "about", "because", "into", "some", "such", "only", "very", "much", "many", "other", "our", "your", "its", "it", "he", "she", "we", "you", "but", "not", "just", "like", "also", "most", "over", "after", "before", "between", "through", "said", "says", "get", "got", "make", "made", "know", "think", "thing", "things", "time", "people", "here", "always", "never", "something", "want", "need", "feel", "day", "today", "good", "really", "still", "even", ) // normalizeDocLang folds a stored verdict into the two values the rest of the // code reasons about: the column holds "" for a document nothing has read yet, // and that means English, which is what every surface did before this phase. func normalizeDocLang(v string) string { if strings.TrimSpace(v) == docLangPair { return docLangPair } return docLangEnglish }