"""世界模型服务(河图枢运行时,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, )