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