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

@@ -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

Binary file not shown.

View File

@@ -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)
}

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.

View File

@@ -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",

View File

@@ -120,3 +120,48 @@ Keep responses concise (2-4 sentences). This is a chat, not an essay. Be encoura
func AskPetalSystemPrompt(original, replacement, suggestionType, explanation, paragraph string) string {
return fmt.Sprintf(askPetalSystemTemplate, original, replacement, suggestionType, explanation, paragraph)
}
// rewriteSystemTemplate drives the "say it more naturally" / tone-rewrite tool.
// The writer selects a passage and picks a style; the model rewrites that
// passage in place. The instruction is deliberately strict about returning ONLY
// the rewritten passage so the result can be dropped straight into the editor —
// no quotes, no preamble, no commentary to strip.
const rewriteSystemTemplate = `You are Petal, a warm English writing assistant helping someone who speaks English ` +
`as a second language. Rewrite the passage the user sends so that it %s, while preserving its original ` +
`meaning. Fix any grammar mistakes and awkward phrasing along the way. Keep it about the same length — ` +
`do not add new ideas, explanations, or commentary.
Respond with ONLY the rewritten passage. No quotation marks around it, no preamble, no notes — just the ` +
`rewritten English text, ready to drop back into the document.`
// styleGuidance maps a rewrite style onto the clause describing the target
// register. "natural" is the default "say it more naturally" action; the rest
// mirror the document-tone vocabulary (see toneGuidance / the ToneSelect UI).
// An unknown style falls back to the natural rewrite.
func styleGuidance(style string) string {
switch style {
case "academic":
return "reads as formal, academic English suited to a school essay or research paper"
case "professional":
return "reads as polished, professional English suited to a workplace email or report"
case "casual":
return "sounds relaxed, friendly, and conversational"
case "humorous":
return "has a light, playful, good-humored tone"
case "creative":
return "is vivid, expressive, and imaginative"
case "persuasive":
return "is confident and persuasive"
default: // "natural"
return "sounds natural and fluent, the way a native English speaker would naturally say it"
}
}
// RewriteMessages builds the message array for a tone-rewrite: the styled system
// instruction plus the passage to rewrite as the user turn.
func RewriteMessages(text, style string) []Message {
return []Message{
{Role: "system", Content: fmt.Sprintf(rewriteSystemTemplate, styleGuidance(style))},
{Role: "user", Content: text},
}
}

46
internal/llm/rewrite.go Normal file
View File

@@ -0,0 +1,46 @@
package llm
import (
"context"
"strings"
)
// RewriteMaxRunes caps how much selected text a single rewrite will accept. A
// rewrite is a focused "fix this sentence/paragraph" action, not a whole-doc
// pass — bounding it keeps latency sane and the model on-task. The handler
// rejects longer selections before calling the model.
const RewriteMaxRunes = 2000
// RunRewrite rewrites a selected passage in the requested style (e.g. "natural",
// "academic"). It is a one-shot Complete — the result is shown as a preview the
// writer accepts or discards, so we want the whole rewrite before rendering.
func RunRewrite(ctx context.Context, client LLMClient, text, style string) (string, error) {
out, err := client.Complete(ctx, CompletionRequest{
Messages: RewriteMessages(text, style),
MaxTokens: 1024,
Temperature: 0.7,
TopP: 0.9,
RepetitionPenalty: 1.1,
})
if err != nil {
return "", err
}
return cleanRewrite(out), nil
}
// cleanRewrite trims the model's output down to just the rewritten passage. The
// prompt asks for no quotes or preamble, but small instruct models occasionally
// wrap the answer in matching quotes — strip a single surrounding pair so the
// text drops cleanly into the editor.
func cleanRewrite(s string) string {
s = strings.TrimSpace(s)
if len(s) >= 2 {
first, last := s[0], s[len(s)-1]
if (first == '"' && last == '"') ||
(first == '\'' && last == '\'') ||
(strings.HasPrefix(s, "“") && strings.HasSuffix(s, "”")) {
s = strings.TrimSpace(strings.Trim(s, "\"'“”"))
}
}
return s
}

View File

@@ -44,6 +44,7 @@ func New(database *db.DB, client llm.LLMClient) *Handler {
func (h *Handler) RegisterDocRoutes(r chi.Router) {
r.Post("/{id}/check", h.check)
r.Post("/{id}/voice", h.voice)
r.Post("/{id}/rewrite", h.rewrite)
r.Get("/{id}/suggestions", h.listForDoc)
}

View File

@@ -0,0 +1,76 @@
package suggestions
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/llm"
)
// rewriteRequest is the body the selection bubble posts: the selected passage
// and the target style ("natural", "academic", …). The text is the client's
// live selection — unlike a checkpoint, there is nothing to anchor server-side,
// so the rewrite is stateless and never persisted (the editor applies it
// directly, and the version history captures the resulting document change).
type rewriteRequest struct {
Text string `json:"text"`
Style string `json:"style"`
}
type rewriteResponse struct {
Rewrite string `json:"rewrite"`
}
// rewrite runs a one-shot tone-rewrite over a selected passage and returns the
// rewritten text for the editor to preview. The document id scopes the request
// to the owner (and reserves room to feed document context to the model later),
// even though the passage itself rides in the body.
func (h *Handler) rewrite(w http.ResponseWriter, r *http.Request) {
docID := chi.URLParam(r, "id")
var body rewriteRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
errorJSON(w, http.StatusBadRequest, "invalid request body")
return
}
text := strings.TrimSpace(body.Text)
if text == "" {
errorJSON(w, http.StatusBadRequest, "no text to rewrite")
return
}
if len([]rune(text)) > llm.RewriteMaxRunes {
errorJSON(w, http.StatusBadRequest, "selection too long to rewrite")
return
}
// Scope to the owner so a stray id can't drive rewrites against another
// user's document (and 404 cleanly when it doesn't exist).
var exists int
err := h.DB.QueryRow(
`SELECT 1 FROM documents WHERE id = ? AND user_id = ?`,
docID, db.LocalUserID,
).Scan(&exists)
if errors.Is(err, sql.ErrNoRows) {
errorJSON(w, http.StatusNotFound, "document not found")
return
}
if err != nil {
serverError(w, err)
return
}
out, err := llm.RunRewrite(r.Context(), h.Client, text, body.Style)
if err != nil {
errorJSON(w, http.StatusBadGateway, "rewrite failed: "+err.Error())
return
}
writeJSON(w, http.StatusOK, rewriteResponse{Rewrite: out})
}

View File

@@ -0,0 +1,79 @@
package suggestions
import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"gitea.parodia.dev/drwily/petal/internal/llm"
)
// recordingClient captures the last Complete request so the rewrite test can
// assert the styled system prompt and the selected passage reached the model.
type recordingClient struct {
response string
last llm.CompletionRequest
}
func (c *recordingClient) Complete(_ context.Context, req llm.CompletionRequest) (string, error) {
c.last = req
return c.response, nil
}
func (c *recordingClient) Stream(_ context.Context, _ llm.CompletionRequest) (<-chan string, error) {
ch := make(chan string)
close(ch)
return ch, nil
}
func TestRewrite(t *testing.T) {
// The model wraps its answer in quotes; cleanRewrite should strip them.
client := &recordingClient{response: `"I have two apples."`}
srv, docID, _ := newTestServer(t, client)
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/rewrite",
`{"text":"I has two apple.","style":"academic"}`)
if rec.Code != http.StatusOK {
t.Fatalf("rewrite: code=%d body=%s", rec.Code, rec.Body)
}
var resp rewriteResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Rewrite != "I have two apples." {
t.Fatalf("rewrite = %q, want the de-quoted text", resp.Rewrite)
}
// The passage rode in as the user turn, and the style steered the system turn.
msgs := client.last.Messages
if len(msgs) != 2 || msgs[0].Role != "system" || msgs[1].Role != "user" {
t.Fatalf("unexpected message shape: %+v", msgs)
}
if msgs[1].Content != "I has two apple." {
t.Fatalf("passage not forwarded: %q", msgs[1].Content)
}
if !strings.Contains(msgs[0].Content, "academic") {
t.Fatalf("system prompt missing academic style guidance:\n%s", msgs[0].Content)
}
}
func TestRewriteEmptyText(t *testing.T) {
client := &recordingClient{response: "anything"}
srv, docID, _ := newTestServer(t, client)
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/rewrite", `{"text":" ","style":"natural"}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("empty text: want 400, got %d", rec.Code)
}
}
func TestRewriteUnknownDoc(t *testing.T) {
client := &recordingClient{response: "anything"}
srv, _, _ := newTestServer(t, client)
rec := do(t, srv, http.MethodPost, "/docs/does-not-exist/rewrite",
`{"text":"hello there","style":"natural"}`)
if rec.Code != http.StatusNotFound {
t.Fatalf("unknown doc: want 404, got %d", rec.Code)
}
}