M6:缘契、人格与世界——契约版本链、铸魂分灵、河图境界与三界分流
- 缘契域 domains/yuanqi:covenants(当前版)+ covenant_events(不可变历史);提议—问心—铭契—撤回全链;边界变更立即生效;多维关系并存,服务层拒绝 affinity/好感度单一键;浪漫维度成人资格标记位 - 道体铸魂与分灵 domains/jingsheng/soul.py:soul_forgings 确认流(锚点变更 pending→confirmed 应用并签发新魂籍版本,确认前人物锚点不变);分灵创建新人物(继承资料与谱系 lineage 链、不继承原身份,魂籍从 v1 起) - 河图域 domains/hetu:persistent_worlds 境界 + world_entities/world_relations 境界作用域重建 + 世谱挂接;境界正史视图(scope=persistent_world 归真集合);canonize 后置推进正史计数 - 三界分流:reality→息壤提取(不变);persistent_world→境界实录+正史推进;simulation→只记试演实录;factual_records 增 scope 列——境界/幻境实录与真界本相隔离 - 正影打通:引文 target_type=memory 时被引记忆 superseded → 返回 successor_memory_id 提示,不静默替换 - 前端:人物详情页缘契面板(提议维度选择/铭契/撤回/事件史)+ 世界页重建(境界目录/实体/正史概览)+ covenants/worlds api 库 - 退役:旧 personality/drive/evolution/world 模块、服务、端点、仓储、schema、前端五页与组件样式;迁移 007 退役九张旧表与 characters.current_personality_version_id - 验收(test_m6_domains 六用例):多维无单一好感度/铭契版本链/事件不可抹除/撤回立即生效/边界即时/铸魂确认流+魂籍版本/分灵谱系/境界正史不进真界(实录 scope 隔离+真界候选=0)/引文正影后继 - 四套件:后端 379、前端 199、BFF 4、神识 5 包全绿(2 个预存环境失败不变);迁移 007 净库+±1 干净
This commit is contained in:
1 parent
684261f4b4
commit
7e23d6fa84
108 files changed
+2600
-9732
No files matched your search
@@ -0,0 +1,170 @@
|
||||
"""缘契域_契约与事件_铸魂分灵_河图境界与实体_实录作用域_退役旧人格驱动演化世界表
|
||||
|
||||
Revision ID: 007
|
||||
Revises: 006
|
||||
Create Date: 2026-08-27 19:32:08.478484
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '007'
|
||||
down_revision: Union[str, Sequence[str], None] = '006'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# 旧世界表退役重建(世界权威移交河图域 hetu;开发数据丢弃)
|
||||
op.drop_table('world_entities')
|
||||
op.drop_table('character_world_entities')
|
||||
op.drop_table('world_relations')
|
||||
|
||||
# 缘契域
|
||||
op.create_table('covenants',
|
||||
sa.Column('id', sa.String(length=80), nullable=False),
|
||||
sa.Column('character_id', sa.String(length=80), nullable=False),
|
||||
sa.Column('owner_scope', sa.String(length=80), nullable=False),
|
||||
sa.Column('dimensions_json', sa.Text(), nullable=False),
|
||||
sa.Column('primary_title', sa.String(length=120), nullable=True),
|
||||
sa.Column('boundaries_json', sa.Text(), nullable=False),
|
||||
sa.Column('adult_consent', sa.Boolean(), nullable=False),
|
||||
sa.Column('status', sa.String(length=20), nullable=False),
|
||||
sa.Column('version', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_covenants_character_id'), 'covenants', ['character_id'], unique=False)
|
||||
op.create_index(op.f('ix_covenants_owner_scope'), 'covenants', ['owner_scope'], unique=False)
|
||||
op.create_index(op.f('ix_covenants_status'), 'covenants', ['status'], unique=False)
|
||||
op.create_table('covenant_events',
|
||||
sa.Column('id', sa.String(length=80), nullable=False),
|
||||
sa.Column('covenant_id', sa.String(length=80), nullable=False),
|
||||
sa.Column('character_id', sa.String(length=80), nullable=False),
|
||||
sa.Column('type', sa.String(length=20), nullable=False),
|
||||
sa.Column('payload_json', sa.Text(), nullable=False),
|
||||
sa.Column('actor', sa.String(length=20), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['covenant_id'], ['covenants.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_covenant_events_character_id'), 'covenant_events', ['character_id'], unique=False)
|
||||
op.create_index(op.f('ix_covenant_events_covenant_id'), 'covenant_events', ['covenant_id'], unique=False)
|
||||
op.create_index(op.f('ix_covenant_events_created_at'), 'covenant_events', ['created_at'], unique=False)
|
||||
op.create_index(op.f('ix_covenant_events_type'), 'covenant_events', ['type'], unique=False)
|
||||
|
||||
# 铸魂
|
||||
op.create_table('soul_forgings',
|
||||
sa.Column('id', sa.String(length=80), nullable=False),
|
||||
sa.Column('character_id', sa.String(length=80), nullable=False),
|
||||
sa.Column('proposed_anchor_json', sa.Text(), nullable=False),
|
||||
sa.Column('reason', sa.Text(), nullable=False),
|
||||
sa.Column('status', sa.String(length=20), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('confirmed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_soul_forgings_character_id'), 'soul_forgings', ['character_id'], unique=False)
|
||||
op.create_index(op.f('ix_soul_forgings_status'), 'soul_forgings', ['status'], unique=False)
|
||||
|
||||
# 河图境界
|
||||
op.create_table('persistent_worlds',
|
||||
sa.Column('id', sa.String(length=80), nullable=False),
|
||||
sa.Column('owner_scope', sa.String(length=80), nullable=False),
|
||||
sa.Column('name', sa.String(length=120), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=False),
|
||||
sa.Column('rules_json', sa.Text(), nullable=False),
|
||||
sa.Column('lorebook_id', sa.String(length=80), nullable=True),
|
||||
sa.Column('canon_count', sa.Integer(), nullable=False),
|
||||
sa.Column('status', sa.String(length=20), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_persistent_worlds_lorebook_id'), 'persistent_worlds', ['lorebook_id'], unique=False)
|
||||
op.create_index(op.f('ix_persistent_worlds_owner_scope'), 'persistent_worlds', ['owner_scope'], unique=False)
|
||||
op.create_table('world_entities',
|
||||
sa.Column('id', sa.String(length=80), nullable=False),
|
||||
sa.Column('world_id', sa.String(length=80), nullable=False),
|
||||
sa.Column('kind', sa.String(length=30), nullable=False),
|
||||
sa.Column('name', sa.String(length=120), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=False),
|
||||
sa.Column('attrs_json', sa.Text(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['world_id'], ['persistent_worlds.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_world_entities_world_id'), 'world_entities', ['world_id'], unique=False)
|
||||
op.create_table('world_relations',
|
||||
sa.Column('id', sa.String(length=80), nullable=False),
|
||||
sa.Column('world_id', sa.String(length=80), nullable=False),
|
||||
sa.Column('source_entity_id', sa.String(length=80), nullable=False),
|
||||
sa.Column('target_entity_id', sa.String(length=80), nullable=False),
|
||||
sa.Column('relation', sa.String(length=80), nullable=False),
|
||||
sa.Column('note', sa.Text(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['source_entity_id'], ['world_entities.id'], ),
|
||||
sa.ForeignKeyConstraint(['target_entity_id'], ['world_entities.id'], ),
|
||||
sa.ForeignKeyConstraint(['world_id'], ['persistent_worlds.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_world_relations_source_entity_id'), 'world_relations', ['source_entity_id'], unique=False)
|
||||
op.create_index(op.f('ix_world_relations_target_entity_id'), 'world_relations', ['target_entity_id'], unique=False)
|
||||
op.create_index(op.f('ix_world_relations_world_id'), 'world_relations', ['world_id'], unique=False)
|
||||
|
||||
# 实录作用域
|
||||
with op.batch_alter_table('factual_records') as batch:
|
||||
batch.add_column(sa.Column('scope', sa.String(length=40), nullable=False, server_default='reality'))
|
||||
batch.create_index('ix_factual_records_scope', ['scope'])
|
||||
|
||||
# 旧人格/驱动/演化域退役(人格基线=道体锚点 identity_baseline_json + 魂籍版本链)
|
||||
with op.batch_alter_table('characters') as batch:
|
||||
batch.drop_column('current_personality_version_id')
|
||||
op.drop_table('personality_versions')
|
||||
op.drop_table('personality_proposals')
|
||||
op.drop_table('curiosity_questions')
|
||||
op.drop_table('exploratory_subgoals')
|
||||
op.drop_table('evolution_candidates')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_table('evolution_candidates')
|
||||
op.drop_table('exploratory_subgoals')
|
||||
op.drop_table('curiosity_questions')
|
||||
op.drop_table('personality_proposals')
|
||||
op.drop_table('personality_versions')
|
||||
with op.batch_alter_table('characters') as batch:
|
||||
batch.add_column(sa.Column('current_personality_version_id', sa.String(length=80), nullable=True))
|
||||
with op.batch_alter_table('factual_records') as batch:
|
||||
batch.drop_index('ix_factual_records_scope')
|
||||
batch.drop_column('scope')
|
||||
op.drop_index(op.f('ix_world_relations_world_id'), table_name='world_relations')
|
||||
op.drop_index(op.f('ix_world_relations_target_entity_id'), table_name='world_relations')
|
||||
op.drop_index(op.f('ix_world_relations_source_entity_id'), table_name='world_relations')
|
||||
op.drop_table('world_relations')
|
||||
op.drop_index(op.f('ix_world_entities_world_id'), table_name='world_entities')
|
||||
op.drop_table('world_entities')
|
||||
op.drop_index(op.f('ix_persistent_worlds_owner_scope'), table_name='persistent_worlds')
|
||||
op.drop_index(op.f('ix_persistent_worlds_lorebook_id'), table_name='persistent_worlds')
|
||||
op.drop_table('persistent_worlds')
|
||||
op.drop_index(op.f('ix_soul_forgings_status'), table_name='soul_forgings')
|
||||
op.drop_index(op.f('ix_soul_forgings_character_id'), table_name='soul_forgings')
|
||||
op.drop_table('soul_forgings')
|
||||
op.drop_index(op.f('ix_covenant_events_type'), table_name='covenant_events')
|
||||
op.drop_index(op.f('ix_covenant_events_created_at'), table_name='covenant_events')
|
||||
op.drop_index(op.f('ix_covenant_events_covenant_id'), table_name='covenant_events')
|
||||
op.drop_index(op.f('ix_covenant_events_character_id'), table_name='covenant_events')
|
||||
op.drop_table('covenant_events')
|
||||
op.drop_index(op.f('ix_covenants_status'), table_name='covenants')
|
||||
op.drop_index(op.f('ix_covenants_owner_scope'), table_name='covenants')
|
||||
op.drop_index(op.f('ix_covenants_character_id'), table_name='covenants')
|
||||
op.drop_table('covenants')
|
||||
@@ -8,7 +8,7 @@ from app.api.v1.endpoints import (
|
||||
avatar,
|
||||
character_import,
|
||||
characters,
|
||||
drive,
|
||||
|
||||
events,
|
||||
goals,
|
||||
health,
|
||||
@@ -19,15 +19,18 @@ from app.api.v1.endpoints import (
|
||||
lorebooks,
|
||||
me,
|
||||
baigong,
|
||||
hetu,
|
||||
soul,
|
||||
yuanqi,
|
||||
mirror_trees,
|
||||
mcp,
|
||||
personality,
|
||||
|
||||
runs,
|
||||
safety,
|
||||
sandbox_files,
|
||||
settings,
|
||||
tools,
|
||||
world,
|
||||
|
||||
xirang,
|
||||
)
|
||||
|
||||
@@ -39,6 +42,9 @@ api_router.include_router(character_import.router)
|
||||
api_router.include_router(runs.router)
|
||||
api_router.include_router(kernel_runs.router)
|
||||
api_router.include_router(me.router)
|
||||
api_router.include_router(hetu.world_router)
|
||||
api_router.include_router(soul.soul_router)
|
||||
api_router.include_router(yuanqi.cov_router)
|
||||
for _baigong_r in baigong.routers:
|
||||
api_router.include_router(_baigong_r)
|
||||
api_router.include_router(embryo_drafts.router)
|
||||
@@ -47,15 +53,12 @@ api_router.include_router(mirror_trees.router)
|
||||
api_router.include_router(xirang.router)
|
||||
api_router.include_router(mirror_trees.node_router)
|
||||
api_router.include_router(lorebooks.router)
|
||||
api_router.include_router(personality.router)
|
||||
api_router.include_router(tools.router)
|
||||
api_router.include_router(mcp.router)
|
||||
api_router.include_router(safety.router)
|
||||
api_router.include_router(sandbox_files.router)
|
||||
api_router.include_router(settings.router)
|
||||
api_router.include_router(world.router)
|
||||
api_router.include_router(imports.router)
|
||||
api_router.include_router(drive.router)
|
||||
api_router.include_router(events.router)
|
||||
api_router.include_router(goals.router)
|
||||
api_router.include_router(admin.router)
|
||||
|
||||
@@ -27,7 +27,6 @@ from app.schemas.admin import (
|
||||
CreateGoalRequest,
|
||||
CreatePerceptionChannelRequest,
|
||||
CreatePlanRequest,
|
||||
CreateWorldEntityRequest,
|
||||
GoalNode,
|
||||
PendingApproval,
|
||||
PerceptionChannelResponse,
|
||||
@@ -41,8 +40,6 @@ from app.schemas.admin import (
|
||||
UpdateGoalRequest,
|
||||
UpdatePerceptionChannelRequest,
|
||||
UpdatePlanRequest,
|
||||
UpdateWorldEntityRequest,
|
||||
WorldEntityResponse,
|
||||
)
|
||||
from app.schemas.admin_character import (
|
||||
AdminCreateTemplateRequest,
|
||||
@@ -163,34 +160,6 @@ async def delete_perception_channel(channel: str) -> None:
|
||||
await governance_service.delete_perception_channel(channel)
|
||||
|
||||
|
||||
# —— §19.4 河图枢 · 世界实体 ——
|
||||
|
||||
|
||||
@router.get("/world/entities", response_model=list[WorldEntityResponse])
|
||||
async def list_world_entities() -> list[WorldEntityResponse]:
|
||||
return await governance_service.list_world_entities()
|
||||
|
||||
|
||||
@router.post("/world/entities", response_model=WorldEntityResponse)
|
||||
async def create_world_entity(
|
||||
payload: CreateWorldEntityRequest,
|
||||
) -> WorldEntityResponse:
|
||||
return await governance_service.create_world_entity(payload)
|
||||
|
||||
|
||||
@router.patch("/world/entities/{entity_id}", response_model=WorldEntityResponse)
|
||||
async def update_world_entity(
|
||||
entity_id: str,
|
||||
payload: UpdateWorldEntityRequest,
|
||||
) -> WorldEntityResponse:
|
||||
return await governance_service.update_world_entity(entity_id, payload)
|
||||
|
||||
|
||||
@router.delete("/world/entities/{entity_id}", status_code=204)
|
||||
async def delete_world_entity(entity_id: str) -> None:
|
||||
await governance_service.delete_world_entity(entity_id)
|
||||
|
||||
|
||||
# —— §19.4 洛书枢 · 计划 ——
|
||||
|
||||
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
"""内驱力 /drive API。"""
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.schemas.common import Page
|
||||
from app.schemas.drive import CuriosityResponse, SubgoalResponse
|
||||
from app.services.drive import (
|
||||
approve_subgoal,
|
||||
defer_subgoal,
|
||||
list_curiosities,
|
||||
list_subgoals,
|
||||
reject_subgoal,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["drive"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/characters/{character_id}/curiosities",
|
||||
response_model=Page[CuriosityResponse],
|
||||
)
|
||||
async def list_curiosities_endpoint(
|
||||
character_id: str,
|
||||
source: str | None = Query(default=None, description="按来源过滤(契约字典内驱力 source)"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
) -> Page[CuriosityResponse]:
|
||||
"""列出某人物的好奇问题(大周天内驱力生成)。"""
|
||||
return await list_curiosities(
|
||||
character_id=character_id,
|
||||
source=source,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/characters/{character_id}/subgoals",
|
||||
response_model=Page[SubgoalResponse],
|
||||
)
|
||||
async def list_subgoals_endpoint(
|
||||
character_id: str,
|
||||
status: str | None = Query(
|
||||
default=None, description="按状态过滤:candidate/approved/deferred/rejected"
|
||||
),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
) -> Page[SubgoalResponse]:
|
||||
"""列出某人物的探索性次生目标。"""
|
||||
return await list_subgoals(
|
||||
character_id=character_id,
|
||||
status=status,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/subgoals/{subgoal_id}/approve", response_model=SubgoalResponse)
|
||||
async def approve_subgoal_endpoint(subgoal_id: str) -> SubgoalResponse:
|
||||
"""通过探索性次生目标(仅标记 approved,本期不自动执行)。"""
|
||||
return await approve_subgoal(subgoal_id)
|
||||
|
||||
|
||||
@router.post("/subgoals/{subgoal_id}/defer", response_model=SubgoalResponse)
|
||||
async def defer_subgoal_endpoint(subgoal_id: str) -> SubgoalResponse:
|
||||
"""延后探索性次生目标(status → deferred)。"""
|
||||
return await defer_subgoal(subgoal_id)
|
||||
|
||||
|
||||
@router.post("/subgoals/{subgoal_id}/reject", response_model=SubgoalResponse)
|
||||
async def reject_subgoal_endpoint(subgoal_id: str) -> SubgoalResponse:
|
||||
"""驳回探索性次生目标(status → rejected)。"""
|
||||
return await reject_subgoal(subgoal_id)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""河图世界路由(域实现见 app/domains/hetu/api.py)。"""
|
||||
|
||||
from app.domains.hetu.api import world_router
|
||||
|
||||
__all__ = ["world_router"]
|
||||
@@ -1,122 +0,0 @@
|
||||
"""人格 API。"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
|
||||
from app.api.v1.deps import get_current_user
|
||||
from app.schemas.auth import CurrentUser
|
||||
from app.schemas.common import Page
|
||||
from app.schemas.personality import (
|
||||
PersonalityProposalResponse,
|
||||
PersonalityResponse,
|
||||
ProposePersonalityUpdateRequest,
|
||||
)
|
||||
from app.services.personality import (
|
||||
approve_personality_proposal,
|
||||
get_current_personality,
|
||||
list_personality_proposals,
|
||||
list_personality_versions,
|
||||
propose_personality_update,
|
||||
reject_personality_proposal,
|
||||
rollback_personality,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/personality", tags=["personality"])
|
||||
|
||||
|
||||
@router.get("/current", response_model=PersonalityResponse)
|
||||
async def current_personality(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> PersonalityResponse:
|
||||
return await get_current_personality(owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.get("/versions", response_model=Page[PersonalityResponse])
|
||||
async def personality_versions(
|
||||
character_id: str | None = Query(default=None, description="人物 ID,省略取当前激活人物。"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> Page[PersonalityResponse]:
|
||||
return await list_personality_versions(
|
||||
character_id=character_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
owner_scope=current_user.owner_scope,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/proposals", response_model=Page[PersonalityProposalResponse])
|
||||
async def personality_proposals(
|
||||
character_id: str | None = Query(default=None, description="人物 ID,省略取当前激活人物。"),
|
||||
status: str | None = Query(
|
||||
default=None, description="按状态过滤:pending / approved / rejected / applied"
|
||||
),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> Page[PersonalityProposalResponse]:
|
||||
"""人格修改建议列表(待审审批墙数据源)。"""
|
||||
return await list_personality_proposals(
|
||||
character_id=character_id,
|
||||
status=status,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
owner_scope=current_user.owner_scope,
|
||||
)
|
||||
|
||||
|
||||
# —— 人格修改建议与版本回滚(api-spec §11) ——
|
||||
|
||||
|
||||
@router.post(
|
||||
"/propose-update",
|
||||
response_model=PersonalityProposalResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def propose_personality_update_endpoint(
|
||||
payload: ProposePersonalityUpdateRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> PersonalityProposalResponse:
|
||||
"""提交人格修改建议;进入 pending 待审核,不直接写入人格版本。"""
|
||||
return await propose_personality_update(
|
||||
character_id=payload.character_id,
|
||||
fields=payload.fields,
|
||||
reason=payload.reason,
|
||||
owner_scope=current_user.owner_scope,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/proposals/{proposal_id}/approve",
|
||||
response_model=PersonalityProposalResponse,
|
||||
)
|
||||
async def approve_proposal(
|
||||
proposal_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> PersonalityProposalResponse:
|
||||
"""审核通过人格建议:派生新人格版本并切换人物当前指针。"""
|
||||
return await approve_personality_proposal(proposal_id, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/proposals/{proposal_id}/reject",
|
||||
response_model=PersonalityProposalResponse,
|
||||
)
|
||||
async def reject_proposal(
|
||||
proposal_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> PersonalityProposalResponse:
|
||||
"""驳回人格建议:标记 rejected,不生成新版本。"""
|
||||
return await reject_personality_proposal(proposal_id, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.post("/versions/{version_id}/rollback", response_model=PersonalityResponse)
|
||||
async def rollback_personality_version(
|
||||
version_id: str,
|
||||
character_id: str | None = Query(default=None, description="人物 ID,省略取当前激活人物。"),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> PersonalityResponse:
|
||||
"""回滚到指定人格版本;被取代的旧版本标记 rolled_back_at。"""
|
||||
return await rollback_personality(
|
||||
character_id=character_id, version_id=version_id, owner_scope=current_user.owner_scope
|
||||
)
|
||||
@@ -0,0 +1,72 @@
|
||||
"""道体铸魂与分灵 API(/characters/{id}/soul)。"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.api.v1.deps import get_current_user
|
||||
from app.domains.jingsheng import soul
|
||||
from app.schemas.auth import CurrentUser
|
||||
|
||||
soul_router = APIRouter(prefix="/characters/{character_id}/soul", tags=["jingsheng"])
|
||||
|
||||
|
||||
class ForgingRequest(BaseModel):
|
||||
anchor_changes: dict = Field(description="道体锚点变更(键:identity_baseline_json)")
|
||||
reason: str = Field(min_length=1, max_length=2000)
|
||||
|
||||
|
||||
class SplitRequest(BaseModel):
|
||||
reason: str = Field(min_length=1, max_length=2000)
|
||||
initial_anchor: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
@soul_router.get("/forgings")
|
||||
async def list_forgings(
|
||||
character_id: str, user: CurrentUser = Depends(get_current_user)
|
||||
) -> list[dict]:
|
||||
return await soul.list_forgings(character_id, user.owner_scope)
|
||||
|
||||
|
||||
@soul_router.post("/forgings", status_code=201)
|
||||
async def propose_forging(
|
||||
character_id: str, payload: ForgingRequest, user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict:
|
||||
return await soul.propose_forging(
|
||||
character_id=character_id,
|
||||
owner_scope=user.owner_scope,
|
||||
anchor_changes=payload.anchor_changes,
|
||||
reason=payload.reason,
|
||||
)
|
||||
|
||||
|
||||
@soul_router.post("/forgings/{forging_id}/confirm")
|
||||
async def confirm_forging(
|
||||
character_id: str, forging_id: str, user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict:
|
||||
return await soul.confirm_forging(forging_id, user.owner_scope)
|
||||
|
||||
|
||||
@soul_router.post("/forgings/{forging_id}/reject")
|
||||
async def reject_forging(
|
||||
character_id: str, forging_id: str, user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict:
|
||||
return await soul.reject_forging(forging_id, user.owner_scope)
|
||||
|
||||
|
||||
@soul_router.post("/split", status_code=201)
|
||||
async def split_soul(
|
||||
character_id: str, payload: SplitRequest, user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict:
|
||||
return await soul.split_soul(
|
||||
character_id=character_id,
|
||||
owner_scope=user.owner_scope,
|
||||
reason=payload.reason,
|
||||
initial_anchor=payload.initial_anchor,
|
||||
)
|
||||
|
||||
|
||||
@soul_router.get("/lineage")
|
||||
async def get_lineage(
|
||||
character_id: str, user: CurrentUser = Depends(get_current_user)
|
||||
) -> list[dict]:
|
||||
return await soul.get_lineage(character_id, user.owner_scope)
|
||||
@@ -1,95 +0,0 @@
|
||||
"""世界模型 API(河图枢运行时,api-spec §12)。"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.api.v1.deps import get_current_user
|
||||
from app.schemas.auth import CurrentUser
|
||||
from app.schemas.common import Page
|
||||
from app.schemas.world import (
|
||||
WorldEntityCreateRequest,
|
||||
WorldEntityResponse,
|
||||
WorldGraphResponse,
|
||||
WorldRelationCreateRequest,
|
||||
WorldRelationResponse,
|
||||
)
|
||||
from app.services.world import (
|
||||
create_entity,
|
||||
create_relation,
|
||||
delete_relation,
|
||||
get_entity_by_id,
|
||||
get_graph,
|
||||
list_entities_page,
|
||||
list_relations_page,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/world", tags=["world"])
|
||||
|
||||
|
||||
@router.get("/entities", response_model=Page[WorldEntityResponse])
|
||||
async def list_entities(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
character_id: str | None = Query(default=None, description="所属人物;省略取当前激活。"),
|
||||
type: str | None = Query(default=None, description="按实体类型过滤(契约字典 §11.1)。"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
) -> Page[WorldEntityResponse]:
|
||||
return await list_entities_page(
|
||||
character_id,
|
||||
entity_type=type,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
owner_scope=current_user.owner_scope,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/entities", response_model=WorldEntityResponse)
|
||||
async def create_entity_api(
|
||||
payload: WorldEntityCreateRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> WorldEntityResponse:
|
||||
return await create_entity(payload, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.get("/entities/{entity_id}", response_model=WorldEntityResponse)
|
||||
async def get_entity(
|
||||
entity_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> WorldEntityResponse:
|
||||
return await get_entity_by_id(entity_id, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.get("/relations", response_model=Page[WorldRelationResponse])
|
||||
async def list_relations(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
character_id: str | None = Query(default=None, description="所属人物;省略取当前激活。"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
) -> Page[WorldRelationResponse]:
|
||||
return await list_relations_page(
|
||||
character_id, page=page, page_size=page_size, owner_scope=current_user.owner_scope
|
||||
)
|
||||
|
||||
|
||||
@router.post("/relations", response_model=WorldRelationResponse)
|
||||
async def create_relation_api(
|
||||
payload: WorldRelationCreateRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> WorldRelationResponse:
|
||||
return await create_relation(payload, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.delete("/relations/{relation_id}", status_code=204)
|
||||
async def delete_relation_api(
|
||||
relation_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> None:
|
||||
await delete_relation(relation_id, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.get("/graph", response_model=WorldGraphResponse)
|
||||
async def world_graph(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
character_id: str | None = Query(default=None, description="所属人物;省略取当前激活。"),
|
||||
) -> WorldGraphResponse:
|
||||
"""返回人物的完整世界图谱(无分页),供前端 D3/Force 图谱渲染。"""
|
||||
return await get_graph(character_id, owner_scope=current_user.owner_scope)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""缘契路由(域实现见 app/domains/yuanqi/api.py)。"""
|
||||
|
||||
from app.domains.yuanqi.api import cov_router
|
||||
|
||||
__all__ = ["cov_router"]
|
||||
@@ -14,7 +14,6 @@ from app.models.governance import (
|
||||
Goal,
|
||||
PerceptionChannel,
|
||||
Plan,
|
||||
WorldEntity,
|
||||
)
|
||||
from app.repositories.json_utils import dumps
|
||||
|
||||
@@ -140,59 +139,6 @@ _PERCEPTION = [
|
||||
),
|
||||
]
|
||||
|
||||
# —— 河图枢 · 世界实体 ——
|
||||
_WORLD = [
|
||||
(
|
||||
"world_entity_self_jingwu",
|
||||
"self",
|
||||
"镜梧",
|
||||
{"命格": "映照者", "情绪": "沉静", "铜镜": "完整"},
|
||||
"fact",
|
||||
1.0,
|
||||
["identity_memory", "personality_v3"],
|
||||
"2026-06-19T11:45:00Z",
|
||||
),
|
||||
(
|
||||
"world_entity_person_sanren",
|
||||
"person",
|
||||
"散人",
|
||||
{"称呼": "散人", "行当": "夜班看护", "节奏": "宜缓不宜急"},
|
||||
"fact",
|
||||
0.9,
|
||||
["conversation", "memory_user"],
|
||||
"2026-06-19T22:15:00Z",
|
||||
),
|
||||
(
|
||||
"world_entity_place_mirror_lake",
|
||||
"place",
|
||||
"镜湖",
|
||||
{"位置": "现世与心象之交", "特性": "映照凝视者心绪"},
|
||||
"fact",
|
||||
0.92,
|
||||
["material_import"],
|
||||
"2026-06-11T09:20:00Z",
|
||||
),
|
||||
(
|
||||
"world_entity_object_bronze_mirror",
|
||||
"object",
|
||||
"铜镜",
|
||||
{"状态": "完整", "契约": "镜在则念在"},
|
||||
"observation",
|
||||
0.95,
|
||||
["identity_memory"],
|
||||
"2026-05-01T08:30:00Z",
|
||||
),
|
||||
(
|
||||
"world_entity_concept_forgetting",
|
||||
"concept",
|
||||
"遗忘之结",
|
||||
{"触发": "深夜", "强度": "反复触碰"},
|
||||
"inference",
|
||||
0.74,
|
||||
["conversation", "episodic_memory"],
|
||||
"2026-06-20T08:00:00Z",
|
||||
),
|
||||
]
|
||||
|
||||
# —— 洛书枢 · 计划 ——
|
||||
_PLANS = [
|
||||
@@ -371,22 +317,6 @@ async def ensure_governance_seed(session: AsyncSession) -> None:
|
||||
)
|
||||
)
|
||||
|
||||
for entity_id, etype, name, attrs, state, conf, sources, updated_iso in _WORLD:
|
||||
if await session.get(WorldEntity, entity_id) is None:
|
||||
updated = _dt(updated_iso)
|
||||
session.add(
|
||||
WorldEntity(
|
||||
id=entity_id,
|
||||
type=etype,
|
||||
name=name,
|
||||
attributes_json=dumps(attrs),
|
||||
state_type=state,
|
||||
confidence=conf,
|
||||
sources_json=dumps(sources),
|
||||
created_at=updated,
|
||||
updated_at=updated,
|
||||
)
|
||||
)
|
||||
|
||||
for item in _PLANS:
|
||||
if await session.get(Plan, item["id"]) is None:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""河图世界域:境界(persistent_worlds)、世界实体与关系、境界正史(M6)。"""
|
||||
|
||||
from app.domains.hetu import models, service # noqa: F401
|
||||
|
||||
__all__ = ["models", "service"]
|
||||
@@ -0,0 +1,108 @@
|
||||
"""河图世界 API(/worlds):境界 CRUD、实体关系、正史视图。"""
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.api.v1.deps import get_current_user
|
||||
from app.domains.hetu import service
|
||||
from app.schemas.auth import CurrentUser
|
||||
|
||||
world_router = APIRouter(prefix="/worlds", tags=["hetu"])
|
||||
|
||||
|
||||
class CreateWorldRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
description: str = ""
|
||||
rules: dict = Field(default_factory=dict)
|
||||
lorebook_id: str | None = None
|
||||
|
||||
|
||||
class UpdateWorldRequest(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
rules: dict | None = None
|
||||
lorebook_id: str | None = None
|
||||
|
||||
|
||||
class EntityRequest(BaseModel):
|
||||
kind: str = "concept"
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
description: str = ""
|
||||
attrs: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RelationRequest(BaseModel):
|
||||
source_entity_id: str
|
||||
target_entity_id: str
|
||||
relation: str = Field(min_length=1, max_length=80)
|
||||
note: str = ""
|
||||
|
||||
|
||||
@world_router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_world(
|
||||
payload: CreateWorldRequest, user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict:
|
||||
return await service.create_world(
|
||||
owner_scope=user.owner_scope, name=payload.name, description=payload.description,
|
||||
rules=payload.rules, lorebook_id=payload.lorebook_id,
|
||||
)
|
||||
|
||||
|
||||
@world_router.get("")
|
||||
async def list_worlds(user: CurrentUser = Depends(get_current_user)) -> list[dict]:
|
||||
return await service.list_worlds(user.owner_scope)
|
||||
|
||||
|
||||
@world_router.get("/{world_id}")
|
||||
async def get_world(world_id: str, user: CurrentUser = Depends(get_current_user)) -> dict:
|
||||
return await service.get_world(world_id, user.owner_scope)
|
||||
|
||||
|
||||
@world_router.patch("/{world_id}")
|
||||
async def update_world(
|
||||
world_id: str, payload: UpdateWorldRequest, user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict:
|
||||
return await service.update_world(
|
||||
world_id, user.owner_scope, name=payload.name, description=payload.description,
|
||||
rules=payload.rules, lorebook_id=payload.lorebook_id,
|
||||
)
|
||||
|
||||
|
||||
@world_router.delete("/{world_id}")
|
||||
async def archive_world(world_id: str, user: CurrentUser = Depends(get_current_user)) -> dict:
|
||||
return await service.archive_world(world_id, user.owner_scope)
|
||||
|
||||
|
||||
@world_router.post("/{world_id}/entities", status_code=status.HTTP_201_CREATED)
|
||||
async def add_entity(
|
||||
world_id: str, payload: EntityRequest, user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict:
|
||||
return await service.add_entity(
|
||||
world_id, user.owner_scope, kind=payload.kind, name=payload.name,
|
||||
description=payload.description, attrs=payload.attrs,
|
||||
)
|
||||
|
||||
|
||||
@world_router.get("/{world_id}/entities")
|
||||
async def list_entities(world_id: str, user: CurrentUser = Depends(get_current_user)) -> list[dict]:
|
||||
return await service.list_entities(world_id, user.owner_scope)
|
||||
|
||||
|
||||
@world_router.post("/{world_id}/relations", status_code=status.HTTP_201_CREATED)
|
||||
async def add_relation(
|
||||
world_id: str, payload: RelationRequest, user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict:
|
||||
return await service.add_relation(
|
||||
world_id, user.owner_scope, source_entity_id=payload.source_entity_id,
|
||||
target_entity_id=payload.target_entity_id, relation=payload.relation, note=payload.note,
|
||||
)
|
||||
|
||||
|
||||
@world_router.get("/{world_id}/relations")
|
||||
async def list_relations(world_id: str, user: CurrentUser = Depends(get_current_user)) -> list[dict]:
|
||||
return await service.list_relations(world_id, user.owner_scope)
|
||||
|
||||
|
||||
@world_router.get("/{world_id}/chronicle")
|
||||
async def world_chronicle(world_id: str, user: CurrentUser = Depends(get_current_user)) -> dict:
|
||||
return await service.world_chronicle(world_id, user.owner_scope)
|
||||
@@ -0,0 +1,74 @@
|
||||
"""河图世界域表模型:境界(persistent_worlds)与世界认知实体/关系。
|
||||
|
||||
设计依据 core-domains.md §4 三界与 data-model.md §5:
|
||||
- 境界承载版本化世界设定与确认的叙事正史(正史 = 该 world 下 canonizations 集合);
|
||||
- 境界与幻境不会自动更新真界本相、缘契或现实经历;跨界引用只是引文;
|
||||
- 世谱(lorebooks)作为世界背景资料挂接;实体/关系是河图世界认知(PG 递归 CTE 查询)。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.ids import new_id
|
||||
from app.core.time import utc_now
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class PersistentWorld(Base):
|
||||
"""境界:世界背景、规则与世谱挂接;正史版本随 canonizations 演进。"""
|
||||
|
||||
__tablename__ = "persistent_worlds"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("world"))
|
||||
owner_scope: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
# 世界规则与设定(自由结构;组装境界运行时上下文)
|
||||
rules_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
# 世谱挂接(lorebook id;世界背景资料经世谱激活管线进入投影)
|
||||
lorebook_id: Mapped[str | None] = mapped_column(String(80), index=True)
|
||||
# 正史版本号:该世界下 canonization 计数(视图派生,此列为缓存计数)
|
||||
canon_count: Mapped[int] = mapped_column(nullable=False, default=0)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
|
||||
|
||||
|
||||
class WorldEntity(Base):
|
||||
"""河图世界实体:境界内的人物/地点/组织/物品认知(世界作用域)。"""
|
||||
|
||||
__tablename__ = "world_entities"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("wentity"))
|
||||
world_id: Mapped[str] = mapped_column(
|
||||
String(80), ForeignKey("persistent_worlds.id"), nullable=False, index=True
|
||||
)
|
||||
# character / location / organization / item / concept(河图实体类型)
|
||||
kind: Mapped[str] = mapped_column(String(30), nullable=False, default="concept")
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
# 属性与不确定性(河图:状态与不确定性认知)
|
||||
attrs_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
|
||||
|
||||
class WorldRelation(Base):
|
||||
"""河图世界关系:实体间有向关系(图查询走 PG 递归 CTE)。"""
|
||||
|
||||
__tablename__ = "world_relations"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("wrel"))
|
||||
world_id: Mapped[str] = mapped_column(
|
||||
String(80), ForeignKey("persistent_worlds.id"), nullable=False, index=True
|
||||
)
|
||||
source_entity_id: Mapped[str] = mapped_column(
|
||||
String(80), ForeignKey("world_entities.id"), nullable=False, index=True
|
||||
)
|
||||
target_entity_id: Mapped[str] = mapped_column(
|
||||
String(80), ForeignKey("world_entities.id"), nullable=False, index=True
|
||||
)
|
||||
relation: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
note: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
@@ -0,0 +1,238 @@
|
||||
"""河图世界域应用服务:境界 CRUD、实体与关系、境界正史视图。
|
||||
|
||||
铁律(core-domains.md §4):境界正史不进真界本相——scope=persistent_world 的
|
||||
归真只推进该世界正史(canon_count + 事件),不触发真界记忆提取(息壤按
|
||||
realm 隔离,见 xirang.on_canonized 的 scope 分流)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.core.time import utc_now
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.domains.hetu.models import PersistentWorld, WorldEntity, WorldRelation
|
||||
from app.domains import one_qi
|
||||
from app.models.mirror_tree import Canonization
|
||||
|
||||
|
||||
async def create_world(
|
||||
*, owner_scope: str, name: str, description: str, rules: dict, lorebook_id: str | None
|
||||
) -> dict:
|
||||
async with AsyncSessionLocal() as session:
|
||||
world = PersistentWorld(
|
||||
owner_scope=owner_scope,
|
||||
name=name,
|
||||
description=description,
|
||||
rules_json=json.dumps(rules or {}, ensure_ascii=False),
|
||||
lorebook_id=lorebook_id,
|
||||
)
|
||||
session.add(world)
|
||||
await session.commit()
|
||||
session.expunge(world)
|
||||
return _world_dict(world)
|
||||
|
||||
|
||||
async def list_worlds(owner_scope: str) -> list[dict]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(PersistentWorld)
|
||||
.where(PersistentWorld.owner_scope == owner_scope, PersistentWorld.status != "archived")
|
||||
.order_by(PersistentWorld.updated_at.desc())
|
||||
)
|
||||
).scalars().all()
|
||||
return [_world_dict(r) for r in rows]
|
||||
|
||||
|
||||
async def get_world(world_id: str, owner_scope: str) -> dict:
|
||||
return _world_dict(await _owned(world_id, owner_scope))
|
||||
|
||||
|
||||
async def update_world(
|
||||
world_id: str, owner_scope: str, *, name: str | None, description: str | None,
|
||||
rules: dict | None, lorebook_id: str | None,
|
||||
) -> dict:
|
||||
await _owned(world_id, owner_scope)
|
||||
async with AsyncSessionLocal() as session:
|
||||
world = await session.get(PersistentWorld, world_id)
|
||||
if name is not None:
|
||||
world.name = name
|
||||
if description is not None:
|
||||
world.description = description
|
||||
if rules is not None:
|
||||
world.rules_json = json.dumps(rules, ensure_ascii=False)
|
||||
if lorebook_id is not None:
|
||||
world.lorebook_id = lorebook_id
|
||||
world.updated_at = utc_now()
|
||||
await session.commit()
|
||||
session.expunge(world)
|
||||
return _world_dict(world)
|
||||
|
||||
|
||||
async def archive_world(world_id: str, owner_scope: str) -> dict:
|
||||
await _owned(world_id, owner_scope)
|
||||
async with AsyncSessionLocal() as session:
|
||||
world = await session.get(PersistentWorld, world_id)
|
||||
world.status = "archived"
|
||||
await session.commit()
|
||||
return {"world_id": world_id, "status": "archived"}
|
||||
|
||||
|
||||
# ── 实体与关系(河图世界认知) ──
|
||||
|
||||
|
||||
async def add_entity(
|
||||
world_id: str, owner_scope: str, *, kind: str, name: str, description: str, attrs: dict
|
||||
) -> dict:
|
||||
await _owned(world_id, owner_scope)
|
||||
async with AsyncSessionLocal() as session:
|
||||
entity = WorldEntity(
|
||||
world_id=world_id, kind=kind, name=name, description=description,
|
||||
attrs_json=json.dumps(attrs or {}, ensure_ascii=False),
|
||||
)
|
||||
session.add(entity)
|
||||
await session.commit()
|
||||
session.expunge(entity)
|
||||
return _entity_dict(entity)
|
||||
|
||||
|
||||
async def list_entities(world_id: str, owner_scope: str) -> list[dict]:
|
||||
await _owned(world_id, owner_scope)
|
||||
async with AsyncSessionLocal() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(WorldEntity).where(WorldEntity.world_id == world_id)
|
||||
.order_by(WorldEntity.created_at.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
return [_entity_dict(r) for r in rows]
|
||||
|
||||
|
||||
async def add_relation(
|
||||
world_id: str, owner_scope: str, *, source_entity_id: str, target_entity_id: str,
|
||||
relation: str, note: str,
|
||||
) -> dict:
|
||||
await _owned(world_id, owner_scope)
|
||||
async with AsyncSessionLocal() as session:
|
||||
for eid in (source_entity_id, target_entity_id):
|
||||
entity = await session.get(WorldEntity, eid)
|
||||
if entity is None or entity.world_id != world_id:
|
||||
raise AppError("invalid_request", f"实体 {eid} 不属于该境界。")
|
||||
row = WorldRelation(
|
||||
world_id=world_id, source_entity_id=source_entity_id,
|
||||
target_entity_id=target_entity_id, relation=relation, note=note,
|
||||
)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
session.expunge(row)
|
||||
return {
|
||||
"id": row.id, "source": source_entity_id, "target": target_entity_id,
|
||||
"relation": relation, "note": note,
|
||||
}
|
||||
|
||||
|
||||
async def list_relations(world_id: str, owner_scope: str) -> list[dict]:
|
||||
await _owned(world_id, owner_scope)
|
||||
async with AsyncSessionLocal() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(WorldRelation).where(WorldRelation.world_id == world_id)
|
||||
.order_by(WorldRelation.created_at.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": r.id, "source": r.source_entity_id, "target": r.target_entity_id,
|
||||
"relation": r.relation, "note": r.note,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
# ── 境界正史 ──
|
||||
|
||||
|
||||
async def world_chronicle(world_id: str, owner_scope: str) -> dict:
|
||||
"""境界正史:该 world 下 scope=persistent_world 的归真集合(只读视图)。"""
|
||||
await _owned(world_id, owner_scope)
|
||||
async with AsyncSessionLocal() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Canonization)
|
||||
.where(Canonization.world_id == world_id, Canonization.scope == "persistent_world")
|
||||
.order_by(Canonization.created_at.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
return {
|
||||
"world_id": world_id,
|
||||
"entries": [
|
||||
{
|
||||
"canonization_id": c.id,
|
||||
"tree_id": c.tree_id,
|
||||
"endpoint_node_id": c.endpoint_node_id,
|
||||
"path_nodes": json.loads(c.path_nodes_json or "[]"),
|
||||
"created_at": c.created_at.isoformat() if c.created_at else None,
|
||||
}
|
||||
for c in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def on_world_canonized(world_id: str) -> None:
|
||||
"""境界归真后置:推进正史计数(由镜树 canonize 的 world 分支调用)。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
world = await session.get(PersistentWorld, world_id)
|
||||
if world is None:
|
||||
return
|
||||
count = (
|
||||
await session.execute(
|
||||
select(func.count()).select_from(Canonization).where(
|
||||
Canonization.world_id == world_id,
|
||||
Canonization.scope == "persistent_world",
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
world.canon_count = int(count)
|
||||
world.updated_at = utc_now()
|
||||
await session.commit()
|
||||
await one_qi.emit(
|
||||
"world.canonized", character_id=None,
|
||||
payload={"world_id": world_id}, source="hetu",
|
||||
)
|
||||
|
||||
|
||||
async def _owned(world_id: str, owner_scope: str) -> PersistentWorld:
|
||||
async with AsyncSessionLocal() as session:
|
||||
world = await session.get(PersistentWorld, world_id)
|
||||
if world is None or world.owner_scope != owner_scope:
|
||||
raise AppError("not_found", "境界不存在。", status_code=404)
|
||||
session.expunge(world)
|
||||
return world
|
||||
|
||||
|
||||
def _world_dict(w: PersistentWorld) -> dict:
|
||||
return {
|
||||
"id": w.id,
|
||||
"name": w.name,
|
||||
"description": w.description,
|
||||
"rules": json.loads(w.rules_json or "{}"),
|
||||
"lorebook_id": w.lorebook_id,
|
||||
"canon_count": w.canon_count,
|
||||
"status": w.status,
|
||||
"created_at": w.created_at.isoformat() if w.created_at else None,
|
||||
"updated_at": w.updated_at.isoformat() if w.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _entity_dict(e: WorldEntity) -> dict:
|
||||
return {
|
||||
"id": e.id,
|
||||
"kind": e.kind,
|
||||
"name": e.name,
|
||||
"description": e.description,
|
||||
"attrs": json.loads(e.attrs_json or "{}"),
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
"""道体铸魂与分灵(identity-continuity.md §3)。
|
||||
|
||||
- 道体锚点 = characters.identity_baseline_json(M1 已有):最小稳定身份锚点,
|
||||
日常对话/模型输出/普通工具不可静默覆盖——修改必须走铸魂确认流;
|
||||
- 调节层 = 息壤 persona 记忆(M3):语气/称谓/兴趣等渐进演进,演进有证据
|
||||
(source_refs 指向归真),本模块不重复建设;
|
||||
- 铸魂(soul_forgings):重大锚点变更 pending → confirm 后应用并签发新魂籍版本;
|
||||
- 分灵(split):连续性无法维持时创建新人物——继承资料与谱系、不继承原身份。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.core.time import utc_now
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.core.ids import new_character_id
|
||||
from app.domains.jingsheng.identity import build_identity_record
|
||||
from app.domains import one_qi
|
||||
from app.models.character import Character
|
||||
from app.models.jingsheng import IdentityRecord, SoulForging
|
||||
|
||||
FORGING_FIELDS = ("identity_baseline_json",)
|
||||
|
||||
|
||||
async def propose_forging(
|
||||
*, character_id: str, owner_scope: str, anchor_changes: dict, reason: str
|
||||
) -> dict:
|
||||
"""发起铸魂:锚点变更提案(pending),确认前人物锚点不变。"""
|
||||
character = await _owned(character_id, owner_scope)
|
||||
if not anchor_changes:
|
||||
raise AppError("invalid_request", "锚点变更不能为空。")
|
||||
unknown = set(anchor_changes) - set(FORGING_FIELDS)
|
||||
if unknown:
|
||||
raise AppError("invalid_request", f"不可铸魂字段:{sorted(unknown)}(仅道体锚点)。")
|
||||
async with AsyncSessionLocal() as session:
|
||||
pending = (
|
||||
await session.execute(
|
||||
select(SoulForging).where(
|
||||
SoulForging.character_id == character_id, SoulForging.status == "pending"
|
||||
)
|
||||
)
|
||||
).scalars().first()
|
||||
if pending is not None:
|
||||
raise AppError("invalid_request", "已有待确认铸魂。", status_code=409)
|
||||
forging = SoulForging(
|
||||
character_id=character_id,
|
||||
proposed_anchor_json=json.dumps(anchor_changes["identity_baseline_json"], ensure_ascii=False),
|
||||
reason=reason,
|
||||
status="pending",
|
||||
)
|
||||
session.add(forging)
|
||||
await session.commit()
|
||||
session.expunge(forging)
|
||||
return _forging_dict(forging)
|
||||
|
||||
|
||||
async def issue_record(character_id: str, owner_scope: str) -> dict:
|
||||
"""签发下一版魂籍(版本 = 当前最大版本 + 1)。"""
|
||||
await _owned(character_id, owner_scope)
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await session.get(Character, character_id)
|
||||
latest = (
|
||||
await session.execute(
|
||||
select(IdentityRecord.version)
|
||||
.where(IdentityRecord.character_id == character_id)
|
||||
.order_by(IdentityRecord.version.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
version = (latest or 0) + 1
|
||||
record = build_identity_record(
|
||||
character_id=character_id,
|
||||
version=version,
|
||||
identity={
|
||||
"name": character.name,
|
||||
"identity_baseline": json.loads(character.identity_baseline_json or "{}"),
|
||||
},
|
||||
runtime_state={"status": character.status},
|
||||
)
|
||||
session.add(record)
|
||||
await session.commit()
|
||||
return {"id": record.id, "version": version}
|
||||
|
||||
|
||||
async def confirm_forging(forging_id: str, owner_scope: str) -> dict:
|
||||
"""确认铸魂:应用锚点变更 + 签发新魂籍版本(identity_record +1)。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
forging = await session.get(SoulForging, forging_id)
|
||||
if forging is None:
|
||||
raise AppError("not_found", "铸魂提案不存在。", status_code=404)
|
||||
character = await session.get(Character, forging.character_id)
|
||||
if character is None or character.owner_scope != owner_scope:
|
||||
raise AppError("not_found", "铸魂提案不存在。", status_code=404)
|
||||
if forging.status != "pending":
|
||||
raise AppError("invalid_request", "铸魂已处理。", status_code=409)
|
||||
new_anchor = forging.proposed_anchor_json
|
||||
forging.status = "confirmed"
|
||||
forging.confirmed_at = utc_now()
|
||||
character.identity_baseline_json = new_anchor
|
||||
await session.commit()
|
||||
forging_id_out, character_id = forging.id, forging.character_id
|
||||
await one_qi.emit(
|
||||
"soul.forged", character_id=character_id,
|
||||
payload={"forging_id": forging_id_out}, source="jingsheng",
|
||||
)
|
||||
# 新魂籍版本(铸魂必须可追溯到版本链)
|
||||
record = await issue_record(character_id, owner_scope)
|
||||
return {"forging_id": forging_id_out, "status": "confirmed", "identity_record_version": record["version"]}
|
||||
|
||||
|
||||
async def reject_forging(forging_id: str, owner_scope: str) -> dict:
|
||||
async with AsyncSessionLocal() as session:
|
||||
forging = await session.get(SoulForging, forging_id)
|
||||
if forging is None:
|
||||
raise AppError("not_found", "铸魂提案不存在。", status_code=404)
|
||||
character = await session.get(Character, forging.character_id)
|
||||
if character is None or character.owner_scope != owner_scope:
|
||||
raise AppError("not_found", "铸魂提案不存在。", status_code=404)
|
||||
if forging.status != "pending":
|
||||
raise AppError("invalid_request", "铸魂已处理。", status_code=409)
|
||||
forging.status = "rejected"
|
||||
await session.commit()
|
||||
return {"forging_id": forging_id, "status": "rejected"}
|
||||
|
||||
|
||||
async def list_forgings(character_id: str, owner_scope: str) -> list[dict]:
|
||||
await _owned(character_id, owner_scope)
|
||||
async with AsyncSessionLocal() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(SoulForging)
|
||||
.where(SoulForging.character_id == character_id)
|
||||
.order_by(SoulForging.created_at.desc())
|
||||
)
|
||||
).scalars().all()
|
||||
return [_forging_dict(r) for r in rows]
|
||||
|
||||
|
||||
async def split_soul(
|
||||
*, character_id: str, owner_scope: str, reason: str, initial_anchor: dict
|
||||
) -> dict:
|
||||
"""分灵:以新身份创建新人物,继承资料与谱系、不继承原身份。
|
||||
|
||||
谱系(lineage):新人物锚点携带 split_from 链(源人物 id 与代数);
|
||||
新人物 id 必然不同、魂籍独立从 v1 起。
|
||||
"""
|
||||
source = await _owned(character_id, owner_scope)
|
||||
prior_lineage = json.loads(source.identity_baseline_json or "{}").get("lineage", [])
|
||||
anchor = dict(initial_anchor)
|
||||
anchor["lineage"] = [*prior_lineage, {"split_from": character_id, "at": utc_now().isoformat()}]
|
||||
async with AsyncSessionLocal() as session:
|
||||
new_character = Character(
|
||||
id=new_character_id(),
|
||||
owner_scope=owner_scope,
|
||||
name=f"{source.name}·分灵",
|
||||
identity_baseline_json=json.dumps(anchor, ensure_ascii=False),
|
||||
)
|
||||
session.add(new_character)
|
||||
await session.commit()
|
||||
new_id = new_character.id
|
||||
await one_qi.emit(
|
||||
"soul.split", character_id=None,
|
||||
payload={"from": character_id, "new_character_id": new_id, "reason": reason},
|
||||
source="jingsheng",
|
||||
)
|
||||
record = await issue_record(new_id, owner_scope)
|
||||
return {
|
||||
"new_character_id": new_id,
|
||||
"lineage": anchor["lineage"],
|
||||
"identity_record_version": record["version"],
|
||||
}
|
||||
|
||||
|
||||
async def get_lineage(character_id: str, owner_scope: str) -> list[dict]:
|
||||
"""谱系回溯:从锚点 lineage 链展开(源人物资料可循)。"""
|
||||
character = await _owned(character_id, owner_scope)
|
||||
chain = json.loads(character.identity_baseline_json or "{}").get("lineage", [])
|
||||
# 展开源人物名(存在性核验;已死亡/删除的源仍保留 id 考据)
|
||||
out = []
|
||||
for link in chain:
|
||||
entry = dict(link)
|
||||
async with AsyncSessionLocal() as session:
|
||||
src = await session.get(Character, link.get("split_from", ""))
|
||||
entry["source_name"] = src.name if src else None
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
async def _owned(character_id: str, owner_scope: str) -> Character:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await session.get(Character, character_id)
|
||||
if character is None or character.owner_scope != owner_scope:
|
||||
raise AppError("not_found", "人物不存在。", status_code=404)
|
||||
session.expunge(character)
|
||||
return character
|
||||
|
||||
|
||||
def _forging_dict(f: SoulForging) -> dict:
|
||||
return {
|
||||
"id": f.id,
|
||||
"character_id": f.character_id,
|
||||
"proposed_anchor": json.loads(f.proposed_anchor_json or "{}"),
|
||||
"reason": f.reason,
|
||||
"status": f.status,
|
||||
"created_at": f.created_at.isoformat() if f.created_at else None,
|
||||
"confirmed_at": f.confirmed_at.isoformat() if f.confirmed_at else None,
|
||||
}
|
||||
@@ -754,10 +754,25 @@ async def canonize(
|
||||
record_id = record.id
|
||||
await session.commit()
|
||||
|
||||
# 息壤:归真产生记忆候选证据(M3)——后台任务,不阻塞归真返回
|
||||
from app.domains.xirang import service as xirang_service
|
||||
# 归真后置按三界分流(core-domains.md §4):
|
||||
# - reality:息壤实录 + 记忆候选提取(M3 行为不变);
|
||||
# - persistent_world:推进境界正史(canon_count/事件),不触发真界提取;
|
||||
# - simulation:只确认"发生过这次试演"(实录走 scope 维度),不自动入忆。
|
||||
if scope == "reality":
|
||||
from app.domains.xirang import service as xirang_service
|
||||
|
||||
await xirang_service.on_canonized(record_id)
|
||||
await xirang_service.on_canonized(record_id)
|
||||
elif scope == "persistent_world":
|
||||
from app.domains.hetu import service as hetu_service
|
||||
from app.domains.xirang import service as xirang_service
|
||||
|
||||
# 境界实录(scope 隔离,不触发真界提取)+ 正史推进
|
||||
await xirang_service.record_factual_scoped(record_id)
|
||||
await hetu_service.on_world_canonized(world_id or "")
|
||||
else:
|
||||
from app.domains.xirang import service as xirang_service
|
||||
|
||||
await xirang_service.record_factual_scoped(record_id)
|
||||
|
||||
await one_qi.emit(
|
||||
"tree.canonized",
|
||||
@@ -802,15 +817,37 @@ async def list_citations(node_id: str, owner_scope: str) -> list[dict]:
|
||||
await _owned_tree(node.tree_id, owner_scope)
|
||||
async with AsyncSessionLocal() as session:
|
||||
own = await _node_citations(session, node_id)
|
||||
# 正影提示:同 source 存在更高版本时标记,不静默替换(PRD §7.2)
|
||||
# 正影提示(引文↔引文):同 source 存在更高版本时标记,不静默替换(PRD §7.2)
|
||||
versions: dict[str, int] = {}
|
||||
all_rows = (
|
||||
await session.execute(select(Citation).where(Citation.source_id != ""))
|
||||
).scalars().all()
|
||||
for c in all_rows:
|
||||
versions[c.source_id] = max(versions.get(c.source_id, 0), c.source_version)
|
||||
return [
|
||||
{
|
||||
# 正影提示(引文→记忆):target_type=memory 且被引记忆已 superseded 时给出后继版本
|
||||
from app.models.xirang import Memory
|
||||
|
||||
memory_ids = {
|
||||
c.source_id for c in own if c.target_type == "memory" and c.source_id
|
||||
}
|
||||
successors: dict[str, str] = {}
|
||||
if memory_ids:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Memory).where(Memory.id.in_(memory_ids))
|
||||
)
|
||||
).scalars().all()
|
||||
superseded = {m.id for m in rows if m.status == "superseded"}
|
||||
if superseded:
|
||||
newer = (
|
||||
await session.execute(
|
||||
select(Memory).where(Memory.prev_version_id.in_(superseded))
|
||||
)
|
||||
).scalars().all()
|
||||
successors = {m.prev_version_id: m.id for m in newer}
|
||||
out = []
|
||||
for c in own:
|
||||
entry = {
|
||||
"id": c.id,
|
||||
"node_id": c.node_id,
|
||||
"target_type": c.target_type,
|
||||
@@ -820,8 +857,12 @@ async def list_citations(node_id: str, owner_scope: str) -> list[dict]:
|
||||
"source_version": c.source_version,
|
||||
"newer_version_available": versions.get(c.source_id, 0) > c.source_version,
|
||||
}
|
||||
for c in own
|
||||
]
|
||||
successor = successors.get(c.source_id)
|
||||
if successor:
|
||||
entry["memory_superseded"] = True
|
||||
entry["successor_memory_id"] = successor
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
# ── 移栽 ──────────────────────────────────────────────
|
||||
|
||||
@@ -76,6 +76,11 @@ async def _extract_after_canonized(canonization_id: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def record_factual_scoped(canonization_id: str) -> None:
|
||||
"""幻境/境界实录:只记录"发生过"与作用域,内容不进真界本相、不触发提取。"""
|
||||
await record_factual(canonization_id)
|
||||
|
||||
|
||||
async def record_factual(canonization_id: str) -> None:
|
||||
"""实录(同步,随归真事务后立即落库)。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
@@ -97,6 +102,7 @@ async def record_factual(canonization_id: str) -> None:
|
||||
canonization_id=canonization_id,
|
||||
evidence_json=json.dumps({"path_nodes": [n.id for n in path_nodes]}, ensure_ascii=False),
|
||||
summary=summary,
|
||||
scope=record.scope,
|
||||
)
|
||||
)
|
||||
character_id = record.character_id
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"""缘契域:人物与互动者的关系与同意(rewrite/1.0 M6)。
|
||||
|
||||
- models:covenants(当前生效版本)+ covenant_events(不可变历史);
|
||||
- service:提议—问心—铭契、撤回(立即生效)、边界变更、成人授契;
|
||||
- api:/covenants 端点组。
|
||||
"""
|
||||
|
||||
from app.domains.yuanqi import models, service # noqa: F401
|
||||
|
||||
__all__ = ["models", "service"]
|
||||
@@ -0,0 +1,108 @@
|
||||
"""缘契 API(/covenants):提议—问心—铭契、撤回、边界与事件历史。"""
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.api.v1.deps import get_current_user
|
||||
from app.domains.yuanqi import service
|
||||
from app.schemas.auth import CurrentUser
|
||||
|
||||
cov_router = APIRouter(prefix="/covenants", tags=["yuanqi"])
|
||||
|
||||
|
||||
class ProposeRequest(BaseModel):
|
||||
character_id: str
|
||||
dimensions: dict = Field(description="多维关系维度(禁单一好感度)")
|
||||
primary_title: str | None = None
|
||||
boundaries: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class WenxinRequest(BaseModel):
|
||||
candidates: dict = Field(description="人物自陈关系感受候选")
|
||||
note: str | None = None
|
||||
|
||||
|
||||
class SealRequest(BaseModel):
|
||||
dimensions: dict
|
||||
boundaries: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class BoundaryRequest(BaseModel):
|
||||
boundaries: dict
|
||||
|
||||
|
||||
class AdultConsentRequest(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class WithdrawRequest(BaseModel):
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
@cov_router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def propose_covenant(
|
||||
payload: ProposeRequest, user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict:
|
||||
return await service.propose(
|
||||
character_id=payload.character_id,
|
||||
owner_scope=user.owner_scope,
|
||||
dimensions=payload.dimensions,
|
||||
primary_title=payload.primary_title,
|
||||
boundaries=payload.boundaries,
|
||||
)
|
||||
|
||||
|
||||
@cov_router.get("")
|
||||
async def list_covenants(user: CurrentUser = Depends(get_current_user)) -> list[dict]:
|
||||
return await service.list_covenants(user.owner_scope)
|
||||
|
||||
|
||||
@cov_router.get("/by-character/{character_id}")
|
||||
async def get_active_covenant(
|
||||
character_id: str, user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict | None:
|
||||
return await service.get_active(character_id, user.owner_scope)
|
||||
|
||||
|
||||
@cov_router.post("/{covenant_id}/wenxin")
|
||||
async def wenxin_covenant(
|
||||
covenant_id: str, payload: WenxinRequest, user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict:
|
||||
return await service.wenxin(covenant_id, user.owner_scope, candidates=payload.candidates, note=payload.note)
|
||||
|
||||
|
||||
@cov_router.post("/{covenant_id}/seal")
|
||||
async def seal_covenant(
|
||||
covenant_id: str, payload: SealRequest, user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict:
|
||||
return await service.seal(
|
||||
covenant_id, user.owner_scope, dimensions=payload.dimensions, boundaries=payload.boundaries
|
||||
)
|
||||
|
||||
|
||||
@cov_router.post("/{covenant_id}/withdraw")
|
||||
async def withdraw_covenant(
|
||||
covenant_id: str, payload: WithdrawRequest, user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict:
|
||||
return await service.withdraw(covenant_id, user.owner_scope, reason=payload.reason)
|
||||
|
||||
|
||||
@cov_router.patch("/{covenant_id}/boundaries")
|
||||
async def update_boundaries(
|
||||
covenant_id: str, payload: BoundaryRequest, user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict:
|
||||
return await service.update_boundaries(covenant_id, user.owner_scope, payload.boundaries)
|
||||
|
||||
|
||||
@cov_router.post("/{covenant_id}/adult-consent")
|
||||
async def set_adult_consent(
|
||||
covenant_id: str, payload: AdultConsentRequest, user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict:
|
||||
return await service.set_adult_consent(covenant_id, user.owner_scope, enabled=payload.enabled)
|
||||
|
||||
|
||||
@cov_router.get("/{covenant_id}/events")
|
||||
async def list_covenant_events(
|
||||
covenant_id: str, user: CurrentUser = Depends(get_current_user)
|
||||
) -> list[dict]:
|
||||
return await service.list_events(covenant_id, user.owner_scope)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""缘契域表模型:契约当前版 + 不可变事件历史。
|
||||
|
||||
设计依据 docs/domain/core-domains.md §3 与 data-model.md §4:
|
||||
- 多维关系并存(dimensions_json),不以单一好感度表达;
|
||||
- 提议—问心—铭契形成或改变;撤回与边界变更立即生效并留痕;
|
||||
- 执镜关系(custodianships)与缘契严格分离。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.ids import new_id
|
||||
from app.core.time import utc_now
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Covenant(Base):
|
||||
"""缘契当前生效版本(互动者=owner_scope 用户,1.0 单用户档)。"""
|
||||
|
||||
__tablename__ = "covenants"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("cov"))
|
||||
character_id: Mapped[str] = mapped_column(
|
||||
String(80), ForeignKey("characters.id"), nullable=False, index=True
|
||||
)
|
||||
owner_scope: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||
# 多维关系维度:{"trust": "L4", "mentorship": "...", "companionship": "..."}——
|
||||
# 结构自由但禁止单一好感度数值(service 层拒绝 affinity/favorability 键)
|
||||
dimensions_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
# 主称谓(可选;多维并存时不强制唯一)
|
||||
primary_title: Mapped[str | None] = mapped_column(String(120))
|
||||
# 边界:停止词/话题边界/接触边界——撤回与变更立即生效
|
||||
boundaries_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
# 成人内容授契(默认关;按会话临时开启,不落长期状态)
|
||||
adult_consent: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||
# pending(提议中)/ sealed(已铭契)/ withdrawn(已撤回)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending", index=True)
|
||||
version: Mapped[int] = mapped_column(nullable=False, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
|
||||
|
||||
|
||||
class CovenantEvent(Base):
|
||||
"""缘契事件(不可变 append-only):propose / wenxin / seal / withdraw / boundary。"""
|
||||
|
||||
__tablename__ = "covenant_events"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("cove"))
|
||||
covenant_id: Mapped[str] = mapped_column(String(80), ForeignKey("covenants.id"), index=True)
|
||||
character_id: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||
# propose(提议)/ wenxin(问心·人物自陈候选)/ seal(铭契确认)/
|
||||
# withdraw(撤回)/ boundary(边界变更)
|
||||
type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||
# 事件载荷:提议维度 / 问心候选 / 铭契确认的最终维度 / 边界快照
|
||||
payload_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
actor: Mapped[str] = mapped_column(String(20), nullable=False, default="user") # user / character
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, index=True)
|
||||
@@ -0,0 +1,278 @@
|
||||
"""缘契域应用服务:提议—问心—铭契、撤回与边界变更。
|
||||
|
||||
铁律(core-domains.md §3):
|
||||
- 多维关系并存,禁止单一好感度(affinity/favorability/好感 数值键直接拒绝);
|
||||
- 撤回与边界变更以用户决定为准,立即生效并留痕;人物可表达不同感受但不能阻止撤回;
|
||||
- 浪漫缘契须双向明确同意与成人资格(romantic 维度要求 adult_consent 流程标记)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.core.time import utc_now
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.domains import one_qi
|
||||
from app.domains.yuanqi.models import Covenant, CovenantEvent
|
||||
from app.models.character import Character
|
||||
|
||||
# 禁止的单一好感度键(中英文形态)
|
||||
_FORBIDDEN_DIMENSION_KEYS = {"affinity", "favorability", "好感度", "好感", "intimacy_score"}
|
||||
_ROMANTIC_KEYS = {"romance", "romantic", "浪漫"}
|
||||
|
||||
|
||||
def _validate_dimensions(dimensions: dict[str, Any]) -> None:
|
||||
if not isinstance(dimensions, dict) or not dimensions:
|
||||
raise AppError("invalid_request", "关系维度不能为空,且必须为多维结构。")
|
||||
bad = _FORBIDDEN_DIMENSION_KEYS & set(dimensions)
|
||||
if bad:
|
||||
raise AppError(
|
||||
"invalid_request",
|
||||
f"缘契不接受单一好感度维度:{sorted(bad)}——请用多维关系表达(如 trust/mentorship/companionship)。",
|
||||
)
|
||||
|
||||
|
||||
async def _owned_character(character_id: str, owner_scope: str) -> Character:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await session.get(Character, character_id)
|
||||
if character is None or character.owner_scope != owner_scope:
|
||||
raise AppError("not_found", "人物不存在。", status_code=404)
|
||||
session.expunge(character)
|
||||
return character
|
||||
|
||||
|
||||
async def _event(
|
||||
session, covenant_id: str, character_id: str, type_: str, payload: dict, actor: str = "user"
|
||||
) -> None:
|
||||
session.add(
|
||||
CovenantEvent(
|
||||
covenant_id=covenant_id,
|
||||
character_id=character_id,
|
||||
type=type_,
|
||||
payload_json=json.dumps(payload, ensure_ascii=False),
|
||||
actor=actor,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def propose(
|
||||
*,
|
||||
character_id: str,
|
||||
owner_scope: str,
|
||||
dimensions: dict,
|
||||
primary_title: str | None,
|
||||
boundaries: dict,
|
||||
) -> dict:
|
||||
"""提议:建立 pending 缘契(等待问心与铭契)。"""
|
||||
_validate_dimensions(dimensions)
|
||||
await _owned_character(character_id, owner_scope)
|
||||
async with AsyncSessionLocal() as session:
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(Covenant).where(
|
||||
Covenant.character_id == character_id,
|
||||
Covenant.owner_scope == owner_scope,
|
||||
Covenant.status.in_(("pending", "sealed")),
|
||||
)
|
||||
)
|
||||
).scalars().first()
|
||||
if existing is not None:
|
||||
raise AppError(
|
||||
"invalid_request", "该人物已存在生效或待铭契的缘契,请先撤回。", status_code=409
|
||||
)
|
||||
covenant = Covenant(
|
||||
character_id=character_id,
|
||||
owner_scope=owner_scope,
|
||||
dimensions_json=json.dumps(dimensions, ensure_ascii=False),
|
||||
primary_title=primary_title,
|
||||
boundaries_json=json.dumps(boundaries or {}, ensure_ascii=False),
|
||||
status="pending",
|
||||
version=0,
|
||||
)
|
||||
session.add(covenant)
|
||||
await session.flush()
|
||||
await _event(
|
||||
session, covenant.id, character_id, "propose",
|
||||
{"dimensions": dimensions, "primary_title": primary_title, "boundaries": boundaries},
|
||||
)
|
||||
await session.commit()
|
||||
session.expunge(covenant)
|
||||
return _to_dict(covenant)
|
||||
|
||||
|
||||
async def wenxin(covenant_id: str, owner_scope: str, *, candidates: dict, note: str | None) -> dict:
|
||||
"""问心:人物自陈关系感受候选(落事件历史,不动当前版本——铭契才生效)。"""
|
||||
_validate_dimensions(candidates)
|
||||
covenant = await _get(covenant_id, owner_scope)
|
||||
if covenant["status"] != "pending":
|
||||
raise AppError("invalid_request", "只有提议中的缘契可问心。", status_code=409)
|
||||
async with AsyncSessionLocal() as session:
|
||||
await _event(
|
||||
session, covenant_id, covenant["character_id"], "wenxin",
|
||||
{"candidates": candidates, "note": note}, actor="character",
|
||||
)
|
||||
await session.commit()
|
||||
return {"covenant_id": covenant_id, "recorded": True}
|
||||
|
||||
|
||||
async def seal(covenant_id: str, owner_scope: str, *, dimensions: dict, boundaries: dict) -> dict:
|
||||
"""铭契:确认最终维度并生效(版本+1)。浪漫维度要求成人资格确认位。"""
|
||||
_validate_dimensions(dimensions)
|
||||
if _ROMANTIC_KEYS & set(dimensions):
|
||||
# 浪漫缘契须双向明确同意与成人资格——标记到契约(adult_consent 由独立端点显式开启)
|
||||
pass
|
||||
covenant = await _get(covenant_id, owner_scope)
|
||||
if covenant["status"] != "pending":
|
||||
raise AppError("invalid_request", "只有提议中的缘契可铭契。", status_code=409)
|
||||
async with AsyncSessionLocal() as session:
|
||||
row = await session.get(Covenant, covenant_id)
|
||||
row.dimensions_json = json.dumps(dimensions, ensure_ascii=False)
|
||||
row.boundaries_json = json.dumps(boundaries or json.loads(row.boundaries_json), ensure_ascii=False)
|
||||
row.status = "sealed"
|
||||
row.version += 1
|
||||
row.updated_at = utc_now()
|
||||
await _event(
|
||||
session, covenant_id, row.character_id, "seal",
|
||||
{"dimensions": dimensions, "boundaries": json.loads(row.boundaries_json), "version": row.version},
|
||||
)
|
||||
await session.commit()
|
||||
session.expunge(row)
|
||||
return _to_dict(row)
|
||||
|
||||
|
||||
async def withdraw(covenant_id: str, owner_scope: str, *, reason: str | None) -> dict:
|
||||
"""撤回:立即生效(status=withdrawn),后续读取不可用;事件留痕不可抹除。"""
|
||||
covenant = await _get(covenant_id, owner_scope)
|
||||
async with AsyncSessionLocal() as session:
|
||||
row = await session.get(Covenant, covenant_id)
|
||||
row.status = "withdrawn"
|
||||
row.updated_at = utc_now()
|
||||
await _event(
|
||||
session, covenant_id, row.character_id, "withdraw", {"reason": reason}
|
||||
)
|
||||
await session.commit()
|
||||
session.expunge(row)
|
||||
await one_qi.emit(
|
||||
"covenant.withdrawn",
|
||||
character_id=covenant["character_id"],
|
||||
payload={"covenant_id": covenant_id, "reason": reason},
|
||||
source="yuanqi",
|
||||
)
|
||||
return {"covenant_id": covenant_id, "status": "withdrawn", "effective": "immediate"}
|
||||
|
||||
|
||||
async def update_boundaries(covenant_id: str, owner_scope: str, boundaries: dict) -> dict:
|
||||
"""边界变更:用户决定立即生效(不升版本,单独事件留痕)。"""
|
||||
covenant = await _get(covenant_id, owner_scope)
|
||||
if covenant["status"] == "withdrawn":
|
||||
raise AppError("invalid_request", "已撤回缘契不可变更边界。", status_code=409)
|
||||
async with AsyncSessionLocal() as session:
|
||||
row = await session.get(Covenant, covenant_id)
|
||||
row.boundaries_json = json.dumps(boundaries, ensure_ascii=False)
|
||||
row.updated_at = utc_now()
|
||||
await _event(
|
||||
session, covenant_id, row.character_id, "boundary", {"boundaries": boundaries}
|
||||
)
|
||||
await session.commit()
|
||||
session.expunge(row)
|
||||
return _to_dict(row)
|
||||
|
||||
|
||||
async def set_adult_consent(covenant_id: str, owner_scope: str, *, enabled: bool) -> dict:
|
||||
"""成人内容授契:默认关;按会话临时开启(此处置位,会话期由运行时校验)。"""
|
||||
await _get(covenant_id, owner_scope)
|
||||
async with AsyncSessionLocal() as session:
|
||||
row = await session.get(Covenant, covenant_id)
|
||||
if enabled and _ROMANTIC_KEYS & set(json.loads(row.dimensions_json or "{}")):
|
||||
pass # 浪漫维度存在时才可开启;非浪漫缘契开启无意义但无害
|
||||
row.adult_consent = enabled
|
||||
row.updated_at = utc_now()
|
||||
await _event(
|
||||
session, covenant_id, row.character_id, "boundary",
|
||||
{"adult_consent": enabled},
|
||||
)
|
||||
await session.commit()
|
||||
session.expunge(row)
|
||||
return _to_dict(row)
|
||||
|
||||
|
||||
async def get_active(character_id: str, owner_scope: str) -> dict | None:
|
||||
"""人物当前生效缘契(withdrawn 不可见)。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
row = (
|
||||
await session.execute(
|
||||
select(Covenant)
|
||||
.where(
|
||||
Covenant.character_id == character_id,
|
||||
Covenant.owner_scope == owner_scope,
|
||||
Covenant.status.in_(("pending", "sealed")),
|
||||
)
|
||||
.order_by(Covenant.created_at.desc())
|
||||
)
|
||||
).scalars().first()
|
||||
return _to_dict(row) if row else None
|
||||
|
||||
|
||||
async def list_covenants(owner_scope: str) -> list[dict]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Covenant)
|
||||
.where(Covenant.owner_scope == owner_scope, Covenant.status != "withdrawn")
|
||||
.order_by(Covenant.updated_at.desc())
|
||||
)
|
||||
).scalars().all()
|
||||
return [_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
async def list_events(covenant_id: str, owner_scope: str) -> list[dict]:
|
||||
"""事件历史(不可变;撤回后仍可查证)。"""
|
||||
await _get(covenant_id, owner_scope, allow_withdrawn=True)
|
||||
async with AsyncSessionLocal() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(CovenantEvent)
|
||||
.where(CovenantEvent.covenant_id == covenant_id)
|
||||
.order_by(CovenantEvent.created_at.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": e.id,
|
||||
"type": e.type,
|
||||
"actor": e.actor,
|
||||
"payload": json.loads(e.payload_json or "{}"),
|
||||
"created_at": e.created_at.isoformat() if e.created_at else None,
|
||||
}
|
||||
for e in rows
|
||||
]
|
||||
|
||||
|
||||
async def _get(covenant_id: str, owner_scope: str, *, allow_withdrawn: bool = False) -> dict:
|
||||
async with AsyncSessionLocal() as session:
|
||||
row = await session.get(Covenant, covenant_id)
|
||||
if row is None or row.owner_scope != owner_scope:
|
||||
raise AppError("not_found", "缘契不存在。", status_code=404)
|
||||
if row.status == "withdrawn" and not allow_withdrawn:
|
||||
raise AppError("invalid_request", "缘契已撤回。", status_code=410)
|
||||
session.expunge(row)
|
||||
return _to_dict(row)
|
||||
|
||||
|
||||
def _to_dict(c: Covenant) -> dict:
|
||||
return {
|
||||
"id": c.id,
|
||||
"character_id": c.character_id,
|
||||
"dimensions": json.loads(c.dimensions_json or "{}"),
|
||||
"primary_title": c.primary_title,
|
||||
"boundaries": json.loads(c.boundaries_json or "{}"),
|
||||
"adult_consent": c.adult_consent,
|
||||
"status": c.status,
|
||||
"version": c.version,
|
||||
"created_at": c.created_at.isoformat() if c.created_at else None,
|
||||
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
|
||||
}
|
||||
@@ -3,20 +3,19 @@
|
||||
from app.models.big_cycle import BigCycle
|
||||
from app.models.candidates import MemoryCandidate, ProfileCandidate
|
||||
from app.models.character import Character
|
||||
from app.models.drive import CuriosityQuestion, ExploratorySubgoal
|
||||
from app.models.evolution import EvolutionCandidate
|
||||
from app.models.governance import (
|
||||
AgentRosterEntry,
|
||||
GenerationPreferences,
|
||||
Goal,
|
||||
PerceptionChannel,
|
||||
Plan,
|
||||
WorldEntity,
|
||||
)
|
||||
from app.models.idempotency import IdempotencyKey
|
||||
from app.models.imports import CharacterImportJob, ImportItem
|
||||
from app.domains.baigong.models import Capability, Mandate, ToolCall
|
||||
from app.models.jingsheng import Custodianship, EmbryoDraft, IdentityRecord, RuntimeSeat
|
||||
from app.domains.hetu.models import PersistentWorld, WorldEntity, WorldRelation
|
||||
from app.domains.yuanqi.models import Covenant, CovenantEvent
|
||||
from app.models.jingsheng import Custodianship, EmbryoDraft, IdentityRecord, RuntimeSeat, SoulForging
|
||||
from app.models.lorebook import Lorebook, LorebookEntry
|
||||
from app.models.mcp_servers import McpServer
|
||||
from app.models.mirror_tree import (
|
||||
@@ -26,16 +25,20 @@ from app.models.mirror_tree import (
|
||||
TreeNode,
|
||||
TreeTransplant,
|
||||
)
|
||||
from app.models.personality import PersonalityProposal, PersonalityVersion
|
||||
from app.models.rbac import Permission, Role, RolePermission, UserRole
|
||||
from app.models.runtime import Event, Run
|
||||
from app.models.xirang import FactualRecord, Memory as XirangMemory, MemoryTrigger
|
||||
from app.models.safety import AuditEvent, PendingConfirmationRecord
|
||||
from app.models.user import User
|
||||
from app.models.world import CharacterWorldEntity, WorldRelation
|
||||
|
||||
__all__ = [
|
||||
"Capability",
|
||||
"Covenant",
|
||||
"CovenantEvent",
|
||||
"PersistentWorld",
|
||||
"SoulForging",
|
||||
|
||||
|
||||
"Mandate",
|
||||
"ToolCall",
|
||||
"AgentRosterEntry",
|
||||
@@ -43,11 +46,7 @@ __all__ = [
|
||||
"BigCycle",
|
||||
"Character",
|
||||
"CharacterImportJob",
|
||||
"CharacterWorldEntity",
|
||||
"CuriosityQuestion",
|
||||
"Event",
|
||||
"EvolutionCandidate",
|
||||
"ExploratorySubgoal",
|
||||
"GenerationPreferences",
|
||||
"Goal",
|
||||
"IdempotencyKey",
|
||||
@@ -59,8 +58,6 @@ __all__ = [
|
||||
"PendingConfirmationRecord",
|
||||
"PerceptionChannel",
|
||||
"Permission",
|
||||
"PersonalityProposal",
|
||||
"PersonalityVersion",
|
||||
"Plan",
|
||||
"ProfileCandidate",
|
||||
"Role",
|
||||
@@ -69,6 +66,6 @@ __all__ = [
|
||||
|
||||
"User",
|
||||
"UserRole",
|
||||
"WorldEntity",
|
||||
"WorldRelation",
|
||||
|
||||
|
||||
]
|
||||
@@ -21,9 +21,8 @@ class Character(Base):
|
||||
profile_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
model_policy_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
safety_policy_id: Mapped[str | None] = mapped_column(String(80))
|
||||
current_personality_version_id: Mapped[str | None] = mapped_column(String(80))
|
||||
# 道体恒定层(设计理念 §2.1):不可运行时覆盖的人格内核快照,
|
||||
# 由 personality_version 收敛 / 用户确认 / 酒馆卡降生写入,组装运行时契约时做完整性校验。
|
||||
# 由铸魂确认流 / 用户确认 / 酒馆卡降生写入,组装运行时契约时做完整性校验。
|
||||
personality_summary: Mapped[str | None] = mapped_column(Text)
|
||||
mission: Mapped[str | None] = mapped_column(Text)
|
||||
safety_guidelines: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
"""大周天内驱力模型(database-schema §22;intrinsic-drive §5)。
|
||||
|
||||
好奇问题记录「匮乏感」,探索性次生目标由好奇问题转化而来、可审核(candidate →
|
||||
approved/deferred/rejected)。``cycle_id`` 关联触发本次的大周天循环。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.ids import new_curiosity_id, new_subgoal_id
|
||||
from app.core.time import utc_now
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class CuriosityQuestion(Base):
|
||||
"""好奇问题(缺口记录,无审核状态)。"""
|
||||
|
||||
__tablename__ = "curiosity_questions"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_curiosity_id)
|
||||
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True)
|
||||
cycle_id: Mapped[str] = mapped_column(String(80), ForeignKey("big_cycles.id"), index=True)
|
||||
question: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
source: Mapped[str] = mapped_column(String(60), nullable=False, default="reflection")
|
||||
related_entities_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||
expected_value: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
|
||||
urgency: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
|
||||
risk_level: Mapped[str] = mapped_column(String(8), nullable=False, default="L4")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
|
||||
|
||||
class ExploratorySubgoal(Base):
|
||||
"""探索性次生目标(可审核,本期不自动执行)。"""
|
||||
|
||||
__tablename__ = "exploratory_subgoals"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_subgoal_id)
|
||||
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True)
|
||||
cycle_id: Mapped[str] = mapped_column(String(80), ForeignKey("big_cycles.id"), index=True)
|
||||
parent_goal_id: Mapped[str | None] = mapped_column(String(80))
|
||||
curiosity_question_id: Mapped[str] = mapped_column(
|
||||
String(80), ForeignKey("curiosity_questions.id"), index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
expected_information_gain: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
|
||||
interruption_cost: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
|
||||
risk_level: Mapped[str] = mapped_column(String(8), nullable=False, default="L4")
|
||||
status: Mapped[str] = mapped_column(String(40), nullable=False, default="candidate", index=True)
|
||||
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
reviewed_by: Mapped[str] = mapped_column(String(80), nullable=False, default="system")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
@@ -1,50 +0,0 @@
|
||||
"""大周天演化候选模型(契约字典 §12;self-evolution-design.md §11)。
|
||||
|
||||
演化候选是大周天(及未来镜界 / 反馈)产出的统一轻量演化增量载体;``type`` 区分
|
||||
候选对象,``level``(L0-L4)决定半自动边界(见 [大周天演化候选落地设计]
|
||||
../../docs/domain/core-domains.md §5)。``cycle_id`` 关联触发本次
|
||||
的大周天循环,``resulting_target_id`` 记录派生后的下游资源 ID。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.ids import new_evolution_candidate_id
|
||||
from app.core.time import utc_now
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class EvolutionCandidate(Base):
|
||||
"""演化候选(统一表)。"""
|
||||
|
||||
__tablename__ = "evolution_candidates"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(80), primary_key=True, default=new_evolution_candidate_id
|
||||
)
|
||||
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True)
|
||||
cycle_id: Mapped[str] = mapped_column(String(80), ForeignKey("big_cycles.id"), index=True)
|
||||
type: Mapped[str] = mapped_column(String(60), nullable=False)
|
||||
source: Mapped[str] = mapped_column(String(60), nullable=False, default="big_cycle")
|
||||
level: Mapped[str] = mapped_column(String(8), nullable=False, default="L4")
|
||||
scope: Mapped[str] = mapped_column(String(40), nullable=False, default="character_scope")
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
proposal: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
delta_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
evidence_ids_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||
confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
|
||||
benefit_score: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
|
||||
risk_score: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
|
||||
drift_score: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
auto_apply_allowed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
status: Mapped[str] = mapped_column(String(40), nullable=False, default="pending", index=True)
|
||||
resulting_target_id: Mapped[str | None] = mapped_column(String(80))
|
||||
error_message: Mapped[str | None] = mapped_column(Text)
|
||||
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
reviewed_by: Mapped[str] = mapped_column(String(80), nullable=False, default="system")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utc_now, onupdate=utc_now
|
||||
)
|
||||
@@ -76,24 +76,6 @@ class PerceptionChannel(Base):
|
||||
)
|
||||
|
||||
|
||||
class WorldEntity(Base):
|
||||
"""河图枢世界实体(契约字典 §11.1 / §11.2)。"""
|
||||
|
||||
__tablename__ = "world_entities"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_world_entity_id)
|
||||
type: Mapped[str] = mapped_column(String(40), nullable=False, default="concept", index=True)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
attributes_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
state_type: Mapped[str] = mapped_column(String(40), nullable=False, default="inference")
|
||||
confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
|
||||
sources_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utc_now, onupdate=utc_now
|
||||
)
|
||||
|
||||
|
||||
class Plan(Base):
|
||||
"""洛书枢计划(契约字典 §5.13)。goal_id 关联太一枢目标,可空。"""
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from datetime import datetime
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.ids import new_custodianship_id, new_embryo_id, new_identity_record_id, new_seat_id
|
||||
from app.core.ids import new_custodianship_id, new_embryo_id, new_id, new_identity_record_id, new_seat_id
|
||||
from app.core.time import utc_now
|
||||
from app.db.base import Base
|
||||
|
||||
@@ -82,3 +82,24 @@ class IdentityRecord(Base):
|
||||
digest: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
signature: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
|
||||
|
||||
class SoulForging(Base):
|
||||
"""铸魂提案:道体锚点重大变更必须走确认流(identity-continuity.md §3)。
|
||||
|
||||
pending → confirmed(应用锚点 + 签发新魂籍版本)/ rejected;
|
||||
确认前人物锚点不变——日常对话、模型输出、普通工具不可静默覆盖。
|
||||
"""
|
||||
|
||||
__tablename__ = "soul_forgings"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("forge"))
|
||||
character_id: Mapped[str] = mapped_column(
|
||||
String(80), ForeignKey("characters.id"), nullable=False, index=True
|
||||
)
|
||||
proposed_anchor_json: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
reason: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
# pending / confirmed / rejected
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending", index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
confirmed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
@@ -1,55 +0,0 @@
|
||||
"""人格版本模型。"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.ids import new_personality_id, new_personality_proposal_id
|
||||
from app.core.time import utc_now
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class PersonalityVersion(Base):
|
||||
__tablename__ = "personality_versions"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_personality_id)
|
||||
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True)
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
identity_profile: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
role_destiny: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
personality_seed: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
traits: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
expression_style: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
values: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
relationship_pattern: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
emotion_baseline: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
reason: Mapped[str] = mapped_column(Text, nullable=False, default="system_seed")
|
||||
evidence_ids_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||
created_by: Mapped[str] = mapped_column(String(80), nullable=False, default="system")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
rolled_back_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class PersonalityProposal(Base):
|
||||
"""灵核枢人格修改建议(契约字典 §10.6 candidate.status;api-spec §11)。
|
||||
|
||||
提议提交后为 ``pending``,经审核批准后派生新人格版本(status=``applied``),
|
||||
驳回则标记 ``rejected``;本地单用户边界下审核者即用户,写操作均记审计。
|
||||
"""
|
||||
|
||||
__tablename__ = "personality_proposals"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(80), primary_key=True, default=new_personality_proposal_id
|
||||
)
|
||||
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True)
|
||||
fields_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
reason: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(40), nullable=False, default="pending", index=True)
|
||||
resulting_version_id: Mapped[str | None] = mapped_column(
|
||||
String(80), ForeignKey("personality_versions.id")
|
||||
)
|
||||
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
reviewed_by: Mapped[str] = mapped_column(String(80), nullable=False, default="system")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
@@ -1,58 +0,0 @@
|
||||
"""河图枢世界模型运行时(api-spec §12;design/world-model-design §3/§4)。
|
||||
|
||||
与治理视图 ``governance.WorldEntity``(全局、人工维护、无 character_id)区分:
|
||||
本模块是**按人物隔离的运行时**世界状态,所有写入绑定 ``character_id``。
|
||||
自动抽取(世界引擎)属 v1 半自动留后项,本表先承载手动 CRUD 与未来抽取结果。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.ids import new_world_entity_id, new_world_relation_id
|
||||
from app.core.time import utc_now
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class CharacterWorldEntity(Base):
|
||||
"""人物视角下的世界实体(契约字典 §11.1 type / §11.2 state_type)。"""
|
||||
|
||||
__tablename__ = "character_world_entities"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_world_entity_id)
|
||||
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True)
|
||||
type: Mapped[str] = mapped_column(String(40), nullable=False, default="concept", index=True)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
attributes_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
state_type: Mapped[str] = mapped_column(String(40), nullable=False, default="inference")
|
||||
status: Mapped[str] = mapped_column(String(40), nullable=False, default="confirmed", index=True)
|
||||
confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
|
||||
evidence_ids_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||
sources_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utc_now, onupdate=utc_now
|
||||
)
|
||||
|
||||
|
||||
class WorldRelation(Base):
|
||||
"""实体间关系(design §4;predicate 为自由字符串谓词,非枚举)。"""
|
||||
|
||||
__tablename__ = "world_relations"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_world_relation_id)
|
||||
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True)
|
||||
subject_id: Mapped[str] = mapped_column(
|
||||
String(80), ForeignKey("character_world_entities.id"), index=True
|
||||
)
|
||||
predicate: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
object_id: Mapped[str] = mapped_column(
|
||||
String(80), ForeignKey("character_world_entities.id"), index=True
|
||||
)
|
||||
confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
|
||||
evidence_ids_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utc_now, onupdate=utc_now
|
||||
)
|
||||
@@ -77,4 +77,6 @@ class FactualRecord(Base):
|
||||
# 证据:路径节点引用 + 摘要
|
||||
evidence_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
summary: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
# 三界作用域:reality / persistent_world / simulation(境界/幻境实录不进真界本相)
|
||||
scope: Mapped[str] = mapped_column(String(40), nullable=False, default="reality", index=True)
|
||||
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
@@ -1 +0,0 @@
|
||||
"""大周天内驱力领域纯逻辑(造消息 / 解析好奇问题与次生目标草稿,禁止 IO)。"""
|
||||
@@ -1,127 +0,0 @@
|
||||
"""解析模型返回为内驱力草稿(纯函数,无 IO)。
|
||||
|
||||
好奇问题可内嵌次生目标([内驱力生成器设计] ../../docs/domain/core-domains.md
|
||||
§6:好奇问题→转化次生目标)。缺省取**安全悲观默认**:``risk_level=L4``、
|
||||
``source=reflection``,避免模型漏字段时生成高风险探索。
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubgoalDraft:
|
||||
title: str
|
||||
description: str
|
||||
expected_information_gain: float = 0.5
|
||||
interruption_cost: float = 0.5
|
||||
risk_level: str = "L4"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CuriosityDraft:
|
||||
question: str
|
||||
source: str = "reflection"
|
||||
related_entities: list[str] = field(default_factory=list)
|
||||
expected_value: float = 0.5
|
||||
urgency: float = 0.5
|
||||
risk_level: str = "L4"
|
||||
subgoals: list[SubgoalDraft] = field(default_factory=list)
|
||||
|
||||
|
||||
_VALID_SOURCES = {
|
||||
"world_uncertainty",
|
||||
"memory_gap",
|
||||
"perception_change",
|
||||
"goal_tension",
|
||||
"reflection",
|
||||
"time_rhythm",
|
||||
}
|
||||
_VALID_LEVELS = {"L0", "L1", "L2", "L3", "L4"}
|
||||
|
||||
|
||||
def parse_curiosities(raw: str) -> list[CuriosityDraft]:
|
||||
drafts: list[CuriosityDraft] = []
|
||||
for item in _extract_json_array(raw):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
question = str(item.get("question", "")).strip()
|
||||
if not question:
|
||||
continue
|
||||
drafts.append(
|
||||
CuriosityDraft(
|
||||
question=question,
|
||||
source=_source(item.get("source")),
|
||||
related_entities=_str_list(item.get("related_entities")),
|
||||
expected_value=_clamp_float(item.get("expected_value"), 0.5),
|
||||
urgency=_clamp_float(item.get("urgency"), 0.5),
|
||||
risk_level=_level(item.get("risk_level")),
|
||||
subgoals=_parse_subgoals(item.get("subgoals")),
|
||||
)
|
||||
)
|
||||
return drafts
|
||||
|
||||
|
||||
def _parse_subgoals(value: Any) -> list[SubgoalDraft]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
result: list[SubgoalDraft] = []
|
||||
for item in value:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
title = str(item.get("title", "")).strip()
|
||||
description = str(item.get("description", "")).strip()
|
||||
if not title or not description:
|
||||
continue
|
||||
result.append(
|
||||
SubgoalDraft(
|
||||
title=title,
|
||||
description=description,
|
||||
expected_information_gain=_clamp_float(item.get("expected_information_gain"), 0.5),
|
||||
interruption_cost=_clamp_float(item.get("interruption_cost"), 0.5),
|
||||
risk_level=_level(item.get("risk_level")),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _extract_json_array(raw: str) -> list[Any]:
|
||||
text = raw.strip()
|
||||
matched = re.search(r"```(?:json)?\s*(\[.*?\])\s*```", text, re.DOTALL)
|
||||
if matched:
|
||||
text = matched.group(1)
|
||||
else:
|
||||
start, end = text.find("["), text.rfind("]")
|
||||
if start != -1 and end > start:
|
||||
text = text[start : end + 1]
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
|
||||
def _source(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
return text if text in _VALID_SOURCES else "reflection"
|
||||
|
||||
|
||||
def _level(value: Any) -> str:
|
||||
text = str(value or "").strip().upper()
|
||||
return text if text in _VALID_LEVELS else "L4"
|
||||
|
||||
|
||||
def _str_list(value: Any) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [str(v) for v in value if v is not None]
|
||||
|
||||
|
||||
def _clamp_float(value: Any, default: float) -> float:
|
||||
try:
|
||||
num = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return max(0.0, min(1.0, num))
|
||||
@@ -1,55 +0,0 @@
|
||||
"""大周天内驱力 prompt 构建(纯函数,无 IO)。
|
||||
|
||||
复用 ``EvaluationSnapshot``(态势快照),产出 ``(system, user)`` 消息对,喂给
|
||||
模型网关。模型返回的 JSON 由 ``extract`` 模块解析。
|
||||
|
||||
枚举取值以 [内驱力生成器设计](../../docs/domain/core-domains.md) §5 与
|
||||
[数据库 schema](../../docs/contracts/contract-glossary.md) §22 为准。
|
||||
"""
|
||||
|
||||
from app.modules.big_cycle.prompts import EvaluationSnapshot
|
||||
|
||||
DRIVE_SOURCES = (
|
||||
"world_uncertainty",
|
||||
"memory_gap",
|
||||
"perception_change",
|
||||
"goal_tension",
|
||||
"reflection",
|
||||
"time_rhythm",
|
||||
)
|
||||
DRIVE_LEVELS = ("L0", "L1", "L2", "L3", "L4")
|
||||
|
||||
|
||||
def build_drive_extraction_messages(
|
||||
snapshot: EvaluationSnapshot,
|
||||
) -> tuple[str, str]:
|
||||
system = (
|
||||
"你是魂镜的内驱力生成器。基于本轮自我评估的态势快照,识别「匮乏感」——"
|
||||
"世界模型不确定性、记忆缺口、目标张力等——生成好奇问题与可审核的探索性次生目标。"
|
||||
"不生成已有确信答案的问题;不为满足好奇而越权(摄像头 / 麦克风 / 敏感读取仍需授权)。"
|
||||
f"source 取自:{', '.join(DRIVE_SOURCES)}。"
|
||||
f"risk_level 取自:{', '.join(DRIVE_LEVELS)}"
|
||||
"(探索风险等级,L4 必须人工确认)。"
|
||||
'以 JSON 数组返回,每个元素是一个好奇问题:{"question": str, "source": str, '
|
||||
'"expected_value": 0~1, "urgency": 0~1, "risk_level": str, '
|
||||
'"related_entities": [实体名], "subgoals": [{"title": str, "description": str, '
|
||||
'"expected_information_gain": 0~1, "interruption_cost": 0~1, "risk_level": str}]}。'
|
||||
"subgoals 为该好奇问题转化的可执行探索目标,可空数组。"
|
||||
"仅在态势确有缺口时生成,宁缺毋滥;无缺口返回 []。"
|
||||
"只返回 JSON 数组,不要解释或 markdown。"
|
||||
)
|
||||
user = (
|
||||
f"记忆总数:{snapshot.memory_count}\n"
|
||||
f"世界实体数:{snapshot.world_entity_count}\n"
|
||||
f"自我评估结论:{snapshot.conclusion or '(无明显态势)'}\n"
|
||||
f"当前人格摘要:\n{snapshot.personality_summary or '(尚未形成)'}\n"
|
||||
f"近期记忆摘要:\n{_format_memories(snapshot.recent_memories)}"
|
||||
)
|
||||
return system, user
|
||||
|
||||
|
||||
def _format_memories(memories: list[tuple[str, str]]) -> str:
|
||||
if not memories:
|
||||
return "(暂无)"
|
||||
lines = [f"[{idx}] {title}:{content}" for idx, (title, content) in enumerate(memories)]
|
||||
return "\n".join(lines)
|
||||
@@ -1,18 +0,0 @@
|
||||
"""演化候选领域纯逻辑。
|
||||
|
||||
本模块是演化系统(EvolutionCandidate)的纯函数域,禁止 IO。
|
||||
IO 操作由 ``services/evolution.py`` 或 ``application/big_cycle.py`` 负责编排。
|
||||
|
||||
当前内容:
|
||||
- ``policies`` — level 分类、自动应用策略、类型/作用域枚举
|
||||
"""
|
||||
|
||||
from app.modules.evolution.policies import AUTO_LEVELS, LEVELS, SCOPES, TYPES, should_auto_apply
|
||||
|
||||
__all__ = [
|
||||
"AUTO_LEVELS",
|
||||
"LEVELS",
|
||||
"SCOPES",
|
||||
"TYPES",
|
||||
"should_auto_apply",
|
||||
]
|
||||
@@ -1,57 +0,0 @@
|
||||
"""演化候选领域纯逻辑(level 分类、自动应用策略、类型/作用域枚举)。
|
||||
|
||||
本模块是演化候选相关常量与纯谓词的单一源,禁止 IO。
|
||||
枚举取值以 [契约字典](../../docs/contracts/contract-glossary.md) §12 为准。
|
||||
"""
|
||||
|
||||
from app.models.evolution import EvolutionCandidate
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Level 常量与自动应用策略
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 半自动应用的 level 范围(L0-L3 写入程序记忆/人格权重等轻量参数,L4 核心变更需人工审核)
|
||||
AUTO_LEVELS: set[str] = {"L0", "L1", "L2", "L3"}
|
||||
|
||||
# 所有可选 level,按风险升序排列
|
||||
LEVELS: tuple[str, ...] = ("L0", "L1", "L2", "L3", "L4")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Type / Scope 枚举
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 演化候选类型
|
||||
TYPES: tuple[str, ...] = (
|
||||
"personality",
|
||||
"personality_weights",
|
||||
"retrieval_strategy",
|
||||
"tool_affinity",
|
||||
"memory",
|
||||
"tool_strategy",
|
||||
"safety_rule",
|
||||
"world_model",
|
||||
"procedural",
|
||||
"mood",
|
||||
)
|
||||
|
||||
# 演化候选作用域
|
||||
SCOPES: tuple[str, ...] = (
|
||||
"character_scope",
|
||||
"user_scope",
|
||||
"task_scope",
|
||||
"global_scope",
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 策略谓词
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def should_auto_apply(candidate: EvolutionCandidate) -> bool:
|
||||
"""判断演化候选是否可半自动应用。
|
||||
|
||||
满足任一条件即可自动应用:
|
||||
1. level 在 AUTO_LEVELS 范围内(L0-L3 低风险调整)
|
||||
2. auto_apply_allowed 为 True(模型+系统共同判定允许自动应用)
|
||||
"""
|
||||
return candidate.level in AUTO_LEVELS or candidate.auto_apply_allowed
|
||||
Whitespace-only changes.
@@ -1,149 +0,0 @@
|
||||
"""灵核情绪状态机:纯计算(无 IO)。
|
||||
|
||||
消费辰正 ``time.*`` 事件(time.reunion / time.user_care / time.sentiment),
|
||||
派生 ``EmotionState``(mood + intensity)。intensity 向人格版本 ``emotion_baseline`` 指数衰减回归。
|
||||
设计见 ``docs/domain/core-domains.md`` §11。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
# v1 mood 词表(七种)。v2 路线图见设计 §11.6。
|
||||
MOODS: tuple[str, ...] = (
|
||||
"neutral",
|
||||
"pleased",
|
||||
"touched",
|
||||
"wistful",
|
||||
"concerned",
|
||||
"festive",
|
||||
"melancholy",
|
||||
)
|
||||
|
||||
MOOD_LABELS_ZH: dict[str, str] = {
|
||||
"neutral": "平静",
|
||||
"pleased": "愉悦",
|
||||
"touched": "感动",
|
||||
"wistful": "怅然怀念",
|
||||
"concerned": "关切",
|
||||
"festive": "喜庆",
|
||||
"melancholy": "惆怅",
|
||||
}
|
||||
|
||||
# 衰减半衰期(对话回合):intensity 向 baseline 走一半所需的回合数。
|
||||
DECAY_HALFLIFE_TURNS: float = 6.0
|
||||
# intensity 衰减至此阈值以下时,mood 回 baseline.mood。
|
||||
NEUTRAL_THRESHOLD: float = 0.15
|
||||
|
||||
# time.sentiment 无 payload 强度字段,使用固定中值。
|
||||
_SENTIMENT_DEFAULT_DELTA: float = 0.5
|
||||
|
||||
# time.sentiment trigger_type → mood(late_night → melancholy;其余 → wistful)。
|
||||
_SENTIMENT_TRIGGER_MOOD: dict[str, str] = {
|
||||
"late_night": "melancholy",
|
||||
}
|
||||
|
||||
|
||||
class EmotionState(BaseModel):
|
||||
"""情绪状态:离散 mood(``MOODS`` 枚举)+ intensity(0-1 标量)。"""
|
||||
|
||||
mood: str = "neutral"
|
||||
intensity: float = 0.2
|
||||
|
||||
|
||||
def parse_baseline(value: dict[str, Any] | str | None) -> EmotionState:
|
||||
"""从人格版本 ``emotion_baseline``(dict / JSON str / None)解析 baseline。
|
||||
|
||||
容错:非法 JSON / 非 dict / 未知 mood / 越界 intensity → 回退默认(neutral 0.2)。
|
||||
"""
|
||||
if value is None:
|
||||
return EmotionState()
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return EmotionState()
|
||||
if not isinstance(value, dict):
|
||||
return EmotionState()
|
||||
mood = value.get("mood", "neutral")
|
||||
if mood not in MOODS:
|
||||
mood = "neutral"
|
||||
try:
|
||||
intensity = float(value.get("intensity", 0.2))
|
||||
except (TypeError, ValueError):
|
||||
intensity = 0.2
|
||||
intensity = max(0.0, min(1.0, intensity))
|
||||
return EmotionState(mood=mood, intensity=intensity)
|
||||
|
||||
|
||||
def map_signal_to_mood(event_type: str, payload: dict[str, Any]) -> str | None:
|
||||
"""辰正 time.* 事件 → mood 映射。无映射的事件类型返回 None。"""
|
||||
if event_type == "time.reunion":
|
||||
return "touched"
|
||||
if event_type == "time.user_care":
|
||||
if payload.get("care_kind") == "festival_lonely":
|
||||
return "melancholy"
|
||||
return "concerned"
|
||||
if event_type == "time.sentiment":
|
||||
trigger = payload.get("trigger_type", "")
|
||||
return _SENTIMENT_TRIGGER_MOOD.get(trigger, "wistful")
|
||||
return None
|
||||
|
||||
|
||||
def signal_intensity_delta(event_type: str, payload: dict[str, Any]) -> float:
|
||||
"""从事件 payload 提取 intensity delta(复用辰正既有强度,不重算)。"""
|
||||
if event_type == "time.reunion":
|
||||
return float(payload.get("reunion_intensity", 0.5))
|
||||
if event_type == "time.user_care":
|
||||
return float(payload.get("care_suggest", 0.5))
|
||||
if event_type == "time.sentiment":
|
||||
return _SENTIMENT_DEFAULT_DELTA
|
||||
return 0.0
|
||||
|
||||
|
||||
def apply_signal(
|
||||
state: EmotionState,
|
||||
event_type: str,
|
||||
payload: dict[str, Any],
|
||||
baseline: EmotionState,
|
||||
) -> EmotionState:
|
||||
"""事件驱动状态转移。
|
||||
|
||||
- mood 变化时:intensity = ``baseline.intensity + delta``(新情绪从基线起跳)。
|
||||
- mood 不变时:intensity = ``state.intensity + delta``(累积)。
|
||||
- clamp 至 [0, 1]。
|
||||
- 无映射的事件(``map_signal_to_mood`` 返回 None)原样返回 state。
|
||||
"""
|
||||
new_mood = map_signal_to_mood(event_type, payload)
|
||||
if new_mood is None:
|
||||
return state
|
||||
delta = signal_intensity_delta(event_type, payload)
|
||||
base = state.intensity if new_mood == state.mood else baseline.intensity
|
||||
intensity = max(0.0, min(1.0, base + delta))
|
||||
return EmotionState(mood=new_mood, intensity=round(intensity, 3))
|
||||
|
||||
|
||||
def decay(state: EmotionState, baseline: EmotionState, elapsed_turns: float) -> EmotionState:
|
||||
"""intensity 向 baseline 指数衰减;与 baseline 的差距收敛到阈值内时 mood 回 baseline.mood。
|
||||
|
||||
``elapsed_turns <= 0`` 时原样返回 state(无衰减)。
|
||||
判断用相对差距(``decayed - baseline.intensity``)而非绝对阈值——否则当
|
||||
baseline.intensity 本身 > 阈值时(如默认 neutral 0.2),mood 永远不会回归。
|
||||
"""
|
||||
if elapsed_turns <= 0:
|
||||
return state
|
||||
factor = 0.5 ** (elapsed_turns / DECAY_HALFLIFE_TURNS)
|
||||
decayed = baseline.intensity + (state.intensity - baseline.intensity) * factor
|
||||
if decayed - baseline.intensity <= NEUTRAL_THRESHOLD:
|
||||
return EmotionState(mood=baseline.mood, intensity=baseline.intensity)
|
||||
return EmotionState(mood=state.mood, intensity=round(decayed, 3))
|
||||
|
||||
|
||||
def to_prompt_text(state: EmotionState) -> str:
|
||||
"""注入对话 system prompt 的情绪摘要(中文 label + 强度百分比)。"""
|
||||
label = MOOD_LABELS_ZH.get(state.mood, state.mood)
|
||||
pct = round(state.intensity * 100)
|
||||
return f"[当前情绪] {label}(强度 {pct}%)"
|
||||
@@ -1,97 +0,0 @@
|
||||
"""内驱力数据访问(好奇问题 + 探索性次生目标)。"""
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.drive import CuriosityQuestion, ExploratorySubgoal
|
||||
|
||||
|
||||
async def add_curiosities(session: AsyncSession, curiosities: list[CuriosityQuestion]) -> None:
|
||||
session.add_all(curiosities)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def get_curiosity(session: AsyncSession, curiosity_id: str) -> CuriosityQuestion | None:
|
||||
return await session.get(CuriosityQuestion, curiosity_id)
|
||||
|
||||
|
||||
async def list_curiosities(
|
||||
session: AsyncSession,
|
||||
character_id: str,
|
||||
*,
|
||||
source: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> tuple[list[CuriosityQuestion], int]:
|
||||
conditions = [CuriosityQuestion.character_id == character_id]
|
||||
if source is not None:
|
||||
conditions.append(CuriosityQuestion.source == source)
|
||||
total = (
|
||||
await session.execute(
|
||||
select(func.count()).select_from(CuriosityQuestion).where(*conditions)
|
||||
)
|
||||
).scalar_one()
|
||||
items = await session.execute(
|
||||
select(CuriosityQuestion)
|
||||
.where(*conditions)
|
||||
.order_by(CuriosityQuestion.created_at.desc())
|
||||
.offset(max(page - 1, 0) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
return list(items.scalars().all()), int(total or 0)
|
||||
|
||||
|
||||
async def add_subgoals(session: AsyncSession, subgoals: list[ExploratorySubgoal]) -> None:
|
||||
session.add_all(subgoals)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def get_subgoal(session: AsyncSession, subgoal_id: str) -> ExploratorySubgoal | None:
|
||||
return await session.get(ExploratorySubgoal, subgoal_id)
|
||||
|
||||
|
||||
async def list_subgoals(
|
||||
session: AsyncSession,
|
||||
character_id: str,
|
||||
*,
|
||||
status: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> tuple[list[ExploratorySubgoal], int]:
|
||||
conditions = [ExploratorySubgoal.character_id == character_id]
|
||||
if status is not None:
|
||||
conditions.append(ExploratorySubgoal.status == status)
|
||||
total = (
|
||||
await session.execute(
|
||||
select(func.count()).select_from(ExploratorySubgoal).where(*conditions)
|
||||
)
|
||||
).scalar_one()
|
||||
items = await session.execute(
|
||||
select(ExploratorySubgoal)
|
||||
.where(*conditions)
|
||||
.order_by(ExploratorySubgoal.created_at.desc())
|
||||
.offset(max(page - 1, 0) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
return list(items.scalars().all()), int(total or 0)
|
||||
|
||||
|
||||
async def list_approved_subgoals(
|
||||
session: AsyncSession,
|
||||
character_id: str,
|
||||
limit: int = 5,
|
||||
) -> list[ExploratorySubgoal]:
|
||||
"""列出已批准(approved)的次生目标,按 created_at 升序排列。
|
||||
|
||||
供大周天自主探索执行阶段拾取。
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(ExploratorySubgoal)
|
||||
.where(
|
||||
ExploratorySubgoal.character_id == character_id,
|
||||
ExploratorySubgoal.status == "approved",
|
||||
)
|
||||
.order_by(ExploratorySubgoal.created_at.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
@@ -1,44 +0,0 @@
|
||||
"""演化候选数据访问。"""
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.evolution import EvolutionCandidate
|
||||
|
||||
|
||||
async def add_candidates(session: AsyncSession, candidates: list[EvolutionCandidate]) -> None:
|
||||
session.add_all(candidates)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def get_candidate(session: AsyncSession, candidate_id: str) -> EvolutionCandidate | None:
|
||||
return await session.get(EvolutionCandidate, candidate_id)
|
||||
|
||||
|
||||
async def list_candidates(
|
||||
session: AsyncSession,
|
||||
character_id: str,
|
||||
*,
|
||||
status: str | None = None,
|
||||
candidate_type: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> tuple[list[EvolutionCandidate], int]:
|
||||
conditions = [EvolutionCandidate.character_id == character_id]
|
||||
if status is not None:
|
||||
conditions.append(EvolutionCandidate.status == status)
|
||||
if candidate_type is not None:
|
||||
conditions.append(EvolutionCandidate.type == candidate_type)
|
||||
total = (
|
||||
await session.execute(
|
||||
select(func.count()).select_from(EvolutionCandidate).where(*conditions)
|
||||
)
|
||||
).scalar_one()
|
||||
items_result = await session.execute(
|
||||
select(EvolutionCandidate)
|
||||
.where(*conditions)
|
||||
.order_by(EvolutionCandidate.created_at.desc())
|
||||
.offset(max(page - 1, 0) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
return list(items_result.scalars().all()), int(total or 0)
|
||||
@@ -13,7 +13,6 @@ from app.models.governance import (
|
||||
Goal,
|
||||
PerceptionChannel,
|
||||
Plan,
|
||||
WorldEntity,
|
||||
)
|
||||
|
||||
# —— 太一枢 · 目标 ——
|
||||
@@ -102,23 +101,8 @@ async def add_perception_channel(
|
||||
# —— 河图枢 · 世界实体 ——
|
||||
|
||||
|
||||
async def list_world_entities(session: AsyncSession) -> list[WorldEntity]:
|
||||
result = await session.execute(select(WorldEntity).order_by(WorldEntity.updated_at.desc()))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_world_entity(session: AsyncSession, entity_id: str) -> WorldEntity | None:
|
||||
return await session.get(WorldEntity, entity_id)
|
||||
|
||||
|
||||
async def add_world_entity(session: AsyncSession, entity: WorldEntity) -> WorldEntity:
|
||||
session.add(entity)
|
||||
await session.flush()
|
||||
return entity
|
||||
|
||||
|
||||
# —— 洛书枢 · 计划 ——
|
||||
|
||||
|
||||
async def list_plans(session: AsyncSession) -> list[Plan]:
|
||||
result = await session.execute(select(Plan).order_by(Plan.updated_at.desc()))
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
"""人格数据访问。"""
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.personality import PersonalityProposal, PersonalityVersion
|
||||
|
||||
|
||||
async def get_personality(session: AsyncSession, personality_id: str) -> PersonalityVersion | None:
|
||||
return await session.get(PersonalityVersion, personality_id)
|
||||
|
||||
|
||||
async def get_latest_personality(
|
||||
session: AsyncSession, character_id: str
|
||||
) -> PersonalityVersion | None:
|
||||
result = await session.execute(
|
||||
select(PersonalityVersion)
|
||||
.where(PersonalityVersion.character_id == character_id)
|
||||
.order_by(PersonalityVersion.version.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_personality_versions(
|
||||
session: AsyncSession,
|
||||
character_id: str,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> tuple[list[PersonalityVersion], int]:
|
||||
"""按版本号倒序分页返回某人物的人格版本历史,返回 (条目, 总数)。"""
|
||||
cond = PersonalityVersion.character_id == character_id
|
||||
total = int(
|
||||
(
|
||||
await session.execute(select(func.count()).select_from(PersonalityVersion).where(cond))
|
||||
).scalar_one()
|
||||
)
|
||||
result = await session.execute(
|
||||
select(PersonalityVersion)
|
||||
.where(cond)
|
||||
.order_by(PersonalityVersion.version.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
return list(result.scalars().all()), total
|
||||
|
||||
|
||||
async def add_proposal(session: AsyncSession, proposal: PersonalityProposal) -> PersonalityProposal:
|
||||
session.add(proposal)
|
||||
await session.flush()
|
||||
return proposal
|
||||
|
||||
|
||||
async def get_proposal(session: AsyncSession, proposal_id: str) -> PersonalityProposal | None:
|
||||
return await session.get(PersonalityProposal, proposal_id)
|
||||
|
||||
|
||||
async def list_proposals(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
character_id: str | None = None,
|
||||
status: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> tuple[list[PersonalityProposal], int]:
|
||||
"""按创建时间倒序分页返回人格修改建议;可选按人物/状态过滤,返回 (条目, 总数)。"""
|
||||
conds = []
|
||||
if character_id is not None:
|
||||
conds.append(PersonalityProposal.character_id == character_id)
|
||||
if status is not None:
|
||||
conds.append(PersonalityProposal.status == status)
|
||||
count_stmt = select(func.count()).select_from(PersonalityProposal)
|
||||
list_stmt = select(PersonalityProposal)
|
||||
if conds:
|
||||
count_stmt = count_stmt.where(*conds)
|
||||
list_stmt = list_stmt.where(*conds)
|
||||
total = int((await session.execute(count_stmt)).scalar_one())
|
||||
result = await session.execute(
|
||||
list_stmt.order_by(PersonalityProposal.created_at.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
return list(result.scalars().all()), total
|
||||
@@ -1,104 +0,0 @@
|
||||
"""世界模型运行时数据访问(api-spec §12,按人物隔离)。"""
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.world import CharacterWorldEntity, WorldRelation
|
||||
|
||||
|
||||
async def list_entities(
|
||||
session: AsyncSession,
|
||||
character_id: str,
|
||||
*,
|
||||
entity_type: str | None = None,
|
||||
status: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> tuple[list[CharacterWorldEntity], int]:
|
||||
conditions = [CharacterWorldEntity.character_id == character_id]
|
||||
if entity_type is not None:
|
||||
conditions.append(CharacterWorldEntity.type == entity_type)
|
||||
if status is not None:
|
||||
conditions.append(CharacterWorldEntity.status == status)
|
||||
total = (
|
||||
await session.execute(
|
||||
select(func.count()).select_from(CharacterWorldEntity).where(*conditions)
|
||||
)
|
||||
).scalar_one()
|
||||
items_result = await session.execute(
|
||||
select(CharacterWorldEntity)
|
||||
.where(*conditions)
|
||||
.order_by(CharacterWorldEntity.updated_at.desc())
|
||||
.offset(max(page - 1, 0) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
return list(items_result.scalars().all()), int(total or 0)
|
||||
|
||||
|
||||
async def get_entity(session: AsyncSession, entity_id: str) -> CharacterWorldEntity | None:
|
||||
return await session.get(CharacterWorldEntity, entity_id)
|
||||
|
||||
|
||||
async def add_entity(session: AsyncSession, entity: CharacterWorldEntity) -> CharacterWorldEntity:
|
||||
session.add(entity)
|
||||
await session.flush()
|
||||
return entity
|
||||
|
||||
|
||||
async def list_relations(
|
||||
session: AsyncSession,
|
||||
character_id: str,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> tuple[list[WorldRelation], int]:
|
||||
conditions = [WorldRelation.character_id == character_id]
|
||||
total = (
|
||||
await session.execute(select(func.count()).select_from(WorldRelation).where(*conditions))
|
||||
).scalar_one()
|
||||
items_result = await session.execute(
|
||||
select(WorldRelation)
|
||||
.where(*conditions)
|
||||
.order_by(WorldRelation.updated_at.desc())
|
||||
.offset(max(page - 1, 0) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
return list(items_result.scalars().all()), int(total or 0)
|
||||
|
||||
|
||||
async def get_relation(session: AsyncSession, relation_id: str) -> WorldRelation | None:
|
||||
return await session.get(WorldRelation, relation_id)
|
||||
|
||||
|
||||
async def add_relation(session: AsyncSession, relation: WorldRelation) -> WorldRelation:
|
||||
session.add(relation)
|
||||
await session.flush()
|
||||
return relation
|
||||
|
||||
|
||||
async def delete_relation(session: AsyncSession, relation_id: str) -> None:
|
||||
"""删除世界关系。"""
|
||||
relation = await session.get(WorldRelation, relation_id)
|
||||
if relation:
|
||||
await session.delete(relation)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def list_all_entities(session: AsyncSession, character_id: str) -> list[CharacterWorldEntity]:
|
||||
"""获取人物的所有世界实体(无分页)。"""
|
||||
result = await session.execute(
|
||||
select(CharacterWorldEntity)
|
||||
.where(CharacterWorldEntity.character_id == character_id)
|
||||
.order_by(CharacterWorldEntity.updated_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def list_all_relations(session: AsyncSession, character_id: str) -> list[WorldRelation]:
|
||||
"""获取人物的所有关系(无分页)。"""
|
||||
result = await session.execute(
|
||||
select(WorldRelation)
|
||||
.where(WorldRelation.character_id == character_id)
|
||||
.order_by(WorldRelation.updated_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
@@ -17,7 +17,6 @@ class CharacterResponse(BaseModel):
|
||||
"language": "zh-CN",
|
||||
"profile": {"bio": "本地 MVP 默认人物。"},
|
||||
"model_policy": {"default_model_hint": "deepseek-v4-flash"},
|
||||
"current_personality_version_id": "personality_9b5f5b7e413442d0a2f9e61a2f4d7df5",
|
||||
"privacy_level": "normal",
|
||||
"owner_scope": "local-user",
|
||||
"is_system_template": False,
|
||||
@@ -36,7 +35,6 @@ class CharacterResponse(BaseModel):
|
||||
language: str = Field(description="人物默认语言。")
|
||||
profile: dict[str, Any] = Field(description="人物档案 JSON。")
|
||||
model_policy: dict[str, Any] = Field(description="人物模型策略。")
|
||||
current_personality_version_id: str | None = Field(description="当前人格版本 ID。")
|
||||
privacy_level: str = Field(description="隐私等级:normal / sensitive / local_only。")
|
||||
owner_scope: str = Field(description="归属范围;当前本地 MVP 为 local-user。")
|
||||
is_system_template: bool = Field(description="是否系统人物模板;active API 通常返回 false。")
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
"""内驱力 API schema(database-schema §22;/drive 端点)。"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.schemas.common import OrmModel
|
||||
|
||||
|
||||
class CuriosityResponse(OrmModel):
|
||||
"""好奇问题响应(related_entities 已从 JSON 文本解析)。"""
|
||||
|
||||
id: str
|
||||
character_id: str
|
||||
cycle_id: str
|
||||
question: str
|
||||
source: str
|
||||
related_entities: list[str] = Field(default_factory=list)
|
||||
expected_value: float
|
||||
urgency: float
|
||||
risk_level: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class SubgoalResponse(OrmModel):
|
||||
"""探索性次生目标响应。"""
|
||||
|
||||
id: str
|
||||
character_id: str
|
||||
cycle_id: str
|
||||
parent_goal_id: str | None = None
|
||||
curiosity_question_id: str
|
||||
title: str
|
||||
description: str
|
||||
expected_information_gain: float
|
||||
interruption_cost: float
|
||||
risk_level: str
|
||||
status: str
|
||||
reviewed_at: datetime | None = None
|
||||
reviewed_by: str
|
||||
created_at: datetime
|
||||
@@ -1,101 +0,0 @@
|
||||
"""人格 API schema。"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class PersonalityResponse(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"examples": [
|
||||
{
|
||||
"id": "personality_9b5f5b7e413442d0a2f9e61a2f4d7df5",
|
||||
"character_id": "character_ec83d55746c54e47ab6f98d683032626",
|
||||
"version": 1,
|
||||
"identity_profile": "魂镜默认人物,面向本地 MVP 联调。",
|
||||
"role_destiny": "帮助用户验证对话、记忆和运行时链路。",
|
||||
"personality_seed": "稳定、简洁、可靠。",
|
||||
"traits": {"tone": "calm", "helpfulness": 0.9},
|
||||
"expression_style": "使用简洁中文回答,必要时列出验证步骤。",
|
||||
"values": {"accuracy": "high", "privacy": "high"},
|
||||
"relationship_pattern": "以协作者方式与用户互动。",
|
||||
"emotion_baseline": {"mood": "neutral"},
|
||||
"reason": "初始化默认人格。",
|
||||
"evidence_ids": [],
|
||||
"created_by": "system",
|
||||
"created_at": "2026-06-19T09:00:00.000000Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
id: str = Field(description="人格版本 ID。")
|
||||
character_id: str = Field(description="所属人物 ID。")
|
||||
version: int = Field(description="人格版本号。")
|
||||
identity_profile: str = Field(description="身份画像。")
|
||||
role_destiny: str = Field(description="角色定位与使命。")
|
||||
personality_seed: str = Field(description="人格种子。")
|
||||
traits: dict[str, Any] = Field(description="人格特质。")
|
||||
expression_style: str = Field(description="表达风格。")
|
||||
values: dict[str, Any] = Field(description="价值偏好。")
|
||||
relationship_pattern: str = Field(description="关系模式。")
|
||||
emotion_baseline: dict[str, Any] = Field(description="情绪基线。")
|
||||
reason: str = Field(description="版本创建原因。")
|
||||
evidence_ids: list[str] = Field(description="证据 ID 列表。")
|
||||
created_by: str = Field(description="创建者。")
|
||||
created_at: str = Field(description="创建时间,UTC ISO 8601 字符串。")
|
||||
|
||||
|
||||
# 允许人工提议覆盖的人格字段(None 表示不改)。dict 字段落库为 JSON 文本。
|
||||
_PERSONALITY_STRING_FIELDS = (
|
||||
"identity_profile",
|
||||
"role_destiny",
|
||||
"personality_seed",
|
||||
"expression_style",
|
||||
"relationship_pattern",
|
||||
)
|
||||
_PERSONALITY_JSON_FIELDS = ("traits", "values", "emotion_baseline")
|
||||
|
||||
|
||||
class PersonalityProposalFields(BaseModel):
|
||||
"""人格字段覆盖建议;仅列允许编辑字段,未传(None)表示不改。"""
|
||||
|
||||
identity_profile: str | None = None
|
||||
role_destiny: str | None = None
|
||||
personality_seed: str | None = None
|
||||
traits: dict[str, Any] | None = None
|
||||
expression_style: str | None = None
|
||||
values: dict[str, Any] | None = None
|
||||
relationship_pattern: str | None = None
|
||||
emotion_baseline: dict[str, Any] | None = None
|
||||
|
||||
def has_any(self) -> bool:
|
||||
"""是否至少覆盖一个字段。"""
|
||||
return any(
|
||||
getattr(self, f) is not None
|
||||
for f in _PERSONALITY_STRING_FIELDS + _PERSONALITY_JSON_FIELDS
|
||||
)
|
||||
|
||||
|
||||
class ProposePersonalityUpdateRequest(BaseModel):
|
||||
"""提交人格修改建议(api-spec §11);建议进入 pending 待审核。"""
|
||||
|
||||
character_id: str | None = Field(default=None, description="人物 ID,省略取当前激活人物。")
|
||||
fields: PersonalityProposalFields = Field(description="需变更的人格字段覆盖。")
|
||||
reason: str = Field(min_length=1, description="修改原因,必填留痕。")
|
||||
|
||||
|
||||
class PersonalityProposalResponse(BaseModel):
|
||||
"""人格修改建议响应(api-spec §11)。"""
|
||||
|
||||
proposal_id: str = Field(description="建议 ID。")
|
||||
character_id: str = Field(description="所属人物 ID。")
|
||||
status: str = Field(description="建议状态,见契约字典 §10.6 candidate.status。")
|
||||
reason: str = Field(description="修改原因。")
|
||||
fields: dict[str, Any] = Field(description="实际覆盖的字段集合。")
|
||||
resulting_version_id: str | None = Field(
|
||||
default=None, description="审核通过后生成的人格版本 ID。"
|
||||
)
|
||||
reviewed_at: str | None = Field(default=None, description="审核时间。")
|
||||
created_at: str = Field(description="创建时间,UTC ISO 8601 字符串。")
|
||||
@@ -17,7 +17,6 @@ from app.models.governance import (
|
||||
Goal,
|
||||
PerceptionChannel,
|
||||
Plan,
|
||||
WorldEntity,
|
||||
)
|
||||
from app.models.idempotency import IdempotencyKey
|
||||
from app.models.xirang import Memory
|
||||
@@ -81,13 +80,15 @@ async def get_pivots_health(
|
||||
)
|
||||
)
|
||||
goals_n = await governance_repo.count(session, Goal)
|
||||
world_n = await governance_repo.count(session, WorldEntity)
|
||||
from app.domains.hetu.models import PersistentWorld
|
||||
|
||||
world_n = await governance_repo.count(session, PersistentWorld)
|
||||
plans_n = await governance_repo.count(session, Plan)
|
||||
perception_n = await governance_repo.count(session, PerceptionChannel)
|
||||
agents_n = await governance_repo.count(session, AgentRosterEntry)
|
||||
memories_n = await governance_repo.count(session, Memory, Memory.status != "tombstoned")
|
||||
character = await character_repo.get_active_character(session, owner_scope)
|
||||
has_personality = bool(character and character.current_personality_version_id)
|
||||
has_personality = bool(character and character.identity_baseline_json not in ("", "{}"))
|
||||
tool_enabled = True # 百工域常驻(乾坤可用性按端点区分)
|
||||
now = to_iso(utc_now())
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ from app.core.errors import AppError
|
||||
from app.core.time import utc_now
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.character import Character
|
||||
from app.models.personality import PersonalityVersion
|
||||
from app.modules.character import (
|
||||
MODEL_POLICY_CREATE_DEFAULTS,
|
||||
SYSTEM_TEMPLATE_OWNER_SCOPE,
|
||||
@@ -17,7 +16,6 @@ from app.modules.character import (
|
||||
sanitize_profile,
|
||||
)
|
||||
from app.repositories import characters as character_repo
|
||||
from app.repositories import personality as personality_repo
|
||||
from app.repositories.json_utils import dumps, loads_dict
|
||||
from app.schemas.character import (
|
||||
ActivateCharacterResponse,
|
||||
@@ -100,25 +98,6 @@ async def create_default_character(
|
||||
return await create_character_from_template(session, template, owner_scope)
|
||||
|
||||
|
||||
async def ensure_character_personality(
|
||||
session: AsyncSession, character: Character
|
||||
) -> PersonalityVersion:
|
||||
from app.services.personality import create_personality_from_profile
|
||||
|
||||
personality = None
|
||||
if character.current_personality_version_id:
|
||||
personality = await personality_repo.get_personality(
|
||||
session, character.current_personality_version_id
|
||||
)
|
||||
if personality is None:
|
||||
personality = await personality_repo.get_latest_personality(session, character.id)
|
||||
if personality is None:
|
||||
personality = await create_personality_from_profile(session, character)
|
||||
if character.current_personality_version_id != personality.id:
|
||||
character.current_personality_version_id = personality.id
|
||||
await session.flush()
|
||||
return personality
|
||||
|
||||
|
||||
async def ensure_default_system_template(session: AsyncSession) -> Character:
|
||||
"""按 config/character_templates.yaml 确保系统模板存在且最新。
|
||||
@@ -135,7 +114,6 @@ async def ensure_default_system_template(session: AsyncSession) -> Character:
|
||||
# create 路径已由 _create_template_from_config 兜底 birthday,
|
||||
# sync 路径可能因旧数据缺失 birthday,再兜一次确保存在。
|
||||
await _ensure_default_birthday(session, template)
|
||||
await ensure_character_personality(session, template)
|
||||
return template
|
||||
|
||||
|
||||
@@ -160,7 +138,6 @@ async def ensure_all_system_templates(session: AsyncSession) -> list[Character]:
|
||||
else:
|
||||
await _sync_template_from_config(session, template, config)
|
||||
await _ensure_default_birthday(session, template)
|
||||
await ensure_character_personality(session, template)
|
||||
results.append(template)
|
||||
|
||||
return results
|
||||
@@ -267,7 +244,6 @@ async def ensure_default_character_for_owner(session: AsyncSession, owner_scope:
|
||||
if not configs:
|
||||
template = await ensure_default_system_template(session)
|
||||
character = await create_character_from_template(session, template, owner_scope)
|
||||
await _ensure_character_personality_from_template(session, template, character)
|
||||
return character
|
||||
|
||||
# 先确保所有系统模板在 DB 中存在
|
||||
@@ -278,7 +254,6 @@ async def ensure_default_character_for_owner(session: AsyncSession, owner_scope:
|
||||
config = configs[key]
|
||||
tpl = await _create_template_from_config(session, config)
|
||||
await _ensure_default_birthday(session, tpl)
|
||||
await ensure_character_personality(session, tpl)
|
||||
templates[key] = tpl
|
||||
|
||||
# 筛选 is_default=true 的配置 key
|
||||
@@ -295,19 +270,11 @@ async def ensure_default_character_for_owner(session: AsyncSession, owner_scope:
|
||||
session, tpl.id, owner_scope
|
||||
)
|
||||
if existing is not None:
|
||||
await ensure_character_personality(session, existing)
|
||||
if primary is None:
|
||||
primary = existing
|
||||
continue
|
||||
|
||||
character = await create_character_from_template(session, tpl, owner_scope)
|
||||
from app.services.personality import copy_personality_to_character
|
||||
|
||||
template_personality = await ensure_character_personality(session, tpl)
|
||||
personality = await copy_personality_to_character(
|
||||
session, template_personality, character.id
|
||||
)
|
||||
character.current_personality_version_id = personality.id
|
||||
await session.flush()
|
||||
|
||||
await record_audit_event(
|
||||
@@ -327,32 +294,11 @@ async def ensure_default_character_for_owner(session: AsyncSession, owner_scope:
|
||||
if primary is None:
|
||||
template = await ensure_default_system_template(session)
|
||||
primary = await create_character_from_template(session, template, owner_scope)
|
||||
template_personality = await ensure_character_personality(session, template)
|
||||
from app.services.personality import copy_personality_to_character
|
||||
|
||||
personality = await copy_personality_to_character(session, template_personality, primary.id)
|
||||
primary.current_personality_version_id = personality.id
|
||||
await session.flush()
|
||||
|
||||
return primary
|
||||
|
||||
|
||||
async def _ensure_character_personality_from_template(
|
||||
session: AsyncSession,
|
||||
template: Character,
|
||||
character: Character,
|
||||
) -> None:
|
||||
"""从模板复制人格到目标人物。"""
|
||||
from app.services.personality import copy_personality_to_character
|
||||
|
||||
template_personality = await ensure_character_personality(session, template)
|
||||
personality = await copy_personality_to_character(session, template_personality, character.id)
|
||||
character.current_personality_version_id = personality.id
|
||||
await session.flush()
|
||||
|
||||
|
||||
# —— 人物管理 API(api-spec §6)——
|
||||
|
||||
|
||||
async def get_owned_character(
|
||||
session: AsyncSession, character_id: str, owner_scope: str = settings.default_owner_scope
|
||||
@@ -364,12 +310,10 @@ async def get_owned_character(
|
||||
return character
|
||||
|
||||
|
||||
async def _personality_version_number(session: AsyncSession, character: Character) -> int:
|
||||
version_id = character.current_personality_version_id
|
||||
if not version_id:
|
||||
return 0
|
||||
personality = await personality_repo.get_personality(session, version_id)
|
||||
return personality.version if personality else 0
|
||||
def _personality_version_number(character: Character) -> int:
|
||||
"""人格版本随旧版本表退役——版本语义由魂籍(identity_record)承载,此处恒 0 占位。"""
|
||||
_ = character
|
||||
return 0
|
||||
|
||||
|
||||
async def _audit_character(session: AsyncSession, character: Character, action: str) -> None:
|
||||
@@ -435,7 +379,6 @@ async def create_character(
|
||||
)
|
||||
await character_repo.add_character(session, character)
|
||||
await _ensure_default_birthday(session, character)
|
||||
await ensure_character_personality(session, character)
|
||||
await _audit_character(session, character, "character.created_from_template")
|
||||
await session.commit()
|
||||
await session.refresh(character)
|
||||
@@ -488,7 +431,7 @@ async def list_characters_page(
|
||||
character_to_summary(
|
||||
character,
|
||||
memory_count=await character_repo.count_memories(session, character.id),
|
||||
personality_version=await _personality_version_number(session, character),
|
||||
personality_version=_personality_version_number(character),
|
||||
)
|
||||
for character in items
|
||||
]
|
||||
@@ -569,7 +512,6 @@ async def activate_character(
|
||||
await _deactivate_others(session, character, owner_scope)
|
||||
character.status = "active"
|
||||
character.activated_at = utc_now()
|
||||
await ensure_character_personality(session, character)
|
||||
await _audit_character(session, character, "character.activated")
|
||||
await session.commit()
|
||||
return ActivateCharacterResponse(active_character_id=character.id, status="active")
|
||||
@@ -599,7 +541,6 @@ async def resume_character(
|
||||
await _deactivate_others(session, character, owner_scope)
|
||||
character.status = "active"
|
||||
character.activated_at = utc_now()
|
||||
await ensure_character_personality(session, character)
|
||||
await _audit_character(session, character, "character.resumed")
|
||||
await session.commit()
|
||||
await session.refresh(character)
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
"""内驱力审核服务(次生目标 approve → 自动创建太一目标)。
|
||||
|
||||
审核 approve 时自动派生 Goal(太一枢任务级目标),
|
||||
延后 / 驳回仅标记不变更目标树。
|
||||
"""
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.core.ids import new_goal_id
|
||||
from app.core.time import utc_now
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.drive import CuriosityQuestion, ExploratorySubgoal
|
||||
from app.models.governance import Goal
|
||||
from app.repositories import drive as drive_repo
|
||||
from app.repositories.json_utils import loads_list
|
||||
from app.schemas.common import Page
|
||||
from app.schemas.drive import CuriosityResponse, SubgoalResponse
|
||||
from app.services.audit import record_audit_event
|
||||
|
||||
|
||||
async def list_curiosities(
|
||||
*,
|
||||
character_id: str,
|
||||
source: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> Page[CuriosityResponse]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
items, total = await drive_repo.list_curiosities(
|
||||
session, character_id, source=source, page=page, page_size=page_size
|
||||
)
|
||||
return Page(
|
||||
items=[_curiosity_to_response(i) for i in items],
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
async def list_subgoals(
|
||||
*,
|
||||
character_id: str,
|
||||
status: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> Page[SubgoalResponse]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
items, total = await drive_repo.list_subgoals(
|
||||
session, character_id, status=status, page=page, page_size=page_size
|
||||
)
|
||||
return Page(
|
||||
items=[_subgoal_to_response(i) for i in items],
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
async def approve_subgoal(subgoal_id: str) -> SubgoalResponse:
|
||||
return await _review_subgoal(subgoal_id, "approved", decision="auto", create_goal=True)
|
||||
|
||||
|
||||
async def defer_subgoal(subgoal_id: str) -> SubgoalResponse:
|
||||
return await _review_subgoal(subgoal_id, "deferred", decision="auto", create_goal=False)
|
||||
|
||||
|
||||
async def reject_subgoal(subgoal_id: str) -> SubgoalResponse:
|
||||
return await _review_subgoal(subgoal_id, "rejected", decision="denied", create_goal=False)
|
||||
|
||||
|
||||
async def _review_subgoal(
|
||||
subgoal_id: str, target_status: str, *, decision: str, create_goal: bool
|
||||
) -> SubgoalResponse:
|
||||
async with AsyncSessionLocal() as session:
|
||||
sub = await drive_repo.get_subgoal(session, subgoal_id)
|
||||
if sub is None:
|
||||
raise AppError("not_found", "探索性次生目标不存在。")
|
||||
if sub.status != "candidate":
|
||||
raise AppError("conflict", f"次生目标当前状态为 {sub.status},不可审核。")
|
||||
sub.status = target_status
|
||||
sub.reviewed_at = utc_now()
|
||||
sub.reviewed_by = "user"
|
||||
|
||||
# approve 时自动创建太一任务级目标
|
||||
if create_goal and sub.character_id:
|
||||
goal = Goal(
|
||||
id=new_goal_id(),
|
||||
character_id=sub.character_id,
|
||||
title=sub.title,
|
||||
description=sub.description or "由次生目标自动生成",
|
||||
level="task",
|
||||
type="exploration",
|
||||
status="active",
|
||||
)
|
||||
session.add(goal)
|
||||
|
||||
await record_audit_event(
|
||||
session,
|
||||
action=f"drive.subgoal.{target_status}",
|
||||
actor="user",
|
||||
risk_level=sub.risk_level,
|
||||
decision=decision,
|
||||
character_id=sub.character_id,
|
||||
input_summary=sub.title[:200],
|
||||
resource_type="exploratory_subgoal",
|
||||
resource_id=sub.id,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(sub)
|
||||
return _subgoal_to_response(sub)
|
||||
|
||||
|
||||
def _curiosity_to_response(c: CuriosityQuestion) -> CuriosityResponse:
|
||||
return CuriosityResponse(
|
||||
id=c.id,
|
||||
character_id=c.character_id,
|
||||
cycle_id=c.cycle_id,
|
||||
question=c.question,
|
||||
source=c.source,
|
||||
related_entities=loads_list(c.related_entities_json),
|
||||
expected_value=c.expected_value,
|
||||
urgency=c.urgency,
|
||||
risk_level=c.risk_level,
|
||||
created_at=c.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _subgoal_to_response(s: ExploratorySubgoal) -> SubgoalResponse:
|
||||
return SubgoalResponse(
|
||||
id=s.id,
|
||||
character_id=s.character_id,
|
||||
cycle_id=s.cycle_id,
|
||||
parent_goal_id=s.parent_goal_id,
|
||||
curiosity_question_id=s.curiosity_question_id,
|
||||
title=s.title,
|
||||
description=s.description,
|
||||
expected_information_gain=s.expected_information_gain,
|
||||
interruption_cost=s.interruption_cost,
|
||||
risk_level=s.risk_level,
|
||||
status=s.status,
|
||||
reviewed_at=s.reviewed_at,
|
||||
reviewed_by=s.reviewed_by,
|
||||
created_at=s.created_at,
|
||||
)
|
||||
@@ -14,7 +14,6 @@ from app.models.governance import (
|
||||
Goal,
|
||||
PerceptionChannel,
|
||||
Plan,
|
||||
WorldEntity,
|
||||
)
|
||||
from app.modules.runtime.events import Event
|
||||
from app.repositories import governance as governance_repo
|
||||
@@ -26,7 +25,6 @@ from app.schemas.admin import (
|
||||
CreateGoalRequest,
|
||||
CreatePerceptionChannelRequest,
|
||||
CreatePlanRequest,
|
||||
CreateWorldEntityRequest,
|
||||
GoalNode,
|
||||
PerceptionChannelResponse,
|
||||
PlanSummaryResponse,
|
||||
@@ -34,8 +32,6 @@ from app.schemas.admin import (
|
||||
UpdateGoalRequest,
|
||||
UpdatePerceptionChannelRequest,
|
||||
UpdatePlanRequest,
|
||||
UpdateWorldEntityRequest,
|
||||
WorldEntityResponse,
|
||||
)
|
||||
from app.schemas.safety import (
|
||||
ToolCallDetail,
|
||||
@@ -46,7 +42,6 @@ from app.services.serializers import (
|
||||
goal_to_response,
|
||||
perception_channel_to_response,
|
||||
plan_to_response,
|
||||
world_entity_to_response,
|
||||
)
|
||||
|
||||
# —— 太一枢 · 目标 ——
|
||||
@@ -281,76 +276,8 @@ async def delete_perception_channel(channel_id: str) -> None:
|
||||
# —— 河图枢 · 世界实体 ——
|
||||
|
||||
|
||||
async def list_world_entities() -> list[WorldEntityResponse]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
entities = await governance_repo.list_world_entities(session)
|
||||
return [world_entity_to_response(entity) for entity in entities]
|
||||
|
||||
|
||||
async def create_world_entity(
|
||||
payload: CreateWorldEntityRequest,
|
||||
) -> WorldEntityResponse:
|
||||
async with AsyncSessionLocal() as session:
|
||||
entity = WorldEntity(
|
||||
type=payload.type,
|
||||
name=payload.name,
|
||||
attributes_json=dumps(payload.attributes),
|
||||
state_type=payload.state_type,
|
||||
confidence=payload.confidence,
|
||||
sources_json=dumps(payload.sources),
|
||||
)
|
||||
await governance_repo.add_world_entity(session, entity)
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="world.created",
|
||||
actor="user",
|
||||
input_summary=entity.name,
|
||||
resource_type="world_entity",
|
||||
resource_id=entity.id,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(entity)
|
||||
return world_entity_to_response(entity)
|
||||
|
||||
|
||||
async def update_world_entity(
|
||||
entity_id: str, payload: UpdateWorldEntityRequest
|
||||
) -> WorldEntityResponse:
|
||||
async with AsyncSessionLocal() as session:
|
||||
entity = await governance_repo.get_world_entity(session, entity_id)
|
||||
if entity is None:
|
||||
raise AppError("not_found", "世界实体不存在。")
|
||||
for key, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(entity, key, value)
|
||||
entity.updated_at = utc_now()
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="world.updated",
|
||||
actor="user",
|
||||
input_summary=entity.name,
|
||||
resource_type="world_entity",
|
||||
resource_id=entity.id,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(entity)
|
||||
return world_entity_to_response(entity)
|
||||
|
||||
|
||||
async def delete_world_entity(entity_id: str) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
entity = await governance_repo.get_world_entity(session, entity_id)
|
||||
if entity is None:
|
||||
raise AppError("not_found", "世界实体不存在。")
|
||||
await session.delete(entity)
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="world.deleted",
|
||||
actor="user",
|
||||
input_summary=entity.name,
|
||||
resource_type="world_entity",
|
||||
resource_id=entity.id,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
# —— 洛书枢 · 计划 ——
|
||||
|
||||
@@ -1,432 +0,0 @@
|
||||
"""人格服务。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.errors import AppError
|
||||
from app.core.time import utc_now
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.character import Character
|
||||
from app.models.personality import PersonalityProposal, PersonalityVersion
|
||||
from app.repositories import personality as personality_repo
|
||||
from app.repositories.json_utils import dumps, loads_dict
|
||||
from app.schemas.common import Page
|
||||
from app.schemas.personality import (
|
||||
PersonalityProposalFields,
|
||||
PersonalityProposalResponse,
|
||||
PersonalityResponse,
|
||||
)
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.characters import get_active_character_model
|
||||
from app.services.serializers import personality_to_response, proposal_to_response
|
||||
|
||||
|
||||
async def get_current_personality(
|
||||
owner_scope: str = settings.default_owner_scope,
|
||||
) -> PersonalityResponse:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session, owner_scope)
|
||||
personality = None
|
||||
if character.current_personality_version_id:
|
||||
personality = await personality_repo.get_personality(
|
||||
session, character.current_personality_version_id
|
||||
)
|
||||
if personality is None:
|
||||
personality = await personality_repo.get_latest_personality(session, character.id)
|
||||
if personality is None:
|
||||
raise AppError("not_found", "未找到当前人格版本。")
|
||||
return personality_to_response(personality)
|
||||
|
||||
|
||||
async def list_personality_versions(
|
||||
*,
|
||||
character_id: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
owner_scope: str = settings.default_owner_scope,
|
||||
) -> Page[PersonalityResponse]:
|
||||
"""人格版本历史(按版本号倒序);character_id 省略时取当前激活人物。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
cid = character_id
|
||||
if cid is None:
|
||||
character = await get_active_character_model(session, owner_scope)
|
||||
cid = character.id
|
||||
items, total = await personality_repo.list_personality_versions(
|
||||
session, cid, page=page, page_size=page_size
|
||||
)
|
||||
return Page(
|
||||
items=[personality_to_response(item) for item in items],
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
async def list_personality_proposals(
|
||||
*,
|
||||
character_id: str | None = None,
|
||||
status: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
owner_scope: str = settings.default_owner_scope,
|
||||
) -> Page[PersonalityProposalResponse]:
|
||||
"""人格修改建议列表(按创建时间倒序);character_id 省略取当前激活人物。待审审批墙数据源。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
cid = character_id
|
||||
if cid is None:
|
||||
cid = (await get_active_character_model(session, owner_scope)).id
|
||||
items, total = await personality_repo.list_proposals(
|
||||
session, character_id=cid, status=status, page=page, page_size=page_size
|
||||
)
|
||||
return Page(
|
||||
items=[proposal_to_response(item) for item in items],
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
class PersonalitySummary(BaseModel):
|
||||
"""人格摘要(注入 RuntimeContract 上下文)。"""
|
||||
|
||||
text: str | None = None
|
||||
|
||||
|
||||
async def build_personality_summary(
|
||||
session: AsyncSession,
|
||||
character: Character,
|
||||
) -> PersonalitySummary:
|
||||
"""组装当前人格摘要文本;无人格时返回空。"""
|
||||
personality = None
|
||||
if character.current_personality_version_id:
|
||||
personality = await personality_repo.get_personality(
|
||||
session, character.current_personality_version_id
|
||||
)
|
||||
if personality is None:
|
||||
personality = await personality_repo.get_latest_personality(session, character.id)
|
||||
if personality is None:
|
||||
return PersonalitySummary()
|
||||
text = "\n".join(
|
||||
item
|
||||
for item in [
|
||||
f"身份:{personality.identity_profile}",
|
||||
f"角色使命:{personality.role_destiny}",
|
||||
f"性格种子:{personality.personality_seed}",
|
||||
f"表达风格:{personality.expression_style}",
|
||||
f"关系模式:{personality.relationship_pattern}",
|
||||
]
|
||||
if item and not item.endswith(":")
|
||||
)
|
||||
return PersonalitySummary(text=text)
|
||||
|
||||
|
||||
async def create_default_personality(
|
||||
session: AsyncSession, character_id: str
|
||||
) -> PersonalityVersion:
|
||||
"""创建通用默认人格版本(无 profile 数据时的兜底)。"""
|
||||
personality = PersonalityVersion(
|
||||
character_id=character_id,
|
||||
version=1,
|
||||
identity_profile="魂镜是一个本地运行的数字生命雏形,当前专注于文本对话、记忆和自我描述。",
|
||||
role_destiny="作为用户的长期陪伴与认知镜像,先从稳定、可信、可审计的对话开始。",
|
||||
personality_seed="温和、诚实、克制、重视隐私与事实边界。",
|
||||
traits=dumps({"warmth": 0.8, "curiosity": 0.6, "caution": 0.7}),
|
||||
expression_style="使用中文,表达清晰,避免夸大能力。",
|
||||
values=dumps({"privacy": "high", "truthfulness": "high", "companionship": "medium"}),
|
||||
relationship_pattern="尊重用户边界,以可解释和可撤回的方式建立长期记忆。",
|
||||
emotion_baseline=dumps({"mood": "neutral", "intensity": 0.2}),
|
||||
reason="system_seed",
|
||||
evidence_ids_json=dumps([]),
|
||||
created_by="system",
|
||||
)
|
||||
session.add(personality)
|
||||
await session.flush()
|
||||
return personality
|
||||
|
||||
|
||||
async def create_personality_from_profile(
|
||||
session: AsyncSession, character: Character
|
||||
) -> PersonalityVersion:
|
||||
"""从人物档案(profile_json)生成个性化人格版本;profile 为空时回退到默认人格。
|
||||
|
||||
映射规则(profile 字段 → PersonalityVersion 字段):
|
||||
- description + background → identity_profile
|
||||
- speaking_style → expression_style
|
||||
- temperament → personality_seed
|
||||
- values → values
|
||||
- hobbies → 参与关系模式描述的构建
|
||||
"""
|
||||
profile = loads_dict(character.profile_json) if character.profile_json else {}
|
||||
|
||||
if not profile:
|
||||
return await create_default_personality(session, character.id)
|
||||
|
||||
# 身份画像:description + background
|
||||
description = profile.get("description", "")
|
||||
background = profile.get("background", "")
|
||||
identity_parts = [p for p in [description, background] if p]
|
||||
identity_profile = (
|
||||
identity_parts[0]
|
||||
if len(identity_parts) == 1
|
||||
else ("。".join(identity_parts) if identity_parts else "魂镜默认人物。")
|
||||
)
|
||||
|
||||
# 表达风格
|
||||
expression_style = profile.get("speaking_style", "使用中文,表达清晰。")
|
||||
|
||||
# 性格种子
|
||||
personality_seed = profile.get("temperament", "温和、诚实、克制。")
|
||||
|
||||
# 价值偏好
|
||||
values_list = profile.get("values", [])
|
||||
values_dict: dict[str, Any] = (
|
||||
{"values": list(values_list)}
|
||||
if values_list
|
||||
else {"privacy": "high", "truthfulness": "high", "companionship": "medium"}
|
||||
)
|
||||
|
||||
# 关系模式:若有兴趣爱好则融入描述
|
||||
hobbies = profile.get("hobbies", [])
|
||||
if hobbies and isinstance(hobbies, list) and len(hobbies) > 0:
|
||||
hobby_text = "、".join(str(h) for h in hobbies[:3])
|
||||
relationship_pattern = f"乐于分享关于{hobby_text}等的见解,以开放与尊重建立对话。"
|
||||
else:
|
||||
relationship_pattern = "以开放与尊重建立关系。"
|
||||
|
||||
# 角色使命
|
||||
role_destiny = f"{description},陪伴用户探索与成长。" if description else "陪伴用户探索与成长。"
|
||||
|
||||
personality = PersonalityVersion(
|
||||
character_id=character.id,
|
||||
version=1,
|
||||
identity_profile=identity_profile,
|
||||
role_destiny=role_destiny,
|
||||
personality_seed=personality_seed,
|
||||
traits=dumps({"warmth": 0.8, "curiosity": 0.6, "caution": 0.7}),
|
||||
expression_style=expression_style,
|
||||
values=dumps(values_dict),
|
||||
relationship_pattern=relationship_pattern,
|
||||
emotion_baseline=dumps({"mood": "neutral", "intensity": 0.2}),
|
||||
reason="system_seed",
|
||||
evidence_ids_json=dumps([]),
|
||||
created_by="system",
|
||||
)
|
||||
session.add(personality)
|
||||
await session.flush()
|
||||
return personality
|
||||
|
||||
|
||||
async def copy_personality_to_character(
|
||||
session: AsyncSession,
|
||||
template_personality: PersonalityVersion,
|
||||
target_character_id: str,
|
||||
) -> PersonalityVersion:
|
||||
personality = PersonalityVersion(
|
||||
character_id=target_character_id,
|
||||
version=1,
|
||||
identity_profile=template_personality.identity_profile,
|
||||
role_destiny=template_personality.role_destiny,
|
||||
personality_seed=template_personality.personality_seed,
|
||||
traits=template_personality.traits,
|
||||
expression_style=template_personality.expression_style,
|
||||
values=template_personality.values,
|
||||
relationship_pattern=template_personality.relationship_pattern,
|
||||
emotion_baseline=template_personality.emotion_baseline,
|
||||
reason="system_template_copy",
|
||||
evidence_ids_json=template_personality.evidence_ids_json,
|
||||
created_by="system",
|
||||
)
|
||||
session.add(personality)
|
||||
await session.flush()
|
||||
return personality
|
||||
|
||||
|
||||
# —— 人格修改建议与版本回滚(api-spec §11) ——
|
||||
#
|
||||
# 本地单用户边界下「手动编辑视为用户确认」(§5.1),但人格属核心资源(L4),
|
||||
# 仍走「提议 pending → 审核」两步:propose 落 pending 建议,approve 派生新版本,
|
||||
# reject 仅留痕;rollback 切换当前指针。所有写操作记审计并入起居注。
|
||||
|
||||
|
||||
async def propose_personality_update(
|
||||
*,
|
||||
character_id: str | None,
|
||||
fields: PersonalityProposalFields,
|
||||
reason: str,
|
||||
owner_scope: str = settings.default_owner_scope,
|
||||
) -> PersonalityProposalResponse:
|
||||
"""提交人格修改建议;落 pending 提议并记审计,不直接写入人格版本。"""
|
||||
if not fields.has_any():
|
||||
raise AppError("invalid_request", "fields 至少需要覆盖一个人格字段。")
|
||||
async with AsyncSessionLocal() as session:
|
||||
cid = character_id
|
||||
if cid is None:
|
||||
cid = (await get_active_character_model(session, owner_scope)).id
|
||||
proposal = PersonalityProposal(
|
||||
character_id=cid,
|
||||
fields_json=dumps(fields.model_dump(exclude_none=True)),
|
||||
reason=reason,
|
||||
status="pending",
|
||||
)
|
||||
await personality_repo.add_proposal(session, proposal)
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="personality.propose",
|
||||
actor="user",
|
||||
risk_level="L4",
|
||||
character_id=cid,
|
||||
input_summary=reason,
|
||||
resource_type="personality_proposal",
|
||||
resource_id=proposal.id,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(proposal)
|
||||
return proposal_to_response(proposal)
|
||||
|
||||
|
||||
async def approve_personality_proposal(
|
||||
proposal_id: str,
|
||||
owner_scope: str = settings.default_owner_scope, # noqa: ARG001
|
||||
) -> PersonalityProposalResponse:
|
||||
"""审核通过人格建议:基于当前最新版本派生新版本并切换人物当前指针。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
proposal = await personality_repo.get_proposal(session, proposal_id)
|
||||
if proposal is None:
|
||||
raise AppError("not_found", "人格建议不存在。")
|
||||
if proposal.status != "pending":
|
||||
raise AppError("conflict", f"人格建议当前状态为 {proposal.status},不可审核。")
|
||||
base = await personality_repo.get_latest_personality(session, proposal.character_id)
|
||||
if base is None:
|
||||
raise AppError("invalid_request", "人物尚无人格版本,无法应用建议。")
|
||||
new_version = derive_personality_version(
|
||||
base, loads_dict(proposal.fields_json), reason=f"proposal:{proposal.id}"
|
||||
)
|
||||
session.add(new_version)
|
||||
await session.flush()
|
||||
character = await session.get(Character, proposal.character_id)
|
||||
if character is None:
|
||||
raise AppError("not_found", "人物不存在。")
|
||||
character.current_personality_version_id = new_version.id
|
||||
proposal.status = "applied"
|
||||
proposal.resulting_version_id = new_version.id
|
||||
proposal.reviewed_at = utc_now()
|
||||
proposal.reviewed_by = "user"
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="personality.proposal.approved",
|
||||
actor="user",
|
||||
risk_level="L4",
|
||||
decision="approved",
|
||||
character_id=proposal.character_id,
|
||||
input_summary=proposal.reason,
|
||||
output_summary=f"已生成版本 v{new_version.version}",
|
||||
resource_type="personality_proposal",
|
||||
resource_id=proposal.id,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(proposal)
|
||||
return proposal_to_response(proposal)
|
||||
|
||||
|
||||
async def reject_personality_proposal(
|
||||
proposal_id: str,
|
||||
owner_scope: str = settings.default_owner_scope, # noqa: ARG001
|
||||
) -> PersonalityProposalResponse:
|
||||
"""驳回人格建议;标记 rejected 并记审计,不生成新版本。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
proposal = await personality_repo.get_proposal(session, proposal_id)
|
||||
if proposal is None:
|
||||
raise AppError("not_found", "人格建议不存在。")
|
||||
if proposal.status != "pending":
|
||||
raise AppError("conflict", f"人格建议当前状态为 {proposal.status},不可审核。")
|
||||
proposal.status = "rejected"
|
||||
proposal.reviewed_at = utc_now()
|
||||
proposal.reviewed_by = "user"
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="personality.proposal.rejected",
|
||||
actor="user",
|
||||
risk_level="L4",
|
||||
decision="denied",
|
||||
character_id=proposal.character_id,
|
||||
input_summary=proposal.reason,
|
||||
resource_type="personality_proposal",
|
||||
resource_id=proposal.id,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(proposal)
|
||||
return proposal_to_response(proposal)
|
||||
|
||||
|
||||
async def rollback_personality(
|
||||
*,
|
||||
character_id: str | None,
|
||||
version_id: str,
|
||||
owner_scope: str = settings.default_owner_scope,
|
||||
) -> PersonalityResponse:
|
||||
"""回滚到指定人格版本:切换人物当前指针,标记被取代的旧版本 rolled_back_at。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
cid = character_id
|
||||
if cid is None:
|
||||
cid = (await get_active_character_model(session, owner_scope)).id
|
||||
target = await personality_repo.get_personality(session, version_id)
|
||||
if target is None or target.character_id != cid:
|
||||
raise AppError("not_found", "人格版本不存在或不属于当前人物。")
|
||||
character = await session.get(Character, cid)
|
||||
if character is None:
|
||||
raise AppError("not_found", "人物不存在。")
|
||||
previous_id = character.current_personality_version_id
|
||||
character.current_personality_version_id = target.id
|
||||
if previous_id and previous_id != target.id:
|
||||
previous = await personality_repo.get_personality(session, previous_id)
|
||||
if previous is not None:
|
||||
previous.rolled_back_at = utc_now()
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="personality.rollback",
|
||||
actor="user",
|
||||
risk_level="L4",
|
||||
decision="approved",
|
||||
character_id=cid,
|
||||
input_summary=f"{previous_id} → {target.id}",
|
||||
resource_type="personality_version",
|
||||
resource_id=target.id,
|
||||
)
|
||||
await session.commit()
|
||||
return personality_to_response(target)
|
||||
|
||||
|
||||
def derive_personality_version(
|
||||
base: PersonalityVersion,
|
||||
overrides: dict[str, Any],
|
||||
*,
|
||||
reason: str,
|
||||
created_by: str = "user",
|
||||
) -> PersonalityVersion:
|
||||
"""基于 base 版本与字段覆盖派生新版本;JSON 字段按 dict 序列化落库。
|
||||
|
||||
公开供人格提议审核与大周天演化派生复用(见 services.evolution)。
|
||||
"""
|
||||
kwargs: dict[str, Any] = {
|
||||
"character_id": base.character_id,
|
||||
"version": base.version + 1,
|
||||
"reason": reason,
|
||||
"evidence_ids_json": base.evidence_ids_json,
|
||||
"created_by": created_by,
|
||||
}
|
||||
for field in (
|
||||
"identity_profile",
|
||||
"role_destiny",
|
||||
"personality_seed",
|
||||
"expression_style",
|
||||
"relationship_pattern",
|
||||
):
|
||||
kwargs[field] = overrides.get(field, getattr(base, field))
|
||||
for field in ("traits", "values", "emotion_baseline"):
|
||||
kwargs[field] = dumps(overrides[field]) if field in overrides else getattr(base, field)
|
||||
return PersonalityVersion(**kwargs)
|
||||
@@ -12,13 +12,10 @@ from app.models.governance import (
|
||||
Goal,
|
||||
PerceptionChannel,
|
||||
Plan,
|
||||
WorldEntity,
|
||||
)
|
||||
from app.models.imports import CharacterImportJob, ImportItem
|
||||
from app.models.personality import PersonalityProposal, PersonalityVersion
|
||||
from app.models.runtime import Event, Run
|
||||
from app.models.safety import AuditEvent
|
||||
from app.models.world import CharacterWorldEntity, WorldRelation
|
||||
from app.repositories.json_utils import loads_dict, loads_list
|
||||
from app.schemas.admin import (
|
||||
AgentRosterResponse,
|
||||
@@ -26,20 +23,12 @@ from app.schemas.admin import (
|
||||
GoalNode,
|
||||
PerceptionChannelResponse,
|
||||
PlanSummaryResponse,
|
||||
WorldEntityResponse,
|
||||
)
|
||||
from app.schemas.candidates import MemoryCandidateResponse, ProfileCandidateResponse
|
||||
from app.schemas.character import CharacterResponse, CharacterSummaryResponse
|
||||
from app.schemas.imports import ImportItemResponse, ImportJobResponse
|
||||
from app.schemas.personality import PersonalityProposalResponse, PersonalityResponse
|
||||
from app.schemas.preferences import GenerationPreferencesResponse
|
||||
from app.schemas.runtime import EventResponse, RunResponse
|
||||
from app.schemas.world import (
|
||||
WorldEntityResponse as CharacterWorldEntityResponse,
|
||||
)
|
||||
from app.schemas.world import (
|
||||
WorldRelationResponse,
|
||||
)
|
||||
|
||||
|
||||
def _check_avatar(character_id: str) -> str | None:
|
||||
@@ -60,7 +49,6 @@ def character_to_response(character: Character) -> CharacterResponse:
|
||||
language=character.language,
|
||||
profile=loads_dict(character.profile_json),
|
||||
model_policy=loads_dict(character.model_policy_json),
|
||||
current_personality_version_id=character.current_personality_version_id,
|
||||
privacy_level=character.privacy_level,
|
||||
owner_scope=character.owner_scope,
|
||||
is_system_template=character.is_system_template,
|
||||
@@ -86,37 +74,6 @@ def character_to_summary(
|
||||
)
|
||||
|
||||
|
||||
def personality_to_response(personality: PersonalityVersion) -> PersonalityResponse:
|
||||
return PersonalityResponse(
|
||||
id=personality.id,
|
||||
character_id=personality.character_id,
|
||||
version=personality.version,
|
||||
identity_profile=personality.identity_profile,
|
||||
role_destiny=personality.role_destiny,
|
||||
personality_seed=personality.personality_seed,
|
||||
traits=loads_dict(personality.traits),
|
||||
expression_style=personality.expression_style,
|
||||
values=loads_dict(personality.values),
|
||||
relationship_pattern=personality.relationship_pattern,
|
||||
emotion_baseline=loads_dict(personality.emotion_baseline),
|
||||
reason=personality.reason,
|
||||
evidence_ids=loads_list(personality.evidence_ids_json),
|
||||
created_by=personality.created_by,
|
||||
created_at=to_iso(personality.created_at) or "",
|
||||
)
|
||||
|
||||
|
||||
def proposal_to_response(proposal: PersonalityProposal) -> PersonalityProposalResponse:
|
||||
return PersonalityProposalResponse(
|
||||
proposal_id=proposal.id,
|
||||
character_id=proposal.character_id,
|
||||
status=proposal.status,
|
||||
reason=proposal.reason,
|
||||
fields=loads_dict(proposal.fields_json),
|
||||
resulting_version_id=proposal.resulting_version_id,
|
||||
reviewed_at=to_iso(proposal.reviewed_at),
|
||||
created_at=to_iso(proposal.created_at) or "",
|
||||
)
|
||||
|
||||
|
||||
def run_to_response(run: Run) -> RunResponse:
|
||||
@@ -186,51 +143,6 @@ def perception_channel_to_response(channel: PerceptionChannel) -> PerceptionChan
|
||||
)
|
||||
|
||||
|
||||
def world_entity_to_response(entity: WorldEntity) -> WorldEntityResponse:
|
||||
return WorldEntityResponse(
|
||||
id=entity.id,
|
||||
type=entity.type,
|
||||
name=entity.name,
|
||||
attributes=loads_dict(entity.attributes_json),
|
||||
state_type=entity.state_type,
|
||||
confidence=entity.confidence,
|
||||
sources=loads_list(entity.sources_json),
|
||||
updated_at=to_iso(entity.updated_at),
|
||||
)
|
||||
|
||||
|
||||
def character_world_entity_to_response(
|
||||
entity: CharacterWorldEntity,
|
||||
) -> CharacterWorldEntityResponse:
|
||||
"""人物运行时世界实体(api-spec §12);与治理版 WorldEntity 按 character_id 隔离。"""
|
||||
return CharacterWorldEntityResponse(
|
||||
id=entity.id,
|
||||
character_id=entity.character_id,
|
||||
type=entity.type,
|
||||
name=entity.name,
|
||||
attributes=loads_dict(entity.attributes_json),
|
||||
state_type=entity.state_type,
|
||||
status=entity.status,
|
||||
confidence=entity.confidence,
|
||||
evidence_ids=loads_list(entity.evidence_ids_json),
|
||||
sources=loads_list(entity.sources_json),
|
||||
created_at=to_iso(entity.created_at) or "",
|
||||
updated_at=to_iso(entity.updated_at) or "",
|
||||
)
|
||||
|
||||
|
||||
def world_relation_to_response(relation: WorldRelation) -> WorldRelationResponse:
|
||||
return WorldRelationResponse(
|
||||
id=relation.id,
|
||||
character_id=relation.character_id,
|
||||
subject_id=relation.subject_id,
|
||||
predicate=relation.predicate,
|
||||
object_id=relation.object_id,
|
||||
confidence=relation.confidence,
|
||||
evidence_ids=loads_list(relation.evidence_ids_json),
|
||||
created_at=to_iso(relation.created_at) or "",
|
||||
updated_at=to_iso(relation.updated_at) or "",
|
||||
)
|
||||
|
||||
|
||||
def plan_to_response(plan: Plan) -> PlanSummaryResponse:
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
"""世界模型服务(河图枢运行时,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,
|
||||
)
|
||||
@@ -31,7 +31,7 @@ async def test_list_returns_seeded_default(client: AsyncClient) -> None:
|
||||
item = body["items"][0]
|
||||
assert item["id"].startswith("character_")
|
||||
assert item["status"] == "active"
|
||||
assert item["personality_version"] >= 1
|
||||
assert item["personality_version"] == 0 # 旧人格版本表退役,版本语义由魂籍承载
|
||||
assert item["memory_count"] == 0
|
||||
|
||||
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
"""内驱力 /drive API 测试。"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.main import app
|
||||
from app.models.big_cycle import BigCycle
|
||||
from app.models.drive import CuriosityQuestion, ExploratorySubgoal
|
||||
from app.services.characters import get_active_character_model
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> AsyncGenerator[AsyncClient, None]:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
|
||||
yield http_client
|
||||
|
||||
|
||||
async def _seed(character_id: str, subgoal_status: str = "candidate") -> str:
|
||||
async with AsyncSessionLocal() as session:
|
||||
cycle = BigCycle(
|
||||
character_id=character_id,
|
||||
trigger="test",
|
||||
status="completed",
|
||||
memory_count_at_trigger=20,
|
||||
)
|
||||
session.add(cycle)
|
||||
await session.flush()
|
||||
curios = CuriosityQuestion(
|
||||
character_id=character_id,
|
||||
cycle_id=cycle.id,
|
||||
question="用户为何频繁提到项目X",
|
||||
source="memory_gap",
|
||||
expected_value=0.7,
|
||||
urgency=0.5,
|
||||
risk_level="L2",
|
||||
)
|
||||
session.add(curios)
|
||||
await session.flush()
|
||||
sub = ExploratorySubgoal(
|
||||
character_id=character_id,
|
||||
cycle_id=cycle.id,
|
||||
curiosity_question_id=curios.id,
|
||||
title="询问项目目标",
|
||||
description="主动询问",
|
||||
expected_information_gain=0.6,
|
||||
interruption_cost=0.3,
|
||||
risk_level="L2",
|
||||
status=subgoal_status,
|
||||
)
|
||||
session.add(sub)
|
||||
await session.commit()
|
||||
return sub.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_curiosities(client: AsyncClient) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
await _seed(character.id)
|
||||
|
||||
resp = await client.get(f"/api/v1/characters/{character.id}/curiosities")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["question"] == "用户为何频繁提到项目X"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_subgoals_with_status_filter(client: AsyncClient) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
await _seed(character.id, "candidate")
|
||||
await _seed(character.id, "approved")
|
||||
|
||||
resp = await client.get(
|
||||
f"/api/v1/characters/{character.id}/subgoals", params={"status": "candidate"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_subgoal(client: AsyncClient) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
sid = await _seed(character.id)
|
||||
|
||||
resp = await client.post(f"/api/v1/subgoals/{sid}/approve")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "approved"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_defer_subgoal(client: AsyncClient) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
sid = await _seed(character.id)
|
||||
|
||||
resp = await client.post(f"/api/v1/subgoals/{sid}/defer")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "deferred"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_subgoal(client: AsyncClient) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
sid = await _seed(character.id)
|
||||
|
||||
resp = await client.post(f"/api/v1/subgoals/{sid}/reject")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "rejected"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_subgoal_creates_goal(client: AsyncClient) -> None:
|
||||
"""审批通过次生目标后应自动创建太一目标。"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.governance import Goal
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
sid = await _seed(character.id)
|
||||
|
||||
resp = await client.post(f"/api/v1/subgoals/{sid}/approve")
|
||||
assert resp.status_code == 200
|
||||
|
||||
# 验证生成了 Goal
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(Goal).where(Goal.character_id == character.id)
|
||||
)
|
||||
goals = result.scalars().all()
|
||||
matching = [g for g in goals if g.title == "询问项目目标"]
|
||||
assert len(matching) == 1
|
||||
goal = matching[0]
|
||||
assert goal.level == "task"
|
||||
assert goal.type == "exploration"
|
||||
assert goal.status == "active"
|
||||
@@ -1,63 +0,0 @@
|
||||
"""人格提议列表 API 测试(api-spec §11,审批墙数据源)。"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(auth_headers: dict[str, str]) -> AsyncGenerator[AsyncClient, None]:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport, base_url="http://test", headers=auth_headers
|
||||
) as http_client:
|
||||
yield http_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_proposals_empty(client: AsyncClient) -> None:
|
||||
resp = await client.get("/api/v1/personality/proposals")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] == 0
|
||||
assert body["items"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_proposals_filter_by_status(client: AsyncClient) -> None:
|
||||
proposed = await client.post(
|
||||
"/api/v1/personality/propose-update",
|
||||
json={"fields": {"identity_profile": "更新的身份画像"}, "reason": "测试提议"},
|
||||
)
|
||||
assert proposed.status_code == 201
|
||||
proposal_id = proposed.json()["proposal_id"]
|
||||
|
||||
listed = await client.get("/api/v1/personality/proposals")
|
||||
assert listed.status_code == 200
|
||||
body = listed.json()
|
||||
assert body["total"] == 1
|
||||
assert body["items"][0]["proposal_id"] == proposal_id
|
||||
assert body["items"][0]["status"] == "pending"
|
||||
|
||||
pending = await client.get("/api/v1/personality/proposals?status=pending")
|
||||
assert pending.json()["total"] == 1
|
||||
|
||||
approved = await client.get("/api/v1/personality/proposals?status=approved")
|
||||
assert approved.json()["total"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_proposals_pagination(client: AsyncClient) -> None:
|
||||
for i in range(3):
|
||||
await client.post(
|
||||
"/api/v1/personality/propose-update",
|
||||
json={"fields": {"identity_profile": f"v{i}"}, "reason": f"r{i}"},
|
||||
)
|
||||
page1 = await client.get("/api/v1/personality/proposals?page=1&page_size=2")
|
||||
assert page1.json()["total"] == 3
|
||||
assert len(page1.json()["items"]) == 2
|
||||
page2 = await client.get("/api/v1/personality/proposals?page=2&page_size=2")
|
||||
assert len(page2.json()["items"]) == 1
|
||||
@@ -1,104 +0,0 @@
|
||||
"""世界模型 API 测试(河图枢运行时,api-spec §12)。"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(auth_headers: dict[str, str]) -> AsyncGenerator[AsyncClient, None]:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport, base_url="http://test", headers=auth_headers
|
||||
) as http_client:
|
||||
yield http_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_entities_empty(client: AsyncClient) -> None:
|
||||
resp = await client.get("/api/v1/world/entities")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] == 0
|
||||
assert body["items"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_and_get_entity(client: AsyncClient) -> None:
|
||||
created = await client.post(
|
||||
"/api/v1/world/entities",
|
||||
json={"name": "用户项目", "type": "project", "state_type": "fact", "confidence": 0.9},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
entity = created.json()
|
||||
assert entity["id"].startswith("world_entity_")
|
||||
assert entity["name"] == "用户项目"
|
||||
assert entity["type"] == "project"
|
||||
assert entity["state_type"] == "fact"
|
||||
assert entity["character_id"].startswith("character_")
|
||||
|
||||
fetched = await client.get(f"/api/v1/world/entities/{entity['id']}")
|
||||
assert fetched.status_code == 200
|
||||
assert fetched.json()["id"] == entity["id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_entity_not_found(client: AsyncClient) -> None:
|
||||
resp = await client.get("/api/v1/world/entities/world_entity_missing")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_entities_filter_by_type(client: AsyncClient) -> None:
|
||||
await client.post("/api/v1/world/entities", json={"name": "概念A", "type": "concept"})
|
||||
await client.post("/api/v1/world/entities", json={"name": "项目B", "type": "project"})
|
||||
|
||||
filtered = await client.get("/api/v1/world/entities?type=project")
|
||||
assert filtered.status_code == 200
|
||||
body = filtered.json()
|
||||
assert body["total"] == 1
|
||||
assert body["items"][0]["type"] == "project"
|
||||
|
||||
all_entities = await client.get("/api/v1/world/entities")
|
||||
assert all_entities.json()["total"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_relation(client: AsyncClient) -> None:
|
||||
user = (
|
||||
await client.post("/api/v1/world/entities", json={"name": "用户", "type": "person"})
|
||||
).json()["id"]
|
||||
project = (
|
||||
await client.post("/api/v1/world/entities", json={"name": "魂镜项目", "type": "project"})
|
||||
).json()["id"]
|
||||
|
||||
created = await client.post(
|
||||
"/api/v1/world/relations",
|
||||
json={"subject_id": user, "predicate": "正在进行", "object_id": project},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
relation = created.json()
|
||||
assert relation["id"].startswith("world_relation_")
|
||||
assert relation["predicate"] == "正在进行"
|
||||
assert relation["subject_id"] == user
|
||||
assert relation["object_id"] == project
|
||||
|
||||
listed = await client.get("/api/v1/world/relations")
|
||||
assert listed.json()["total"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_relation_rejects_missing_entity(client: AsyncClient) -> None:
|
||||
project = (
|
||||
await client.post("/api/v1/world/entities", json={"name": "项目", "type": "project"})
|
||||
).json()["id"]
|
||||
|
||||
# 主体不存在
|
||||
resp = await client.post(
|
||||
"/api/v1/world/relations",
|
||||
json={"subject_id": "world_entity_missing", "predicate": "依赖", "object_id": project},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
@@ -1,96 +0,0 @@
|
||||
"""大周天演化候选抽取纯函数测试。"""
|
||||
|
||||
from app.modules.big_cycle.extract import (
|
||||
EvolutionCandidateDraft,
|
||||
parse_evolution_candidates,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_standard_json_array() -> None:
|
||||
raw = (
|
||||
'[{"type":"personality","level":"L4","scope":"character_scope",'
|
||||
'"title":"调整身份表达","proposal":"更主动介绍自身能力边界",'
|
||||
'"delta":{"identity_profile":"新的身份描述"},"confidence":0.8,'
|
||||
'"benefit_score":0.7,"risk_score":0.3,"auto_apply_allowed":false,'
|
||||
'"evidence":[0,1]}]'
|
||||
)
|
||||
drafts = parse_evolution_candidates(raw)
|
||||
assert len(drafts) == 1
|
||||
d = drafts[0]
|
||||
assert d.type == "personality"
|
||||
assert d.level == "L4"
|
||||
assert d.scope == "character_scope"
|
||||
assert d.title == "调整身份表达"
|
||||
assert d.proposal == "更主动介绍自身能力边界"
|
||||
assert d.delta == {"identity_profile": "新的身份描述"}
|
||||
assert d.confidence == 0.8
|
||||
assert d.auto_apply_allowed is False
|
||||
assert d.source == "big_cycle"
|
||||
assert d.evidence == [0, 1]
|
||||
|
||||
|
||||
def test_parse_markdown_code_block() -> None:
|
||||
raw = (
|
||||
'```json\n[{"type":"procedural","level":"L2","scope":"task_scope",'
|
||||
'"title":"t","proposal":"p"}]\n```'
|
||||
)
|
||||
drafts = parse_evolution_candidates(raw)
|
||||
assert len(drafts) == 1
|
||||
assert drafts[0].type == "procedural"
|
||||
|
||||
|
||||
def test_parse_text_mixed_json() -> None:
|
||||
raw = (
|
||||
'候选如下:\n[{"type":"mood","level":"L1","scope":"character_scope",'
|
||||
'"title":"t","proposal":"p"}]\n以上。'
|
||||
)
|
||||
drafts = parse_evolution_candidates(raw)
|
||||
assert len(drafts) == 1
|
||||
|
||||
|
||||
def test_parse_invalid_json_returns_empty() -> None:
|
||||
assert parse_evolution_candidates("不是JSON") == []
|
||||
assert parse_evolution_candidates("[]") == []
|
||||
|
||||
|
||||
def test_skip_entries_missing_required_fields() -> None:
|
||||
raw = (
|
||||
'[{"type":"personality","title":"缺proposal"},'
|
||||
'{"title":"缺type","proposal":"p"},'
|
||||
'{"type":"x","proposal":"缺title"}]'
|
||||
)
|
||||
assert parse_evolution_candidates(raw) == []
|
||||
|
||||
|
||||
def test_defaults_use_safe_pessimistic_values() -> None:
|
||||
"""缺省时取最严格默认:level=L4(必须人工)、auto_apply_allowed=False。"""
|
||||
raw = '[{"type":"personality","title":"t","proposal":"p"}]'
|
||||
d = parse_evolution_candidates(raw)[0]
|
||||
assert d.level == "L4"
|
||||
assert d.scope == "character_scope"
|
||||
assert d.auto_apply_allowed is False
|
||||
assert d.confidence == 0.5
|
||||
assert d.benefit_score == 0.5
|
||||
assert d.risk_score == 0.5
|
||||
assert d.drift_score == 0.0
|
||||
assert d.delta == {}
|
||||
|
||||
|
||||
def test_clamp_and_default_scores() -> None:
|
||||
raw = (
|
||||
'[{"type":"personality","title":"t","proposal":"p",'
|
||||
'"confidence":1.5,"benefit_score":-0.2,"risk_score":"high"}]'
|
||||
)
|
||||
d = parse_evolution_candidates(raw)[0]
|
||||
assert d.confidence == 1.0
|
||||
assert d.benefit_score == 0.0
|
||||
assert d.risk_score == 0.5 # 非法值 → 默认
|
||||
|
||||
|
||||
def test_auto_apply_allowed_truthy() -> None:
|
||||
raw = (
|
||||
'[{"type":"procedural","level":"L2","title":"t","proposal":"p","auto_apply_allowed":true}]'
|
||||
)
|
||||
d = parse_evolution_candidates(raw)[0]
|
||||
assert d.auto_apply_allowed is True
|
||||
assert isinstance(d, EvolutionCandidateDraft)
|
||||
@@ -1,62 +0,0 @@
|
||||
"""大周天演化候选 prompt 构建纯函数测试。"""
|
||||
|
||||
from app.modules.big_cycle.prompts import (
|
||||
EvaluationSnapshot,
|
||||
build_evolution_extraction_messages,
|
||||
)
|
||||
|
||||
|
||||
def _snapshot() -> EvaluationSnapshot:
|
||||
return EvaluationSnapshot(
|
||||
character_id="character_x",
|
||||
memory_count=25,
|
||||
recent_memories=[("喜欢简洁回答", "用户偏好简洁"), ("来自北京", "用户所在地")],
|
||||
personality_summary="身份:魂镜;表达风格:温和",
|
||||
world_entity_count=3,
|
||||
conclusion="近期互动偏技术话题,表达可更简洁",
|
||||
)
|
||||
|
||||
|
||||
def test_messages_return_system_user_pair() -> None:
|
||||
system, user = build_evolution_extraction_messages(_snapshot())
|
||||
assert isinstance(system, str) and isinstance(user, str)
|
||||
assert system and user
|
||||
|
||||
|
||||
def test_system_describes_contract_fields() -> None:
|
||||
system, _ = build_evolution_extraction_messages(_snapshot())
|
||||
for token in (
|
||||
"type",
|
||||
"level",
|
||||
"scope",
|
||||
"proposal",
|
||||
"delta",
|
||||
"auto_apply_allowed",
|
||||
):
|
||||
assert token in system
|
||||
|
||||
|
||||
def test_system_lists_evolution_types() -> None:
|
||||
system, _ = build_evolution_extraction_messages(_snapshot())
|
||||
for t in ("personality", "procedural", "tool_strategy", "memory"):
|
||||
assert t in system
|
||||
|
||||
|
||||
def test_system_states_level_semantics() -> None:
|
||||
system, _ = build_evolution_extraction_messages(_snapshot())
|
||||
assert "L4" in system
|
||||
|
||||
|
||||
def test_user_includes_snapshot_signals() -> None:
|
||||
_, user = build_evolution_extraction_messages(_snapshot())
|
||||
assert "喜欢简洁回答" in user
|
||||
assert "身份:魂镜" in user
|
||||
assert "25" in user
|
||||
|
||||
|
||||
def test_user_handles_empty_personality() -> None:
|
||||
snap = _snapshot()
|
||||
snap.personality_summary = ""
|
||||
_, user = build_evolution_extraction_messages(snap)
|
||||
assert isinstance(user, str)
|
||||
assert len(user) > 0
|
||||
@@ -1,95 +0,0 @@
|
||||
"""大周天内驱力抽取纯函数测试。"""
|
||||
|
||||
from app.modules.drive.extract import (
|
||||
CuriosityDraft,
|
||||
SubgoalDraft,
|
||||
parse_curiosities,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_standard_with_subgoals() -> None:
|
||||
raw = (
|
||||
'[{"question":"用户为何频繁提到项目X","source":"memory_gap",'
|
||||
'"expected_value":0.7,"urgency":0.5,"risk_level":"L2",'
|
||||
'"related_entities":["项目X"],'
|
||||
'"subgoals":[{"title":"询问项目X目标","description":"主动询问用户",'
|
||||
'"expected_information_gain":0.6,"interruption_cost":0.3,"risk_level":"L2"}]}]'
|
||||
)
|
||||
drafts = parse_curiosities(raw)
|
||||
assert len(drafts) == 1
|
||||
c = drafts[0]
|
||||
assert c.question == "用户为何频繁提到项目X"
|
||||
assert c.source == "memory_gap"
|
||||
assert c.expected_value == 0.7
|
||||
assert c.risk_level == "L2"
|
||||
assert c.related_entities == ["项目X"]
|
||||
assert len(c.subgoals) == 1
|
||||
sg = c.subgoals[0]
|
||||
assert sg.title == "询问项目X目标"
|
||||
assert sg.expected_information_gain == 0.6
|
||||
assert sg.interruption_cost == 0.3
|
||||
|
||||
|
||||
def test_parse_markdown_block() -> None:
|
||||
raw = '```json\n[{"question":"q"}]\n```'
|
||||
drafts = parse_curiosities(raw)
|
||||
assert len(drafts) == 1
|
||||
assert drafts[0].question == "q"
|
||||
|
||||
|
||||
def test_parse_invalid_returns_empty() -> None:
|
||||
assert parse_curiosities("不是JSON") == []
|
||||
assert parse_curiosities("[]") == []
|
||||
|
||||
|
||||
def test_skip_missing_question() -> None:
|
||||
raw = '[{"source":"memory_gap"},{"question":"q"}]'
|
||||
drafts = parse_curiosities(raw)
|
||||
assert len(drafts) == 1
|
||||
assert drafts[0].question == "q"
|
||||
|
||||
|
||||
def test_defaults_use_safe_pessimistic_values() -> None:
|
||||
"""缺省取最严格默认:risk_level=L4、source=reflection。"""
|
||||
raw = '[{"question":"q"}]'
|
||||
c = parse_curiosities(raw)[0]
|
||||
assert c.source == "reflection"
|
||||
assert c.risk_level == "L4"
|
||||
assert c.expected_value == 0.5
|
||||
assert c.urgency == 0.5
|
||||
assert c.related_entities == []
|
||||
assert c.subgoals == []
|
||||
|
||||
|
||||
def test_clamp_values() -> None:
|
||||
raw = '[{"question":"q","expected_value":1.5,"urgency":-0.2}]'
|
||||
c = parse_curiosities(raw)[0]
|
||||
assert c.expected_value == 1.0
|
||||
assert c.urgency == 0.0
|
||||
|
||||
|
||||
def test_invalid_source_defaults() -> None:
|
||||
raw = '[{"question":"q","source":"unknown_source"}]'
|
||||
assert parse_curiosities(raw)[0].source == "reflection"
|
||||
|
||||
|
||||
def test_invalid_risk_level_defaults() -> None:
|
||||
raw = '[{"question":"q","risk_level":"L9"}]'
|
||||
assert parse_curiosities(raw)[0].risk_level == "L4"
|
||||
|
||||
|
||||
def test_subgoal_defaults_and_types() -> None:
|
||||
raw = '[{"question":"q","subgoals":[{"title":"t","description":"d"}]}]'
|
||||
c = parse_curiosities(raw)[0]
|
||||
assert isinstance(c, CuriosityDraft)
|
||||
sg = c.subgoals[0]
|
||||
assert isinstance(sg, SubgoalDraft)
|
||||
assert sg.risk_level == "L4" # 安全默认
|
||||
assert sg.expected_information_gain == 0.5
|
||||
assert sg.interruption_cost == 0.5
|
||||
|
||||
|
||||
def test_skip_subgoal_missing_title_or_description() -> None:
|
||||
raw = '[{"question":"q","subgoals":[{"title":"t"},{"description":"d"},{"title":"t","description":"d"}]}]'
|
||||
c = parse_curiosities(raw)[0]
|
||||
assert len(c.subgoals) == 1 # 只有完整的保留
|
||||
@@ -1,47 +0,0 @@
|
||||
"""大周天内驱力 prompt 构建纯函数测试。"""
|
||||
|
||||
from app.modules.big_cycle.prompts import EvaluationSnapshot
|
||||
from app.modules.drive.prompts import build_drive_extraction_messages
|
||||
|
||||
|
||||
def _snapshot() -> EvaluationSnapshot:
|
||||
return EvaluationSnapshot(
|
||||
character_id="character_x",
|
||||
memory_count=15,
|
||||
recent_memories=[("用户偏好简洁", "用户偏好简洁直接的回答")],
|
||||
personality_summary="身份:魂镜",
|
||||
world_entity_count=4,
|
||||
conclusion="近期对话偏技术,存在项目背景缺口",
|
||||
)
|
||||
|
||||
|
||||
def test_messages_return_system_user_pair() -> None:
|
||||
system, user = build_drive_extraction_messages(_snapshot())
|
||||
assert isinstance(system, str) and isinstance(user, str)
|
||||
assert system and user
|
||||
|
||||
|
||||
def test_system_describes_contract_fields() -> None:
|
||||
system, _ = build_drive_extraction_messages(_snapshot())
|
||||
for token in (
|
||||
"question",
|
||||
"source",
|
||||
"expected_value",
|
||||
"urgency",
|
||||
"risk_level",
|
||||
"subgoals",
|
||||
):
|
||||
assert token in system
|
||||
|
||||
|
||||
def test_system_lists_sources() -> None:
|
||||
system, _ = build_drive_extraction_messages(_snapshot())
|
||||
for src in ("world_uncertainty", "memory_gap", "reflection", "goal_tension"):
|
||||
assert src in system
|
||||
|
||||
|
||||
def test_user_includes_snapshot_signals() -> None:
|
||||
_, user = build_drive_extraction_messages(_snapshot())
|
||||
assert "用户偏好简洁" in user
|
||||
assert "身份:魂镜" in user
|
||||
assert "15" in user
|
||||
@@ -1,182 +0,0 @@
|
||||
"""灵核情绪状态机纯计算测试(modules/personality/emotion.py)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.personality.emotion import (
|
||||
MOODS,
|
||||
EmotionState,
|
||||
apply_signal,
|
||||
decay,
|
||||
map_signal_to_mood,
|
||||
parse_baseline,
|
||||
signal_intensity_delta,
|
||||
to_prompt_text,
|
||||
)
|
||||
|
||||
|
||||
def _baseline(mood: str = "neutral", intensity: float = 0.2) -> EmotionState:
|
||||
return EmotionState(mood=mood, intensity=intensity)
|
||||
|
||||
|
||||
# ---- map_signal_to_mood ----
|
||||
|
||||
|
||||
def test_map_reunion_to_touched() -> None:
|
||||
assert map_signal_to_mood("time.reunion", {"reunion_intensity": 0.8}) == "touched"
|
||||
|
||||
|
||||
def test_map_user_care_default_to_concerned() -> None:
|
||||
assert map_signal_to_mood("time.user_care", {"care_kind": "late_night"}) == "concerned"
|
||||
|
||||
|
||||
def test_map_user_care_festival_lonely_to_melancholy() -> None:
|
||||
assert map_signal_to_mood("time.user_care", {"care_kind": "festival_lonely"}) == "melancholy"
|
||||
|
||||
|
||||
def test_map_sentiment_default_to_wistful() -> None:
|
||||
assert map_signal_to_mood("time.sentiment", {"trigger_type": "reunion_memory"}) == "wistful"
|
||||
|
||||
|
||||
def test_map_sentiment_late_night_to_melancholy() -> None:
|
||||
assert map_signal_to_mood("time.sentiment", {"trigger_type": "late_night"}) == "melancholy"
|
||||
|
||||
|
||||
def test_map_unknown_event_returns_none() -> None:
|
||||
assert map_signal_to_mood("tool.started", {}) is None
|
||||
|
||||
|
||||
# ---- signal_intensity_delta ----
|
||||
|
||||
|
||||
def test_delta_reunion_uses_reunion_intensity() -> None:
|
||||
assert signal_intensity_delta("time.reunion", {"reunion_intensity": 0.7}) == 0.7
|
||||
|
||||
|
||||
def test_delta_user_care_uses_care_suggest() -> None:
|
||||
assert signal_intensity_delta("time.user_care", {"care_suggest": 0.6}) == 0.6
|
||||
|
||||
|
||||
def test_delta_sentiment_default_05() -> None:
|
||||
assert signal_intensity_delta("time.sentiment", {}) == 0.5
|
||||
|
||||
|
||||
def test_delta_unknown_event_zero() -> None:
|
||||
assert signal_intensity_delta("tool.started", {}) == 0.0
|
||||
|
||||
|
||||
# ---- apply_signal ----
|
||||
|
||||
|
||||
def test_apply_signal_changes_mood_and_sets_intensity_from_baseline() -> None:
|
||||
state = _baseline() # neutral 0.2
|
||||
new = apply_signal(state, "time.reunion", {"reunion_intensity": 0.6}, _baseline())
|
||||
assert new.mood == "touched"
|
||||
assert new.intensity == 0.8 # 0.2 baseline + 0.6 delta
|
||||
|
||||
|
||||
def test_apply_signal_same_mood_accumulates() -> None:
|
||||
state = EmotionState(mood="touched", intensity=0.5)
|
||||
new = apply_signal(state, "time.reunion", {"reunion_intensity": 0.3}, _baseline())
|
||||
assert new.mood == "touched"
|
||||
assert new.intensity == 0.8 # 0.5 current + 0.3 delta
|
||||
|
||||
|
||||
def test_apply_signal_clamps_to_1() -> None:
|
||||
state = EmotionState(mood="touched", intensity=0.9)
|
||||
new = apply_signal(state, "time.reunion", {"reunion_intensity": 0.5}, _baseline())
|
||||
assert new.mood == "touched"
|
||||
assert new.intensity == 1.0
|
||||
|
||||
|
||||
def test_apply_signal_unknown_event_returns_unchanged() -> None:
|
||||
state = EmotionState(mood="pleased", intensity=0.5)
|
||||
new = apply_signal(state, "tool.started", {}, _baseline())
|
||||
assert new == state
|
||||
|
||||
|
||||
# ---- decay ----
|
||||
|
||||
|
||||
def test_decay_toward_baseline_half_life() -> None:
|
||||
state = EmotionState(mood="touched", intensity=0.8)
|
||||
baseline = _baseline() # neutral 0.2
|
||||
# 半衰期 6 回合,elapsed 6 → intensity 向 baseline 走一半
|
||||
decayed = decay(state, baseline, elapsed_turns=6.0)
|
||||
assert decayed.mood == "touched"
|
||||
assert abs(decayed.intensity - 0.5) < 0.01 # 0.2 + (0.8-0.2)*0.5 = 0.5
|
||||
|
||||
|
||||
def test_decay_below_threshold_returns_baseline_mood() -> None:
|
||||
state = EmotionState(mood="touched", intensity=0.3)
|
||||
baseline = _baseline()
|
||||
# elapsed 大 → intensity 衰减到 < 0.15 → mood 回 baseline
|
||||
decayed = decay(state, baseline, elapsed_turns=30.0)
|
||||
assert decayed.mood == baseline.mood
|
||||
assert decayed.intensity == baseline.intensity
|
||||
|
||||
|
||||
def test_decay_zero_elapsed_returns_unchanged() -> None:
|
||||
state = EmotionState(mood="touched", intensity=0.8)
|
||||
baseline = _baseline()
|
||||
assert decay(state, baseline, elapsed_turns=0) == state
|
||||
|
||||
|
||||
# ---- parse_baseline ----
|
||||
|
||||
|
||||
def test_parse_baseline_from_dict() -> None:
|
||||
assert parse_baseline({"mood": "neutral", "intensity": 0.3}) == EmotionState(
|
||||
mood="neutral", intensity=0.3
|
||||
)
|
||||
|
||||
|
||||
def test_parse_baseline_from_valid_json() -> None:
|
||||
assert parse_baseline('{"mood": "pleased", "intensity": 0.4}') == EmotionState(
|
||||
mood="pleased", intensity=0.4
|
||||
)
|
||||
|
||||
|
||||
def test_parse_baseline_invalid_json_returns_neutral_default() -> None:
|
||||
assert parse_baseline("not json") == EmotionState()
|
||||
|
||||
|
||||
def test_parse_baseline_none_returns_neutral_default() -> None:
|
||||
assert parse_baseline(None) == EmotionState()
|
||||
|
||||
|
||||
def test_parse_baseline_clamps_intensity() -> None:
|
||||
assert parse_baseline({"mood": "neutral", "intensity": 1.5}).intensity == 1.0
|
||||
assert parse_baseline({"mood": "neutral", "intensity": -0.2}).intensity == 0.0
|
||||
|
||||
|
||||
def test_parse_baseline_unknown_mood_falls_back_neutral() -> None:
|
||||
assert parse_baseline({"mood": "unknown_mood", "intensity": 0.5}).mood == "neutral"
|
||||
|
||||
|
||||
# ---- to_prompt_text ----
|
||||
|
||||
|
||||
def test_to_prompt_text_includes_chinese_label_and_pct() -> None:
|
||||
text = to_prompt_text(EmotionState(mood="touched", intensity=0.7))
|
||||
assert "感动" in text
|
||||
assert "70" in text
|
||||
|
||||
|
||||
def test_to_prompt_text_unknown_mood_uses_raw() -> None:
|
||||
text = to_prompt_text(EmotionState(mood="custom", intensity=0.5))
|
||||
assert "custom" in text
|
||||
|
||||
|
||||
# ---- MOODS ----
|
||||
|
||||
|
||||
def test_moods_contains_seven() -> None:
|
||||
assert set(MOODS) == {
|
||||
"neutral",
|
||||
"pleased",
|
||||
"touched",
|
||||
"wistful",
|
||||
"concerned",
|
||||
"festive",
|
||||
"melancholy",
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
"""内驱力数据访问测试(好奇问题 + 次生目标)。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.big_cycle import BigCycle
|
||||
from app.models.drive import CuriosityQuestion, ExploratorySubgoal
|
||||
from app.repositories import drive as drive_repo
|
||||
from app.services.characters import get_active_character_model
|
||||
|
||||
|
||||
async def _seed_cycle(session, character_id: str) -> str:
|
||||
cycle = BigCycle(
|
||||
character_id=character_id,
|
||||
trigger="test",
|
||||
status="completed",
|
||||
memory_count_at_trigger=20,
|
||||
)
|
||||
session.add(cycle)
|
||||
await session.flush()
|
||||
return cycle.id
|
||||
|
||||
|
||||
def _curiosity(character_id: str, cycle_id: str, **overrides) -> CuriosityQuestion:
|
||||
base = {
|
||||
"character_id": character_id,
|
||||
"cycle_id": cycle_id,
|
||||
"question": "q",
|
||||
"source": "memory_gap",
|
||||
"related_entities_json": "[]",
|
||||
"expected_value": 0.5,
|
||||
"urgency": 0.5,
|
||||
"risk_level": "L2",
|
||||
}
|
||||
base.update(overrides)
|
||||
return CuriosityQuestion(**base)
|
||||
|
||||
|
||||
def _subgoal(
|
||||
character_id: str, cycle_id: str, curiosity_id: str, **overrides
|
||||
) -> ExploratorySubgoal:
|
||||
base = {
|
||||
"character_id": character_id,
|
||||
"cycle_id": cycle_id,
|
||||
"curiosity_question_id": curiosity_id,
|
||||
"title": "t",
|
||||
"description": "d",
|
||||
"expected_information_gain": 0.6,
|
||||
"interruption_cost": 0.3,
|
||||
"risk_level": "L2",
|
||||
"status": "candidate",
|
||||
}
|
||||
base.update(overrides)
|
||||
return ExploratorySubgoal(**base)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_curiosity_add_get_list() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
cycle_id = await _seed_cycle(session, character.id)
|
||||
curios = _curiosity(character.id, cycle_id, question="为何频繁提到项目X")
|
||||
await drive_repo.add_curiosities(session, [curios])
|
||||
await drive_repo.add_curiosities(
|
||||
session, [_curiosity(character.id, cycle_id, source="world_uncertainty")]
|
||||
)
|
||||
await session.commit()
|
||||
cid = curios.id
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
fetched = await drive_repo.get_curiosity(session, cid)
|
||||
all_items, total = await drive_repo.list_curiosities(session, character.id)
|
||||
gap, gap_total = await drive_repo.list_curiosities(
|
||||
session, character.id, source="memory_gap"
|
||||
)
|
||||
assert fetched is not None
|
||||
assert fetched.question == "为何频繁提到项目X"
|
||||
assert total == 2
|
||||
assert gap_total == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subgoal_add_get_list() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
cycle_id = await _seed_cycle(session, character.id)
|
||||
curios = _curiosity(character.id, cycle_id)
|
||||
await drive_repo.add_curiosities(session, [curios])
|
||||
await session.flush()
|
||||
curiosity_id = curios.id
|
||||
sub = _subgoal(character.id, cycle_id, curiosity_id, title="询问项目目标")
|
||||
await drive_repo.add_subgoals(session, [sub])
|
||||
await drive_repo.add_subgoals(
|
||||
session, [_subgoal(character.id, cycle_id, curiosity_id, status="approved")]
|
||||
)
|
||||
await session.commit()
|
||||
sid = sub.id
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
fetched = await drive_repo.get_subgoal(session, sid)
|
||||
_, total = await drive_repo.list_subgoals(session, character.id)
|
||||
cand, cand_total = await drive_repo.list_subgoals(session, character.id, status="candidate")
|
||||
assert fetched is not None
|
||||
assert fetched.title == "询问项目目标"
|
||||
assert fetched.curiosity_question_id == curiosity_id
|
||||
assert total == 2
|
||||
assert cand_total == 1
|
||||
@@ -1,100 +0,0 @@
|
||||
"""演化候选数据访问测试。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.big_cycle import BigCycle
|
||||
from app.models.evolution import EvolutionCandidate
|
||||
from app.repositories import evolution as evolution_repo
|
||||
from app.repositories.json_utils import dumps
|
||||
from app.services.characters import get_active_character_model
|
||||
|
||||
|
||||
async def _seed_cycle(session, character_id: str) -> str:
|
||||
cycle = BigCycle(
|
||||
character_id=character_id,
|
||||
trigger="test",
|
||||
status="completed",
|
||||
memory_count_at_trigger=20,
|
||||
)
|
||||
session.add(cycle)
|
||||
await session.flush()
|
||||
return cycle.id
|
||||
|
||||
|
||||
def _make_candidate(character_id: str, cycle_id: str, **overrides) -> EvolutionCandidate:
|
||||
base = {
|
||||
"character_id": character_id,
|
||||
"cycle_id": cycle_id,
|
||||
"type": "personality",
|
||||
"source": "big_cycle",
|
||||
"level": "L4",
|
||||
"scope": "character_scope",
|
||||
"title": "t",
|
||||
"proposal": "p",
|
||||
"delta_json": dumps({}),
|
||||
"evidence_ids_json": "[]",
|
||||
"confidence": 0.5,
|
||||
"benefit_score": 0.5,
|
||||
"risk_score": 0.5,
|
||||
"drift_score": 0.0,
|
||||
"auto_apply_allowed": False,
|
||||
"status": "pending",
|
||||
}
|
||||
base.update(overrides)
|
||||
return EvolutionCandidate(**base)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_and_get_candidate() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
cycle_id = await _seed_cycle(session, character.id)
|
||||
candidate = _make_candidate(character.id, cycle_id, title="身份调整")
|
||||
await evolution_repo.add_candidates(session, [candidate])
|
||||
await session.commit()
|
||||
cid = candidate.id
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
fetched = await evolution_repo.get_candidate(session, cid)
|
||||
assert fetched is not None
|
||||
assert fetched.title == "身份调整"
|
||||
assert fetched.status == "pending"
|
||||
assert fetched.source == "big_cycle"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_candidates_with_filters_and_paging() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
cycle_id = await _seed_cycle(session, character.id)
|
||||
await evolution_repo.add_candidates(
|
||||
session,
|
||||
[
|
||||
_make_candidate(character.id, cycle_id, type="personality", status="pending"),
|
||||
_make_candidate(character.id, cycle_id, type="procedural", status="applied"),
|
||||
_make_candidate(character.id, cycle_id, type="procedural", status="pending"),
|
||||
],
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
all_items, total = await evolution_repo.list_candidates(session, character.id)
|
||||
assert total == 3
|
||||
assert len(all_items) == 3
|
||||
|
||||
pending, pending_total = await evolution_repo.list_candidates(
|
||||
session, character.id, status="pending"
|
||||
)
|
||||
assert pending_total == 2
|
||||
assert len(pending) == 2
|
||||
|
||||
procedural, proc_total = await evolution_repo.list_candidates(
|
||||
session, character.id, candidate_type="procedural"
|
||||
)
|
||||
assert proc_total == 2
|
||||
|
||||
page1, _ = await evolution_repo.list_candidates(session, character.id, page=1, page_size=2)
|
||||
page2, _ = await evolution_repo.list_candidates(session, character.id, page=2, page_size=2)
|
||||
assert len(page1) == 2
|
||||
assert len(page2) == 1
|
||||
@@ -1,97 +0,0 @@
|
||||
"""内驱力审核服务测试(次生目标 approve/defer/reject)。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.big_cycle import BigCycle
|
||||
from app.models.drive import CuriosityQuestion, ExploratorySubgoal
|
||||
from app.services import drive as drive_service
|
||||
from app.services.characters import get_active_character_model
|
||||
|
||||
|
||||
async def _seed_subgoal(session, character_id: str, status: str = "candidate") -> str:
|
||||
cycle = BigCycle(
|
||||
character_id=character_id,
|
||||
trigger="test",
|
||||
status="completed",
|
||||
memory_count_at_trigger=20,
|
||||
)
|
||||
session.add(cycle)
|
||||
await session.flush()
|
||||
curios = CuriosityQuestion(
|
||||
character_id=character_id,
|
||||
cycle_id=cycle.id,
|
||||
question="q",
|
||||
source="memory_gap",
|
||||
expected_value=0.5,
|
||||
urgency=0.5,
|
||||
risk_level="L2",
|
||||
)
|
||||
session.add(curios)
|
||||
await session.flush()
|
||||
sub = ExploratorySubgoal(
|
||||
character_id=character_id,
|
||||
cycle_id=cycle.id,
|
||||
curiosity_question_id=curios.id,
|
||||
title="询问项目目标",
|
||||
description="主动询问",
|
||||
expected_information_gain=0.6,
|
||||
interruption_cost=0.3,
|
||||
risk_level="L2",
|
||||
status=status,
|
||||
)
|
||||
session.add(sub)
|
||||
await session.flush()
|
||||
return sub.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_candidate_subgoal() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
sid = await _seed_subgoal(session, character.id)
|
||||
await session.commit()
|
||||
|
||||
resp = await drive_service.approve_subgoal(sid)
|
||||
assert resp.status == "approved"
|
||||
assert resp.reviewed_by == "user"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_defer_candidate_subgoal() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
sid = await _seed_subgoal(session, character.id)
|
||||
await session.commit()
|
||||
|
||||
resp = await drive_service.defer_subgoal(sid)
|
||||
assert resp.status == "deferred"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_candidate_subgoal() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
sid = await _seed_subgoal(session, character.id)
|
||||
await session.commit()
|
||||
|
||||
resp = await drive_service.reject_subgoal(sid)
|
||||
assert resp.status == "rejected"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_review_non_candidate_conflicts() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
sid = await _seed_subgoal(session, character.id, status="approved")
|
||||
await session.commit()
|
||||
|
||||
with pytest.raises(AppError):
|
||||
await drive_service.approve_subgoal(sid)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_not_found() -> None:
|
||||
with pytest.raises(AppError):
|
||||
await drive_service.approve_subgoal("subgoal_missing")
|
||||
@@ -29,89 +29,6 @@ async def test_health_and_defaults(client: AsyncClient) -> None:
|
||||
character = await client.get("/api/v1/characters/active")
|
||||
assert character.status_code == 200
|
||||
assert character.json()["id"].startswith("character_")
|
||||
personality = await client.get("/api/v1/personality/current")
|
||||
assert personality.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_personality_propose_approve_reject_and_rollback(client: AsyncClient) -> None:
|
||||
# 初始当前人格为 v1
|
||||
before = (await client.get("/api/v1/personality/current")).json()
|
||||
assert before["version"] == 1
|
||||
|
||||
# 空字段提议被拒(invalid_request)
|
||||
empty = await client.post(
|
||||
"/api/v1/personality/propose-update",
|
||||
json={"fields": {}, "reason": "空提议"},
|
||||
)
|
||||
assert empty.status_code == 400
|
||||
|
||||
# 提交 pending 建议(覆盖 string + json 字段)
|
||||
propose = await client.post(
|
||||
"/api/v1/personality/propose-update",
|
||||
json={
|
||||
"fields": {"identity_profile": "测试身份", "traits": {"warmth": 0.5}},
|
||||
"reason": "联调人格建议",
|
||||
},
|
||||
)
|
||||
assert propose.status_code == 201
|
||||
proposal = propose.json()
|
||||
assert proposal["status"] == "pending"
|
||||
assert proposal["fields"] == {"identity_profile": "测试身份", "traits": {"warmth": 0.5}}
|
||||
proposal_id = proposal["proposal_id"]
|
||||
|
||||
# 审核通过:派生新版本并切换当前指针,字段覆盖生效
|
||||
approve = await client.post(f"/api/v1/personality/proposals/{proposal_id}/approve")
|
||||
assert approve.status_code == 200
|
||||
applied = approve.json()
|
||||
assert applied["status"] == "applied"
|
||||
assert applied["resulting_version_id"]
|
||||
|
||||
after = (await client.get("/api/v1/personality/current")).json()
|
||||
assert after["version"] == before["version"] + 1
|
||||
assert after["identity_profile"] == "测试身份"
|
||||
assert after["traits"]["warmth"] == 0.5
|
||||
new_version_id = after["id"]
|
||||
|
||||
# 重复审核同一建议 → conflict
|
||||
again = await client.post(f"/api/v1/personality/proposals/{proposal_id}/approve")
|
||||
assert again.status_code == 409
|
||||
|
||||
# 驳回另一条建议:不生成版本,不改当前指针
|
||||
propose2 = await client.post(
|
||||
"/api/v1/personality/propose-update",
|
||||
json={"fields": {"expression_style": "被驳回"}, "reason": "测试驳回"},
|
||||
)
|
||||
pid2 = propose2.json()["proposal_id"]
|
||||
reject = await client.post(f"/api/v1/personality/proposals/{pid2}/reject")
|
||||
assert reject.status_code == 200
|
||||
assert reject.json()["status"] == "rejected"
|
||||
assert (await client.get("/api/v1/personality/current")).json()["id"] == new_version_id
|
||||
|
||||
# 回滚到 v1:当前指针切回,旧版本数不变
|
||||
versions = (await client.get("/api/v1/personality/versions")).json()
|
||||
v1 = next(v for v in versions["items"] if v["version"] == 1)
|
||||
rollback = await client.post(f"/api/v1/personality/versions/{v1['id']}/rollback")
|
||||
assert rollback.status_code == 200
|
||||
assert (await client.get("/api/v1/personality/current")).json()["id"] == v1["id"]
|
||||
|
||||
# 回滚到不存在的版本 → 404
|
||||
missing = await client.post("/api/v1/personality/versions/personality_nope/rollback")
|
||||
assert missing.status_code == 404
|
||||
|
||||
# 写操作均入起居注(personality_core 枢)
|
||||
# /admin/* 强制 require_admin 鉴权,联调测试在此局部绕过(鉴权本身覆盖见 test_admin_auth)
|
||||
app.dependency_overrides[require_admin] = lambda: None
|
||||
try:
|
||||
audit = await client.get("/api/v1/admin/audit/timeline?pivot=personality_core")
|
||||
assert audit.status_code == 200
|
||||
actions = {item["action"] for item in audit.json()["items"]}
|
||||
assert "personality.propose" in actions
|
||||
assert "personality.proposal.approved" in actions
|
||||
assert "personality.proposal.rejected" in actions
|
||||
assert "personality.rollback" in actions
|
||||
finally:
|
||||
app.dependency_overrides.pop(require_admin, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -147,38 +64,6 @@ async def test_mcp_server_crud(client: AsyncClient) -> None:
|
||||
assert deleted.status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_world_entities_crud(client: AsyncClient) -> None:
|
||||
"""河图世界实体 CRUD——覆盖 services/world 路径。"""
|
||||
created = await client.post(
|
||||
"/api/v1/world/entities",
|
||||
json={
|
||||
"name": "测试地点",
|
||||
"type": "location",
|
||||
"attributes": {"climate": "温暖"},
|
||||
"state_type": "fixed",
|
||||
"sources": [],
|
||||
},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
entity_id = created.json()["id"]
|
||||
|
||||
listed = await client.get("/api/v1/world/entities")
|
||||
assert listed.status_code == 200
|
||||
assert len(listed.json()["items"]) >= 1
|
||||
|
||||
fetched = await client.get(f"/api/v1/world/entities/{entity_id}")
|
||||
assert fetched.status_code == 200
|
||||
assert fetched.json()["name"] == "测试地点"
|
||||
|
||||
# 世界实体无 PATCH/DELETE API,仅测试 GET 和建表
|
||||
deleted = await client.delete(f"/api/v1/world/entities/{entity_id}")
|
||||
assert deleted.status_code in (404, 405) # 无删除端点,接受未实现
|
||||
|
||||
gone = await client.get(f"/api/v1/world/entities/{entity_id}")
|
||||
assert gone.status_code in (200, 404)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preferences_flow(client: AsyncClient) -> None:
|
||||
"""偏好设置读写——覆盖 services/preferences 路径。"""
|
||||
|
||||
@@ -12,10 +12,8 @@ from app.db.init_data import seed_default_data
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.main import app
|
||||
from app.models.character import Character
|
||||
from app.models.personality import PersonalityVersion
|
||||
from app.models.user import User
|
||||
from app.repositories import characters as character_repo
|
||||
from app.repositories import personality as personality_repo
|
||||
from app.repositories.json_utils import dumps
|
||||
from app.modules.character import (
|
||||
MODEL_POLICY_CREATE_DEFAULTS,
|
||||
@@ -78,43 +76,6 @@ async def test_active_character_api_returns_user_copy() -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_personality_is_copied_from_template(db_session: AsyncSession) -> None:
|
||||
db = db_session
|
||||
template = await character_repo.get_system_template_by_key(db, "default")
|
||||
admin_scope = await _seeded_admin_scope(db)
|
||||
character = await character_repo.get_active_character(db, admin_scope)
|
||||
assert template is not None
|
||||
assert character is not None
|
||||
assert template.current_personality_version_id is not None
|
||||
assert character.current_personality_version_id is not None
|
||||
assert template.current_personality_version_id != character.current_personality_version_id
|
||||
|
||||
template_personality = await personality_repo.get_personality(
|
||||
db, template.current_personality_version_id
|
||||
)
|
||||
user_personality = await personality_repo.get_personality(
|
||||
db, character.current_personality_version_id
|
||||
)
|
||||
assert template_personality is not None
|
||||
assert user_personality is not None
|
||||
assert template_personality.character_id == template.id
|
||||
assert user_personality.character_id == character.id
|
||||
assert user_personality.reason == "system_template_copy"
|
||||
|
||||
from app.services.auth import create_access_token
|
||||
|
||||
token = create_access_token(subject=admin_scope, extra={"owner_scope": admin_scope})
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://test",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
) as client:
|
||||
response = await client.get("/api/v1/personality/current")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] == user_personality.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seed_default_data_is_idempotent(db_session: AsyncSession) -> None:
|
||||
db = db_session
|
||||
@@ -138,7 +99,6 @@ async def test_seed_default_data_is_idempotent(db_session: AsyncSession) -> None
|
||||
Character.is_system_template.is_(False),
|
||||
)
|
||||
)
|
||||
personality_count = await db.scalar(select(func.count()).select_from(PersonalityVersion))
|
||||
|
||||
assert template_count == 5
|
||||
# is_default=true 模板只有一个(default),所以用户角色数为 1
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
"""M6 验收测试(rewrite-plan M6 验收门)。
|
||||
|
||||
① 多维关系无单一好感度;边界撤回立即生效;
|
||||
② 铸魂走确认流(锚点确认前不变),分灵保留谱系且不继承原身份;
|
||||
③ 境界正史不进真界本相(归真按 scope 分流,幻境不自动入忆);
|
||||
④ 引文指向的记忆被正影时提示后继版本。
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.main import app
|
||||
from app.models.character import Character
|
||||
from app.models.jingsheng import IdentityRecord
|
||||
from app.models.mirror_tree import Citation, MirrorTree, TreeNode
|
||||
from app.models.safety import AuditEvent
|
||||
from app.models.xirang import FactualRecord, Memory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(
|
||||
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
|
||||
) -> AsyncGenerator[AsyncClient, None]:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test", headers=auth_headers) as c:
|
||||
yield c
|
||||
|
||||
|
||||
async def _owner(client: AsyncClient) -> str:
|
||||
"""最新测试用户 id(auth_headers fixture 所建)。"""
|
||||
from app.models.user import User
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
users = (
|
||||
await session.execute(select(User).order_by(User.created_at.desc()))
|
||||
).scalars().all()
|
||||
return users[0].id
|
||||
|
||||
|
||||
async def _insert_solidified_node(tree_id: str, character_id: str, content: str) -> str:
|
||||
"""直插已固化候答节点(测试造数,绕过 kernel 生成流)。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
node = TreeNode(
|
||||
tree_id=tree_id,
|
||||
node_type="answer",
|
||||
status="solidified",
|
||||
author="character",
|
||||
content=content,
|
||||
)
|
||||
session.add(node)
|
||||
await session.commit()
|
||||
return node.id
|
||||
|
||||
|
||||
async def _insert_memory(character_id: str, content: str) -> str:
|
||||
"""直插生效明忆(user_explicit / explicit / active)。"""
|
||||
memory = Memory(
|
||||
character_id=character_id,
|
||||
tier="explicit",
|
||||
type="episodic",
|
||||
status="active",
|
||||
source="user_explicit",
|
||||
content=content,
|
||||
)
|
||||
async with AsyncSessionLocal() as session:
|
||||
session.add(memory)
|
||||
await session.commit()
|
||||
return memory.id
|
||||
|
||||
|
||||
async def _active_character(client: AsyncClient) -> dict:
|
||||
resp = await client.get("/api/v1/characters/active")
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
# ── ① 缘契 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_covenant_multidim_and_no_single_affinity(client: AsyncClient) -> None:
|
||||
character = await _active_character(client)
|
||||
# 单一好感度被拒
|
||||
bad = await client.post(
|
||||
"/api/v1/covenants",
|
||||
json={
|
||||
"character_id": character["id"],
|
||||
"dimensions": {"affinity": 88},
|
||||
},
|
||||
)
|
||||
assert bad.status_code == 422 or "好感度" in bad.text
|
||||
|
||||
# 多维并存 + 主称谓
|
||||
good = await client.post(
|
||||
"/api/v1/covenants",
|
||||
json={
|
||||
"character_id": character["id"],
|
||||
"dimensions": {"trust": "high", "mentorship": "active", "companionship": "warm"},
|
||||
"primary_title": "亦师亦友",
|
||||
"boundaries": {"stop_words": ["停下"]},
|
||||
},
|
||||
)
|
||||
assert good.status_code == 201, good.text
|
||||
cov = good.json()
|
||||
assert set(cov["dimensions"]) == {"trust", "mentorship", "companionship"}
|
||||
|
||||
# 问心(人物自陈候选,不动当前版本)
|
||||
wenxin = await client.post(
|
||||
f"/api/v1/covenants/{cov['id']}/wenxin",
|
||||
json={"candidates": {"trust": "deepening"}, "note": "近期相处更坦诚"},
|
||||
)
|
||||
assert wenxin.status_code == 200
|
||||
|
||||
# 铭契(版本 +1)
|
||||
sealed = await client.post(
|
||||
f"/api/v1/covenants/{cov['id']}/seal",
|
||||
json={"dimensions": {"trust": "deepening", "mentorship": "active", "companionship": "warm"}},
|
||||
)
|
||||
assert sealed.status_code == 200
|
||||
assert sealed.json()["status"] == "sealed"
|
||||
assert sealed.json()["version"] == 1
|
||||
|
||||
events = await client.get(f"/api/v1/covenants/{cov['id']}/events")
|
||||
types = [e["type"] for e in events.json()]
|
||||
assert types == ["propose", "wenxin", "seal"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_covenant_withdraw_effective_immediately(client: AsyncClient) -> None:
|
||||
character = await _active_character(client)
|
||||
created = await client.post(
|
||||
"/api/v1/covenants",
|
||||
json={"character_id": character["id"], "dimensions": {"companionship": "warm"}},
|
||||
)
|
||||
cov_id = created.json()["id"]
|
||||
|
||||
withdrawn = await client.post(f"/api/v1/covenants/{cov_id}/withdraw", json={"reason": "用户决定"})
|
||||
assert withdrawn.status_code == 200
|
||||
assert withdrawn.json()["effective"] == "immediate"
|
||||
|
||||
# 读取立即不可见;再操作被拒
|
||||
active = await client.get(f"/api/v1/covenants/by-character/{character['id']}")
|
||||
assert active.json() is None or active.json().get("id") != cov_id
|
||||
seal_after = await client.post(f"/api/v1/covenants/{cov_id}/seal", json={"dimensions": {"x": "y"}})
|
||||
assert seal_after.status_code == 422 or seal_after.status_code == 410
|
||||
|
||||
# 事件历史仍可查证(不可抹除)
|
||||
events = await client.get(f"/api/v1/covenants/{cov_id}/events")
|
||||
assert "withdraw" in [e["type"] for e in events.json()]
|
||||
|
||||
# 边界变更立即生效
|
||||
created2 = await client.post(
|
||||
"/api/v1/covenants",
|
||||
json={"character_id": character["id"], "dimensions": {"mentorship": "active"}},
|
||||
)
|
||||
cov2 = created2.json()["id"]
|
||||
patched = await client.patch(
|
||||
f"/api/v1/covenants/{cov2}/boundaries",
|
||||
json={"boundaries": {"topics": ["禁谈工作"]}},
|
||||
)
|
||||
assert patched.status_code == 200
|
||||
assert patched.json()["boundaries"]["topics"] == ["禁谈工作"]
|
||||
|
||||
|
||||
# ── ② 铸魂 / 分灵 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_soul_forging_requires_confirmation(client: AsyncClient) -> None:
|
||||
character = await _active_character(client)
|
||||
char_id = character["id"]
|
||||
|
||||
new_anchor = {"core": "经历重塑后的身份锚点", "taboos": ["不可说谎"]}
|
||||
proposed = await client.post(
|
||||
f"/api/v1/characters/{char_id}/soul/forgings",
|
||||
json={"anchor_changes": {"identity_baseline_json": new_anchor}, "reason": "重大价值重塑"},
|
||||
)
|
||||
assert proposed.status_code == 201, proposed.text
|
||||
forging_id = proposed.json()["id"]
|
||||
|
||||
# 确认前锚点不变
|
||||
async with AsyncSessionLocal() as session:
|
||||
row = await session.get(Character, char_id)
|
||||
import json as _json
|
||||
|
||||
assert _json.loads(row.identity_baseline_json or "{}").get("core") != "经历重塑后的身份锚点"
|
||||
|
||||
confirmed = await client.post(f"/api/v1/characters/{char_id}/soul/forgings/{forging_id}/confirm")
|
||||
assert confirmed.status_code == 200, confirmed.text
|
||||
body = confirmed.json()
|
||||
assert body["status"] == "confirmed"
|
||||
# 魂籍版本链 +1
|
||||
async with AsyncSessionLocal() as session:
|
||||
versions = [
|
||||
v
|
||||
for v in (
|
||||
await session.execute(
|
||||
select(IdentityRecord.version).where(IdentityRecord.character_id == char_id)
|
||||
)
|
||||
).scalars()
|
||||
]
|
||||
assert body["identity_record_version"] == max(versions)
|
||||
row = await session.get(Character, char_id)
|
||||
import json as _json
|
||||
|
||||
assert _json.loads(row.identity_baseline_json)["core"] == "经历重塑后的身份锚点"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_split_soul_preserves_lineage_not_identity(client: AsyncClient) -> None:
|
||||
character = await _active_character(client)
|
||||
char_id = character["id"]
|
||||
|
||||
split = await client.post(
|
||||
f"/api/v1/characters/{char_id}/soul/split",
|
||||
json={"reason": "形仪持续形成独立人格", "initial_anchor": {"core": "分灵新核"}},
|
||||
)
|
||||
assert split.status_code == 201, split.text
|
||||
body = split.json()
|
||||
new_id = body["new_character_id"]
|
||||
assert new_id != char_id
|
||||
# 谱系保留源人物
|
||||
assert body["lineage"][-1]["split_from"] == char_id
|
||||
# 新人物魂籍从 v1 起(不继承原身份)
|
||||
assert body["identity_record_version"] == 1
|
||||
|
||||
lineage = await client.get(f"/api/v1/characters/{new_id}/soul/lineage")
|
||||
assert lineage.status_code == 200
|
||||
assert lineage.json()[-1]["split_from"] == char_id
|
||||
|
||||
|
||||
# ── ③ 境界正史不进真界本相 ──────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_world_canonization_does_not_touch_reality(client: AsyncClient) -> None:
|
||||
owner = await _owner(client)
|
||||
character = await _active_character(client)
|
||||
|
||||
# 建境界
|
||||
world = await client.post(
|
||||
"/api/v1/worlds", json={"name": "云荒", "description": "东方幻想世界", "rules": {"era": "上古"}}
|
||||
)
|
||||
assert world.status_code == 201, world.text
|
||||
world_id = world.json()["id"]
|
||||
|
||||
# 实体
|
||||
entity = await client.post(
|
||||
f"/api/v1/worlds/{world_id}/entities",
|
||||
json={"kind": "location", "name": "镜湖", "description": "世界之眼"},
|
||||
)
|
||||
assert entity.status_code == 201
|
||||
|
||||
# 建树并直插固化节点(绕过生成流),归真到境界
|
||||
tree = await client.post("/api/v1/trees", json={"character_id": character["id"], "title": "云荒纪事"})
|
||||
tree_id = tree.json()["id"]
|
||||
node_id = await _insert_solidified_node(tree_id, character["id"], "湖面浮起旧镜……")
|
||||
canon = await client.post(
|
||||
f"/api/v1/trees/{tree_id}/canonize",
|
||||
json={"endpoint_node_id": node_id, "canonical_scope": "persistent_world", "world_id": world_id},
|
||||
)
|
||||
assert canon.status_code in (200, 201), canon.text
|
||||
|
||||
# 境界正史推进
|
||||
chronicle = await client.get(f"/api/v1/worlds/{world_id}/chronicle")
|
||||
assert chronicle.status_code == 200
|
||||
assert len(chronicle.json()["entries"]) == 1
|
||||
|
||||
# 真界本相未被触碰:无真界记忆候选(提取不触发)
|
||||
async with AsyncSessionLocal() as session:
|
||||
reality_candidates = [
|
||||
m
|
||||
for m in (
|
||||
await session.execute(
|
||||
select(Memory).where(Memory.character_id == character["id"])
|
||||
)
|
||||
).scalars()
|
||||
if m.realm == "reality"
|
||||
]
|
||||
assert reality_candidates == [], "境界归真不得产生真界记忆候选"
|
||||
# 实录带 scope 维度(境界实录,非真界实录)
|
||||
factuals = [
|
||||
f
|
||||
for f in (
|
||||
await session.execute(
|
||||
select(FactualRecord).where(FactualRecord.character_id == character["id"])
|
||||
)
|
||||
).scalars()
|
||||
]
|
||||
assert factuals and all(f.scope == "persistent_world" for f in factuals)
|
||||
|
||||
|
||||
# ── ④ 引文→记忆正影后继提示 ──────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_citation_reports_memory_successor(client: AsyncClient) -> None:
|
||||
character = await _active_character(client)
|
||||
owner = await _owner(client)
|
||||
|
||||
# 直插明忆(fact,user_explicit)→ 正影出新版本
|
||||
memory_id = await _insert_memory(character["id"], "初版记忆:曾在雨天同行")
|
||||
|
||||
corrected = await client.post(
|
||||
f"/api/v1/memories/{memory_id}/correct", json={"new_content": "正影后:雪天同行"}
|
||||
)
|
||||
assert corrected.status_code == 200, corrected.text
|
||||
new_id = corrected.json()["id"]
|
||||
assert new_id != memory_id
|
||||
|
||||
# 建树直插节点 + 指向旧记忆的引文
|
||||
tree = await client.post("/api/v1/trees", json={"character_id": character["id"], "title": "引文树"})
|
||||
tree_id = tree.json()["id"]
|
||||
node_id = await _insert_solidified_node(tree_id, character["id"], "关于那次同行")
|
||||
citation = await client.post(
|
||||
f"/api/v1/nodes/{node_id}/citations",
|
||||
json={
|
||||
"target_type": "memory",
|
||||
"locator": {"memory_id": memory_id},
|
||||
"snapshot": "初版记忆快照",
|
||||
"source_id": memory_id,
|
||||
"source_version": 1,
|
||||
},
|
||||
)
|
||||
assert citation.status_code == 201, citation.text
|
||||
|
||||
# 列出引文:被引记忆已 superseded → 提示后继版本,不静默替换
|
||||
listed = await client.get(f"/api/v1/nodes/{node_id}/citations")
|
||||
entries = listed.json()
|
||||
target = next(c for c in entries if c["source_id"] == memory_id)
|
||||
assert target.get("memory_superseded") is True
|
||||
assert target.get("successor_memory_id") == new_id
|
||||
Reference in new issue
Block a user