feat: AI report quality check before submission
Adds POST /api/incidents/ai/quality-check using Claude tool_use to score incident descriptions 1-10. Report form blocks submission when score < 6, shows amber feedback with suggestions, and offers a 'Submit anyway' override. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FFDuBhMKvoWjrWT3ZnGmmr
This commit is contained in:
@@ -0,0 +1,57 @@
|
|||||||
|
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) {
|
||||||
|
const supabase = await createClient()
|
||||||
|
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||||
|
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
|
const body = await request.json() as { description?: string; incident_type?: string }
|
||||||
|
if (!body.description || !body.incident_type) {
|
||||||
|
return NextResponse.json({ error: 'description and incident_type required' }, { status: 422 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = await anthropic.messages.create({
|
||||||
|
model: 'claude-opus-4-8',
|
||||||
|
thinking: { type: 'adaptive' },
|
||||||
|
max_tokens: 1024,
|
||||||
|
tools: [{
|
||||||
|
name: 'assess_quality',
|
||||||
|
description: 'Assess HSE incident report description quality',
|
||||||
|
input_schema: {
|
||||||
|
type: 'object' as const,
|
||||||
|
properties: {
|
||||||
|
score: { type: 'number', description: '1-10 quality score' },
|
||||||
|
passes: { type: 'boolean', description: 'True when score is 6 or above' },
|
||||||
|
feedback: { type: 'string', description: 'One-sentence quality summary' },
|
||||||
|
suggestions: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
|
description: 'Up to 3 concrete suggestions to improve the description',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['score', 'passes', 'feedback', 'suggestions'],
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
tool_choice: { type: 'tool', name: 'assess_quality' },
|
||||||
|
messages: [{
|
||||||
|
role: 'user',
|
||||||
|
content: `You are an HSE reporting assistant for a Malaysian 3PL warehouse. Assess this incident report description.
|
||||||
|
|
||||||
|
Incident type: ${body.incident_type}
|
||||||
|
Description: ${body.description}
|
||||||
|
|
||||||
|
Score 1–10 based on: specificity (location, time, persons involved), completeness (what happened + immediate actions), and clarity. Score 6 or above passes. If score is below 6, give up to 3 actionable suggestions.`,
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
|
||||||
|
const toolBlock = message.content.find(b => b.type === 'tool_use')
|
||||||
|
if (!toolBlock || toolBlock.type !== 'tool_use') {
|
||||||
|
return NextResponse.json({ error: 'AI assessment failed' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(toolBlock.input)
|
||||||
|
}
|
||||||
@@ -33,6 +33,13 @@ export function ReportForm({ zoneToken }: Props) {
|
|||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [files, setFiles] = useState<File[]>([])
|
const [files, setFiles] = useState<File[]>([])
|
||||||
|
const [qualityCheck, setQualityCheck] = useState<{
|
||||||
|
score: number
|
||||||
|
passes: boolean
|
||||||
|
feedback: string
|
||||||
|
suggestions: string[]
|
||||||
|
} | null>(null)
|
||||||
|
const [overrideQuality, setOverrideQuality] = useState(false)
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
incident_type: '' as IncidentType | '',
|
incident_type: '' as IncidentType | '',
|
||||||
@@ -47,6 +54,34 @@ export function ReportForm({ zoneToken }: Props) {
|
|||||||
setError(null)
|
setError(null)
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
|
|
||||||
|
if (!overrideQuality) {
|
||||||
|
try {
|
||||||
|
const qcRes = await fetch('/api/incidents/ai/quality-check', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
description: form.description,
|
||||||
|
incident_type: form.incident_type,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (qcRes.ok) {
|
||||||
|
const qc = await qcRes.json() as {
|
||||||
|
score: number
|
||||||
|
passes: boolean
|
||||||
|
feedback: string
|
||||||
|
suggestions: string[]
|
||||||
|
}
|
||||||
|
setQualityCheck(qc)
|
||||||
|
if (!qc.passes) {
|
||||||
|
setSubmitting(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Quality check failure is non-blocking — proceed with submission
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
if (zoneToken) fd.append('zone_token', zoneToken)
|
if (zoneToken) fd.append('zone_token', zoneToken)
|
||||||
@@ -115,6 +150,31 @@ export function ReportForm({ zoneToken }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{qualityCheck && !qualityCheck.passes && (
|
||||||
|
<div className="rounded-lg bg-amber-50 border border-amber-200 p-3 space-y-2">
|
||||||
|
<p className="text-xs font-semibold text-amber-700 uppercase tracking-wide">
|
||||||
|
Report quality — {qualityCheck.score}/10
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-amber-800">{qualityCheck.feedback}</p>
|
||||||
|
{qualityCheck.suggestions.length > 0 && (
|
||||||
|
<ul className="list-disc list-inside space-y-1">
|
||||||
|
{qualityCheck.suggestions.map((s, i) => (
|
||||||
|
<li key={i} className="text-xs text-amber-700">{s}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<label className="flex items-center gap-2 text-xs text-amber-700 cursor-pointer mt-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={overrideQuality}
|
||||||
|
onChange={e => setOverrideQuality(e.target.checked)}
|
||||||
|
className="rounded border-amber-300 text-amber-600"
|
||||||
|
/>
|
||||||
|
Submit anyway
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="flex items-center gap-3 cursor-pointer">
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('@/lib/supabase/server', () => ({
|
||||||
|
createClient: vi.fn().mockResolvedValue({
|
||||||
|
auth: {
|
||||||
|
getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null }),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/claude/client', () => ({
|
||||||
|
anthropic: {
|
||||||
|
messages: {
|
||||||
|
create: vi.fn().mockResolvedValue({
|
||||||
|
content: [{
|
||||||
|
type: 'tool_use',
|
||||||
|
name: 'assess_quality',
|
||||||
|
input: { score: 8, passes: true, feedback: 'Clear description.', suggestions: [] },
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('POST /api/incidents/ai/quality-check', () => {
|
||||||
|
it('returns 422 when description is missing', async () => {
|
||||||
|
const { POST } = await import('@/app/api/incidents/ai/quality-check/route')
|
||||||
|
const req = new Request('http://localhost/api/incidents/ai/quality-check', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ incident_type: 'injury' }),
|
||||||
|
})
|
||||||
|
const res = await POST(req as never)
|
||||||
|
expect(res.status).toBe(422)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns quality assessment from Claude', async () => {
|
||||||
|
const { POST } = await import('@/app/api/incidents/ai/quality-check/route')
|
||||||
|
const req = new Request('http://localhost/api/incidents/ai/quality-check', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
description: 'Forklift operator collided with racking in zone B at 14:30, injuring left arm.',
|
||||||
|
incident_type: 'injury',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const res = await POST(req as never)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body).toHaveProperty('score')
|
||||||
|
expect(body).toHaveProperty('passes')
|
||||||
|
expect(body).toHaveProperty('feedback')
|
||||||
|
expect(body).toHaveProperty('suggestions')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user