"""Needs review items (#249): persist and read capture-bound ambiguous evidence. An item is one ambiguous resolver result for one annotation byte record (the whole-file SHA-256 of one physical file at one locator), keyed by ``(byte_id, resolver_version, evidence_hash)``. Recording is idempotent: the same evidence for the same bytes is a duplicate and never touches an existing item, its state, revision or decisions. Changed bytes are a new byte record and therefore a new pending item; the old one stays. Callers pass verified facts (the capture's digest, size and ``mtime_ns``); nothing here reads the filesystem. Writes run in one ``_pair_transaction`` fenced by the storage epoch the capture was taken under. """ import hashlib import json import os import re import sqlite3 from typing import Iterator try: from ..utils.hashing import generate_prompt_hash except ImportError: from utils.hashing import generate_prompt_hash from .annotation_schema import ( BYTES_TABLE, FILES_TABLE, METADATA_TABLE as ANNOTATION_METADATA_TABLE, ) from .annotations import _new_id, _validate_locator, _validate_target from .models import normalize_image_path, utc_now_iso from .review_schema import ( ITEMS_TABLE, MAX_CANDIDATES, MAX_SPELLED_PATH_BYTES, METADATA_TABLE, canonical_evidence, evidence_digest, ) # Only this module's table constants are interpolated into the SQL below. _INSERT_ITEM = ( f"INSERT OR IGNORE INTO {ITEMS_TABLE}(id,byte_id,spelled_path," # nosec B608 "resolver_version,evidence_hash,evidence,candidate_count,limited,state," "revision,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,'pending',0,?,?)" ) _BUMP_REVIEW_INDEX = ( f"UPDATE {METADATA_TABLE} SET " # nosec B608 "review_index_revision=review_index_revision+1 WHERE singleton_id=1" ) _REVIEW_INDEX = ( f"SELECT review_index_revision FROM {METADATA_TABLE} " # nosec B608 "WHERE singleton_id=1" ) _BUMP_IMAGE_INDEX = ( f"UPDATE {ANNOTATION_METADATA_TABLE} SET " # nosec B608 "image_index_revision=image_index_revision+1 WHERE singleton_id=1" ) _ITEMS_OF_LOCATOR = ( "SELECT i.id,i.byte_id,i.resolver_version,i.evidence_hash," # nosec B608 "i.evidence,i.candidate_count,i.limited,i.state,i.revision,i.created_at," f"i.updated_at,b.sha256 FROM {FILES_TABLE} f " f"JOIN {BYTES_TABLE} b ON b.file_id=f.id " f"JOIN {ITEMS_TABLE} i ON i.byte_id=b.id " "WHERE f.locator=? ORDER BY i.created_at,i.id" ) _QUEUED = ( f"SELECT 1 FROM {FILES_TABLE} f " # nosec B608 f"JOIN {BYTES_TABLE} b ON b.file_id=f.id " f"JOIN {ITEMS_TABLE} i ON i.byte_id=b.id " "WHERE f.locator=? AND b.size=? AND b.mtime_ns=? AND i.resolver_version=? " "LIMIT 1" ) _EPOCH = "SELECT storage_epoch FROM execution_history_metadata WHERE singleton_id=1" _PENDING_COUNT = ( f"SELECT COUNT(*) FROM {ITEMS_TABLE} WHERE state='pending'" # nosec B608 ) _QUEUE_COLUMNS = ( "id", "state", "revision", "created_at", "candidate_count", "limited", "spelled_path", "locator", ) _QUEUE_SELECT = ( "SELECT i.id,i.state,i.revision,i.created_at,i.candidate_count," # nosec B608 f"i.limited,i.spelled_path,f.locator FROM {ITEMS_TABLE} i " f"JOIN {BYTES_TABLE} b ON b.id=i.byte_id " f"JOIN {FILES_TABLE} f ON f.id=b.file_id " ) _QUEUE_FIRST = _QUEUE_SELECT + ( "WHERE i.state='pending' ORDER BY i.created_at,i.id LIMIT ?" ) _QUEUE_AFTER = _QUEUE_SELECT + ( "WHERE i.state='pending' AND (i.created_at,i.id)>(?,?) " "ORDER BY i.created_at,i.id LIMIT ?" ) _DETAIL_COLUMNS = _QUEUE_COLUMNS + ( "byte_id", "resolver_version", "evidence_hash", "evidence", "updated_at", "sha256", ) _DETAIL = ( "SELECT i.id,i.state,i.revision,i.created_at,i.candidate_count," # nosec B608 "i.limited,i.spelled_path,f.locator,i.byte_id,i.resolver_version," "i.evidence_hash,i.evidence,i.updated_at,b.sha256 " f"FROM {ITEMS_TABLE} i JOIN {BYTES_TABLE} b ON b.id=i.byte_id " f"JOIN {FILES_TABLE} f ON f.id=b.file_id WHERE i.id=?" ) # The legacy rows and pair links the ingester's compatibility rules read. _DEPENDENT_IMAGES = ( "SELECT gi.id,p.hash FROM generated_images gi " "JOIN prompts p ON p.id=gi.prompt_id " "WHERE gi.file_path=? OR gi.image_path=? ORDER BY gi.id" ) _DEPENDENT_LINKS = ( "SELECT DISTINCT ip.pair_id,n.hash FROM image_prompt_pairs ip " "JOIN prompt_pairs pp ON pp.id=ip.pair_id " "LEFT JOIN prompts n ON n.id=pp.negative_prompt_id " "WHERE ip.image_id=? ORDER BY ip.pair_id" ) _TITLES = ( "SELECT p.hash,s.title FROM prompts p " "JOIN prompt_library_state s ON s.prompt_id=p.id " "WHERE p.hash IN ({marks}) AND s.title IS NOT NULL ORDER BY p.id" ) _ITEM_ID = re.compile(r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}") _ITEM_COLUMNS = ( "id", "byte_id", "resolver_version", "evidence_hash", "evidence", "candidate_count", "limited", "state", "revision", "created_at", "updated_at", "sha256", ) def _validate_spelling(spelled_path: object, locator: str) -> None: if ( not isinstance(spelled_path, str) or not spelled_path or len(spelled_path.encode("utf-8", "surrogatepass")) > MAX_SPELLED_PATH_BYTES or os.path.normcase(spelled_path) != locator ): raise ValueError("spelled_path must be the file's spelling of its locator") def dependency_fingerprint(conn: sqlite3.Connection, image_path: str) -> str: """SHA-256 of what Apply's compatibility depends on for ``image_path``. The exact legacy rows of the file with their positive hashes, and each row's pair links with the negative hash, read as the ingester reads them. Unrelated prompt changes (tags, titles) do not move it. """ images = conn.execute( _DEPENDENT_IMAGES, (normalize_image_path(image_path), image_path) ).fetchall() rows = [] for image_id, positive_hash in images: links = conn.execute(_DEPENDENT_LINKS, (image_id,)).fetchall() rows.append([image_id, positive_hash, [list(link) for link in links]]) text = canonical_evidence({"v": 1, "images": rows}) return hashlib.sha256(text.encode("utf-8")).hexdigest() class ReviewOperations: """PromptDatabase mixin: insert and read Needs review items.""" def record_review_item( self, *, locator: str, spelled_path: str, sha256: str, size: int, mtime_ns: int, epoch: str, resolver_version: int, evidence: dict, candidate_count: int, limited: bool, ) -> bool: """Store one ambiguous result for these exact bytes; True when new. The byte record is created (or its verified fingerprint refreshed) like an observe. ``spelled_path`` is the file's own spelling (it must fold to ``locator``); targets are formatted from it. Raises ``LibraryCommandError("storage-changed")`` when the storage epoch is no longer ``epoch`` (a restore since the capture) and ``ValueError`` for invalid facts or evidence. """ _validate_target(locator, sha256, size, mtime_ns) _validate_spelling(spelled_path, locator) if type(resolver_version) is not int or resolver_version < 1: raise ValueError("resolver_version must be a positive integer") if ( type(candidate_count) is not int or not 0 <= candidate_count <= MAX_CANDIDATES ): raise ValueError(f"candidate_count must be between 0 and {MAX_CANDIDATES}") if not isinstance(limited, bool) or (limited and candidate_count): raise ValueError("A limited item carries no candidates") text = canonical_evidence(evidence) evidence_hash = evidence_digest(text) with self._pair_transaction() as conn: self._library_epoch(conn, epoch) now = utc_now_iso() file_id = self._annotation_owner(conn, locator, now) byte_id, _revision, created, moved = self._annotation_byte( conn, file_id, sha256, size, mtime_ns, now ) if created or moved: conn.execute(_BUMP_IMAGE_INDEX) cursor = conn.execute( _INSERT_ITEM, ( _new_id(), byte_id, spelled_path, resolver_version, evidence_hash, text, candidate_count, int(limited), now, now, ), ) inserted = cursor.rowcount > 0 if inserted: conn.execute(_BUMP_REVIEW_INDEX) return inserted def review_index_revision(self) -> int: """The queue's revision (moved by every queue change).""" row = self.model.get_connection().execute(_REVIEW_INDEX).fetchone() if row is None: raise sqlite3.DatabaseError("Missing review queue metadata") return row[0] def review_items_for(self, locator: str) -> list: """Every item of the file at ``locator``, oldest first, evidence parsed.""" _validate_locator(locator) return list(self._iter_review_items(locator)) def _iter_review_items(self, locator: str) -> Iterator[dict]: conn = self.model.get_connection() for row in conn.execute(_ITEMS_OF_LOCATOR, (locator,)): item = dict(zip(_ITEM_COLUMNS, tuple(row))) item["evidence"] = json.loads(item["evidence"]) item["limited"] = bool(item["limited"]) yield item # -- queue reads (#249 E3) ------------------------------------------------- def review_fingerprint_queued( self, locator: str, size: int, mtime_ns: int, resolver_version: int ) -> bool: """True when an item cites a byte record verified at this exact stat. A rescan uses it to skip the copy and hash of a file it already queued: the same ``(size, mtime_ns)`` the bytes were verified with. """ row = ( self.model.get_connection() .execute(_QUEUED, (locator, size, mtime_ns, resolver_version)) .fetchone() ) return row is not None def review_queue_page(self, *, limit: int, after: tuple | None = None) -> dict: """One page of pending items, oldest first, from one snapshot. ``after`` is the ``(created_at, id)`` keyset of the last item already shown. Returns ``{epoch, revision, total, items, more}``; ``total`` is the exact pending count and ``items`` carry their ``spelled_path`` and ``locator`` (server-side only). """ if type(limit) is not int or limit < 1: raise ValueError("limit must be a positive integer") with self._annotation_read() as conn: epoch = conn.execute(_EPOCH).fetchone() revision = conn.execute(_REVIEW_INDEX).fetchone() if epoch is None or revision is None: raise sqlite3.DatabaseError("Missing review queue metadata") total = conn.execute(_PENDING_COUNT).fetchone()[0] if after is None: rows = conn.execute(_QUEUE_FIRST, (limit + 1,)).fetchall() else: rows = conn.execute(_QUEUE_AFTER, (*after, limit + 1)).fetchall() items = [_queue_row(row, _QUEUE_COLUMNS) for row in rows[:limit]] return { "epoch": epoch[0], "revision": revision[0], "total": total, "items": items, "more": len(rows) > limit, } def review_item(self, item_id: str) -> dict | None: """One item with its parsed evidence and the pending total, or None.""" if not isinstance(item_id, str) or _ITEM_ID.fullmatch(item_id) is None: return None with self._annotation_read() as conn: epoch = conn.execute(_EPOCH).fetchone() if epoch is None: raise sqlite3.DatabaseError("Missing execution history metadata") row = conn.execute(_DETAIL, (item_id,)).fetchone() if row is None: return None item = _queue_row(row, _DETAIL_COLUMNS) item["evidence"] = json.loads(item["evidence"]) item["epoch"] = epoch[0] item["total"] = conn.execute(_PENDING_COUNT).fetchone()[0] return item def review_dependency_fingerprint(self, image_path: str) -> str: with self._annotation_read() as conn: return dependency_fingerprint(conn, image_path) def stored_prompt_titles(self, texts) -> dict: """Library titles of saved prompts by text (dedup hash), when set.""" hashes = {generate_prompt_hash(text): text for text in texts if text.strip()} if not hashes: return {} marks = ",".join("?" * len(hashes)) found: dict[str, tuple] = {} conn = self.model.get_connection() for prompt_hash, title in conn.execute( _TITLES.format(marks=marks), list(hashes) ): text = hashes[prompt_hash] found[text] = found.get(text, ()) + (title,) return found def _queue_row(row, columns) -> dict: item = dict(zip(columns, tuple(row))) item["limited"] = bool(item["limited"]) return item