diff --git a/app/api/dashboard/ai/risk-flags/route.ts b/app/api/dashboard/ai/risk-flags/route.ts index 5fd954d..4197b09 100644 --- a/app/api/dashboard/ai/risk-flags/route.ts +++ b/app/api/dashboard/ai/risk-flags/route.ts @@ -2,7 +2,7 @@ export const dynamic = 'force-dynamic' import { NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' -import { createAnthropicClient } from '@/lib/claude/client' +import { createDeepSeekClient } from '@/lib/claude/client' import { getApiKey } from '@/lib/settings' type ZoneAggregate = { @@ -92,41 +92,43 @@ export async function POST() { return NextResponse.json({ error: 'Rate limit: wait 60 seconds between AI requests' }, { status: 429 }) } - const anthropicKey = await getApiKey(supabase, 'ANTHROPIC_API_KEY') - const anthropic = createAnthropicClient(anthropicKey) + const deepseekKey = await getApiKey(supabase, 'DEEPSEEK_API_KEY') + const client = createDeepSeekClient(deepseekKey) - let message: Awaited> + let res: Awaited> try { - message = await anthropic.messages.create({ - model: 'claude-opus-4-8', - thinking: { type: 'adaptive' }, + res = await client.chat.completions.create({ + model: 'deepseek-chat', max_tokens: 2048, tools: [{ - name: 'flag_rising_risk', - description: 'Flag warehouse zones showing rising safety risk from 90-day incident aggregates', - input_schema: { - type: 'object' as const, - properties: { - flags: { - type: 'array', - items: { - type: 'object', - properties: { - zone: { type: 'string' }, - site: { type: 'string' }, - risk_level: { type: 'string', enum: ['low', 'medium', 'high'] }, - rationale: { type: 'string', description: 'One or two sentences citing the numbers' }, - recommended_action: { type: 'string', description: 'One concrete preventive action' }, + type: 'function', + function: { + name: 'flag_rising_risk', + description: 'Flag warehouse zones showing rising safety risk from 90-day incident aggregates', + parameters: { + type: 'object', + properties: { + flags: { + type: 'array', + items: { + type: 'object', + properties: { + zone: { type: 'string' }, + site: { type: 'string' }, + risk_level: { type: 'string', enum: ['low', 'medium', 'high'] }, + rationale: { type: 'string', description: 'One or two sentences citing the numbers' }, + recommended_action: { type: 'string', description: 'One concrete preventive action' }, + }, + required: ['zone', 'site', 'risk_level', 'rationale', 'recommended_action'], }, - required: ['zone', 'site', 'risk_level', 'rationale', 'recommended_action'], }, + summary: { type: 'string', description: 'Two-sentence overall risk picture' }, }, - summary: { type: 'string', description: 'Two-sentence overall risk picture' }, + required: ['flags', 'summary'], }, - required: ['flags', 'summary'], }, }], - tool_choice: { type: 'tool', name: 'flag_rising_risk' }, + tool_choice: { type: 'function', function: { name: 'flag_rising_risk' } }, messages: [{ role: 'user', content: `You are an HSE risk analyst for a Malaysian 3PL warehouse operator. Below are 90-day incident aggregates per zone. "first_half" is incidents in days 90-46, "second_half" is days 45-0 — a rising second_half means worsening trend. Near-miss and hazard reports are leading indicators; injuries are lagging. @@ -142,11 +144,13 @@ ${JSON.stringify(aggregates, null, 2)} return NextResponse.json({ error: 'AI service unavailable' }, { status: 503 }) } - const toolBlock = message.content.find(b => b.type === 'tool_use') - if (!toolBlock || toolBlock.type !== 'tool_use') - return NextResponse.json({ error: 'AI suggestion failed' }, { status: 500 }) + const call = res.choices[0]?.message?.tool_calls?.[0] + if (!call || call.type !== 'function') return NextResponse.json({ error: 'AI suggestion failed' }, { status: 500 }) + + let input: { flags?: unknown; summary?: unknown } + try { input = JSON.parse(call.function.arguments) } + catch { return NextResponse.json({ error: 'AI returned unexpected structure' }, { status: 500 }) } - const input = toolBlock.input as { flags?: unknown; summary?: unknown } if (!Array.isArray(input.flags) || typeof input.summary !== 'string') return NextResponse.json({ error: 'AI returned unexpected structure' }, { status: 500 }) @@ -164,7 +168,7 @@ ${JSON.stringify(aggregates, null, 2)} p_table_name: 'incidents', p_record_id: user.id, p_action: 'ai_risk_flags', - p_new_value: { flags, summary: input.summary, model: 'claude-opus-4-8' } as never, + p_new_value: { flags, summary: input.summary, model: 'deepseek-chat' } as never, }) return NextResponse.json({ flags, summary: input.summary }) diff --git a/app/api/incidents/[id]/ai/rca-draft/route.ts b/app/api/incidents/[id]/ai/rca-draft/route.ts index a84b557..62edf9b 100644 --- a/app/api/incidents/[id]/ai/rca-draft/route.ts +++ b/app/api/incidents/[id]/ai/rca-draft/route.ts @@ -2,7 +2,7 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' -import { createAnthropicClient } from '@/lib/claude/client' +import { createDeepSeekClient } from '@/lib/claude/client' import { getApiKey } from '@/lib/settings' export async function POST( @@ -28,8 +28,8 @@ export async function POST( if ((recentCount ?? 0) > 0) return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 }) - const anthropicKey = await getApiKey(supabase, 'ANTHROPIC_API_KEY') - const anthropic = createAnthropicClient(anthropicKey) + const deepseekKey = await getApiKey(supabase, 'DEEPSEEK_API_KEY') + const client = createDeepSeekClient(deepseekKey) const { data: incident } = await supabase .from('incidents') @@ -55,44 +55,46 @@ export async function POST( const siteName = (incident.sites as unknown as { name: string } | null)?.name ?? 'Unknown' const zoneName = (incident.zones as unknown as { name: string } | null)?.name ?? 'Unknown' - let message: Awaited> + let res: Awaited> try { - message = await anthropic.messages.create({ - model: 'claude-opus-4-8', - thinking: { type: 'adaptive' }, + res = await client.chat.completions.create({ + model: 'deepseek-chat', max_tokens: 2048, tools: [{ - name: 'draft_rca', - description: 'Draft a 5-Why root cause analysis and CAPA suggestions for an HSE incident', - input_schema: { - type: 'object' as const, - properties: { - five_why_steps: { - type: 'array', - items: { - type: 'object', - properties: { - why: { type: 'string', description: 'The why question' }, - answer: { type: 'string', description: 'The finding or answer' }, + type: 'function', + function: { + name: 'draft_rca', + description: 'Draft a 5-Why root cause analysis and CAPA suggestions for an HSE incident', + parameters: { + type: 'object', + properties: { + five_why_steps: { + type: 'array', + items: { + type: 'object', + properties: { + why: { type: 'string', description: 'The why question' }, + answer: { type: 'string', description: 'The finding or answer' }, + }, + required: ['why', 'answer'], }, - required: ['why', 'answer'], + description: '3 to 5 why steps', + }, + root_cause_summary: { + type: 'string', + description: 'One-sentence root cause statement', + }, + capa_suggestions: { + type: 'array', + items: { type: 'string' }, + description: 'Up to 3 corrective/preventive action suggestions', }, - description: '3 to 5 why steps', - }, - root_cause_summary: { - type: 'string', - description: 'One-sentence root cause statement', - }, - capa_suggestions: { - type: 'array', - items: { type: 'string' }, - description: 'Up to 3 corrective/preventive action suggestions', }, + required: ['five_why_steps', 'root_cause_summary', 'capa_suggestions'], }, - required: ['five_why_steps', 'root_cause_summary', 'capa_suggestions'], }, }], - tool_choice: { type: 'tool', name: 'draft_rca' }, + tool_choice: { type: 'function', function: { name: 'draft_rca' } }, messages: [{ role: 'user', content: `You are an experienced HSE investigator for a Malaysian 3PL warehouse. Draft a 5-Why root cause analysis for this incident. @@ -114,15 +116,12 @@ Provide 3–5 Why steps drilling from immediate cause to root cause. Give a one- return NextResponse.json({ error: 'AI service unavailable' }, { status: 503 }) } - const toolBlock = message.content.find(b => b.type === 'tool_use') - if (!toolBlock || toolBlock.type !== 'tool_use') - return NextResponse.json({ error: 'AI draft failed' }, { status: 500 }) + const call = res.choices[0]?.message?.tool_calls?.[0] + if (!call || call.type !== 'function') return NextResponse.json({ error: 'AI draft failed' }, { status: 500 }) - const draft = toolBlock.input as { - five_why_steps?: unknown - root_cause_summary?: unknown - capa_suggestions?: unknown - } + let draft: { five_why_steps?: unknown; root_cause_summary?: unknown; capa_suggestions?: unknown } + try { draft = JSON.parse(call.function.arguments) } + catch { return NextResponse.json({ error: 'AI returned unexpected structure' }, { status: 500 }) } if ( !Array.isArray(draft.five_why_steps) || @@ -138,7 +137,7 @@ Provide 3–5 Why steps drilling from immediate cause to root cause. Give a one- p_action: 'ai_rca_draft', p_new_value: { root_cause_summary: draft.root_cause_summary, - model: 'claude-opus-4-8', + model: 'deepseek-chat', } as never, }) diff --git a/app/api/incidents/[id]/ai/triage-suggest/route.ts b/app/api/incidents/[id]/ai/triage-suggest/route.ts index 7237ef1..f9bec3e 100644 --- a/app/api/incidents/[id]/ai/triage-suggest/route.ts +++ b/app/api/incidents/[id]/ai/triage-suggest/route.ts @@ -2,7 +2,7 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' -import { createAnthropicClient } from '@/lib/claude/client' +import { createDeepSeekClient } from '@/lib/claude/client' import { getApiKey } from '@/lib/settings' export async function POST( @@ -28,8 +28,8 @@ export async function POST( if ((recentCount ?? 0) > 0) return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 }) - const anthropicKey = await getApiKey(supabase, 'ANTHROPIC_API_KEY') - const anthropic = createAnthropicClient(anthropicKey) + const deepseekKey = await getApiKey(supabase, 'DEEPSEEK_API_KEY') + const client = createDeepSeekClient(deepseekKey) const { data: incident } = await supabase .from('incidents') @@ -46,32 +46,34 @@ export async function POST( medical_status: string | null } - let message: Awaited> + let res: Awaited> try { - message = await anthropic.messages.create({ - model: 'claude-opus-4-8', - thinking: { type: 'adaptive' }, + res = await client.chat.completions.create({ + model: 'deepseek-chat', max_tokens: 1024, tools: [{ - name: 'suggest_triage', - description: 'Suggest severity rating and NADOPOD 2004 DOSH classification for a warehouse incident', - input_schema: { - type: 'object' as const, - properties: { - severity: { type: 'number', description: '1=minor, 2=low, 3=moderate, 4=serious, 5=critical/fatality' }, - is_fatality: { type: 'boolean' }, - is_serious_bodily_injury: { type: 'boolean', description: 'Fracture, amputation, blindness, serious burn, or similar' }, - is_dangerous_occurrence: { type: 'boolean', description: 'Structural collapse, explosion, fire, scaffold collapse, etc.' }, - is_occupational_disease: { type: 'boolean', description: 'Disease arising from workplace exposure' }, - rationale: { type: 'string', description: 'One-sentence rationale citing NADOPOD 2004 where applicable' }, + type: 'function', + function: { + name: 'suggest_triage', + description: 'Suggest severity rating and NADOPOD 2004 DOSH classification for a warehouse incident', + parameters: { + type: 'object', + properties: { + severity: { type: 'number', description: '1=minor, 2=low, 3=moderate, 4=serious, 5=critical/fatality' }, + is_fatality: { type: 'boolean' }, + is_serious_bodily_injury: { type: 'boolean', description: 'Fracture, amputation, blindness, serious burn, or similar' }, + is_dangerous_occurrence: { type: 'boolean', description: 'Structural collapse, explosion, fire, scaffold collapse, etc.' }, + is_occupational_disease: { type: 'boolean', description: 'Disease arising from workplace exposure' }, + rationale: { type: 'string', description: 'One-sentence rationale citing NADOPOD 2004 where applicable' }, + }, + required: [ + 'severity', 'is_fatality', 'is_serious_bodily_injury', + 'is_dangerous_occurrence', 'is_occupational_disease', 'rationale', + ], }, - required: [ - 'severity', 'is_fatality', 'is_serious_bodily_injury', - 'is_dangerous_occurrence', 'is_occupational_disease', 'rationale', - ], }, }], - tool_choice: { type: 'tool', name: 'suggest_triage' }, + tool_choice: { type: 'function', function: { name: 'suggest_triage' } }, messages: [{ role: 'user', content: `You are an HSE triage specialist for a Malaysian 3PL warehouse. Assess this incident under NADOPOD 2004. @@ -89,11 +91,10 @@ Suggest severity (1–5) and tick the appropriate NADOPOD 2004 flags. Give a one return NextResponse.json({ error: 'AI service unavailable' }, { status: 503 }) } - const toolBlock = message.content.find(b => b.type === 'tool_use') - if (!toolBlock || toolBlock.type !== 'tool_use') - return NextResponse.json({ error: 'AI suggestion failed' }, { status: 500 }) + const call = res.choices[0]?.message?.tool_calls?.[0] + if (!call || call.type !== 'function') return NextResponse.json({ error: 'AI suggestion failed' }, { status: 500 }) - const input = toolBlock.input as { + let input: { severity?: unknown is_fatality?: unknown is_serious_bodily_injury?: unknown @@ -101,6 +102,8 @@ Suggest severity (1–5) and tick the appropriate NADOPOD 2004 flags. Give a one is_occupational_disease?: unknown rationale?: unknown } + try { input = JSON.parse(call.function.arguments) } + catch { return NextResponse.json({ error: 'AI returned unexpected structure' }, { status: 500 }) } if ( typeof input.severity !== 'number' || @@ -117,8 +120,8 @@ Suggest severity (1–5) and tick the appropriate NADOPOD 2004 flags. Give a one p_table_name: 'incidents', p_record_id: id, p_action: 'ai_triage_suggest', - p_new_value: { suggestion: toolBlock.input, model: 'claude-opus-4-8' } as never, + p_new_value: { suggestion: input, model: 'deepseek-chat' } as never, }) - return NextResponse.json(toolBlock.input) + return NextResponse.json(input) } diff --git a/app/api/incidents/ai/quality-check/route.ts b/app/api/incidents/ai/quality-check/route.ts index dbc4eab..0241553 100644 --- a/app/api/incidents/ai/quality-check/route.ts +++ b/app/api/incidents/ai/quality-check/route.ts @@ -2,7 +2,7 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' -import { createAnthropicClient } from '@/lib/claude/client' +import { createDeepSeekClient } from '@/lib/claude/client' import { getApiKey } from '@/lib/settings' export async function POST(request: NextRequest) { @@ -20,8 +20,8 @@ export async function POST(request: NextRequest) { if ((recentCount ?? 0) > 0) return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 }) - const anthropicKey = await getApiKey(supabase, 'ANTHROPIC_API_KEY') - const anthropic = createAnthropicClient(anthropicKey) + const deepseekKey = await getApiKey(supabase, 'DEEPSEEK_API_KEY') + const client = createDeepSeekClient(deepseekKey) let body: { description?: string; incident_type?: string } try { @@ -33,31 +33,33 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'description and incident_type required' }, { status: 422 }) } - let message: Awaited> + let res: Awaited> try { - message = await anthropic.messages.create({ - model: 'claude-opus-4-8', - thinking: { type: 'adaptive' }, + res = await client.chat.completions.create({ + model: 'deepseek-chat', max_tokens: 1024, tools: [{ - name: 'assess_quality', - description: 'Assess HSE incident report description quality', - input_schema: { - type: 'object' as const, - properties: { - score: { type: 'number', description: '1-10 quality score' }, - passes: { type: 'boolean', description: 'True when score is 6 or above' }, - feedback: { type: 'string', description: 'One-sentence quality summary' }, - suggestions: { - type: 'array', - items: { type: 'string' }, - description: 'Up to 3 concrete suggestions to improve the description', + type: 'function', + function: { + name: 'assess_quality', + description: 'Assess HSE incident report description quality', + parameters: { + type: 'object', + properties: { + score: { type: 'number', description: '1-10 quality score' }, + passes: { type: 'boolean', description: 'True when score is 6 or above' }, + feedback: { type: 'string', description: 'One-sentence quality summary' }, + suggestions: { + type: 'array', + items: { type: 'string' }, + description: 'Up to 3 concrete suggestions to improve the description', + }, }, + required: ['score', 'passes', 'feedback', 'suggestions'], }, - required: ['score', 'passes', 'feedback', 'suggestions'], }, }], - tool_choice: { type: 'tool', name: 'assess_quality' }, + tool_choice: { type: 'function', function: { name: 'assess_quality' } }, messages: [{ role: 'user', content: `You are an HSE reporting assistant for a Malaysian 3PL warehouse. Assess this incident report description. @@ -72,17 +74,13 @@ Score 1–10 based on: specificity (location, time, persons involved), completen return NextResponse.json({ error: 'AI service unavailable' }, { status: 503 }) } - const toolBlock = message.content.find(b => b.type === 'tool_use') - if (!toolBlock || toolBlock.type !== 'tool_use') { - return NextResponse.json({ error: 'AI assessment failed' }, { status: 500 }) - } + const call = res.choices[0]?.message?.tool_calls?.[0] + if (!call || call.type !== 'function') return NextResponse.json({ error: 'AI assessment failed' }, { status: 500 }) + + let input: { score?: unknown; passes?: unknown; feedback?: unknown; suggestions?: unknown } + try { input = JSON.parse(call.function.arguments) } + catch { return NextResponse.json({ error: 'AI returned unexpected structure' }, { status: 500 }) } - const input = toolBlock.input as { - score?: unknown - passes?: unknown - feedback?: unknown - suggestions?: unknown - } if ( typeof input.score !== 'number' || typeof input.passes !== 'boolean' || @@ -96,7 +94,7 @@ Score 1–10 based on: specificity (location, time, persons involved), completen p_table_name: 'incidents', p_record_id: user.id, p_action: 'ai_quality_check', - p_new_value: { score: input.score, passes: input.passes, model: 'claude-opus-4-8' } as never, + p_new_value: { score: input.score, passes: input.passes, model: 'deepseek-chat' } as never, }) return NextResponse.json(input) diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index 7d25500..11ef811 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -5,6 +5,7 @@ import { createClient } from '@/lib/supabase/server' const ALLOWED_KEYS = [ 'ANTHROPIC_API_KEY', + 'DEEPSEEK_API_KEY', 'VOYAGE_API_KEY', 'META_WHATSAPP_PHONE_NUMBER_ID', 'META_WHATSAPP_ACCESS_TOKEN', diff --git a/lib/claude/client.ts b/lib/claude/client.ts index 76264ba..3827931 100644 --- a/lib/claude/client.ts +++ b/lib/claude/client.ts @@ -1,5 +1,5 @@ -import Anthropic from '@anthropic-ai/sdk' +import OpenAI from 'openai' -export function createAnthropicClient(apiKey: string): Anthropic { - return new Anthropic({ apiKey }) +export function createDeepSeekClient(apiKey: string): OpenAI { + return new OpenAI({ apiKey, baseURL: 'https://api.deepseek.com' }) } diff --git a/package-lock.json b/package-lock.json index e739f42..77dc84e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@supabase/supabase-js": "^2.110.2", "idb": "^8.0.3", "next": "^15.5.20", + "openai": "^6.46.0", "pdf-lib": "^1.17.1", "react": "^19.2.7", "react-dom": "^19.2.7", @@ -7287,6 +7288,36 @@ "node": ">=12.20.0" } }, + "node_modules/openai": { + "version": "6.46.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.46.0.tgz", + "integrity": "sha512-DFg6jEPT2RO+oAyXtddeUJU8zkGy1OQ1AjGzNIJUMQG03TTqvCpy9tBpQ+2VVVnvrl3E56F8GEin2JYtWpITtA==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", diff --git a/package.json b/package.json index 89b47d3..d6983e0 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@supabase/supabase-js": "^2.110.2", "idb": "^8.0.3", "next": "^15.5.20", + "openai": "^6.46.0", "pdf-lib": "^1.17.1", "react": "^19.2.7", "react-dom": "^19.2.7", diff --git a/supabase/migrations/20260712000020_deepseek_api_key.sql b/supabase/migrations/20260712000020_deepseek_api_key.sql new file mode 100644 index 0000000..0d2072a --- /dev/null +++ b/supabase/migrations/20260712000020_deepseek_api_key.sql @@ -0,0 +1,4 @@ +-- Add DEEPSEEK_API_KEY placeholder to app_settings +insert into app_settings (key, value) values + ('DEEPSEEK_API_KEY', '') +on conflict (key) do nothing; diff --git a/tests/api/incidents/quality-check.test.ts b/tests/api/incidents/quality-check.test.ts index 7455b9a..3d67525 100644 --- a/tests/api/incidents/quality-check.test.ts +++ b/tests/api/incidents/quality-check.test.ts @@ -1,23 +1,39 @@ import { describe, it, expect, vi } from 'vitest' +const qualityOutput = { score: 8, passes: true, feedback: 'Clear description.', suggestions: [] } + vi.mock('@/lib/supabase/server', () => ({ createClient: vi.fn().mockResolvedValue({ auth: { getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null }), }, + from: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + gte: vi.fn().mockResolvedValue({ count: 0 }), + }), + rpc: vi.fn().mockResolvedValue({ error: null }), }), })) vi.mock('@/lib/claude/client', () => ({ - createAnthropicClient: vi.fn().mockReturnValue({ - messages: { - create: vi.fn().mockResolvedValue({ - content: [{ - type: 'tool_use', - name: 'assess_quality', - input: { score: 8, passes: true, feedback: 'Clear description.', suggestions: [] }, - }], - }), + createDeepSeekClient: vi.fn().mockReturnValue({ + chat: { + completions: { + create: vi.fn().mockResolvedValue({ + choices: [{ + message: { + tool_calls: [{ + type: 'function', + function: { + name: 'assess_quality', + arguments: JSON.stringify(qualityOutput), + }, + }], + }, + }], + }), + }, }, }), })) @@ -38,7 +54,7 @@ describe('POST /api/incidents/ai/quality-check', () => { expect(res.status).toBe(422) }) - it('returns quality assessment from Claude', async () => { + it('returns quality assessment from DeepSeek', async () => { const { POST } = await import('@/app/api/incidents/ai/quality-check/route') const req = new Request('http://localhost/api/incidents/ai/quality-check', { method: 'POST', diff --git a/tests/api/incidents/rca-draft.test.ts b/tests/api/incidents/rca-draft.test.ts index 484e03a..07b680c 100644 --- a/tests/api/incidents/rca-draft.test.ts +++ b/tests/api/incidents/rca-draft.test.ts @@ -14,6 +14,20 @@ const mockIncident = { zones: { name: 'Zone B' }, } +const rcaOutput = { + five_why_steps: [ + { why: 'Why did the incident happen?', answer: 'Forklift entered zone B without checking for pedestrians.' }, + { why: 'Why was there no check?', answer: 'No pedestrian exclusion zone marked at zone B entrance.' }, + { why: 'Why was it not marked?', answer: 'Site hazard assessment did not include zone B forklift route.' }, + ], + root_cause_summary: 'Absence of pedestrian exclusion zone and forklift route hazard assessment in zone B.', + capa_suggestions: [ + 'Mark pedestrian exclusion zones at all forklift routes in zone B within 7 days.', + 'Update site hazard assessment to include forklift routes in all zones.', + 'Conduct forklift safety refresher for all operators within 30 days.', + ], +} + vi.mock('@/lib/supabase/server', () => ({ createClient: vi.fn().mockResolvedValue({ auth: { @@ -22,6 +36,7 @@ vi.mock('@/lib/supabase/server', () => ({ from: vi.fn().mockReturnValue({ select: vi.fn().mockReturnThis(), eq: vi.fn().mockReturnThis(), + gte: vi.fn().mockResolvedValue({ count: 0 }), single: vi.fn() .mockResolvedValueOnce({ data: { role: 'hse' } }) // profile .mockResolvedValueOnce({ data: mockIncident }), // incident @@ -31,27 +46,23 @@ vi.mock('@/lib/supabase/server', () => ({ })) vi.mock('@/lib/claude/client', () => ({ - createAnthropicClient: vi.fn().mockReturnValue({ - messages: { - create: vi.fn().mockResolvedValue({ - content: [{ - type: 'tool_use', - name: 'draft_rca', - input: { - five_why_steps: [ - { why: 'Why did the incident happen?', answer: 'Forklift entered zone B without checking for pedestrians.' }, - { why: 'Why was there no check?', answer: 'No pedestrian exclusion zone marked at zone B entrance.' }, - { why: 'Why was it not marked?', answer: 'Site hazard assessment did not include zone B forklift route.' }, - ], - root_cause_summary: 'Absence of pedestrian exclusion zone and forklift route hazard assessment in zone B.', - capa_suggestions: [ - 'Mark pedestrian exclusion zones at all forklift routes in zone B within 7 days.', - 'Update site hazard assessment to include forklift routes in all zones.', - 'Conduct forklift safety refresher for all operators within 30 days.', - ], - }, - }], - }), + createDeepSeekClient: vi.fn().mockReturnValue({ + chat: { + completions: { + create: vi.fn().mockResolvedValue({ + choices: [{ + message: { + tool_calls: [{ + type: 'function', + function: { + name: 'draft_rca', + arguments: JSON.stringify(rcaOutput), + }, + }], + }, + }], + }), + }, }, }), })) diff --git a/tests/api/incidents/triage-suggest.test.ts b/tests/api/incidents/triage-suggest.test.ts index 418320f..adfac79 100644 --- a/tests/api/incidents/triage-suggest.test.ts +++ b/tests/api/incidents/triage-suggest.test.ts @@ -9,6 +9,15 @@ const mockIncident = { medical_status: 'medical_treatment', } +const triageOutput = { + severity: 3, + is_fatality: false, + is_serious_bodily_injury: true, + is_dangerous_occurrence: false, + is_occupational_disease: false, + rationale: 'Fracture constitutes serious bodily injury under NADOPOD 2004.', +} + vi.mock('@/lib/supabase/server', () => ({ createClient: vi.fn().mockResolvedValue({ auth: { @@ -17,6 +26,7 @@ vi.mock('@/lib/supabase/server', () => ({ from: vi.fn().mockReturnValue({ select: vi.fn().mockReturnThis(), eq: vi.fn().mockReturnThis(), + gte: vi.fn().mockResolvedValue({ count: 0 }), single: vi.fn() .mockResolvedValueOnce({ data: { role: 'hse' } }) // profile .mockResolvedValueOnce({ data: mockIncident }), // incident @@ -26,22 +36,23 @@ vi.mock('@/lib/supabase/server', () => ({ })) vi.mock('@/lib/claude/client', () => ({ - createAnthropicClient: vi.fn().mockReturnValue({ - messages: { - create: vi.fn().mockResolvedValue({ - content: [{ - type: 'tool_use', - name: 'suggest_triage', - input: { - severity: 3, - is_fatality: false, - is_serious_bodily_injury: true, - is_dangerous_occurrence: false, - is_occupational_disease: false, - rationale: 'Fracture constitutes serious bodily injury under NADOPOD 2004.', - }, - }], - }), + createDeepSeekClient: vi.fn().mockReturnValue({ + chat: { + completions: { + create: vi.fn().mockResolvedValue({ + choices: [{ + message: { + tool_calls: [{ + type: 'function', + function: { + name: 'suggest_triage', + arguments: JSON.stringify(triageOutput), + }, + }], + }, + }], + }), + }, }, }), }))