diff --git a/BUILD_PLAN.md b/BUILD_PLAN.md index c91954e..27cca05 100644 --- a/BUILD_PLAN.md +++ b/BUILD_PLAN.md @@ -81,16 +81,23 @@ Multi-session build. **Source of truth for what's done and what's next.** Update - [x] **Stop saving empty docs** — blank `Untitled` drafts now self-discard: `handleCreate` reuses an existing blank instead of stacking another; `openDoc` deletes the blank doc being left. Cleaned the 2 existing orphan empties from the live DB. (Backend also refuses to snapshot empties.) - Verified: go build/vet/test + tsc + vite all clean; live smoke on a throwaway binary — auto-snapshot on save, throttle holds at 1, manual snapshot, restore brings back exact text + leaves a `pre_restore`, empty doc → no snapshot, md/docx export with CJK+bold+heading+list, docx validates as "Microsoft Word 2007+". +### Phase 9 — ESL superpowers ✅ +- [x] **Inline Chinese gloss (offline)** — embedded English→Chinese dictionary (`internal/lexicon/data/gloss.json.gz`, ~1.3MB, 57k common words built from ECDICT via `scripts/build_gloss.py`: frequency-gated to rank ≤50k, `[网络]`/slang/archaic sense-lines dropped, trimmed to ≤3 senses / 80 chars). `Lexicon` gains a `gloss` map + `Gloss(word)` (same `candidates()` de-inflection as defs/syns); `Result` gains a `Gloss` field. Two surfaces: the right-click **WordCard** now leads with the 中文 gloss, and a new lightweight `GET /api/gloss/{word}` (→ `{word, gloss}`, cached) backs the **hover tooltip**. Offline + instant, works with the LLM down (north-star reliability). Frontend: `GlossTip` (dark pointer-events-none bubble under the resting word; 350ms hover delay; reuses `wordAt` so CJK is never glossed — it's the source language), wired into `EditorCore`'s `onMouseMove`/`onMouseLeave` with a request-token guard, suppressed during selection/preview/other popovers. +- [x] **"Say it more naturally" / tone-rewrite** — selecting text pops a `SelectionBubble` (✨更自然 + the tone vocabulary 学术/专业/轻松/幽默/创意/说服, mirrored from `ToneSelect`/`styleGuidance`). Picking a style calls `POST /api/docs/:id/rewrite` (`{text, style}` → `{rewrite}`), shown in a `RewritePreview` (original struck-through → rewrite, 用这个/取消, breathing-dot loading, gentle retry on failure). Accept applies it in-editor via `insertContentAt` over the captured PM range. Backend: `llm.RunRewrite` (one-shot Complete, `RewriteMaxRunes` 2000 cap, `cleanRewrite` strips stray wrapping quotes) + `rewriteSystemTemplate`/`styleGuidance` in `prompts.go`; handler in `internal/suggestions/rewrite.go` (owner-scoped 404, 400 on empty/too-long, 502 on LLM-down). **Stateless** — not persisted as a suggestion; the version history captures the resulting doc change. +- Tests: `lexicon` gloss + Lookup-includes-gloss + inflection/miss; `suggestions` rewrite happy-path (style steering + de-quote asserted), empty→400, unknown-doc→404. go build/vet/test clean, tsc clean, vite build OK. Live smoke vs a fake vLLM (fresh port 8055, throwaway DB; pre-existing dev servers on :8077/:8099 untouched): gloss for `river`/inflected/CJK-empty/nonsense-empty, `word/happy` carries the gloss, rewrite returns text, empty→400, unknown→404, LLM-down→502; new CSS classes present in the built bundle. +- **Known limitation**: `Gloss` tries the literal form first (matching defs/syns ordering), so an inflected word that is *itself* a separate ECDICT headword resolves to that entry rather than de-inflecting (e.g. `rivers` → the proper-noun "Rivers" sense, not `river`). The base form always glosses correctly; acceptable. + ### Deferred (post-v1-local) - [ ] Authentik OIDC auth + session middleware ← **on hold: user doing foundational work first** - [ ] Copyleaks Tier-2 + webhook HMAC - [ ] Dockerfile, docker-compose, Traefik, deploy to write.parodia.dev ### Next-up (post-v1 product, agreed with user 2026-06-26) -- [ ] **Phase 9 — ESL superpowers**: inline Chinese gloss on hover/select; "say it more naturally" / tone-rewrite. +- [x] **Phase 9 — ESL superpowers**: inline Chinese gloss on hover/select; "say it more naturally" / tone-rewrite. ✅ (see Phase 9 above) - [ ] **Phase 10 — organization & polish**: cross-doc search, folders/tags, tablet/touch polish, warm LLM-down failure states. ## Session log +- 2026-06-26: **Phase 9 complete** (ESL superpowers: inline Chinese gloss + tone-rewrite). Decisions confirmed with user: gloss is an **offline EC dictionary** (instant, LLM-down-proof, fits the embedded-lexicon ethos), rewrite is a **selection bubble**. Data: `scripts/build_gloss.py` builds `internal/lexicon/data/gloss.json.gz` from ECDICT (66MB csv → 1.3MB gz, 57k freq-≤50k words, cleaned/trimmed). Backend: `lexicon` gloss map + `Gloss()`/`Result.Gloss` + `GET /api/gloss/{word}`; `llm.RunRewrite` + rewrite prompt/`styleGuidance`; `internal/suggestions/rewrite.go` (`POST /api/docs/:id/rewrite`, stateless, owner-scoped). Frontend: `GlossTip` hover tooltip (350ms delay, reuses `wordAt`, CJK-safe) + gloss line in `WordCard`; `SelectionBubble` + `RewritePreview` wired through `EditorCore` (onMouseMove/onSelectionUpdate, request-token guards, clears on edit/doc-switch); `api.glossWord`/`api.rewriteSelection`; CSS for the three new surfaces (+ print-hidden). Tests added in `lexicon` and `suggestions`. All builds/tests/vet/tsc/vite clean; live smoke vs fake vLLM on :8055 verified gloss + rewrite + 400/404/502 paths. Next: **Phase 10 (organization & polish).** - 2026-06-26: **Phase 8 complete** (Trust foundation: version history + export) + empty-doc fix. Backend: migration `0003_document_versions`; `internal/docs/versions.go` (throttled auto-snapshot wired into `update`, manual snapshot, restore-with-pre_restore, prune to 40 auto, owner-scoped via join) and `internal/docs/export.go` (pure-Go Tiptap-JSON → md/html/txt/docx, no deps, CJK-safe filenames via RFC 5987). `DocumentVersion` model + kind constants. Tests: `versions_test.go`, `export_test.go` (incl. valid-zip docx assertion). Frontend: `api.client` version/export methods; `ExportMenu` + `HistoryPanel` components wired into the title row; `@media print` stylesheet + `.petal-no-print` for the browser PDF path; `editorEpoch` remount on restore. Empty-doc fix in `App.tsx` (blank drafts reuse-on-create + discard-on-leave via refs to dodge stale closures); deleted 2 orphan empties from the live :8099 DB. Multi-session plan agreed: this session = Phase 8; **Phase 9 (ESL gloss + tone-rewrite)** next, then **Phase 10 (search/folders/polish)**; **auth/deploy (was Phase 11) shelved** until user's foundational work lands. All builds/tests/vet clean; live smoke verified the full version+export+restore flow end-to-end. Next: **Phase 9.** - 2026-06-25: Spec reviewed & amended (voice/grammar decoupled, ctx cap, routes, voice DB type, honey color, string-anchoring). Build plan created. - 2026-06-25: **Phase 0 complete.** Go module + chi server, config loader, React/Vite/Tailwind-v4 scaffold with full design tokens, frontend embedded & served by the binary, verified end-to-end. Toolchain: Go 1.24.4, Node 22, npm 10. Next: **Phase 1 (data layer)** — SQLite via modernc, models, seed `local` user. diff --git a/cmd/server/main.go b/cmd/server/main.go index dd51762..7242b94 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -69,8 +69,12 @@ func main() { // Per-suggestion actions (accept/dismiss) under /api/suggestions. api.Mount("/suggestions", sug.Routes()) - // Offline word lookups (definition + synonyms) for the right-click popover. - api.Mount("/word", lexicon.NewHandler().Routes()) + // Offline lexicon: full word lookups (gloss + definition + synonyms) for + // the right-click popover, and the lightweight Chinese-only gloss for the + // inline hover/select tooltip. One handler so the datasets load once. + lex := lexicon.NewHandler() + api.Mount("/word", lex.Routes()) + api.Mount("/gloss", lex.GlossRoutes()) }) // Everything else: serve the embedded SPA (with index.html fallback for client routing). diff --git a/internal/lexicon/data.go b/internal/lexicon/data.go index 3417921..3856d97 100644 --- a/internal/lexicon/data.go +++ b/internal/lexicon/data.go @@ -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 diff --git a/internal/lexicon/data/gloss.json.gz b/internal/lexicon/data/gloss.json.gz new file mode 100644 index 0000000..6d60e0d Binary files /dev/null and b/internal/lexicon/data/gloss.json.gz differ diff --git a/internal/lexicon/handlers.go b/internal/lexicon/handlers.go index 0554bcb..2e299ae 100644 --- a/internal/lexicon/handlers.go +++ b/internal/lexicon/handlers.go @@ -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) +} diff --git a/internal/lexicon/lexicon.go b/internal/lexicon/lexicon.go index aeb4af8..a6b4cab 100644 --- a/internal/lexicon/lexicon.go +++ b/internal/lexicon/lexicon.go @@ -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. diff --git a/internal/lexicon/lexicon_test.go b/internal/lexicon/lexicon_test.go index 29d52b1..c54a51a 100644 --- a/internal/lexicon/lexicon_test.go +++ b/internal/lexicon/lexicon_test.go @@ -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", diff --git a/internal/llm/prompts.go b/internal/llm/prompts.go index 8a9179e..da70a33 100644 --- a/internal/llm/prompts.go +++ b/internal/llm/prompts.go @@ -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}, + } +} diff --git a/internal/llm/rewrite.go b/internal/llm/rewrite.go new file mode 100644 index 0000000..c669c48 --- /dev/null +++ b/internal/llm/rewrite.go @@ -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 +} diff --git a/internal/suggestions/handlers.go b/internal/suggestions/handlers.go index 8c48e22..0a060a5 100644 --- a/internal/suggestions/handlers.go +++ b/internal/suggestions/handlers.go @@ -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) } diff --git a/internal/suggestions/rewrite.go b/internal/suggestions/rewrite.go new file mode 100644 index 0000000..92da4de --- /dev/null +++ b/internal/suggestions/rewrite.go @@ -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}) +} diff --git a/internal/suggestions/rewrite_test.go b/internal/suggestions/rewrite_test.go new file mode 100644 index 0000000..1524bcb --- /dev/null +++ b/internal/suggestions/rewrite_test.go @@ -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) + } +} diff --git a/scripts/build_gloss.py b/scripts/build_gloss.py new file mode 100644 index 0000000..d2450cd --- /dev/null +++ b/scripts/build_gloss.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Build the embedded English->Chinese gloss dataset from ECDICT. + +Petal shows an instant Chinese gloss when the writer (an English-as-a-second- +language user whose first language is Mandarin) hovers or right-clicks a word. +The gloss is served offline from a small map compiled into the Go binary, the +same way the English definitions/synonyms are (see internal/lexicon). + +Source: ECDICT (https://github.com/skywind3000/ECDICT), MIT-licensed. We keep +only common single words that carry a Chinese translation, trim each gloss to a +couple of senses, and drop the noisy "[网络]" (internet-slang) lines — the result +gzips to a few MB, in line with the other lexicon assets. + +Usage: + curl -sL https://raw.githubusercontent.com/skywind3000/ECDICT/master/ecdict.csv -o ecdict.csv + python3 scripts/build_gloss.py ecdict.csv internal/lexicon/data/gloss.json.gz +""" +import csv +import gzip +import json +import re +import sys + +# Frequency gate: keep a word only if ECDICT ranks it in the BNC or COCA +# frequency lists (frq/bnc > 0). That bounds the asset to the words an ESL +# writer actually meets, dropping the long tail of archaic/technical headwords. +# Words below this rank but present nowhere in the frequency lists are skipped. +MAX_RANK = 50000 + +# A gloss is at most this many sense-lines and characters, so the hover bubble +# stays a glance, not a wall of text. +MAX_SENSES = 3 +MAX_CHARS = 80 + +# Single English word: letters, with internal apostrophe/hyphen (so "don't", +# "well-being" survive but multi-word phrases and codes are dropped). +WORD_RE = re.compile(r"^[a-z][a-z'\-]*[a-z]$|^[a-z]$") + +# Tag lines we drop from a translation: "[网络]" is crowd-sourced internet slang, +# "[俚]" slang, "[古]" archaic — none help an ESL writer pick everyday meaning. +DROP_TAG_RE = re.compile(r"^\[(网络|俚|古|罕|废)\]") + + +def clean_gloss(translation: str) -> str: + """Trim an ECDICT translation to a compact, glanceable Chinese gloss.""" + # ECDICT separates senses with a literal backslash-n; normalize to real + # newlines (and tolerate genuine newlines) before splitting. + translation = translation.replace("\\n", "\n") + senses = [] + for line in translation.split("\n"): + line = line.strip() + if not line or DROP_TAG_RE.match(line): + continue + senses.append(line) + if len(senses) >= MAX_SENSES: + break + gloss = ";".join(senses) + if len(gloss) > MAX_CHARS: + gloss = gloss[:MAX_CHARS].rstrip(";,;, ") + "…" + return gloss + + +def rank(row: dict) -> int: + """Best (lowest, non-zero) frequency rank across COCA and BNC.""" + ranks = [] + for key in ("frq", "bnc"): + try: + v = int(row.get(key) or 0) + except ValueError: + v = 0 + if v > 0: + ranks.append(v) + return min(ranks) if ranks else 0 + + +def main(src: str, dst: str) -> None: + out: dict[str, str] = {} + kept_rank: dict[str, int] = {} + with open(src, newline="", encoding="utf-8") as f: + for row in csv.DictReader(f): + word = (row.get("word") or "").strip().lower() + translation = (row.get("translation") or "").strip() + if not word or not translation or not WORD_RE.match(word): + continue + r = rank(row) + if r == 0 or r > MAX_RANK: + continue + gloss = clean_gloss(translation) + if not gloss: + continue + # On a duplicate headword keep the more frequent (lower-rank) entry. + if word in kept_rank and kept_rank[word] <= r: + continue + out[word] = gloss + kept_rank[word] = r + + payload = json.dumps(out, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + with gzip.open(dst, "wb", compresslevel=9) as gz: + gz.write(payload) + print(f"{len(out)} words -> {dst} ({len(payload)} bytes json)") + + +if __name__ == "__main__": + if len(sys.argv) != 3: + sys.exit("usage: build_gloss.py ") + main(sys.argv[1], sys.argv[2]) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 2996d61..99edcb4 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -37,14 +37,21 @@ export interface WordMeaning { example?: string } -// The offline lookup for one word: a few definition senses and a list of -// synonyms. Either list may be empty when the word isn't a headword. +// The offline lookup for one word: a Chinese gloss, a few definition senses, and +// a list of synonyms. Any of these may be empty when the word isn't a headword. export interface WordInfo { word: string + gloss: string // Chinese translation; '' when the word isn't in the gloss set definitions: WordMeaning[] synonyms: string[] } +// The lightweight Chinese-only gloss behind the inline hover/select tooltip. +export interface Gloss { + word: string + gloss: string +} + export type SuggestionType = 'grammar' | 'phrasing' | 'idiom' | 'clarity' | 'voice' // A point-in-time snapshot of a document. List responses omit the heavy @@ -134,8 +141,19 @@ export const api = { exportUrl: (id: string, format: ExportFormat) => `/api/docs/${id}/export?format=${format}`, - // Offline word lookup (definition + synonyms) for the right-click popover. + // Offline word lookup (gloss + definition + synonyms) for the right-click popover. lookupWord: (word: string) => req(`/word/${encodeURIComponent(word)}`), + // Lightweight Chinese-only gloss for the inline hover/select tooltip — instant + // and offline, so it fires on hover without spinning up the heavier lookup. + glossWord: (word: string) => req(`/gloss/${encodeURIComponent(word)}`), + // Tone-rewrite: rewrites a selected passage in the given style ('natural', + // 'academic', …) and returns the rewritten text for an in-editor preview. Not + // persisted — the editor applies it directly on accept. + rewriteSelection: (docId: string, text: string, style: string) => + req<{ rewrite: string }>(`/docs/${docId}/rewrite`, { + method: 'POST', + body: JSON.stringify({ text, style }), + }), // Current deployed build id — changes whenever a new frontend ships. The // app polls this to offer a refresh. Bypasses any cache so the answer is live. diff --git a/web/src/components/Editor/EditorCore.tsx b/web/src/components/Editor/EditorCore.tsx index 917b211..3a0c4f8 100644 --- a/web/src/components/Editor/EditorCore.tsx +++ b/web/src/components/Editor/EditorCore.tsx @@ -11,6 +11,9 @@ import { SuggestionHighlight, setSuggestions, findRange } from './SuggestionHigh import { SpellCheck, setSpellChecker, wordAt } from './SpellCheck' import { MisspellCard } from './MisspellCard' import { WordCard } from './WordCard' +import { GlossTip } from './GlossTip' +import { SelectionBubble } from './SelectionBubble' +import { RewritePreview, type RewriteStatus } from './RewritePreview' import { api, type Suggestion, type WordInfo } from '../../api/client' import type { SpellChecker } from '../../hooks/useSpellChecker' @@ -106,6 +109,39 @@ interface HoverState { left: number } +// The inline hover gloss: the word under the resting pointer, its Chinese +// translation, the PM range it covers (to dedupe re-fetches), and where to anchor. +interface GlossState { + word: string + gloss: string + from: number + to: number + top: number + left: number +} + +// A non-empty text selection that the rewrite bubble hovers over. +interface SelectionState { + from: number + to: number + text: string + top: number + left: number +} + +// An in-flight or completed tone-rewrite preview, pinned over the selection it +// came from. `from`/`to` are the PM range the accepted rewrite replaces. +interface RewriteState { + from: number + to: number + original: string + style: string + top: number + left: number + status: RewriteStatus + rewrite: string +} + // EditorCore is the Tiptap instance: StarterKit formatting plus underline, text // alignment, a placeholder, character counting, and the suggestion-highlight // decoration layer. Hovering a highlight opens its SuggestionCard. @@ -138,6 +174,15 @@ export function EditorCore({ // While the Ask Petal panel is expanded the card is pinned: the hover-close // timer is suppressed so chatting doesn't dismiss it. A click outside closes. const [pinned, setPinned] = useState(false) + // Inline hover gloss (rest the pointer on a word → its Chinese meaning). + const [gloss, setGloss] = useState(null) + const glossTimer = useRef>(undefined) + const glossReqRef = useRef(0) + // The rewrite affordances: a bubble over the current selection, and the + // preview that replaces it once a style is chosen. + const [selection, setSelection] = useState(null) + const [rewrite, setRewrite] = useState(null) + const rewriteReqRef = useRef(0) const editor = useEditor({ extensions: [ @@ -158,12 +203,37 @@ export function EditorCore({ // Any edit shifts positions, stranding the popover anchors. setMisspell(null) setWordInfo(null) + setGloss(null) + setSelection(null) + // A stale rewrite preview points at a range that just moved — drop it. + rewriteReqRef.current++ + setRewrite(null) onChange({ content: JSON.stringify(editor.getJSON()), content_text: editor.getText(), word_count: editor.storage.characterCount.words(), }) }, + onSelectionUpdate: ({ editor }) => { + // A selection supersedes the hover gloss; an empty one clears the bubble. + setGloss(null) + const { from, to, empty } = editor.state.selection + const wrapper = wrapperRef.current + if (empty || !wrapper) { + setSelection(null) + return + } + const text = editor.state.doc.textBetween(from, to, ' ').trim() + if (!text) { + setSelection(null) + return + } + const start = editor.view.coordsAtPos(from) + const wrapRect = wrapper.getBoundingClientRect() + const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - 360)) + const top = start.top - wrapRect.top + setSelection({ from, to, text, top, left }) + }, }) // When the selected document changes, swap in its content without emitting an @@ -174,6 +244,10 @@ export function EditorCore({ setHover(null) setMisspell(null) setWordInfo(null) + setGloss(null) + setSelection(null) + rewriteReqRef.current++ + setRewrite(null) // eslint-disable-next-line react-hooks/exhaustive-deps }, [docId, editor]) @@ -381,6 +455,143 @@ export function EditorCore({ [editor, wordInfo], ) + // Hover gloss: rest the pointer on an English word and, after a short delay, + // show its Chinese meaning from the offline gloss dataset. Suppressed while a + // selection, preview, or another popover is active, or over a word that has its + // own affordance (a suggestion highlight / a misspelling). + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + if (!editor) return + if (selection || rewrite || misspell || wordInfo || pinned) return + if (!editor.state.selection.empty) return + const t = e.target as HTMLElement + const clear = () => { + clearTimeout(glossTimer.current) + glossReqRef.current++ + setGloss(null) + } + if (t.closest('.petal-suggestion') || t.closest('.petal-misspelling')) { + clear() + return + } + const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY }) + if (!coords) { + clear() + return + } + const range = wordAt(editor.state.doc, coords.pos) + if (!range) { + clear() + return + } + // Already showing this exact word — leave it be (no flicker on micro-moves). + if (gloss && gloss.from === range.from && gloss.to === range.to) return + clearTimeout(glossTimer.current) + const token = ++glossReqRef.current + glossTimer.current = setTimeout(() => { + api + .glossWord(range.word) + .then((g) => { + if (token !== glossReqRef.current) return + const wrapper = wrapperRef.current + if (!g.gloss || !wrapper) { + setGloss(null) + return + } + const start = editor.view.coordsAtPos(range.from) + const end = editor.view.coordsAtPos(range.to) + const wrapRect = wrapper.getBoundingClientRect() + const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - 280)) + const top = end.bottom - wrapRect.top + 6 + setGloss({ word: range.word, gloss: g.gloss, from: range.from, to: range.to, top, left }) + }) + .catch(() => { + if (token === glossReqRef.current) setGloss(null) + }) + }, 350) + }, + [editor, selection, rewrite, misspell, wordInfo, pinned, gloss], + ) + + // Leaving the editor surface drops any pending/shown gloss. + const handleMouseLeave = useCallback(() => { + clearTimeout(glossTimer.current) + glossReqRef.current++ + setGloss(null) + }, []) + + // runRewrite fires the LLM rewrite for a captured range + style and tracks it + // through loading → ready/error. A request token discards a response whose + // preview has since been cancelled or superseded. + const runRewrite = useCallback( + (from: number, to: number, original: string, style: string, top: number, left: number) => { + const token = ++rewriteReqRef.current + setRewrite({ from, to, original, style, top, left, status: 'loading', rewrite: '' }) + api + .rewriteSelection(docId, original, style) + .then((res) => { + if (token === rewriteReqRef.current) { + setRewrite((r) => (r ? { ...r, status: 'ready', rewrite: res.rewrite } : null)) + } + }) + .catch((err) => { + console.error('rewrite failed', err) + if (token === rewriteReqRef.current) { + setRewrite((r) => (r ? { ...r, status: 'error' } : null)) + } + }) + }, + [docId], + ) + + // Picking a style in the selection bubble: capture the selection's range + + // text, anchor a preview below it, and kick off the rewrite. + const handleRewrite = useCallback( + (style: string) => { + if (!editor || !selection) return + const wrapper = wrapperRef.current + if (!wrapper) return + const start = editor.view.coordsAtPos(selection.from) + const end = editor.view.coordsAtPos(selection.to) + const wrapRect = wrapper.getBoundingClientRect() + const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - 340)) + const top = end.bottom - wrapRect.top + 6 + setSelection(null) + setGloss(null) + runRewrite(selection.from, selection.to, selection.text, style, top, left) + }, + [editor, selection, runRewrite], + ) + + const acceptRewrite = useCallback(() => { + if (editor && rewrite && rewrite.rewrite) { + editor.chain().focus().insertContentAt({ from: rewrite.from, to: rewrite.to }, rewrite.rewrite).run() + } + rewriteReqRef.current++ + setRewrite(null) + }, [editor, rewrite]) + + const cancelRewrite = useCallback(() => { + rewriteReqRef.current++ + setRewrite(null) + }, []) + + const retryRewrite = useCallback(() => { + if (rewrite) runRewrite(rewrite.from, rewrite.to, rewrite.original, rewrite.style, rewrite.top, rewrite.left) + }, [rewrite, runRewrite]) + + // A pointer-down outside the rewrite preview dismisses it. + useEffect(() => { + if (!rewrite) return + const onDown = (e: MouseEvent) => { + if ((e.target as HTMLElement).closest('.petal-rewrite-card')) return + rewriteReqRef.current++ + setRewrite(null) + } + document.addEventListener('mousedown', onDown) + return () => document.removeEventListener('mousedown', onDown) + }, [rewrite]) + // A pointer-down outside the word popover (and not on another word, which would // reopen it via the context menu) closes it. useEffect(() => { @@ -409,6 +620,7 @@ export function EditorCore({ useEffect(() => () => { clearTimeout(closeTimer.current) clearTimeout(confettiTimer.current) + clearTimeout(glossTimer.current) }, []) // While pinned (Ask Petal open), a pointer-down outside the card closes it — @@ -430,11 +642,32 @@ export function EditorCore({ className="relative flex-1" onMouseOver={handleMouseOver} onMouseOut={handleMouseOut} + onMouseMove={handleMouseMove} + onMouseLeave={handleMouseLeave} onClick={handleSpellClick} onContextMenu={handleContextMenu} > {confetti && } + {gloss && } + {selection && !rewrite && ( + + )} + {rewrite && ( + + )} {wordInfo && ( + {gloss} + + ) +} diff --git a/web/src/components/Editor/RewritePreview.tsx b/web/src/components/Editor/RewritePreview.tsx new file mode 100644 index 0000000..5d6520c --- /dev/null +++ b/web/src/components/Editor/RewritePreview.tsx @@ -0,0 +1,136 @@ +import { REWRITE_STYLES } from './SelectionBubble' + +// RewritePreview shows the result of a tone-rewrite before it touches the +// document: the writer's original passage, the model's rewrite beneath it, and +// accept/cancel. It mirrors the SuggestionCard's warm, bilingual chrome. While +// the model runs it shows a breathing dot; on failure it offers a gentle retry. + +export type RewriteStatus = 'loading' | 'ready' | 'error' + +interface Props { + style: string // the chosen rewrite style key (for the header label) + status: RewriteStatus + original: string + rewrite: string + cardStyle: React.CSSProperties + onAccept: () => void + onCancel: () => void + onRetry: () => void +} + +const CJK = "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif" + +export function RewritePreview({ + style, + status, + original, + rewrite, + cardStyle, + onAccept, + onCancel, + onRetry, +}: Props) { + const meta = REWRITE_STYLES.find((s) => s.value === style) ?? REWRITE_STYLES[0] + + return ( +
e.stopPropagation()} + style={{ + width: 340, + background: 'var(--color-surface)', + border: '1px solid var(--color-border)', + borderRadius: 'var(--radius-card)', + boxShadow: 'var(--shadow-soft)', + fontFamily: CJK, + ...cardStyle, + }} + > +
+ + {meta.emoji} + {meta.zh} · {meta.en} + + + 改写 · Rewrite + +
+ + {/* Original passage, dimmed — what's being replaced. */} +

+ {original} +

+ + {status === 'loading' && ( +
+ + 改写中… · Rewriting… +
+ )} + + {status === 'error' && ( +
+

+ 改写失败,请再试一次 · Couldn’t rewrite — try again +

+
+ + +
+
+ )} + + {status === 'ready' && ( + <> +

+ {rewrite} +

+
+ + +
+ + )} +
+ ) +} diff --git a/web/src/components/Editor/SelectionBubble.tsx b/web/src/components/Editor/SelectionBubble.tsx new file mode 100644 index 0000000..f06873f --- /dev/null +++ b/web/src/components/Editor/SelectionBubble.tsx @@ -0,0 +1,85 @@ +// SelectionBubble floats above a text selection and offers to rewrite it: a +// prominent "✨ 更自然 Say it naturally" action plus the tone vocabulary (学术, +// 轻松, …) mirrored from the document-tone picker. Picking one hands the style up +// to EditorCore, which calls the LLM and shows a preview. Buttons use +// onMouseDown→preventDefault so clicking them doesn't collapse the selection +// before the handler captures its range. + +export interface RewriteStyle { + value: string + emoji: string + zh: string + en: string +} + +// 'natural' is the default "say it more naturally" rewrite; the rest mirror the +// llm styleGuidance keys (and the ToneSelect labels) so the two stay in step. +export const REWRITE_STYLES: RewriteStyle[] = [ + { value: 'natural', emoji: '✨', zh: '更自然', en: 'Natural' }, + { value: 'academic', emoji: '🎓', zh: '学术', en: 'Academic' }, + { value: 'professional', emoji: '💼', zh: '专业', en: 'Professional' }, + { value: 'casual', emoji: '☕', zh: '轻松', en: 'Casual' }, + { value: 'humorous', emoji: '😄', zh: '幽默', en: 'Humorous' }, + { value: 'creative', emoji: '🎨', zh: '创意', en: 'Creative' }, + { value: 'persuasive', emoji: '📣', zh: '说服', en: 'Persuasive' }, +] + +interface Props { + style: React.CSSProperties + onRewrite: (style: string) => void +} + +const CJK = "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif" + +export function SelectionBubble({ style, onRewrite }: Props) { + const [natural, ...tones] = REWRITE_STYLES + + return ( +
e.preventDefault()} // keep the editor selection + style={{ + background: 'var(--color-surface)', + border: '1px solid var(--color-border)', + borderRadius: 'var(--radius-pill)', + boxShadow: 'var(--shadow-soft)', + fontFamily: CJK, + ...style, + }} + > + + + + + {tones.map((t) => ( + + ))} +
+ ) +} diff --git a/web/src/components/Editor/WordCard.tsx b/web/src/components/Editor/WordCard.tsx index e73dfcc..d82877a 100644 --- a/web/src/components/Editor/WordCard.tsx +++ b/web/src/components/Editor/WordCard.tsx @@ -17,7 +17,8 @@ interface Props { export function WordCard({ word, info, loading, style, onReplace }: Props) { const definitions = info?.definitions ?? [] const synonyms = info?.synonyms ?? [] - const empty = !loading && definitions.length === 0 && synonyms.length === 0 + const gloss = info?.gloss ?? '' + const empty = !loading && !gloss && definitions.length === 0 && synonyms.length === 0 return (
+ {/* Chinese gloss first — it's what the Mandarin-speaking writer reaches for. */} + {gloss && ( +

+ {gloss} +

+ )} + {loading && (