fix(images): carry title source and cut first-line titles like the Library
Summaries gain title_source (stored or text). The inspector always shows a stored title and confirms only a first-line title against the file, so a stored title equal to the first line is no longer withheld. A link-title read failure no longer marks a readable link unavailable. First-line titles over 80 characters end with an ellipsis, as the Library's title() does. The card gate for unconfirmed first-line titles is pinned as an expected failure for B2.
This commit is contained in:
+18
-8
@@ -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 = (
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
+22
-22
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
+20
-4
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user