* feat(tags): keep unused tags in the shared vocabulary * feat(vocabulary): add bounded tag and category reads * feat(vocabulary): guard tag create, rename and merge * feat(vocabulary): preview and apply category consolidation * refactor(vocabulary): share canonical token hashing * test(vocabulary): prove retained tags survive restore * test(vocabulary): cover category paging tie-break and clarify contract docs * test(vocabulary): assert rejected tag creations write nothing
311 lines
12 KiB
Python
311 lines
12 KiB
Python
"""Transactional native Library commands guarded by epoch and edit token."""
|
|
|
|
import re
|
|
import sqlite3
|
|
|
|
from .library import _ELIGIBLE
|
|
from .models import utc_now_iso
|
|
|
|
try:
|
|
from ..utils.validators import (
|
|
validate_category,
|
|
validate_prompt_text,
|
|
validate_tags,
|
|
)
|
|
except ImportError:
|
|
from utils.validators import validate_category, validate_prompt_text, validate_tags
|
|
|
|
METADATA_FIELDS = frozenset({"title", "category", "tags", "rating", "notes"})
|
|
MAX_TEXT_FIELD = 10000
|
|
_UUID = re.compile(r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}")
|
|
_TOKEN = re.compile(r"[0-9a-f]{64}")
|
|
# Parent metadata stored on the prompt row; title lives in prompt_library_state.
|
|
_PROMPT_COLUMNS = ("category", "rating", "notes")
|
|
|
|
|
|
class LibraryCommandError(Exception):
|
|
"""A native command lost a race against storage or another writer.
|
|
|
|
``code`` is a wire code (``metadata-conflict``, ``storage-changed`` or
|
|
``vocabulary-conflict`` for a tag or category that changed since it was read),
|
|
``epoch`` the storage epoch read inside the transaction and ``positive``
|
|
the current owner record when it may be disclosed.
|
|
"""
|
|
|
|
def __init__(self, code: str, epoch: str, positive: dict | None = None):
|
|
super().__init__(code)
|
|
self.code = code
|
|
self.epoch = epoch
|
|
self.positive = positive
|
|
|
|
|
|
def validate_library_metadata(metadata: dict, *, require_title: bool = False) -> None:
|
|
"""Reject anything but a nonempty allowlisted parent metadata object."""
|
|
if not isinstance(metadata, dict) or not metadata:
|
|
raise ValueError("metadata must be a nonempty object")
|
|
unknown = set(metadata) - METADATA_FIELDS
|
|
if unknown:
|
|
raise ValueError("Unknown metadata fields: " + ", ".join(sorted(unknown)))
|
|
if require_title and (
|
|
not isinstance(metadata.get("title"), str) or not metadata["title"].strip()
|
|
):
|
|
raise ValueError("title is required")
|
|
for name in ("title", "notes"):
|
|
value = metadata.get(name)
|
|
if value is not None and not isinstance(value, str):
|
|
raise ValueError(f"{name} must be a string or null")
|
|
if value is not None and len(value) > MAX_TEXT_FIELD:
|
|
raise ValueError(f"{name} is too long")
|
|
if metadata.get("category") is not None:
|
|
validate_category(metadata["category"])
|
|
if "tags" in metadata:
|
|
if not isinstance(metadata["tags"], list):
|
|
raise ValueError("tags must be a list of strings")
|
|
validate_tags(metadata["tags"])
|
|
rating = metadata.get("rating")
|
|
if rating is not None and (type(rating) is not int or not 1 <= rating <= 5):
|
|
raise ValueError("rating must be an integer between 1 and 5 or null")
|
|
|
|
|
|
def normalize_library_metadata(metadata: dict) -> dict:
|
|
"""Return validated metadata with category trimmed and blank stored as NULL."""
|
|
if "category" not in metadata:
|
|
return metadata
|
|
category = metadata["category"]
|
|
return {**metadata, "category": (category or "").strip() or None}
|
|
|
|
|
|
def normalize_library_tags(tags: list[str]) -> list[str]:
|
|
"""Trim and deduplicate validated tags, keeping first-seen order."""
|
|
return list(dict.fromkeys(tag.strip() for tag in tags))
|
|
|
|
|
|
def _validate_epoch(expected_epoch: str) -> None:
|
|
if not isinstance(expected_epoch, str) or not _UUID.fullmatch(expected_epoch):
|
|
raise ValueError("expected_epoch must be a canonical UUID")
|
|
|
|
|
|
def _validate_guards(positive_id: int, expected_epoch: str, if_match: str) -> None:
|
|
if type(positive_id) is not int or not 1 <= positive_id <= 2**63 - 1:
|
|
raise ValueError("Invalid positive prompt ID")
|
|
_validate_epoch(expected_epoch)
|
|
if not isinstance(if_match, str) or not _TOKEN.fullmatch(if_match):
|
|
raise ValueError("if_match must be 64 lowercase hexadecimal characters")
|
|
|
|
|
|
def _validate_negative_text(negative_text: str) -> None:
|
|
"""Require an explicit string; blank deliberately means the empty negative."""
|
|
if not isinstance(negative_text, str):
|
|
raise ValueError("negative_text must be explicit; use an empty string for none")
|
|
if negative_text.strip():
|
|
validate_prompt_text(negative_text)
|
|
|
|
|
|
class LibraryCommandOperations:
|
|
"""Connection-level parent commands mixed into PromptDatabase."""
|
|
|
|
def _library_epoch(self, conn: sqlite3.Connection, expected_epoch: str) -> str:
|
|
row = conn.execute(
|
|
"SELECT storage_epoch FROM execution_history_metadata WHERE singleton_id=1"
|
|
).fetchone()
|
|
if row is None:
|
|
raise sqlite3.DatabaseError("Missing execution history metadata")
|
|
if row[0] != expected_epoch:
|
|
raise LibraryCommandError("storage-changed", row[0])
|
|
return row[0]
|
|
|
|
def _library_parent(
|
|
self, conn: sqlite3.Connection, epoch: str, positive_id: int
|
|
) -> dict | None:
|
|
rows = self._library_parents(
|
|
conn, epoch, _ELIGIBLE + " AND p.id=?", [positive_id]
|
|
)
|
|
return rows[0] if rows else None
|
|
|
|
def _ensure_library_parent(self, conn: sqlite3.Connection, prompt_id: int) -> None:
|
|
"""Register explicit parent intent once; an existing row is never touched."""
|
|
if conn.execute(
|
|
"SELECT 1 FROM prompt_library_state WHERE prompt_id=?", (prompt_id,)
|
|
).fetchone():
|
|
return
|
|
conn.execute(
|
|
"INSERT INTO prompt_library_state(prompt_id,title) VALUES(?,NULL)",
|
|
(prompt_id,),
|
|
)
|
|
|
|
def _apply_library_metadata(
|
|
self, conn: sqlite3.Connection, current: dict, metadata: dict
|
|
) -> None:
|
|
"""Write only fields that differ; stamp ``prompts.updated_at`` once."""
|
|
prompt_id = current["id"]
|
|
changed = False
|
|
if "title" in metadata and metadata["title"] != current["title"]:
|
|
conn.execute(
|
|
"UPDATE prompt_library_state SET title=? WHERE prompt_id=?",
|
|
(metadata["title"], prompt_id),
|
|
)
|
|
changed = True
|
|
columns = {
|
|
name: metadata[name]
|
|
for name in _PROMPT_COLUMNS
|
|
if name in metadata and metadata[name] != current[name]
|
|
}
|
|
if "tags" in metadata:
|
|
tags = normalize_library_tags(metadata["tags"])
|
|
if set(tags) != set(current["tags"]):
|
|
self._sync_prompt_tags(conn, prompt_id, tags)
|
|
changed = True
|
|
if columns or changed:
|
|
columns["updated_at"] = utc_now_iso()
|
|
conn.execute(
|
|
"UPDATE prompts SET "
|
|
+ ",".join(f"{name}=?" for name in columns)
|
|
+ " WHERE id=?",
|
|
[*columns.values(), prompt_id],
|
|
)
|
|
|
|
def update_library_parent(
|
|
self,
|
|
positive_id: int,
|
|
*,
|
|
expected_epoch: str,
|
|
if_match: str,
|
|
metadata: dict,
|
|
) -> dict | None:
|
|
"""Apply explicit parent metadata if the epoch and edit token still match.
|
|
|
|
Returns ``{library_epoch, positive}`` read before commit, or None when
|
|
the positive is missing or not a Library parent. Raises
|
|
LibraryCommandError for a rotated epoch or a stale token.
|
|
"""
|
|
_validate_guards(positive_id, expected_epoch, if_match)
|
|
validate_library_metadata(metadata)
|
|
metadata = normalize_library_metadata(metadata)
|
|
with self._pair_transaction() as conn:
|
|
epoch = self._library_epoch(conn, expected_epoch)
|
|
current = self._library_parent(conn, epoch, positive_id)
|
|
if current is None:
|
|
return None
|
|
if current["edit_token"] != if_match:
|
|
raise LibraryCommandError("metadata-conflict", epoch, current)
|
|
self._ensure_library_parent(conn, positive_id)
|
|
self._apply_library_metadata(conn, current, metadata)
|
|
positive = self._library_parent(conn, epoch, positive_id)
|
|
if positive is None:
|
|
raise sqlite3.DatabaseError("Library parent vanished during update")
|
|
return {"library_epoch": epoch, "positive": positive}
|
|
|
|
def _recipe_negative(
|
|
self, conn: sqlite3.Connection, negative_text: str
|
|
) -> int | None:
|
|
if not negative_text.strip():
|
|
return None
|
|
return self._pair_component_outcome(conn, negative_text)[0]
|
|
|
|
def _recipe_receipt(
|
|
self,
|
|
conn: sqlite3.Connection,
|
|
epoch: str,
|
|
positive_id: int,
|
|
pair_id: int,
|
|
*,
|
|
positive_created: bool,
|
|
pair_created: bool,
|
|
) -> dict:
|
|
"""Read both owners inside the write transaction for the receipt."""
|
|
positive = self._library_parent(conn, epoch, positive_id)
|
|
pairs = self._read_pair_rows(conn, "pp.id = ?", [pair_id])
|
|
if positive is None or not pairs:
|
|
raise sqlite3.DatabaseError("Library recipe vanished during save")
|
|
return {
|
|
"library_epoch": epoch,
|
|
"positive": positive,
|
|
"pair": pairs[0],
|
|
"positive_created": positive_created,
|
|
"pair_created": pair_created,
|
|
# Parent metadata is applied only to a newly inserted positive.
|
|
"parent_metadata_applied": positive_created,
|
|
}
|
|
|
|
def create_library_recipe(
|
|
self,
|
|
positive_text: str,
|
|
negative_text: str,
|
|
*,
|
|
expected_epoch: str,
|
|
parent_metadata: dict,
|
|
) -> dict:
|
|
"""Create or reuse a positive and its ordered pair in one transaction.
|
|
|
|
Submitted parent metadata applies only when the positive component is
|
|
newly inserted; a reused component keeps its own metadata. New pairs get
|
|
empty pair metadata. Reused pairs keep theirs, including archive state.
|
|
Every success registers the positive as a Library parent.
|
|
"""
|
|
_validate_epoch(expected_epoch)
|
|
validate_prompt_text(positive_text)
|
|
_validate_negative_text(negative_text)
|
|
validate_library_metadata(parent_metadata, require_title=True)
|
|
parent_metadata = normalize_library_metadata(parent_metadata)
|
|
with self._pair_transaction() as conn:
|
|
epoch = self._library_epoch(conn, expected_epoch)
|
|
positive_id, positive_created = self._pair_component_outcome(
|
|
conn, positive_text
|
|
)
|
|
negative_id = self._recipe_negative(conn, negative_text)
|
|
pair_id, pair_created = self._save_pair_components(
|
|
conn, positive_id, negative_id
|
|
)
|
|
self._ensure_library_parent(conn, positive_id)
|
|
if positive_created:
|
|
current = self._library_parent(conn, epoch, positive_id)
|
|
if current is None:
|
|
raise sqlite3.DatabaseError("New Library parent is not eligible")
|
|
self._apply_library_metadata(conn, current, parent_metadata)
|
|
return self._recipe_receipt(
|
|
conn,
|
|
epoch,
|
|
positive_id,
|
|
pair_id,
|
|
positive_created=positive_created,
|
|
pair_created=pair_created,
|
|
)
|
|
|
|
def add_library_negative(
|
|
self,
|
|
positive_id: int,
|
|
negative_text: str,
|
|
*,
|
|
expected_epoch: str,
|
|
if_match: str,
|
|
) -> dict | None:
|
|
"""Create or reuse a pair under the exact stored positive.
|
|
|
|
The stored positive ID and text are used as they are; only the negative
|
|
is resolved. Returns None when the positive is missing or not a Library
|
|
parent, and raises LibraryCommandError for a rotated epoch or a stale
|
|
token, in both cases before any insertion.
|
|
"""
|
|
_validate_guards(positive_id, expected_epoch, if_match)
|
|
_validate_negative_text(negative_text)
|
|
with self._pair_transaction() as conn:
|
|
epoch = self._library_epoch(conn, expected_epoch)
|
|
current = self._library_parent(conn, epoch, positive_id)
|
|
if current is None:
|
|
return None
|
|
if current["edit_token"] != if_match:
|
|
raise LibraryCommandError("metadata-conflict", epoch, current)
|
|
negative_id = self._recipe_negative(conn, negative_text)
|
|
pair_id, pair_created = self._save_pair_components(
|
|
conn, positive_id, negative_id
|
|
)
|
|
self._ensure_library_parent(conn, positive_id)
|
|
return self._recipe_receipt(
|
|
conn,
|
|
epoch,
|
|
positive_id,
|
|
pair_id,
|
|
positive_created=False,
|
|
pair_created=pair_created,
|
|
)
|