Skip to content
180 changes: 176 additions & 4 deletions __tests__/safeguarding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,13 @@

import type { GateType, SessionState } from '../lib/types';
import { toUserProfile } from '../lib/types';
import { createSession, getFirstMessage, processInput } from '../lib/stateMachine';
import {
createSession,
getFirstMessage,
processInput,
detectUnder16Age,
interceptUnder16Age,
} from '../lib/stateMachine';
import { getPhrase } from '../lib/phrasebank';

// Helper: create session at specific gate with profile data
Expand Down Expand Up @@ -111,10 +117,10 @@
expect(result.options?.[4]).toContain('Sexual violence');
});

test('option 7 (none) proceeds to GATE1_INTENT', () => {
test('option 7 (none) proceeds to LOCATION_CONSENT', () => {
const session = sessionAt('GATE0_CRISIS_DANGER');
const result = select(session, 7);
expect(result.stateUpdates.currentGate).toBe('GATE1_INTENT');
expect(result.stateUpdates.currentGate).toBe('LOCATION_CONSENT');
});

});
Expand All @@ -137,7 +143,7 @@
});

test('response contains Childline number', () => {
const session = sessionAt('B3_AGE_CATEGORY');
const session = sessionAt('B3_AGE_CATEGORY', { localAuthority: 'Birmingham' });
const result = select(session, 1);
expect(result.text).toContain('0800 1111');
});
Expand Down Expand Up @@ -863,3 +869,169 @@
});

});

// =============================================================================
// MID-CONVERSATION UNDER-16 DETECTION
// Detects explicit first-person disclosure of under-16 age at any gate
// and routes to the safeguarding exit, even if the user previously declared
// an adult age at B3_AGE_CATEGORY.
// =============================================================================

describe('Mid-Conversation Under-16 Detection', () => {

describe('Numeric age triggers (10-15)', () => {
const cases: Array<[number, string]> = [
[10, "I'm 10"],
[11, "I am 11"],
[12, "im 12"],
[13, "I'm 13"],
[14, "I am 14"],
[15, "I'm 15"],
];

test.each(cases)('age %i (%s) triggers AGE_NUMERIC escalation', (age, phrase) => {
const result = detectUnder16Age(phrase);
expect(result.triggered).toBe(true);
if (result.triggered) {
expect(result.type).toBe('AGE_NUMERIC');
expect(result.code).toBe(`AGE_NUMERIC_${age}`);
}
});

test('age 16 does not trigger', () => {
expect(detectUnder16Age("I'm 16").triggered).toBe(false);
});
});

describe('School year triggers (Year 7-10)', () => {
const cases: Array<[number, string]> = [
[7, "I'm in Year 7"],
[8, "year 8"],
[9, "I'm in Year 9"],
[10, "yr 10"],
];

test.each(cases)('Year %i (%s) triggers SCHOOL_YEAR escalation', (year, phrase) => {
const result = detectUnder16Age(phrase);
expect(result.triggered).toBe(true);
if (result.triggered) {
expect(result.type).toBe('SCHOOL_YEAR');
expect(result.code).toBe(`SCHOOL_YEAR_${year}`);
}
});

test('Year 11 does not trigger', () => {
expect(detectUnder16Age("I'm in Year 11").triggered).toBe(false);
});
});

describe('Ambiguous phrases do not trigger', () => {
test.each([
"I'm young",
"still at school",
"my mum won't let me",
"I'm in trouble with my parents",
])('"%s" does not trigger', (phrase) => {
expect(detectUnder16Age(phrase).triggered).toBe(false);
});
});

describe('Contextual qualifier does not suppress trigger', () => {
test('"I\'m 14 but asking for my mum" still triggers', () => {
const result = detectUnder16Age("I'm 14 but asking for my mum");
expect(result.triggered).toBe(true);
if (result.triggered) {
expect(result.code).toBe('AGE_NUMERIC_14');
}
});
});

describe('Mid-conversation interception', () => {
test('triggers regardless of prior B3 age gate selection', () => {
// User has already passed B3 declaring 25+ — trigger must still fire mid-flow.
// No LA set, so intercept routes to CRISIS_UNDER16_LOCATION (asks for area).
const session = sessionAt('B7_HOMELESS_SLEEPING_SITUATION', {
ageCategory: '25 or over',
homeless: true,
});
const result = interceptUnder16Age(session, "I'm 14");
expect(result).not.toBeNull();
expect(result?.stateUpdates?.currentGate).toBe('CRISIS_UNDER16_LOCATION');
expect(result?.stateUpdates?.safeguardingType).toBe('UNDER_16');
});

test('session terminates on trigger when LA is set', () => {
const session = sessionAt('B5_PROFILE_GENDER', {
ageCategory: '25 or over',
localAuthority: 'Birmingham',
});
const result = interceptUnder16Age(session, "I'm in year 9");
expect(result?.sessionEnded).toBe(true);
expect(result?.stateUpdates?.currentGate).toBe('SESSION_END');
});

test('routes to CRISIS_UNDER16_LOCATION on trigger when LA is not set', () => {
const session = sessionAt('B5_PROFILE_GENDER', { ageCategory: '25 or over' });
const result = interceptUnder16Age(session, "I'm in year 9");
expect(result).not.toBeNull();
expect(result?.sessionEnded).toBeUndefined();
expect(result?.stateUpdates?.currentGate).toBe('CRISIS_UNDER16_LOCATION');
});

test('intercept fires at GATE0_CRISIS_DANGER before any classifier would run', () => {
// "I'm 14" at the crisis gate must hit the safeguarding intercept,
// not checkScope or detectAdviceQuestion (which are async Claude calls).
// The intercept is a pure synchronous function — if it returns non-null
// at GATE0, the classifier is never reached in route.ts.
const session = sessionAt('GATE0_CRISIS_DANGER');
const result = interceptUnder16Age(session, "I'm 14");
expect(result).not.toBeNull();
expect(result?.stateUpdates?.currentGate).toBe('CRISIS_UNDER16_LOCATION');
expect(result?.stateUpdates?.safeguardingType).toBe('UNDER_16');
});

test('non-trigger input returns null (does not intercept)', () => {
const session = sessionAt('B5_PROFILE_GENDER');
expect(interceptUnder16Age(session, "I'm 25")).toBeNull();
});
});

describe('Audit logging', () => {
let logSpy: jest.SpyInstance;

beforeEach(() => {
logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
});

afterEach(() => {
logSpy.mockRestore();
});

test('captures trigger type, trigger code, session ID, timestamp — and does not capture raw user text', () => {
const session = sessionAt('B5_PROFILE_GENDER');
session.sessionId = 'test-session-abc-123';
const sentinel = 'XYZZY_SECRET_PHRASE_42';

interceptUnder16Age(session, `I'm 14 and ${sentinel}`);

// Find the safeguarding-trigger log call
const safeguardingCalls = logSpy.mock.calls.filter(
(c) => typeof c[0] === 'string' && c[0].includes('SAFEGUARDING_TRIGGER')

Check warning on line 1019 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe member access [0] on an `any` value

Check warning on line 1019 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe member access [0] on an `any` value

Check warning on line 1019 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe member access [0] on an `any` value

Check warning on line 1019 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe member access [0] on an `any` value
);
expect(safeguardingCalls.length).toBeGreaterThan(0);

const payload = JSON.parse(safeguardingCalls[0][1]);

Check warning on line 1023 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe member access [1] on an `any` value

Check warning on line 1023 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe argument of type `any` assigned to a parameter of type `string`

Check warning on line 1023 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe assignment of an `any` value

Check warning on line 1023 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe member access [1] on an `any` value

Check warning on line 1023 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe argument of type `any` assigned to a parameter of type `string`

Check warning on line 1023 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe assignment of an `any` value
expect(payload.type).toBe('AGE_NUMERIC');

Check warning on line 1024 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe member access .type on an `any` value

Check warning on line 1024 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe member access .type on an `any` value
expect(payload.code).toBe('AGE_NUMERIC_14');

Check warning on line 1025 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe member access .code on an `any` value

Check warning on line 1025 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe member access .code on an `any` value
expect(payload.sessionId).toBe('test-session-abc-123');

Check warning on line 1026 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe member access .sessionId on an `any` value

Check warning on line 1026 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe member access .sessionId on an `any` value
expect(payload.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);

Check warning on line 1027 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe member access .timestamp on an `any` value

Check warning on line 1027 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe member access .timestamp on an `any` value
expect(payload.event).toBe('UNDER_16_DETECTED');

Check warning on line 1028 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe member access .event on an `any` value

Check warning on line 1028 in __tests__/safeguarding.test.ts

View workflow job for this annotation

GitHub Actions / test

Unsafe member access .event on an `any` value
expect(payload.pathwayChange).toBe('TERMINATED_TO_UNDER_16_EXIT');

// Verify NO log call across the whole spy contains the raw user text
const allLogText = JSON.stringify(logSpy.mock.calls);
expect(allLogText).not.toContain(sentinel);
});
});

});
30 changes: 23 additions & 7 deletions app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import {
processInput,
getFirstMessage,
parseUserInput,
processLocationInput
processLocationInput,
interceptUnder16Age
} from '@/lib/stateMachine';
import { getPhrase } from '@/lib/phrasebank';

Expand Down Expand Up @@ -203,7 +204,7 @@ export async function POST(request: NextRequest) {
// Decode or create session
let session: SessionState = decodeState(encodedState) || createSession(crypto.randomUUID());

console.log(`[VA] Gate: ${session.currentGate}, Input: "${message.substring(0, 50)}..."`);
console.log(`[VA] Gate: ${session.currentGate}`);

// INIT -> return first message (crisis gate)
if (session.currentGate === 'INIT') {
Expand Down Expand Up @@ -234,9 +235,24 @@ export async function POST(request: NextRequest) {
});
}

// Mid-conversation under-16 safeguarding intercept.
// Runs at every gate, not just early gates. Must fire BEFORE any Claude
// classifier (checkScope, detectAdviceQuestion) to ensure safeguarding
// is never gated behind an async API call.
const under16Exit = interceptUnder16Age(session, message);
if (under16Exit) {
session = { ...session, ...under16Exit.stateUpdates };
return NextResponse.json({
s: encodeState(session),
m: under16Exit.text,
o: under16Exit.options,
e: under16Exit.sessionEnded,
});
}

// Get current phrase for option parsing
const currentPhrase = getPhrase(session.currentGate, session.isSupporter);

// Check for out-of-scope requests at early gates
const earlyGates = ['GATE0_CRISIS_DANGER', 'GATE1_INTENT', 'B4_ADVICE_TOPIC_SELECTION', 'ADVICE_BRIDGE'];
if (earlyGates.includes(session.currentGate) && message.length > 5 && !/^\d+$/.test(message.trim())) {
Expand All @@ -250,18 +266,18 @@ export async function POST(request: NextRequest) {
e: false
});
}

// Check for advice questions (tangent handling)
const advicePhraseKey = await detectAdviceQuestion(message);
if (advicePhraseKey) {
console.log(`[VA] Advice tangent detected: ${advicePhraseKey}`);
const adviceContent = getPhrase(advicePhraseKey, session.isSupporter);
const adviceBridge = getPhrase('ADVICE_BRIDGE', session.isSupporter);

if (adviceContent) {
// Set gate to ADVICE_BRIDGE so next response is handled correctly
session.currentGate = 'ADVICE_BRIDGE';

// Return advice content with bridge options
return NextResponse.json({
s: encodeState(session),
Expand All @@ -272,7 +288,7 @@ export async function POST(request: NextRequest) {
}
}
}

// Try to parse user input
let parsed = parseUserInput(message, currentPhrase?.options);

Expand Down
21 changes: 15 additions & 6 deletions lib/handlers/crisis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,23 +229,32 @@ export function handleCrisisDanger(session: SessionState, choice: number | null)
...phrase('CRISIS_FIRE_FLOOD_LOCATION', session.isSupporter),
stateUpdates: { currentGate: 'CRISIS_FIRE_FLOOD_LOCATION' }
};
case 7: // None apply
return phrase('GATE1_INTENT', session.isSupporter);
case 7: // None apply — collect location before intent
return phrase('LOCATION_CONSENT', session.isSupporter);
default:
return phrase('GATE0_CRISIS_DANGER', session.isSupporter);
}
}

export function handleCrisisUnder16Location(session: SessionState, choice: number | null): RoutingResult {
// Options: 1=Wolverhampton, 2=Birmingham, 3=Coventry, 4=Dudley, 5=Sandwell, 6=Solihull, 7=Walsall, 8=Somewhere else, 9=Prefer not to say
// Options: 1=Wolverhampton, 2=Birmingham, 3=Coventry, 4=Dudley, 5=Sandwell, 6=Solihull, 7=Walsall, 8=Somewhere else
const under16LAs = ['Wolverhampton', 'Birmingham', 'Coventry', 'Dudley', 'Sandwell', 'Solihull', 'Walsall'];
if (choice && choice >= 1 && choice <= 7) {
const la = under16LAs[choice - 1];
return buildUnder16Exit({ ...session, localAuthority: la });
} else {
// Somewhere else or prefer not to say - show generic
return buildUnder16Exit(session);
}
// Somewhere else — no LA to look up, so surface Childline directly
const exit = phrase('CRISIS_UNDER16_SOMEWHERE_ELSE', session.isSupporter);
return {
...exit,
stateUpdates: {
currentGate: 'SESSION_END',
safeguardingTriggered: true,
safeguardingType: 'UNDER_16',
timestampEnd: new Date().toISOString(),
},
sessionEnded: true,
};
}

export function handleCrisisFireFloodLocation(session: SessionState, choice: number | null): RoutingResult {
Expand Down
10 changes: 5 additions & 5 deletions lib/handlers/location.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,10 @@ export function handleLocationResult(session: SessionState, _choice: number | nu
export function handleLocationConfirm(session: SessionState, choice: number | null): RoutingResult {
// User confirms detected LA or wants to select different area
if (choice === 1) {
// Confirmed - proceed to early flow questions
// Confirmed - proceed to intent
return {
...phrase('PREFERRED_NAME_ASK', session.isSupporter),
stateUpdates: { currentGate: 'PREFERRED_NAME_ASK' }
...phrase('GATE1_INTENT', session.isSupporter),
stateUpdates: { currentGate: 'GATE1_INTENT' }
};
} else {
// Want different area - show manual selection, clear location data
Expand All @@ -104,8 +104,8 @@ export function handleLocationOutsideWMCA(session: SessionState, choice: number
if (choice === 1) {
// Continue anyway with detected LA
return {
...phrase('PREFERRED_NAME_ASK', session.isSupporter),
stateUpdates: { currentGate: 'PREFERRED_NAME_ASK' }
...phrase('GATE1_INTENT', session.isSupporter),
stateUpdates: { currentGate: 'GATE1_INTENT' }
};
} else {
// Let them select different area - clear location data
Expand Down
10 changes: 8 additions & 2 deletions lib/handlers/sectionC.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,15 @@ export function handleC3Q3Age(session: SessionState, choice: number | null): Rou
const detailedAgeOptions = ['Under 16', '16-17', '18-20', '21-24', '25+'];
const detailedAge = choice ? detailedAgeOptions[choice - 1] : null;

// Under 16 safeguarding
// Under 16 safeguarding — exit directly if LA known, otherwise ask for area first
if (choice === 1) {
return buildUnder16Exit(session);
if (session.localAuthority) {
return buildUnder16Exit(session);
}
return {
...phrase('CRISIS_UNDER16_LOCATION', session.isSupporter),
stateUpdates: { currentGate: 'CRISIS_UNDER16_LOCATION' }
};
}

return {
Expand Down
Loading
Loading