feat: switch AI provider from Anthropic to DeepSeek

- lib/claude/client.ts: replace Anthropic SDK with openai package pointed at DeepSeek baseURL
- 4 AI routes: port tool definitions, tool_choice, and output parsing to OpenAI function-calling format
- Drop thinking:{type:'adaptive'} (no DeepSeek equivalent); model string → deepseek-chat
- settings/route.ts: add DEEPSEEK_API_KEY to ALLOWED_KEYS
- migration: seed DEEPSEEK_API_KEY placeholder row in app_settings
- tests: update 3 AI route tests to mock createDeepSeekClient + OpenAI response shape

Voyage AI embedding path untouched (DeepSeek has no embeddings endpoint).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPf5Rc8QPx6V8KLEEgfKEQ
This commit is contained in:
2026-07-12 21:51:15 +08:00
co-authored by Claude Sonnet 4.6
parent 8fe036bc1a
commit b2891a433d
12 changed files with 262 additions and 183 deletions
+19 -15
View File
@@ -2,7 +2,7 @@ export const dynamic = 'force-dynamic'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { createAnthropicClient } from '@/lib/claude/client' import { createDeepSeekClient } from '@/lib/claude/client'
import { getApiKey } from '@/lib/settings' import { getApiKey } from '@/lib/settings'
type ZoneAggregate = { type ZoneAggregate = {
@@ -92,20 +92,21 @@ export async function POST() {
return NextResponse.json({ error: 'Rate limit: wait 60 seconds between AI requests' }, { status: 429 }) return NextResponse.json({ error: 'Rate limit: wait 60 seconds between AI requests' }, { status: 429 })
} }
const anthropicKey = await getApiKey(supabase, 'ANTHROPIC_API_KEY') const deepseekKey = await getApiKey(supabase, 'DEEPSEEK_API_KEY')
const anthropic = createAnthropicClient(anthropicKey) const client = createDeepSeekClient(deepseekKey)
let message: Awaited<ReturnType<typeof anthropic.messages.create>> let res: Awaited<ReturnType<typeof client.chat.completions.create>>
try { try {
message = await anthropic.messages.create({ res = await client.chat.completions.create({
model: 'claude-opus-4-8', model: 'deepseek-chat',
thinking: { type: 'adaptive' },
max_tokens: 2048, max_tokens: 2048,
tools: [{ tools: [{
type: 'function',
function: {
name: 'flag_rising_risk', name: 'flag_rising_risk',
description: 'Flag warehouse zones showing rising safety risk from 90-day incident aggregates', description: 'Flag warehouse zones showing rising safety risk from 90-day incident aggregates',
input_schema: { parameters: {
type: 'object' as const, type: 'object',
properties: { properties: {
flags: { flags: {
type: 'array', type: 'array',
@@ -125,8 +126,9 @@ export async function POST() {
}, },
required: ['flags', 'summary'], required: ['flags', 'summary'],
}, },
},
}], }],
tool_choice: { type: 'tool', name: 'flag_rising_risk' }, tool_choice: { type: 'function', function: { name: 'flag_rising_risk' } },
messages: [{ messages: [{
role: 'user', 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. 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 }) return NextResponse.json({ error: 'AI service unavailable' }, { status: 503 })
} }
const toolBlock = message.content.find(b => b.type === 'tool_use') const call = res.choices[0]?.message?.tool_calls?.[0]
if (!toolBlock || toolBlock.type !== 'tool_use') if (!call || call.type !== 'function') return NextResponse.json({ error: 'AI suggestion failed' }, { status: 500 })
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') if (!Array.isArray(input.flags) || typeof input.summary !== 'string')
return NextResponse.json({ error: 'AI returned unexpected structure' }, { status: 500 }) return NextResponse.json({ error: 'AI returned unexpected structure' }, { status: 500 })
@@ -164,7 +168,7 @@ ${JSON.stringify(aggregates, null, 2)}
p_table_name: 'incidents', p_table_name: 'incidents',
p_record_id: user.id, p_record_id: user.id,
p_action: 'ai_risk_flags', 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 }) return NextResponse.json({ flags, summary: input.summary })
+18 -19
View File
@@ -2,7 +2,7 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { createAnthropicClient } from '@/lib/claude/client' import { createDeepSeekClient } from '@/lib/claude/client'
import { getApiKey } from '@/lib/settings' import { getApiKey } from '@/lib/settings'
export async function POST( export async function POST(
@@ -28,8 +28,8 @@ export async function POST(
if ((recentCount ?? 0) > 0) if ((recentCount ?? 0) > 0)
return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 }) return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 })
const anthropicKey = await getApiKey(supabase, 'ANTHROPIC_API_KEY') const deepseekKey = await getApiKey(supabase, 'DEEPSEEK_API_KEY')
const anthropic = createAnthropicClient(anthropicKey) const client = createDeepSeekClient(deepseekKey)
const { data: incident } = await supabase const { data: incident } = await supabase
.from('incidents') .from('incidents')
@@ -55,17 +55,18 @@ export async function POST(
const siteName = (incident.sites as unknown as { name: string } | null)?.name ?? 'Unknown' const siteName = (incident.sites as unknown as { name: string } | null)?.name ?? 'Unknown'
const zoneName = (incident.zones as unknown as { name: string } | null)?.name ?? 'Unknown' const zoneName = (incident.zones as unknown as { name: string } | null)?.name ?? 'Unknown'
let message: Awaited<ReturnType<typeof anthropic.messages.create>> let res: Awaited<ReturnType<typeof client.chat.completions.create>>
try { try {
message = await anthropic.messages.create({ res = await client.chat.completions.create({
model: 'claude-opus-4-8', model: 'deepseek-chat',
thinking: { type: 'adaptive' },
max_tokens: 2048, max_tokens: 2048,
tools: [{ tools: [{
type: 'function',
function: {
name: 'draft_rca', name: 'draft_rca',
description: 'Draft a 5-Why root cause analysis and CAPA suggestions for an HSE incident', description: 'Draft a 5-Why root cause analysis and CAPA suggestions for an HSE incident',
input_schema: { parameters: {
type: 'object' as const, type: 'object',
properties: { properties: {
five_why_steps: { five_why_steps: {
type: 'array', type: 'array',
@@ -91,8 +92,9 @@ export async function POST(
}, },
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: [{ messages: [{
role: 'user', role: 'user',
content: `You are an experienced HSE investigator for a Malaysian 3PL warehouse. Draft a 5-Why root cause analysis for this incident. 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 35 Why steps drilling from immediate cause to root cause. Give a one-
return NextResponse.json({ error: 'AI service unavailable' }, { status: 503 }) return NextResponse.json({ error: 'AI service unavailable' }, { status: 503 })
} }
const toolBlock = message.content.find(b => b.type === 'tool_use') const call = res.choices[0]?.message?.tool_calls?.[0]
if (!toolBlock || toolBlock.type !== 'tool_use') if (!call || call.type !== 'function') return NextResponse.json({ error: 'AI draft failed' }, { status: 500 })
return NextResponse.json({ error: 'AI draft failed' }, { status: 500 })
const draft = toolBlock.input as { let draft: { five_why_steps?: unknown; root_cause_summary?: unknown; capa_suggestions?: unknown }
five_why_steps?: unknown try { draft = JSON.parse(call.function.arguments) }
root_cause_summary?: unknown catch { return NextResponse.json({ error: 'AI returned unexpected structure' }, { status: 500 }) }
capa_suggestions?: unknown
}
if ( if (
!Array.isArray(draft.five_why_steps) || !Array.isArray(draft.five_why_steps) ||
@@ -138,7 +137,7 @@ Provide 35 Why steps drilling from immediate cause to root cause. Give a one-
p_action: 'ai_rca_draft', p_action: 'ai_rca_draft',
p_new_value: { p_new_value: {
root_cause_summary: draft.root_cause_summary, root_cause_summary: draft.root_cause_summary,
model: 'claude-opus-4-8', model: 'deepseek-chat',
} as never, } as never,
}) })
@@ -2,7 +2,7 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { createAnthropicClient } from '@/lib/claude/client' import { createDeepSeekClient } from '@/lib/claude/client'
import { getApiKey } from '@/lib/settings' import { getApiKey } from '@/lib/settings'
export async function POST( export async function POST(
@@ -28,8 +28,8 @@ export async function POST(
if ((recentCount ?? 0) > 0) if ((recentCount ?? 0) > 0)
return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 }) return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 })
const anthropicKey = await getApiKey(supabase, 'ANTHROPIC_API_KEY') const deepseekKey = await getApiKey(supabase, 'DEEPSEEK_API_KEY')
const anthropic = createAnthropicClient(anthropicKey) const client = createDeepSeekClient(deepseekKey)
const { data: incident } = await supabase const { data: incident } = await supabase
.from('incidents') .from('incidents')
@@ -46,17 +46,18 @@ export async function POST(
medical_status: string | null medical_status: string | null
} }
let message: Awaited<ReturnType<typeof anthropic.messages.create>> let res: Awaited<ReturnType<typeof client.chat.completions.create>>
try { try {
message = await anthropic.messages.create({ res = await client.chat.completions.create({
model: 'claude-opus-4-8', model: 'deepseek-chat',
thinking: { type: 'adaptive' },
max_tokens: 1024, max_tokens: 1024,
tools: [{ tools: [{
type: 'function',
function: {
name: 'suggest_triage', name: 'suggest_triage',
description: 'Suggest severity rating and NADOPOD 2004 DOSH classification for a warehouse incident', description: 'Suggest severity rating and NADOPOD 2004 DOSH classification for a warehouse incident',
input_schema: { parameters: {
type: 'object' as const, type: 'object',
properties: { properties: {
severity: { type: 'number', description: '1=minor, 2=low, 3=moderate, 4=serious, 5=critical/fatality' }, severity: { type: 'number', description: '1=minor, 2=low, 3=moderate, 4=serious, 5=critical/fatality' },
is_fatality: { type: 'boolean' }, is_fatality: { type: 'boolean' },
@@ -70,8 +71,9 @@ export async function POST(
'is_dangerous_occurrence', 'is_occupational_disease', 'rationale', 'is_dangerous_occurrence', 'is_occupational_disease', 'rationale',
], ],
}, },
},
}], }],
tool_choice: { type: 'tool', name: 'suggest_triage' }, tool_choice: { type: 'function', function: { name: 'suggest_triage' } },
messages: [{ messages: [{
role: 'user', role: 'user',
content: `You are an HSE triage specialist for a Malaysian 3PL warehouse. Assess this incident under NADOPOD 2004. content: `You are an HSE triage specialist for a Malaysian 3PL warehouse. Assess this incident under NADOPOD 2004.
@@ -89,11 +91,10 @@ Suggest severity (15) and tick the appropriate NADOPOD 2004 flags. Give a one
return NextResponse.json({ error: 'AI service unavailable' }, { status: 503 }) return NextResponse.json({ error: 'AI service unavailable' }, { status: 503 })
} }
const toolBlock = message.content.find(b => b.type === 'tool_use') const call = res.choices[0]?.message?.tool_calls?.[0]
if (!toolBlock || toolBlock.type !== 'tool_use') if (!call || call.type !== 'function') return NextResponse.json({ error: 'AI suggestion failed' }, { status: 500 })
return NextResponse.json({ error: 'AI suggestion failed' }, { status: 500 })
const input = toolBlock.input as { let input: {
severity?: unknown severity?: unknown
is_fatality?: unknown is_fatality?: unknown
is_serious_bodily_injury?: unknown is_serious_bodily_injury?: unknown
@@ -101,6 +102,8 @@ Suggest severity (15) and tick the appropriate NADOPOD 2004 flags. Give a one
is_occupational_disease?: unknown is_occupational_disease?: unknown
rationale?: unknown rationale?: unknown
} }
try { input = JSON.parse(call.function.arguments) }
catch { return NextResponse.json({ error: 'AI returned unexpected structure' }, { status: 500 }) }
if ( if (
typeof input.severity !== 'number' || typeof input.severity !== 'number' ||
@@ -117,8 +120,8 @@ Suggest severity (15) and tick the appropriate NADOPOD 2004 flags. Give a one
p_table_name: 'incidents', p_table_name: 'incidents',
p_record_id: id, p_record_id: id,
p_action: 'ai_triage_suggest', 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)
} }
+19 -21
View File
@@ -2,7 +2,7 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { createAnthropicClient } from '@/lib/claude/client' import { createDeepSeekClient } from '@/lib/claude/client'
import { getApiKey } from '@/lib/settings' import { getApiKey } from '@/lib/settings'
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@@ -20,8 +20,8 @@ export async function POST(request: NextRequest) {
if ((recentCount ?? 0) > 0) if ((recentCount ?? 0) > 0)
return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 }) return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 })
const anthropicKey = await getApiKey(supabase, 'ANTHROPIC_API_KEY') const deepseekKey = await getApiKey(supabase, 'DEEPSEEK_API_KEY')
const anthropic = createAnthropicClient(anthropicKey) const client = createDeepSeekClient(deepseekKey)
let body: { description?: string; incident_type?: string } let body: { description?: string; incident_type?: string }
try { try {
@@ -33,17 +33,18 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'description and incident_type required' }, { status: 422 }) return NextResponse.json({ error: 'description and incident_type required' }, { status: 422 })
} }
let message: Awaited<ReturnType<typeof anthropic.messages.create>> let res: Awaited<ReturnType<typeof client.chat.completions.create>>
try { try {
message = await anthropic.messages.create({ res = await client.chat.completions.create({
model: 'claude-opus-4-8', model: 'deepseek-chat',
thinking: { type: 'adaptive' },
max_tokens: 1024, max_tokens: 1024,
tools: [{ tools: [{
type: 'function',
function: {
name: 'assess_quality', name: 'assess_quality',
description: 'Assess HSE incident report description quality', description: 'Assess HSE incident report description quality',
input_schema: { parameters: {
type: 'object' as const, type: 'object',
properties: { properties: {
score: { type: 'number', description: '1-10 quality score' }, score: { type: 'number', description: '1-10 quality score' },
passes: { type: 'boolean', description: 'True when score is 6 or above' }, passes: { type: 'boolean', description: 'True when score is 6 or above' },
@@ -56,8 +57,9 @@ export async function POST(request: NextRequest) {
}, },
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: [{ messages: [{
role: 'user', role: 'user',
content: `You are an HSE reporting assistant for a Malaysian 3PL warehouse. Assess this incident report description. content: `You are an HSE reporting assistant for a Malaysian 3PL warehouse. Assess this incident report description.
@@ -72,17 +74,13 @@ Score 110 based on: specificity (location, time, persons involved), completen
return NextResponse.json({ error: 'AI service unavailable' }, { status: 503 }) return NextResponse.json({ error: 'AI service unavailable' }, { status: 503 })
} }
const toolBlock = message.content.find(b => b.type === 'tool_use') const call = res.choices[0]?.message?.tool_calls?.[0]
if (!toolBlock || toolBlock.type !== 'tool_use') { if (!call || call.type !== 'function') return NextResponse.json({ error: 'AI assessment failed' }, { status: 500 })
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 ( if (
typeof input.score !== 'number' || typeof input.score !== 'number' ||
typeof input.passes !== 'boolean' || typeof input.passes !== 'boolean' ||
@@ -96,7 +94,7 @@ Score 110 based on: specificity (location, time, persons involved), completen
p_table_name: 'incidents', p_table_name: 'incidents',
p_record_id: user.id, p_record_id: user.id,
p_action: 'ai_quality_check', 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) return NextResponse.json(input)
+1
View File
@@ -5,6 +5,7 @@ import { createClient } from '@/lib/supabase/server'
const ALLOWED_KEYS = [ const ALLOWED_KEYS = [
'ANTHROPIC_API_KEY', 'ANTHROPIC_API_KEY',
'DEEPSEEK_API_KEY',
'VOYAGE_API_KEY', 'VOYAGE_API_KEY',
'META_WHATSAPP_PHONE_NUMBER_ID', 'META_WHATSAPP_PHONE_NUMBER_ID',
'META_WHATSAPP_ACCESS_TOKEN', 'META_WHATSAPP_ACCESS_TOKEN',
+3 -3
View File
@@ -1,5 +1,5 @@
import Anthropic from '@anthropic-ai/sdk' import OpenAI from 'openai'
export function createAnthropicClient(apiKey: string): Anthropic { export function createDeepSeekClient(apiKey: string): OpenAI {
return new Anthropic({ apiKey }) return new OpenAI({ apiKey, baseURL: 'https://api.deepseek.com' })
} }
+31
View File
@@ -13,6 +13,7 @@
"@supabase/supabase-js": "^2.110.2", "@supabase/supabase-js": "^2.110.2",
"idb": "^8.0.3", "idb": "^8.0.3",
"next": "^15.5.20", "next": "^15.5.20",
"openai": "^6.46.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
@@ -7287,6 +7288,36 @@
"node": ">=12.20.0" "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": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+1
View File
@@ -17,6 +17,7 @@
"@supabase/supabase-js": "^2.110.2", "@supabase/supabase-js": "^2.110.2",
"idb": "^8.0.3", "idb": "^8.0.3",
"next": "^15.5.20", "next": "^15.5.20",
"openai": "^6.46.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
@@ -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;
+22 -6
View File
@@ -1,24 +1,40 @@
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect, vi } from 'vitest'
const qualityOutput = { score: 8, passes: true, feedback: 'Clear description.', suggestions: [] }
vi.mock('@/lib/supabase/server', () => ({ vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn().mockResolvedValue({ createClient: vi.fn().mockResolvedValue({
auth: { auth: {
getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null }), 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', () => ({ vi.mock('@/lib/claude/client', () => ({
createAnthropicClient: vi.fn().mockReturnValue({ createDeepSeekClient: vi.fn().mockReturnValue({
messages: { chat: {
completions: {
create: vi.fn().mockResolvedValue({ create: vi.fn().mockResolvedValue({
content: [{ choices: [{
type: 'tool_use', message: {
tool_calls: [{
type: 'function',
function: {
name: 'assess_quality', name: 'assess_quality',
input: { score: 8, passes: true, feedback: 'Clear description.', suggestions: [] }, arguments: JSON.stringify(qualityOutput),
},
}],
},
}], }],
}), }),
}, },
},
}), }),
})) }))
@@ -38,7 +54,7 @@ describe('POST /api/incidents/ai/quality-check', () => {
expect(res.status).toBe(422) 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 { POST } = await import('@/app/api/incidents/ai/quality-check/route')
const req = new Request('http://localhost/api/incidents/ai/quality-check', { const req = new Request('http://localhost/api/incidents/ai/quality-check', {
method: 'POST', method: 'POST',
+35 -24
View File
@@ -14,30 +14,7 @@ const mockIncident = {
zones: { name: 'Zone B' }, zones: { name: 'Zone B' },
} }
vi.mock('@/lib/supabase/server', () => ({ const rcaOutput = {
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(),
single: vi.fn()
.mockResolvedValueOnce({ data: { role: 'hse' } }) // profile
.mockResolvedValueOnce({ data: mockIncident }), // incident
}),
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: 'draft_rca',
input: {
five_why_steps: [ five_why_steps: [
{ why: 'Why did the incident happen?', answer: 'Forklift entered zone B without checking for pedestrians.' }, { 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 there no check?', answer: 'No pedestrian exclusion zone marked at zone B entrance.' },
@@ -49,10 +26,44 @@ vi.mock('@/lib/claude/client', () => ({
'Update site hazard assessment to include forklift routes in all zones.', 'Update site hazard assessment to include forklift routes in all zones.',
'Conduct forklift safety refresher for all operators within 30 days.', 'Conduct forklift safety refresher for all operators within 30 days.',
], ],
}
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 }),
single: vi.fn()
.mockResolvedValueOnce({ data: { role: 'hse' } }) // profile
.mockResolvedValueOnce({ data: mockIncident }), // incident
}),
rpc: vi.fn().mockResolvedValue({ error: null }),
}),
}))
vi.mock('@/lib/claude/client', () => ({
createDeepSeekClient: vi.fn().mockReturnValue({
chat: {
completions: {
create: vi.fn().mockResolvedValue({
choices: [{
message: {
tool_calls: [{
type: 'function',
function: {
name: 'draft_rca',
arguments: JSON.stringify(rcaOutput),
},
}],
}, },
}], }],
}), }),
}, },
},
}), }),
})) }))
+22 -11
View File
@@ -9,6 +9,15 @@ const mockIncident = {
medical_status: 'medical_treatment', 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', () => ({ vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn().mockResolvedValue({ createClient: vi.fn().mockResolvedValue({
auth: { auth: {
@@ -17,6 +26,7 @@ vi.mock('@/lib/supabase/server', () => ({
from: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({
select: vi.fn().mockReturnThis(), select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(), eq: vi.fn().mockReturnThis(),
gte: vi.fn().mockResolvedValue({ count: 0 }),
single: vi.fn() single: vi.fn()
.mockResolvedValueOnce({ data: { role: 'hse' } }) // profile .mockResolvedValueOnce({ data: { role: 'hse' } }) // profile
.mockResolvedValueOnce({ data: mockIncident }), // incident .mockResolvedValueOnce({ data: mockIncident }), // incident
@@ -26,23 +36,24 @@ vi.mock('@/lib/supabase/server', () => ({
})) }))
vi.mock('@/lib/claude/client', () => ({ vi.mock('@/lib/claude/client', () => ({
createAnthropicClient: vi.fn().mockReturnValue({ createDeepSeekClient: vi.fn().mockReturnValue({
messages: { chat: {
completions: {
create: vi.fn().mockResolvedValue({ create: vi.fn().mockResolvedValue({
content: [{ choices: [{
type: 'tool_use', message: {
tool_calls: [{
type: 'function',
function: {
name: 'suggest_triage', name: 'suggest_triage',
input: { arguments: JSON.stringify(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.',
}, },
}], }],
}), }),
}, },
},
}), }),
})) }))