Skip to content

Commit 6b9addc

Browse files
authored
Merge pull request #650 from Kinnouts/feat/spell-check-batch
Feat/spell check batch
2 parents 12eb86c + 115fa29 commit 6b9addc

16 files changed

Lines changed: 996 additions & 233 deletions

File tree

apps/api/app/routes_v1.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ func registerV1Routes(ctx context.Context, r chi.Router, pool *pgxpool.Pool, rdb
7575

7676
if serviceEnabled(cfg, "text") {
7777
textRouter := chi.NewRouter()
78-
text.RegisterRoutes(textRouter, pool)
78+
text.RegisterRoutes(textRouter, pool, cfg)
7979
r.Mount("/text", textRouter)
8080
}
8181

apps/api/go.mod

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ require (
5858
github.com/nyaruka/phonenumbers v1.7.4
5959
github.com/pemistahl/lingua-go v1.4.0
6060
github.com/ringsaturn/tzf v1.2.1
61-
github.com/sajari/fuzzy v1.0.0
6261
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
6362
github.com/spf13/cobra v1.10.2
6463
github.com/yuin/goldmark v1.8.2

apps/api/platform/config/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ type Config struct {
1616
EnabledServices string // comma-separated list of services to mount; empty = all
1717
Environment string // "development" | "staging" | "production"
1818
SentryDSN string
19+
LanguageToolURL string // base URL for LanguageTool spell-check service
1920
}
2021

2122
func Load() Config {
@@ -33,6 +34,7 @@ func Load() Config {
3334
EnabledServices: envOrDefault("ENABLED_SERVICES", ""),
3435
Environment: envOrDefault("ENVIRONMENT", "development"),
3536
SentryDSN: envOrDefault("SENTRY_DSN", "https://df9023edf98442d4887d1040de81b768@issues.bobadilla.tech/1"),
37+
LanguageToolURL: envOrDefault("LANGUAGETOOL_URL", "http://localhost:8010"),
3638
}
3739
}
3840

apps/api/services/text/router.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"github.com/go-chi/chi/v5"
55
"github.com/jackc/pgx/v5/pgxpool"
66

7+
"requiems-api/platform/config"
78
"requiems-api/services/text/detectlanguage"
89
"requiems-api/services/text/lorem"
910
"requiems-api/services/text/normalize"
@@ -14,14 +15,14 @@ import (
1415
"requiems-api/services/text/words"
1516
)
1617

17-
func RegisterRoutes(r chi.Router, pool *pgxpool.Pool) {
18+
func RegisterRoutes(r chi.Router, pool *pgxpool.Pool, cfg config.Config) {
1819
wordsSvc := words.NewService(pool)
1920
words.RegisterRoutes(r, wordsSvc)
2021

2122
loremSvc := lorem.NewService()
2223
lorem.RegisterRoutes(r, loremSvc)
2324

24-
spellcheckSvc := spellcheck.NewService()
25+
spellcheckSvc := spellcheck.NewService(cfg.LanguageToolURL)
2526
spellcheck.RegisterRoutes(r, spellcheckSvc)
2627

2728
thesaurusSvc := thesaurus.NewService()
Lines changed: 127 additions & 146 deletions
Original file line numberDiff line numberDiff line change
@@ -1,189 +1,170 @@
11
package spellcheck
22

33
import (
4-
"bufio"
5-
"bytes"
6-
_ "embed"
7-
"errors"
4+
"encoding/json"
85
"fmt"
9-
"regexp"
6+
"net/http"
7+
"net/url"
108
"strings"
11-
"unicode"
12-
13-
"github.com/sajari/fuzzy"
9+
"sync"
10+
"time"
1411
)
1512

16-
//go:embed data/words.txt
17-
var wordListData []byte
13+
const defaultLanguageToolURL = "http://localhost:8010"
1814

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+
}
2119

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"`
2724
}
2825

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"`
3328
}
3429

35-
// Service performs spell checking and correction.
30+
// Service calls LanguageTool for spell checking.
3631
type Service struct {
37-
model *fuzzy.Model
38-
initErr error
32+
client *http.Client
33+
baseURL string
3934
}
4035

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
5341
}
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, "/"),
5645
}
57-
m.Train(words)
58-
return &Service{model: m}
5946
}
6047

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.
6750
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)
7062
}
63+
defer resp.Body.Close()
7164

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)
10567
}
106-
// Append any trailing non-word characters.
107-
builder.WriteString(text[prev:])
10868

109-
if corrections == nil {
110-
corrections = []Correction{}
69+
var ltResp ltResponse
70+
if err := json.NewDecoder(resp.Body).Decode(&ltResp); err != nil {
71+
return Result{}, fmt.Errorf("spellcheck: failed to decode response: %w", err)
11172
}
11273

113-
return Result{
114-
Corrected: builder.String(),
115-
Corrections: corrections,
116-
}, nil
74+
return buildResult(text, ltResp.Matches), nil
11775
}
11876

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)
126108
}
127109

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+
}
132113

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+
}
138123

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
149133
}
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)
154141
}
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+
})
155148
}
156149

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
176157
}
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:]...)...)
180160
}
181161

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{}
186164
}
187165

188-
return dst
166+
return Result{
167+
Corrected: string(corrected),
168+
Corrections: corrections,
169+
}
189170
}

0 commit comments

Comments
 (0)