Dinamush commited on
Commit
e535bfb
·
1 Parent(s): a8d8aa7

Enhance backend API and frontend components to support full scores for classified items. Updated the database schema to include a new column for full scores, modified the item retrieval logic to optionally include these scores, and added a new endpoint for fetching item scores. Updated the frontend to display score debug information and improved state management for score loading. Enhanced tests to cover new functionality.

Browse files
backend/app/api.py CHANGED
@@ -60,7 +60,7 @@ def _settings_from_db() -> AppSettings:
60
  )
61
 
62
 
63
- def _item_from_row(row: dict) -> ClassifiedItem:
64
  return ClassifiedItem(
65
  id=row["id"],
66
  run_id=row["run_id"],
@@ -69,6 +69,11 @@ def _item_from_row(row: dict) -> ClassifiedItem:
69
  primary_tag=row["primary_tag"],
70
  primary_score=row["primary_score"],
71
  secondary_suggestions=from_json(row.get("secondary_json") or "[]", default=[]),
 
 
 
 
 
72
  suggested_destination=row["suggested_destination"],
73
  final_tag=row["final_tag"],
74
  final_destination=row["final_destination"],
@@ -146,7 +151,7 @@ def _execute_run(
146
  reason = None
147
  except Exception:
148
  logger.exception("inference_failed run_id=%d image=%s", run_id, image_path)
149
- primary_tag, primary_score, secondary = None, None, []
150
  needs_review = True
151
  reason = "Inference failed for this image; requires manual review."
152
  failed += 1
@@ -169,8 +174,8 @@ def _execute_run(
169
  """
170
  INSERT INTO items (
171
  run_id, file_path, relative_path, primary_tag, primary_score, secondary_json,
172
- suggested_destination, final_tag, final_destination, status, needs_review, review_reason
173
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
174
  """,
175
  (
176
  run_id,
@@ -179,6 +184,7 @@ def _execute_run(
179
  primary_tag,
180
  primary_score,
181
  to_json(secondary),
 
182
  suggested_destination,
183
  primary_tag,
184
  suggested_destination,
@@ -330,6 +336,7 @@ def get_run_items(
330
  run_id: int,
331
  status: str | None = None,
332
  needs_review: bool | None = None,
 
333
  ) -> list[ClassifiedItem]:
334
  query = "SELECT * FROM items WHERE run_id = ?"
335
  params: list = [run_id]
@@ -341,7 +348,7 @@ def get_run_items(
341
  params.append(1 if needs_review else 0)
342
  query += " ORDER BY id ASC"
343
  rows = fetch_all(query, tuple(params))
344
- return [_item_from_row(r) for r in rows]
345
 
346
 
347
  @router.patch("/items/{item_id}", response_model=ClassifiedItem)
@@ -375,6 +382,17 @@ def update_item(item_id: int, payload: UpdateItemRequest) -> ClassifiedItem:
375
  return _item_from_row(updated)
376
 
377
 
 
 
 
 
 
 
 
 
 
 
 
378
  @router.get("/items/{item_id}/preview")
379
  def get_item_preview(item_id: int) -> FileResponse:
380
  row = fetch_one("SELECT file_path FROM items WHERE id = ?", (item_id,))
 
60
  )
61
 
62
 
63
+ def _item_from_row(row: dict, include_full_scores: bool = False) -> ClassifiedItem:
64
  return ClassifiedItem(
65
  id=row["id"],
66
  run_id=row["run_id"],
 
69
  primary_tag=row["primary_tag"],
70
  primary_score=row["primary_score"],
71
  secondary_suggestions=from_json(row.get("secondary_json") or "[]", default=[]),
72
+ full_scores=(
73
+ from_json(row.get("full_scores_json") or "{}", default={})
74
+ if include_full_scores
75
+ else None
76
+ ),
77
  suggested_destination=row["suggested_destination"],
78
  final_tag=row["final_tag"],
79
  final_destination=row["final_destination"],
 
151
  reason = None
152
  except Exception:
153
  logger.exception("inference_failed run_id=%d image=%s", run_id, image_path)
154
+ scores, primary_tag, primary_score, secondary = {}, None, None, []
155
  needs_review = True
156
  reason = "Inference failed for this image; requires manual review."
157
  failed += 1
 
174
  """
175
  INSERT INTO items (
176
  run_id, file_path, relative_path, primary_tag, primary_score, secondary_json,
177
+ full_scores_json, suggested_destination, final_tag, final_destination, status, needs_review, review_reason
178
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
179
  """,
180
  (
181
  run_id,
 
184
  primary_tag,
185
  primary_score,
186
  to_json(secondary),
187
+ to_json(scores),
188
  suggested_destination,
189
  primary_tag,
190
  suggested_destination,
 
336
  run_id: int,
337
  status: str | None = None,
338
  needs_review: bool | None = None,
339
+ include_scores: bool = Query(False),
340
  ) -> list[ClassifiedItem]:
341
  query = "SELECT * FROM items WHERE run_id = ?"
342
  params: list = [run_id]
 
348
  params.append(1 if needs_review else 0)
349
  query += " ORDER BY id ASC"
350
  rows = fetch_all(query, tuple(params))
351
+ return [_item_from_row(r, include_full_scores=include_scores) for r in rows]
352
 
353
 
354
  @router.patch("/items/{item_id}", response_model=ClassifiedItem)
 
382
  return _item_from_row(updated)
383
 
384
 
385
+ @router.get("/items/{item_id}/scores")
386
+ def get_item_scores(item_id: int) -> dict:
387
+ row = fetch_one("SELECT id, full_scores_json FROM items WHERE id = ?", (item_id,))
388
+ if not row:
389
+ raise HTTPException(status_code=404, detail="Item not found")
390
+ return {
391
+ "item_id": row["id"],
392
+ "full_scores": from_json(row.get("full_scores_json") or "{}", default={}),
393
+ }
394
+
395
+
396
  @router.get("/items/{item_id}/preview")
397
  def get_item_preview(item_id: int) -> FileResponse:
398
  row = fetch_one("SELECT file_path FROM items WHERE id = ?", (item_id,))
backend/app/schemas.py CHANGED
@@ -56,6 +56,7 @@ class ClassifiedItem(BaseModel):
56
  primary_tag: str | None
57
  primary_score: float | None
58
  secondary_suggestions: list[SecondarySuggestion] = Field(default_factory=list)
 
59
  suggested_destination: str | None
60
  final_tag: str | None
61
  final_destination: str | None
 
56
  primary_tag: str | None
57
  primary_score: float | None
58
  secondary_suggestions: list[SecondarySuggestion] = Field(default_factory=list)
59
+ full_scores: dict[str, float] | None = None
60
  suggested_destination: str | None
61
  final_tag: str | None
62
  final_destination: str | None
backend/app/services.py CHANGED
@@ -30,7 +30,7 @@ def normalize_tag_name(value: str) -> str:
30
  for ch in text:
31
  if ch.isalnum():
32
  normalized.append(ch)
33
- elif ch in {" ", "-", ".", "/"}:
34
  normalized.append("_")
35
  return "".join(normalized).strip("_")
36
 
@@ -130,7 +130,7 @@ def extract_scores(image_path: Path) -> dict[str, float]:
130
  threshold=0.0,
131
  size=448,
132
  keep_ratio=True,
133
- drop_overlap=True,
134
  use_real_name=False,
135
  )
136
 
@@ -150,7 +150,16 @@ def extract_scores(image_path: Path) -> dict[str, float]:
150
  def choose_best_tags(
151
  scores: dict[str, float], allowed_tags: set[str], max_secondary: int = 3
152
  ) -> tuple[str | None, float | None, list[dict[str, float]]]:
153
- candidates = [(tag, score) for tag, score in scores.items() if tag in allowed_tags]
 
 
 
 
 
 
 
 
 
154
  candidates.sort(key=lambda x: x[1], reverse=True)
155
  if not candidates:
156
  return None, None, []
 
30
  for ch in text:
31
  if ch.isalnum():
32
  normalized.append(ch)
33
+ elif ch in {" ", "-", ".", "/", "_"}:
34
  normalized.append("_")
35
  return "".join(normalized).strip("_")
36
 
 
130
  threshold=0.0,
131
  size=448,
132
  keep_ratio=True,
133
+ drop_overlap=False,
134
  use_real_name=False,
135
  )
136
 
 
150
  def choose_best_tags(
151
  scores: dict[str, float], allowed_tags: set[str], max_secondary: int = 3
152
  ) -> tuple[str | None, float | None, list[dict[str, float]]]:
153
+ allowed_by_normalized = {normalize_tag_name(tag): tag for tag in allowed_tags}
154
+ best_by_allowed: dict[str, float] = {}
155
+ for tag, score in scores.items():
156
+ resolved = allowed_by_normalized.get(tag) or allowed_by_normalized.get(normalize_tag_name(tag))
157
+ if resolved is None:
158
+ continue
159
+ current = best_by_allowed.get(resolved)
160
+ if current is None or score > current:
161
+ best_by_allowed[resolved] = float(score)
162
+ candidates = list(best_by_allowed.items())
163
  candidates.sort(key=lambda x: x[1], reverse=True)
164
  if not candidates:
165
  return None, None, []
backend/app/storage.py CHANGED
@@ -68,6 +68,7 @@ def init_db() -> None:
68
  """
69
  )
70
  _ensure_runs_columns(conn)
 
71
  except sqlite3.DatabaseError:
72
  logger.exception("failed to initialize database")
73
  raise
@@ -92,6 +93,18 @@ def _ensure_runs_columns(conn: sqlite3.Connection) -> None:
92
  conn.execute(f"ALTER TABLE runs ADD COLUMN {name} {definition}")
93
 
94
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  def fetch_one(query: str, params: tuple[Any, ...] = ()) -> dict[str, Any] | None:
96
  try:
97
  with get_connection() as conn:
 
68
  """
69
  )
70
  _ensure_runs_columns(conn)
71
+ _ensure_items_columns(conn)
72
  except sqlite3.DatabaseError:
73
  logger.exception("failed to initialize database")
74
  raise
 
93
  conn.execute(f"ALTER TABLE runs ADD COLUMN {name} {definition}")
94
 
95
 
96
+ def _ensure_items_columns(conn: sqlite3.Connection) -> None:
97
+ expected_columns = {
98
+ "full_scores_json": "TEXT NOT NULL DEFAULT '{}'",
99
+ }
100
+ rows = conn.execute("PRAGMA table_info(items)").fetchall()
101
+ existing = {row[1] for row in rows}
102
+ for name, definition in expected_columns.items():
103
+ if name in existing:
104
+ continue
105
+ conn.execute(f"ALTER TABLE items ADD COLUMN {name} {definition}")
106
+
107
+
108
  def fetch_one(query: str, params: tuple[Any, ...] = ()) -> dict[str, Any] | None:
109
  try:
110
  with get_connection() as conn:
backend/tests/test_api_run_progress.py CHANGED
@@ -139,6 +139,135 @@ def test_item_preview_returns_image(monkeypatch, tmp_path: Path):
139
  assert preview_resp.content == b"fake-image-bytes"
140
 
141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  def test_run_cancel_sets_cancelled(monkeypatch, tmp_path: Path):
143
  root = tmp_path / "root_cancel"
144
  cats = tmp_path / "cats_cancel"
 
139
  assert preview_resp.content == b"fake-image-bytes"
140
 
141
 
142
+ def test_selected_tag_wins_when_global_top_not_selected(monkeypatch, tmp_path: Path):
143
+ root = tmp_path / "root_selected"
144
+ cats = tmp_path / "cats_selected"
145
+ root.mkdir()
146
+ cats.mkdir()
147
+ (cats / "monster_girl").mkdir()
148
+ (cats / "slime_girl").mkdir()
149
+ file_path = root / "s.png"
150
+ file_path.write_text("fake", encoding="utf-8")
151
+
152
+ def fake_scan_images(_root):
153
+ return ScanOutput(
154
+ image_paths=[file_path],
155
+ stats=ScanStats(
156
+ total_files=1,
157
+ eligible_images=1,
158
+ ignored_unsupported=0,
159
+ ignored_gif=0,
160
+ failed_to_read=0,
161
+ ),
162
+ )
163
+
164
+ monkeypatch.setattr("app.api.scan_images", fake_scan_images)
165
+ monkeypatch.setattr(
166
+ "app.api.extract_scores",
167
+ lambda _p: {"1girl": 0.99, "monster_girl": 0.85, "slime_girl": 0.82},
168
+ )
169
+ monkeypatch.setattr("app.api.load_known_tags", lambda _p: {"1girl", "monster_girl", "slime_girl"})
170
+ monkeypatch.setattr(
171
+ "app.api.discover_tag_folders",
172
+ lambda _root, _tags, _selected: [
173
+ FolderMapping(
174
+ folder_name="monster_girl",
175
+ normalized_name="monster_girl",
176
+ matched_tag="monster_girl",
177
+ matched=True,
178
+ ),
179
+ FolderMapping(
180
+ folder_name="slime_girl",
181
+ normalized_name="slime_girl",
182
+ matched_tag="slime_girl",
183
+ matched=True,
184
+ ),
185
+ ],
186
+ )
187
+
188
+ with TestClient(app) as client:
189
+ start_resp = client.post(
190
+ "/api/runs/start",
191
+ json={
192
+ "root_repo": str(root),
193
+ "categories_root": str(cats),
194
+ "confidence_threshold": 0.8,
195
+ "selected_folders": ["monster_girl", "slime_girl"],
196
+ },
197
+ )
198
+ start_resp.raise_for_status()
199
+ run_id = start_resp.json()["run_id"]
200
+ final = _wait_for_status(client, run_id, {"completed", "failed", "cancelled"})
201
+ assert final is not None
202
+ assert final["status"] == "completed"
203
+ items_resp = client.get(f"/api/runs/{run_id}/items")
204
+ items_resp.raise_for_status()
205
+ item = items_resp.json()[0]
206
+ assert item["primary_tag"] == "monster_girl"
207
+ assert item["status"] == "approved"
208
+ assert item["needs_review"] is False
209
+
210
+
211
+ def test_item_scores_debug_endpoint(monkeypatch, tmp_path: Path):
212
+ root = tmp_path / "root_scores"
213
+ cats = tmp_path / "cats_scores"
214
+ root.mkdir()
215
+ cats.mkdir()
216
+ (cats / "1girl").mkdir()
217
+ file_path = root / "z.jpg"
218
+ file_path.write_text("fake", encoding="utf-8")
219
+
220
+ def fake_scan_images(_root):
221
+ return ScanOutput(
222
+ image_paths=[file_path],
223
+ stats=ScanStats(
224
+ total_files=1,
225
+ eligible_images=1,
226
+ ignored_unsupported=0,
227
+ ignored_gif=0,
228
+ failed_to_read=0,
229
+ ),
230
+ )
231
+
232
+ monkeypatch.setattr("app.api.scan_images", fake_scan_images)
233
+ monkeypatch.setattr("app.api.extract_scores", lambda _p: {"1girl": 0.92, "solo": 0.88})
234
+ monkeypatch.setattr("app.api.load_known_tags", lambda _p: {"1girl", "solo"})
235
+ monkeypatch.setattr(
236
+ "app.api.discover_tag_folders",
237
+ lambda _root, _tags, _selected: [
238
+ FolderMapping(
239
+ folder_name="1girl", normalized_name="1girl", matched_tag="1girl", matched=True
240
+ )
241
+ ],
242
+ )
243
+
244
+ with TestClient(app) as client:
245
+ start_resp = client.post(
246
+ "/api/runs/start",
247
+ json={
248
+ "root_repo": str(root),
249
+ "categories_root": str(cats),
250
+ "confidence_threshold": 0.6,
251
+ "selected_folders": ["1girl"],
252
+ },
253
+ )
254
+ start_resp.raise_for_status()
255
+ run_id = start_resp.json()["run_id"]
256
+ final = _wait_for_status(client, run_id, {"completed", "failed", "cancelled"})
257
+ assert final is not None
258
+ assert final["status"] == "completed"
259
+
260
+ items_resp = client.get(f"/api/runs/{run_id}/items")
261
+ items_resp.raise_for_status()
262
+ item_id = items_resp.json()[0]["id"]
263
+ debug_resp = client.get(f"/api/items/{item_id}/scores")
264
+ debug_resp.raise_for_status()
265
+ payload = debug_resp.json()
266
+ assert payload["item_id"] == item_id
267
+ assert payload["full_scores"]["1girl"] == 0.92
268
+ assert payload["full_scores"]["solo"] == 0.88
269
+
270
+
271
  def test_run_cancel_sets_cancelled(monkeypatch, tmp_path: Path):
272
  root = tmp_path / "root_cancel"
273
  cats = tmp_path / "cats_cancel"
backend/tests/test_services.py CHANGED
@@ -56,6 +56,22 @@ def test_choose_best_tags_single_primary_and_secondary() -> None:
56
  assert secondary[0]["tag"] == "solo"
57
 
58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  def test_migrate_file_copy_with_collision_suffix(tmp_path: Path) -> None:
60
  src = tmp_path / "sample.jpg"
61
  src.write_text("abc", encoding="utf-8")
 
56
  assert secondary[0]["tag"] == "solo"
57
 
58
 
59
+ def test_choose_best_tags_ignores_non_selected_high_score() -> None:
60
+ scores = {"1girl": 0.99, "monster_girl": 0.81, "slime_girl": 0.77}
61
+ primary_tag, primary_score, secondary = choose_best_tags(scores, {"monster_girl", "slime_girl"})
62
+ assert primary_tag == "monster_girl"
63
+ assert primary_score == 0.81
64
+ assert secondary == [{"tag": "slime_girl", "score": 0.77}]
65
+
66
+
67
+ def test_choose_best_tags_matches_normalized_selected_tags() -> None:
68
+ scores = {"monster girl": 0.88, "slime-girl": 0.84}
69
+ primary_tag, primary_score, secondary = choose_best_tags(scores, {"monster_girl", "slime_girl"})
70
+ assert primary_tag == "monster_girl"
71
+ assert primary_score == 0.88
72
+ assert secondary == [{"tag": "slime_girl", "score": 0.84}]
73
+
74
+
75
  def test_migrate_file_copy_with_collision_suffix(tmp_path: Path) -> None:
76
  src = tmp_path / "sample.jpg"
77
  src.write_text("abc", encoding="utf-8")
frontend/src/App.jsx CHANGED
@@ -24,6 +24,9 @@ function App() {
24
  const [tagOptions, setTagOptions] = useState([]);
25
  const [selectedTags, setSelectedTags] = useState([]);
26
  const [previewErrors, setPreviewErrors] = useState({});
 
 
 
27
  const [finalTagDrafts, setFinalTagDrafts] = useState({});
28
  const [opsLoading, setOpsLoading] = useState({
29
  saving: false,
@@ -277,10 +280,28 @@ function App() {
277
  }
278
  }
279
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
  function addSelectedTag(value) {
281
  if (!value) return;
282
- if (selectedTags.includes(value)) return;
283
- setSelectedTags([...selectedTags, value]);
284
  }
285
 
286
  async function addValidatedTag(rawValue) {
@@ -317,7 +338,7 @@ function App() {
317
  }
318
 
319
  function removeSelectedTag(value) {
320
- setSelectedTags(selectedTags.filter((t) => t !== value));
321
  }
322
 
323
  return (
@@ -491,11 +512,12 @@ function App() {
491
  <tr>
492
  <th>Select</th>
493
  <th>Image</th>
494
- <th>Primary</th>
495
  <th>Score</th>
496
- <th>Secondary</th>
497
  <th>Status</th>
498
  <th>Final Tag</th>
 
499
  <th>Review</th>
500
  </tr>
501
  </thead>
@@ -548,6 +570,18 @@ function App() {
548
  onChange={(e) => queueFinalTagUpdate(item.id, e.target.value)}
549
  />
550
  </td>
 
 
 
 
 
 
 
 
 
 
 
 
551
  <td>
552
  <button
553
  disabled={opsLoading.updatingStatus}
 
24
  const [tagOptions, setTagOptions] = useState([]);
25
  const [selectedTags, setSelectedTags] = useState([]);
26
  const [previewErrors, setPreviewErrors] = useState({});
27
+ const [expandedScoreRows, setExpandedScoreRows] = useState({});
28
+ const [scoreDebugByItem, setScoreDebugByItem] = useState({});
29
+ const [scoreLoadingByItem, setScoreLoadingByItem] = useState({});
30
  const [finalTagDrafts, setFinalTagDrafts] = useState({});
31
  const [opsLoading, setOpsLoading] = useState({
32
  saving: false,
 
280
  }
281
  }
282
 
283
+ async function toggleScoreDebug(itemId) {
284
+ const isExpanded = Boolean(expandedScoreRows[itemId]);
285
+ if (isExpanded) {
286
+ setExpandedScoreRows((prev) => ({ ...prev, [itemId]: false }));
287
+ return;
288
+ }
289
+ setExpandedScoreRows((prev) => ({ ...prev, [itemId]: true }));
290
+ if (scoreDebugByItem[itemId]) return;
291
+ setScoreLoadingByItem((prev) => ({ ...prev, [itemId]: true }));
292
+ try {
293
+ const response = await api.getItemScores(itemId);
294
+ setScoreDebugByItem((prev) => ({ ...prev, [itemId]: response.full_scores || {} }));
295
+ } catch (err) {
296
+ setError(`Failed to load score debug JSON: ${err.message}`);
297
+ } finally {
298
+ setScoreLoadingByItem((prev) => ({ ...prev, [itemId]: false }));
299
+ }
300
+ }
301
+
302
  function addSelectedTag(value) {
303
  if (!value) return;
304
+ setSelectedTags((prev) => (prev.includes(value) ? prev : [...prev, value]));
 
305
  }
306
 
307
  async function addValidatedTag(rawValue) {
 
338
  }
339
 
340
  function removeSelectedTag(value) {
341
+ setSelectedTags((prev) => prev.filter((t) => t !== value));
342
  }
343
 
344
  return (
 
512
  <tr>
513
  <th>Select</th>
514
  <th>Image</th>
515
+ <th>Primary (Selected)</th>
516
  <th>Score</th>
517
+ <th>Secondary (Selected)</th>
518
  <th>Status</th>
519
  <th>Final Tag</th>
520
+ <th>Debug Scores</th>
521
  <th>Review</th>
522
  </tr>
523
  </thead>
 
570
  onChange={(e) => queueFinalTagUpdate(item.id, e.target.value)}
571
  />
572
  </td>
573
+ <td>
574
+ <button onClick={() => toggleScoreDebug(item.id)}>
575
+ {expandedScoreRows[item.id] ? "Hide JSON" : "Show JSON"}
576
+ </button>
577
+ {expandedScoreRows[item.id] && (
578
+ <pre className="debug-json">
579
+ {scoreLoadingByItem[item.id]
580
+ ? "Loading..."
581
+ : JSON.stringify(scoreDebugByItem[item.id] || {}, null, 2)}
582
+ </pre>
583
+ )}
584
+ </td>
585
  <td>
586
  <button
587
  disabled={opsLoading.updatingStatus}
frontend/src/api.js CHANGED
@@ -137,10 +137,16 @@ function makeMockItems(runId, selectedFolders, threshold) {
137
  suggested_destination: `${mockState.settings.categories_root || "/mock/categories"}/${tag}`,
138
  final_tag: tag,
139
  final_destination: `${mockState.settings.categories_root || "/mock/categories"}/${tag}`,
140
- status: "proposed",
141
  needs_review: needsReview,
142
  review_reason: needsReview ? `Below threshold (${score.toFixed(3)} < ${threshold.toFixed(3)})` : null,
143
  migrated_to: null,
 
 
 
 
 
 
144
  };
145
  });
146
  }
@@ -289,6 +295,13 @@ function mockRequest(path, options = {}) {
289
  persistMockState();
290
  return Promise.resolve(item);
291
  }
 
 
 
 
 
 
 
292
  if (path.match(/^\/runs\/\d+\/batch$/) && method === "POST") {
293
  const runId = Number(path.split("/")[2]);
294
  const run = mockState.runs[runId];
@@ -367,9 +380,13 @@ export const api = {
367
  if (filters.needs_review !== undefined) {
368
  params.set("needs_review", String(filters.needs_review));
369
  }
 
 
 
370
  const query = params.toString();
371
  return request(`/runs/${runId}/items${query ? `?${query}` : ""}`);
372
  },
 
373
  updateItem: (itemId, payload) =>
374
  request(`/items/${itemId}`, {
375
  method: "PATCH",
 
137
  suggested_destination: `${mockState.settings.categories_root || "/mock/categories"}/${tag}`,
138
  final_tag: tag,
139
  final_destination: `${mockState.settings.categories_root || "/mock/categories"}/${tag}`,
140
+ status: needsReview ? "proposed" : "approved",
141
  needs_review: needsReview,
142
  review_reason: needsReview ? `Below threshold (${score.toFixed(3)} < ${threshold.toFixed(3)})` : null,
143
  migrated_to: null,
144
+ full_scores: Object.fromEntries(
145
+ [tag, ...pool.filter((x) => x !== tag)].map((name, i) => [
146
+ name,
147
+ Math.max(0.1, score - i * 0.08),
148
+ ])
149
+ ),
150
  };
151
  });
152
  }
 
295
  persistMockState();
296
  return Promise.resolve(item);
297
  }
298
+ if (path.match(/^\/items\/\d+\/scores$/) && method === "GET") {
299
+ const itemId = Number(path.split("/")[2]);
300
+ const run = Object.values(mockState.runs).find((r) => r.items.some((i) => i.id === itemId));
301
+ if (!run) return Promise.reject(new Error("Item not found"));
302
+ const item = run.items.find((i) => i.id === itemId);
303
+ return Promise.resolve({ item_id: item.id, full_scores: item.full_scores || {} });
304
+ }
305
  if (path.match(/^\/runs\/\d+\/batch$/) && method === "POST") {
306
  const runId = Number(path.split("/")[2]);
307
  const run = mockState.runs[runId];
 
380
  if (filters.needs_review !== undefined) {
381
  params.set("needs_review", String(filters.needs_review));
382
  }
383
+ if (filters.include_scores) {
384
+ params.set("include_scores", "true");
385
+ }
386
  const query = params.toString();
387
  return request(`/runs/${runId}/items${query ? `?${query}` : ""}`);
388
  },
389
+ getItemScores: (itemId) => request(`/items/${itemId}/scores`),
390
  updateItem: (itemId, payload) =>
391
  request(`/items/${itemId}`, {
392
  method: "PATCH",
frontend/src/styles.css CHANGED
@@ -106,3 +106,16 @@ td {
106
  font-size: 12px;
107
  overflow-wrap: anywhere;
108
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  font-size: 12px;
107
  overflow-wrap: anywhere;
108
  }
109
+
110
+ .debug-json {
111
+ margin-top: 6px;
112
+ max-width: 360px;
113
+ max-height: 220px;
114
+ overflow: auto;
115
+ font-size: 11px;
116
+ line-height: 1.35;
117
+ background: #0f172a;
118
+ color: #e2e8f0;
119
+ border-radius: 8px;
120
+ padding: 8px;
121
+ }