Compare commits
16 Commits
631279bd3a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49e84278d5 | ||
|
|
9d2501a625 | ||
|
|
08b801e752 | ||
|
|
dd1dd7879b | ||
|
|
cb8f132d43 | ||
|
|
cea25a3ebc | ||
|
|
96f68a91ee | ||
|
|
3867cca2fa | ||
|
|
8be852ddf2 | ||
|
|
4d544f29b5 | ||
|
|
8c6bc1604b | ||
|
|
4161830da6 | ||
|
|
8aa437ec82 | ||
|
|
20442d1356 | ||
|
|
375e0dde4c | ||
|
|
7824b68ea1 |
@@ -105,25 +105,25 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
|
||||
- [x] **English phonetic (pivot from pinyin)** — for a native-Mandarin English learner the useful pronunciation aid is the English IPA, not pinyin (she reads Chinese fluently). `scripts/build_phonetic.py` extracts ECDICT's `phonetic` column (same source/freq-gate as the gloss); `phonetic.json.gz` embedded + lazily loaded; `Result.Phonetic` resolved via the same de-inflecting candidate walk; shown as `/ˈrɪvər/` in the WordCard. **Full dataset built from ECDICT: 46,579 words (361KB gz)**, in line with the other lexicon assets. The script also has a `--seed` mode (71 curated common words) that ships as a fallback / works without the csv. Curated seed entries (clean IPA) override the ECDICT form where both exist.
|
||||
- Verified: go build/vet/test (incl. new `TestExportAll`), tsc, vite build all clean; live smoke vs throwaway binaries — `/word/river|running|serendipity|rivers` all return phonetic (`rivers`→`river` de-inflected), export-all returns a valid 2-entry zip with de-duped CJK filenames + dated name, route doesn't collide with `/{id}`.
|
||||
|
||||
### Phase 12 — Collocation coach 🔜 (planned 2026-06-26)
|
||||
### Phase 12 — Collocation coach ✅ (2026-06-26)
|
||||
**Why:** ESL writers nail grammar but miss *which words go together* — "do a decision" → "make a decision", "strong rain" → "heavy rain". These aren't *wrong*, so the grammar pass won't flag them; they're just non-native. Gentle "natives usually say…" hints are the single highest-leverage upgrade for making her writing sound native. **Build this first** — it's small and de-risks the migration-rebuild pattern Phase 13 also needs.
|
||||
|
||||
**Key insight:** the suggestion pipeline is already generic over a `pass` + a `pendingScope` "family" (`runPass` in `internal/suggestions/handlers.go`; grammar + voice already prove it). Collocation drops in as a **third family** and reuses the entire accept/dismiss/re-anchoring/rail/Mandarin-explanation machinery — no new frontend rendering layer.
|
||||
- [ ] `internal/llm/collocation.go` — `CollocationInterval` (~25s) + `RunCollocation(...)`, reusing the existing `ParseCheckpoint` parser (same as `RunVoice`). No new parsing.
|
||||
- [ ] `internal/llm/prompts.go` — `CollocationMessages(contentText, tone)`. **This prompt is the whole feature**: flag only non-wrong-but-non-native word pairings; explicitly defer real grammar errors to the grammar family; phrase every explanation as warm "natives usually say…" with a Mandarin gloss — never "error/wrong".
|
||||
- [ ] `internal/db/models.go` + migration `0005` — new suggestion type `collocation`. ⚠️ The `type` column has a `CHECK(...)` and **SQLite can't `ALTER` a CHECK** → migration must **rebuild** the `suggestions` table (create new w/ extended CHECK, copy rows, drop, rename, recreate `idx_suggestions_doc_id`). Not a one-line ALTER.
|
||||
- [ ] `internal/suggestions/handlers.go` — add `collocationScope` (`deleteWhere: "type = 'collocation'"`, `forceType: collocation`), a `CollocationLimit` on `Handler` (+ wire in `New`), register `POST /{id}/collocation`. Mirrors `voice`, ~15 lines. Also extend `normalizeType` to accept the new type.
|
||||
- [ ] Frontend: `suggestionMeta.ts` collocation entry (💡 icon + its own warm color so cards read as friendly tips); `client.ts` `collocation(docId)`; a gentle trigger like the existing "Check my voice" pill — **"Make it sound natural 🌸"** — rendering straight into `SuggestionRail`/`SuggestionCard`.
|
||||
- [ ] Verify via the millenia UI-test harness (real-browser editor check) + go/tsc/vite clean. **Est. ~½ day** (mostly the new prompt + the table-rebuild migration).
|
||||
- [x] `internal/llm/collocation.go` — `CollocationInterval` (25s) + `RunCollocation(...)`, reusing the existing `ParseCheckpoint` parser (same as `RunVoice`). Whole-document (no `TruncateDoc`), tone passed through.
|
||||
- [x] `internal/llm/prompts.go` — `CollocationMessages(contentText, tone)` + `collocationSystemPrompt`. The prompt flags only non-wrong-but-non-native word pairings ("do a decision" → "make a decision"), explicitly defers grammar/spelling to the grammar family, and frames every explanation as warm "Natives usually say…" with a Mandarin gloss — never "error/wrong/mistake".
|
||||
- [x] `internal/db/models.go` (`SuggestionTypeCollocation`) + migration `0005_collocation_suggestion_type` — **rebuilds** the `suggestions` table (new table w/ extended CHECK, copy rows, drop, rename, recreate `idx_suggestions_doc_id`), since SQLite can't `ALTER` a CHECK. Verified against a copy of the live DB (5 migrations apply cleanly, collocation insert accepted, rows preserved).
|
||||
- [x] `internal/suggestions/handlers.go` — `collocationScope` (`deleteWhere: "type = 'collocation'"`, `forceType: collocation`), `CollocationLimit` on `Handler` (+ wired in `New`), `POST /{id}/collocation`, `normalizeType` extended. **Also fixed `grammarScope`** from `type != 'voice'` → `type NOT IN ('voice','collocation')` so a grammar checkpoint no longer wipes the collocation pending flags (the third family must survive like voice does).
|
||||
- [x] Frontend: `suggestionMeta.ts` collocation entry (`--color-blossom` warm pink, "Word pairing" label); `client.ts` `collocationDoc(docId)` + `SuggestionType` extended; `index.css` token + `.petal-suggestion-collocation` decoration; `useCheckpoint` `collocating`/`runCollocation` (mirrors `runVoice`, shares the run-token guard); `Toolbar` blossom **"Make it sound natural 🌸"** pill (→ "Reading…"); `StatusBar` breathing blossom dot "Finding natural phrasing…"; threaded through `EditorCore`/`App`. Renders straight into the existing `SuggestionRail`/`SuggestionCard` (Accept applies the native pairing).
|
||||
- [x] Verified: go build/vet/test (`TestCollocationPassCoexists` — three families coexist, grammar checkpoint doesn't wipe voice/collocation), tsc, vite build, vitest 51/51 all clean; live smoke vs the binary (dead LLM) → collocation route returns the warm 502 like check/voice.
|
||||
|
||||
### Phase 13 — Vocabulary garden (spaced repetition) 🔜 (planned 2026-06-26)
|
||||
### Phase 13 — Vocabulary garden (spaced repetition) ✅ (2026-06-26)
|
||||
**Why:** the lexicon (`internal/lexicon`) is a stateless static-dataset lookup — **nothing records which words she's looked up.** Capturing them turns passive lookups into real vocabulary, and the review surface ties straight into the "petal garden" delight idea (words become blossoms; the sleeping kitten naps among them). Build **after** Phase 12.
|
||||
- [ ] New `internal/vocab` package + migration `0005`/`0006` — `vocab_words` table: `word, gloss, phonetic, example` (sentence captured at lookup for context), `doc_id`, + SM-2-lite scheduling: `due_at, interval_days, ease, reps, lapses, last_reviewed`; `UNIQUE(user_id, word)`.
|
||||
- [ ] Auto-capture: when `WordCard` calls `lookupWord`, also fire `POST /api/vocab` (upsert; new word → `due_at = now + 1 day`). Looking words up *is* the data source — zero extra effort from her.
|
||||
- [ ] Endpoints: `POST /api/vocab` (record/upsert), `GET /api/vocab/due` (cards due now), `POST /api/vocab/{id}/review` (grade again/good/easy → reschedule), `GET /api/vocab` (full garden), `DELETE /api/vocab/{id}`.
|
||||
- [ ] SR scheduler: gentle SM-2-lite / Leitner intervals (1d → 3d → 7d → 16d → 35d). No streak-shaming. This is the only genuinely new logic.
|
||||
- [ ] Frontend Garden panel (sibling to `HistoryPanel`/`DocList`): each learned word a blossom, more reps → more bloomed; the `PetalCompanion` kitten naps among them. Flashcard review: captured sentence with the word blanked → recall → EN↔中文 flip → again/good/easy (direction flips for recognition *and* production). A 🤍 "save to garden" on `WordCard` for explicit saves alongside auto-capture.
|
||||
- [ ] Verify via the millenia UI-test harness + go/tsc/vite clean. **Est. ~1½–2 days** (new package + table + panel/review UI; reuses existing panel/companion patterns).
|
||||
- [x] New `internal/vocab` package + migration `0006_vocab_garden` (0005 was taken by collocation) — `vocab_words` table: `word, gloss, phonetic, example` (sentence captured at lookup), `doc_id` (`ON DELETE SET NULL` so a word outlives its source doc), + SM-2-lite scheduling: `due_at, interval_days, ease, reps, lapses, last_reviewed`; `UNIQUE(user_id, word)` + `idx_vocab_due`.
|
||||
- [x] Auto-capture: `EditorCore.openWordLookup` fires `POST /api/vocab` after a successful lookup — **only for words the dictionary actually knows** (a real gloss or definition), so typos/proper-noun lookups don't clutter the garden. Captures the surrounding sentence (`sentenceAround`) + `doc_id`. Idempotent upsert: re-looking-up a word refreshes its gloss/phonetic/example but never resets its schedule.
|
||||
- [x] Endpoints (`internal/vocab/handlers.go`): `POST /api/vocab` (upsert; new word → `due_at = datetime('now','+1 day')`), `GET /api/vocab/due` (due now, server-side `datetime('now')` comparison — avoids JS local-vs-UTC parsing bugs), `POST /api/vocab/{id}/review` (grade → reschedule via `datetime('now','+N days')`), `GET /api/vocab` (full garden), `DELETE /api/vocab/{id}`. All owner-scoped.
|
||||
- [x] SR scheduler (`internal/vocab/scheduler.go`): gentle SM-2-lite / Leitner ladder (1d → 3d → 7d → 16d → 35d, then geometric by ease). "again" steps back to 1d + counts a lapse + nudges ease down (floored at 1.3) — no harsh wipe; "good" climbs one rung; "easy" climbs a rung and a bit more + raises ease. No streaks to break.
|
||||
- [x] Frontend `GardenPanel` (slide-over drawer, sibling to `HistoryPanel`): each word a blossom that opens further with reps (🌱→🌿→🌷→🌸→🌺); a "复习 N 个词 · Review N due" button; per-word detail (phonetic/example/source-doc/remove); footer "🐱💤 N 朵花在花园里" — the sleepy kitten napping among the blossoms. **Flashcard review**: due queue, the example sentence with the word blanked (`blankOut`), flip to reveal word+phonetic+gloss+sentence, again/good/easy grades; **direction alternates** by cursor parity for recognition (EN→中文) *and* production (中文→EN). A 🤍/💚 "save to garden" toggle on `WordCard` alongside the silent auto-capture. Opened from a global 🌷 词汇花园 button in the app header. All copy bilingual zh-first.
|
||||
- [x] Verified: go build/vet/test (`scheduler_test.go` — ladder/again-gentle/easy-further; `handlers_test.go` — capture/upsert/due/review/delete lifecycle + empty-word 400 + doc-delete SET NULL), tsc, vite build, vitest 51/51 all clean; live smoke vs the binary (throwaway DB) — full capture→list→due→review→delete flow + 400 on bad grade verified end-to-end.
|
||||
|
||||
### Phase 14 — Companion warmth + bedtime nag + night mode ✅
|
||||
**Why:** the companion kitten is the heart of Petal's "built for her" feel. Three additions: (1) a wider, fresher pool of **encouraging phrases** so cheers don't repeat as quickly; (2) when she's still writing **late at night (≥11pm)**, the kitten gently nags her to go to bed; (3) at the same hour the whole app drifts into a calm **night mode** — dark moonlit theme + the falling petals become **falling stars**. Caring, never scolding — the sleepy-cat gag makes "you should be sleeping too 🐱💤" land perfectly. Self-contained, frontend-only.
|
||||
@@ -142,11 +142,12 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
|
||||
### Next-up (post-v1 product, agreed with user 2026-06-26)
|
||||
- [x] **Phase 9 — ESL superpowers**: inline Chinese gloss on hover/select; "say it more naturally" / tone-rewrite. ✅ (see Phase 9 above)
|
||||
- [x] **Phase 10 — organization & polish**: cross-doc search, tags, tablet/touch polish, warm LLM-down failure states. ✅ (see Phase 10 above; "tags only" chosen over folders, FTS5 over LIKE)
|
||||
- [ ] **Phase 12 — collocation coach**: gentle "natives usually say…" hints for non-native word pairings, as a third suggestion family. 🔜 (see Phase 12 above; build first)
|
||||
- [ ] **Phase 13 — vocabulary garden**: spaced-repetition review built from looked-up words, surfaced as a blooming garden. 🔜 (see Phase 13 above)
|
||||
- [x] **Phase 12 — collocation coach**: gentle "natives usually say…" hints for non-native word pairings, as a third suggestion family. ✅ (see Phase 12 above)
|
||||
- [x] **Phase 13 — vocabulary garden**: spaced-repetition review built from looked-up words, surfaced as a blooming garden. ✅ (see Phase 13 above)
|
||||
- [x] **Phase 14 — companion warmth + bedtime nag + night mode**: more encouraging phrases, a gentle "go to bed" nudge after 11pm, and a calm dark theme + falling stars at night. ✅ (see Phase 14 above)
|
||||
|
||||
## Session log
|
||||
- 2026-06-26: **Phases 12 + 13 complete** (collocation coach + vocabulary garden — "finish the rest of the build plan except Authentik/Traefik"). **Phase 12**: collocation drops in as a third suggestion family reusing the whole `runPass`/`pendingScope` machinery — `llm/collocation.go` (`RunCollocation`, 25s floor, reuses `ParseCheckpoint`), `collocationSystemPrompt`/`CollocationMessages` (warm "Natives usually say…" + Mandarin gloss, defers grammar elsewhere), migration `0005` **rebuilds** the suggestions table to extend the `type` CHECK (SQLite can't ALTER a CHECK), `collocationScope` + `CollocationLimit` + `POST /{id}/collocation`. **Caught a latent bug**: `grammarScope` was `type != 'voice'` → would wipe collocation flags; fixed to `type NOT IN ('voice','collocation')`. Frontend: `--color-blossom` pink, "Make it sound natural 🌸" toolbar pill, `collocating`/`runCollocation` in `useCheckpoint`, StatusBar dot — all into the existing rail/card. **Phase 13**: new `internal/vocab` package — migration `0006_vocab_garden` (`vocab_words`, SM-2-lite columns, doc_id `ON DELETE SET NULL`, `UNIQUE(user_id,word)`), `scheduler.go` (Leitner ladder 1/3/7/16/35 → geometric; gentle "again", no streak-shaming), `handlers.go` (capture-upsert/list/due/review/delete, all owner-scoped, time math via SQLite `datetime()` so stored values stay canonical-UTC). Auto-capture wired into `EditorCore.openWordLookup` (only dictionary-known words, captures the surrounding sentence + doc_id) + a 🤍/💚 toggle on `WordCard`. `GardenPanel` slide-over: blossom grid (bloom stage by reps), flashcard review (sentence blanked, flip, again/good/easy, direction alternates recognition↔production), sleepy-kitten footer; opened from a global 🌷 header button. Tests: `TestCollocationPassCoexists`, vocab `scheduler_test.go` + `handlers_test.go`, db CHECK test extended. go build/vet/test + tsc + vite + vitest (51/51) all clean; migration verified against a copy of the live DB; live backend smoke (throwaway DB) walked the full vocab lifecycle + the warm-502 collocation path. **Remaining: only the deferred infra bucket** — Authentik auth, Copyleaks Tier-2 (needs a public webhook), Docker/Traefik/deploy — all on hold per the user's "except Authentik/Traefik".
|
||||
- 2026-06-26: **Phase 14 complete** (companion warmth + bedtime nag + night mode). `tips.ts`: `ENCOURAGEMENTS` 5→10 lines; new `BEDTIME` array (4 lines, user-supplied English wit + gentle Mandarin leads). `useCompanion.ts`: bedtime branch in the 10s heartbeat (after idle-return + break, before the generic tip); only nudges while actively writing; own `lastBedtime` ref + 30min `BEDTIME_GAP`, respects `PROACTIVE_GAP`; new `'bedtime'` `BubbleTone` lingers ~4s longer. **Night mode** (added same session, user request): `lib/night.ts` centralizes `isBedtime()` + window (now shared by the nag too); `hooks/useNightMode.ts` toggles `petal-night` on `<html>` (60s re-check); `index.css` `html.petal-night` re-points only the palette tokens → whole UI flips via `var()` (no component edits), 600ms dusk fade, print stays white; `PetalFall` gains a `night` prop → chunky cartoon power stars (`makeCartoonStar`, Mario/Kirby-style, 5 candy colors) mixed ~70/30 with small twinkle sparkles, gentle spin + shallow shimmer, effect re-inits on flip; App: `useNightMode()` → `<PetalFall night={night}/>`. tsc + vite clean, companion vitest 45/45; verified with real-browser Playwright screenshots (clock mocked to 23:30) — day petals/cream vs night stars/dark-plum, both pretty. Bedtime window is `BEDTIME_FROM`/`BEDTIME_TO` (local clock) for easy retune.
|
||||
- 2026-06-26: **Phase 11 complete** (writer power-ups, batch requested as "do it all"). Seven features: (1) in-doc **Find & Replace** — `SearchHighlight` decoration extension + `FindReplace` bar (Ctrl/Cmd+F, match-case, replace-all back-to-front, DOM scroll that doesn't trigger the selection bubble); (2) **read-aloud** Web Speech util + 🔊 in WordCard & selection bubble; (3) **keyboard/touch access** — Ctrl/Cmd+D caret lookup, Ctrl/Cmd+J rewrite, touch long-press (refactored `handleContextMenu` → shared `openWordLookup(pos)`); (4) **export-all** backup zip (`GET /api/docs/export-all`, `TestExportAll`, sidebar download links); (5) **smart typography** input-rules extension (curly quotes/em-dash/ellipsis, ASCII-only so CJK untouched); (6) **duplicate doc + sidebar sort + outline popover**; (7) **English phonetic** (pivoted from pinyin — IPA is what an English learner needs; pinyin annotates Chinese she already reads) via `scripts/build_phonetic.py` + embedded `phonetic.json.gz` + `Result.Phonetic` + WordCard `/ˈrɪvər/` line — **full 46,579-word dataset built from ECDICT** (the csv re-download worked; `--seed` mode kept as a csv-free fallback). Also folded in this session: the **selection-bubble vs copy/paste fix** (bubble deferred to pointer-up + container `pointer-events:none` so it never sits where you click). go build/vet/test + tsc + vite all clean; live smoke verified word-phonetic (incl. de-inflection) + export-all zip (de-duped CJK names, route priority). Next: deferred bucket (auth/Copyleaks/deploy), still on hold per user.
|
||||
- 2026-06-26: **Phase 10 complete** (organization & polish). Scope confirmed with user: all four areas, **tags** (not folders), **FTS5** search. Backend: migration `0004_tags_and_search` (tags + document_tags + `documents_fts` trigram virtual table with sync triggers + back-fill); `db.Tag` model + color constants; `internal/docs/tags.go` (tag CRUD + idempotent assignment + `tagsByDoc` helper, doc list now carries tags); `internal/docs/search.go` (`GET /api/search`, FTS for ≥3 runes + LIKE fallback for 1-2, Go-built sentinel-highlighted rune-aware snippets, owner-scoped). Mounted `/api/tags` + `/api/search` in main.go. Frontend: `useTags`, `TagChip`/`TagPicker`/`SearchBox`, rewritten `DocList`/`DocListItem` (chips + filter bar + search), `api.search`/tag methods + `splitSnippet`/`tagColorVar`; responsive sidebar drawer (hamburger + scrim, <768px) + `pointer:coarse` tap-target/affordance CSS; tap-to-open + outside-pointerdown-close for suggestion cards (touch); `useCheckpoint` `llmDown` flag → warm bilingual "小助手在休息" StatusBar note. Tests: `tags_test.go`, `search_test.go` (incl. update-trigger re-index). All builds/tests/vet/tsc/vite clean; live smoke vs binary on :8061 (dead LLM host) verified search EN/CJK/2-char, full tag lifecycle, check→502 warm path, bundle contents; FTS backfill of pre-existing docs verified. **All v1 phases (0–7) + post-v1 product (8–10) done.** Remaining: deferred bucket (auth/Copyleaks/deploy), on hold per user.
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||
"gitea.parodia.dev/drwily/petal/internal/suggestions"
|
||||
"gitea.parodia.dev/drwily/petal/internal/tts"
|
||||
"gitea.parodia.dev/drwily/petal/internal/vocab"
|
||||
"gitea.parodia.dev/drwily/petal/web"
|
||||
)
|
||||
|
||||
@@ -47,6 +48,11 @@ func main() {
|
||||
log.Printf("frontend build version %s", version)
|
||||
|
||||
r.Route("/api", func(api chi.Router) {
|
||||
// Cap request bodies so a runaway or hostile client can't stream an
|
||||
// unbounded payload into a JSON decoder. Image uploads carry their own
|
||||
// (larger) limit inside the images handler, so they're exempt here.
|
||||
api.Use(limitBody(maxAPIBodyBytes, "/api/images"))
|
||||
|
||||
api.Get("/health", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||
@@ -83,6 +89,10 @@ func main() {
|
||||
api.Mount("/word", lex.Routes())
|
||||
api.Mount("/gloss", lex.GlossRoutes())
|
||||
|
||||
// Vocabulary garden: words the writer looks up are captured here and
|
||||
// surfaced for gentle spaced-repetition review.
|
||||
api.Mount("/vocab", vocab.New(database).Routes())
|
||||
|
||||
// Editor image uploads, stored on disk and served back by content hash.
|
||||
imgHandler, err := images.New(cfg.ImageDir)
|
||||
if err != nil {
|
||||
@@ -109,6 +119,32 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// maxAPIBodyBytes caps a JSON API request body at 2 MiB. That's far above any
|
||||
// real document save (the body is text plus lightweight marks; images upload
|
||||
// separately by reference) while still bounding abuse. Exceeding it makes the
|
||||
// handler's json.Decode fail, which surfaces as a 400.
|
||||
const maxAPIBodyBytes = 2 << 20
|
||||
|
||||
// limitBody wraps each request body in an http.MaxBytesReader so handlers can't
|
||||
// be made to read an unbounded payload. Paths under any of exemptPrefixes are
|
||||
// left alone (e.g. image uploads, which set their own, larger limit).
|
||||
func limitBody(max int64, exemptPrefixes ...string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
for _, p := range exemptPrefixes {
|
||||
if strings.HasPrefix(r.URL.Path, p) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
if r.Body != nil {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, max)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// buildVersion derives a short, stable identifier for the currently embedded
|
||||
// frontend by hashing dist/index.html. Vite stamps content-hashed asset names
|
||||
// into that file each build, so the digest is a reliable "did the deploy
|
||||
|
||||
@@ -260,6 +260,111 @@ END;
|
||||
|
||||
INSERT INTO documents_fts (doc_id, title, content_text)
|
||||
SELECT id, title, content_text FROM documents;
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Collocation coach (Phase 12). Adds a third suggestion family,
|
||||
// 'collocation', for gentle "natives usually say…" hints on
|
||||
// non-native word pairings. The `type` column carries a CHECK
|
||||
// constraint and SQLite cannot ALTER one in place, so we rebuild the
|
||||
// suggestions table with the extended CHECK, copy every row across,
|
||||
// and recreate its index. Nothing references suggestions, so dropping
|
||||
// the old table is safe; the new table keeps the same ON DELETE
|
||||
// CASCADE to documents.
|
||||
name: "0005_collocation_suggestion_type",
|
||||
stmt: `
|
||||
CREATE TABLE suggestions_new (
|
||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
from_pos INTEGER NOT NULL,
|
||||
to_pos INTEGER NOT NULL,
|
||||
original TEXT NOT NULL,
|
||||
replacement TEXT NOT NULL,
|
||||
explanation TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('grammar','phrasing','idiom','clarity','voice','collocation')),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','accepted','rejected')),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO suggestions_new (id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at)
|
||||
SELECT id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at FROM suggestions;
|
||||
|
||||
DROP TABLE suggestions;
|
||||
ALTER TABLE suggestions_new RENAME TO suggestions;
|
||||
CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Vocabulary garden (Phase 13). Every word the writer looks up is
|
||||
// captured here and put on a gentle spaced-repetition schedule, turning
|
||||
// passive lookups into real vocabulary. `example` holds the sentence the
|
||||
// word appeared in (captured at lookup) for context during review;
|
||||
// `doc_id` links back to where she met the word (nulled if that doc is
|
||||
// deleted — the word stays in the garden). The SM-2-lite scheduling
|
||||
// columns (due_at/interval_days/ease/reps/lapses/last_reviewed) drive a
|
||||
// Leitner-style ladder (see internal/vocab/scheduler.go). UNIQUE on
|
||||
// (user_id, word) makes capture an idempotent upsert.
|
||||
name: "0006_vocab_garden",
|
||||
stmt: `
|
||||
CREATE TABLE vocab_words (
|
||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
word TEXT NOT NULL,
|
||||
gloss TEXT NOT NULL DEFAULT '',
|
||||
phonetic TEXT NOT NULL DEFAULT '',
|
||||
example TEXT NOT NULL DEFAULT '',
|
||||
doc_id TEXT REFERENCES documents(id) ON DELETE SET NULL,
|
||||
due_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
interval_days INTEGER NOT NULL DEFAULT 0,
|
||||
ease REAL NOT NULL DEFAULT 2.5,
|
||||
reps INTEGER NOT NULL DEFAULT 0,
|
||||
lapses INTEGER NOT NULL DEFAULT 0,
|
||||
last_reviewed DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, word)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_vocab_due ON vocab_words(user_id, due_at);
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Vocabulary garden, follow-up. Some looked-up words have an English
|
||||
// definition but no Chinese gloss; those produced an unanswerable review
|
||||
// card (review reveals only the gloss). `definition` stores a short
|
||||
// English sense captured at lookup as a fallback "meaning" so such words
|
||||
// are still reviewable.
|
||||
name: "0007_vocab_definition",
|
||||
stmt: `
|
||||
ALTER TABLE vocab_words ADD COLUMN definition TEXT NOT NULL DEFAULT '';
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Deterministic mechanics pass. Adds a 'mechanics' suggestion family for
|
||||
// rule-based fixes (doubled words, spacing/punctuation, lowercase "i",
|
||||
// curated confusables) detected in pure Go — no LLM. As with 0005, the
|
||||
// `type` CHECK can't be ALTERed in place, so rebuild the table with the
|
||||
// extended constraint, copy every row across, and recreate the index.
|
||||
name: "0008_mechanics_suggestion_type",
|
||||
stmt: `
|
||||
CREATE TABLE suggestions_new (
|
||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
from_pos INTEGER NOT NULL,
|
||||
to_pos INTEGER NOT NULL,
|
||||
original TEXT NOT NULL,
|
||||
replacement TEXT NOT NULL,
|
||||
explanation TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('grammar','phrasing','idiom','clarity','voice','collocation','mechanics')),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','accepted','rejected')),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO suggestions_new (id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at)
|
||||
SELECT id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at FROM suggestions;
|
||||
|
||||
DROP TABLE suggestions;
|
||||
ALTER TABLE suggestions_new RENAME TO suggestions;
|
||||
CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -39,6 +39,14 @@ func TestOpenMigratesAndSeeds(t *testing.T) {
|
||||
t.Fatalf("insert voice suggestion: %v", err)
|
||||
}
|
||||
|
||||
// The collocation type (added by migration 0005's table rebuild) is permitted.
|
||||
if _, err := d.Exec(
|
||||
`INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type)
|
||||
VALUES ('d1', 0, 9, 'do a decision', 'make a decision', 'natives usually say…', 'collocation')`,
|
||||
); err != nil {
|
||||
t.Fatalf("insert collocation suggestion: %v", err)
|
||||
}
|
||||
|
||||
// An invalid type is rejected.
|
||||
if _, err := d.Exec(
|
||||
`INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type)
|
||||
|
||||
@@ -88,18 +88,20 @@ type Suggestion struct {
|
||||
Original string `json:"original"`
|
||||
Replacement string `json:"replacement"`
|
||||
Explanation string `json:"explanation"`
|
||||
Type string `json:"type"` // grammar | phrasing | idiom | clarity | voice
|
||||
Type string `json:"type"` // grammar | phrasing | idiom | clarity | voice | collocation
|
||||
Status string `json:"status"` // pending | accepted | rejected
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Suggestion type and status values, mirrored from the schema CHECK constraints.
|
||||
const (
|
||||
SuggestionTypeGrammar = "grammar"
|
||||
SuggestionTypePhrasing = "phrasing"
|
||||
SuggestionTypeIdiom = "idiom"
|
||||
SuggestionTypeClarity = "clarity"
|
||||
SuggestionTypeVoice = "voice"
|
||||
SuggestionTypeGrammar = "grammar"
|
||||
SuggestionTypePhrasing = "phrasing"
|
||||
SuggestionTypeIdiom = "idiom"
|
||||
SuggestionTypeClarity = "clarity"
|
||||
SuggestionTypeVoice = "voice"
|
||||
SuggestionTypeCollocation = "collocation"
|
||||
SuggestionTypeMechanics = "mechanics" // deterministic rule-based pass (no LLM)
|
||||
|
||||
SuggestionStatusPending = "pending"
|
||||
SuggestionStatusAccepted = "accepted"
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||
)
|
||||
|
||||
// exportRoutes registers the download endpoints: one document
|
||||
@@ -48,7 +49,7 @@ func (h *Handler) exportAll(w http.ResponseWriter, r *http.Request) {
|
||||
db.LocalUserID,
|
||||
)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
@@ -62,12 +63,12 @@ func (h *Handler) exportAll(w http.ResponseWriter, r *http.Request) {
|
||||
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
|
||||
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
|
||||
); err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
body, err := format.render(doc)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
base := sanitizeFilename(doc.Title)
|
||||
@@ -82,20 +83,20 @@ func (h *Handler) exportAll(w http.ResponseWriter, r *http.Request) {
|
||||
seen[key]++
|
||||
f, err := zw.Create(name)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if _, err := f.Write(body); err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -148,13 +149,13 @@ func (h *Handler) export(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := format.render(doc)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||
)
|
||||
|
||||
// Handler holds the dependencies shared by every document route.
|
||||
@@ -62,7 +63,7 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
|
||||
db.LocalUserID,
|
||||
)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
@@ -72,20 +73,20 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
|
||||
for rows.Next() {
|
||||
var d docSummary
|
||||
if err := rows.Scan(&d.ID, &d.Title, &d.WordCount, &d.UpdatedAt); err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
out = append(out, d)
|
||||
ids = append(ids, d.ID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
byDoc, err := h.tagsByDoc(ids)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
for i := range out {
|
||||
@@ -94,7 +95,7 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
|
||||
out[i].Tags = []db.Tag{}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
httputil.WriteJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// create inserts a fresh blank document and returns it in full.
|
||||
@@ -109,10 +110,10 @@ func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
|
||||
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, doc)
|
||||
httputil.WriteJSON(w, http.StatusCreated, doc)
|
||||
}
|
||||
|
||||
// get returns a single full document by id.
|
||||
@@ -123,10 +124,10 @@ func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, doc)
|
||||
httputil.WriteJSON(w, http.StatusOK, doc)
|
||||
}
|
||||
|
||||
// updateRequest is the auto-save payload. Every field is optional (a pointer) so
|
||||
@@ -163,7 +164,7 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
|
||||
req.Title, req.Content, req.ContentText, req.Tone, req.WordCount, id, db.LocalUserID,
|
||||
)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
@@ -173,7 +174,7 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
doc, err := h.fetch(id)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -186,7 +187,7 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, doc)
|
||||
httputil.WriteJSON(w, http.StatusOK, doc)
|
||||
}
|
||||
|
||||
// delete removes a document (suggestions cascade via the FK).
|
||||
@@ -196,7 +197,7 @@ func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
|
||||
chi.URLParam(r, "id"), db.LocalUserID,
|
||||
)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
@@ -222,21 +223,12 @@ func (h *Handler) fetch(id string) (db.Document, error) {
|
||||
}
|
||||
|
||||
// --- small response helpers -------------------------------------------------
|
||||
//
|
||||
// The generic JSON/error helpers live in internal/httputil; these are the
|
||||
// document-specific shorthands that carry domain wording.
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
func badRequest(w http.ResponseWriter, msg string) { httputil.BadRequest(w, msg) }
|
||||
func notFound(w http.ResponseWriter) {
|
||||
httputil.ErrorJSON(w, http.StatusNotFound, "document not found")
|
||||
}
|
||||
|
||||
func errorJSON(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func serverError(w http.ResponseWriter, err error) {
|
||||
errorJSON(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
func badRequest(w http.ResponseWriter, msg string) { errorJSON(w, http.StatusBadRequest, msg) }
|
||||
func notFound(w http.ResponseWriter) { errorJSON(w, http.StatusNotFound, "document not found") }
|
||||
func notFoundMsg(w http.ResponseWriter, msg string) { errorJSON(w, http.StatusNotFound, msg) }
|
||||
func notFoundMsg(w http.ResponseWriter, msg string) { httputil.ErrorJSON(w, http.StatusNotFound, msg) }
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||
)
|
||||
|
||||
// Search snippet shaping.
|
||||
@@ -57,7 +58,7 @@ func (h *Handler) SearchRoutes() chi.Router {
|
||||
func (h *Handler) search(w http.ResponseWriter, r *http.Request) {
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
if q == "" {
|
||||
writeJSON(w, http.StatusOK, []searchResult{})
|
||||
httputil.WriteJSON(w, http.StatusOK, []searchResult{})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -82,20 +83,20 @@ func (h *Handler) search(w http.ResponseWriter, r *http.Request) {
|
||||
phrase, db.LocalUserID, maxSearchResults,
|
||||
)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
defer sqlRows.Close()
|
||||
for sqlRows.Next() {
|
||||
var rw row
|
||||
if err := sqlRows.Scan(&rw.id, &rw.title, &rw.contentText, &rw.wordCount, &rw.updatedAt); err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
rows = append(rows, rw)
|
||||
}
|
||||
if err := sqlRows.Err(); err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
@@ -112,20 +113,20 @@ func (h *Handler) search(w http.ResponseWriter, r *http.Request) {
|
||||
db.LocalUserID, like, like, maxSearchResults,
|
||||
)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
defer sqlRows.Close()
|
||||
for sqlRows.Next() {
|
||||
var rw row
|
||||
if err := sqlRows.Scan(&rw.id, &rw.title, &rw.contentText, &rw.wordCount, &rw.updatedAt); err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
rows = append(rows, rw)
|
||||
}
|
||||
if err := sqlRows.Err(); err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -145,7 +146,7 @@ func (h *Handler) search(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
byDoc, err := h.tagsByDoc(ids)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
for i := range out {
|
||||
@@ -154,7 +155,7 @@ func (h *Handler) search(w http.ResponseWriter, r *http.Request) {
|
||||
out[i].Tags = []db.Tag{}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
httputil.WriteJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// escapeLike escapes the LIKE metacharacters (% and _) and the escape character
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||
)
|
||||
|
||||
// validTagColors is the palette a tag may use, mirrored from the design tokens.
|
||||
@@ -57,7 +58,7 @@ func (h *Handler) listTags(w http.ResponseWriter, r *http.Request) {
|
||||
db.LocalUserID,
|
||||
)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
@@ -66,16 +67,16 @@ func (h *Handler) listTags(w http.ResponseWriter, r *http.Request) {
|
||||
for rows.Next() {
|
||||
var t db.Tag
|
||||
if err := rows.Scan(&t.ID, &t.Name, &t.Color, &t.DocCount); err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
httputil.WriteJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
type tagRequest struct {
|
||||
@@ -106,10 +107,10 @@ func (h *Handler) createTag(w http.ResponseWriter, r *http.Request) {
|
||||
db.LocalUserID, name, normalizeColor(req.Color),
|
||||
).Scan(&t.ID, &t.Name, &t.Color)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, t)
|
||||
httputil.WriteJSON(w, http.StatusCreated, t)
|
||||
}
|
||||
|
||||
// updateTag renames and/or recolors a tag. Both fields optional via pointers so
|
||||
@@ -147,7 +148,7 @@ func (h *Handler) updateTag(w http.ResponseWriter, r *http.Request) {
|
||||
namePtr, colorPtr, id, db.LocalUserID,
|
||||
)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
@@ -160,10 +161,10 @@ func (h *Handler) updateTag(w http.ResponseWriter, r *http.Request) {
|
||||
`SELECT id, name, color FROM tags WHERE id = ? AND user_id = ?`,
|
||||
id, db.LocalUserID,
|
||||
).Scan(&t.ID, &t.Name, &t.Color); err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
httputil.WriteJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
// deleteTag removes a tag; its document assignments cascade away via the FK.
|
||||
@@ -173,7 +174,7 @@ func (h *Handler) deleteTag(w http.ResponseWriter, r *http.Request) {
|
||||
chi.URLParam(r, "id"), db.LocalUserID,
|
||||
)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
@@ -216,7 +217,7 @@ func (h *Handler) assignTag(w http.ResponseWriter, r *http.Request) {
|
||||
ON CONFLICT(doc_id, tag_id) DO NOTHING`,
|
||||
docID, req.TagID,
|
||||
); err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -235,7 +236,7 @@ func (h *Handler) unassignTag(w http.ResponseWriter, r *http.Request) {
|
||||
`DELETE FROM document_tags WHERE doc_id = ? AND tag_id = ?`,
|
||||
docID, tagID,
|
||||
); err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||
)
|
||||
|
||||
// Version-history tuning.
|
||||
@@ -29,8 +30,8 @@ const (
|
||||
// resolve to /api/docs/{id}/versions...
|
||||
func (h *Handler) versionRoutes(r chi.Router) {
|
||||
r.Get("/{id}/versions", h.listVersions)
|
||||
r.Post("/{id}/versions", h.createVersion) // explicit "save a restore point"
|
||||
r.Get("/{id}/versions/{vid}", h.getVersion) // full body for preview
|
||||
r.Post("/{id}/versions", h.createVersion) // explicit "save a restore point"
|
||||
r.Get("/{id}/versions/{vid}", h.getVersion) // full body for preview
|
||||
r.Post("/{id}/versions/{vid}/restore", h.restoreVersion)
|
||||
}
|
||||
|
||||
@@ -50,7 +51,7 @@ func (h *Handler) listVersions(w http.ResponseWriter, r *http.Request) {
|
||||
docID, db.LocalUserID,
|
||||
)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
@@ -59,16 +60,16 @@ func (h *Handler) listVersions(w http.ResponseWriter, r *http.Request) {
|
||||
for rows.Next() {
|
||||
var v db.DocumentVersion
|
||||
if err := rows.Scan(&v.ID, &v.DocID, &v.Title, &v.WordCount, &v.Kind, &v.CreatedAt); err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
httputil.WriteJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// getVersion returns one snapshot in full (including content) for preview.
|
||||
@@ -79,10 +80,10 @@ func (h *Handler) getVersion(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, v)
|
||||
httputil.WriteJSON(w, http.StatusOK, v)
|
||||
}
|
||||
|
||||
// createVersion takes an explicit, user-requested ('manual') restore point from
|
||||
@@ -96,16 +97,16 @@ func (h *Handler) createVersion(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
v, err := h.insertVersion(doc, db.VersionKindManual)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, v)
|
||||
httputil.WriteJSON(w, http.StatusCreated, v)
|
||||
}
|
||||
|
||||
// restoreVersion copies a snapshot back onto the live document. Before
|
||||
@@ -121,17 +122,17 @@ func (h *Handler) restoreVersion(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
current, err := h.fetch(docID)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if _, err := h.insertVersion(current, db.VersionKindPreRestore); err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -143,7 +144,7 @@ func (h *Handler) restoreVersion(w http.ResponseWriter, r *http.Request) {
|
||||
v.Title, v.Content, v.ContentText, v.WordCount, docID, db.LocalUserID,
|
||||
)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
@@ -153,10 +154,10 @@ func (h *Handler) restoreVersion(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
doc, err := h.fetch(docID)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, doc)
|
||||
httputil.WriteJSON(w, http.StatusOK, doc)
|
||||
}
|
||||
|
||||
// maybeAutoSnapshot records a throttled background snapshot of the just-saved
|
||||
|
||||
37
internal/httputil/json.go
Normal file
37
internal/httputil/json.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Package httputil holds the small HTTP response helpers shared by every API
|
||||
// handler package (docs, suggestions, vocab, …). Keeping them in one place means
|
||||
// JSON encoding and — crucially — error handling behave identically everywhere:
|
||||
// internal errors are logged in full but never leaked to the client.
|
||||
package httputil
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// WriteJSON encodes v as the response body with the given status code.
|
||||
func WriteJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// ErrorJSON sends a {"error": msg} body with the given status. The message is
|
||||
// caller-chosen and safe to show the client.
|
||||
func ErrorJSON(w http.ResponseWriter, status int, msg string) {
|
||||
WriteJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// BadRequest is the common 400 shorthand.
|
||||
func BadRequest(w http.ResponseWriter, msg string) {
|
||||
ErrorJSON(w, http.StatusBadRequest, msg)
|
||||
}
|
||||
|
||||
// ServerError logs the real error (with full detail, for the operator) and
|
||||
// returns a generic 500 to the client — raw database/internal errors must never
|
||||
// reach the browser, where they leak schema and implementation details.
|
||||
func ServerError(w http.ResponseWriter, err error) {
|
||||
log.Printf("internal error: %v", err)
|
||||
ErrorJSON(w, http.StatusInternalServerError, "something went wrong")
|
||||
}
|
||||
@@ -89,6 +89,14 @@ func ParseCheckpoint(raw string) ([]RawSuggestion, error) {
|
||||
if s.Original == "" {
|
||||
continue
|
||||
}
|
||||
// Drop no-ops: the model sometimes "flags" a correct sentence and echoes
|
||||
// it verbatim as the replacement, producing a card whose before/after are
|
||||
// identical and whose explanation says it's already fine — a suggestion
|
||||
// that suggests nothing. Awareness-only families (voice) carry an empty
|
||||
// replacement, so this only ever fires on the edit families.
|
||||
if s.Replacement != "" && s.Original == s.Replacement {
|
||||
continue
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
|
||||
@@ -37,6 +37,25 @@ func TestParseCheckpoint(t *testing.T) {
|
||||
raw: "I could not find any issues!",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
// A correct sentence echoed verbatim as its own replacement is a card
|
||||
// that suggests nothing — dropped. The second item is a real edit.
|
||||
name: "drops no-op where replacement equals original",
|
||||
raw: `{"suggestions":[{"original":"It wasn't the most graceful morning.","replacement":"It wasn't the most graceful morning.","explanation":"used correctly here","type":"idiom"},{"original":"teh","replacement":"the","explanation":"typo","type":"grammar"}]}`,
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
// Whitespace-only difference is still a no-op once both are trimmed.
|
||||
name: "drops no-op after whitespace trim",
|
||||
raw: `{"suggestions":[{"original":"the cat sat","replacement":" the cat sat ","explanation":"already fine","type":"grammar"}]}`,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
// Awareness-only voice flags carry an empty replacement and must survive.
|
||||
name: "keeps awareness-only flag with empty replacement",
|
||||
raw: `{"suggestions":[{"original":"a long run-on passage","replacement":"","explanation":"this drifts from your voice","type":"voice"}]}`,
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "braces inside string values",
|
||||
raw: `{"suggestions":[{"original":"use {x}","replacement":"use x","explanation":"drop the braces","type":"clarity"}]}`,
|
||||
|
||||
35
internal/llm/collocation.go
Normal file
35
internal/llm/collocation.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CollocationInterval is the minimum gap between collocation passes for one
|
||||
// document. Like the voice pass it runs on an explicit user action ("Make it
|
||||
// sound natural") rather than a typing cadence, so this floor only guards the
|
||||
// inference endpoint against the button being mashed.
|
||||
const CollocationInterval = 25 * time.Second
|
||||
|
||||
// RunCollocation sends the WHOLE document for a collocation pass — gentle
|
||||
// "natives usually say…" hints on non-native word pairings — and parses the JSON
|
||||
// result with the checkpoint's tolerant parser. It is deliberately NOT
|
||||
// TruncateDoc'd: like the voice pass it reads the whole document so the hints
|
||||
// reflect the full piece. Each flag carries a native replacement to apply.
|
||||
//
|
||||
// The tone argument is accepted for a uniform pass signature and passed through
|
||||
// to the prompt so a hint can prefer a register-appropriate pairing.
|
||||
func RunCollocation(ctx context.Context, client LLMClient, contentText, tone string) ([]RawSuggestion, error) {
|
||||
raw, err := client.Complete(ctx, CompletionRequest{
|
||||
Messages: CollocationMessages(contentText, tone),
|
||||
MaxTokens: 2048,
|
||||
Temperature: 0.3,
|
||||
RepetitionPenalty: 1.15,
|
||||
TopP: 0.9,
|
||||
Stop: []string{"```", "\n\n\n\n"},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseCheckpoint(raw)
|
||||
}
|
||||
@@ -92,6 +92,54 @@ func VoiceMessages(contentText string) []Message {
|
||||
}
|
||||
}
|
||||
|
||||
// collocationSystemPrompt drives the collocation coach — the single most
|
||||
// valuable polish for an ESL writer. It flags word PAIRINGS that are not wrong,
|
||||
// just non-native ("do a decision" → "make a decision", "strong rain" → "heavy
|
||||
// rain"), and explicitly DEFERS real grammar/spelling errors to the grammar
|
||||
// checkpoint so the two families don't overlap. Every explanation is framed as a
|
||||
// warm "natives usually say…" note with a short Mandarin gloss — never
|
||||
// "error/wrong" — because these are stylistic, not mistakes. It is a distinct
|
||||
// pass from the grammar checkpoint (do not bundle them). `replacement` carries
|
||||
// the natural pairing the writer can accept in one tap.
|
||||
const collocationSystemPrompt = `You are a warm, encouraging writing assistant helping someone who speaks English as a second language. ` +
|
||||
`You are reviewing a COMPLETE document for COLLOCATIONS only — the natural word pairings native speakers use.
|
||||
|
||||
A collocation is a pair or short group of words that native speakers habitually say together. ESL writers ` +
|
||||
`often choose words that are grammatically correct but sound non-native: "do a decision" instead of "make a decision", ` +
|
||||
`"strong rain" instead of "heavy rain", "say a joke" instead of "tell a joke". These are NOT grammar mistakes — they ` +
|
||||
`are just not what a native speaker would naturally say.
|
||||
|
||||
Identify up to 5 such non-native word pairings. For each, give the natural pairing a native speaker would use. ` +
|
||||
`Be gentle and specific. Do NOT flag grammar errors, spelling mistakes, or unclear sentences — those are handled ` +
|
||||
`elsewhere. Only flag word pairings that are correct but sound non-native.%s
|
||||
|
||||
Phrase every explanation warmly as "Natives usually say…" and include a brief Simplified Chinese gloss in parentheses. ` +
|
||||
`Never use the words "error", "wrong", or "mistake" — these are friendly polish, not corrections.
|
||||
|
||||
Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
|
||||
{
|
||||
"suggestions": [
|
||||
{
|
||||
"original": "exact word pairing from the document",
|
||||
"replacement": "the natural native pairing",
|
||||
"explanation": "friendly note, e.g. 'Natives usually say \"make a decision\" rather than \"do a decision\" (native usage / 地道说法).'",
|
||||
"type": "collocation"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
If every pairing already sounds natural, return: {"suggestions": []}`
|
||||
|
||||
// CollocationMessages builds the message array for a collocation pass over the
|
||||
// WHOLE document (no truncation), gently steered toward the document's tone so a
|
||||
// hint can prefer a register-appropriate pairing.
|
||||
func CollocationMessages(contentText, tone string) []Message {
|
||||
return []Message{
|
||||
{Role: "system", Content: fmt.Sprintf(collocationSystemPrompt, toneGuidance(tone))},
|
||||
{Role: "user", Content: contentText},
|
||||
}
|
||||
}
|
||||
|
||||
// askPetalSystemTemplate is the Ask Petal tutor prompt. The suggestion context
|
||||
// is interpolated in; the user's own messages are appended after this system
|
||||
// turn by the caller.
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||
)
|
||||
|
||||
@@ -29,7 +30,7 @@ func (h *Handler) chat(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var body chatRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
errorJSON(w, http.StatusBadRequest, "invalid request body")
|
||||
httputil.ErrorJSON(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -37,8 +38,8 @@ func (h *Handler) chat(w http.ResponseWriter, r *http.Request) {
|
||||
// scoped to the local user so a stray id can't read another user's doc.
|
||||
var (
|
||||
original, replacement, explanation, typ string
|
||||
fromPos int
|
||||
contentText string
|
||||
fromPos int
|
||||
contentText string
|
||||
)
|
||||
err := h.DB.QueryRow(
|
||||
`SELECT s.original, s.replacement, s.explanation, s.type, s.from_pos, d.content_text
|
||||
@@ -48,11 +49,11 @@ func (h *Handler) chat(w http.ResponseWriter, r *http.Request) {
|
||||
sugID, db.LocalUserID,
|
||||
).Scan(&original, &replacement, &explanation, &typ, &fromPos, &contentText)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
errorJSON(w, http.StatusNotFound, "suggestion not found")
|
||||
httputil.ErrorJSON(w, http.StatusNotFound, "suggestion not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -63,7 +64,7 @@ func (h *Handler) chat(w http.ResponseWriter, r *http.Request) {
|
||||
// Flush through; bail with a plain error if somehow they don't.
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
serverError(w, errors.New("streaming unsupported"))
|
||||
httputil.ServerError(w, errors.New("streaming unsupported"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -71,7 +72,7 @@ func (h *Handler) chat(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
// The stream never opened (e.g. LLM unreachable) — a normal JSON error is
|
||||
// still appropriate since we haven't written SSE headers yet.
|
||||
errorJSON(w, http.StatusBadGateway, "chat failed: "+err.Error())
|
||||
httputil.ErrorJSON(w, http.StatusBadGateway, "chat failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -10,12 +10,14 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||
)
|
||||
|
||||
@@ -23,19 +25,22 @@ import (
|
||||
// grammar checkpoint and the voice pass each get their own per-document rate
|
||||
// limiter — they are independent passes with different cadences.
|
||||
type Handler struct {
|
||||
DB *db.DB
|
||||
Client llm.LLMClient
|
||||
Limit *llm.RateLimiter // grammar checkpoint floor
|
||||
VoiceLimit *llm.RateLimiter // voice-consistency floor
|
||||
DB *db.DB
|
||||
Client llm.LLMClient
|
||||
Limit *llm.RateLimiter // grammar checkpoint floor
|
||||
VoiceLimit *llm.RateLimiter // voice-consistency floor
|
||||
CollocationLimit *llm.RateLimiter // collocation-coach floor
|
||||
}
|
||||
|
||||
// New constructs a Handler with per-document checkpoint and voice rate limiters.
|
||||
// New constructs a Handler with per-document checkpoint, voice, and collocation
|
||||
// rate limiters.
|
||||
func New(database *db.DB, client llm.LLMClient) *Handler {
|
||||
return &Handler{
|
||||
DB: database,
|
||||
Client: client,
|
||||
Limit: llm.NewRateLimiter(llm.CheckpointInterval),
|
||||
VoiceLimit: llm.NewRateLimiter(llm.VoiceInterval),
|
||||
DB: database,
|
||||
Client: client,
|
||||
Limit: llm.NewRateLimiter(llm.CheckpointInterval),
|
||||
VoiceLimit: llm.NewRateLimiter(llm.VoiceInterval),
|
||||
CollocationLimit: llm.NewRateLimiter(llm.CollocationInterval),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +48,9 @@ func New(database *db.DB, client llm.LLMClient) *Handler {
|
||||
// the existing /api/docs router so they share its base path.
|
||||
func (h *Handler) RegisterDocRoutes(r chi.Router) {
|
||||
r.Post("/{id}/check", h.check)
|
||||
r.Post("/{id}/mechanics", h.mechanics)
|
||||
r.Post("/{id}/voice", h.voice)
|
||||
r.Post("/{id}/collocation", h.collocation)
|
||||
r.Post("/{id}/rewrite", h.rewrite)
|
||||
r.Get("/{id}/suggestions", h.listForDoc)
|
||||
}
|
||||
@@ -60,16 +67,131 @@ func (h *Handler) Routes() chi.Router {
|
||||
}
|
||||
|
||||
// check runs a grammar checkpoint over the document. Fast, typing-cadence pass.
|
||||
// The deterministic mechanics family is owned by a separate pass (see mechanics),
|
||||
// detected client-side; a grammar checkpoint leaves those flags untouched.
|
||||
func (h *Handler) check(w http.ResponseWriter, r *http.Request) {
|
||||
h.runPass(w, r, h.Limit, llm.RunCheckpoint, grammarScope)
|
||||
}
|
||||
|
||||
// mechanicsFinding is one deterministic, rule-based fix detected client-side (see
|
||||
// web Companion/prose.ts). Detection lives in the frontend — the same rules that
|
||||
// power the companion's prose notes — so the server only persists these; it does
|
||||
// not compute them. Offsets are exact plaintext spans from the detector.
|
||||
type mechanicsFinding struct {
|
||||
From int `json:"from"`
|
||||
To int `json:"to"`
|
||||
Original string `json:"original"`
|
||||
Replacement string `json:"replacement"`
|
||||
Explanation string `json:"explanation"`
|
||||
}
|
||||
|
||||
// maxMechanicsFindings caps a single submission so a runaway client can't flood
|
||||
// the table; far above any realistic count for one document.
|
||||
const maxMechanicsFindings = 500
|
||||
|
||||
// mechanics persists the client-detected deterministic fixes as the 'mechanics'
|
||||
// family and returns the document's unified pending set. Free and not rate-
|
||||
// limited — it runs alongside the grammar checkpoint. It mirrors a single LLM
|
||||
// family: it replaces only the pending mechanics rows, honours actioned-
|
||||
// suppression, and (unlike the LLM passes) keeps the detector's exact offsets
|
||||
// rather than re-locating by string, which matters when the same word repeats.
|
||||
func (h *Handler) mechanics(w http.ResponseWriter, r *http.Request) {
|
||||
docID := chi.URLParam(r, "id")
|
||||
|
||||
// Confirm the document exists (and is the local user's) for clean 404s.
|
||||
var exists bool
|
||||
err := h.DB.QueryRow(
|
||||
`SELECT EXISTS(SELECT 1 FROM documents WHERE id = ? AND user_id = ?)`,
|
||||
docID, db.LocalUserID,
|
||||
).Scan(&exists)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
httputil.ErrorJSON(w, http.StatusNotFound, "document not found")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Findings []mechanicsFinding `json:"findings"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
|
||||
httputil.BadRequest(w, "invalid request body")
|
||||
return
|
||||
}
|
||||
if len(body.Findings) > maxMechanicsFindings {
|
||||
body.Findings = body.Findings[:maxMechanicsFindings]
|
||||
}
|
||||
|
||||
if err := h.replaceMechanics(docID, body.Findings); err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
out, err := h.fetchPending(docID)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httputil.WriteJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// replaceMechanics swaps the document's pending mechanics rows for the supplied
|
||||
// findings in one transaction, leaving the LLM families and actioned rows
|
||||
// untouched. Findings the user already accepted or dismissed are suppressed (the
|
||||
// detector has no memory between runs), and malformed spans are skipped.
|
||||
func (h *Handler) replaceMechanics(docID string, findings []mechanicsFinding) error {
|
||||
tx, err := h.DB.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.Exec(
|
||||
`DELETE FROM suggestions WHERE doc_id = ? AND status = ? AND type = ?`,
|
||||
docID, db.SuggestionStatusPending, db.SuggestionTypeMechanics,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sup, err := buildSuppressor(tx, docID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, f := range findings {
|
||||
if f.From < 0 || f.To <= f.From || strings.TrimSpace(f.Original) == "" {
|
||||
continue // malformed span — the client re-anchors by string anyway
|
||||
}
|
||||
if sup.suppressed(f.Original, f.Replacement) {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
docID, f.From, f.To, f.Original, f.Replacement, f.Explanation, db.SuggestionTypeMechanics,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// voice runs a Tier-1 voice-consistency pass over the whole document. Slow,
|
||||
// explicit-action pass; replaces only the pending voice flags.
|
||||
func (h *Handler) voice(w http.ResponseWriter, r *http.Request) {
|
||||
h.runPass(w, r, h.VoiceLimit, llm.RunVoice, voiceScope)
|
||||
}
|
||||
|
||||
// collocation runs the collocation coach over the whole document, flagging
|
||||
// non-native word pairings. Explicit-action pass; replaces only the pending
|
||||
// collocation flags.
|
||||
func (h *Handler) collocation(w http.ResponseWriter, r *http.Request) {
|
||||
h.runPass(w, r, h.CollocationLimit, llm.RunCollocation, collocationScope)
|
||||
}
|
||||
|
||||
// pass is the signature shared by the grammar checkpoint and the voice pass:
|
||||
// given the document text and the document's tone it returns the model's raw
|
||||
// suggestions. The voice pass ignores tone (see llm.RunVoice).
|
||||
@@ -88,17 +210,17 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
|
||||
docID, db.LocalUserID,
|
||||
).Scan(&contentText, &tone)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
errorJSON(w, http.StatusNotFound, "document not found")
|
||||
httputil.ErrorJSON(w, http.StatusNotFound, "document not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Nothing to analyze on an empty document — skip the LLM round-trip.
|
||||
if strings.TrimSpace(contentText) == "" {
|
||||
writeJSON(w, http.StatusOK, []db.Suggestion{})
|
||||
httputil.WriteJSON(w, http.StatusOK, []db.Suggestion{})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -108,10 +230,10 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
|
||||
// error, so the frontend keeps showing current suggestions.
|
||||
existing, err := h.fetchPending(docID)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, existing)
|
||||
httputil.WriteJSON(w, http.StatusOK, existing)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -121,12 +243,12 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
|
||||
// the per-document slot for the full interval — stranding the frontend's
|
||||
// auto-retry on the throttle path. Release it so a retry can re-run.
|
||||
limiter.Release(docID, slotAt)
|
||||
errorJSON(w, http.StatusBadGateway, "llm pass failed: "+err.Error())
|
||||
httputil.ErrorJSON(w, http.StatusBadGateway, "llm pass failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.replacePending(docID, contentText, raw, scope); err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -135,10 +257,10 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
|
||||
// throttle path above stays consistent with the success path.
|
||||
out, err := h.fetchPending(docID)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
httputil.WriteJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// pendingScope describes how one LLM pass touches the shared suggestions table:
|
||||
@@ -151,23 +273,28 @@ type pendingScope struct {
|
||||
}
|
||||
|
||||
var (
|
||||
// grammarScope owns the grammar/phrasing/idiom/clarity flags (everything but voice).
|
||||
grammarScope = pendingScope{deleteWhere: "type != 'voice'", forceType: ""}
|
||||
// grammarScope owns the grammar/phrasing/idiom/clarity flags — everything but
|
||||
// the other self-owned families (voice, collocation, mechanics), which run on
|
||||
// their own cadence/pass and must survive a grammar checkpoint. Notably the
|
||||
// deterministic mechanics pass writes its rows in the same /check request just
|
||||
// before this DELETE runs, so excluding it here is what keeps them alive.
|
||||
grammarScope = pendingScope{deleteWhere: "type NOT IN ('voice','collocation','mechanics')", forceType: ""}
|
||||
// voiceScope owns the voice flags only.
|
||||
voiceScope = pendingScope{deleteWhere: "type = 'voice'", forceType: db.SuggestionTypeVoice}
|
||||
// collocationScope owns the collocation flags only.
|
||||
collocationScope = pendingScope{deleteWhere: "type = 'collocation'", forceType: db.SuggestionTypeCollocation}
|
||||
)
|
||||
|
||||
// replacePending swaps a document's pending suggestions within one family for a
|
||||
// fresh batch in a single transaction. Accepted/rejected suggestions and the
|
||||
// other family's pending rows are left untouched.
|
||||
//
|
||||
// Suggestions the user already accepted or dismissed are suppressed from the
|
||||
// fresh batch: the model has no memory between passes, so without this it would
|
||||
// re-propose the identical edit on the very next checkpoint — re-nagging a
|
||||
// sentence the user already resolved. This matters most when an accept silently
|
||||
// no-ops (the `original` text couldn't be anchored in the editor, so the doc
|
||||
// never changed): the sentence is unaltered, yet the user shouldn't see the same
|
||||
// card again.
|
||||
// Suggestions touching a sentence the user already settled are suppressed from
|
||||
// the fresh batch (see suppressor): not just the identical edit re-proposed, but
|
||||
// reversals and re-polishing of the model's own just-accepted output — the
|
||||
// "fickle, keeps going back and forth on a few sentences" behavior. The model has
|
||||
// no memory between passes, so without this it re-opens resolved sentences every
|
||||
// checkpoint.
|
||||
func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggestion, scope pendingScope) error {
|
||||
tx, err := h.DB.Begin()
|
||||
if err != nil {
|
||||
@@ -182,13 +309,13 @@ func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggest
|
||||
return err
|
||||
}
|
||||
|
||||
actioned, err := actionedKeys(tx, docID)
|
||||
sup, err := buildSuppressor(tx, docID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, s := range raw {
|
||||
if _, seen := actioned[suggestionKey(s.Original, s.Replacement)]; seen {
|
||||
if sup.suppressed(s.Original, s.Replacement) {
|
||||
continue
|
||||
}
|
||||
typ := scope.forceType
|
||||
@@ -208,37 +335,131 @@ func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggest
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// actionedKeys returns the set of original→replacement keys the user has already
|
||||
// accepted or rejected for this document, so a fresh checkpoint can skip
|
||||
// re-proposing them. Keyed on the trimmed original+replacement pair, matching the
|
||||
// normalization ParseCheckpoint applies, so a genuinely different edit on the same
|
||||
// sentence is not suppressed.
|
||||
func actionedKeys(tx *sql.Tx, docID string) (map[string]struct{}, error) {
|
||||
// dedupQuoteReplacer folds every straight/curly single- and double-quote variant
|
||||
// (and backtick/acute accent) onto one canonical character. The editor and the
|
||||
// model both rewrite quotes between passes — a sentence accepted with "…" comes
|
||||
// back flagged with '…' — so without folding, byte-identical text reads as a
|
||||
// different edit and the suppression below misses it. (This normalization is for
|
||||
// dedup ONLY; the frontend still anchors on the verbatim `original`.)
|
||||
var dedupQuoteReplacer = strings.NewReplacer(
|
||||
"‘", "'", "’", "'", "‚", "'", "‛", "'", // single curly
|
||||
"“", "'", "”", "'", "„", "'", "″", "'", // double curly
|
||||
"\"", "'", "`", "'", "´", "'", // straight double, backtick, acute
|
||||
)
|
||||
|
||||
// normalizeForDedup canonicalizes a string for suppression comparisons: quotes
|
||||
// folded (above) and runs of whitespace collapsed to single spaces (so a reflowed
|
||||
// paragraph still matches). Used only to decide what to suppress, never to alter
|
||||
// stored or rendered text.
|
||||
func normalizeForDedup(s string) string {
|
||||
return strings.Join(strings.Fields(dedupQuoteReplacer.Replace(s)), " ")
|
||||
}
|
||||
|
||||
// suppressor decides which fresh suggestions to drop because the user has already
|
||||
// settled the sentence they touch. The model has no memory between passes, so on
|
||||
// every checkpoint it re-examines the current text and proposes edits — including
|
||||
// ones that re-open a sentence the user already resolved. Three families of those
|
||||
// are suppressed (all compared under normalizeForDedup):
|
||||
//
|
||||
// - pairs: the identical edit, re-proposed verbatim (the original "accept it,
|
||||
// then it nags again" case; also covers a silent no-op accept that left the
|
||||
// text unchanged).
|
||||
// - actionedOrig: any edit whose original is a span the user already accepted or
|
||||
// dismissed an edit on — "you already decided about this exact sentence."
|
||||
// - acceptedRepl: any edit whose original is text the user accepted AS a
|
||||
// replacement — i.e. the model re-touching its own just-accepted output, which
|
||||
// is how the reversals and endless re-polishing arise (accept "due to the
|
||||
// rain", next pass proposes changing "due to the rain" back). Guarded to
|
||||
// multi-word spans so word-level fixes aren't swept up as collateral.
|
||||
// - acceptedReplList holds the same accepted replacements for a containment
|
||||
// check: the model evades the exact acceptedRepl match by re-flagging a
|
||||
// *sub-clause* of an accepted sentence (flag "she was…due to the rain" instead
|
||||
// of the whole sentence). When one of the new original / an accepted
|
||||
// replacement contains the other and the shorter side is substantial
|
||||
// (>= minContainWords words), it's the same settled span and is dropped.
|
||||
type suppressor struct {
|
||||
pairs map[string]struct{}
|
||||
actionedOrig map[string]struct{}
|
||||
acceptedRepl map[string]struct{}
|
||||
acceptedReplList []string
|
||||
}
|
||||
|
||||
// minContainWords is the floor for the containment check: the shorter of the two
|
||||
// spans must be at least this many words before a substring relationship counts
|
||||
// as "the same settled text." High enough that an incidental common phrase ("the
|
||||
// rain") can't suppress an unrelated sentence, low enough to catch a re-flagged
|
||||
// clause.
|
||||
const minContainWords = 4
|
||||
|
||||
// suppressed reports whether a fresh suggestion should be dropped as already
|
||||
// settled. An empty original is never suppressed here (it can't anchor anyway and
|
||||
// is dropped upstream).
|
||||
func (s suppressor) suppressed(original, replacement string) bool {
|
||||
o := normalizeForDedup(original)
|
||||
if o == "" {
|
||||
return false
|
||||
}
|
||||
if _, ok := s.pairs[o+"\x00"+normalizeForDedup(replacement)]; ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := s.actionedOrig[o]; ok {
|
||||
return true
|
||||
}
|
||||
if strings.ContainsRune(o, ' ') {
|
||||
if _, ok := s.acceptedRepl[o]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Containment: the model re-flagged a sub-clause of (or a window around) an
|
||||
// accepted span. Suppress when one contains the other and the shorter side is
|
||||
// a substantial multi-word run.
|
||||
for _, r := range s.acceptedReplList {
|
||||
shorter, longer := o, r
|
||||
if len(r) < len(o) {
|
||||
shorter, longer = r, o
|
||||
}
|
||||
if len(strings.Fields(shorter)) >= minContainWords && strings.Contains(longer, shorter) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// buildSuppressor loads the document's accepted/rejected edits and indexes them
|
||||
// into the three suppression families described on suppressor.
|
||||
func buildSuppressor(tx *sql.Tx, docID string) (suppressor, error) {
|
||||
rows, err := tx.Query(
|
||||
`SELECT original, replacement FROM suggestions
|
||||
`SELECT original, replacement, status FROM suggestions
|
||||
WHERE doc_id = ? AND status IN (?, ?)`,
|
||||
docID, db.SuggestionStatusAccepted, db.SuggestionStatusRejected,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return suppressor{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
keys := make(map[string]struct{})
|
||||
for rows.Next() {
|
||||
var original, replacement string
|
||||
if err := rows.Scan(&original, &replacement); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys[suggestionKey(original, replacement)] = struct{}{}
|
||||
s := suppressor{
|
||||
pairs: make(map[string]struct{}),
|
||||
actionedOrig: make(map[string]struct{}),
|
||||
acceptedRepl: make(map[string]struct{}),
|
||||
}
|
||||
return keys, rows.Err()
|
||||
}
|
||||
|
||||
// suggestionKey is the dedup identity for a suggestion: its trimmed original and
|
||||
// replacement text. Two suggestions with the same key are "the same edit."
|
||||
func suggestionKey(original, replacement string) string {
|
||||
return strings.TrimSpace(original) + "\x00" + strings.TrimSpace(replacement)
|
||||
for rows.Next() {
|
||||
var original, replacement, status string
|
||||
if err := rows.Scan(&original, &replacement, &status); err != nil {
|
||||
return suppressor{}, err
|
||||
}
|
||||
o := normalizeForDedup(original)
|
||||
r := normalizeForDedup(replacement)
|
||||
s.pairs[o+"\x00"+r] = struct{}{}
|
||||
if o != "" {
|
||||
s.actionedOrig[o] = struct{}{}
|
||||
}
|
||||
if status == db.SuggestionStatusAccepted && r != "" {
|
||||
s.acceptedRepl[r] = struct{}{}
|
||||
s.acceptedReplList = append(s.acceptedReplList, r)
|
||||
}
|
||||
}
|
||||
return s, rows.Err()
|
||||
}
|
||||
|
||||
// listForDoc returns the document's current pending suggestions (used when the
|
||||
@@ -246,10 +467,10 @@ func suggestionKey(original, replacement string) string {
|
||||
func (h *Handler) listForDoc(w http.ResponseWriter, r *http.Request) {
|
||||
out, err := h.fetchPending(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
httputil.WriteJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *Handler) fetchPending(docID string) ([]db.Suggestion, error) {
|
||||
@@ -276,7 +497,51 @@ func (h *Handler) fetchPending(docID string) ([]db.Suggestion, error) {
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dedupeSpans(out), nil
|
||||
}
|
||||
|
||||
// dedupeSpans resolves collisions between the deterministic mechanics family and
|
||||
// the LLM families: when a mechanics finding and an LLM suggestion fight over the
|
||||
// same characters, mechanics wins and the LLM card is dropped. Its span is exact
|
||||
// (the detector matched it), whereas the LLM positions are only advisory
|
||||
// (re-anchored by string at render), so the precise fix should own the span.
|
||||
//
|
||||
// This deliberately does NOT dedupe LLM-vs-LLM overlaps: voice (awareness-only,
|
||||
// no replacement) and collocation legitimately co-occupy the same span, and that
|
||||
// is intended. Suggestions that never anchored (from_pos < 0) occupy no real span
|
||||
// and are always kept.
|
||||
func dedupeSpans(in []db.Suggestion) []db.Suggestion {
|
||||
type span struct{ from, to int }
|
||||
var claimed []span
|
||||
for _, s := range in {
|
||||
if s.Type == db.SuggestionTypeMechanics && s.FromPos >= 0 {
|
||||
claimed = append(claimed, span{s.FromPos, s.ToPos})
|
||||
}
|
||||
}
|
||||
if len(claimed) == 0 {
|
||||
return in
|
||||
}
|
||||
|
||||
out := make([]db.Suggestion, 0, len(in))
|
||||
for _, s := range in {
|
||||
if s.Type != db.SuggestionTypeMechanics && s.FromPos >= 0 {
|
||||
overlaps := false
|
||||
for _, sp := range claimed {
|
||||
if s.FromPos < sp.to && sp.from < s.ToPos {
|
||||
overlaps = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if overlaps {
|
||||
continue // an exact mechanics fix owns these characters
|
||||
}
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// accept marks a suggestion accepted (the client applies the replacement text).
|
||||
@@ -295,11 +560,11 @@ func (h *Handler) setStatus(w http.ResponseWriter, r *http.Request, status strin
|
||||
status, chi.URLParam(r, "id"), db.SuggestionStatusPending,
|
||||
)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
errorJSON(w, http.StatusNotFound, "pending suggestion not found")
|
||||
httputil.ErrorJSON(w, http.StatusNotFound, "pending suggestion not found")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -320,25 +585,9 @@ func locate(contentText, original string) (int, int) {
|
||||
// defaulting unknown values to grammar so a stray label never trips the CHECK.
|
||||
func normalizeType(t string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(t)) {
|
||||
case db.SuggestionTypeGrammar, db.SuggestionTypePhrasing, db.SuggestionTypeIdiom, db.SuggestionTypeClarity:
|
||||
case db.SuggestionTypeGrammar, db.SuggestionTypePhrasing, db.SuggestionTypeIdiom, db.SuggestionTypeClarity, db.SuggestionTypeCollocation:
|
||||
return strings.ToLower(strings.TrimSpace(t))
|
||||
default:
|
||||
return db.SuggestionTypeGrammar
|
||||
}
|
||||
}
|
||||
|
||||
// --- response helpers -------------------------------------------------------
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func errorJSON(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func serverError(w http.ResponseWriter, err error) {
|
||||
errorJSON(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
@@ -165,6 +165,48 @@ func TestResolvedSuggestionsNotReproposed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFickleEditsSuppressed proves the suppressor kills the "keeps going back and
|
||||
// forth on a few sentences" behavior seen live on the Missing Key doc: a later
|
||||
// pass that (a) reverses an edit the user just accepted — even with the editor's
|
||||
// double→single quote churn that defeats a byte-exact match — or (b) re-polishes
|
||||
// the model's own accepted output. A genuinely new edit on a fresh sentence still
|
||||
// survives.
|
||||
func TestFickleEditsSuppressed(t *testing.T) {
|
||||
client := &stubClient{response: `{"suggestions":[
|
||||
{"original":"He left \"early,\" because of the rain.","replacement":"He left \"early,\" due to the rain.","explanation":"smoother","type":"phrasing"},
|
||||
{"original":"The cat always have a calm face.","replacement":"The cat always has a calm face.","explanation":"agreement","type":"grammar"}
|
||||
]}`}
|
||||
srv, docID, h := newTestServer(t, client)
|
||||
h.Limit = llm.NewRateLimiter(0)
|
||||
|
||||
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||
var got []db.Suggestion
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("first pass: want 2, got %d", len(got))
|
||||
}
|
||||
for _, s := range got {
|
||||
do(t, srv, http.MethodPost, "/suggestions/"+s.ID+"/accept", "")
|
||||
}
|
||||
|
||||
// Reversal of the first accept (note the " → ' quote churn) and a re-polish of
|
||||
// the second accept must both be dropped; only the unrelated edit survives.
|
||||
client.response = `{"suggestions":[
|
||||
{"original":"He left 'early,' due to the rain.","replacement":"He left 'early,' because of the rain.","explanation":"reverts the accepted edit","type":"phrasing"},
|
||||
{"original":"The cat always has a calm face.","replacement":"The cat always seems to have a calm face.","explanation":"re-polishes accepted output","type":"phrasing"},
|
||||
{"original":"due to the rain","replacement":"because of the rain","explanation":"sub-clause reversal of the accepted span","type":"phrasing"},
|
||||
{"original":"She drived home.","replacement":"She drove home.","explanation":"past tense","type":"grammar"}
|
||||
]}`
|
||||
rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||
var again []db.Suggestion
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &again); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(again) != 1 || again[0].Original != "She drived home." {
|
||||
t.Fatalf("want only the new edit to survive, got %d: %+v", len(again), again)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoicePassCoexists proves the voice pass and the grammar checkpoint own
|
||||
// disjoint pending families: running one never wipes the other's flags, and
|
||||
// each endpoint returns the unified pending set.
|
||||
@@ -227,6 +269,65 @@ func TestVoicePassCoexists(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollocationPassCoexists proves the collocation coach is a third
|
||||
// independent family: it never wipes grammar or voice pending flags, a grammar
|
||||
// checkpoint never wipes its flags, and each endpoint returns the unified set.
|
||||
func TestCollocationPassCoexists(t *testing.T) {
|
||||
client := &switchClient{}
|
||||
srv, docID, h := newTestServer(t, client)
|
||||
// Zero the floors so the test can re-run passes without waiting them out.
|
||||
h.Limit = llm.NewRateLimiter(0)
|
||||
h.VoiceLimit = llm.NewRateLimiter(0)
|
||||
h.CollocationLimit = llm.NewRateLimiter(0)
|
||||
|
||||
// Grammar + voice first, so all three families are exercised.
|
||||
client.response = `{"suggestions":[{"original":"I has","replacement":"I have","explanation":"agreement","type":"grammar"}]}`
|
||||
if rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("check: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
client.response = `{"suggestions":[{"original":"two apple","replacement":null,"explanation":"sounds formal","type":"voice"}]}`
|
||||
if rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/voice", ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("voice: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
// Collocation pass → a third flag (with a replacement). It must keep the
|
||||
// grammar and voice flags; the response is the unified set of all three.
|
||||
client.response = `{"suggestions":[{"original":"two apple","replacement":"two apples","explanation":"Natives usually say…","type":"collocation"}]}`
|
||||
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/collocation", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("collocation: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
var got []db.Suggestion
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
byType := map[string]bool{}
|
||||
for _, s := range got {
|
||||
byType[s.Type] = true
|
||||
}
|
||||
if !byType[db.SuggestionTypeGrammar] || !byType[db.SuggestionTypeVoice] || !byType[db.SuggestionTypeCollocation] {
|
||||
t.Fatalf("collocation response should carry all three families, got %+v", got)
|
||||
}
|
||||
|
||||
// A grammar checkpoint must NOT wipe the voice or collocation flags.
|
||||
client.response = `{"suggestions":[]}`
|
||||
rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("second check: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
byType = map[string]bool{}
|
||||
for _, s := range got {
|
||||
byType[s.Type] = true
|
||||
}
|
||||
if byType[db.SuggestionTypeGrammar] {
|
||||
t.Fatalf("grammar flag should have cleared, got %+v", got)
|
||||
}
|
||||
if !byType[db.SuggestionTypeVoice] || !byType[db.SuggestionTypeCollocation] {
|
||||
t.Fatalf("grammar checkpoint wiped a sibling family, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// switchClient returns a response that can be swapped between calls.
|
||||
type switchClient struct {
|
||||
response string
|
||||
@@ -257,3 +358,116 @@ func TestCheckpointRateLimit(t *testing.T) {
|
||||
t.Fatalf("rate limit should suppress the second model call, got %d calls", client.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// postMechanics submits a deterministic-findings batch (as the client's prose
|
||||
// detector would) and returns the unified pending set.
|
||||
func postMechanics(t *testing.T, srv http.Handler, docID, findingsJSON string) []db.Suggestion {
|
||||
t.Helper()
|
||||
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/mechanics", `{"findings":`+findingsJSON+`}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("mechanics: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
var got []db.Suggestion
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
// TestMechanicsPersistsFindings proves the endpoint persists client-detected
|
||||
// findings as a 'mechanics' family with the exact offsets the client supplied,
|
||||
// and that a later grammar checkpoint leaves them untouched (own family).
|
||||
func TestMechanicsPersistsFindings(t *testing.T) {
|
||||
client := &stubClient{response: `{"suggestions":[]}`}
|
||||
srv, docID, _ := newTestServer(t, client)
|
||||
|
||||
got := postMechanics(t, srv, docID, `[
|
||||
{"from":0,"to":1,"original":"i","replacement":"I","explanation":"capitalize I"},
|
||||
{"from":6,"to":13,"original":"the the","replacement":"the","explanation":"doubled word"}
|
||||
]`)
|
||||
var mech []db.Suggestion
|
||||
for _, s := range got {
|
||||
if s.Type == db.SuggestionTypeMechanics {
|
||||
mech = append(mech, s)
|
||||
}
|
||||
}
|
||||
if len(mech) != 2 {
|
||||
t.Fatalf("want 2 mechanics findings, got %d: %+v", len(mech), got)
|
||||
}
|
||||
// The client's exact offsets are preserved verbatim.
|
||||
for _, s := range mech {
|
||||
if s.Original == "the the" && (s.FromPos != 6 || s.ToPos != 13) {
|
||||
t.Errorf("offsets not preserved: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// A grammar checkpoint must not wipe the mechanics family.
|
||||
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("check: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
var after []db.Suggestion
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &after)
|
||||
count := 0
|
||||
for _, s := range after {
|
||||
if s.Type == db.SuggestionTypeMechanics {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("grammar checkpoint disturbed the mechanics family: got %d, want 2", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMechanicsWinsSpanCollision proves that when a mechanics finding and an LLM
|
||||
// grammar suggestion claim overlapping characters, the LLM card is dropped from
|
||||
// the response and the exact mechanics fix owns the span.
|
||||
func TestMechanicsWinsSpanCollision(t *testing.T) {
|
||||
// LLM grammar suggestion covers "the the cat", overlapping the mechanics
|
||||
// "the the" finding at [0,7].
|
||||
client := &stubClient{response: `{"suggestions":[
|
||||
{"original":"the the cat","replacement":"the cat","explanation":"wordy","type":"grammar"}
|
||||
]}`}
|
||||
srv, docID, h := newTestServer(t, client)
|
||||
if _, err := h.DB.Exec(`UPDATE documents SET content_text = ? WHERE id = ?`, "the the cat sat", docID); err != nil {
|
||||
t.Fatalf("set content: %v", err)
|
||||
}
|
||||
|
||||
// Persist the mechanics finding, then run the grammar pass.
|
||||
postMechanics(t, srv, docID, `[{"from":0,"to":7,"original":"the the","replacement":"the","explanation":"doubled word"}]`)
|
||||
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("check: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
var got []db.Suggestion
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
for _, s := range got {
|
||||
if s.Type == db.SuggestionTypeGrammar {
|
||||
t.Fatalf("overlapping grammar card should have been dropped in favor of mechanics, got %+v", got)
|
||||
}
|
||||
}
|
||||
if len(got) != 1 || got[0].Type != db.SuggestionTypeMechanics {
|
||||
t.Fatalf("want sole mechanics finding, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMechanicsActionedSuppression proves a dismissed mechanics fix is not
|
||||
// re-proposed on the next submission of the same finding.
|
||||
func TestMechanicsActionedSuppression(t *testing.T) {
|
||||
client := &stubClient{response: `{"suggestions":[]}`}
|
||||
srv, docID, _ := newTestServer(t, client)
|
||||
|
||||
findings := `[{"from":6,"to":13,"original":"the the","replacement":"the","explanation":"doubled word"}]`
|
||||
got := postMechanics(t, srv, docID, findings)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("want 1 finding, got %+v", got)
|
||||
}
|
||||
// Dismiss it, then resubmit the same finding — it must stay suppressed.
|
||||
do(t, srv, http.MethodPost, "/suggestions/"+got[0].ID+"/dismiss", "")
|
||||
again := postMechanics(t, srv, docID, findings)
|
||||
if len(again) != 0 {
|
||||
t.Fatalf("dismissed mechanics fix should not reappear, got %+v", again)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||
)
|
||||
|
||||
@@ -36,17 +37,17 @@ func (h *Handler) rewrite(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var body rewriteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
errorJSON(w, http.StatusBadRequest, "invalid request body")
|
||||
httputil.ErrorJSON(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
text := strings.TrimSpace(body.Text)
|
||||
if text == "" {
|
||||
errorJSON(w, http.StatusBadRequest, "no text to rewrite")
|
||||
httputil.ErrorJSON(w, http.StatusBadRequest, "no text to rewrite")
|
||||
return
|
||||
}
|
||||
if len([]rune(text)) > llm.RewriteMaxRunes {
|
||||
errorJSON(w, http.StatusBadRequest, "selection too long to rewrite")
|
||||
httputil.ErrorJSON(w, http.StatusBadRequest, "selection too long to rewrite")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -58,19 +59,19 @@ func (h *Handler) rewrite(w http.ResponseWriter, r *http.Request) {
|
||||
docID, db.LocalUserID,
|
||||
).Scan(&exists)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
errorJSON(w, http.StatusNotFound, "document not found")
|
||||
httputil.ErrorJSON(w, http.StatusNotFound, "document not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.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())
|
||||
httputil.ErrorJSON(w, http.StatusBadGateway, "rewrite failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, rewriteResponse{Rewrite: out})
|
||||
httputil.WriteJSON(w, http.StatusOK, rewriteResponse{Rewrite: out})
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||
)
|
||||
|
||||
@@ -33,25 +34,25 @@ func (h *Handler) translate(w http.ResponseWriter, r *http.Request) {
|
||||
sugID, db.LocalUserID,
|
||||
).Scan(&explanation)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
errorJSON(w, http.StatusNotFound, "suggestion not found")
|
||||
httputil.ErrorJSON(w, http.StatusNotFound, "suggestion not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
explanation = strings.TrimSpace(explanation)
|
||||
if explanation == "" {
|
||||
writeJSON(w, http.StatusOK, translateResponse{Translation: ""})
|
||||
httputil.WriteJSON(w, http.StatusOK, translateResponse{Translation: ""})
|
||||
return
|
||||
}
|
||||
|
||||
out, err := llm.RunTranslate(r.Context(), h.Client, explanation)
|
||||
if err != nil {
|
||||
errorJSON(w, http.StatusBadGateway, "translate failed: "+err.Error())
|
||||
httputil.ErrorJSON(w, http.StatusBadGateway, "translate failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, translateResponse{Translation: out})
|
||||
httputil.WriteJSON(w, http.StatusOK, translateResponse{Translation: out})
|
||||
}
|
||||
|
||||
307
internal/vocab/handlers.go
Normal file
307
internal/vocab/handlers.go
Normal file
@@ -0,0 +1,307 @@
|
||||
package vocab
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||
)
|
||||
|
||||
// Word is one entry in the vocabulary garden: the looked-up word with its gloss,
|
||||
// phonetic, and the sentence it was met in, plus its spaced-repetition state.
|
||||
type Word struct {
|
||||
ID string `json:"id"`
|
||||
Word string `json:"word"`
|
||||
Gloss string `json:"gloss"`
|
||||
Definition string `json:"definition"` // English fallback meaning when there's no Chinese gloss
|
||||
Phonetic string `json:"phonetic"`
|
||||
Example string `json:"example"`
|
||||
DocID *string `json:"doc_id"`
|
||||
DueAt time.Time `json:"due_at"`
|
||||
IntervalDays int `json:"interval_days"`
|
||||
Ease float64 `json:"ease"`
|
||||
Reps int `json:"reps"`
|
||||
Lapses int `json:"lapses"`
|
||||
LastReviewed *time.Time `json:"last_reviewed"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Handler owns the vocabulary-garden routes. Everything is scoped to the local
|
||||
// user while auth is deferred.
|
||||
type Handler struct {
|
||||
DB *db.DB
|
||||
}
|
||||
|
||||
func New(database *db.DB) *Handler { return &Handler{DB: database} }
|
||||
|
||||
// Routes mounts the garden endpoints under /api/vocab.
|
||||
func (h *Handler) Routes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/", h.list) // the whole garden
|
||||
r.Get("/due", h.due) // only the cards due for review now
|
||||
r.Post("/", h.capture) // record/upsert a looked-up word
|
||||
r.Post("/{id}/review", h.review)
|
||||
r.Delete("/{id}", h.remove)
|
||||
return r
|
||||
}
|
||||
|
||||
const vocabColumns = `id, word, gloss, definition, phonetic, example, doc_id,
|
||||
due_at, interval_days, ease, reps, lapses, last_reviewed, created_at`
|
||||
|
||||
func scanWord(s interface {
|
||||
Scan(dest ...any) error
|
||||
}) (Word, error) {
|
||||
var w Word
|
||||
err := s.Scan(
|
||||
&w.ID, &w.Word, &w.Gloss, &w.Definition, &w.Phonetic, &w.Example, &w.DocID,
|
||||
&w.DueAt, &w.IntervalDays, &w.Ease, &w.Reps, &w.Lapses, &w.LastReviewed, &w.CreatedAt,
|
||||
)
|
||||
return w, err
|
||||
}
|
||||
|
||||
// list returns the full garden, newest blossoms first.
|
||||
func (h *Handler) list(w http.ResponseWriter, _ *http.Request) {
|
||||
h.queryList(w, `SELECT `+vocabColumns+` FROM vocab_words
|
||||
WHERE user_id = ? ORDER BY created_at DESC`, db.LocalUserID)
|
||||
}
|
||||
|
||||
// due returns only the cards whose review time has arrived, soonest first.
|
||||
func (h *Handler) due(w http.ResponseWriter, _ *http.Request) {
|
||||
h.queryList(w, `SELECT `+vocabColumns+` FROM vocab_words
|
||||
WHERE user_id = ? AND due_at <= datetime('now') ORDER BY due_at ASC`, db.LocalUserID)
|
||||
}
|
||||
|
||||
func (h *Handler) queryList(w http.ResponseWriter, query string, args ...any) {
|
||||
rows, err := h.DB.Query(query, args...)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Word{}
|
||||
for rows.Next() {
|
||||
word, err := scanWord(rows)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
out = append(out, word)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httputil.WriteJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
type captureRequest struct {
|
||||
Word string `json:"word"`
|
||||
Gloss string `json:"gloss"`
|
||||
Definition string `json:"definition"`
|
||||
Phonetic string `json:"phonetic"`
|
||||
Example string `json:"example"`
|
||||
DocID *string `json:"doc_id"`
|
||||
}
|
||||
|
||||
// Field-length caps. The captured fields come from the offline lexicon and the
|
||||
// editor selection, not free-typed prose, so we clamp rather than reject — a
|
||||
// lookup should never fail because a definition ran long. Counts are runes so a
|
||||
// Chinese gloss isn't cut mid-character. The word is the upsert key, so an absurd
|
||||
// "word" is bounded too.
|
||||
const (
|
||||
maxWordLen = 128
|
||||
maxGlossLen = 512
|
||||
maxDefinitionLen = 4096
|
||||
maxPhoneticLen = 256
|
||||
maxExampleLen = 4096
|
||||
)
|
||||
|
||||
// clamp trims s to at most max runes (rune-safe so multibyte glosses survive).
|
||||
func clamp(s string, max int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
return string(r[:max])
|
||||
}
|
||||
|
||||
// capture records a looked-up word. It's an idempotent upsert keyed on the word:
|
||||
// a new word lands due tomorrow (interval 1 day); an existing word keeps its
|
||||
// schedule untouched but refreshes its gloss/phonetic/example/doc_id so the most
|
||||
// recent context wins. Looking words up IS the data source — no extra effort.
|
||||
func (h *Handler) capture(w http.ResponseWriter, r *http.Request) {
|
||||
var req captureRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httputil.ErrorJSON(w, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
// Normalize the same way the lexicon does (internal/lexicon: lower+trim) so
|
||||
// the UNIQUE(user_id, word) upsert is genuinely idempotent — otherwise
|
||||
// "Apple" at a sentence start and "apple" mid-line would create two separate
|
||||
// cards on independent schedules.
|
||||
word := clamp(strings.ToLower(strings.TrimSpace(req.Word)), maxWordLen)
|
||||
if word == "" {
|
||||
httputil.ErrorJSON(w, http.StatusBadRequest, "word is required")
|
||||
return
|
||||
}
|
||||
req.Gloss = clamp(req.Gloss, maxGlossLen)
|
||||
req.Definition = clamp(req.Definition, maxDefinitionLen)
|
||||
req.Phonetic = clamp(req.Phonetic, maxPhoneticLen)
|
||||
req.Example = clamp(req.Example, maxExampleLen)
|
||||
|
||||
// A blank doc_id is the same as none; otherwise the word must belong to a
|
||||
// document the user actually owns. Without this check a stale or forged id
|
||||
// would hit the foreign key and leak a raw "FOREIGN KEY constraint" 500
|
||||
// instead of a clean 400 (and, once auth lands, would let a word be attached
|
||||
// to another user's document).
|
||||
if req.DocID != nil {
|
||||
if strings.TrimSpace(*req.DocID) == "" {
|
||||
req.DocID = nil
|
||||
} else {
|
||||
var ok int
|
||||
err := h.DB.QueryRow(
|
||||
`SELECT 1 FROM documents WHERE id = ? AND user_id = ?`,
|
||||
*req.DocID, db.LocalUserID,
|
||||
).Scan(&ok)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
httputil.ErrorJSON(w, http.StatusBadRequest, "unknown doc_id")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// New rows start due tomorrow; ON CONFLICT refreshes context but leaves the
|
||||
// schedule (due_at/reps/interval/ease) alone so re-looking-up a word never
|
||||
// resets its progress.
|
||||
_, err := h.DB.Exec(
|
||||
`INSERT INTO vocab_words (user_id, word, gloss, definition, phonetic, example, doc_id, due_at, interval_days)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now', '+1 day'), 1)
|
||||
ON CONFLICT(user_id, word) DO UPDATE SET
|
||||
gloss = excluded.gloss,
|
||||
definition = excluded.definition,
|
||||
phonetic = excluded.phonetic,
|
||||
example = CASE WHEN excluded.example != '' THEN excluded.example ELSE vocab_words.example END,
|
||||
doc_id = COALESCE(excluded.doc_id, vocab_words.doc_id)`,
|
||||
db.LocalUserID, word, req.Gloss, req.Definition, req.Phonetic, req.Example, req.DocID,
|
||||
)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
out, err := h.fetch(word)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httputil.WriteJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// fetch loads one word row by its (user, word) key.
|
||||
func (h *Handler) fetch(word string) (Word, error) {
|
||||
return scanWord(h.DB.QueryRow(
|
||||
`SELECT `+vocabColumns+` FROM vocab_words WHERE user_id = ? AND word = ?`,
|
||||
db.LocalUserID, word,
|
||||
))
|
||||
}
|
||||
|
||||
type reviewRequest struct {
|
||||
Grade Grade `json:"grade"`
|
||||
}
|
||||
|
||||
// review grades one card and reschedules it. The grade drives the SM-2-lite
|
||||
// scheduler; the new interval is applied as `due_at = now + interval days`.
|
||||
func (h *Handler) review(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var req reviewRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httputil.ErrorJSON(w, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
if req.Grade != GradeAgain && req.Grade != GradeGood && req.Grade != GradeEasy {
|
||||
httputil.ErrorJSON(w, http.StatusBadRequest, "grade must be again, good, or easy")
|
||||
return
|
||||
}
|
||||
|
||||
// The grade is computed in Go (the SM-2-lite scheduler), so the read and the
|
||||
// write must be one atomic unit: a bare SELECT-then-UPDATE could interleave
|
||||
// with a concurrent review of the same card and lose an update. Wrap both in a
|
||||
// transaction.
|
||||
tx, err := h.DB.Begin()
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback() // no-op once committed
|
||||
|
||||
var cur State
|
||||
err = tx.QueryRow(
|
||||
`SELECT reps, interval_days, ease, lapses FROM vocab_words WHERE id = ? AND user_id = ?`,
|
||||
id, db.LocalUserID,
|
||||
).Scan(&cur.Reps, &cur.Interval, &cur.Ease, &cur.Lapses)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
httputil.ErrorJSON(w, http.StatusNotFound, "word not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
nxt := cur.next(req.Grade)
|
||||
// `datetime('now', '+N days')` keeps the stored value in SQLite's canonical
|
||||
// text format, matching CURRENT_TIMESTAMP and the due query's comparison.
|
||||
offset := "+" + strconv.Itoa(nxt.Interval) + " days"
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE vocab_words SET
|
||||
reps = ?, interval_days = ?, ease = ?, lapses = ?,
|
||||
last_reviewed = datetime('now'), due_at = datetime('now', ?)
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
nxt.Reps, nxt.Interval, nxt.Ease, nxt.Lapses, offset, id, db.LocalUserID,
|
||||
); err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
out, err := scanWord(tx.QueryRow(
|
||||
`SELECT `+vocabColumns+` FROM vocab_words WHERE id = ? AND user_id = ?`, id, db.LocalUserID,
|
||||
))
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httputil.WriteJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// remove deletes a word from the garden (e.g. the writer already knows it).
|
||||
func (h *Handler) remove(w http.ResponseWriter, r *http.Request) {
|
||||
res, err := h.DB.Exec(
|
||||
`DELETE FROM vocab_words WHERE id = ? AND user_id = ?`,
|
||||
chi.URLParam(r, "id"), db.LocalUserID,
|
||||
)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
httputil.ErrorJSON(w, http.StatusNotFound, "word not found")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
249
internal/vocab/handlers_test.go
Normal file
249
internal/vocab/handlers_test.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package vocab
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
)
|
||||
|
||||
func newTestServer(t *testing.T) (http.Handler, *db.DB) {
|
||||
t.Helper()
|
||||
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() })
|
||||
r := chi.NewRouter()
|
||||
r.Mount("/vocab", New(database).Routes())
|
||||
return r, database
|
||||
}
|
||||
|
||||
func do(t *testing.T, srv http.Handler, method, path, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var r *http.Request
|
||||
if body != "" {
|
||||
r = httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||
} else {
|
||||
r = httptest.NewRequest(method, path, nil)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, r)
|
||||
return rec
|
||||
}
|
||||
|
||||
// TestCaptureUpsertAndReview walks the garden lifecycle: capture a word (lands
|
||||
// in the future, not yet due), re-capture refreshes context without resetting
|
||||
// schedule, a forced-due word shows up in /due, a review reschedules it, and
|
||||
// delete removes it.
|
||||
func TestCaptureUpsertAndReview(t *testing.T) {
|
||||
srv, database := newTestServer(t)
|
||||
|
||||
// Capture a new word → 201, due tomorrow (interval 1), not in /due yet.
|
||||
rec := do(t, srv, http.MethodPost, "/vocab",
|
||||
`{"word":"serendipity","gloss":"机缘巧合","phonetic":"/ˌserənˈdɪpəti/","example":"What a serendipity."}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("capture: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
var w Word
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &w)
|
||||
if w.ID == "" || w.Word != "serendipity" || w.Gloss != "机缘巧合" {
|
||||
t.Fatalf("captured word wrong: %+v", w)
|
||||
}
|
||||
if w.IntervalDays != 1 || w.Reps != 0 {
|
||||
t.Fatalf("new word should start at interval 1, reps 0: %+v", w)
|
||||
}
|
||||
|
||||
// Not due yet (due tomorrow).
|
||||
rec = do(t, srv, http.MethodGet, "/vocab/due", "")
|
||||
var due []Word
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &due)
|
||||
if len(due) != 0 {
|
||||
t.Fatalf("freshly-captured word should not be due yet, got %d", len(due))
|
||||
}
|
||||
|
||||
// Re-capture with a new gloss → still one row (idempotent upsert), refreshed.
|
||||
rec = do(t, srv, http.MethodPost, "/vocab",
|
||||
`{"word":"serendipity","gloss":"意外的好运","phonetic":"/x/","example":""}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("re-capture: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
rec = do(t, srv, http.MethodGet, "/vocab", "")
|
||||
var all []Word
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &all)
|
||||
if len(all) != 1 {
|
||||
t.Fatalf("re-capture should not add a row, got %d", len(all))
|
||||
}
|
||||
if all[0].Gloss != "意外的好运" {
|
||||
t.Fatalf("gloss should refresh on re-capture, got %q", all[0].Gloss)
|
||||
}
|
||||
if all[0].Example != "What a serendipity." {
|
||||
t.Fatalf("empty example should not clobber the prior one, got %q", all[0].Example)
|
||||
}
|
||||
|
||||
// Force it due now, then it appears in /due.
|
||||
if _, err := database.Exec(`UPDATE vocab_words SET due_at = datetime('now','-1 hour')`); err != nil {
|
||||
t.Fatalf("force due: %v", err)
|
||||
}
|
||||
rec = do(t, srv, http.MethodGet, "/vocab/due", "")
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &due)
|
||||
if len(due) != 1 {
|
||||
t.Fatalf("forced-due word should be in /due, got %d", len(due))
|
||||
}
|
||||
|
||||
// Review "good" → reschedules forward (interval 3 = second ladder rung, since
|
||||
// reps was 0 and a capture set interval 1 but reps 0; first good → rung 1 = 1).
|
||||
rec = do(t, srv, http.MethodPost, "/vocab/"+w.ID+"/review", `{"grade":"good"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("review: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
var reviewed Word
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &reviewed)
|
||||
if reviewed.Reps != 1 || reviewed.IntervalDays != 1 {
|
||||
t.Fatalf("first good review should be reps 1, interval 1: %+v", reviewed)
|
||||
}
|
||||
if reviewed.LastReviewed == nil {
|
||||
t.Fatalf("review should stamp last_reviewed")
|
||||
}
|
||||
|
||||
// After a forward review it's no longer due.
|
||||
rec = do(t, srv, http.MethodGet, "/vocab/due", "")
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &due)
|
||||
if len(due) != 0 {
|
||||
t.Fatalf("reviewed word should leave /due, got %d", len(due))
|
||||
}
|
||||
|
||||
// Bad grade → 400.
|
||||
if rec = do(t, srv, http.MethodPost, "/vocab/"+w.ID+"/review", `{"grade":"nope"}`); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("bad grade: want 400, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// Delete → 204, garden empty.
|
||||
if rec = do(t, srv, http.MethodDelete, "/vocab/"+w.ID, ""); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete: code=%d", rec.Code)
|
||||
}
|
||||
if rec = do(t, srv, http.MethodDelete, "/vocab/"+w.ID, ""); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("re-delete: want 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCaptureValidation rejects an empty word.
|
||||
func TestCaptureValidation(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
if rec := do(t, srv, http.MethodPost, "/vocab", `{"word":" "}`); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("empty word: want 400, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCaptureStoresDefinitionFallback proves a word with an English definition
|
||||
// but no Chinese gloss still round-trips its definition, so review has a meaning
|
||||
// to reveal instead of a blank card.
|
||||
func TestCaptureStoresDefinitionFallback(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
rec := do(t, srv, http.MethodPost, "/vocab",
|
||||
`{"word":"ineffable","definition":"too great to be expressed in words"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("capture: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
var w Word
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &w)
|
||||
if w.Gloss != "" {
|
||||
t.Fatalf("expected no gloss, got %q", w.Gloss)
|
||||
}
|
||||
if w.Definition != "too great to be expressed in words" {
|
||||
t.Fatalf("definition not stored, got %q", w.Definition)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCaptureCaseInsensitive proves "Apple" (sentence start) and "apple"
|
||||
// (mid-line) land in the SAME garden card — capture normalizes to lower+trim
|
||||
// like the lexicon, so the idempotent upsert isn't defeated by casing.
|
||||
func TestCaptureCaseInsensitive(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
if rec := do(t, srv, http.MethodPost, "/vocab", `{"word":"Apple","gloss":"苹果"}`); rec.Code != http.StatusCreated {
|
||||
t.Fatalf("capture Apple: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
if rec := do(t, srv, http.MethodPost, "/vocab", `{"word":" apple ","gloss":"苹果"}`); rec.Code != http.StatusCreated {
|
||||
t.Fatalf("capture apple: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
rec := do(t, srv, http.MethodGet, "/vocab", "")
|
||||
var all []Word
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &all)
|
||||
if len(all) != 1 {
|
||||
t.Fatalf("Apple/apple should be one card, got %d", len(all))
|
||||
}
|
||||
if all[0].Word != "apple" {
|
||||
t.Fatalf("word should be normalized to lowercase, got %q", all[0].Word)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCaptureUnknownDocID rejects an unowned/stale doc_id with a clean 400
|
||||
// rather than letting it hit the foreign key and leak a raw 500.
|
||||
func TestCaptureUnknownDocID(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
rec := do(t, srv, http.MethodPost, "/vocab",
|
||||
`{"word":"quixotic","gloss":"不切实际的","doc_id":"does-not-exist"}`)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("unknown doc_id: want 400, got %d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
// A blank doc_id is treated as none, not an error.
|
||||
if rec := do(t, srv, http.MethodPost, "/vocab", `{"word":"limpid","gloss":"清澈的","doc_id":""}`); rec.Code != http.StatusCreated {
|
||||
t.Fatalf("blank doc_id should be accepted as none: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCaptureClampsLongFields proves over-long fields are clamped (not rejected)
|
||||
// so a lookup never fails on length, and the word key stays bounded.
|
||||
func TestCaptureClampsLongFields(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
longWord := strings.Repeat("a", maxWordLen+50)
|
||||
longDef := strings.Repeat("x", maxDefinitionLen+50)
|
||||
rec := do(t, srv, http.MethodPost, "/vocab",
|
||||
`{"word":"`+longWord+`","definition":"`+longDef+`"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("capture: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
var w Word
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &w)
|
||||
if len([]rune(w.Word)) != maxWordLen {
|
||||
t.Fatalf("word should clamp to %d runes, got %d", maxWordLen, len([]rune(w.Word)))
|
||||
}
|
||||
if len([]rune(w.Definition)) != maxDefinitionLen {
|
||||
t.Fatalf("definition should clamp to %d runes, got %d", maxDefinitionLen, len([]rune(w.Definition)))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocLinkSurvivesDocDelete proves the ON DELETE SET NULL keeps a word in the
|
||||
// garden when its source document is removed.
|
||||
func TestDocLinkSurvivesDocDelete(t *testing.T) {
|
||||
srv, database := newTestServer(t)
|
||||
var docID string
|
||||
if err := database.QueryRow(
|
||||
`INSERT INTO documents (user_id, content_text) VALUES (?, 'hi') RETURNING id`, db.LocalUserID,
|
||||
).Scan(&docID); err != nil {
|
||||
t.Fatalf("seed doc: %v", err)
|
||||
}
|
||||
rec := do(t, srv, http.MethodPost, "/vocab", `{"word":"ephemeral","gloss":"短暂的","doc_id":"`+docID+`"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("capture: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
if _, err := database.Exec(`DELETE FROM documents WHERE id = ?`, docID); err != nil {
|
||||
t.Fatalf("delete doc: %v", err)
|
||||
}
|
||||
rec = do(t, srv, http.MethodGet, "/vocab", "")
|
||||
var all []Word
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &all)
|
||||
if len(all) != 1 {
|
||||
t.Fatalf("word should survive doc deletion, got %d", len(all))
|
||||
}
|
||||
if all[0].DocID != nil {
|
||||
t.Fatalf("doc_id should be nulled after doc delete, got %v", *all[0].DocID)
|
||||
}
|
||||
}
|
||||
114
internal/vocab/scheduler.go
Normal file
114
internal/vocab/scheduler.go
Normal file
@@ -0,0 +1,114 @@
|
||||
// Package vocab implements the vocabulary garden: it captures words the writer
|
||||
// looks up and schedules them for gentle spaced-repetition review. The scheduler
|
||||
// is a deliberately forgiving SM-2-lite / Leitner hybrid — no streaks to break,
|
||||
// no harsh resets beyond a single step back — because this is a confidence
|
||||
// builder, not a drill sergeant.
|
||||
package vocab
|
||||
|
||||
import "math"
|
||||
|
||||
// Grade is the writer's self-assessment after seeing a flashcard's answer.
|
||||
type Grade string
|
||||
|
||||
const (
|
||||
GradeAgain Grade = "again" // didn't recall it — show again soon
|
||||
GradeGood Grade = "good" // recalled it — advance one rung
|
||||
GradeEasy Grade = "easy" // knew it instantly — advance a little further
|
||||
)
|
||||
|
||||
// ladder is the Leitner interval ladder in days for the first several successful
|
||||
// reviews: 1 → 3 → 7 → 16 → 35. Beyond the ladder, intervals grow by the card's
|
||||
// ease factor so well-known words drift far into the future.
|
||||
var ladder = []int{1, 3, 7, 16, 35}
|
||||
|
||||
const (
|
||||
minEase = 1.3
|
||||
maxEase = 3.0 // ease ceiling — keeps "easy" from compounding growth without bound
|
||||
startEase = 2.5
|
||||
easyBonus = 1.3 // multiplier applied on top of a "good" step for "easy"
|
||||
easeAgainDelta = -0.20 // ease nudged down on a lapse (still floored at minEase)
|
||||
easeEasyDelta = 0.15 // ease nudged up when a card feels easy
|
||||
|
||||
// maxInterval caps how far a card can drift into the future. Even a word
|
||||
// graded "easy" many times resurfaces about once a year, so nothing silently
|
||||
// leaves the garden forever (the whole point is to keep words in gentle
|
||||
// rotation, not to retire them).
|
||||
maxInterval = 365
|
||||
)
|
||||
|
||||
// State is the spaced-repetition state of one card. It mirrors the scheduling
|
||||
// columns on vocab_words so a review is "load State → next(grade) → persist".
|
||||
type State struct {
|
||||
Reps int
|
||||
Interval int // days until the next review
|
||||
Ease float64
|
||||
Lapses int
|
||||
}
|
||||
|
||||
// next returns the card's state after a review with the given grade. It never
|
||||
// mutates the receiver. "again" steps the card back to a 1-day interval and
|
||||
// counts a lapse (but only nudges ease down, never wipes progress harshly);
|
||||
// "good" advances one rung of the ladder; "easy" advances a rung and a bit more.
|
||||
func (s State) next(grade Grade) State {
|
||||
ease := s.Ease
|
||||
if ease == 0 {
|
||||
ease = startEase
|
||||
}
|
||||
|
||||
switch grade {
|
||||
case GradeAgain:
|
||||
return State{
|
||||
Reps: 0,
|
||||
Interval: 1,
|
||||
Ease: clampEase(ease + easeAgainDelta),
|
||||
Lapses: s.Lapses + 1,
|
||||
}
|
||||
case GradeEasy:
|
||||
ease = clampEase(ease + easeEasyDelta)
|
||||
reps := s.Reps + 1
|
||||
return State{
|
||||
Reps: reps,
|
||||
Interval: clampInterval(int(math.Round(float64(goodInterval(reps, s.Interval, ease)) * easyBonus))),
|
||||
Ease: ease,
|
||||
Lapses: s.Lapses,
|
||||
}
|
||||
default: // GradeGood
|
||||
reps := s.Reps + 1
|
||||
return State{
|
||||
Reps: reps,
|
||||
Interval: clampInterval(goodInterval(reps, s.Interval, ease)),
|
||||
Ease: ease,
|
||||
Lapses: s.Lapses,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// goodInterval returns the day-interval for a successful review: the explicit
|
||||
// ladder while it lasts, then geometric growth by ease once past it.
|
||||
func goodInterval(reps, prevInterval int, ease float64) int {
|
||||
if reps >= 1 && reps <= len(ladder) {
|
||||
return ladder[reps-1]
|
||||
}
|
||||
prev := prevInterval
|
||||
if prev < ladder[len(ladder)-1] {
|
||||
prev = ladder[len(ladder)-1]
|
||||
}
|
||||
return int(math.Round(float64(prev) * ease))
|
||||
}
|
||||
|
||||
func clampEase(e float64) float64 {
|
||||
if e < minEase {
|
||||
return minEase
|
||||
}
|
||||
if e > maxEase {
|
||||
return maxEase
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func clampInterval(d int) int {
|
||||
if d > maxInterval {
|
||||
return maxInterval
|
||||
}
|
||||
return d
|
||||
}
|
||||
88
internal/vocab/scheduler_test.go
Normal file
88
internal/vocab/scheduler_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package vocab
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestLadderProgression walks a card up the Leitner ladder on repeated "good"
|
||||
// reviews: 1 → 3 → 7 → 16 → 35 days, then geometric growth by ease.
|
||||
func TestLadderProgression(t *testing.T) {
|
||||
s := State{Ease: startEase}
|
||||
want := []int{1, 3, 7, 16, 35}
|
||||
for i, w := range want {
|
||||
s = s.next(GradeGood)
|
||||
if s.Interval != w {
|
||||
t.Fatalf("rung %d: interval=%d, want %d", i, s.Interval, w)
|
||||
}
|
||||
if s.Reps != i+1 {
|
||||
t.Fatalf("rung %d: reps=%d, want %d", i, s.Reps, i+1)
|
||||
}
|
||||
}
|
||||
// Past the ladder it grows by ease (35 * 2.5 = 87.5 → 88).
|
||||
s = s.next(GradeGood)
|
||||
if s.Interval != 88 {
|
||||
t.Fatalf("post-ladder interval=%d, want 88", s.Interval)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgainStepsBackGently proves a lapse resets to a 1-day interval and counts
|
||||
// a lapse, but only nudges ease down (never below the floor) — no harsh wipe.
|
||||
func TestAgainStepsBackGently(t *testing.T) {
|
||||
s := State{Reps: 4, Interval: 35, Ease: startEase}
|
||||
s = s.next(GradeAgain)
|
||||
if s.Interval != 1 {
|
||||
t.Fatalf("again interval=%d, want 1", s.Interval)
|
||||
}
|
||||
if s.Reps != 0 {
|
||||
t.Fatalf("again reps=%d, want 0", s.Reps)
|
||||
}
|
||||
if s.Lapses != 1 {
|
||||
t.Fatalf("again lapses=%d, want 1", s.Lapses)
|
||||
}
|
||||
if s.Ease != startEase+easeAgainDelta {
|
||||
t.Fatalf("again ease=%v, want %v", s.Ease, startEase+easeAgainDelta)
|
||||
}
|
||||
|
||||
// Ease never drops below the floor no matter how many lapses.
|
||||
low := State{Ease: minEase}
|
||||
for i := 0; i < 5; i++ {
|
||||
low = low.next(GradeAgain)
|
||||
}
|
||||
if low.Ease < minEase {
|
||||
t.Fatalf("ease fell below floor: %v", low.Ease)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCapsBoundGrowth proves runaway "easy" grading can't push ease or the
|
||||
// interval past their ceilings, so a word always resurfaces within a year.
|
||||
func TestCapsBoundGrowth(t *testing.T) {
|
||||
s := State{Ease: startEase}
|
||||
for i := 0; i < 40; i++ {
|
||||
s = s.next(GradeEasy)
|
||||
if s.Ease > maxEase {
|
||||
t.Fatalf("step %d: ease %v exceeded cap %v", i, s.Ease, maxEase)
|
||||
}
|
||||
if s.Interval > maxInterval {
|
||||
t.Fatalf("step %d: interval %d exceeded cap %d", i, s.Interval, maxInterval)
|
||||
}
|
||||
}
|
||||
// After enough "easy" reviews it should be pinned at the ceilings.
|
||||
if s.Interval != maxInterval {
|
||||
t.Fatalf("interval should saturate at %d, got %d", maxInterval, s.Interval)
|
||||
}
|
||||
if s.Ease != maxEase {
|
||||
t.Fatalf("ease should saturate at %v, got %v", maxEase, s.Ease)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEasyAdvancesFurther proves "easy" both bumps ease and lands a longer
|
||||
// interval than a plain "good" at the same rung.
|
||||
func TestEasyAdvancesFurther(t *testing.T) {
|
||||
base := State{Reps: 2, Interval: 7, Ease: startEase}
|
||||
good := base.next(GradeGood)
|
||||
easy := base.next(GradeEasy)
|
||||
if easy.Interval <= good.Interval {
|
||||
t.Fatalf("easy interval %d should exceed good interval %d", easy.Interval, good.Interval)
|
||||
}
|
||||
if easy.Ease <= startEase {
|
||||
t.Fatalf("easy should raise ease, got %v", easy.Ease)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { EditorCore, type EditorChange } from './components/Editor/EditorCore'
|
||||
import { ToneSelect } from './components/Editor/ToneSelect'
|
||||
import { ExportMenu } from './components/Export/ExportMenu'
|
||||
import { HistoryPanel } from './components/History/HistoryPanel'
|
||||
import { GardenPanel } from './components/Garden/GardenPanel'
|
||||
import { StatusBar } from './components/StatusBar/StatusBar'
|
||||
import { PetalCompanion } from './components/Companion/PetalCompanion'
|
||||
import { UpdateBanner } from './components/UpdateBanner/UpdateBanner'
|
||||
@@ -46,6 +47,7 @@ export default function App() {
|
||||
// editor to remount with the restored content (its initialContent is read
|
||||
// only on mount).
|
||||
const [historyOpen, setHistoryOpen] = useState(false)
|
||||
const [gardenOpen, setGardenOpen] = useState(false)
|
||||
const [editorEpoch, setEditorEpoch] = useState(0)
|
||||
|
||||
// Live mirrors of the current doc's editable fields so the "discard blank
|
||||
@@ -70,9 +72,11 @@ export default function App() {
|
||||
suggestions,
|
||||
checking,
|
||||
voicing,
|
||||
collocating,
|
||||
llmDown,
|
||||
schedule: scheduleCheckpoint,
|
||||
runVoice,
|
||||
runCollocation,
|
||||
removeSuggestion,
|
||||
} = useCheckpoint(currentDoc?.id ?? null)
|
||||
// Browser-side spell checker — loads the en-US dictionary once per session.
|
||||
@@ -270,7 +274,7 @@ export default function App() {
|
||||
if (currentDoc) {
|
||||
patchSummary(currentDoc.id, { word_count: change.word_count })
|
||||
schedule(change)
|
||||
scheduleCheckpoint()
|
||||
scheduleCheckpoint(change.content_text)
|
||||
}
|
||||
},
|
||||
[currentDoc, patchSummary, schedule, scheduleCheckpoint],
|
||||
@@ -395,6 +399,23 @@ export default function App() {
|
||||
</button>
|
||||
<span className="text-xl">🌸</span>
|
||||
<span className="text-lg font-extrabold text-plum">Petal</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGardenOpen(true)}
|
||||
aria-label="Vocabulary garden"
|
||||
title="Words you've looked up, blooming for review"
|
||||
className="petal-tap-sm ml-auto inline-flex h-9 items-center gap-1.5 whitespace-nowrap px-3 text-sm font-bold"
|
||||
style={{
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
background: 'var(--color-surface)',
|
||||
color: 'var(--color-plum)',
|
||||
boxShadow: 'var(--shadow-soft)',
|
||||
}}
|
||||
>
|
||||
<span aria-hidden>🌷</span>
|
||||
<span>词汇花园</span>
|
||||
<span style={{ color: 'var(--color-muted)' }}>· Garden</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
@@ -471,6 +492,8 @@ export default function App() {
|
||||
onDismiss={handleDismiss}
|
||||
onVoiceCheck={runVoice}
|
||||
voicing={voicing}
|
||||
onCollocationCheck={runCollocation}
|
||||
collocating={collocating}
|
||||
onFocusMode={() => setFocusMode(true)}
|
||||
spellChecker={spellChecker}
|
||||
onAddWord={addWord}
|
||||
@@ -484,6 +507,7 @@ export default function App() {
|
||||
saveStatus={status}
|
||||
checking={checking}
|
||||
voicing={voicing}
|
||||
collocating={collocating}
|
||||
llmDown={llmDown}
|
||||
/>
|
||||
</div>
|
||||
@@ -507,6 +531,16 @@ export default function App() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{gardenOpen && (
|
||||
<GardenPanel
|
||||
onClose={() => setGardenOpen(false)}
|
||||
onOpenDoc={(id) => {
|
||||
setGardenOpen(false)
|
||||
void openDoc(id)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{updateAvailable && <UpdateBanner />}
|
||||
|
||||
<div className="petal-no-print">
|
||||
|
||||
@@ -76,7 +76,30 @@ export interface Gloss {
|
||||
gloss: string
|
||||
}
|
||||
|
||||
export type SuggestionType = 'grammar' | 'phrasing' | 'idiom' | 'clarity' | 'voice'
|
||||
export type SuggestionType = 'grammar' | 'phrasing' | 'idiom' | 'clarity' | 'voice' | 'collocation' | 'mechanics'
|
||||
|
||||
// One word in the vocabulary garden: a looked-up word with its gloss/phonetic,
|
||||
// the sentence it was met in, and its spaced-repetition state. `reps` drives how
|
||||
// "bloomed" its blossom looks; `due_at` decides when it next surfaces for review.
|
||||
export interface VocabWord {
|
||||
id: string
|
||||
word: string
|
||||
gloss: string
|
||||
definition: string // English fallback meaning shown in review when there's no gloss
|
||||
phonetic: string
|
||||
example: string
|
||||
doc_id: string | null
|
||||
due_at: string
|
||||
interval_days: number
|
||||
ease: number
|
||||
reps: number
|
||||
lapses: number
|
||||
last_reviewed: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// The self-assessment grades a flashcard review can record.
|
||||
export type VocabGrade = 'again' | 'good' | 'easy'
|
||||
|
||||
// A point-in-time snapshot of a document. List responses omit the heavy
|
||||
// content/content_text fields; they arrive only on getVersion (preview/restore).
|
||||
@@ -112,6 +135,17 @@ export interface Suggestion {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// A deterministic, rule-based fix detected client-side (see Companion/prose.ts).
|
||||
// The frontend owns mechanics detection; the backend only persists these as the
|
||||
// 'mechanics' suggestion family. Spans are exact plaintext offsets.
|
||||
export interface MechanicsFinding {
|
||||
from: number
|
||||
to: number
|
||||
original: string
|
||||
replacement: string
|
||||
explanation: string
|
||||
}
|
||||
|
||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`/api${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -141,6 +175,18 @@ export const api = {
|
||||
// Voice-consistency pass: whole-document, explicit-action, slower. Returns the
|
||||
// unified pending set too. Rate-limited per document server-side.
|
||||
voiceDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/voice`, { method: 'POST' }),
|
||||
// Collocation coach: whole-document, explicit-action pass flagging non-native
|
||||
// word pairings ("do a decision" → "make a decision"). Returns the unified
|
||||
// pending set too. Rate-limited per document server-side.
|
||||
collocationDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/collocation`, { method: 'POST' }),
|
||||
// Mechanics pass: persist the client-detected deterministic fixes as the
|
||||
// 'mechanics' family and return the unified pending set. Not rate-limited (it's
|
||||
// free, local detection); runs alongside the grammar checkpoint.
|
||||
submitMechanics: (id: string, findings: MechanicsFinding[]) =>
|
||||
req<Suggestion[]>(`/docs/${id}/mechanics`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ findings }),
|
||||
}),
|
||||
// Pending suggestions for a doc, loaded when the editor opens it.
|
||||
listSuggestions: (id: string) => req<Suggestion[]>(`/docs/${id}/suggestions`),
|
||||
acceptSuggestion: (id: string) =>
|
||||
@@ -221,6 +267,24 @@ export const api = {
|
||||
unassignTag: (docId: string, tagId: string) =>
|
||||
req<void>(`/docs/${docId}/tags/${tagId}`, { method: 'DELETE' }),
|
||||
|
||||
// Vocabulary garden. recordVocab captures (or refreshes) a looked-up word —
|
||||
// idempotent per word, fired automatically on lookup. listVocab is the whole
|
||||
// garden; dueVocab is just the cards ready for review; reviewVocab grades one
|
||||
// card and returns its rescheduled state; deleteVocab removes a word.
|
||||
recordVocab: (body: {
|
||||
word: string
|
||||
gloss?: string
|
||||
definition?: string
|
||||
phonetic?: string
|
||||
example?: string
|
||||
doc_id?: string | null
|
||||
}) => req<VocabWord>('/vocab', { method: 'POST', body: JSON.stringify(body) }),
|
||||
listVocab: () => req<VocabWord[]>('/vocab'),
|
||||
dueVocab: () => req<VocabWord[]>('/vocab/due'),
|
||||
reviewVocab: (id: string, grade: VocabGrade) =>
|
||||
req<VocabWord>(`/vocab/${id}/review`, { method: 'POST', body: JSON.stringify({ grade }) }),
|
||||
deleteVocab: (id: string) => req<void>(`/vocab/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// 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.
|
||||
version: () => req<{ version: string }>('/version', { cache: 'no-store' }),
|
||||
|
||||
@@ -28,7 +28,12 @@ let current: { audio: HTMLAudioElement; url: string } | null = null
|
||||
// a newer tap can detect it's stale and bow out instead of double-playing.
|
||||
let requestSeq = 0
|
||||
|
||||
function stopCurrent(): void {
|
||||
// stopSpeech halts any read-aloud in flight — both the server-audio element and
|
||||
// the Web Speech fallback — and bumps requestSeq so a fetch still in flight bows
|
||||
// out instead of playing late. Exported so a panel can cancel audio on unmount,
|
||||
// keeping a word's pronunciation from outliving the panel that started it.
|
||||
export function stopSpeech(): void {
|
||||
requestSeq++
|
||||
if (current) {
|
||||
current.audio.pause()
|
||||
URL.revokeObjectURL(current.url)
|
||||
@@ -77,7 +82,7 @@ export function detectLang(text: string): string {
|
||||
// with no configured voice).
|
||||
export function speak(text: string, lang = detectLang(text)): void {
|
||||
if (!text.trim()) return
|
||||
stopCurrent()
|
||||
stopSpeech()
|
||||
const seq = ++requestSeq
|
||||
|
||||
fetch('/api/tts', {
|
||||
|
||||
@@ -248,3 +248,119 @@ describe('capitalization basics', () => {
|
||||
expectRule('The sun set slowly. it was a beautiful evening down by the river.', 'cap-sentence')
|
||||
})
|
||||
})
|
||||
|
||||
import { mechanicsFindings } from './prose'
|
||||
|
||||
// mechanicsFindings is the applyable subset feeding the suggestion cards. Each
|
||||
// finding must carry an EXACT span (text.slice(from,to) === original) and a
|
||||
// replacement that actually changes the text — these become one-click edits.
|
||||
describe('mechanicsFindings — applyable fixes', () => {
|
||||
// Every finding's span must be exact, regardless of which rule produced it.
|
||||
function expectExactSpans(text: string) {
|
||||
const found = mechanicsFindings(text)
|
||||
for (const f of found) {
|
||||
expect(text.slice(f.from, f.to), `span for ${JSON.stringify(f)}`).toBe(f.original)
|
||||
expect(f.replacement).not.toBe(f.original)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
const one = (text: string, rulePred: (f: ReturnType<typeof mechanicsFindings>[number]) => boolean) =>
|
||||
expectExactSpans(text).find(rulePred)
|
||||
|
||||
it('doubled word → single word, exact span', () => {
|
||||
const f = one('I saw the the cat in the garden today.', (f) => f.original === 'the the')
|
||||
expect(f?.replacement).toBe('the')
|
||||
})
|
||||
|
||||
it('finds BOTH doublings with distinct spans', () => {
|
||||
const found = mechanicsFindings('the the dog and the the cat ran around the yard.')
|
||||
.filter((f) => f.original === 'the the')
|
||||
expect(found).toHaveLength(2)
|
||||
expect(found[0].from).not.toBe(found[1].from)
|
||||
})
|
||||
|
||||
// Spans are widened to a distinctive phrase so they re-anchor by string in the
|
||||
// editor (a bare "a"/"i"/" is" would match the wrong spot). The replacement
|
||||
// carries the whole corrected phrase.
|
||||
|
||||
it('lowercase standalone i is awareness-only (no card)', () => {
|
||||
// It still flags as a companion bubble (see analyzeProse), but a single
|
||||
// character can't be anchored, so it must NOT become a card.
|
||||
expect(mechanicsFindings('Yesterday i walked to the store and came home.').length).toBe(0)
|
||||
expect(rulesFor('Yesterday i walked to the store and came home.')).toContain('cap-i')
|
||||
})
|
||||
|
||||
it('a → an: spans the whole "a <noun>" phrase', () => {
|
||||
const f = one('She found a umbrella under the old wooden table.', (f) => f.original === 'a umbrella')
|
||||
expect(f?.replacement).toBe('an umbrella')
|
||||
})
|
||||
|
||||
it('uncountable plural → singular', () => {
|
||||
const f = one('She gave me many informations about the new job.', (f) => f.original === 'informations')
|
||||
expect(f?.replacement).toBe('information')
|
||||
})
|
||||
|
||||
it('lowercase proper noun → capitalized', () => {
|
||||
const f = one('I am learning english during my free time this year.', (f) => f.original === 'english')
|
||||
expect(f?.replacement).toBe('English')
|
||||
})
|
||||
|
||||
it('subject-verb agreement → corrects the verb, spans subject+verb', () => {
|
||||
const f = one('She have three cats and a dog at her house.', (f) => f.original === 'She have')
|
||||
expect(f?.replacement).toBe('She has')
|
||||
})
|
||||
|
||||
it('singular noun after a number → plural, spans number+noun', () => {
|
||||
const f = one('I bought five apple at the market this morning.', (f) => f.original === 'five apple')
|
||||
expect(f?.replacement).toBe('five apples')
|
||||
})
|
||||
|
||||
it('comparative + then → than, spans the phrase', () => {
|
||||
const f = one('This book is better then the one I read last week.', (f) => f.original === 'better then')
|
||||
expect(f?.replacement).toBe('better than')
|
||||
})
|
||||
|
||||
it('space before punctuation is removed (spans the word)', () => {
|
||||
const f = one('I love it , it makes me very happy indeed.', (f) => f.original === 'it ,')
|
||||
expect(f?.replacement).toBe('it,')
|
||||
})
|
||||
|
||||
it('missing space after a comma is added (spans both words)', () => {
|
||||
const f = one('I bought apples,bananas and pears at the store.', (f) => f.original === 'apples,bananas')
|
||||
expect(f?.replacement).toBe('apples, bananas')
|
||||
})
|
||||
|
||||
it('stacked determiner drops the article', () => {
|
||||
const f = one('She left the my book on the kitchen table again.', (f) => f.original === 'the my')
|
||||
expect(f?.replacement).toBe('my')
|
||||
})
|
||||
|
||||
it('there is + plural → there are, spans the phrase', () => {
|
||||
const f = one('There is many people waiting outside in the cold.', (f) => f.original === 'There is many')
|
||||
expect(f?.replacement).toBe('There are many')
|
||||
})
|
||||
|
||||
it("it's own → its own, spans the phrase", () => {
|
||||
const f = one("The cat licked it's own paw very slowly today.", (f) => f.original === "it's own")
|
||||
expect(f?.replacement).toBe('its own')
|
||||
})
|
||||
|
||||
it("its a → it's a, spans the phrase", () => {
|
||||
const f = one('I think its a wonderful day to go outside now.', (f) => f.original === 'its a')
|
||||
expect(f?.replacement).toBe("it's a")
|
||||
})
|
||||
|
||||
// Awareness-only rules never produce cards — they stay companion bubbles.
|
||||
it('run-ons and splices produce NO card findings', () => {
|
||||
const runOn = 'I went to the market and I bought some apples and then I saw my friend and we talked for a while and after that we went to the cafe and ordered coffee and cake together.'
|
||||
expect(mechanicsFindings(runOn).some((f) => f.original.length > 30)).toBe(false)
|
||||
const splice = 'I finished the report, I sent it to my boss right away today.'
|
||||
// no card spans the comma-join; only (if any) small word-level fixes
|
||||
expect(mechanicsFindings(splice).every((f) => f.to - f.from < 12)).toBe(true)
|
||||
})
|
||||
|
||||
it('clean prose yields no findings', () => {
|
||||
expect(mechanicsFindings('The garden was quiet and the rain fell softly on the leaves.')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
// Each finding becomes a Mandarin-first bubble Line (see tips.ts), often quoting
|
||||
// a short slice of her own sentence so the advice clearly belongs to *this*
|
||||
// paragraph and not a generic tip jar.
|
||||
//
|
||||
// Two consumers share these rules (one source of truth, no duplication):
|
||||
// • the companion bubbles — awareness notes, shown one at a time (analyzeProse)
|
||||
// • the suggestion cards — applyable one-click fixes (mechanicsFindings)
|
||||
// A rule is "applyable" when it can name an exact span and a single replacement;
|
||||
// it attaches a `fix` and feeds the cards. Awareness-only rules (run-ons, comma
|
||||
// splices, …) carry no `fix` and stay companion bubbles. The companion hides
|
||||
// fix-bearing hints so the same span never appears as both a bubble and a card.
|
||||
|
||||
import type { Line } from './tips'
|
||||
|
||||
@@ -17,6 +25,28 @@ export interface ProseHint extends Line {
|
||||
id: string
|
||||
// Rule family, used to vary advice (don't show the same kind twice in a row).
|
||||
rule: string
|
||||
// Present only on *applyable* findings: the exact span and the text to swap in.
|
||||
// These become suggestion cards; the companion skips them (see useCompanion).
|
||||
fix?: Fix
|
||||
}
|
||||
|
||||
// An exact, one-click edit: replace text[from:to] with `replacement`.
|
||||
export interface Fix {
|
||||
from: number
|
||||
to: number
|
||||
replacement: string
|
||||
}
|
||||
|
||||
// A deterministic suggestion-card finding, derived from an applyable hint. Mirrors
|
||||
// the backend's card shape (original/replacement/explanation + span) so the card
|
||||
// pipeline can persist it as the 'mechanics' family. `explanation` is the English
|
||||
// note; the card's own translate action renders Mandarin on demand.
|
||||
export interface MechanicsFinding {
|
||||
from: number
|
||||
to: number
|
||||
original: string
|
||||
replacement: string
|
||||
explanation: string
|
||||
}
|
||||
|
||||
// ── small text helpers ──────────────────────────────────────────────────────
|
||||
@@ -48,11 +78,23 @@ function key(s: string): string {
|
||||
return s.toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 48)
|
||||
}
|
||||
|
||||
// Capitalize `repl`'s first letter when `original` started uppercase, so fixing a
|
||||
// sentence-initial word doesn't quietly lowercase the line.
|
||||
function matchCase(original: string, repl: string): string {
|
||||
if (!original || !repl) return repl
|
||||
const c = original[0]
|
||||
if (c >= 'A' && c <= 'Z') return repl[0].toUpperCase() + repl.slice(1)
|
||||
return repl
|
||||
}
|
||||
|
||||
// ── individual rules ────────────────────────────────────────────────────────
|
||||
// Each rule pushes at most a couple of findings; the caller shows one at a time.
|
||||
// Each rule pushes its findings; awareness rules cap themselves to a couple so a
|
||||
// single sentence can't flood the bubble, while applyable rules report every
|
||||
// occurrence (each becomes its own card). Applyable rules attach a `fix`.
|
||||
|
||||
// Run-on / overly long sentences — the single most common readability problem
|
||||
// for ESL writers, who often chain clauses that a period would serve better.
|
||||
// Awareness-only: there is no single mechanical fix for "split this sentence".
|
||||
function runOns(text: string, out: ProseHint[]) {
|
||||
let found = 0
|
||||
for (const s of sentences(text)) {
|
||||
@@ -110,6 +152,7 @@ const INTRO_LEAD = new Set([
|
||||
// approximate that by requiring the lead to be ≥3 words and not begin with an
|
||||
// introductory word — short or transition-led leads are intro phrases, not
|
||||
// spliced clauses. Precision over recall: a wrong nudge costs more than a miss.
|
||||
// Awareness-only: the fix is ambiguous (period vs. a joining word).
|
||||
function commaSplices(text: string, out: ProseHint[]) {
|
||||
let found = 0
|
||||
for (const s of sentences(text)) {
|
||||
@@ -137,6 +180,7 @@ function commaSplices(text: string, out: ProseHint[]) {
|
||||
// Unclear antecedent — a sentence that opens with This/That/These/Those riding
|
||||
// straight into a verb, with no noun naming what it points back to. Only flag
|
||||
// when there's a prior sentence (so an antecedent is actually in question).
|
||||
// Awareness-only: naming the referent needs the writer.
|
||||
const ANTECEDENT_RE =
|
||||
/^(This|That|These|Those)\s+(is|are|was|were|will|would|can|could|should|makes?|made|means?|shows?|showed|gives?|gave|causes?|caused|creates?|created|leads?|led|results?|happens?|happened|helps?|helped)\b/
|
||||
|
||||
@@ -160,7 +204,8 @@ function antecedents(text: string, out: ProseHint[]) {
|
||||
|
||||
// Oxford comma — a 3+ item list ending “… X and Y” with no comma before the
|
||||
// conjunction. Guarded by a clause-starter stoplist so we don't mistake a
|
||||
// compound clause (“…, but she and I…”) for a list.
|
||||
// compound clause (“…, but she and I…”) for a list. Awareness-only: inserting
|
||||
// the serial comma is borderline-stylistic, so we nudge rather than auto-edit.
|
||||
const OXFORD_RE = /(\w+),\s+([\w'’-]+(?:\s+[\w'’-]+){0,2})\s+(and|or)\s+[\w'’-]+/g
|
||||
const CLAUSE_STARTERS = new Set([
|
||||
'but', 'so', 'because', 'which', 'who', 'that', 'when', 'while', 'if',
|
||||
@@ -187,81 +232,32 @@ function oxford(text: string, out: ProseHint[]) {
|
||||
}
|
||||
}
|
||||
|
||||
// a vs. an — chosen by the *sound* of the next word. We work only on lowercase
|
||||
// words (sidestepping proper nouns / acronyms) and keep sound-exception lists.
|
||||
const A_BEFORE_VOWEL_RE = /\ba\s+([aeiou][a-z]+)\b/g
|
||||
// Vowel-spelled but consonant-sounding → “a” is correct, don't flag.
|
||||
const CONSONANT_SOUND_RE = /^(uni|use|usu|util|euro?|eul|ewe|once|one|ubiqu|unanim)/
|
||||
const AN_BEFORE_CONSONANT_RE = /\ban\s+([b-df-hj-np-tv-z][a-z]+)\b/g
|
||||
// Consonant-spelled but vowel-sounding (silent h) → “an” is correct, don't flag.
|
||||
const VOWEL_SOUND_RE = /^(hour|honest|honou?r|heir|homage)/
|
||||
// A transition word opening a sentence with no comma after it (“However we…”).
|
||||
// Limited to conjunctive adverbs that strongly want the comma — sequence words
|
||||
// like “Then/First/Finally” are left out (their comma is optional). Awareness-
|
||||
// only: it fires per-sentence (offsets are sentence-relative), so we nudge.
|
||||
const INTRO_RE =
|
||||
/^(However|Therefore|Moreover|Furthermore|Nevertheless|Nonetheless|Meanwhile|Consequently|In addition|In conclusion|As a result|On the other hand|For example|For instance)\s+[A-Za-z]/
|
||||
|
||||
function articles(text: string, out: ProseHint[]) {
|
||||
let m: RegExpExecArray | null
|
||||
let found = 0
|
||||
A_BEFORE_VOWEL_RE.lastIndex = 0
|
||||
while ((m = A_BEFORE_VOWEL_RE.exec(text)) && found < 1) {
|
||||
if (CONSONANT_SOUND_RE.test(m[1])) continue
|
||||
found++
|
||||
out.push({
|
||||
id: `article-an:${key(m[1])}`,
|
||||
rule: 'article',
|
||||
zh: `元音开头的词前用 “an”:“an ${m[1]}”。`,
|
||||
en: `Before a vowel sound, use “an”: “an ${m[1]}”.`,
|
||||
})
|
||||
}
|
||||
AN_BEFORE_CONSONANT_RE.lastIndex = 0
|
||||
while ((m = AN_BEFORE_CONSONANT_RE.exec(text)) && found < 2) {
|
||||
if (VOWEL_SOUND_RE.test(m[1])) continue
|
||||
found++
|
||||
out.push({
|
||||
id: `article-a:${key(m[1])}`,
|
||||
rule: 'article',
|
||||
zh: `辅音开头的词前用 “a”:“a ${m[1]}”。`,
|
||||
en: `Before a consonant sound, use “a”: “a ${m[1]}”.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Doubled word — “the the”, “is is”. Excludes the handful of doublings that can
|
||||
// be legitimate (“the fact that that happened”, “she had had enough”).
|
||||
const DOUBLE_RE = /\b([A-Za-z]+)\s+\1\b/gi
|
||||
const LEGIT_DOUBLES = new Set(['that', 'had'])
|
||||
|
||||
function doubles(text: string, out: ProseHint[]) {
|
||||
let m: RegExpExecArray | null
|
||||
let found = 0
|
||||
DOUBLE_RE.lastIndex = 0
|
||||
while ((m = DOUBLE_RE.exec(text)) && found < 1) {
|
||||
const w = m[1].toLowerCase()
|
||||
if (LEGIT_DOUBLES.has(w)) continue
|
||||
found++
|
||||
out.push({
|
||||
id: `double:${key(m[0])}`,
|
||||
rule: 'double',
|
||||
zh: `“${m[1]}” 好像写了两遍,检查一下哦。`,
|
||||
en: `“${m[1]} ${m[1]}” — looks like a word got doubled.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Lowercase standalone “I”. Conservative: only when bounded by spaces / line
|
||||
// edge, to avoid tangling with “i.e.” and the like.
|
||||
const LOWER_I_RE = /(?:^|\s)i(?=\s|$)/m
|
||||
|
||||
function pronounI(text: string, out: ProseHint[]) {
|
||||
if (LOWER_I_RE.test(text)) {
|
||||
out.push({
|
||||
id: 'cap-i',
|
||||
rule: 'cap-i',
|
||||
zh: '英文里的 “I”(我)任何时候都要大写哦。',
|
||||
en: 'In English, “I” is always written as a capital letter.',
|
||||
})
|
||||
function introComma(text: string, out: ProseHint[]) {
|
||||
for (const s of sentences(text)) {
|
||||
const m = s.match(INTRO_RE)
|
||||
if (m) {
|
||||
out.push({
|
||||
id: `introcomma:${m[1].toLowerCase()}`,
|
||||
rule: 'introcomma',
|
||||
zh: `开头的过渡词后面加个逗号:“${m[1]}, …”。`,
|
||||
en: `Put a comma after the opening transition: “${m[1]}, …”.`,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sentence not starting with a capital. We look after a terminator, and ignore
|
||||
// CJK sentences (she writes Mandarin too — those don't take Latin capitals).
|
||||
// Awareness-only: a bare “. x” also matches abbreviations (“U.S. then”), so we
|
||||
// nudge rather than auto-capitalize.
|
||||
const LOWER_START_RE = /[.!?]\s+([a-z])/
|
||||
|
||||
function sentenceCaps(text: string, out: ProseHint[]) {
|
||||
@@ -275,40 +271,148 @@ function sentenceCaps(text: string, out: ProseHint[]) {
|
||||
}
|
||||
}
|
||||
|
||||
// A stray space *before* punctuation (a common habit carried from CJK spacing).
|
||||
const SPACE_BEFORE_PUNCT_RE = /[A-Za-z] +([,;:!?]|\.(?!\.))/
|
||||
// ── applyable rules (also feed the suggestion cards) ─────────────────────────
|
||||
|
||||
function spaceBeforePunct(text: string, out: ProseHint[]) {
|
||||
if (SPACE_BEFORE_PUNCT_RE.test(text)) {
|
||||
// Doubled word — “the the”, “is is”. Excludes the handful of doublings that can
|
||||
// be legitimate (“the fact that that happened”, “she had had enough”).
|
||||
const DOUBLE_RE = /\b([A-Za-z]+)(\s+)\1\b/gi
|
||||
const LEGIT_DOUBLES = new Set(['that', 'had'])
|
||||
|
||||
function doubles(text: string, out: ProseHint[]) {
|
||||
let m: RegExpExecArray | null
|
||||
DOUBLE_RE.lastIndex = 0
|
||||
while ((m = DOUBLE_RE.exec(text))) {
|
||||
const w = m[1].toLowerCase()
|
||||
if (LEGIT_DOUBLES.has(w)) continue
|
||||
const from = m.index
|
||||
const to = from + m[0].length
|
||||
out.push({
|
||||
id: 'space-punct',
|
||||
rule: 'space-punct',
|
||||
zh: '标点前面不用空格,逗号、句号紧跟在前一个词后面就好。',
|
||||
en: 'No space before punctuation — it tucks right against the word.',
|
||||
id: `double:${from}:${key(m[0])}`,
|
||||
rule: 'double',
|
||||
zh: `“${m[1]}” 好像写了两遍,检查一下哦。`,
|
||||
en: `“${m[1]} ${m[1]}” — looks like a word got doubled.`,
|
||||
fix: { from, to, replacement: m[1] },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Chinese-L1 interference rules (Tier 1) ──────────────────────────────────
|
||||
// Mandarin lacks plural inflection, articles, and verb agreement, so these
|
||||
// patterns are the predictable places that grammar "leaks" into her English.
|
||||
// All are closed-list or strong-signal to keep false positives near zero.
|
||||
// Lowercase standalone “I”. Conservative: only when bounded by spaces / line
|
||||
// edge, to avoid tangling with “i.e.” and the like. Awareness-only: a single
|
||||
// character can't be re-anchored unambiguously by string (every word holds an
|
||||
// “i”), so this stays a companion nudge rather than a one-click card.
|
||||
const LOWER_I_RE = /(^|[^\p{L}\p{N}_])i(?=$|[^\p{L}\p{N}_])/mu
|
||||
|
||||
function pronounI(text: string, out: ProseHint[]) {
|
||||
const m = LOWER_I_RE.exec(text)
|
||||
LOWER_I_RE.lastIndex = 0
|
||||
if (!m) return
|
||||
const at = m.index + m[1].length
|
||||
if (text[at + 1] === '.') return // “i.e.” etc. — precision over recall
|
||||
out.push({
|
||||
id: 'cap-i',
|
||||
rule: 'cap-i',
|
||||
zh: '英文里的 “I”(我)任何时候都要大写哦。',
|
||||
en: 'In English, “I” is always written as a capital letter.',
|
||||
})
|
||||
}
|
||||
|
||||
// A stray space *before* punctuation (a common habit carried from CJK spacing).
|
||||
const SPACE_BEFORE_PUNCT_RE = /([A-Za-z]+) +([,;:!?]|\.(?!\.))/g
|
||||
|
||||
function spaceBeforePunct(text: string, out: ProseHint[]) {
|
||||
let m: RegExpExecArray | null
|
||||
SPACE_BEFORE_PUNCT_RE.lastIndex = 0
|
||||
while ((m = SPACE_BEFORE_PUNCT_RE.exec(text))) {
|
||||
const from = m.index
|
||||
const to = from + m[0].length
|
||||
out.push({
|
||||
id: `space-punct:${from}`,
|
||||
rule: 'space-punct',
|
||||
zh: '标点前面不用空格,逗号、句号紧跟在前一个词后面就好。',
|
||||
en: 'No space before punctuation — it tucks right against the word.',
|
||||
fix: { from, to, replacement: m[1] + m[2] },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Missing space *after* a comma or sentence period (“apple,banana”, “end.Next”).
|
||||
// The comma case requires letters on both sides so numbers like 1,000 are safe;
|
||||
// the period case requires a real word boundary so “e.g.”/“U.S.” are skipped.
|
||||
const NO_SPACE_COMMA_RE = /([A-Za-z]+,)([A-Za-z]+)/g
|
||||
const NO_SPACE_PERIOD_RE = /([a-z]{2,}\.)([A-Z][a-z]+)/g
|
||||
|
||||
function spaceAfterPunct(text: string, out: ProseHint[]) {
|
||||
for (const re of [NO_SPACE_COMMA_RE, NO_SPACE_PERIOD_RE]) {
|
||||
let m: RegExpExecArray | null
|
||||
re.lastIndex = 0
|
||||
while ((m = re.exec(text))) {
|
||||
const from = m.index
|
||||
const to = from + m[0].length
|
||||
out.push({
|
||||
id: `space-after:${from}`,
|
||||
rule: 'space-after',
|
||||
zh: '逗号、句号后面要空一格,再接下一个词。',
|
||||
en: 'Add a space after a comma or period before the next word.',
|
||||
fix: { from, to, replacement: `${m[1]} ${m[2]}` },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// a vs. an — chosen by the *sound* of the next word. We work only on lowercase
|
||||
// words (sidestepping proper nouns / acronyms) and keep sound-exception lists.
|
||||
const A_BEFORE_VOWEL_RE = /\b(a)\s+([aeiou][a-z]+)\b/g
|
||||
// Vowel-spelled but consonant-sounding → “a” is correct, don't flag.
|
||||
const CONSONANT_SOUND_RE = /^(uni|use|usu|util|euro?|eul|ewe|once|one|ubiqu|unanim)/
|
||||
const AN_BEFORE_CONSONANT_RE = /\b(an)\s+([b-df-hj-np-tv-z][a-z]+)\b/g
|
||||
// Consonant-spelled but vowel-sounding (silent h) → “an” is correct, don't flag.
|
||||
const VOWEL_SOUND_RE = /^(hour|honest|honou?r|heir|homage)/
|
||||
|
||||
function articles(text: string, out: ProseHint[]) {
|
||||
let m: RegExpExecArray | null
|
||||
A_BEFORE_VOWEL_RE.lastIndex = 0
|
||||
while ((m = A_BEFORE_VOWEL_RE.exec(text))) {
|
||||
if (CONSONANT_SOUND_RE.test(m[2])) continue
|
||||
// Span the whole "a <noun>" so the original is a distinctive, anchorable
|
||||
// phrase (a bare "a" matches everywhere). Replacement swaps only the article.
|
||||
out.push({
|
||||
id: `article-an:${m.index}`,
|
||||
rule: 'article',
|
||||
zh: `元音开头的词前用 “an”:“an ${m[2]}”。`,
|
||||
en: `Before a vowel sound, use “an”: “an ${m[2]}”.`,
|
||||
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], 'an') + m[0].slice(m[1].length) },
|
||||
})
|
||||
}
|
||||
AN_BEFORE_CONSONANT_RE.lastIndex = 0
|
||||
while ((m = AN_BEFORE_CONSONANT_RE.exec(text))) {
|
||||
if (VOWEL_SOUND_RE.test(m[2])) continue
|
||||
out.push({
|
||||
id: `article-a:${m.index}`,
|
||||
rule: 'article',
|
||||
zh: `辅音开头的词前用 “a”:“a ${m[2]}”。`,
|
||||
en: `Before a consonant sound, use “a”: “a ${m[2]}”.`,
|
||||
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], 'a') + m[0].slice(m[1].length) },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Mass nouns that Mandarin speakers very commonly pluralize. These words are
|
||||
// essentially never valid with a trailing “s”, so the list is its own guard.
|
||||
const UNCOUNTABLE_PLURAL_RE =
|
||||
/\b(informations|advices|knowledges|equipments|furnitures|homeworks|softwares|hardwares|luggages|baggages|sceneries|machineries)\b/i
|
||||
/\b(informations|advices|knowledges|equipments|furnitures|homeworks|softwares|hardwares|luggages|baggages|sceneries|machineries)\b/gi
|
||||
|
||||
function uncountables(text: string, out: ProseHint[]) {
|
||||
const m = UNCOUNTABLE_PLURAL_RE.exec(text)
|
||||
let m: RegExpExecArray | null
|
||||
UNCOUNTABLE_PLURAL_RE.lastIndex = 0
|
||||
if (m) {
|
||||
const singular = m[1].replace(/s$/i, '')
|
||||
while ((m = UNCOUNTABLE_PLURAL_RE.exec(text))) {
|
||||
const word = m[1]
|
||||
const singular = word.replace(/s$/i, '')
|
||||
out.push({
|
||||
id: `uncountable:${m[1].toLowerCase()}`,
|
||||
id: `uncountable:${m.index}`,
|
||||
rule: 'uncountable',
|
||||
zh: `“${m[1]}” 是不可数名词,不用加 s,写 “${singular}” 就好。`,
|
||||
en: `“${m[1]}” is uncountable — drop the “s”: just “${singular}”.`,
|
||||
zh: `“${word}” 是不可数名词,不用加 s,写 “${singular}” 就好。`,
|
||||
en: `“${word}” is uncountable — drop the “s”: just “${singular}”.`,
|
||||
fix: { from: m.index, to: m.index + word.length, replacement: singular },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -324,17 +428,20 @@ const PROPER_NOUNS =
|
||||
'italian|russian|portuguese|arabic|vietnamese|thai|american|british|canadian|' +
|
||||
'australian|mexican|brazilian|european'
|
||||
// Case-sensitive (lowercase-only) so already-capitalized words aren't flagged.
|
||||
const PROPER_NOUN_RE = new RegExp(`\\b(${PROPER_NOUNS})\\b`)
|
||||
const PROPER_NOUN_RE = new RegExp(`\\b(${PROPER_NOUNS})\\b`, 'g')
|
||||
|
||||
function properCaps(text: string, out: ProseHint[]) {
|
||||
const m = PROPER_NOUN_RE.exec(text)
|
||||
if (m) {
|
||||
const fixed = m[1][0].toUpperCase() + m[1].slice(1)
|
||||
let m: RegExpExecArray | null
|
||||
PROPER_NOUN_RE.lastIndex = 0
|
||||
while ((m = PROPER_NOUN_RE.exec(text))) {
|
||||
const word = m[1]
|
||||
const fixed = word[0].toUpperCase() + word.slice(1)
|
||||
out.push({
|
||||
id: `propercap:${m[1]}`,
|
||||
id: `propercap:${m.index}`,
|
||||
rule: 'propercap',
|
||||
zh: `语言、国籍、星期和月份在英文里要大写:“${fixed}”。`,
|
||||
en: `Languages, days, and months are capitalized in English: “${fixed}”.`,
|
||||
fix: { from: m.index, to: m.index + word.length, replacement: fixed },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -367,17 +474,20 @@ const CAUSATIVE = new Set([
|
||||
|
||||
function subjectVerbAgreement(text: string, out: ProseHint[]) {
|
||||
let m: RegExpExecArray | null
|
||||
let found = 0
|
||||
SVA_RE.lastIndex = 0
|
||||
while ((m = SVA_RE.exec(text)) && found < 2) {
|
||||
while ((m = SVA_RE.exec(text))) {
|
||||
if (m[1] && CAUSATIVE.has(m[1].toLowerCase())) continue
|
||||
found++
|
||||
const fixed = third(m[3])
|
||||
const verb = m[3]
|
||||
const fixed = third(verb)
|
||||
// Span the whole match (subject + verb, plus any captured lead) so the
|
||||
// original anchors; the replacement corrects only the trailing verb.
|
||||
const head = m[0].slice(0, m[0].length - verb.length)
|
||||
out.push({
|
||||
id: `sva:${m[2].toLowerCase()}:${m[3].toLowerCase()}`,
|
||||
id: `sva:${m.index}`,
|
||||
rule: 'sva',
|
||||
zh: `主语是 he/she/it 时,动词要加 -s:“${m[2]} ${fixed}”。`,
|
||||
en: `After he/she/it the verb takes “-s”: “${m[2]} ${fixed}”.`,
|
||||
fix: { from: m.index, to: m.index + m[0].length, replacement: head + matchCase(verb, fixed) },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -397,35 +507,40 @@ const NON_S_PLURAL = new Set([
|
||||
|
||||
function pluralAfterNumber(text: string, out: ProseHint[]) {
|
||||
let m: RegExpExecArray | null
|
||||
let found = 0
|
||||
NUMBER_NOUN_RE.lastIndex = 0
|
||||
while ((m = NUMBER_NOUN_RE.exec(text)) && found < 1) {
|
||||
while ((m = NUMBER_NOUN_RE.exec(text))) {
|
||||
const noun = m[2].toLowerCase()
|
||||
if (noun.endsWith('s') || NON_S_PLURAL.has(noun)) continue
|
||||
found++
|
||||
// Span "<number> <noun>" so the original anchors; append “s” to the noun.
|
||||
out.push({
|
||||
id: `plural:${key(m[0])}`,
|
||||
id: `plural:${m.index}`,
|
||||
rule: 'plural',
|
||||
zh: `“${m[1]}” 后面的名词要用复数:“${m[1]} ${m[2]}s”。`,
|
||||
en: `After “${m[1]}”, the noun is plural: “${m[1]} ${m[2]}s”.`,
|
||||
fix: { from: m.index, to: m.index + m[0].length, replacement: `${m[0]}s` },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Two stacked determiners (“the my book”, “a the”) — Mandarin uses a bare
|
||||
// possessive, so the article often gets stacked on top. One of the two must go.
|
||||
// possessive, so the article often gets stacked on top. The article goes.
|
||||
const DOUBLE_DET_RE =
|
||||
/\b(a|an|the)\s+(a|an|the|my|your|his|her|its|our|their)\b/gi
|
||||
/\b(a|an|the)(\s+)(a|an|the|my|your|his|her|its|our|their)\b/gi
|
||||
|
||||
function doubleDeterminer(text: string, out: ProseHint[]) {
|
||||
const m = DOUBLE_DET_RE.exec(text)
|
||||
let m: RegExpExecArray | null
|
||||
DOUBLE_DET_RE.lastIndex = 0
|
||||
if (m) {
|
||||
while ((m = DOUBLE_DET_RE.exec(text))) {
|
||||
// Two *identical* words ("the the") are a doubled word, not stacked
|
||||
// determiners — leave that to the `doubles` rule so we don't double-flag.
|
||||
if (m[1].toLowerCase() === m[3].toLowerCase()) continue
|
||||
out.push({
|
||||
id: `doubledet:${key(m[0])}`,
|
||||
id: `doubledet:${m.index}`,
|
||||
rule: 'doubledet',
|
||||
zh: `“${m[1]} ${m[2]}” 用了两个限定词,留一个就好(比如去掉 “${m[1]}”)。`,
|
||||
en: `“${m[1]} ${m[2]}” stacks two determiners — keep just one.`,
|
||||
zh: `“${m[1]} ${m[3]}” 用了两个限定词,留一个就好(比如去掉 “${m[1]}”)。`,
|
||||
en: `“${m[1]} ${m[3]}” stacks two determiners — keep just one.`,
|
||||
// Drop the article (m[1]); keep the second determiner, casing preserved.
|
||||
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], m[3]) },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -434,46 +549,52 @@ function doubleDeterminer(text: string, out: ProseHint[]) {
|
||||
// on clearly-plural cues (skip “some/any”, which are fine with singular mass
|
||||
// nouns: “there is some water”).
|
||||
const THERE_IS_RE =
|
||||
/\bthere(?:\s+is|'s|’s)\s+(many|several|numerous|various|few|two|three|four|five|six|seven|eight|nine|ten)\b/i
|
||||
/\bthere(\s+is|'s|’s)\s+(many|several|numerous|various|few|two|three|four|five|six|seven|eight|nine|ten)\b/gi
|
||||
|
||||
function thereIsPlural(text: string, out: ProseHint[]) {
|
||||
const m = THERE_IS_RE.exec(text)
|
||||
let m: RegExpExecArray | null
|
||||
THERE_IS_RE.lastIndex = 0
|
||||
if (m) {
|
||||
while ((m = THERE_IS_RE.exec(text))) {
|
||||
// Span the whole "there is <plural>" phrase so it anchors; swap the verb part
|
||||
// (m[1]: “ is” / “'s”) for “ are”, preserving the “there” casing and the cue.
|
||||
const replacement = m[0].slice(0, 5) + ' are' + m[0].slice(5 + m[1].length)
|
||||
out.push({
|
||||
id: `thereis:${m[1].toLowerCase()}`,
|
||||
id: `thereis:${m.index}`,
|
||||
rule: 'thereis',
|
||||
zh: `后面是复数时用 “there are”:“there are ${m[1]}…”。`,
|
||||
en: `With a plural, use “there are”: “there are ${m[1]}…”.`,
|
||||
zh: `后面是复数时用 “there are”:“there are ${m[2]}…”。`,
|
||||
en: `With a plural, use “there are”: “there are ${m[2]}…”.`,
|
||||
fix: { from: m.index, to: m.index + m[0].length, replacement },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── confusables (Tier 2) ─────────────────────────────────────────────────────
|
||||
|
||||
// its / it's — only the two unambiguous directions: “it's own” (always its own)
|
||||
// and “its a/an” (the possessive can't take an article → it's a/an).
|
||||
const ITS_OWN_RE = /\bit's\s+own\b/i
|
||||
const ITS_ARTICLE_RE = /\bits\s+(a|an)\b/i
|
||||
const ITS_OWN_RE = /\b(it's|it’s)\s+own\b/gi
|
||||
const ITS_ARTICLE_RE = /\bits\s+(a|an)\b/gi
|
||||
|
||||
function itsConfusion(text: string, out: ProseHint[]) {
|
||||
if (ITS_OWN_RE.test(text)) {
|
||||
let m: RegExpExecArray | null
|
||||
ITS_OWN_RE.lastIndex = 0
|
||||
while ((m = ITS_OWN_RE.exec(text))) {
|
||||
// Span "it's own" so it anchors; swap the leading token to the possessive.
|
||||
out.push({
|
||||
id: 'its-own',
|
||||
id: `its-own:${m.index}`,
|
||||
rule: 'its',
|
||||
zh: '“it’s” = “it is”;表示“它的”要用 “its”,所以是 “its own”。',
|
||||
en: '“it’s” means “it is” — the possessive is “its”: “its own”.',
|
||||
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], 'its') + m[0].slice(m[1].length) },
|
||||
})
|
||||
return
|
||||
}
|
||||
const m = ITS_ARTICLE_RE.exec(text)
|
||||
ITS_ARTICLE_RE.lastIndex = 0
|
||||
if (m) {
|
||||
while ((m = ITS_ARTICLE_RE.exec(text))) {
|
||||
// Span "its a/an" so it anchors; swap "its" → "it's".
|
||||
out.push({
|
||||
id: 'its-article',
|
||||
id: `its-article:${m.index}`,
|
||||
rule: 'its',
|
||||
zh: `这里应该是 “it’s ${m[1]}”(it is),“its” 是“它的”。`,
|
||||
en: `Here it should be “it’s ${m[1]}” (it is); “its” means belonging to it.`,
|
||||
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[0][0], "it's") + m[0].slice(3) },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -485,57 +606,20 @@ const COMPARATIVES =
|
||||
'better|more|less|rather|worse|greater|other|older|younger|bigger|smaller|' +
|
||||
'larger|faster|slower|higher|lower|cheaper|stronger|weaker|easier|harder|' +
|
||||
'earlier|later|sooner|longer|shorter|taller|richer|poorer|happier|safer'
|
||||
const THAN_THEN_RE = new RegExp(`\\b(${COMPARATIVES})\\s+then\\b`, 'i')
|
||||
const THAN_THEN_RE = new RegExp(`\\b(${COMPARATIVES})(\\s+)then\\b`, 'gi')
|
||||
|
||||
function thanThen(text: string, out: ProseHint[]) {
|
||||
const m = THAN_THEN_RE.exec(text)
|
||||
if (m) {
|
||||
let m: RegExpExecArray | null
|
||||
THAN_THEN_RE.lastIndex = 0
|
||||
while ((m = THAN_THEN_RE.exec(text))) {
|
||||
// Span "<comparative> then" so it anchors; swap the trailing “then” → “than”.
|
||||
const thenFrom = m.index + m[0].length - 4
|
||||
out.push({
|
||||
id: `than:${m[1].toLowerCase()}`,
|
||||
id: `than:${m.index}`,
|
||||
rule: 'than',
|
||||
zh: `比较的时候用 “than”,不是 “then”:“${m[1]} than”。`,
|
||||
en: `For comparisons use “than”, not “then”: “${m[1]} than”.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A transition word opening a sentence with no comma after it (“However we…”).
|
||||
// Limited to conjunctive adverbs that strongly want the comma — sequence words
|
||||
// like “Then/First/Finally” are left out (their comma is optional).
|
||||
const INTRO_RE =
|
||||
/^(However|Therefore|Moreover|Furthermore|Nevertheless|Nonetheless|Meanwhile|Consequently|In addition|In conclusion|As a result|On the other hand|For example|For instance)\s+[A-Za-z]/
|
||||
|
||||
function introComma(text: string, out: ProseHint[]) {
|
||||
let found = false
|
||||
for (const s of sentences(text)) {
|
||||
const m = s.match(INTRO_RE)
|
||||
if (m) {
|
||||
out.push({
|
||||
id: `introcomma:${m[1].toLowerCase()}`,
|
||||
rule: 'introcomma',
|
||||
zh: `开头的过渡词后面加个逗号:“${m[1]}, …”。`,
|
||||
en: `Put a comma after the opening transition: “${m[1]}, …”.`,
|
||||
})
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// Missing space *after* a comma or sentence period (“apple,banana”, “end.Next”).
|
||||
// The comma case requires letters on both sides so numbers like 1,000 are safe;
|
||||
// the period case requires a real word boundary so “e.g.”/“U.S.” are skipped.
|
||||
const NO_SPACE_COMMA_RE = /[A-Za-z],[A-Za-z]/
|
||||
const NO_SPACE_PERIOD_RE = /[a-z]{2,}\.[A-Z][a-z]/
|
||||
|
||||
function spaceAfterPunct(text: string, out: ProseHint[]) {
|
||||
if (NO_SPACE_COMMA_RE.test(text) || NO_SPACE_PERIOD_RE.test(text)) {
|
||||
out.push({
|
||||
id: 'space-after',
|
||||
rule: 'space-after',
|
||||
zh: '逗号、句号后面要空一格,再接下一个词。',
|
||||
en: 'Add a space after a comma or period before the next word.',
|
||||
fix: { from: m.index, to: m.index + m[0].length, replacement: m[0].slice(0, m[0].length - 4) + matchCase(text[thenFrom], 'than') },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -568,6 +652,8 @@ const RULES: Array<(text: string, out: ProseHint[]) => void> = [
|
||||
|
||||
// analyzeProse returns context-aware hints, highest-priority first. It bails on
|
||||
// text too short to advise on (mid-thought drafts shouldn't get picked apart).
|
||||
// Hints that carry a `fix` are applyable (they also surface as suggestion cards);
|
||||
// the companion filters those out so a span isn't both a bubble and a card.
|
||||
export function analyzeProse(text: string): ProseHint[] {
|
||||
const englishWords = text.match(ENGLISH_WORD_RE)?.length ?? 0
|
||||
if (englishWords < 8) return []
|
||||
@@ -575,3 +661,35 @@ export function analyzeProse(text: string): ProseHint[] {
|
||||
for (const rule of RULES) rule(text, out)
|
||||
return out
|
||||
}
|
||||
|
||||
// mechanicsFindings returns every applyable deterministic fix in the text, as
|
||||
// suggestion-card findings with exact spans. No word-count floor: a doubled word
|
||||
// or a stray lowercase “i” is worth fixing even in a short draft, the way a
|
||||
// spell-checker would. The card pipeline persists these as the 'mechanics'
|
||||
// family; collisions with the LLM cards are resolved server-side (mechanics
|
||||
// wins, since its span is exact).
|
||||
export function mechanicsFindings(text: string): MechanicsFinding[] {
|
||||
const hints: ProseHint[] = []
|
||||
for (const rule of RULES) rule(text, hints)
|
||||
const found: MechanicsFinding[] = []
|
||||
for (const h of hints) {
|
||||
if (!h.fix) continue
|
||||
const { from, to, replacement } = h.fix
|
||||
const original = text.slice(from, to)
|
||||
if (!original || original === replacement) continue
|
||||
found.push({ from, to, original, replacement, explanation: h.en })
|
||||
}
|
||||
// Two rules can occasionally claim overlapping spans (e.g. a doubled word that
|
||||
// also reads as stacked determiners). Resolve to one card per stretch of text:
|
||||
// earlier start wins, ties broken by the longer span. The backend resolves the
|
||||
// separate mechanics-vs-LLM collisions; this handles mechanics-vs-mechanics.
|
||||
found.sort((a, b) => (a.from !== b.from ? a.from - b.from : b.to - a.to))
|
||||
const out: MechanicsFinding[] = []
|
||||
let lastEnd = -1
|
||||
for (const f of found) {
|
||||
if (f.from < lastEnd) continue
|
||||
out.push(f)
|
||||
lastEnd = f.to
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -49,6 +49,13 @@ export const BEDTIME: Line[] = [
|
||||
{ zh: '太累可写不出好文字呀,早点歇着吧 🌙', en: 'A tired writer is a bad writer — get some rest.' },
|
||||
{ zh: '好好睡一觉,灵感自己会来 ✨', en: 'Sleep is a wondrous enabler.' },
|
||||
{ zh: '听见了吗?没有吧——大家都睡了,你也该睡啦 😴', en: "Hear that? No… you don't, because everyone is sleeping and you should be too." },
|
||||
// A few old Chinese proverbs on sleep (zh = a faithful rendering of the
|
||||
// English sense — the verified classical 原文 isn't recoverable; swap in the
|
||||
// exact source text if you have it). They read a touch wittier/wiser than the
|
||||
// gentle lines above, which suits a late-night nudge.
|
||||
{ zh: '一夜不眠,十日不安。', en: "The loss of one night's sleep is followed by ten days of inconvenience." },
|
||||
{ zh: '前半夜醒着想自己的过错,后半夜睡着才想别人的不是。', en: 'Think of your own faults the first part of the night when you are awake, and of the faults of others the latter part of the night when you are asleep.' },
|
||||
{ zh: '黄昏与人相骂,夜半独自难眠。', en: 'Curse your spouse at evening, sleep alone at night.' },
|
||||
]
|
||||
|
||||
// First hello when the app opens.
|
||||
|
||||
@@ -127,7 +127,10 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
|
||||
// every finding was shown recently. Avoids repeating a finding or the same
|
||||
// rule family back-to-back so the companion never feels like a broken record.
|
||||
const nextTip = useCallback((): Bubble => {
|
||||
const hints = analyzeProse(textRef.current)
|
||||
// Applyable hints (those with a `fix`) surface as one-click suggestion cards,
|
||||
// so the companion skips them — a span shouldn't be both a bubble and a card.
|
||||
// What's left is the awareness notes (run-ons, splices, …) the kitten alone gives.
|
||||
const hints = analyzeProse(textRef.current).filter((h) => !h.fix)
|
||||
const fresh = hints.find(
|
||||
(h) => !recentHints.current.includes(h.id) && h.rule !== lastRule.current,
|
||||
)
|
||||
|
||||
@@ -34,9 +34,12 @@ export function AskPetal({ suggestionId, explanation }: Props) {
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
}, [messages])
|
||||
|
||||
// Focus the input when the panel opens.
|
||||
// Focus the input when the panel opens. preventScroll: the card is already on
|
||||
// screen as an absolutely-positioned overlay, and a default focus() would make
|
||||
// the browser scroll its ancestor to "reveal" the input — jumping the document
|
||||
// to the top.
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
inputRef.current?.focus({ preventScroll: true })
|
||||
}, [])
|
||||
|
||||
// Fetch the Chinese translation of the explanation to seed the first bubble.
|
||||
@@ -95,7 +98,7 @@ export function AskPetal({ suggestionId, explanation }: Props) {
|
||||
console.error('Ask Petal chat failed:', err)
|
||||
} finally {
|
||||
setStreaming(false)
|
||||
inputRef.current?.focus()
|
||||
inputRef.current?.focus({ preventScroll: true })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,10 @@ interface Props {
|
||||
// it runs (drives the toolbar button's loading state).
|
||||
onVoiceCheck: () => void
|
||||
voicing: boolean
|
||||
// Triggers the whole-document collocation coach; `collocating` is true while it
|
||||
// runs (drives the toolbar button's loading state).
|
||||
onCollocationCheck: () => void
|
||||
collocating: boolean
|
||||
// Fired when the editor gains focus, so the app can enter distraction-free mode.
|
||||
onFocusMode?: () => void
|
||||
// Browser-side spell checker (null until the dictionary loads). Adding a word
|
||||
@@ -81,6 +85,33 @@ interface WordInfoState {
|
||||
left: number
|
||||
loading: boolean
|
||||
info: WordInfo | null
|
||||
// Garden state: the captured word's id (null until the auto-capture returns or
|
||||
// after it's removed) and whether it's currently in the garden.
|
||||
vocabId: string | null
|
||||
saved: boolean
|
||||
}
|
||||
|
||||
// sentenceAround pulls the sentence containing `word` out of a block of text, so
|
||||
// a captured vocab word carries the context it was met in. Falls back to the
|
||||
// whole (trimmed, length-capped) text when no sentence boundary is found.
|
||||
function sentenceAround(text: string, wordStart: number): string {
|
||||
const stops = /[.!?。!?\n]/
|
||||
let start = 0
|
||||
for (let i = wordStart - 1; i >= 0; i--) {
|
||||
if (stops.test(text[i])) {
|
||||
start = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
let end = text.length
|
||||
for (let i = wordStart; i < text.length; i++) {
|
||||
if (stops.test(text[i])) {
|
||||
end = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
const s = text.slice(start, end).trim()
|
||||
return s.length > 240 ? s.slice(0, 240).trim() + '…' : s
|
||||
}
|
||||
|
||||
// A tiny CSS-only confetti burst played at an accept. Four palette-colored dots
|
||||
@@ -185,6 +216,8 @@ export function EditorCore({
|
||||
onDismiss,
|
||||
onVoiceCheck,
|
||||
voicing,
|
||||
onCollocationCheck,
|
||||
collocating,
|
||||
onFocusMode,
|
||||
spellChecker,
|
||||
onAddWord,
|
||||
@@ -547,9 +580,37 @@ export function EditorCore({
|
||||
[onDismiss, closeCard],
|
||||
)
|
||||
|
||||
// Click a red-underlined word to open its spelling popover, anchored under the
|
||||
// word. posAtCoords→wordAt resolves the exact PM span (robust to the same
|
||||
// misspelling appearing elsewhere); nspell supplies the corrections.
|
||||
// openMisspellAt resolves the word at a document position and, if the checker
|
||||
// flags it as misspelled, opens the spelling popover anchored under the word.
|
||||
// Returns true if it opened a card. We resolve the word from coordinates and
|
||||
// the checker rather than from the `.petal-misspelling` DOM span: clicking a
|
||||
// word moves the caret into it, which fires the decoration rebuild that
|
||||
// deliberately un-underlines the caret word (see SpellCheck's caret exemption),
|
||||
// so by the time a click/contextmenu handler runs the span is already gone.
|
||||
const openMisspellAt = useCallback(
|
||||
(pos: number): boolean => {
|
||||
if (!editor || !spellChecker) return false
|
||||
const range = wordAt(editor.state.doc, pos)
|
||||
if (!range || spellChecker.correct(range.word)) return false
|
||||
const wrapper = wrapperRef.current
|
||||
if (!wrapper) return false
|
||||
const start = editor.view.coordsAtPos(range.from)
|
||||
const end = editor.view.coordsAtPos(range.to)
|
||||
const wrapRect = wrapper.getBoundingClientRect()
|
||||
const cardWidth = 240
|
||||
const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - cardWidth))
|
||||
const top = end.bottom - wrapRect.top + 6
|
||||
// Opening a spelling popover supersedes any AI-suggestion or lookup card.
|
||||
closeCard()
|
||||
setWordInfo(null)
|
||||
setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
|
||||
return true
|
||||
},
|
||||
[editor, spellChecker, closeCard],
|
||||
)
|
||||
|
||||
// Click a misspelled word to open its spelling popover, anchored under the
|
||||
// word; nspell supplies the corrections.
|
||||
//
|
||||
// Tapping an AI-suggestion highlight also opens its card here — on touch there's
|
||||
// no hover, so the tap is the only way in (mouse users still get hover).
|
||||
@@ -570,26 +631,16 @@ export function EditorCore({
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!editor || !spellChecker) return
|
||||
const target = (e.target as HTMLElement).closest('.petal-misspelling') as HTMLElement | null
|
||||
if (!target) return
|
||||
const wrapper = wrapperRef.current
|
||||
if (!wrapper) return
|
||||
if (!editor) return
|
||||
// Only act on clicks in the editor text itself — the floating cards/popovers
|
||||
// are children of this same wrapper, and a click on one shouldn't resolve a
|
||||
// (hidden) word behind it.
|
||||
if (!(e.target as HTMLElement).closest('.petal-prose')) return
|
||||
const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY })
|
||||
if (!coords) return
|
||||
const range = wordAt(editor.state.doc, coords.pos)
|
||||
if (!range) return
|
||||
const elRect = target.getBoundingClientRect()
|
||||
const wrapRect = wrapper.getBoundingClientRect()
|
||||
const cardWidth = 240
|
||||
const left = Math.max(0, Math.min(elRect.left - wrapRect.left, wrapper.clientWidth - cardWidth))
|
||||
const top = elRect.bottom - wrapRect.top + 6
|
||||
// Opening a spelling popover supersedes any AI-suggestion hover card.
|
||||
closeCard()
|
||||
setWordInfo(null)
|
||||
setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
|
||||
openMisspellAt(coords.pos)
|
||||
},
|
||||
[editor, spellChecker, closeCard, openCardFor, railEnabled],
|
||||
[editor, openMisspellAt, openCardFor, railEnabled],
|
||||
)
|
||||
|
||||
const replaceMisspelling = useCallback(
|
||||
@@ -607,6 +658,20 @@ export function EditorCore({
|
||||
setMisspell(null)
|
||||
}, [misspell, onAddWord])
|
||||
|
||||
// exampleAt pulls the sentence containing the position out of its block, for
|
||||
// review context in the garden. textBetween with a single-char leaf/break
|
||||
// placeholder keeps the string indices aligned with ProseMirror's parentOffset
|
||||
// (so a hard break or inline atom before the word doesn't desync the slice).
|
||||
const exampleAt = useCallback(
|
||||
(pos: number): string => {
|
||||
if (!editor) return ''
|
||||
const $pos = editor.state.doc.resolve(pos)
|
||||
const text = $pos.parent.textBetween(0, $pos.parent.content.size, '\n', ' ')
|
||||
return sentenceAround(text, Math.max(0, $pos.parentOffset))
|
||||
},
|
||||
[editor],
|
||||
)
|
||||
|
||||
// openWordLookup resolves the exact word span at a document position, anchors a
|
||||
// popover beneath it, and kicks off the offline lookup. The card opens
|
||||
// immediately in a loading state and fills in when the (local) lookup returns.
|
||||
@@ -630,13 +695,40 @@ export function EditorCore({
|
||||
closeCard()
|
||||
setMisspell(null)
|
||||
const token = ++wordReqRef.current
|
||||
setWordInfo({ word: range.word, from: range.from, to: range.to, top, left, loading: true, info: null })
|
||||
setWordInfo({ word: range.word, from: range.from, to: range.to, top, left, loading: true, info: null, vocabId: null, saved: false })
|
||||
// The sentence the word sits in, for review context in the garden.
|
||||
const example = exampleAt(range.from)
|
||||
api
|
||||
.lookupWord(range.word)
|
||||
.then((info) => {
|
||||
if (token === wordReqRef.current) {
|
||||
setWordInfo((w) => (w ? { ...w, loading: false, info } : null))
|
||||
}
|
||||
if (token !== wordReqRef.current) return
|
||||
// Auto-capture into the vocabulary garden — only words the dictionary
|
||||
// actually knows (a real gloss or definition), so accidental lookups of
|
||||
// typos or proper nouns don't clutter the garden. Looking words up IS
|
||||
// the data source; this costs the writer nothing.
|
||||
const known = !!info.gloss || info.definitions.length > 0
|
||||
// Reflect the saved state optimistically so the heart shows 💚 the
|
||||
// moment a known word loads, rather than flashing 🤍 until the capture
|
||||
// round-trips. vocabId is filled in when recordVocab returns.
|
||||
setWordInfo((w) => (w ? { ...w, loading: false, info, saved: known } : null))
|
||||
if (!known) return
|
||||
api
|
||||
.recordVocab({
|
||||
word: range.word,
|
||||
gloss: info.gloss,
|
||||
definition: info.definitions[0]?.definition ?? '',
|
||||
phonetic: info.phonetic,
|
||||
example,
|
||||
doc_id: docId,
|
||||
})
|
||||
.then((row) => {
|
||||
// Discard a late capture if the card has since been superseded (a
|
||||
// new lookup, navigation, or an explicit remove all bump the token),
|
||||
// so it can't resurrect a word the writer just removed.
|
||||
if (token !== wordReqRef.current) return
|
||||
setWordInfo((w) => (w && w.word === range.word ? { ...w, vocabId: row.id, saved: true } : w))
|
||||
})
|
||||
.catch((err) => console.error('vocab capture failed', err))
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('word lookup failed', err)
|
||||
@@ -645,11 +737,48 @@ export function EditorCore({
|
||||
}
|
||||
})
|
||||
},
|
||||
[editor, closeCard],
|
||||
[editor, closeCard, docId],
|
||||
)
|
||||
|
||||
// Right-click a word to look it up. Right-clicking off any word falls through
|
||||
// to the native menu (so copy/paste-by-menu still works — see the Selection fix).
|
||||
// Toggle a looked-up word in/out of the vocabulary garden from the WordCard
|
||||
// heart. Auto-capture saves it on lookup; this lets the writer remove a word
|
||||
// she already knows (or re-add one she removed by mistake).
|
||||
const toggleSaveWord = useCallback(() => {
|
||||
// Read the current card and do the network side effects OUTSIDE the state
|
||||
// updater — an updater must be pure (React StrictMode double-invokes it,
|
||||
// which would otherwise fire each request twice).
|
||||
const w = wordInfo
|
||||
if (!w || !w.info) return
|
||||
if (w.vocabId) {
|
||||
// Already in the garden — remove it, and invalidate any in-flight capture
|
||||
// for this card so a late auto-capture can't resurrect the removed word.
|
||||
const id = w.vocabId
|
||||
wordReqRef.current++
|
||||
setWordInfo((cur) => (cur ? { ...cur, saved: false, vocabId: null } : cur))
|
||||
api.deleteVocab(id).catch((err) => console.error('vocab remove failed', err))
|
||||
return
|
||||
}
|
||||
// Not in the garden yet — save it (idempotent upsert keyed on the word).
|
||||
const word = w.word
|
||||
const example = exampleAt(w.from)
|
||||
setWordInfo((cur) => (cur ? { ...cur, saved: true } : cur))
|
||||
api
|
||||
.recordVocab({
|
||||
word,
|
||||
gloss: w.info.gloss,
|
||||
definition: w.info.definitions[0]?.definition ?? '',
|
||||
phonetic: w.info.phonetic,
|
||||
example,
|
||||
doc_id: docId,
|
||||
})
|
||||
.then((row) => setWordInfo((cur) => (cur && cur.word === word ? { ...cur, vocabId: row.id, saved: true } : cur)))
|
||||
.catch((err) => console.error('vocab save failed', err))
|
||||
}, [wordInfo, exampleAt, docId])
|
||||
|
||||
// Right-click a misspelled word for spelling corrections (the familiar "did you
|
||||
// mean" gesture); right-click any other word to look it up. Right-clicking off
|
||||
// any word falls through to the native menu (so copy/paste-by-menu still works
|
||||
// — see the Selection fix).
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!editor) return
|
||||
@@ -657,9 +786,11 @@ export function EditorCore({
|
||||
if (!coords) return
|
||||
if (!wordAt(editor.state.doc, coords.pos)) return
|
||||
e.preventDefault()
|
||||
// A misspelled word offers corrections first; otherwise look it up.
|
||||
if (openMisspellAt(coords.pos)) return
|
||||
openWordLookup(coords.pos)
|
||||
},
|
||||
[editor, openWordLookup],
|
||||
[editor, openMisspellAt, openWordLookup],
|
||||
)
|
||||
|
||||
// Touch has no hover or right-click, so a long-press (~500ms without moving)
|
||||
@@ -926,7 +1057,13 @@ export function EditorCore({
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<Toolbar editor={editor} onVoiceCheck={onVoiceCheck} voicing={voicing} />
|
||||
<Toolbar
|
||||
editor={editor}
|
||||
onVoiceCheck={onVoiceCheck}
|
||||
voicing={voicing}
|
||||
onCollocationCheck={onCollocationCheck}
|
||||
collocating={collocating}
|
||||
/>
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className="relative flex-1"
|
||||
@@ -969,6 +1106,8 @@ export function EditorCore({
|
||||
word={wordInfo.word}
|
||||
info={wordInfo.info}
|
||||
loading={wordInfo.loading}
|
||||
saved={wordInfo.saved}
|
||||
onToggleSave={toggleSaveWord}
|
||||
style={{ top: wordInfo.top, left: wordInfo.left }}
|
||||
onReplace={replaceWord}
|
||||
/>
|
||||
|
||||
@@ -11,11 +11,15 @@ interface Props {
|
||||
word: string
|
||||
info: WordInfo | null
|
||||
loading: boolean
|
||||
// Whether the word is in the vocabulary garden (auto-saved on lookup). The
|
||||
// heart toggles it; `onToggleSave` removes/re-adds it.
|
||||
saved: boolean
|
||||
onToggleSave: () => void
|
||||
style: React.CSSProperties
|
||||
onReplace: (synonym: string) => void
|
||||
}
|
||||
|
||||
export function WordCard({ word, info, loading, style, onReplace }: Props) {
|
||||
export function WordCard({ word, info, loading, saved, onToggleSave, style, onReplace }: Props) {
|
||||
const definitions = info?.definitions ?? []
|
||||
const synonyms = info?.synonyms ?? []
|
||||
const gloss = info?.gloss ?? ''
|
||||
@@ -48,18 +52,35 @@ export function WordCard({ word, info, loading, style, onReplace }: Props) {
|
||||
<span className="font-bold" style={{ color: 'var(--color-plum)' }}>
|
||||
{word}
|
||||
</span>
|
||||
{speechSupported() && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => speak(word)}
|
||||
aria-label={`Pronounce ${word}`}
|
||||
title="朗读 · Read aloud"
|
||||
className="ml-auto flex h-7 w-7 items-center justify-center rounded-full text-sm"
|
||||
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
||||
>
|
||||
🔊
|
||||
</button>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
{!empty && !loading && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSave}
|
||||
aria-label={saved ? 'Remove from vocabulary garden' : 'Save to vocabulary garden'}
|
||||
aria-pressed={saved}
|
||||
title={saved ? '已在词汇花园 · In your garden (tap to remove)' : '加入词汇花园 · Save to garden'}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full text-sm transition-transform"
|
||||
style={{
|
||||
background: saved ? 'var(--color-accent)' : 'var(--color-surface-alt)',
|
||||
}}
|
||||
>
|
||||
{saved ? '💚' : '🤍'}
|
||||
</button>
|
||||
)}
|
||||
{speechSupported() && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => speak(word)}
|
||||
aria-label={`Pronounce ${word}`}
|
||||
title="朗读 · Read aloud"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full text-sm"
|
||||
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
||||
>
|
||||
🔊
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* How to say it — the pronunciation aid for an English learner, paired
|
||||
|
||||
@@ -9,4 +9,6 @@ export const TYPE_META: Record<SuggestionType, { color: string; label: string }>
|
||||
idiom: { color: 'var(--color-lavender)', label: 'Idiom' },
|
||||
clarity: { color: 'var(--color-sky)', label: 'Clarity' },
|
||||
voice: { color: 'var(--color-honey)', label: 'Voice' },
|
||||
collocation: { color: 'var(--color-blossom)', label: 'Word pairing' },
|
||||
mechanics: { color: 'var(--color-sage)', label: 'Tidy-up' },
|
||||
}
|
||||
|
||||
521
web/src/components/Garden/GardenPanel.tsx
Normal file
521
web/src/components/Garden/GardenPanel.tsx
Normal file
@@ -0,0 +1,521 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { api, type VocabGrade, type VocabWord } from '../../api/client'
|
||||
import { speak, speechSupported, stopSpeech } from '../../audio/speech'
|
||||
import { useFocusTrap } from '../../hooks/useFocusTrap'
|
||||
|
||||
// GardenPanel is the vocabulary garden: every word the writer has looked up,
|
||||
// grown into a blossom that opens further the more she remembers it, plus a
|
||||
// gentle spaced-repetition review. Words are captured automatically on lookup
|
||||
// (zero effort), so the garden fills itself as she writes. The sleepy kitten
|
||||
// naps among the blossoms — the same companion gag, at rest in her little
|
||||
// meadow. Bilingual, zh-first, to match Petal's chrome.
|
||||
|
||||
interface Props {
|
||||
onClose: () => void
|
||||
// Open the document a word was met in (so "where did I see this?" is one tap).
|
||||
onOpenDoc?: (docId: string) => void
|
||||
}
|
||||
|
||||
// blossom maps a word's successful-review count to how bloomed its flower looks:
|
||||
// a fresh seedling opens into a full blossom as it's remembered. No wilting — a
|
||||
// forgotten word just stops climbing, never shames.
|
||||
function blossom(reps: number): string {
|
||||
if (reps <= 0) return '🌱'
|
||||
if (reps <= 2) return '🌿'
|
||||
if (reps <= 4) return '🌷'
|
||||
if (reps <= 6) return '🌸'
|
||||
return '🌺'
|
||||
}
|
||||
|
||||
// blankOut hides the target word in its example sentence with a soft blank, so a
|
||||
// flashcard can quiz recall in context. Whole-word, case-insensitive.
|
||||
function blankOut(sentence: string, word: string): string {
|
||||
if (!sentence) return ''
|
||||
try {
|
||||
const re = new RegExp(`\\b${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'gi')
|
||||
return sentence.replace(re, '____')
|
||||
} catch {
|
||||
return sentence
|
||||
}
|
||||
}
|
||||
|
||||
export function GardenPanel({ onClose, onOpenDoc }: Props) {
|
||||
const [words, setWords] = useState<VocabWord[] | null>(null)
|
||||
const [due, setDue] = useState<VocabWord[]>([])
|
||||
const [error, setError] = useState(false)
|
||||
// Review session state: the queue (snapshot of due at start), a cursor, and
|
||||
// whether the current card's answer is revealed.
|
||||
const [queue, setQueue] = useState<VocabWord[] | null>(null)
|
||||
const [cursor, setCursor] = useState(0)
|
||||
const [revealed, setRevealed] = useState(false)
|
||||
const [expanded, setExpanded] = useState<string | null>(null)
|
||||
const panelRef = useFocusTrap<HTMLElement>()
|
||||
|
||||
// Read-aloud is fire-and-forget, so a word she tapped could still be speaking
|
||||
// when the panel closes. Cancel any in-flight audio on unmount so it can't
|
||||
// outlive the garden.
|
||||
useEffect(() => stopSpeech, [])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError(false)
|
||||
// Settle the two requests independently: a failed /due shouldn't blank the
|
||||
// whole garden when the word list loaded fine. Only listVocab failing is a
|
||||
// true error state; a dueVocab failure just hides the review button.
|
||||
const [allRes, dueRes] = await Promise.allSettled([api.listVocab(), api.dueVocab()])
|
||||
if (allRes.status === 'fulfilled') setWords(allRes.value)
|
||||
else setError(true)
|
||||
if (dueRes.status === 'fulfilled') setDue(dueRes.value)
|
||||
else setDue([])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
// Escape closes the panel (or ends a review session back to the garden).
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (queue) setQueue(null)
|
||||
else onClose()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onClose, queue])
|
||||
|
||||
const startReview = useCallback(() => {
|
||||
if (due.length === 0) return
|
||||
setQueue(due)
|
||||
setCursor(0)
|
||||
setRevealed(false)
|
||||
}, [due])
|
||||
|
||||
const grade = useCallback(
|
||||
async (g: VocabGrade) => {
|
||||
if (!queue) return
|
||||
const card = queue[cursor]
|
||||
if (card) {
|
||||
try {
|
||||
await api.reviewVocab(card.id, g)
|
||||
} catch {
|
||||
/* keep going — a failed grade just won't reschedule */
|
||||
}
|
||||
}
|
||||
const nextCursor = cursor + 1
|
||||
if (nextCursor >= queue.length) {
|
||||
// Session done — refresh the garden and drop back to it.
|
||||
setQueue(null)
|
||||
void load()
|
||||
} else {
|
||||
setCursor(nextCursor)
|
||||
setRevealed(false)
|
||||
}
|
||||
},
|
||||
[queue, cursor, load],
|
||||
)
|
||||
|
||||
const removeWord = useCallback(async (id: string) => {
|
||||
setWords((prev) => (prev ? prev.filter((w) => w.id !== id) : prev))
|
||||
setDue((prev) => prev.filter((w) => w.id !== id))
|
||||
try {
|
||||
await api.deleteVocab(id)
|
||||
} catch {
|
||||
void load()
|
||||
}
|
||||
}, [load])
|
||||
|
||||
return (
|
||||
<div className="petal-no-print fixed inset-0 z-40 flex justify-end">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ background: 'rgba(61, 46, 57, 0.18)' }}
|
||||
onClick={() => (queue ? setQueue(null) : onClose())}
|
||||
/>
|
||||
|
||||
<aside
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="词汇花园 · Vocabulary Garden"
|
||||
tabIndex={-1}
|
||||
className="relative flex h-full w-full max-w-[420px] flex-col"
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
borderLeft: '1px solid var(--color-border)',
|
||||
boxShadow: 'var(--shadow-soft)',
|
||||
}}
|
||||
>
|
||||
<header
|
||||
className="flex items-center justify-between px-5 py-4"
|
||||
style={{ borderBottom: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<div>
|
||||
<div className="text-base font-extrabold text-plum">🌷 词汇花园 · Vocabulary Garden</div>
|
||||
<div className="text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||
{queue ? '复习中 · Reviewing — recall, then grade yourself' : 'Words you looked up, blooming as you learn them'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close garden"
|
||||
onClick={onClose}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-lg"
|
||||
style={{ color: 'var(--color-muted)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{queue ? (
|
||||
<ReviewSession
|
||||
queue={queue}
|
||||
cursor={cursor}
|
||||
revealed={revealed}
|
||||
onReveal={() => setRevealed(true)}
|
||||
onGrade={grade}
|
||||
onQuit={() => setQueue(null)}
|
||||
/>
|
||||
) : (
|
||||
<GardenView
|
||||
words={words}
|
||||
due={due}
|
||||
error={error}
|
||||
expanded={expanded}
|
||||
onToggleExpand={(id) => setExpanded((cur) => (cur === id ? null : id))}
|
||||
onStartReview={startReview}
|
||||
onRemove={removeWord}
|
||||
onOpenDoc={onOpenDoc}
|
||||
onRetry={load}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- garden grid ------------------------------------------------------------
|
||||
|
||||
function GardenView({
|
||||
words,
|
||||
due,
|
||||
error,
|
||||
expanded,
|
||||
onToggleExpand,
|
||||
onStartReview,
|
||||
onRemove,
|
||||
onOpenDoc,
|
||||
onRetry,
|
||||
}: {
|
||||
words: VocabWord[] | null
|
||||
due: VocabWord[]
|
||||
error: boolean
|
||||
expanded: string | null
|
||||
onToggleExpand: (id: string) => void
|
||||
onStartReview: () => void
|
||||
onRemove: (id: string) => void
|
||||
onOpenDoc?: (docId: string) => void
|
||||
onRetry: () => void
|
||||
}) {
|
||||
// Index the due cards once so the per-word "due" check below is O(1), not a
|
||||
// linear scan of `due` for every word in the garden.
|
||||
const dueIds = useMemo(() => new Set(due.map((d) => d.id)), [due])
|
||||
return (
|
||||
<>
|
||||
{due.length > 0 && (
|
||||
<div className="px-4 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStartReview}
|
||||
className="w-full rounded-full py-3 text-sm font-extrabold text-white"
|
||||
style={{ background: 'var(--color-accent)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||
>
|
||||
复习 {due.length} 个词 · Review {due.length} due 🌸
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
||||
{error ? (
|
||||
<div className="px-2 py-6 text-center text-sm" style={{ color: 'var(--color-muted)' }}>
|
||||
Couldn’t load your garden just now.
|
||||
<button onClick={onRetry} className="ml-1 font-bold text-plum underline">
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
) : words === null ? (
|
||||
<div className="px-2 py-6 text-center text-sm" style={{ color: 'var(--color-muted)' }}>
|
||||
Loading…
|
||||
</div>
|
||||
) : words.length === 0 ? (
|
||||
<div className="px-3 py-10 text-center" style={{ color: 'var(--color-muted)' }}>
|
||||
<div className="mb-2 text-4xl">🌱🐱💤</div>
|
||||
<p className="text-sm leading-relaxed">
|
||||
你的花园还空着。<br />
|
||||
右键点一个英文单词查它的意思——它就会在这里发芽。
|
||||
</p>
|
||||
<p className="mt-2 text-xs">
|
||||
Your garden is empty. Look up an English word (right-click it) and it’ll sprout here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{words.map((w) => {
|
||||
const open = expanded === w.id
|
||||
const isDue = dueIds.has(w.id)
|
||||
return (
|
||||
<li key={w.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleExpand(w.id)}
|
||||
className="flex w-full items-center gap-2.5 rounded-2xl px-3 py-2.5 text-left"
|
||||
style={{
|
||||
background: open ? 'var(--color-surface-alt)' : 'transparent',
|
||||
border: `1px solid ${open ? 'var(--color-border)' : 'transparent'}`,
|
||||
}}
|
||||
>
|
||||
<span className="text-xl" aria-hidden>
|
||||
{blossom(w.reps)}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm font-bold text-plum">{w.word}</span>
|
||||
{(w.gloss || w.definition) && (
|
||||
<span
|
||||
className="truncate text-xs"
|
||||
style={{
|
||||
color: 'var(--color-muted)',
|
||||
fontFamily: w.gloss
|
||||
? "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
|
||||
: 'var(--font-body)',
|
||||
}}
|
||||
>
|
||||
{w.gloss || w.definition}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{isDue && (
|
||||
<span
|
||||
className="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold"
|
||||
style={{ background: 'var(--color-accent)', color: '#fff' }}
|
||||
>
|
||||
待复习 · due
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="mb-1 ml-9 mr-2 mt-1 flex flex-col gap-2 text-xs" style={{ color: 'var(--color-plum)' }}>
|
||||
{w.phonetic && <span style={{ color: 'var(--color-muted)' }}>/{w.phonetic}/</span>}
|
||||
{w.example && (
|
||||
<p className="italic leading-snug" style={{ fontFamily: 'var(--font-body)' }}>
|
||||
“{w.example}”
|
||||
</p>
|
||||
)}
|
||||
<div className="text-[11px]" style={{ color: 'var(--color-muted)' }}>
|
||||
复习 {w.reps} 次 · seen {w.reps}× · 间隔 {w.interval_days}d
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{speechSupported() && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => speak(w.word)}
|
||||
className="rounded-full px-2.5 py-1 text-xs font-semibold"
|
||||
style={{ background: 'var(--color-surface-alt)' }}
|
||||
>
|
||||
🔊 朗读
|
||||
</button>
|
||||
)}
|
||||
{w.doc_id && onOpenDoc && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenDoc(w.doc_id as string)}
|
||||
className="rounded-full px-2.5 py-1 text-xs font-semibold"
|
||||
style={{ background: 'var(--color-surface-alt)' }}
|
||||
>
|
||||
📄 出处 · Source
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(w.id)}
|
||||
className="ml-auto rounded-full px-2.5 py-1 text-xs font-semibold"
|
||||
style={{ color: 'var(--color-muted)' }}
|
||||
>
|
||||
🗑 移除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!error && words && words.length > 0 && (
|
||||
<div
|
||||
className="shrink-0 px-4 py-2.5 text-center text-[11px]"
|
||||
style={{ borderTop: '1px solid var(--color-border)', color: 'var(--color-muted)' }}
|
||||
>
|
||||
🐱💤 {words.length} 朵花在花园里 · {words.length} blossom{words.length > 1 ? 's' : ''} growing
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// --- flashcard review -------------------------------------------------------
|
||||
|
||||
function ReviewSession({
|
||||
queue,
|
||||
cursor,
|
||||
revealed,
|
||||
onReveal,
|
||||
onGrade,
|
||||
onQuit,
|
||||
}: {
|
||||
queue: VocabWord[]
|
||||
cursor: number
|
||||
revealed: boolean
|
||||
onReveal: () => void
|
||||
onGrade: (g: VocabGrade) => void
|
||||
onQuit: () => void
|
||||
}) {
|
||||
const card = queue[cursor]
|
||||
// The meaning shown/asked is the Chinese gloss, or the English definition when
|
||||
// a word has no gloss — so definition-only words are still reviewable.
|
||||
const meaning = card?.gloss || card?.definition || ''
|
||||
// Alternate the quiz direction so she practices both recognition (see the
|
||||
// English, recall the meaning) and production (see the meaning, recall the
|
||||
// English word). Parity of the cursor keeps it deterministic within a session.
|
||||
const production = cursor % 2 === 1 && !!meaning
|
||||
|
||||
const prompt = useMemo(() => {
|
||||
if (!card) return ''
|
||||
if (production) return meaning
|
||||
return card.example ? blankOut(card.example, card.word) : card.word
|
||||
}, [card, production, meaning])
|
||||
|
||||
if (!card) return null
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col px-5 py-4">
|
||||
<div className="mb-3 flex items-center justify-between text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||
<span>
|
||||
{cursor + 1} / {queue.length}
|
||||
</span>
|
||||
<button type="button" onClick={onQuit} className="font-semibold underline">
|
||||
结束 · End
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* The card */}
|
||||
<div
|
||||
className="flex flex-1 flex-col items-center justify-center rounded-3xl px-5 py-8 text-center"
|
||||
style={{ background: 'var(--color-surface-alt)', border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<div className="mb-2 text-3xl" aria-hidden>
|
||||
{blossom(card.reps)}
|
||||
</div>
|
||||
<div
|
||||
className="text-xl font-extrabold leading-snug text-plum"
|
||||
style={{
|
||||
fontFamily: production
|
||||
? "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
|
||||
: 'var(--font-body)',
|
||||
}}
|
||||
>
|
||||
{prompt}
|
||||
</div>
|
||||
<div className="mt-1 text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||
{production ? '这个中文意思的英文单词是?· Which English word?' : '这个词什么意思?· What does this mean?'}
|
||||
</div>
|
||||
|
||||
{revealed && (
|
||||
<div className="mt-5 w-full border-t pt-4" style={{ borderColor: 'var(--color-border)' }}>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span className="text-lg font-extrabold text-plum">{card.word}</span>
|
||||
{speechSupported() && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => speak(card.word)}
|
||||
aria-label={`Pronounce ${card.word}`}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full text-xs"
|
||||
style={{ background: 'var(--color-surface)' }}
|
||||
>
|
||||
🔊
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{card.phonetic && (
|
||||
<div className="mt-0.5 text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||
/{card.phonetic}/
|
||||
</div>
|
||||
)}
|
||||
{meaning && (
|
||||
<div
|
||||
className="mt-1 text-sm font-semibold"
|
||||
style={{
|
||||
color: 'var(--color-accent-hover)',
|
||||
// Chinese gloss gets the CJK stack; an English definition fallback
|
||||
// reads better in the body font.
|
||||
fontFamily: card.gloss
|
||||
? "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
|
||||
: 'var(--font-body)',
|
||||
}}
|
||||
>
|
||||
{meaning}
|
||||
</div>
|
||||
)}
|
||||
{card.example && (
|
||||
<p className="mt-2 text-xs italic leading-snug" style={{ color: 'var(--color-muted)', fontFamily: 'var(--font-body)' }}>
|
||||
“{card.example}”
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="mt-4">
|
||||
{!revealed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReveal}
|
||||
className="w-full rounded-full py-3 text-sm font-extrabold text-white"
|
||||
style={{ background: 'var(--color-accent)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||
>
|
||||
翻看答案 · Show answer
|
||||
</button>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<GradeButton color="var(--color-peach)" zh="再来" en="Again" onClick={() => onGrade('again')} />
|
||||
<GradeButton color="var(--color-mint)" zh="记得" en="Good" onClick={() => onGrade('good')} />
|
||||
<GradeButton color="var(--color-honey)" zh="太简单" en="Easy" onClick={() => onGrade('easy')} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GradeButton({ color, zh, en, onClick }: { color: string; zh: string; en: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex flex-col items-center rounded-2xl py-2.5 text-plum"
|
||||
style={{ background: color }}
|
||||
>
|
||||
<span className="text-sm font-extrabold">{zh}</span>
|
||||
<span className="text-[11px] font-semibold opacity-80">{en}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api, type Document, type DocumentVersion } from '../../api/client'
|
||||
import { useFocusTrap } from '../../hooks/useFocusTrap'
|
||||
|
||||
// HistoryPanel is the "time machine" drawer: every snapshot Petal kept of this
|
||||
// document, newest first, with a one-click preview and restore. It's the safety
|
||||
@@ -41,6 +42,7 @@ export function HistoryPanel({ docId, onClose, onRestored }: Props) {
|
||||
const [selected, setSelected] = useState<DocumentVersion | null>(null)
|
||||
const [preview, setPreview] = useState<DocumentVersion | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const panelRef = useFocusTrap<HTMLElement>()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError(false)
|
||||
@@ -99,6 +101,11 @@ export function HistoryPanel({ docId, onClose, onRestored }: Props) {
|
||||
/>
|
||||
|
||||
<aside
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="历史 · History"
|
||||
tabIndex={-1}
|
||||
className="relative flex h-full w-full max-w-[380px] flex-col"
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
|
||||
37
web/src/components/StatusBar/PetalsToggle.tsx
Normal file
37
web/src/components/StatusBar/PetalsToggle.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { isPetalsEnabled, onPetalsEnabledChange, setPetalsEnabled } from '../../effects/petals'
|
||||
|
||||
// A tiny toggle for Petal's ambient falling-blossom layer, sitting just left of
|
||||
// the sound toggle in the status bar. Some people find the drifting petals
|
||||
// distracting, so this turns them off entirely. Bilingual tooltip (she reads
|
||||
// Mandarin first), and the choice persists across reloads.
|
||||
export function PetalsToggle() {
|
||||
const [on, setOn] = useState(isPetalsEnabled)
|
||||
|
||||
// Stay in sync if the setting is flipped elsewhere.
|
||||
useEffect(() => onPetalsEnabledChange(setOn), [])
|
||||
|
||||
const toggle = () => {
|
||||
const next = !on
|
||||
setPetalsEnabled(next)
|
||||
setOn(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-pressed={on}
|
||||
title={on ? '花瓣开 · Petals on' : '花瓣关 · Petals off'}
|
||||
aria-label={on ? 'Hide falling petals' : 'Show falling petals'}
|
||||
className="flex items-center justify-center rounded-full px-2 py-1 text-xl leading-none transition-colors"
|
||||
style={{ color: on ? 'var(--color-accent)' : 'var(--color-muted)', lineHeight: 1 }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-accent-hover)')}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.color = on ? 'var(--color-accent)' : 'var(--color-muted)')
|
||||
}
|
||||
>
|
||||
<span aria-hidden>{on ? '🌸' : '🍃'}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Fragment, useEffect, useRef, useState } from 'react'
|
||||
import type { SaveStatus } from '../../hooks/useAutoSave'
|
||||
import { StatsPanel } from './StatsPanel'
|
||||
import { PetalsToggle } from './PetalsToggle'
|
||||
import { SoundToggle } from './SoundToggle'
|
||||
|
||||
interface Props {
|
||||
@@ -12,6 +13,8 @@ interface Props {
|
||||
checking: boolean
|
||||
// True while a whole-document voice pass runs — shows a breathing honey dot.
|
||||
voicing: boolean
|
||||
// True while a collocation pass runs — shows a breathing blossom dot.
|
||||
collocating: boolean
|
||||
// True when Petal can't reach its LLM helper — shows a gentle, reassuring note
|
||||
// (the writing still saves locally, so this is awareness, not an error).
|
||||
llmDown: boolean
|
||||
@@ -28,8 +31,40 @@ const SAVE_LABEL: Record<SaveStatus, string> = {
|
||||
// StatusBar is the slim footer: word count on the left, save state and the
|
||||
// grammar-checkpoint indicator on the right. The checkpoint dot is a soft rose
|
||||
// circle that breathes while a check is in flight (spec → Signature animations).
|
||||
export function StatusBar({ wordCount, text, saveStatus, checking, voicing, llmDown }: Props) {
|
||||
// The live "Petal is working" indicators. Each is a breathing dot + label shown
|
||||
// while its pass is in flight; driving them from one array keeps the markup (and
|
||||
// the "nothing in flight" check below) in lockstep as passes are added.
|
||||
interface Indicator {
|
||||
active: boolean
|
||||
color: string
|
||||
title: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export function StatusBar({ wordCount, text, saveStatus, checking, voicing, collocating, llmDown }: Props) {
|
||||
const label = SAVE_LABEL[saveStatus]
|
||||
|
||||
const indicators: Indicator[] = [
|
||||
{
|
||||
active: checking,
|
||||
color: 'var(--color-accent)',
|
||||
title: 'Petal is reading your writing…',
|
||||
label: 'Checking…',
|
||||
},
|
||||
{
|
||||
active: voicing,
|
||||
color: 'var(--color-honey)',
|
||||
title: 'Petal is reading your voice…',
|
||||
label: 'Reading your voice…',
|
||||
},
|
||||
{
|
||||
active: collocating,
|
||||
color: 'var(--color-blossom)',
|
||||
title: 'Petal is looking for more natural word pairings…',
|
||||
label: 'Finding natural phrasing…',
|
||||
},
|
||||
]
|
||||
const anyBusy = indicators.some((i) => i.active)
|
||||
// The expanded stats panel toggles open when the word count is clicked.
|
||||
const [statsOpen, setStatsOpen] = useState(false)
|
||||
const statsRef = useRef<HTMLDivElement>(null)
|
||||
@@ -66,31 +101,21 @@ export function StatusBar({ wordCount, text, saveStatus, checking, voicing, llmD
|
||||
</button>
|
||||
{statsOpen && <StatsPanel text={text} wordCount={wordCount} />}
|
||||
</div>
|
||||
{checking && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="inline-flex items-center gap-1.5" title="Petal is reading your writing…">
|
||||
<span
|
||||
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
|
||||
style={{ background: 'var(--color-accent)' }}
|
||||
/>
|
||||
Checking…
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{voicing && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="inline-flex items-center gap-1.5" title="Petal is reading your voice…">
|
||||
<span
|
||||
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
|
||||
style={{ background: 'var(--color-honey)' }}
|
||||
/>
|
||||
Reading your voice…
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{llmDown && !checking && !voicing && (
|
||||
{indicators
|
||||
.filter((i) => i.active)
|
||||
.map((i) => (
|
||||
<Fragment key={i.label}>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="inline-flex items-center gap-1.5" title={i.title}>
|
||||
<span
|
||||
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
|
||||
style={{ background: i.color }}
|
||||
/>
|
||||
{i.label}
|
||||
</span>
|
||||
</Fragment>
|
||||
))}
|
||||
{llmDown && !anyBusy && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span
|
||||
@@ -121,7 +146,8 @@ export function StatusBar({ wordCount, text, saveStatus, checking, voicing, llmD
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<div className="ml-auto">
|
||||
<div className="ml-auto flex items-center">
|
||||
<PetalsToggle />
|
||||
<SoundToggle />
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -8,6 +8,9 @@ interface Props {
|
||||
// Runs the whole-document voice-consistency pass; `voicing` shows its progress.
|
||||
onVoiceCheck: () => void
|
||||
voicing: boolean
|
||||
// Runs the whole-document collocation coach; `collocating` shows its progress.
|
||||
onCollocationCheck: () => void
|
||||
collocating: boolean
|
||||
}
|
||||
|
||||
// A formatting button. `active` gets the rose pill treatment so the writer can
|
||||
@@ -165,7 +168,7 @@ function Swatch({
|
||||
// Toolbar renders inline formatting controls bound to the live Tiptap editor.
|
||||
// useEditorState subscribes to just the flags it reads, so the buttons reflect
|
||||
// the current selection without re-rendering the whole tree on every keystroke.
|
||||
export function Toolbar({ editor, onVoiceCheck, voicing }: Props) {
|
||||
export function Toolbar({ editor, onVoiceCheck, voicing, onCollocationCheck, collocating }: Props) {
|
||||
// Which popover (if any) is open. Only one at a time.
|
||||
const [menu, setMenu] = useState<'color' | 'highlight' | 'size' | 'link' | 'table' | 'outline' | null>(null)
|
||||
const [linkUrl, setLinkUrl] = useState('')
|
||||
@@ -594,6 +597,28 @@ export function Toolbar({ editor, onVoiceCheck, voicing }: Props) {
|
||||
/>
|
||||
{voicing ? 'Reading…' : 'Check my voice 🍯'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Make it sound natural"
|
||||
disabled={collocating}
|
||||
onMouseDown={(e) => e.preventDefault()} // keep editor selection
|
||||
onClick={onCollocationCheck}
|
||||
className="ml-1 inline-flex h-8 items-center gap-1.5 whitespace-nowrap px-3 text-xs font-bold transition-colors disabled:opacity-70"
|
||||
style={{
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
color: 'var(--color-plum)',
|
||||
background: 'var(--color-blossom)',
|
||||
}}
|
||||
title="Find word pairings that natives usually say differently"
|
||||
>
|
||||
<span
|
||||
className={collocating ? 'petal-checkpoint-dot inline-block h-2 w-2 rounded-full' : 'hidden'}
|
||||
style={{ background: 'var(--color-plum)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
{collocating ? 'Reading…' : 'Make it sound natural 🌸'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { isPetalsEnabled, onPetalsEnabledChange } from './petals'
|
||||
|
||||
// PetalFall is the cozy ambient layer: a fixed, non-interactive 2D canvas that
|
||||
// drifts soft cherry-blossom petals down the page. Each petal is a real
|
||||
@@ -212,8 +213,19 @@ interface Petal {
|
||||
|
||||
export function PetalFall({ night = false }: { night?: boolean }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const [enabled, setEnabled] = useState(isPetalsEnabled)
|
||||
|
||||
// Let the status-bar toggle switch the ambient layer on/off live.
|
||||
useEffect(() => onPetalsEnabledChange(setEnabled), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
// User turned the ambient petals off — wipe any frame left on the canvas.
|
||||
const el = canvasRef.current
|
||||
el?.getContext('2d')?.clearRect(0, 0, el.width, el.height)
|
||||
return
|
||||
}
|
||||
|
||||
const reduce =
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
||||
@@ -347,7 +359,7 @@ export function PetalFall({ night = false }: { night?: boolean }) {
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
petals = []
|
||||
}
|
||||
}, [night])
|
||||
}, [night, enabled])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
|
||||
36
web/src/effects/petals.ts
Normal file
36
web/src/effects/petals.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// The on/off preference for the ambient falling-petals layer (PetalFall). Some
|
||||
// people find drifting petals distracting rather than cozy, so the whole effect
|
||||
// is opt-out-able and the choice persists in localStorage so it survives reloads.
|
||||
// Mirrors the tiny pub/sub used for the sound mute toggle (../audio/sounds).
|
||||
|
||||
const STORAGE_KEY = 'petal.petals'
|
||||
|
||||
let enabled = readEnabled()
|
||||
const listeners = new Set<(on: boolean) => void>()
|
||||
|
||||
function readEnabled(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) !== 'off'
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function isPetalsEnabled(): boolean {
|
||||
return enabled
|
||||
}
|
||||
|
||||
export function setPetalsEnabled(on: boolean): void {
|
||||
enabled = on
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, on ? 'on' : 'off')
|
||||
} catch {
|
||||
/* private mode — choice just won't persist */
|
||||
}
|
||||
listeners.forEach((fn) => fn(on))
|
||||
}
|
||||
|
||||
export function onPetalsEnabledChange(fn: (on: boolean) => void): () => void {
|
||||
listeners.add(fn)
|
||||
return () => listeners.delete(fn)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api, type Suggestion } from '../api/client'
|
||||
import { mechanicsFindings } from '../components/Companion/prose'
|
||||
|
||||
const DEBOUNCE_MS = 4000
|
||||
|
||||
@@ -13,6 +14,8 @@ export function useCheckpoint(docId: string | null) {
|
||||
const [checking, setChecking] = useState(false)
|
||||
// True while a whole-document voice pass is in flight (explicit user action).
|
||||
const [voicing, setVoicing] = useState(false)
|
||||
// True while a whole-document collocation pass is in flight (explicit action).
|
||||
const [collocating, setCollocating] = useState(false)
|
||||
// True when the last LLM pass couldn't reach the model (server 502 or network
|
||||
// error). Drives a gentle, reassuring "helper is resting" note — the writing
|
||||
// itself still saves fine, so this is awareness, not an error. Cleared on the
|
||||
@@ -25,6 +28,13 @@ export function useCheckpoint(docId: string | null) {
|
||||
const docIdRef = useRef(docId)
|
||||
docIdRef.current = docId
|
||||
|
||||
// Latest plaintext, kept fresh by schedule(), so the deterministic mechanics
|
||||
// pass (detected client-side, see prose.ts) reads the current document when the
|
||||
// debounce fires — no need to re-thread text through every call. null until the
|
||||
// first edit of the current doc, so a tone-only check before any edit doesn't
|
||||
// submit an empty batch and wipe the doc's persisted mechanics rows.
|
||||
const latestTextRef = useRef<string | null>(null)
|
||||
|
||||
// Token to discard responses from a doc we've since navigated away from.
|
||||
const runRef = useRef(0)
|
||||
|
||||
@@ -44,6 +54,21 @@ export function useCheckpoint(docId: string | null) {
|
||||
setChecking(true)
|
||||
let retrying = false
|
||||
try {
|
||||
// Deterministic mechanics first: detect client-side and persist as the
|
||||
// 'mechanics' family before the grammar pass, so the checkpoint's unified
|
||||
// response already carries them. Best-effort and only on the initial try —
|
||||
// a grammar retry shouldn't re-submit unchanged findings. A mechanics
|
||||
// failure must not block the grammar pass.
|
||||
if (attempt === 0 && latestTextRef.current !== null) {
|
||||
try {
|
||||
// Render the mechanics fixes immediately — they're instant (no LLM), so
|
||||
// the unified set they return shouldn't wait on the slow grammar pass.
|
||||
const withMech = await api.submitMechanics(id, mechanicsFindings(latestTextRef.current))
|
||||
if (run === runRef.current && id === docIdRef.current) setSuggestions(withMech)
|
||||
} catch (err) {
|
||||
console.error('mechanics submit failed', err)
|
||||
}
|
||||
}
|
||||
const fresh = await api.checkDoc(id)
|
||||
if (run === runRef.current && id === docIdRef.current) {
|
||||
setSuggestions(fresh)
|
||||
@@ -66,31 +91,59 @@ export function useCheckpoint(docId: string | null) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Run the voice-consistency pass now (explicit "Check my voice" action). Like
|
||||
// runCheck it returns the unified pending set, so grammar highlights survive.
|
||||
// Shares the run token so navigating away discards a late voice response.
|
||||
const runVoice = useCallback(async () => {
|
||||
const id = docIdRef.current
|
||||
if (!id) return
|
||||
clearTimeout(retryRef.current) // a voice pass supersedes a queued grammar retry
|
||||
const run = ++runRef.current
|
||||
setVoicing(true)
|
||||
try {
|
||||
const full = await api.voiceDoc(id)
|
||||
if (run === runRef.current && id === docIdRef.current) {
|
||||
setSuggestions(full)
|
||||
setLlmDown(false)
|
||||
// runExplicitPass drives a whole-document, explicit-action pass (voice,
|
||||
// collocation): it returns the unified pending set, so the other families'
|
||||
// highlights survive, and shares the run token so navigating away discards a
|
||||
// late response. It supersedes any queued grammar retry AND clears the
|
||||
// checkpoint's "checking" state, so the breathing rose dot can't linger on
|
||||
// after the retry timer it would have cleared is cancelled here.
|
||||
const runExplicitPass = useCallback(
|
||||
async (call: (id: string) => Promise<Suggestion[]>, setBusy: (b: boolean) => void, label: string) => {
|
||||
const id = docIdRef.current
|
||||
if (!id) return
|
||||
// An explicit action fully supersedes a queued auto-check and grammar
|
||||
// retry, and takes over the indicator — clear the stranded "checking" dot.
|
||||
clearTimeout(debounceRef.current)
|
||||
clearTimeout(retryRef.current)
|
||||
setChecking(false)
|
||||
const run = ++runRef.current
|
||||
setBusy(true)
|
||||
try {
|
||||
const full = await call(id)
|
||||
if (run === runRef.current && id === docIdRef.current) {
|
||||
setSuggestions(full)
|
||||
setLlmDown(false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`${label} failed`, err)
|
||||
if (run === runRef.current) setLlmDown(true)
|
||||
} finally {
|
||||
// setBusy is THIS invocation's own flag (a newer run sets its own), so
|
||||
// clear it unconditionally — otherwise an overlapping pass that bumped
|
||||
// the run token would strand this spinner on "Reading…" forever.
|
||||
setBusy(false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('voice pass failed', err)
|
||||
if (run === runRef.current) setLlmDown(true)
|
||||
} finally {
|
||||
if (run === runRef.current) setVoicing(false)
|
||||
}
|
||||
}, [])
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// Call on every edit; schedules a check 4s after typing settles.
|
||||
const schedule = useCallback(() => {
|
||||
// Run the voice-consistency pass now (explicit "Check my voice" action).
|
||||
const runVoice = useCallback(
|
||||
() => runExplicitPass(api.voiceDoc, setVoicing, 'voice pass'),
|
||||
[runExplicitPass],
|
||||
)
|
||||
|
||||
// Run the collocation coach now (explicit "Make it sound natural" action).
|
||||
const runCollocation = useCallback(
|
||||
() => runExplicitPass(api.collocationDoc, setCollocating, 'collocation pass'),
|
||||
[runExplicitPass],
|
||||
)
|
||||
|
||||
// Call on every edit; schedules a check 4s after typing settles. Pass the
|
||||
// current plaintext so the mechanics pass sees the latest document; omit it
|
||||
// (e.g. a tone-only change) to reuse the last text.
|
||||
const schedule = useCallback((text?: string) => {
|
||||
if (text !== undefined) latestTextRef.current = text
|
||||
clearTimeout(debounceRef.current)
|
||||
clearTimeout(retryRef.current) // a fresh edit supersedes any queued retry
|
||||
debounceRef.current = setTimeout(() => void runCheck(), DEBOUNCE_MS)
|
||||
@@ -102,9 +155,11 @@ export function useCheckpoint(docId: string | null) {
|
||||
clearTimeout(debounceRef.current)
|
||||
clearTimeout(retryRef.current)
|
||||
runRef.current++
|
||||
latestTextRef.current = null // unknown until the new doc's first edit
|
||||
setSuggestions([])
|
||||
setChecking(false)
|
||||
setVoicing(false)
|
||||
setCollocating(false)
|
||||
setLlmDown(false)
|
||||
if (!docId) return
|
||||
let cancelled = false
|
||||
@@ -134,5 +189,5 @@ export function useCheckpoint(docId: string | null) {
|
||||
setSuggestions((prev) => prev.filter((s) => s.id !== id))
|
||||
}, [])
|
||||
|
||||
return { suggestions, checking, voicing, llmDown, schedule, runVoice, removeSuggestion }
|
||||
return { suggestions, checking, voicing, collocating, llmDown, schedule, runVoice, runCollocation, removeSuggestion }
|
||||
}
|
||||
|
||||
59
web/src/hooks/useFocusTrap.ts
Normal file
59
web/src/hooks/useFocusTrap.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
// useFocusTrap makes a slide-over / modal keyboard-accessible. Attach the
|
||||
// returned ref to the dialog container and, while it's mounted, it:
|
||||
// • moves focus into the panel on open (so Tab/Escape work without a click),
|
||||
// • keeps Tab/Shift+Tab cycling within the panel instead of escaping to the
|
||||
// page behind the backdrop, and
|
||||
// • restores focus to whatever was focused before it opened on unmount.
|
||||
// Escape handling stays with each panel, which has its own close semantics.
|
||||
export function useFocusTrap<T extends HTMLElement>() {
|
||||
const ref = useRef<T>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const node = ref.current
|
||||
if (!node) return
|
||||
|
||||
// Remember where focus was so we can hand it back when the panel closes.
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null
|
||||
|
||||
const focusables = () =>
|
||||
Array.from(
|
||||
node.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
).filter((el) => el.offsetParent !== null)
|
||||
|
||||
// Focus the first interactive element, or the container itself as a fallback.
|
||||
const first = focusables()[0]
|
||||
if (first) first.focus()
|
||||
else node.focus()
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return
|
||||
const items = focusables()
|
||||
if (items.length === 0) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
const firstEl = items[0]
|
||||
const lastEl = items[items.length - 1]
|
||||
const active = document.activeElement
|
||||
if (e.shiftKey && active === firstEl) {
|
||||
e.preventDefault()
|
||||
lastEl.focus()
|
||||
} else if (!e.shiftKey && active === lastEl) {
|
||||
e.preventDefault()
|
||||
firstEl.focus()
|
||||
}
|
||||
}
|
||||
|
||||
node.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
node.removeEventListener('keydown', onKeyDown)
|
||||
previouslyFocused?.focus?.()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return ref
|
||||
}
|
||||
@@ -20,6 +20,8 @@
|
||||
--color-lavender: #C5B4E8; /* idiom */
|
||||
--color-sky: #A8CCE8; /* clarity */
|
||||
--color-honey: #CE9B4F; /* voice */
|
||||
--color-blossom: #E59ABF; /* collocation — warm blossom pink */
|
||||
--color-sage: #B7C7B9; /* mechanics — calm sage, a gentle tidy-up nudge */
|
||||
--color-success: #8FCFA8; /* saved / accepted */
|
||||
|
||||
/* Typography */
|
||||
@@ -232,6 +234,8 @@ button, a, input {
|
||||
.petal-suggestion-idiom { border-bottom-color: var(--color-lavender); }
|
||||
.petal-suggestion-clarity { border-bottom-color: var(--color-sky); }
|
||||
.petal-suggestion-voice { border-bottom-color: var(--color-honey); }
|
||||
.petal-suggestion-collocation { border-bottom-color: var(--color-blossom); }
|
||||
.petal-suggestion-mechanics { border-bottom-color: var(--color-sage); }
|
||||
|
||||
@keyframes petal-suggestion-in {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
|
||||
Reference in New Issue
Block a user