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,
|
avatar,
|
||||||
character_import,
|
character_import,
|
||||||
characters,
|
characters,
|
||||||
drive,
|
|
||||||
events,
|
events,
|
||||||
goals,
|
goals,
|
||||||
health,
|
health,
|
||||||
@@ -19,15 +19,18 @@ from app.api.v1.endpoints import (
|
|||||||
lorebooks,
|
lorebooks,
|
||||||
me,
|
me,
|
||||||
baigong,
|
baigong,
|
||||||
|
hetu,
|
||||||
|
soul,
|
||||||
|
yuanqi,
|
||||||
mirror_trees,
|
mirror_trees,
|
||||||
mcp,
|
mcp,
|
||||||
personality,
|
|
||||||
runs,
|
runs,
|
||||||
safety,
|
safety,
|
||||||
sandbox_files,
|
sandbox_files,
|
||||||
settings,
|
settings,
|
||||||
tools,
|
tools,
|
||||||
world,
|
|
||||||
xirang,
|
xirang,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,6 +42,9 @@ api_router.include_router(character_import.router)
|
|||||||
api_router.include_router(runs.router)
|
api_router.include_router(runs.router)
|
||||||
api_router.include_router(kernel_runs.router)
|
api_router.include_router(kernel_runs.router)
|
||||||
api_router.include_router(me.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:
|
for _baigong_r in baigong.routers:
|
||||||
api_router.include_router(_baigong_r)
|
api_router.include_router(_baigong_r)
|
||||||
api_router.include_router(embryo_drafts.router)
|
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(xirang.router)
|
||||||
api_router.include_router(mirror_trees.node_router)
|
api_router.include_router(mirror_trees.node_router)
|
||||||
api_router.include_router(lorebooks.router)
|
api_router.include_router(lorebooks.router)
|
||||||
api_router.include_router(personality.router)
|
|
||||||
api_router.include_router(tools.router)
|
api_router.include_router(tools.router)
|
||||||
api_router.include_router(mcp.router)
|
api_router.include_router(mcp.router)
|
||||||
api_router.include_router(safety.router)
|
api_router.include_router(safety.router)
|
||||||
api_router.include_router(sandbox_files.router)
|
api_router.include_router(sandbox_files.router)
|
||||||
api_router.include_router(settings.router)
|
api_router.include_router(settings.router)
|
||||||
api_router.include_router(world.router)
|
|
||||||
api_router.include_router(imports.router)
|
api_router.include_router(imports.router)
|
||||||
api_router.include_router(drive.router)
|
|
||||||
api_router.include_router(events.router)
|
api_router.include_router(events.router)
|
||||||
api_router.include_router(goals.router)
|
api_router.include_router(goals.router)
|
||||||
api_router.include_router(admin.router)
|
api_router.include_router(admin.router)
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ from app.schemas.admin import (
|
|||||||
CreateGoalRequest,
|
CreateGoalRequest,
|
||||||
CreatePerceptionChannelRequest,
|
CreatePerceptionChannelRequest,
|
||||||
CreatePlanRequest,
|
CreatePlanRequest,
|
||||||
CreateWorldEntityRequest,
|
|
||||||
GoalNode,
|
GoalNode,
|
||||||
PendingApproval,
|
PendingApproval,
|
||||||
PerceptionChannelResponse,
|
PerceptionChannelResponse,
|
||||||
@@ -41,8 +40,6 @@ from app.schemas.admin import (
|
|||||||
UpdateGoalRequest,
|
UpdateGoalRequest,
|
||||||
UpdatePerceptionChannelRequest,
|
UpdatePerceptionChannelRequest,
|
||||||
UpdatePlanRequest,
|
UpdatePlanRequest,
|
||||||
UpdateWorldEntityRequest,
|
|
||||||
WorldEntityResponse,
|
|
||||||
)
|
)
|
||||||
from app.schemas.admin_character import (
|
from app.schemas.admin_character import (
|
||||||
AdminCreateTemplateRequest,
|
AdminCreateTemplateRequest,
|
||||||
@@ -163,34 +160,6 @@ async def delete_perception_channel(channel: str) -> None:
|
|||||||
await governance_service.delete_perception_channel(channel)
|
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 洛书枢 · 计划 ——
|
# —— §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,
|
Goal,
|
||||||
PerceptionChannel,
|
PerceptionChannel,
|
||||||
Plan,
|
Plan,
|
||||||
WorldEntity,
|
|
||||||
)
|
)
|
||||||
from app.repositories.json_utils import dumps
|
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 = [
|
_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:
|
for item in _PLANS:
|
||||||
if await session.get(Plan, item["id"]) is None:
|
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
|
record_id = record.id
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
# 息壤:归真产生记忆候选证据(M3)——后台任务,不阻塞归真返回
|
# 归真后置按三界分流(core-domains.md §4):
|
||||||
from app.domains.xirang import service as xirang_service
|
# - 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(
|
await one_qi.emit(
|
||||||
"tree.canonized",
|
"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)
|
await _owned_tree(node.tree_id, owner_scope)
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
own = await _node_citations(session, node_id)
|
own = await _node_citations(session, node_id)
|
||||||
# 正影提示:同 source 存在更高版本时标记,不静默替换(PRD §7.2)
|
# 正影提示(引文↔引文):同 source 存在更高版本时标记,不静默替换(PRD §7.2)
|
||||||
versions: dict[str, int] = {}
|
versions: dict[str, int] = {}
|
||||||
all_rows = (
|
all_rows = (
|
||||||
await session.execute(select(Citation).where(Citation.source_id != ""))
|
await session.execute(select(Citation).where(Citation.source_id != ""))
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
for c in all_rows:
|
for c in all_rows:
|
||||||
versions[c.source_id] = max(versions.get(c.source_id, 0), c.source_version)
|
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,
|
"id": c.id,
|
||||||
"node_id": c.node_id,
|
"node_id": c.node_id,
|
||||||
"target_type": c.target_type,
|
"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,
|
"source_version": c.source_version,
|
||||||
"newer_version_available": versions.get(c.source_id, 0) > 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 def record_factual(canonization_id: str) -> None:
|
||||||
"""实录(同步,随归真事务后立即落库)。"""
|
"""实录(同步,随归真事务后立即落库)。"""
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
@@ -97,6 +102,7 @@ async def record_factual(canonization_id: str) -> None:
|
|||||||
canonization_id=canonization_id,
|
canonization_id=canonization_id,
|
||||||
evidence_json=json.dumps({"path_nodes": [n.id for n in path_nodes]}, ensure_ascii=False),
|
evidence_json=json.dumps({"path_nodes": [n.id for n in path_nodes]}, ensure_ascii=False),
|
||||||
summary=summary,
|
summary=summary,
|
||||||
|
scope=record.scope,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
character_id = record.character_id
|
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.big_cycle import BigCycle
|
||||||
from app.models.candidates import MemoryCandidate, ProfileCandidate
|
from app.models.candidates import MemoryCandidate, ProfileCandidate
|
||||||
from app.models.character import Character
|
from app.models.character import Character
|
||||||
from app.models.drive import CuriosityQuestion, ExploratorySubgoal
|
|
||||||
from app.models.evolution import EvolutionCandidate
|
|
||||||
from app.models.governance import (
|
from app.models.governance import (
|
||||||
AgentRosterEntry,
|
AgentRosterEntry,
|
||||||
GenerationPreferences,
|
GenerationPreferences,
|
||||||
Goal,
|
Goal,
|
||||||
PerceptionChannel,
|
PerceptionChannel,
|
||||||
Plan,
|
Plan,
|
||||||
WorldEntity,
|
|
||||||
)
|
)
|
||||||
from app.models.idempotency import IdempotencyKey
|
from app.models.idempotency import IdempotencyKey
|
||||||
from app.models.imports import CharacterImportJob, ImportItem
|
from app.models.imports import CharacterImportJob, ImportItem
|
||||||
from app.domains.baigong.models import Capability, Mandate, ToolCall
|
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.lorebook import Lorebook, LorebookEntry
|
||||||
from app.models.mcp_servers import McpServer
|
from app.models.mcp_servers import McpServer
|
||||||
from app.models.mirror_tree import (
|
from app.models.mirror_tree import (
|
||||||
@@ -26,16 +25,20 @@ from app.models.mirror_tree import (
|
|||||||
TreeNode,
|
TreeNode,
|
||||||
TreeTransplant,
|
TreeTransplant,
|
||||||
)
|
)
|
||||||
from app.models.personality import PersonalityProposal, PersonalityVersion
|
|
||||||
from app.models.rbac import Permission, Role, RolePermission, UserRole
|
from app.models.rbac import Permission, Role, RolePermission, UserRole
|
||||||
from app.models.runtime import Event, Run
|
from app.models.runtime import Event, Run
|
||||||
from app.models.xirang import FactualRecord, Memory as XirangMemory, MemoryTrigger
|
from app.models.xirang import FactualRecord, Memory as XirangMemory, MemoryTrigger
|
||||||
from app.models.safety import AuditEvent, PendingConfirmationRecord
|
from app.models.safety import AuditEvent, PendingConfirmationRecord
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.world import CharacterWorldEntity, WorldRelation
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Capability",
|
"Capability",
|
||||||
|
"Covenant",
|
||||||
|
"CovenantEvent",
|
||||||
|
"PersistentWorld",
|
||||||
|
"SoulForging",
|
||||||
|
|
||||||
|
|
||||||
"Mandate",
|
"Mandate",
|
||||||
"ToolCall",
|
"ToolCall",
|
||||||
"AgentRosterEntry",
|
"AgentRosterEntry",
|
||||||
@@ -43,11 +46,7 @@ __all__ = [
|
|||||||
"BigCycle",
|
"BigCycle",
|
||||||
"Character",
|
"Character",
|
||||||
"CharacterImportJob",
|
"CharacterImportJob",
|
||||||
"CharacterWorldEntity",
|
|
||||||
"CuriosityQuestion",
|
|
||||||
"Event",
|
"Event",
|
||||||
"EvolutionCandidate",
|
|
||||||
"ExploratorySubgoal",
|
|
||||||
"GenerationPreferences",
|
"GenerationPreferences",
|
||||||
"Goal",
|
"Goal",
|
||||||
"IdempotencyKey",
|
"IdempotencyKey",
|
||||||
@@ -59,8 +58,6 @@ __all__ = [
|
|||||||
"PendingConfirmationRecord",
|
"PendingConfirmationRecord",
|
||||||
"PerceptionChannel",
|
"PerceptionChannel",
|
||||||
"Permission",
|
"Permission",
|
||||||
"PersonalityProposal",
|
|
||||||
"PersonalityVersion",
|
|
||||||
"Plan",
|
"Plan",
|
||||||
"ProfileCandidate",
|
"ProfileCandidate",
|
||||||
"Role",
|
"Role",
|
||||||
@@ -69,6 +66,6 @@ __all__ = [
|
|||||||
|
|
||||||
"User",
|
"User",
|
||||||
"UserRole",
|
"UserRole",
|
||||||
"WorldEntity",
|
|
||||||
"WorldRelation",
|
|
||||||
]
|
]
|
||||||
@@ -21,9 +21,8 @@ class Character(Base):
|
|||||||
profile_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
profile_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||||
model_policy_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))
|
safety_policy_id: Mapped[str | None] = mapped_column(String(80))
|
||||||
current_personality_version_id: Mapped[str | None] = mapped_column(String(80))
|
|
||||||
# 道体恒定层(设计理念 §2.1):不可运行时覆盖的人格内核快照,
|
# 道体恒定层(设计理念 §2.1):不可运行时覆盖的人格内核快照,
|
||||||
# 由 personality_version 收敛 / 用户确认 / 酒馆卡降生写入,组装运行时契约时做完整性校验。
|
# 由铸魂确认流 / 用户确认 / 酒馆卡降生写入,组装运行时契约时做完整性校验。
|
||||||
personality_summary: Mapped[str | None] = mapped_column(Text)
|
personality_summary: Mapped[str | None] = mapped_column(Text)
|
||||||
mission: Mapped[str | None] = mapped_column(Text)
|
mission: Mapped[str | None] = mapped_column(Text)
|
||||||
safety_guidelines: 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):
|
class Plan(Base):
|
||||||
"""洛书枢计划(契约字典 §5.13)。goal_id 关联太一枢目标,可空。"""
|
"""洛书枢计划(契约字典 §5.13)。goal_id 关联太一枢目标,可空。"""
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from datetime import datetime
|
|||||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
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.core.time import utc_now
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
|
|
||||||
@@ -82,3 +82,24 @@ class IdentityRecord(Base):
|
|||||||
digest: Mapped[str] = mapped_column(String(64), nullable=False)
|
digest: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
signature: Mapped[str] = mapped_column(String(128), nullable=False)
|
signature: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
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="{}")
|
evidence_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||||
summary: 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)
|
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,
|
Goal,
|
||||||
PerceptionChannel,
|
PerceptionChannel,
|
||||||
Plan,
|
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]:
|
async def list_plans(session: AsyncSession) -> list[Plan]:
|
||||||
result = await session.execute(select(Plan).order_by(Plan.updated_at.desc()))
|
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",
|
"language": "zh-CN",
|
||||||
"profile": {"bio": "本地 MVP 默认人物。"},
|
"profile": {"bio": "本地 MVP 默认人物。"},
|
||||||
"model_policy": {"default_model_hint": "deepseek-v4-flash"},
|
"model_policy": {"default_model_hint": "deepseek-v4-flash"},
|
||||||
"current_personality_version_id": "personality_9b5f5b7e413442d0a2f9e61a2f4d7df5",
|
|
||||||
"privacy_level": "normal",
|
"privacy_level": "normal",
|
||||||
"owner_scope": "local-user",
|
"owner_scope": "local-user",
|
||||||
"is_system_template": False,
|
"is_system_template": False,
|
||||||
@@ -36,7 +35,6 @@ class CharacterResponse(BaseModel):
|
|||||||
language: str = Field(description="人物默认语言。")
|
language: str = Field(description="人物默认语言。")
|
||||||
profile: dict[str, Any] = Field(description="人物档案 JSON。")
|
profile: dict[str, Any] = Field(description="人物档案 JSON。")
|
||||||
model_policy: dict[str, Any] = Field(description="人物模型策略。")
|
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。")
|
privacy_level: str = Field(description="隐私等级:normal / sensitive / local_only。")
|
||||||
owner_scope: str = Field(description="归属范围;当前本地 MVP 为 local-user。")
|
owner_scope: str = Field(description="归属范围;当前本地 MVP 为 local-user。")
|
||||||
is_system_template: bool = Field(description="是否系统人物模板;active API 通常返回 false。")
|
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,
|
Goal,
|
||||||
PerceptionChannel,
|
PerceptionChannel,
|
||||||
Plan,
|
Plan,
|
||||||
WorldEntity,
|
|
||||||
)
|
)
|
||||||
from app.models.idempotency import IdempotencyKey
|
from app.models.idempotency import IdempotencyKey
|
||||||
from app.models.xirang import Memory
|
from app.models.xirang import Memory
|
||||||
@@ -81,13 +80,15 @@ async def get_pivots_health(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
goals_n = await governance_repo.count(session, Goal)
|
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)
|
plans_n = await governance_repo.count(session, Plan)
|
||||||
perception_n = await governance_repo.count(session, PerceptionChannel)
|
perception_n = await governance_repo.count(session, PerceptionChannel)
|
||||||
agents_n = await governance_repo.count(session, AgentRosterEntry)
|
agents_n = await governance_repo.count(session, AgentRosterEntry)
|
||||||
memories_n = await governance_repo.count(session, Memory, Memory.status != "tombstoned")
|
memories_n = await governance_repo.count(session, Memory, Memory.status != "tombstoned")
|
||||||
character = await character_repo.get_active_character(session, owner_scope)
|
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 # 百工域常驻(乾坤可用性按端点区分)
|
tool_enabled = True # 百工域常驻(乾坤可用性按端点区分)
|
||||||
now = to_iso(utc_now())
|
now = to_iso(utc_now())
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from app.core.errors import AppError
|
|||||||
from app.core.time import utc_now
|
from app.core.time import utc_now
|
||||||
from app.db.session import AsyncSessionLocal
|
from app.db.session import AsyncSessionLocal
|
||||||
from app.models.character import Character
|
from app.models.character import Character
|
||||||
from app.models.personality import PersonalityVersion
|
|
||||||
from app.modules.character import (
|
from app.modules.character import (
|
||||||
MODEL_POLICY_CREATE_DEFAULTS,
|
MODEL_POLICY_CREATE_DEFAULTS,
|
||||||
SYSTEM_TEMPLATE_OWNER_SCOPE,
|
SYSTEM_TEMPLATE_OWNER_SCOPE,
|
||||||
@@ -17,7 +16,6 @@ from app.modules.character import (
|
|||||||
sanitize_profile,
|
sanitize_profile,
|
||||||
)
|
)
|
||||||
from app.repositories import characters as character_repo
|
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.repositories.json_utils import dumps, loads_dict
|
||||||
from app.schemas.character import (
|
from app.schemas.character import (
|
||||||
ActivateCharacterResponse,
|
ActivateCharacterResponse,
|
||||||
@@ -100,25 +98,6 @@ async def create_default_character(
|
|||||||
return await create_character_from_template(session, template, owner_scope)
|
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:
|
async def ensure_default_system_template(session: AsyncSession) -> Character:
|
||||||
"""按 config/character_templates.yaml 确保系统模板存在且最新。
|
"""按 config/character_templates.yaml 确保系统模板存在且最新。
|
||||||
@@ -135,7 +114,6 @@ async def ensure_default_system_template(session: AsyncSession) -> Character:
|
|||||||
# create 路径已由 _create_template_from_config 兜底 birthday,
|
# create 路径已由 _create_template_from_config 兜底 birthday,
|
||||||
# sync 路径可能因旧数据缺失 birthday,再兜一次确保存在。
|
# sync 路径可能因旧数据缺失 birthday,再兜一次确保存在。
|
||||||
await _ensure_default_birthday(session, template)
|
await _ensure_default_birthday(session, template)
|
||||||
await ensure_character_personality(session, template)
|
|
||||||
return template
|
return template
|
||||||
|
|
||||||
|
|
||||||
@@ -160,7 +138,6 @@ async def ensure_all_system_templates(session: AsyncSession) -> list[Character]:
|
|||||||
else:
|
else:
|
||||||
await _sync_template_from_config(session, template, config)
|
await _sync_template_from_config(session, template, config)
|
||||||
await _ensure_default_birthday(session, template)
|
await _ensure_default_birthday(session, template)
|
||||||
await ensure_character_personality(session, template)
|
|
||||||
results.append(template)
|
results.append(template)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
@@ -267,7 +244,6 @@ async def ensure_default_character_for_owner(session: AsyncSession, owner_scope:
|
|||||||
if not configs:
|
if not configs:
|
||||||
template = await ensure_default_system_template(session)
|
template = await ensure_default_system_template(session)
|
||||||
character = await create_character_from_template(session, template, owner_scope)
|
character = await create_character_from_template(session, template, owner_scope)
|
||||||
await _ensure_character_personality_from_template(session, template, character)
|
|
||||||
return character
|
return character
|
||||||
|
|
||||||
# 先确保所有系统模板在 DB 中存在
|
# 先确保所有系统模板在 DB 中存在
|
||||||
@@ -278,7 +254,6 @@ async def ensure_default_character_for_owner(session: AsyncSession, owner_scope:
|
|||||||
config = configs[key]
|
config = configs[key]
|
||||||
tpl = await _create_template_from_config(session, config)
|
tpl = await _create_template_from_config(session, config)
|
||||||
await _ensure_default_birthday(session, tpl)
|
await _ensure_default_birthday(session, tpl)
|
||||||
await ensure_character_personality(session, tpl)
|
|
||||||
templates[key] = tpl
|
templates[key] = tpl
|
||||||
|
|
||||||
# 筛选 is_default=true 的配置 key
|
# 筛选 is_default=true 的配置 key
|
||||||
@@ -295,19 +270,11 @@ async def ensure_default_character_for_owner(session: AsyncSession, owner_scope:
|
|||||||
session, tpl.id, owner_scope
|
session, tpl.id, owner_scope
|
||||||
)
|
)
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
await ensure_character_personality(session, existing)
|
|
||||||
if primary is None:
|
if primary is None:
|
||||||
primary = existing
|
primary = existing
|
||||||
continue
|
continue
|
||||||
|
|
||||||
character = await create_character_from_template(session, tpl, owner_scope)
|
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 session.flush()
|
||||||
|
|
||||||
await record_audit_event(
|
await record_audit_event(
|
||||||
@@ -327,32 +294,11 @@ async def ensure_default_character_for_owner(session: AsyncSession, owner_scope:
|
|||||||
if primary is None:
|
if primary is None:
|
||||||
template = await ensure_default_system_template(session)
|
template = await ensure_default_system_template(session)
|
||||||
primary = await create_character_from_template(session, template, owner_scope)
|
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()
|
await session.flush()
|
||||||
|
|
||||||
return primary
|
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(
|
async def get_owned_character(
|
||||||
session: AsyncSession, character_id: str, owner_scope: str = settings.default_owner_scope
|
session: AsyncSession, character_id: str, owner_scope: str = settings.default_owner_scope
|
||||||
@@ -364,12 +310,10 @@ async def get_owned_character(
|
|||||||
return character
|
return character
|
||||||
|
|
||||||
|
|
||||||
async def _personality_version_number(session: AsyncSession, character: Character) -> int:
|
def _personality_version_number(character: Character) -> int:
|
||||||
version_id = character.current_personality_version_id
|
"""人格版本随旧版本表退役——版本语义由魂籍(identity_record)承载,此处恒 0 占位。"""
|
||||||
if not version_id:
|
_ = character
|
||||||
return 0
|
return 0
|
||||||
personality = await personality_repo.get_personality(session, version_id)
|
|
||||||
return personality.version if personality else 0
|
|
||||||
|
|
||||||
|
|
||||||
async def _audit_character(session: AsyncSession, character: Character, action: str) -> None:
|
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 character_repo.add_character(session, character)
|
||||||
await _ensure_default_birthday(session, character)
|
await _ensure_default_birthday(session, character)
|
||||||
await ensure_character_personality(session, character)
|
|
||||||
await _audit_character(session, character, "character.created_from_template")
|
await _audit_character(session, character, "character.created_from_template")
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(character)
|
await session.refresh(character)
|
||||||
@@ -488,7 +431,7 @@ async def list_characters_page(
|
|||||||
character_to_summary(
|
character_to_summary(
|
||||||
character,
|
character,
|
||||||
memory_count=await character_repo.count_memories(session, character.id),
|
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
|
for character in items
|
||||||
]
|
]
|
||||||
@@ -569,7 +512,6 @@ async def activate_character(
|
|||||||
await _deactivate_others(session, character, owner_scope)
|
await _deactivate_others(session, character, owner_scope)
|
||||||
character.status = "active"
|
character.status = "active"
|
||||||
character.activated_at = utc_now()
|
character.activated_at = utc_now()
|
||||||
await ensure_character_personality(session, character)
|
|
||||||
await _audit_character(session, character, "character.activated")
|
await _audit_character(session, character, "character.activated")
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return ActivateCharacterResponse(active_character_id=character.id, status="active")
|
return ActivateCharacterResponse(active_character_id=character.id, status="active")
|
||||||
@@ -599,7 +541,6 @@ async def resume_character(
|
|||||||
await _deactivate_others(session, character, owner_scope)
|
await _deactivate_others(session, character, owner_scope)
|
||||||
character.status = "active"
|
character.status = "active"
|
||||||
character.activated_at = utc_now()
|
character.activated_at = utc_now()
|
||||||
await ensure_character_personality(session, character)
|
|
||||||
await _audit_character(session, character, "character.resumed")
|
await _audit_character(session, character, "character.resumed")
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(character)
|
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,
|
Goal,
|
||||||
PerceptionChannel,
|
PerceptionChannel,
|
||||||
Plan,
|
Plan,
|
||||||
WorldEntity,
|
|
||||||
)
|
)
|
||||||
from app.modules.runtime.events import Event
|
from app.modules.runtime.events import Event
|
||||||
from app.repositories import governance as governance_repo
|
from app.repositories import governance as governance_repo
|
||||||
@@ -26,7 +25,6 @@ from app.schemas.admin import (
|
|||||||
CreateGoalRequest,
|
CreateGoalRequest,
|
||||||
CreatePerceptionChannelRequest,
|
CreatePerceptionChannelRequest,
|
||||||
CreatePlanRequest,
|
CreatePlanRequest,
|
||||||
CreateWorldEntityRequest,
|
|
||||||
GoalNode,
|
GoalNode,
|
||||||
PerceptionChannelResponse,
|
PerceptionChannelResponse,
|
||||||
PlanSummaryResponse,
|
PlanSummaryResponse,
|
||||||
@@ -34,8 +32,6 @@ from app.schemas.admin import (
|
|||||||
UpdateGoalRequest,
|
UpdateGoalRequest,
|
||||||
UpdatePerceptionChannelRequest,
|
UpdatePerceptionChannelRequest,
|
||||||
UpdatePlanRequest,
|
UpdatePlanRequest,
|
||||||
UpdateWorldEntityRequest,
|
|
||||||
WorldEntityResponse,
|
|
||||||
)
|
)
|
||||||
from app.schemas.safety import (
|
from app.schemas.safety import (
|
||||||
ToolCallDetail,
|
ToolCallDetail,
|
||||||
@@ -46,7 +42,6 @@ from app.services.serializers import (
|
|||||||
goal_to_response,
|
goal_to_response,
|
||||||
perception_channel_to_response,
|
perception_channel_to_response,
|
||||||
plan_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,
|
Goal,
|
||||||
PerceptionChannel,
|
PerceptionChannel,
|
||||||
Plan,
|
Plan,
|
||||||
WorldEntity,
|
|
||||||
)
|
)
|
||||||
from app.models.imports import CharacterImportJob, ImportItem
|
from app.models.imports import CharacterImportJob, ImportItem
|
||||||
from app.models.personality import PersonalityProposal, PersonalityVersion
|
|
||||||
from app.models.runtime import Event, Run
|
from app.models.runtime import Event, Run
|
||||||
from app.models.safety import AuditEvent
|
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.repositories.json_utils import loads_dict, loads_list
|
||||||
from app.schemas.admin import (
|
from app.schemas.admin import (
|
||||||
AgentRosterResponse,
|
AgentRosterResponse,
|
||||||
@@ -26,20 +23,12 @@ from app.schemas.admin import (
|
|||||||
GoalNode,
|
GoalNode,
|
||||||
PerceptionChannelResponse,
|
PerceptionChannelResponse,
|
||||||
PlanSummaryResponse,
|
PlanSummaryResponse,
|
||||||
WorldEntityResponse,
|
|
||||||
)
|
)
|
||||||
from app.schemas.candidates import MemoryCandidateResponse, ProfileCandidateResponse
|
from app.schemas.candidates import MemoryCandidateResponse, ProfileCandidateResponse
|
||||||
from app.schemas.character import CharacterResponse, CharacterSummaryResponse
|
from app.schemas.character import CharacterResponse, CharacterSummaryResponse
|
||||||
from app.schemas.imports import ImportItemResponse, ImportJobResponse
|
from app.schemas.imports import ImportItemResponse, ImportJobResponse
|
||||||
from app.schemas.personality import PersonalityProposalResponse, PersonalityResponse
|
|
||||||
from app.schemas.preferences import GenerationPreferencesResponse
|
from app.schemas.preferences import GenerationPreferencesResponse
|
||||||
from app.schemas.runtime import EventResponse, RunResponse
|
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:
|
def _check_avatar(character_id: str) -> str | None:
|
||||||
@@ -60,7 +49,6 @@ def character_to_response(character: Character) -> CharacterResponse:
|
|||||||
language=character.language,
|
language=character.language,
|
||||||
profile=loads_dict(character.profile_json),
|
profile=loads_dict(character.profile_json),
|
||||||
model_policy=loads_dict(character.model_policy_json),
|
model_policy=loads_dict(character.model_policy_json),
|
||||||
current_personality_version_id=character.current_personality_version_id,
|
|
||||||
privacy_level=character.privacy_level,
|
privacy_level=character.privacy_level,
|
||||||
owner_scope=character.owner_scope,
|
owner_scope=character.owner_scope,
|
||||||
is_system_template=character.is_system_template,
|
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:
|
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:
|
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]
|
item = body["items"][0]
|
||||||
assert item["id"].startswith("character_")
|
assert item["id"].startswith("character_")
|
||||||
assert item["status"] == "active"
|
assert item["status"] == "active"
|
||||||
assert item["personality_version"] >= 1
|
assert item["personality_version"] == 0 # 旧人格版本表退役,版本语义由魂籍承载
|
||||||
assert item["memory_count"] == 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")
|
character = await client.get("/api/v1/characters/active")
|
||||||
assert character.status_code == 200
|
assert character.status_code == 200
|
||||||
assert character.json()["id"].startswith("character_")
|
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
|
@pytest.mark.asyncio
|
||||||
@@ -147,38 +64,6 @@ async def test_mcp_server_crud(client: AsyncClient) -> None:
|
|||||||
assert deleted.status_code == 204
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_preferences_flow(client: AsyncClient) -> None:
|
async def test_preferences_flow(client: AsyncClient) -> None:
|
||||||
"""偏好设置读写——覆盖 services/preferences 路径。"""
|
"""偏好设置读写——覆盖 services/preferences 路径。"""
|
||||||
|
|||||||
@@ -12,10 +12,8 @@ from app.db.init_data import seed_default_data
|
|||||||
from app.db.session import AsyncSessionLocal
|
from app.db.session import AsyncSessionLocal
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.models.character import Character
|
from app.models.character import Character
|
||||||
from app.models.personality import PersonalityVersion
|
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.repositories import characters as character_repo
|
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.repositories.json_utils import dumps
|
||||||
from app.modules.character import (
|
from app.modules.character import (
|
||||||
MODEL_POLICY_CREATE_DEFAULTS,
|
MODEL_POLICY_CREATE_DEFAULTS,
|
||||||
@@ -78,43 +76,6 @@ async def test_active_character_api_returns_user_copy() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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
|
@pytest.mark.asyncio
|
||||||
async def test_seed_default_data_is_idempotent(db_session: AsyncSession) -> None:
|
async def test_seed_default_data_is_idempotent(db_session: AsyncSession) -> None:
|
||||||
db = db_session
|
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),
|
Character.is_system_template.is_(False),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
personality_count = await db.scalar(select(func.count()).select_from(PersonalityVersion))
|
|
||||||
|
|
||||||
assert template_count == 5
|
assert template_count == 5
|
||||||
# is_default=true 模板只有一个(default),所以用户角色数为 1
|
# 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` | 不可变历史:提议、问心(人物自陈候选)、铭契确认、撤回与边界变更。即时停止与撤回立即生效并留痕 |
|
| `covenant_events` | 不可变历史:提议、问心(人物自陈候选)、铭契确认、撤回与边界变更。即时停止与撤回立即生效并留痕 |
|
||||||
|
|
||||||
## 5. 三界域
|
## 5. 三界域
|
||||||
|
|
||||||
| 表 | 要点 |
|
| 表 | 要点 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `persistent_worlds` | 境界:世界背景、规则、实体索引、正史版本 |
|
| `persistent_worlds`(M6 已落地) | 境界:世界背景、规则、实体索引、正史版本(canon_count 缓存)+ 世谱挂接 lorebook_id |
|
||||||
| `world_entities` / `world_relations` | 河图世界认知:实体与关系(图查询走 PG 递归 CTE;Neo4j/NebulaGraph 只在规模需要时作为投影库引入,不是事实源) |
|
| `world_entities` / `world_relations` | 河图世界认知:实体与关系(图查询走 PG 递归 CTE;Neo4j/NebulaGraph 只在规模需要时作为投影库引入,不是事实源) |
|
||||||
| `lorebooks` / `lorebook_entries` | 世谱(ST 世界信息兼容):关键词/次级键/常驻/位置/深度/顺序/概率/时序效果/递归与预算参数;导入导出兼容 ST 格式 |
|
| `lorebooks` / `lorebook_entries` | 世谱(ST 世界信息兼容):关键词/次级键/常驻/位置/深度/顺序/概率/时序效果/递归与预算参数;导入导出兼容 ST 格式 |
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
| 乾坤沙箱 | M5 | 局部实现 | 隔离执行/HMAC/超时取消已有;设备安全与双裁决待扩 |
|
| 乾坤沙箱 | M5 | 局部实现 | 隔离执行/HMAC/超时取消已有;设备安全与双裁决待扩 |
|
||||||
| 博闻 MCP 搜索 | M5 | 局部实现 | 多引擎/密钥池/熔断/缓存可用;待登记入百工 |
|
| 博闻 MCP 搜索 | M5 | 局部实现 | 多引擎/密钥池/熔断/缓存可用;待登记入百工 |
|
||||||
| 百工与司命 | M5 | 已验证 | M5 收口(2026-08-27,本地开发档):能力注册表(capabilities 表 + 四维风险 + 内置种子 echo/datetime/qiankun.shell/web.fetch)、授契/行契(撤销即失效)、请命(六要素 brief + 超时拒绝 HJ-1302)、行动账(tool_calls 观察标证 + audit 留痕)、乾坤 HMAC 执行、MCP→能力派生;验收四测试(未授契被拒留痕/请命超时不执行/禁域不可关闭专项/结果为观察不入忆)全绿;前端百工页四区。沙箱进程隔离 e2e 预存环境失败与本域无关 |
|
| 百工与司命 | 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,天目待接通感 |
|
| 天目 / 元炁 | M4/M7 | 局部实现 | 服务可用;元炁待接入神识 Endpoint,天目待接通感 |
|
||||||
| 镜树前端 / 记忆治理 / 百工页 | M2+ | 局部实现 | 镜树页与灵胚页已上线(M2c,2026-08-21):路径阅读流/候答切换/采用归真/局部树图/SSE 芽体/导入卡;type-check + vitest 210 passed;记忆治理页随 M3、百工页随 M5 |
|
| 镜树前端 / 记忆治理 / 百工页 | 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 快照 |
|
| 导入(角色卡 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 标证。
|
- 验收(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)全绿。
|
- 证据:后端 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 面)
|
### M7 形仪与语音(P1 面)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Link from "next/link";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
import { CharacterIdentityCard } from "@/components/characters/CharacterIdentityCard";
|
import { CharacterIdentityCard } from "@/components/characters/CharacterIdentityCard";
|
||||||
|
import { CovenantPanel } from "@/components/characters/CovenantPanel";
|
||||||
import { CharacterLifecycleBar } from "@/components/characters/CharacterLifecycleBar";
|
import { CharacterLifecycleBar } from "@/components/characters/CharacterLifecycleBar";
|
||||||
import { ModelPolicyEditForm } from "@/components/characters/ModelPolicyEditForm";
|
import { ModelPolicyEditForm } from "@/components/characters/ModelPolicyEditForm";
|
||||||
import { ProfileEditForm } from "@/components/characters/ProfileEditForm";
|
import { ProfileEditForm } from "@/components/characters/ProfileEditForm";
|
||||||
@@ -12,6 +13,7 @@ import { CharacterStatusBadge } from "@/components/ui/StatusBadge";
|
|||||||
import { serverApi } from "@/lib/api/server-client";
|
import { serverApi } from "@/lib/api/server-client";
|
||||||
import { describeError } from "@/lib/utils/error";
|
import { describeError } from "@/lib/utils/error";
|
||||||
import { formatDateTime } from "@/lib/utils/format";
|
import { formatDateTime } from "@/lib/utils/format";
|
||||||
|
import type { Covenant } from "@/lib/api/covenants";
|
||||||
import type { Character } from "@/types/api";
|
import type { Character } from "@/types/api";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -28,12 +30,21 @@ export default async function CharacterDetailPage({
|
|||||||
const { characterId } = await params;
|
const { characterId } = await params;
|
||||||
|
|
||||||
let character = null;
|
let character = null;
|
||||||
|
let covenant: Covenant | null = null;
|
||||||
let error: string | null = null;
|
let error: string | null = null;
|
||||||
try {
|
try {
|
||||||
character = await serverApi.get<Character>(`/characters/${characterId}`);
|
character = await serverApi.get<Character>(`/characters/${characterId}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = describeError(e);
|
error = describeError(e);
|
||||||
}
|
}
|
||||||
|
// 缘契独立拉取,失败静默(无缘契是合法状态)
|
||||||
|
try {
|
||||||
|
covenant = await serverApi.get<Covenant | null>(
|
||||||
|
`/covenants/by-character/${characterId}`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
covenant = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
@@ -62,6 +73,10 @@ export default async function CharacterDetailPage({
|
|||||||
|
|
||||||
<CharacterIdentityCard character={character} />
|
<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">
|
<div className="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
<ProfileEditForm character={character} />
|
<ProfileEditForm character={character} />
|
||||||
<ModelPolicyEditForm 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 { WorldWorkbench } from "@/components/world/WorldWorkbench";
|
||||||
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 { serverApi } from "@/lib/api/server-client";
|
import { serverApi } from "@/lib/api/server-client";
|
||||||
import { describeError } from "@/lib/utils/error";
|
import { describeError } from "@/lib/utils/error";
|
||||||
import type {
|
import type { PersistentWorld } from "@/lib/api/worlds";
|
||||||
Character,
|
|
||||||
Paginated,
|
|
||||||
WorldEntity,
|
|
||||||
WorldRelation,
|
|
||||||
} from "@/types/api";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 世界页 · 数字生命对世界的认知(河图枢主域视图)。
|
* 世界页(M6 河图重建):境界列表 + 境界详情(实体/关系/正史)。
|
||||||
* 设计稿 world-preview.html — 人物 pill + section-label + 关系图谱 + 实体名册 + 不确定性 + 世界观共振。
|
* 境界正史不进真界本相——正史条目只读呈现。
|
||||||
* 实体 CRUD 走 admin 域(WorldEntityList,保留编辑/删除);关系图谱走独立 /world/relations。
|
|
||||||
*/
|
*/
|
||||||
export default async function WorldPage() {
|
export default async function WorldPage() {
|
||||||
let entities: WorldEntity[] = [];
|
let worlds: PersistentWorld[] = [];
|
||||||
let relations: WorldRelation[] = [];
|
|
||||||
let character: Character | null = null;
|
|
||||||
let characterName = "";
|
|
||||||
let error: string | null = null;
|
let error: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
entities = await serverApi.get<WorldEntity[]>("/admin/world/entities");
|
worlds = await serverApi.get<PersistentWorld[]>("/worlds");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = describeError(e);
|
error = describeError(e);
|
||||||
}
|
}
|
||||||
|
return <WorldWorkbench initialWorlds={worlds} error={error} />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -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 { PolicyMatrix } from "@/components/admin/PolicyMatrix";
|
||||||
import { AuditConsole } from "@/components/admin/AuditConsole";
|
import { AuditConsole } from "@/components/admin/AuditConsole";
|
||||||
import { ToolRegistry } from "@/components/admin/ToolRegistry";
|
import { ToolRegistry } from "@/components/admin/ToolRegistry";
|
||||||
import { PersonalityEditor } from "@/components/admin/PersonalityEditor";
|
|
||||||
import { GoalTree } from "@/components/admin/GoalTree";
|
import { GoalTree } from "@/components/admin/GoalTree";
|
||||||
import { PerceptionConsentMatrix } from "@/components/admin/PerceptionConsentMatrix";
|
import { PerceptionConsentMatrix } from "@/components/admin/PerceptionConsentMatrix";
|
||||||
import { WorldEntityList } from "@/components/admin/WorldEntityList";
|
import { WorldEntityList } from "@/components/admin/WorldEntityList";
|
||||||
@@ -36,7 +35,6 @@ import type {
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const VALID_PIVOTS: Pivot[] = [
|
const VALID_PIVOTS: Pivot[] = [
|
||||||
"personality_core",
|
|
||||||
"goal_orchestrator",
|
"goal_orchestrator",
|
||||||
"safety_governor",
|
"safety_governor",
|
||||||
"perception",
|
"perception",
|
||||||
@@ -67,8 +65,6 @@ export default async function PivotDetailPage({
|
|||||||
return <SafetyGovernorDetail />;
|
return <SafetyGovernorDetail />;
|
||||||
case "tool_runtime":
|
case "tool_runtime":
|
||||||
return <ToolRuntimeDetail />;
|
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() {
|
async function GoalOrchestratorDetail() {
|
||||||
let goals: GoalNode[] = [];
|
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">
|
<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 && (
|
{FEATURE_FLAGS.worldPanel && (
|
||||||
<Link href="/world" className="transition-colors hover:text-fg">
|
<Link href="/world" className="transition-colors hover:text-fg">
|
||||||
世界
|
世界
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{FEATURE_FLAGS.timePanel && (
|
|
||||||
<Link href="/time" className="transition-colors hover:text-fg">
|
|
||||||
时态
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
<Link href="/archive" className="transition-colors hover:text-fg">
|
<Link href="/archive" className="transition-colors hover:text-fg">
|
||||||
归档
|
归档
|
||||||
</Link>
|
</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,
|
MemoryIcon,
|
||||||
ChatIcon,
|
ChatIcon,
|
||||||
CharacterIcon,
|
CharacterIcon,
|
||||||
CosmosIcon,
|
|
||||||
DriveIcon,
|
|
||||||
ModelIcon,
|
ModelIcon,
|
||||||
PersonalityIcon,
|
|
||||||
RunIcon,
|
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
ToolsIcon,
|
ToolsIcon,
|
||||||
WorldIcon,
|
WorldIcon,
|
||||||
WuxingIcon,
|
|
||||||
} from "@/components/ui/Icons";
|
} from "@/components/ui/Icons";
|
||||||
import { Badge } from "@/components/ui/Badge";
|
import { Badge } from "@/components/ui/Badge";
|
||||||
import { FEATURE_FLAGS } from "@/lib/config";
|
import { FEATURE_FLAGS } from "@/lib/config";
|
||||||
@@ -43,38 +38,25 @@ interface NavGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主导航分组:灵台(对话/轨迹)、法身(人物/人格/演化)、
|
* 主导航分组:灵台(镜树)、法身(灵胚/人物)、内景(世界)、
|
||||||
* 内景(内驱力/世界/时态)、藏识(记忆/归档)、百工(工具/模型/设置)。
|
* 藏识(记忆/归档)、百工(工具/神识阁/设置)。
|
||||||
* 每组不超过 4 项,符合 product-ui §1 导航 IA ≤8 项的建议;
|
* 旧人格/内驱力/五行/灵枢/轨迹页随 M6 退役(人格=道体锚点+缘契,轨迹并入镜树)。
|
||||||
* 分组后侧栏视觉有序,降低认知负荷。
|
|
||||||
*/
|
*/
|
||||||
const NAV_GROUPS: NavGroup[] = [
|
const NAV_GROUPS: NavGroup[] = [
|
||||||
{
|
{
|
||||||
title: "灵台",
|
title: "灵台",
|
||||||
items: [
|
items: [{ href: "/trees", label: "镜树", icon: <ChatIcon /> }],
|
||||||
{ href: "/trees", label: "镜树", icon: <ChatIcon /> },
|
|
||||||
{ href: "/runs", label: "轨迹", icon: <RunIcon /> },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "法身",
|
title: "法身",
|
||||||
items: [
|
items: [
|
||||||
{ href: "/embryos", label: "灵胚", icon: <CharacterIcon /> },
|
{ href: "/embryos", label: "灵胚", icon: <CharacterIcon /> },
|
||||||
{ href: "/cosmos", label: "灵枢", icon: <CosmosIcon /> },
|
|
||||||
{ href: "/wuxing", label: "五行", icon: <WuxingIcon /> },
|
|
||||||
{ href: "/characters", label: "人物", icon: <CharacterIcon /> },
|
{ href: "/characters", label: "人物", icon: <CharacterIcon /> },
|
||||||
{ href: "/personality", label: "人格", icon: <PersonalityIcon /> },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "内景",
|
title: "内景",
|
||||||
items: [
|
items: [
|
||||||
{
|
|
||||||
href: "/drive",
|
|
||||||
label: "内驱力",
|
|
||||||
icon: <DriveIcon />,
|
|
||||||
enabled: FEATURE_FLAGS.drivePanel,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
href: "/world",
|
href: "/world",
|
||||||
label: "世界",
|
label: "世界",
|
||||||
@@ -86,7 +68,7 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
{
|
{
|
||||||
title: "藏识",
|
title: "藏识",
|
||||||
items: [
|
items: [
|
||||||
{ href: "/memory", label: "记忆", icon: <MemoryIcon /> },
|
{ href: "/memories", label: "记忆", icon: <MemoryIcon /> },
|
||||||
{ href: "/archive", label: "归档", icon: <ArchiveIcon /> },
|
{ 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>
|
</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 charactersApi from "./characters";
|
||||||
export * as runsApi from "./runs";
|
export * as runsApi from "./runs";
|
||||||
export * as sandboxApi from "./sandbox";
|
export * as sandboxApi from "./sandbox";
|
||||||
export * as personalityApi from "./personality";
|
|
||||||
export * as driveApi from "./drive";
|
|
||||||
export * as healthApi from "./health";
|
export * as healthApi from "./health";
|
||||||
export * as toolsApi from "./tools";
|
export * as toolsApi from "./tools";
|
||||||
export * as safetyApi from "./safety";
|
export * as safetyApi from "./safety";
|
||||||
@@ -18,9 +16,10 @@ export * as adminApi from "./admin";
|
|||||||
export * as settingsApi from "./settings";
|
export * as settingsApi from "./settings";
|
||||||
export * as meApi from "./me";
|
export * as meApi from "./me";
|
||||||
export * as baigongApi from "./baigong";
|
export * as baigongApi from "./baigong";
|
||||||
|
export * as covenantApi from "./covenants";
|
||||||
|
export * as worldApi from "./worlds";
|
||||||
export * as importsApi from "./imports";
|
export * as importsApi from "./imports";
|
||||||
export * as extractionApi from "./extraction";
|
export * as extractionApi from "./extraction";
|
||||||
export * as worldApi from "./world";
|
|
||||||
export * as memoriesApi from "./memories";
|
export * as memoriesApi from "./memories";
|
||||||
export * as lorebooksApi from "./lorebooks";
|
export * as lorebooksApi from "./lorebooks";
|
||||||
export * as skillsApi from "./skills";
|
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