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
165 lines
5.5 KiB
Go
165 lines
5.5 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
|
|
"gitea.parodia.dev/drwily/petal/internal/config"
|
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
|
"gitea.parodia.dev/drwily/petal/internal/docs"
|
|
"gitea.parodia.dev/drwily/petal/internal/images"
|
|
"gitea.parodia.dev/drwily/petal/internal/lexicon"
|
|
"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"
|
|
)
|
|
|
|
func main() {
|
|
cfg := config.Load()
|
|
|
|
database, err := db.Open(cfg.DatabasePath)
|
|
if err != nil {
|
|
log.Fatalf("database: %v", err)
|
|
}
|
|
defer database.Close()
|
|
log.Printf("database ready at %s", cfg.DatabasePath)
|
|
|
|
r := chi.NewRouter()
|
|
r.Use(middleware.RequestID)
|
|
r.Use(middleware.RealIP)
|
|
r.Use(middleware.Logger)
|
|
r.Use(middleware.Recoverer)
|
|
|
|
// Build version: a hash of the embedded SPA shell. Vite rewrites index.html
|
|
// with content-hashed asset names on every build, so this string changes
|
|
// exactly when a new frontend is deployed — the client polls it to know when
|
|
// to offer a refresh.
|
|
version := buildVersion()
|
|
log.Printf("frontend build version %s", version)
|
|
|
|
r.Route("/api", func(api chi.Router) {
|
|
api.Get("/health", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
|
})
|
|
|
|
api.Get("/version", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
// Never cache: a stale cached version would defeat the whole check.
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_, _ = w.Write([]byte(`{"version":"` + version + `"}`))
|
|
})
|
|
|
|
llmClient := llm.NewLLMClient(cfg)
|
|
sug := suggestions.New(database, llmClient)
|
|
|
|
// Document CRUD plus the doc-scoped checkpoint/list suggestion routes,
|
|
// both under /api/docs.
|
|
docsHandler := docs.New(database)
|
|
docsRouter := docsHandler.Routes()
|
|
sug.RegisterDocRoutes(docsRouter)
|
|
api.Mount("/docs", docsRouter)
|
|
|
|
// Tag management (the roster) and cross-document full-text search.
|
|
api.Mount("/tags", docsHandler.TagRoutes())
|
|
api.Mount("/search", docsHandler.SearchRoutes())
|
|
|
|
// Per-suggestion actions (accept/dismiss) under /api/suggestions.
|
|
api.Mount("/suggestions", sug.Routes())
|
|
|
|
// Offline lexicon: full word lookups (gloss + definition + synonyms) for
|
|
// the right-click popover, and the lightweight Chinese-only gloss for the
|
|
// inline hover/select tooltip. One handler so the datasets load once.
|
|
lex := lexicon.NewHandler()
|
|
api.Mount("/word", lex.Routes())
|
|
api.Mount("/gloss", lex.GlossRoutes())
|
|
|
|
// 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 {
|
|
log.Fatalf("image store: %v", err)
|
|
}
|
|
api.Mount("/images", imgHandler.Routes())
|
|
|
|
// Read-aloud: proxy short passages to a local Piper TTS server. Only
|
|
// mounted when TTS_ENDPOINT is configured; otherwise the frontend falls
|
|
// back to the browser's Web Speech API on its own.
|
|
if ttsHandler, ok := tts.New(cfg); ok {
|
|
api.Mount("/tts", ttsHandler.Routes())
|
|
log.Printf("read-aloud enabled (TTS endpoint=%s)", cfg.TTSEndpoint)
|
|
}
|
|
})
|
|
|
|
// Everything else: serve the embedded SPA (with index.html fallback for client routing).
|
|
r.NotFound(spaHandler())
|
|
|
|
addr := ":" + cfg.Port
|
|
log.Printf("petal listening on %s (LLM backend=%s)", addr, cfg.LLMBackend)
|
|
if err := http.ListenAndServe(addr, r); err != nil {
|
|
log.Fatalf("server error: %v", err)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// change?" signal. Falls back to "dev" when the frontend hasn't been built.
|
|
func buildVersion() string {
|
|
data, err := fs.ReadFile(web.DistFS, "dist/index.html")
|
|
if err != nil {
|
|
return "dev"
|
|
}
|
|
sum := sha256.Sum256(data)
|
|
return hex.EncodeToString(sum[:])[:12]
|
|
}
|
|
|
|
// spaHandler serves the embedded web/dist as a single-page app: static files
|
|
// when they exist, falling back to index.html for unknown paths. If the
|
|
// frontend hasn't been built yet, it returns a friendly dev hint instead.
|
|
func spaHandler() http.HandlerFunc {
|
|
sub, err := fs.Sub(web.DistFS, "dist")
|
|
if err != nil {
|
|
log.Fatalf("embed sub: %v", err)
|
|
}
|
|
|
|
if _, err := fs.Stat(sub, "index.html"); errors.Is(err, fs.ErrNotExist) {
|
|
return func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("Petal backend is running, but the frontend isn't built yet.\n" +
|
|
"Run `npm run build` in web/, or use `npm run dev` for the dev server on :5173.\n"))
|
|
}
|
|
}
|
|
|
|
fileServer := http.FileServer(http.FS(sub))
|
|
return func(w http.ResponseWriter, req *http.Request) {
|
|
p := strings.TrimPrefix(req.URL.Path, "/")
|
|
if p == "" {
|
|
p = "index.html"
|
|
}
|
|
if _, err := fs.Stat(sub, p); errors.Is(err, fs.ErrNotExist) {
|
|
// Unknown path → let the SPA router handle it.
|
|
req2 := new(http.Request)
|
|
*req2 = *req
|
|
req2.URL.Path = "/"
|
|
fileServer.ServeHTTP(w, req2)
|
|
return
|
|
}
|
|
fileServer.ServeHTTP(w, req)
|
|
}
|
|
}
|