277 lines
9.3 KiB
Python
277 lines
9.3 KiB
Python
"""世谱服务:注入编排 + CRUD。
|
|
|
|
注入编排(build_lorebook_injection):查条目 → 匹配 → 预算 → 分组为 before/after 文本,
|
|
IO 在此层完成;纯匹配逻辑在 app.modules.lorebook。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from collections import defaultdict
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.errors import AppError
|
|
from app.db.session import AsyncSessionLocal
|
|
from app.models.lorebook import Lorebook as LorebookOrm
|
|
from app.models.lorebook import LorebookEntry as LorebookEntryOrm
|
|
from app.modules.lorebook import (
|
|
LorebookBook,
|
|
LorebookEntry,
|
|
build_injections,
|
|
match_entries,
|
|
select_within_budget,
|
|
)
|
|
from app.repositories import lorebook as lorebook_repo
|
|
from app.repositories.json_utils import dumps
|
|
from app.schemas.lorebook import (
|
|
LorebookCreateRequest,
|
|
LorebookEntryCreateRequest,
|
|
LorebookEntryResponse,
|
|
LorebookEntryUpdateRequest,
|
|
LorebookResponse,
|
|
LorebookUpdateRequest,
|
|
)
|
|
|
|
# ==================== 注入编排(供 send_message 调用)====================
|
|
|
|
|
|
async def build_lorebook_injection(
|
|
session: AsyncSession,
|
|
character_id: str,
|
|
owner_scope: str,
|
|
scan_text: str,
|
|
) -> tuple[str | None, str | None]:
|
|
"""扫描对话文本,返回世谱注入的 (before_char, after_char);无命中返回 (None, None)。
|
|
|
|
每本书各自按其 token_budget 裁剪,再跨书合并按 position 分组。
|
|
"""
|
|
books = await lorebook_repo.list_active_books(session, character_id, owner_scope)
|
|
if not books:
|
|
return None, None
|
|
|
|
entries = await lorebook_repo.list_entries_for_books(session, [b.id for b in books])
|
|
if not entries:
|
|
return None, None
|
|
|
|
entries_by_book: dict[str, list[LorebookEntryOrm]] = defaultdict(list)
|
|
for entry in entries:
|
|
entries_by_book[entry.lorebook_id].append(entry)
|
|
|
|
kept_all = []
|
|
for book_orm in books:
|
|
module_book = _to_module_book(book_orm, entries_by_book.get(book_orm.id, []))
|
|
matched = match_entries(module_book, scan_text)
|
|
kept_all.extend(
|
|
select_within_budget(matched, token_budget=module_book.token_budget)
|
|
)
|
|
|
|
injections = build_injections(kept_all)
|
|
return injections.get("before_char"), injections.get("after_char")
|
|
|
|
|
|
def _to_module_book(book_orm: LorebookOrm, entries_orm: list[LorebookEntryOrm]) -> LorebookBook:
|
|
return LorebookBook(
|
|
id=book_orm.id,
|
|
name=book_orm.name,
|
|
description=book_orm.description,
|
|
entries=[_to_module_entry(e) for e in entries_orm],
|
|
scan_depth=book_orm.scan_depth,
|
|
token_budget=book_orm.token_budget,
|
|
recursive_scanning=book_orm.recursive_scanning,
|
|
scope=book_orm.scope,
|
|
)
|
|
|
|
|
|
def _to_module_entry(entry_orm: LorebookEntryOrm) -> LorebookEntry:
|
|
return LorebookEntry(
|
|
id=entry_orm.id,
|
|
keys=json.loads(entry_orm.keys_json or "[]"),
|
|
content=entry_orm.content,
|
|
secondary_keys=json.loads(entry_orm.secondary_keys_json or "[]"),
|
|
constant=entry_orm.constant,
|
|
selective=entry_orm.selective,
|
|
enabled=entry_orm.enabled,
|
|
case_sensitive=entry_orm.case_sensitive,
|
|
use_regex=entry_orm.use_regex,
|
|
position=entry_orm.position,
|
|
insertion_order=entry_orm.insertion_order,
|
|
priority=entry_orm.priority,
|
|
comment=entry_orm.comment,
|
|
bound_entity_id=entry_orm.bound_entity_id,
|
|
)
|
|
|
|
|
|
# ==================== CRUD(供 API 调用)====================
|
|
|
|
|
|
async def create_lorebook(
|
|
session: AsyncSession, payload: LorebookCreateRequest, owner_scope: str
|
|
) -> LorebookResponse:
|
|
book = LorebookOrm(
|
|
character_id=payload.character_id,
|
|
name=payload.name,
|
|
description=payload.description,
|
|
scan_depth=payload.scan_depth,
|
|
token_budget=payload.token_budget,
|
|
recursive_scanning=payload.recursive_scanning,
|
|
scope=payload.scope,
|
|
owner_scope=owner_scope,
|
|
)
|
|
session.add(book)
|
|
await session.flush()
|
|
response = _book_response(book, [])
|
|
await session.commit()
|
|
return response
|
|
|
|
|
|
async def list_lorebooks(
|
|
*,
|
|
character_id: str | None,
|
|
owner_scope: str,
|
|
scope: str | None = None,
|
|
) -> list[LorebookResponse]:
|
|
async with AsyncSessionLocal() as session:
|
|
books = await lorebook_repo.list_books(session, character_id, owner_scope, scope=scope)
|
|
return [_book_response(b, []) for b in books]
|
|
|
|
|
|
async def get_lorebook(lorebook_id: str, owner_scope: str) -> LorebookResponse:
|
|
async with AsyncSessionLocal() as session:
|
|
book = await _require_book(session, lorebook_id, owner_scope)
|
|
entries = await lorebook_repo.list_book_entries(session, lorebook_id)
|
|
return _book_response(book, entries)
|
|
|
|
|
|
async def update_lorebook(
|
|
lorebook_id: str, payload: LorebookUpdateRequest, owner_scope: str
|
|
) -> LorebookResponse:
|
|
async with AsyncSessionLocal() as session:
|
|
book = await _require_book(session, lorebook_id, owner_scope)
|
|
for field in ("name", "description", "scan_depth", "token_budget", "recursive_scanning"):
|
|
value = getattr(payload, field)
|
|
if value is not None:
|
|
setattr(book, field, value)
|
|
await session.commit()
|
|
entries = await lorebook_repo.list_book_entries(session, lorebook_id)
|
|
return _book_response(book, entries)
|
|
|
|
|
|
async def delete_lorebook(lorebook_id: str, owner_scope: str) -> LorebookResponse:
|
|
async with AsyncSessionLocal() as session:
|
|
book = await _require_book(session, lorebook_id, owner_scope)
|
|
book.status = "archived"
|
|
await session.commit()
|
|
return _book_response(book, [])
|
|
|
|
|
|
async def create_entry(
|
|
session: AsyncSession,
|
|
lorebook_id: str,
|
|
payload: LorebookEntryCreateRequest,
|
|
owner_scope: str,
|
|
) -> LorebookEntryResponse:
|
|
await _require_book(session, lorebook_id, owner_scope)
|
|
entry = LorebookEntryOrm(
|
|
lorebook_id=lorebook_id,
|
|
keys_json=dumps(payload.keys),
|
|
content=payload.content,
|
|
secondary_keys_json=dumps(payload.secondary_keys),
|
|
constant=payload.constant,
|
|
selective=payload.selective,
|
|
enabled=payload.enabled,
|
|
case_sensitive=payload.case_sensitive,
|
|
use_regex=payload.use_regex,
|
|
position=payload.position,
|
|
insertion_order=payload.insertion_order,
|
|
priority=payload.priority,
|
|
comment=payload.comment,
|
|
bound_entity_id=payload.bound_entity_id,
|
|
)
|
|
session.add(entry)
|
|
await session.flush()
|
|
response = _entry_response(entry)
|
|
await session.commit()
|
|
return response
|
|
|
|
|
|
async def update_entry(
|
|
lorebook_id: str,
|
|
entry_id: str,
|
|
payload: LorebookEntryUpdateRequest,
|
|
owner_scope: str,
|
|
) -> LorebookEntryResponse:
|
|
async with AsyncSessionLocal() as session:
|
|
await _require_book(session, lorebook_id, owner_scope)
|
|
entry = await _require_entry(session, entry_id, lorebook_id)
|
|
for key, value in payload.model_dump(exclude_unset=True).items():
|
|
if key in ("keys", "secondary_keys"):
|
|
setattr(entry, f"{key}_json", dumps(value))
|
|
else:
|
|
setattr(entry, key, value)
|
|
await session.commit()
|
|
return _entry_response(entry)
|
|
|
|
|
|
async def delete_entry(lorebook_id: str, entry_id: str, owner_scope: str) -> None:
|
|
async with AsyncSessionLocal() as session:
|
|
await _require_book(session, lorebook_id, owner_scope)
|
|
entry = await _require_entry(session, entry_id, lorebook_id)
|
|
await session.delete(entry)
|
|
await session.commit()
|
|
|
|
|
|
async def _require_book(
|
|
session: AsyncSession, lorebook_id: str, owner_scope: str
|
|
) -> LorebookOrm:
|
|
book = await lorebook_repo.get_book(session, lorebook_id)
|
|
if book is None or book.owner_scope != owner_scope or book.status == "archived":
|
|
raise AppError("not_found", "世谱不存在。")
|
|
return book
|
|
|
|
|
|
async def _require_entry(
|
|
session: AsyncSession, entry_id: str, lorebook_id: str
|
|
) -> LorebookEntryOrm:
|
|
entry = await lorebook_repo.get_entry(session, entry_id)
|
|
if entry is None or entry.lorebook_id != lorebook_id:
|
|
raise AppError("not_found", "条目不存在。")
|
|
return entry
|
|
|
|
|
|
def _entry_response(entry: LorebookEntryOrm) -> LorebookEntryResponse:
|
|
return LorebookEntryResponse(
|
|
id=entry.id,
|
|
lorebook_id=entry.lorebook_id,
|
|
keys=json.loads(entry.keys_json or "[]"),
|
|
content=entry.content,
|
|
secondary_keys=json.loads(entry.secondary_keys_json or "[]"),
|
|
constant=entry.constant,
|
|
selective=entry.selective,
|
|
enabled=entry.enabled,
|
|
case_sensitive=entry.case_sensitive,
|
|
use_regex=entry.use_regex,
|
|
position=entry.position,
|
|
insertion_order=entry.insertion_order,
|
|
priority=entry.priority,
|
|
comment=entry.comment,
|
|
bound_entity_id=entry.bound_entity_id,
|
|
)
|
|
|
|
|
|
def _book_response(
|
|
book: LorebookOrm, entries: list[LorebookEntryOrm]
|
|
) -> LorebookResponse:
|
|
return LorebookResponse(
|
|
id=book.id,
|
|
character_id=book.character_id,
|
|
name=book.name,
|
|
description=book.description,
|
|
scope=book.scope,
|
|
scan_depth=book.scan_depth,
|
|
token_budget=book.token_budget,
|
|
recursive_scanning=book.recursive_scanning,
|
|
status=book.status,
|
|
entries=[_entry_response(e) for e in entries],
|
|
)
|