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
231 lines
6.8 KiB
Go
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
|
|
}
|