Add regional variant tagging (us/gb) for English words

- SCOWL loader now loads american-words and british-words alongside
  english-words, tagging each with variant "us", "gb", or NULL (common)
- Words appearing in both American and British lists get NULL variant
- Add variant column to words table with schema migration
- API: /define returns variant field for English, /random and /words
  accept ?variant=us|gb filter
- Add total word count to server status line
- Fix rows.Close leak in PopulateCMUTails, add rows.Err() check
- Add tests for PopulateCMUTails, cmuTail, TotalWordCount
- Expand test seed data and assertions to cover pt-PT definitions,
  synonyms, and validity checks
- Update README with variant docs, /difficulty, /words, /rhyme
  endpoints, and current database statistics

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-04-04 15:21:27 -07:00
parent 28ec11ba4e
commit 704c06f41d
8 changed files with 488 additions and 71 deletions

View File

@@ -64,7 +64,7 @@ curl 'localhost:7777/valid?word=running&lang=en'
### `GET /random` ### `GET /random`
Return a random word. Optional filters: `pos` (`noun`, `verb`, `adjective`, `adverb`), `min`/`max` (word length), `min_freq` (frequency score), `min_difficulty`/`max_difficulty` (0.01.0). Return a random word. Optional filters: `pos` (`noun`, `verb`, `adjective`, `adverb`), `min`/`max` (word length), `min_freq` (frequency score), `min_difficulty`/`max_difficulty` (0.01.0), `variant` (`us` or `gb`, English only).
```bash ```bash
curl 'localhost:7777/random?lang=en&pos=noun&min=5&max=8&max_difficulty=0.5' curl 'localhost:7777/random?lang=en&pos=noun&min=5&max=8&max_difficulty=0.5'
@@ -73,16 +73,17 @@ curl 'localhost:7777/random?lang=en&pos=noun&min=5&max=8&max_difficulty=0.5'
### `GET /define` ### `GET /define`
Return all definitions ordered by source priority. Curated sources (WordNet, WOLF, Dicionário Aberto) appear before Wiktionary supplemental definitions. Return all definitions ordered by source priority. Curated sources (WordNet, WOLF, Dicionário Aberto) appear before Wiktionary supplemental definitions. For English words, the response includes a `variant` field (`"us"`, `"gb"`, or omitted for common English) indicating regional spelling.
```bash ```bash
curl 'localhost:7777/define?word=ephemeral&lang=en' curl 'localhost:7777/define?word=ardor&lang=en'
{ {
"word": "ephemeral", "word": "ardor",
"lang": "en", "lang": "en",
"variant": "us",
"definitions": [ "definitions": [
{"pos":"adjective","gloss":"lasting for a very short time","source":"wordnet","priority":10}, {"pos":"noun","gloss":"a feeling of strong eagerness","source":"wordnet","priority":10},
{"pos":"adjective","gloss":"lasting only for a day","source":"wiktionary","priority":20} {"pos":"noun","gloss":"feelings of great warmth and intensity","source":"wordnet","priority":10}
] ]
} }
``` ```
@@ -176,6 +177,33 @@ curl 'localhost:7777/frequency/batch?words=house,cat,ephemeral&lang=en'
{"lang":"en","frequencies":{"cat":800,"ephemeral":50,"house":800}} {"lang":"en","frequencies":{"cat":800,"ephemeral":50,"house":800}}
``` ```
### `GET /difficulty`
Return the difficulty score for a word (0.0 = easiest, 1.0 = hardest).
```bash
curl 'localhost:7777/difficulty?word=ephemeral&lang=en'
{"word":"ephemeral","lang":"en","difficulty":0.72}
```
### `GET /words`
Return a list of words matching filters. Same filter options as `/random` plus `variant`. Returns up to 20,000 results.
```bash
curl 'localhost:7777/words?lang=en&min=5&max=5&variant=gb&min_freq=500'
{"lang":"en","count":42,"words":["colour","fibre","honour",...]}
```
### `GET /rhyme`
Return English rhyming words based on CMU phoneme tail matching. Optional `limit` parameter (default 10).
```bash
curl 'localhost:7777/rhyme?word=cat&limit=5'
{"word":"cat","rhymes":["bat","flat","hat","mat","sat"]}
```
### `GET /health` ### `GET /health`
Health check with database statistics. Always unauthenticated — safe for monitoring tools. Health check with database statistics. Always unauthenticated — safe for monitoring tools.
@@ -185,9 +213,9 @@ curl localhost:7777/health
{ {
"status": "ok", "status": "ok",
"db_path": "./dict.db", "db_path": "./dict.db",
"word_counts": {"en":214832,"fr":82104,"pt-PT":71088,"zh":120000}, "word_counts": {"en":136615,"fr":56096,"pt-PT":136300,"zh":120883},
"def_counts": {"en":380000,"fr":200000,"pt-PT":120000,"zh":80000}, "def_counts": {"en":361640,"fr":137738,"pt-PT":84577,"zh":199929},
"imported_at": "2026-03-28T14:22:00Z", "imported_at": "2026-04-04T21:31:17Z",
"schema_version": "2" "schema_version": "2"
} }
``` ```
@@ -248,7 +276,7 @@ go run ./cmd/dictimport [flags]
| Loader | Language | What it provides | | Loader | Language | What it provides |
|---|---|---| |---|---|---|
| `scowl` | en | Word list with frequency tiers | | `scowl` | en | Word list with frequency tiers and regional variant tagging (us/gb) |
| `affix-en` | en | Inflected form expansion | | `affix-en` | en | Inflected form expansion |
| `wordnet` | en | Definitions, synonyms, antonyms, synset IDs | | `wordnet` | en | Definitions, synonyms, antonyms, synset IDs |
| `subtlex` | en | SUBTLEX-US subtitle frequency data | | `subtlex` | en | SUBTLEX-US subtitle frequency data |
@@ -267,6 +295,7 @@ go run ./cmd/dictimport [flags]
| `wiktionary-pt` | pt-PT | Supplemental definitions, synonyms, antonyms, translations, IPA, etymology (filtered to European Portuguese entries) | | `wiktionary-pt` | pt-PT | Supplemental definitions, synonyms, antonyms, translations, IPA, etymology (filtered to European Portuguese entries) |
| `cedict` | zh | Words, definitions, translations via CC-CEDICT | | `cedict` | zh | Words, definitions, translations via CC-CEDICT |
| `wiktionary-zh` | zh | Supplemental Chinese data | | `wiktionary-zh` | zh | Supplemental Chinese data |
| `prune-orphans` | all | Removes ghost words with no definitions, frequency, or references |
| `difficulty` | all | Composite difficulty scoring — runs last | | `difficulty` | all | Composite difficulty scoring — runs last |
--- ---
@@ -276,7 +305,7 @@ go run ./cmd/dictimport [flags]
| Source | Language | What it provides | | Source | Language | What it provides |
|---|---|---| |---|---|---|
| [SCOWL](https://wordlist.aspell.net/) | en | Word list + frequency tiers | | [SCOWL](https://wordlist.aspell.net/) | en | Word list + frequency tiers |
| [WordNet 3.1](https://wordnet.princeton.edu/) | en | Definitions, synonyms, antonyms, synset IDs | | [WordNet 3.0](https://wordnet.princeton.edu/) | en | Definitions, synonyms, antonyms, synset IDs |
| [SUBTLEX-US](http://www.ugent.be/pp/experimentele-psychologie/en/research/documents/subtlexus) | en | Subtitle-based word frequency | | [SUBTLEX-US](http://www.ugent.be/pp/experimentele-psychologie/en/research/documents/subtlexus) | en | Subtitle-based word frequency |
| [CMU Pronouncing Dictionary](http://www.speech.cs.cmu.edu/cgi-bin/cmudict) | en | Phoneme pronunciation | | [CMU Pronouncing Dictionary](http://www.speech.cs.cmu.edu/cgi-bin/cmudict) | en | Phoneme pronunciation |
| [Hunspell/LibreOffice](https://github.com/LibreOffice/dictionaries) | fr, pt-PT | Word lists + affix rules | | [Hunspell/LibreOffice](https://github.com/LibreOffice/dictionaries) | fr, pt-PT | Word lists + affix rules |
@@ -310,7 +339,7 @@ Schema version 2. Full schema in `internal/dictionary/db.go`.
| Table | Purpose | | Table | Purpose |
|---|---| |---|---|
| `words` | Core word inventory: word, lang, pos, frequency, difficulty | | `words` | Core word inventory: word, lang, pos, frequency, difficulty, variant |
| `definitions` | Definitions with source and priority | | `definitions` | Definitions with source and priority |
| `synonyms` | Synonym relationships | | `synonyms` | Synonym relationships |
| `antonyms` | Antonym relationships | | `antonyms` | Antonym relationships |
@@ -393,15 +422,15 @@ data/ Source data files (gitignored)
## Expected Database Size ## Expected Database Size
| Language | Words | Definitions | Size | | Language | Words | Definitions |
|---|---|---|---| |---|---|---|
| en | ~220k+ | ~380k | ~180 MB | | en | ~137k | ~362k |
| fr | ~90k+ | ~200k | ~80 MB | | fr | ~56k | ~138k |
| pt-PT | ~75k+ | ~120k | ~50 MB | | pt-PT | ~136k | ~85k |
| zh | varies | ~80k | ~20 MB | | zh | ~121k | ~200k |
| **Total** | | | **~330 MB** | | **Total** | **~450k** | **~785k** |
Word counts exceed base form counts due to affix expansion of inflected forms. English words include ~3,800 US-only and ~3,900 GB-only regional variants. Word counts include inflected forms from affix expansion, with ghost words pruned automatically.
--- ---

View File

@@ -103,9 +103,15 @@ func main() {
mux.HandleFunc("GET /pronunciation", handlePronunciation(dict)) mux.HandleFunc("GET /pronunciation", handlePronunciation(dict))
mux.HandleFunc("GET /etymology", handleEtymology(dict)) mux.HandleFunc("GET /etymology", handleEtymology(dict))
mux.HandleFunc("GET /difficulty", handleDifficulty(dict)) mux.HandleFunc("GET /difficulty", handleDifficulty(dict))
mux.HandleFunc("GET /words", handleWords(dict))
mux.HandleFunc("GET /rhyme", handleRhyme(dict)) mux.HandleFunc("GET /rhyme", handleRhyme(dict))
mux.HandleFunc("GET /health", handleHealth(dict, *dbPath)) mux.HandleFunc("GET /health", handleHealth(dict, *dbPath))
// Cache total word count for status line
if wc, err := dict.TotalWordCount(); err == nil {
totalWords.Store(uint64(wc))
}
serverStart = time.Now() serverStart = time.Now()
addr := fmt.Sprintf("%s:%d", *host, *port) addr := fmt.Sprintf("%s:%d", *host, *port)
@@ -154,6 +160,7 @@ func (r *statusRecorder) WriteHeader(code int) {
var ( var (
queryCount atomic.Uint64 queryCount atomic.Uint64
errorCount atomic.Uint64 errorCount atomic.Uint64
totalWords atomic.Uint64
serverStart time.Time serverStart time.Time
lastPath atomic.Value // stores string lastPath atomic.Value // stores string
) )
@@ -172,8 +179,9 @@ func loggingMiddleware(next http.Handler) http.Handler {
rps := float64(n) / time.Since(serverStart).Seconds() rps := float64(n) / time.Since(serverStart).Seconds()
errs := errorCount.Load() errs := errorCount.Load()
last, _ := lastPath.Load().(string) last, _ := lastPath.Load().(string)
fmt.Fprintf(os.Stdout, "\r\033[2KQueries: %d | Errors: %d | Req/s: %.1f | Uptime: %s | Last: %s", words := totalWords.Load()
n, errs, rps, uptime, last) fmt.Fprintf(os.Stdout, "\r\033[2KWords: %dk | Queries: %d | Errors: %d | Req/s: %.1f | Uptime: %s | Last: %s",
words/1000, n, errs, rps, uptime, last)
slog.Debug("request", slog.Debug("request",
"method", r.Method, "method", r.Method,
"path", r.URL.Path, "path", r.URL.Path,
@@ -201,6 +209,39 @@ func authMiddleware(token string, next http.Handler) http.Handler {
}) })
} }
func parseOptions(r *http.Request) dictionary.Options {
opts := dictionary.Options{
POS: r.URL.Query().Get("pos"),
Variant: r.URL.Query().Get("variant"),
}
if v := r.URL.Query().Get("min"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
opts.MinLength = n
}
}
if v := r.URL.Query().Get("max"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
opts.MaxLength = n
}
}
if v := r.URL.Query().Get("min_freq"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
opts.MinFrequency = n
}
}
if v := r.URL.Query().Get("min_difficulty"); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
opts.MinDifficulty = f
}
}
if v := r.URL.Query().Get("max_difficulty"); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
opts.MaxDifficulty = f
}
}
return opts
}
func writeJSON(w http.ResponseWriter, status int, v any) { func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) w.WriteHeader(status)
@@ -246,34 +287,7 @@ func handleRandom(dict *dictionary.Dictionary) http.HandlerFunc {
return return
} }
opts := dictionary.Options{ opts := parseOptions(r)
POS: r.URL.Query().Get("pos"),
}
if v := r.URL.Query().Get("min"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
opts.MinLength = n
}
}
if v := r.URL.Query().Get("max"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
opts.MaxLength = n
}
}
if v := r.URL.Query().Get("min_freq"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
opts.MinFrequency = n
}
}
if v := r.URL.Query().Get("min_difficulty"); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
opts.MinDifficulty = f
}
}
if v := r.URL.Query().Get("max_difficulty"); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
opts.MaxDifficulty = f
}
}
result, err := dict.RandomWord(lang, opts) result, err := dict.RandomWord(lang, opts)
if err == dictionary.ErrNoMatch { if err == dictionary.ErrNoMatch {
@@ -293,6 +307,38 @@ func handleRandom(dict *dictionary.Dictionary) http.HandlerFunc {
} }
} }
func handleWords(dict *dictionary.Dictionary) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
lang := r.URL.Query().Get("lang")
if lang == "" {
writeError(w, 400, "bad_request", "lang is required")
return
}
if !dictionary.ValidLang(lang) {
writeError(w, 400, "bad_request", "unsupported language: "+lang)
return
}
opts := parseOptions(r)
words, err := dict.Words(lang, opts)
if err == dictionary.ErrNoMatch {
writeError(w, 404, "no_match", "no words match the given filters")
return
}
if err != nil {
slog.Error("words", "error", err)
writeError(w, 500, "internal", "internal error")
return
}
writeJSON(w, 200, map[string]any{
"lang": lang,
"count": len(words),
"words": words,
})
}
}
func handleDefine(dict *dictionary.Dictionary) http.HandlerFunc { func handleDefine(dict *dictionary.Dictionary) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
word := r.URL.Query().Get("word") word := r.URL.Query().Get("word")
@@ -312,11 +358,17 @@ func handleDefine(dict *dictionary.Dictionary) http.HandlerFunc {
writeError(w, 500, "internal", "internal error") writeError(w, 500, "internal", "internal error")
return return
} }
writeJSON(w, 200, map[string]any{ resp := map[string]any{
"word": word, "word": word,
"lang": lang, "lang": lang,
"definitions": defs, "definitions": defs,
}) }
if lang == "en" {
if v, err := dict.WordVariant(word, lang); err == nil && v != "" {
resp["variant"] = v
}
}
writeJSON(w, 200, resp)
} }
} }

View File

@@ -49,6 +49,7 @@ func setupTestServer(t *testing.T) (*dictionary.Dictionary, *http.ServeMux) {
mux.HandleFunc("GET /define", handleDefine(dict)) mux.HandleFunc("GET /define", handleDefine(dict))
mux.HandleFunc("GET /synonyms", handleSynonyms(dict)) mux.HandleFunc("GET /synonyms", handleSynonyms(dict))
mux.HandleFunc("GET /translate", handleTranslate(dict)) mux.HandleFunc("GET /translate", handleTranslate(dict))
mux.HandleFunc("GET /words", handleWords(dict))
mux.HandleFunc("GET /health", handleHealth(dict, ":memory:")) mux.HandleFunc("GET /health", handleHealth(dict, ":memory:"))
t.Cleanup(func() { db.Close() }) t.Cleanup(func() { db.Close() })
@@ -201,6 +202,55 @@ func TestTranslateEndpoint(t *testing.T) {
} }
} }
func TestWordsEndpoint(t *testing.T) {
_, mux := setupTestServer(t)
srv := httptest.NewServer(mux)
defer srv.Close()
// All English words
resp, _ := http.Get(srv.URL + "/words?lang=en")
if resp.StatusCode != 200 {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var result struct {
Lang string `json:"lang"`
Count int `json:"count"`
Words []string `json:"words"`
}
json.NewDecoder(resp.Body).Decode(&result)
resp.Body.Close()
if result.Lang != "en" {
t.Errorf("lang = %q, want en", result.Lang)
}
if result.Count != len(result.Words) {
t.Errorf("count %d != len(words) %d", result.Count, len(result.Words))
}
if result.Count != 2 { // happy, cat
t.Errorf("count = %d, want 2", result.Count)
}
// With length filter — no match
resp, _ = http.Get(srv.URL + "/words?lang=en&min=100")
if resp.StatusCode != 404 {
t.Errorf("no match: status = %d, want 404", resp.StatusCode)
}
resp.Body.Close()
// Missing lang
resp, _ = http.Get(srv.URL + "/words")
if resp.StatusCode != 400 {
t.Errorf("missing lang: status = %d, want 400", resp.StatusCode)
}
resp.Body.Close()
// Bad lang
resp, _ = http.Get(srv.URL + "/words?lang=xx")
if resp.StatusCode != 400 {
t.Errorf("bad lang: status = %d, want 400", resp.StatusCode)
}
resp.Body.Close()
}
func TestHealthEndpoint(t *testing.T) { func TestHealthEndpoint(t *testing.T) {
_, mux := setupTestServer(t) _, mux := setupTestServer(t)
srv := httptest.NewServer(mux) srv := httptest.NewServer(mux)

View File

@@ -18,6 +18,7 @@ CREATE TABLE IF NOT EXISTS words (
pos TEXT, pos TEXT,
frequency INTEGER DEFAULT 0, frequency INTEGER DEFAULT 0,
difficulty REAL DEFAULT NULL, difficulty REAL DEFAULT NULL,
variant TEXT,
UNIQUE(word, lang) UNIQUE(word, lang)
); );
@@ -159,6 +160,7 @@ func BootstrapSchema(db *sql.DB) error {
migrations := []string{ migrations := []string{
"ALTER TABLE words ADD COLUMN difficulty REAL DEFAULT NULL", "ALTER TABLE words ADD COLUMN difficulty REAL DEFAULT NULL",
"ALTER TABLE pronunciations ADD COLUMN cmu_tail TEXT", "ALTER TABLE pronunciations ADD COLUMN cmu_tail TEXT",
"ALTER TABLE words ADD COLUMN variant TEXT",
} }
for _, m := range migrations { for _, m := range migrations {
if _, err := db.Exec(m); err != nil { if _, err := db.Exec(m); err != nil {
@@ -205,6 +207,7 @@ func PopulateCMUTails(db *sql.DB) (int, error) {
if err != nil { if err != nil {
return 0, fmt.Errorf("dictionary: populate cmu tails: %w", err) return 0, fmt.Errorf("dictionary: populate cmu tails: %w", err)
} }
defer rows.Close()
type entry struct { type entry struct {
id int id int
@@ -214,12 +217,13 @@ func PopulateCMUTails(db *sql.DB) (int, error) {
for rows.Next() { for rows.Next() {
var e entry var e entry
if err := rows.Scan(&e.id, &e.value); err != nil { if err := rows.Scan(&e.id, &e.value); err != nil {
rows.Close()
return 0, fmt.Errorf("dictionary: populate cmu tails scan: %w", err) return 0, fmt.Errorf("dictionary: populate cmu tails scan: %w", err)
} }
entries = append(entries, e) entries = append(entries, e)
} }
rows.Close() if err := rows.Err(); err != nil {
return 0, fmt.Errorf("dictionary: populate cmu tails iterate: %w", err)
}
if len(entries) == 0 { if len(entries) == 0 {
return 0, nil return 0, nil

View File

@@ -28,6 +28,7 @@ type Options struct {
MinFrequency int // 0 = no filter MinFrequency int // 0 = no filter
MinDifficulty float64 // 0.0 = no filter MinDifficulty float64 // 0.0 = no filter
MaxDifficulty float64 // 0.0 = no filter MaxDifficulty float64 // 0.0 = no filter
Variant string // "", "us", "gb" — empty means no filter
} }
type Definition struct { type Definition struct {

View File

@@ -44,10 +44,13 @@ func seedTestData(t *testing.T, db *sql.DB) {
// Definitions // Definitions
mustExec(t, db, "INSERT INTO definitions (word_id, pos, gloss, source, priority) VALUES (1, 'adjective', 'feeling pleasure', 'wordnet', 10)") mustExec(t, db, "INSERT INTO definitions (word_id, pos, gloss, source, priority) VALUES (1, 'adjective', 'feeling pleasure', 'wordnet', 10)")
mustExec(t, db, "INSERT INTO definitions (word_id, pos, gloss, source, priority) VALUES (1, 'adjective', 'feeling joy or contentment', 'wiktionary', 20)") mustExec(t, db, "INSERT INTO definitions (word_id, pos, gloss, source, priority) VALUES (1, 'adjective', 'feeling joy or contentment', 'wiktionary', 20)")
mustExec(t, db, "INSERT INTO definitions (word_id, pos, gloss, source, priority) VALUES (4, 'noun', 'um animal felino', 'dicionario', 10)")
mustExec(t, db, "INSERT INTO definitions (word_id, pos, gloss, source, priority) VALUES (4, 'noun', 'um felino doméstico', 'wiktionary', 20)")
// Synonyms // Synonyms
mustExec(t, db, "INSERT INTO synonyms (word_id, synonym, source) VALUES (1, 'glad', 'wordnet')") mustExec(t, db, "INSERT INTO synonyms (word_id, synonym, source) VALUES (1, 'glad', 'wordnet')")
mustExec(t, db, "INSERT INTO synonyms (word_id, synonym, source) VALUES (1, 'joyful', 'wordnet')") mustExec(t, db, "INSERT INTO synonyms (word_id, synonym, source) VALUES (1, 'joyful', 'wordnet')")
mustExec(t, db, "INSERT INTO synonyms (word_id, synonym, source) VALUES (4, 'bichano', 'wiktionary')")
// Translations // Translations
mustExec(t, db, "INSERT INTO translations (word_id, translation, target_lang, source) VALUES (5, 'chat', 'fr', 'wiktionary')") mustExec(t, db, "INSERT INTO translations (word_id, translation, target_lang, source) VALUES (5, 'chat', 'fr', 'wiktionary')")
@@ -69,6 +72,8 @@ func TestIsValidWord(t *testing.T) {
{"nonexistent", "en", false}, {"nonexistent", "en", false},
{"chat", "fr", true}, {"chat", "fr", true},
{"chat", "en", false}, {"chat", "en", false},
{"gato", "pt-PT", true},
{"gato", "en", false},
} }
for _, tt := range tests { for _, tt := range tests {
got, err := d.IsValidWord(tt.word, tt.lang) got, err := d.IsValidWord(tt.word, tt.lang)
@@ -143,6 +148,18 @@ func TestDefine(t *testing.T) {
t.Errorf("second def source = %q, want wiktionary", defs[1].Source) t.Errorf("second def source = %q, want wiktionary", defs[1].Source)
} }
// pt-PT definitions
defs, err = d.Define("gato", "pt-PT")
if err != nil {
t.Fatalf("Define(gato): %v", err)
}
if len(defs) != 2 {
t.Fatalf("Define(gato) returned %d defs, want 2", len(defs))
}
if defs[0].Source != "dicionario" {
t.Errorf("first pt-PT def source = %q, want dicionario", defs[0].Source)
}
// No definitions // No definitions
defs, err = d.Define("run", "en") defs, err = d.Define("run", "en")
if err != nil { if err != nil {
@@ -167,6 +184,15 @@ func TestSynonyms(t *testing.T) {
t.Fatalf("Synonyms returned %d, want 2", len(syns)) t.Fatalf("Synonyms returned %d, want 2", len(syns))
} }
// pt-PT synonyms
syns, err = d.Synonyms("gato", "pt-PT")
if err != nil {
t.Fatalf("Synonyms(gato): %v", err)
}
if len(syns) != 1 || syns[0] != "bichano" {
t.Errorf("Synonyms(gato) = %v, want [bichano]", syns)
}
// No synonyms // No synonyms
syns, err = d.Synonyms("run", "en") syns, err = d.Synonyms("run", "en")
if err != nil { if err != nil {
@@ -294,6 +320,124 @@ func TestMeta(t *testing.T) {
} }
} }
func TestWords(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
// Add some 5-letter words with varying frequency
mustExec(t, db, "INSERT INTO words (word, lang, pos, frequency) VALUES ('crane', 'en', 'noun', 200)")
mustExec(t, db, "INSERT INTO words (word, lang, pos, frequency) VALUES ('house', 'en', 'noun', 800)")
mustExec(t, db, "INSERT INTO words (word, lang, pos, frequency) VALUES ('light', 'en', 'noun', 500)")
mustExec(t, db, "INSERT INTO words (word, lang, pos, frequency) VALUES ('quick', 'en', 'adjective', 100)")
d := NewFromDB(db)
// All 5-letter English words
words, err := d.Words("en", Options{MinLength: 5, MaxLength: 5})
if err != nil {
t.Fatalf("Words: %v", err)
}
if len(words) != 5 { // crane, happy, house, light, quick
t.Errorf("Words returned %d, want 5", len(words))
}
// Filter by frequency
words, err = d.Words("en", Options{MinLength: 5, MaxLength: 5, MinFrequency: 500})
if err != nil {
t.Fatalf("Words with freq filter: %v", err)
}
if len(words) != 2 { // house(800), light(500)
t.Errorf("Words with freq filter returned %d, want 2", len(words))
}
// Filter by POS
words, err = d.Words("en", Options{MinLength: 5, MaxLength: 5, POS: "noun"})
if err != nil {
t.Fatalf("Words with POS filter: %v", err)
}
if len(words) != 3 { // crane, house, light
t.Errorf("Words with POS filter returned %d, want 3", len(words))
}
// No match
_, err = d.Words("en", Options{MinLength: 100})
if err != ErrNoMatch {
t.Errorf("expected ErrNoMatch, got %v", err)
}
// Results are ordered alphabetically
words, err = d.Words("en", Options{MinLength: 5, MaxLength: 5, POS: "noun"})
if err != nil {
t.Fatal(err)
}
for i := 1; i < len(words); i++ {
if words[i] < words[i-1] {
t.Errorf("words not sorted: %q before %q", words[i-1], words[i])
}
}
}
func TestPopulateCMUTails(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
// Insert CMU pronunciation entries without tails
mustExec(t, db, "INSERT INTO pronunciations (word_id, format, value, source) VALUES (5, 'cmu', 'K AE1 T', 'cmudict')")
mustExec(t, db, "INSERT INTO pronunciations (word_id, format, value, source) VALUES (1, 'cmu', 'HH AE1 P IY0', 'cmudict')")
// IPA entry should be ignored
mustExec(t, db, "INSERT INTO pronunciations (word_id, format, value, source) VALUES (2, 'ipa', '/ɹʌn/', 'wiktionary')")
n, err := PopulateCMUTails(db)
if err != nil {
t.Fatalf("PopulateCMUTails: %v", err)
}
if n != 2 {
t.Errorf("PopulateCMUTails returned %d, want 2", n)
}
// Verify tails were set correctly
var tail string
db.QueryRow("SELECT cmu_tail FROM pronunciations WHERE value = 'K AE1 T'").Scan(&tail)
if tail != "AE1 T" {
t.Errorf("cmu_tail for 'K AE1 T' = %q, want 'AE1 T'", tail)
}
db.QueryRow("SELECT cmu_tail FROM pronunciations WHERE value = 'HH AE1 P IY0'").Scan(&tail)
if tail != "AE1 P IY0" {
t.Errorf("cmu_tail for 'HH AE1 P IY0' = %q, want 'AE1 P IY0'", tail)
}
// Running again should be a no-op
n, err = PopulateCMUTails(db)
if err != nil {
t.Fatalf("PopulateCMUTails second run: %v", err)
}
if n != 0 {
t.Errorf("PopulateCMUTails second run returned %d, want 0", n)
}
}
func TestCmuTail(t *testing.T) {
tests := []struct {
input string
want string
}{
{"K AE1 T", "AE1 T"},
{"HH AE1 P IY0", "AE1 P IY0"},
{"IH0 F EH1 M ER0 AH0 L", "EH1 M ER0 AH0 L"},
{"AH0", "AH0"}, // no stressed vowel, falls back to any vowel
{"K T S", ""}, // no vowels at all
}
for _, tt := range tests {
got := cmuTail(tt.input)
if got != tt.want {
t.Errorf("cmuTail(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestWordCount(t *testing.T) { func TestWordCount(t *testing.T) {
db := setupTestDB(t) db := setupTestDB(t)
defer db.Close() defer db.Close()
@@ -310,4 +454,15 @@ func TestWordCount(t *testing.T) {
if counts["fr"] != 1 { if counts["fr"] != 1 {
t.Errorf("fr word count = %d, want 1", counts["fr"]) t.Errorf("fr word count = %d, want 1", counts["fr"])
} }
if counts["pt-PT"] != 1 {
t.Errorf("pt-PT word count = %d, want 1", counts["pt-PT"])
}
total, err := d.TotalWordCount()
if err != nil {
t.Fatal(err)
}
if total != 5 {
t.Errorf("total word count = %d, want 5", total)
}
} }

View File

@@ -18,6 +18,22 @@ func (d *Dictionary) IsValidWord(word, lang string) (bool, error) {
return exists, nil return exists, nil
} }
// WordVariant returns the regional variant tag for a word ("us", "gb", or "").
func (d *Dictionary) WordVariant(word, lang string) (string, error) {
var variant sql.NullString
err := d.db.QueryRow(
"SELECT variant FROM words WHERE word = ? AND lang = ?",
strings.ToLower(word), lang,
).Scan(&variant)
if err == sql.ErrNoRows {
return "", nil
}
if err != nil {
return "", fmt.Errorf("dictionary: word variant: %w", err)
}
return variant.String, nil
}
type RandomResult struct { type RandomResult struct {
Word string `json:"word"` Word string `json:"word"`
Difficulty *float64 `json:"difficulty,omitempty"` Difficulty *float64 `json:"difficulty,omitempty"`
@@ -51,6 +67,10 @@ func (d *Dictionary) RandomWord(lang string, opts Options) (RandomResult, error)
query += " AND difficulty <= ?" query += " AND difficulty <= ?"
args = append(args, opts.MaxDifficulty) args = append(args, opts.MaxDifficulty)
} }
if opts.Variant != "" {
query += " AND variant = ?"
args = append(args, opts.Variant)
}
query += " ORDER BY RANDOM() LIMIT 1" query += " ORDER BY RANDOM() LIMIT 1"
@@ -69,6 +89,64 @@ func (d *Dictionary) RandomWord(lang string, opts Options) (RandomResult, error)
return result, nil return result, nil
} }
func (d *Dictionary) Words(lang string, opts Options) ([]string, error) {
query := "SELECT DISTINCT word FROM words WHERE lang = ?"
args := []any{lang}
if opts.POS != "" {
query += " AND pos = ?"
args = append(args, opts.POS)
}
if opts.MinLength > 0 {
query += " AND LENGTH(word) >= ?"
args = append(args, opts.MinLength)
}
if opts.MaxLength > 0 {
query += " AND LENGTH(word) <= ?"
args = append(args, opts.MaxLength)
}
if opts.MinFrequency > 0 {
query += " AND frequency >= ?"
args = append(args, opts.MinFrequency)
}
if opts.MinDifficulty > 0 {
query += " AND difficulty >= ?"
args = append(args, opts.MinDifficulty)
}
if opts.MaxDifficulty > 0 {
query += " AND difficulty <= ?"
args = append(args, opts.MaxDifficulty)
}
if opts.Variant != "" {
query += " AND variant = ?"
args = append(args, opts.Variant)
}
query += " ORDER BY word LIMIT 20000"
rows, err := d.db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("dictionary: words: %w", err)
}
defer rows.Close()
var words []string
for rows.Next() {
var w string
if err := rows.Scan(&w); err != nil {
return nil, fmt.Errorf("dictionary: words scan: %w", err)
}
words = append(words, w)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("dictionary: words: %w", err)
}
if len(words) == 0 {
return nil, ErrNoMatch
}
return words, nil
}
func (d *Dictionary) Define(word, lang string) ([]Definition, error) { func (d *Dictionary) Define(word, lang string) ([]Definition, error) {
rows, err := d.db.Query(` rows, err := d.db.Query(`
SELECT d.pos, d.gloss, d.source, d.priority SELECT d.pos, d.gloss, d.source, d.priority
@@ -187,6 +265,15 @@ func (d *Dictionary) WordCount() (map[string]int, error) {
return counts, rows.Err() return counts, rows.Err()
} }
func (d *Dictionary) TotalWordCount() (int, error) {
var count int
err := d.db.QueryRow("SELECT COUNT(*) FROM words").Scan(&count)
if err != nil {
return 0, fmt.Errorf("dictionary: total word count: %w", err)
}
return count, nil
}
// Frequency returns the frequency score for a word in a language. // Frequency returns the frequency score for a word in a language.
// Returns 0 if the word is not found or has no frequency data. // Returns 0 if the word is not found or has no frequency data.
// Higher values indicate more common words. // Higher values indicate more common words.

View File

@@ -31,35 +31,61 @@ var scowlFrequency = map[int]int{
} }
type scowlFile struct { type scowlFile struct {
path string path string
size int size int
variant string // "us", "gb", or "" for common English
} }
func (SCOWLLoader) Load(db *sql.DB, dataDir string) error { func (SCOWLLoader) Load(db *sql.DB, dataDir string) error {
pattern := filepath.Join(dataDir, "scowl", "final", "english-words.*") type prefixVariant struct {
files, err := filepath.Glob(pattern) prefix string
if err != nil { variant string
return fmt.Errorf("scowl: glob: %w", err)
} }
if len(files) == 0 { prefixes := []prefixVariant{
return fmt.Errorf("scowl: no files matching %s", pattern) {"english-words", ""},
{"american-words", "us"},
{"british-words", "gb"},
} }
// Filter to sizes <= 70 and track the tier var allFiles []string
for _, pv := range prefixes {
pattern := filepath.Join(dataDir, "scowl", "final", pv.prefix+".*")
matches, err := filepath.Glob(pattern)
if err != nil {
return fmt.Errorf("scowl: glob: %w", err)
}
allFiles = append(allFiles, matches...)
}
if len(allFiles) == 0 {
return fmt.Errorf("scowl: no word files found in %s", filepath.Join(dataDir, "scowl", "final"))
}
// Build a map from prefix to variant for quick lookup
prefixToVariant := make(map[string]string)
for _, pv := range prefixes {
prefixToVariant[pv.prefix] = pv.variant
}
// Filter to sizes <= 70 and track the tier + variant
var selected []scowlFile var selected []scowlFile
for _, f := range files { for _, f := range allFiles {
base := filepath.Base(f) base := filepath.Base(f)
// Files are like english-words.10, english-words.20, etc. // Files are like english-words.10, american-words.35, etc.
parts := strings.SplitN(base, ".", 2) parts := strings.SplitN(base, ".", 2)
if len(parts) != 2 { if len(parts) != 2 {
continue continue
} }
prefix := parts[0]
variant, ok := prefixToVariant[prefix]
if !ok {
continue
}
var size int var size int
if _, err := fmt.Sscanf(parts[1], "%d", &size); err != nil { if _, err := fmt.Sscanf(parts[1], "%d", &size); err != nil {
continue continue
} }
if size <= 70 { if size <= 70 {
selected = append(selected, scowlFile{path: f, size: size}) selected = append(selected, scowlFile{path: f, size: size, variant: variant})
} }
} }
@@ -69,8 +95,15 @@ func (SCOWLLoader) Load(db *sql.DB, dataDir string) error {
} }
defer tx.Rollback() defer tx.Rollback()
stmt, err := tx.Prepare(`INSERT INTO words (word, lang, frequency) VALUES (?, 'en', ?) stmt, err := tx.Prepare(`INSERT INTO words (word, lang, frequency, variant) VALUES (?, 'en', ?, ?)
ON CONFLICT(word, lang) DO UPDATE SET frequency = MAX(frequency, excluded.frequency)`) ON CONFLICT(word, lang) DO UPDATE SET
frequency = MAX(frequency, excluded.frequency),
variant = CASE
WHEN words.variant IS NULL THEN excluded.variant
WHEN excluded.variant IS NULL THEN words.variant
WHEN words.variant = excluded.variant THEN words.variant
ELSE NULL
END`)
if err != nil { if err != nil {
return fmt.Errorf("scowl: prepare: %w", err) return fmt.Errorf("scowl: prepare: %w", err)
} }
@@ -79,7 +112,7 @@ func (SCOWLLoader) Load(db *sql.DB, dataDir string) error {
var count int var count int
for _, sf := range selected { for _, sf := range selected {
freq := scowlFrequency[sf.size] freq := scowlFrequency[sf.size]
n, err := loadSCOWLFile(stmt, sf.path, freq) n, err := loadSCOWLFile(stmt, sf.path, freq, sf.variant)
if err != nil { if err != nil {
return fmt.Errorf("scowl: load %s: %w", sf.path, err) return fmt.Errorf("scowl: load %s: %w", sf.path, err)
} }
@@ -93,13 +126,19 @@ func (SCOWLLoader) Load(db *sql.DB, dataDir string) error {
return nil return nil
} }
func loadSCOWLFile(stmt *sql.Stmt, path string, freq int) (int, error) { func loadSCOWLFile(stmt *sql.Stmt, path string, freq int, variant string) (int, error) {
f, err := os.Open(path) f, err := os.Open(path)
if err != nil { if err != nil {
return 0, err return 0, err
} }
defer f.Close() defer f.Close()
// Convert empty variant to SQL NULL
var variantParam any
if variant != "" {
variantParam = variant
}
var count int var count int
scanner := bufio.NewScanner(f) scanner := bufio.NewScanner(f)
for scanner.Scan() { for scanner.Scan() {
@@ -111,7 +150,7 @@ func loadSCOWLFile(stmt *sql.Stmt, path string, freq int) (int, error) {
if containsAny(word, " ", "'", "-") || containsDigit(word) { if containsAny(word, " ", "'", "-") || containsDigit(word) {
continue continue
} }
if _, err := stmt.Exec(word, freq); err != nil { if _, err := stmt.Exec(word, freq, variantParam); err != nil {
return 0, err return 0, err
} }
count++ count++