|
1 | 1 | package spellcheck |
2 | 2 |
|
3 | 3 | import ( |
4 | | - "bufio" |
5 | | - "bytes" |
6 | | - _ "embed" |
7 | | - "errors" |
| 4 | + "encoding/json" |
8 | 5 | "fmt" |
9 | | - "regexp" |
| 6 | + "net/http" |
| 7 | + "net/url" |
10 | 8 | "strings" |
11 | | - "unicode" |
12 | | - |
13 | | - "github.com/sajari/fuzzy" |
| 9 | + "sync" |
| 10 | + "time" |
14 | 11 | ) |
15 | 12 |
|
16 | | -//go:embed data/words.txt |
17 | | -var wordListData []byte |
| 13 | +const defaultLanguageToolURL = "http://localhost:8010" |
18 | 14 |
|
19 | | -// wordRe matches sequences of ASCII letters (words). |
20 | | -var wordRe = regexp.MustCompile(`[a-zA-Z]+`) |
| 15 | +// ltResponse is the subset of the LanguageTool /v2/check response we need. |
| 16 | +type ltResponse struct { |
| 17 | + Matches []ltMatch `json:"matches"` |
| 18 | +} |
21 | 19 |
|
22 | | -// Correction describes a single spelling mistake and its suggested fix. |
23 | | -type Correction struct { |
24 | | - Original string `json:"original"` |
25 | | - Suggested string `json:"suggested"` |
26 | | - Position int `json:"position"` |
| 20 | +type ltMatch struct { |
| 21 | + Offset int `json:"offset"` |
| 22 | + Length int `json:"length"` |
| 23 | + Replacements []ltReplacement `json:"replacements"` |
27 | 24 | } |
28 | 25 |
|
29 | | -// Result is the response payload for the spell check endpoint. |
30 | | -type Result struct { |
31 | | - Corrected string `json:"corrected"` |
32 | | - Corrections []Correction `json:"corrections"` |
| 26 | +type ltReplacement struct { |
| 27 | + Value string `json:"value"` |
33 | 28 | } |
34 | 29 |
|
35 | | -// Service performs spell checking and correction. |
| 30 | +// Service calls LanguageTool for spell checking. |
36 | 31 | type Service struct { |
37 | | - model *fuzzy.Model |
38 | | - initErr error |
| 32 | + client *http.Client |
| 33 | + baseURL string |
39 | 34 | } |
40 | 35 |
|
41 | | -// NewService returns a new spellcheck Service. If the embedded word list |
42 | | -// cannot be loaded the service is still valid but Check will return an error. |
43 | | -func NewService() *Service { |
44 | | - m := fuzzy.NewModel() |
45 | | - m.SetThreshold(1) |
46 | | - |
47 | | - var words []string |
48 | | - scanner := bufio.NewScanner(bytes.NewReader(wordListData)) |
49 | | - for scanner.Scan() { |
50 | | - if w := strings.TrimSpace(scanner.Text()); w != "" { |
51 | | - words = append(words, w) |
52 | | - } |
| 36 | +// NewService returns a new spellcheck Service backed by LanguageTool. |
| 37 | +// baseURL defaults to http://localhost:8010 if empty. |
| 38 | +func NewService(baseURL string) *Service { |
| 39 | + if baseURL == "" { |
| 40 | + baseURL = defaultLanguageToolURL |
53 | 41 | } |
54 | | - if err := scanner.Err(); err != nil { |
55 | | - return &Service{initErr: fmt.Errorf("spellcheck: word list unavailable: %w", err)} |
| 42 | + return &Service{ |
| 43 | + client: &http.Client{Timeout: 10 * time.Second}, |
| 44 | + baseURL: strings.TrimRight(baseURL, "/"), |
56 | 45 | } |
57 | | - m.Train(words) |
58 | | - return &Service{model: m} |
59 | 46 | } |
60 | 47 |
|
61 | | -// errUnavailable is returned by Check when the service failed to initialise. |
62 | | -var errUnavailable = errors.New("spellcheck: service unavailable") |
63 | | - |
64 | | -// Check inspects text for spelling mistakes and returns the corrected text |
65 | | -// together with a list of individual corrections. It returns an error if the |
66 | | -// service failed to initialise. |
| 48 | +// Check inspects text for spelling mistakes using LanguageTool and returns |
| 49 | +// the corrected text together with a list of individual corrections. |
67 | 50 | func (s *Service) Check(text string) (Result, error) { |
68 | | - if s.initErr != nil { |
69 | | - return Result{}, errUnavailable |
| 51 | + form := url.Values{} |
| 52 | + form.Set("text", text) |
| 53 | + form.Set("language", "en-US") |
| 54 | + |
| 55 | + resp, err := s.client.Post( |
| 56 | + s.baseURL+"/v2/check", |
| 57 | + "application/x-www-form-urlencoded", |
| 58 | + strings.NewReader(form.Encode()), |
| 59 | + ) |
| 60 | + if err != nil { |
| 61 | + return Result{}, fmt.Errorf("spellcheck: languagetool unreachable: %w", err) |
70 | 62 | } |
| 63 | + defer resp.Body.Close() |
71 | 64 |
|
72 | | - var corrections []Correction |
73 | | - |
74 | | - // Find all word positions so that we can replace in one pass. |
75 | | - locs := wordRe.FindAllStringIndex(text, -1) |
76 | | - |
77 | | - // Build the corrected string by walking through every word location. |
78 | | - var builder strings.Builder |
79 | | - prev := 0 |
80 | | - for _, loc := range locs { |
81 | | - start, end := loc[0], loc[1] |
82 | | - word := text[start:end] |
83 | | - lower := strings.ToLower(word) |
84 | | - |
85 | | - suggestion := bestSuggestion(lower, s.model) |
86 | | - |
87 | | - // bestSuggestion returns the input unchanged when the word is already |
88 | | - // in the model's vocabulary. |
89 | | - if suggestion != lower && suggestion != "" { |
90 | | - display := matchCase(word, suggestion) |
91 | | - |
92 | | - corrections = append(corrections, Correction{ |
93 | | - Original: word, |
94 | | - Suggested: display, |
95 | | - Position: len([]rune(text[:start])), |
96 | | - }) |
97 | | - |
98 | | - // Copy verbatim text before this word, then the correction. |
99 | | - builder.WriteString(text[prev:start]) |
100 | | - builder.WriteString(display) |
101 | | - } else { |
102 | | - builder.WriteString(text[prev:end]) |
103 | | - } |
104 | | - prev = end |
| 65 | + if resp.StatusCode < 200 || resp.StatusCode >= 300 { |
| 66 | + return Result{}, fmt.Errorf("spellcheck: languagetool returned status %d", resp.StatusCode) |
105 | 67 | } |
106 | | - // Append any trailing non-word characters. |
107 | | - builder.WriteString(text[prev:]) |
108 | 68 |
|
109 | | - if corrections == nil { |
110 | | - corrections = []Correction{} |
| 69 | + var ltResp ltResponse |
| 70 | + if err := json.NewDecoder(resp.Body).Decode(<Resp); err != nil { |
| 71 | + return Result{}, fmt.Errorf("spellcheck: failed to decode response: %w", err) |
111 | 72 | } |
112 | 73 |
|
113 | | - return Result{ |
114 | | - Corrected: builder.String(), |
115 | | - Corrections: corrections, |
116 | | - }, nil |
| 74 | + return buildResult(text, ltResp.Matches), nil |
117 | 75 | } |
118 | 76 |
|
119 | | -// bestSuggestion picks the most likely correction from the model's exhaustive |
120 | | -// potentials by preferring (in order): lowest Levenshtein distance, then same |
121 | | -// first letter as the input, then highest corpus score. |
122 | | -func bestSuggestion(input string, m *fuzzy.Model) string { |
123 | | - potentials := m.Potentials(input, true) |
124 | | - if len(potentials) == 0 { |
125 | | - return input |
| 77 | +// CheckBatch processes multiple texts concurrently and returns spell-check |
| 78 | +// results for each. Up to 10 LanguageTool requests run in parallel to avoid |
| 79 | +// flooding the instance. Results are written to pre-allocated index slots so |
| 80 | +// input order is always preserved. If an individual text fails, its slot |
| 81 | +// receives a zero-value Result and the rest of the batch continues. |
| 82 | +func (s *Service) CheckBatch(texts []string) []Result { |
| 83 | + const maxWorkers = 10 |
| 84 | + |
| 85 | + results := make([]Result, len(texts)) |
| 86 | + sem := make(chan struct{}, maxWorkers) |
| 87 | + var wg sync.WaitGroup |
| 88 | + |
| 89 | + for i, text := range texts { |
| 90 | + wg.Add(1) |
| 91 | + sem <- struct{}{} |
| 92 | + go func(i int, text string) { |
| 93 | + defer wg.Done() |
| 94 | + defer func() { <-sem }() |
| 95 | + defer func() { |
| 96 | + if rec := recover(); rec != nil { |
| 97 | + results[i] = Result{} |
| 98 | + } |
| 99 | + }() |
| 100 | + |
| 101 | + r, err := s.Check(text) |
| 102 | + if err != nil { |
| 103 | + results[i] = Result{} |
| 104 | + return |
| 105 | + } |
| 106 | + results[i] = r |
| 107 | + }(i, text) |
126 | 108 | } |
127 | 109 |
|
128 | | - // Return the word unchanged when it is already in the vocabulary. |
129 | | - if p, ok := potentials[input]; ok && p.Leven == 0 { |
130 | | - return input |
131 | | - } |
| 110 | + wg.Wait() |
| 111 | + return results |
| 112 | +} |
132 | 113 |
|
133 | | - type candidate struct { |
134 | | - term string |
135 | | - lev int |
136 | | - score int |
137 | | - } |
| 114 | +// isValidSpan reports whether m describes a non-empty, non-overflowing span |
| 115 | +// within a slice of runeCount runes. |
| 116 | +func isValidSpan(m ltMatch, runeCount int) bool { |
| 117 | + return len(m.Replacements) > 0 && |
| 118 | + m.Offset >= 0 && |
| 119 | + m.Length >= 0 && |
| 120 | + m.Offset < runeCount && |
| 121 | + m.Length <= runeCount-m.Offset |
| 122 | +} |
138 | 123 |
|
139 | | - var best candidate |
140 | | - first := true |
141 | | - for _, p := range potentials { |
142 | | - if p.Leven == 0 { |
143 | | - // Exact match – the word is correct, no suggestion needed. |
144 | | - return input |
145 | | - } |
146 | | - bonus := 0 |
147 | | - if p.Term != "" && input != "" && p.Term[0] == input[0] { |
148 | | - bonus = 100 |
| 124 | +// buildResult converts LanguageTool matches into our Result type. |
| 125 | +// Replacements are applied in reverse order to keep offsets valid. |
| 126 | +func buildResult(text string, matches []ltMatch) Result { |
| 127 | + runes := []rune(text) |
| 128 | + corrections := make([]Correction, 0, len(matches)) |
| 129 | + |
| 130 | + for _, m := range matches { |
| 131 | + if !isValidSpan(m, len(runes)) { |
| 132 | + continue |
149 | 133 | } |
150 | | - effectiveScore := p.Score + bonus |
151 | | - if first || p.Leven < best.lev || (p.Leven == best.lev && effectiveScore > best.score) { |
152 | | - best = candidate{term: p.Term, lev: p.Leven, score: effectiveScore} |
153 | | - first = false |
| 134 | + // Collect up to 3 suggestions so callers can present a picker. |
| 135 | + suggestions := make([]string, 0, 3) |
| 136 | + for i, r := range m.Replacements { |
| 137 | + if i >= 3 { |
| 138 | + break |
| 139 | + } |
| 140 | + suggestions = append(suggestions, r.Value) |
154 | 141 | } |
| 142 | + corrections = append(corrections, Correction{ |
| 143 | + Original: string(runes[m.Offset : m.Offset+m.Length]), |
| 144 | + Suggested: m.Replacements[0].Value, |
| 145 | + Suggestions: suggestions, |
| 146 | + Position: m.Offset, |
| 147 | + }) |
155 | 148 | } |
156 | 149 |
|
157 | | - return best.term |
158 | | -} |
159 | | - |
160 | | -// matchCase applies the capitalisation pattern of src to dst so that, for |
161 | | -// example, "Ths" → "This" and "THs" → "THIS". |
162 | | -func matchCase(src, dst string) string { |
163 | | - if src == "" || dst == "" { |
164 | | - return dst |
165 | | - } |
166 | | - |
167 | | - srcRunes := []rune(src) |
168 | | - dstRunes := []rune(dst) |
169 | | - |
170 | | - // All-uppercase source → uppercase suggestion. |
171 | | - allUpper := true |
172 | | - for _, r := range srcRunes { |
173 | | - if unicode.IsLetter(r) && !unicode.IsUpper(r) { |
174 | | - allUpper = false |
175 | | - break |
| 150 | + // Apply replacements in reverse order to preserve offsets. |
| 151 | + corrected := make([]rune, len(runes)) |
| 152 | + copy(corrected, runes) |
| 153 | + for i := len(matches) - 1; i >= 0; i-- { |
| 154 | + m := matches[i] |
| 155 | + if !isValidSpan(m, len(corrected)) { |
| 156 | + continue |
176 | 157 | } |
177 | | - } |
178 | | - if allUpper { |
179 | | - return strings.ToUpper(dst) |
| 158 | + repl := []rune(m.Replacements[0].Value) |
| 159 | + corrected = append(corrected[:m.Offset], append(repl, corrected[m.Offset+m.Length:]...)...) |
180 | 160 | } |
181 | 161 |
|
182 | | - // First letter capitalised → capitalise first letter of suggestion. |
183 | | - if unicode.IsUpper(srcRunes[0]) { |
184 | | - dstRunes[0] = unicode.ToUpper(dstRunes[0]) |
185 | | - return string(dstRunes) |
| 162 | + if corrections == nil { |
| 163 | + corrections = []Correction{} |
186 | 164 | } |
187 | 165 |
|
188 | | - return dst |
| 166 | + return Result{ |
| 167 | + Corrected: string(corrected), |
| 168 | + Corrections: corrections, |
| 169 | + } |
189 | 170 | } |
0 commit comments