diff --git a/app/api/incidents/[id]/ai/rca-draft/route.ts b/app/api/incidents/[id]/ai/rca-draft/route.ts new file mode 100644 index 0000000..b1c2c0e --- /dev/null +++ b/app/api/incidents/[id]/ai/rca-draft/route.ts @@ -0,0 +1,127 @@ +export const dynamic = 'force-dynamic' + +import { NextRequest, NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { anthropic } from '@/lib/claude/client' + +export async function POST( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const supabase = await createClient() + const { data: { user }, error: authError } = await supabase.auth.getUser() + if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const { data: profile } = await supabase.from('users').select('role').eq('id', user.id).single() + if (!profile || !['hse', 'admin'].includes(profile.role)) + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + + const { data: incident } = await supabase + .from('incidents') + .select(` + id, incident_type, description, severity, injury_involved, medical_status, + is_fatality, is_serious_bodily_injury, triage_notes, + sites (name), zones (name) + `) + .eq('id', id) + .single() + if (!incident) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + + const inc = incident as { + incident_type: string + description: string + severity: number | null + injury_involved: boolean + medical_status: string | null + is_fatality: boolean + is_serious_bodily_injury: boolean + triage_notes: string | null + } + 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 message = await anthropic.messages.create({ + model: 'claude-opus-4-8', + thinking: { type: 'adaptive' }, + 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' }, + }, + 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', + }, + }, + required: ['five_why_steps', 'root_cause_summary', 'capa_suggestions'], + }, + }], + tool_choice: { type: 'tool', 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. + +Site: ${siteName} +Zone: ${zoneName} +Incident type: ${inc.incident_type} +Description: ${inc.description} +Severity: ${inc.severity ?? 'not yet assigned'}/5 +Injury involved: ${inc.injury_involved ? `yes — ${inc.medical_status}` : 'no'} +Fatality: ${inc.is_fatality ? 'yes' : 'no'} +Serious bodily injury: ${inc.is_serious_bodily_injury ? 'yes' : 'no'} +Triage notes: ${inc.triage_notes ?? 'none'} + +Provide 3–5 Why steps drilling from immediate cause to root cause. Give a one-sentence root cause statement. Suggest 3 corrective/preventive actions appropriate for a Malaysian warehouse context.`, + }], + }) + + 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 draft = toolBlock.input as { + five_why_steps?: unknown + root_cause_summary?: unknown + capa_suggestions?: unknown + } + + 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 supabase.rpc('write_audit_log', { + p_table_name: 'incidents', + p_record_id: id, + p_action: 'ai_rca_draft', + p_new_value: { + root_cause_summary: draft.root_cause_summary, + model: 'claude-opus-4-8', + } as never, + }) + + return NextResponse.json(draft) +} diff --git a/components/incidents/investigation-form.tsx b/components/incidents/investigation-form.tsx index 5fda866..d115b48 100644 --- a/components/incidents/investigation-form.tsx +++ b/components/incidents/investigation-form.tsx @@ -40,6 +40,7 @@ export function InvestigationForm({ incidentId, existingInvestigationId }: Props const [complete, setComplete] = useState(false) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) + const [aiDraftLoading, setAiDraftLoading] = useState(false) function addWhyStep() { if (fiveWhy.length >= 5) return @@ -57,6 +58,31 @@ export function InvestigationForm({ incidentId, existingInvestigationId }: Props setFishbone(fishbone.map((c, i) => i === catIdx ? { ...c, causes: [...c.causes, ''] } : c)) } + async function getAiDraft() { + setAiDraftLoading(true) + try { + const res = await fetch(`/api/incidents/${incidentId}/ai/rca-draft`, { method: 'POST' }) + if (!res.ok) return + const draft = await res.json() as { + five_why_steps: Array<{ why: string; answer: string }> + root_cause_summary: string + capa_suggestions: string[] + } + if (draft.five_why_steps?.length > 0) { + setMethod('five_why') + setFiveWhy(draft.five_why_steps) + } + if (draft.root_cause_summary) setRootCause(draft.root_cause_summary) + if (draft.capa_suggestions?.length > 0) { + setFindingsText(draft.capa_suggestions.map((s, i) => `${i + 1}. ${s}`).join('\n')) + } + } catch { + // Non-blocking — investigator can fill manually + } finally { + setAiDraftLoading(false) + } + } + async function handleSubmit(e: React.FormEvent) { e.preventDefault() setSaving(true) @@ -99,6 +125,16 @@ export function InvestigationForm({ incidentId, existingInvestigationId }: Props return (
+
+ +