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 +23 -5
- backend/app/schemas.py +1 -0
- backend/app/services.py +12 -3
- backend/app/storage.py +13 -0
- backend/tests/test_api_run_progress.py +129 -0
- backend/tests/test_services.py +16 -0
- frontend/src/App.jsx +39 -5
- frontend/src/api.js +18 -1
- frontend/src/styles.css +13 -0
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=
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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(
|
| 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 |
+
}
|