diff --git a/app/(protected)/hse/incidents/[id]/page.tsx b/app/(protected)/hse/incidents/[id]/page.tsx index fefa9f5..ea67000 100644 --- a/app/(protected)/hse/incidents/[id]/page.tsx +++ b/app/(protected)/hse/incidents/[id]/page.tsx @@ -4,6 +4,7 @@ import { notFound } from 'next/navigation' import Link from 'next/link' import { createClient } from '@/lib/supabase/server' import { IncidentDetail, type Incident } from '@/components/incidents/incident-detail' +import { SimilarIncidentsPanel } from '@/components/incidents/similar-incidents-panel' interface Props { params: Promise<{ id: string }> @@ -94,6 +95,7 @@ export default async function HseIncidentDetailPage({ params }: Props) { )} + ) } diff --git a/app/api/incidents/[id]/similar/route.ts b/app/api/incidents/[id]/similar/route.ts new file mode 100644 index 0000000..0043856 --- /dev/null +++ b/app/api/incidents/[id]/similar/route.ts @@ -0,0 +1,46 @@ +export const dynamic = 'force-dynamic' + +import { NextRequest, NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { embedText } from '@/lib/claude/embed' + +export async function GET( + _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, description, embedding') + .eq('id', id) + .single() + if (!incident) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + + const inc = incident as { id: string; description: string; embedding: string | null } + + let embeddingVec: number[] + if (inc.embedding) { + embeddingVec = JSON.parse(inc.embedding) as number[] + } else { + embeddingVec = await embedText(inc.description) + await supabase.from('incidents').update({ + embedding: `[${embeddingVec.join(',')}]` as unknown as string, + }).eq('id', id) + } + + const { data: similar } = await supabase.rpc('match_incidents', { + query_embedding: `[${embeddingVec.join(',')}]`, + exclude_id: id, + match_count: 5, + }) + + return NextResponse.json(similar ?? []) +} diff --git a/app/api/incidents/route.ts b/app/api/incidents/route.ts index 5e85610..4c7a76f 100644 --- a/app/api/incidents/route.ts +++ b/app/api/incidents/route.ts @@ -109,5 +109,14 @@ export async function POST(request: Request) { sendNewIncidentEmail(incident.id, zone.site_id, incident.reference_no ?? '', input.incident_type) .catch(err => console.error('email notification failed:', err)) + // Embed description asynchronously for future similarity search + import('@/lib/claude/embed').then(({ embedText }) => + embedText(input.description.trim()).then(embedding => + supabase.from('incidents').update({ + embedding: `[${embedding.join(',')}]` as unknown as string, + }).eq('id', incident.id) + ) + ).catch(err => console.error('embed error:', err)) + return NextResponse.json({ id: incident.id, reference_no: incident.reference_no }, { status: 201 }) } diff --git a/components/incidents/similar-incidents-panel.tsx b/components/incidents/similar-incidents-panel.tsx new file mode 100644 index 0000000..1c58967 --- /dev/null +++ b/components/incidents/similar-incidents-panel.tsx @@ -0,0 +1,79 @@ +'use client' + +import { useEffect, useState } from 'react' + +type SimilarIncident = { + id: string + reference_no: string | null + incident_type: string + description: string + severity: number | null + similarity: number +} + +const TYPE_LABELS: Record = { + injury: 'Injury', + near_miss: 'Near Miss', + hazard: 'Hazard', + asset_damage: 'Asset Damage', + environmental: 'Environmental', + security: 'Security', + fire: 'Fire', +} + +interface Props { + incidentId: string +} + +export function SimilarIncidentsPanel({ incidentId }: Props) { + const [similar, setSimilar] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(false) + + useEffect(() => { + fetch(`/api/incidents/${incidentId}/similar`) + .then(r => { + if (!r.ok) throw new Error('failed') + return r.json() + }) + .then(data => setSimilar(Array.isArray(data) ? data : [])) + .catch(() => setError(true)) + .finally(() => setLoading(false)) + }, [incidentId]) + + if (loading) { + return ( + + Loading similar incidents… + + ) + } + if (error || similar.length === 0) return null + + return ( + + + Similar Past Incidents + + + {similar.map(inc => ( + + + + {inc.reference_no ?? inc.id.slice(0, 8)} · {TYPE_LABELS[inc.incident_type] ?? inc.incident_type} + + + {Math.round(inc.similarity * 100)}% similar + + + {inc.description} + + ))} + + + ) +}
Loading similar incidents…
{inc.description}