Numan Saeed commited on
Commit
10ac650
·
1 Parent(s): 484ef72

Add feedback system with correction logging

Browse files

Features:
- 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)

Files changed (29) hide show
  1. backend/app/.DS_Store +0 -0
  2. backend/app/__pycache__/__init__.cpython-310.pyc +0 -0
  3. backend/app/__pycache__/__init__.cpython-312.pyc +0 -0
  4. backend/app/__pycache__/main.cpython-310.pyc +0 -0
  5. backend/app/__pycache__/main.cpython-312.pyc +0 -0
  6. backend/app/main.py +2 -1
  7. backend/app/routes/__init__.py +2 -1
  8. backend/app/routes/__pycache__/__init__.cpython-310.pyc +0 -0
  9. backend/app/routes/__pycache__/__init__.cpython-312.pyc +0 -0
  10. backend/app/routes/__pycache__/classification.cpython-310.pyc +0 -0
  11. backend/app/routes/__pycache__/classification.cpython-312.pyc +0 -0
  12. backend/app/routes/__pycache__/feedback.cpython-310.pyc +0 -0
  13. backend/app/routes/__pycache__/gestational_age.cpython-310.pyc +0 -0
  14. backend/app/routes/feedback.py +162 -0
  15. backend/app/services/__pycache__/__init__.cpython-310.pyc +0 -0
  16. backend/app/services/__pycache__/__init__.cpython-312.pyc +0 -0
  17. backend/app/services/__pycache__/feedback.cpython-310.pyc +0 -0
  18. backend/app/services/__pycache__/model.cpython-310.pyc +0 -0
  19. backend/app/services/__pycache__/model.cpython-312.pyc +0 -0
  20. backend/app/services/__pycache__/preprocessing.cpython-310.pyc +0 -0
  21. backend/app/services/feedback.py +258 -0
  22. frontend/src/App.tsx +30 -5
  23. frontend/src/components/FeedbackSection.tsx +223 -0
  24. frontend/src/components/Header.tsx +24 -2
  25. frontend/src/components/ResultsCard.tsx +29 -6
  26. frontend/src/components/SessionHistory.tsx +171 -0
  27. frontend/src/components/index.ts +2 -0
  28. frontend/src/lib/api.ts +185 -4
  29. 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" target="_blank" rel="noopener noreferrer"
 
 
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="bg-gradient-to-r from-nvidia-green/10 to-nvidia-green/5 border border-nvidia-green/20 rounded-xl p-4 shadow-card">
 
 
 
 
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="text-xl font-bold text-nvidia-green">
51
  {formatLabel(topResult.label)}
52
  </span>
53
- <span className="text-lg font-semibold text-text-primary">
54
- {topResult.confidence.toFixed(1)}%
55
- </span>
 
 
 
 
 
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
- // Auto-detect: localhost for dev, empty for HF Spaces (same origin)
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 { classifyImage, getFilePreview, getFileType, isDicomFile, type ClassificationResult, type PreprocessingInfo } from '../lib/api';
 
 
 
 
 
 
 
 
 
 
 
10
 
11
- export function ClassificationPage() {
 
 
 
 
 
 
 
 
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>