fix(history): join case-variant raw text to the history of the prompt it counts toward (#460) (#471)

* fix(history): join outer-whitespace raw text to the history of the prompt it saves (#438)

The Library stores text.strip() while a run keeps its exact raw text. The
history window, run detail scope id, newest pair run and prompt hints now
compare the raw text's stored form (one rule, utils/hashing.py, mirrored in
SQLite with the same code points). Hints also keep matching the exact raw
text, so older rows that kept outer whitespace stay hinted and protected.

* fix(history): join case-variant raw text to the history of the prompt it counts toward (#460)

The prompt hash folds case, so a run of "A Lighthouse" counts toward the
saved "a lighthouse" but never joined its history. History now compares the
hash's own key (prompt_key = strip + str.lower()); SQL calls Python for it
through pm_prompt_key/pm_prompt_hash registered on every managed connection,
since SQLite's lower() folds ASCII only. Hints match by text first (#438) and
fall back to the dedup hash only when no text matches, so no existing hint
becomes ambiguous. A history position from before this change still resumes.

* test(history): a real-host case-variant re-queue joins the prompt's history (#460)
This commit is contained in:
Vito
2026-10-29 13:25:04 +00:00
committed by GitHub
parent b6e0439421
commit 89d94cdb2b
12 changed files with 368 additions and 59 deletions
+47 -27
View File
@@ -7,7 +7,11 @@ from typing import Callable, ContextManager, Iterator
try:
from ..utils.execution_history_events import RunFinish, encode_command
from ..utils.hashing import stored_prompt_text, stored_prompt_text_sql
from ..utils.hashing import (
generate_prompt_hash,
stored_prompt_text,
stored_prompt_text_sql,
)
from ..utils.execution_contexts import (
CAPTURE_REASONS,
ContextBundle,
@@ -16,7 +20,11 @@ try:
)
except ImportError:
from utils.execution_history_events import RunFinish, encode_command
from utils.hashing import stored_prompt_text, stored_prompt_text_sql
from utils.hashing import (
generate_prompt_hash,
stored_prompt_text,
stored_prompt_text_sql,
)
from utils.execution_contexts import (
CAPTURE_REASONS,
ContextBundle,
@@ -149,9 +157,12 @@ def _role_hints(role: str) -> str:
# Set form of the read-time prompt-hint rule in get_execution_contexts():
# a stored role hint is live only in the current storage epoch, and only when
# exactly one prompt has its raw snapshot text or that text's stored form
# (outer whitespace stripped, #438), and that is the hinted one (_prompt_hint).
# Evaluated once per statement, never per prompt row.
# the hinted prompt is the one match of _prompt_hint: by text (the raw
# snapshot text or its stored form, outer whitespace stripped, #438), or, only
# when no prompt has either text, by dedup hash (case folded too, #460).
# The hash is a fallback, so a hint #438 kept is never made ambiguous.
# Needs register_prompt_functions (pm_prompt_hash). The hint rows are the
# outer loop (CROSS JOIN), so each prompts lookup is by index.
# Defines the CTE ``live_hint_prompts(prompt_id)``; prefix it with WITH.
LIVE_HINT_CTES = (
"stored_hints(prompt_id, raw, stored) AS ("
@@ -160,7 +171,12 @@ LIVE_HINT_CTES = (
+ _role_hints("negative")
+ "), hint_matches(prompt_id, raw, stored, match_id) AS ("
"SELECT h.prompt_id, h.raw, h.stored, p.id FROM stored_hints h "
"JOIN prompts p ON p.text COLLATE BINARY IN (h.raw, h.stored)"
"CROSS JOIN prompts p ON p.text COLLATE BINARY IN (h.raw, h.stored) "
"UNION ALL "
"SELECT h.prompt_id, h.raw, h.stored, p.id FROM stored_hints h "
"CROSS JOIN prompts p ON p.hash = pm_prompt_hash(h.raw) "
"WHERE NOT EXISTS (SELECT 1 FROM prompts q "
"WHERE q.text COLLATE BINARY IN (h.raw, h.stored))"
"), live_hint_prompts(prompt_id) AS ("
"SELECT prompt_id FROM hint_matches GROUP BY prompt_id, raw, stored "
"HAVING COUNT(*) = 1 AND MIN(match_id) = prompt_id)"
@@ -169,20 +185,32 @@ LIVE_HINT_PROMPT_IDS_SQL = (
"WITH " + LIVE_HINT_CTES + " SELECT DISTINCT prompt_id FROM live_hint_prompts"
)
_BY_TEXT = "SELECT id FROM prompts WHERE text COLLATE BINARY IN (?,?) LIMIT 2"
def _text_matches(conn: sqlite3.Connection, raw: str) -> list:
return [row[0] for row in conn.execute(_BY_TEXT, (raw, stored_prompt_text(raw)))]
def _prompt_hint(conn: sqlite3.Connection, raw: str | None) -> int | None:
"""The one prompt whose text is ``raw`` or the stored form of it (#438).
"""The one prompt that stores ``raw`` (#438), else the one it counts toward.
The Library stores ``text.strip()``; the exact form keeps hints of older
rows whose stored text kept outer whitespace.
By text first: ``raw`` or ``raw.strip()`` (the exact form keeps hints of
older rows that kept outer whitespace). Only when no prompt has either
text, by the dedup hash, which also folds case (#460).
"""
if raw is None:
return None
rows = conn.execute(
"SELECT id FROM prompts WHERE text COLLATE BINARY IN (?,?) LIMIT 2",
(raw, stored_prompt_text(raw)),
).fetchall()
return rows[0][0] if len(rows) == 1 else None
matches = _text_matches(conn, raw)
if not matches:
matches = [
row[0]
for row in conn.execute(
"SELECT id FROM prompts WHERE hash=? LIMIT 2",
(generate_prompt_hash(raw),),
)
]
return matches[0] if len(matches) == 1 else None
def _hints(
@@ -191,24 +219,16 @@ def _hints(
pos_id, neg_id = _prompt_hint(conn, positive), _prompt_hint(conn, negative)
stored = None if negative is None else stored_prompt_text(negative)
pair_id = None
# A known negative that names no prompt because several share its text
negative_ambiguous = (
stored is not None
and neg_id is None
and conn.execute(
"SELECT 1 FROM prompts WHERE text COLLATE BINARY IN (?,?) LIMIT 1",
(negative, stored),
).fetchone()
is not None
stored is not None and neg_id is None and bool(_text_matches(conn, negative))
)
if pos_id is not None and stored is not None and not negative_ambiguous:
rows = conn.execute(
"SELECT pp.id FROM prompt_pairs pp LEFT JOIN prompts n ON "
"n.id=pp.negative_prompt_id "
"SELECT pp.id FROM prompt_pairs pp "
"WHERE pp.positive_prompt_id=? AND ((pp.negative_prompt_id IS NULL AND "
"?='') "
"OR (pp.negative_prompt_id=? AND n.text COLLATE BINARY IN (?,?))) "
"LIMIT 2",
(pos_id, stored, neg_id, negative, stored),
"?='') OR pp.negative_prompt_id=?) LIMIT 2",
(pos_id, stored, neg_id),
).fetchall()
if len(rows) == 1:
pair_id = rows[0][0]
+3 -7
View File
@@ -23,10 +23,10 @@ from pathlib import PurePosixPath, PureWindowsPath
from urllib.parse import quote
try:
from ..utils.hashing import generate_prompt_hash
from ..utils.hashing import register_prompt_functions
from .models import normalize_image_path
except ImportError:
from utils.hashing import generate_prompt_hash
from utils.hashing import register_prompt_functions
from database.models import normalize_image_path
logger = logging.getLogger("prompt_manager.database.health")
@@ -125,12 +125,8 @@ def _foreign_keys(conn, _roots):
)
def _prompt_hash(text):
return generate_prompt_hash(text) if isinstance(text, str) else None
def _canonical_hashes(conn, _roots):
conn.create_function("pm_prompt_hash", 1, _prompt_hash, deterministic=True)
register_prompt_functions(conn) # pm_prompt_hash, shared with history (#460)
return _counted(
f"Prompt {row[0]}: the stored hash does not match its text"
for row in conn.execute(
+23 -16
View File
@@ -16,9 +16,9 @@ from .execution_outputs import _capture, _REFERENCE_COLUMNS
from .library import _ELIGIBLE
try:
from ..utils.hashing import stored_prompt_text, stored_prompt_text_sql
from ..utils.hashing import prompt_key, stored_prompt_text
except ImportError:
from utils.hashing import stored_prompt_text, stored_prompt_text_sql
from utils.hashing import prompt_key, stored_prompt_text
RunKey = tuple[int, str, int, str]
@@ -115,12 +115,12 @@ _MATCHING = (
)
def _scope_text_ids(conn: sqlite3.Connection, scope: str) -> str:
"""Every recorded raw text whose stored form is ``scope``, as a JSON array.
def _scope_text_ids(conn: sqlite3.Connection, key: str) -> str:
"""Every recorded raw text whose prompt key is ``key``, as a JSON array.
The Library stores ``text.strip()`` while a run keeps its exact raw text,
so a raw text joins the scope of the prompt it saves (#438). The values
are compared, never only their digests.
A run keeps its exact raw text, while the Library saves and counts it as
the prompt with its key (outer whitespace #438, case #460). The values are
compared, never only their digests; ``pm_prompt_key`` is Python's key.
"""
return json.dumps(
[
@@ -130,16 +130,16 @@ def _scope_text_ids(conn: sqlite3.Connection, scope: str) -> str:
"SELECT t.text_id FROM (SELECT DISTINCT raw_text_id AS text_id "
"FROM execution_prompt_snapshots) s CROSS JOIN "
"execution_context_texts t WHERE t.text_id=s.text_id AND "
f"{stored_prompt_text_sql('t.text_value')}=? COLLATE BINARY",
(scope,),
"pm_prompt_key(t.text_value)=? COLLATE BINARY",
(key,),
)
]
)
def _scope_text_id(raw: str | None) -> str | None:
"""The scope digest of a raw text: that of the text the Library stores."""
return None if raw is None else content_digest("text", (stored_prompt_text(raw),))
"""The scope digest of a raw text: that of its prompt key."""
return None if raw is None else content_digest("text", (prompt_key(raw),))
def _limit(limit: int) -> None:
@@ -290,7 +290,14 @@ class HistoryReadOperations:
return None
text = saved["text"]
raw_id = _scope_text_id(text)
if after is not None and after.raw_text_id != raw_id:
# A position from before #460 names the scope by the stripped text
# alone; it is the same scope, so a page open across the upgrade resumes.
legacy_id = (
None
if text is None
else content_digest("text", (stored_prompt_text(text),))
)
if after is not None and after.raw_text_id not in (raw_id, legacy_id):
raise ValueError("History position text scope changed")
group = {
"positive_id": positive_id,
@@ -309,7 +316,7 @@ class HistoryReadOperations:
)
if raw_id is None:
return result
scope = _scope_text_ids(conn, stored_prompt_text(text))
scope = _scope_text_ids(conn, prompt_key(text))
where = f"r.run_id IN ({_MATCHING})"
params = [scope]
if after is not None:
@@ -465,7 +472,7 @@ class HistoryReadOperations:
if saved["text"] is None:
return {"run": None}
epoch = self.get_execution_epoch()
stored = stored_prompt_text(saved["text"])
key = prompt_key(saved["text"])
candidates = conn.execute(
"SELECT r.run_id,rc.anchor_id,rc.context_id,"
"c.positive_snapshot_id,c.negative_snapshot_id "
@@ -477,7 +484,7 @@ class HistoryReadOperations:
"AND rc.reference_epoch=? "
f"ORDER BY {_ORDER},rc.anchor_id COLLATE BINARY LIMIT ?",
(
_scope_text_ids(conn, stored),
_scope_text_ids(conn, key),
pair_id,
epoch,
_PAIR_RUN_CANDIDATES,
@@ -489,7 +496,7 @@ class HistoryReadOperations:
if (
positive is None
or positive["raw"] is None
or stored_prompt_text(positive["raw"]) != stored
or prompt_key(positive["raw"]) != key
):
continue
hints = _hints(conn, positive["raw"], negative["raw"] if negative else None)
+6
View File
@@ -44,6 +44,11 @@ from .health import BATCH_ROWS, link_states, readable_roots, readonly_uri
from .pairs import PairDependencyError
from .workflow_documents import documents_of_rows, release_documents
try:
from ..utils.hashing import register_prompt_functions
except ImportError:
from utils.hashing import register_prompt_functions
MAINTENANCE_OPERATIONS = (
"duplicate_prompts",
"optimize",
@@ -289,6 +294,7 @@ def _read_snapshot(db_path, read):
conn = sqlite3.connect(readonly_uri(db_path), uri=True)
try:
conn.row_factory = sqlite3.Row
register_prompt_functions(conn) # LIVE_HINT_CTES calls them (#460)
conn.execute("BEGIN") # one read snapshot for the whole plan
return read(conn)
finally:
+8
View File
@@ -38,6 +38,11 @@ from .review_schema import (
review_without_annotations_error,
)
from .saved_views import migrate_saved_views, saved_views_schema_error
try:
from ..utils.hashing import register_prompt_functions
except ImportError:
from utils.hashing import register_prompt_functions
from .image_categories import image_categories_schema_error, migrate_image_categories
from .workflow_documents import (
DOCUMENT_ID_COLUMNS,
@@ -153,12 +158,15 @@ def configure_connection(conn: sqlite3.Connection) -> None:
(common on Windows) let other writers' polling busy handlers starve past
BUSY_TIMEOUT_SECONDS and fail with "database is locked" (#242). Without
WAL (for example an in-memory database) SQLite's FULL default is kept.
It also registers the prompt-key SQL functions (register_prompt_functions).
"""
journal_mode = conn.execute("PRAGMA journal_mode = WAL").fetchone()[0]
conn.execute("PRAGMA foreign_keys = ON")
conn.execute(f"PRAGMA busy_timeout = {BUSY_TIMEOUT_SECONDS * 1000}")
if str(journal_mode).lower() == "wal":
conn.execute("PRAGMA synchronous = NORMAL")
# History reads and the live-hint rule call Python's prompt key (#460)
register_prompt_functions(conn)
class PromptModel: