Files
dreamdict/dreamdict-blueprint.md
prosolis 207086da08 Add Phase 2+3 features: antonyms, backing, pronunciation, etymology, difficulty, affix expansion
New endpoints: /antonyms, /backing, /pronunciation, /etymology with difficulty
scoring on /random. Cross-language synset backing links French/Portuguese words
to English equivalents via WordNet 3.0 synset IDs (matching WOLF and OMW offsets).

New loaders: Hunspell affix expansion (fr, pt-PT), English programmatic inflector,
CMU Pronouncing Dictionary, SUBTLEX-US frequency, CETEMPúblico frequency,
Open Multilingual Wordnet (Portuguese), and SQL-based difficulty scoring.

Schema v2 adds tables: antonyms, synsets, word_synsets, pronunciations, etymology
with migration support for existing databases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 01:01:06 -07:00

1113 lines
35 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# DreamDict — Standalone Dictionary Service: Implementation Blueprint
## Overview
`dreamdict` is a self-contained HTTP dictionary service providing word validation, random
word generation, definitions, synonyms, and cross-language translation for English (`en`),
French (`fr`), and European Portuguese (`pt-PT`).
It runs as a plain Go binary managed by systemd on the same host as GogoBee and Ollama.
GogoBee calls it over localhost HTTP. No Docker, no Traefik, no external network exposure.
If GogoBee later containerizes, only the service URL in GogoBee's config changes. The
service itself is unmodified.
---
## Motivation
GogoBee is approaching 50,000 lines of code across 47+ modules. A dictionary package with
loaders, a SQLite dependency, and an import CLI would compound that bloat. Dictionary logic
has no business living inside a Matrix bot. `dreamdict` is a clean extraction: self-contained
infrastructure with a stable HTTP API, independently deployable, independently updatable,
with no knowledge of Matrix, rooms, or bot commands.
---
## Repository
Separate repository: `dreamdict` (not a GogoBee internal package).
```
dreamdict/
├── cmd/
│ ├── server/
│ │ └── main.go # HTTP server binary
│ └── dictimport/
│ └── main.go # One-time data import CLI
├── internal/
│ ├── dictionary/
│ │ ├── dict.go # Core package: public API
│ │ ├── db.go # SQLite connection + schema bootstrap
│ │ └── query.go # IsValidWord, RandomWord, Define, Synonyms, Translate
│ └── loader/
│ ├── loader.go # Shared interface + orchestrator
│ ├── scowl.go # English word list
│ ├── hunspell.go # French + pt-PT word lists (shared, parameterized)
│ ├── wordnet.go # English definitions + synonyms
│ ├── lexique.go # French POS + frequency enrichment
│ ├── wolf.go # French definitions + synonyms
│ ├── dicionario.go # pt-PT definitions
│ └── wiktionary.go # All langs: supplemental definitions + translations
├── data/
│ └── .gitkeep # Source data files (not committed)
├── dict.db # Generated SQLite database (not committed)
├── scripts/
│ └── download-dict-data.sh
├── deploy/
│ └── dreamdict.service # systemd unit file
├── go.mod
└── README.md
```
---
## HTTP API
The server listens on `127.0.0.1:7777` by default (configurable via `--port` flag or
`DREAMDICT_PORT` env var). All responses are JSON. All endpoints are read-only GET requests.
### `GET /valid`
Check if a word exists in a language's word list.
**Query params:** `word` (required), `lang` (required: `en`, `fr`, `pt-PT`)
**Response:**
```json
{"valid": true}
```
---
### `GET /random`
Return a random word for a language, with optional filters.
**Query params:**
- `lang` (required)
- `pos` (optional: `noun`, `verb`, `adjective`, `adverb`)
- `min` (optional: minimum word length, integer)
- `max` (optional: maximum word length, integer)
- `min_freq` (optional: minimum frequency score, integer; only meaningful for `fr`)
**Response:**
```json
{"word": "ephemeral"}
```
**Error (no words match filters):**
```json
{"error": "no_match", "message": "no words match the given filters"}
```
---
### `GET /define`
Return all definitions for a word, ordered by source priority.
**Query params:** `word` (required), `lang` (required)
**Response:**
```json
{
"word": "ephemeral",
"lang": "en",
"definitions": [
{"pos": "adjective", "gloss": "lasting for a very short time", "source": "wordnet", "priority": 10},
{"pos": "adjective", "gloss": "lasting only for a day", "source": "wiktionary", "priority": 20}
]
}
```
**Word exists but has no definitions:**
```json
{"word": "...", "lang": "...", "definitions": []}
```
---
### `GET /synonyms`
Return deduplicated synonyms for a word.
**Query params:** `word` (required), `lang` (required)
**Response:**
```json
{"word": "happy", "lang": "en", "synonyms": ["glad", "joyful", "content", "pleased"]}
```
---
### `GET /translate`
Return known equivalents of a word in another language.
**Query params:** `word` (required), `from` (required), `to` (required)
**Response:**
```json
{"word": "cat", "from": "en", "to": "fr", "translations": ["chat", "minet"]}
```
---
### `GET /health`
Health check and database stats.
**Response:**
```json
{
"status": "ok",
"db_path": "/opt/dreamdict/dict.db",
"word_counts": {"en": 214832, "fr": 82104, "pt-PT": 71088},
"def_counts": {"en": 380000, "fr": 200000, "pt-PT": 120000},
"imported_at": "2025-03-28T14:22:00Z",
"schema_version": "1"
}
```
---
## Core Package (`internal/dictionary`)
### `dict.go`
```go
package dictionary
type Options struct {
MinLength int
MaxLength int
POS string // "", "noun", "verb", "adjective", "adverb"
MinFrequency int // 0 = no filter
}
type Definition struct {
POS string
Gloss string
Source string
Priority int
}
type Dictionary struct { /* unexported */ }
func New(dbPath string) (*Dictionary, error)
func (d *Dictionary) Close() error
func (d *Dictionary) IsValidWord(word, lang string) (bool, error)
func (d *Dictionary) RandomWord(lang string, opts Options) (string, error)
func (d *Dictionary) Define(word, lang string) ([]Definition, error)
func (d *Dictionary) Synonyms(word, lang string) ([]string, error)
func (d *Dictionary) Translate(word, fromLang, toLang string) ([]string, error)
func (d *Dictionary) WordCount() (map[string]int, error)
func (d *Dictionary) SupportedLangs() ([]string, error)
func ValidLang(lang string) bool // "en", "fr", "pt-PT"
var (
ErrNoMatch = errors.New("dictionary: no word matches filters")
ErrNotSeeded = errors.New("dictionary: database not seeded, run: go run ./cmd/dictimport")
)
```
---
## Database Schema
Use `modernc.org/sqlite` (pure Go, no CGO).
```sql
CREATE TABLE IF NOT EXISTS words (
id INTEGER PRIMARY KEY AUTOINCREMENT,
word TEXT NOT NULL,
lang TEXT NOT NULL,
pos TEXT,
frequency INTEGER DEFAULT 0,
UNIQUE(word, lang)
);
CREATE INDEX IF NOT EXISTS idx_words_lang ON words(lang);
CREATE INDEX IF NOT EXISTS idx_words_lang_pos ON words(lang, pos);
CREATE INDEX IF NOT EXISTS idx_words_word ON words(word);
CREATE TABLE IF NOT EXISTS definitions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
word_id INTEGER NOT NULL REFERENCES words(id) ON DELETE CASCADE,
pos TEXT,
gloss TEXT NOT NULL,
source TEXT NOT NULL,
priority INTEGER NOT NULL DEFAULT 99,
UNIQUE(word_id, gloss)
);
CREATE INDEX IF NOT EXISTS idx_definitions_word_id ON definitions(word_id);
CREATE INDEX IF NOT EXISTS idx_definitions_word_id_priority ON definitions(word_id, priority);
CREATE TABLE IF NOT EXISTS synonyms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
word_id INTEGER NOT NULL REFERENCES words(id) ON DELETE CASCADE,
synonym TEXT NOT NULL,
source TEXT NOT NULL,
UNIQUE(word_id, synonym)
);
CREATE INDEX IF NOT EXISTS idx_synonyms_word_id ON synonyms(word_id);
CREATE TABLE IF NOT EXISTS translations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
word_id INTEGER NOT NULL REFERENCES words(id) ON DELETE CASCADE,
translation TEXT NOT NULL,
target_lang TEXT NOT NULL,
source TEXT NOT NULL,
UNIQUE(word_id, translation, target_lang)
);
CREATE INDEX IF NOT EXISTS idx_translations_word_id ON translations(word_id);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
```
### Definition Priority
| Source | Priority |
|---|---|
| `wordnet` | 10 |
| `wolf` | 10 |
| `dicionario-aberto` | 10 |
| `wiktionary` | 20 |
---
## Server Binary (`cmd/server/main.go`)
### Flags / Env Vars
```
--port PORT Listen port (default 7777) DREAMDICT_PORT
--db PATH Path to dict.db DREAMDICT_DB
--host HOST Listen host (default 127.0.0.1) DREAMDICT_HOST
```
Default host is `127.0.0.1` — the service binds localhost only. It is never exposed to the
external network. If GogoBee containerizes in future, change host to `0.0.0.0` and control
access at the Docker network level.
### Startup Behaviour
- Open the SQLite database on startup; log and exit if unavailable or not seeded
- Register HTTP handlers for all six endpoints
- Log `dreamdict listening on 127.0.0.1:7777` on successful start
- Handle `SIGTERM` / `SIGINT` for graceful shutdown (close DB, drain in-flight requests)
### HTTP Handler Pattern
All handlers follow the same structure:
1. Parse and validate query params — return `400` with `{"error": "...", "message": "..."}` on bad input
2. Call the appropriate `dictionary.*` method
3. Return `200` with JSON response, or `404` if the word is not found, or `500` on
unexpected errors
4. Log errors at ERROR level; log each request at DEBUG level (controllable via `--log-level`)
Use only standard library `net/http` — no framework needed for six endpoints.
---
## Import CLI (`cmd/dictimport/main.go`)
### Flags
```
--db Path to output dict.db (default: ./dict.db)
--data Path to data directory (default: ./data/)
--langs Comma-separated langs (default: en,fr,pt-PT)
--clean Drop and recreate all tables before import
--skip Comma-separated loader names to skip
```
### Execution Order
**English:**
1. `scowl` — populates words
2. `wordnet` — definitions + synonyms
3. `wiktionary-en` — supplemental definitions, synonyms, translations
**French:**
1. `hunspell-fr` — populates words
2. `lexique` — enriches POS + frequency
3. `wolf` — definitions + synonyms
4. `wiktionary-fr` — supplemental definitions, synonyms, translations
**pt-PT:**
1. `hunspell-pt` — populates words
2. `dicionario` — definitions
3. `wiktionary-pt` — supplemental definitions, synonyms, translations
Use `INSERT OR IGNORE` throughout. Wrap each loader in a single transaction; commit on
completion. Print per-loader row counts and elapsed time. Print final summary.
---
## Data Sources & Loader Specs
---
### English — SCOWL (`loader/scowl.go`)
**Source:** https://wordlist.aspell.net/
**Files:** All `final/english-words.NN` where NN ≤ 70
One word per line. Skip blank lines and `#` comments. Lowercase. Skip words with
spaces, digits, hyphens, or apostrophes. `INSERT OR IGNORE INTO words (word, lang) VALUES (?, 'en')`.
**Expected:** ~170,000220,000 words.
---
### French + pt-PT — Hunspell (`loader/hunspell.go`)
**Source:** https://github.com/LibreOffice/dictionaries
**Files:** `fr_FR/fr_FR.dic`, `pt_PT/pt_PT.dic`
Skip line 1 (word count). Split each line on `/`, take left side as base word. Lowercase.
Skip words with digits, spaces, or non-Latin characters.
Same function handles both languages: `func Load(path string, lang string, db *sql.DB) error`
**Expected:** ~60,00090,000 words per language.
---
### English Definitions + Synonyms — WordNet (`loader/wordnet.go`)
**Source:** https://wordnet.princeton.edu/download/current-version
**Files:** `dict/data.noun`, `dict/data.verb`, `dict/data.adj`, `dict/data.adv`
Skip lines starting with two spaces (headers). Parse `ss_type` for POS. Extract all words
in synset. Take gloss text before the first `;` (drops usage examples). Insert definitions
at `priority=10`. Insert intra-synset word pairs as synonyms. Skip words containing `_`
for synonym insertion.
---
### French Enrichment — Lexique (`loader/lexique.go`)
**Source:** http://www.lexique.org/?page_id=319
**File:** `Lexique383.tsv`
Columns: 0=`ortho`, 3=`cgram`, 8=`freqfilms2`. POS map: `NOM→noun`, `VER→verb`,
`ADJ→adjective`, `ADV→adverb`. Frequency = `freqfilms2 × 1000` truncated to int.
On conflict with existing Hunspell row: `UPDATE ... SET pos=?, frequency=? WHERE pos IS NULL`.
---
### French Definitions + Synonyms — WOLF (`loader/wolf.go`)
**Source:** https://github.com/nicolashernandez/WOLF
**File:** `wolf-1.0b4.xml.bz2` → decompress to `wolf.xml`
Stream-parse XML with `encoding/xml`. For each `<SYNSET>`: collect `<LITERAL>` values,
map `<POS>`, insert definitions at `priority=10`, insert intra-synset synonym pairs.
---
### pt-PT Definitions — Dicionário Aberto (`loader/dicionario.go`)
**Source:** https://github.com/jorgecardoso/dicionario-aberto
**File:** `dicionario-aberto.xml`
**⚠️ Inspect the file before writing the parser:**
```bash
head -100 data/dicionario-aberto.xml
```
Print distinct `<pos>` values before writing the POS map. Element names in the live file
may differ from older documentation. Stream-parse XML. Insert definitions at `priority=10`.
---
### All Languages — Wiktionary (`loader/wiktionary.go`)
**Source:** kaikki.org pre-parsed Wiktionary JSONL dumps
| Target file | Lang | URL |
|---|---|---|
| `kaikki-en.jsonl` | `en` | https://kaikki.org/dictionary/English/kaikki.org-dictionary-English.json |
| `kaikki-fr.jsonl` | `fr` | https://kaikki.org/dictionary/French/kaikki.org-dictionary-French.json |
| `kaikki-pt.jsonl` | `pt-PT` | https://kaikki.org/dictionary/Portuguese/kaikki.org-dictionary-Portuguese.json |
**JSON structure per line:**
```json
{
"word": "example",
"pos": "noun",
"lang_code": "en",
"senses": [
{"glosses": ["a representative instance"], "tags": []},
{"glosses": ["past tense of exemplify"], "tags": ["form-of"]}
],
"synonyms": [{"word": "instance"}],
"translations": [
{"code": "fr", "word": "exemple"},
{"code": "pt", "word": "exemplo"}
]
}
```
**POS mapping:** `noun→noun`, `verb→verb`, `adj→adjective`, `adv→adverb`, `name→noun`.
**Import logic:**
- Use `bufio.Scanner` with 10MB buffer (default 64KB truncates long lines silently)
- Wrap in single transaction; commit every 10,000 rows
- Verify `lang_code` matches expected language; skip if not
- Lowercase word; skip if contains spaces or digits
- Skip senses tagged `form-of` or `alt-of`
- Skip glosses that are empty, start with `(`, or exceed 500 characters
- Insert definitions at `priority=20`; `UNIQUE(word_id, gloss)` handles deduplication silently
- Insert synonyms; `UNIQUE(word_id, synonym)` handles deduplication silently
- **Translations:** for each entry in `translations[]`, if `code` maps to a supported lang
and `code != source lang`, insert into `translations` table
- Code mapping: `fr→fr`, `pt→pt-PT`, `en→en`
- Skip translations where `word` contains spaces or digits
**pt-PT Brazil filtering:** Reject senses where `tags` contains `"Brazil"` or
`"Brazilian Portuguese"`. Accept untagged senses and senses tagged `"Portugal"` or
`"European Portuguese"`.
**Expected additional definitions after deduplication:**
- en: ~200,000400,000
- fr: ~80,000150,000
- pt-PT: ~40,00080,000
---
## Deployment (systemd)
### File Locations
```
/usr/local/bin/dreamdict # compiled binary
/opt/dreamdict/dict.db # SQLite database
/opt/dreamdict/data/ # source data files (can be deleted after import)
/etc/dreamdict/dreamdict.env # environment config
/var/log/dreamdict/ # logs (or use journald)
```
### `deploy/dreamdict.service`
```ini
[Unit]
Description=DreamDict dictionary service
After=network.target
# If GogoBee ever runs as a service too:
# Before=dreamdict.service
[Service]
Type=simple
User=dreamdict
Group=dreamdict
EnvironmentFile=/etc/dreamdict/dreamdict.env
ExecStart=/usr/local/bin/dreamdict server \
--host 127.0.0.1 \
--port 7777 \
--db /opt/dreamdict/dict.db
Restart=on-failure
RestartSec=5s
# Hardening
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/opt/dreamdict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
```
### `/etc/dreamdict/dreamdict.env`
```
DREAMDICT_PORT=7777
DREAMDICT_DB=/opt/dreamdict/dict.db
```
### Install Steps
```bash
# Create user
useradd --system --no-create-home --shell /usr/sbin/nologin dreamdict
# Create dirs
mkdir -p /opt/dreamdict /etc/dreamdict /var/log/dreamdict
chown dreamdict:dreamdict /opt/dreamdict /var/log/dreamdict
# Build and install binary
go build -o /usr/local/bin/dreamdict ./cmd/server
chmod 755 /usr/local/bin/dreamdict
# Run import (as yourself, not dreamdict user)
go run ./cmd/dictimport --db /opt/dreamdict/dict.db --data /opt/dreamdict/data/
chown dreamdict:dreamdict /opt/dreamdict/dict.db
# Install and start service
cp deploy/dreamdict.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now dreamdict
# Verify
systemctl status dreamdict
curl http://127.0.0.1:7777/health
```
### Updating the Database
```bash
# Stop service, rebuild database, restart
systemctl stop dreamdict
go run ./cmd/dictimport --db /opt/dreamdict/dict.db --clean
chown dreamdict:dreamdict /opt/dreamdict/dict.db
systemctl start dreamdict
```
---
## GogoBee Integration
GogoBee gets a single `DreamDictClient` struct. Zero dictionary logic inside the bot.
### Client Package
Add to GogoBee: `internal/dreamclient/client.go`
```go
package dreamclient
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
)
type Client struct {
baseURL string
httpClient *http.Client
}
func New(baseURL string) *Client {
return &Client{
baseURL: baseURL,
httpClient: &http.Client{Timeout: 3 * time.Second},
}
}
func (c *Client) IsValidWord(word, lang string) (bool, error)
func (c *Client) RandomWord(lang string, pos string, min, max int) (string, error)
func (c *Client) Define(word, lang string) ([]Definition, error)
func (c *Client) Synonyms(word, lang string) ([]string, error)
func (c *Client) Translate(word, fromLang, toLang string) ([]string, error)
func (c *Client) Health() (*HealthResponse, error)
```
### Configuration
In GogoBee's config:
```yaml
dictionary:
url: "http://127.0.0.1:7777"
```
When GogoBee eventually containerizes, this becomes the container network address.
No other changes required.
### Replacing Wordnik Calls
Run `grep -r -i wordnik .` in the GogoBee repo to find all call sites. Replace:
| Wordnik call | GogoBee replacement |
|---|---|
| `GET /word/{word}/valid` | `dictClient.IsValidWord(word, lang)` |
| `GET /words/randomWord` | `dictClient.RandomWord(lang, pos, min, max)` |
| `GET /word/{word}/definitions` | `dictClient.Define(word, lang)` |
| `GET /word/{word}/relatedWords` | `dictClient.Synonyms(word, lang)` |
Calls previously hardcoded to English: keep `lang = "en"`.
### Startup Behaviour
On GogoBee startup, call `/health` to verify dreamdict is reachable. Log a warning (not
a fatal error) if unavailable — dictionary-dependent commands should fail gracefully with
a user-facing message rather than taking the whole bot down.
---
## Data File Checklist
| File | Lang | Source |
|---|---|---|
| `scowl/final/english-words.*` | en | wordlist.aspell.net |
| `wordnet/dict/data.{noun,verb,adj,adv}` | en | wordnet.princeton.edu |
| `kaikki-en.jsonl` | en | kaikki.org |
| `fr_FR.dic` | fr | github.com/LibreOffice/dictionaries |
| `Lexique383.tsv` | fr | lexique.org |
| `wolf.xml` | fr | github.com/nicolashernandez/WOLF |
| `kaikki-fr.jsonl` | fr | kaikki.org |
| `pt_PT.dic` | pt-PT | github.com/LibreOffice/dictionaries |
| `dicionario-aberto.xml` | pt-PT | github.com/jorgecardoso/dicionario-aberto |
| `kaikki-pt.jsonl` | pt-PT | kaikki.org |
`scripts/download-dict-data.sh` automates all downloads. Idempotent — skips existing files
unless `--force` passed. Handles tar extraction (SCOWL), bz2 decompression (WOLF), and
git clones where no stable direct URL exists.
---
## `go.mod` Dependencies
```
modernc.org/sqlite # Pure-Go SQLite, no CGO
```
Standard library covers everything else: `net/http`, `encoding/xml`, `encoding/json`,
`bufio`, `compress/bzip2`, `database/sql`, `flag`, `log/slog`.
---
## Testing
### Service Tests (`internal/dictionary/dict_test.go`)
Use `:memory:` SQLite. Cover:
1. `IsValidWord` — known word true, unknown false, case-insensitive
2. `RandomWord` — matches length + POS filters; returns `ErrNoMatch` on empty set
3. `Define` — priority ordering (wordnet before wiktionary); empty slice for no definitions
4. `Synonyms` — deduplicated; empty slice when none
5. `Translate` — returns correct target-lang translations; empty slice when none
6. `ErrNotSeeded` — when `meta` table has no rows
7. Gloss deduplication — identical gloss twice → one row
8. Synonym deduplication — identical synonym twice → one row
9. Translation deduplication — identical translation twice → one row
10. `form-of` filtering — senses tagged `form-of` not inserted
11. pt-PT Brazil filtering — `Brazil`-tagged senses rejected; untagged accepted
### HTTP Handler Tests (`cmd/server/`)
Use `httptest.NewServer`. Cover:
1. `/valid` — 200 true/false, 400 on missing params
2. `/random` — 200 with word, 404 on no match
3. `/define` — 200 with ordered definitions, 200 with empty array on no defs
4. `/synonyms` — 200 with list, 200 with empty array
5. `/translate` — 200 with list, 200 with empty array
6. `/health` — 200 with counts
---
## Expected Database Size
| Language | Words | Definitions | Approx size |
|---|---|---|---|
| en | ~220,000 | ~380,000 | ~180MB |
| fr | ~90,000 | ~200,000 | ~80MB |
| pt-PT | ~75,000 | ~120,000 | ~50MB |
| **Total** | | | **~310MB** |
Add to `.gitignore`: `dict.db`, `data/`
---
## Phase 2: Affix Expansion
**Implement shortly after initial launch, not at initial launch.**
The base-forms-only approach in the initial import means `IsValidWord("running", "en")`
returns false because only `"run"` is stored. For Hangman this doesn't matter — DreamDict
generates the puzzle word and it's always a base form. But for any command where a player
submits a word for validation (UNO, word games, future puzzle modes), this is a real
correctness bug, not a theoretical one.
**Recommended approach:** affix expansion at import time using `go-hunspell`
(`github.com/trustmaster/go-hunspell`). Parse the `.aff` rules for each language and
generate all valid inflected forms during `dictimport`, storing them in the `words` table
alongside base forms. This keeps `IsValidWord` as a simple indexed lookup with no runtime
stemming logic.
**Do not implement stemming as an alternative.** Stemming at query time is more complex,
language-specific, and redundant if you expand affixes at import time. Pick one direction —
affix expansion at import is the cleaner path for this architecture.
**Sequencing:** get the service running and validate data quality across all three languages
first. Affix expansion is a `dictimport` change only — the HTTP API, schema, and GogoBee
client are untouched.
---
## Deferred: Traefik Route for Second Consumer
When a second consumer for DreamDict exists, adding a Traefik route on parodia.dev's
internal network is trivial — one label, one DNS entry, twenty minutes of work. Do it
then, not now. The service architecture requires no changes at that point.
---
## Not Worth Doing: HTTP Caching Headers
The consumers are on localhost, there is no intermediary cache, and SQLite responses are
already faster than any cache lookup. Drop this entirely.
---
## Phase 2
Implement after initial launch is stable and data quality has been validated across all
three languages. Each item below is scoped and sequenced independently -- they do not
depend on each other unless noted.
---
### P2-1: Hunspell Affix Expansion
See "Phase 2: Affix Expansion" section above. Implement first among Phase 2 items as it
fixes a real correctness bug for player-submitted word validation.
---
### P2-2: WordNet Synset Cross-Language Backing
**The problem with the current translation approach:**
The current `translations` table is populated opportunistically from Wiktionary tags, which
are inconsistently applied. Coverage is good for common words and thin everywhere else.
This is a structural ceiling, not a data quality problem that more sources can fix.
**The intentional approach:**
Princeton WordNet assigns every concept a stable synset ID. WOLF maps French words to
those same synset IDs. The Open Multilingual Wordnet (OMW) does the same for Portuguese.
Two words in different languages that share a synset ID are semantic equivalents with high
confidence -- two independent projects made that mapping independently. DreamDict currently
discards synset IDs during import. Preserving them unlocks proper cross-language backing.
**Schema additions:**
```sql
CREATE TABLE IF NOT EXISTS synsets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
synset_id TEXT NOT NULL UNIQUE, -- Princeton WN ID e.g. '00914031-a'
pos TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS word_synsets (
word_id INTEGER NOT NULL REFERENCES words(id) ON DELETE CASCADE,
synset_id INTEGER NOT NULL REFERENCES synsets(id) ON DELETE CASCADE,
source TEXT NOT NULL, -- 'wordnet', 'wolf', 'omw'
PRIMARY KEY (word_id, synset_id)
);
CREATE INDEX IF NOT EXISTS idx_word_synsets_word_id ON word_synsets(word_id);
CREATE INDEX IF NOT EXISTS idx_word_synsets_synset_id ON word_synsets(synset_id);
```
**Loader changes:**
- `wordnet.go` -- store the synset offset as `synset_id` in `synsets`, link words via
`word_synsets` with `source='wordnet'`
- `wolf.go` -- WOLF synset IDs are Princeton WN IDs; store and link with `source='wolf'`
- New loader `omw.go` -- Open Multilingual Wordnet Portuguese data; maps pt words to
Princeton synset IDs; link with `source='omw'`
- Source: https://github.com/omwn/omw-data
- File: Portuguese tab file from the OMW release
**New API method:**
```go
// EnglishBacking returns English words and their WordNet definitions that share
// a synset with the given word. Semantic equivalence, not translation guesswork.
func (d *Dictionary) EnglishBacking(word, lang string) ([]EnglishEquivalent, error)
type EnglishEquivalent struct {
Word string
Definition string // the WordNet gloss for that synset
Synset string
}
```
**New endpoint:**
```
GET /backing?word=éphémère&lang=fr
→ {
"word": "éphémère",
"lang": "fr",
"equivalents": [
{"word": "ephemeral", "definition": "lasting a very short time", "synset": "00914031-a"}
]
}
```
**Fallback behaviour:**
Words with no synset mapping (proper nouns, invented words, very obscure entries) fall back
to the existing `translations` table as a secondary source. The two mechanisms complement
each other -- synsets for authoritative coverage, Wiktionary translations for the rest.
**GogoBee impact:**
Every French and Portuguese word with a WOLF or OMW synset mapping automatically gets
authoritative English backing with a real WordNet definition attached -- not just a word,
but the concept it represents. Update `DreamDictClient` with a `EnglishBacking()` method.
---
### P2-3: Antonyms
**Data source:** Already in Princeton WordNet. The pointer data in `data.*` files includes
antonym relations marked with `!` as the pointer symbol. The current `wordnet.go` loader
skips all pointer types. This is a loader change only.
**Schema addition:**
```sql
CREATE TABLE IF NOT EXISTS antonyms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
word_id INTEGER NOT NULL REFERENCES words(id) ON DELETE CASCADE,
antonym TEXT NOT NULL,
source TEXT NOT NULL, -- 'wordnet', 'wiktionary'
UNIQUE(word_id, antonym)
);
CREATE INDEX IF NOT EXISTS idx_antonyms_word_id ON antonyms(word_id);
```
**Loader changes:**
- `wordnet.go` -- parse pointer lines with symbol `!`; for each antonym pointer, insert
into `antonyms` with `source='wordnet'`
- `wiktionary.go` -- kaikki.org entries carry an `antonyms[]` array alongside `synonyms[]`;
parse and insert with `source='wiktionary'`
**New API method and endpoint:**
```go
func (d *Dictionary) Antonyms(word, lang string) ([]string, error)
```
```
GET /antonyms?word=happy&lang=en
→ {"word": "happy", "lang": "en", "antonyms": ["unhappy", "sad", "miserable"]}
```
**GogoBee uses:** Opposite-word puzzle modes, richer `!define` output, future word game
mechanics that need semantic opposites.
---
### P2-4: Frequency Weighting for English and Portuguese
**The problem:** `RandomWord()` for English and Portuguese is currently unweighted --
`ORDER BY RANDOM()` across the full word table. A Hangman puzzle is as likely to return
"aardvark" as "house." French already has frequency data from Lexique. English and
Portuguese need the same.
**Data sources:**
- **English:** SUBTLEX-US (Brysbaert & New, 2009) -- subtitle frequency corpus, freely
available. Provides word frequency per million from film/TV subtitles. Same format as
Lexique conceptually.
- URL: http://www.ugent.be/pp/experimentele-psychologie/en/research/documents/subtlexus
- File: `SUBTLEX-US.xlsx` (export to TSV before import)
- **Portuguese:** SUBTLEX-PT exists but coverage skews pt-BR. Better option for pt-PT is
the frequency list derived from the CETEMPúblico corpus (European Portuguese newspaper
corpus), available via Linguateca.
- URL: https://www.linguateca.pt/acesso/corpus.php?corpus=CETEMPUBLICO
- Alternative: use Wiktionary frequency tags as a rough proxy if CETEMPúblico access
is cumbersome -- lower quality but available immediately.
**Loader changes:**
- New `subtlex.go` -- TSV import for English frequency, same pattern as `lexique.go`;
`UPDATE words SET frequency=? WHERE word=? AND lang='en' AND frequency=0`
- New `cetempublico.go` (or `wiktfreq.go` as fallback) -- same pattern for pt-PT
**Schema:** No change -- `frequency` column already exists on `words` for all languages.
**API change:** `MinFrequency` in `Options` already exists. No API change needed -- this
just makes the filter meaningful for `en` and `pt-PT` for the first time.
**DreamDict dictimport change:** Add both loaders to the English and pt-PT execution order,
running after word list population.
---
## Phase 3
Implement after Phase 2 is complete and stable. These are higher-effort or lower-urgency
than Phase 2 items but all have clear value.
---
### P3-1: Word Difficulty Scoring
**What it is:** A composite difficulty score derived from data already in the database --
no new sources required. Computed at import time and stored as a column.
**Formula (tune after testing):**
```
difficulty = normalize(1 / frequency) * 0.5
+ normalize(length) * 0.3
+ normalize(syllable_count) * 0.2
```
Syllable count can be approximated from the word string (vowel cluster counting) or pulled
from CMU Pronouncing Dictionary (see P3-2) once that data exists.
**Schema addition:**
```sql
ALTER TABLE words ADD COLUMN difficulty REAL DEFAULT NULL;
-- NULL = not yet scored; 0.0 = easiest; 1.0 = hardest
```
**API change:**
Add to `Options`:
```go
MaxDifficulty float64 // 0.01.0; 0 = no filter
MinDifficulty float64
```
Add to `RandomWord()` response (exposed via new `WordDetail` return type if needed) and
as a metadata field on `/random` response:
```json
{"word": "ephemeral", "difficulty": 0.74}
```
**GogoBee uses:** Tiered Hangman difficulty without hardcoded word lists. "Easy", "Medium",
"Hard" modes map to difficulty ranges. Arena word puzzles can scale to player level.
---
### P3-2: Pronunciation Data
**Data source:** CMU Pronouncing Dictionary (CMUdict)
- URL: http://www.speech.cs.cmu.edu/cgi-bin/cmudict
- Format: plain text, one entry per line: `EPHEMERAL IH0 F EH1 M ER0 AH0 L`
- Coverage: English only (~134,000 entries)
- License: BSD-style, freely usable
For French and Portuguese, IPA data is available in the kaikki.org Wiktionary dumps under
a `sounds` array per entry -- the current wiktionary loader ignores this field.
**Schema addition:**
```sql
CREATE TABLE IF NOT EXISTS pronunciations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
word_id INTEGER NOT NULL REFERENCES words(id) ON DELETE CASCADE,
format TEXT NOT NULL, -- 'cmu', 'ipa'
value TEXT NOT NULL,
source TEXT NOT NULL, -- 'cmudict', 'wiktionary'
UNIQUE(word_id, format, source)
);
CREATE INDEX IF NOT EXISTS idx_pronunciations_word_id ON pronunciations(word_id);
```
**Loader changes:**
- New `cmudict.go` -- plain text import; match words to existing `words` table entries;
insert with `format='cmu'`, `source='cmudict'`
- `wiktionary.go` -- extend to parse `sounds[].ipa` field; insert with `format='ipa'`,
`source='wiktionary'`
**New API method and endpoint:**
```go
func (d *Dictionary) Pronunciation(word, lang string) ([]Pronunciation, error)
type Pronunciation struct {
Format string // 'cmu', 'ipa'
Value string
Source string
}
```
```
GET /pronunciation?word=ephemeral&lang=en
→ {
"word": "ephemeral",
"lang": "en",
"pronunciations": [
{"format": "cmu", "value": "IH0 F EH1 M ER0 AH0 L", "source": "cmudict"},
{"format": "ipa", "value": "ɪˈfɛm.ər.əl", "source": "wiktionary"}
]
}
```
**GogoBee uses:** `!define` output enrichment, syllable count for difficulty scoring (P3-1),
future rhyme game mechanics (words sharing a CMU tail sequence are rhymes).
---
### P3-3: Etymology
**Data source:** kaikki.org Wiktionary dumps already downloaded -- etymology data is in the
`etymology_text` field per entry. The current `wiktionary.go` loader ignores it.
**Schema addition:**
```sql
CREATE TABLE IF NOT EXISTS etymology (
id INTEGER PRIMARY KEY AUTOINCREMENT,
word_id INTEGER NOT NULL REFERENCES words(id) ON DELETE CASCADE,
text TEXT NOT NULL, -- free-form etymology string from Wiktionary
source TEXT NOT NULL, -- 'wiktionary'
UNIQUE(word_id, source)
);
CREATE INDEX IF NOT EXISTS idx_etymology_word_id ON etymology(word_id);
```
**Loader change:**
- `wiktionary.go` -- extend to parse `etymology_text` field; insert into `etymology` if
non-empty and not a redirect/stub string (skip entries that are just "See X" or empty
after trimming)
**New API method and endpoint:**
```go
func (d *Dictionary) Etymology(word, lang string) (string, error)
```
```
GET /etymology?word=ephemeral&lang=en
→ {
"word": "ephemeral",
"lang": "en",
"etymology": "From Medieval Latin ephemerus, from Ancient Greek ἐφήμερος (ephḗmeros, \"lasting only a day\"), from ἐπί (epí, \"on\") + ἡμέρα (hēméra, \"day\")."
}
```
**GogoBee uses:** `!etymology` command for the community. Particularly relevant given the
pt-PT context -- shared Latin/Greek roots between Portuguese, French, and English make
etymology a genuine learning tool, not just trivia. Word of the Day can optionally append
a one-line etymology note.
**Quality note:** Wiktionary etymology text is free-form and inconsistently structured.
Surface it as-is rather than attempting to parse it into structured fields. A raw string
is useful; a badly parsed structured field is not.
---
## Roadmap Summary
| Phase | Item | Effort | Payoff |
|---|---|---|---|
| 2 | Affix expansion | Medium | High -- fixes real validation bug |
| 2 | Synset cross-language backing | Medium | High -- authoritative multilingual semantics |
| 2 | Antonyms | Low | Medium -- already in WordNet data |
| 2 | Frequency for en + pt-PT | Low-Medium | High -- better random word quality |
| 3 | Difficulty scoring | Low | High -- derived, no new data |
| 3 | Pronunciation | Medium | Medium -- en only initially |
| 3 | Etymology | Low | High -- data already downloaded |