- New StepGridReview component: split-view (scan image left, grid right), confidence stats, row-accept buttons, zoom controls - Kombi Pipeline case 6 now uses StepGridReview instead of plain GridEditor - Kombi step label changed to "Review & GT" - Ground Truth queue page simplified to overview/navigation only (links to Kombi pipeline for actual review work) - Deep-link support: /ai/ocr-overlay?session=xxx&mode=kombi Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
421 lines
15 KiB
TypeScript
421 lines
15 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* Ground-Truth Queue & Progress
|
|
*
|
|
* Overview page showing all sessions with their GT status.
|
|
* Clicking a session opens it in the Kombi Pipeline (/ai/ocr-overlay)
|
|
* where the actual review (split-view, inline edit, GT marking) happens.
|
|
*/
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { PagePurpose } from '@/components/common/PagePurpose'
|
|
|
|
const KLAUSUR_API = '/klausur-api'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface Session {
|
|
id: string
|
|
name: string
|
|
filename: string
|
|
status: string
|
|
created_at: string
|
|
document_category: string | null
|
|
has_ground_truth: boolean
|
|
}
|
|
|
|
interface GTSession {
|
|
session_id: string
|
|
name: string
|
|
filename: string
|
|
document_category: string | null
|
|
pipeline: string | null
|
|
saved_at: string | null
|
|
summary: {
|
|
total_zones: number
|
|
total_columns: number
|
|
total_rows: number
|
|
total_cells: number
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export default function GroundTruthQueuePage() {
|
|
const router = useRouter()
|
|
const [allSessions, setAllSessions] = useState<Session[]>([])
|
|
const [gtSessions, setGtSessions] = useState<GTSession[]>([])
|
|
const [filter, setFilter] = useState<'all' | 'unreviewed' | 'reviewed'>('all')
|
|
const [loading, setLoading] = useState(true)
|
|
const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set())
|
|
const [marking, setMarking] = useState(false)
|
|
const [markResult, setMarkResult] = useState<string | null>(null)
|
|
|
|
// Load sessions + GT sessions
|
|
const loadData = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const [sessRes, gtRes] = await Promise.all([
|
|
fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions?limit=200`),
|
|
fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/ground-truth-sessions`),
|
|
])
|
|
|
|
if (sessRes.ok) {
|
|
const data = await sessRes.json()
|
|
const gtSet = new Set<string>()
|
|
|
|
if (gtRes.ok) {
|
|
const gtData = await gtRes.json()
|
|
const gts: GTSession[] = gtData.sessions || []
|
|
setGtSessions(gts)
|
|
for (const g of gts) gtSet.add(g.session_id)
|
|
}
|
|
|
|
const sessions: Session[] = (data.sessions || [])
|
|
.filter((s: any) => !s.parent_session_id)
|
|
.map((s: any) => ({
|
|
id: s.id,
|
|
name: s.name || '',
|
|
filename: s.filename || '',
|
|
status: s.status || 'active',
|
|
created_at: s.created_at || '',
|
|
document_category: s.document_category || null,
|
|
has_ground_truth: gtSet.has(s.id),
|
|
}))
|
|
setAllSessions(sessions)
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load data:', e)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
loadData()
|
|
}, [loadData])
|
|
|
|
// Filtered sessions
|
|
const filteredSessions = allSessions.filter((s) => {
|
|
if (filter === 'unreviewed') return !s.has_ground_truth
|
|
if (filter === 'reviewed') return s.has_ground_truth
|
|
return true
|
|
})
|
|
|
|
const reviewedCount = allSessions.filter((s) => s.has_ground_truth).length
|
|
const totalCount = allSessions.length
|
|
const pct = totalCount > 0 ? Math.round((reviewedCount / totalCount) * 100) : 0
|
|
|
|
// Open session in Kombi pipeline
|
|
const openInPipeline = (sessionId: string) => {
|
|
router.push(`/ai/ocr-overlay?session=${sessionId}&mode=kombi`)
|
|
}
|
|
|
|
// Batch mark as GT
|
|
const batchMark = async () => {
|
|
setMarking(true)
|
|
let success = 0
|
|
for (const sid of selectedSessions) {
|
|
try {
|
|
const res = await fetch(
|
|
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}/mark-ground-truth?pipeline=kombi`,
|
|
{ method: 'POST' },
|
|
)
|
|
if (res.ok) success++
|
|
} catch {
|
|
/* skip */
|
|
}
|
|
}
|
|
setSelectedSessions(new Set())
|
|
setMarking(false)
|
|
setMarkResult(`${success} Sessions als Ground Truth markiert`)
|
|
setTimeout(() => setMarkResult(null), 3000)
|
|
loadData()
|
|
}
|
|
|
|
const toggleSelect = (id: string) => {
|
|
setSelectedSessions((prev) => {
|
|
const next = new Set(prev)
|
|
if (next.has(id)) next.delete(id)
|
|
else next.add(id)
|
|
return next
|
|
})
|
|
}
|
|
|
|
const selectAll = () => {
|
|
if (selectedSessions.size === filteredSessions.length) {
|
|
setSelectedSessions(new Set())
|
|
} else {
|
|
setSelectedSessions(new Set(filteredSessions.map((s) => s.id)))
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="max-w-5xl mx-auto p-4 space-y-4">
|
|
<PagePurpose
|
|
title="Ground Truth Queue"
|
|
purpose="Uebersicht aller OCR-Sessions und deren Ground-Truth-Status. Zum Pruefen und Korrigieren eine Session oeffnen — sie wird im Kombi-Modus (OCR Overlay) bearbeitet."
|
|
audience={['Entwickler', 'QA']}
|
|
defaultCollapsed
|
|
architecture={{
|
|
services: ['klausur-service (FastAPI, Port 8086)'],
|
|
databases: ['PostgreSQL (ocr_pipeline_sessions)'],
|
|
}}
|
|
relatedPages={[
|
|
{
|
|
name: 'Kombi Pipeline',
|
|
href: '/ai/ocr-overlay',
|
|
description: 'Sessions bearbeiten und GT markieren',
|
|
},
|
|
{
|
|
name: 'OCR Regression',
|
|
href: '/ai/ocr-regression',
|
|
description: 'Regressions-Tests',
|
|
},
|
|
]}
|
|
/>
|
|
|
|
{/* Progress Bar */}
|
|
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<h2 className="text-lg font-bold text-slate-900">
|
|
Ground Truth Fortschritt
|
|
</h2>
|
|
<span className="text-sm text-slate-500">
|
|
{reviewedCount} von {totalCount} markiert ({pct}%)
|
|
</span>
|
|
</div>
|
|
<div className="w-full bg-slate-100 rounded-full h-2.5">
|
|
<div
|
|
className="bg-teal-500 h-2.5 rounded-full transition-all duration-500"
|
|
style={{ width: `${pct}%` }}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500">
|
|
<span className="flex items-center gap-1">
|
|
<span className="w-2 h-2 rounded-full bg-teal-400" />
|
|
{reviewedCount} Ground Truth
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<span className="w-2 h-2 rounded-full bg-slate-300" />
|
|
{totalCount - reviewedCount} offen
|
|
</span>
|
|
<span>
|
|
{gtSessions.reduce((sum, g) => sum + g.summary.total_cells, 0)}{' '}
|
|
Referenz-Zellen gesamt
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filter + Actions */}
|
|
<div className="flex items-center gap-4 flex-wrap">
|
|
<div className="flex gap-1 bg-slate-100 rounded-lg p-1">
|
|
{(['all', 'unreviewed', 'reviewed'] as const).map((f) => (
|
|
<button
|
|
key={f}
|
|
onClick={() => setFilter(f)}
|
|
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
|
|
filter === f
|
|
? 'bg-white text-slate-900 shadow-sm font-medium'
|
|
: 'text-slate-500 hover:text-slate-700'
|
|
}`}
|
|
>
|
|
{f === 'all'
|
|
? 'Alle'
|
|
: f === 'unreviewed'
|
|
? 'Offen'
|
|
: 'Ground Truth'}
|
|
<span className="ml-1 text-xs text-slate-400">
|
|
(
|
|
{
|
|
allSessions.filter((s) =>
|
|
f === 'unreviewed'
|
|
? !s.has_ground_truth
|
|
: f === 'reviewed'
|
|
? s.has_ground_truth
|
|
: true,
|
|
).length
|
|
}
|
|
)
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="ml-auto flex items-center gap-2">
|
|
{selectedSessions.size > 0 && (
|
|
<button
|
|
onClick={batchMark}
|
|
disabled={marking}
|
|
className="px-3 py-1.5 bg-teal-600 text-white text-sm rounded-lg hover:bg-teal-700 disabled:opacity-50"
|
|
>
|
|
{marking
|
|
? 'Markiere...'
|
|
: `${selectedSessions.size} als GT markieren`}
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={selectAll}
|
|
className="px-3 py-1.5 text-sm text-slate-500 hover:text-slate-700 border border-slate-200 rounded-lg hover:bg-slate-50"
|
|
>
|
|
{selectedSessions.size === filteredSessions.length
|
|
? 'Keine auswaehlen'
|
|
: 'Alle auswaehlen'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Toast */}
|
|
{markResult && (
|
|
<div className="p-3 rounded-lg text-sm bg-emerald-50 text-emerald-700 border border-emerald-200">
|
|
{markResult}
|
|
</div>
|
|
)}
|
|
|
|
{/* Session List */}
|
|
{loading ? (
|
|
<div className="text-center py-12 text-slate-400">
|
|
Lade Sessions...
|
|
</div>
|
|
) : filteredSessions.length === 0 ? (
|
|
<div className="text-center py-12 text-slate-400">
|
|
<p className="text-lg">Keine Sessions in dieser Ansicht</p>
|
|
</div>
|
|
) : (
|
|
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-slate-200 bg-slate-50 text-left text-slate-500">
|
|
<th className="px-4 py-2 w-8">
|
|
<input
|
|
type="checkbox"
|
|
checked={
|
|
selectedSessions.size === filteredSessions.length &&
|
|
filteredSessions.length > 0
|
|
}
|
|
onChange={selectAll}
|
|
className="rounded border-slate-300"
|
|
/>
|
|
</th>
|
|
<th className="px-4 py-2 font-medium">Status</th>
|
|
<th className="px-4 py-2 font-medium">Session</th>
|
|
<th className="px-4 py-2 font-medium">Kategorie</th>
|
|
<th className="px-4 py-2 font-medium">Erstellt</th>
|
|
<th className="px-4 py-2 font-medium text-right">
|
|
Aktion
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredSessions.map((s) => {
|
|
const gt = gtSessions.find((g) => g.session_id === s.id)
|
|
return (
|
|
<tr
|
|
key={s.id}
|
|
className="border-b border-slate-50 hover:bg-slate-50 transition-colors"
|
|
>
|
|
<td className="px-4 py-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedSessions.has(s.id)}
|
|
onChange={() => toggleSelect(s.id)}
|
|
className="rounded border-slate-300"
|
|
/>
|
|
</td>
|
|
<td className="px-4 py-2">
|
|
{s.has_ground_truth ? (
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-emerald-100 text-emerald-700 border border-emerald-200">
|
|
<svg
|
|
className="w-3 h-3"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M5 13l4 4L19 7"
|
|
/>
|
|
</svg>
|
|
GT
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-slate-100 text-slate-500 border border-slate-200">
|
|
Offen
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-2">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex-shrink-0 w-8 h-8 rounded bg-slate-100 overflow-hidden">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${s.id}/thumbnail?size=64`}
|
|
alt=""
|
|
className="w-full h-full object-cover"
|
|
loading="lazy"
|
|
onError={(e) => {
|
|
;(e.target as HTMLImageElement).style.display =
|
|
'none'
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="font-medium text-slate-900 truncate">
|
|
{s.name || s.filename || s.id.slice(0, 8)}
|
|
</div>
|
|
{gt && (
|
|
<div className="text-xs text-slate-400">
|
|
{gt.summary.total_cells} Zellen,{' '}
|
|
{gt.summary.total_zones} Zonen
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-2">
|
|
{s.document_category ? (
|
|
<span className="text-xs bg-slate-100 px-1.5 py-0.5 rounded text-slate-600">
|
|
{s.document_category}
|
|
</span>
|
|
) : (
|
|
<span className="text-xs text-slate-300">—</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-2 text-slate-500">
|
|
{new Date(s.created_at).toLocaleDateString('de-DE', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: '2-digit',
|
|
})}
|
|
</td>
|
|
<td className="px-4 py-2 text-right">
|
|
<button
|
|
onClick={() => openInPipeline(s.id)}
|
|
className="px-3 py-1 text-xs bg-teal-600 text-white rounded hover:bg-teal-700 transition-colors"
|
|
>
|
|
{s.has_ground_truth
|
|
? 'Ueberpruefen'
|
|
: 'Im Kombi-Modus oeffnen'}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|