Files
adminandClaude ed2a2f65e4 fix: switch AI endpoints from tool-calling to JSON output mode
DeepSeek v4-pro reasoning model rejects tool_choice parameter.
All 4 endpoints now use system prompts with JSON schema
instructions and parse content as JSON instead of tool_calls.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-26 10:49:44 +08:00

116 lines
4.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth/get-session'
import { withUser, asAdmin } from '@/lib/db/with-user'
import { writeAuditLog } from '@/lib/db/audit'
import { incidents, auditLog, sites, zones } from '@/lib/db/schema'
import { eq, and, gte, sql } from 'drizzle-orm'
import { createDeepSeekClient } from '@/lib/claude/client'
import { getApiKey } from '@/lib/settings'
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
if (!['hse', 'admin'].includes(session.role))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
// Rate limit
const since = new Date(Date.now() - 60_000)
const [rateRow] = await asAdmin(db =>
db.select({ cnt: sql<number>`count(*)` }).from(auditLog)
.where(and(
eq(auditLog.changedBy, session.sub),
eq(auditLog.action, 'ai_rca_draft'),
gte(auditLog.changedAt, since),
))
)
if (Number(rateRow?.cnt ?? 0) > 0)
return NextResponse.json({ error: 'Rate limited — please wait 60 seconds' }, { status: 429 })
const deepseekKey = await getApiKey('DEEPSEEK_API_KEY')
const client = createDeepSeekClient(deepseekKey)
const [incident] = await withUser(session.sub, async tx =>
tx.select({
incidentType: incidents.incidentType,
description: incidents.description,
severity: incidents.severity,
injuryInvolved: incidents.injuryInvolved,
medicalStatus: incidents.medicalStatus,
isFatality: incidents.isFatality,
isSeriousBodilyInjury: incidents.isSeriousBodilyInjury,
triageNotes: incidents.triageNotes,
siteName: sites.name,
zoneName: zones.name,
})
.from(incidents)
.leftJoin(sites, eq(incidents.siteId, sites.id))
.leftJoin(zones, eq(incidents.zoneId, zones.id))
.where(eq(incidents.id, id))
.limit(1)
)
if (!incident) return NextResponse.json({ error: 'Not found' }, { status: 404 })
let res: Awaited<ReturnType<typeof client.chat.completions.create>>
try {
res = await client.chat.completions.create({
model: 'deepseek-v4-pro',
max_tokens: 2048,
messages: [
{
role: 'system',
content: `You are an experienced HSE investigator for a Malaysian 3PL warehouse. Draft a 5-Why root cause analysis for incidents. You must respond with valid JSON only — no markdown, no explanation outside the JSON object.
Output exactly this JSON structure:
{
"five_why_steps": [
{ "why": "<the why question>", "answer": "<the finding or answer>" }
],
"root_cause_summary": "<one-sentence root cause statement>",
"capa_suggestions": ["<up to 3 corrective/preventive actions>"]
}
Provide 35 Why steps drilling from immediate cause to root cause. Frame CAPA suggestions appropriate for a Malaysian warehouse context.`,
},
{
role: 'user',
content: `Draft a 5-Why root cause analysis for this incident. Respond with JSON only.\n\nSite: ${incident.siteName ?? 'Unknown'}\nZone: ${incident.zoneName ?? 'Unknown'}\nIncident type: ${incident.incidentType}\nDescription: ${incident.description}\nSeverity: ${incident.severity ?? 'not yet assigned'}/5\nInjury involved: ${incident.injuryInvolved ? `yes — ${incident.medicalStatus}` : 'no'}\nFatality: ${incident.isFatality ? 'yes' : 'no'}\nSerious bodily injury: ${incident.isSeriousBodilyInjury ? 'yes' : 'no'}\nTriage notes: ${incident.triageNotes ?? 'none'}`,
},
],
})
} catch {
return NextResponse.json({ error: 'AI service unavailable' }, { status: 503 })
}
const raw = res.choices[0]?.message?.content
if (!raw) return NextResponse.json({ error: 'AI returned empty response' }, { status: 500 })
const json = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '').trim()
let draft: { five_why_steps?: unknown; root_cause_summary?: unknown; capa_suggestions?: unknown }
try { draft = JSON.parse(json) }
catch { return NextResponse.json({ error: 'AI returned unexpected structure' }, { status: 500 }) }
if (
!Array.isArray(draft.five_why_steps) ||
typeof draft.root_cause_summary !== 'string' ||
!Array.isArray(draft.capa_suggestions)
) {
return NextResponse.json({ error: 'AI returned unexpected structure' }, { status: 500 })
}
await withUser(session.sub, async tx => {
await writeAuditLog(tx, 'incidents', id, 'ai_rca_draft', {
root_cause_summary: draft.root_cause_summary as string,
model: 'deepseek-v4-pro',
})
})
return NextResponse.json(draft)
}