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:
@@ -1,9 +1,10 @@
|
||||
// Package lexicon serves offline word lookups — a definition and a list of
|
||||
// synonyms for a single word — from two public-domain datasets compiled into the
|
||||
// binary. Definitions come from the Wordset dictionary (modern, concise glosses
|
||||
// with a part of speech and example, which read kindly for an ESL writer);
|
||||
// synonyms come from the Moby Thesaurus. Both are gzipped JSON, decompressed
|
||||
// lazily on first use so a writer who never right-clicks a word pays nothing.
|
||||
// Package lexicon serves offline word lookups — a Chinese gloss, a definition,
|
||||
// and a list of synonyms for a single word — from public datasets compiled into
|
||||
// the binary. Definitions come from the Wordset dictionary (modern, concise
|
||||
// glosses with a part of speech and example, which read kindly for an ESL
|
||||
// writer); synonyms come from the Moby Thesaurus; the Chinese gloss comes from
|
||||
// ECDICT. All are gzipped JSON, decompressed lazily on first use so a writer who
|
||||
// never looks up a word pays nothing.
|
||||
package lexicon
|
||||
|
||||
import _ "embed"
|
||||
@@ -19,3 +20,10 @@ var definitionsGz []byte
|
||||
//
|
||||
//go:embed data/synonyms.json.gz
|
||||
var synonymsGz []byte
|
||||
|
||||
// glossGz is the gzipped English→Chinese gloss map: word → 中文 gloss, lowercase
|
||||
// keys. Built from ECDICT (scripts/build_gloss.py), filtered to common words so
|
||||
// an ESL writer who speaks Mandarin gets an instant translation on hover/lookup.
|
||||
//
|
||||
//go:embed data/gloss.json.gz
|
||||
var glossGz []byte
|
||||
|
||||
BIN
internal/lexicon/data/gloss.json.gz
Normal file
BIN
internal/lexicon/data/gloss.json.gz
Normal file
Binary file not shown.
@@ -25,6 +25,15 @@ func (h *Handler) Routes() chi.Router {
|
||||
return r
|
||||
}
|
||||
|
||||
// GlossRoutes returns the router mounted at /api/gloss — the lightweight
|
||||
// Chinese-only lookup behind the inline hover/select gloss. It shares the
|
||||
// Handler's Lexicon, so the datasets still load just once.
|
||||
func (h *Handler) GlossRoutes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/{word}", h.gloss)
|
||||
return r
|
||||
}
|
||||
|
||||
// lookup returns the definition + synonyms for one word. A word found in neither
|
||||
// dataset still returns 200 with empty lists, so the popover can show a friendly
|
||||
// "nothing found" rather than an error state.
|
||||
@@ -48,3 +57,25 @@ func (h *Handler) lookup(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_ = json.NewEncoder(w).Encode(res)
|
||||
}
|
||||
|
||||
// gloss returns just the Chinese translation for one word. Like lookup, a miss
|
||||
// is a 200 with an empty gloss so the hover tooltip can quietly skip rather than
|
||||
// error.
|
||||
func (h *Handler) gloss(w http.ResponseWriter, r *http.Request) {
|
||||
word := chi.URLParam(r, "word")
|
||||
if decoded, err := url.PathUnescape(word); err == nil {
|
||||
word = decoded
|
||||
}
|
||||
|
||||
res, err := h.Lex.Gloss(word)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_ = json.NewEncoder(w).Encode(res)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -51,6 +51,48 @@ func TestLookupUnknownWord(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGloss(t *testing.T) {
|
||||
l := New()
|
||||
// A common word carries a Chinese gloss...
|
||||
res, err := l.Gloss("river")
|
||||
if err != nil {
|
||||
t.Fatalf("Gloss: %v", err)
|
||||
}
|
||||
if res.Word != "river" {
|
||||
t.Errorf("Word = %q, want %q", res.Word, "river")
|
||||
}
|
||||
if res.Gloss == "" {
|
||||
t.Errorf("expected a Chinese gloss for %q, got none", "river")
|
||||
}
|
||||
// ...and the inflected form resolves via the same de-inflection.
|
||||
inflected, err := l.Gloss("rivers")
|
||||
if err != nil {
|
||||
t.Fatalf("Gloss(rivers): %v", err)
|
||||
}
|
||||
if inflected.Gloss == "" {
|
||||
t.Errorf("expected a gloss for inflected %q, got none", "rivers")
|
||||
}
|
||||
// A nonsense word is a clean empty (not an error).
|
||||
miss, err := l.Gloss("zzzxqqq")
|
||||
if err != nil {
|
||||
t.Fatalf("Gloss(miss): %v", err)
|
||||
}
|
||||
if miss.Gloss != "" {
|
||||
t.Errorf("expected empty gloss for nonsense word, got %q", miss.Gloss)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupIncludesGloss(t *testing.T) {
|
||||
l := New()
|
||||
res, err := l.Lookup("happy")
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup: %v", err)
|
||||
}
|
||||
if res.Gloss == "" {
|
||||
t.Errorf("expected Lookup to include a Chinese gloss for %q", "happy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidates(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"cats": "cat",
|
||||
|
||||
Reference in New Issue
Block a user