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

@@ -31,35 +31,61 @@ var scowlFrequency = map[int]int{
}
type scowlFile struct {
path string
size int
path string
size int
variant string // "us", "gb", or "" for common English
}
func (SCOWLLoader) Load(db *sql.DB, dataDir string) error {
pattern := filepath.Join(dataDir, "scowl", "final", "english-words.*")
files, err := filepath.Glob(pattern)
if err != nil {
return fmt.Errorf("scowl: glob: %w", err)
type prefixVariant struct {
prefix string
variant string
}
if len(files) == 0 {
return fmt.Errorf("scowl: no files matching %s", pattern)
prefixes := []prefixVariant{
{"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
for _, f := range files {
for _, f := range allFiles {
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)
if len(parts) != 2 {
continue
}
prefix := parts[0]
variant, ok := prefixToVariant[prefix]
if !ok {
continue
}
var size int
if _, err := fmt.Sscanf(parts[1], "%d", &size); err != nil {
continue
}
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()
stmt, err := tx.Prepare(`INSERT INTO words (word, lang, frequency) VALUES (?, 'en', ?)
ON CONFLICT(word, lang) DO UPDATE SET frequency = MAX(frequency, excluded.frequency)`)
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),
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 {
return fmt.Errorf("scowl: prepare: %w", err)
}
@@ -79,7 +112,7 @@ func (SCOWLLoader) Load(db *sql.DB, dataDir string) error {
var count int
for _, sf := range selected {
freq := scowlFrequency[sf.size]
n, err := loadSCOWLFile(stmt, sf.path, freq)
n, err := loadSCOWLFile(stmt, sf.path, freq, sf.variant)
if err != nil {
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
}
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)
if err != nil {
return 0, err
}
defer f.Close()
// Convert empty variant to SQL NULL
var variantParam any
if variant != "" {
variantParam = variant
}
var count int
scanner := bufio.NewScanner(f)
for scanner.Scan() {
@@ -111,7 +150,7 @@ func loadSCOWLFile(stmt *sql.Stmt, path string, freq int) (int, error) {
if containsAny(word, " ", "'", "-") || containsDigit(word) {
continue
}
if _, err := stmt.Exec(word, freq); err != nil {
if _, err := stmt.Exec(word, freq, variantParam); err != nil {
return 0, err
}
count++