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
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:
@@ -28,6 +28,10 @@ export default function OcrOverlayPage() {
|
||||
const [editNameValue, setEditNameValue] = useState('')
|
||||
const [editingCategory, setEditingCategory] = useState<string | null>(null)
|
||||
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[]>(
|
||||
OVERLAY_PIPELINE_STEPS.map((s, i) => ({
|
||||
...s,
|
||||
@@ -64,6 +68,8 @@ export default function OcrOverlayPage() {
|
||||
setSessionId(sid)
|
||||
setSessionName(data.name || data.filename || '')
|
||||
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
|
||||
const ocrEngine = data.word_result?.ocr_engine
|
||||
@@ -239,6 +245,33 @@ export default function OcrOverlayPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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 = () => {
|
||||
if (mode === 'paddle-direct' || mode === 'kombi') {
|
||||
switch (currentStep) {
|
||||
@@ -472,14 +505,48 @@ export default function OcrOverlayPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active session info */}
|
||||
{/* Active session info + category picker */}
|
||||
{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>
|
||||
{activeCategory && (() => {
|
||||
const cat = DOCUMENT_CATEGORIES.find(c => c.value === activeCategory)
|
||||
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
|
||||
})()}
|
||||
<button
|
||||
onClick={() => setEditingActiveCategory(!editingActiveCategory)}
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -543,6 +610,26 @@ export default function OcrOverlayPage() {
|
||||
/>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -210,7 +210,7 @@ export function StepGroundTruth({ sessionId, onNext }: StepGroundTruthProps) {
|
||||
setGtMessage('')
|
||||
try {
|
||||
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' }
|
||||
)
|
||||
if (!resp.ok) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import logging
|
||||
from datetime import datetime, timezone
|
||||
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 ocr_pipeline_session_store import (
|
||||
@@ -49,7 +49,10 @@ def _extract_cells_for_comparison(grid_result: dict) -> List[Dict[str, Any]]:
|
||||
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."""
|
||||
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_rows = sum(len(z.get("rows", [])) for z in grid_result.get("zones", []))
|
||||
|
||||
return {
|
||||
snapshot = {
|
||||
"saved_at": datetime.now(timezone.utc).isoformat(),
|
||||
"version": 1,
|
||||
"pipeline": pipeline,
|
||||
"summary": {
|
||||
"total_zones": total_zones,
|
||||
"total_columns": total_columns,
|
||||
@@ -68,6 +72,7 @@ def _build_reference_snapshot(grid_result: dict) -> dict:
|
||||
},
|
||||
"cells": cells,
|
||||
}
|
||||
return snapshot
|
||||
|
||||
|
||||
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")
|
||||
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."""
|
||||
session = await get_session_db(session_id)
|
||||
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.",
|
||||
)
|
||||
|
||||
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
|
||||
gt = session.get("ground_truth") or {}
|
||||
@@ -224,6 +243,8 @@ async def list_ground_truth_sessions():
|
||||
"session_id": s["id"],
|
||||
"name": s.get("name", ""),
|
||||
"filename": s.get("filename", ""),
|
||||
"document_category": s.get("document_category"),
|
||||
"pipeline": ref.get("pipeline"),
|
||||
"saved_at": ref.get("saved_at"),
|
||||
"summary": ref.get("summary", {}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user