Spaces:
Runtime error
Runtime error
Numan Saeed commited on
Commit ·
10ac650
1
Parent(s): 484ef72
Add feedback system with correction logging
Browse filesFeatures:
- Correct/Incorrect feedback buttons after classification
- Dropdown to select correct label when prediction is wrong
- Optional notes field for reviewers
- Low confidence warning (< 70%)
- Session tracking with history panel
- CSV export of feedback data
- Feedback counter in header
Components added:
- FeedbackSection.tsx
- SessionHistory.tsx
- feedback.py (service + routes)
- backend/app/.DS_Store +0 -0
- backend/app/__pycache__/__init__.cpython-310.pyc +0 -0
- backend/app/__pycache__/__init__.cpython-312.pyc +0 -0
- backend/app/__pycache__/main.cpython-310.pyc +0 -0
- backend/app/__pycache__/main.cpython-312.pyc +0 -0
- backend/app/main.py +2 -1
- backend/app/routes/__init__.py +2 -1
- backend/app/routes/__pycache__/__init__.cpython-310.pyc +0 -0
- backend/app/routes/__pycache__/__init__.cpython-312.pyc +0 -0
- backend/app/routes/__pycache__/classification.cpython-310.pyc +0 -0
- backend/app/routes/__pycache__/classification.cpython-312.pyc +0 -0
- backend/app/routes/__pycache__/feedback.cpython-310.pyc +0 -0
- backend/app/routes/__pycache__/gestational_age.cpython-310.pyc +0 -0
- backend/app/routes/feedback.py +162 -0
- backend/app/services/__pycache__/__init__.cpython-310.pyc +0 -0
- backend/app/services/__pycache__/__init__.cpython-312.pyc +0 -0
- backend/app/services/__pycache__/feedback.cpython-310.pyc +0 -0
- backend/app/services/__pycache__/model.cpython-310.pyc +0 -0
- backend/app/services/__pycache__/model.cpython-312.pyc +0 -0
- backend/app/services/__pycache__/preprocessing.cpython-310.pyc +0 -0
- backend/app/services/feedback.py +258 -0
- frontend/src/App.tsx +30 -5
- frontend/src/components/FeedbackSection.tsx +223 -0
- frontend/src/components/Header.tsx +24 -2
- frontend/src/components/ResultsCard.tsx +29 -6
- frontend/src/components/SessionHistory.tsx +171 -0
- frontend/src/components/index.ts +2 -0
- frontend/src/lib/api.ts +185 -4
- frontend/src/pages/ClassificationPage.tsx +66 -4
backend/app/.DS_Store
ADDED
|
Binary file (6.15 kB). View file
|
|
|
backend/app/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (169 Bytes). View file
|
|
|
backend/app/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (173 Bytes). View file
|
|
|
backend/app/__pycache__/main.cpython-310.pyc
ADDED
|
Binary file (2.83 kB). View file
|
|
|
backend/app/__pycache__/main.cpython-312.pyc
ADDED
|
Binary file (3.09 kB). View file
|
|
|
backend/app/main.py
CHANGED
|
@@ -5,7 +5,7 @@ from contextlib import asynccontextmanager
|
|
| 5 |
from pathlib import Path
|
| 6 |
import sys
|
| 7 |
|
| 8 |
-
from .routes import classification_router, gestational_age_router
|
| 9 |
from .services.model import model_service
|
| 10 |
|
| 11 |
# Get assets directory - handle both development and PyInstaller frozen modes
|
|
@@ -71,6 +71,7 @@ app.add_middleware(
|
|
| 71 |
# Include routers
|
| 72 |
app.include_router(classification_router)
|
| 73 |
app.include_router(gestational_age_router)
|
|
|
|
| 74 |
|
| 75 |
|
| 76 |
@app.get("/", tags=["Health"])
|
|
|
|
| 5 |
from pathlib import Path
|
| 6 |
import sys
|
| 7 |
|
| 8 |
+
from .routes import classification_router, gestational_age_router, feedback_router
|
| 9 |
from .services.model import model_service
|
| 10 |
|
| 11 |
# Get assets directory - handle both development and PyInstaller frozen modes
|
|
|
|
| 71 |
# Include routers
|
| 72 |
app.include_router(classification_router)
|
| 73 |
app.include_router(gestational_age_router)
|
| 74 |
+
app.include_router(feedback_router)
|
| 75 |
|
| 76 |
|
| 77 |
@app.get("/", tags=["Health"])
|
backend/app/routes/__init__.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
from .classification import router as classification_router
|
| 2 |
from .gestational_age import router as gestational_age_router
|
|
|
|
| 3 |
|
| 4 |
-
__all__ = ["classification_router", "gestational_age_router"]
|
| 5 |
|
|
|
|
| 1 |
from .classification import router as classification_router
|
| 2 |
from .gestational_age import router as gestational_age_router
|
| 3 |
+
from .feedback import router as feedback_router
|
| 4 |
|
| 5 |
+
__all__ = ["classification_router", "gestational_age_router", "feedback_router"]
|
| 6 |
|
backend/app/routes/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (376 Bytes). View file
|
|
|
backend/app/routes/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (388 Bytes). View file
|
|
|
backend/app/routes/__pycache__/classification.cpython-310.pyc
ADDED
|
Binary file (2.7 kB). View file
|
|
|
backend/app/routes/__pycache__/classification.cpython-312.pyc
ADDED
|
Binary file (3.88 kB). View file
|
|
|
backend/app/routes/__pycache__/feedback.cpython-310.pyc
ADDED
|
Binary file (5.29 kB). View file
|
|
|
backend/app/routes/__pycache__/gestational_age.cpython-310.pyc
ADDED
|
Binary file (1.63 kB). View file
|
|
|
backend/app/routes/feedback.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
API routes for feedback management.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, HTTPException, Query
|
| 6 |
+
from fastapi.responses import PlainTextResponse
|
| 7 |
+
from pydantic import BaseModel
|
| 8 |
+
from typing import List, Dict, Optional
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
|
| 11 |
+
from ..services.feedback import feedback_service
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"])
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# Pydantic models for request/response
|
| 18 |
+
class PredictionDetail(BaseModel):
|
| 19 |
+
label: str
|
| 20 |
+
probability: float
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class FeedbackCreate(BaseModel):
|
| 24 |
+
session_id: str
|
| 25 |
+
filename: str
|
| 26 |
+
file_type: str
|
| 27 |
+
predicted_label: str
|
| 28 |
+
predicted_confidence: float
|
| 29 |
+
all_predictions: List[PredictionDetail]
|
| 30 |
+
is_correct: bool
|
| 31 |
+
correct_label: Optional[str] = None
|
| 32 |
+
reviewer_notes: Optional[str] = None
|
| 33 |
+
patient_id: Optional[str] = None
|
| 34 |
+
image_hash: Optional[str] = None
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class FeedbackResponse(BaseModel):
|
| 38 |
+
id: str
|
| 39 |
+
session_id: str
|
| 40 |
+
timestamp: str
|
| 41 |
+
filename: str
|
| 42 |
+
file_type: str
|
| 43 |
+
patient_id: Optional[str]
|
| 44 |
+
image_hash: Optional[str]
|
| 45 |
+
predicted_label: str
|
| 46 |
+
predicted_confidence: float
|
| 47 |
+
all_predictions: List[Dict]
|
| 48 |
+
is_correct: bool
|
| 49 |
+
correct_label: Optional[str]
|
| 50 |
+
reviewer_notes: Optional[str]
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class SessionCreate(BaseModel):
|
| 54 |
+
pass # No parameters needed
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class SessionResponse(BaseModel):
|
| 58 |
+
session_id: str
|
| 59 |
+
created_at: str
|
| 60 |
+
image_count: int
|
| 61 |
+
feedback_count: int
|
| 62 |
+
correct_count: int
|
| 63 |
+
incorrect_count: int
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class StatisticsResponse(BaseModel):
|
| 67 |
+
total_feedback: int
|
| 68 |
+
correct_count: int
|
| 69 |
+
incorrect_count: int
|
| 70 |
+
accuracy: float
|
| 71 |
+
by_label: Dict
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# Session endpoints
|
| 75 |
+
@router.post("/session", response_model=Dict)
|
| 76 |
+
async def create_session():
|
| 77 |
+
"""Create a new feedback session."""
|
| 78 |
+
session_id = feedback_service.create_session()
|
| 79 |
+
session = feedback_service.get_session(session_id)
|
| 80 |
+
return {"session_id": session_id, **session}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
@router.get("/session/{session_id}", response_model=Dict)
|
| 84 |
+
async def get_session(session_id: str):
|
| 85 |
+
"""Get session information."""
|
| 86 |
+
session = feedback_service.get_session(session_id)
|
| 87 |
+
if not session:
|
| 88 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 89 |
+
return {"session_id": session_id, **session}
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@router.get("/sessions", response_model=Dict)
|
| 93 |
+
async def get_all_sessions():
|
| 94 |
+
"""Get all sessions."""
|
| 95 |
+
return feedback_service.get_all_sessions()
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@router.post("/session/{session_id}/image-analyzed")
|
| 99 |
+
async def record_image_analyzed(session_id: str):
|
| 100 |
+
"""Record that an image was analyzed in this session."""
|
| 101 |
+
feedback_service.update_session_stats(session_id, image_analyzed=True)
|
| 102 |
+
return {"status": "ok"}
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# Feedback endpoints
|
| 106 |
+
@router.post("/", response_model=FeedbackResponse)
|
| 107 |
+
async def create_feedback(feedback: FeedbackCreate):
|
| 108 |
+
"""Submit feedback for a prediction."""
|
| 109 |
+
# Convert pydantic models to dicts
|
| 110 |
+
all_predictions_dict = [{"label": p.label, "probability": p.probability} for p in feedback.all_predictions]
|
| 111 |
+
|
| 112 |
+
entry = feedback_service.add_feedback(
|
| 113 |
+
session_id=feedback.session_id,
|
| 114 |
+
filename=feedback.filename,
|
| 115 |
+
file_type=feedback.file_type,
|
| 116 |
+
predicted_label=feedback.predicted_label,
|
| 117 |
+
predicted_confidence=feedback.predicted_confidence,
|
| 118 |
+
all_predictions=all_predictions_dict,
|
| 119 |
+
is_correct=feedback.is_correct,
|
| 120 |
+
correct_label=feedback.correct_label,
|
| 121 |
+
reviewer_notes=feedback.reviewer_notes,
|
| 122 |
+
patient_id=feedback.patient_id,
|
| 123 |
+
image_hash=feedback.image_hash
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
return entry
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
@router.get("/", response_model=List[FeedbackResponse])
|
| 130 |
+
async def get_feedback(session_id: Optional[str] = Query(None)):
|
| 131 |
+
"""Get all feedback, optionally filtered by session."""
|
| 132 |
+
return feedback_service.get_feedback(session_id)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
@router.get("/statistics", response_model=StatisticsResponse)
|
| 136 |
+
async def get_statistics(session_id: Optional[str] = Query(None)):
|
| 137 |
+
"""Get feedback statistics."""
|
| 138 |
+
return feedback_service.get_statistics(session_id)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
@router.get("/export/csv", response_class=PlainTextResponse)
|
| 142 |
+
async def export_csv(session_id: Optional[str] = Query(None)):
|
| 143 |
+
"""Export feedback to CSV format."""
|
| 144 |
+
csv_content = feedback_service.export_to_csv(session_id)
|
| 145 |
+
if not csv_content:
|
| 146 |
+
raise HTTPException(status_code=404, detail="No feedback data to export")
|
| 147 |
+
|
| 148 |
+
return PlainTextResponse(
|
| 149 |
+
content=csv_content,
|
| 150 |
+
media_type="text/csv",
|
| 151 |
+
headers={
|
| 152 |
+
"Content-Disposition": f"attachment; filename=fetalclip_feedback_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
| 153 |
+
}
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
@router.delete("/{feedback_id}")
|
| 158 |
+
async def delete_feedback(feedback_id: str):
|
| 159 |
+
"""Delete a feedback entry."""
|
| 160 |
+
if feedback_service.delete_feedback(feedback_id):
|
| 161 |
+
return {"status": "deleted", "id": feedback_id}
|
| 162 |
+
raise HTTPException(status_code=404, detail="Feedback not found")
|
backend/app/services/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (246 Bytes). View file
|
|
|
backend/app/services/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (259 Bytes). View file
|
|
|
backend/app/services/__pycache__/feedback.cpython-310.pyc
ADDED
|
Binary file (7.51 kB). View file
|
|
|
backend/app/services/__pycache__/model.cpython-310.pyc
ADDED
|
Binary file (9.16 kB). View file
|
|
|
backend/app/services/__pycache__/model.cpython-312.pyc
ADDED
|
Binary file (14.2 kB). View file
|
|
|
backend/app/services/__pycache__/preprocessing.cpython-310.pyc
ADDED
|
Binary file (12.6 kB). View file
|
|
|
backend/app/services/feedback.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Feedback service for storing and managing prediction feedback.
|
| 3 |
+
Stores feedback in a local JSON file for simplicity and privacy.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
import uuid
|
| 8 |
+
import hashlib
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import List, Dict, Optional
|
| 12 |
+
import csv
|
| 13 |
+
import io
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class FeedbackService:
|
| 17 |
+
"""Service for managing prediction feedback with local JSON storage."""
|
| 18 |
+
|
| 19 |
+
def __init__(self, storage_dir: Optional[Path] = None):
|
| 20 |
+
"""Initialize feedback service with storage directory."""
|
| 21 |
+
if storage_dir is None:
|
| 22 |
+
# Default to user's home directory for persistence
|
| 23 |
+
storage_dir = Path.home() / ".fetalclip"
|
| 24 |
+
|
| 25 |
+
self.storage_dir = Path(storage_dir)
|
| 26 |
+
self.storage_dir.mkdir(parents=True, exist_ok=True)
|
| 27 |
+
self.feedback_file = self.storage_dir / "feedback.json"
|
| 28 |
+
self.sessions_file = self.storage_dir / "sessions.json"
|
| 29 |
+
|
| 30 |
+
# Initialize files if they don't exist
|
| 31 |
+
if not self.feedback_file.exists():
|
| 32 |
+
self._save_feedback([])
|
| 33 |
+
if not self.sessions_file.exists():
|
| 34 |
+
self._save_sessions({})
|
| 35 |
+
|
| 36 |
+
def _load_feedback(self) -> List[Dict]:
|
| 37 |
+
"""Load feedback from JSON file."""
|
| 38 |
+
try:
|
| 39 |
+
with open(self.feedback_file, 'r') as f:
|
| 40 |
+
return json.load(f)
|
| 41 |
+
except (json.JSONDecodeError, FileNotFoundError):
|
| 42 |
+
return []
|
| 43 |
+
|
| 44 |
+
def _save_feedback(self, feedback: List[Dict]):
|
| 45 |
+
"""Save feedback to JSON file."""
|
| 46 |
+
with open(self.feedback_file, 'w') as f:
|
| 47 |
+
json.dump(feedback, f, indent=2, default=str)
|
| 48 |
+
|
| 49 |
+
def _load_sessions(self) -> Dict:
|
| 50 |
+
"""Load sessions from JSON file."""
|
| 51 |
+
try:
|
| 52 |
+
with open(self.sessions_file, 'r') as f:
|
| 53 |
+
return json.load(f)
|
| 54 |
+
except (json.JSONDecodeError, FileNotFoundError):
|
| 55 |
+
return {}
|
| 56 |
+
|
| 57 |
+
def _save_sessions(self, sessions: Dict):
|
| 58 |
+
"""Save sessions to JSON file."""
|
| 59 |
+
with open(self.sessions_file, 'w') as f:
|
| 60 |
+
json.dump(sessions, f, indent=2, default=str)
|
| 61 |
+
|
| 62 |
+
def create_session(self) -> str:
|
| 63 |
+
"""Create a new session and return its ID."""
|
| 64 |
+
session_id = str(uuid.uuid4())[:8]
|
| 65 |
+
sessions = self._load_sessions()
|
| 66 |
+
sessions[session_id] = {
|
| 67 |
+
"created_at": datetime.now().isoformat(),
|
| 68 |
+
"image_count": 0,
|
| 69 |
+
"feedback_count": 0,
|
| 70 |
+
"correct_count": 0,
|
| 71 |
+
"incorrect_count": 0
|
| 72 |
+
}
|
| 73 |
+
self._save_sessions(sessions)
|
| 74 |
+
return session_id
|
| 75 |
+
|
| 76 |
+
def get_session(self, session_id: str) -> Optional[Dict]:
|
| 77 |
+
"""Get session info by ID."""
|
| 78 |
+
sessions = self._load_sessions()
|
| 79 |
+
return sessions.get(session_id)
|
| 80 |
+
|
| 81 |
+
def get_all_sessions(self) -> Dict:
|
| 82 |
+
"""Get all sessions."""
|
| 83 |
+
return self._load_sessions()
|
| 84 |
+
|
| 85 |
+
def update_session_stats(self, session_id: str, is_correct: Optional[bool] = None, image_analyzed: bool = False):
|
| 86 |
+
"""Update session statistics."""
|
| 87 |
+
sessions = self._load_sessions()
|
| 88 |
+
if session_id not in sessions:
|
| 89 |
+
sessions[session_id] = {
|
| 90 |
+
"created_at": datetime.now().isoformat(),
|
| 91 |
+
"image_count": 0,
|
| 92 |
+
"feedback_count": 0,
|
| 93 |
+
"correct_count": 0,
|
| 94 |
+
"incorrect_count": 0
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
if image_analyzed:
|
| 98 |
+
sessions[session_id]["image_count"] += 1
|
| 99 |
+
|
| 100 |
+
if is_correct is not None:
|
| 101 |
+
sessions[session_id]["feedback_count"] += 1
|
| 102 |
+
if is_correct:
|
| 103 |
+
sessions[session_id]["correct_count"] += 1
|
| 104 |
+
else:
|
| 105 |
+
sessions[session_id]["incorrect_count"] += 1
|
| 106 |
+
|
| 107 |
+
self._save_sessions(sessions)
|
| 108 |
+
|
| 109 |
+
def add_feedback(
|
| 110 |
+
self,
|
| 111 |
+
session_id: str,
|
| 112 |
+
filename: str,
|
| 113 |
+
file_type: str,
|
| 114 |
+
predicted_label: str,
|
| 115 |
+
predicted_confidence: float,
|
| 116 |
+
all_predictions: List[Dict],
|
| 117 |
+
is_correct: bool,
|
| 118 |
+
correct_label: Optional[str] = None,
|
| 119 |
+
reviewer_notes: Optional[str] = None,
|
| 120 |
+
patient_id: Optional[str] = None,
|
| 121 |
+
image_hash: Optional[str] = None
|
| 122 |
+
) -> Dict:
|
| 123 |
+
"""Add new feedback entry."""
|
| 124 |
+
feedback_id = str(uuid.uuid4())[:12]
|
| 125 |
+
|
| 126 |
+
entry = {
|
| 127 |
+
"id": feedback_id,
|
| 128 |
+
"session_id": session_id,
|
| 129 |
+
"timestamp": datetime.now().isoformat(),
|
| 130 |
+
"filename": filename,
|
| 131 |
+
"file_type": file_type,
|
| 132 |
+
"patient_id": patient_id,
|
| 133 |
+
"image_hash": image_hash,
|
| 134 |
+
"predicted_label": predicted_label,
|
| 135 |
+
"predicted_confidence": round(predicted_confidence, 2),
|
| 136 |
+
"all_predictions": all_predictions,
|
| 137 |
+
"is_correct": is_correct,
|
| 138 |
+
"correct_label": correct_label if not is_correct else None,
|
| 139 |
+
"reviewer_notes": reviewer_notes
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
feedback_list = self._load_feedback()
|
| 143 |
+
feedback_list.append(entry)
|
| 144 |
+
self._save_feedback(feedback_list)
|
| 145 |
+
|
| 146 |
+
# Update session stats
|
| 147 |
+
self.update_session_stats(session_id, is_correct=is_correct)
|
| 148 |
+
|
| 149 |
+
return entry
|
| 150 |
+
|
| 151 |
+
def get_feedback(self, session_id: Optional[str] = None) -> List[Dict]:
|
| 152 |
+
"""Get all feedback, optionally filtered by session."""
|
| 153 |
+
feedback_list = self._load_feedback()
|
| 154 |
+
|
| 155 |
+
if session_id:
|
| 156 |
+
feedback_list = [f for f in feedback_list if f.get("session_id") == session_id]
|
| 157 |
+
|
| 158 |
+
return feedback_list
|
| 159 |
+
|
| 160 |
+
def get_feedback_by_id(self, feedback_id: str) -> Optional[Dict]:
|
| 161 |
+
"""Get single feedback entry by ID."""
|
| 162 |
+
feedback_list = self._load_feedback()
|
| 163 |
+
for entry in feedback_list:
|
| 164 |
+
if entry.get("id") == feedback_id:
|
| 165 |
+
return entry
|
| 166 |
+
return None
|
| 167 |
+
|
| 168 |
+
def delete_feedback(self, feedback_id: str) -> bool:
|
| 169 |
+
"""Delete feedback entry by ID."""
|
| 170 |
+
feedback_list = self._load_feedback()
|
| 171 |
+
original_length = len(feedback_list)
|
| 172 |
+
feedback_list = [f for f in feedback_list if f.get("id") != feedback_id]
|
| 173 |
+
|
| 174 |
+
if len(feedback_list) < original_length:
|
| 175 |
+
self._save_feedback(feedback_list)
|
| 176 |
+
return True
|
| 177 |
+
return False
|
| 178 |
+
|
| 179 |
+
def export_to_csv(self, session_id: Optional[str] = None) -> str:
|
| 180 |
+
"""Export feedback to CSV format."""
|
| 181 |
+
feedback_list = self.get_feedback(session_id)
|
| 182 |
+
|
| 183 |
+
if not feedback_list:
|
| 184 |
+
return ""
|
| 185 |
+
|
| 186 |
+
output = io.StringIO()
|
| 187 |
+
|
| 188 |
+
# Define CSV columns
|
| 189 |
+
fieldnames = [
|
| 190 |
+
"timestamp",
|
| 191 |
+
"session_id",
|
| 192 |
+
"filename",
|
| 193 |
+
"patient_id",
|
| 194 |
+
"file_type",
|
| 195 |
+
"predicted_label",
|
| 196 |
+
"predicted_confidence",
|
| 197 |
+
"is_correct",
|
| 198 |
+
"correct_label",
|
| 199 |
+
"reviewer_notes"
|
| 200 |
+
]
|
| 201 |
+
|
| 202 |
+
writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction='ignore')
|
| 203 |
+
writer.writeheader()
|
| 204 |
+
|
| 205 |
+
for entry in feedback_list:
|
| 206 |
+
# Flatten the entry for CSV
|
| 207 |
+
row = {
|
| 208 |
+
"timestamp": entry.get("timestamp", ""),
|
| 209 |
+
"session_id": entry.get("session_id", ""),
|
| 210 |
+
"filename": entry.get("filename", ""),
|
| 211 |
+
"patient_id": entry.get("patient_id", ""),
|
| 212 |
+
"file_type": entry.get("file_type", ""),
|
| 213 |
+
"predicted_label": entry.get("predicted_label", ""),
|
| 214 |
+
"predicted_confidence": entry.get("predicted_confidence", ""),
|
| 215 |
+
"is_correct": "Yes" if entry.get("is_correct") else "No",
|
| 216 |
+
"correct_label": entry.get("correct_label", ""),
|
| 217 |
+
"reviewer_notes": entry.get("reviewer_notes", "")
|
| 218 |
+
}
|
| 219 |
+
writer.writerow(row)
|
| 220 |
+
|
| 221 |
+
return output.getvalue()
|
| 222 |
+
|
| 223 |
+
def get_statistics(self, session_id: Optional[str] = None) -> Dict:
|
| 224 |
+
"""Get feedback statistics."""
|
| 225 |
+
feedback_list = self.get_feedback(session_id)
|
| 226 |
+
|
| 227 |
+
total = len(feedback_list)
|
| 228 |
+
correct = sum(1 for f in feedback_list if f.get("is_correct"))
|
| 229 |
+
incorrect = total - correct
|
| 230 |
+
|
| 231 |
+
# Count by predicted label
|
| 232 |
+
label_stats = {}
|
| 233 |
+
for entry in feedback_list:
|
| 234 |
+
label = entry.get("predicted_label", "Unknown")
|
| 235 |
+
if label not in label_stats:
|
| 236 |
+
label_stats[label] = {"total": 0, "correct": 0, "incorrect": 0}
|
| 237 |
+
label_stats[label]["total"] += 1
|
| 238 |
+
if entry.get("is_correct"):
|
| 239 |
+
label_stats[label]["correct"] += 1
|
| 240 |
+
else:
|
| 241 |
+
label_stats[label]["incorrect"] += 1
|
| 242 |
+
|
| 243 |
+
return {
|
| 244 |
+
"total_feedback": total,
|
| 245 |
+
"correct_count": correct,
|
| 246 |
+
"incorrect_count": incorrect,
|
| 247 |
+
"accuracy": round(correct / total * 100, 1) if total > 0 else 0,
|
| 248 |
+
"by_label": label_stats
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
@staticmethod
|
| 252 |
+
def compute_image_hash(image_bytes: bytes) -> str:
|
| 253 |
+
"""Compute SHA256 hash of image bytes."""
|
| 254 |
+
return hashlib.sha256(image_bytes).hexdigest()[:16]
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
# Singleton instance
|
| 258 |
+
feedback_service = FeedbackService()
|
frontend/src/App.tsx
CHANGED
|
@@ -1,10 +1,10 @@
|
|
| 1 |
-
import { useState, useEffect } from 'react';
|
| 2 |
import { Scan, Calendar } from 'lucide-react';
|
| 3 |
import { Header } from './components/Header';
|
| 4 |
import { Tabs } from './components/Tabs';
|
| 5 |
import { ClassificationPage } from './pages/ClassificationPage';
|
| 6 |
import { GestationalAgePage } from './pages/GestationalAgePage';
|
| 7 |
-
import { checkHealth } from './lib/api';
|
| 8 |
|
| 9 |
const tabs = [
|
| 10 |
{ id: 'classification', label: 'View Classification', icon: <Scan className="w-4 h-4" /> },
|
|
@@ -14,6 +14,11 @@ const tabs = [
|
|
| 14 |
function App() {
|
| 15 |
const [activeTab, setActiveTab] = useState('classification');
|
| 16 |
const [isConnected, setIsConnected] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
useEffect(() => {
|
| 19 |
const checkConnection = async () => {
|
|
@@ -26,17 +31,35 @@ function App() {
|
|
| 26 |
return () => clearInterval(interval);
|
| 27 |
}, []);
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
return (
|
| 30 |
<div className="h-screen flex flex-col bg-dark-bg overflow-hidden">
|
| 31 |
{/* Header - fixed height */}
|
| 32 |
-
<Header isConnected={isConnected} />
|
| 33 |
|
| 34 |
{/* Tabs - fixed height */}
|
| 35 |
<Tabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} />
|
| 36 |
|
| 37 |
{/* Main content - fills remaining space */}
|
| 38 |
<main className="flex-1 flex min-h-0 overflow-hidden">
|
| 39 |
-
{activeTab === 'classification' && <ClassificationPage />}
|
| 40 |
{activeTab === 'gestational-age' && <GestationalAgePage />}
|
| 41 |
</main>
|
| 42 |
|
|
@@ -54,7 +77,9 @@ function App() {
|
|
| 54 |
🤗 Model Hub
|
| 55 |
</a>
|
| 56 |
<a
|
| 57 |
-
href="https://arxiv.org/abs/2502.14807"
|
|
|
|
|
|
|
| 58 |
className="text-accent-blue hover:text-accent-blue-hover transition-colors font-medium"
|
| 59 |
>
|
| 60 |
📄 Paper
|
|
|
|
| 1 |
+
import { useState, useEffect, useCallback } from 'react';
|
| 2 |
import { Scan, Calendar } from 'lucide-react';
|
| 3 |
import { Header } from './components/Header';
|
| 4 |
import { Tabs } from './components/Tabs';
|
| 5 |
import { ClassificationPage } from './pages/ClassificationPage';
|
| 6 |
import { GestationalAgePage } from './pages/GestationalAgePage';
|
| 7 |
+
import { checkHealth, getFeedbackStats } from './lib/api';
|
| 8 |
|
| 9 |
const tabs = [
|
| 10 |
{ id: 'classification', label: 'View Classification', icon: <Scan className="w-4 h-4" /> },
|
|
|
|
| 14 |
function App() {
|
| 15 |
const [activeTab, setActiveTab] = useState('classification');
|
| 16 |
const [isConnected, setIsConnected] = useState(false);
|
| 17 |
+
const [feedbackStats, setFeedbackStats] = useState<{
|
| 18 |
+
total: number;
|
| 19 |
+
correct: number;
|
| 20 |
+
incorrect: number;
|
| 21 |
+
} | null>(null);
|
| 22 |
|
| 23 |
useEffect(() => {
|
| 24 |
const checkConnection = async () => {
|
|
|
|
| 31 |
return () => clearInterval(interval);
|
| 32 |
}, []);
|
| 33 |
|
| 34 |
+
// Load feedback stats
|
| 35 |
+
const loadFeedbackStats = useCallback(async () => {
|
| 36 |
+
try {
|
| 37 |
+
const stats = await getFeedbackStats();
|
| 38 |
+
setFeedbackStats({
|
| 39 |
+
total: stats.total_feedback,
|
| 40 |
+
correct: stats.correct_count,
|
| 41 |
+
incorrect: stats.incorrect_count
|
| 42 |
+
});
|
| 43 |
+
} catch {
|
| 44 |
+
// Ignore errors - stats are optional
|
| 45 |
+
}
|
| 46 |
+
}, []);
|
| 47 |
+
|
| 48 |
+
useEffect(() => {
|
| 49 |
+
loadFeedbackStats();
|
| 50 |
+
}, [loadFeedbackStats]);
|
| 51 |
+
|
| 52 |
return (
|
| 53 |
<div className="h-screen flex flex-col bg-dark-bg overflow-hidden">
|
| 54 |
{/* Header - fixed height */}
|
| 55 |
+
<Header isConnected={isConnected} feedbackStats={feedbackStats} />
|
| 56 |
|
| 57 |
{/* Tabs - fixed height */}
|
| 58 |
<Tabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} />
|
| 59 |
|
| 60 |
{/* Main content - fills remaining space */}
|
| 61 |
<main className="flex-1 flex min-h-0 overflow-hidden">
|
| 62 |
+
{activeTab === 'classification' && <ClassificationPage onFeedbackUpdate={loadFeedbackStats} />}
|
| 63 |
{activeTab === 'gestational-age' && <GestationalAgePage />}
|
| 64 |
</main>
|
| 65 |
|
|
|
|
| 77 |
🤗 Model Hub
|
| 78 |
</a>
|
| 79 |
<a
|
| 80 |
+
href="https://arxiv.org/abs/2502.14807"
|
| 81 |
+
target="_blank"
|
| 82 |
+
rel="noopener noreferrer"
|
| 83 |
className="text-accent-blue hover:text-accent-blue-hover transition-colors font-medium"
|
| 84 |
>
|
| 85 |
📄 Paper
|
frontend/src/components/FeedbackSection.tsx
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react';
|
| 2 |
+
import { Check, X, ChevronDown, Send, MessageSquare } from 'lucide-react';
|
| 3 |
+
import { FETAL_VIEW_LABELS, submitFeedback, FeedbackCreate, ClassificationResult } from '../lib/api';
|
| 4 |
+
|
| 5 |
+
interface FeedbackSectionProps {
|
| 6 |
+
sessionId: string;
|
| 7 |
+
filename: string;
|
| 8 |
+
fileType: 'dicom' | 'image';
|
| 9 |
+
predictions: ClassificationResult[];
|
| 10 |
+
topPrediction: ClassificationResult | null;
|
| 11 |
+
patientId?: string;
|
| 12 |
+
imageHash?: string;
|
| 13 |
+
onFeedbackSubmitted?: () => void;
|
| 14 |
+
disabled?: boolean;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export function FeedbackSection({
|
| 18 |
+
sessionId,
|
| 19 |
+
filename,
|
| 20 |
+
fileType,
|
| 21 |
+
predictions,
|
| 22 |
+
topPrediction,
|
| 23 |
+
patientId,
|
| 24 |
+
imageHash,
|
| 25 |
+
onFeedbackSubmitted,
|
| 26 |
+
disabled = false,
|
| 27 |
+
}: FeedbackSectionProps) {
|
| 28 |
+
const [feedbackState, setFeedbackState] = useState<'none' | 'correct' | 'incorrect'>('none');
|
| 29 |
+
const [correctLabel, setCorrectLabel] = useState<string>('');
|
| 30 |
+
const [notes, setNotes] = useState<string>('');
|
| 31 |
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
| 32 |
+
const [submitted, setSubmitted] = useState(false);
|
| 33 |
+
const [showDropdown, setShowDropdown] = useState(false);
|
| 34 |
+
|
| 35 |
+
const handleFeedback = async (isCorrect: boolean) => {
|
| 36 |
+
if (disabled || !topPrediction) return;
|
| 37 |
+
|
| 38 |
+
if (isCorrect) {
|
| 39 |
+
// Submit immediately for correct predictions
|
| 40 |
+
setFeedbackState('correct');
|
| 41 |
+
await submitFeedbackData(true);
|
| 42 |
+
} else {
|
| 43 |
+
// Show correction form for incorrect predictions
|
| 44 |
+
setFeedbackState('incorrect');
|
| 45 |
+
setShowDropdown(true);
|
| 46 |
+
}
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
const submitFeedbackData = async (isCorrect: boolean, overrideLabel?: string, overrideNotes?: string) => {
|
| 50 |
+
if (!topPrediction || isSubmitting) return;
|
| 51 |
+
|
| 52 |
+
setIsSubmitting(true);
|
| 53 |
+
try {
|
| 54 |
+
const feedbackData: FeedbackCreate = {
|
| 55 |
+
session_id: sessionId,
|
| 56 |
+
filename,
|
| 57 |
+
file_type: fileType,
|
| 58 |
+
predicted_label: topPrediction.label,
|
| 59 |
+
predicted_confidence: topPrediction.confidence,
|
| 60 |
+
all_predictions: predictions.map(p => ({
|
| 61 |
+
label: p.label,
|
| 62 |
+
probability: p.confidence
|
| 63 |
+
})),
|
| 64 |
+
is_correct: isCorrect,
|
| 65 |
+
correct_label: overrideLabel || correctLabel || undefined,
|
| 66 |
+
reviewer_notes: overrideNotes || notes || undefined,
|
| 67 |
+
patient_id: patientId,
|
| 68 |
+
image_hash: imageHash,
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
await submitFeedback(feedbackData);
|
| 72 |
+
setSubmitted(true);
|
| 73 |
+
onFeedbackSubmitted?.();
|
| 74 |
+
} catch (error) {
|
| 75 |
+
console.error('Failed to submit feedback:', error);
|
| 76 |
+
} finally {
|
| 77 |
+
setIsSubmitting(false);
|
| 78 |
+
}
|
| 79 |
+
};
|
| 80 |
+
|
| 81 |
+
const handleSubmitCorrection = async () => {
|
| 82 |
+
if (!correctLabel) return;
|
| 83 |
+
await submitFeedbackData(false, correctLabel, notes);
|
| 84 |
+
};
|
| 85 |
+
|
| 86 |
+
if (submitted) {
|
| 87 |
+
return (
|
| 88 |
+
<div className="bg-primary/10 border border-primary/30 rounded-lg p-3">
|
| 89 |
+
<div className="flex items-center gap-2 text-primary">
|
| 90 |
+
<Check className="w-4 h-4" />
|
| 91 |
+
<span className="text-sm font-medium">Feedback recorded</span>
|
| 92 |
+
{feedbackState === 'correct' ? (
|
| 93 |
+
<span className="text-xs text-text-muted ml-auto">Confirmed correct</span>
|
| 94 |
+
) : (
|
| 95 |
+
<span className="text-xs text-text-muted ml-auto">Corrected to: {correctLabel}</span>
|
| 96 |
+
)}
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
if (disabled || !topPrediction) {
|
| 103 |
+
return (
|
| 104 |
+
<div className="bg-surface-secondary rounded-lg p-3 opacity-50">
|
| 105 |
+
<div className="flex items-center gap-2 text-text-muted">
|
| 106 |
+
<MessageSquare className="w-4 h-4" />
|
| 107 |
+
<span className="text-sm">Run classification to provide feedback</span>
|
| 108 |
+
</div>
|
| 109 |
+
</div>
|
| 110 |
+
);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
return (
|
| 114 |
+
<div className="bg-surface-secondary rounded-lg p-3 space-y-3">
|
| 115 |
+
{/* Feedback header */}
|
| 116 |
+
<div className="flex items-center justify-between">
|
| 117 |
+
<div className="flex items-center gap-2 text-text-muted">
|
| 118 |
+
<MessageSquare className="w-4 h-4" />
|
| 119 |
+
<span className="text-sm font-medium">Is this prediction correct?</span>
|
| 120 |
+
</div>
|
| 121 |
+
|
| 122 |
+
{feedbackState === 'none' && (
|
| 123 |
+
<div className="flex gap-2">
|
| 124 |
+
<button
|
| 125 |
+
onClick={() => handleFeedback(true)}
|
| 126 |
+
className="flex items-center gap-1.5 px-3 py-1.5 bg-green-500/20 hover:bg-green-500/30 text-green-600 rounded-md text-sm font-medium transition-colors"
|
| 127 |
+
>
|
| 128 |
+
<Check className="w-3.5 h-3.5" />
|
| 129 |
+
Correct
|
| 130 |
+
</button>
|
| 131 |
+
<button
|
| 132 |
+
onClick={() => handleFeedback(false)}
|
| 133 |
+
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-500/20 hover:bg-red-500/30 text-red-600 rounded-md text-sm font-medium transition-colors"
|
| 134 |
+
>
|
| 135 |
+
<X className="w-3.5 h-3.5" />
|
| 136 |
+
Incorrect
|
| 137 |
+
</button>
|
| 138 |
+
</div>
|
| 139 |
+
)}
|
| 140 |
+
</div>
|
| 141 |
+
|
| 142 |
+
{/* Correction form */}
|
| 143 |
+
{feedbackState === 'incorrect' && (
|
| 144 |
+
<div className="space-y-3 pt-2 border-t border-border">
|
| 145 |
+
{/* Label dropdown */}
|
| 146 |
+
<div className="relative">
|
| 147 |
+
<label className="block text-xs font-medium text-gray-600 mb-1.5">Correct Label</label>
|
| 148 |
+
<button
|
| 149 |
+
onClick={() => setShowDropdown(!showDropdown)}
|
| 150 |
+
className="w-full flex items-center justify-between px-3 py-2.5 bg-white border border-gray-300 rounded-lg text-sm text-left hover:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all"
|
| 151 |
+
>
|
| 152 |
+
<span className={correctLabel ? 'text-gray-800 font-medium' : 'text-gray-400'}>
|
| 153 |
+
{correctLabel || 'Select correct view...'}
|
| 154 |
+
</span>
|
| 155 |
+
<ChevronDown className={`w-4 h-4 text-gray-400 transition-transform ${showDropdown ? 'rotate-180' : ''}`} />
|
| 156 |
+
</button>
|
| 157 |
+
|
| 158 |
+
{showDropdown && (
|
| 159 |
+
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-xl max-h-60 overflow-y-auto">
|
| 160 |
+
{FETAL_VIEW_LABELS.map((label) => (
|
| 161 |
+
<button
|
| 162 |
+
key={label}
|
| 163 |
+
onClick={() => {
|
| 164 |
+
setCorrectLabel(label);
|
| 165 |
+
setShowDropdown(false);
|
| 166 |
+
}}
|
| 167 |
+
className={`w-full px-3 py-2.5 text-sm text-left transition-colors border-b border-gray-100 last:border-b-0
|
| 168 |
+
${label === correctLabel ? 'bg-primary/10 text-primary font-medium' : 'text-gray-700 hover:bg-gray-50'}
|
| 169 |
+
${label === topPrediction?.label ? 'opacity-50 bg-gray-50' : ''}`}
|
| 170 |
+
>
|
| 171 |
+
{label}
|
| 172 |
+
{label === topPrediction?.label && (
|
| 173 |
+
<span className="text-xs text-gray-400 ml-2">(predicted)</span>
|
| 174 |
+
)}
|
| 175 |
+
</button>
|
| 176 |
+
))}
|
| 177 |
+
</div>
|
| 178 |
+
)}
|
| 179 |
+
</div>
|
| 180 |
+
|
| 181 |
+
{/* Notes input */}
|
| 182 |
+
<div>
|
| 183 |
+
<label className="block text-xs font-medium text-gray-600 mb-1.5">Notes (optional)</label>
|
| 184 |
+
<textarea
|
| 185 |
+
value={notes}
|
| 186 |
+
onChange={(e) => setNotes(e.target.value)}
|
| 187 |
+
placeholder="Any additional notes about this case..."
|
| 188 |
+
className="w-full px-3 py-2.5 bg-white border border-gray-300 rounded-lg text-sm text-gray-800 resize-none h-16 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/50 transition-all"
|
| 189 |
+
/>
|
| 190 |
+
</div>
|
| 191 |
+
|
| 192 |
+
{/* Submit button */}
|
| 193 |
+
<div className="flex gap-2">
|
| 194 |
+
<button
|
| 195 |
+
onClick={() => {
|
| 196 |
+
setFeedbackState('none');
|
| 197 |
+
setCorrectLabel('');
|
| 198 |
+
setNotes('');
|
| 199 |
+
}}
|
| 200 |
+
className="px-3 py-1.5 text-sm text-text-muted hover:text-text-primary transition-colors"
|
| 201 |
+
>
|
| 202 |
+
Cancel
|
| 203 |
+
</button>
|
| 204 |
+
<button
|
| 205 |
+
onClick={handleSubmitCorrection}
|
| 206 |
+
disabled={!correctLabel || isSubmitting}
|
| 207 |
+
className="flex-1 flex items-center justify-center gap-2 px-3 py-1.5 bg-primary hover:bg-primary-hover text-white rounded-md text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
| 208 |
+
>
|
| 209 |
+
{isSubmitting ? (
|
| 210 |
+
<span>Submitting...</span>
|
| 211 |
+
) : (
|
| 212 |
+
<>
|
| 213 |
+
<Send className="w-3.5 h-3.5" />
|
| 214 |
+
<span>Submit Correction</span>
|
| 215 |
+
</>
|
| 216 |
+
)}
|
| 217 |
+
</button>
|
| 218 |
+
</div>
|
| 219 |
+
</div>
|
| 220 |
+
)}
|
| 221 |
+
</div>
|
| 222 |
+
);
|
| 223 |
+
}
|
frontend/src/components/Header.tsx
CHANGED
|
@@ -1,10 +1,15 @@
|
|
| 1 |
-
import { Zap } from 'lucide-react';
|
| 2 |
|
| 3 |
interface HeaderProps {
|
| 4 |
isConnected: boolean;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
}
|
| 6 |
|
| 7 |
-
export function Header({ isConnected }: HeaderProps) {
|
| 8 |
return (
|
| 9 |
<header className="bg-white border-b border-dark-border px-8 py-4 shadow-sm">
|
| 10 |
<div className="flex items-center justify-between">
|
|
@@ -22,6 +27,23 @@ export function Header({ isConnected }: HeaderProps) {
|
|
| 22 |
</div>
|
| 23 |
</div>
|
| 24 |
<div className="flex items-center gap-5">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-dark-input border border-dark-border">
|
| 26 |
<div
|
| 27 |
className={`w-2 h-2 rounded-full transition-colors ${
|
|
|
|
| 1 |
+
import { Zap, Check, X, MessageSquare } from 'lucide-react';
|
| 2 |
|
| 3 |
interface HeaderProps {
|
| 4 |
isConnected: boolean;
|
| 5 |
+
feedbackStats?: {
|
| 6 |
+
total: number;
|
| 7 |
+
correct: number;
|
| 8 |
+
incorrect: number;
|
| 9 |
+
} | null;
|
| 10 |
}
|
| 11 |
|
| 12 |
+
export function Header({ isConnected, feedbackStats }: HeaderProps) {
|
| 13 |
return (
|
| 14 |
<header className="bg-white border-b border-dark-border px-8 py-4 shadow-sm">
|
| 15 |
<div className="flex items-center justify-between">
|
|
|
|
| 27 |
</div>
|
| 28 |
</div>
|
| 29 |
<div className="flex items-center gap-5">
|
| 30 |
+
{/* Feedback counter */}
|
| 31 |
+
{feedbackStats && feedbackStats.total > 0 && (
|
| 32 |
+
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-dark-input border border-dark-border">
|
| 33 |
+
<MessageSquare className="w-3.5 h-3.5 text-accent-blue" />
|
| 34 |
+
<span className="text-xs text-text-secondary">
|
| 35 |
+
<span className="text-green-600 font-medium">{feedbackStats.correct}</span>
|
| 36 |
+
<span className="text-text-muted mx-1">/</span>
|
| 37 |
+
<span className="text-red-500 font-medium">{feedbackStats.incorrect}</span>
|
| 38 |
+
</span>
|
| 39 |
+
{feedbackStats.total >= 5 && (
|
| 40 |
+
<span className="text-xs text-text-muted">
|
| 41 |
+
({Math.round(feedbackStats.correct / feedbackStats.total * 100)}%)
|
| 42 |
+
</span>
|
| 43 |
+
)}
|
| 44 |
+
</div>
|
| 45 |
+
)}
|
| 46 |
+
|
| 47 |
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-dark-input border border-dark-border">
|
| 48 |
<div
|
| 49 |
className={`w-2 h-2 rounded-full transition-colors ${
|
frontend/src/components/ResultsCard.tsx
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
|
|
| 1 |
import { formatLabel } from '../lib/utils';
|
| 2 |
import type { ClassificationResult } from '../lib/api';
|
| 3 |
|
| 4 |
interface ResultsCardProps {
|
| 5 |
results: ClassificationResult[] | null;
|
| 6 |
isLoading: boolean;
|
|
|
|
| 7 |
}
|
| 8 |
|
| 9 |
-
export function ResultsCard({ results, isLoading }: ResultsCardProps) {
|
| 10 |
if (isLoading) {
|
| 11 |
return (
|
| 12 |
<div className="space-y-3 animate-pulse">
|
|
@@ -38,21 +40,42 @@ export function ResultsCard({ results, isLoading }: ResultsCardProps) {
|
|
| 38 |
}
|
| 39 |
|
| 40 |
const topResult = results[0];
|
|
|
|
| 41 |
|
| 42 |
return (
|
| 43 |
<div className="space-y-3 animate-fade-in">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
{/* Top Prediction */}
|
| 45 |
-
<div className=
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
<p className="text-[10px] uppercase tracking-wider text-text-muted mb-1">
|
| 47 |
Top Prediction
|
| 48 |
</p>
|
| 49 |
<div className="flex items-baseline justify-between">
|
| 50 |
-
<span className=
|
| 51 |
{formatLabel(topResult.label)}
|
| 52 |
</span>
|
| 53 |
-
<
|
| 54 |
-
{
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
</div>
|
| 57 |
</div>
|
| 58 |
|
|
|
|
| 1 |
+
import { AlertTriangle } from 'lucide-react';
|
| 2 |
import { formatLabel } from '../lib/utils';
|
| 3 |
import type { ClassificationResult } from '../lib/api';
|
| 4 |
|
| 5 |
interface ResultsCardProps {
|
| 6 |
results: ClassificationResult[] | null;
|
| 7 |
isLoading: boolean;
|
| 8 |
+
confidenceThreshold?: number; // Default 70%
|
| 9 |
}
|
| 10 |
|
| 11 |
+
export function ResultsCard({ results, isLoading, confidenceThreshold = 70 }: ResultsCardProps) {
|
| 12 |
if (isLoading) {
|
| 13 |
return (
|
| 14 |
<div className="space-y-3 animate-pulse">
|
|
|
|
| 40 |
}
|
| 41 |
|
| 42 |
const topResult = results[0];
|
| 43 |
+
const isLowConfidence = topResult.confidence < confidenceThreshold;
|
| 44 |
|
| 45 |
return (
|
| 46 |
<div className="space-y-3 animate-fade-in">
|
| 47 |
+
{/* Low Confidence Warning */}
|
| 48 |
+
{isLowConfidence && (
|
| 49 |
+
<div className="flex items-start gap-2 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
| 50 |
+
<AlertTriangle className="w-4 h-4 text-yellow-600 flex-shrink-0 mt-0.5" />
|
| 51 |
+
<div className="text-xs text-yellow-800">
|
| 52 |
+
<span className="font-semibold">Low Confidence:</span> The model is uncertain about this prediction.
|
| 53 |
+
Please verify manually and provide feedback.
|
| 54 |
+
</div>
|
| 55 |
+
</div>
|
| 56 |
+
)}
|
| 57 |
+
|
| 58 |
{/* Top Prediction */}
|
| 59 |
+
<div className={`bg-gradient-to-r ${
|
| 60 |
+
isLowConfidence
|
| 61 |
+
? 'from-yellow-50 to-yellow-50/50 border-yellow-200'
|
| 62 |
+
: 'from-nvidia-green/10 to-nvidia-green/5 border-nvidia-green/20'
|
| 63 |
+
} border rounded-xl p-4 shadow-card`}>
|
| 64 |
<p className="text-[10px] uppercase tracking-wider text-text-muted mb-1">
|
| 65 |
Top Prediction
|
| 66 |
</p>
|
| 67 |
<div className="flex items-baseline justify-between">
|
| 68 |
+
<span className={`text-xl font-bold ${isLowConfidence ? 'text-yellow-700' : 'text-nvidia-green'}`}>
|
| 69 |
{formatLabel(topResult.label)}
|
| 70 |
</span>
|
| 71 |
+
<div className="flex items-center gap-1.5">
|
| 72 |
+
{isLowConfidence && <AlertTriangle className="w-4 h-4 text-yellow-600" />}
|
| 73 |
+
<span className={`text-lg font-semibold ${
|
| 74 |
+
isLowConfidence ? 'text-yellow-700' : 'text-text-primary'
|
| 75 |
+
}`}>
|
| 76 |
+
{topResult.confidence.toFixed(1)}%
|
| 77 |
+
</span>
|
| 78 |
+
</div>
|
| 79 |
</div>
|
| 80 |
</div>
|
| 81 |
|
frontend/src/components/SessionHistory.tsx
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from 'react';
|
| 2 |
+
import { History, Check, X, AlertTriangle, Download, ChevronDown, ChevronUp } from 'lucide-react';
|
| 3 |
+
import { getFeedback, exportFeedbackCSV, FeedbackEntry, FeedbackStats, getFeedbackStats } from '../lib/api';
|
| 4 |
+
|
| 5 |
+
interface SessionHistoryProps {
|
| 6 |
+
sessionId: string;
|
| 7 |
+
onSelectEntry?: (entry: FeedbackEntry) => void;
|
| 8 |
+
refreshTrigger?: number; // Increment to trigger refresh
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export function SessionHistory({ sessionId, onSelectEntry, refreshTrigger }: SessionHistoryProps) {
|
| 12 |
+
const [entries, setEntries] = useState<FeedbackEntry[]>([]);
|
| 13 |
+
const [stats, setStats] = useState<FeedbackStats | null>(null);
|
| 14 |
+
const [isExpanded, setIsExpanded] = useState(false);
|
| 15 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 16 |
+
|
| 17 |
+
useEffect(() => {
|
| 18 |
+
loadData();
|
| 19 |
+
}, [sessionId, refreshTrigger]);
|
| 20 |
+
|
| 21 |
+
const loadData = async () => {
|
| 22 |
+
if (!sessionId) return;
|
| 23 |
+
|
| 24 |
+
setIsLoading(true);
|
| 25 |
+
try {
|
| 26 |
+
const [feedbackData, statsData] = await Promise.all([
|
| 27 |
+
getFeedback(sessionId),
|
| 28 |
+
getFeedbackStats(sessionId)
|
| 29 |
+
]);
|
| 30 |
+
setEntries(feedbackData);
|
| 31 |
+
setStats(statsData);
|
| 32 |
+
} catch (error) {
|
| 33 |
+
console.error('Failed to load session data:', error);
|
| 34 |
+
} finally {
|
| 35 |
+
setIsLoading(false);
|
| 36 |
+
}
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
const handleExport = async () => {
|
| 40 |
+
try {
|
| 41 |
+
await exportFeedbackCSV(sessionId);
|
| 42 |
+
} catch (error) {
|
| 43 |
+
console.error('Failed to export:', error);
|
| 44 |
+
}
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
if (entries.length === 0 && !isLoading) {
|
| 48 |
+
return null; // Don't show anything if no entries
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
return (
|
| 52 |
+
<div className="bg-surface-secondary rounded-lg overflow-hidden">
|
| 53 |
+
{/* Header - always visible */}
|
| 54 |
+
<button
|
| 55 |
+
onClick={() => setIsExpanded(!isExpanded)}
|
| 56 |
+
className="w-full flex items-center justify-between px-3 py-2.5 hover:bg-surface-secondary/80 transition-colors"
|
| 57 |
+
>
|
| 58 |
+
<div className="flex items-center gap-2">
|
| 59 |
+
<History className="w-4 h-4 text-accent-blue" />
|
| 60 |
+
<span className="text-sm font-medium text-text-primary">Session History</span>
|
| 61 |
+
{stats && (
|
| 62 |
+
<span className="text-xs text-text-muted">
|
| 63 |
+
({stats.total_feedback} reviewed)
|
| 64 |
+
</span>
|
| 65 |
+
)}
|
| 66 |
+
</div>
|
| 67 |
+
|
| 68 |
+
<div className="flex items-center gap-3">
|
| 69 |
+
{stats && stats.total_feedback > 0 && (
|
| 70 |
+
<div className="flex items-center gap-2 text-xs">
|
| 71 |
+
<span className="flex items-center gap-1 text-green-600">
|
| 72 |
+
<Check className="w-3 h-3" />
|
| 73 |
+
{stats.correct_count}
|
| 74 |
+
</span>
|
| 75 |
+
<span className="flex items-center gap-1 text-red-500">
|
| 76 |
+
<X className="w-3 h-3" />
|
| 77 |
+
{stats.incorrect_count}
|
| 78 |
+
</span>
|
| 79 |
+
<span className="text-text-muted">
|
| 80 |
+
({stats.accuracy}% acc)
|
| 81 |
+
</span>
|
| 82 |
+
</div>
|
| 83 |
+
)}
|
| 84 |
+
{isExpanded ? (
|
| 85 |
+
<ChevronUp className="w-4 h-4 text-text-muted" />
|
| 86 |
+
) : (
|
| 87 |
+
<ChevronDown className="w-4 h-4 text-text-muted" />
|
| 88 |
+
)}
|
| 89 |
+
</div>
|
| 90 |
+
</button>
|
| 91 |
+
|
| 92 |
+
{/* Expanded content */}
|
| 93 |
+
{isExpanded && (
|
| 94 |
+
<div className="border-t border-border">
|
| 95 |
+
{/* Export button */}
|
| 96 |
+
{entries.length > 0 && (
|
| 97 |
+
<div className="px-3 py-2 border-b border-border">
|
| 98 |
+
<button
|
| 99 |
+
onClick={handleExport}
|
| 100 |
+
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs bg-accent-blue/10 hover:bg-accent-blue/20 text-accent-blue rounded-md transition-colors"
|
| 101 |
+
>
|
| 102 |
+
<Download className="w-3.5 h-3.5" />
|
| 103 |
+
Export to CSV
|
| 104 |
+
</button>
|
| 105 |
+
</div>
|
| 106 |
+
)}
|
| 107 |
+
|
| 108 |
+
{/* Entry list */}
|
| 109 |
+
<div className="max-h-48 overflow-y-auto">
|
| 110 |
+
{isLoading ? (
|
| 111 |
+
<div className="px-3 py-4 text-center text-sm text-text-muted">
|
| 112 |
+
Loading...
|
| 113 |
+
</div>
|
| 114 |
+
) : entries.length === 0 ? (
|
| 115 |
+
<div className="px-3 py-4 text-center text-sm text-text-muted">
|
| 116 |
+
No feedback recorded yet
|
| 117 |
+
</div>
|
| 118 |
+
) : (
|
| 119 |
+
entries.map((entry) => (
|
| 120 |
+
<div
|
| 121 |
+
key={entry.id}
|
| 122 |
+
onClick={() => onSelectEntry?.(entry)}
|
| 123 |
+
className={`px-3 py-2 border-b border-border/50 last:border-b-0 hover:bg-surface cursor-pointer transition-colors`}
|
| 124 |
+
>
|
| 125 |
+
<div className="flex items-start justify-between gap-2">
|
| 126 |
+
<div className="flex-1 min-w-0">
|
| 127 |
+
<div className="flex items-center gap-2">
|
| 128 |
+
<span className="text-xs font-mono text-text-muted truncate max-w-[120px]">
|
| 129 |
+
{entry.filename}
|
| 130 |
+
</span>
|
| 131 |
+
<span className={`flex items-center gap-0.5 text-xs ${entry.is_correct ? 'text-green-600' : 'text-red-500'}`}>
|
| 132 |
+
{entry.is_correct ? (
|
| 133 |
+
<Check className="w-3 h-3" />
|
| 134 |
+
) : (
|
| 135 |
+
<X className="w-3 h-3" />
|
| 136 |
+
)}
|
| 137 |
+
</span>
|
| 138 |
+
</div>
|
| 139 |
+
<div className="text-xs text-text-primary mt-0.5">
|
| 140 |
+
{entry.is_correct ? (
|
| 141 |
+
entry.predicted_label
|
| 142 |
+
) : (
|
| 143 |
+
<span>
|
| 144 |
+
<span className="line-through text-text-muted">{entry.predicted_label}</span>
|
| 145 |
+
{' → '}
|
| 146 |
+
<span className="text-primary">{entry.correct_label}</span>
|
| 147 |
+
</span>
|
| 148 |
+
)}
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
<div className="flex flex-col items-end">
|
| 152 |
+
<span className={`text-xs font-medium ${
|
| 153 |
+
entry.predicted_confidence >= 70 ? 'text-green-600' :
|
| 154 |
+
entry.predicted_confidence >= 50 ? 'text-yellow-600' : 'text-red-500'
|
| 155 |
+
}`}>
|
| 156 |
+
{entry.predicted_confidence}%
|
| 157 |
+
</span>
|
| 158 |
+
{entry.predicted_confidence < 70 && (
|
| 159 |
+
<AlertTriangle className="w-3 h-3 text-yellow-600" />
|
| 160 |
+
)}
|
| 161 |
+
</div>
|
| 162 |
+
</div>
|
| 163 |
+
</div>
|
| 164 |
+
))
|
| 165 |
+
)}
|
| 166 |
+
</div>
|
| 167 |
+
</div>
|
| 168 |
+
)}
|
| 169 |
+
</div>
|
| 170 |
+
);
|
| 171 |
+
}
|
frontend/src/components/index.ts
CHANGED
|
@@ -9,3 +9,5 @@ export { GAResultsCard } from './GAResultsCard';
|
|
| 9 |
export { Panel } from './Panel';
|
| 10 |
export { Tabs } from './Tabs';
|
| 11 |
export { PreprocessingBadge } from './PreprocessingBadge';
|
|
|
|
|
|
|
|
|
| 9 |
export { Panel } from './Panel';
|
| 10 |
export { Tabs } from './Tabs';
|
| 11 |
export { PreprocessingBadge } from './PreprocessingBadge';
|
| 12 |
+
export { FeedbackSection } from './FeedbackSection';
|
| 13 |
+
export { SessionHistory } from './SessionHistory';
|
frontend/src/lib/api.ts
CHANGED
|
@@ -1,7 +1,4 @@
|
|
| 1 |
-
|
| 2 |
-
const API_BASE = typeof window !== 'undefined' && window.location.hostname === 'localhost'
|
| 3 |
-
? 'http://localhost:8000'
|
| 4 |
-
: '';
|
| 5 |
|
| 6 |
export interface ClassificationResult {
|
| 7 |
label: string;
|
|
@@ -119,3 +116,187 @@ export async function checkHealth(): Promise<boolean> {
|
|
| 119 |
return false;
|
| 120 |
}
|
| 121 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const API_BASE = 'http://localhost:8000';
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
export interface ClassificationResult {
|
| 4 |
label: string;
|
|
|
|
| 116 |
return false;
|
| 117 |
}
|
| 118 |
}
|
| 119 |
+
|
| 120 |
+
// ==================== Feedback API ====================
|
| 121 |
+
|
| 122 |
+
export interface PredictionDetail {
|
| 123 |
+
label: string;
|
| 124 |
+
probability: number;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
export interface FeedbackEntry {
|
| 128 |
+
id: string;
|
| 129 |
+
session_id: string;
|
| 130 |
+
timestamp: string;
|
| 131 |
+
filename: string;
|
| 132 |
+
file_type: string;
|
| 133 |
+
patient_id: string | null;
|
| 134 |
+
image_hash: string | null;
|
| 135 |
+
predicted_label: string;
|
| 136 |
+
predicted_confidence: number;
|
| 137 |
+
all_predictions: PredictionDetail[];
|
| 138 |
+
is_correct: boolean;
|
| 139 |
+
correct_label: string | null;
|
| 140 |
+
reviewer_notes: string | null;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
export interface SessionInfo {
|
| 144 |
+
session_id: string;
|
| 145 |
+
created_at: string;
|
| 146 |
+
image_count: number;
|
| 147 |
+
feedback_count: number;
|
| 148 |
+
correct_count: number;
|
| 149 |
+
incorrect_count: number;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
export interface FeedbackStats {
|
| 153 |
+
total_feedback: number;
|
| 154 |
+
correct_count: number;
|
| 155 |
+
incorrect_count: number;
|
| 156 |
+
accuracy: number;
|
| 157 |
+
by_label: Record<string, { total: number; correct: number; incorrect: number }>;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
export interface FeedbackCreate {
|
| 161 |
+
session_id: string;
|
| 162 |
+
filename: string;
|
| 163 |
+
file_type: string;
|
| 164 |
+
predicted_label: string;
|
| 165 |
+
predicted_confidence: number;
|
| 166 |
+
all_predictions: PredictionDetail[];
|
| 167 |
+
is_correct: boolean;
|
| 168 |
+
correct_label?: string;
|
| 169 |
+
reviewer_notes?: string;
|
| 170 |
+
patient_id?: string;
|
| 171 |
+
image_hash?: string;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
// Create a new session
|
| 175 |
+
export async function createSession(): Promise<SessionInfo> {
|
| 176 |
+
const response = await fetch(`${API_BASE}/api/v1/feedback/session`, {
|
| 177 |
+
method: 'POST',
|
| 178 |
+
});
|
| 179 |
+
|
| 180 |
+
if (!response.ok) {
|
| 181 |
+
throw new Error('Failed to create session');
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
return response.json();
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
// Get session info
|
| 188 |
+
export async function getSession(sessionId: string): Promise<SessionInfo> {
|
| 189 |
+
const response = await fetch(`${API_BASE}/api/v1/feedback/session/${sessionId}`);
|
| 190 |
+
|
| 191 |
+
if (!response.ok) {
|
| 192 |
+
throw new Error('Session not found');
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
return response.json();
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
// Record image analyzed
|
| 199 |
+
export async function recordImageAnalyzed(sessionId: string): Promise<void> {
|
| 200 |
+
await fetch(`${API_BASE}/api/v1/feedback/session/${sessionId}/image-analyzed`, {
|
| 201 |
+
method: 'POST',
|
| 202 |
+
});
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
// Submit feedback
|
| 206 |
+
export async function submitFeedback(feedback: FeedbackCreate): Promise<FeedbackEntry> {
|
| 207 |
+
const response = await fetch(`${API_BASE}/api/v1/feedback/`, {
|
| 208 |
+
method: 'POST',
|
| 209 |
+
headers: {
|
| 210 |
+
'Content-Type': 'application/json',
|
| 211 |
+
},
|
| 212 |
+
body: JSON.stringify(feedback),
|
| 213 |
+
});
|
| 214 |
+
|
| 215 |
+
if (!response.ok) {
|
| 216 |
+
const error = await response.json();
|
| 217 |
+
throw new Error(error.detail || 'Failed to submit feedback');
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
return response.json();
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
// Get all feedback
|
| 224 |
+
export async function getFeedback(sessionId?: string): Promise<FeedbackEntry[]> {
|
| 225 |
+
const url = sessionId
|
| 226 |
+
? `${API_BASE}/api/v1/feedback/?session_id=${sessionId}`
|
| 227 |
+
: `${API_BASE}/api/v1/feedback/`;
|
| 228 |
+
|
| 229 |
+
const response = await fetch(url);
|
| 230 |
+
|
| 231 |
+
if (!response.ok) {
|
| 232 |
+
throw new Error('Failed to get feedback');
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
return response.json();
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
// Get feedback statistics
|
| 239 |
+
export async function getFeedbackStats(sessionId?: string): Promise<FeedbackStats> {
|
| 240 |
+
const url = sessionId
|
| 241 |
+
? `${API_BASE}/api/v1/feedback/statistics?session_id=${sessionId}`
|
| 242 |
+
: `${API_BASE}/api/v1/feedback/statistics`;
|
| 243 |
+
|
| 244 |
+
const response = await fetch(url);
|
| 245 |
+
|
| 246 |
+
if (!response.ok) {
|
| 247 |
+
throw new Error('Failed to get statistics');
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
return response.json();
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
// Export feedback as CSV
|
| 254 |
+
export async function exportFeedbackCSV(sessionId?: string): Promise<void> {
|
| 255 |
+
const url = sessionId
|
| 256 |
+
? `${API_BASE}/api/v1/feedback/export/csv?session_id=${sessionId}`
|
| 257 |
+
: `${API_BASE}/api/v1/feedback/export/csv`;
|
| 258 |
+
|
| 259 |
+
const response = await fetch(url);
|
| 260 |
+
|
| 261 |
+
if (!response.ok) {
|
| 262 |
+
throw new Error('No feedback data to export');
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
const blob = await response.blob();
|
| 266 |
+
const downloadUrl = window.URL.createObjectURL(blob);
|
| 267 |
+
const a = document.createElement('a');
|
| 268 |
+
a.href = downloadUrl;
|
| 269 |
+
a.download = `fetalclip_feedback_${new Date().toISOString().slice(0, 10)}.csv`;
|
| 270 |
+
document.body.appendChild(a);
|
| 271 |
+
a.click();
|
| 272 |
+
a.remove();
|
| 273 |
+
window.URL.revokeObjectURL(downloadUrl);
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
// Delete feedback entry
|
| 277 |
+
export async function deleteFeedback(feedbackId: string): Promise<void> {
|
| 278 |
+
const response = await fetch(`${API_BASE}/api/v1/feedback/${feedbackId}`, {
|
| 279 |
+
method: 'DELETE',
|
| 280 |
+
});
|
| 281 |
+
|
| 282 |
+
if (!response.ok) {
|
| 283 |
+
throw new Error('Failed to delete feedback');
|
| 284 |
+
}
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
// List of all fetal view labels
|
| 288 |
+
export const FETAL_VIEW_LABELS = [
|
| 289 |
+
"Fetal abdomen",
|
| 290 |
+
"Fetal brain (Transventricular)",
|
| 291 |
+
"Fetal brain (Transcerebellar)",
|
| 292 |
+
"Fetal femur",
|
| 293 |
+
"Fetal thorax (4-chamber view)",
|
| 294 |
+
"Fetal face (Lips)",
|
| 295 |
+
"Fetal brain (Transthalamic)",
|
| 296 |
+
"Other",
|
| 297 |
+
"Maternal anatomy",
|
| 298 |
+
"Fetal face (Profile)",
|
| 299 |
+
"Fetal thorax (3-vessel view)",
|
| 300 |
+
"Fetal thorax (LVOT)",
|
| 301 |
+
"Fetal thorax (RVOT)"
|
| 302 |
+
];
|
frontend/src/pages/ClassificationPage.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { useState, useCallback } from 'react';
|
| 2 |
import { Search, ChevronLeft, ChevronRight, FolderOpen } from 'lucide-react';
|
| 3 |
import { Panel } from '../components/Panel';
|
| 4 |
import { FileUpload } from '../components/FileUpload';
|
|
@@ -6,9 +6,28 @@ import { Button } from '../components/Button';
|
|
| 6 |
import { Slider } from '../components/Slider';
|
| 7 |
import { ResultsCard } from '../components/ResultsCard';
|
| 8 |
import { PreprocessingBadge } from '../components/PreprocessingBadge';
|
| 9 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
// File state
|
| 13 |
const [file, setFile] = useState<File | null>(null);
|
| 14 |
const [preview, setPreview] = useState<string | null>(null);
|
|
@@ -29,6 +48,19 @@ export function ClassificationPage() {
|
|
| 29 |
// Image view tab
|
| 30 |
const [imageTab, setImageTab] = useState<'input' | 'processed'>('input');
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
const loadPreview = useCallback(async (selectedFile: File) => {
|
| 33 |
// For regular images, create local preview
|
| 34 |
if (!isDicomFile(selectedFile.name)) {
|
|
@@ -123,6 +155,11 @@ export function ClassificationPage() {
|
|
| 123 |
setProcessedImage(`data:image/png;base64,${response.preprocessing.processed_image_base64}`);
|
| 124 |
}
|
| 125 |
setImageTab('processed');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
} catch (err) {
|
| 127 |
setError(err instanceof Error ? err.message : 'Classification failed');
|
| 128 |
setResults(null);
|
|
@@ -132,6 +169,11 @@ export function ClassificationPage() {
|
|
| 132 |
}
|
| 133 |
};
|
| 134 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
const fileType = file ? getFileType(file.name) : null;
|
| 136 |
|
| 137 |
return (
|
|
@@ -293,8 +335,28 @@ export function ClassificationPage() {
|
|
| 293 |
}
|
| 294 |
className="flex-1 flex flex-col min-h-0"
|
| 295 |
>
|
| 296 |
-
<div className="flex-1 overflow-y-auto">
|
| 297 |
<ResultsCard results={results} isLoading={isLoading} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
</div>
|
| 299 |
</Panel>
|
| 300 |
</div>
|
|
|
|
| 1 |
+
import { useState, useCallback, useEffect } from 'react';
|
| 2 |
import { Search, ChevronLeft, ChevronRight, FolderOpen } from 'lucide-react';
|
| 3 |
import { Panel } from '../components/Panel';
|
| 4 |
import { FileUpload } from '../components/FileUpload';
|
|
|
|
| 6 |
import { Slider } from '../components/Slider';
|
| 7 |
import { ResultsCard } from '../components/ResultsCard';
|
| 8 |
import { PreprocessingBadge } from '../components/PreprocessingBadge';
|
| 9 |
+
import { FeedbackSection } from '../components/FeedbackSection';
|
| 10 |
+
import { SessionHistory } from '../components/SessionHistory';
|
| 11 |
+
import {
|
| 12 |
+
classifyImage,
|
| 13 |
+
getFilePreview,
|
| 14 |
+
getFileType,
|
| 15 |
+
isDicomFile,
|
| 16 |
+
createSession,
|
| 17 |
+
recordImageAnalyzed,
|
| 18 |
+
type ClassificationResult,
|
| 19 |
+
type PreprocessingInfo
|
| 20 |
+
} from '../lib/api';
|
| 21 |
|
| 22 |
+
interface ClassificationPageProps {
|
| 23 |
+
onFeedbackUpdate?: () => void;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export function ClassificationPage({ onFeedbackUpdate }: ClassificationPageProps) {
|
| 27 |
+
// Session state
|
| 28 |
+
const [sessionId, setSessionId] = useState<string>('');
|
| 29 |
+
const [feedbackRefresh, setFeedbackRefresh] = useState(0);
|
| 30 |
+
|
| 31 |
// File state
|
| 32 |
const [file, setFile] = useState<File | null>(null);
|
| 33 |
const [preview, setPreview] = useState<string | null>(null);
|
|
|
|
| 48 |
// Image view tab
|
| 49 |
const [imageTab, setImageTab] = useState<'input' | 'processed'>('input');
|
| 50 |
|
| 51 |
+
// Initialize session on mount
|
| 52 |
+
useEffect(() => {
|
| 53 |
+
const initSession = async () => {
|
| 54 |
+
try {
|
| 55 |
+
const session = await createSession();
|
| 56 |
+
setSessionId(session.session_id);
|
| 57 |
+
} catch (err) {
|
| 58 |
+
console.error('Failed to create session:', err);
|
| 59 |
+
}
|
| 60 |
+
};
|
| 61 |
+
initSession();
|
| 62 |
+
}, []);
|
| 63 |
+
|
| 64 |
const loadPreview = useCallback(async (selectedFile: File) => {
|
| 65 |
// For regular images, create local preview
|
| 66 |
if (!isDicomFile(selectedFile.name)) {
|
|
|
|
| 155 |
setProcessedImage(`data:image/png;base64,${response.preprocessing.processed_image_base64}`);
|
| 156 |
}
|
| 157 |
setImageTab('processed');
|
| 158 |
+
|
| 159 |
+
// Record image analyzed for session stats
|
| 160 |
+
if (sessionId) {
|
| 161 |
+
await recordImageAnalyzed(sessionId);
|
| 162 |
+
}
|
| 163 |
} catch (err) {
|
| 164 |
setError(err instanceof Error ? err.message : 'Classification failed');
|
| 165 |
setResults(null);
|
|
|
|
| 169 |
}
|
| 170 |
};
|
| 171 |
|
| 172 |
+
const handleFeedbackSubmitted = () => {
|
| 173 |
+
setFeedbackRefresh(prev => prev + 1);
|
| 174 |
+
onFeedbackUpdate?.();
|
| 175 |
+
};
|
| 176 |
+
|
| 177 |
const fileType = file ? getFileType(file.name) : null;
|
| 178 |
|
| 179 |
return (
|
|
|
|
| 335 |
}
|
| 336 |
className="flex-1 flex flex-col min-h-0"
|
| 337 |
>
|
| 338 |
+
<div className="flex-1 overflow-y-auto space-y-4">
|
| 339 |
<ResultsCard results={results} isLoading={isLoading} />
|
| 340 |
+
|
| 341 |
+
{/* Feedback Section */}
|
| 342 |
+
{results && results.length > 0 && file && (
|
| 343 |
+
<FeedbackSection
|
| 344 |
+
sessionId={sessionId}
|
| 345 |
+
filename={file.name}
|
| 346 |
+
fileType={fileType || 'image'}
|
| 347 |
+
predictions={results}
|
| 348 |
+
topPrediction={results[0]}
|
| 349 |
+
onFeedbackSubmitted={handleFeedbackSubmitted}
|
| 350 |
+
/>
|
| 351 |
+
)}
|
| 352 |
+
|
| 353 |
+
{/* Session History */}
|
| 354 |
+
{sessionId && (
|
| 355 |
+
<SessionHistory
|
| 356 |
+
sessionId={sessionId}
|
| 357 |
+
refreshTrigger={feedbackRefresh}
|
| 358 |
+
/>
|
| 359 |
+
)}
|
| 360 |
</div>
|
| 361 |
</Panel>
|
| 362 |
</div>
|