Files
petal/internal/suggestions/growth.go
T
prosolis e9b8595456 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
2026-07-27 14:16:59 -07:00

231 lines
6.8 KiB
Go

package suggestions
import (
"net/http"
"gitea.parodia.dev/drwily/petal/internal/auth"
"gitea.parodia.dev/drwily/petal/internal/httputil"
"gitea.parodia.dev/drwily/petal/internal/vocab"
)
// The growth journal.
//
// The suggestions table already records everything this needs — it is purely a
// read-side view, with no new capture and no model call. Two framing rules
// decide what may appear here, and they are enforced in the SQL rather than left
// to the copy:
//
// 1. It reports growth, never an error tally. Nothing counts what she got
// wrong this month; the signals are things that *stopped* happening and
// phrasing that *stuck*.
// 2. It only ever compares the writer to her own past self. There is no
// target, no average, no other user anywhere in these queries.
//
// A quiet month is quiet: every signal below is omitted rather than softened
// when the data isn't there, because an invented milestone is worse than none.
// Journal is one writer's growth over the recent windows.
type Journal struct {
// Kept / KeptBefore are edits she took on board in the last 30 days and in
// the 30 before that — her own past self, the only comparison offered.
Kept int `json:"kept"`
KeptBefore int `json:"kept_before"`
// Stuck: phrasing she was given that now turns up across her own writing.
Stuck []Chunk `json:"stuck"`
// Faded: things she used to need fixing and hasn't, recently.
Faded []Fade `json:"faded"`
}
// Chunk is a phrase that has stuck: it appears in Docs of her documents now.
type Chunk struct {
Phrase string `json:"phrase"`
Docs int `json:"docs"`
}
// Fade is a pattern that has stopped appearing. Times is how often it came up
// during the earlier window — context for "and not since", never a scoreboard.
type Fade struct {
Pattern string `json:"pattern"`
Times int `json:"times"`
}
// Journal windows, in days. `recent` is the month being reported on; `history`
// reaches back far enough that a pattern's absence means something (one quiet
// fortnight doesn't).
const (
recentDays = 30
historyDays = 120
maxSignals = 3 // per list: a journal is a couple of warm lines, not a report
)
// growth serves GET /api/suggestions/growth.
func (h *Handler) growth(w http.ResponseWriter, r *http.Request) {
userID := auth.UserID(r.Context())
j := Journal{Stuck: []Chunk{}, Faded: []Fade{}}
err := h.DB.QueryRow(
`SELECT
sum(CASE WHEN s.resolved_at >= datetime('now', '-30 days') THEN 1 ELSE 0 END),
sum(CASE WHEN s.resolved_at < datetime('now', '-30 days')
AND s.resolved_at >= datetime('now', '-60 days') THEN 1 ELSE 0 END)
FROM suggestions s JOIN documents d ON d.id = s.doc_id
WHERE d.user_id = ? AND s.status = 'accepted' AND s.resolved_at IS NOT NULL`,
userID,
).Scan(&nullInt{&j.Kept}, &nullInt{&j.KeptBefore})
if err != nil {
httputil.ServerError(w, err)
return
}
stuck, err := h.stuck(userID)
if err != nil {
httputil.ServerError(w, err)
return
}
j.Stuck = stuck
faded, err := h.faded(userID)
if err != nil {
httputil.ServerError(w, err)
return
}
j.Faded = faded
httputil.WriteJSON(w, http.StatusOK, j)
}
// stuck finds accepted phrasing that now appears in more than one of her own
// documents. One document is just the edit itself, still sitting where it was
// applied; a second is her reaching for the phrase on her own, which is the
// whole claim the line makes.
func (h *Handler) stuck(userID string) ([]Chunk, error) {
rows, err := h.DB.Query(
`SELECT DISTINCT s.replacement
FROM suggestions s JOIN documents d ON d.id = s.doc_id
WHERE d.user_id = ? AND s.status = 'accepted'
AND s.resolved_at >= datetime('now', '-120 days')
AND trim(s.replacement) != ''
ORDER BY s.resolved_at DESC
LIMIT 40`,
userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
// vocab.PhraseKey is the same definition of "a learnable chunk" the garden
// plants, so the journal and the garden can never disagree about what counts.
var phrases []string
for rows.Next() {
var replacement string
if err := rows.Scan(&replacement); err != nil {
return nil, err
}
if key := vocab.PhraseKey(replacement); key != "" {
phrases = append(phrases, key)
}
}
if err := rows.Err(); err != nil {
return nil, err
}
out := []Chunk{}
for _, p := range phrases {
var docs int
if err := h.DB.QueryRow(
`SELECT count(*) FROM documents WHERE user_id = ? AND instr(lower(content_text), ?) > 0`,
userID, p,
).Scan(&docs); err != nil {
return nil, err
}
if docs >= 2 {
out = append(out, Chunk{Phrase: p, Docs: docs})
}
}
sortDesc(out, func(c Chunk) int { return c.Docs })
return trim(out, maxSignals), nil
}
// faded finds patterns she used to be corrected on during the earlier part of
// the history window and hasn't been since.
//
// The guard that makes this honest: it says nothing at all unless she has
// actually been writing lately. Without it, a month away from Petal would be
// reported back to her as progress, which is the one way this feature could lie.
func (h *Handler) faded(userID string) ([]Fade, error) {
var wroteRecently int
if err := h.DB.QueryRow(
`SELECT count(*) FROM suggestions s JOIN documents d ON d.id = s.doc_id
WHERE d.user_id = ? AND s.resolved_at >= datetime('now', '-30 days')`,
userID,
).Scan(&wroteRecently); err != nil {
return nil, err
}
if wroteRecently == 0 {
return []Fade{}, nil
}
rows, err := h.DB.Query(
`SELECT lower(trim(s.original)) AS pattern, count(*) AS times
FROM suggestions s JOIN documents d ON d.id = s.doc_id
WHERE d.user_id = ? AND s.status = 'accepted'
AND s.resolved_at < datetime('now', '-30 days')
AND s.resolved_at >= datetime('now', '-120 days')
AND trim(s.original) != ''
AND pattern NOT IN (
SELECT lower(trim(s2.original))
FROM suggestions s2 JOIN documents d2 ON d2.id = s2.doc_id
WHERE d2.user_id = ? AND s2.status = 'accepted'
AND s2.resolved_at >= datetime('now', '-30 days'))
GROUP BY pattern
HAVING times >= 2
ORDER BY times DESC
LIMIT 3`,
userID, userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Fade{}
for rows.Next() {
var f Fade
if err := rows.Scan(&f.Pattern, &f.Times); err != nil {
return nil, err
}
out = append(out, f)
}
return out, rows.Err()
}
// nullInt scans a possibly-NULL aggregate into an int (SUM over no rows is
// NULL, which is a zero here, not an error).
type nullInt struct{ dst *int }
func (n *nullInt) Scan(v any) error {
switch t := v.(type) {
case int64:
*n.dst = int(t)
case nil:
*n.dst = 0
}
return nil
}
func sortDesc[T any](s []T, key func(T) int) {
for i := 1; i < len(s); i++ {
for j := i; j > 0 && key(s[j]) > key(s[j-1]); j-- {
s[j], s[j-1] = s[j-1], s[j]
}
}
}
func trim[T any](s []T, n int) []T {
if len(s) > n {
return s[:n]
}
return s
}