From d0644ab2ebfd138244251f8d43034980b233efa4 Mon Sep 17 00:00:00 2001 From: sudacode Date: Wed, 8 Jul 2026 02:15:58 -0700 Subject: [PATCH] fix(stats): parse v3 reading-aware known-word cache in stats server (#149) --- changes/stats-known-words-v3-cache.md | 4 ++ .../services/__tests__/stats-server.test.ts | 38 +++++++++++++++++++ src/core/services/stats-server.ts | 15 ++++++++ 3 files changed, 57 insertions(+) create mode 100644 changes/stats-known-words-v3-cache.md diff --git a/changes/stats-known-words-v3-cache.md b/changes/stats-known-words-v3-cache.md new file mode 100644 index 00000000..3b06934b --- /dev/null +++ b/changes/stats-known-words-v3-cache.md @@ -0,0 +1,4 @@ +type: fixed +area: stats + +- Session known-word counts no longer show 0 everywhere. The stats server's known-word cache parser only understood the v1/v2 cache formats, so after the reading-aware v3 cache upgrade it silently treated the cache as missing; it now flattens v3 note entries into the headword set. diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts index 8d1a5c5c..d51fc060 100644 --- a/src/core/services/__tests__/stats-server.test.ts +++ b/src/core/services/__tests__/stats-server.test.ts @@ -521,6 +521,44 @@ describe('stats server API routes', () => { }); }); + it('GET /api/stats/sessions enriches known-word metrics from a v3 reading-aware cache', async () => { + await withTempDir(async (dir) => { + const cachePath = path.join(dir, 'known-words.json'); + fs.writeFileSync( + cachePath, + JSON.stringify({ + version: 3, + refreshedAtMs: 1, + scope: 'deck:test', + notes: { + '101': [{ word: 'する', reading: 'する' }], + '102': [{ word: '猫', reading: null }], + }, + }), + ); + + const app = createStatsApp( + createMockTracker({ + getSessionWordsByLine: async (sessionId: number) => + sessionId === 1 + ? [ + { lineIndex: 1, headword: 'する', occurrenceCount: 2 }, + { lineIndex: 2, headword: '未知', occurrenceCount: 1 }, + ] + : [], + }), + { knownWordCachePath: cachePath }, + ); + + const res = await app.request('/api/stats/sessions?limit=5'); + assert.equal(res.status, 200); + const body = await res.json(); + const first = body[0]; + assert.equal(first.knownWordsSeen, 2); + assert.equal(first.knownWordRate, 66.7); + }); + }); + it('GET /api/stats/sessions/:id/events forwards event type filters to the tracker', async () => { let seenSessionId = 0; let seenLimit = 0; diff --git a/src/core/services/stats-server.ts b/src/core/services/stats-server.ts index b1f28050..83af29f6 100644 --- a/src/core/services/stats-server.ts +++ b/src/core/services/stats-server.ts @@ -261,10 +261,25 @@ function loadKnownWordsSet(cachePath: string | undefined): Set | null { const raw = JSON.parse(readFileSync(cachePath, 'utf-8')) as { version?: number; words?: string[]; + notes?: Record>; }; if ((raw.version === 1 || raw.version === 2) && Array.isArray(raw.words)) { return new Set(raw.words); } + // v3 stores reading-aware entries per note; stats rows only carry + // headwords, so flatten to a word set (reading-agnostic, fail-open). + if (raw.version === 3 && raw.notes && typeof raw.notes === 'object') { + const words = new Set(); + for (const entries of Object.values(raw.notes)) { + if (!Array.isArray(entries)) continue; + for (const entry of entries) { + if (entry && typeof entry.word === 'string' && entry.word) { + words.add(entry.word); + } + } + } + return words; + } } catch { /* ignore */ }