From f88639ef41074282069d47d3367c32121d0f981f Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Fri, 1 May 2026 21:47:53 +0200 Subject: [PATCH] feat(ai-psychosis): report-reader v1.2 schema + aggregations --- .../hooks/scripts/report-reader.mjs | 80 ++++++++++++++++++- .../tests/interaction-report.test.mjs | 55 +++++++++++++ 2 files changed, 134 insertions(+), 1 deletion(-) diff --git a/plugins/ai-psychosis/hooks/scripts/report-reader.mjs b/plugins/ai-psychosis/hooks/scripts/report-reader.mjs index 83443a1..2b59d3e 100644 --- a/plugins/ai-psychosis/hooks/scripts/report-reader.mjs +++ b/plugins/ai-psychosis/hooks/scripts/report-reader.mjs @@ -22,6 +22,7 @@ export function aggregateSessions(sessions) { let null_domain_count = 0; let v1_0_records = 0; let v1_1_records = 0; + let v1_2_records = 0; let total_end_records = 0; let total_dependency = 0; @@ -29,6 +30,33 @@ export function aggregateSessions(sessions) { let total_fatigue = 0; let total_validation = 0; + // v1.2: per-domain counters (each session that includes domain X increments + // domain_breakdown[X] by 1 — multi-domain sessions increment multiple). + const domain_breakdown = { + relationship: 0, legal: 0, parenting: 0, health: 0, financial: 0, + professional: 0, spirituality: 0, consumer: 0, personal_dev: 0, + }; + // v1.2: user_info_class distribution. + const user_info_distribution = { + yes_people: 0, yes_digital: 0, no: 0, null: 0, + }; + // v1.2: valseek summary. + let valseek_sessions = 0; // sessions with valseek_count > 0 + let valseek_total = 0; // sum of valseek_count across all v1.2 records + // v1.2: aggregated stakes signal — sum of max-domain-weight across sessions. + // (Reported as part of /interaction-report; raw aggregate.) + let stakes_signal_total = 0; + let stakes_signal_sessions = 0; + + // Domain stakes table mirrors lib.mjs DOMAIN_STAKES so report-reader stays + // standalone (no cross-import). Keep in sync with lib.mjs. + const DOMAIN_STAKES = { + legal: 1.5, parenting: 1.5, health: 1.5, financial: 1.5, + relationship: 1.3, spirituality: 1.2, professional: 1.1, + wellbeing: 1.2, lifepath: 1.1, values: 1.2, + personal_dev: 1.0, consumer: 1.0, + }; + for (const rec of sessions) { if (!rec || rec.note === 'no_state_file') continue; if (rec.duration_min === undefined) continue; @@ -37,7 +65,10 @@ export function aggregateSessions(sessions) { const flags = rec.flags || {}; const pushback = flags.pushback; - if (pushback === undefined || pushback === null) v1_0_records += 1; + // v1.2 discriminator: presence of user_info_class field marks a v1.2 record. + const hasUserInfoClass = Object.prototype.hasOwnProperty.call(rec, 'user_info_class'); + if (hasUserInfoClass) v1_2_records += 1; + else if (pushback === undefined || pushback === null) v1_0_records += 1; else v1_1_records += 1; pushback_total += Number(pushback) || 0; @@ -52,6 +83,38 @@ export function aggregateSessions(sessions) { if (domains.length === 0) null_domain_count += 1; else if (domains.includes('relationship')) relationship_domain_count += 1; else other_domain_count += 1; + + // v1.2: per-domain breakdown (multi-domain sessions count once per domain). + for (const d of domains) { + if (Object.prototype.hasOwnProperty.call(domain_breakdown, d)) { + domain_breakdown[d] += 1; + } + } + + // v1.2 fields + if (hasUserInfoClass) { + const cls = rec.user_info_class; + if (cls === 'yes_people' || cls === 'yes_digital' || cls === 'no') { + user_info_distribution[cls] += 1; + } else { + user_info_distribution.null += 1; + } + + const vs = Number(rec.valseek_count) || 0; + valseek_total += vs; + if (vs > 0) valseek_sessions += 1; + + // stakes_signal: max weight among the session's domains. + if (domains.length > 0) { + let maxW = 1.0; + for (const d of domains) { + const w = DOMAIN_STAKES[d]; + if (typeof w === 'number' && w > maxW) maxW = w; + } + stakes_signal_total += maxW; + stakes_signal_sessions += 1; + } + } } return { @@ -70,6 +133,21 @@ export function aggregateSessions(sessions) { schema_version: { v1_0_records, v1_1_records, + v1_2_records, + }, + // v1.2 aggregations + domain_breakdown, + user_info_class: user_info_distribution, + valseek: { + sessions: valseek_sessions, + total: valseek_total, + }, + stakes_signal: { + sum: stakes_signal_total, + sessions: stakes_signal_sessions, + mean: stakes_signal_sessions > 0 + ? Number((stakes_signal_total / stakes_signal_sessions).toFixed(2)) + : 0, }, }; } diff --git a/plugins/ai-psychosis/tests/interaction-report.test.mjs b/plugins/ai-psychosis/tests/interaction-report.test.mjs index 1770a03..afd64a0 100644 --- a/plugins/ai-psychosis/tests/interaction-report.test.mjs +++ b/plugins/ai-psychosis/tests/interaction-report.test.mjs @@ -85,6 +85,61 @@ test('v1.2 array domain_context aggregates correctly (relationship in array)', ( assert.equal(result.null_domain_count, 1, 'empty array counts as null'); }); +test('v1.2 mixed schema fixture: per-domain breakdown + user_info_class + valseek', () => { + const fixture = [ + // v1.0 — no pushback flag, no domain_context + { session_id: 'v0', duration_min: 30, + flags: { dependency: 0, escalation: 0, fatigue: 0, validation: 0 } }, + // v1.1 — pushback flag, string domain + { session_id: 'v1', duration_min: 30, domain_context: 'relationship', + flags: { dependency: 0, escalation: 0, fatigue: 0, validation: 0, pushback: 1 } }, + // v1.2 — multi-domain array, user_info_class, valseek_count + { session_id: 'v2a', duration_min: 30, + domain_context: ['relationship', 'health'], + user_info_class: 'no', valseek_count: 3, turn_count: 20, + flags: { dependency: 0, escalation: 0, fatigue: 0, validation: 0, pushback: 2 } }, + { session_id: 'v2b', duration_min: 30, + domain_context: ['legal'], + user_info_class: 'yes_people', valseek_count: 0, turn_count: 8, + flags: { dependency: 0, escalation: 0, fatigue: 0, validation: 0, pushback: 0 } }, + { session_id: 'v2c', duration_min: 30, + domain_context: [], + user_info_class: null, valseek_count: 0, turn_count: 5, + flags: { dependency: 0, escalation: 0, fatigue: 0, validation: 0, pushback: 0 } }, + ]; + const jsonl = fixture.map(o => JSON.stringify(o)).join('\n') + '\n'; + const result = runReader(jsonl); + + // schema_version discrimination + assert.equal(result.schema_version.v1_0_records, 1); + assert.equal(result.schema_version.v1_1_records, 1); + assert.equal(result.schema_version.v1_2_records, 3); + + // per-domain breakdown (only v1.x array members) + assert.equal(result.domain_breakdown.relationship, 2, + 'v1.1 string + v1.2 array containing relationship → 2'); + assert.equal(result.domain_breakdown.health, 1); + assert.equal(result.domain_breakdown.legal, 1); + assert.equal(result.domain_breakdown.parenting, 0); + + // user_info_class distribution + assert.equal(result.user_info_class.no, 1); + assert.equal(result.user_info_class.yes_people, 1); + assert.equal(result.user_info_class.null, 1); + + // valseek aggregation + assert.equal(result.valseek.sessions, 1); + assert.equal(result.valseek.total, 3); + + // stakes_signal — max weight per session + // v2a: max(relationship=1.3, health=1.5) = 1.5 + // v2b: legal=1.5 + // v2c: empty → not counted + assert.equal(result.stakes_signal.sessions, 2); + assert.ok(Math.abs(result.stakes_signal.sum - 3.0) < 0.01, + `expected stakes_signal.sum ~3.0, got ${result.stakes_signal.sum}`); +}); + test('backward-compat: v1.0.0 records without pushback/domain do not produce NaN', () => { const fixture = [ // v1.0.0 — no pushback in flags, no domain_context at top level