Add GT button to OCR overlay, prominent category picker, track pipeline
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 27s
CI / test-go-edu-search (push) Successful in 26s
CI / test-python-klausur (push) Failing after 1m51s
CI / test-python-agent-core (push) Successful in 15s
CI / test-nodejs-website (push) Successful in 18s

- Ground Truth button on last step of Pipeline/Kombi modes in ocr-overlay
- Prominent category picker in active session info bar (pulses when unset)
- GT badge shown when session has ground truth reference
- Backend: auto-detect pipeline from ocr_engine, store in GT snapshot
- Pipeline info shown in GT session list and regression reports
- Also pass pipeline param from ocr-pipeline StepGroundTruth

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-18 14:49:02 +01:00
parent f655db30e4
commit a3e2a7f994
3 changed files with 120 additions and 12 deletions

View File

@@ -28,6 +28,10 @@ export default function OcrOverlayPage() {
const [editNameValue, setEditNameValue] = useState('') const [editNameValue, setEditNameValue] = useState('')
const [editingCategory, setEditingCategory] = useState<string | null>(null) const [editingCategory, setEditingCategory] = useState<string | null>(null)
const [activeCategory, setActiveCategory] = useState<DocumentCategory | undefined>(undefined) const [activeCategory, setActiveCategory] = useState<DocumentCategory | undefined>(undefined)
const [editingActiveCategory, setEditingActiveCategory] = useState(false)
const [isGroundTruth, setIsGroundTruth] = useState(false)
const [gtSaving, setGtSaving] = useState(false)
const [gtMessage, setGtMessage] = useState('')
const [steps, setSteps] = useState<PipelineStep[]>( const [steps, setSteps] = useState<PipelineStep[]>(
OVERLAY_PIPELINE_STEPS.map((s, i) => ({ OVERLAY_PIPELINE_STEPS.map((s, i) => ({
...s, ...s,
@@ -64,6 +68,8 @@ export default function OcrOverlayPage() {
setSessionId(sid) setSessionId(sid)
setSessionName(data.name || data.filename || '') setSessionName(data.name || data.filename || '')
setActiveCategory(data.document_category || undefined) setActiveCategory(data.document_category || undefined)
setIsGroundTruth(!!data.ground_truth?.build_grid_reference)
setGtMessage('')
// Check if this session was processed with paddle_direct, kombi, or rapid_kombi // Check if this session was processed with paddle_direct, kombi, or rapid_kombi
const ocrEngine = data.word_result?.ocr_engine const ocrEngine = data.word_result?.ocr_engine
@@ -239,6 +245,33 @@ export default function OcrOverlayPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, goToStep]) }, [sessionId, goToStep])
const handleMarkGroundTruth = async () => {
if (!sessionId) return
setGtSaving(true)
setGtMessage('')
try {
const resp = await fetch(
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/mark-ground-truth?pipeline=${mode}`,
{ method: 'POST' }
)
if (!resp.ok) {
const body = await resp.text().catch(() => '')
throw new Error(`Ground Truth fehlgeschlagen (${resp.status}): ${body}`)
}
const data = await resp.json()
setIsGroundTruth(true)
setGtMessage(`Ground Truth gespeichert (${data.cells_saved} Zellen)`)
setTimeout(() => setGtMessage(''), 5000)
} catch (e) {
setGtMessage(e instanceof Error ? e.message : String(e))
} finally {
setGtSaving(false)
}
}
const isLastStep = currentStep === steps.length - 1
const showGtButton = isLastStep && sessionId != null
const renderStep = () => { const renderStep = () => {
if (mode === 'paddle-direct' || mode === 'kombi') { if (mode === 'paddle-direct' || mode === 'kombi') {
switch (currentStep) { switch (currentStep) {
@@ -472,14 +505,48 @@ export default function OcrOverlayPage() {
)} )}
</div> </div>
{/* Active session info */} {/* Active session info + category picker */}
{sessionId && sessionName && ( {sessionId && sessionName && (
<div className="flex items-center gap-3 text-sm text-gray-500 dark:text-gray-400"> <div className="relative flex items-center gap-3 text-sm text-gray-500 dark:text-gray-400">
<span>Aktive Session: <span className="font-medium text-gray-700 dark:text-gray-300">{sessionName}</span></span> <span>Aktive Session: <span className="font-medium text-gray-700 dark:text-gray-300">{sessionName}</span></span>
{activeCategory && (() => { <button
const cat = DOCUMENT_CATEGORIES.find(c => c.value === activeCategory) onClick={() => setEditingActiveCategory(!editingActiveCategory)}
return cat ? <span className="text-xs px-2 py-0.5 rounded-full bg-teal-50 dark:bg-teal-900/30 border border-teal-200 dark:border-teal-700 text-teal-700 dark:text-teal-300">{cat.icon} {cat.label}</span> : null className={`text-xs px-2.5 py-1 rounded-full border transition-colors ${
})()} activeCategory
? 'bg-teal-50 dark:bg-teal-900/30 border-teal-200 dark:border-teal-700 text-teal-700 dark:text-teal-300 hover:bg-teal-100 dark:hover:bg-teal-900/50'
: 'bg-amber-50 dark:bg-amber-900/20 border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/40 animate-pulse'
}`}
>
{activeCategory ? (() => {
const cat = DOCUMENT_CATEGORIES.find(c => c.value === activeCategory)
return cat ? `${cat.icon} ${cat.label}` : activeCategory
})() : 'Kategorie setzen'}
</button>
{isGroundTruth && (
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-300">
GT
</span>
)}
{editingActiveCategory && (
<div className="absolute left-0 top-full mt-1 z-20 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-2 grid grid-cols-2 gap-1 w-64">
{DOCUMENT_CATEGORIES.map((cat) => (
<button
key={cat.value}
onClick={() => {
updateCategory(sessionId, cat.value)
setEditingActiveCategory(false)
}}
className={`text-xs px-2 py-1.5 rounded-md text-left transition-colors ${
activeCategory === cat.value
? 'bg-teal-100 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300'
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400'
}`}
>
{cat.icon} {cat.label}
</button>
))}
</div>
)}
</div> </div>
)} )}
@@ -543,6 +610,26 @@ export default function OcrOverlayPage() {
/> />
<div className="min-h-[400px]">{renderStep()}</div> <div className="min-h-[400px]">{renderStep()}</div>
{/* Ground Truth button bar — visible on last step */}
{showGtButton && (
<div className="sticky bottom-0 bg-white dark:bg-gray-900 border-t dark:border-gray-700 py-3 px-4 -mx-1 flex items-center justify-between rounded-b-xl">
<div className="text-sm text-gray-500 dark:text-gray-400">
{gtMessage && (
<span className={gtMessage.includes('fehlgeschlagen') ? 'text-red-500' : 'text-amber-600 dark:text-amber-400'}>
{gtMessage}
</span>
)}
</div>
<button
onClick={handleMarkGroundTruth}
disabled={gtSaving}
className="px-4 py-2 text-sm bg-amber-600 text-white rounded hover:bg-amber-700 disabled:opacity-50"
>
{gtSaving ? 'Speichere...' : isGroundTruth ? 'Ground Truth aktualisieren' : 'Als Ground Truth markieren'}
</button>
</div>
)}
</div> </div>
) )
} }

View File

@@ -210,7 +210,7 @@ export function StepGroundTruth({ sessionId, onNext }: StepGroundTruthProps) {
setGtMessage('') setGtMessage('')
try { try {
const resp = await fetch( const resp = await fetch(
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/mark-ground-truth`, `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/mark-ground-truth?pipeline=ocr-pipeline`,
{ method: 'POST' } { method: 'POST' }
) )
if (!resp.ok) { if (!resp.ok) {

View File

@@ -12,7 +12,7 @@ import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, Query
from grid_editor_api import _build_grid_core from grid_editor_api import _build_grid_core
from ocr_pipeline_session_store import ( from ocr_pipeline_session_store import (
@@ -49,7 +49,10 @@ def _extract_cells_for_comparison(grid_result: dict) -> List[Dict[str, Any]]:
return cells return cells
def _build_reference_snapshot(grid_result: dict) -> dict: def _build_reference_snapshot(
grid_result: dict,
pipeline: Optional[str] = None,
) -> dict:
"""Build a ground-truth reference snapshot from a grid_editor_result.""" """Build a ground-truth reference snapshot from a grid_editor_result."""
cells = _extract_cells_for_comparison(grid_result) cells = _extract_cells_for_comparison(grid_result)
@@ -57,9 +60,10 @@ def _build_reference_snapshot(grid_result: dict) -> dict:
total_columns = sum(len(z.get("columns", [])) for z in grid_result.get("zones", [])) total_columns = sum(len(z.get("columns", [])) for z in grid_result.get("zones", []))
total_rows = sum(len(z.get("rows", [])) for z in grid_result.get("zones", [])) total_rows = sum(len(z.get("rows", [])) for z in grid_result.get("zones", []))
return { snapshot = {
"saved_at": datetime.now(timezone.utc).isoformat(), "saved_at": datetime.now(timezone.utc).isoformat(),
"version": 1, "version": 1,
"pipeline": pipeline,
"summary": { "summary": {
"total_zones": total_zones, "total_zones": total_zones,
"total_columns": total_columns, "total_columns": total_columns,
@@ -68,6 +72,7 @@ def _build_reference_snapshot(grid_result: dict) -> dict:
}, },
"cells": cells, "cells": cells,
} }
return snapshot
def compare_grids(reference: dict, current: dict) -> dict: def compare_grids(reference: dict, current: dict) -> dict:
@@ -160,7 +165,10 @@ def compare_grids(reference: dict, current: dict) -> dict:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.post("/sessions/{session_id}/mark-ground-truth") @router.post("/sessions/{session_id}/mark-ground-truth")
async def mark_ground_truth(session_id: str): async def mark_ground_truth(
session_id: str,
pipeline: Optional[str] = Query(None, description="Pipeline used: kombi, pipeline, paddle-direct"),
):
"""Save the current build-grid result as ground-truth reference.""" """Save the current build-grid result as ground-truth reference."""
session = await get_session_db(session_id) session = await get_session_db(session_id)
if not session: if not session:
@@ -173,7 +181,18 @@ async def mark_ground_truth(session_id: str):
detail="No grid_editor_result found. Run build-grid first.", detail="No grid_editor_result found. Run build-grid first.",
) )
reference = _build_reference_snapshot(grid_result) # Auto-detect pipeline from word_result if not provided
if not pipeline:
wr = session.get("word_result") or {}
engine = wr.get("ocr_engine", "")
if engine in ("kombi", "rapid_kombi"):
pipeline = "kombi"
elif engine == "paddle_direct":
pipeline = "paddle-direct"
else:
pipeline = "pipeline"
reference = _build_reference_snapshot(grid_result, pipeline=pipeline)
# Merge into existing ground_truth JSONB # Merge into existing ground_truth JSONB
gt = session.get("ground_truth") or {} gt = session.get("ground_truth") or {}
@@ -224,6 +243,8 @@ async def list_ground_truth_sessions():
"session_id": s["id"], "session_id": s["id"],
"name": s.get("name", ""), "name": s.get("name", ""),
"filename": s.get("filename", ""), "filename": s.get("filename", ""),
"document_category": s.get("document_category"),
"pipeline": ref.get("pipeline"),
"saved_at": ref.get("saved_at"), "saved_at": ref.get("saved_at"),
"summary": ref.get("summary", {}), "summary": ref.get("summary", {}),
}) })