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:
2026-07-11 16:18:39 +08:00
co-authored by Claude Sonnet 4.6
parent ab6299f963
commit 3bec95aaf8
3 changed files with 172 additions and 0 deletions
@@ -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 110 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)
}
+60
View File
@@ -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
+55
View File
@@ -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')
})
})