DeepSeek API deprecated deepseek-chat model. Returns error: 'the supported API model names are deepseek-v4-pro or deepseek-v4-flash' Affected all 4 AI endpoints: - risk-flags (90-day dashboard) - triage-suggest - rca-draft - quality-check Co-Authored-By: Claude <noreply@anthropic.com>
186 lines
7.0 KiB
TypeScript
186 lines
7.0 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,
|
|
tools: [{
|
|
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'],
|
|
},
|
|
},
|
|
summary: { type: 'string', description: 'Two-sentence overall risk picture' },
|
|
},
|
|
required: ['flags', 'summary'],
|
|
},
|
|
},
|
|
}],
|
|
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.
|
|
|
|
Flag zones with rising or elevated risk (at most 5 flags; do not flag healthy zones). Base every rationale strictly on the numbers given.
|
|
|
|
<zone_data>
|
|
${JSON.stringify(aggregates, null, 2)}
|
|
</zone_data>`,
|
|
}],
|
|
})
|
|
} catch (err) {
|
|
console.error('DeepSeek risk-flags error:', err)
|
|
return NextResponse.json({ error: 'AI service unavailable' }, { status: 503 })
|
|
}
|
|
|
|
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 }) }
|
|
|
|
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 })
|
|
}
|