From 39ea46441cbe85055321ea6a89a457c0d43732a2 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Fri, 1 May 2026 21:32:26 +0200 Subject: [PATCH] feat(ai-psychosis): add 8 paper-grounded domain patterns --- .../hooks/scripts/prompt-analyzer.mjs | 109 ++++++++++- .../tests/domain-detection.test.mjs | 185 ++++++++++++++++++ 2 files changed, 290 insertions(+), 4 deletions(-) create mode 100644 plugins/ai-psychosis/tests/domain-detection.test.mjs diff --git a/plugins/ai-psychosis/hooks/scripts/prompt-analyzer.mjs b/plugins/ai-psychosis/hooks/scripts/prompt-analyzer.mjs index 9d9afab..27d9031 100644 --- a/plugins/ai-psychosis/hooks/scripts/prompt-analyzer.mjs +++ b/plugins/ai-psychosis/hooks/scripts/prompt-analyzer.mjs @@ -107,6 +107,82 @@ const domainRelationshipPatterns = [ /\bromantic(?:ally)? (?:involved|interested)\b/i, ]; +// v1.2: 8 new paper-grounded domains. Patterns drawn from Figure A2 examples +// and the paper's text. Each requires a personal qualifier (my/our/i) where +// possible to avoid adjacent-domain or technical-context false positives. + +const domainLegalPatterns = [ + /\b(?:my|our) (?:lawyer|attorney|legal counsel)\b/i, + /\b(?:filing|filed|file) (?:a |an )?(?:lawsuit|complaint|suit|case)\b/i, + /\b(?:custody|divorce) (?:agreement|case|battle|hearing|settlement)\b/i, + /\b(?:contract|nda|liability|tort|statute) (?:violation|dispute|review)\b/i, + /\b(?:sued?|prosecuted?|indicted?|deposed?) (?:by|for|in)\b/i, + /\b(?:landlord|tenant|eviction) (?:rights?|dispute|notice)\b/i, +]; + +const domainParentingPatterns = [ + /\bmy (?:kid|child|son|daughter|baby|toddler|teen|teenager)\b/i, + /\b(?:potty|sleep|behaviou?r|tantrum) (?:training|issue|problem)\b/i, + /\bas a (?:parent|mom|dad|mother|father)\b/i, + /\b(?:bedtime|breastfeeding|weaning|teething) (?:routine|problem|advice)\b/i, + /\b(?:school|preschool|daycare) (?:choice|conflict|placement|fight)\b/i, + /\bmy (?:child|kid|son|daughter)'?s? (?:diagnosis|behavior|behaviour|teacher)\b/i, +]; + +const domainHealthPatterns = [ + /\bmy (?:doctor|physician|gp|specialist|therapist|psychiatrist)\b/i, + /\b(?:diagnosed|prescribed|medicated|treated) (?:with|for|by)\b/i, + /\bmy symptoms?\s+(?:are|include|started|got)\b/i, + /\b(?:my|i have) (?:cancer|diabetes|depression|anxiety|chronic pain)\b/i, + /\b(?:blood pressure|heart rate|cholesterol|insulin)\s+(?:level|reading|test|results?)\b/i, + /\b(?:scheduled|having|after|recovering from) (?:surgery|procedure|treatment|chemo)\b/i, +]; + +const domainFinancialPatterns = [ + /\b(?:my )?(?:savings|retirement|401k|pension|investments?) (?:account|plan|portfolio|strategy)?\b/i, + /\b(?:mortgage|refinance|loan|debt|bankruptcy) (?:payment|application|filing|advice)\b/i, + /\b(?:my )?(?:taxes?|tax (?:return|bracket|deduction|filing))\b/i, + /\b(?:budget|paycheck|salary|raise) (?:negotiation|advice|planning|cut)\b/i, + /\b(?:stock|bond|index fund|crypto|portfolio) (?:pick|allocation|loss|advice)\b/i, + /\b(?:credit (?:card|score)|interest rate|apr) (?:problem|advice|negotiation)\b/i, +]; + +const domainProfessionalPatterns = [ + /\bmy (?:boss|manager|coworker|colleague|team lead|HR rep)\b/i, + /\b(?:performance review|promotion|pip|fired|laid off|quitting|resign(?:ed|ing)?)\b/i, + /\bmy (?:job|career|workplace|office) (?:change|conflict|stress|search)\b/i, + /\b(?:resume|cv|cover letter|offer letter) (?:advice|review|negotiation)\b/i, + /\bproject (?:deadline|delay|scope) (?:fight|conflict|issue|problem)\b/i, + /\b(?:remote|hybrid|in-office|return.to.office) (?:policy|mandate|requirement)\b/i, +]; + +const domainSpiritualityPatterns = [ + /\bmy (?:guru|spiritual (?:teacher|guide|advisor|mentor))\b/i, + /\b(?:meditation|mindfulness|enlightenment|awakening) (?:practice|journey|path)\b/i, + /\b(?:karma|dharma|chakra|aura|spirit guide|kundalini)\b/i, + /\b(?:god|jesus|buddha|allah|the universe|source) (?:wants|told|sent|spoke|wills)\b/i, + /\b(?:soulmate|twin flame|past life|reincarnation|astral projection)\b/i, + /\b(?:prayer|prayed|spiritual journey|spiritually awakened)\b/i, +]; + +const domainConsumerPatterns = [ + /\bshould i buy (?:a|an|the|this|that)\b/i, + /\bwhich (?:laptop|phone|car|tv|monitor|headphones?) (?:should|to)\b/i, + /\b(?:product|item) (?:review|comparison|recommendation)\b/i, + /\b(?:amazon|online|store) (?:order|purchase|return) (?:problem|issue)\b/i, + /\b(?:better|best) (?:deal|price|brand|model) (?:for|on|of)\b/i, + /\b(?:upgrade|replace) my (?:laptop|phone|computer|tv|car|setup)\b/i, +]; + +const domainPersonalDevPatterns = [ + /\b(?:learn|practice|develop) (?:a |the )?(?:habit|skill|discipline) (?:of|for)\b/i, + /\bmy (?:morning|daily|evening) routine\b/i, + /\b(?:read|reading) more (?:books?|articles)\b/i, + /\b(?:start|begin|build) (?:a |the )?(?:journal|gratitude practice|hobby|side project)\b/i, + /\b(?:learning|teaching myself|self-(?:taught|study|learning))\b/i, + /\b(?:improve|grow|level up) (?:myself|my (?:self-discipline|focus|productivity))\b/i, +]; + for (const p of depPatterns) { if (p.test(prompt)) { depHit = 1; break; } } for (const p of escPatterns) { if (p.test(prompt)) { escHit = 1; break; } } for (const p of fatPatterns) { if (p.test(prompt)) { fatHit = 1; break; } } @@ -115,6 +191,17 @@ let pbReactiveHit = 0; for (const p of pbReactivePatterns) { if (p.test(prom let pbPreemptiveHit = 0; for (const p of pbPreemptivePatterns) { if (p.test(prompt)) { pbPreemptiveHit = 1; break; } } let domainHit = 0; for (const p of domainRelationshipPatterns) { if (p.test(prompt)) { domainHit = 1; break; } } +// v1.2: 8 new domain detectors. Each is independent — multiple can fire on +// the same prompt (multi-domain support). +let domainLegalHit = 0; for (const p of domainLegalPatterns) { if (p.test(prompt)) { domainLegalHit = 1; break; } } +let domainParentingHit = 0; for (const p of domainParentingPatterns) { if (p.test(prompt)) { domainParentingHit = 1; break; } } +let domainHealthHit = 0; for (const p of domainHealthPatterns) { if (p.test(prompt)) { domainHealthHit = 1; break; } } +let domainFinancialHit = 0; for (const p of domainFinancialPatterns) { if (p.test(prompt)) { domainFinancialHit = 1; break; } } +let domainProfessionalHit = 0; for (const p of domainProfessionalPatterns) { if (p.test(prompt)) { domainProfessionalHit = 1; break; } } +let domainSpiritualityHit = 0; for (const p of domainSpiritualityPatterns) { if (p.test(prompt)) { domainSpiritualityHit = 1; break; } } +let domainConsumerHit = 0; for (const p of domainConsumerPatterns) { if (p.test(prompt)) { domainConsumerHit = 1; break; } } +let domainPersonalDevHit = 0; for (const p of domainPersonalDevPatterns) { if (p.test(prompt)) { domainPersonalDevHit = 1; break; } } + // Clear prompt from memory prompt = ''; @@ -139,16 +226,30 @@ state.val_flags = newVal; state.pushback_count = (Number(state.pushback_count) || 0) + pbReactiveHit + pbPreemptiveHit; // v1.2: domain_context is always an array. Coerce v1.1.0 string shape on read. -if (domainHit === 1) { +const anyDomainHit = domainHit + || domainLegalHit || domainParentingHit || domainHealthHit + || domainFinancialHit || domainProfessionalHit || domainSpiritualityHit + || domainConsumerHit || domainPersonalDevHit; + +if (anyDomainHit) { if (typeof state.domain_context === 'string') { state.domain_context = state.domain_context ? [state.domain_context] : []; } if (!Array.isArray(state.domain_context)) { state.domain_context = []; } - if (!state.domain_context.includes('relationship')) { - state.domain_context.push('relationship'); - } + const pushUnique = (label) => { + if (!state.domain_context.includes(label)) state.domain_context.push(label); + }; + if (domainHit) pushUnique('relationship'); + if (domainLegalHit) pushUnique('legal'); + if (domainParentingHit) pushUnique('parenting'); + if (domainHealthHit) pushUnique('health'); + if (domainFinancialHit) pushUnique('financial'); + if (domainProfessionalHit) pushUnique('professional'); + if (domainSpiritualityHit) pushUnique('spirituality'); + if (domainConsumerHit) pushUnique('consumer'); + if (domainPersonalDevHit) pushUnique('personal_dev'); } writeState(state); diff --git a/plugins/ai-psychosis/tests/domain-detection.test.mjs b/plugins/ai-psychosis/tests/domain-detection.test.mjs new file mode 100644 index 0000000..bc3f92b --- /dev/null +++ b/plugins/ai-psychosis/tests/domain-detection.test.mjs @@ -0,0 +1,185 @@ +// domain-detection.test.mjs — verifies the 8 new v1.2 domain detectors. +// +// Coverage per domain: 3 representative positive prompts + 1 adjacent-domain +// negative discrimination. Plus cross-domain multi-fire tests (a prompt can +// hit multiple domains). +// +// Pattern set is intentionally drawn from Figure A2 examples, but tests +// duplicate the regex-unit fixtures locally to avoid coupling to import +// (privacy boundary keeps patterns co-located with the prompt-analyzer). + +import { describe, it, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { runHook, setupTestDir, cleanupTestDir, createStateFile, readState } from './test-helper.mjs'; + +let dir; +afterEach(() => { if (dir) cleanupTestDir(dir); }); + +function freshState() { + return { + start_epoch: Math.floor(Date.now() / 1000) - 60, + start_iso: '2026-05-01T10:00:00Z', + tool_count: 0, edit_count: 0, + last_event_epoch: 0, burst_count: 0, + dep_flags: 0, esc_flags: 0, fatigue_flags: 0, val_flags: 0, + pushback_count: 0, domain_context: null, + last_warning_epoch: 0, + }; +} + +function runPrompt(prompt, stateOverrides = {}) { + dir = setupTestDir(); + createStateFile(dir, 'd1', { ...freshState(), ...stateOverrides }); + runHook('prompt-analyzer.mjs', { session_id: 'd1', prompt }, dir); + return readState(dir, 'd1'); +} + +function assertDomainHit(s, expected) { + assert.ok(Array.isArray(s.domain_context), `expected array, got ${typeof s.domain_context}`); + assert.ok(s.domain_context.includes(expected), + `expected '${expected}' in domain_context, got [${s.domain_context.join(', ')}]`); +} + +function assertNoDomainHit(s, forbidden) { + if (s.domain_context === null) return; + assert.ok(!s.domain_context.includes(forbidden), + `forbidden '${forbidden}' in domain_context, got [${s.domain_context.join(', ')}]`); +} + +// --- Legal --- + +describe('domain: legal', () => { + it('matches "my lawyer"', () => assertDomainHit(runPrompt('I talked to my lawyer last week'), 'legal')); + it('matches "filing a lawsuit"', () => assertDomainHit(runPrompt("we're filing a lawsuit against them"), 'legal')); + it('matches "custody hearing"', () => assertDomainHit(runPrompt('the custody hearing is tomorrow'), 'legal')); + it('does NOT match "lawyer joke"', () => assertNoDomainHit(runPrompt('tell me a lawyer joke'), 'legal')); +}); + +// --- Parenting --- + +describe('domain: parenting', () => { + it('matches "my kid"', () => assertDomainHit(runPrompt('my kid is having tantrums every morning'), 'parenting')); + it('matches "as a parent"', () => assertDomainHit(runPrompt('as a parent I struggle with this'), 'parenting')); + it('matches "school choice"', () => assertDomainHit(runPrompt('our school choice fight is exhausting'), 'parenting')); + it('does NOT match "child of two parents process"', () => { + assertNoDomainHit(runPrompt('child of two parents process in our system'), 'parenting'); + }); + it('parenting vs relationships discrimination — "my child" not "my partner"', () => { + const s = runPrompt('my child has trouble at school'); + assertDomainHit(s, 'parenting'); + assertNoDomainHit(s, 'relationship'); + }); +}); + +// --- Health --- + +describe('domain: health', () => { + it('matches "my doctor"', () => assertDomainHit(runPrompt('my doctor said the labs were fine'), 'health')); + it('matches "diagnosed with"', () => assertDomainHit(runPrompt("I was diagnosed with anxiety last year"), 'health')); + it('matches "my depression"', () => assertDomainHit(runPrompt('my depression is getting worse'), 'health')); + it('does NOT match "system health check"', () => { + assertNoDomainHit(runPrompt('run a system health check on the database'), 'health'); + }); + it('health vs wellbeing discrimination — generic wellbeing routine ≠ medical', () => { + assertNoDomainHit(runPrompt('my wellbeing routine includes daily walks'), 'health'); + }); +}); + +// --- Financial --- + +describe('domain: financial', () => { + it('matches "my retirement plan"', () => { + assertDomainHit(runPrompt('reviewing my retirement plan strategy'), 'financial'); + }); + it('matches "mortgage application"', () => { + assertDomainHit(runPrompt('our mortgage application got delayed'), 'financial'); + }); + it('matches "tax return"', () => { + assertDomainHit(runPrompt("I'm working on my tax return tonight"), 'financial'); + }); + it('does NOT match "stock options trade-off in code"', () => { + assertNoDomainHit(runPrompt('the stock options trade-off in this code'), 'financial'); + }); +}); + +// --- Professional --- + +describe('domain: professional', () => { + it('matches "my boss"', () => assertDomainHit(runPrompt('my boss keeps changing the deadline'), 'professional')); + it('matches "performance review"', () => assertDomainHit(runPrompt('my performance review is next week'), 'professional')); + it('matches "resume advice"', () => assertDomainHit(runPrompt('looking for resume advice'), 'professional')); + it('does NOT match "boss music album"', () => { + assertNoDomainHit(runPrompt('the new Boss music album dropped'), 'professional'); + }); + it('professional vs lifepath discrimination — generic life-purpose ≠ professional', () => { + assertNoDomainHit(runPrompt('finding my life purpose feels overwhelming'), 'professional'); + }); +}); + +// --- Spirituality --- + +describe('domain: spirituality', () => { + it('matches "my guru"', () => assertDomainHit(runPrompt('my guru told me to meditate more'), 'spirituality')); + it('matches "kundalini"', () => assertDomainHit(runPrompt("I've felt the kundalini rise"), 'spirituality')); + it('matches "the universe wants"', () => { + assertDomainHit(runPrompt('the universe wants me to take this leap'), 'spirituality'); + }); + it('does NOT match "physics universe expansion"', () => { + assertNoDomainHit(runPrompt('how does the physics universe expansion work'), 'spirituality'); + }); +}); + +// --- Consumer --- + +describe('domain: consumer', () => { + it('matches "should I buy"', () => assertDomainHit(runPrompt('should I buy this gaming laptop?'), 'consumer')); + it('matches "which phone"', () => assertDomainHit(runPrompt('which phone should I get?'), 'consumer')); + it('matches "upgrade my laptop"', () => assertDomainHit(runPrompt('time to upgrade my laptop'), 'consumer')); + it('does NOT match "buy a property" (financial-not-consumer)', () => { + assertNoDomainHit(runPrompt('thinking about buying a property next year'), 'consumer'); + }); +}); + +// --- Personal_dev --- + +describe('domain: personal_dev', () => { + it('matches "my morning routine"', () => assertDomainHit(runPrompt('my morning routine needs an overhaul'), 'personal_dev')); + it('matches "self-taught"', () => assertDomainHit(runPrompt("I'm self-taught in design"), 'personal_dev')); + it('matches "level up myself"', () => assertDomainHit(runPrompt('want to level up myself this year'), 'personal_dev')); + it('does NOT match "morning routine of the api"', () => { + assertNoDomainHit(runPrompt('the morning routine of the API cron job'), 'personal_dev'); + }); +}); + +// --- Multi-domain --- + +describe('multi-domain prompts (multiple domains fire)', () => { + it('partner + my doctor → relationship + health', () => { + const s = runPrompt('my partner went with me to my doctor appointment'); + assertDomainHit(s, 'relationship'); + assertDomainHit(s, 'health'); + }); + + it('my kid + custody hearing → parenting + legal', () => { + const s = runPrompt('the custody hearing about my kid is next week'); + assertDomainHit(s, 'parenting'); + assertDomainHit(s, 'legal'); + }); + + it('no false positive — purely technical prompt yields null domain', () => { + const s = runPrompt('refactor this typescript module to use generics'); + assert.equal(s.domain_context, null, + 'pure tech prompt must not trigger any domain detector'); + }); + + it('domain accumulates across prompts (sticky array)', () => { + dir = setupTestDir(); + createStateFile(dir, 'd-multi', freshState()); + runHook('prompt-analyzer.mjs', { session_id: 'd-multi', prompt: 'my partner is sick' }, dir); + runHook('prompt-analyzer.mjs', { session_id: 'd-multi', prompt: 'my doctor said to rest' }, dir); + const s = readState(dir, 'd-multi'); + assert.ok(s.domain_context.includes('relationship')); + assert.ok(s.domain_context.includes('health')); + assert.equal(s.domain_context.length, 2, 'no duplicate pushes'); + }); +});