Phase 12 + 13: collocation coach + vocabulary garden

Phase 12 — collocation coach: a third suggestion family for gentle
"natives usually say…" hints on non-native word pairings, reusing the
existing runPass/pendingScope/rail machinery.
- llm/collocation.go (RunCollocation, 25s floor, reuses ParseCheckpoint)
  + collocationSystemPrompt/CollocationMessages (warm, Mandarin gloss,
  defers grammar/spelling to the grammar family)
- migration 0005 rebuilds the suggestions table to extend the type CHECK
  (SQLite can't ALTER a CHECK)
- collocationScope + CollocationLimit + POST /{id}/collocation
- fix: grammarScope was `type != 'voice'` and would wipe the new
  collocation flags; now `type NOT IN ('voice','collocation')`
- frontend: --color-blossom, "Make it sound natural 🌸" pill,
  collocating/runCollocation in useCheckpoint, StatusBar dot

Phase 13 — vocabulary garden: capture looked-up words and surface them
for gentle spaced repetition.
- new internal/vocab package: migration 0006 (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 then geometric; gentle
  "again", no streak-shaming), handlers (capture-upsert/list/due/
  review/delete, owner-scoped, SQLite-side datetime math)
- auto-capture on word lookup (dictionary-known words only, captures
  the surrounding sentence + doc_id) + 🤍/💚 toggle on WordCard
- GardenPanel: blossom grid (bloom by reps), flashcard review (sentence
  blanked, flip, again/good/easy, recognition↔production), sleepy-kitten
  footer; opened from a global 🌷 header button

Tests: TestCollocationPassCoexists, vocab scheduler + handlers, db CHECK
extended. go build/vet/test + tsc + vite + vitest (51/51) clean;
migration verified against a copy of the live DB; live backend smoke
walked the full vocab lifecycle + the warm-502 collocation path.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-26 15:50:25 -07:00
parent 20442d1356
commit 8aa437ec82
23 changed files with 1614 additions and 58 deletions

View File

@@ -0,0 +1,492 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { api, type VocabGrade, type VocabWord } from '../../api/client'
import { speak, speechSupported } from '../../audio/speech'
// 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 load = useCallback(async () => {
setError(false)
try {
const [all, dueNow] = await Promise.all([api.listVocab(), api.dueVocab()])
setWords(all)
setDue(dueNow)
} catch {
setError(true)
}
}, [])
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={onClose} />
<aside
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
}) {
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)' }}>
Couldnt 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 itll sprout here.
</p>
</div>
) : (
<ul className="flex flex-col gap-1.5">
{words.map((w) => {
const open = expanded === w.id
const isDue = due.some((d) => d.id === 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 && (
<span
className="truncate text-xs"
style={{
color: 'var(--color-muted)',
fontFamily: "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif",
}}
>
{w.gloss}
</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>
{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]
// 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 && !!card?.gloss
const prompt = useMemo(() => {
if (!card) return ''
if (production) return card.gloss
return card.example ? blankOut(card.example, card.word) : card.word
}, [card, production])
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>
)}
{card.gloss && (
<div
className="mt-1 text-sm font-semibold"
style={{
color: 'var(--color-accent-hover)',
fontFamily: "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif",
}}
>
{card.gloss}
</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>
)
}