mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-24 05:41:53 +00:00
v3 library: growth-edge "practice next" recommender — P3 (#704)
* v3 library: growth-edge "practice next" recommender — P3 The "Keep practicing" shelf stops being recency-only: a new GET /api/library/practice-suggestions ranks started-but-unmastered songs by difficulty-appropriateness x mastery-proximity (the growth edge - the mid-difficulty, closest-to-mastery material where practice pays off fastest), and the shelf sources it instead of filtering /api/stats/recent. - Score = difficulty band fit (your 1-5 rating; unrated degrades to the middle band so the shelf works before any ratings exist) x proximity to the 0.9 mastery threshold. Read-only - never writes difficulty. - A shelf click opens the closest-to-mastery arrangement. - Per-arrangement difficulty and seed-from-authored intentionally NOT faked: there is no authored/derived difficulty on songs yet (the feedpak difficulty spec is unmerged) and the personal rating is per-song - both revisit when that field lands. 9 endpoint tests. tailwind.min.css regenerated (generated file - on a merge conflict, re-run scripts/build-tailwind.sh). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN * fix(v3): deterministic tiebreak (filename) in practice-next ordering (PR #704 review) Add r["filename"] as the final sort component so suggestions with equal growth_score and equal/None last_played_at order deterministically instead of by SQLite's unordered agg.items() scan. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
parent
feaaa5cd81
commit
77e5a4982b
103
server.py
103
server.py
@ -501,6 +501,13 @@ def next_library_cursor(sort: str, last_song: dict | None) -> str | None:
|
||||
return _encode_cursor([last_song[key], last_song["filename"]])
|
||||
|
||||
|
||||
# Song-level "mastered" threshold — best accuracy across a song's arrangements
|
||||
# at/above this counts as in your repertoire. One number shared by the green
|
||||
# accuracy badge, the Repertoire meter, the mastery filter/sort, and the P3
|
||||
# growth-edge recommender (matches the frontend MASTERY_ACCURACY).
|
||||
MASTERY_ACCURACY = 0.9
|
||||
|
||||
|
||||
class MetadataDB:
|
||||
def __init__(self):
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
@ -1661,6 +1668,78 @@ class MetadataDB:
|
||||
for r in rows
|
||||
]
|
||||
|
||||
# ── FUTURE ENHANCEMENT (revisit once the feedpak difficulty spec is locked) ──
|
||||
# The library-metadata design (§8) calls for user-difficulty to be
|
||||
# PER-ARRANGEMENT ("easy on bass ≠ easy on lead") and SEEDED FROM the authored/
|
||||
# derived difficulty so it's never blank. Neither ships here on purpose:
|
||||
# • personal difficulty is currently per-FILENAME (P1's song_user_meta);
|
||||
# per-arrangement is a P1-schema + Details-drawer (P2) re-scope; and
|
||||
# • there is NO authored/derived difficulty field on `songs` yet — that waits
|
||||
# on the feedpak difficulty spec (the #37-family FEP), which is unmerged.
|
||||
# So this recommender ships the growth-edge PAYOFF now and degrades gracefully
|
||||
# (an unrated song is treated as mid). When the feedpak difficulty field lands,
|
||||
# revisit: (1) seed unset user-difficulty from authored instead of assuming mid,
|
||||
# and (2) score per (filename, arrangement) rather than per song.
|
||||
@staticmethod
|
||||
def _growth_edge_score(best_accuracy: float, user_difficulty) -> float:
|
||||
"""The 'practice next' score = difficulty-appropriateness × proximity to
|
||||
mastery. Peaks where a song is BOTH at a productive challenge level (the
|
||||
mid difficulty band) AND close to — but not yet at — mastery (the
|
||||
goal-gradient push). An UNSET personal difficulty is treated as mid, so
|
||||
the recommender still works before anything is rated (it degrades to
|
||||
closest-to-mastery-first) — see P3 notes: authored/derived difficulty
|
||||
seeding waits on the feedpak difficulty spec.
|
||||
|
||||
diff_weight: 3 → 1.0, 2/4 → 0.8, 1/5 → 0.6 (extremes deprioritized, never
|
||||
zeroed — you grow on the challenging middle, not the trivially easy or the
|
||||
frustratingly hard). Never writes anything."""
|
||||
d = user_difficulty if user_difficulty is not None else 3
|
||||
weight = 1.0 - abs(d - 3) * 0.2
|
||||
return weight * (best_accuracy or 0.0)
|
||||
|
||||
def growth_edge_suggestions(self, limit: int = 8) -> list[dict]:
|
||||
"""Attempted-but-not-yet-mastered songs ranked by the growth-edge score —
|
||||
the 'Keep practicing' recommender that replaces recency-only ordering.
|
||||
Song-level (best accuracy across arrangements, like the badge); the
|
||||
suggested `arrangement` is the one you're closest to mastering, so the
|
||||
shelf opens the version worth pushing. Read-only."""
|
||||
limit = max(1, min(24, int(limit)))
|
||||
rows = self.conn.execute(
|
||||
"SELECT filename, arrangement, best_accuracy, plays, last_played_at "
|
||||
"FROM song_stats WHERE 1=1 " + self._existing_song_filter()
|
||||
).fetchall()
|
||||
# Aggregate per song: best accuracy + the arrangement that owns it, total
|
||||
# plays, most-recent play (used as a stable tiebreak).
|
||||
agg: dict = {}
|
||||
for fn, arr, acc, plays, lp in rows:
|
||||
a = agg.get(fn)
|
||||
if a is None:
|
||||
a = agg[fn] = {"acc": None, "arr": 0, "plays": 0, "lp": None}
|
||||
a["plays"] += (plays or 0)
|
||||
if acc is not None and (a["acc"] is None or acc > a["acc"]):
|
||||
a["acc"] = acc
|
||||
a["arr"] = arr
|
||||
if lp and (not a["lp"] or lp > a["lp"]):
|
||||
a["lp"] = lp
|
||||
cands = [(fn, a) for fn, a in agg.items()
|
||||
if a["plays"] > 0 and a["acc"] is not None and a["acc"] < MASTERY_ACCURACY]
|
||||
if not cands:
|
||||
return []
|
||||
diffs = self.user_meta_map([fn for fn, _ in cands]) # {filename: 1..5}
|
||||
out = []
|
||||
for fn, a in cands:
|
||||
d = diffs.get(fn)
|
||||
out.append({
|
||||
"filename": fn,
|
||||
"best_accuracy": a["acc"],
|
||||
"arrangement": a["arr"],
|
||||
"last_played_at": a["lp"],
|
||||
"user_difficulty": d,
|
||||
"growth_score": round(self._growth_edge_score(a["acc"], d), 6),
|
||||
})
|
||||
out.sort(key=lambda r: (r["growth_score"], r["last_played_at"] or "", r["filename"]), reverse=True)
|
||||
return out[:limit]
|
||||
|
||||
# ── Playlists ─────────────────────────────────────────────────────────--
|
||||
SAVED_KEY = "saved_for_later"
|
||||
|
||||
@ -6076,6 +6155,30 @@ def api_top_stats(limit: int = 5):
|
||||
return out
|
||||
|
||||
|
||||
@app.get("/api/library/practice-suggestions")
|
||||
def api_practice_suggestions(limit: int = 8):
|
||||
"""Growth-edge 'practice next' shelf (P3): attempted-but-not-mastered songs
|
||||
ranked by difficulty-appropriateness × mastery-proximity, joined to song
|
||||
metadata. Replaces the recency-only 'Keep practicing' shelf ordering. Local
|
||||
library only — reads local practice stats."""
|
||||
from urllib.parse import quote
|
||||
out = []
|
||||
for r in meta_db.growth_edge_suggestions(limit):
|
||||
meta = meta_db.conn.execute(
|
||||
"SELECT title, artist, tuning_name FROM songs WHERE filename = ?",
|
||||
(r["filename"],),
|
||||
).fetchone()
|
||||
title, artist, tuning_name = meta if meta else (None, None, None)
|
||||
out.append({
|
||||
**r,
|
||||
"title": title or r["filename"],
|
||||
"artist": artist or "",
|
||||
"tuning_name": tuning_name or "",
|
||||
"art_url": f"/api/song/{quote(r['filename'])}/art",
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@app.get("/api/stats/{filename:path}")
|
||||
def api_song_stats(filename: str):
|
||||
return meta_db.get_song_stats(filename)
|
||||
|
||||
@ -505,12 +505,12 @@
|
||||
}
|
||||
|
||||
// ── Practice-aware library home (repertoire meter + "Keep practicing") ─────
|
||||
// Both read data we already have: state.accuracy (/api/stats/best =
|
||||
// {filename: best_accuracy}) and /api/stats/recent. A song is "in your
|
||||
// repertoire" at the same threshold the green accuracy badge uses (>= 0.9);
|
||||
// a started song below that is "in progress". This is descriptive
|
||||
// encouragement — it never gates content, decays, or nags (the goal-gradient
|
||||
// / endowed-progress idea, kept healthy).
|
||||
// The meter reads state.accuracy (/api/stats/best = {filename: best_accuracy});
|
||||
// the shelf reads the growth-edge recommender (/api/library/practice-suggestions,
|
||||
// P3). A song is "in your repertoire" at the same threshold the green accuracy
|
||||
// badge uses (>= 0.9); a started song below that is "in progress". This is
|
||||
// descriptive encouragement — it never gates content, decays, or nags (the
|
||||
// goal-gradient / endowed-progress idea, kept healthy).
|
||||
const MASTERY_ACCURACY = 0.9;
|
||||
|
||||
function _repertoireCounts() {
|
||||
@ -525,9 +525,9 @@
|
||||
// The home block is the unfiltered "front door": shown on the grid view when
|
||||
// the user isn't running a focused query (search / filter) or selecting.
|
||||
// Local provider only — the meter's mastered count and the shelf both read
|
||||
// local practice stats (state.accuracy / /api/stats/recent), so on a remote
|
||||
// provider they'd mix local numerators with a remote song total and play
|
||||
// local files while browsing a remote library. Hide it there.
|
||||
// local practice stats (state.accuracy / the practice-suggestions endpoint),
|
||||
// so on a remote provider they'd mix local numerators with a remote song total
|
||||
// and play local files while browsing a remote library. Hide it there.
|
||||
function libHomeVisible() {
|
||||
return state.view === 'grid' && state.provider === 'local'
|
||||
&& !state.selectMode && !state.q && activeFilterCount() === 0;
|
||||
@ -543,10 +543,10 @@
|
||||
const myToken = ++_homeToken;
|
||||
// Unfiltered library size for the meter denominator (the grid's
|
||||
// state.total tracks the active filter; the meter is library-wide) +
|
||||
// recently-played rows for the shelf, fetched together.
|
||||
const [stats, recent] = await Promise.all([
|
||||
// the growth-edge "practice next" shelf rows, fetched together.
|
||||
const [stats, suggestions] = await Promise.all([
|
||||
jget('/api/library/stats?provider=' + enc(state.provider)),
|
||||
jget('/api/stats/recent?limit=24'),
|
||||
jget('/api/library/practice-suggestions?limit=8'),
|
||||
]);
|
||||
if (_homeToken !== myToken || !libHomeVisible()) { // changed mid-fetch
|
||||
if (_homeToken === myToken) host.classList.add('hidden');
|
||||
@ -554,22 +554,13 @@
|
||||
}
|
||||
const total = (stats && (stats.total_songs ?? stats.total)) || 0;
|
||||
if (total <= 0) { host.classList.add('hidden'); return; } // empty library
|
||||
// Shelf = recently-played, not-yet-mastered songs, newest first. Mastery
|
||||
// is per-SONG (state.accuracy = MAX best across arrangements, what the
|
||||
// green badge shows) — recents are per-(song,arrangement), so dedupe by
|
||||
// filename and gate on the song's best, keeping the shelf and its badges
|
||||
// consistent (no green-badged "keep practicing" card, no dupes).
|
||||
const acc = state.accuracy || {};
|
||||
const seen = new Set();
|
||||
const shelf = (Array.isArray(recent) ? recent : [])
|
||||
.filter((r) => {
|
||||
if (!r || seen.has(r.filename)) return false;
|
||||
const best = acc[r.filename];
|
||||
if (typeof best !== 'number' || best >= MASTERY_ACCURACY) return false;
|
||||
seen.add(r.filename);
|
||||
return true;
|
||||
})
|
||||
.slice(0, 8);
|
||||
// Shelf = the growth-edge recommender (P3): attempted-but-not-mastered
|
||||
// songs ordered by difficulty-appropriateness × mastery-proximity, so it
|
||||
// points at the version worth practicing next rather than just the most
|
||||
// recent. The server already gates (not-mastered) + aggregates per song,
|
||||
// so the rows are the shelf as-is; each row's `arrangement` is the one
|
||||
// closest to mastery (what a click should open).
|
||||
const shelf = Array.isArray(suggestions) ? suggestions : [];
|
||||
|
||||
const { mastered, learning } = _repertoireCounts();
|
||||
const pct = Math.max(0, Math.min(100, Math.round((mastered / total) * 100)));
|
||||
@ -606,7 +597,7 @@
|
||||
// The home block sits above the grid sizer, so its height shifts where the
|
||||
// window maps in scroll space — repaint the window once it's laid out.
|
||||
if (state.view === 'grid') requestWindowRender();
|
||||
// Wire shelf cards → play (mirrors playCard's local path; recents are
|
||||
// Wire shelf cards → play (mirrors playCard's local path; suggestions are
|
||||
// always local-library rows, so no provider sync is needed).
|
||||
host.querySelectorAll('.v3-kp-card').forEach((btn) => btn.addEventListener('click', () => {
|
||||
const fn = btn.getAttribute('data-kp');
|
||||
|
||||
140
tests/test_practice_suggestions.py
Normal file
140
tests/test_practice_suggestions.py
Normal file
@ -0,0 +1,140 @@
|
||||
"""Tests for the P3 growth-edge 'practice next' recommender —
|
||||
GET /api/library/practice-suggestions + meta_db.growth_edge_suggestions().
|
||||
|
||||
The score is difficulty-appropriateness (mid band wins) × mastery-proximity
|
||||
(closer to 0.9, not yet there). Read-only: it must never write difficulty.
|
||||
Personal difficulty is per-filename (P1); authored/derived seeding + true
|
||||
per-arrangement difficulty are deferred pending the feedpak difficulty spec."""
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server(tmp_path, monkeypatch, isolate_logging):
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
||||
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
|
||||
sys.modules.pop("server", None)
|
||||
srv = importlib.import_module("server")
|
||||
try:
|
||||
yield srv
|
||||
finally:
|
||||
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
sys.modules.pop("server", None)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(server):
|
||||
return TestClient(server.app)
|
||||
|
||||
|
||||
def _seed(server, fn, title=None):
|
||||
server.meta_db.put(fn, 0, 0, {"title": title or fn.split(".")[0], "artist": "A"})
|
||||
|
||||
|
||||
def _play(server, fn, acc, arr=0):
|
||||
"""Record a scored attempt so the song has a best_accuracy."""
|
||||
server.meta_db.record_session(fn, arr, score=int(acc * 1000), accuracy=acc)
|
||||
|
||||
|
||||
def _diff(client, fn, d):
|
||||
client.put(f"/api/song/{fn}/user-meta", json={"user_difficulty": d})
|
||||
|
||||
|
||||
def _suggest(client, limit=8):
|
||||
return client.get(f"/api/library/practice-suggestions?limit={limit}").json()
|
||||
|
||||
|
||||
SUG = "/api/library/practice-suggestions"
|
||||
|
||||
|
||||
# ── Gating: only attempted & not-yet-mastered ────────────────────────────────
|
||||
|
||||
def test_excludes_mastered_and_unattempted(client, server):
|
||||
_seed(server, "mastered.archive"); _play(server, "mastered.archive", 0.95)
|
||||
_seed(server, "inprog.archive"); _play(server, "inprog.archive", 0.6)
|
||||
_seed(server, "fresh.archive") # never played
|
||||
got = {r["filename"] for r in _suggest(client)}
|
||||
assert got == {"inprog.archive"}
|
||||
|
||||
|
||||
def test_empty_when_nothing_attempted(client, server):
|
||||
_seed(server, "a.archive")
|
||||
assert _suggest(client) == []
|
||||
|
||||
|
||||
# ── Ordering: mid-difficulty preferred at equal accuracy ──────────────────────
|
||||
|
||||
def test_mid_difficulty_ranks_above_extremes(client, server):
|
||||
for fn in ("mid.archive", "easy.archive", "hard.archive"):
|
||||
_seed(server, fn); _play(server, fn, 0.6)
|
||||
_diff(client, "mid.archive", 3)
|
||||
_diff(client, "easy.archive", 1)
|
||||
_diff(client, "hard.archive", 5)
|
||||
order = [r["filename"] for r in _suggest(client)]
|
||||
assert order[0] == "mid.archive"
|
||||
# 1 and 5 share the same weight, so both trail mid
|
||||
assert set(order[1:]) == {"easy.archive", "hard.archive"}
|
||||
|
||||
|
||||
# ── Ordering: closer-to-mastery preferred at equal difficulty ─────────────────
|
||||
|
||||
def test_closer_to_mastery_ranks_higher(client, server):
|
||||
_seed(server, "almost.archive"); _play(server, "almost.archive", 0.85)
|
||||
_seed(server, "early.archive"); _play(server, "early.archive", 0.4)
|
||||
# both unrated (→ treated as mid), so accuracy proximity decides
|
||||
order = [r["filename"] for r in _suggest(client)]
|
||||
assert order == ["almost.archive", "early.archive"]
|
||||
|
||||
|
||||
def test_unrated_still_surfaces_as_mid(client, server):
|
||||
"""Before anything is rated the shelf must still work (degrades to
|
||||
closest-to-mastery). An unrated song outranks a very-easy rated one at
|
||||
similar accuracy."""
|
||||
_seed(server, "unrated.archive"); _play(server, "unrated.archive", 0.7)
|
||||
_seed(server, "veryeasy.archive"); _play(server, "veryeasy.archive", 0.72)
|
||||
_diff(client, "veryeasy.archive", 1)
|
||||
order = [r["filename"] for r in _suggest(client)]
|
||||
# unrated (weight 1.0 × 0.70 = 0.70) beats very-easy (0.6 × 0.72 = 0.432)
|
||||
assert order[0] == "unrated.archive"
|
||||
|
||||
|
||||
# ── Best arrangement = the one closest to mastery ────────────────────────────
|
||||
|
||||
def test_suggested_arrangement_is_best(client, server):
|
||||
_seed(server, "multi.archive")
|
||||
_play(server, "multi.archive", 0.5, arr=0)
|
||||
_play(server, "multi.archive", 0.8, arr=1) # closer to mastery
|
||||
r = _suggest(client)[0]
|
||||
assert r["filename"] == "multi.archive"
|
||||
assert r["arrangement"] == 1
|
||||
assert r["best_accuracy"] == 0.8
|
||||
|
||||
|
||||
# ── Enrichment + limit ───────────────────────────────────────────────────────
|
||||
|
||||
def test_rows_are_enriched(client, server):
|
||||
_seed(server, "song.archive", title="My Song"); _play(server, "song.archive", 0.6)
|
||||
r = _suggest(client)[0]
|
||||
assert r["title"] == "My Song" and r["artist"] == "A"
|
||||
assert r["art_url"].endswith("/art")
|
||||
assert "growth_score" in r
|
||||
|
||||
|
||||
def test_limit_is_respected(client, server):
|
||||
for i in range(5):
|
||||
fn = f"s{i}.archive"; _seed(server, fn); _play(server, fn, 0.5 + i * 0.05)
|
||||
assert len(_suggest(client, limit=3)) == 3
|
||||
|
||||
|
||||
# ── Read-only: never writes difficulty ───────────────────────────────────────
|
||||
|
||||
def test_recommender_never_writes_difficulty(client, server):
|
||||
_seed(server, "a.archive"); _play(server, "a.archive", 0.6)
|
||||
_suggest(client)
|
||||
assert client.get("/api/song/a.archive/user-meta").json()["user_difficulty"] is None
|
||||
Loading…
Reference in New Issue
Block a user