~bigbes/sr-ht-spec

ref: 6e59bf0ffc7d16f509dad40063d35bf8f6fb98a7 sr-ht-spec/search/lang.go -rw-r--r-- 4.7 KiB
6e59bf0f — Eugene Blikh api: advertise an empty scope list, not null 8 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
package search

import (
	"strings"
	"unicode"
)

// Lang is a language the index has a stemming analyzer for. Every indexed
// document is labelled with exactly one — its dominant language — and its text
// is routed block by block into the matching analyzed field.
type Lang string

const (
	LangEN Lang = "en"
	LangRU Lang = "ru"
)

// DefaultLang is the label a document with too little text to classify gets,
// and the language a block falls back to when it is too short to classify on
// its own. English rather than Russian because the machine-generated half of
// this corpus — frontmatter keys, paths, identifiers, fenced code — is English
// whatever language the prose around it is written in.
const DefaultLang = LangEN

// ruLetterRatio is the share of a text's letters that must be Cyrillic for it
// to count as Russian.
//
// The threshold is deliberately far below one half. The two error directions
// are not symmetric in likelihood: an English block essentially never contains
// Cyrillic at all, while a Russian block in this corpus routinely carries a
// third or more Latin letters — identifiers, product names, and untranslated
// technical terms are written in Latin inside Russian prose. A 0.5 threshold
// would therefore misfile real Russian paragraphs as English, and misfile
// almost no English ones as Russian.
const ruLetterRatio = 0.35

// minDetectLetters is the least number of letters a text needs before its
// script mix is treated as evidence. Below it, a single Latin acronym in a
// Russian heading (or one Russian word in an English one) would decide the
// whole block, so the caller's fallback is used instead.
const minDetectLetters = 12

// Detect classifies a text by script. ok is false when the text carries too
// few letters to classify, in which case the caller supplies the fallback —
// Detect never guesses.
func Detect(text string) (lang Lang, ok bool) {
	var cyrillic, letters int
	for _, r := range text {
		if !unicode.IsLetter(r) {
			continue
		}
		letters++
		if unicode.Is(unicode.Cyrillic, r) {
			cyrillic++
		}
	}
	if letters < minDetectLetters {
		return "", false
	}
	if float64(cyrillic)/float64(letters) >= ruLetterRatio {
		return LangRU, true
	}
	return LangEN, true
}

// DetectIn classifies a text, falling back to a language when it is too short
// to classify on its own.
func DetectIn(text string, fallback Lang) Lang {
	if l, ok := Detect(text); ok {
		return l
	}
	return fallback
}

// route splits a text into its Russian and its English part, block by block,
// so that each half is stemmed by the analyzer that understands it.
//
// This is the part the design left open, and per-*document* routing — the
// obvious reading of "detect the language and write the matching field" — is
// not sufficient. The two analyzers pass each other's script through
// untouched: bleve's `ru` analyzer leaves "indexes" as "indexes" and its `en`
// analyzer leaves "индексы" as "индексы". Foreign-script terms therefore still
// match literally (which is why a single-analyzer index is not catastrophic),
// but they match *unstemmed*, so "index" does not find "indexes" and
// "документы" does not find "документ". A specification whose prose is Russian
// and whose examples, headings and quoted requirements are English is one
// document, and one label for it necessarily mangles one of its two halves.
//
// Routing per block costs nothing extra — the document is being walked anyway —
// and removes the failure entirely: each block lands in the field whose
// analyzer stems it, and a query is run against both fields. Duplicating the
// whole text into both fields would also fix the stemming, but it doubles the
// index and double-counts every document that matches in both fields, which
// biases ranking toward mixed documents for no reason related to relevance.
//
// The unit is a line, because that is the unit the text arrives in: doc's
// plain-text projection emits a newline after every block-level node and at
// every soft line break, and frontmatter search text is one "key: value" line
// per key. So a line here is a paragraph, a wrapped fragment of one, a heading,
// a table row, a list item or a line of code. A line too short to classify
// takes fallback, which is the document's dominant language — the language of
// the prose it sits inside.
func route(text string, fallback Lang) (ru, en string) {
	var ruB, enB strings.Builder
	for _, line := range strings.Split(text, "\n") {
		if strings.TrimSpace(line) == "" {
			continue
		}
		b := &enB
		if DetectIn(line, fallback) == LangRU {
			b = &ruB
		}
		if b.Len() > 0 {
			b.WriteByte('\n')
		}
		b.WriteString(line)
	}
	return ruB.String(), enB.String()
}