diff --git a/TESTING.md b/TESTING.md index 101ad57f..46b91b7b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -4845,6 +4845,8 @@ follows the safety backup policy like them (no forced backup). Rules writes `prompt_pairs`, `prompt_pair_tags`, `updated_at`, a tag or Library state. - The job lists 200 changed rows ("Prompt 12: category “night”; tags a, b"); its Download report lists every one (`truncated: false`, `Job.record_counted(complete=True)`). + Protected lists one row per kept prompt in the review's wording and order ("Prompt 7: + kept, pairs disagree on category: Adult, asian"), also complete in the report. - Activity shows a row with no path as a record: its detail as plain text, never an open or locate action, and the filter reads the detail. A maintenance job of records (every operation but Missing-file links) says "No records in this group.", "No listed record diff --git a/database/pair_metadata_repair.py b/database/pair_metadata_repair.py index 4b8a497a..0e430764 100644 --- a/database/pair_metadata_repair.py +++ b/database/pair_metadata_repair.py @@ -304,6 +304,25 @@ def _protected_detail(result, key): return "Kept: pairs disagree on tags" +def _kept(results): + """One row per kept prompt, its review reasons without "Kept: " (#550).""" + rows = [] + for result in results: + classes = set(_protected_classes(result)) + if classes: + rows.append( + { + "prompt_id": result["id"], + "reasons": [ + _protected_detail(result, key).removeprefix("Kept: ") + for key in DETAIL_ORDER + if key in classes + ], + } + ) + return rows + + def _details(results, targets): rows = [] for key in DETAIL_ORDER: @@ -398,8 +417,10 @@ def _fill_tags(conn, fills, expected): def apply_pair_metadata(conn, fingerprint, targets): """Apply the reviewed fills on ``conn`` (inside BEGIN IMMEDIATE). - Returns ``{changed, categories, tag_sets, rows}``; ``rows`` lists every - filled prompt (``{prompt_id, category?, tags?}``), never capped. + Returns ``{changed, categories, tag_sets, rows, kept}``; ``rows`` lists + every filled prompt (``{prompt_id, category?, tags?}``) and ``kept`` every + prompt the plan keeps (``{prompt_id, reasons}``, in the review's wording + and order), both never capped. """ results, current = _state(conn) if current != fingerprint or _targets(results) != targets: @@ -427,4 +448,5 @@ def apply_pair_metadata(conn, fingerprint, targets): "categories": len(categories), "tag_sets": len(tags), "rows": rows, + "kept": _kept(results), } diff --git a/py/api/maintenance.py b/py/api/maintenance.py index 00ea4c12..0320e167 100644 --- a/py/api/maintenance.py +++ b/py/api/maintenance.py @@ -697,7 +697,19 @@ class MaintenanceRoutesMixin: rows, complete=plan.operation in COMPLETE_RECORD, ) - job.record_counted("protected", payload["protected"]) + if "kept" in result: + # #550: each kept record with its reason, like the changed ones. + kept = [ + { + "path": "", + "detail": f"Prompt {item['prompt_id']}: kept, " + + "; ".join(item["reasons"]), + } + for item in result["kept"] + ] + job.record_counted("protected", len(kept), kept, complete=True) + else: + job.record_counted("protected", payload["protected"]) line = _changed_line( plan.operation, result["changed"], result.get("marks", 0), result ) diff --git a/tests/browser/workspace-system-maintenance.spec.mjs b/tests/browser/workspace-system-maintenance.spec.mjs index be893bcc..9b7c3f66 100644 --- a/tests/browser/workspace-system-maintenance.spec.mjs +++ b/tests/browser/workspace-system-maintenance.spec.mjs @@ -225,8 +225,13 @@ test('D192: Pair category and tags reviews old Pair prompts, keeps an edited one expect(await records.first().evaluate(node => node.localName)).toBe('div'); await expect(results.locator('.outcome-files button')).toHaveCount(0); await expect(results.getByRole('searchbox', { name: 'Filter results' })).toHaveAttribute('placeholder', 'Filter records…'); - // Protected is counted, not listed: it says records, not files. + // Protected lists each kept prompt with the review's reason (#550 live re-check). await results.locator('.outcome-list > button').filter({ hasText: 'Protected' }).click(); + await expect(records).toHaveCount(1); + await expect(records.first()).toHaveText( + /^Prompt \d+: kept, edited after it was saved; its empty category or tags may be intentional$/); + // An empty group says records, not files. + await results.locator('.outcome-list > button').filter({ hasText: 'Errors' }).click(); await expect(results.locator('.outcome-files > p.empty-state')).toHaveText('No records in this group.'); const library = await serverJson(request, '/prompt_manager/library'); const lantern = library.items.find(item => item.text === 'S550 lantern'); diff --git a/tests/test_api_maintenance_pair_metadata.py b/tests/test_api_maintenance_pair_metadata.py index c8a1f02a..08fc3365 100644 --- a/tests/test_api_maintenance_pair_metadata.py +++ b/tests/test_api_maintenance_pair_metadata.py @@ -84,6 +84,47 @@ class TestPairMetadataRoutes(PairMetadataAPITestCase): self.assertEqual(plan["protected"]["reasons"], ["1 where pairs disagree"]) self.assertEqual(job.counters["protected"], 1) self.assertIsNone(self.category(prompt)) + # Live test (#550): the count comes with its rows, in the review's wording. + self.assertEqual( + job.outcomes["protected"], + [ + { + "path": "", + "root": "", + "root_index": None, + "detail": ( + f"Prompt {prompt}: kept, pairs disagree on category: x, y" + ), + } + ], + ) + report = job.report()["outcomes"]["protected"] + self.assertEqual((report["count"], report["truncated"]), (1, False)) + + async def test_report_lists_every_kept_prompt(self): + PromptManagerConfig.SAFETY_BACKUP = False + count = MAX_OUTCOME_ROWS + 5 + for n in range(count): + _pair, prompt = self.pair(f"kept {n:03d}", category="portrait") + with self.api.db.model.get_connection() as conn: + conn.execute( + "UPDATE prompts SET updated_at='2099-01-01T00:00:00+00:00' " + "WHERE id=?", + (prompt,), + ) + _plan, _summary, job = await self.run_apply(OPERATION) + self.assertEqual(job.counters["protected"], count) + self.assertEqual(len(job.outcomes["protected"]), MAX_OUTCOME_ROWS) + protected = job.report()["outcomes"]["protected"] + self.assertEqual( + (len(protected["rows"]), protected["truncated"]), (count, False) + ) + self.assertTrue( + protected["rows"][0]["detail"].endswith( + ": kept, edited after it was saved; its empty category or tags " + "may be intentional" + ) + ) async def test_policy_off_writes_without_a_backup(self): PromptManagerConfig.SAFETY_BACKUP = False diff --git a/tests/test_maintenance_pair_metadata.py b/tests/test_maintenance_pair_metadata.py index 400071bd..df401098 100644 --- a/tests/test_maintenance_pair_metadata.py +++ b/tests/test_maintenance_pair_metadata.py @@ -401,6 +401,52 @@ class TestApply(PairMetadataCase): ) self.assertEqual(self.sql("SELECT id, name FROM tags ORDER BY id"), tag_rows) + def test_apply_returns_one_kept_row_per_protected_prompt(self): + _a, conflict = self.pair("conflict", "n1", category="asian") + self.pair("conflict", "n2", category="Adult") + _b, edited = self.pair("edited", category="portrait") + self.edit_stamp(edited) + _c, both = self.pair( + "both", "n1", category="x", tags=[f"t{n}" for n in range(21)] + ) + self.pair("both", "n2", category="y", tags=[f"t{n}" for n in range(21)]) + _d, filled = self.pair("fill", category="portrait") + draft = self.preview() + result = self.apply(draft) + self.assertEqual( + result["kept"], + [ + { + "prompt_id": conflict, + "reasons": ["pairs disagree on category: Adult, asian"], + }, + { + "prompt_id": edited, + "reasons": [ + "edited after it was saved; its empty category or tags " + "may be intentional" + ], + }, + { + "prompt_id": both, + "reasons": [ + "pairs disagree on category: x, y", + "its pair has more than 20 tags", + ], + }, + ], + ) + self.assertEqual(len(result["kept"]), draft.protected["count"]) + self.assertEqual(self.category(filled), "portrait") + + def test_kept_rows_are_never_capped(self): + for n in range(205): + _p, prompt = self.pair(f"kept {n:03d}", category="portrait") + self.edit_stamp(prompt) + result = self.apply(self.preview()) + self.assertEqual(result["changed"], 0) + self.assertEqual(len(result["kept"]), 205) + def test_second_run_finds_nothing_and_a_partial_fill_completes_later(self): _a, prompt = self.pair("partial", "n1", category="portrait", tags=["a"]) p2, _same = self.pair("partial", "n2", category="portrait", tags=["c"])