Files
hunjing/backend/app/services/world.py
T
2026-08-21 16:00:13 +08:00

199 lines
7.3 KiB
Python

"""世界模型服务(河图枢运行时,api-spec §12)。
按人物视角隔离:所有读写经 ``resolve_active_character`` 绑定当前激活人物。
自动抽取(世界引擎)属 v1 半自动留后项,本层先承载手动 CRUD。
"""
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.errors import AppError
from app.db.session import AsyncSessionLocal
from app.models.world import CharacterWorldEntity, WorldRelation
from app.repositories import world as world_repo
from app.repositories.json_utils import dumps
from app.schemas.common import Page
from app.schemas.world import (
WorldEntityCreateRequest,
WorldEntityResponse,
WorldGraphResponse,
WorldRelationCreateRequest,
WorldRelationResponse,
)
from app.services.audit import record_audit_event
from app.services.characters import resolve_active_character
from app.services.serializers import (
character_world_entity_to_response,
world_relation_to_response,
)
async def list_entities_page(
character_id: str | None = None,
*,
entity_type: str | None = None,
page: int = 1,
page_size: int = 20,
owner_scope: str = settings.default_owner_scope,
) -> Page[WorldEntityResponse]:
async with AsyncSessionLocal() as session:
character = await resolve_active_character(session, character_id, owner_scope=owner_scope)
safe_page = max(page, 1)
safe_size = min(max(page_size, 1), 100)
items, total = await world_repo.list_entities(
session,
character.id,
entity_type=entity_type,
page=safe_page,
page_size=safe_size,
)
return Page(
items=[character_world_entity_to_response(item) for item in items],
page=safe_page,
page_size=safe_size,
total=total,
)
async def get_entity_by_id(
entity_id: str, owner_scope: str = settings.default_owner_scope
) -> WorldEntityResponse:
async with AsyncSessionLocal() as session:
character = await resolve_active_character(session, None, owner_scope=owner_scope)
entity = await world_repo.get_entity(session, entity_id)
if entity is None or entity.character_id != character.id:
raise AppError("not_found", "世界实体不存在。")
return character_world_entity_to_response(entity)
async def create_entity(
payload: WorldEntityCreateRequest, owner_scope: str = settings.default_owner_scope
) -> WorldEntityResponse:
async with AsyncSessionLocal() as session:
character = await resolve_active_character(
session, payload.character_id, owner_scope=owner_scope
)
entity = CharacterWorldEntity(
character_id=character.id,
type=payload.type,
name=payload.name,
attributes_json=dumps(payload.attributes),
state_type=payload.state_type,
confidence=payload.confidence,
evidence_ids_json=dumps(payload.evidence_ids),
sources_json=dumps(payload.sources),
)
await world_repo.add_entity(session, entity)
await _audit(session, character.id, "world.entity.created", entity.name, entity.id)
await session.commit()
await session.refresh(entity)
return character_world_entity_to_response(entity)
async def list_relations_page(
character_id: str | None = None,
*,
page: int = 1,
page_size: int = 20,
owner_scope: str = settings.default_owner_scope,
) -> Page[WorldRelationResponse]:
async with AsyncSessionLocal() as session:
character = await resolve_active_character(session, character_id, owner_scope=owner_scope)
safe_page = max(page, 1)
safe_size = min(max(page_size, 1), 100)
items, total = await world_repo.list_relations(
session, character.id, page=safe_page, page_size=safe_size
)
return Page(
items=[world_relation_to_response(item) for item in items],
page=safe_page,
page_size=safe_size,
total=total,
)
async def create_relation(
payload: WorldRelationCreateRequest, owner_scope: str = settings.default_owner_scope
) -> WorldRelationResponse:
async with AsyncSessionLocal() as session:
character = await resolve_active_character(
session, payload.character_id, owner_scope=owner_scope
)
subject = await _ensure_owned_entity(session, character.id, payload.subject_id, "主体")
obj = await _ensure_owned_entity(session, character.id, payload.object_id, "客体")
relation = WorldRelation(
character_id=character.id,
subject_id=subject.id,
predicate=payload.predicate,
object_id=obj.id,
confidence=payload.confidence,
evidence_ids_json=dumps(payload.evidence_ids),
)
await world_repo.add_relation(session, relation)
await _audit(
session, character.id, "world.relation.created", relation.predicate, relation.id
)
await session.commit()
await session.refresh(relation)
return world_relation_to_response(relation)
async def delete_relation(
relation_id: str, owner_scope: str = settings.default_owner_scope
) -> None:
"""删除世界关系。"""
async with AsyncSessionLocal() as session:
character = await resolve_active_character(session, None, owner_scope=owner_scope)
relation = await world_repo.get_relation(session, relation_id)
if relation is None or relation.character_id != character.id:
raise AppError("not_found", "关系不存在。")
await world_repo.delete_relation(session, relation_id)
await _audit(
session, character.id, "world.relation.deleted", relation.predicate, relation_id
)
await session.commit()
async def get_graph(
character_id: str | None = None,
*,
owner_scope: str = settings.default_owner_scope,
) -> WorldGraphResponse:
"""返回人物的完整世界图谱(无分页,所有节点与边),供前端渲染力导向图。"""
async with AsyncSessionLocal() as session:
character = await resolve_active_character(session, character_id, owner_scope=owner_scope)
entities = await world_repo.list_all_entities(session, character.id)
relations = await world_repo.list_all_relations(session, character.id)
return WorldGraphResponse(
nodes=[character_world_entity_to_response(e) for e in entities],
edges=[world_relation_to_response(r) for r in relations],
)
async def _ensure_owned_entity(
session: AsyncSession, character_id: str, entity_id: str, role: str
) -> CharacterWorldEntity:
entity = await world_repo.get_entity(session, entity_id)
if entity is None or entity.character_id != character_id:
raise AppError("not_found", f"关系{role}实体不存在。")
return entity
async def _audit(
session: AsyncSession,
character_id: str,
action: str,
summary: str,
resource_id: str,
) -> None:
await record_audit_event(
session,
action=action,
character_id=character_id,
actor="user",
decision="auto",
input_summary=summary,
resource_type="world_entity" if "entity" in action else "world_relation",
resource_id=resource_id,
)