diff --git a/__tests__/safeguarding.test.ts b/__tests__/safeguarding.test.ts index 049388c..233c119 100644 --- a/__tests__/safeguarding.test.ts +++ b/__tests__/safeguarding.test.ts @@ -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 @@ -111,10 +117,10 @@ describe('Crisis Gate', () => { 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'); }); }); @@ -137,7 +143,7 @@ describe('Under 16', () => { }); 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'); }); @@ -863,3 +869,169 @@ describe('Housing Options Involvement', () => { }); }); + +// ============================================================================= +// 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') + ); + expect(safeguardingCalls.length).toBeGreaterThan(0); + + const payload = JSON.parse(safeguardingCalls[0][1]); + expect(payload.type).toBe('AGE_NUMERIC'); + expect(payload.code).toBe('AGE_NUMERIC_14'); + expect(payload.sessionId).toBe('test-session-abc-123'); + expect(payload.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(payload.event).toBe('UNDER_16_DETECTED'); + 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); + }); + }); + +}); diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 98a996d..c456b66 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -9,7 +9,8 @@ import { processInput, getFirstMessage, parseUserInput, - processLocationInput + processLocationInput, + interceptUnder16Age } from '@/lib/stateMachine'; import { getPhrase } from '@/lib/phrasebank'; @@ -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') { @@ -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())) { @@ -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), @@ -272,7 +288,7 @@ export async function POST(request: NextRequest) { } } } - + // Try to parse user input let parsed = parseUserInput(message, currentPhrase?.options); diff --git a/lib/handlers/crisis.ts b/lib/handlers/crisis.ts index a8b277b..257f31d 100644 --- a/lib/handlers/crisis.ts +++ b/lib/handlers/crisis.ts @@ -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 { diff --git a/lib/handlers/location.ts b/lib/handlers/location.ts index c821147..a81ee19 100644 --- a/lib/handlers/location.ts +++ b/lib/handlers/location.ts @@ -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 @@ -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 diff --git a/lib/handlers/sectionC.ts b/lib/handlers/sectionC.ts index e32f3fc..b126b0c 100644 --- a/lib/handlers/sectionC.ts +++ b/lib/handlers/sectionC.ts @@ -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 { diff --git a/lib/handlers/shared.ts b/lib/handlers/shared.ts index 018f4c6..3361ee8 100644 --- a/lib/handlers/shared.ts +++ b/lib/handlers/shared.ts @@ -57,23 +57,22 @@ export function buildUnder16Exit(session: SessionState): RoutingResult { text += `Thank you for reaching out. Because you are under 16, there are specialist services that can help keep you safe. It takes courage to ask for help, and you've done the right thing.\n\n`; } - // Local Children's Services (if we have LA info) - if (childServices) { - text += `CHILDREN'S SERVICES\n`; - text += `${childServices.name}\n`; - if (childServices.phoneOOH) { - text += `${childServices.phone} (out of hours: ${childServices.phoneOOH})\n`; - } else { - text += `${childServices.phone}\n`; - } - text += `${childServices.website}\n`; - text += `They can talk through what's happening and help work out the best support\n\n`; + // Local Children's Services — LA must be known by this point. + // All call sites should route through CRISIS_UNDER16_LOCATION first + // if LA is not yet set, so this is never reached without a valid LA. + if (!childServices) { + throw new Error(`buildUnder16Exit called without a valid local authority (got: ${JSON.stringify(session.localAuthority)}). Route to CRISIS_UNDER16_LOCATION first.`); + } + + text += `CHILDREN'S SERVICES\n`; + text += `${childServices.name}\n`; + if (childServices.phoneOOH) { + text += `${childServices.phone} (out of hours: ${childServices.phoneOOH})\n`; } else { - text += `CHILDREN'S SERVICES\n`; - text += `Local council Children's Services\n`; - text += `https://www.gov.uk/find-local-council\n`; - text += `They can talk through what's happening and help work out the best support\n\n`; + text += `${childServices.phone}\n`; } + text += `${childServices.website}\n`; + text += `They can talk through what's happening and help work out the best support\n\n`; // Childline text += `SPECIALIST HELPLINE\n`; diff --git a/lib/phrasebank.ts b/lib/phrasebank.ts index e55ee39..8de9d99 100644 --- a/lib/phrasebank.ts +++ b/lib/phrasebank.ts @@ -1434,6 +1434,78 @@ What is their gender? options: ["Female", "Male", "Non-binary or other", "Prefer not to say"] }, + CRISIS_UNDER16_LOCATION: { + text: `Thank you for telling me. You've done the right thing by reaching out. So I can give you the right Children's Services to contact, please tell me which area you're in. + +1. Wolverhampton +2. Birmingham +3. Coventry +4. Dudley +5. Sandwell +6. Solihull +7. Walsall +8. Somewhere else`, + options: ["Wolverhampton", "Birmingham", "Coventry", "Dudley", "Sandwell", "Solihull", "Walsall", "Somewhere else"] + }, + + CRISIS_UNDER16_LOCATION__SUPPORTER: { + text: `Thank you for letting me know. So I can give you the right Children's Services to contact for them, please tell me which area they're in. + +1. Wolverhampton +2. Birmingham +3. Coventry +4. Dudley +5. Sandwell +6. Solihull +7. Walsall +8. Somewhere else`, + options: ["Wolverhampton", "Birmingham", "Coventry", "Dudley", "Sandwell", "Solihull", "Walsall", "Somewhere else"] + }, + + UNDER16_INTERCEPT_PREFIX: { + text: `From what you just said, it sounds like you may be under 16. Because of that, I'm going to point you to specialist services for young people instead — they can help in ways I can't.` + }, + + UNDER16_INTERCEPT_PREFIX__SUPPORTER: { + text: `From what you've shared, it sounds like the person you're supporting may be under 16. Because of that, I'm going to point you to specialist services for young people instead — they can help in ways I can't.` + }, + + CRISIS_UNDER16_SOMEWHERE_ELSE: { + text: `Thank you for telling me. Even though I can't look up your local Children's Services, there are people who can help you right now. + +SPECIALIST HELPLINE +Childline +0800 1111 (free, confidential, 24/7) +https://www.childline.org.uk +You can call or chat online about anything — they'll listen and help you work out what to do next. + +You can also talk to any trusted adult — a teacher, a family member, or your local council's Children's Services team. + +--- +If you are in immediate danger, call 999.` + }, + + CRISIS_UNDER16_SOMEWHERE_ELSE__SUPPORTER: { + text: `Thank you for letting me know. Even though I can't look up their local Children's Services, there are people who can help right now. + +SPECIALIST HELPLINE +Childline +0800 1111 (free, confidential, 24/7) +https://www.childline.org.uk +A free helpline for young people to call or chat online about anything. + +SPECIALIST HELPLINE +NSPCC Helpline (for adults) +0808 800 5000 (free, 24/7) +https://www.nspcc.org.uk/keeping-children-safe/reporting-abuse/ +For adults who are worried about a child. + +They can also be supported by a trusted adult — a teacher, a family member, or their local council's Children's Services team. + +--- +If they are in immediate danger, call 999.` + }, + // ============================================================ // SAFEGUARDING EXITS // ============================================================ diff --git a/lib/stateMachine.ts b/lib/stateMachine.ts index f0f852d..4a82373 100644 --- a/lib/stateMachine.ts +++ b/lib/stateMachine.ts @@ -870,13 +870,13 @@ export function getFirstMessage(_session: SessionState): RoutingResult { export function parseUserInput(input: string, options?: string[]): number | null { const trimmed = input.trim(); - + // Direct number const num = parseInt(trimmed, 10); if (!isNaN(num) && num >= 1 && (!options || num <= options.length)) { return num; } - + // Text matching if (options) { const lower = trimmed.toLowerCase(); @@ -886,10 +886,107 @@ export function parseUserInput(input: string, options?: string[]): number | null } } } - + return null; } +// ============================================================ +// MID-CONVERSATION UNDER-16 SAFEGUARDING DETECTION +// ============================================================ +// +// Intercepts free-text user input at every gate and looks for explicit +// disclosure of under-16 age. Triggers an immediate safeguarding exit +// regardless of where in the conversation the user currently is, even if +// they previously declared an adult age at B3_AGE_CATEGORY. +// +// Trigger patterns are intentionally narrow: +// 1. First-person numeric statement of ages 10-15 ("I'm 14", "I am 13"). +// 2. England/Wales/NI school years 7-10 ("Year 9", "yr 10"). +// +// Deliberately NOT triggered by: ages 16+, Years 11+, ambiguous phrases +// like "I'm young" or "still at school". A trigger fires even when wrapped +// in contextual qualifiers ("I'm 14 but asking for my mum"). + +export type AgeTriggerResult = + | { triggered: true; type: 'AGE_NUMERIC' | 'SCHOOL_YEAR'; code: string } + | { triggered: false }; + +// First-person statement: "i'm/i am/im" + age 10-15. +// Negative lookahead excludes unit-of-measurement false positives +// ("I'm 14 minutes late", "I'm 14 years older than her"). +const NUMERIC_AGE_RE = /\bi[\u2018\u2019']?\s*a?m\s+(1[0-5])\b(?!\s+(?:minutes?|mins?|hours?|hrs?|days?|weeks?|wks?|months?|miles?|km|kilometres?|kilometers?|metres?|meters?|feet|ft|inches?|stone|kgs?|lbs?|pounds?|percent|%|years?\s+(?:older|younger|ago)))/i; + +// England/Wales/NI school years 7-10. Year 11+ deliberately excluded +// because Year 11 students may already be 16. +const SCHOOL_YEAR_RE = /\b(?:year|yr)\s*(10|7|8|9)\b/i; + +export function detectUnder16Age(input: string): AgeTriggerResult { + if (!input) return { triggered: false }; + + const numMatch = input.match(NUMERIC_AGE_RE); + if (numMatch) { + return { triggered: true, type: 'AGE_NUMERIC', code: `AGE_NUMERIC_${numMatch[1]}` }; + } + + const yearMatch = input.match(SCHOOL_YEAR_RE); + if (yearMatch) { + return { triggered: true, type: 'SCHOOL_YEAR', code: `SCHOOL_YEAR_${yearMatch[1]}` }; + } + + return { triggered: false }; +} + +// Categorical audit log for safeguarding triggers. Never includes raw user input. +function logUnder16Trigger( + sessionId: string, + fromGate: string, + trigger: { type: string; code: string } +): void { + console.log('[VA] SAFEGUARDING_TRIGGER:', JSON.stringify({ + event: 'UNDER_16_DETECTED', + type: trigger.type, + code: trigger.code, + sessionId, + fromGate, + timestamp: new Date().toISOString(), + pathwayChange: 'TERMINATED_TO_UNDER_16_EXIT', + })); +} + +// Mid-conversation intercept. Returns a session-ending RoutingResult if +// the input discloses under-16 age, otherwise returns null so the caller +// can continue normal processing. +export function interceptUnder16Age(session: SessionState, input: string): RoutingResult | null { + const trigger = detectUnder16Age(input); + if (!trigger.triggered) return null; + + logUnder16Trigger(session.sessionId, session.currentGate, trigger); + + const prefix = getPhrase('UNDER16_INTERCEPT_PREFIX', session.isSupporter); + const explanation = prefix ? `${prefix.text}\n\n` : ''; + + // If LA is already known, exit directly with localised Children's Services info. + // Otherwise, route to CRISIS_UNDER16_LOCATION to ask for their area first. + if (session.localAuthority) { + const exit = buildUnder16Exit(session); + return { + ...exit, + text: explanation + exit.text, + }; + } + + const locationPhrase = getPhrase('CRISIS_UNDER16_LOCATION', session.isSupporter); + return { + text: explanation + (locationPhrase?.text || ''), + options: locationPhrase?.options, + stateUpdates: { + currentGate: 'CRISIS_UNDER16_LOCATION', + safeguardingTriggered: true, + safeguardingType: 'UNDER_16', + }, + }; +} + // ============================================================ // MAIN ROUTING // ============================================================ @@ -945,8 +1042,8 @@ export function processInput(session: SessionState, input: string): RoutingResul }; case 3: // Specific org return { - ...phrase('B1_LOCAL_AUTHORITY', session.isSupporter), - stateUpdates: { currentGate: 'B1_LOCAL_AUTHORITY', intentType: 'ORGANISATION' } + ...phrase('PREFERRED_NAME_ASK', session.isSupporter), + stateUpdates: { currentGate: 'PREFERRED_NAME_ASK', intentType: 'ORGANISATION' } }; default: return phrase('GATE1_INTENT', session.isSupporter); @@ -1000,9 +1097,8 @@ export function processInput(session: SessionState, input: string): RoutingResul case 'GATE2_ROUTE_SELECTION': const routeType = choice === 1 ? 'FULL' : 'QUICK'; return { - ...phrase('LOCATION_CONSENT', session.isSupporter), - stateUpdates: { currentGate: 'LOCATION_CONSENT', routeType }, - responseType: 'location_consent' + ...phrase('PREFERRED_NAME_ASK', session.isSupporter), + stateUpdates: { currentGate: 'PREFERRED_NAME_ASK', routeType } }; // ======================================== @@ -1040,8 +1136,8 @@ export function processInput(session: SessionState, input: string): RoutingResul } return { - ...phrase('PREFERRED_NAME_ASK', session.isSupporter), - stateUpdates: { currentGate: 'PREFERRED_NAME_ASK', localAuthority: la } + ...phrase('GATE1_INTENT', session.isSupporter), + stateUpdates: { currentGate: 'GATE1_INTENT', localAuthority: la } }; // ======================================== @@ -1111,10 +1207,16 @@ export function processInput(session: SessionState, input: string): RoutingResul case 'B3_AGE_CATEGORY': const ageOptions = ['Under 16', '16-17', '18-24', '25 or over']; const age = choice ? ageOptions[choice - 1] : null; - - // Under 16 safeguarding exit + + // 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' } + }; } // Youth flag for 16-17