Inline Chinese gloss (offline) and a "say it more naturally" / tone-rewrite,
the two ESL features for the Mandarin-speaking writer.
Gloss: embedded English→Chinese dictionary (gloss.json.gz, 57k common words
built from ECDICT via scripts/build_gloss.py). lexicon gains Gloss()/Result.Gloss
and a lightweight GET /api/gloss/{word}; the right-click WordCard leads with the
中文; GlossTip shows it on a 350ms hover (reuses wordAt, so CJK is never glossed).
Offline + instant, works with the LLM down.
Rewrite: selecting text pops a SelectionBubble (✨更自然 + the tone vocabulary);
picking a style calls POST /api/docs/:id/rewrite (llm.RunRewrite, stateless,
owner-scoped) and shows a RewritePreview (original→rewrite, accept/cancel/retry).
Accept applies it in-editor.
Tests added in lexicon and suggestions. go build/vet/test, tsc, vite all clean;
live smoke vs a fake vLLM verified gloss + rewrite + 400/404/502 paths.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
250 lines
7.2 KiB
Go
250 lines
7.2 KiB
Go
package lexicon
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// Meaning is one sense of a word: its part of speech, the gloss, and an optional
|
|
// usage example. The frontend renders a few of these in the word popover.
|
|
type Meaning struct {
|
|
PartOfSpeech string `json:"part_of_speech"`
|
|
Definition string `json:"definition"`
|
|
Example string `json:"example,omitempty"`
|
|
}
|
|
|
|
// Result is the full lookup for one word. Either list may be empty (the word
|
|
// isn't a headword in that dataset); the frontend handles a partial or empty
|
|
// result gracefully. Gloss is the Chinese translation (empty when the word isn't
|
|
// in the gloss dataset) — shown first in the popover for the Mandarin-speaking
|
|
// writer.
|
|
type Result struct {
|
|
Word string `json:"word"`
|
|
Gloss string `json:"gloss"`
|
|
Definitions []Meaning `json:"definitions"`
|
|
Synonyms []string `json:"synonyms"`
|
|
}
|
|
|
|
// GlossResult is the lightweight payload for the inline hover/select gloss: just
|
|
// the word and its Chinese translation, no definitions or synonyms. Kept small
|
|
// so the hover tooltip is instant and trivially cacheable.
|
|
type GlossResult struct {
|
|
Word string `json:"word"`
|
|
Gloss string `json:"gloss"`
|
|
}
|
|
|
|
// maxSynonyms caps how many synonyms we hand the popover, even though the dataset
|
|
// stores up to ~50 per word — a long flat wall of words overwhelms more than it
|
|
// helps, especially for an ESL reader scanning for the right fit.
|
|
const maxSynonyms = 16
|
|
|
|
// maxDefinitions caps the senses shown so the popover stays a glance, not an
|
|
// essay.
|
|
const maxDefinitions = 4
|
|
|
|
// Lexicon holds the lazily-loaded datasets. The maps are populated once, on the
|
|
// first Lookup, behind a sync.Once so startup stays instant and a load error is
|
|
// remembered rather than retried on every request.
|
|
type Lexicon struct {
|
|
once sync.Once
|
|
loadErr error
|
|
defs map[string][][]string // word → [[pos, def, example], …]
|
|
synonyms map[string][]string // word → [synonym, …]
|
|
gloss map[string]string // word → Chinese gloss
|
|
}
|
|
|
|
// New returns a Lexicon. The datasets aren't read until the first Lookup.
|
|
func New() *Lexicon { return &Lexicon{} }
|
|
|
|
func (l *Lexicon) load() {
|
|
l.once.Do(func() {
|
|
if err := gunzipJSON(definitionsGz, &l.defs); err != nil {
|
|
l.loadErr = fmt.Errorf("load definitions: %w", err)
|
|
return
|
|
}
|
|
if err := gunzipJSON(synonymsGz, &l.synonyms); err != nil {
|
|
l.loadErr = fmt.Errorf("load synonyms: %w", err)
|
|
return
|
|
}
|
|
if err := gunzipJSON(glossGz, &l.gloss); err != nil {
|
|
l.loadErr = fmt.Errorf("load gloss: %w", err)
|
|
return
|
|
}
|
|
})
|
|
}
|
|
|
|
// Lookup returns the definition senses and synonyms for word. It first tries the
|
|
// word as written (lowercased), then a few simple morphological reductions
|
|
// (plurals, -ed/-ing/-ly) so "running" or "happily" still resolve. A word found
|
|
// in neither dataset yields a Result with empty lists (not an error).
|
|
func (l *Lexicon) Lookup(word string) (Result, error) {
|
|
l.load()
|
|
if l.loadErr != nil {
|
|
return Result{}, l.loadErr
|
|
}
|
|
|
|
norm := strings.ToLower(strings.TrimSpace(word))
|
|
res := Result{Word: word, Definitions: []Meaning{}, Synonyms: []string{}}
|
|
if norm == "" {
|
|
return res, nil
|
|
}
|
|
|
|
res.Gloss = lookupGloss(l.gloss, norm)
|
|
|
|
if raw := lookupDefs(l.defs, norm); raw != nil {
|
|
for _, m := range raw {
|
|
res.Definitions = append(res.Definitions, toMeaning(m))
|
|
if len(res.Definitions) >= maxDefinitions {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if syns := lookupSyns(l.synonyms, norm); syns != nil {
|
|
if len(syns) > maxSynonyms {
|
|
syns = syns[:maxSynonyms]
|
|
}
|
|
res.Synonyms = append(res.Synonyms, syns...)
|
|
}
|
|
|
|
return res, nil
|
|
}
|
|
|
|
// Gloss returns just the Chinese translation for word (empty when absent). This
|
|
// is the fast path behind the inline hover/select gloss — it skips the
|
|
// definition and synonym datasets entirely.
|
|
func (l *Lexicon) Gloss(word string) (GlossResult, error) {
|
|
l.load()
|
|
if l.loadErr != nil {
|
|
return GlossResult{}, l.loadErr
|
|
}
|
|
norm := strings.ToLower(strings.TrimSpace(word))
|
|
return GlossResult{Word: word, Gloss: lookupGloss(l.gloss, norm)}, nil
|
|
}
|
|
|
|
// lookupGloss walks the candidate forms of a word and returns the first gloss
|
|
// hit (so "running"/"studies" resolve via the same de-inflection as defs/syns).
|
|
func lookupGloss(m map[string]string, word string) string {
|
|
if word == "" {
|
|
return ""
|
|
}
|
|
for _, c := range candidates(word) {
|
|
if v, ok := m[c]; ok {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// lookupDefs / lookupSyns walk the candidate forms of a word and return the
|
|
// first dataset hit. They're separate (rather than a generic helper) only
|
|
// because the two maps have different value types.
|
|
func lookupDefs(m map[string][][]string, word string) [][]string {
|
|
for _, c := range candidates(word) {
|
|
if v, ok := m[c]; ok {
|
|
return v
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func lookupSyns(m map[string][]string, word string) []string {
|
|
for _, c := range candidates(word) {
|
|
if v, ok := m[c]; ok {
|
|
return v
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// candidates returns the lemma forms to try, in priority order: the word itself,
|
|
// then conservative de-inflections. This is deliberately lightweight — a full
|
|
// stemmer would over-reduce ("business" → "busy") and surface wrong entries; a
|
|
// handful of common English suffix rules covers the everyday cases without a
|
|
// dependency. Duplicates are fine (map lookup is cheap); order is what matters.
|
|
func candidates(word string) []string {
|
|
out := []string{word}
|
|
add := func(s string) {
|
|
if len(s) >= 2 && s != word {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
|
|
switch {
|
|
case strings.HasSuffix(word, "ies"): // studies → study
|
|
add(word[:len(word)-3] + "y")
|
|
case strings.HasSuffix(word, "es"): // boxes → box, wishes → wish
|
|
add(word[:len(word)-2])
|
|
add(word[:len(word)-1])
|
|
case strings.HasSuffix(word, "s"): // cats → cat
|
|
add(word[:len(word)-1])
|
|
}
|
|
|
|
if strings.HasSuffix(word, "ing") { // running → run, making → make
|
|
stem := word[:len(word)-3]
|
|
add(stem)
|
|
add(stem + "e")
|
|
add(undouble(stem))
|
|
}
|
|
if strings.HasSuffix(word, "ed") { // hoped → hope, stopped → stop
|
|
stem := word[:len(word)-2]
|
|
add(stem)
|
|
add(word[:len(word)-1])
|
|
add(undouble(stem))
|
|
}
|
|
if strings.HasSuffix(word, "ly") { // happily handled above via ies path? no — quickly → quick
|
|
add(word[:len(word)-2])
|
|
}
|
|
if strings.HasSuffix(word, "ily") { // happily → happy
|
|
add(word[:len(word)-3] + "y")
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
// undouble collapses a doubled final consonant (stopp → stop, runn → run) so the
|
|
// -ed/-ing stems of doubled-consonant verbs resolve to their base form.
|
|
func undouble(stem string) string {
|
|
n := len(stem)
|
|
if n >= 2 && stem[n-1] == stem[n-2] {
|
|
return stem[:n-1]
|
|
}
|
|
return stem
|
|
}
|
|
|
|
// toMeaning maps a compact [pos, def, example] triple from the dataset onto the
|
|
// JSON-friendly Meaning. The dataset always stores three elements, but we guard
|
|
// the length so a malformed row can't panic.
|
|
func toMeaning(m []string) Meaning {
|
|
var out Meaning
|
|
if len(m) > 0 {
|
|
out.PartOfSpeech = m[0]
|
|
}
|
|
if len(m) > 1 {
|
|
out.Definition = m[1]
|
|
}
|
|
if len(m) > 2 {
|
|
out.Example = m[2]
|
|
}
|
|
return out
|
|
}
|
|
|
|
// gunzipJSON decompresses gz and decodes the JSON into v.
|
|
func gunzipJSON(gz []byte, v any) error {
|
|
r, err := gzip.NewReader(bytes.NewReader(gz))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer r.Close()
|
|
data, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return json.Unmarshal(data, v)
|
|
}
|