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 [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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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", {}),
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user