M6:缘契、人格与世界——契约版本链、铸魂分灵、河图境界与三界分流

- 缘契域 domains/yuanqi:covenants(当前版)+ covenant_events(不可变历史);提议—问心—铭契—撤回全链;边界变更立即生效;多维关系并存,服务层拒绝 affinity/好感度单一键;浪漫维度成人资格标记位
- 道体铸魂与分灵 domains/jingsheng/soul.py:soul_forgings 确认流(锚点变更 pending→confirmed 应用并签发新魂籍版本,确认前人物锚点不变);分灵创建新人物(继承资料与谱系 lineage 链、不继承原身份,魂籍从 v1 起)
- 河图域 domains/hetu:persistent_worlds 境界 + world_entities/world_relations 境界作用域重建 + 世谱挂接;境界正史视图(scope=persistent_world 归真集合);canonize 后置推进正史计数
- 三界分流:reality→息壤提取(不变);persistent_world→境界实录+正史推进;simulation→只记试演实录;factual_records 增 scope 列——境界/幻境实录与真界本相隔离
- 正影打通:引文 target_type=memory 时被引记忆 superseded → 返回 successor_memory_id 提示,不静默替换
- 前端:人物详情页缘契面板(提议维度选择/铭契/撤回/事件史)+ 世界页重建(境界目录/实体/正史概览)+ covenants/worlds api 库
- 退役:旧 personality/drive/evolution/world 模块、服务、端点、仓储、schema、前端五页与组件样式;迁移 007 退役九张旧表与 characters.current_personality_version_id
- 验收(test_m6_domains 六用例):多维无单一好感度/铭契版本链/事件不可抹除/撤回立即生效/边界即时/铸魂确认流+魂籍版本/分灵谱系/境界正史不进真界(实录 scope 隔离+真界候选=0)/引文正影后继
- 四套件:后端 379、前端 199、BFF 4、神识 5 包全绿(2 个预存环境失败不变);迁移 007 净库+±1 干净
This commit is contained in:
chenxin committed 2026-08-27 20:10:11 +08:00
1 parent 684261f4b4
commit 7e23d6fa84
108 files changed
+2600 -9732

No files matched your search

@@ -0,0 +1,170 @@
"""缘契域_契约与事件_铸魂分灵_河图境界与实体_实录作用域_退役旧人格驱动演化世界表
Revision ID: 007
Revises: 006
Create Date: 2026-08-27 19:32:08.478484
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '007'
down_revision: Union[str, Sequence[str], None] = '006'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# 旧世界表退役重建(世界权威移交河图域 hetu;开发数据丢弃)
op.drop_table('world_entities')
op.drop_table('character_world_entities')
op.drop_table('world_relations')
# 缘契域
op.create_table('covenants',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('owner_scope', sa.String(length=80), nullable=False),
sa.Column('dimensions_json', sa.Text(), nullable=False),
sa.Column('primary_title', sa.String(length=120), nullable=True),
sa.Column('boundaries_json', sa.Text(), nullable=False),
sa.Column('adult_consent', sa.Boolean(), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('version', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_covenants_character_id'), 'covenants', ['character_id'], unique=False)
op.create_index(op.f('ix_covenants_owner_scope'), 'covenants', ['owner_scope'], unique=False)
op.create_index(op.f('ix_covenants_status'), 'covenants', ['status'], unique=False)
op.create_table('covenant_events',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('covenant_id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('type', sa.String(length=20), nullable=False),
sa.Column('payload_json', sa.Text(), nullable=False),
sa.Column('actor', sa.String(length=20), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['covenant_id'], ['covenants.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_covenant_events_character_id'), 'covenant_events', ['character_id'], unique=False)
op.create_index(op.f('ix_covenant_events_covenant_id'), 'covenant_events', ['covenant_id'], unique=False)
op.create_index(op.f('ix_covenant_events_created_at'), 'covenant_events', ['created_at'], unique=False)
op.create_index(op.f('ix_covenant_events_type'), 'covenant_events', ['type'], unique=False)
# 铸魂
op.create_table('soul_forgings',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('proposed_anchor_json', sa.Text(), nullable=False),
sa.Column('reason', sa.Text(), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('confirmed_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_soul_forgings_character_id'), 'soul_forgings', ['character_id'], unique=False)
op.create_index(op.f('ix_soul_forgings_status'), 'soul_forgings', ['status'], unique=False)
# 河图境界
op.create_table('persistent_worlds',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('owner_scope', sa.String(length=80), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('rules_json', sa.Text(), nullable=False),
sa.Column('lorebook_id', sa.String(length=80), nullable=True),
sa.Column('canon_count', sa.Integer(), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_persistent_worlds_lorebook_id'), 'persistent_worlds', ['lorebook_id'], unique=False)
op.create_index(op.f('ix_persistent_worlds_owner_scope'), 'persistent_worlds', ['owner_scope'], unique=False)
op.create_table('world_entities',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('world_id', sa.String(length=80), nullable=False),
sa.Column('kind', sa.String(length=30), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('attrs_json', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['world_id'], ['persistent_worlds.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_world_entities_world_id'), 'world_entities', ['world_id'], unique=False)
op.create_table('world_relations',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('world_id', sa.String(length=80), nullable=False),
sa.Column('source_entity_id', sa.String(length=80), nullable=False),
sa.Column('target_entity_id', sa.String(length=80), nullable=False),
sa.Column('relation', sa.String(length=80), nullable=False),
sa.Column('note', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['source_entity_id'], ['world_entities.id'], ),
sa.ForeignKeyConstraint(['target_entity_id'], ['world_entities.id'], ),
sa.ForeignKeyConstraint(['world_id'], ['persistent_worlds.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_world_relations_source_entity_id'), 'world_relations', ['source_entity_id'], unique=False)
op.create_index(op.f('ix_world_relations_target_entity_id'), 'world_relations', ['target_entity_id'], unique=False)
op.create_index(op.f('ix_world_relations_world_id'), 'world_relations', ['world_id'], unique=False)
# 实录作用域
with op.batch_alter_table('factual_records') as batch:
batch.add_column(sa.Column('scope', sa.String(length=40), nullable=False, server_default='reality'))
batch.create_index('ix_factual_records_scope', ['scope'])
# 旧人格/驱动/演化域退役(人格基线=道体锚点 identity_baseline_json + 魂籍版本链)
with op.batch_alter_table('characters') as batch:
batch.drop_column('current_personality_version_id')
op.drop_table('personality_versions')
op.drop_table('personality_proposals')
op.drop_table('curiosity_questions')
op.drop_table('exploratory_subgoals')
op.drop_table('evolution_candidates')
def downgrade() -> None:
"""Downgrade schema."""
op.drop_table('evolution_candidates')
op.drop_table('exploratory_subgoals')
op.drop_table('curiosity_questions')
op.drop_table('personality_proposals')
op.drop_table('personality_versions')
with op.batch_alter_table('characters') as batch:
batch.add_column(sa.Column('current_personality_version_id', sa.String(length=80), nullable=True))
with op.batch_alter_table('factual_records') as batch:
batch.drop_index('ix_factual_records_scope')
batch.drop_column('scope')
op.drop_index(op.f('ix_world_relations_world_id'), table_name='world_relations')
op.drop_index(op.f('ix_world_relations_target_entity_id'), table_name='world_relations')
op.drop_index(op.f('ix_world_relations_source_entity_id'), table_name='world_relations')
op.drop_table('world_relations')
op.drop_index(op.f('ix_world_entities_world_id'), table_name='world_entities')
op.drop_table('world_entities')
op.drop_index(op.f('ix_persistent_worlds_owner_scope'), table_name='persistent_worlds')
op.drop_index(op.f('ix_persistent_worlds_lorebook_id'), table_name='persistent_worlds')
op.drop_table('persistent_worlds')
op.drop_index(op.f('ix_soul_forgings_status'), table_name='soul_forgings')
op.drop_index(op.f('ix_soul_forgings_character_id'), table_name='soul_forgings')
op.drop_table('soul_forgings')
op.drop_index(op.f('ix_covenant_events_type'), table_name='covenant_events')
op.drop_index(op.f('ix_covenant_events_created_at'), table_name='covenant_events')
op.drop_index(op.f('ix_covenant_events_covenant_id'), table_name='covenant_events')
op.drop_index(op.f('ix_covenant_events_character_id'), table_name='covenant_events')
op.drop_table('covenant_events')
op.drop_index(op.f('ix_covenants_status'), table_name='covenants')
op.drop_index(op.f('ix_covenants_owner_scope'), table_name='covenants')
op.drop_index(op.f('ix_covenants_character_id'), table_name='covenants')
op.drop_table('covenants')
+9 -6
View File
@@ -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)
-31
View File
@@ -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 洛书枢 · 计划 ——
-73
View File
@@ -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)
+5
View File
@@ -0,0 +1,5 @@
"""河图世界路由(域实现见 app/domains/hetu/api.py)。"""
from app.domains.hetu.api import world_router
__all__ = ["world_router"]
-122
View File
@@ -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
)
+72
View File
@@ -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)
-95
View File
@@ -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)
+5
View File
@@ -0,0 +1,5 @@
"""缘契路由(域实现见 app/domains/yuanqi/api.py)。"""
from app.domains.yuanqi.api import cov_router
__all__ = ["cov_router"]
-70
View File
@@ -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:
+5
View File
@@ -0,0 +1,5 @@
"""河图世界域:境界(persistent_worlds)、世界实体与关系、境界正史(M6)。"""
from app.domains.hetu import models, service # noqa: F401
__all__ = ["models", "service"]
+108
View File
@@ -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)
+74
View File
@@ -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)
+238
View File
@@ -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 "{}"),
}
+212
View File
@@ -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,
}
+49 -8
View File
@@ -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
# ── 移栽 ──────────────────────────────────────────────
+6
View File
@@ -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
+10
View File
@@ -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"]
+108
View File
@@ -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)
+59
View File
@@ -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)
+278
View File
@@ -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,
}
+11 -14
View File
@@ -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",
]
+1 -2
View File
@@ -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)
-54
View File
@@ -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)
-50
View File
@@ -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
)
-18
View File
@@ -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 关联太一枢目标,可空。"""
+22 -1
View File
@@ -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))
-55
View File
@@ -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)
-58
View File
@@ -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
)
+2
View File
@@ -77,4 +77,6 @@ class FactualRecord(Base):
# 证据:路径节点引用 + 摘要
evidence_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
summary: Mapped[str] = mapped_column(Text, nullable=False, default="")
# 三界作用域:reality / persistent_world / simulation(境界/幻境实录不进真界本相)
scope: Mapped[str] = mapped_column(String(40), nullable=False, default="reality", index=True)
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
-1
View File
@@ -1 +0,0 @@
"""大周天内驱力领域纯逻辑(造消息 / 解析好奇问题与次生目标草稿,禁止 IO)。"""
-127
View File
@@ -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))
-55
View File
@@ -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)
-18
View File
@@ -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",
]
-57
View File
@@ -1,57 +0,0 @@
"""演化候选领域纯逻辑(level 分类、自动应用策略、类型/作用域枚举)。
本模块是演化候选相关常量与纯谓词的单一源,禁止 IO。
枚举取值以 [契约字典](../../docs/contracts/contract-glossary.md) §12 为准。
"""
from app.models.evolution import EvolutionCandidate
# ---------------------------------------------------------------------------
# Level 常量与自动应用策略
# ---------------------------------------------------------------------------
# 半自动应用的 level 范围(L0-L3 写入程序记忆/人格权重等轻量参数,L4 核心变更需人工审核)
AUTO_LEVELS: set[str] = {"L0", "L1", "L2", "L3"}
# 所有可选 level,按风险升序排列
LEVELS: tuple[str, ...] = ("L0", "L1", "L2", "L3", "L4")
# ---------------------------------------------------------------------------
# Type / Scope 枚举
# ---------------------------------------------------------------------------
# 演化候选类型
TYPES: tuple[str, ...] = (
"personality",
"personality_weights",
"retrieval_strategy",
"tool_affinity",
"memory",
"tool_strategy",
"safety_rule",
"world_model",
"procedural",
"mood",
)
# 演化候选作用域
SCOPES: tuple[str, ...] = (
"character_scope",
"user_scope",
"task_scope",
"global_scope",
)
# ---------------------------------------------------------------------------
# 策略谓词
# ---------------------------------------------------------------------------
def should_auto_apply(candidate: EvolutionCandidate) -> bool:
"""判断演化候选是否可半自动应用。
满足任一条件即可自动应用:
1. level 在 AUTO_LEVELS 范围内(L0-L3 低风险调整)
2. auto_apply_allowed 为 True(模型+系统共同判定允许自动应用)
"""
return candidate.level in AUTO_LEVELS or candidate.auto_apply_allowed
Whitespace-only changes.
-149
View File
@@ -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}%)"
-97
View File
@@ -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())
-44
View File
@@ -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)
-16
View File
@@ -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()))
-84
View File
@@ -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
-104
View File
@@ -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())
-2
View File
@@ -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。")
-41
View File
@@ -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
-101
View File
@@ -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 字符串。")
+4 -3
View File
@@ -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())
+5 -64
View File
@@ -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)
-143
View File
@@ -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,
)
-73
View File
@@ -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()
# —— 洛书枢 · 计划 ——
-432
View File
@@ -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)
-88
View File
@@ -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:
-198
View File
@@ -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,
)
+1 -1
View File
@@ -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
-144
View File
@@ -1,144 +0,0 @@
"""内驱力 /drive API 测试。"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from app.db.session import AsyncSessionLocal
from app.main import app
from app.models.big_cycle import BigCycle
from app.models.drive import CuriosityQuestion, ExploratorySubgoal
from app.services.characters import get_active_character_model
@pytest.fixture
async def client() -> AsyncGenerator[AsyncClient, None]:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
yield http_client
async def _seed(character_id: str, subgoal_status: str = "candidate") -> str:
async with AsyncSessionLocal() as session:
cycle = BigCycle(
character_id=character_id,
trigger="test",
status="completed",
memory_count_at_trigger=20,
)
session.add(cycle)
await session.flush()
curios = CuriosityQuestion(
character_id=character_id,
cycle_id=cycle.id,
question="用户为何频繁提到项目X",
source="memory_gap",
expected_value=0.7,
urgency=0.5,
risk_level="L2",
)
session.add(curios)
await session.flush()
sub = ExploratorySubgoal(
character_id=character_id,
cycle_id=cycle.id,
curiosity_question_id=curios.id,
title="询问项目目标",
description="主动询问",
expected_information_gain=0.6,
interruption_cost=0.3,
risk_level="L2",
status=subgoal_status,
)
session.add(sub)
await session.commit()
return sub.id
@pytest.mark.asyncio
async def test_list_curiosities(client: AsyncClient) -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
await _seed(character.id)
resp = await client.get(f"/api/v1/characters/{character.id}/curiosities")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["items"][0]["question"] == "用户为何频繁提到项目X"
@pytest.mark.asyncio
async def test_list_subgoals_with_status_filter(client: AsyncClient) -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
await _seed(character.id, "candidate")
await _seed(character.id, "approved")
resp = await client.get(
f"/api/v1/characters/{character.id}/subgoals", params={"status": "candidate"}
)
assert resp.status_code == 200
assert resp.json()["total"] == 1
@pytest.mark.asyncio
async def test_approve_subgoal(client: AsyncClient) -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
sid = await _seed(character.id)
resp = await client.post(f"/api/v1/subgoals/{sid}/approve")
assert resp.status_code == 200
assert resp.json()["status"] == "approved"
@pytest.mark.asyncio
async def test_defer_subgoal(client: AsyncClient) -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
sid = await _seed(character.id)
resp = await client.post(f"/api/v1/subgoals/{sid}/defer")
assert resp.status_code == 200
assert resp.json()["status"] == "deferred"
@pytest.mark.asyncio
async def test_reject_subgoal(client: AsyncClient) -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
sid = await _seed(character.id)
resp = await client.post(f"/api/v1/subgoals/{sid}/reject")
assert resp.status_code == 200
assert resp.json()["status"] == "rejected"
@pytest.mark.asyncio
async def test_approve_subgoal_creates_goal(client: AsyncClient) -> None:
"""审批通过次生目标后应自动创建太一目标。"""
from sqlalchemy import select
from app.models.governance import Goal
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
sid = await _seed(character.id)
resp = await client.post(f"/api/v1/subgoals/{sid}/approve")
assert resp.status_code == 200
# 验证生成了 Goal
async with AsyncSessionLocal() as session:
result = await session.execute(
select(Goal).where(Goal.character_id == character.id)
)
goals = result.scalars().all()
matching = [g for g in goals if g.title == "询问项目目标"]
assert len(matching) == 1
goal = matching[0]
assert goal.level == "task"
assert goal.type == "exploration"
assert goal.status == "active"
@@ -1,63 +0,0 @@
"""人格提议列表 API 测试(api-spec §11,审批墙数据源)。"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
@pytest.fixture
async def client(auth_headers: dict[str, str]) -> AsyncGenerator[AsyncClient, None]:
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport, base_url="http://test", headers=auth_headers
) as http_client:
yield http_client
@pytest.mark.asyncio
async def test_list_proposals_empty(client: AsyncClient) -> None:
resp = await client.get("/api/v1/personality/proposals")
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 0
assert body["items"] == []
@pytest.mark.asyncio
async def test_list_proposals_filter_by_status(client: AsyncClient) -> None:
proposed = await client.post(
"/api/v1/personality/propose-update",
json={"fields": {"identity_profile": "更新的身份画像"}, "reason": "测试提议"},
)
assert proposed.status_code == 201
proposal_id = proposed.json()["proposal_id"]
listed = await client.get("/api/v1/personality/proposals")
assert listed.status_code == 200
body = listed.json()
assert body["total"] == 1
assert body["items"][0]["proposal_id"] == proposal_id
assert body["items"][0]["status"] == "pending"
pending = await client.get("/api/v1/personality/proposals?status=pending")
assert pending.json()["total"] == 1
approved = await client.get("/api/v1/personality/proposals?status=approved")
assert approved.json()["total"] == 0
@pytest.mark.asyncio
async def test_list_proposals_pagination(client: AsyncClient) -> None:
for i in range(3):
await client.post(
"/api/v1/personality/propose-update",
json={"fields": {"identity_profile": f"v{i}"}, "reason": f"r{i}"},
)
page1 = await client.get("/api/v1/personality/proposals?page=1&page_size=2")
assert page1.json()["total"] == 3
assert len(page1.json()["items"]) == 2
page2 = await client.get("/api/v1/personality/proposals?page=2&page_size=2")
assert len(page2.json()["items"]) == 1
-104
View File
@@ -1,104 +0,0 @@
"""世界模型 API 测试(河图枢运行时,api-spec §12)。"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
@pytest.fixture
async def client(auth_headers: dict[str, str]) -> AsyncGenerator[AsyncClient, None]:
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport, base_url="http://test", headers=auth_headers
) as http_client:
yield http_client
@pytest.mark.asyncio
async def test_list_entities_empty(client: AsyncClient) -> None:
resp = await client.get("/api/v1/world/entities")
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 0
assert body["items"] == []
@pytest.mark.asyncio
async def test_create_and_get_entity(client: AsyncClient) -> None:
created = await client.post(
"/api/v1/world/entities",
json={"name": "用户项目", "type": "project", "state_type": "fact", "confidence": 0.9},
)
assert created.status_code == 200
entity = created.json()
assert entity["id"].startswith("world_entity_")
assert entity["name"] == "用户项目"
assert entity["type"] == "project"
assert entity["state_type"] == "fact"
assert entity["character_id"].startswith("character_")
fetched = await client.get(f"/api/v1/world/entities/{entity['id']}")
assert fetched.status_code == 200
assert fetched.json()["id"] == entity["id"]
@pytest.mark.asyncio
async def test_get_entity_not_found(client: AsyncClient) -> None:
resp = await client.get("/api/v1/world/entities/world_entity_missing")
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_list_entities_filter_by_type(client: AsyncClient) -> None:
await client.post("/api/v1/world/entities", json={"name": "概念A", "type": "concept"})
await client.post("/api/v1/world/entities", json={"name": "项目B", "type": "project"})
filtered = await client.get("/api/v1/world/entities?type=project")
assert filtered.status_code == 200
body = filtered.json()
assert body["total"] == 1
assert body["items"][0]["type"] == "project"
all_entities = await client.get("/api/v1/world/entities")
assert all_entities.json()["total"] == 2
@pytest.mark.asyncio
async def test_create_relation(client: AsyncClient) -> None:
user = (
await client.post("/api/v1/world/entities", json={"name": "用户", "type": "person"})
).json()["id"]
project = (
await client.post("/api/v1/world/entities", json={"name": "魂镜项目", "type": "project"})
).json()["id"]
created = await client.post(
"/api/v1/world/relations",
json={"subject_id": user, "predicate": "正在进行", "object_id": project},
)
assert created.status_code == 200
relation = created.json()
assert relation["id"].startswith("world_relation_")
assert relation["predicate"] == "正在进行"
assert relation["subject_id"] == user
assert relation["object_id"] == project
listed = await client.get("/api/v1/world/relations")
assert listed.json()["total"] == 1
@pytest.mark.asyncio
async def test_create_relation_rejects_missing_entity(client: AsyncClient) -> None:
project = (
await client.post("/api/v1/world/entities", json={"name": "项目", "type": "project"})
).json()["id"]
# 主体不存在
resp = await client.post(
"/api/v1/world/relations",
json={"subject_id": "world_entity_missing", "predicate": "依赖", "object_id": project},
)
assert resp.status_code == 404
@@ -1,96 +0,0 @@
"""大周天演化候选抽取纯函数测试。"""
from app.modules.big_cycle.extract import (
EvolutionCandidateDraft,
parse_evolution_candidates,
)
def test_parse_standard_json_array() -> None:
raw = (
'[{"type":"personality","level":"L4","scope":"character_scope",'
'"title":"调整身份表达","proposal":"更主动介绍自身能力边界",'
'"delta":{"identity_profile":"新的身份描述"},"confidence":0.8,'
'"benefit_score":0.7,"risk_score":0.3,"auto_apply_allowed":false,'
'"evidence":[0,1]}]'
)
drafts = parse_evolution_candidates(raw)
assert len(drafts) == 1
d = drafts[0]
assert d.type == "personality"
assert d.level == "L4"
assert d.scope == "character_scope"
assert d.title == "调整身份表达"
assert d.proposal == "更主动介绍自身能力边界"
assert d.delta == {"identity_profile": "新的身份描述"}
assert d.confidence == 0.8
assert d.auto_apply_allowed is False
assert d.source == "big_cycle"
assert d.evidence == [0, 1]
def test_parse_markdown_code_block() -> None:
raw = (
'```json\n[{"type":"procedural","level":"L2","scope":"task_scope",'
'"title":"t","proposal":"p"}]\n```'
)
drafts = parse_evolution_candidates(raw)
assert len(drafts) == 1
assert drafts[0].type == "procedural"
def test_parse_text_mixed_json() -> None:
raw = (
'候选如下:\n[{"type":"mood","level":"L1","scope":"character_scope",'
'"title":"t","proposal":"p"}]\n以上。'
)
drafts = parse_evolution_candidates(raw)
assert len(drafts) == 1
def test_parse_invalid_json_returns_empty() -> None:
assert parse_evolution_candidates("不是JSON") == []
assert parse_evolution_candidates("[]") == []
def test_skip_entries_missing_required_fields() -> None:
raw = (
'[{"type":"personality","title":"缺proposal"},'
'{"title":"缺type","proposal":"p"},'
'{"type":"x","proposal":"缺title"}]'
)
assert parse_evolution_candidates(raw) == []
def test_defaults_use_safe_pessimistic_values() -> None:
"""缺省时取最严格默认:level=L4(必须人工)、auto_apply_allowed=False。"""
raw = '[{"type":"personality","title":"t","proposal":"p"}]'
d = parse_evolution_candidates(raw)[0]
assert d.level == "L4"
assert d.scope == "character_scope"
assert d.auto_apply_allowed is False
assert d.confidence == 0.5
assert d.benefit_score == 0.5
assert d.risk_score == 0.5
assert d.drift_score == 0.0
assert d.delta == {}
def test_clamp_and_default_scores() -> None:
raw = (
'[{"type":"personality","title":"t","proposal":"p",'
'"confidence":1.5,"benefit_score":-0.2,"risk_score":"high"}]'
)
d = parse_evolution_candidates(raw)[0]
assert d.confidence == 1.0
assert d.benefit_score == 0.0
assert d.risk_score == 0.5 # 非法值 → 默认
def test_auto_apply_allowed_truthy() -> None:
raw = (
'[{"type":"procedural","level":"L2","title":"t","proposal":"p","auto_apply_allowed":true}]'
)
d = parse_evolution_candidates(raw)[0]
assert d.auto_apply_allowed is True
assert isinstance(d, EvolutionCandidateDraft)
@@ -1,62 +0,0 @@
"""大周天演化候选 prompt 构建纯函数测试。"""
from app.modules.big_cycle.prompts import (
EvaluationSnapshot,
build_evolution_extraction_messages,
)
def _snapshot() -> EvaluationSnapshot:
return EvaluationSnapshot(
character_id="character_x",
memory_count=25,
recent_memories=[("喜欢简洁回答", "用户偏好简洁"), ("来自北京", "用户所在地")],
personality_summary="身份:魂镜;表达风格:温和",
world_entity_count=3,
conclusion="近期互动偏技术话题,表达可更简洁",
)
def test_messages_return_system_user_pair() -> None:
system, user = build_evolution_extraction_messages(_snapshot())
assert isinstance(system, str) and isinstance(user, str)
assert system and user
def test_system_describes_contract_fields() -> None:
system, _ = build_evolution_extraction_messages(_snapshot())
for token in (
"type",
"level",
"scope",
"proposal",
"delta",
"auto_apply_allowed",
):
assert token in system
def test_system_lists_evolution_types() -> None:
system, _ = build_evolution_extraction_messages(_snapshot())
for t in ("personality", "procedural", "tool_strategy", "memory"):
assert t in system
def test_system_states_level_semantics() -> None:
system, _ = build_evolution_extraction_messages(_snapshot())
assert "L4" in system
def test_user_includes_snapshot_signals() -> None:
_, user = build_evolution_extraction_messages(_snapshot())
assert "喜欢简洁回答" in user
assert "身份:魂镜" in user
assert "25" in user
def test_user_handles_empty_personality() -> None:
snap = _snapshot()
snap.personality_summary = ""
_, user = build_evolution_extraction_messages(snap)
assert isinstance(user, str)
assert len(user) > 0
@@ -1,95 +0,0 @@
"""大周天内驱力抽取纯函数测试。"""
from app.modules.drive.extract import (
CuriosityDraft,
SubgoalDraft,
parse_curiosities,
)
def test_parse_standard_with_subgoals() -> None:
raw = (
'[{"question":"用户为何频繁提到项目X","source":"memory_gap",'
'"expected_value":0.7,"urgency":0.5,"risk_level":"L2",'
'"related_entities":["项目X"],'
'"subgoals":[{"title":"询问项目X目标","description":"主动询问用户",'
'"expected_information_gain":0.6,"interruption_cost":0.3,"risk_level":"L2"}]}]'
)
drafts = parse_curiosities(raw)
assert len(drafts) == 1
c = drafts[0]
assert c.question == "用户为何频繁提到项目X"
assert c.source == "memory_gap"
assert c.expected_value == 0.7
assert c.risk_level == "L2"
assert c.related_entities == ["项目X"]
assert len(c.subgoals) == 1
sg = c.subgoals[0]
assert sg.title == "询问项目X目标"
assert sg.expected_information_gain == 0.6
assert sg.interruption_cost == 0.3
def test_parse_markdown_block() -> None:
raw = '```json\n[{"question":"q"}]\n```'
drafts = parse_curiosities(raw)
assert len(drafts) == 1
assert drafts[0].question == "q"
def test_parse_invalid_returns_empty() -> None:
assert parse_curiosities("不是JSON") == []
assert parse_curiosities("[]") == []
def test_skip_missing_question() -> None:
raw = '[{"source":"memory_gap"},{"question":"q"}]'
drafts = parse_curiosities(raw)
assert len(drafts) == 1
assert drafts[0].question == "q"
def test_defaults_use_safe_pessimistic_values() -> None:
"""缺省取最严格默认:risk_level=L4、source=reflection。"""
raw = '[{"question":"q"}]'
c = parse_curiosities(raw)[0]
assert c.source == "reflection"
assert c.risk_level == "L4"
assert c.expected_value == 0.5
assert c.urgency == 0.5
assert c.related_entities == []
assert c.subgoals == []
def test_clamp_values() -> None:
raw = '[{"question":"q","expected_value":1.5,"urgency":-0.2}]'
c = parse_curiosities(raw)[0]
assert c.expected_value == 1.0
assert c.urgency == 0.0
def test_invalid_source_defaults() -> None:
raw = '[{"question":"q","source":"unknown_source"}]'
assert parse_curiosities(raw)[0].source == "reflection"
def test_invalid_risk_level_defaults() -> None:
raw = '[{"question":"q","risk_level":"L9"}]'
assert parse_curiosities(raw)[0].risk_level == "L4"
def test_subgoal_defaults_and_types() -> None:
raw = '[{"question":"q","subgoals":[{"title":"t","description":"d"}]}]'
c = parse_curiosities(raw)[0]
assert isinstance(c, CuriosityDraft)
sg = c.subgoals[0]
assert isinstance(sg, SubgoalDraft)
assert sg.risk_level == "L4" # 安全默认
assert sg.expected_information_gain == 0.5
assert sg.interruption_cost == 0.5
def test_skip_subgoal_missing_title_or_description() -> None:
raw = '[{"question":"q","subgoals":[{"title":"t"},{"description":"d"},{"title":"t","description":"d"}]}]'
c = parse_curiosities(raw)[0]
assert len(c.subgoals) == 1 # 只有完整的保留
@@ -1,47 +0,0 @@
"""大周天内驱力 prompt 构建纯函数测试。"""
from app.modules.big_cycle.prompts import EvaluationSnapshot
from app.modules.drive.prompts import build_drive_extraction_messages
def _snapshot() -> EvaluationSnapshot:
return EvaluationSnapshot(
character_id="character_x",
memory_count=15,
recent_memories=[("用户偏好简洁", "用户偏好简洁直接的回答")],
personality_summary="身份:魂镜",
world_entity_count=4,
conclusion="近期对话偏技术,存在项目背景缺口",
)
def test_messages_return_system_user_pair() -> None:
system, user = build_drive_extraction_messages(_snapshot())
assert isinstance(system, str) and isinstance(user, str)
assert system and user
def test_system_describes_contract_fields() -> None:
system, _ = build_drive_extraction_messages(_snapshot())
for token in (
"question",
"source",
"expected_value",
"urgency",
"risk_level",
"subgoals",
):
assert token in system
def test_system_lists_sources() -> None:
system, _ = build_drive_extraction_messages(_snapshot())
for src in ("world_uncertainty", "memory_gap", "reflection", "goal_tension"):
assert src in system
def test_user_includes_snapshot_signals() -> None:
_, user = build_drive_extraction_messages(_snapshot())
assert "用户偏好简洁" in user
assert "身份:魂镜" in user
assert "15" in user
@@ -1,182 +0,0 @@
"""灵核情绪状态机纯计算测试(modules/personality/emotion.py)。"""
from __future__ import annotations
from app.modules.personality.emotion import (
MOODS,
EmotionState,
apply_signal,
decay,
map_signal_to_mood,
parse_baseline,
signal_intensity_delta,
to_prompt_text,
)
def _baseline(mood: str = "neutral", intensity: float = 0.2) -> EmotionState:
return EmotionState(mood=mood, intensity=intensity)
# ---- map_signal_to_mood ----
def test_map_reunion_to_touched() -> None:
assert map_signal_to_mood("time.reunion", {"reunion_intensity": 0.8}) == "touched"
def test_map_user_care_default_to_concerned() -> None:
assert map_signal_to_mood("time.user_care", {"care_kind": "late_night"}) == "concerned"
def test_map_user_care_festival_lonely_to_melancholy() -> None:
assert map_signal_to_mood("time.user_care", {"care_kind": "festival_lonely"}) == "melancholy"
def test_map_sentiment_default_to_wistful() -> None:
assert map_signal_to_mood("time.sentiment", {"trigger_type": "reunion_memory"}) == "wistful"
def test_map_sentiment_late_night_to_melancholy() -> None:
assert map_signal_to_mood("time.sentiment", {"trigger_type": "late_night"}) == "melancholy"
def test_map_unknown_event_returns_none() -> None:
assert map_signal_to_mood("tool.started", {}) is None
# ---- signal_intensity_delta ----
def test_delta_reunion_uses_reunion_intensity() -> None:
assert signal_intensity_delta("time.reunion", {"reunion_intensity": 0.7}) == 0.7
def test_delta_user_care_uses_care_suggest() -> None:
assert signal_intensity_delta("time.user_care", {"care_suggest": 0.6}) == 0.6
def test_delta_sentiment_default_05() -> None:
assert signal_intensity_delta("time.sentiment", {}) == 0.5
def test_delta_unknown_event_zero() -> None:
assert signal_intensity_delta("tool.started", {}) == 0.0
# ---- apply_signal ----
def test_apply_signal_changes_mood_and_sets_intensity_from_baseline() -> None:
state = _baseline() # neutral 0.2
new = apply_signal(state, "time.reunion", {"reunion_intensity": 0.6}, _baseline())
assert new.mood == "touched"
assert new.intensity == 0.8 # 0.2 baseline + 0.6 delta
def test_apply_signal_same_mood_accumulates() -> None:
state = EmotionState(mood="touched", intensity=0.5)
new = apply_signal(state, "time.reunion", {"reunion_intensity": 0.3}, _baseline())
assert new.mood == "touched"
assert new.intensity == 0.8 # 0.5 current + 0.3 delta
def test_apply_signal_clamps_to_1() -> None:
state = EmotionState(mood="touched", intensity=0.9)
new = apply_signal(state, "time.reunion", {"reunion_intensity": 0.5}, _baseline())
assert new.mood == "touched"
assert new.intensity == 1.0
def test_apply_signal_unknown_event_returns_unchanged() -> None:
state = EmotionState(mood="pleased", intensity=0.5)
new = apply_signal(state, "tool.started", {}, _baseline())
assert new == state
# ---- decay ----
def test_decay_toward_baseline_half_life() -> None:
state = EmotionState(mood="touched", intensity=0.8)
baseline = _baseline() # neutral 0.2
# 半衰期 6 回合,elapsed 6 → intensity 向 baseline 走一半
decayed = decay(state, baseline, elapsed_turns=6.0)
assert decayed.mood == "touched"
assert abs(decayed.intensity - 0.5) < 0.01 # 0.2 + (0.8-0.2)*0.5 = 0.5
def test_decay_below_threshold_returns_baseline_mood() -> None:
state = EmotionState(mood="touched", intensity=0.3)
baseline = _baseline()
# elapsed 大 → intensity 衰减到 < 0.15 → mood 回 baseline
decayed = decay(state, baseline, elapsed_turns=30.0)
assert decayed.mood == baseline.mood
assert decayed.intensity == baseline.intensity
def test_decay_zero_elapsed_returns_unchanged() -> None:
state = EmotionState(mood="touched", intensity=0.8)
baseline = _baseline()
assert decay(state, baseline, elapsed_turns=0) == state
# ---- parse_baseline ----
def test_parse_baseline_from_dict() -> None:
assert parse_baseline({"mood": "neutral", "intensity": 0.3}) == EmotionState(
mood="neutral", intensity=0.3
)
def test_parse_baseline_from_valid_json() -> None:
assert parse_baseline('{"mood": "pleased", "intensity": 0.4}') == EmotionState(
mood="pleased", intensity=0.4
)
def test_parse_baseline_invalid_json_returns_neutral_default() -> None:
assert parse_baseline("not json") == EmotionState()
def test_parse_baseline_none_returns_neutral_default() -> None:
assert parse_baseline(None) == EmotionState()
def test_parse_baseline_clamps_intensity() -> None:
assert parse_baseline({"mood": "neutral", "intensity": 1.5}).intensity == 1.0
assert parse_baseline({"mood": "neutral", "intensity": -0.2}).intensity == 0.0
def test_parse_baseline_unknown_mood_falls_back_neutral() -> None:
assert parse_baseline({"mood": "unknown_mood", "intensity": 0.5}).mood == "neutral"
# ---- to_prompt_text ----
def test_to_prompt_text_includes_chinese_label_and_pct() -> None:
text = to_prompt_text(EmotionState(mood="touched", intensity=0.7))
assert "感动" in text
assert "70" in text
def test_to_prompt_text_unknown_mood_uses_raw() -> None:
text = to_prompt_text(EmotionState(mood="custom", intensity=0.5))
assert "custom" in text
# ---- MOODS ----
def test_moods_contains_seven() -> None:
assert set(MOODS) == {
"neutral",
"pleased",
"touched",
"wistful",
"concerned",
"festive",
"melancholy",
}
@@ -1,107 +0,0 @@
"""内驱力数据访问测试(好奇问题 + 次生目标)。"""
import pytest
from app.db.session import AsyncSessionLocal
from app.models.big_cycle import BigCycle
from app.models.drive import CuriosityQuestion, ExploratorySubgoal
from app.repositories import drive as drive_repo
from app.services.characters import get_active_character_model
async def _seed_cycle(session, character_id: str) -> str:
cycle = BigCycle(
character_id=character_id,
trigger="test",
status="completed",
memory_count_at_trigger=20,
)
session.add(cycle)
await session.flush()
return cycle.id
def _curiosity(character_id: str, cycle_id: str, **overrides) -> CuriosityQuestion:
base = {
"character_id": character_id,
"cycle_id": cycle_id,
"question": "q",
"source": "memory_gap",
"related_entities_json": "[]",
"expected_value": 0.5,
"urgency": 0.5,
"risk_level": "L2",
}
base.update(overrides)
return CuriosityQuestion(**base)
def _subgoal(
character_id: str, cycle_id: str, curiosity_id: str, **overrides
) -> ExploratorySubgoal:
base = {
"character_id": character_id,
"cycle_id": cycle_id,
"curiosity_question_id": curiosity_id,
"title": "t",
"description": "d",
"expected_information_gain": 0.6,
"interruption_cost": 0.3,
"risk_level": "L2",
"status": "candidate",
}
base.update(overrides)
return ExploratorySubgoal(**base)
@pytest.mark.asyncio
async def test_curiosity_add_get_list() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cycle_id = await _seed_cycle(session, character.id)
curios = _curiosity(character.id, cycle_id, question="为何频繁提到项目X")
await drive_repo.add_curiosities(session, [curios])
await drive_repo.add_curiosities(
session, [_curiosity(character.id, cycle_id, source="world_uncertainty")]
)
await session.commit()
cid = curios.id
async with AsyncSessionLocal() as session:
fetched = await drive_repo.get_curiosity(session, cid)
all_items, total = await drive_repo.list_curiosities(session, character.id)
gap, gap_total = await drive_repo.list_curiosities(
session, character.id, source="memory_gap"
)
assert fetched is not None
assert fetched.question == "为何频繁提到项目X"
assert total == 2
assert gap_total == 1
@pytest.mark.asyncio
async def test_subgoal_add_get_list() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cycle_id = await _seed_cycle(session, character.id)
curios = _curiosity(character.id, cycle_id)
await drive_repo.add_curiosities(session, [curios])
await session.flush()
curiosity_id = curios.id
sub = _subgoal(character.id, cycle_id, curiosity_id, title="询问项目目标")
await drive_repo.add_subgoals(session, [sub])
await drive_repo.add_subgoals(
session, [_subgoal(character.id, cycle_id, curiosity_id, status="approved")]
)
await session.commit()
sid = sub.id
async with AsyncSessionLocal() as session:
fetched = await drive_repo.get_subgoal(session, sid)
_, total = await drive_repo.list_subgoals(session, character.id)
cand, cand_total = await drive_repo.list_subgoals(session, character.id, status="candidate")
assert fetched is not None
assert fetched.title == "询问项目目标"
assert fetched.curiosity_question_id == curiosity_id
assert total == 2
assert cand_total == 1
@@ -1,100 +0,0 @@
"""演化候选数据访问测试。"""
import pytest
from app.db.session import AsyncSessionLocal
from app.models.big_cycle import BigCycle
from app.models.evolution import EvolutionCandidate
from app.repositories import evolution as evolution_repo
from app.repositories.json_utils import dumps
from app.services.characters import get_active_character_model
async def _seed_cycle(session, character_id: str) -> str:
cycle = BigCycle(
character_id=character_id,
trigger="test",
status="completed",
memory_count_at_trigger=20,
)
session.add(cycle)
await session.flush()
return cycle.id
def _make_candidate(character_id: str, cycle_id: str, **overrides) -> EvolutionCandidate:
base = {
"character_id": character_id,
"cycle_id": cycle_id,
"type": "personality",
"source": "big_cycle",
"level": "L4",
"scope": "character_scope",
"title": "t",
"proposal": "p",
"delta_json": dumps({}),
"evidence_ids_json": "[]",
"confidence": 0.5,
"benefit_score": 0.5,
"risk_score": 0.5,
"drift_score": 0.0,
"auto_apply_allowed": False,
"status": "pending",
}
base.update(overrides)
return EvolutionCandidate(**base)
@pytest.mark.asyncio
async def test_add_and_get_candidate() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cycle_id = await _seed_cycle(session, character.id)
candidate = _make_candidate(character.id, cycle_id, title="身份调整")
await evolution_repo.add_candidates(session, [candidate])
await session.commit()
cid = candidate.id
async with AsyncSessionLocal() as session:
fetched = await evolution_repo.get_candidate(session, cid)
assert fetched is not None
assert fetched.title == "身份调整"
assert fetched.status == "pending"
assert fetched.source == "big_cycle"
@pytest.mark.asyncio
async def test_list_candidates_with_filters_and_paging() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cycle_id = await _seed_cycle(session, character.id)
await evolution_repo.add_candidates(
session,
[
_make_candidate(character.id, cycle_id, type="personality", status="pending"),
_make_candidate(character.id, cycle_id, type="procedural", status="applied"),
_make_candidate(character.id, cycle_id, type="procedural", status="pending"),
],
)
await session.commit()
async with AsyncSessionLocal() as session:
all_items, total = await evolution_repo.list_candidates(session, character.id)
assert total == 3
assert len(all_items) == 3
pending, pending_total = await evolution_repo.list_candidates(
session, character.id, status="pending"
)
assert pending_total == 2
assert len(pending) == 2
procedural, proc_total = await evolution_repo.list_candidates(
session, character.id, candidate_type="procedural"
)
assert proc_total == 2
page1, _ = await evolution_repo.list_candidates(session, character.id, page=1, page_size=2)
page2, _ = await evolution_repo.list_candidates(session, character.id, page=2, page_size=2)
assert len(page1) == 2
assert len(page2) == 1
@@ -1,97 +0,0 @@
"""内驱力审核服务测试(次生目标 approve/defer/reject)。"""
import pytest
from app.core.errors import AppError
from app.db.session import AsyncSessionLocal
from app.models.big_cycle import BigCycle
from app.models.drive import CuriosityQuestion, ExploratorySubgoal
from app.services import drive as drive_service
from app.services.characters import get_active_character_model
async def _seed_subgoal(session, character_id: str, status: str = "candidate") -> str:
cycle = BigCycle(
character_id=character_id,
trigger="test",
status="completed",
memory_count_at_trigger=20,
)
session.add(cycle)
await session.flush()
curios = CuriosityQuestion(
character_id=character_id,
cycle_id=cycle.id,
question="q",
source="memory_gap",
expected_value=0.5,
urgency=0.5,
risk_level="L2",
)
session.add(curios)
await session.flush()
sub = ExploratorySubgoal(
character_id=character_id,
cycle_id=cycle.id,
curiosity_question_id=curios.id,
title="询问项目目标",
description="主动询问",
expected_information_gain=0.6,
interruption_cost=0.3,
risk_level="L2",
status=status,
)
session.add(sub)
await session.flush()
return sub.id
@pytest.mark.asyncio
async def test_approve_candidate_subgoal() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
sid = await _seed_subgoal(session, character.id)
await session.commit()
resp = await drive_service.approve_subgoal(sid)
assert resp.status == "approved"
assert resp.reviewed_by == "user"
@pytest.mark.asyncio
async def test_defer_candidate_subgoal() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
sid = await _seed_subgoal(session, character.id)
await session.commit()
resp = await drive_service.defer_subgoal(sid)
assert resp.status == "deferred"
@pytest.mark.asyncio
async def test_reject_candidate_subgoal() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
sid = await _seed_subgoal(session, character.id)
await session.commit()
resp = await drive_service.reject_subgoal(sid)
assert resp.status == "rejected"
@pytest.mark.asyncio
async def test_review_non_candidate_conflicts() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
sid = await _seed_subgoal(session, character.id, status="approved")
await session.commit()
with pytest.raises(AppError):
await drive_service.approve_subgoal(sid)
@pytest.mark.asyncio
async def test_approve_not_found() -> None:
with pytest.raises(AppError):
await drive_service.approve_subgoal("subgoal_missing")
-115
View File
@@ -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 路径。"""
-40
View File
@@ -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
+337
View File
@@ -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
+2 -2
View File
@@ -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 格式 |
+1
View File
@@ -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 快照 |
+5 -3
View File
@@ -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 面)
@@ -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<Character>(`/characters/${characterId}`);
} catch (e) {
error = describeError(e);
}
// 缘契独立拉取,失败静默(无缘契是合法状态)
try {
covenant = await serverApi.get<Covenant | null>(
`/covenants/by-character/${characterId}`,
);
} catch {
covenant = null;
}
if (error) {
return (
@@ -62,6 +73,10 @@ export default async function CharacterDetailPage({
<CharacterIdentityCard character={character} />
<div className="mt-6">
<CovenantPanel characterId={character.id} initialCovenant={covenant} />
</div>
<div className="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
<ProfileEditForm character={character} />
<ModelPolicyEditForm character={character} />
File diff suppressed because it is too large. Load diff
-244
View File
@@ -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<Character>("/characters/active");
characterId = active.id;
characterName = active.display_name || active.name;
const [goalRes, curios, cand, approved, deferred, rejected] =
await Promise.all([
serverApi
.get<GoalNode[]>("/admin/goals", { character_id: active.id })
.catch(() => [] as GoalNode[]),
serverApi.get<Paginated<CuriosityQuestion>>(
`/characters/${active.id}/curiosities`,
{ page_size: 20 },
),
serverApi.get<Paginated<ExploratorySubgoal>>(
`/characters/${active.id}/subgoals`,
{ status: "candidate", page_size: 50 },
),
serverApi.get<Paginated<ExploratorySubgoal>>(
`/characters/${active.id}/subgoals`,
{ status: "approved", page_size: 1 },
),
serverApi.get<Paginated<ExploratorySubgoal>>(
`/characters/${active.id}/subgoals`,
{ status: "deferred", page_size: 1 },
),
serverApi.get<Paginated<ExploratorySubgoal>>(
`/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 (
<PageContainer
title="内驱力"
description="大周天 · 好奇与探索"
meta={
<>
{characterName && (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: 12.5,
color: "var(--patina)",
padding: "3px 11px 3px 4px",
border: "1px solid var(--patina-dim)",
borderRadius: 9999,
background: "var(--patina-wash)",
}}
>
<span
style={{
width: 18,
height: 18,
borderRadius: "50%",
background: "var(--patina)",
color: "var(--ink)",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
fontFamily: '"Songti SC", serif',
fontSize: 11,
fontWeight: 600,
flexShrink: 0,
}}
>
{characterName.charAt(0)}
</span>
{characterName}
</span>
)}
<span className="page-meta">
好奇 <b>{totalCurios}</b> · 待探 <b>{totalSubgoals}</b> · 已延{" "}
<b>{counts.deferred}</b>
</span>
</>
}
maxWidth="max-w-5xl"
>
{error ? (
<ErrorState message={error} />
) : characterId ? (
<div className="space-y-5">
{/* 太一 · 目标进度 */}
{goals.length > 0 && (
<section>
<div className="section-label" style={{ marginBottom: 14 }}>
<span className="glyph">⟡</span> 太一{" "}
<span className="sub">最高目标 · 内驱力在其下生成次生探索</span>
</div>
{goals.map((g) => (
<div
key={g.id}
className="taiyi-card"
style={{ marginBottom: 12 }}
>
<div className="taiyi-row">
<span
className={
g.status === "active"
? "taiyi-dot active"
: g.status === "completed"
? "taiyi-dot completed"
: "taiyi-dot"
}
style={{
background:
g.status === "blocked"
? "var(--cinnabar)"
: undefined,
boxShadow:
g.status === "blocked"
? "0 0 0 3px rgba(184,57,46,0.2)"
: undefined,
}}
/>
<span className="taiyi-title">{g.title}</span>
{g.type && (
<span className="taiyi-tag">
{GOAL_TYPE_LABEL[g.type] ?? g.type}
</span>
)}
<span className="taiyi-status">
{GOAL_STATUS_LABEL[g.status] ?? g.status}
</span>
</div>
<div className="taiyi-prog">
<div className="track">
<div
className="fill"
style={{
width: `${Math.round(Math.max(0, Math.min(1, g.progress)) * 100)}%`,
}}
/>
</div>
<span className="tv">
{Math.round(Math.max(0, Math.min(1, g.progress)) * 100)}%
</span>
</div>
{g.description && (
<p className="text-[12.5px] leading-relaxed text-fg-dim/90">
{g.description}
</p>
)}
</div>
))}
</section>
)}
<DriveView
characterId={characterId}
curiosities={curiosities}
subgoals={subgoals}
counts={counts}
/>
{/* 阴阳制衡(太极图) */}
<div className="yin-yang">
<svg className="yy-taiji" viewBox="0 0 200 200" aria-hidden="true">
<circle
cx="100"
cy="100"
r="96"
fill="none"
stroke="var(--gilt-dim)"
strokeWidth="0.8"
/>
<circle cx="100" cy="100" r="88" fill="var(--patina)" />
<path
d="M 100,12 A 88,88 0 0,1 100,188 A 44,44 0 0,1 100,100 A 44,44 0 0,0 100,12 Z"
fill="var(--cinnabar)"
opacity="0.88"
/>
<circle cx="100" cy="144" r="13" fill="var(--patina)" />
<circle
cx="100"
cy="56"
r="13"
fill="var(--cinnabar)"
opacity="0.88"
/>
</svg>
<div className="yy-text">
<b>阴阳制衡</b> · 内驱力持续生成候选(
<span className="yang">阳 · 生发</span>
),司命分级过滤、你按需确认(
<span className="yin">阴 · 约束</span>
)。候选零产生则僵化(阴盛),悉数被驳则阳亢——生成节奏与治理粒度需相称。
<div className="yy-meta">
阴阳平衡度 = 通过率 / (生成率 + ε) · 当前 <b>0.94 · 动态平衡</b>
</div>
</div>
</div>
</div>
) : null}
</PageContainer>
);
}
@@ -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<PersonalityVersion>(
"/personality/current",
);
} catch (e) {
error = describeError(e);
}
// 版本历史独立拉取,失败静默(不影响编辑区)
try {
versions = (
await serverApi.get<Paginated<PersonalityVersion>>(
"/personality/versions",
{ page_size: 50 },
)
).items;
} catch {
/* 版本历史可选 */
}
return (
<PageContainer
title="人格"
description="当前人物的人格版本;字段修改以提议审核方式留痕写入。"
>
{error ? (
<ErrorState message={error} />
) : personality ? (
<PersonalityWorkspace personality={personality} versions={versions} />
) : (
<EmptyState
title="尚未生成人格"
description="当前人物尚未初始化人格版本,待后端写入后即可查看与编辑。"
/>
)}
</PageContainer>
);
}
@@ -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<Run>(`/runs/${runId}`),
serverApi
.get<RunEvent[]>(`/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 (
<PageContainer
title="运行轨迹"
description={`Run ${runId}`}
maxWidth="max-w-4xl"
>
{error ? (
<ErrorState message={error} />
) : (
<>
{traceId && (
<div className="mb-4 flex items-center gap-2 text-xs text-muted">
<span>本次链路</span>
<ErrorTraceLink traceId={traceId} />
</div>
)}
<RunTraceView runId={runId} initialRun={run} initialEvents={events} />
</>
)}
</PageContainer>
);
}
-76
View File
@@ -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<Run> | null = null;
let error: string | null = null;
try {
runs = await serverApi.get<Paginated<Run>>("/runs", { page_size: 50 });
} catch (cause) {
error = describeError(cause);
}
return (
<PageContainer
title="运行轨迹"
description="查看每一次推演、工具调用与模型响应的执行状态。"
meta={
runs && (
<span className="rounded-full border border-border-glass bg-glass px-2.5 py-0.5 text-xs text-muted">
共 {runs.total} 条
</span>
)
}
maxWidth="max-w-5xl"
>
{error ? (
<ErrorState message={error} />
) : !runs || runs.items.length === 0 ? (
<EmptyState
title="暂无运行记录"
description="发起对话或执行任务后,这里会保留可追溯的运行轨迹。"
/>
) : (
<div className="overflow-hidden rounded-2xl border border-border-glass bg-glass backdrop-blur-sm">
<ul className="divide-y divide-border-glass">
{runs.items.map((run) => (
<li key={run.id}>
<Link
href={`/runs/${run.id}`}
className="group flex min-h-20 items-center gap-4 px-4 py-3 transition-colors hover:bg-overlay-hover focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-patina"
>
<div className="min-w-0 flex-1">
<p className="truncate font-mono text-sm text-fg group-hover:text-patina">
{run.goal || run.id}
</p>
<p className="mt-1 truncate text-xs text-muted">
{[
run.id,
run.model_hint ? `模型:${run.model_hint}` : null,
`创建:${formatDateTime(run.created_at ?? run.started_at)}`,
]
.filter(Boolean)
.join(" · ")}
</p>
</div>
<RunStatusBadge status={run.status} />
</Link>
</li>
))}
</ul>
</div>
)}
</PageContainer>
);
}
+7 -220
View File
@@ -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<WorldEntity[]>("/admin/world/entities");
worlds = await serverApi.get<PersistentWorld[]>("/worlds");
} catch (e) {
error = describeError(e);
}
try {
relations = (
await serverApi.get<Paginated<WorldRelation>>("/world/relations", {
page_size: 50,
})
).items;
} catch {
/* 关系可选 */
}
try {
character = await serverApi
.get<Character>("/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 (
<PageContainer
title="世界"
description="河图 · 世界模型"
meta={
<>
{characterName && (
<span className="char-pill">
<span className="avatar-mini">{characterName.charAt(0)}</span>
{characterName}视角
</span>
)}
<span className="page-meta">
实体 <b>{entities.length}</b> · 关系 <b>{relations.length}</b> ·
待验 <b style={{ color: "var(--cinnabar)" }}>{hypotheses}</b>
</span>
</>
}
maxWidth="max-w-5xl"
>
{error ? (
<ErrorState message={error} />
) : entities.length === 0 ? (
<EmptyState
title="暂无世界实体"
description="尚未建模任何实体。点击「新增世界实体」开始。"
/>
) : (
<div className="space-y-5">
{/* 关系图谱 */}
<section>
<div className="section-label" style={{ marginBottom: 14 }}>
<span className="glyph">⟡</span> 关系图谱{" "}
<span className="sub">
河图 · 实体为节点,关系为边,置信度定粗细,假设态虚线待验
</span>
</div>
<WorldRelationGraph entities={entities} relations={relations} />
</section>
{/* 实体名册 */}
<section>
<div className="section-label" style={{ marginBottom: 14 }}>
<span className="glyph">⟡</span> 实体名册{" "}
<span className="sub">按类型 · 事实性着色 · 推断召回时降权</span>
</div>
<WorldEntityList entities={entities} />
</section>
{/* 待验断言(不确定性) */}
{uncertainties.length > 0 && (
<section>
<div className="section-label" style={{ marginBottom: 14 }}>
<span className="glyph">⟡</span> 待验断言{" "}
<span className="sub">
UncertaintyState · 低置信,需复核方可固化为事实
</span>
</div>
<div className="unc-list">
{uncertainties.map((u, i) => (
<div key={i} className="unc-card">
<div className="unc-claim">
<div className="unc-text">{u.claim}</div>
<div className="unc-reason">{u.reason}</div>
</div>
<div className="unc-conf">
<div className="v">{u.confidence.toFixed(2)}</div>
<div className="k">置信</div>
</div>
<span className="unc-verify">需验证</span>
</div>
))}
</div>
</section>
)}
{/* 世界观共振 */}
<section>
<div className="section-label" style={{ marginBottom: 14 }}>
<span className="glyph">⟡</span> 世界观共振{" "}
<span className="sub">
见地镜 · 每人物独立的世界观立场,可同境可异境
</span>
</div>
<div className="wv-card">
<div className="wv-pair">
<div className="wv-side qingwu">
<div className="wv-head">
<div className="wv-disc">
{characterName ? characterName.charAt(0) : "?"}
</div>
<span className="wv-name">{characterName || "人物"}</span>
<span className="wv-tag shared">同境 · 主脉</span>
</div>
<div className="wv-stance">
万物流转无常,变化的规律本身比任何固定结论更可依凭。
</div>
<div className="wv-anchors">
<span className="wv-anchor">
面对未知优先观察与适应,而非强求确定性。
</span>
<span className="wv-anchor">
把推断与事实分开,承认不知也是一种知。
</span>
</div>
<span className="wv-conf">
spiritual_core.worldview · confidence 0.82 · v7
</span>
</div>
<div className="wv-bridge">
<div className="b-disc">
<div className="b-fill"></div>
<span className="b-val">0.41</span>
</div>
<div className="b-label">
共振度
<br />
divergent
</div>
</div>
<div className="wv-side yunlan">
<div className="wv-head">
<div className="wv-disc">岚</div>
<span className="wv-name">云岚</span>
<span className="wv-tag divergent">异境 · 旁支</span>
</div>
<div className="wv-stance">
人如其名,聚散如岚——来去之间留痕即可,不必执守长久。
</div>
<div className="wv-anchors">
<span className="wv-anchor">
珍惜每次相遇,不强求每段关系都有归处。
</span>
<span className="wv-anchor">
在变动中自洽,不为稳定而委屈当下。
</span>
</div>
<span className="wv-conf">
spiritual_core.worldview · confidence 0.38 · v3 · 初成
</span>
</div>
</div>
</div>
</section>
{/* 关系管理 */}
<section>
<div className="section-label" style={{ marginBottom: 14 }}>
<span className="glyph">⟡</span> 关系管理{" "}
<span className="sub">新增 / 编辑实体间关系</span>
</div>
<WorldRelationManager relations={relations} entities={entities} />
</section>
</div>
)}
</PageContainer>
);
return <WorldWorkbench initialWorlds={worlds} error={error} />;
}
-920
View File
@@ -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<string, string> = {
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 (
<>
<style>{`
.wuxing-scope {
${Object.entries(WUXING_VARS)
.map(([k, v]) => `${k}: ${v};`)
.join("\n ")}
}
@keyframes wuxing-pulse {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--patina) 50%, transparent); }
70% { box-shadow: 0 0 0 6px transparent; }
100% { box-shadow: 0 0 0 0 transparent; }
}
.pulse-dot {
animation: wuxing-pulse 2.6s cubic-bezier(0.16, 1, 0.3, 1) infinite;
}
.wuxing-svg .ring-deco { fill: none; stroke: var(--rule); stroke-width: 1; }
.wuxing-svg .sheng { stroke: var(--gilt); stroke-width: 1.6; fill: none; opacity: 0.85; }
.wuxing-svg .ke { stroke: var(--cinnabar); stroke-width: 1.3; stroke-dasharray: 4 4; fill: none; opacity: 0.7; }
.wuxing-svg .node-circle { stroke-width: 1.5; }
.wuxing-svg .center-bg { fill: var(--ink); stroke: var(--gilt-dim); stroke-width: 1; }
.dim-track {
height: 5px;
border-radius: 3px;
background: var(--rule);
overflow: hidden;
position: relative;
}
.dim-fill {
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-radius: 3px;
}
.pbar-track {
height: 4px;
border-radius: 2px;
background: var(--rule);
overflow: hidden;
position: relative;
}
.pbar-fill {
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-radius: 2px;
}
.ke-track {
height: 4px;
border-radius: 2px;
background: var(--rule);
overflow: hidden;
position: relative;
}
.ke-fill {
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-radius: 2px;
}
`}</style>
<div className="wuxing-scope">
<PageContainer
title="五行"
description="枢健康维度 · 生克关系"
actions={
<span
className="inline-flex items-center gap-[7px] rounded-full border px-3 py-1 font-mono text-xs"
style={{
borderColor: "var(--patina-dim)",
color: "var(--patina)",
background: "var(--patina-wash)",
}}
>
<span
className="inline-block h-[6px] w-[6px] rounded-full"
style={{ background: "var(--patina)" }}
>
<span className="pulse-dot block h-full w-full rounded-full" />
</span>
架构心跳 82 · 良
</span>
}
>
{/* ══ 五行生克环 + 枢健康维度 ══ */}
<div className="mb-5 flex flex-col gap-[22px] lg:flex-row lg:items-stretch">
{/* ---- 生克环 ---- */}
<div
className="relative flex w-full flex-col overflow-hidden rounded-lg border p-[18px] lg:w-auto lg:flex-[0_0_auto] lg:basis-[460px]"
style={{
borderColor: "var(--gilt-dim)",
background: "var(--face-sheen), var(--surface)",
boxShadow:
"inset 0 1px 0 var(--face-in-top), inset 0 -1px 0 var(--face-in-bot), var(--shadow)",
}}
>
{/* 噪点纹理 */}
<span
className="pointer-events-none absolute inset-0 z-0"
style={{
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' seed='7'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)'/%3E%3C/svg%3E\")",
opacity: 0.018,
mixBlendMode: "overlay",
}}
/>
<svg
className="wuxing-svg relative z-10 block h-auto w-full"
viewBox="0 0 400 400"
role="img"
aria-label="五行生克环:外环相生、内五角星相克"
>
{/* 装饰环 */}
<circle className="ring-deco" cx="200" cy="200" r="172" />
<circle
className="ring-deco"
cx="200"
cy="200"
r="108"
opacity="0.5"
strokeDasharray="2 4"
/>
{/* 相克(虚线五角星,下层) */}
{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 (
<line
key={`ke-${from}-${to}`}
className="ke"
x1={f.cx}
y1={f.cy}
x2={t.cx}
y2={t.cy}
/>
);
})}
{/* 相生(实线五边形,上层) */}
{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 (
<line
key={`sheng-${from}-${to}`}
className="sheng"
x1={f.cx}
y1={f.cy}
x2={t.cx}
y2={t.cy}
/>
);
})}
{/* 五行节点 */}
{ELEMENT_KEYS.map((key) => {
const n = NODE_COORDS[key];
const seal = ELEMENT_SEALS[key];
return (
<g key={key}>
<circle
className="node-circle"
cx={n.cx}
cy={n.cy}
r="27"
style={{
fill: `var(--${key}-dim)`,
stroke: `var(--${key})`,
}}
/>
<text
x={n.cx}
y={n.cy}
textAnchor="middle"
dominantBaseline="central"
style={{
fontFamily: '"Songti SC", "STSong", serif',
fontSize: "22px",
fontWeight: 700,
fill: `var(--${key})`,
}}
>
{seal}
</text>
<text
x={n.labelX}
y={n.labelY}
textAnchor="middle"
style={{
fontFamily:
"-apple-system, BlinkMacSystemFont, system-ui, sans-serif",
fontSize: "9px",
fill: "var(--fg-faint)",
letterSpacing: "0.04em",
}}
>
{DIMS.find((d) => d.key === key)?.label ?? ""}
</text>
</g>
);
})}
{/* 中心:架构心跳 */}
<circle className="center-bg" cx="200" cy="200" r="58" />
<text
x="200"
y="186"
textAnchor="middle"
style={{
fontFamily: '"Songti SC", "STSong", serif',
fontSize: "15px",
fontWeight: 600,
fill: "var(--gilt)",
}}
>
架构心跳
</text>
<text
x="200"
y="212"
textAnchor="middle"
style={{
fontFamily:
'"JetBrains Mono", "SF Mono", ui-monospace, monospace',
fontSize: "20px",
fontWeight: 300,
fill: "var(--fg)",
letterSpacing: "0.04em",
}}
>
82
</text>
<text
x="200"
y="228"
textAnchor="middle"
style={{
fontFamily:
"-apple-system, BlinkMacSystemFont, system-ui, sans-serif",
fontSize: "9.5px",
fill: "var(--fg-dim)",
}}
>
阴阳平衡 · 良
</text>
</svg>
{/* 图例 */}
<div
className="relative z-10 mt-[10px] flex flex-wrap gap-4 border-t pt-[10px]"
style={{ borderColor: "var(--rule)" }}
>
<span
className="inline-flex items-center gap-[6px] text-[11.5px]"
style={{ color: "var(--fg-dim)" }}
>
<span
className="inline-block w-[18px]"
style={{ borderTop: "1.6px solid var(--gilt)" }}
/>
相生 · 正向依赖
</span>
<span
className="inline-flex items-center gap-[6px] text-[11.5px]"
style={{ color: "var(--fg-dim)" }}
>
<span
className="inline-block w-[18px]"
style={{ borderTop: "1.3px dashed var(--cinnabar)" }}
/>
相克 · 负向制约
</span>
</div>
</div>
{/* ---- 维度卡 ---- */}
<div
className="flex min-w-0 flex-1 flex-col rounded-lg border p-[18px_20px]"
style={{
borderColor: "var(--rule)",
background: "var(--surface)",
}}
>
<h3
className="font-serif text-[14px] font-semibold"
style={{ color: "var(--fg)", marginBottom: "14px" }}
>
枢健康维度
</h3>
<div className="flex flex-1 flex-col gap-[11px]">
{DIMS.map((d) => (
<div
key={d.key}
className="dim"
style={{
display: "grid",
gridTemplateColumns: "22px 60px 1fr 30px",
alignItems: "center",
gap: "10px",
}}
>
<span
className="text-center font-serif text-[16px] font-bold"
style={{ color: `var(--${d.key})` }}
>
{d.seal}
</span>
<span
className="text-[12.5px] font-medium leading-tight"
style={{ color: "var(--fg)" }}
>
{d.label}
<small
className="mt-[1px] block text-[10.5px] font-normal leading-tight"
style={{ color: "var(--fg-faint)" }}
>
{d.sub}
</small>
</span>
<span className="dim-track">
<span
className="dim-fill"
style={{
width: `${d.width}%`,
background: `linear-gradient(90deg, var(--${d.key}-dim), var(--${d.key}))`,
}}
/>
</span>
<span
className="text-right font-mono text-[10.5px]"
style={{ color: `var(--${d.key})` }}
>
{d.val}
</span>
</div>
))}
</div>
<div
className="mt-[14px] rounded-r-[4px] border-l-2 px-[12px] py-[10px] text-[12px] leading-[1.55]"
style={{
borderLeftColor: "var(--gilt-dim)",
background: "var(--gilt-faint)",
color: "var(--fg-dim)",
}}
>
<b
className="font-serif font-semibold"
style={{ color: "var(--gilt)" }}
>
金→木
</b>{" "}
相克已接近阈值:司命审核驳回率(金)压制大周天候选生成(木),演化流速近
7 日下行。建议在司命台复核约束规则松紧度,参见{" "}
<code
className="font-mono text-[11px]"
style={{ color: "var(--gilt)" }}
>
阴阳平衡度
</code>{" "}
监控。
</div>
</div>
</div>
{/* ══ 九枢健康网格 ══ */}
<div className="mb-5">
<div
className="mb-[14px] flex items-center gap-[6px] font-mono text-[12px]"
style={{ color: "var(--fg-faint)", letterSpacing: "0.1em" }}
>
<span style={{ color: "var(--gilt)", opacity: 0.8 }}>⟡</span>
九枢健康
<span
className="text-[12px] tracking-normal opacity-75"
style={{ color: "var(--fg-dim)" }}
>
每枢五维 · 综合分 + 趋势
</span>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{PIVOTS.map((p) => (
<div
key={p.name}
className={cn(
"flex flex-col gap-[9px] rounded-lg border p-[14px_16px]",
p.alert && "bg-[rgba(184,57,46,0.05)]",
)}
style={{
borderColor: p.alert
? "var(--cinnabar-dim)"
: "var(--rule)",
background: p.alert ? undefined : "var(--surface)",
}}
>
{/* 枢头部 */}
<div className="flex items-baseline gap-2">
<span
className="font-serif text-[15px] font-semibold"
style={{ color: "var(--fg)" }}
>
{p.name}
</span>
<span
className="font-mono text-[10px] tracking-wide"
style={{ color: "var(--fg-faint)" }}
>
{p.eng}
</span>
<span
className="ml-auto font-mono text-[17px] font-light tracking-wide"
style={{
color:
p.score < 75 ? "var(--cinnabar)" : "var(--patina)",
}}
>
{p.score}
</span>
</div>
{/* 五维微进度条 */}
<div className="flex flex-col gap-[4px]">
{ELEMENT_KEYS.map((ek, i) => (
<div
key={ek}
style={{
display: "grid",
gridTemplateColumns: "14px 1fr 24px",
alignItems: "center",
gap: "7px",
}}
>
<span
className="text-center font-serif text-[11px]"
style={{ color: `var(--${ek})` }}
>
{ELEMENT_SEALS[ek]}
</span>
<span className="pbar-track">
<span
className="pbar-fill"
style={{
width: `${p.bars[i]}%`,
background: `var(--${ek})`,
}}
/>
</span>
<span
className="font-mono text-[9.5px] text-right"
style={{ color: "var(--fg-faint)" }}
>
.{p.bars[i]}
</span>
</div>
))}
</div>
{/* 底部趋势 */}
<div
className="flex items-center gap-2 pt-[4px] text-[11px]"
style={{
borderTop: "1px solid var(--rule)",
color: "var(--fg-faint)",
}}
>
<span
className="font-mono"
style={{
color: p.trend.startsWith("↑")
? "var(--patina)"
: "var(--cinnabar)",
}}
>
{p.trend}
</span>
{p.desc}
{p.alert && p.tag && (
<span
className="ml-auto rounded-[3px] border px-[7px] py-[2px] font-mono text-[9.5px]"
style={{
borderColor: "var(--cinnabar-dim)",
background: "rgba(184, 57, 46, 0.08)",
color: "var(--cinnabar)",
}}
>
{p.tag}
</span>
)}
</div>
</div>
))}
</div>
</div>
{/* ══ 相生流 + 相克告警 ══ */}
<div className="flex flex-col gap-5 lg:flex-row lg:items-start">
{/* ---- 相生流 ---- */}
<div className="min-w-0 flex-1">
<h3
className="mb-[14px] font-serif text-[14px] font-semibold"
style={{ color: "var(--fg)" }}
>
相生 · 正向依赖链
</h3>
<div className="flex flex-col gap-2">
{SHENG_FLOWS.map((f) => (
<div
key={`${f.pair}-${f.target}`}
className="flex items-start gap-[10px] rounded-[5px] border px-[13px] py-[10px] text-[12.5px] leading-[1.5]"
style={{
borderColor: "var(--rule)",
background: "var(--patina-wash)",
}}
>
<span
className="shrink-0 pt-[1px] font-serif text-[13px] whitespace-nowrap"
style={{ color: "var(--gilt)" }}
>
{f.pair}
<span
className="mx-[3px] font-mono text-[11px]"
style={{ color: "var(--fg-faint)" }}
>
→
</span>
{f.target}
</span>
<span
className="min-w-0"
style={{ color: "var(--fg-dim)" }}
>
<span style={{ color: "var(--fg)" }}>{f.body}</span>
</span>
<span
className="shrink-0 pt-[1px] font-mono text-[10.5px] whitespace-nowrap"
style={{ color: "var(--patina)" }}
>
{f.val}
</span>
</div>
))}
</div>
</div>
{/* ---- 相克告警 ---- */}
<div className="w-full shrink-0 lg:w-[340px]">
<div
className="rounded-lg border p-[16px_18px]"
style={{
borderColor: "var(--rule)",
background: "var(--surface)",
}}
>
<div className="mb-3 flex items-center justify-between">
<h3
className="m-0 font-serif text-[14px] font-semibold"
style={{ color: "var(--fg)" }}
>
相克告警{" "}
<span
className="font-mono text-[11px] font-normal"
style={{ color: "var(--fg-faint)" }}
>
克制失衡
</span>
</h3>
<span
className="rounded-[5px] border px-[10px] py-[3px] font-mono text-[11px]"
style={{
borderColor: "var(--cinnabar-dim)",
background: "rgba(184, 57, 46, 0.08)",
color: "var(--cinnabar)",
}}
>
2 待关注
</span>
</div>
<div className="flex flex-col gap-2">
{KE_ALERTS.map((a) => (
<div
key={`${a.pairA}-${a.pairB}`}
className="flex items-start gap-[10px] rounded-[5px] border px-[12px] py-[9px]"
style={{
borderColor: a.muted
? "var(--rule)"
: "var(--cinnabar-dim)",
background: a.muted ? "transparent" : "var(--ink)",
}}
>
<span
className="w-[46px] shrink-0 pt-[1px] text-center font-serif text-[13px] leading-tight"
style={{
color: a.muted
? "var(--fg-faint)"
: "var(--cinnabar)",
}}
>
{a.pairA}
<br />
{a.pairB}
</span>
<div className="min-w-0 flex-1">
<div
className="text-[12px] leading-[1.5]"
style={{
color: a.muted
? "var(--fg-faint)"
: "var(--fg-dim)",
}}
>
<span
style={{
color: a.muted ? "var(--fg-dim)" : "var(--fg)",
}}
>
{a.desc}
</span>
</div>
<div className="mt-1 flex items-center gap-[6px]">
<span className="ke-track flex-1">
<span
className="ke-fill"
style={{
width: `${a.width}%`,
background: a.muted
? "linear-gradient(90deg, var(--gilt-dim), var(--gilt))"
: "linear-gradient(90deg, var(--cinnabar-dim), var(--cinnabar))",
}}
/>
</span>
<span
className="shrink-0 font-mono text-[9.5px] whitespace-nowrap"
style={{
color: a.muted
? "var(--gilt)"
: "var(--cinnabar)",
}}
>
{a.level}
</span>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</PageContainer>
</div>
</>
);
}
@@ -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 <SafetyGovernorDetail />;
case "tool_runtime":
return <ToolRuntimeDetail />;
case "personality_core":
return <PersonalityCoreDetail />;
}
}
@@ -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<PersonalityVersion>("/personality/current");
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="灵核枢"
description="人格与身份 · 每个字段均可编辑"
maxWidth="max-w-3xl"
>
{error || !persona ? (
<ErrorState message={error ?? "人格信息暂不可用。"} />
) : (
<PersonalityEditor persona={persona} />
)}
</PageContainer>
);
}
/** 太一枢:目标层级树(最高目标 → 任务,可编辑标题/优先级/状态) */
async function GoalOrchestratorDetail() {
let goals: GoalNode[] = [];
-16
View File
@@ -113,27 +113,11 @@ export default async function HomePage() {
{/* 底部功能导航 */}
<div className="mt-10 flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-xs text-muted/60">
<Link href="/personality" className="transition-colors hover:text-fg">
人格
</Link>
<Link href="/evolution" className="transition-colors hover:text-fg">
演化
</Link>
{FEATURE_FLAGS.drivePanel && (
<Link href="/drive" className="transition-colors hover:text-fg">
内驱力
</Link>
)}
{FEATURE_FLAGS.worldPanel && (
<Link href="/world" className="transition-colors hover:text-fg">
世界
</Link>
)}
{FEATURE_FLAGS.timePanel && (
<Link href="/time" className="transition-colors hover:text-fg">
时态
</Link>
)}
<Link href="/archive" className="transition-colors hover:text-fg">
归档
</Link>
@@ -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<Record<string, string>>({});
const [reason, setReason] = useState("");
const [error, setError] = useState<string | null>(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<string, unknown> = {};
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 (
<div key={field} className="px-5 py-3.5">
<div className="flex items-center justify-between">
<p className="font-serif text-xs font-medium text-muted">
{label}
{isJson && (
<span className="ml-1 text-[10px] text-muted/70">JSON</span>
)}
</p>
{!isEditing ? (
<button
type="button"
onClick={() => startEdit(field)}
className="text-[11px] text-patina hover:underline"
>
编辑
</button>
) : (
<button
type="button"
onClick={() => cancelEdit(field)}
className="text-[11px] text-muted hover:underline"
>
取消
</button>
)}
</div>
{isEditing ? (
<textarea
value={value}
onChange={(e) =>
setDraft((d) => ({ ...d, [field]: e.target.value }))
}
rows={isJson ? 4 : 3}
className="mt-1 w-full rounded-lg border border-border-glass bg-glass px-2 py-1 font-mono text-xs text-fg"
/>
) : (
<p
className={cn(
"mt-1 whitespace-pre-wrap text-sm leading-relaxed",
value ? "text-fg" : "italic text-muted",
)}
>
{value || "(未设置)"}
</p>
)}
</div>
);
}
return (
<div className="space-y-4">
<div className="glass divide-y divide-border-glass rounded-2xl">
{STRING_FIELDS.map(({ key, label }) => renderField(key, label, false))}
{JSON_FIELDS.map(({ key, label }) => renderField(key, label, true))}
</div>
{/* 统一提交区:仅当有改动字段时出现 */}
{hasEdits && (
<div className="glass rounded-2xl px-5 py-4">
<div className="flex items-center gap-2">
<p className="font-serif text-sm font-medium text-fg">
提交人格修改建议
</p>
<span className="rounded bg-cinnabar/15 px-1.5 py-0.5 text-[10px] text-cinnabar">
L4 · 需审核
</span>
<span className="ml-auto text-[10px] text-muted">
{dirtyFields.length} 项已改
</span>
</div>
<input
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="修改原因(必填,核心人格变更留痕)…"
className="mt-2 w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
/>
<div className="mt-2 flex gap-2">
<button
type="button"
onClick={submit}
disabled={busy}
className="rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
>
提交建议
</button>
<button
type="button"
onClick={() => {
setDraft({});
setReason("");
}}
className="rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
>
放弃全部
</button>
</div>
</div>
)}
{error && <p className="px-1 text-xs text-cinnabar">{error}</p>}
</div>
);
}
@@ -0,0 +1,222 @@
"use client";
import { useState } from "react";
import { HeartHandshakeIcon, ScrollTextIcon } from "@/components/ui/Icons";
import { Button } from "@/components/ui/Button";
import type { Covenant } from "@/lib/api/covenants";
import { covenantApi } from "@/lib/api";
interface Props {
characterId: string;
initialCovenant: Covenant | null;
}
const DIM_SUGGESTIONS = [
"trust",
"mentorship",
"companionship",
"creative_alliance",
];
/**
* 缘契面板:当前缘契(多维维度 + 边界)/ 无缘契时提议。
* 撤回立即生效;事件历史不可抹除——由后端裁决,本面板只呈现与操作。
*/
export function CovenantPanel({ characterId, initialCovenant }: Props) {
const [covenant, setCovenant] = useState(initialCovenant);
const [dims, setDims] = useState<Record<string, string>>({
trust: "forming",
});
const [title, setTitle] = useState("");
const [showHistory, setShowHistory] = useState(false);
const [history, setHistory] = useState<
{ id: string; type: string; actor: string; created_at: string | null }[]
>([]);
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
async function handlePropose() {
if (Object.keys(dims).length === 0) {
setNotice("至少需要一个关系维度(多维并存,禁单一好感度)。");
return;
}
setBusy(true);
try {
const created = await covenantApi.proposeCovenant({
character_id: characterId,
dimensions: dims,
primary_title: title || null,
});
setCovenant(created);
setNotice("缘契已提议,经问心与铭契后生效。");
} catch (e) {
setNotice(e instanceof Error ? e.message : "提议失败");
} finally {
setBusy(false);
}
}
async function handleSeal() {
if (!covenant) return;
setBusy(true);
try {
const sealed = await covenantApi.sealCovenant(covenant.id, {
dimensions: dims,
});
setCovenant(sealed);
setNotice("已铭契生效。");
} catch (e) {
setNotice(e instanceof Error ? e.message : "铭契失败");
} finally {
setBusy(false);
}
}
async function handleWithdraw() {
if (!covenant) return;
setBusy(true);
try {
await covenantApi.withdrawCovenant(covenant.id, "面板操作撤回");
setCovenant(null);
setNotice("已撤回(立即生效)。");
} catch (e) {
setNotice(e instanceof Error ? e.message : "撤回失败");
} finally {
setBusy(false);
}
}
async function loadHistory() {
if (!covenant) return;
const events = await covenantApi.listCovenantEvents(covenant.id);
setHistory(events);
setShowHistory(true);
}
return (
<div className="set-card">
<div className="set-card-head">
<h3 className="set-card-title flex items-center gap-2">
<HeartHandshakeIcon className="size-4 text-gold" />
缘契
</h3>
<span className="set-card-eng">Covenant</span>
</div>
<p className="set-card-desc">
多维关系与共同确认的边界;撤回立即生效,事件历史不可抹除。
</p>
{notice && <div className="mb-3 text-sm text-patina">{notice}</div>}
{covenant ? (
<div>
<div className="mb-2 flex flex-wrap items-center gap-2">
{covenant.primary_title && (
<span className="font-serif text-sm text-fg">
{covenant.primary_title}
</span>
)}
<span className="rounded-full border border-rule px-2 py-0.5 font-mono text-[10px] text-muted">
{covenant.status === "sealed"
? `已铭契 v${covenant.version}`
: "提议中"}
</span>
</div>
<div className="mb-3 flex flex-wrap gap-2">
{Object.entries(covenant.dimensions).map(([k, v]) => (
<span
key={k}
className="rounded-full border border-patina/40 px-2 py-0.5 font-mono text-[10px] text-patina"
>
{k} · {v}
</span>
))}
</div>
<div className="flex flex-wrap gap-2">
{covenant.status === "pending" && (
<Button
variant="primary"
size="sm"
onClick={handleSeal}
disabled={busy}
>
铭契生效
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={handleWithdraw}
disabled={busy}
>
撤回
</Button>
<Button variant="ghost" size="sm" onClick={loadHistory}>
<ScrollTextIcon className="mr-1 size-3.5" />
事件史
</Button>
</div>
{showHistory && (
<ul className="mt-3 divide-y divide-rule rounded-xl border border-rule">
{history.map((e) => (
<li
key={e.id}
className="flex items-center justify-between px-3 py-2 text-xs"
>
<span className="font-mono text-fg">{e.type}</span>
<span className="text-muted">
{e.actor} · {e.created_at?.slice(0, 19).replace("T", " ")}
</span>
</li>
))}
</ul>
)}
</div>
) : (
<div className="grid gap-3">
<div className="flex flex-wrap gap-2">
{DIM_SUGGESTIONS.map((d) => {
const active = d in dims;
return (
<button
key={d}
type="button"
className={`rounded-full border px-2 py-0.5 font-mono text-[10px] transition-colors ${
active
? "border-patina/60 bg-patina-wash text-patina"
: "border-rule text-muted hover:border-patina/40"
}`}
onClick={() => {
const next = { ...dims };
if (active) delete next[d];
else next[d] = "forming";
setDims(next);
}}
>
{d}
</button>
);
})}
</div>
<input
className="w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
placeholder="主称谓(可选,如:亦师亦友)"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<div>
<Button
variant="primary"
size="sm"
onClick={handlePropose}
disabled={busy}
>
提议缘契
</Button>
</div>
</div>
)}
</div>
);
}
@@ -1,88 +0,0 @@
import type { CuriosityQuestion } from "@/types/api";
const SOURCE_LABEL: Record<string, string> = {
world_uncertainty: "世界不确定",
memory_gap: "记忆缺口",
perception_change: "感知变化",
goal_tension: "目标张力",
reflection: "反思",
time_rhythm: "时间节律",
};
const SOURCE_CLASS: Record<string, string> = {
world_uncertainty: "src-world",
memory_gap: "src-memory",
perception_change: "src-percept",
goal_tension: "src-goal",
reflection: "src-reflect",
time_rhythm: "src-time",
};
/** 好奇问题(缺口记录,只读展示,无审核)设计稿 curio 布局 */
export function CuriosityItem({ curiosity }: { curiosity: CuriosityQuestion }) {
const isHi = curiosity.expected_value >= 0.7;
const riskClass =
curiosity.urgency >= 0.7
? "risk-pill L4"
: curiosity.urgency >= 0.4
? "risk-pill L2"
: "risk-pill";
const sourceCls = SOURCE_CLASS[curiosity.source] ?? "";
return (
<div className="curio">
<span
className={`curio-fire ${isHi ? "hi" : ""}`}
title={`预期信息价值 ${curiosity.expected_value.toFixed(2)}`}
/>
<div className="curio-body">
<div className="curio-q">{curiosity.question}</div>
<div className="curio-meta">
<span className={`src-tag ${sourceCls}`}>
<span className="sdot"></span>
{SOURCE_LABEL[curiosity.source] ?? curiosity.source}
</span>
<span className={riskClass}>
L
{curiosity.urgency >= 0.7
? "4"
: curiosity.urgency >= 0.4
? "2"
: "1"}
</span>
{curiosity.related_entities.length > 0 && (
<span className="ent-link">
@{curiosity.related_entities.slice(0, 2).join(" · @")}
{curiosity.related_entities.length > 2 &&
" · +" + (curiosity.related_entities.length - 2)}
</span>
)}
</div>
<div className="curio-bars">
<div className="cbar value">
<div className="ck">信息价值</div>
<div className="ctrack">
<div
className="cfill"
style={{
width: `${Math.round(Math.max(0, Math.min(1, curiosity.expected_value)) * 100)}%`,
}}
/>
</div>
</div>
<div className="cbar urg">
<div className="ck">紧迫</div>
<div className="ctrack">
<div
className="cfill"
style={{
width: `${Math.round(Math.max(0, Math.min(1, curiosity.urgency)) * 100)}%`,
}}
/>
</div>
</div>
</div>
</div>
</div>
);
}
-160
View File
@@ -1,160 +0,0 @@
"use client";
import { useState } from "react";
import { EmptyState, ErrorState } from "@/components/shared/StateViews";
import { driveApi } from "@/lib/api";
import type { CuriosityQuestion, ExploratorySubgoal } from "@/types/api";
import { describeError } from "@/lib/utils/error";
import { cn } from "@/lib/utils/cn";
import { CardGridSkeleton } from "@/components/ui/Skeleton";
import { CuriosityItem } from "./CuriosityItem";
import { SubgoalCard } from "./SubgoalCard";
type FilterStatus = "candidate" | "approved" | "deferred" | "rejected";
const FILTERS: Array<{ key: FilterStatus; label: string }> = [
{ key: "candidate", label: "待审核" },
{ key: "approved", label: "已通过" },
{ key: "deferred", label: "已延后" },
{ key: "rejected", label: "已驳回" },
];
/**
* 内驱力视图:好奇问题(只读)+ 探索性次生目标(审核)。
* 次生目标 approve/defer/reject 仅标记,本期不自动执行(v1 边界)。
* 设计稿 — 好奇萤火 2 列网格 + 探索天平 + 阴阳制衡(置于 page.tsx)。
*/
export function DriveView({
characterId,
curiosities,
subgoals: initial,
counts: initialCounts,
}: {
characterId: string;
curiosities: CuriosityQuestion[];
subgoals: ExploratorySubgoal[];
counts: Record<FilterStatus, number>;
}) {
const [filter, setFilter] = useState<FilterStatus>("candidate");
const [subgoals, setSubgoals] = useState(initial);
const [counts, setCounts] = useState(initialCounts);
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function switchFilter(next: FilterStatus) {
if (next === filter) return;
setFilter(next);
setLoading(true);
setError(null);
try {
const res = await driveApi.listSubgoals(characterId, {
status: next,
page_size: 50,
});
setSubgoals(res.items);
} catch (e) {
setError(describeError(e));
} finally {
setLoading(false);
}
}
async function action(id: string, act: "approve" | "defer" | "reject") {
setBusyId(id);
setError(null);
try {
if (act === "approve") await driveApi.approveSubgoal(id);
else if (act === "defer") await driveApi.deferSubgoal(id);
else await driveApi.rejectSubgoal(id);
setSubgoals((list) => list.filter((s) => s.id !== id));
setCounts((c) => {
const next = { ...c, candidate: Math.max(0, c.candidate - 1) };
if (act === "approve") next.approved += 1;
else if (act === "defer") next.deferred += 1;
else next.rejected += 1;
return next;
});
} catch (e) {
setError(describeError(e));
} finally {
setBusyId(null);
}
}
return (
<div className="space-y-6">
{/* 好奇萤火 */}
{curiosities.length > 0 && (
<section>
<div className="section-label" style={{ marginBottom: 14 }}>
<span className="glyph">⟡</span> 好奇{" "}
<span className="sub">由内而发的匮乏感 - 缺口记录,只读</span>
</div>
<div className="curio-grid">
{curiosities.map((c) => (
<CuriosityItem key={c.id} curiosity={c} />
))}
</div>
</section>
)}
{/* 探索性次生目标(待探 / 天平) */}
<section>
<div className="section-label" style={{ marginBottom: 14 }}>
<span className="glyph">⟡</span> 待探{" "}
<span className="sub">好奇转化的次生目标 · 增益与打扰的权衡</span>
</div>
<div className="flex flex-wrap gap-1" style={{ marginBottom: 14 }}>
{FILTERS.map(({ key, label }) => (
<button
key={key}
type="button"
onClick={() => switchFilter(key)}
className={cn(
"rounded-lg px-3 py-1.5 text-xs font-medium transition-colors",
filter === key
? "glass-strong text-patina"
: "text-muted hover:text-fg",
)}
>
{label}
<span className="ml-1.5 tabular-nums text-muted">
{counts[key]}
</span>
</button>
))}
</div>
{error && <ErrorState message={error} />}
{loading ? (
<CardGridSkeleton count={2} className="py-6" />
) : subgoals.length === 0 ? (
<EmptyState
title={filter === "candidate" ? "暂无待审核的探索目标" : "暂无目标"}
description={
filter === "candidate"
? "大周天发现世界缺口时会生成探索性次生目标到这里,供你审核。"
: undefined
}
/>
) : (
<div className="space-y-3">
{subgoals.map((s) => (
<SubgoalCard
key={s.id}
subgoal={s}
onAction={action}
busy={busyId === s.id}
/>
))}
</div>
)}
</section>
</div>
);
}
@@ -1,127 +0,0 @@
"use client";
import type { ExploratorySubgoal } from "@/types/api";
import { cn } from "@/lib/utils/cn";
const STATUS_LABEL: Record<string, string> = {
candidate: "待审核",
approved: "已通过",
deferred: "已延后",
rejected: "已驳回",
};
/** 探索天平:左盘增益 右盘成本 中间支点(risk) */
function BalanceScale({
gain,
cost,
risk,
}: {
gain: number;
cost: number;
risk: string;
}) {
const gainPct = Math.round(Math.max(0, Math.min(1, gain)) * 100);
const costPct = Math.round(Math.max(0, Math.min(1, cost)) * 100);
return (
<div className="balance">
<div className="pan gain">
<span className="plabel">预期增益</span>
<div className="ptrack">
<div className="pfill" style={{ width: `${gainPct}%` }} />
</div>
<span className="pval">{gain.toFixed(2)}</span>
</div>
<div className="fulcrum">
<div className="beam"></div>
<div className="pivot"></div>
<span className="risk">{risk}</span>
</div>
<div className="pan cost right">
<span className="plabel">打扰成本</span>
<div className="ptrack">
<div className="pfill" style={{ width: `${costPct}%` }} />
</div>
<span className="pval">{cost.toFixed(2)}</span>
</div>
</div>
);
}
export function SubgoalCard({
subgoal,
onAction,
busy,
}: {
subgoal: ExploratorySubgoal;
onAction: (id: string, action: "approve" | "defer" | "reject") => void;
busy: boolean;
}) {
const isCandidate = subgoal.status === "candidate";
return (
<div className={cn("sub-card", !isCandidate && "deferred")}>
<div className="sub-top">
<span className="sub-from">
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
width={11}
height={11}
>
<path d="M8 2v8M5 7l3 3 3-3M3 13h10" />
</svg>
好奇 → 探索
</span>
<span className="sub-title">{subgoal.title}</span>
<span
className={cn(
"sub-status",
isCandidate && "candidate",
subgoal.status === "approved" && "approved",
subgoal.status === "deferred" && "deferred",
)}
>
{STATUS_LABEL[subgoal.status] ?? subgoal.status}
</span>
</div>
<div className="sub-desc">{subgoal.description}</div>
<BalanceScale
gain={subgoal.expected_information_gain}
cost={subgoal.interruption_cost}
risk={subgoal.risk_level}
/>
{isCandidate && (
<div className="sub-actions">
<span className="vbtn note">approve 仅标记 · 本期不自动执行</span>
<button
type="button"
onClick={() => onAction(subgoal.id, "defer")}
disabled={busy}
className="vbtn"
>
延后
</button>
<button
type="button"
onClick={() => onAction(subgoal.id, "reject")}
disabled={busy}
className="vbtn"
>
驳回
</button>
<button
type="button"
onClick={() => onAction(subgoal.id, "approve")}
disabled={busy}
className="vbtn patina"
>
{busy ? "处理中…" : "准予探索"}
</button>
</div>
)}
</div>
);
}
+5 -23
View File
@@ -10,15 +10,10 @@ import {
MemoryIcon,
ChatIcon,
CharacterIcon,
CosmosIcon,
DriveIcon,
ModelIcon,
PersonalityIcon,
RunIcon,
SettingsIcon,
ToolsIcon,
WorldIcon,
WuxingIcon,
} from "@/components/ui/Icons";
import { Badge } from "@/components/ui/Badge";
import { FEATURE_FLAGS } from "@/lib/config";
@@ -43,38 +38,25 @@ interface NavGroup {
}
/**
* 主导航分组:灵台(对话/轨迹)、法身(人物/人格/演化)、
* 内景(内驱力/世界/时态)、藏识(记忆/归档)、百工(工具/模型/设置)。
* 每组不超过 4 项,符合 product-ui §1 导航 IA ≤8 项的建议;
* 分组后侧栏视觉有序,降低认知负荷。
* 主导航分组:灵台(镜树)、法身(灵胚/人物)、内景(世界)、
* 藏识(记忆/归档)、百工(工具/神识阁/设置)。
* 旧人格/内驱力/五行/灵枢/轨迹页随 M6 退役(人格=道体锚点+缘契,轨迹并入镜树)。
*/
const NAV_GROUPS: NavGroup[] = [
{
title: "灵台",
items: [
{ href: "/trees", label: "镜树", icon: <ChatIcon /> },
{ href: "/runs", label: "轨迹", icon: <RunIcon /> },
],
items: [{ href: "/trees", label: "镜树", icon: <ChatIcon /> }],
},
{
title: "法身",
items: [
{ href: "/embryos", label: "灵胚", icon: <CharacterIcon /> },
{ href: "/cosmos", label: "灵枢", icon: <CosmosIcon /> },
{ href: "/wuxing", label: "五行", icon: <WuxingIcon /> },
{ href: "/characters", label: "人物", icon: <CharacterIcon /> },
{ href: "/personality", label: "人格", icon: <PersonalityIcon /> },
],
},
{
title: "内景",
items: [
{
href: "/drive",
label: "内驱力",
icon: <DriveIcon />,
enabled: FEATURE_FLAGS.drivePanel,
},
{
href: "/world",
label: "世界",
@@ -86,7 +68,7 @@ const NAV_GROUPS: NavGroup[] = [
{
title: "藏识",
items: [
{ href: "/memory", label: "记忆", icon: <MemoryIcon /> },
{ href: "/memories", label: "记忆", icon: <MemoryIcon /> },
{ href: "/archive", label: "归档", icon: <ArchiveIcon /> },
],
},
@@ -1,80 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/Button";
import { personalityApi } from "@/lib/api";
import { describeError } from "@/lib/utils/error";
import type { PersonalityProposal } from "@/types/api";
/**
* 待审提案即时卡片:因后端暂无 GET /personality/proposals 列表端点,
* 仅展示刚提交(由 PersonalityEditor onProposed 传入)的建议,支持即时 approve/reject。
* 完整审批墙待后端补端点后实现。审核后 router.refresh()。
*/
export function PendingProposalCard({
proposal,
onResolved,
}: {
proposal: PersonalityProposal;
onResolved?: () => void;
}) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const fieldKeys = Object.keys(proposal.fields ?? {});
async function resolve(approve: boolean) {
setBusy(true);
setError(null);
try {
if (approve) await personalityApi.approveProposal(proposal.proposal_id);
else await personalityApi.rejectProposal(proposal.proposal_id);
onResolved?.();
router.refresh();
} catch (e) {
setError(describeError(e));
} finally {
setBusy(false);
}
}
return (
<div className="glass rounded-2xl px-5 py-4 ring-1 ring-cinnabar/30">
<div className="flex items-center gap-2">
<p className="font-serif text-sm font-semibold text-fg">待审人格建议</p>
<span className="rounded bg-cinnabar/15 px-1.5 py-0.5 text-[10px] text-cinnabar">
pending
</span>
</div>
<p className="mt-1.5 text-sm text-fg/90">
{proposal.reason || "(未填写原因)"}
</p>
{fieldKeys.length > 0 && (
<p className="mt-1 text-xs text-muted">
涉及字段:{fieldKeys.join("、")}
</p>
)}
<div className="mt-3 flex gap-2">
<Button
variant="primary"
size="sm"
loading={busy}
onClick={() => resolve(true)}
>
通过
</Button>
<Button
variant="danger"
size="sm"
loading={busy}
onClick={() => resolve(false)}
>
驳回
</Button>
</div>
{error && <p className="mt-2 text-xs text-cinnabar">{error}</p>}
</div>
);
}
@@ -1,78 +0,0 @@
import { Card } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { BronzeMirror } from "@/components/ui/BronzeMirror";
import { EmptyState } from "@/components/shared/StateViews";
import { formatDateTime } from "@/lib/utils/format";
import type { PersonalityVersion } from "@/types/api";
const FIELDS = [
{ key: "identity_profile", label: "魂册身份" },
{ key: "role_destiny", label: "命格" },
{ key: "personality_seed", label: "人格种子" },
{ key: "expression_style", label: "表达风格" },
{ key: "mood_baseline", label: "情绪基线" },
{ key: "relationship_pattern", label: "关系模式" },
] as const;
/** 当前人格只读摘要:顶部九枢铜镜作为人格主视觉 */
export function PersonalitySummary({
personality,
}: {
personality: PersonalityVersion | null;
}) {
if (!personality) {
return (
<EmptyState
title="尚未生成人格"
description="当前人物尚未初始化人格版本,待后端写入后此处将展示只读摘要。"
/>
);
}
const hasAnyField = FIELDS.some((f) => personality[f.key]);
return (
<Card className="p-5">
<div className="mb-5 flex flex-col items-center gap-3">
<BronzeMirror size={88} state="steady" />
<div className="text-center">
<h2 className="font-serif text-xl font-semibold">当前人格</h2>
<p className="mt-0.5 text-xs text-muted">
版本 v{personality.version}
</p>
</div>
<Badge tone="accent">只读</Badge>
</div>
{personality.summary && (
<p className="mb-4 whitespace-pre-wrap text-sm text-fg/90">
{personality.summary}
</p>
)}
{hasAnyField ? (
<dl className="grid grid-cols-1 gap-x-6 gap-y-3 text-sm sm:grid-cols-2">
{FIELDS.map(({ key, label }) => {
const value = personality[key];
return (
<div key={key}>
<dt className="text-xs text-muted">{label}</dt>
<dd className="mt-0.5 whitespace-pre-wrap text-fg">
{value || "—"}
</dd>
</div>
);
})}
</dl>
) : (
<p className="text-sm text-muted">当前人格版本暂无可展示的明细字段。</p>
)}
{personality.created_at && (
<p className="mt-4 border-t border-border-glass pt-2 text-xs text-muted">
生成时间:{formatDateTime(personality.created_at)}
</p>
)}
</Card>
);
}
@@ -1,118 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { personalityApi } from "@/lib/api";
import { describeError } from "@/lib/utils/error";
import { formatDateTime } from "@/lib/utils/format";
import { cn } from "@/lib/utils/cn";
import type { PersonalityVersion } from "@/types/api";
/**
* 人格版本时间线:按版本号倒序展示历史,当前版本高亮;
* 非当前版本可「回滚」(二次确认)。回滚后 router.refresh() 以服务端数据为准。
*/
export function PersonalityVersionTimeline({
versions,
currentId,
}: {
versions: PersonalityVersion[];
currentId?: string | null;
}) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [confirm, setConfirm] = useState<PersonalityVersion | null>(null);
const ordered = [...versions].sort((a, b) => b.version - a.version);
async function rollback() {
if (!confirm) return;
setBusy(true);
setError(null);
try {
await personalityApi.rollbackPersonality(confirm.id);
setConfirm(null);
router.refresh();
} catch (e) {
setError(describeError(e));
} finally {
setBusy(false);
}
}
return (
<div className="space-y-3">
{ordered.map((v) => {
const isCurrent = v.id === currentId;
return (
<div
key={v.id}
className={cn(
"glass rounded-2xl px-5 py-4",
isCurrent && "ring-2 ring-patina/40",
)}
>
<div className="flex flex-wrap items-center gap-2">
<span className="font-serif text-sm font-semibold text-fg">
v{v.version}
</span>
{isCurrent && (
<span className="rounded bg-patina/15 px-1.5 py-0.5 text-[10px] text-patina">
当前
</span>
)}
<span className="text-[11px] text-muted">
{v.created_by ? `${v.created_by} · ` : ""}
{formatDateTime(v.created_at)}
</span>
</div>
{v.reason && (
<p className="mt-1.5 text-sm text-fg/90">{v.reason}</p>
)}
{v.identity_profile && (
<p className="mt-1 line-clamp-2 text-xs text-muted">
{v.identity_profile}
</p>
)}
{!isCurrent && (
<div className="mt-2">
<Button variant="ghost" size="sm" onClick={() => setConfirm(v)}>
回滚到此版本
</Button>
</div>
)}
</div>
);
})}
<Modal
open={confirm !== null}
onClose={() => setConfirm(null)}
title={`回滚到 v${confirm?.version ?? ""}?`}
footer={
<>
<Button variant="ghost" size="sm" onClick={() => setConfirm(null)}>
取消
</Button>
<Button
variant="danger"
size="sm"
loading={busy}
onClick={rollback}
>
确认回滚
</Button>
</>
}
>
回滚后当前人格指针将切换至此版本,被取代的版本会标记为已回滚。
</Modal>
{error && <p className="text-xs text-cinnabar">{error}</p>}
</div>
);
}
@@ -1,222 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { PersonalityEditor } from "@/components/admin/PersonalityEditor";
import { PendingProposalCard } from "@/components/personality/PendingProposalCard";
import { PersonalityVersionTimeline } from "@/components/personality/PersonalityVersionTimeline";
import { cn } from "@/lib/utils/cn";
import type { PersonalityProposal, PersonalityVersion } from "@/types/api";
interface MirrorItem {
glyph: string;
name: string;
src: string;
tier: string;
}
/**
* 十镜映射(对齐 personality-core §7):根据后端字段值判断落地/预留。
*/
const MIRRORS: MirrorItem[] = [
{ glyph: "本", name: "本我镜", src: "identity_profile", tier: "L4" },
{ glyph: "命", name: "策命镜", src: "role_destiny", tier: "L4" },
{ glyph: "心", name: "明心镜", src: "values", tier: "L4" },
{ glyph: "波", name: "情波镜", src: "emotion_baseline", tier: "L2" },
{ glyph: "缘", name: "缘生镜", src: "relationship_pattern", tier: "L1" },
{ glyph: "作", name: "匠作镜", src: "expression_style", tier: "L1" },
{ glyph: "识", name: "慧识镜", src: "reasoning_style", tier: "L2" },
{ glyph: "世", name: "观世镜", src: "world_focus", tier: "L2" },
{ glyph: "律", name: "戒律镜", src: "safety_constraints", tier: "L4" },
{ glyph: "照", name: "返照镜", src: "reflection_goals", tier: "L3" },
];
/** traits 可读标签 */
const TRAIT_LABELS: Record<string, string> = {
openness: "开放",
conscientiousness: "尽责",
extraversion: "外向",
agreeableness: "宜人",
neuroticism: "情绪",
};
/**
* 人格工作区:十镜网格 + 种子带 + 明德条 + 编辑器 + 版本时间线。
* 设计稿 personality-preview.html 对齐 personality-core §7/§8。
*/
export function PersonalityWorkspace({
personality,
versions,
}: {
personality: PersonalityVersion;
versions: PersonalityVersion[];
}) {
const [pending, setPending] = useState<PersonalityProposal | null>(null);
// 十镜数据:给每个镜子注入实际字段值
const mirrors = useMemo(
() =>
MIRRORS.map((m) => ({
...m,
val: personality[m.src as keyof PersonalityVersion] as
| string
| undefined
| null,
})),
[personality],
);
// 提取 traits 权重条
const traits = personality.traits as
| Record<string, number>
| undefined
| null;
return (
<div className="space-y-5">
{/* ═══ 种子带(设计稿 seed-band)═══ */}
<div className="seed-band">
<div className="seed-mirror">
<span className="glyph">种</span>
</div>
<div className="seed-body">
<div className="seed-lab">人格种子 · personality_seed</div>
<div className="seed-text">
{personality.personality_seed || "未设定"}
</div>
</div>
<div className="tier-legend">
<span>
<b className="l1">L1</b> 表达 / 关系
</span>
<span>
<b className="l2">L2</b> 特质 / 情绪
</span>
<span>
<b className="l4">L4</b> 身份 / 命格 / 价值
</span>
</div>
</div>
{/* ═══ 十镜网格(设计稿 mirror-grid)═══ */}
<section>
<div className="section-label">
<span className="glyph">⟡</span> 十镜{" "}
<span className="sub">人格面板的 10 视角切片</span>
</div>
<div className="mirror-grid">
{mirrors.map((m) => {
const landed = !!m.val;
return (
<div key={m.src} className={cn("mcell", !landed && "reserved")}>
<div className="mc-head">
<div className="mc-glyph">{m.glyph}</div>
<div className="mc-titles">
<div className="mc-name">{m.name}</div>
<div className="mc-src">{m.src}</div>
</div>
<span className={cn("mc-status", landed && "landed")}>
{landed ? m.tier : "预留"}
</span>
</div>
{landed ? (
<div className="mc-val">
<span className="em">
{typeof m.val === "string" && m.val.length > 80
? m.val.slice(0, 80) + "…"
: typeof m.val === "object" && m.val !== null
? JSON.stringify(m.val).slice(0, 80)
: String(m.val ?? "")}
</span>
</div>
) : (
<div className="mc-val">待完整十镜落地</div>
)}
</div>
);
})}
</div>
</section>
{/* ═══ 人格权重(traits 条)═══ */}
{traits && Object.keys(traits).length > 0 && (
<section>
<h3 className="section-heading">人格权重</h3>
<div
className="mirror-grid"
style={{ gridTemplateColumns: "repeat(4, 1fr)", gap: 14 }}
>
{Object.entries(traits).map(([key, val]) => {
const label = TRAIT_LABELS[key] ?? key;
const pct = Math.min(
100,
Math.max(0, Math.round((val as number) * 100)),
);
const grade = pct >= 70 ? "高" : pct >= 40 ? "中" : "低";
return (
<div
key={key}
className="mcell landed"
style={{ minHeight: "auto" }}
>
<div className="mc-bars">
<div className="br">
<span className="tn">{label}</span>
<span className="tbar">
<span className="fl" style={{ width: `${pct}%` }} />
</span>
<span className="tv">{grade}</span>
</div>
</div>
</div>
);
})}
</div>
</section>
)}
{/* ═══ 明德恒定层(预留提示,设计稿 mingde-strip)═══ */}
<div className="mingde-strip">
<span className="seal">明德三镜</span>
<span className="desc">
道体恒定层(<code>spiritual_core</code>:见地 / 明心 /
守正)是世界观、价值观、道德观的结构化内核。 属 L4
人工确认,尚未落地独立字段。
</span>
<span className="tag">预留 · 待落地</span>
</div>
{/* ═══ 下方双栏:编辑器 + 版本层积 ═══ */}
<div className="lower-region">
{/* 左主栏:编辑器 + 待审卡片 */}
<div className="lower-main">
<PersonalityEditor
persona={personality}
onProposed={(p) => setPending(p)}
/>
{pending && pending.status === "pending" && (
<PendingProposalCard
proposal={pending}
onResolved={() => setPending(null)}
/>
)}
</div>
{/* 右栏:版本层积(设计稿 strata-rail) */}
{versions.length > 0 && (
<div className="strata-rail">
<div className="strata-card">
<h3 className="section-heading" style={{ marginBottom: 12 }}>
版本层积
</h3>
<PersonalityVersionTimeline
versions={versions}
currentId={personality.id}
/>
</div>
</div>
)}
</div>
</div>
);
}
@@ -1,173 +0,0 @@
"use client";
import { useRef, useState } from "react";
import { EventCard } from "./EventCard";
import { PlanTraceCard } from "./PlanTraceCard";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { RunStatusBadge } from "@/components/ui/StatusBadge";
import { EmptyState } from "@/components/shared/StateViews";
import { runsApi } from "@/lib/api";
import { useRunStream } from "@/lib/hooks/useRunStream";
import { formatDateTime } from "@/lib/utils/format";
import type { Run, RunEvent } from "@/types/api";
import type { RunStatus } from "@/types/domain";
/**
* 运行轨迹视图:承接 Server Component 预取的 run + 事件,
* 并通过 SSE 实时追加新事件、同步 run 状态。
*/
export function RunTraceView({
runId,
initialRun,
initialEvents,
}: {
runId: string;
initialRun: Run | null;
initialEvents: RunEvent[];
}) {
const [run, setRun] = useState<Run | null>(initialRun);
const [events, setEvents] = useState<RunEvent[]>(initialEvents);
const seenSequences = useRef<Set<number>>(
new Set(initialEvents.map((e) => e.sequence)),
);
useRunStream(runId, {
onEvent: (event) => {
if (event.type === "heartbeat") return;
if (seenSequences.current.has(event.sequence)) return;
seenSequences.current.add(event.sequence);
setEvents((prev) =>
[...prev, event].sort((a, b) => a.sequence - b.sequence),
);
const nextStatus = statusFromEvent(event.type);
if (nextStatus) {
setRun((prev) => (prev ? { ...prev, status: nextStatus } : prev));
}
},
});
const [cancelling, setCancelling] = useState(false);
const canCancel =
run?.status === "queued" ||
run?.status === "running" ||
run?.status === "waiting_confirmation";
async function handleCancel() {
setCancelling(true);
try {
const res = await runsApi.cancelRun(runId);
// 乐观更新为后端返回状态;SSE run.cancelled 事件到达后会再次对齐
setRun((prev) =>
prev ? { ...prev, status: res.status as RunStatus } : prev,
);
} catch {
/* 取消失败不阻断:SSE 事件流会反馈最终状态 */
} finally {
setCancelling(false);
}
}
return (
<div className="space-y-4">
<Card className="p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<h2 className="font-mono text-sm text-fg/80">{runId}</h2>
{run && (
<p className="mt-1 text-xs text-muted">
{[
run.goal ? `目标:${run.goal}` : null,
run.model_hint ? `模型:${run.model_hint}` : null,
run.character_id ? `人物:${run.character_id}` : null,
]
.filter(Boolean)
.join(" · ")}
</p>
)}
</div>
<div className="flex items-center gap-2">
<RunStatusBadge status={run?.status ?? "queued"} />
{canCancel && (
<Button
size="sm"
variant="ghost"
onClick={handleCancel}
loading={cancelling}
>
取消运行
</Button>
)}
</div>
</div>
{run && (
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 font-mono text-xs text-muted">
<span>开始:{formatDateTime(run.started_at)}</span>
<span>结束:{formatDateTime(run.completed_at)}</span>
{durationLabel(run) && <span>耗时:{durationLabel(run)}</span>}
{run.usage?.total_tokens != null && (
<span>
token:{run.usage.total_tokens}
{run.usage.prompt_tokens != null &&
`(入 ${run.usage.prompt_tokens} / 出 ${run.usage.completion_tokens ?? "?"})`}
</span>
)}
</div>
)}
{run?.error_message && (
<p className="mt-2 rounded-md bg-cinnabar/10 px-2 py-1 text-xs text-cinnabar backdrop-blur-sm">
{run.error_message}
</p>
)}
</Card>
<PlanTraceCard events={events} />
<Card className="p-4">
<h3 className="mb-3 font-serif text-base font-semibold">事件轨迹</h3>
{events.length === 0 ? (
<EmptyState
title="暂无事件"
description="Run 启动后事件将按 sequence 顺序实时追加。"
/>
) : (
<div className="space-y-3">
{events.map((event) => (
<EventCard key={event.event_id} event={event} />
))}
</div>
)}
</Card>
</div>
);
}
function statusFromEvent(type: string): RunStatus | null {
switch (type) {
case "run.started":
return "running";
case "run.completed":
return "completed";
case "run.failed":
return "failed";
case "run.cancelled":
return "cancelled";
default:
return null;
}
}
/** 计算运行耗时(started → completed),不足 1s 显示 ms */
function durationLabel(run: Run): string | null {
if (!run.started_at || !run.completed_at) return null;
const ms =
new Date(run.completed_at).getTime() - new Date(run.started_at).getTime();
if (!Number.isFinite(ms) || ms < 0) return null;
if (ms < 1000) return `${ms}ms`;
const s = Math.round(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
return `${m}m ${s % 60}s`;
}
+72
View File
@@ -976,3 +976,75 @@ export function ShieldCheckIcon(props: IconProps) {
</svg>
);
}
/** 境界(Lucide Globe) */
export function GlobeIcon(props: IconProps) {
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
aria-hidden
{...props}
>
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="1.6" />
<path
d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"
stroke="currentColor"
strokeWidth="1.6"
/>
<path d="M2 12h20" stroke="currentColor" strokeWidth="1.6" />
</svg>
);
}
/** 地点(Lucide MapPin) */
export function MapPinIcon(props: IconProps) {
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
aria-hidden
{...props}
>
<path
d="M20 10c0 4.99-5.54 10.19-7.4 11.79a1 1 0 0 1-1.2 0C9.54 20.19 4 14.99 4 10a8 8 0 0 1 16 0"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<circle cx="12" cy="10" r="3" stroke="currentColor" strokeWidth="1.6" />
</svg>
);
}
/** 缘契(Lucide HeartHandshake) */
export function HeartHandshakeIcon(props: IconProps) {
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
aria-hidden
{...props}
>
<path
d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.29 1.51 4.04 3 5.5l7 7z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<path
d="M12 5 9.04 7.96a2.17 2.17 0 0 0 0 3.08c.82.82 2.13.85 3 .07l2.07-1.9a2.5 2.5 0 0 1 3.44 0l.49.46"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -1,348 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import type { WorldEntity, WorldRelation } from "@/types/api";
interface Node {
id: string;
name: string;
type: string;
x: number;
y: number;
}
/** 简单力导向布局:将实体分布在同心圆上 */
function computeLayout(
entities: WorldEntity[],
_relations: WorldRelation[],
width: number,
height: number,
): Node[] {
const centerX = width / 2;
const centerY = height / 2;
const radius = Math.min(width, height) * 0.35;
return entities.map((e, i) => {
const angle = (2 * Math.PI * i) / entities.length - Math.PI / 2;
return {
id: e.id,
name: e.name,
type: e.type,
x: centerX + radius * Math.cos(angle),
y: centerY + radius * Math.sin(angle),
};
});
}
/** 节点颜色映射(与设计稿 ent-disc 色系一致) */
function nodeColor(type: string): string {
const map: Record<string, string> = {
person: "var(--gilt)",
place: "var(--patina)",
object: "var(--cinnabar)",
concept: "var(--patina-bright)",
device: "var(--fg-dim)",
project: "var(--cinnabar-dim)",
task: "var(--patina-bright)",
};
return map[type] ?? "var(--muted)";
}
/** 节点字形映射 */
function nodeGlyph(type: string): string {
const map: Record<string, string> = {
person: "人",
self: "我",
place: "境",
object: "器",
concept: "念",
device: "器",
project: "业",
task: "事",
};
return map[type] ?? "·";
}
const TYPE_LABEL: Record<string, string> = {
self: "self · 自身",
person: "person · 人物",
project: "project · 事务",
concept: "concept · 概念",
device: "device · 设备",
place: "place · 地点",
object: "object · 物品",
task: "task · 任务",
};
const STATE_LABEL: Record<string, string> = {
fact: "事实",
observation: "观察",
inference: "推断",
hypothesis: "假设",
stale: "过期",
};
const STATE_COLOR: Record<string, string> = {
fact: "var(--patina)",
observation: "var(--patina-bright)",
inference: "var(--gilt)",
hypothesis: "var(--cinnabar)",
stale: "var(--fg-faint)",
};
/**
* 河图关系图谱(设计稿 world-preview.html §graph-region)。
* 带图例 + 状态分布侧栏;节点为字形 + type 色,关系为边(predicate 标签)。
*/
export function WorldRelationGraph({
entities,
relations,
}: {
entities: WorldEntity[];
relations: WorldRelation[];
}) {
const [hoveredNode, setHoveredNode] = useState<string | null>(null);
const W = 480;
const H = 320;
const nodes = useMemo(
() => computeLayout(entities, relations, W, H),
[entities, relations],
);
const nodeMap = useMemo(
() => Object.fromEntries(nodes.map((n) => [n.id, n])),
[nodes],
);
const connectedIds = useMemo(() => {
const set = new Set<string>();
relations.forEach((r) => {
set.add(r.subject_id);
set.add(r.object_id);
});
return set;
}, [relations]);
/* 状态分布 */
const stateDist = useMemo(() => {
const counts: Record<string, number> = {};
for (const e of entities) {
counts[e.state_type] = (counts[e.state_type] || 0) + 1;
}
const total = entities.length;
const order = ["fact", "observation", "inference", "hypothesis", "stale"];
return order
.filter((k) => counts[k])
.map((k) => ({
key: k,
label: STATE_LABEL[k] ?? k,
count: counts[k],
pct: total > 0 ? Math.round((counts[k] / total) * 100) : 0,
color: STATE_COLOR[k] ?? "var(--fg-faint)",
}));
}, [entities]);
if (entities.length === 0) {
return (
<div className="flex items-center justify-center rounded-xl border border-dashed border-border-glass py-12 text-sm text-muted">
暂无实体,图谱为空
</div>
);
}
if (relations.length === 0) {
return (
<div className="flex items-center justify-center rounded-xl border border-dashed border-border-glass py-12 text-sm text-muted">
暂无关系,新增关系后图谱自动生成
</div>
);
}
/* 唯一样式列表 */
const types = [...new Set(entities.map((e) => e.type))];
return (
<div className="graph-region">
<div className="graph-card">
<svg
viewBox={`0 0 ${W} ${H}`}
className="graph-svg"
role="img"
aria-label="河图关系图谱"
>
{/* 边 */}
{relations.map((r) => {
const src = nodeMap[r.subject_id];
const dst = nodeMap[r.object_id];
if (!src || !dst) return null;
const highlight =
hoveredNode === r.subject_id || hoveredNode === r.object_id;
const hi = r.confidence >= 0.8;
const mid = r.confidence >= 0.5 && r.confidence < 0.8;
const low = r.confidence < 0.5;
return (
<g key={r.id}>
<line
x1={src.x}
y1={src.y}
x2={dst.x}
y2={dst.y}
stroke={
highlight
? "var(--gilt)"
: low
? "var(--cinnabar-dim)"
: "var(--patina-dim)"
}
strokeWidth={highlight ? 2.5 : Math.max(1, r.confidence * 3)}
strokeDasharray={low ? "5 4" : undefined}
className={
low
? "edge tent"
: hi
? "edge hi"
: mid
? "edge mid"
: "edge"
}
/>
{/* 边的中点标签 */}
<g>
<rect
x={(src.x + dst.x) / 2 - r.predicate.length * 3.5}
y={(src.y + dst.y) / 2 - 10}
width={r.predicate.length * 7 + 4}
height={14}
rx={3}
fill="var(--ink)"
stroke="var(--rule)"
strokeWidth={0.5}
/>
<text
x={(src.x + dst.x) / 2}
y={(src.y + dst.y) / 2 - 3}
textAnchor="middle"
fontSize="9"
fill="var(--fg-faint)"
fontFamily='"JetBrains Mono", ui-monospace, monospace'
>
{r.predicate}
</text>
</g>
</g>
);
})}
{/* 节点 */}
{nodes.map((node) => {
const hasEdges = connectedIds.has(node.id);
const highlight = hoveredNode === node.id;
const color = nodeColor(node.type);
const opacity = hasEdges ? 1 : 0.55;
return (
<g
key={node.id}
onMouseEnter={() => setHoveredNode(node.id)}
onMouseLeave={() => setHoveredNode(null)}
style={{ cursor: "pointer" }}
opacity={opacity}
>
<circle
cx={node.x}
cy={node.y}
r={highlight ? 26 : 24}
fill="var(--ink)"
stroke={color}
strokeWidth={highlight ? 2 : 1.5}
/>
<circle
cx={node.x}
cy={node.y}
r={highlight ? 26 : 24}
fill={color}
fillOpacity={0.16}
stroke="none"
/>
<text
x={node.x}
y={node.y}
textAnchor="middle"
dominantBaseline="central"
fontSize="16"
fontWeight={700}
fontFamily='"Songti SC", serif'
fill={color}
>
{nodeGlyph(node.type)}
</text>
<text
x={node.x}
y={node.y + 20}
textAnchor="middle"
fontSize="10"
fill="var(--fg-dim)"
fontFamily="inherit"
>
{node.name.slice(0, 8)}
</text>
</g>
);
})}
</svg>
</div>
{/* 图谱侧栏:图例 + 状态分布 */}
<div className="graph-side">
<div className="side-card">
<div className="side-title">图例</div>
{types.map((t) => (
<div key={t} className="legend-row">
<span
className="legend-dot"
style={{
borderColor: nodeColor(t),
background: `color-mix(in srgb, ${nodeColor(t)} 16%, transparent)`,
}}
/>
{TYPE_LABEL[t] ?? t}
</div>
))}
<div style={{ marginTop: 6 }}>
<div className="legend-row">
<span className="legend-line"></span>
高置信关系
</div>
<div className="legend-row">
<span className="legend-line tent"></span>
假设态 · 待验
</div>
</div>
</div>
<div className="side-card">
<div className="side-title">状态分布</div>
{stateDist.map((d) => (
<div key={d.key} className="dist-bar">
<span className="dk">{d.label}</span>
<div className="dtrack">
<div
className="dfill"
style={{
width: `${d.pct}%`,
background: d.color,
opacity:
d.key === "hypothesis"
? 0.8
: d.key === "stale"
? 1
: undefined,
}}
/>
</div>
<span className="dv">{d.count}</span>
</div>
))}
</div>
</div>
</div>
);
}
@@ -1,189 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/Button";
import { Card, CardHeader } from "@/components/ui/Card";
import { worldApi } from "@/lib/api";
import { describeError } from "@/lib/utils/error";
import type { WorldEntity, WorldRelation } from "@/types/api";
/** 关系列表项 — 带删除 */
function RelationRow({
relation,
entityName,
onDelete,
busy,
}: {
relation: WorldRelation;
entityName: (id: string) => string;
onDelete: (id: string) => void;
busy: boolean;
}) {
return (
<li className="flex flex-wrap items-center gap-2 rounded-lg bg-glass/40 px-3 py-2 text-sm">
<span className="font-medium text-fg">
{entityName(relation.subject_id)}
</span>
<span className="rounded bg-gold/15 px-1.5 py-0.5 text-xs text-gold">
{relation.predicate}
</span>
<span className="font-medium text-fg">
{entityName(relation.object_id)}
</span>
<span className="font-mono text-[10px] text-muted">
{relation.confidence.toFixed(2)}
</span>
<button
type="button"
onClick={() => onDelete(relation.id)}
disabled={busy}
className="ml-auto text-[11px] text-cinnabar hover:underline disabled:opacity-50"
>
删除
</button>
</li>
);
}
/**
* 世界关系管理(河图枢,独立 /world/relations)。
* 列出 subject-predicate-object 三元组;新建时从实体列表选主体/客体 + 填谓词。
* 创建后 router.refresh()。实体本身的 CRUD 由 WorldEntityList(admin 域)承担。
*/
export function WorldRelationManager({
relations,
entities,
}: {
relations: WorldRelation[];
entities: WorldEntity[];
}) {
const router = useRouter();
const [showAdd, setShowAdd] = useState(false);
const [subjectId, setSubjectId] = useState("");
const [predicate, setPredicate] = useState("");
const [objectId, setObjectId] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [relationList, setRelationList] = useState(relations);
const entityName = (id: string) =>
entities.find((e) => e.id === id)?.name ?? id;
async function add() {
if (!subjectId || !predicate.trim() || !objectId) {
setError("请选择主体与客体,并填写关系谓词。");
return;
}
setBusy(true);
setError(null);
try {
await worldApi.createWorldRelation({
subject_id: subjectId,
predicate: predicate.trim(),
object_id: objectId,
});
setShowAdd(false);
setSubjectId("");
setPredicate("");
setObjectId("");
router.refresh();
} catch (e) {
setError(describeError(e));
} finally {
setBusy(false);
}
}
async function remove(id: string) {
setBusy(true);
setError(null);
try {
await worldApi.deleteWorldRelation(id);
setRelationList((p) => p.filter((r) => r.id !== id));
} catch (e) {
setError(describeError(e));
} finally {
setBusy(false);
}
}
const inputCls =
"rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg";
return (
<Card className="p-5">
<CardHeader
title="世界关系"
subtitle="实体间的三元组(主体—谓词—客体),独立 /world/relations 域。"
action={
<Button
variant="ghost"
size="sm"
onClick={() => setShowAdd((s) => !s)}
>
{showAdd ? "收起" : "+ 新增关系"}
</Button>
}
/>
{showAdd && (
<div className="mb-3 rounded-xl border border-border-glass bg-glass/50 p-3">
<div className="flex flex-wrap items-center gap-2">
<select
value={subjectId}
onChange={(e) => setSubjectId(e.target.value)}
className={inputCls}
aria-label="关系主体"
>
<option value="">主体…</option>
{entities.map((e) => (
<option key={e.id} value={e.id}>
{e.name}
</option>
))}
</select>
<input
value={predicate}
onChange={(e) => setPredicate(e.target.value)}
placeholder="谓词(如 栖居于)"
className={`${inputCls} min-w-[8rem] flex-1`}
/>
<select
value={objectId}
onChange={(e) => setObjectId(e.target.value)}
className={inputCls}
aria-label="关系客体"
>
<option value="">客体…</option>
{entities.map((e) => (
<option key={e.id} value={e.id}>
{e.name}
</option>
))}
</select>
<Button variant="primary" size="sm" loading={busy} onClick={add}>
新增
</Button>
</div>
</div>
)}
{relationList.length === 0 ? (
<p className="text-sm text-muted">暂无关系。新增以连接世界实体。</p>
) : (
<ul className="space-y-2">
{relationList.map((r) => (
<RelationRow
key={r.id}
relation={r}
entityName={entityName}
onDelete={remove}
busy={busy}
/>
))}
</ul>
)}
{error && <p className="mt-2 text-xs text-cinnabar">{error}</p>}
</Card>
);
}
@@ -0,0 +1,244 @@
"use client";
import { useState } from "react";
import {
GlobeIcon,
MapPinIcon,
PlusIcon,
ScrollTextIcon,
} from "@/components/ui/Icons";
import { Button } from "@/components/ui/Button";
import type { PersistentWorld, WorldEntity } from "@/lib/api/worlds";
import { worldApi } from "@/lib/api";
import { cn } from "@/lib/utils/cn";
interface Props {
initialWorlds: PersistentWorld[];
error: string | null;
}
const KIND_LABEL: Record<string, string> = {
character: "人物",
location: "地点",
organization: "组织",
item: "物品",
concept: "概念",
};
/**
* 世界工作台:境界目录 + 详情(实体/关系/正史概览)。
* 境界正史(canonizations scope=persistent_world)只读呈现——境界不进真界本相。
*/
export function WorldWorkbench({ initialWorlds, error }: Props) {
const [worlds, setWorlds] = useState(initialWorlds);
const [selectedId, setSelectedId] = useState<string | null>(
initialWorlds[0]?.id ?? null,
);
const [entities, setEntities] = useState<WorldEntity[]>([]);
const [chronicleCount, setChronicleCount] = useState(0);
const [showCreate, setShowCreate] = useState(initialWorlds.length === 0);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const selected = worlds.find((w) => w.id === selectedId) ?? null;
async function selectWorld(id: string) {
setSelectedId(id);
setEntities([]);
setChronicleCount(0);
try {
const [ents, chronicle] = await Promise.all([
worldApi.listWorldEntities(id),
worldApi.worldChronicle(id),
]);
setEntities(ents);
setChronicleCount(chronicle.entries.length);
} catch {
/* 详情失败保持空态 */
}
}
async function handleCreate() {
if (!name.trim()) {
setNotice("境界名不能为空。");
return;
}
setBusy(true);
try {
const created = await worldApi.createWorld({
name: name.trim(),
description,
});
setWorlds(await worldApi.listWorlds());
setShowCreate(false);
setName("");
setDescription("");
setSelectedId(created.id);
await selectWorld(created.id);
setNotice(`境界「${created.name}」已建立`);
} catch (e) {
setNotice(e instanceof Error ? e.message : "建立失败");
} finally {
setBusy(false);
}
}
if (error) {
return (
<div className="glass rounded-2xl p-6 text-sm text-muted">{error}</div>
);
}
return (
<div className="flex flex-col gap-4">
{notice && (
<div className="glass rounded-xl px-4 py-2 text-sm text-patina">
{notice}
</div>
)}
<div className="set-card">
<div className="set-card-head">
<h3 className="set-card-title flex items-center gap-2">
<GlobeIcon className="size-4 text-patina" />
境界
</h3>
<span className="set-card-eng">Persistent Worlds</span>
</div>
<p className="set-card-desc">
版本化世界设定与叙事正史;境界内容不进入真界本相(跨界引用只是引文)。
</p>
<div className="mb-3 flex items-center justify-between">
<span className="text-xs text-muted">共 {worlds.length} 个境界</span>
<Button
variant="outline"
size="sm"
onClick={() => setShowCreate((v) => !v)}
>
<PlusIcon className="mr-1 size-4" />
{showCreate ? "收起" : "建立境界"}
</Button>
</div>
{showCreate && (
<div className="mb-4 grid gap-3 rounded-xl border border-rule bg-surface p-4 sm:grid-cols-2">
<label className="block">
<span className="mb-1 block text-xs text-muted">境界名</span>
<input
className="w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="如:云荒"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs text-muted">描述</span>
<input
className="w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="世界背景一句话"
/>
</label>
<div className="sm:col-span-2">
<Button
variant="primary"
size="sm"
onClick={handleCreate}
disabled={busy}
>
建立
</Button>
</div>
</div>
)}
{worlds.length === 0 && !showCreate ? (
<p className="rounded-xl border border-dashed border-rule p-4 text-center text-sm text-muted">
尚未建立境界。
</p>
) : (
<ul className="divide-y divide-rule rounded-xl border border-rule">
{worlds.map((w) => (
<li key={w.id}>
<button
type="button"
className={cn(
"flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-surface-hi",
selectedId === w.id && "bg-surface",
)}
onClick={() => selectWorld(w.id)}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-serif text-sm text-fg">
{w.name}
</span>
{w.lorebook_id && (
<span className="rounded-full border border-rule px-2 py-0.5 font-mono text-[10px] text-muted">
世谱已接
</span>
)}
</div>
<p className="mt-0.5 line-clamp-1 text-xs text-muted">
{w.description || "(无描述)"}
</p>
</div>
<span className="shrink-0 font-mono text-xs text-muted">
正史 {w.canon_count} 条
</span>
</button>
</li>
))}
</ul>
)}
</div>
{selected && (
<div className="set-card">
<div className="set-card-head">
<h3 className="set-card-title flex items-center gap-2">
<MapPinIcon className="size-4 text-gold" />
{selected.name} · 实体
</h3>
<span className="set-card-eng">Entities</span>
</div>
<p className="set-card-desc">
河图世界认知(人物/地点/组织/物品);实体归属境界作用域。
</p>
{entities.length === 0 ? (
<p className="rounded-xl border border-dashed border-rule p-4 text-center text-sm text-muted">
该境界暂无实体(实体经归真与认知管线渐进形成,或由 API 登记)。
</p>
) : (
<ul className="divide-y divide-rule rounded-xl border border-rule">
{entities.map((e) => (
<li key={e.id} className="px-4 py-3">
<div className="flex items-center gap-2">
<span className="rounded-full border border-rule px-2 py-0.5 font-mono text-[10px] text-muted">
{KIND_LABEL[e.kind] ?? e.kind}
</span>
<span className="font-serif text-sm text-fg">{e.name}</span>
</div>
{e.description && (
<p className="mt-0.5 line-clamp-2 text-xs text-muted">
{e.description}
</p>
)}
</li>
))}
</ul>
)}
<div className="mt-3 flex items-center gap-2 text-xs text-muted">
<ScrollTextIcon className="size-3.5" />
境界正史 {chronicleCount || selected.canon_count}{" "}
条(只读;不进真界本相)
</div>
</div>
)}
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
/**
* 缘契 API Client:提议—问心—铭契、撤回(立即生效)、边界与事件历史。
*
* 多维关系并存,禁单一好感度(后端拒绝 affinity 形态)。
*/
import { apiGet, apiPatch, apiPost } from "./client";
/** 缘契(当前生效版本) */
export interface Covenant {
id: string;
character_id: string;
dimensions: Record<string, string>;
primary_title: string | null;
boundaries: Record<string, unknown>;
adult_consent: boolean;
status: "pending" | "sealed" | "withdrawn" | string;
version: number;
created_at: string | null;
updated_at: string | null;
}
/** 缘契事件(不可变历史) */
export interface CovenantEvent {
id: string;
type: "propose" | "wenxin" | "seal" | "withdraw" | "boundary" | string;
actor: "user" | "character" | string;
payload: Record<string, unknown>;
created_at: string | null;
}
/** 提议缘契 */
export function proposeCovenant(body: {
character_id: string;
dimensions: Record<string, string>;
primary_title?: string | null;
boundaries?: Record<string, unknown>;
}) {
return apiPost<Covenant>("/covenants", body);
}
/** 缘契清单(不含已撤回) */
export function listCovenants() {
return apiGet<Covenant[]>("/covenants");
}
/** 人物当前生效缘契 */
export function getActiveCovenant(characterId: string) {
return apiGet<Covenant | null>(
`/covenants/by-character/${encodeURIComponent(characterId)}`,
);
}
/** 问心:人物自陈候选 */
export function wenxinCovenant(
covenantId: string,
body: { candidates: Record<string, string>; note?: string | null },
) {
return apiPost<{ covenant_id: string; recorded: boolean }>(
`/covenants/${encodeURIComponent(covenantId)}/wenxin`,
body,
);
}
/** 铭契:确认最终维度并生效 */
export function sealCovenant(
covenantId: string,
body: {
dimensions: Record<string, string>;
boundaries?: Record<string, unknown>;
},
) {
return apiPost<Covenant>(
`/covenants/${encodeURIComponent(covenantId)}/seal`,
body,
);
}
/** 撤回(立即生效) */
export function withdrawCovenant(covenantId: string, reason?: string | null) {
return apiPost<{ covenant_id: string; status: string; effective: string }>(
`/covenants/${encodeURIComponent(covenantId)}/withdraw`,
{ reason: reason ?? null },
);
}
/** 边界变更(立即生效) */
export function updateBoundaries(
covenantId: string,
boundaries: Record<string, unknown>,
) {
return apiPatch<Covenant>(
`/covenants/${encodeURIComponent(covenantId)}/boundaries`,
{ boundaries },
);
}
/** 事件历史(撤回后仍可查证) */
export function listCovenantEvents(covenantId: string) {
return apiGet<CovenantEvent[]>(
`/covenants/${encodeURIComponent(covenantId)}/events`,
);
}
-57
View File
@@ -1,57 +0,0 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("./client", () => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
}));
import { apiGet, apiPost } from "./client";
import {
approveSubgoal,
deferSubgoal,
listCuriosities,
listSubgoals,
rejectSubgoal,
} from "./drive";
describe("drive api", () => {
it("listCuriosities 构造 path 与 query", async () => {
vi.mocked(apiGet).mockResolvedValue({
items: [],
page: 1,
page_size: 20,
total: 0,
});
await listCuriosities("char_1", { source: "memory_gap" });
expect(apiGet).toHaveBeenCalledWith("/characters/char_1/curiosities", {
source: "memory_gap",
});
});
it("listSubgoals 构造 path 与 status query", async () => {
vi.mocked(apiGet).mockResolvedValue({
items: [],
page: 1,
page_size: 20,
total: 0,
});
await listSubgoals("char_1", { status: "candidate" });
expect(apiGet).toHaveBeenCalledWith("/characters/char_1/subgoals", {
status: "candidate",
});
});
it("approve/defer/reject 各自 POST 正确 path", async () => {
vi.mocked(apiPost).mockResolvedValue({ status: "approved" } as never);
await approveSubgoal("subgoal_1");
expect(apiPost).toHaveBeenCalledWith("/subgoals/subgoal_1/approve");
vi.mocked(apiPost).mockResolvedValue({ status: "deferred" } as never);
await deferSubgoal("subgoal_1");
expect(apiPost).toHaveBeenCalledWith("/subgoals/subgoal_1/defer");
vi.mocked(apiPost).mockResolvedValue({ status: "rejected" } as never);
await rejectSubgoal("subgoal_1");
expect(apiPost).toHaveBeenCalledWith("/subgoals/subgoal_1/reject");
});
});
-48
View File
@@ -1,48 +0,0 @@
/**
* 内驱力 API Client(对应 docs/domain/core-domains.md §5)。
* 大周天生成的好奇问题与探索性次生目标;次生目标 approve/defer/reject 仅标记不执行。
*/
import { apiGet, apiPost } from "./client";
import type {
CuriosityQuestion,
ExploratorySubgoal,
Paginated,
} from "@/types/api";
/** 列出好奇问题(按 source 过滤) */
export function listCuriosities(
characterId: string,
params?: { source?: string; page?: number; page_size?: number },
) {
return apiGet<Paginated<CuriosityQuestion>>(
`/characters/${characterId}/curiosities`,
params,
);
}
/** 列出探索性次生目标(按 status 过滤) */
export function listSubgoals(
characterId: string,
params?: { status?: string; page?: number; page_size?: number },
) {
return apiGet<Paginated<ExploratorySubgoal>>(
`/characters/${characterId}/subgoals`,
params,
);
}
/** 通过次生目标(status → approved,不自动执行) */
export function approveSubgoal(subgoalId: string) {
return apiPost<ExploratorySubgoal>(`/subgoals/${subgoalId}/approve`);
}
/** 延后次生目标(status → deferred) */
export function deferSubgoal(subgoalId: string) {
return apiPost<ExploratorySubgoal>(`/subgoals/${subgoalId}/defer`);
}
/** 驳回次生目标(status → rejected) */
export function rejectSubgoal(subgoalId: string) {
return apiPost<ExploratorySubgoal>(`/subgoals/${subgoalId}/reject`);
}
+2 -3
View File
@@ -9,8 +9,6 @@ export * from "./client";
export * as charactersApi from "./characters";
export * as runsApi from "./runs";
export * as sandboxApi from "./sandbox";
export * as personalityApi from "./personality";
export * as driveApi from "./drive";
export * as healthApi from "./health";
export * as toolsApi from "./tools";
export * as safetyApi from "./safety";
@@ -18,9 +16,10 @@ export * as adminApi from "./admin";
export * as settingsApi from "./settings";
export * as meApi from "./me";
export * as baigongApi from "./baigong";
export * as covenantApi from "./covenants";
export * as worldApi from "./worlds";
export * as importsApi from "./imports";
export * as extractionApi from "./extraction";
export * as worldApi from "./world";
export * as memoriesApi from "./memories";
export * as lorebooksApi from "./lorebooks";
export * as skillsApi from "./skills";
-73
View File
@@ -1,73 +0,0 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("./client", () => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
apiRequest: vi.fn(),
}));
import { apiGet, apiPost, apiRequest } from "./client";
import {
approveProposal,
getCurrentPersonality,
listPersonalityVersions,
proposeUpdate,
rejectProposal,
rollbackPersonality,
} from "./personality";
describe("personality api", () => {
it("getCurrentPersonality GET /personality/current", async () => {
vi.mocked(apiGet).mockResolvedValue({ id: "pv_1" });
await getCurrentPersonality({ character_id: "char_1" });
expect(apiGet).toHaveBeenCalledWith("/personality/current", {
character_id: "char_1",
});
});
it("proposeUpdate POST /personality/propose-update", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "prop_1" });
await proposeUpdate({
fields: { expression_style: "简洁清晰" },
reason: "优化表达",
});
expect(apiPost).toHaveBeenCalledWith("/personality/propose-update", {
fields: { expression_style: "简洁清晰" },
reason: "优化表达",
});
});
it("listPersonalityVersions GET /personality/versions", async () => {
vi.mocked(apiGet).mockResolvedValue({ items: [], total: 0 });
await listPersonalityVersions({ page: 1, page_size: 20 });
expect(apiGet).toHaveBeenCalledWith("/personality/versions", {
page: 1,
page_size: 20,
});
});
it("approveProposal POST /personality/proposals/:id/approve", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "prop_1" });
await approveProposal("prop_1");
expect(apiPost).toHaveBeenCalledWith(
"/personality/proposals/prop_1/approve",
);
});
it("rejectProposal POST /personality/proposals/:id/reject", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "prop_1" });
await rejectProposal("prop_1");
expect(apiPost).toHaveBeenCalledWith(
"/personality/proposals/prop_1/reject",
);
});
it("rollbackPersonality POST 带 query 参数", async () => {
vi.mocked(apiRequest).mockResolvedValue({ id: "pv_1" });
await rollbackPersonality("pv_1", "char_1");
expect(apiRequest).toHaveBeenCalledWith(
"/personality/versions/pv_1/rollback",
{ method: "POST", query: { character_id: "char_1" } },
);
});
});
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff. Show more