From e137270f45f6d3f173a078ebd280819eb1638038 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Wed, 28 Oct 2026 06:41:55 -0700 Subject: [PATCH 1/3] fix(ingestion): no joined PromptManager text from the legacy floor; junk keeps its step's status The legacy parser returns prepend + text + append, so its answer is refused when a PromptManager-family node has a prepend or append part (#343). Junk found at the undecided-graph step keeps that step's unlinked status and review, so an ambiguous file is still queued for Needs review. The monitor policy's ambiguous file no longer asks for the recorded run, and the file size may be read lazily. --- tests/test_file_prompt.py | 85 ++++++++++++++++++++++++++++++++++++++- utils/file_prompt.py | 57 +++++++++++++++++++++----- 2 files changed, 130 insertions(+), 12 deletions(-) diff --git a/tests/test_file_prompt.py b/tests/test_file_prompt.py index 79c944f0..bceea11c 100644 --- a/tests/test_file_prompt.py +++ b/tests/test_file_prompt.py @@ -82,6 +82,43 @@ def _gated(graph): return graph +def _prompt_manager_branches(prepend="PRE", append="APP"): + """Two KSampler branches, each fed by a PromptManagerText string.""" + graph = sampled_graph() + del graph["1"] + sampler = graph.pop("3") + for index, text in enumerate(("a cat", "a dog"), start=1): + base = index * 10 + graph[str(base)] = { + "class_type": "PromptManagerText", + "inputs": {"text": text, "prepend_text": prepend, "append_text": append}, + } + graph[str(base + 1)] = { + "class_type": "CLIPTextEncode", + "inputs": {"text": [str(base), 0]}, + } + graph[str(base + 2)] = { + **sampler, + "inputs": {**sampler["inputs"], "positive": [str(base + 1), 0]}, + } + return graph + + +def _numeric_first_branches(): + """Three branches; the legacy parser's answer (the first) is a number.""" + graph = sampled_graph("2048") + for index, text in ((5, "a cat"), (6, "a dog")): + graph[str(index * 10)] = { + "class_type": "CLIPTextEncode", + "inputs": {"text": text}, + } + graph[str(index * 10 + 1)] = { + **graph["3"], + "inputs": {**graph["3"]["inputs"], "positive": [str(index * 10), 0]}, + } + return graph + + class TestStepsBeforeResolving(unittest.TestCase): def test_step_1_no_metadata_tries_the_recorded_run(self): for metadata in ({}, None): @@ -114,6 +151,19 @@ class TestStepsBeforeResolving(unittest.TestCase): ) self.assertNotIn("file_info", _decide(_graph(sampled_graph())).canonical) + def test_a_file_size_reader_is_called_only_for_prompt_metadata(self): + reads = [] + + def size(): + reads.append(1) + return 9 + + for metadata in ({}, {"workflow": "{}"}): + _decide(metadata, file_size=size) + self.assertEqual(reads, []) + decision = _decide(_graph(sampled_graph()), file_size=size) + self.assertEqual((reads, decision.canonical["file_info"]), ([1], {"size": 9})) + class TestResolvedSteps(unittest.TestCase): def test_step_6_a_blank_positive_counts_as_unresolved(self): @@ -184,8 +234,10 @@ class TestUndecidedGraph(unittest.TestCase): self.assertEqual(decision.source, "legacy-parser") def test_step_8_1_the_file_name_anchor_alone_never_decides(self): - """#390 ruling: a file only its saver's file name resolves is queued - for Needs review by the scan, never linked; the monitor leaves it.""" + """#390: the file-name anchor never decides a link. The scan's legacy + floor may still link the file (as before #247), and a file nothing + links is queued with the anchor's choice per #390; the monitor + stores nothing.""" name = "s45_branch_2_00001_.png" scan = _decide(_graph(shapes.branch_bench()), lookup=_Lookup(), file_name=name) self.assertEqual((scan.kind, scan.source), ("positive", "legacy-parser")) @@ -231,8 +283,26 @@ class TestUndecidedGraph(unittest.TestCase): ) self.assertEqual(decision.kind, "never") self.assertEqual(decision.unlinked_status, "ambiguous") + self.assertFalse(decision.try_recorded_run) self.assertEqual(lookup.calls, 1) + def test_step_8_4_joined_prompt_manager_text_is_never_the_legacy_answer(self): + """The legacy parser returns prepend + text + append; the stored text + of a PromptManager node cannot be told from it.""" + decision = _decide(_graph(_prompt_manager_branches()), lookup=_Lookup()) + self.assertEqual(decision.kind, "never") + self.assertEqual(decision.unlinked_status, "ambiguous") + self.assertEqual(decision.review, "ambiguous") + self.assertTrue(decision.try_recorded_run) + linked = _prompt_manager_branches(prepend=["99", 0], append="") + self.assertEqual(_decide(_graph(linked), lookup=_Lookup()).kind, "never") + + def test_step_8_4_prompt_manager_text_without_parts_keeps_the_floor(self): + """Regression guard (passes on the base): no prepend or append.""" + graph = _prompt_manager_branches(prepend="", append=" ") + decision = _decide(_graph(graph), lookup=_Lookup()) + self.assertEqual((decision.kind, decision.text), ("positive", "a cat")) + def test_step_8_4_an_ambiguous_file_gets_the_legacy_answer_in_the_scan(self): decision = _decide(_graph(shapes.shared_prefix()), lookup=_Lookup()) self.assertEqual(decision.kind, "positive") @@ -358,6 +428,7 @@ class TestJunkGate(unittest.TestCase): "nan", "a portrait saved as final.safetensors", "4k", + "C:\\My Models\\x.safetensors", ): with self.subTest(text=text): self.assertIsNone(junk_text(text)) @@ -381,6 +452,16 @@ class TestJunkGate(unittest.TestCase): decision = _decide(_graph(_gated(sampled_graph(LORA))), lookup=_Lookup()) self.assertEqual(decision.source, "junk:lora-only") self.assertEqual(decision.kind, "never") + self.assertEqual(decision.unlinked_status, "unresolved") + + def test_step_13_junk_at_step_8_keeps_that_steps_status_and_review(self): + # An ambiguous file whose legacy answer is a number is still a + # Needs review case: its status stays "ambiguous". + decision = _decide(_graph(_numeric_first_branches()), lookup=_Lookup()) + self.assertEqual((decision.kind, decision.source), ("never", "junk:number")) + self.assertEqual(decision.unlinked_status, "ambiguous") + self.assertEqual(decision.review, "ambiguous") + self.assertTrue(decision.try_recorded_run) class TestParseComfyuiPrompt(unittest.TestCase): diff --git a/utils/file_prompt.py b/utils/file_prompt.py index b0b6b81d..ae6bb4f2 100644 --- a/utils/file_prompt.py +++ b/utils/file_prompt.py @@ -20,7 +20,7 @@ import json import re from dataclasses import dataclass, field from enum import Enum -from typing import Any, Callable, Mapping, Optional +from typing import Any, Callable, Mapping, Optional, Union from .generation_params import ( PLACEHOLDERS, @@ -95,7 +95,7 @@ def decide_file_prompt( metadata: Any, *, file_name: Optional[str], - file_size: Optional[int], + file_size: Union[None, int, Callable[[], Optional[int]]], in_output: bool, lookup: Optional[Callable[[], Any]], policy: Policy, @@ -106,6 +106,8 @@ def decide_file_prompt( (the monitor). ``lookup`` returns the file's ``RecordedOutput`` or None; it is None when history must not be asked yet. It is called at most once, and only for a graph the default profile leaves undecided. + ``file_size`` may be a callable: it is read only for a file with prompt + metadata whose ``file_info`` has no size. """ if not metadata: return Decision(NEVER, try_recorded_run=True) @@ -127,7 +129,7 @@ def decide_file_prompt( lookup, policy, ) - return _gated(_decide(found)) + return _gated(_decide(found), found) def _decide(found: _File) -> Decision: @@ -165,7 +167,11 @@ def _undecided_graph(found: _File) -> Decision: if found.lookup is None and found.in_output: return _decision(found, HISTORY, None, "none") if not inspected.malformed and inspected.resolution["state"] == "ambiguous": - return _never(found, _unlinked(found), try_recorded_run=True) + return _never(found, _unlinked(found)) + if _joins_prompt_manager_parts(found.canonical["prompt"]): + # The legacy parser joins prepend + text + append: the text a + # PromptManager node stores cannot be told from it (#343). + return _never(found, _unlinked(found), try_recorded_run=True) text = graph_prompt_text(found.metadata) if text is not None: return _decision(found, POSITIVE, text, "legacy-parser") @@ -187,8 +193,35 @@ def _sampler_less(found: _File, graph: Any) -> Decision: return _never(found, status, try_recorded_run=True) -def _gated(decision: Decision) -> Decision: - """Junk text is never stored; the file reports ``found`` (step 13).""" +_UNDECIDED_SOURCES = ("inspector", "recorded-run", "legacy-parser") + + +def _joins_prompt_manager_parts(graph: Any) -> bool: + """Whether a PromptManager-family node has a prepend or append part.""" + if not isinstance(graph, dict): + return False + for node in graph.values(): + if not isinstance(node, dict): + continue + name, inputs = node.get("class_type"), node.get("inputs") + if not isinstance(name, str) or "promptmanager" not in name.lower(): + continue + if not isinstance(inputs, dict): + continue + for key in ("prepend_text", "append_text"): + part = inputs.get(key) + if part is not None and not (isinstance(part, str) and not part.strip()): + return True + return False + + +def _gated(decision: Decision, found: _File) -> Decision: + """Junk text is never stored (step 13). + + A junk answer of the undecided-graph step keeps that step's unlinked + status, so an ambiguous file is still a Needs review case; any other + step reports ``found``. + """ if decision.kind not in (PAIR, POSITIVE): return decision junk = junk_text(decision.text) @@ -201,7 +234,9 @@ def _gated(decision: Decision) -> Decision: source=f"junk:{junk}", review=decision.review, try_recorded_run=True, - unlinked_status="found", + unlinked_status=( + _unlinked(found) if decision.source in _UNDECIDED_SOURCES else "found" + ), ) @@ -263,7 +298,7 @@ def _parsed(metadata: Mapping) -> dict: return canonical -def _with_size(canonical: dict, file_size: Optional[int]) -> None: +def _with_size(canonical: dict, file_size: Any) -> None: """Record the file's size in ``file_info`` unless the metadata carries one. Stored as ``generated_images.file_size``; the "new" scan scope skips a @@ -271,8 +306,10 @@ def _with_size(canonical: dict, file_size: Optional[int]) -> None: """ info = canonical.get("file_info") info = dict(info) if isinstance(info, dict) else {} - if info.get("size") is None and file_size is not None: - info["size"] = file_size + if info.get("size") is None: + size = file_size() if callable(file_size) else file_size + if size is not None: + info["size"] = size if info: canonical["file_info"] = info From a6d634509b7c87ac21c8ad9d8065a52b13e7e51b Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Wed, 28 Oct 2026 06:43:02 -0700 Subject: [PATCH 2/3] fix(ingestion): keep the legacy answer that is a PromptManager node's own text The joined-text refusal also refused the #170 parity files whose PromptManager prepend is a link the browser parser does not follow: its answer is then the node's literal text, which is what the node stores. Refuse only an answer that is not such a node's own text. --- tests/test_file_prompt.py | 5 ++++- utils/file_prompt.py | 40 +++++++++++++++++++++++++++++---------- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/tests/test_file_prompt.py b/tests/test_file_prompt.py index bceea11c..23991b60 100644 --- a/tests/test_file_prompt.py +++ b/tests/test_file_prompt.py @@ -294,8 +294,11 @@ class TestUndecidedGraph(unittest.TestCase): self.assertEqual(decision.unlinked_status, "ambiguous") self.assertEqual(decision.review, "ambiguous") self.assertTrue(decision.try_recorded_run) + # A linked part the parser does not follow: its answer is the node's + # own text, which is what the node stores (#170 parity). linked = _prompt_manager_branches(prepend=["99", 0], append="") - self.assertEqual(_decide(_graph(linked), lookup=_Lookup()).kind, "never") + decision = _decide(_graph(linked), lookup=_Lookup()) + self.assertEqual((decision.kind, decision.text), ("positive", "a cat")) def test_step_8_4_prompt_manager_text_without_parts_keeps_the_floor(self): """Regression guard (passes on the base): no prepend or append.""" diff --git a/utils/file_prompt.py b/utils/file_prompt.py index ae6bb4f2..d66594b2 100644 --- a/utils/file_prompt.py +++ b/utils/file_prompt.py @@ -168,11 +168,11 @@ def _undecided_graph(found: _File) -> Decision: return _decision(found, HISTORY, None, "none") if not inspected.malformed and inspected.resolution["state"] == "ambiguous": return _never(found, _unlinked(found)) - if _joins_prompt_manager_parts(found.canonical["prompt"]): + text = graph_prompt_text(found.metadata) + if text is not None and _joined_text(found.canonical["prompt"], text): # The legacy parser joins prepend + text + append: the text a # PromptManager node stores cannot be told from it (#343). return _never(found, _unlinked(found), try_recorded_run=True) - text = graph_prompt_text(found.metadata) if text is not None: return _decision(found, POSITIVE, text, "legacy-parser") return _never(found, _unlinked(found), try_recorded_run=True) @@ -196,22 +196,42 @@ def _sampler_less(found: _File, graph: Any) -> Decision: _UNDECIDED_SOURCES = ("inspector", "recorded-run", "legacy-parser") -def _joins_prompt_manager_parts(graph: Any) -> bool: - """Whether a PromptManager-family node has a prepend or append part.""" +def _joined_text(graph: Any, text: str) -> bool: + """Whether ``text`` may hold a PromptManager node's prepend or append. + + True when a PromptManager-family node has a prepend or append part and + ``text`` is not the literal ``text`` of one such node (the legacy + parser's answer when it does not follow a linked part). + """ + own = _prompt_manager_parts(graph) + return own is not None and text.strip() not in own + + +def _prompt_manager_parts(graph: Any) -> Optional[set]: + """The literal texts of PromptManager-family nodes with a prepend or + append part, or None when no such node exists.""" if not isinstance(graph, dict): - return False + return None + found = None for node in graph.values(): if not isinstance(node, dict): continue name, inputs = node.get("class_type"), node.get("inputs") if not isinstance(name, str) or "promptmanager" not in name.lower(): continue - if not isinstance(inputs, dict): + if not isinstance(inputs, dict) or not _has_part(inputs): continue - for key in ("prepend_text", "append_text"): - part = inputs.get(key) - if part is not None and not (isinstance(part, str) and not part.strip()): - return True + found = found if found is not None else set() + if isinstance(inputs.get("text"), str): + found.add(inputs["text"].strip()) + return found + + +def _has_part(inputs: dict) -> bool: + for key in ("prepend_text", "append_text"): + part = inputs.get(key) + if part is not None and not (isinstance(part, str) and not part.strip()): + return True return False From b107de70f805b406a9ea27210577a0b2bf723e97 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Wed, 28 Oct 2026 06:43:48 -0700 Subject: [PATCH 3/3] fix(scan): one prompt per file for recorded-run links; writes survive a library move The recorded run's stored prompt now links in one transaction with the file's link rows, so a file linked to another prompt is a conflict instead of a second link. The scan's stores run through survive_library_switch. A single file asks history once (a one-file _BatchHistory), the scan passes in_output=False and reads the file size only for files with prompt metadata. A conflict row reads "Kept: already linked to a different prompt". TESTING.md records the rules. --- TESTING.md | 22 +++++++---- database/scanned_prompts.py | 34 ++++++++++++++++- py/api/admin.py | 69 +++++++++++++++++------------------ tests/test_pair_scan.py | 41 +++++++++++++++++++++ tests/test_run_attribution.py | 61 +++++++++++++++++++++++++++++++ tests/test_scanned_prompts.py | 21 +++++++++++ 6 files changed, 203 insertions(+), 45 deletions(-) diff --git a/TESTING.md b/TESTING.md index acdb15e6..8baf446f 100644 --- a/TESTING.md +++ b/TESTING.md @@ -5050,12 +5050,14 @@ workflows without a PromptManager node. It does no file or database I/O. pair, A1111, sampler-less). Only a graph it leaves ambiguous or unresolved reaches the inspector's resolve (`inspection_resolution` in `utils/prompt_inspector.py`, the inspection profile, then the recorded saver - anchor of #386; never the file-name saver anchor: a file only its saver's - file name resolves is queued for Needs review, never linked, the #390 ruling, - `name_anchor=False`), and only that positive's `storage_text` is stored (never + anchor of #386; `name_anchor=False`: the file-name anchor never decides a + link; the scan's legacy floor may still link the file, as before #247, and a + file nothing links is queued with the anchor's choice per #390), and only that positive's `storage_text` is stored (never prepend or append text, never `recorded_texts`). Then the #170 browser parser (`graph_prompt_text`), the legacy floor, then the recorded run's stored - prompt. History is asked once per file and only at that step; the scan's + prompt. The legacy answer is refused when a PromptManager-family node has a + prepend or append part and the answer is not that node's own `text` (the + parser joins the parts, so the stored text cannot be told from it). History is asked once per file and only at that step; the scan's batch lookup stays lazy and makes one `recorded_runs` pass for the stored prompt and the saver (`_BatchHistory`). - **Policies.** `SCAN` keeps the legacy floor for every undecided file. @@ -5064,14 +5066,18 @@ workflows without a PromptManager node. It does no file or database I/O. malformed one still gets the legacy floor. - **Junk.** `junk_text` refuses `blank`, `number` (the #252 `_numeric` rule), `lora-only` (only `` tags and separators) and `model-file` (one - token ending in a model extension) at whichever step found the text; the - file reports `found` and nothing later is tried. + token ending in a model extension) at whichever step found the text and + nothing later is tried; the file reports `found`, except at the undecided + graph step, which keeps its own status (an ambiguous file is still queued). - **One prompt per file.** `database/scanned_prompts.py` stores a positive in one `BEGIN IMMEDIATE` transaction: a file already linked to another prompt (either spelling, `file_link_rows`/`link_conflict`, the pair ingester's rule) is `conflict` and nothing is written; a failed link rolls the new prompt back - (`found`); two writers of one text store one prompt. A rescan therefore never - adds a second link to a file an older scan linked to the legacy answer. + (`found`); two writers of one text store one prompt. A recorded run's stored + prompt links the same way (`link_stored_prompt_or_raise`). A rescan therefore + never adds a second link to a file an older scan linked to the legacy answer; + the row reads "Kept: already linked to a different prompt". Every scan write + runs through `survive_library_switch`, so a library move reruns it. - **Marks.** Every prompt ingestion creates, pair components included, carries category `scanned`, tag `auto-scanned` and the note `Auto-scanned from ` (`SCAN_MARKS`); a reused prompt keeps its fields. Run counts are diff --git a/database/scanned_prompts.py b/database/scanned_prompts.py index aa34a11f..4cfc611d 100644 --- a/database/scanned_prompts.py +++ b/database/scanned_prompts.py @@ -47,11 +47,41 @@ def store_scanned_prompt_or_raise( def store_scanned_prompt( db: Any, path: str, canonical: Dict[str, Any], text: str, marks: Marks ) -> Tuple[str, Optional[int]]: - """The scan's form: an error is logged and reported as ``("found", None)``.""" + """The scan's form: an error is logged and reported as ``("found", None)``. + + A write that failed because Move library switched runs again on the new + library (``survive_library_switch``); the rerun re-checks the link rows. + """ try: - return store_scanned_prompt_or_raise(db, path, canonical, text, marks) + return db.survive_library_switch( + lambda: store_scanned_prompt_or_raise(db, path, canonical, text, marks) + ) except Exception as error: _LOGGER.error( f"Failed to store the prompt of {os.path.basename(path)}: {error}" ) return "found", None + + +def link_stored_prompt_or_raise( + db: Any, path: str, canonical: Dict[str, Any], prompt_id: int +) -> Optional[str]: + """Link ``path`` to the stored prompt ``prompt_id`` (recorded history, #342). + + ``linked``, ``unchanged`` (already linked to it), ``conflict`` (linked to + another prompt; nothing written) or None when the prompt is gone. One + ``BEGIN IMMEDIATE`` transaction; database errors are raised. + """ + with db._pair_transaction() as conn: + rows = file_link_rows(conn, path) + if len(rows) > 1 or (rows and rows[0]["prompt_id"] != prompt_id): + return "conflict" + if rows: + return "unchanged" + exists = conn.execute( + "SELECT 1 FROM prompts WHERE id = ?", (prompt_id,) + ).fetchone() + if exists is None: + return None + db._insert_image_link(conn, prompt_id, path, canonical) + return "linked" diff --git a/py/api/admin.py b/py/api/admin.py index 68b9bf5a..8f38671e 100644 --- a/py/api/admin.py +++ b/py/api/admin.py @@ -44,26 +44,26 @@ try: from ...database.run_attribution import ( RecordedOutput, host_output_directory, - in_output_folder, named_prompt, - recorded_output_lookup, recorded_runs, - stored_run_prompt, ) - from ...database.scanned_prompts import store_scanned_prompt + from ...database.scanned_prompts import ( + link_stored_prompt_or_raise, + store_scanned_prompt, + ) from ...utils.file_prompt import NEVER, PAIR, Policy, decide_file_prompt except ImportError: from database.pair_ingestion import SCAN_MARKS, ingest_resolved_pair from database.run_attribution import ( RecordedOutput, host_output_directory, - in_output_folder, named_prompt, - recorded_output_lookup, recorded_runs, - stored_run_prompt, ) - from database.scanned_prompts import store_scanned_prompt + from database.scanned_prompts import ( + link_stored_prompt_or_raise, + store_scanned_prompt, + ) from utils.file_prompt import NEVER, PAIR, Policy, decide_file_prompt @@ -250,7 +250,7 @@ SCAN_DETAILS = { "skipped": "Skipped ยท already linked", "ambiguous": "Needs review: more than one prompt could match", "unresolved": "Needs review: the prompt could not be traced in the workflow", - "conflict": "Needs review: already linked to a different prompt", + "conflict": "Kept: already linked to a different prompt", "failed": "Error: the prompt could not be stored", "error": "Error: the file could not be processed (see the server log)", "found": "Metadata found, but no prompt text to store", @@ -1709,16 +1709,14 @@ class AdminRoutesMixin: the batch's lazy recorded-history lookup. """ path = str(media_file) - if history is not None: - lookup = functools.partial(history.recorded, path) - else: - lookup = functools.partial(recorded_output_lookup(self.db), path) + if history is None: # one file: history is still asked once + history = _BatchHistory(self, [media_file]) decision = decide_file_prompt( metadata, file_name=Path(path).name, - file_size=_file_size(media_file), - in_output=in_output_folder(path, host_output_directory()), - lookup=lookup, + file_size=functools.partial(_file_size, media_file), + in_output=False, # only the monitor policy reads it + lookup=functools.partial(history.recorded, path), policy=Policy.SCAN, ) self._note_review(media_file, decision, ambiguous, untraced, records) @@ -1731,8 +1729,15 @@ class AdminRoutesMixin: return linked return decision.unlinked_status if decision.kind == PAIR: - result = ingest_resolved_pair( - self.db, path, decision.canonical, decision.resolution, marks=SCAN_MARKS + # A rerun after a library move re-checks the link rows. + result = self.db.survive_library_switch( + lambda: ingest_resolved_pair( + self.db, + path, + decision.canonical, + decision.resolution, + marks=SCAN_MARKS, + ) ) if not detailed: return result["status"] @@ -1765,31 +1770,25 @@ class AdminRoutesMixin: ``{"status", "title"}``), or None when history names no single stored prompt for the file (``database.run_attribution``). Never creates a prompt. Works without any prompt metadata in the file. ``history`` is - the batch's precomputed lookup; without it the file is looked up alone. + the batch's lookup (``_BatchHistory``). A file already linked to + another prompt is a ``conflict``: one prompt per file. """ - if history is not None: - prompt_id = history.get(str(media_file)) - else: - try: - prompt_id = stored_run_prompt( - self.db, str(media_file), host_output_directory() - ) - except Exception: - self.logger.warning( - f"History attribution failed for {media_file.name}", - exc_info=True, - ) - return None + if history is None: + history = _BatchHistory(self, [media_file]) + prompt_id = history.get(str(media_file)) if prompt_id is None: return None try: - image_id = self.db.link_image_to_prompt( - prompt_id, str(media_file), metadata=canonical + status = self.db.survive_library_switch( + lambda: link_stored_prompt_or_raise( + self.db, str(media_file), canonical, prompt_id + ) ) except Exception as e: self.logger.error(f"Failed to link {media_file.name} from history: {e}") return "found" - status = "linked" if image_id else "unchanged" + if status is None: + return None if not detailed or status not in SCAN_TITLED_DETAILS: return status prompt = self.db.get_prompt_by_id(prompt_id) or {} diff --git a/tests/test_pair_scan.py b/tests/test_pair_scan.py index 5fb5f685..0640a09e 100644 --- a/tests/test_pair_scan.py +++ b/tests/test_pair_scan.py @@ -1,7 +1,9 @@ """Metadata scanning enriches known pairs and conservatively leaves conflicts.""" import json +import sqlite3 from pathlib import Path +from unittest.mock import patch from tests.test_api_admin import AdminAPITestCase from utils.hashing import generate_prompt_hash @@ -549,3 +551,42 @@ class TestScanDecision(AdminAPITestCase): self.assertEqual(status, "added") self.assertEqual((ambiguous, untraced), ([path], [])) self.assertEqual(records["canonical"]["prompt"], g) + + def test_an_ambiguous_file_with_a_junk_answer_is_still_queued(self): + from tests.test_file_prompt import _numeric_first_branches + + path = self._path("numbers.png") + meta = {"prompt": json.dumps(_numeric_first_branches())} + with patch.object( + type(self.api), "_record_scanned_review", autospec=True + ) as record: + counts = self.api._ingest_scan_batch_sync([(path, meta)]) + self.assertEqual(counts["ambiguous"], 1, counts) + record.assert_called_once() + self.assertEqual(self._links(path), []) + + def test_a_conflict_row_says_the_link_was_kept(self): + from py.api.admin import _scan_outcome + + _group, row, _line = _scan_outcome(self._path("c.png"), [], "conflict") + self.assertEqual(row["detail"], "Kept: already linked to a different prompt") + + def test_a_library_move_reruns_the_scan_writes(self): + from py.api import admin + + real = admin.ingest_resolved_pair + model = self.api.db.model + + def moved_once(*args, **kwargs): + if not calls: + calls.append(1) + model._generation += 1 + raise sqlite3.OperationalError("closed") + return real(*args, **kwargs) + + calls = [] + path = self._path("pair.png") + with patch.object(admin, "ingest_resolved_pair", side_effect=moved_once): + meta = {"prompt": json.dumps(sampled_graph("cat", "blur"))} + self.assertEqual(self.api._ingest_scanned_file(path, meta), "added") + self.assertEqual(calls, [1]) diff --git a/tests/test_run_attribution.py b/tests/test_run_attribution.py index b041bd14..dbc05d53 100644 --- a/tests/test_run_attribution.py +++ b/tests/test_run_attribution.py @@ -492,6 +492,47 @@ class TestScanUsesHistory(RecordedRuns, AdminAPITestCase): db.get_prompt_by_hash(generate_prompt_hash(shapes.BRANCH_TEXTS[1])) ) + def test_a_recorded_run_never_adds_a_second_link(self): + db = self.api.db + db.save_prompt(text=LLM_TEXT, prompt_hash=generate_prompt_hash(LLM_TEXT)) + other = db.save_prompt(text="a dog", prompt_hash=generate_prompt_hash("a dog")) + path = self.write("bare_00003_.png") + db.link_image_to_prompt(other, str(path)) + self.record_run(db, str(self.output_dir), [("", path.name)], [LLM_TEXT]) + self.assertEqual(self.api._ingest_scanned_file(path, {}), "conflict") + rows = db.model.get_connection().execute( + "SELECT prompt_id FROM generated_images WHERE image_path=?", (str(path),) + ) + self.assertEqual([row[0] for row in rows], [other]) + + def test_a_single_file_asks_history_once(self): + db = self.api.db + db.save_prompt(text=LLM_TEXT, prompt_hash=generate_prompt_hash(LLM_TEXT)) + path = self.write("llm_00002_.png") + self.record_run(db, str(self.output_dir), [("", path.name)], [LLM_TEXT]) + meta = {"prompt": json.dumps(llm_text_graph())} + statements = [] + conn = db.model.get_connection() + conn.set_trace_callback(statements.append) + try: + self.assertEqual(self.api._ingest_scanned_file(path, meta), "linked") + finally: + conn.set_trace_callback(None) + lookups = [s for s in statements if "FROM execution_run_outputs" in s] + self.assertEqual(len(lookups), 1) + + def test_a_file_the_recorded_saver_links_is_not_queued(self): + """A default-ambiguous file linked at the inspector's step is no + Needs review case.""" + with mock.patch.object( + type(self.api), "_record_scanned_review", autospec=True + ) as record: + _paths, counts = self.scan_dataset( + ["IMG_GEN_00003_.png", "IMG_GEN_00004_.png"], ["13", "23"] + ) + self.assertEqual(counts["added"], 2, counts) + record.assert_not_called() + class TestHostHelpers(unittest.TestCase): """The host lookups degrade to "no proof", never to an error.""" @@ -893,6 +934,26 @@ class TestScanBatchLookup(RecordedRuns, AdminAPITestCase): self.assertIsNone(history.recorded(str(path))) self.assertIsNone(history.get(str(path))) + def test_a_failed_prompt_lookup_is_logged_and_names_nothing(self): + from py.api.admin import _BatchHistory + + path = self.output_dir / "one.png" + path.write_bytes(b"png") + root = str(self.output_dir) + self.record_run(self.api.db, root, [("", path.name)], [LLM_TEXT]) + history = _BatchHistory(self.api, [path]) + with ( + mock.patch( + "database.run_attribution.host_output_directory", return_value=root + ), + mock.patch( + "py.api.admin.named_prompt", side_effect=sqlite3.OperationalError("x") + ), + self.assertLogs("prompt_manager", "WARNING"), + ): + self.assertIsNone(history.get(str(path))) + self.assertEqual(history.recorded(str(path)).saver_id, "9") + class TestLinkedOutputRoot(RecordedRuns, unittest.TestCase): """R2: ComfyUI's output folder reached through a symlink or junction.""" diff --git a/tests/test_scanned_prompts.py b/tests/test_scanned_prompts.py index 0cabf9b1..3e4ec95c 100644 --- a/tests/test_scanned_prompts.py +++ b/tests/test_scanned_prompts.py @@ -166,6 +166,27 @@ class TestStoreScannedPrompt(StoreCase): self.assertEqual(len(self.prompts()), 1) self.assertEqual(len(self.links()), 1) + def test_a_library_move_reruns_the_store(self): + from database import scanned_prompts + + real = scanned_prompts.store_scanned_prompt_or_raise + calls = [] + + def moved_once(*args, **kwargs): + if not calls: + calls.append(1) + self.db.model._generation += 1 + raise sqlite3.OperationalError("closed") + return real(*args, **kwargs) + + with mock.patch.object( + scanned_prompts, "store_scanned_prompt_or_raise", side_effect=moved_once + ): + status, _id = store_scanned_prompt( + self.db, self.path, META, "a cat", SCAN_MARKS + ) + self.assertEqual((status, calls), ("added", [1])) + class TestPairMarks(StoreCase): def test_marks_reach_only_created_components(self):