Phase 9: ESL superpowers — Chinese gloss + tone-rewrite

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
This commit is contained in:
prosolis
2026-06-26 00:07:26 -07:00
parent 8e1111d768
commit 60eba25fee
20 changed files with 1036 additions and 14 deletions

View File

@@ -20,13 +20,24 @@ type Meaning struct {
// 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.
// 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.
@@ -44,6 +55,7 @@ type Lexicon struct {
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.
@@ -59,6 +71,10 @@ func (l *Lexicon) load() {
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
}
})
}
@@ -78,6 +94,8 @@ func (l *Lexicon) Lookup(word string) (Result, error) {
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))
@@ -97,6 +115,32 @@ func (l *Lexicon) Lookup(word string) (Result, error) {
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.