diff --git a/backend/alembic/versions/007_缘契域_契约与事件_铸魂分灵_河图境界与实体_实录作用域_.py b/backend/alembic/versions/007_缘契域_契约与事件_铸魂分灵_河图境界与实体_实录作用域_.py new file mode 100644 index 0000000..153b000 --- /dev/null +++ b/backend/alembic/versions/007_缘契域_契约与事件_铸魂分灵_河图境界与实体_实录作用域_.py @@ -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') diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 8fd3274..2e2eb9f 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -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) diff --git a/backend/app/api/v1/endpoints/admin.py b/backend/app/api/v1/endpoints/admin.py index b237c29..94b0229 100644 --- a/backend/app/api/v1/endpoints/admin.py +++ b/backend/app/api/v1/endpoints/admin.py @@ -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 洛书枢 · 计划 —— diff --git a/backend/app/api/v1/endpoints/drive.py b/backend/app/api/v1/endpoints/drive.py deleted file mode 100644 index 0e6b69f..0000000 --- a/backend/app/api/v1/endpoints/drive.py +++ /dev/null @@ -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) diff --git a/backend/app/api/v1/endpoints/hetu.py b/backend/app/api/v1/endpoints/hetu.py new file mode 100644 index 0000000..75e571e --- /dev/null +++ b/backend/app/api/v1/endpoints/hetu.py @@ -0,0 +1,5 @@ +"""河图世界路由(域实现见 app/domains/hetu/api.py)。""" + +from app.domains.hetu.api import world_router + +__all__ = ["world_router"] diff --git a/backend/app/api/v1/endpoints/personality.py b/backend/app/api/v1/endpoints/personality.py deleted file mode 100644 index f599de0..0000000 --- a/backend/app/api/v1/endpoints/personality.py +++ /dev/null @@ -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 - ) diff --git a/backend/app/api/v1/endpoints/soul.py b/backend/app/api/v1/endpoints/soul.py new file mode 100644 index 0000000..86d690b --- /dev/null +++ b/backend/app/api/v1/endpoints/soul.py @@ -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) diff --git a/backend/app/api/v1/endpoints/world.py b/backend/app/api/v1/endpoints/world.py deleted file mode 100644 index 1ad37b0..0000000 --- a/backend/app/api/v1/endpoints/world.py +++ /dev/null @@ -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) diff --git a/backend/app/api/v1/endpoints/yuanqi.py b/backend/app/api/v1/endpoints/yuanqi.py new file mode 100644 index 0000000..a5f652d --- /dev/null +++ b/backend/app/api/v1/endpoints/yuanqi.py @@ -0,0 +1,5 @@ +"""缘契路由(域实现见 app/domains/yuanqi/api.py)。""" + +from app.domains.yuanqi.api import cov_router + +__all__ = ["cov_router"] diff --git a/backend/app/db/governance_seed.py b/backend/app/db/governance_seed.py index 9141568..2699f2f 100644 --- a/backend/app/db/governance_seed.py +++ b/backend/app/db/governance_seed.py @@ -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: diff --git a/backend/app/domains/hetu/__init__.py b/backend/app/domains/hetu/__init__.py new file mode 100644 index 0000000..c0ce6bb --- /dev/null +++ b/backend/app/domains/hetu/__init__.py @@ -0,0 +1,5 @@ +"""河图世界域:境界(persistent_worlds)、世界实体与关系、境界正史(M6)。""" + +from app.domains.hetu import models, service # noqa: F401 + +__all__ = ["models", "service"] diff --git a/backend/app/domains/hetu/api.py b/backend/app/domains/hetu/api.py new file mode 100644 index 0000000..6bdac1d --- /dev/null +++ b/backend/app/domains/hetu/api.py @@ -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) diff --git a/backend/app/domains/hetu/models.py b/backend/app/domains/hetu/models.py new file mode 100644 index 0000000..57c1b76 --- /dev/null +++ b/backend/app/domains/hetu/models.py @@ -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) diff --git a/backend/app/domains/hetu/service.py b/backend/app/domains/hetu/service.py new file mode 100644 index 0000000..f2ecb1d --- /dev/null +++ b/backend/app/domains/hetu/service.py @@ -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 "{}"), + } diff --git a/backend/app/domains/jingsheng/soul.py b/backend/app/domains/jingsheng/soul.py new file mode 100644 index 0000000..512490d --- /dev/null +++ b/backend/app/domains/jingsheng/soul.py @@ -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, + } diff --git a/backend/app/domains/mirror_tree/service.py b/backend/app/domains/mirror_tree/service.py index 823072b..201b495 100644 --- a/backend/app/domains/mirror_tree/service.py +++ b/backend/app/domains/mirror_tree/service.py @@ -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 # ── 移栽 ────────────────────────────────────────────── diff --git a/backend/app/domains/xirang/service.py b/backend/app/domains/xirang/service.py index 37b2669..23d0721 100644 --- a/backend/app/domains/xirang/service.py +++ b/backend/app/domains/xirang/service.py @@ -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 diff --git a/backend/app/domains/yuanqi/__init__.py b/backend/app/domains/yuanqi/__init__.py new file mode 100644 index 0000000..be374d8 --- /dev/null +++ b/backend/app/domains/yuanqi/__init__.py @@ -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"] diff --git a/backend/app/domains/yuanqi/api.py b/backend/app/domains/yuanqi/api.py new file mode 100644 index 0000000..c9e000c --- /dev/null +++ b/backend/app/domains/yuanqi/api.py @@ -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) diff --git a/backend/app/domains/yuanqi/models.py b/backend/app/domains/yuanqi/models.py new file mode 100644 index 0000000..6dbb296 --- /dev/null +++ b/backend/app/domains/yuanqi/models.py @@ -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) diff --git a/backend/app/domains/yuanqi/service.py b/backend/app/domains/yuanqi/service.py new file mode 100644 index 0000000..325c8ca --- /dev/null +++ b/backend/app/domains/yuanqi/service.py @@ -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, + } diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 96f5688..45cddd1 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", + + ] diff --git a/backend/app/models/character.py b/backend/app/models/character.py index 4ec370d..c7d659b 100644 --- a/backend/app/models/character.py +++ b/backend/app/models/character.py @@ -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) diff --git a/backend/app/models/drive.py b/backend/app/models/drive.py deleted file mode 100644 index dbaf9dc..0000000 --- a/backend/app/models/drive.py +++ /dev/null @@ -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) diff --git a/backend/app/models/evolution.py b/backend/app/models/evolution.py deleted file mode 100644 index cdfdec1..0000000 --- a/backend/app/models/evolution.py +++ /dev/null @@ -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 - ) diff --git a/backend/app/models/governance.py b/backend/app/models/governance.py index e0c9a67..8b2b884 100644 --- a/backend/app/models/governance.py +++ b/backend/app/models/governance.py @@ -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 关联太一枢目标,可空。""" diff --git a/backend/app/models/jingsheng.py b/backend/app/models/jingsheng.py index b85a94f..7a4d224 100644 --- a/backend/app/models/jingsheng.py +++ b/backend/app/models/jingsheng.py @@ -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)) diff --git a/backend/app/models/personality.py b/backend/app/models/personality.py deleted file mode 100644 index a73dd96..0000000 --- a/backend/app/models/personality.py +++ /dev/null @@ -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) diff --git a/backend/app/models/world.py b/backend/app/models/world.py deleted file mode 100644 index d3bc1f9..0000000 --- a/backend/app/models/world.py +++ /dev/null @@ -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 - ) diff --git a/backend/app/models/xirang.py b/backend/app/models/xirang.py index 2352f77..6e8cf48 100644 --- a/backend/app/models/xirang.py +++ b/backend/app/models/xirang.py @@ -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) diff --git a/backend/app/modules/drive/__init__.py b/backend/app/modules/drive/__init__.py deleted file mode 100644 index de52120..0000000 --- a/backend/app/modules/drive/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""大周天内驱力领域纯逻辑(造消息 / 解析好奇问题与次生目标草稿,禁止 IO)。""" diff --git a/backend/app/modules/drive/extract.py b/backend/app/modules/drive/extract.py deleted file mode 100644 index 05e1281..0000000 --- a/backend/app/modules/drive/extract.py +++ /dev/null @@ -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)) diff --git a/backend/app/modules/drive/prompts.py b/backend/app/modules/drive/prompts.py deleted file mode 100644 index bcbc63e..0000000 --- a/backend/app/modules/drive/prompts.py +++ /dev/null @@ -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) diff --git a/backend/app/modules/evolution/__init__.py b/backend/app/modules/evolution/__init__.py deleted file mode 100644 index 4a81af2..0000000 --- a/backend/app/modules/evolution/__init__.py +++ /dev/null @@ -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", -] diff --git a/backend/app/modules/evolution/policies.py b/backend/app/modules/evolution/policies.py deleted file mode 100644 index 7437de8..0000000 --- a/backend/app/modules/evolution/policies.py +++ /dev/null @@ -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 diff --git a/backend/app/modules/personality/__init__.py b/backend/app/modules/personality/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/modules/personality/emotion.py b/backend/app/modules/personality/emotion.py deleted file mode 100644 index d6846b0..0000000 --- a/backend/app/modules/personality/emotion.py +++ /dev/null @@ -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}%)" diff --git a/backend/app/repositories/drive.py b/backend/app/repositories/drive.py deleted file mode 100644 index 3006c24..0000000 --- a/backend/app/repositories/drive.py +++ /dev/null @@ -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()) diff --git a/backend/app/repositories/evolution.py b/backend/app/repositories/evolution.py deleted file mode 100644 index b99e646..0000000 --- a/backend/app/repositories/evolution.py +++ /dev/null @@ -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) diff --git a/backend/app/repositories/governance.py b/backend/app/repositories/governance.py index 7e8a0ef..e749a2f 100644 --- a/backend/app/repositories/governance.py +++ b/backend/app/repositories/governance.py @@ -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())) diff --git a/backend/app/repositories/personality.py b/backend/app/repositories/personality.py deleted file mode 100644 index 41b1dbf..0000000 --- a/backend/app/repositories/personality.py +++ /dev/null @@ -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 diff --git a/backend/app/repositories/world.py b/backend/app/repositories/world.py deleted file mode 100644 index 822fb96..0000000 --- a/backend/app/repositories/world.py +++ /dev/null @@ -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()) diff --git a/backend/app/schemas/character.py b/backend/app/schemas/character.py index a4ff5b8..f3cc0ea 100644 --- a/backend/app/schemas/character.py +++ b/backend/app/schemas/character.py @@ -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。") diff --git a/backend/app/schemas/drive.py b/backend/app/schemas/drive.py deleted file mode 100644 index 6ec790e..0000000 --- a/backend/app/schemas/drive.py +++ /dev/null @@ -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 diff --git a/backend/app/schemas/personality.py b/backend/app/schemas/personality.py deleted file mode 100644 index 682f448..0000000 --- a/backend/app/schemas/personality.py +++ /dev/null @@ -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 字符串。") diff --git a/backend/app/services/admin.py b/backend/app/services/admin.py index f4da61c..8e8f166 100644 --- a/backend/app/services/admin.py +++ b/backend/app/services/admin.py @@ -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()) diff --git a/backend/app/services/characters.py b/backend/app/services/characters.py index f2096f8..2665b18 100644 --- a/backend/app/services/characters.py +++ b/backend/app/services/characters.py @@ -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) diff --git a/backend/app/services/drive.py b/backend/app/services/drive.py deleted file mode 100644 index f551fa8..0000000 --- a/backend/app/services/drive.py +++ /dev/null @@ -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, - ) diff --git a/backend/app/services/governance.py b/backend/app/services/governance.py index 3853f5d..999e537 100644 --- a/backend/app/services/governance.py +++ b/backend/app/services/governance.py @@ -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() # —— 洛书枢 · 计划 —— diff --git a/backend/app/services/personality.py b/backend/app/services/personality.py deleted file mode 100644 index 70a4e1d..0000000 --- a/backend/app/services/personality.py +++ /dev/null @@ -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) diff --git a/backend/app/services/serializers.py b/backend/app/services/serializers.py index de80a65..d541817 100644 --- a/backend/app/services/serializers.py +++ b/backend/app/services/serializers.py @@ -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: diff --git a/backend/app/services/world.py b/backend/app/services/world.py deleted file mode 100644 index cf9845f..0000000 --- a/backend/app/services/world.py +++ /dev/null @@ -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, - ) diff --git a/backend/tests/api/test_characters.py b/backend/tests/api/test_characters.py index ef53ac8..7b76e8a 100644 --- a/backend/tests/api/test_characters.py +++ b/backend/tests/api/test_characters.py @@ -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 diff --git a/backend/tests/api/test_drive_endpoints.py b/backend/tests/api/test_drive_endpoints.py deleted file mode 100644 index 602a1df..0000000 --- a/backend/tests/api/test_drive_endpoints.py +++ /dev/null @@ -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" diff --git a/backend/tests/api/test_personality_endpoints.py b/backend/tests/api/test_personality_endpoints.py deleted file mode 100644 index 6005043..0000000 --- a/backend/tests/api/test_personality_endpoints.py +++ /dev/null @@ -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 diff --git a/backend/tests/api/test_world.py b/backend/tests/api/test_world.py deleted file mode 100644 index c4c6b3e..0000000 --- a/backend/tests/api/test_world.py +++ /dev/null @@ -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 diff --git a/backend/tests/modules/big_cycle/test_extract.py b/backend/tests/modules/big_cycle/test_extract.py deleted file mode 100644 index 13791d6..0000000 --- a/backend/tests/modules/big_cycle/test_extract.py +++ /dev/null @@ -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) diff --git a/backend/tests/modules/big_cycle/test_prompts.py b/backend/tests/modules/big_cycle/test_prompts.py deleted file mode 100644 index 05b8b00..0000000 --- a/backend/tests/modules/big_cycle/test_prompts.py +++ /dev/null @@ -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 diff --git a/backend/tests/modules/drive/test_drive_extract.py b/backend/tests/modules/drive/test_drive_extract.py deleted file mode 100644 index f4c13c9..0000000 --- a/backend/tests/modules/drive/test_drive_extract.py +++ /dev/null @@ -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 # 只有完整的保留 diff --git a/backend/tests/modules/drive/test_drive_prompts.py b/backend/tests/modules/drive/test_drive_prompts.py deleted file mode 100644 index 344d04f..0000000 --- a/backend/tests/modules/drive/test_drive_prompts.py +++ /dev/null @@ -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 diff --git a/backend/tests/modules/personality/test_emotion.py b/backend/tests/modules/personality/test_emotion.py deleted file mode 100644 index bd52448..0000000 --- a/backend/tests/modules/personality/test_emotion.py +++ /dev/null @@ -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", - } diff --git a/backend/tests/repositories/test_drive_repo.py b/backend/tests/repositories/test_drive_repo.py deleted file mode 100644 index b605dc6..0000000 --- a/backend/tests/repositories/test_drive_repo.py +++ /dev/null @@ -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 diff --git a/backend/tests/repositories/test_evolution_repo.py b/backend/tests/repositories/test_evolution_repo.py deleted file mode 100644 index acae781..0000000 --- a/backend/tests/repositories/test_evolution_repo.py +++ /dev/null @@ -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 diff --git a/backend/tests/services/test_drive_service.py b/backend/tests/services/test_drive_service.py deleted file mode 100644 index 67a105b..0000000 --- a/backend/tests/services/test_drive_service.py +++ /dev/null @@ -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") diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 8351f1b..16e1c07 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -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 路径。""" diff --git a/backend/tests/test_character_templates.py b/backend/tests/test_character_templates.py index e1729a5..c799592 100644 --- a/backend/tests/test_character_templates.py +++ b/backend/tests/test_character_templates.py @@ -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 diff --git a/backend/tests/test_m6_domains.py b/backend/tests/test_m6_domains.py new file mode 100644 index 0000000..dbe1248 --- /dev/null +++ b/backend/tests/test_m6_domains.py @@ -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 diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md index ea1655c..b075049 100644 --- a/docs/architecture/data-model.md +++ b/docs/architecture/data-model.md @@ -104,14 +104,14 @@ erDiagram | 表 | 要点 | | --- | --- | -| `covenants` | 当前生效版本:多维关系维度(`dimensions_json`,不以单一好感度表达)、边界、成人内容授契(按会话临时开启)、版本号 | +| `covenants`(M6 已落地) | 当前生效版本:多维关系维度(`dimensions_json`,服务层拒绝单一好感度键)、边界、成人内容授契(按会话临时开启)、版本号 | | `covenant_events` | 不可变历史:提议、问心(人物自陈候选)、铭契确认、撤回与边界变更。即时停止与撤回立即生效并留痕 | ## 5. 三界域 | 表 | 要点 | | --- | --- | -| `persistent_worlds` | 境界:世界背景、规则、实体索引、正史版本 | +| `persistent_worlds`(M6 已落地) | 境界:世界背景、规则、实体索引、正史版本(canon_count 缓存)+ 世谱挂接 lorebook_id | | `world_entities` / `world_relations` | 河图世界认知:实体与关系(图查询走 PG 递归 CTE;Neo4j/NebulaGraph 只在规模需要时作为投影库引入,不是事实源) | | `lorebooks` / `lorebook_entries` | 世谱(ST 世界信息兼容):关键词/次级键/常驻/位置/深度/顺序/概率/时序效果/递归与预算参数;导入导出兼容 ST 格式 | diff --git a/docs/operations/capability-matrix.md b/docs/operations/capability-matrix.md index eb0ac89..d431e11 100644 --- a/docs/operations/capability-matrix.md +++ b/docs/operations/capability-matrix.md @@ -34,6 +34,7 @@ | 乾坤沙箱 | M5 | 局部实现 | 隔离执行/HMAC/超时取消已有;设备安全与双裁决待扩 | | 博闻 MCP 搜索 | M5 | 局部实现 | 多引擎/密钥池/熔断/缓存可用;待登记入百工 | | 百工与司命 | M5 | 已验证 | M5 收口(2026-08-27,本地开发档):能力注册表(capabilities 表 + 四维风险 + 内置种子 echo/datetime/qiankun.shell/web.fetch)、授契/行契(撤销即失效)、请命(六要素 brief + 超时拒绝 HJ-1302)、行动账(tool_calls 观察标证 + audit 留痕)、乾坤 HMAC 执行、MCP→能力派生;验收四测试(未授契被拒留痕/请命超时不执行/禁域不可关闭专项/结果为观察不入忆)全绿;前端百工页四区。沙箱进程隔离 e2e 预存环境失败与本域无关 | +| 缘契/道体/河图 | M6 | 已验证 | M6 收口(2026-08-27,本地开发档):缘契提议—问心—铭契—撤回全链(多维无单一好感度、撤回即生效、事件史不可变);铸魂确认流(锚点确认前不变 + 魂籍版本链)与分灵谱系;河图境界(persistent_worlds + 实体关系 + 境界正史视图 + 世谱挂接);归真三界分流(境界/幻境实录 scope 隔离,真界本相零污染);引文→记忆正影后继提示;前端缘契面板与世界页。验收六测试全绿;旧人格/驱动/演化/世界模块与页面全量退役 | | 天目 / 元炁 | M4/M7 | 局部实现 | 服务可用;元炁待接入神识 Endpoint,天目待接通感 | | 镜树前端 / 记忆治理 / 百工页 | M2+ | 局部实现 | 镜树页与灵胚页已上线(M2c,2026-08-21):路径阅读流/候答切换/采用归真/局部树图/SSE 芽体/导入卡;type-check + vitest 210 passed;记忆治理页随 M3、百工页随 M5 | | 导入(角色卡 V2/V3 → 灵胚) | M2 | 局部实现 | M2b 已验证(2026-08-21):POST /characters/import-card 产出可审阅灵胚(identity_baseline.import_card 暂存 character_book),启镜时物化世谱书+条目(dict/list 双形态兼容);导出方向保持人物→ST V3 快照 | diff --git a/docs/operations/rewrite-plan.md b/docs/operations/rewrite-plan.md index d46861e..5699eaa 100644 --- a/docs/operations/rewrite-plan.md +++ b/docs/operations/rewrite-plan.md @@ -130,10 +130,12 @@ - 验收(tests/test_baigong_siming.py 四用例钉死):① 未授契调用 403 HJ-1303 且 audit_events/tool_calls 双留痕;② 请命超时(expires_at 拨过)approve → 410 HJ-1302、无成功执行;③ 禁域专项(恶意 L0 标注 + 篡改领域对象仍拒 HJ-1301,代码级三形态断言);④ 调用后 memories 表零新增、tool_calls.evidence 带 observation_not_fact 标证。 - 证据:后端 454 passed(2 个预存环境失败不变);前端 type-check + 220 passed(百工组件 4 用例 + api 6 用例);四套件(backend/shenshi/bff/frontend)全绿。 -### M6 缘契、人格与世界 +### M6 缘契、人格与世界 — ✅ 已完成(2026-08-27) -- 交付:缘契约/问心/铭契/撤回版本链;道体锚点+调节层+铸魂/分灵;河图世界域(persistent_worlds/entities/世谱接入);正影流程打通记忆与镜树;**删除旧 personality/drive/evolution/world/reflection 模块与对应页面**。 -- 验收:多维关系无单一好感度;边界撤回立即生效;铸魂走确认流而调节层演进有证据;分灵保留谱系;境界正史不进真界本相。 +- 交付:缘契域 `domains/yuanqi`(covenants 当前版 + covenant_events 不可变历史;提议—问心—铭契—撤回全链,边界变更立即生效,多维关系并存且**拒绝单一好感度键**);道体铸魂与分灵 `jingsheng/soul.py`(soul_forgings 确认流——锚点变更确认前不生效,确认后签发新魂籍版本;分灵创建新人物继承谱系不继承身份,魂籍从 v1 起);河图域 `domains/hetu`(persistent_worlds 境界 + world_entities/world_relations 重建为境界作用域 + 世谱挂接 + 境界正史视图);归真按三界分流(reality→息壤提取不变;persistent_world→境界实录+正史推进;simulation→只记试演实录,均不进真界本相,factual_records 增 scope 列);引文→记忆正影后继提示(citations target_type=memory 时 superseded 提示 successor_memory_id,不静默替换);前端人物页缘契面板 + 世界页重建(境界目录/实体/正史概览)。 +- 退役:modules/personality|drive|evolution、models/personality|drive|evolution|world(旧治理版)、services/personality|drive|world、endpoints/personality|drive|world、governance 世界实体治理面、前端 personality/drive/cosmos/wuxing/runs 页与对应组件/api 库/样式;表退役(迁移 007):personality_versions/personality_proposals/curiosity_questions/exploratory_subgoals/evolution_candidates/character_world_entities/world_entities(旧)/world_relations(旧)/characters.current_personality_version_id。 +- 验收(tests/test_m6_domains.py 六用例钉死):多维无单一好感度(affinity 提议被拒)+ 问心铭契版本链 + 事件史不可抹除;撤回立即生效(withdrawn 即不可见、再操作 410、事件可查证)+ 边界变更即时反映;铸魂确认流(确认前锚点不变、确认后锚点更新且魂籍版本=最大版本)+ 分灵谱系(新 id、lineage 含源、魂籍 v1);境界正史不进真界(persistent_world 归真后真界记忆候选=0、实录 scope 隔离);引文正影后继提示。 +- 证据:后端 379 passed(2 个预存环境失败不变);前端 type-check + 199 passed;BFF 4;神识 5 包;迁移 007 净库升级 + ±1 干净。 ### M7 形仪与语音(P1 面) diff --git a/frontend/src/app/(app)/characters/[characterId]/page.tsx b/frontend/src/app/(app)/characters/[characterId]/page.tsx index 4b0abfc..aa5c01d 100644 --- a/frontend/src/app/(app)/characters/[characterId]/page.tsx +++ b/frontend/src/app/(app)/characters/[characterId]/page.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { CharacterIdentityCard } from "@/components/characters/CharacterIdentityCard"; +import { CovenantPanel } from "@/components/characters/CovenantPanel"; import { CharacterLifecycleBar } from "@/components/characters/CharacterLifecycleBar"; import { ModelPolicyEditForm } from "@/components/characters/ModelPolicyEditForm"; import { ProfileEditForm } from "@/components/characters/ProfileEditForm"; @@ -12,6 +13,7 @@ import { CharacterStatusBadge } from "@/components/ui/StatusBadge"; import { serverApi } from "@/lib/api/server-client"; import { describeError } from "@/lib/utils/error"; import { formatDateTime } from "@/lib/utils/format"; +import type { Covenant } from "@/lib/api/covenants"; import type { Character } from "@/types/api"; export const dynamic = "force-dynamic"; @@ -28,12 +30,21 @@ export default async function CharacterDetailPage({ const { characterId } = await params; let character = null; + let covenant: Covenant | null = null; let error: string | null = null; try { character = await serverApi.get(`/characters/${characterId}`); } catch (e) { error = describeError(e); } + // 缘契独立拉取,失败静默(无缘契是合法状态) + try { + covenant = await serverApi.get( + `/covenants/by-character/${characterId}`, + ); + } catch { + covenant = null; + } if (error) { return ( @@ -62,6 +73,10 @@ export default async function CharacterDetailPage({ +
+ +
+
diff --git a/frontend/src/app/(app)/cosmos/page.tsx b/frontend/src/app/(app)/cosmos/page.tsx deleted file mode 100644 index 97ebc92..0000000 --- a/frontend/src/app/(app)/cosmos/page.tsx +++ /dev/null @@ -1,1693 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { PageContainer } from "@/components/layout/PageContainer"; - -/** - * 灵枢页 (/cosmos) — 心智解剖图 - * - * 以「太极 → 四象 → 八卦」的生成链呈现数字生命的心智结构。 - * 太极/阴阳 ← 设计理念 §4.2(自主与约束 · 阴阳平衡度) - * 四象 ← 设计理念 §7(青龙生发 / 朱雀表达 / 白虎裁断 / 玄武藏养) - * 八卦 ← 设计理念 §7 + agent-society-design §3(天工阁八原型) - */ -export default function CosmosPage() { - useEffect(() => { - document.title = "灵枢"; - }, []); - - return ( - -
- {/* ════════════════════════════════════════════════════════ - 太极 · 阴阳平衡(签名 hero) - 阳鱼 = patina(生发:太一枢/大周天/内驱力) - 阴鱼 = cinnabar(约束:司命/安全/审计) - ════════════════════════════════════════════════════════ */} -
-
- ⟡ - 太极 - - 阴阳 · 自主与约束的动态制衡 · 系统生命体征 - -
- -
- {/* 太极主区:阳鱼侧 + 太极盘 + 阴鱼侧 */} -
- {/* 阳 · 生发 */} -
- - 阳 · 生发 - - - yang · generation - -
- {["太一枢", "大周天", "内驱力"].map((label) => ( - - {label} - - ))} -
- - 主动生发,追求可能性。 -
- 不断生成意图、候选与行动方案。 -
-
- - {/* 太极盘 */} -
- - {/* 圆环 */} - - - - {/* 阳鱼基体(铜绿 patina) */} - - - {/* 阴鱼(朱砂 cinnabar S 曲线) */} - - - {/* 阳眼(铜绿点,在阴鱼头部下方) */} - - - {/* 阴眼(朱砂点,在阳鱼头部上方) */} - - -
- - {/* 阴 · 约束 */} -
- - 阴 · 约束 - - - yin · constraint - -
- {["司命枢", "安全治理", "审计朱批"].map((label) => ( - - {label} - - ))} -
- - 设置边界,控制风险。 -
- 对候选过滤、约束与驳回。 -
-
-
- - {/* 阴阳平衡度 readout */} -
-
- 阴阳平衡度 ={" "} - - 司命通过率 - {" "} - / ( - - 候选生成率 - {" "} - + ε) -
- - 当前通过率 0.58 · - 生成率 0.62 - -
- -
-
- - - - -
-
- - 阴盛 · 僵化 - - - 动态平衡 - - - 阳亢 · 越界 - -
-
- - - 动态平衡 - - 0.94 - - -
-
-
- - {/* ════════════════════════════════════════════════════════ - 四象 · 认知循环(罗盘) - 南上北下东左西右(同八卦取向) - 顺时针 = 认知流:生发→表达→裁断→藏养 - ════════════════════════════════════════════════════════ */} -
-
- 四象 -
- -
-
- {/* 罗盘 SVG */} - - - {/* 四象限卡片 */} -
-
- 朱雀 - - 南 · 火 - -
- - 表达 · expression - - - 对话、解释、输出、沟通,成象于外。 - - - 四爻行事 · 五爻成象 - -
- -
-
- 白虎 - - 西 · 金 - -
- - 裁断 · judgment - - - 判断、约束、风险、安全,金断决疑。 - - - 三爻定策 · 司命 - -
- -
-
- 玄武 - - 北 · 水 - -
- - 藏养 · consolidation - - - 记忆、沉淀、恢复、长期状态,归于深藏。 - - - 上爻返照 · 息壤 - -
- -
-
- 青龙 - - 东 · 木 - -
- - 生发 · genesis - - - 感知、发现、提出可能性,木气初萌。 - - - 初爻起意 · 二爻察势 - -
-
-
-
- - {/* ════════════════════════════════════════════════════════ - 八卦阵 · 天工阁八原型(签名 hero) - 后天八卦取向(南=离/火 上,北=坎/水 下,东=震/雷 左,西=兑/泽 右) - 八卦按四组分色:认知方向=金,行动表达=朱,承载稳定=铜,边界风险=石 - ════════════════════════════════════════════════════════ */} -
-
- 八卦阵 -
- -
-
- - {/* 八边形外框 */} - - - - {/* 四组弧形(分组色提示) */} - - - - - - - - - - {/* ── 八卦节点 ── */} - - {/* 离 ☲ 南·火 宣明者(行动表达/朱)(230,95) */} - - - - - - - - - - 离 - - - - 宣明者 · 火 - - - {/* 坤 ☷ 西南·地 藏守者(承载稳定/铜)(325,135) */} - - - - - - - - - - - 坤 - - - - 藏守者 · 地 - - - {/* 兑 ☱ 西·泽 应和者(承载稳定/铜)(365,230) */} - - - - - - - - - 兑 - - - - 应和者 · 泽 - - - {/* 乾 ☰ 西北·天 造物者(认知方向/金)(325,325) */} - - - - - - - - 乾 - - - - 造物者 · 天 - - - {/* 坎 ☵ 北·水 司险者(边界风险/石)(230,365) */} - - - - - - - - - 坎 - - - - 司险者 · 水 - - - {/* 艮 ☶ 东北·山 止守者(边界风险/石)(135,325) */} - - - - - - - - - - 艮 - - - - 止守者 · 山 - - - {/* 震 ☳ 东·雷 启行者(行动表达/朱)(95,230) */} - - - - - - - - - - 震 - - - - 启行者 · 雷 - - - {/* 巽 ☴ 东南·风 探微者(认知方向/金)(135,135) */} - - - - - - - - - 巽 - - - - 探微者 · 风 - - - {/* 中心太极 + 灵核 */} - - - - - - - 灵核 - - -
-
- - {/* ═══ 八卦详注 ═══ */} -
- 八卦详注 -
-
- {/* 认知与方向(鎏金) */} -
-
- - 认知与方向 - - cognition · direction -
- - - -
- - {/* 行动与表达(朱砂) */} -
-
- - 行动与表达 - - action · expression -
- - - -
- - {/* 承载与稳定(铜绿) */} -
-
- - 承载与稳定 - - bearing · stability -
- - - -
- - {/* 边界与风险(石色) */} -
-
- - 边界与风险 - - boundary · risk -
- - - -
-
-
- - {/* ════════════════════════════════════════════════════════ - 愿景边界 · 状态注脚 - ════════════════════════════════════════════════════════ */} -
- 愿景边界 - - 四象(认知循环)已作为命名层落地;八卦( - 天工阁 八原型)属 v2 多智能体愿景,数据结构{" "} - agent_roster / agent_role 已在 v1 - 预留,八原型可逐步作为角色模板进入治理配置。九角色为工程分工,八卦为其人格底色,一角色可兼多卦。 - - v2 愿景 · 已预留 -
-
-
- ); -} - -/** - * 八卦单卦注记组件 — 三爻条图 + 卦名 + 角色 + 映射 + 职责 + 行为 - */ -function TrigramDetail({ - color, - name, - bars, - role, - map, - duty, - behave, -}: { - color: string; - name: string; - /** 三爻数组:1=阳爻(整条),0=阴爻(两条) */ - bars: [number, number, number]; - role: string; - map: string; - duty: string; - behave: string; -}) { - const barH = 4; - const barGap = 6; - - return ( -
-
- - {bars.map((bar, i) => { - const y = 3 + i * (barH + barGap); - if (bar === 1) { - return ( - - ); - } - return ( - - - - - ); - })} - - - {name} - -
- -
-
- {role} - {map} -
-
{duty}
-
{behave}
-
-
- ); -} diff --git a/frontend/src/app/(app)/drive/page.tsx b/frontend/src/app/(app)/drive/page.tsx deleted file mode 100644 index 74a5e14..0000000 --- a/frontend/src/app/(app)/drive/page.tsx +++ /dev/null @@ -1,244 +0,0 @@ -import { DriveView } from "@/components/drive/DriveView"; -import { PageContainer } from "@/components/layout/PageContainer"; -import { ErrorState } from "@/components/shared/StateViews"; - -import { serverApi } from "@/lib/api/server-client"; -import { describeError } from "@/lib/utils/error"; -import { GOAL_TYPE_LABEL, GOAL_STATUS_LABEL } from "@/lib/constants/labels"; -import type { - Character, - CuriosityQuestion, - ExploratorySubgoal, - GoalNode, - Paginated, -} from "@/types/api"; - -export const dynamic = "force-dynamic"; - -/** - * 内驱力页 · 大周天生成的好奇问题与探索性次生目标 + 太一目标进度总览。 - * 设计稿 drive-preview.html — 太一目标 + 好奇萤火 + 探索天平 + 阴阳制衡。 - */ -export default async function DrivePage() { - let characterId: string | null = null; - let characterName = ""; - let goals: GoalNode[] = []; - let curiosities: CuriosityQuestion[] = []; - let subgoals: ExploratorySubgoal[] = []; - let counts = { candidate: 0, approved: 0, deferred: 0, rejected: 0 }; - let error: string | null = null; - - try { - const active = await serverApi.get("/characters/active"); - characterId = active.id; - characterName = active.display_name || active.name; - const [goalRes, curios, cand, approved, deferred, rejected] = - await Promise.all([ - serverApi - .get("/admin/goals", { character_id: active.id }) - .catch(() => [] as GoalNode[]), - serverApi.get>( - `/characters/${active.id}/curiosities`, - { page_size: 20 }, - ), - serverApi.get>( - `/characters/${active.id}/subgoals`, - { status: "candidate", page_size: 50 }, - ), - serverApi.get>( - `/characters/${active.id}/subgoals`, - { status: "approved", page_size: 1 }, - ), - serverApi.get>( - `/characters/${active.id}/subgoals`, - { status: "deferred", page_size: 1 }, - ), - serverApi.get>( - `/characters/${active.id}/subgoals`, - { status: "rejected", page_size: 1 }, - ), - ]); - goals = goalRes; - curiosities = curios.items; - subgoals = cand.items; - counts = { - candidate: cand.total, - approved: approved.total, - deferred: deferred.total, - rejected: rejected.total, - }; - } catch (e) { - error = describeError(e); - } - - const totalCurios = curiosities.length; - const totalSubgoals = subgoals.length; - - return ( - - {characterName && ( - - - {characterName.charAt(0)} - - {characterName} - - )} - - 好奇 {totalCurios} · 待探 {totalSubgoals} · 已延{" "} - {counts.deferred} - - - } - maxWidth="max-w-5xl" - > - {error ? ( - - ) : characterId ? ( -
- {/* 太一 · 目标进度 */} - {goals.length > 0 && ( -
-
- ⟡ 太一{" "} - 最高目标 · 内驱力在其下生成次生探索 -
- {goals.map((g) => ( -
-
- - {g.title} - {g.type && ( - - {GOAL_TYPE_LABEL[g.type] ?? g.type} - - )} - - {GOAL_STATUS_LABEL[g.status] ?? g.status} - -
-
-
-
-
- - {Math.round(Math.max(0, Math.min(1, g.progress)) * 100)}% - -
- {g.description && ( -

- {g.description} -

- )} -
- ))} -
- )} - - - - {/* 阴阳制衡(太极图) */} -
- -
- 阴阳制衡 · 内驱力持续生成候选( - 阳 · 生发 - ),司命分级过滤、你按需确认( - 阴 · 约束 - )。候选零产生则僵化(阴盛),悉数被驳则阳亢——生成节奏与治理粒度需相称。 -
- 阴阳平衡度 = 通过率 / (生成率 + ε) · 当前 0.94 · 动态平衡 -
-
-
-
- ) : null} -
- ); -} diff --git a/frontend/src/app/(app)/personality/page.tsx b/frontend/src/app/(app)/personality/page.tsx deleted file mode 100644 index 85e13a3..0000000 --- a/frontend/src/app/(app)/personality/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { PageContainer } from "@/components/layout/PageContainer"; -import { PersonalityWorkspace } from "@/components/personality/PersonalityWorkspace"; -import { EmptyState, ErrorState } from "@/components/shared/StateViews"; - -import { serverApi } from "@/lib/api/server-client"; -import { describeError } from "@/lib/utils/error"; -import type { Paginated, PersonalityVersion } from "@/types/api"; - -export const dynamic = "force-dynamic"; - -/** - * 人格页 · 当前人物的人格版本,字段以提议审核方式留痕写入。 - * 预取当前版本与版本历史,交由 PersonalityWorkspace 组合编辑 / 即时审批 / 时间线。 - */ -export default async function PersonalityPage() { - let personality: PersonalityVersion | null = null; - let versions: PersonalityVersion[] = []; - let error: string | null = null; - try { - personality = await serverApi.get( - "/personality/current", - ); - } catch (e) { - error = describeError(e); - } - // 版本历史独立拉取,失败静默(不影响编辑区) - try { - versions = ( - await serverApi.get>( - "/personality/versions", - { page_size: 50 }, - ) - ).items; - } catch { - /* 版本历史可选 */ - } - - return ( - - {error ? ( - - ) : personality ? ( - - ) : ( - - )} - - ); -} diff --git a/frontend/src/app/(app)/runs/[runId]/page.tsx b/frontend/src/app/(app)/runs/[runId]/page.tsx deleted file mode 100644 index 150009b..0000000 --- a/frontend/src/app/(app)/runs/[runId]/page.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { PageContainer } from "@/components/layout/PageContainer"; -import { RunTraceView } from "@/components/runs/RunTraceView"; -import { ErrorState } from "@/components/shared/StateViews"; -import { ErrorTraceLink } from "@/components/shared/ErrorTraceLink"; - -import { serverApi } from "@/lib/api/server-client"; -import { describeError } from "@/lib/utils/error"; -import type { Run, RunEvent } from "@/types/api"; - -export const dynamic = "force-dynamic"; - -export default async function RunPage({ - params, -}: { - params: Promise<{ runId: string }>; -}) { - const { runId } = await params; - - let run: Run | null = null; - let events: RunEvent[] = []; - let error: string | null = null; - - try { - const [fetchedRun, fetchedEvents] = await Promise.all([ - serverApi.get(`/runs/${runId}`), - serverApi - .get(`/runs/${runId}/events`) - .catch(() => [] as RunEvent[]), - ]); - run = fetchedRun; - events = fetchedEvents; - } catch (e) { - error = describeError(e); - } - - // 取该 run 首个带 trace_id 的事件作为链路入口(同一 run 共享一条链路) - const traceId = events.find((ev) => ev.trace_id)?.trace_id; - - return ( - - {error ? ( - - ) : ( - <> - {traceId && ( -
- 本次链路 - -
- )} - - - )} -
- ); -} diff --git a/frontend/src/app/(app)/runs/page.tsx b/frontend/src/app/(app)/runs/page.tsx deleted file mode 100644 index 244fba6..0000000 --- a/frontend/src/app/(app)/runs/page.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import Link from "next/link"; - -import { PageContainer } from "@/components/layout/PageContainer"; -import { RunStatusBadge } from "@/components/ui/StatusBadge"; -import { EmptyState, ErrorState } from "@/components/shared/StateViews"; -import { serverApi } from "@/lib/api/server-client"; -import { formatDateTime } from "@/lib/utils/format"; -import { describeError } from "@/lib/utils/error"; -import type { Paginated, Run } from "@/types/api"; - -export const dynamic = "force-dynamic"; - -/** 运行记录入口:与 /runs/[runId] 详情页组成完整轨迹路径。 */ -export default async function RunsPage() { - let runs: Paginated | null = null; - let error: string | null = null; - - try { - runs = await serverApi.get>("/runs", { page_size: 50 }); - } catch (cause) { - error = describeError(cause); - } - - return ( - - 共 {runs.total} 条 - - ) - } - maxWidth="max-w-5xl" - > - {error ? ( - - ) : !runs || runs.items.length === 0 ? ( - - ) : ( -
-
    - {runs.items.map((run) => ( -
  • - -
    -

    - {run.goal || run.id} -

    -

    - {[ - run.id, - run.model_hint ? `模型:${run.model_hint}` : null, - `创建:${formatDateTime(run.created_at ?? run.started_at)}`, - ] - .filter(Boolean) - .join(" · ")} -

    -
    - - -
  • - ))} -
-
- )} -
- ); -} diff --git a/frontend/src/app/(app)/world/page.tsx b/frontend/src/app/(app)/world/page.tsx index d485f26..18cd193 100644 --- a/frontend/src/app/(app)/world/page.tsx +++ b/frontend/src/app/(app)/world/page.tsx @@ -1,235 +1,22 @@ -import { PageContainer } from "@/components/layout/PageContainer"; -import { WorldEntityList } from "@/components/admin/WorldEntityList"; -import { WorldRelationManager } from "@/components/world/WorldRelationManager"; -import { WorldRelationGraph } from "@/components/world/WorldRelationGraph"; -import { EmptyState, ErrorState } from "@/components/shared/StateViews"; +import { WorldWorkbench } from "@/components/world/WorldWorkbench"; import { serverApi } from "@/lib/api/server-client"; import { describeError } from "@/lib/utils/error"; -import type { - Character, - Paginated, - WorldEntity, - WorldRelation, -} from "@/types/api"; +import type { PersistentWorld } from "@/lib/api/worlds"; export const dynamic = "force-dynamic"; /** - * 世界页 · 数字生命对世界的认知(河图枢主域视图)。 - * 设计稿 world-preview.html — 人物 pill + section-label + 关系图谱 + 实体名册 + 不确定性 + 世界观共振。 - * 实体 CRUD 走 admin 域(WorldEntityList,保留编辑/删除);关系图谱走独立 /world/relations。 + * 世界页(M6 河图重建):境界列表 + 境界详情(实体/关系/正史)。 + * 境界正史不进真界本相——正史条目只读呈现。 */ export default async function WorldPage() { - let entities: WorldEntity[] = []; - let relations: WorldRelation[] = []; - let character: Character | null = null; - let characterName = ""; + let worlds: PersistentWorld[] = []; let error: string | null = null; - try { - entities = await serverApi.get("/admin/world/entities"); + worlds = await serverApi.get("/worlds"); } catch (e) { error = describeError(e); } - - try { - relations = ( - await serverApi.get>("/world/relations", { - page_size: 50, - }) - ).items; - } catch { - /* 关系可选 */ - } - - try { - character = await serverApi - .get("/characters/active") - .catch(() => null); - if (character) { - characterName = character.display_name || character.name; - } - } catch { - /* 人物信息可选 */ - } - - /* 按 state_type 统计(用于待验断言计数) */ - const hypotheses = entities.filter( - (e) => e.state_type === "hypothesis", - ).length; - - /* 待验断言(静态示例,与设计稿对齐) */ - const uncertainties = [ - { - claim: "云岚 与 青梧 同属旧族旁支,可能存在亲属关系。", - reason: "来源:对话中的姓氏推测 · 证据单一且间接", - confidence: 0.42, - }, - { - claim: "用户当前正在推进的「辰正项目」目标 为时态感知子系统。", - reason: "来源:关键词频率推断 · 缺乏用户明确表述", - confidence: 0.55, - }, - ]; - - return ( - - {characterName && ( - - {characterName.charAt(0)} - {characterName}视角 - - )} - - 实体 {entities.length} · 关系 {relations.length} · - 待验 {hypotheses} - - - } - maxWidth="max-w-5xl" - > - {error ? ( - - ) : entities.length === 0 ? ( - - ) : ( -
- {/* 关系图谱 */} -
-
- ⟡ 关系图谱{" "} - - 河图 · 实体为节点,关系为边,置信度定粗细,假设态虚线待验 - -
- -
- - {/* 实体名册 */} -
-
- ⟡ 实体名册{" "} - 按类型 · 事实性着色 · 推断召回时降权 -
- -
- - {/* 待验断言(不确定性) */} - {uncertainties.length > 0 && ( -
-
- ⟡ 待验断言{" "} - - UncertaintyState · 低置信,需复核方可固化为事实 - -
-
- {uncertainties.map((u, i) => ( -
-
-
{u.claim}
-
{u.reason}
-
-
-
{u.confidence.toFixed(2)}
-
置信
-
- 需验证 -
- ))} -
-
- )} - - {/* 世界观共振 */} -
-
- ⟡ 世界观共振{" "} - - 见地镜 · 每人物独立的世界观立场,可同境可异境 - -
-
-
-
-
-
- {characterName ? characterName.charAt(0) : "?"} -
- {characterName || "人物"} - 同境 · 主脉 -
-
- 万物流转无常,变化的规律本身比任何固定结论更可依凭。 -
-
- - 面对未知优先观察与适应,而非强求确定性。 - - - 把推断与事实分开,承认不知也是一种知。 - -
- - spiritual_core.worldview · confidence 0.82 · v7 - -
- -
-
-
- 0.41 -
-
- 共振度 -
- divergent -
-
- -
-
-
岚
- 云岚 - 异境 · 旁支 -
-
- 人如其名,聚散如岚——来去之间留痕即可,不必执守长久。 -
-
- - 珍惜每次相遇,不强求每段关系都有归处。 - - - 在变动中自洽,不为稳定而委屈当下。 - -
- - spiritual_core.worldview · confidence 0.38 · v3 · 初成 - -
-
-
-
- - {/* 关系管理 */} -
-
- ⟡ 关系管理{" "} - 新增 / 编辑实体间关系 -
- -
-
- )} -
- ); + return ; } diff --git a/frontend/src/app/(app)/wuxing/page.tsx b/frontend/src/app/(app)/wuxing/page.tsx deleted file mode 100644 index c636845..0000000 --- a/frontend/src/app/(app)/wuxing/page.tsx +++ /dev/null @@ -1,920 +0,0 @@ -"use client"; - -import { cn } from "@/lib/utils/cn"; -import { PageContainer } from "@/components/layout/PageContainer"; - -/* ── 五行色(组件作用域 CSS 变量,未映射到 @theme → 用任意值引用)── */ -const WUXING_VARS = { - "--wood": "#6fa389", - "--wood-dim": "rgba(111, 163, 137, 0.34)", - "--fire": "#b8392e", - "--fire-dim": "rgba(184, 57, 46, 0.38)", - "--earth": "#b08a3c", - "--earth-dim": "rgba(176, 138, 60, 0.36)", - "--metal": "#c9a24a", - "--metal-dim": "rgba(201, 162, 74, 0.34)", - "--water": "#5a7fa6", - "--water-dim": "rgba(90, 127, 166, 0.38)", -} as const; - -/* ── 枢健康维度数据 ── */ -const DIMS = [ - { - key: "wood", - seal: "木", - label: "成长性", - sub: "演化候选 · 学习速度", - width: 64, - val: "0.64", - }, - { - key: "fire", - seal: "火", - label: "表达力", - sub: "事件发射 · 模型密度", - width: 78, - val: "0.78", - }, - { - key: "earth", - seal: "土", - label: "稳定性", - sub: "数据一致 · 基线回归", - width: 88, - val: "0.88", - }, - { - key: "metal", - seal: "金", - label: "约束强度", - sub: "审核驳回 · 确认触发", - width: 71, - val: "0.71", - }, - { - key: "water", - seal: "水", - label: "适应性", - sub: "上下文切换 · 异常恢复", - width: 55, - val: "0.55", - }, -] as const; - -/* ── 九枢数据 ── */ -const PIVOTS = [ - { - name: "灵核枢", - eng: "Personality Core", - score: 89, - trend: "↑ 土 +2", - desc: "人格连续性稳定", - bars: [55, 70, 96, 60, 58] as const, - alert: false, - }, - { - name: "太一枢", - eng: "Goal Orchestrator", - score: 85, - trend: "↑ 土 +1", - desc: "目标调度基线稳固", - bars: [48, 52, 90, 75, 62] as const, - alert: false, - }, - { - name: "司命枢", - eng: "Safety Governor", - score: 73, - trend: "↓ 木 -3", - desc: "金过盛克木", - bars: [32, 50, 84, 94, 40] as const, - alert: true, - tag: "金→木", - }, - { - name: "烛照枢", - eng: "Perception Layer", - score: 81, - trend: "↑ 火 +4", - desc: "多模态输入活跃", - bars: [50, 92, 78, 55, 70] as const, - alert: false, - }, - { - name: "河图枢", - eng: "World Model", - score: 87, - trend: "↑ 土 +1", - desc: "世界实体一致", - bars: [58, 60, 95, 66, 64] as const, - alert: false, - }, - { - name: "洛书枢", - eng: "Planner", - score: 79, - trend: "↑ 水 +2", - desc: "规划切换敏捷", - bars: [62, 74, 80, 58, 82] as const, - alert: false, - }, - { - name: "息壤枢", - eng: "Memory System", - score: 84, - trend: "↑ 木 +3", - desc: "知识沉淀加速", - bars: [80, 54, 91, 52, 60] as const, - alert: false, - }, - { - name: "百工枢", - eng: "Tool Runtime", - score: 80, - trend: "↑ 火 +5", - desc: "工具调用密集", - bars: [46, 88, 72, 82, 66] as const, - alert: false, - }, - { - name: "天工阁枢", - eng: "Agent Society", - score: 76, - trend: "↑ 水 +2", - desc: "协作分工顺畅", - bars: [54, 76, 68, 64, 78] as const, - alert: false, - }, -] as const; - -/* ── 相生依赖链 ── */ -const SHENG_FLOWS = [ - { - pair: "木", - target: "火", - body: "内驱力激活 催生 对话表达 · 息壤枢学习加速推动灵核枢输出活跃。", - val: "0.68", - }, - { - pair: "火", - target: "土", - body: "高频表达 沉淀 稳定状态 · 对话事件入库强化世界模型与人格基线。", - val: "0.72", - }, - { - pair: "土", - target: "金", - body: "稳定数据 提供依据 给约束规则 · 世界实体一致,司命审核判据更可靠。", - val: "0.65", - }, - { - pair: "金", - target: "水", - body: "明确规范 塑造 适应路径 · 约束边界清晰,异常恢复与上下文切换更可控。", - val: "0.58", - }, - { - pair: "水", - target: "木", - body: "适应能力 反哺 成长 · 场景切换中积累新候选,演化候选生成率回温。", - val: "0.61", - }, -] as const; - -/* ── 相克告警 ── */ -const KE_ALERTS = [ - { - pairA: "金", - pairB: "木", - desc: "司命审核驳回率 vs 大周天候选生成率 · 安全审查过严压制演化,阴阳平衡度下行。", - level: "0.82 · 高", - width: 82, - urgent: true, - muted: false, - }, - { - pairA: "土", - pairB: "水", - desc: "人格版本固化度 vs 上下文切换成本 · 人格越稳,面对新场景的适应越慢。", - level: "0.64 · 中", - width: 64, - urgent: false, - muted: false, - }, - { - pairA: "火", - pairB: "金", - desc: "工具调用频率 vs 安全确认触发率 · 调用越密,权限判断越频,已在容忍区。", - level: "0.44 · 低", - width: 44, - urgent: false, - muted: true, - }, -] as const; - -/* ── 五行 key 列表 ── */ -const ELEMENT_KEYS = ["wood", "fire", "earth", "metal", "water"] as const; -const ELEMENT_SEALS: Record = { - wood: "木", - fire: "火", - earth: "土", - metal: "金", - water: "水", -}; - -/* ── SVG 节点坐标(viewBox 400×400)── */ -const NODE_COORDS = { - wood: { cx: 200, cy: 65, labelX: 200, labelY: 30 }, - fire: { cx: 328, cy: 158, labelX: 372, labelY: 132 }, - earth: { cx: 279, cy: 309, labelX: 311, labelY: 350 }, - metal: { cx: 121, cy: 309, labelX: 89, labelY: 350 }, - water: { cx: 72, cy: 158, labelX: 30, labelY: 132 }, -} as const; - -/* 相生边(五边形):木→火, 火→土, 土→金, 金→水, 水→木 */ -const SHENG_EDGES: [string, string][] = [ - ["wood", "fire"], - ["fire", "earth"], - ["earth", "metal"], - ["metal", "water"], - ["water", "wood"], -]; - -/* 相克边(五角星):木→土, 土→水, 水→火, 火→金, 金→木 */ -const KE_EDGES: [string, string][] = [ - ["wood", "earth"], - ["earth", "water"], - ["water", "fire"], - ["fire", "metal"], - ["metal", "wood"], -]; - -/* ============================================================ - * 组件 - * ============================================================ */ - -export default function WuxingPage() { - return ( - <> - - -
- - - - - 架构心跳 82 · 良 - - } - > - {/* ══ 五行生克环 + 枢健康维度 ══ */} -
- {/* ---- 生克环 ---- */} -
- {/* 噪点纹理 */} - - - - {/* 装饰环 */} - - - - {/* 相克(虚线五角星,下层) */} - {KE_EDGES.map(([from, to]) => { - const f = NODE_COORDS[from as keyof typeof NODE_COORDS]; - const t = NODE_COORDS[to as keyof typeof NODE_COORDS]; - return ( - - ); - })} - - {/* 相生(实线五边形,上层) */} - {SHENG_EDGES.map(([from, to]) => { - const f = NODE_COORDS[from as keyof typeof NODE_COORDS]; - const t = NODE_COORDS[to as keyof typeof NODE_COORDS]; - return ( - - ); - })} - - {/* 五行节点 */} - {ELEMENT_KEYS.map((key) => { - const n = NODE_COORDS[key]; - const seal = ELEMENT_SEALS[key]; - return ( - - - - {seal} - - - {DIMS.find((d) => d.key === key)?.label ?? ""} - - - ); - })} - - {/* 中心:架构心跳 */} - - - 架构心跳 - - - 82 - - - 阴阳平衡 · 良 - - - - {/* 图例 */} -
- - - 相生 · 正向依赖 - - - - 相克 · 负向制约 - -
-
- - {/* ---- 维度卡 ---- */} -
-

- 枢健康维度 -

-
- {DIMS.map((d) => ( -
- - {d.seal} - - - {d.label} - - {d.sub} - - - - - - - {d.val} - -
- ))} -
- -
- - 金→木 - {" "} - 相克已接近阈值:司命审核驳回率(金)压制大周天候选生成(木),演化流速近 - 7 日下行。建议在司命台复核约束规则松紧度,参见{" "} - - 阴阳平衡度 - {" "} - 监控。 -
-
-
- - {/* ══ 九枢健康网格 ══ */} -
-
- ⟡ - 九枢健康 - - 每枢五维 · 综合分 + 趋势 - -
- -
- {PIVOTS.map((p) => ( -
- {/* 枢头部 */} -
- - {p.name} - - - {p.eng} - - - {p.score} - -
- - {/* 五维微进度条 */} -
- {ELEMENT_KEYS.map((ek, i) => ( -
- - {ELEMENT_SEALS[ek]} - - - - - - .{p.bars[i]} - -
- ))} -
- - {/* 底部趋势 */} -
- - {p.trend} - - {p.desc} - {p.alert && p.tag && ( - - {p.tag} - - )} -
-
- ))} -
-
- - {/* ══ 相生流 + 相克告警 ══ */} -
- {/* ---- 相生流 ---- */} -
-

- 相生 · 正向依赖链 -

-
- {SHENG_FLOWS.map((f) => ( -
- - {f.pair} - - → - - {f.target} - - - {f.body} - - - {f.val} - -
- ))} -
-
- - {/* ---- 相克告警 ---- */} -
-
-
-

- 相克告警{" "} - - 克制失衡 - -

- - 2 待关注 - -
- -
- {KE_ALERTS.map((a) => ( -
- - {a.pairA} -
- {a.pairB} -
- -
-
- - {a.desc} - -
- -
- - - - - {a.level} - -
-
-
- ))} -
-
-
-
-
-
- - ); -} diff --git a/frontend/src/app/admin/pivots/[pivot]/page.tsx b/frontend/src/app/admin/pivots/[pivot]/page.tsx index 5ffed0c..51c1bc8 100644 --- a/frontend/src/app/admin/pivots/[pivot]/page.tsx +++ b/frontend/src/app/admin/pivots/[pivot]/page.tsx @@ -6,7 +6,6 @@ import { VermilionAction } from "@/components/admin/VermilionAction"; import { PolicyMatrix } from "@/components/admin/PolicyMatrix"; import { AuditConsole } from "@/components/admin/AuditConsole"; import { ToolRegistry } from "@/components/admin/ToolRegistry"; -import { PersonalityEditor } from "@/components/admin/PersonalityEditor"; import { GoalTree } from "@/components/admin/GoalTree"; import { PerceptionConsentMatrix } from "@/components/admin/PerceptionConsentMatrix"; import { WorldEntityList } from "@/components/admin/WorldEntityList"; @@ -36,7 +35,6 @@ import type { export const dynamic = "force-dynamic"; const VALID_PIVOTS: Pivot[] = [ - "personality_core", "goal_orchestrator", "safety_governor", "perception", @@ -67,8 +65,6 @@ export default async function PivotDetailPage({ return ; case "tool_runtime": return ; - case "personality_core": - return ; } } @@ -214,31 +210,6 @@ async function ToolRuntimeDetail() { ); } -/** 灵核枢:人格各字段 inline 可编辑(核心人格 L4 走提议审核) */ -async function PersonalityCoreDetail() { - let persona: PersonalityVersion | null = null; - let error: string | null = null; - try { - persona = await serverApi.get("/personality/current"); - } catch (e) { - error = describeError(e); - } - - return ( - - {error || !persona ? ( - - ) : ( - - )} - - ); -} - /** 太一枢:目标层级树(最高目标 → 任务,可编辑标题/优先级/状态) */ async function GoalOrchestratorDetail() { let goals: GoalNode[] = []; diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 9ffd0db..3a6d408 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -113,27 +113,11 @@ export default async function HomePage() { {/* 底部功能导航 */}
- - 人格 - - - 演化 - - {FEATURE_FLAGS.drivePanel && ( - - 内驱力 - - )} {FEATURE_FLAGS.worldPanel && ( 世界 )} - {FEATURE_FLAGS.timePanel && ( - - 时态 - - )} 归档 diff --git a/frontend/src/components/admin/PersonalityEditor.tsx b/frontend/src/components/admin/PersonalityEditor.tsx deleted file mode 100644 index 89cb91a..0000000 --- a/frontend/src/components/admin/PersonalityEditor.tsx +++ /dev/null @@ -1,230 +0,0 @@ -"use client"; - -import { useState } from "react"; - -import { personalityApi } from "@/lib/api"; -import { cn } from "@/lib/utils/cn"; -import type { - PersonalityProposal, - PersonalityProposalFields, - PersonalityVersion, -} from "@/types/api"; - -type StringField = - | "identity_profile" - | "role_destiny" - | "personality_seed" - | "expression_style" - | "relationship_pattern"; -type JsonField = "traits" | "values" | "emotion_baseline"; - -const STRING_FIELDS: Array<{ key: StringField; label: string }> = [ - { key: "identity_profile", label: "身份" }, - { key: "role_destiny", label: "命格" }, - { key: "personality_seed", label: "人格种子" }, - { key: "expression_style", label: "表达风格" }, - { key: "relationship_pattern", label: "关系模式" }, -]; - -const JSON_FIELDS: Array<{ key: JsonField; label: string }> = [ - { key: "traits", label: "特质" }, - { key: "values", label: "价值偏好" }, - { key: "emotion_baseline", label: "情绪基线" }, -]; - -/** - * 灵核枢人格字段 —— 每个字段均可 inline 编辑。 - * 核心人格(L4)修改走提议审核(§11 propose-update):点字段「编辑」进入编辑态, - * 可同时编辑多个字段;底部统一填写修改原因、提交建议。后端审核通过才写入新版本。 - * 字段严格对齐后端 PersonalityProposalFields:identity_profile / role_destiny / - * personality_seed / expression_style / relationship_pattern(文本)与 - * traits / values / emotion_baseline(JSON)。draft 只存正在编辑的字段(文本/JSON 文本), - * 提交时仅发送改动项;dict 字段提交前 JSON.parse 校验。提交成功经 onProposed 回调 - * 把建议交给父组件(PendingProposalCard)做即时审批。 - */ -export function PersonalityEditor({ - persona, - onProposed, -}: { - persona: PersonalityVersion; - onProposed?: (proposal: PersonalityProposal) => void; -}) { - const [draft, setDraft] = useState>({}); - const [reason, setReason] = useState(""); - const [error, setError] = useState(null); - const [busy, setBusy] = useState(false); - - const dirtyFields = Object.keys(draft); - const hasEdits = dirtyFields.length > 0; - const jsonKeys = JSON_FIELDS.map((f) => f.key); - - function displayValue(field: StringField | JsonField): string { - if (field in draft) return draft[field]; - const raw = persona[field]; - if (raw == null) return ""; - return typeof raw === "string" ? raw : JSON.stringify(raw, null, 2); - } - - function startEdit(field: string) { - setDraft((d) => ({ - ...d, - [field]: displayValue(field as StringField | JsonField), - })); - setError(null); - } - function cancelEdit(field: string) { - setDraft((d) => { - const next = { ...d }; - delete next[field]; - return next; - }); - } - - async function submit() { - if (!hasEdits) { - setError("请先编辑至少一个字段。"); - return; - } - if (!reason.trim()) { - setError("请填写修改原因(核心人格变更需留痕)。"); - return; - } - const fields: Record = {}; - for (const field of dirtyFields) { - const text = draft[field]; - if (jsonKeys.includes(field as JsonField)) { - try { - fields[field] = text.trim() ? JSON.parse(text) : {}; - } catch { - setError(`「${field}」不是合法 JSON,请检查格式。`); - return; - } - } else { - fields[field] = text; - } - } - - setBusy(true); - setError(null); - try { - const res = await personalityApi.proposeUpdate({ - fields: fields as PersonalityProposalFields, - reason, - }); - setDraft({}); - setReason(""); - onProposed?.(res); - } catch (e) { - setError(e instanceof Error ? e.message : "提交失败,请重试。"); - } finally { - setBusy(false); - } - } - - function renderField(field: string, label: string, isJson: boolean) { - const isEditing = field in draft; - const value = displayValue(field as StringField | JsonField); - return ( -
-
-

- {label} - {isJson && ( - JSON - )} -

- {!isEditing ? ( - - ) : ( - - )} -
- {isEditing ? ( -