From 930f5c72d0658b09435cf3d5787f922be07db223 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sat, 17 Oct 2026 06:58:22 -0700 Subject: [PATCH] fix(pairs): honor recent sorting and conservative scan outcomes (#193) --- database/pairs.py | 11 +++++++++-- py/api/admin.py | 26 +++++++++++++++++++------- py/api/pairs.py | 1 + tests/js/scan-progress.test.js | 8 ++++++++ tests/test_api_pairs.py | 9 +++++++++ tests/test_pair_scan.py | 30 ++++++++++++++++++++++++++++++ 6 files changed, 76 insertions(+), 9 deletions(-) diff --git a/database/pairs.py b/database/pairs.py index 9738549..c250b02 100644 --- a/database/pairs.py +++ b/database/pairs.py @@ -191,8 +191,15 @@ class PairOperations: include_archived: bool = False, limit: int = 50, offset: int = 0, + sort: str = "newest", ) -> Dict[str, Any]: - """List pairs with all-tag filtering and stable newest-first pagination.""" + """List pairs with all-tag filtering and stable paginated ordering.""" + orders = { + "newest": "pp.id DESC", + "last_used_desc": "julianday(pp.last_used_at) DESC, pp.id DESC", + } + if sort not in orders: + raise ValueError("Unknown pair sort order") if type(limit) is not int or not 1 <= limit <= 500: raise ValueError("limit must be between 1 and 500") if type(offset) is not int or offset < 0: @@ -227,7 +234,7 @@ class PairOperations: conn, where, [*params, limit, offset], - " ORDER BY pp.id DESC LIMIT ? OFFSET ?", + f" ORDER BY {orders[sort]} LIMIT ? OFFSET ?", ) return { "pairs": pairs, diff --git a/py/api/admin.py b/py/api/admin.py index 35b6552..8175854 100644 --- a/py/api/admin.py +++ b/py/api/admin.py @@ -1480,7 +1480,16 @@ class AdminRoutesMixin: continue counts["found"] += 1 status = outcome["status"] if isinstance(outcome, dict) else outcome - if status in counts: + if status in ( + "added", + "linked", + "enriched", + "unchanged", + "ambiguous", + "unresolved", + "conflict", + "failed", + ): counts[status] += 1 if isinstance(outcome, dict): counts["pairs_added"] += outcome.get("pairs_added", 0) @@ -1492,11 +1501,12 @@ class AdminRoutesMixin: if not metadata: return None canonical = dict(metadata) - if isinstance(canonical.get("prompt"), str): - try: - canonical["prompt"] = json.loads(canonical["prompt"]) - except (ValueError, TypeError): - pass + for key in ("prompt", "workflow"): + if isinstance(canonical.get(key), str): + try: + canonical[key] = json.loads(canonical[key]) + except (ValueError, TypeError): + pass if not canonical.get("prompt") and not canonical.get("parameters"): return None resolution = resolve_prompt_pair(canonical) @@ -1505,7 +1515,9 @@ class AdminRoutesMixin: self.db, str(media_file), canonical, resolution ) return result if detailed else result["status"] - if resolution["state"] in ("ambiguous", "unresolved"): + if resolution["state"] == "ambiguous": + return "ambiguous" + if resolution["state"] == "unresolved": if canonical.get("prompt") or not isinstance( canonical.get("parameters"), str ): diff --git a/py/api/pairs.py b/py/api/pairs.py index ab31e2c..acbde50 100644 --- a/py/api/pairs.py +++ b/py/api/pairs.py @@ -129,6 +129,7 @@ class PairRoutesMixin: category=request.query.get("category"), tags=request.query.getall("tag", []), include_archived=archived == "true", + sort=request.query.get("sort", "newest"), limit=limit, offset=offset, ) diff --git a/tests/js/scan-progress.test.js b/tests/js/scan-progress.test.js index 008cff0..837bc7a 100644 --- a/tests/js/scan-progress.test.js +++ b/tests/js/scan-progress.test.js @@ -95,3 +95,11 @@ test('completion label reports pair enrichment, unresolved work and failures', ( assert.match(completionLabel(state),/4 need review, 1 failed/); assert.match(completionLabel({...state,cancelled:true}),/^Scan cancelled!/); }); + +test('older servers without pair counts retain a complete readable summary', () => { + const {completionLabel}=require('../../web/js/scan-progress.js'); + const state=reduceScanEvent(initialState(),{type:'complete',processed:2,found:1,added:1,linked:0}); + assert.match(completionLabel(initialState()),/0 new pairs, 0 pair links; 0 need review, 0 failed/); + assert.match(completionLabel(state),/0 enriched, 0 unchanged/); + assert.equal(fromStatus({running:true,last_event:{type:'unknown'}}).status,'running'); +}); diff --git a/tests/test_api_pairs.py b/tests/test_api_pairs.py index d0b6503..bf95c66 100644 --- a/tests/test_api_pairs.py +++ b/tests/test_api_pairs.py @@ -113,3 +113,12 @@ class TestPairAPI(PromptAPITestCase): "/prompt_manager/bulk/delete", json={"prompt_ids": [prompt_id]} ) self.assertEqual(response.status, 409) + + async def test_recent_pairs_sort_by_use_and_reject_unknown_sort(self): + first = (await (await self._create()).json())["pair"] + await self._create(negative_text="noise") + self.api.db.record_prompt_pair_use(first["id"]) + response = await self.client.get("/prompt_manager/pairs?sort=last_used_desc") + self.assertEqual((await response.json())["pairs"][0]["id"], first["id"]) + response = await self.client.get("/prompt_manager/pairs?sort=invalid") + self.assertEqual(response.status, 400) diff --git a/tests/test_pair_scan.py b/tests/test_pair_scan.py index 939a909..c8d055d 100644 --- a/tests/test_pair_scan.py +++ b/tests/test_pair_scan.py @@ -81,3 +81,33 @@ class TestPairScan(AdminAPITestCase): )["unchanged"], 1, ) + + def test_legacy_found_outcome_is_not_counted_twice(self): + result = self.api._ingest_scan_batch_sync( + [(self.output_dir / "blank.png", {"parameters": "Negative prompt: blur"})] + ) + self.assertEqual(result["processed"], 1) + self.assertEqual(result["found"], 1) + + def test_ambiguous_a1111_does_not_fall_back_to_legacy_guess(self): + meta = { + "parameters": ( + "cat\nNegative prompt: blur\nNegative prompt: noise" + "\nSteps: 20, Sampler: Euler" + ) + } + self.assertEqual( + self.api._ingest_scanned_file(self.output_dir / "ambiguous.png", meta), + "ambiguous", + ) + self.assertEqual(self.api.db.get_recent_prompts()["prompts"], []) + + def test_raw_png_workflow_is_stored_as_object_and_repeat_is_unchanged(self): + path = self.output_dir / "workflow.png" + workflow = {"nodes": [{"id": 1, "type": "PromptManagerPair"}]} + metadata = {"prompt": json.dumps(graph()), "workflow": json.dumps(workflow)} + self.assertEqual(self.api._ingest_scanned_file(path, metadata), "added") + self.assertEqual( + self.api.db.get_image_prompt_info(str(path))["workflow_data"], workflow + ) + self.assertEqual(self.api._ingest_scanned_file(path, metadata), "unchanged")