Ctrl K
ComunidadGuías…

src/webui/services/lorebooks.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Canonical Lorebook book / binding / entry / import / export use cases.

Moved verbatim from WebAPI (Track API-1). Behaviour is unchanged.
Must not import sibling services; cross-service behaviour arrives injected.
"""

from __future__ import annotations

import time
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from typing import Any

from src.content_modules.projection import ContentProjection
from src.content_modules.refs import ContentRef, ContentRefError, parse_content_ref
from src.engine.world.contracts import SOURCE_REF_KINDS
from src.lorebook.activation import DEFAULT_VECTOR_ACTIVATION
from src.lorebook.exporter import export_lorebook_native, export_lorebook_v3
from src.lorebook.store import LorebookIdentityConflict

#: Canonical defaults for a *newly created* entry. Legacy adapter drafts and the
#: world-copy / NPC compatibility paths keep their own historical defaults; these
#: apply only at the canonical create-entry boundary.
CANONICAL_ENTRY_DEFAULTS: dict[str, Any] = {
    "priority": 100,
    "order": 100,
    "prompt_slot": "world_background",
    "vector_activation": DEFAULT_VECTOR_ACTIVATION,
}


@dataclass(frozen=True)
class LorebookDependencies:
    lorebook: Any
    get_instance: Callable[[str], Any | None]
    get_lore_retriever: Callable[[], Any | None]


class LorebookRowProjection:
    """Project one canonical Book onto the existing management-row shape.

    The row is a read-only copy assembled from the canonical book and its
    binding facts.  Keeping this implementation behind the shared
    ``ContentProjection`` contract gives later PR D a real caller without
    changing the current HTTP response or moving ownership into the UI.
    """

    def project(
        self,
        content: Mapping[str, Any],
        *,
        context: Mapping[str, Any] | None = None,
    ) -> dict[str, Any]:
        row = dict(content)
        bindings = [dict(binding) for binding in ((context or {}).get("bindings") or [])]
        row["bindings"] = bindings
        row["scope"] = _book_scope(bindings)
        row["primary"] = any(str(b.get("role") or "") == "primary" for b in bindings)
        return row


_LOREBOOK_ROW_PROJECTION: ContentProjection = LorebookRowProjection()


def list_lorebooks(
    deps: LorebookDependencies,
    world_id: str = "",
    game_key: str = "",
    *,
    include_all: bool = False,
) -> dict[str, Any]:
    """List canonical books visible in a world-management scope.

    Each row carries the binding-derived ``scope`` / ``primary`` facts the
    management UI has to show (name · Primary · scope · enabled). They are
    derived from the canonical bindings rather than stored on the book, so a
    book bound in several scopes reports the most specific one it has here.
    """

    bindings_by_book: dict[str, list[dict[str, Any]]] = {}
    for binding in deps.lorebook.list_bindings():
        bindings_by_book.setdefault(str(binding.get("book_id") or ""), []).append(binding)
    # The management endpoint keeps its historical world/game/global scope by
    # default.  The content hub and package exporter need an explicit catalogue
    # view so a world-bound book is not silently omitted when no world is
    # selected.  Keeping the switch explicit avoids changing create-game
    # binding semantics for existing callers.
    if include_all:
        scoped_books = deps.lorebook.list_lorebooks()
    else:
        game_books = _game_scoped_lorebooks(deps, game_key) if game_key else []
        if world_id and game_books:
            # 浏览某个世界时,绑定在其它世界上的对局书不属于这个书单:
            # 否则 A 局的 runtime 书会混进 B 世界的列表,条目视图和视角
            # 投影从此指向两个世界(列表停在 A 书、可见性按 B 世界算)。
            # 没有 world binding 的对局/角色书保持可见——它们不属于任何
            # 世界,仍是「当前游戏 / 角色」scope 的一部分。
            def _belongs_to_world(book: dict[str, Any]) -> bool:
                worlds = {
                    str(binding.get("scope_id") or "")
                    for binding in bindings_by_book.get(str(book.get("id") or ""), [])
                    if str(binding.get("scope_kind") or "") == "world"
                }
                return not worlds or world_id in worlds

            game_books = [book for book in game_books if _belongs_to_world(book)]
        scoped_books = [
            *(deps.lorebook.list_lorebooks(scope_kind="world", scope_id=world_id) if world_id else []),
            *deps.lorebook.list_lorebooks(scope_kind="global", scope_id=""),
            *game_books,
        ]
    # 完全没有 binding 的 Book 也必须可见:否则「新建世界书」和「暂不绑定」导入
    # 都会产出一本谁也管不到的隐形书,用户再也无法给它加绑定。
    unbound = [
        book for book in deps.lorebook.list_lorebooks()
        if not bindings_by_book.get(str(book.get("id") or ""))
    ]
    seen: set[str] = set()
    merged = []
    for book in [*scoped_books, *unbound]:
        book_id = str(book.get("id") or "")
        if not book_id or book_id in seen:
            continue
        seen.add(book_id)
        bindings = bindings_by_book.get(book_id, [])
        row = _LOREBOOK_ROW_PROJECTION.project(book, context={"bindings": bindings})
        row["entry_count"] = len(deps.lorebook.list_book_entries(book_id))
        ref = _book_content_ref(book, bindings)
        if ref is not None:
            row["ref"] = ref
        merged.append(row)
    return {"books": merged}


def _book_content_ref(book: Mapping[str, Any], bindings: Sequence[Mapping[str, Any]]) -> dict[str, Any] | None:
    """Expose canonical provenance for create-game bindings.

    Legacy native books have no portable source identity.  The server adapts
    those rows at this read boundary using their owning scope, while refusing
    to invent a ref when neither a scope nor a stable book id exists.
    """
    book_id = str(book.get("id") or "").strip()
    kind = str(book.get("source_kind") or "").strip().lower()
    source_id = str(book.get("source_id") or "").strip()
    if kind == "native" or kind not in SOURCE_REF_KINDS:
        world_binding = next((b for b in bindings if str(b.get("scope_kind") or "") == "world" and str(b.get("scope_id") or "")), None)
        if world_binding:
            kind, source_id = "world", str(world_binding.get("scope_id") or "")
        elif book_id:
            kind, source_id = "user", book_id
    if not kind or not source_id or not book_id:
        return None
    try:
        return parse_content_ref({
            "source_kind": kind, "source_id": source_id, "kind": "lorebook", "id": book_id,
            "digest": str(book.get("source_digest") or ""),
        }, default_source="").to_dict()
    except (ContentRefError, ValueError, TypeError):
        return None

def _game_scoped_lorebooks(deps: LorebookDependencies, game_key: str) -> list[dict[str, Any]]:
    """Books bound to this game, or to one of its characters.

    Without this the workspace's 当前游戏 / 角色 scope filters would be
    decorative: such books exist in the canonical store but would never be
    listed for management.
    """

    rows = list(deps.lorebook.list_lorebooks(scope_kind="game", scope_id=game_key))
    instance = deps.get_instance(game_key) if game_key else None
    players = getattr(instance, "players", None) if instance is not None else None
    for uid in sorted(players or {}):
        rows.extend(deps.lorebook.list_lorebooks(scope_kind="character", scope_id=str(uid)))
    return rows

def _book_scope(bindings: list[dict[str, Any]]) -> str:
    """The scope label for a book: most specific binding wins, else unbound."""

    order = ("character", "game", "world", "global")
    kinds = {str(binding.get("scope_kind") or "") for binding in bindings}
    for kind in order:
        if kind in kinds:
            return kind
    return ""

#: Provenance and import identity of a Book. Only server-side import code sets
#: them; the generic create/update use cases never accept them from a client,
#: otherwise a caller could claim (or squat) another source's identity.
BOOK_IDENTITY_FIELDS = frozenset({
    "source_kind", "source_id", "external_id", "source_version", "source_digest",
    "import_link", "import_state_digest",
})


def _without_identity(fields: dict[str, Any]) -> dict[str, Any]:
    return {key: value for key, value in fields.items() if key not in BOOK_IDENTITY_FIELDS}


def _identity_conflict(exc: LorebookIdentityConflict) -> dict[str, Any]:
    return {"ok": False, "error": str(exc), "error_code": exc.code}


def create_lorebook(deps: LorebookDependencies, book: dict[str, Any]) -> dict[str, Any]:
    book = _without_identity(dict(book))
    if not str(book.get("id") or "").strip() or not str(book.get("name") or "").strip():
        return {"ok": False, "error": "id and name are required"}
    if deps.lorebook.get_lorebook(book["id"]):
        return {"ok": False, "error": "Lorebook already exists"}
    try:
        deps.lorebook.create_lorebook(book)
    except LorebookIdentityConflict as exc:
        return _identity_conflict(exc)
    return {"ok": True, "book": deps.lorebook.get_lorebook(book["id"])}

def update_lorebook(deps: LorebookDependencies, book_id: str, updates: dict[str, Any]) -> dict[str, Any]:
    if deps.lorebook.get_lorebook(book_id) is None:
        return {"ok": False, "error": "Lorebook not found"}
    deps.lorebook.update_lorebook(book_id, _without_identity(dict(updates)))
    return {"ok": True, "book": deps.lorebook.get_lorebook(book_id)}

def delete_lorebook(deps: LorebookDependencies, book_id: str) -> dict[str, Any]:
    try:
        deleted = deps.lorebook.delete_lorebook(book_id)
    except ValueError as exc:
        return {"ok": False, "error": str(exc)}
    return {"ok": deleted, **({} if deleted else {"error": "Lorebook not found"}), "book_id": book_id}

def list_lorebook_bindings(deps: LorebookDependencies, book_id: str) -> dict[str, Any]:
    if deps.lorebook.get_lorebook(book_id) is None:
        return {"ok": False, "error": "Lorebook not found", "bindings": []}
    return {"ok": True, "bindings": [b for b in deps.lorebook.list_bindings() if b.get("book_id") == book_id]}

def create_lorebook_binding(deps: LorebookDependencies, book_id: str, binding: dict[str, Any]) -> dict[str, Any]:
    if deps.lorebook.get_lorebook(book_id) is None:
        return {"ok": False, "error": "Lorebook not found"}
    payload = dict(binding)
    payload["book_id"] = book_id
    if not str(payload.get("id") or "").strip():
        return {"ok": False, "error": "id is required"}
    deps.lorebook.bind_lorebook(payload)
    return {"ok": True, "binding": next((b for b in deps.lorebook.list_bindings() if b["id"] == payload["id"]), None)}

def update_lorebook_binding(deps: LorebookDependencies, binding_id: str, updates: dict[str, Any]) -> dict[str, Any]:
    try:
        changed = deps.lorebook.update_binding(binding_id, updates)
    except ValueError as exc:
        return {"ok": False, "error": str(exc)}
    if not changed:
        return {"ok": False, "error": "Binding not found"}
    return {"ok": True, "binding": next((b for b in deps.lorebook.list_bindings() if b["id"] == binding_id), None)}

def delete_lorebook_binding(deps: LorebookDependencies, binding_id: str) -> dict[str, Any]:
    try:
        deleted = deps.lorebook.delete_binding(binding_id)
    except ValueError as exc:
        return {"ok": False, "error": str(exc)}
    return {"ok": deleted, **({} if deleted else {"error": "Binding not found"}), "binding_id": binding_id}

def list_lorebook_entries(deps: LorebookDependencies, book_id: str) -> dict[str, Any]:
    book = deps.lorebook.get_lorebook(book_id)
    if not book:
        return {"ok": False, "error": "Lorebook not found", "entries": []}
    return {"ok": True, "book": book, "entries": deps.lorebook.list_book_entries(book_id)}

def save_lorebook_entry(deps: LorebookDependencies, book_id: str, entry: dict[str, Any]) -> dict[str, Any]:
    book_id = str(book_id or "")
    if deps.lorebook.get_lorebook(book_id) is None:
        return {"ok": False, "error": "Lorebook not found", "error_code": "book_not_found"}
    payload = dict(entry)
    payload["book_id"] = book_id
    payload.setdefault("id", f"{book_id}:entry:{time.time_ns()}")
    entry_id = str(payload["id"])
    get_book_entry = getattr(deps.lorebook, "get_book_entry", None)
    if deps.lorebook.get_entry(entry_id) is not None:
        # Ownership isolation: entry.id is a global canonical PK, but it may
        # only be rewritten through the book that actually owns it.
        if not deps.lorebook.update_book_entry(book_id, entry_id, payload):
            return {"ok": False, "error": "Entry belongs to another lorebook",
                    "error_code": "entry_book_mismatch", "entry_id": entry_id}
        saved = get_book_entry(book_id, entry_id) if callable(get_book_entry) else deps.lorebook.get_entry(entry_id)
        return {"ok": True, "entry": saved}
    # Canonical new-entry defaults apply on create only. An update must be
    # able to clear a field without it silently snapping back to a default.
    for key, value in CANONICAL_ENTRY_DEFAULTS.items():
        payload.setdefault(key, value)
    add_book_entry = getattr(deps.lorebook, "add_book_entry", None)
    if callable(add_book_entry):
        add_book_entry(book_id, payload)
    else:
        # Compatibility stores from older integrations still expose only the
        # legacy add_entry adapter; keep this fallback at the service boundary.
        deps.lorebook.add_entry(payload)
    saved = get_book_entry(book_id, entry_id) if callable(get_book_entry) else deps.lorebook.get_entry(entry_id)
    return {"ok": True, "entry": saved}

def move_lorebook_entry(
    deps: LorebookDependencies, book_id: str, entry_id: str, target_book_id: str,
) -> dict[str, Any]:
    """Move an entry to another book, keeping its canonical entry id."""

    target_book_id = str(target_book_id or "")
    if deps.lorebook.get_lorebook(str(book_id or "")) is None:
        return {"ok": False, "error": "Lorebook not found", "error_code": "book_not_found"}
    if not target_book_id or deps.lorebook.get_lorebook(target_book_id) is None:
        return {"ok": False, "error": "Target lorebook not found",
                "error_code": "target_book_not_found"}
    if not deps.lorebook.move_entry(str(book_id), target_book_id, str(entry_id or "")):
        # Distinguish "no such entry" from "that entry belongs to another
        # book": the second is an ownership conflict, not a 404.
        if deps.lorebook.get_entry(str(entry_id or "")) is not None:
            return {"ok": False, "error": "Entry belongs to another lorebook",
                    "error_code": "entry_book_mismatch", "entry_id": str(entry_id)}
        return {"ok": False, "error": "Entry not found in this lorebook",
                "error_code": "entry_not_found", "entry_id": str(entry_id)}
    return {
        "ok": True, "entry": deps.lorebook.get_entry(str(entry_id)),
        "entry_id": str(entry_id), "book_id": target_book_id,
    }

def delete_lorebook_entry(deps: LorebookDependencies, book_id: str, entry_id: str) -> dict[str, Any]:
    # Ownership isolation: a DELETE through book A must never remove an
    # entry that lives in book B.
    book_id = str(book_id or "")
    if deps.lorebook.get_lorebook(book_id) is None:
        return {"ok": False, "error": "Lorebook not found", "error_code": "book_not_found"}
    if not deps.lorebook.delete_book_entry(book_id, str(entry_id or "")):
        return {"ok": False, "error": "Entry not found in this lorebook",
                "error_code": "entry_not_found", "entry_id": entry_id}
    return {"ok": True, "entry_id": entry_id}

def export_lorebook(deps: LorebookDependencies, book_id: str) -> dict[str, Any]:
    book = deps.lorebook.get_lorebook(book_id)
    if not book:
        return {"ok": False, "error": "Lorebook not found"}
    v3 = export_lorebook_v3(deps.lorebook, book_id)
    return {"ok": True, "format": "lorebook_v3", **v3,
            "native_backup": export_lorebook_native(deps.lorebook, book_id)}

async def lorebook_activation_preview(deps: LorebookDependencies, payload: dict[str, Any]) -> dict[str, Any]:
    """Dry-run the same retriever used by rounds and return its safe trace."""
    game_key = str(payload.get("game_key") or "")
    instance = deps.get_instance(game_key) if game_key else None
    retriever = deps.get_lore_retriever()
    if instance is None or retriever is None:
        return {"ok": False, "error": "game_key must reference an active game"}
    viewer: Any = payload.get("viewer") if isinstance(payload.get("viewer"), dict) else {}
    viewer_is_gm = bool(viewer.get("is_gm", True))
    viewer_uid = str(viewer.get("uid") or "")
    matches = await retriever.retrieve(
        instance, str(payload.get("action_text") or ""),
        viewer_is_gm=viewer_is_gm, viewer_uid=viewer_uid or None,
        viewer_name=str(viewer.get("name") or ""), mutate_timers=False,
        action_actor_uids=[viewer_uid] if viewer_uid and not viewer_is_gm else [],
    )
    trace = list(getattr(instance, "lorebook_activation_trace", []) or [])
    return {"ok": True, "entries": matches if viewer_is_gm else [row for row in matches if row.get("id")], "trace": trace}

def _legacy_existing_refs(deps: LorebookDependencies) -> list[Any]:
    from src.content_modules.refs import ContentRef

    existing_refs: list[ContentRef] = []
    for book in deps.lorebook.list_lorebooks():
        source_kind = str(book.get("source_kind") or "").strip()
        source_id = str(book.get("source_id") or "").strip()
        if not source_kind or not source_id:
            continue
        # The legacy plan identifies a Book by its source alone: Books that
        # name an external id, or no longer follow their source, are not it.
        if str(book.get("external_id") or "") or str(book.get("import_link") or "") == "detached":
            continue
        try:
            existing_refs.append(ContentRef(
                source_kind=source_kind,
                source_id=source_id,
                kind="lorebook",
                id=str(((book.get("settings") or {}).get("diceframe_import") or {}).get("external_id") or source_id),
                explicit=True,
                digest=str(book.get("source_digest") or ""),
            ))
        except ValueError:
            # Historical books without portable provenance are not silently
            # matched to an external draft; they remain explicit legacy data.
            continue
    return existing_refs


def _import_request(body: dict[str, Any]) -> tuple[dict[str, Any], Any]:
    """Split a preview/commit body into the Lorebook document and its identity.

    ``{"payload": {...}, "source": ..., "external_id": ...}`` carries an
    explicit document; any other body *is* the document (legacy preview).
    """

    from src.content_modules.plan import declared_import_source

    if isinstance(body.get("payload"), dict):
        return body["payload"], declared_import_source(body.get("source"), body.get("external_id"))
    return body, None


def _import_plan_view(plan: Any) -> dict[str, Any]:
    from src.content_modules.plan import plan_digest

    return {"plan_digest": plan_digest([plan.item]), "items": [plan.item.to_portable_dict()]}


def preview_lorebook_import(deps: LorebookDependencies, payload: dict[str, Any]) -> dict[str, Any]:
    from dataclasses import asdict
    from src.lorebook.import_plan import plan_lorebook_import
    from src.lorebook.importer import preview_lorebook_import

    try:
        document, declared = _import_request(payload)
    except ValueError as exc:
        return {"ok": False, "error": str(exc), "error_code": IMPORT_SOURCE_INVALID}
    result = preview_lorebook_import(
        document,
        existing_refs=_legacy_existing_refs(deps),
        duplicate_policy=str(payload.get("duplicate_policy") or "update"),
    )
    result["book"] = asdict(result["book"])
    for key in ("content_draft", "commit_plan"):
        if key in result:
            result[key] = result[key].to_portable_dict()
    result["plan"] = _import_plan_view(
        plan_lorebook_import(deps.lorebook, document, declared=declared)
    )
    return result


def _binding_error(binding: Any) -> dict[str, Any] | None:
    from src.lorebook.store import normalize_scope_kind

    if binding is None:
        return None
    if not isinstance(binding, dict):
        return {"ok": False, "error": "binding must be an object"}
    if "scope_kind" not in binding:
        return {"ok": False, "error": "binding requires a canonical scope_kind"}
    try:
        normalize_scope_kind(binding.get("scope_kind"))
    except ValueError as exc:
        return {"ok": False, "error": str(exc)}
    return None


def commit_lorebook_import(
    deps: LorebookDependencies,
    payload: dict[str, Any],
    binding: dict[str, Any] | None = None,
    book_id: str | None = None,
    plan_digest: str = "",
    duplicate_policy: str = "update",
) -> dict[str, Any]:
    from src.lorebook.importer import commit_lorebook_import, draft_lorebook_import

    draft = draft_lorebook_import(payload)
    preview_payload = dict(payload)
    if duplicate_policy not in {"update", "duplicate", "skip"}:
        return {"ok": False, "error": "invalid duplicate policy"}
    # Changing the explicit conflict choice is part of confirmation. Fence the
    # content and catalog against the originally reviewed plan first.
    current = preview_lorebook_import(deps, preview_payload)
    if (current.get("commit_plan") or {}).get("unresolved_references"):
        return {"ok": False, "code": "IMPORT_UNRESOLVED_REFERENCES", "error": "Import has unresolved content references"}
    if plan_digest:
        if str(current.get("plan_digest") or "") != str(plan_digest):
            return {"ok": False, "code": "IMPORT_PLAN_STALE", "error": "导入预览已过期,请重新预览"}
    operations = (current.get("commit_plan") or {}).get("operations") or []
    matched = [row for row in operations if isinstance(row, dict) and row.get("existing_ref")]
    if matched and duplicate_policy == "skip":
        return {"ok": True, "skipped": True, "entries": 0, "warnings": list(draft.warnings)}
    if duplicate_policy == "duplicate":
        if book_id:
            return {"ok": False, "error": "duplicate requires a new book target"}
        import uuid
        book_id = f"import:copy:{uuid.uuid4().hex}"
    elif matched and not book_id:
        ref = matched[0]["existing_ref"]
        candidates = []
        for book in deps.lorebook.list_lorebooks():
            external = str(((book.get("settings") or {}).get("diceframe_import") or {}).get("external_id") or book.get("source_id") or "")
            if (book.get("source_kind"), book.get("source_id"), external) == (ref["source_kind"], ref["source_id"], ref["id"]):
                candidates.append(str(book["id"]))
        if len(candidates) > 1:
            return {"ok": False, "error": "multiple matching books; select an explicit existing target"}
        if candidates:
            book_id = candidates[0]
    # Validate the requested binding before any canonical write so a bad
    # scope cannot leave a half-imported book behind.
    invalid = _binding_error(binding)
    if invalid is not None:
        return invalid
    try:
        imported_book_id = commit_lorebook_import(deps.lorebook, draft, binding, book_id=book_id)
    except LorebookIdentityConflict as exc:
        return _identity_conflict(exc)
    except ValueError as exc:
        # The store boundary is the authority for scope validity.
        return {"ok": False, "error": str(exc)}
    return {"ok": True, "book_id": imported_book_id, "entries": len(draft.entries), "warnings": draft.warnings}


IMPORT_SOURCE_INVALID = "IMPORT_SOURCE_INVALID"
BINDING_REQUIRES_WRITE = "BINDING_REQUIRES_WRITE"
BOOK_ID_NOT_ALLOWED = "BOOK_ID_NOT_ALLOWED"


def commit_lorebook_plan(deps: LorebookDependencies, body: dict[str, Any]) -> dict[str, Any]:
    """Commit a previewed plan: revalidate it, then apply the user's decision.

    The server recomputes the plan from the re-sent document and the current
    store. If it no longer matches the ``plan_digest`` the user confirmed, the
    commit is refused with ``PLAN_STALE`` and a fresh preview, so a decision is
    never applied to a state the user did not see.
    """

    from src.content_modules.plan import PLAN_STALE, PlanDecisionError, plan_digest, resolve_decision
    from src.lorebook.import_plan import execute_lorebook_plan, plan_lorebook_import

    if not isinstance(body.get("payload"), dict):
        return {"ok": False, "error": "payload must be an object"}
    if body.get("book_id"):
        # A plan targets the Book its identity resolves to; importing into an
        # arbitrary Book stays on the legacy route.
        return {"ok": False, "error": "book_id cannot be combined with a plan",
                "error_code": BOOK_ID_NOT_ALLOWED}
    binding = body.get("binding")
    invalid = _binding_error(binding)
    if invalid is not None:
        return invalid
    try:
        document, declared = _import_request(body)
    except ValueError as exc:
        return {"ok": False, "error": str(exc), "error_code": IMPORT_SOURCE_INVALID}

    plan = plan_lorebook_import(deps.lorebook, document, declared=declared)
    current_digest = plan_digest([plan.item])
    if str(body.get("plan_digest") or "") != current_digest:
        return {
            "ok": False, "error_code": PLAN_STALE,
            "error": "the server state changed since the preview; review the new plan",
            "preview": preview_lorebook_import(deps, body),
        }
    try:
        decision = resolve_decision(plan.item, body.get("decision"))
        writes_nothing = decision == "skip" or (
            decision == "update" and plan.item.action == "unchanged"
        )
        if binding and writes_nothing:
            # A binding is applied with the Book it belongs to; an answer
            # that writes nothing must not silently drop it.
            return {"ok": False, "error_code": BINDING_REQUIRES_WRITE,
                    "error": "a binding can only be applied with update, duplicate or create"}
        result = execute_lorebook_plan(deps.lorebook, plan, decision, binding=binding)
    except PlanDecisionError as exc:
        return {"ok": False, "error": str(exc), "error_code": exc.code}
    except LorebookIdentityConflict as exc:
        return _identity_conflict(exc)
    except ValueError as exc:
        return {"ok": False, "error": str(exc)}
    book = deps.lorebook.get_lorebook(result["book_id"]) or {}
    return {
        "ok": True, **result,
        "state_token": str(int(book.get("revision") or 0)),
        "entries": len(plan.draft.entries),
        "warnings": plan.draft.warnings,
    }


def create_reviewed_lorebook(deps: LorebookDependencies, payload: dict[str, Any], book_id: str) -> dict[str, Any]:
    """Create a user-owned reviewed book at an explicit identity without upserting."""
    from src.lorebook.importer import commit_lorebook_import, draft_lorebook_import
    from src.engine.world.contracts import canonical_id
    identity = canonical_id(book_id, field="reviewed book id")
    if preview_lorebook_import(deps, payload)["commit_plan"]["unresolved_references"]:
        raise ValueError("unresolved lorebook references")
    with deps.lorebook.transaction():
        if deps.lorebook.get_lorebook(identity) is not None:
            raise ValueError("lorebook target already exists")
        draft = draft_lorebook_import(payload)
        draft.source.update({"kind": "user", "id": identity, "source_id": identity, "external_id": identity})
        commit_lorebook_import(deps.lorebook, draft, book_id=identity)
    return {"ok": True, "book_id": identity, "entries": len(draft.entries), "warnings": draft.warnings}