diff --git a/database/library.py b/database/library.py index 8ef900e6..b961f27a 100644 --- a/database/library.py +++ b/database/library.py @@ -21,19 +21,29 @@ _JS_TRIM = "".join(map(chr, _TRIM_CODES)) DISPLAY_TITLE_LIMIT = 80 -def display_title(stored_titles: tuple, text: object) -> str | None: - """Return the first non-blank stored title, else the text's first line. +def titled(stored_titles: tuple, text: object) -> tuple[str | None, str | None]: + """Return ``(title, source)``: a stored title, else the text's first line. - Blank follows JavaScript's trim, as in the Library view; the first line is - trimmed and cut to DISPLAY_TITLE_LIMIT characters. None when both are empty. + ``source`` is ``"stored"`` for the first non-blank stored title (blank as + in JavaScript's trim, like the Library view), ``"text"`` for the first line, + and None when both are empty. The first line is trimmed and, past + DISPLAY_TITLE_LIMIT characters, cut with an ellipsis like the Library's + ``title()``. """ for title in stored_titles: if isinstance(title, str) and title.strip(_JS_TRIM): - return title + return title, "stored" if not isinstance(text, str): - return None - first = text.split("\n")[0].strip(_JS_TRIM)[:DISPLAY_TITLE_LIMIT] - return first or None + return None, None + first = text.split("\n")[0].strip(_JS_TRIM) + if len(first) > DISPLAY_TITLE_LIMIT: + first = first[:DISPLAY_TITLE_LIMIT] + "\u2026" + return (first, "text") if first else (None, None) + + +def display_title(stored_titles: tuple, text: object) -> str | None: + """The Library display title of ``titled``, without its source.""" + return titled(stored_titles, text)[0] _NAME = ( diff --git a/database/operations.py b/database/operations.py index 9627c915..56622e28 100644 --- a/database/operations.py +++ b/database/operations.py @@ -10,7 +10,7 @@ from typing import Optional, List, Dict, Any from .models import PromptModel, normalize_image_path, utc_now_iso from .pairs import PairDependencyError, PairOperations -from .library import LibraryOperations, display_title +from .library import LibraryOperations, titled from .library_commands import LibraryCommandOperations from .vocabulary import VocabularyOperations from .execution_history import ExecutionHistoryOperations @@ -211,6 +211,7 @@ def _empty_link_summary(status: str) -> Dict[str, Any]: "status": status, "positive_id": None, "title": None, + "title_source": None, "category": None, "rating": None, } @@ -223,10 +224,12 @@ def _link_summary(row: tuple) -> Dict[str, Any]: else: positive, text, category, rating = row[2], row[3], row[4], row[5] titles = (row[6],) + title, source = titled(titles, text) return { "status": "linked", "positive_id": positive, - "title": display_title(titles, text), + "title": title, + "title_source": source, "category": category, "rating": rating, } diff --git a/py/api/images.py b/py/api/images.py index bb994d41..db0affd8 100644 --- a/py/api/images.py +++ b/py/api/images.py @@ -47,23 +47,23 @@ def _unavailable_link() -> dict: "status": "unavailable", "positive_id": None, "title": None, + "title_source": None, "category": None, "rating": None, } -def _verified_title(saved: dict | None, title: str | None, observed: dict): - """The inspector's Library title, withholding unverified saved prompt text. +def _verified_title(summary: dict | None, observed: dict) -> str | None: + """The inspector's Library title, withholding unconfirmed saved prompt text. - A stored title is always shown. A title taken from the saved positive's - first line is shown only when the file's own resolved positive gives the - same title, because a saved association never replaces file evidence. + A stored title is user data and always shows. A title taken from the saved + positive's first line shows only when the file's own resolved positive gives + the same title, because a saved association never replaces file evidence. """ - if title is None or not isinstance(saved, dict): - return title - pair = saved.get("pair") - text = pair.get("positive_text") if isinstance(pair, dict) else saved.get("text") - if title != display_title((), text): + if not isinstance(summary, dict) or summary.get("status") != "linked": + return None + title = summary.get("title") + if summary.get("title_source") != "text": return title side = observed.get("prompts", {}).get("positive") or {} resolved = side.get("status") == "resolved" @@ -1743,29 +1743,33 @@ class ImageRoutesMixin: try: def inspect() -> bytes | None: - saved, unavailable, title = None, False, None + saved, unavailable, summary = None, False, None try: saved = self.db.get_image_prompt_info( str(path), False, report_ambiguity=True ) - if saved and saved.get("association_status") != "ambiguous": - summary = self.db.get_image_link_summaries([str(path)], False) - title = summary[str(path)]["title"] except Exception: unavailable = True self.logger.warning("Saved inspection display could not be read") + if saved and saved.get("association_status") != "ambiguous": + try: + summary = self.db.get_image_link_summaries([str(path)], False) + summary = summary[str(path)] + except Exception: + # The link stays known; only its title is withheld. + self.logger.warning("Inspection link title could not be read") try: observed = inspect_current_file( path, source_label=_public_path(roots[index]), saved=saved, library=self._inspection_library( - saved, unavailable=unavailable, title=title + saved, unavailable=unavailable ), ) except CurrentFileChanged: return None - observed["library"]["title"] = _verified_title(saved, title, observed) + observed["library"]["title"] = _verified_title(summary, observed) return encode_inspection_response( image={ "root_index": index, @@ -1798,10 +1802,8 @@ class ImageRoutesMixin: {"success": False, "error": "Prompt inspection failed"}, status=500 ) - def _inspection_library( - self, saved: dict | None, *, unavailable: bool, title: str | None = None - ) -> dict: - """The inspector's Library facts; ``title`` is the card's display title.""" + def _inspection_library(self, saved: dict | None, *, unavailable: bool) -> dict: + """The inspector's Library facts; the route sets the title afterwards.""" result = { "status": "unavailable" if unavailable else "none", "scope": "saved-library", @@ -1823,7 +1825,6 @@ class ImageRoutesMixin: status="linked", positive_id=pair.get("positive_prompt_id"), pair_id=pair.get("id"), - title=title, category=pair.get("category"), tags=pair.get("tags"), metadata_scope="pair", @@ -1832,7 +1833,6 @@ class ImageRoutesMixin: result.update( status="linked", positive_id=saved.get("prompt_id"), - title=title, category=saved.get("category"), tags=saved.get("tags"), metadata_scope="positive", diff --git a/tests/test_api_workspace_images.py b/tests/test_api_workspace_images.py index c388b4ff..a5597f52 100644 --- a/tests/test_api_workspace_images.py +++ b/tests/test_api_workspace_images.py @@ -354,7 +354,7 @@ class TestWorkspaceImages(ImageAPITestCase): db.link_image_to_prompt(second, str(files["ambiguous"])) return { "titled": ("linked", titled, "Stored library title"), - "plain": ("linked", plain, long_line.strip()[:80]), + "plain": ("linked", plain, long_line.strip()[:80] + "\u2026"), "pair": ( "linked", db.get_prompt_pair(pair)["positive_prompt_id"], @@ -379,7 +379,14 @@ class TestWorkspaceImages(ImageAPITestCase): linked = items[name]["linked"] self.assertEqual( set(linked), - {"status", "positive_id", "title", "category", "rating"}, + { + "status", + "positive_id", + "title", + "title_source", + "category", + "rating", + }, ) self.assertEqual( (linked["status"], linked["positive_id"], linked["title"]), @@ -403,22 +410,66 @@ class TestWorkspaceImages(ImageAPITestCase): ("Night", 5), ) - async def test_unverified_first_line_title_stays_out_of_inspection(self): + async def test_stored_title_shows_on_card_and_inspector_without_evidence(self): + # A stored title is user data: shown on both, even when it equals the + # first line and the file carries no prompt metadata. + prompt = self._save_prompt("Same words\nrest") + with self.api.db.model.get_connection() as connection: + connection.execute( + "INSERT INTO prompt_library_state(prompt_id,title) VALUES(?,?)", + (prompt, "Same words"), + ) + path = self.media("stored.png") + self.api.db.link_image_to_prompt(prompt, str(path)) + _, data = await self.get() + linked = data["images"][0]["linked"] + self.assertEqual( + (linked["title"], linked["title_source"]), ("Same words", "stored") + ) + library = await self.inspected("stored.png", data["images"][0]) + self.assertEqual(library["title"], linked["title"]) + self.assertEqual(library["positive_id"], prompt) + + @unittest.expectedFailure # B2 gates text titles on the file's parsed positive. + async def test_unconfirmed_text_title_is_withheld_on_card_and_inspector(self): prompt = self._save_prompt("Secret first line\nrest") path = self.media("plain.png") self.api.db.link_image_to_prompt(prompt, str(path)) _, data = await self.get() item = data["images"][0] - self.assertEqual(item["linked"]["title"], "Secret first line") response = await self.client.get( "/prompt_manager/images/inspect/plain.png", params={"root": 0, "root_scope": item["root_scope"]}, ) body = await response.json() - self.assertEqual(body["library"]["status"], "linked") self.assertEqual(body["library"]["positive_id"], prompt) self.assertIsNone(body["library"]["title"]) self.assertNotIn("Secret first line", json.dumps(body)) + self.assertIsNone(item["linked"]["title"]) + self.assertNotIn("Secret first line", json.dumps(data)) + + async def test_summary_failure_keeps_inspector_link(self): + prompt = self._save_prompt("Readable link") + path = self.media("a.png") + self.api.db.link_image_to_prompt(prompt, str(path)) + _, data = await self.get() + with patch.object( + self.api.db, + "get_image_link_summaries", + side_effect=RuntimeError("summary gone"), + ): + library = await self.inspected("a.png", data["images"][0]) + self.assertEqual(library["status"], "linked") + self.assertEqual(library["positive_id"], prompt) + self.assertIsNone(library["title"]) + + async def inspected(self, name, item): + response = await self.client.get( + f"/prompt_manager/images/inspect/{name}", + params={"root": 0, "root_scope": item["root_scope"]}, + ) + self.assertEqual(response.status, 200) + return (await response.json())["library"] async def test_link_summary_uses_one_query_per_page(self): prompt = self._save_prompt("Paged prompt") @@ -469,6 +520,7 @@ class TestWorkspaceImages(ImageAPITestCase): self.assertNotEqual(threads[0], event_thread) self.assertEqual(data["images"][0]["linked"]["status"], "unavailable") self.assertIsNone(data["images"][0]["linked"]["title"]) + self.assertIsNone(data["images"][0]["linked"]["title_source"]) self.assertNotIn("database is gone", str(data)) diff --git a/tests/test_database.py b/tests/test_database.py index a5b3f1ef..36bad169 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -1499,6 +1499,7 @@ class TestImageLinkSummaries(DatabaseTestCase): "status": expected, "positive_id": None, "title": None, + "title_source": None, "category": None, "rating": None, }, @@ -1510,6 +1511,7 @@ class TestImageLinkSummaries(DatabaseTestCase): "status": "linked", "positive_id": owner, "title": "Owner prompt", + "title_source": "text", "category": "Cat", "rating": 4, }, @@ -1530,10 +1532,24 @@ class TestImageLinkSummaries(DatabaseTestCase): paths[name] = os.path.join("out", f"{name}.png") self.db.link_image_to_prompt(prompt, paths[name]) summaries = self.db.get_image_link_summaries(list(paths.values())) - self.assertEqual(summaries[paths["titled"]]["title"], "Stored title") - self.assertEqual(summaries[paths["plain"]]["title"], long_line.strip()[:80]) - self.assertLessEqual(len(summaries[paths["plain"]]["title"]), 80) - self.assertEqual(summaries[paths["blank"]]["title"], "Blank title text") + sources = { + name: (summaries[path]["title"], summaries[path]["title_source"]) + for name, path in paths.items() + } + self.assertEqual(sources["titled"], ("Stored title", "stored")) + # Cut like the Library's title(): 80 characters, then an ellipsis. + self.assertEqual(sources["plain"], (long_line.strip()[:80] + "\u2026", "text")) + self.assertEqual(sources["blank"], ("Blank title text", "text")) + + def test_display_title_matches_library_title_cut(self): + from database.library import display_title + + exact = "x" * 80 + self.assertEqual(display_title((), exact), exact) + self.assertEqual(display_title((), exact + "y"), exact + "\u2026") + self.assertEqual(display_title((), " short \nnext"), "short") + self.assertIsNone(display_title((" ",), " \n")) + self.assertEqual(display_title(("Kept ",), "text"), "Kept ") def test_summary_uses_one_select_per_200_paths(self): prompt = self._save("Batch")