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>
176 lines
6.4 KiB
TypeScript
176 lines
6.4 KiB
TypeScript
export const dynamic = 'force-dynamic'
|
|
|
|
import { 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, sites, zones, auditLog } from '@/lib/db/schema'
|
|
import { eq, and, gte, desc } from 'drizzle-orm'
|
|
import { createDeepSeekClient } from '@/lib/claude/client'
|
|
import { getApiKey } from '@/lib/settings'
|
|
|
|
type ZoneAggregate = {
|
|
zone: string
|
|
site: string
|
|
total: number
|
|
near_miss: number
|
|
hazard: number
|
|
injury: number
|
|
avg_severity: number | null
|
|
first_half: number
|
|
second_half: number
|
|
}
|
|
|
|
export async function POST() {
|
|
const session = await getSession()
|
|
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
if (!['hse', 'admin', 'management'].includes(session.role))
|
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
|
|
const now = new Date()
|
|
const ninetyDaysAgo = new Date(now)
|
|
ninetyDaysAgo.setDate(now.getDate() - 90)
|
|
const midpoint = new Date(now)
|
|
midpoint.setDate(now.getDate() - 45)
|
|
|
|
const rows = await withUser(session.sub, async tx =>
|
|
tx.select({
|
|
incidentType: incidents.incidentType,
|
|
severity: incidents.severity,
|
|
reportedAt: incidents.reportedAt,
|
|
zoneName: zones.name,
|
|
siteName: sites.name,
|
|
})
|
|
.from(incidents)
|
|
.leftJoin(zones, eq(incidents.zoneId, zones.id))
|
|
.leftJoin(sites, eq(incidents.siteId, sites.id))
|
|
.where(gte(incidents.reportedAt, ninetyDaysAgo))
|
|
)
|
|
|
|
if (rows.length === 0)
|
|
return NextResponse.json({ flags: [], summary: 'No incidents in the last 90 days.' })
|
|
|
|
const zoneMap = new Map<string, ZoneAggregate & { severitySum: number; severityCount: number }>()
|
|
for (const r of rows) {
|
|
const zone = r.zoneName ?? 'Unknown zone'
|
|
const site = r.siteName ?? 'Unknown site'
|
|
const key = `${site}|${zone}`
|
|
let agg = zoneMap.get(key)
|
|
if (!agg) {
|
|
agg = {
|
|
zone, site, total: 0, near_miss: 0, hazard: 0, injury: 0,
|
|
avg_severity: null, first_half: 0, second_half: 0, severitySum: 0, severityCount: 0,
|
|
}
|
|
zoneMap.set(key, agg)
|
|
}
|
|
agg.total++
|
|
if (r.incidentType === 'near_miss') agg.near_miss++
|
|
if (r.incidentType === 'hazard') agg.hazard++
|
|
if (r.incidentType === 'injury') agg.injury++
|
|
if (typeof r.severity === 'number') {
|
|
agg.severitySum += r.severity
|
|
agg.severityCount++
|
|
}
|
|
if (r.reportedAt && new Date(r.reportedAt) < midpoint) agg.first_half++
|
|
else agg.second_half++
|
|
}
|
|
|
|
const aggregates: ZoneAggregate[] = [...zoneMap.values()].map(a => ({
|
|
zone: a.zone,
|
|
site: a.site,
|
|
total: a.total,
|
|
near_miss: a.near_miss,
|
|
hazard: a.hazard,
|
|
injury: a.injury,
|
|
avg_severity: a.severityCount > 0 ? Math.round((a.severitySum / a.severityCount) * 10) / 10 : null,
|
|
first_half: a.first_half,
|
|
second_half: a.second_half,
|
|
}))
|
|
|
|
// Rate limit: 1 AI call per 60s per user (checked via audit_log)
|
|
const [lastCall] = await asAdmin(db =>
|
|
db.select({ changedAt: auditLog.changedAt })
|
|
.from(auditLog)
|
|
.where(and(
|
|
eq(auditLog.changedBy, session.sub),
|
|
eq(auditLog.action, 'ai_risk_flags'),
|
|
))
|
|
.orderBy(desc(auditLog.changedAt))
|
|
.limit(1)
|
|
)
|
|
|
|
if (lastCall && Date.now() - new Date(lastCall.changedAt!).getTime() < 60_000) {
|
|
return NextResponse.json({ error: 'Rate limit: wait 60 seconds between AI requests' }, { status: 429 })
|
|
}
|
|
|
|
const deepseekKey = await getApiKey('DEEPSEEK_API_KEY')
|
|
const client = createDeepSeekClient(deepseekKey)
|
|
|
|
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 HSE risk analyst for a Malaysian 3PL warehouse operator. You must respond with valid JSON only — no markdown, no explanation outside the JSON object.
|
|
|
|
Output exactly this JSON structure:
|
|
{
|
|
"flags": [
|
|
{
|
|
"zone": "string",
|
|
"site": "string",
|
|
"risk_level": "low" | "medium" | "high",
|
|
"rationale": "One or two sentences citing the numbers",
|
|
"recommended_action": "One concrete preventive action"
|
|
}
|
|
],
|
|
"summary": "Two-sentence overall risk picture"
|
|
}
|
|
|
|
Flag only zones with rising or elevated risk (at most 5 flags; do not flag healthy zones). Base every rationale strictly on the numbers given. "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.`,
|
|
},
|
|
{
|
|
role: 'user',
|
|
content: `Below are 90-day incident aggregates per zone. Respond with JSON only.\n\n<zone_data>\n${JSON.stringify(aggregates, null, 2)}\n</zone_data>`,
|
|
},
|
|
],
|
|
})
|
|
} catch (err) {
|
|
console.error('DeepSeek risk-flags error:', err)
|
|
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 })
|
|
|
|
// Strip markdown code fences if present
|
|
const json = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '').trim()
|
|
|
|
let input: { flags?: unknown; summary?: unknown }
|
|
try { input = JSON.parse(json) }
|
|
catch { return NextResponse.json({ error: 'AI returned unexpected structure' }, { status: 500 }) }
|
|
|
|
if (!Array.isArray(input.flags) || typeof input.summary !== 'string')
|
|
return NextResponse.json({ error: 'AI returned unexpected structure' }, { status: 500 })
|
|
|
|
const flags = input.flags.filter(
|
|
(f: unknown): f is { zone: string; site: string; risk_level: string; rationale: string; recommended_action: string } =>
|
|
typeof f === 'object' && f !== null &&
|
|
typeof (f as Record<string, unknown>).zone === 'string' &&
|
|
typeof (f as Record<string, unknown>).site === 'string' &&
|
|
['low', 'medium', 'high'].includes((f as Record<string, unknown>).risk_level as string) &&
|
|
typeof (f as Record<string, unknown>).rationale === 'string' &&
|
|
typeof (f as Record<string, unknown>).recommended_action === 'string',
|
|
)
|
|
|
|
await withUser(session.sub, async tx =>
|
|
writeAuditLog(tx, 'incidents', session.sub, 'ai_risk_flags',
|
|
{ flags, summary: input.summary, model: 'deepseek-v4-pro' })
|
|
)
|
|
|
|
return NextResponse.json({ flags, summary: input.summary })
|
|
}
|