src/webui/services/lorebooks.py
Rama main · réplica de lectura del repositorio original.
"""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}