Dinamush commited on
Commit
8a42224
·
1 Parent(s): 293f222

Implement destination folder validation in migrate_run function. Add checks to ensure the destination folder exists and is a directory, with appropriate error handling and logging for failed migrations. This enhances the robustness of the migration process by preventing invalid paths from causing issues.

Browse files
backend/app/api.py CHANGED
@@ -1060,6 +1060,30 @@ def migrate_run(run_id: int, payload: MigrateRequest) -> MigrateResponse:
1060
  try:
1061
  if payload.create_missing_folders:
1062
  destination_folder.mkdir(parents=True, exist_ok=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1063
  except Exception:
1064
  failed_count += 1
1065
  results.append(
 
1060
  try:
1061
  if payload.create_missing_folders:
1062
  destination_folder.mkdir(parents=True, exist_ok=True)
1063
+ elif not destination_folder.exists():
1064
+ failed_count += 1
1065
+ results.append(
1066
+ {
1067
+ "item_id": row["id"],
1068
+ "source": str(source),
1069
+ "destination": str(destination_folder),
1070
+ "success": False,
1071
+ "error": "Destination folder does not exist",
1072
+ }
1073
+ )
1074
+ continue
1075
+ elif not destination_folder.is_dir():
1076
+ failed_count += 1
1077
+ results.append(
1078
+ {
1079
+ "item_id": row["id"],
1080
+ "source": str(source),
1081
+ "destination": str(destination_folder),
1082
+ "success": False,
1083
+ "error": "Destination path exists but is not a folder",
1084
+ }
1085
+ )
1086
+ continue
1087
  except Exception:
1088
  failed_count += 1
1089
  results.append(
backend/scripts/audit_classify_accuracy.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Offline + optional live audit of taxonomy routing and tagger outputs.
3
+
4
+ Usage (from backend/):
5
+ ../.venv/Scripts/python.exe scripts/audit_classify_accuracy.py
6
+ ../.venv/Scripts/python.exe scripts/audit_classify_accuracy.py --images DIR
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import sys
13
+ import time
14
+ from pathlib import Path
15
+
16
+ ROOT = Path(__file__).resolve().parents[1]
17
+ sys.path.insert(0, str(ROOT))
18
+
19
+ from app.taxonomy import choose_best_destination, reload_taxonomy # noqa: E402
20
+
21
+ SELECTED = {
22
+ "fertilization",
23
+ "NTR",
24
+ "incest",
25
+ "nakadashi",
26
+ "fellatio",
27
+ "loli",
28
+ "shota",
29
+ "monster_girl",
30
+ "furry",
31
+ "Pokemon",
32
+ }
33
+
34
+ FIXTURES: list[tuple[str, dict[str, float], str | None]] = [
35
+ ("loli_hard", {"loli": 0.92, "flat_chest": 0.99}, "loli"),
36
+ ("fashion_fp", {"lolita_fashion": 0.99, "gothic_lolita": 0.95}, None),
37
+ ("shota_hard", {"shota": 0.88, "1boy": 0.99}, "shota"),
38
+ ("ntr_hard", {"netorare": 0.8}, "NTR"),
39
+ ("incest_hard", {"incest": 0.8, "siblings": 0.99}, "incest"),
40
+ ("siblings_fp", {"siblings": 0.99}, None),
41
+ ("nakadashi", {"internal_cumshot": 0.9}, "nakadashi"),
42
+ ("fert_over_creampie", {"fertilization": 0.8, "cum_in_pussy": 0.99}, "fertilization"),
43
+ ("fellatio_impl", {"deepthroat": 0.9}, "fellatio"),
44
+ ("monster_girl", {"monster_girl": 0.9, "horns": 0.99}, "monster_girl"),
45
+ ("parts_fp", {"horns": 0.99, "wings": 0.98}, None),
46
+ ("furry", {"furry_female": 0.9, "animal_ears": 0.99}, "furry"),
47
+ ("pokemon", {"pokemon_(creature)": 0.9}, "Pokemon"),
48
+ ]
49
+
50
+
51
+ def audit_fixtures() -> int:
52
+ reload_taxonomy()
53
+ failed = 0
54
+ print("=== TAXONOMY FIXTURE AUDIT ===")
55
+ for name, scores, expected in FIXTURES:
56
+ folder, score, _ = choose_best_destination(scores, SELECTED)
57
+ ok = folder == expected
58
+ mark = "PASS" if ok else "FAIL"
59
+ if not ok:
60
+ failed += 1
61
+ print(f"{mark} {name}: got={folder}({score}) expected={expected}")
62
+ return failed
63
+
64
+
65
+ def audit_images(image_dir: Path, models: list[str], limit: int) -> int:
66
+ from app.services import extract_scores
67
+
68
+ paths = sorted(
69
+ p
70
+ for p in image_dir.rglob("*")
71
+ if p.suffix.lower() in {".jpg", ".jpeg", ".png", ".webp", ".bmp"}
72
+ )[:limit]
73
+ if not paths:
74
+ print(f"ERROR: no images under {image_dir}", file=sys.stderr)
75
+ return 1
76
+
77
+ print(f"\n=== MODEL IMAGE AUDIT ({len(paths)} images) ===")
78
+ failed = 0
79
+ for model in models:
80
+ empty = 0
81
+ routed = 0
82
+ errors = 0
83
+ latencies: list[float] = []
84
+ samples: list[dict] = []
85
+ for path in paths:
86
+ t0 = time.perf_counter()
87
+ try:
88
+ scores = extract_scores(path, tagger_model=model, wd_general_threshold=0.35)
89
+ except Exception as err:
90
+ errors += 1
91
+ print(f" ERR {model} {path.name}: {err}")
92
+ continue
93
+ latencies.append((time.perf_counter() - t0) * 1000)
94
+ if not scores:
95
+ empty += 1
96
+ folder, score, _ = choose_best_destination(scores, SELECTED)
97
+ if folder is not None:
98
+ routed += 1
99
+ if len(samples) < 3:
100
+ top = sorted(scores.items(), key=lambda kv: kv[1], reverse=True)[:5]
101
+ samples.append(
102
+ {
103
+ "file": path.name,
104
+ "folder": folder,
105
+ "folder_score": score,
106
+ "top": [f"{t}={s:.3f}" for t, s in top],
107
+ }
108
+ )
109
+ avg = sum(latencies) / len(latencies) if latencies else None
110
+ ok = errors == 0 and empty == 0
111
+ if not ok:
112
+ failed += 1
113
+ print(
114
+ json.dumps(
115
+ {
116
+ "model": model,
117
+ "ok": ok,
118
+ "images": len(paths),
119
+ "empty_scores": empty,
120
+ "errors": errors,
121
+ "taxonomy_routed": routed,
122
+ "avg_ms": round(avg, 1) if avg is not None else None,
123
+ "samples": samples,
124
+ },
125
+ indent=2,
126
+ )
127
+ )
128
+ return failed
129
+
130
+
131
+ def main() -> int:
132
+ parser = argparse.ArgumentParser()
133
+ parser.add_argument("--images", type=Path, default=None)
134
+ parser.add_argument("--limit", type=int, default=12)
135
+ parser.add_argument(
136
+ "--models",
137
+ nargs="+",
138
+ default=["ml_danbooru", "wd_swinv2_v3", "wd_eva02_large"],
139
+ )
140
+ args = parser.parse_args()
141
+ failed = audit_fixtures()
142
+ if args.images:
143
+ failed += audit_images(args.images, args.models, args.limit)
144
+ else:
145
+ print("\n(skip image audit: pass --images DIR for live tagger accuracy smoke)")
146
+ print(f"\n=== DONE failed={failed} ===")
147
+ return 1 if failed else 0
148
+
149
+
150
+ if __name__ == "__main__":
151
+ raise SystemExit(main())
backend/tests/test_api_migrate.py ADDED
@@ -0,0 +1,378 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Post-approval migrate path: API + filesystem behavior."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from fastapi.testclient import TestClient
8
+
9
+ from app.main import app
10
+ from app.storage import execute, fetch_one, to_json
11
+
12
+
13
+ def _seed_completed_run(
14
+ root: Path,
15
+ cats: Path,
16
+ items: list[dict],
17
+ *,
18
+ status: str = "completed",
19
+ ) -> int:
20
+ run_id = execute(
21
+ """
22
+ INSERT INTO runs (
23
+ root_repo, categories_root, confidence_threshold, status,
24
+ total_images, processed_images, failed_images, cancel_requested, tagger_model
25
+ ) VALUES (?, ?, 0.6, ?, ?, ?, 0, 0, 'wd_swinv2_v3')
26
+ """,
27
+ (str(root), str(cats), status, len(items), len(items)),
28
+ )
29
+ for item in items:
30
+ execute(
31
+ """
32
+ INSERT INTO items (
33
+ run_id, file_path, relative_path, primary_tag, primary_score, secondary_json,
34
+ full_scores_json, suggested_destination, final_tag, final_destination,
35
+ status, needs_review, review_reason
36
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
37
+ """,
38
+ (
39
+ run_id,
40
+ item["file_path"],
41
+ item.get("relative_path") or Path(item["file_path"]).name,
42
+ item.get("primary_tag"),
43
+ item.get("primary_score"),
44
+ to_json(item.get("secondary") or []),
45
+ to_json(item.get("scores") or {}),
46
+ item.get("suggested_destination"),
47
+ item.get("final_tag", item.get("primary_tag")),
48
+ item.get("final_destination"),
49
+ item.get("status", "approved"),
50
+ 1 if item.get("needs_review") else 0,
51
+ item.get("review_reason"),
52
+ ),
53
+ )
54
+ return run_id
55
+
56
+
57
+ def test_migrate_copy_approved_only_and_skips_non_approved(tmp_path: Path) -> None:
58
+ root = tmp_path / "root"
59
+ cats = tmp_path / "cats"
60
+ root.mkdir()
61
+ cats.mkdir()
62
+ a = root / "a.jpg"
63
+ b = root / "b.jpg"
64
+ c = root / "c.jpg"
65
+ a.write_bytes(b"aaa")
66
+ b.write_bytes(b"bbb")
67
+ c.write_bytes(b"ccc")
68
+ dest_loli = cats / "loli"
69
+ dest_shota = cats / "shota"
70
+
71
+ run_id = _seed_completed_run(
72
+ root,
73
+ cats,
74
+ [
75
+ {
76
+ "file_path": str(a),
77
+ "primary_tag": "loli",
78
+ "primary_score": 0.9,
79
+ "final_destination": str(dest_loli),
80
+ "status": "approved",
81
+ },
82
+ {
83
+ "file_path": str(b),
84
+ "primary_tag": "shota",
85
+ "primary_score": 0.85,
86
+ "final_destination": str(dest_shota),
87
+ "status": "proposed",
88
+ },
89
+ {
90
+ "file_path": str(c),
91
+ "primary_tag": "loli",
92
+ "primary_score": 0.7,
93
+ "final_destination": str(dest_loli),
94
+ "status": "rejected",
95
+ },
96
+ ],
97
+ )
98
+
99
+ with TestClient(app) as client:
100
+ resp = client.post(
101
+ f"/api/runs/{run_id}/migrate",
102
+ json={"mode": "copy", "create_missing_folders": True},
103
+ )
104
+ resp.raise_for_status()
105
+ payload = resp.json()
106
+ assert payload["total_candidates"] == 1
107
+ assert payload["migrated_count"] == 1
108
+ assert payload["failed_count"] == 0
109
+ assert (dest_loli / "a.jpg").is_file()
110
+ assert (dest_loli / "a.jpg").read_bytes() == b"aaa"
111
+ assert a.exists() # copy keeps source
112
+ assert not (dest_shota / "b.jpg").exists()
113
+ assert not (dest_loli / "c.jpg").exists()
114
+
115
+ item = fetch_one("SELECT status, migrated_to FROM items WHERE file_path = ?", (str(a),))
116
+ assert item["status"] == "migrated"
117
+ assert item["migrated_to"].endswith("a.jpg")
118
+
119
+
120
+ def test_migrate_move_removes_source(tmp_path: Path) -> None:
121
+ root = tmp_path / "root"
122
+ cats = tmp_path / "cats" / "NTR"
123
+ root.mkdir()
124
+ cats.mkdir(parents=True)
125
+ src = root / "ntr.jpg"
126
+ src.write_bytes(b"ntr-bytes")
127
+ run_id = _seed_completed_run(
128
+ root,
129
+ cats.parent,
130
+ [
131
+ {
132
+ "file_path": str(src),
133
+ "primary_tag": "NTR",
134
+ "primary_score": 0.8,
135
+ "final_destination": str(cats),
136
+ "status": "approved",
137
+ }
138
+ ],
139
+ )
140
+
141
+ with TestClient(app) as client:
142
+ resp = client.post(
143
+ f"/api/runs/{run_id}/migrate",
144
+ json={"mode": "move", "create_missing_folders": False},
145
+ )
146
+ resp.raise_for_status()
147
+ payload = resp.json()
148
+ assert payload["migrated_count"] == 1
149
+ assert not src.exists()
150
+ assert (cats / "ntr.jpg").read_bytes() == b"ntr-bytes"
151
+
152
+
153
+ def test_migrate_collision_suffix(tmp_path: Path) -> None:
154
+ root = tmp_path / "root"
155
+ dest = tmp_path / "cats" / "furry"
156
+ root.mkdir()
157
+ dest.mkdir(parents=True)
158
+ src = root / "dup.jpg"
159
+ src.write_bytes(b"new")
160
+ (dest / "dup.jpg").write_bytes(b"old")
161
+ run_id = _seed_completed_run(
162
+ root,
163
+ dest.parent,
164
+ [
165
+ {
166
+ "file_path": str(src),
167
+ "primary_tag": "furry",
168
+ "final_destination": str(dest),
169
+ "status": "approved",
170
+ }
171
+ ],
172
+ )
173
+
174
+ with TestClient(app) as client:
175
+ resp = client.post(
176
+ f"/api/runs/{run_id}/migrate",
177
+ json={"mode": "copy", "create_missing_folders": True},
178
+ )
179
+ resp.raise_for_status()
180
+ assert resp.json()["migrated_count"] == 1
181
+ assert (dest / "dup.jpg").read_bytes() == b"old"
182
+ assert (dest / "dup_1.jpg").read_bytes() == b"new"
183
+
184
+
185
+ def test_migrate_creates_taxonomy_folder(tmp_path: Path) -> None:
186
+ root = tmp_path / "root"
187
+ cats = tmp_path / "cats"
188
+ root.mkdir()
189
+ cats.mkdir()
190
+ src = root / "poke.webp"
191
+ src.write_bytes(b"poke")
192
+ dest = cats / "Pokemon"
193
+ run_id = _seed_completed_run(
194
+ root,
195
+ cats,
196
+ [
197
+ {
198
+ "file_path": str(src),
199
+ "primary_tag": "Pokemon",
200
+ "final_destination": str(dest),
201
+ "status": "approved",
202
+ }
203
+ ],
204
+ )
205
+
206
+ with TestClient(app) as client:
207
+ resp = client.post(
208
+ f"/api/runs/{run_id}/migrate",
209
+ json={"mode": "copy", "create_missing_folders": True},
210
+ )
211
+ resp.raise_for_status()
212
+ assert resp.json()["migrated_count"] == 1
213
+ assert dest.is_dir()
214
+ assert (dest / "poke.webp").is_file()
215
+
216
+
217
+ def test_migrate_fails_missing_source_and_missing_destination(tmp_path: Path) -> None:
218
+ root = tmp_path / "root"
219
+ cats = tmp_path / "cats"
220
+ root.mkdir()
221
+ cats.mkdir()
222
+ missing = root / "gone.jpg"
223
+ present = root / "ok.jpg"
224
+ present.write_bytes(b"ok")
225
+ run_id = _seed_completed_run(
226
+ root,
227
+ cats,
228
+ [
229
+ {
230
+ "file_path": str(missing),
231
+ "primary_tag": "loli",
232
+ "final_destination": str(cats / "loli"),
233
+ "status": "approved",
234
+ },
235
+ {
236
+ "file_path": str(present),
237
+ "primary_tag": "loli",
238
+ "final_destination": None,
239
+ "status": "approved",
240
+ },
241
+ ],
242
+ )
243
+
244
+ with TestClient(app) as client:
245
+ resp = client.post(
246
+ f"/api/runs/{run_id}/migrate",
247
+ json={"mode": "copy", "create_missing_folders": True},
248
+ )
249
+ resp.raise_for_status()
250
+ payload = resp.json()
251
+ assert payload["total_candidates"] == 2
252
+ assert payload["migrated_count"] == 0
253
+ assert payload["failed_count"] == 2
254
+ errors = {r["error"] for r in payload["results"]}
255
+ assert any("does not exist" in (e or "") for e in errors)
256
+ assert any("No destination" in (e or "") for e in errors)
257
+
258
+
259
+ def test_migrate_create_missing_folders_false_fails_when_absent(tmp_path: Path) -> None:
260
+ root = tmp_path / "root"
261
+ cats = tmp_path / "cats"
262
+ root.mkdir()
263
+ cats.mkdir()
264
+ src = root / "x.jpg"
265
+ src.write_bytes(b"x")
266
+ dest = cats / "incest" # not created
267
+ run_id = _seed_completed_run(
268
+ root,
269
+ cats,
270
+ [
271
+ {
272
+ "file_path": str(src),
273
+ "primary_tag": "incest",
274
+ "final_destination": str(dest),
275
+ "status": "approved",
276
+ }
277
+ ],
278
+ )
279
+
280
+ with TestClient(app) as client:
281
+ resp = client.post(
282
+ f"/api/runs/{run_id}/migrate",
283
+ json={"mode": "copy", "create_missing_folders": False},
284
+ )
285
+ resp.raise_for_status()
286
+ payload = resp.json()
287
+ assert payload["migrated_count"] == 0
288
+ assert payload["failed_count"] == 1
289
+ assert not dest.exists()
290
+ assert "does not exist" in (payload["results"][0].get("error") or "").lower()
291
+
292
+
293
+ def test_migrate_rejects_running_run(tmp_path: Path) -> None:
294
+ root = tmp_path / "root"
295
+ cats = tmp_path / "cats"
296
+ root.mkdir()
297
+ cats.mkdir()
298
+ run_id = _seed_completed_run(root, cats, [], status="running")
299
+ with TestClient(app) as client:
300
+ resp = client.post(
301
+ f"/api/runs/{run_id}/migrate",
302
+ json={"mode": "copy", "create_missing_folders": True},
303
+ )
304
+ assert resp.status_code == 409
305
+
306
+
307
+ def test_migrate_idempotent_second_pass(tmp_path: Path) -> None:
308
+ root = tmp_path / "root"
309
+ dest = tmp_path / "cats" / "fellatio"
310
+ root.mkdir()
311
+ dest.mkdir(parents=True)
312
+ src = root / "f.jpg"
313
+ src.write_bytes(b"f")
314
+ run_id = _seed_completed_run(
315
+ root,
316
+ dest.parent,
317
+ [
318
+ {
319
+ "file_path": str(src),
320
+ "primary_tag": "fellatio",
321
+ "final_destination": str(dest),
322
+ "status": "approved",
323
+ }
324
+ ],
325
+ )
326
+ with TestClient(app) as client:
327
+ first = client.post(
328
+ f"/api/runs/{run_id}/migrate",
329
+ json={"mode": "copy", "create_missing_folders": True},
330
+ )
331
+ first.raise_for_status()
332
+ assert first.json()["migrated_count"] == 1
333
+ second = client.post(
334
+ f"/api/runs/{run_id}/migrate",
335
+ json={"mode": "copy", "create_missing_folders": True},
336
+ )
337
+ second.raise_for_status()
338
+ assert second.json()["total_candidates"] == 0
339
+ assert second.json()["migrated_count"] == 0
340
+
341
+
342
+ def test_approve_then_migrate_end_to_end(tmp_path: Path) -> None:
343
+ root = tmp_path / "root"
344
+ cats = tmp_path / "cats"
345
+ root.mkdir()
346
+ cats.mkdir()
347
+ src = root / "review_me.jpg"
348
+ src.write_bytes(b"rev")
349
+ dest = cats / "nakadashi"
350
+ run_id = _seed_completed_run(
351
+ root,
352
+ cats,
353
+ [
354
+ {
355
+ "file_path": str(src),
356
+ "primary_tag": "nakadashi",
357
+ "primary_score": 0.55,
358
+ "suggested_destination": str(dest),
359
+ "final_tag": "nakadashi",
360
+ "final_destination": str(dest),
361
+ "status": "proposed",
362
+ "needs_review": True,
363
+ "review_reason": "Below threshold",
364
+ }
365
+ ],
366
+ )
367
+ item = fetch_one("SELECT id FROM items WHERE run_id = ?", (run_id,))
368
+ with TestClient(app) as client:
369
+ patch = client.patch(f"/api/items/{item['id']}", json={"status": "approved"})
370
+ patch.raise_for_status()
371
+ assert patch.json()["status"] == "approved"
372
+ mig = client.post(
373
+ f"/api/runs/{run_id}/migrate",
374
+ json={"mode": "copy", "create_missing_folders": True},
375
+ )
376
+ mig.raise_for_status()
377
+ assert mig.json()["migrated_count"] == 1
378
+ assert (dest / "review_me.jpg").is_file()
backend/tests/test_classify_accuracy.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Accuracy audit for taxonomy routing + classify gates (score fixtures)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+ from PIL import Image
9
+
10
+ from app.api import _classify_from_scores
11
+ from app.taxonomy import choose_best_destination, reload_taxonomy
12
+
13
+
14
+ SELECTED_ALL = {
15
+ "fertilization",
16
+ "NTR",
17
+ "incest",
18
+ "nakadashi",
19
+ "fellatio",
20
+ "loli",
21
+ "shota",
22
+ "monster_girl",
23
+ "furry",
24
+ "Pokemon",
25
+ }
26
+
27
+
28
+ @pytest.fixture(autouse=True)
29
+ def _reload_default_taxonomy() -> None:
30
+ reload_taxonomy()
31
+
32
+
33
+ @pytest.mark.parametrize(
34
+ "scores,expected",
35
+ [
36
+ ({"loli": 0.92, "flat_chest": 0.99, "lolita_fashion": 0.95}, "loli"),
37
+ ({"shota": 0.88, "1boy": 0.99, "child": 0.9}, "shota"),
38
+ ({"netorare": 0.81, "caught": 0.99}, "NTR"),
39
+ ({"incest": 0.77, "siblings": 0.99}, "incest"),
40
+ ({"internal_cumshot": 0.9, "cum_in_pussy": 0.95}, "nakadashi"),
41
+ ({"fertilization": 0.8, "cum_in_pussy": 0.99}, "fertilization"),
42
+ ({"impregnation": 0.85, "pregnant": 0.99}, "fertilization"),
43
+ ({"irrumatio": 0.9}, "fellatio"),
44
+ ({"monster_girl": 0.9, "horns": 0.99, "wings": 0.98}, "monster_girl"),
45
+ ({"slime_girl": 0.86}, "monster_girl"),
46
+ ({"furry": 0.9, "animal_ears": 0.99}, "furry"),
47
+ ({"pokemon_(creature)": 0.91, "pokemon_ears": 0.99}, "Pokemon"),
48
+ # False-positive clusters must not route
49
+ ({"lolita_fashion": 0.99, "gothic_lolita": 0.98, "flat_chest": 0.97}, None),
50
+ ({"1boy": 0.99, "male_focus": 0.98, "otoko_no_ko": 0.9}, None),
51
+ ({"animal_ears": 0.99, "fake_animal_ears": 0.9}, None),
52
+ ({"siblings": 0.99}, None),
53
+ ({"pregnant": 0.99}, None),
54
+ ({"voyeurism": 0.99, "caught": 0.9}, None),
55
+ ({"horns": 0.99, "wings": 0.98, "tail": 0.97}, None),
56
+ ({"oral": 0.99}, None),
57
+ ],
58
+ )
59
+ def test_taxonomy_routing_matrix(scores: dict[str, float], expected: str | None) -> None:
60
+ folder, _score, _secondary = choose_best_destination(scores, SELECTED_ALL)
61
+ assert folder == expected
62
+
63
+
64
+ def test_classify_gate_clears_weak_taxonomy_winner(tmp_path: Path) -> None:
65
+ result = _classify_from_scores(
66
+ tmp_path / "x.jpg",
67
+ {"loli": 0.2, "1girl": 0.99},
68
+ {"loli", "shota"},
69
+ confidence_threshold=0.6,
70
+ )
71
+ assert result.primary_tag is None
72
+ assert result.needs_review is True
73
+ assert result.secondary[0]["tag"] == "loli"
74
+
75
+
76
+ def test_classify_assigns_strong_taxonomy_winner(tmp_path: Path) -> None:
77
+ result = _classify_from_scores(
78
+ tmp_path / "x.jpg",
79
+ {"loli": 0.91, "flat_chest": 0.95, "shota": 0.4},
80
+ {"loli", "shota", "furry"},
81
+ confidence_threshold=0.6,
82
+ )
83
+ assert result.primary_tag == "loli"
84
+ assert result.primary_score == 0.91
85
+ assert result.needs_review is False
86
+
87
+
88
+ def test_priority_fertilization_over_nakadashi_on_tie(tmp_path: Path) -> None:
89
+ result = _classify_from_scores(
90
+ tmp_path / "x.jpg",
91
+ {"fertilization": 0.8, "internal_cumshot": 0.8},
92
+ {"fertilization", "nakadashi"},
93
+ confidence_threshold=0.6,
94
+ )
95
+ assert result.primary_tag == "fertilization"
96
+
97
+
98
+ def test_model_smoke_nonempty_scores_all_taggers(tmp_path: Path) -> None:
99
+ """Smoke: each tagger returns a non-empty score dict on a tiny RGB image.
100
+
101
+ This is not semantic accuracy (no labels); it catches broken sessions /
102
+ empty-output regressions across models.
103
+ """
104
+ pytest.importorskip("imgutils")
105
+ from app.services import extract_scores
106
+
107
+ img = tmp_path / "probe.png"
108
+ Image.new("RGB", (448, 448), color=(180, 120, 160)).save(img)
109
+
110
+ models = ["ml_danbooru", "wd_swinv2_v3", "wd_eva02_large"]
111
+ for model in models:
112
+ try:
113
+ scores = extract_scores(img, tagger_model=model, wd_general_threshold=0.35)
114
+ except Exception as err: # pragma: no cover - environment-specific
115
+ pytest.skip(f"tagger {model} unavailable: {err}")
116
+ assert isinstance(scores, dict)
117
+ assert len(scores) > 0, f"{model} returned empty scores"
118
+ assert all(0.0 <= float(v) <= 1.0 for v in scores.values())