Let the garden keep what she was given, not only what she sought

Two halves of the same idea, both read out of work Petal already
records.

Planting: an accepted collocation is a learnable chunk, so it becomes a
phrase card. The scheduler didn't need to know — a three-word chunk
climbs the ladder exactly like a looked-up word. What needed care was
deciding what *isn't* a chunk (single words are word choice; a
six-word-plus "collocation" is a rewritten sentence, and sentences make
miserable flashcards), and that the example must be the *corrected*
sentence — the stored draft still holds the phrasing she just left
behind. Re-accepting the same chunk leaves the existing card alone
rather than resetting a schedule it has been climbing. The whole thing
is best-effort: accepting an edit must never fail because a flashcard
couldn't be made.

The growth journal: kept this month beside kept the month before, the
phrasing that stuck, the patterns that faded. The queries were the easy
part; the honesty is the feature. "Stuck" needs the phrase in a *second*
document, because one document is just the edit where she left it.
"Faded" says nothing at all unless she has been writing lately —
otherwise a month away from Petal comes back to her as progress, which
is the one way this could lie. And a suggestion had to start recording
when she *decided* it, not when the model proposed it, so 0012 adds
resolved_at and backfills the old rows to their created_at.

It lives as a second tab in the garden, and it feeds the kitten: after
an accept she now sometimes hears something true of her alone, once per
line, half the time, never waited for.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
prosolis
2026-07-27 14:16:59 -07:00
parent 7b845644be
commit e9b8595456
19 changed files with 1328 additions and 5 deletions
+70 -1
View File
@@ -11,6 +11,7 @@ import (
"encoding/json"
"errors"
"io"
"log"
"net/http"
"strings"
@@ -20,6 +21,7 @@ import (
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/httputil"
"gitea.parodia.dev/drwily/petal/internal/llm"
"gitea.parodia.dev/drwily/petal/internal/vocab"
)
// Handler holds the dependencies for the checkpoint + suggestion routes. The
@@ -60,6 +62,10 @@ func (h *Handler) RegisterDocRoutes(r chi.Router) {
// actions.
func (h *Handler) Routes() chi.Router {
r := chi.NewRouter()
// The growth journal reads the same table these actions write, so it lives
// here rather than growing its own mount. A literal segment, so it can never
// be shadowed by an id.
r.Get("/growth", h.growth)
r.Post("/{id}/accept", h.accept)
r.Post("/{id}/dismiss", h.dismiss)
r.Post("/{id}/chat", h.chat)
@@ -577,7 +583,7 @@ func (h *Handler) dismiss(w http.ResponseWriter, r *http.Request) {
// no rows and surfaces as a 404.
func (h *Handler) setStatus(w http.ResponseWriter, r *http.Request, status string) {
res, err := h.DB.Exec(
`UPDATE suggestions SET status = ?
`UPDATE suggestions SET status = ?, resolved_at = datetime('now')
WHERE id = ? AND status = ?
AND doc_id IN (SELECT id FROM documents WHERE user_id = ?)`,
status, chi.URLParam(r, "id"), db.SuggestionStatusPending,
@@ -591,9 +597,72 @@ func (h *Handler) setStatus(w http.ResponseWriter, r *http.Request, status strin
httputil.ErrorJSON(w, http.StatusNotFound, "pending suggestion not found")
return
}
if status == db.SuggestionStatusAccepted {
h.plant(chi.URLParam(r, "id"), auth.UserID(r.Context()))
}
w.WriteHeader(http.StatusNoContent)
}
// plant grows an accepted collocation into a vocabulary-garden phrase card. It
// runs after the status write and swallows its own errors: accepting an edit is
// the thing the writer asked for, and it must not fail — or even feel slower —
// because a flashcard couldn't be made.
//
// Only collocations are planted. The other families correct *this* sentence
// ("their" → "there", a comma, a clearer clause); a collocation is the one that
// hands over a reusable chunk, which is the only thing worth reviewing in a week.
func (h *Handler) plant(id, userID string) {
var s db.Suggestion
var contentText string
err := h.DB.QueryRow(
`SELECT s.type, s.original, s.replacement, s.explanation, s.doc_id, d.content_text
FROM suggestions s JOIN documents d ON d.id = s.doc_id
WHERE s.id = ? AND d.user_id = ?`,
id, userID,
).Scan(&s.Type, &s.Original, &s.Replacement, &s.Explanation, &s.DocID, &contentText)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
log.Printf("suggestions: could not read %s for planting: %v", id, err)
}
return
}
if s.Type != db.SuggestionTypeCollocation || strings.TrimSpace(s.Replacement) == "" {
return
}
// The stored text is still the pre-accept draft — the client applies the
// replacement in the editor. Correct the sentence here so the flashcard
// quizzes the phrasing she is keeping, not the one she just left behind.
docID := s.DocID
if _, err := vocab.Plant(h.DB, userID, vocab.Phrase{
Text: s.Replacement,
Meaning: s.Explanation,
Example: correctedSentence(contentText, s.Original, s.Replacement),
DocID: &docID,
}); err != nil {
log.Printf("suggestions: could not plant %s: %v", id, err)
}
}
// correctedSentence returns the sentence of contentText containing original,
// with original swapped for replacement. Returns "" when the original isn't
// found (the draft moved on) — a card with no example still reviews, just
// without the cloze, so there's nothing to fall back to and nothing to guess.
func correctedSentence(contentText, original, replacement string) string {
idx := strings.Index(contentText, original)
if original == "" || idx < 0 {
return ""
}
start := strings.LastIndexAny(contentText[:idx], ".!?\n")
end := strings.IndexAny(contentText[idx+len(original):], ".!?\n")
if end < 0 {
end = len(contentText)
} else {
end += idx + len(original) + 1 // keep the terminator
}
sentence := strings.TrimSpace(contentText[start+1 : end])
return strings.Replace(sentence, original, replacement, 1)
}
// locate finds the plaintext offsets of original within contentText. Returns
// (-1, -1) when not found; the frontend anchors by string regardless, so a miss
// here is non-fatal.