Files
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
}