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:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
76
internal/suggestions/rewrite.go
Normal file
76
internal/suggestions/rewrite.go
Normal 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})
|
||||
}
|
||||
79
internal/suggestions/rewrite_test.go
Normal file
79
internal/suggestions/rewrite_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user