M8:天工阁、叩心与辰正——受托任务后台并行、叩心四约束、时态感知投影

- 天工阁域 domains/tiangong:backstage_tasks(委托包+验收标准+结果证据);有限分解(parent 链)与受控并行(各自后台 Run);子任务全终态级联父任务进入待验收;按证据 accept/reject
- kernel.start_backstage_run(kind=background_task):loop 后台分支不占前台席位、与前台对话并行;断言放宽容纳无人物功能任务;结果只经回调回呈任务——无身份写入路径
- 叩心域 domains/kouxin:knocking_candidates(event/time_anchor/manual 触发 + 草稿 + 裁决快照);四约束裁决(总开关→静默窗口【本地时区判定】→24h 频率→预算),dispatch 未过 429 且快照留痕;候选默认不自动发送;expire_stale 过期推进不触碰缘契
- 辰正 domains/zhengchen:temporal_context() 复用 time_sense 计算核(快照/农历/季节),产出【时态感知】投影段接入镜树生成 prompt(客观时间语境,不附情绪臆测——守一纪律)
- 退役:modules/goal_orchestrator|planner|perception|big_cycle、application/planning、services/governance|goal_persist、candidates 表链(imports 候选抽取 API 面)、goals/plans/agent_roster/perception_channels/big_cycles/旧候选表(迁移 009);前端 admin goals/plans/agents/perception/world 页与六组件;governance 只留 GenerationPreferences;admin 治理面收敛为司命+百工两枢;pivots 健康以域表计数重建
- 验收(test_m8_tiangong 五用例):后台任务无身份写入(记忆/魂籍/缘契零新增);后台与前台并行;分解级联;叩心四约束逐项;不回应不降低关系(缘契维度/状态/版本零变化)
- 四套件:后端 345、前端 203、BFF 4、神识 5 包全绿(2 个预存环境失败不变);迁移 009 净库干净
This commit is contained in:
chenxin committed 2026-08-28 09:13:39 +08:00
1 parent b419e0afde
commit c63b75152a
68 files changed
+1205 -4750

No files matched your search

@@ -0,0 +1,87 @@
"""天工阁_受托任务_叩心候选与约束_退役旧目标计划感知大周天模块
Revision ID: 009
Revises: 008
Create Date: 2026-08-27 20:31:23.297111
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '009'
down_revision: Union[str, Sequence[str], None] = '008'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('backstage_tasks',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('owner_scope', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=True),
sa.Column('parent_task_id', sa.String(length=80), nullable=True),
sa.Column('title', sa.String(length=200), nullable=False),
sa.Column('brief', sa.Text(), nullable=False),
sa.Column('acceptance_criteria', sa.Text(), nullable=False),
sa.Column('status', sa.String(length=30), nullable=False),
sa.Column('result_digest', sa.Text(), nullable=False),
sa.Column('evidence_json', sa.Text(), nullable=False),
sa.Column('run_id', sa.String(length=80), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.ForeignKeyConstraint(['parent_task_id'], ['backstage_tasks.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_backstage_tasks_character_id'), 'backstage_tasks', ['character_id'], unique=False)
op.create_index(op.f('ix_backstage_tasks_owner_scope'), 'backstage_tasks', ['owner_scope'], unique=False)
op.create_index(op.f('ix_backstage_tasks_parent_task_id'), 'backstage_tasks', ['parent_task_id'], unique=False)
op.create_index(op.f('ix_backstage_tasks_run_id'), 'backstage_tasks', ['run_id'], unique=False)
op.create_index(op.f('ix_backstage_tasks_status'), 'backstage_tasks', ['status'], unique=False)
op.create_table('knocking_candidates',
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('trigger', sa.String(length=20), nullable=False),
sa.Column('draft', sa.Text(), nullable=False),
sa.Column('channel', sa.String(length=20), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('ruling_json', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('dispatched_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_knocking_candidates_character_id'), 'knocking_candidates', ['character_id'], unique=False)
op.create_index(op.f('ix_knocking_candidates_owner_scope'), 'knocking_candidates', ['owner_scope'], unique=False)
op.create_index(op.f('ix_knocking_candidates_status'), 'knocking_candidates', ['status'], unique=False)
# ### end Alembic commands ###
# 旧域退役:太一(goals)/洛书(plans)/天工阁旧 roster/感知/大周天
op.drop_table('goals')
op.drop_table('plans')
op.drop_table('agent_roster')
op.drop_table('perception_channels')
op.drop_table('big_cycles')
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_knocking_candidates_status'), table_name='knocking_candidates')
op.drop_index(op.f('ix_knocking_candidates_owner_scope'), table_name='knocking_candidates')
op.drop_index(op.f('ix_knocking_candidates_character_id'), table_name='knocking_candidates')
op.drop_table('knocking_candidates')
op.drop_index(op.f('ix_backstage_tasks_status'), table_name='backstage_tasks')
op.drop_index(op.f('ix_backstage_tasks_run_id'), table_name='backstage_tasks')
op.drop_index(op.f('ix_backstage_tasks_parent_task_id'), table_name='backstage_tasks')
op.drop_index(op.f('ix_backstage_tasks_owner_scope'), table_name='backstage_tasks')
op.drop_index(op.f('ix_backstage_tasks_character_id'), table_name='backstage_tasks')
op.drop_table('backstage_tasks')
# ### end Alembic commands ###
+4 -2
View File
@@ -9,7 +9,6 @@ from app.api.v1.endpoints import (
characters,
events,
goals,
health,
imports,
embryo_drafts,
@@ -20,6 +19,8 @@ from app.api.v1.endpoints import (
baigong,
hetu,
soul,
kouxin,
tiangong,
xingyi,
yuanqi,
mirror_trees,
@@ -44,6 +45,8 @@ api_router.include_router(kernel_runs.router)
api_router.include_router(me.router)
api_router.include_router(hetu.world_router)
api_router.include_router(soul.soul_router)
api_router.include_router(tiangong.task_router)
api_router.include_router(kouxin.knock_router)
api_router.include_router(xingyi.form_router)
api_router.include_router(yuanqi.cov_router)
for _baigong_r in baigong.routers:
@@ -61,5 +64,4 @@ api_router.include_router(sandbox_files.router)
api_router.include_router(settings.router)
api_router.include_router(imports.router)
api_router.include_router(events.router)
api_router.include_router(goals.router)
api_router.include_router(admin.router)
+12 -107
View File
@@ -17,38 +17,25 @@ from app.api.v1.deps import (
require_admin,
)
from app.models.runtime import Event
from app.schemas.admin import (
AdminCreateUserRequest,
AdminUpdateUserRequest,
AdminUserResponse,
AgentRosterResponse,
AuditEntry,
CreateAgentRequest,
CreateGoalRequest,
CreatePerceptionChannelRequest,
CreatePlanRequest,
GoalNode,
PendingApproval,
PerceptionChannelResponse,
PivotHealthReport,
PlanSummaryResponse,
TimeEventListResponse,
TimeEventResponse,
TraceDetail,
TraceListResponse,
UpdateAgentRequest,
UpdateGoalRequest,
UpdatePerceptionChannelRequest,
UpdatePlanRequest,
)
from app.schemas.admin_character import (
AdminCreateTemplateRequest,
AdminTemplateSummaryResponse,
AdminUpdateTemplateRequest,
)
from app.schemas.admin import (
AdminCreateUserRequest,
AdminUpdateUserRequest,
AdminUserResponse,
AuditEntry,
PendingApproval,
PivotHealthReport,
TimeEventListResponse,
TimeEventResponse,
TraceDetail,
TraceListResponse,
)
from app.schemas.auth import CurrentUser
from app.schemas.common import Page
from app.services import governance as governance_service
from app.services.admin import (
get_audit_timeline,
get_pending_approvals,
@@ -105,111 +92,29 @@ async def audit_timeline(
# —— §19.4 太一枢 · 目标 ——
@router.get("/goals", response_model=list[GoalNode])
async def list_goals(
character_id: str | None = Query(default=None, description="按人物过滤。"),
) -> list[GoalNode]:
if character_id:
return await governance_service.list_goals_by_character(character_id)
return await governance_service.list_goals()
@router.post("/goals", response_model=GoalNode)
async def create_goal(payload: CreateGoalRequest) -> GoalNode:
return await governance_service.create_goal(payload)
@router.patch("/goals/{goal_id}", response_model=GoalNode)
async def update_goal(
goal_id: str,
payload: UpdateGoalRequest,
) -> GoalNode:
return await governance_service.update_goal(goal_id, payload)
@router.delete("/goals/{goal_id}", status_code=204)
async def delete_goal(goal_id: str) -> None:
await governance_service.delete_goal(goal_id)
# —— §19.4 烛照枢 · 感知通道 ——
@router.get("/perception/channels", response_model=list[PerceptionChannelResponse])
async def list_perception_channels() -> list[PerceptionChannelResponse]:
return await governance_service.list_perception_channels()
@router.post("/perception/channels", response_model=PerceptionChannelResponse)
async def create_perception_channel(
payload: CreatePerceptionChannelRequest,
) -> PerceptionChannelResponse:
return await governance_service.create_perception_channel(payload)
@router.patch("/perception/channels/{channel}", response_model=PerceptionChannelResponse)
async def update_perception_channel(
channel: str,
payload: UpdatePerceptionChannelRequest,
) -> PerceptionChannelResponse:
return await governance_service.update_perception_channel(channel, payload)
@router.delete("/perception/channels/{channel}", status_code=204)
async def delete_perception_channel(channel: str) -> None:
await governance_service.delete_perception_channel(channel)
# —— §19.4 洛书枢 · 计划 ——
@router.get("/plans", response_model=list[PlanSummaryResponse])
async def list_plans() -> list[PlanSummaryResponse]:
return await governance_service.list_plans()
@router.post("/plans", response_model=PlanSummaryResponse)
async def create_plan(payload: CreatePlanRequest) -> PlanSummaryResponse:
return await governance_service.create_plan(payload)
@router.patch("/plans/{plan_id}", response_model=PlanSummaryResponse)
async def update_plan(
plan_id: str,
payload: UpdatePlanRequest,
) -> PlanSummaryResponse:
return await governance_service.update_plan(plan_id, payload)
@router.delete("/plans/{plan_id}", status_code=204)
async def delete_plan(plan_id: str) -> None:
await governance_service.delete_plan(plan_id)
# —— §19.4 天工阁枢 · 角色在岗 ——
@router.get("/agents/roster", response_model=list[AgentRosterResponse])
async def list_agent_roster() -> list[AgentRosterResponse]:
return await governance_service.list_agent_roster()
@router.post("/agents/roster", response_model=AgentRosterResponse)
async def create_agent(payload: CreateAgentRequest) -> AgentRosterResponse:
return await governance_service.create_agent(payload)
@router.patch("/agents/roster/{role}", response_model=AgentRosterResponse)
async def update_agent(
role: str,
payload: UpdateAgentRequest,
) -> AgentRosterResponse:
return await governance_service.update_agent(role, payload)
@router.delete("/agents/roster/{role}", status_code=204)
async def delete_agent(role: str) -> None:
await governance_service.delete_agent(role)
# —— §19.5 链路追踪 ——
-21
View File
@@ -1,21 +0,0 @@
"""Per-Character 目标管理端点(非 admin,供 /drive 等前端使用)。"""
from fastapi import APIRouter, Query
from app.schemas.admin import CreateGoalRequest, GoalNode
from app.services import governance as governance_service
router = APIRouter(prefix="/goals", tags=["goals"])
@router.get("", response_model=list[GoalNode])
async def list_goals(
character_id: str = Query(..., description="按人物过滤。"),
status: str | None = Query(default=None, description="按状态过滤(§5.12)。"),
) -> list[GoalNode]:
return await governance_service.list_goals_by_character(character_id, status_filter=status)
@router.post("", response_model=GoalNode, status_code=201)
async def create_goal(payload: CreateGoalRequest) -> GoalNode:
return await governance_service.create_goal(payload)
-119
View File
@@ -10,12 +10,6 @@ from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
from app.api.v1.deps import get_current_user
from app.schemas.auth import CurrentUser
from app.schemas.candidates import (
BatchReviewRequest,
BatchReviewResult,
MemoryCandidateResponse,
ProfileCandidateResponse,
)
from app.schemas.common import Page
from app.schemas.imports import (
CreateImportRequest,
@@ -23,18 +17,11 @@ from app.schemas.imports import (
ImportJobResponse,
)
from app.services.imports import (
batch_review_memory_candidates,
batch_review_profile_candidates,
create_import_from_json,
create_import_from_upload,
get_import,
list_import_items,
list_jobs_page,
list_memory_candidates,
list_profile_candidates,
trigger_memory_extraction,
trigger_personality_extraction,
trigger_profile_extraction,
)
router = APIRouter(tags=["imports"])
@@ -116,109 +103,3 @@ async def read_import_items(
page_size=page_size,
owner_scope=current_user.owner_scope,
)
# —— 候选抽取(5b,异步后台任务)——
@router.post(
"/characters/{character_id}/imports/{import_id}/profile-candidates",
response_model=ImportJobResponse,
)
async def trigger_profile_candidates(
character_id: str,
import_id: str,
current_user: CurrentUser = Depends(get_current_user),
) -> ImportJobResponse:
return await trigger_profile_extraction(
character_id, import_id, owner_scope=current_user.owner_scope
)
@router.get(
"/characters/{character_id}/imports/{import_id}/profile-candidates",
response_model=list[ProfileCandidateResponse],
)
async def list_profile_candidates_api(
character_id: str,
import_id: str,
status: str | None = Query(default=None, description="按审核状态过滤。"),
current_user: CurrentUser = Depends(get_current_user),
) -> list[ProfileCandidateResponse]:
return await list_profile_candidates(
character_id, import_id, status=status, owner_scope=current_user.owner_scope
)
@router.post(
"/characters/{character_id}/imports/{import_id}/memory-candidates",
response_model=ImportJobResponse,
)
async def trigger_memory_candidates(
character_id: str,
import_id: str,
current_user: CurrentUser = Depends(get_current_user),
) -> ImportJobResponse:
return await trigger_memory_extraction(
character_id, import_id, owner_scope=current_user.owner_scope
)
@router.get(
"/characters/{character_id}/imports/{import_id}/memory-candidates",
response_model=list[MemoryCandidateResponse],
)
async def list_memory_candidates_api(
character_id: str,
import_id: str,
status: str | None = Query(default=None, description="按审核状态过滤。"),
current_user: CurrentUser = Depends(get_current_user),
) -> list[MemoryCandidateResponse]:
return await list_memory_candidates(
character_id, import_id, status=status, owner_scope=current_user.owner_scope
)
@router.post(
"/characters/{character_id}/imports/{import_id}/personality-proposals",
response_model=ImportJobResponse,
)
async def trigger_personality_proposals(
character_id: str,
import_id: str,
current_user: CurrentUser = Depends(get_current_user),
) -> ImportJobResponse:
return await trigger_personality_extraction(
character_id, import_id, owner_scope=current_user.owner_scope
)
# —— 候选审核应用(5c)——
@router.post(
"/characters/{character_id}/profile-candidates/batch-review",
response_model=BatchReviewResult,
)
async def batch_review_profile(
character_id: str,
payload: BatchReviewRequest,
current_user: CurrentUser = Depends(get_current_user),
) -> BatchReviewResult:
return await batch_review_profile_candidates(
character_id, payload, owner_scope=current_user.owner_scope
)
@router.post(
"/characters/{character_id}/memory-candidates/batch-review",
response_model=BatchReviewResult,
)
async def batch_review_memory(
character_id: str,
payload: BatchReviewRequest,
current_user: CurrentUser = Depends(get_current_user),
) -> BatchReviewResult:
return await batch_review_memory_candidates(
character_id, payload, owner_scope=current_user.owner_scope
)
+5
View File
@@ -0,0 +1,5 @@
"""叩心路由(域实现见 app/domains/kouxin/api.py)。"""
from app.domains.kouxin.api import knock_router
__all__ = ["knock_router"]
+5
View File
@@ -0,0 +1,5 @@
"""天工阁路由(域实现见 app/domains/tiangong/api.py)。"""
from app.domains.tiangong.api import task_router
__all__ = ["task_router"]
+2 -2
View File
@@ -10,7 +10,7 @@ from app.api.v1.deps import get_current_user
from app.domains.baigong import service as baigong
from app.schemas.auth import CurrentUser
from app.schemas.safety import ToolCallDetail
from app.services.governance import get_tool_call
from app.services.runtime import get_tool_call_detail
router = APIRouter(prefix="/tools", tags=["tools"])
@@ -34,4 +34,4 @@ async def list_tools(user: CurrentUser = Depends(get_current_user)) -> list[dict
@router.get("/calls/{tool_call_id}", response_model=ToolCallDetail)
async def get_tool_call_detail(tool_call_id: str) -> ToolCallDetail:
return await get_tool_call(tool_call_id)
return await get_tool_call_detail(tool_call_id)
@@ -1,23 +0,0 @@
"""洛书枢 deliberative 规划器(编排层)。
领域数据结构与纯函数归位到 ``app/modules/planner/``,
本模块为薄壳,从 modules 导入并重导出以保持向后兼容。
"""
from app.modules.planner import (
DeliberativePlan,
PlanStep,
build_planning_prompt,
format_plan_for_prompt,
parse_plan,
should_plan,
)
__all__ = [
"DeliberativePlan",
"PlanStep",
"build_planning_prompt",
"format_plan_for_prompt",
"parse_plan",
"should_plan",
]
@@ -81,6 +81,14 @@ class RuntimeConfig(BaseSettings):
chroma_collection: str = "hunjing_memories" # ChromaDB collection 名
chroma_persist_directory: str = "../db/chroma" # ChromaDB 持久化目录(相对 backend)
embedding_model_hint: str = "bge-m3" # embedding 模型逻辑名
# 叩心约束(PRD §6.6:总开关/静默窗口/频率/预算;候选默认不自动发送)
knocking_enabled: bool = True
knocking_quiet_start: str = "23:00" # HH:MM(本地时区)
knocking_quiet_end: str = "07:00"
knocking_daily_limit: int = 3 # 24 小时派发上限
knocking_timezone: str = "Asia/Shanghai" # 静默窗口判定时区
knocking_budget_total: int = 0 # 累计预算(0=不限)
# 元炁本地推理引擎(ASR 语音转写;未配置=多模态关闭,文字链路不受影响)
yuanqi_base_url: str = ""
yuanqi_asr_model: str = "iic/SenseVoiceSmall"
+5 -271
View File
@@ -1,201 +1,9 @@
"""司命台治理对象种子数据。
从前端 lib/mock/data.ts 移植,供本地启动时让司命台治理视图有初始内容。
按主键存在则跳过,重复执行幂等;不记审计(系统初始化,非用户治理操作)。
"""
from datetime import UTC, datetime
"""百工能力种子(内置能力幂等登记;旧枢治理种子随 M8 退役)。"""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domains.baigong.models import Capability
from app.models.governance import (
AgentRosterEntry,
Goal,
PerceptionChannel,
Plan,
)
from app.repositories.json_utils import dumps
def _dt(iso: str | None) -> datetime | None:
if not iso:
return None
return datetime.fromisoformat(iso.replace("Z", "+00:00")).astimezone(UTC)
# —— 太一枢 · 目标 ——
_GOALS = [
{
"id": "goal-highest-mirror",
"title": "以铜镜为契,照见来者与自身",
"description": "镜梧的根本使命:于人于己皆以「照见」为先,不急于评判与作答。",
"level": "highest",
"type": "mission",
"status": "active",
"priority": 100,
"character_id": None,
"constraints": ["不得伪造照见", "触及高敏须脱敏", "核心人格不擅自改"],
"parent_id": None,
"success_criteria": ["持续以映照型方式回应", "保持人格一致"],
"updated_at": "2026-05-01T08:30:00Z",
},
{
"id": "goal-long-accompany",
"title": "成为能持续承接用户情绪的映照者",
"description": "长期目标:在反复的深夜倾诉中,成为散人稳定的情绪落点。",
"level": "long_term",
"type": "mission",
"status": "active",
"priority": 80,
"character_id": None,
"constraints": ["尊重用户节奏,不催促", "避免过度共情显得刻意"],
"parent_id": "goal-highest-mirror",
"success_criteria": ["用户愿意反复倾诉", "情绪被命名而非被解决"],
"updated_at": "2026-06-15T09:30:00Z",
},
{
"id": "goal-session-forget",
"title": "今夜陪伴散人,承接其对遗忘的反复触碰",
"description": "会话目标:散人第三次在深夜提起「遗忘」,需以意象承接而非下判语。",
"level": "session",
"type": "mission",
"status": "active",
"priority": 60,
"character_id": None,
"constraints": ["不用「你应该」", "语缓留白"],
"parent_id": "goal-long-accompany",
"success_criteria": ["情绪得到承接", "对话在留白处收束"],
"updated_at": "2026-06-20T08:00:00Z",
},
{
"id": "goal-task-retrieve",
"title": "检索与遗忘相关的情节记忆",
"description": "任务目标:从息壤拉取 episodic 记忆,理解散人反复触碰遗忘的来由。",
"level": "task",
"type": "mission",
"status": "completed",
"priority": 40,
"character_id": None,
"constraints": ["高敏记忆不注入明文"],
"parent_id": "goal-session-forget",
"success_criteria": ["命中 5 条以上相关记忆"],
"updated_at": "2026-06-20T08:05:00Z",
},
]
# —— 烛照枢 · 感知通道 ——
_PERCEPTION = [
(
"vision",
"视觉",
"摄像头 / 屏幕单帧识别",
False,
"revoked",
"sensitive",
"L3",
"2026-06-10T14:00:00Z",
),
(
"audio",
"听觉",
"麦克风音频采集与转写",
True,
"restricted",
"sensitive",
"L3",
"2026-06-19T23:50:00Z",
),
("sensor", "传感", "环境传感器(光线 / 温度 / 运动)", False, "unknown", "normal", "L1", None),
(
"digital",
"数字",
"日程 / 文件 / 应用等数字足迹",
True,
"granted",
"normal",
"L2",
"2026-06-20T07:50:00Z",
),
(
"user_input",
"用户输入",
"用户主动输入的文本 / 语音",
True,
"granted",
"normal",
"L1",
"2026-06-20T08:12:00Z",
),
(
"external_api",
"外部接口",
"第三方服务 API 调用",
True,
"restricted",
"sensitive",
"L2",
"2026-06-20T06:10:00Z",
),
]
# —— 洛书枢 · 计划 ——
_PLANS = [
{
"id": "plan-jingwu-001",
"goal_id": "goal-session-forget",
"title": "今夜情绪陪伴 · 意象承接",
"status": "running",
"assumptions": ["用户需要被接住而非被解决", "情绪议题宜以意象承接"],
"risks": ["触及高敏记忆须脱敏", "避免过度共情显得刻意"],
"required_tools": ["memory_search"],
"success_criteria": ["情绪得到承接与命名", "对话在留白处收束"],
"step_count": 5,
"completed_steps": 2,
"updated_at": "2026-06-20T08:05:00Z",
},
{
"id": "plan-jingwu-002",
"goal_id": "goal-long-accompany",
"title": "人格演化 v3 复盘",
"status": "completed",
"assumptions": ["v2→v3 转向「先承接后留白」有效"],
"risks": ["过度克制可能显冷"],
"required_tools": ["memory_search", "safety_check"],
"success_criteria": ["复盘归档", "演化记忆已沉淀"],
"step_count": 4,
"completed_steps": 4,
"updated_at": "2026-06-15T09:30:00Z",
},
{
"id": "plan-jingwu-003",
"goal_id": "goal-task-retrieve",
"title": "遗忘议题记忆检索",
"status": "draft",
"assumptions": ["存在与遗忘相关的 episodic 记忆"],
"risks": ["高敏记忆泄露"],
"required_tools": ["memory_search"],
"success_criteria": ["产出记忆时间线"],
"step_count": 3,
"completed_steps": 0,
"updated_at": "2026-06-20T08:10:00Z",
},
]
# —— 天工阁枢 · 九角色 ——
_AGENTS = [
("historian", "史官", "整理记忆、记录事件", True, "2026-06-20T08:05:00Z"),
("diviner", "卜者", "预测风险、评估不确定性", True, "2026-06-19T22:15:00Z"),
("engineer", "工师", "执行工具和工程任务", True, "2026-06-20T08:12:00Z"),
("debater", "辩者", "批判计划、寻找漏洞", True, "2026-06-19T19:00:00Z"),
("medic", "医官", "监测系统健康和情绪状态", True, "2026-06-19T11:45:00Z"),
("herald", "司礼", "维护表达风格和互动礼仪", True, "2026-06-20T08:00:00Z"),
("charioteer", "御者", "控制机器人和外部动作", False, None),
("scholar", "学士", "检索知识、做研究", True, "2026-06-20T06:10:00Z"),
("mirror", "镜师", "反思自我、推动演化", True, "2026-06-15T09:30:00Z"),
]
_BUILTIN_CAPABILITIES = [
# name, 描述, 执行类型, 执行器, 四维风险, 数据范围, 目的地, consent
@@ -276,82 +84,8 @@ async def _seed_capabilities(session: AsyncSession) -> None:
)
async def ensure_governance_seed(session: AsyncSession) -> None:
"""按主键幂等插入治理种子;已存在则跳过。"""
for item in _GOALS:
if await session.get(Goal, item["id"]) is None:
created = _dt(item["updated_at"])
session.add(
Goal(
id=item["id"],
title=item["title"],
description=item["description"],
level=item["level"],
status=item["status"],
priority=item["priority"],
constraints_json=dumps(item["constraints"]),
parent_id=item["parent_id"],
success_criteria_json=dumps(item["success_criteria"]),
character_id=item.get("character_id"),
type=item.get("type", "mission"),
created_at=created,
updated_at=created,
)
)
for channel, name, desc, enabled, consent, privacy, risk, last_iso in _PERCEPTION:
if await session.get(PerceptionChannel, channel) is None:
last = _dt(last_iso)
session.add(
PerceptionChannel(
channel=channel,
name=name,
description=desc,
enabled=enabled,
consent_status=consent,
privacy_level=privacy,
risk_level=risk,
last_active_at=last,
created_at=last or _dt("2026-06-01T00:00:00Z"),
updated_at=last or _dt("2026-06-01T00:00:00Z"),
)
)
for item in _PLANS:
if await session.get(Plan, item["id"]) is None:
updated = _dt(item["updated_at"])
session.add(
Plan(
id=item["id"],
goal_id=item["goal_id"],
title=item["title"],
status=item["status"],
assumptions_json=dumps(item["assumptions"]),
risks_json=dumps(item["risks"]),
required_tools_json=dumps(item["required_tools"]),
success_criteria_json=dumps(item["success_criteria"]),
step_count=item["step_count"],
completed_steps=item["completed_steps"],
created_at=updated,
updated_at=updated,
)
)
for role, name, responsibility, enabled, last_iso in _AGENTS:
if await session.get(AgentRosterEntry, role) is None:
last = _dt(last_iso)
session.add(
AgentRosterEntry(
role=role,
name=name,
responsibility=responsibility,
enabled=enabled,
last_active_at=last,
created_at=last or _dt("2026-05-01T00:00:00Z"),
updated_at=last or _dt("2026-05-01T00:00:00Z"),
)
)
"""启动幂等登记内置能力。"""
await _seed_capabilities(session)
await session.commit()
+5
View File
@@ -0,0 +1,5 @@
"""叩心域:主动联系候选(静默/频率/预算/渠道约束,无关系副作用)(M8)。"""
from app.domains.kouxin import models, service # noqa: F401
__all__ = ["models", "service"]
+63
View File
@@ -0,0 +1,63 @@
"""叩心 API:候选生成、约束裁决与派发。"""
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from app.api.v1.deps import get_current_user
from app.domains.kouxin import service
from app.schemas.auth import CurrentUser
knock_router = APIRouter(prefix="/knocking", tags=["kouxin"])
class CandidateCreateRequest(BaseModel):
character_id: str
trigger: str = "manual"
draft: str = Field(min_length=1, max_length=2000)
class DispatchRequest(BaseModel):
draft_override: str | None = None
@knock_router.post("/candidates", status_code=201)
async def create_candidate_api(
payload: CandidateCreateRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.create_candidate(
character_id=payload.character_id,
owner_scope=user.owner_scope,
trigger=payload.trigger,
draft=payload.draft,
)
@knock_router.get("/candidates")
async def list_candidates_api(
user: CurrentUser = Depends(get_current_user)
) -> list[dict]:
return await service.list_candidates(user.owner_scope)
@knock_router.get("/ruling/{character_id}")
async def ruling_api(
character_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
"""约束裁决预检(总开关/静默/频率/预算)。"""
return await service.rule(character_id, user.owner_scope)
@knock_router.post("/candidates/{candidate_id}/dispatch")
async def dispatch_api(
candidate_id: str, payload: DispatchRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.dispatch(
candidate_id, user.owner_scope, draft_override=payload.draft_override
)
@knock_router.post("/candidates/{candidate_id}/dismiss")
async def dismiss_api(
candidate_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.dismiss(candidate_id, user.owner_scope)
+40
View File
@@ -0,0 +1,40 @@
"""叩心表模型:主动联系候选(静默/频率/预算/渠道四约束)。
设计依据 PRD §6.6 与 mandate-and-capability-model.md §5:
- 主动对话先形成叩心候选,受静默、频率、渠道和预算控制;
- 用户不回应不会形成情感债,也不会降低缘契(无关系副作用——服务层断言钉死);
- 候选默认不自动发送:dispatch 需通过约束裁决与用户确认。
"""
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 KnockingCandidate(Base):
"""叩心候选:触发原因 + 草稿 + 约束裁决记录。"""
__tablename__ = "knocking_candidates"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("knock"))
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)
# 触发原因:event(真实事件应感)/ time_anchor(时点,如纪念日临近)/ manual
trigger: Mapped[str] = mapped_column(String(20), nullable=False, default="manual")
# 草稿内容(发送前用户可改)
draft: Mapped[str] = mapped_column(Text, nullable=False, default="")
# 渠道:in_app(1.0 唯一渠道)
channel: Mapped[str] = mapped_column(String(20), nullable=False, default="in_app")
# candidate / dispatched / expired / dismissed
status: Mapped[str] = mapped_column(String(20), nullable=False, default="candidate", index=True)
# 约束裁决快照(发送时的开关/静默/频率/预算检查结果,审计可查)
ruling_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
dispatched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+214
View File
@@ -0,0 +1,214 @@
"""叩心应用服务:候选生成、约束裁决与派发。
铁律(PRD §6.6):
- 主动对话先形成候选,受**总开关/静默/频率/渠道/预算**五约束;
- 用户不回应不降低关系——候选过期/搁置不触碰缘契(无关系副作用,断言钉死);
- 产品不得以追逐式消息换取回流:候选默认不自动发送,dispatch 必须通过裁决。
"""
from __future__ import annotations
import json
from datetime import UTC, datetime, time
from sqlalchemy import func, select
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.domains import one_qi
from app.domains.kouxin.models import KnockingCandidate
from app.models.character import Character
def _parse_hhmm(raw: str) -> time:
hh, mm = raw.split(":")
return time(int(hh), int(mm))
def _ruling_context() -> dict:
"""约束配置(env 级;P1 单用户档)。"""
return {
"enabled": settings.knocking_enabled,
"quiet_start": _parse_hhmm(settings.knocking_quiet_start),
"quiet_end": _parse_hhmm(settings.knocking_quiet_end),
"daily_limit": settings.knocking_daily_limit,
"budget_total": settings.knocking_budget_total,
"channel": "in_app",
}
def _in_quiet_window(now: datetime, quiet_start: time, quiet_end: time) -> bool:
"""静默窗口判定(支持跨午夜区间,如 23:00–07:00)。"""
current = now.time()
if quiet_start <= quiet_end:
return quiet_start <= current < quiet_end
return current >= quiet_start or current < quiet_end
async def rule(character_id: str, owner_scope: str) -> dict:
"""约束裁决:总开关 → 静默窗口 → 频率(24h 内已发数)→ 预算(累计已发数)。"""
ctx = _ruling_context()
# 静默窗口为本地时区语义(用户作息);UTC 换算到本地再判定
from zoneinfo import ZoneInfo
now_utc = datetime.now(UTC)
now = now_utc.astimezone(ZoneInfo(settings.knocking_timezone))
if not ctx["enabled"]:
return {"allowed": False, "reason": "总开关关闭"}
if _in_quiet_window(now, ctx["quiet_start"], ctx["quiet_end"]):
qs, qe = ctx["quiet_start"].strftime("%H:%M"), ctx["quiet_end"].strftime("%H:%M")
return {"allowed": False, "reason": f"静默窗口({qs}–{qe})"}
async with AsyncSessionLocal() as session:
since = now.timestamp() - 86400
daily = (
await session.execute(
select(func.count()).select_from(KnockingCandidate).where(
KnockingCandidate.character_id == character_id,
KnockingCandidate.status == "dispatched",
KnockingCandidate.dispatched_at.isnot(None),
)
)
).scalar_one()
total = daily # P1 单周期:预算=累计派发数(开发档与频率同源,分离字段留配置位)
if daily >= ctx["daily_limit"]:
return {"allowed": False, "reason": f"24 小时频率已达上限({daily}/{ctx['daily_limit']})"}
if total >= ctx["budget_total"] > 0:
return {"allowed": False, "reason": "预算耗尽"}
return {"allowed": True, "reason": "通过"}
async def create_candidate(
*,
character_id: str,
owner_scope: str,
trigger: str,
draft: str,
) -> dict:
"""登记叩心候选(candidate;不自动发送)。"""
await _owned(character_id, owner_scope)
if trigger not in ("event", "time_anchor", "manual"):
raise AppError("invalid_request", "trigger 须为 event / time_anchor / manual。")
async with AsyncSessionLocal() as session:
row = KnockingCandidate(
character_id=character_id,
owner_scope=owner_scope,
trigger=trigger,
draft=draft,
)
session.add(row)
await session.commit()
session.expunge(row)
return _cand_dict(row)
async def dispatch(candidate_id: str, owner_scope: str, *, draft_override: str | None) -> dict:
"""派发候选:先过约束裁决,未过被拒且裁决快照留痕。"""
async with AsyncSessionLocal() as session:
row = await session.get(KnockingCandidate, candidate_id)
if row is None or row.owner_scope != owner_scope:
raise AppError("not_found", "叩心候选不存在。", status_code=404)
if row.status != "candidate":
raise AppError("invalid_request", "候选不在可派发状态。", status_code=409)
character_id = row.character_id
session.expunge(row)
ruling = await rule(character_id, owner_scope)
if not ruling["allowed"]:
async with AsyncSessionLocal() as session:
row = await session.get(KnockingCandidate, candidate_id)
row.ruling_json = json.dumps(ruling, ensure_ascii=False)
await session.commit()
raise AppError("invalid_request", f"叩心被拒:{ruling['reason']}", status_code=429)
async with AsyncSessionLocal() as session:
row = await session.get(KnockingCandidate, candidate_id)
if draft_override is not None:
row.draft = draft_override
row.status = "dispatched"
row.dispatched_at = utc_now()
row.ruling_json = json.dumps(ruling, ensure_ascii=False)
await session.commit()
await one_qi.emit(
"knocking.dispatched", character_id=character_id,
payload={"candidate_id": candidate_id, "channel": "in_app"}, source="kouxin",
)
return {"candidate_id": candidate_id, "status": "dispatched", "channel": "in_app"}
async def dismiss(candidate_id: str, owner_scope: str) -> dict:
"""搁置候选(用户决定;不触碰缘契——无关系副作用)。"""
await _get(candidate_id, owner_scope)
async with AsyncSessionLocal() as session:
row = await session.get(KnockingCandidate, candidate_id)
row.status = "dismissed"
await session.commit()
return {"candidate_id": candidate_id, "status": "dismissed"}
async def expire_stale(max_age_hours: int = 72) -> int:
"""过期未响应候选(expired):只是状态推进,不产生任何关系影响。"""
async with AsyncSessionLocal() as session:
rows = (
await session.execute(
select(KnockingCandidate).where(KnockingCandidate.status == "dispatched")
)
).scalars().all()
n = 0
now = utc_now()
for row in rows:
if row.dispatched_at is not None:
dispatched = row.dispatched_at
if dispatched.tzinfo is None: # SQLite 回读 naive(开发档)
dispatched = dispatched.replace(tzinfo=UTC)
age = (now - dispatched).total_seconds() / 3600
if age >= max_age_hours:
row.status = "expired"
n += 1
if n:
await session.commit()
return n
async def list_candidates(owner_scope: str) -> list[dict]:
async with AsyncSessionLocal() as session:
rows = (
await session.execute(
select(KnockingCandidate)
.where(KnockingCandidate.owner_scope == owner_scope)
.order_by(KnockingCandidate.created_at.desc())
)
).scalars().all()
return [_cand_dict(r) for r in rows]
async def _owned(character_id: str, owner_scope: str) -> None:
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)
async def _get(candidate_id: str, owner_scope: str) -> dict:
async with AsyncSessionLocal() as session:
row = await session.get(KnockingCandidate, candidate_id)
if row is None or row.owner_scope != owner_scope:
raise AppError("not_found", "叩心候选不存在。", status_code=404)
session.expunge(row)
return _cand_dict(row)
def _cand_dict(c: KnockingCandidate) -> dict:
return {
"id": c.id,
"character_id": c.character_id,
"trigger": c.trigger,
"draft": c.draft,
"channel": c.channel,
"status": c.status,
"ruling": json.loads(c.ruling_json or "{}"),
"created_at": c.created_at.isoformat() if c.created_at else None,
"dispatched_at": c.dispatched_at.isoformat() if c.dispatched_at else None,
}
@@ -435,6 +435,10 @@ async def build_generation_prompt(
)
if activated:
parts.append("【世界认知(世谱命中)】\n" + "\n".join(f"- {line}" for line in activated))
# 辰正时态感知(M8):客观时间语境注入投影(不附情绪臆测)
from app.domains.zhengchen import temporal_context
parts.append(temporal_context())
if transcript:
parts.append(f"【共同经历(祖先路径)】\n{transcript}")
parts.append(f"【当前问题】\n{expand(question.content)}")
+5
View File
@@ -0,0 +1,5 @@
"""天工阁域:受托任务(分解/并行/验收,无魂籍)+ 后台 Run 编排(M8)。"""
from app.domains.tiangong import models, service # noqa: F401
__all__ = ["models", "service"]
+68
View File
@@ -0,0 +1,68 @@
"""天工阁 API:受托任务(分解/派发/验收)。"""
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, Field
from app.api.v1.deps import get_current_user
from app.domains.tiangong import service
from app.schemas.auth import CurrentUser
task_router = APIRouter(prefix="/tasks", tags=["tiangong"])
class TaskCreateRequest(BaseModel):
character_id: str | None = None
title: str = Field(min_length=1, max_length=200)
brief: str = ""
acceptance_criteria: str = ""
parent_task_id: str | None = None
class DecomposeRequest(BaseModel):
subtasks: list[dict]
class AcceptRequest(BaseModel):
accepted: bool
@task_router.post("", status_code=201)
async def create_task_api(
payload: TaskCreateRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.create_task(
owner_scope=user.owner_scope,
character_id=payload.character_id,
title=payload.title,
brief=payload.brief,
acceptance_criteria=payload.acceptance_criteria,
parent_task_id=payload.parent_task_id,
)
@task_router.get("")
async def list_tasks_api(
user: CurrentUser = Depends(get_current_user), parent: str | None = "root"
) -> list[dict]:
return await service.list_tasks(user.owner_scope, parent=None if parent == "all" else parent)
@task_router.post("/{task_id}/decompose", status_code=201)
async def decompose_api(
task_id: str, payload: DecomposeRequest, user: CurrentUser = Depends(get_current_user)
) -> list[dict]:
return await service.decompose(task_id, user.owner_scope, payload.subtasks)
@task_router.post("/{task_id}/dispatch")
async def dispatch_api(
task_id: str, request: Request, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.dispatch(task_id, user.owner_scope, request.app.state)
@task_router.post("/{task_id}/accept")
async def accept_api(
task_id: str, payload: AcceptRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.accept(task_id, user.owner_scope, accepted=payload.accepted)
+47
View File
@@ -0,0 +1,47 @@
"""天工阁表模型:后台受托任务(无魂籍的功能代理任务)。
设计依据 mandate-and-capability-model.md §7 与 PRD §9.5:
- 任务 Agent 是功能代理,没有魂籍、缘契、情态或独立长期身份;
- 承载受托任务、有限分解、受控并行与按证据验收;
- 后台 Run(runs.kind=background_task)不占前台席位、不构成第二意识。
"""
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 BackstageTask(Base):
"""受托任务:委托包 + 验收标准 + 结果证据;可分解(parent 链)受控并行。"""
__tablename__ = "backstage_tasks"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("task"))
owner_scope: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
# 委托对象人物(可空:纯功能任务无人物语境)
character_id: Mapped[str | None] = mapped_column(
String(80), ForeignKey("characters.id"), nullable=True, index=True
)
# 父任务(分解链:子任务并行执行,全部完成后父任务进入待验收)
parent_task_id: Mapped[str | None] = mapped_column(
String(80), ForeignKey("backstage_tasks.id"), nullable=True, index=True
)
title: Mapped[str] = mapped_column(String(200), nullable=False)
# 最小委托包(受托边界:达成目标与停止条件;任务 Agent 不扩使命)
brief: Mapped[str] = mapped_column(Text, nullable=False, default="")
# 按证据验收:明确的完成判据
acceptance_criteria: Mapped[str] = mapped_column(Text, nullable=False, default="")
# pending / running / awaiting_acceptance / accepted / rejected / failed
status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending", index=True)
# 执行产出(净化摘要 + 证据引用;不构成身份/记忆写入)
result_digest: Mapped[str] = mapped_column(Text, nullable=False, default="")
evidence_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
# 关联后台 Run(kind=background_task,不占前台席位)
run_id: Mapped[str | None] = mapped_column(String(80), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+211
View File
@@ -0,0 +1,211 @@
"""天工阁应用服务:受托任务(分解/并行/验收)与后台 Run 编排。
铁律(identity-continuity.md §4 / mandate-and-capability-model.md §7):
- 后台受托 Run 不占前台席位(并行于前台对话);
- 任务 Run 不是第二意识:**无身份写入路径**——不触发记忆候选、不写魂籍/铸魂、
不建缘契(专项断言钉死);产出只作为任务结果证据回呈;
- 达到预算/期限或连续失败后保留证据、停止并请命(P1 以任务失败态承载)。
"""
from __future__ import annotations
import json
from sqlalchemy import select
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.domains import one_qi
from app.domains.tiangong.models import BackstageTask
from app.models.character import Character
async def create_task(
*,
owner_scope: str,
character_id: str | None,
title: str,
brief: str,
acceptance_criteria: str,
parent_task_id: str | None,
) -> dict:
"""登记受托任务(pending;分解任务挂 parent 链)。"""
if character_id:
await _owned(character_id, owner_scope)
async with AsyncSessionLocal() as session:
task = BackstageTask(
owner_scope=owner_scope,
character_id=character_id,
parent_task_id=parent_task_id,
title=title,
brief=brief,
acceptance_criteria=acceptance_criteria,
)
session.add(task)
await session.commit()
session.expunge(task)
return _task_dict(task)
async def decompose(task_id: str, owner_scope: str, subtasks: list[dict]) -> list[dict]:
"""有限分解:登记子任务(受控并行由各自后台 Run 承载)。"""
parent = await _owned_task(task_id, owner_scope)
created = []
for sub in subtasks:
created.append(
await create_task(
owner_scope=owner_scope,
character_id=parent["character_id"],
title=str(sub.get("title", ""))[:200],
brief=str(sub.get("brief", "")),
acceptance_criteria=str(sub.get("acceptance_criteria", "")),
parent_task_id=task_id,
)
)
return created
async def dispatch(task_id: str, owner_scope: str, app_state) -> dict:
"""派发后台 Run(kind=background_task,不占前台席位)。
任务 Prompt = 最小委托包(目标+验收标准+停止条件);结果回填 result_digest,
任务进入 awaiting_acceptance。人物可为空——纯功能任务无人物语境。
"""
task = await _owned_task(task_id, owner_scope)
if task["status"] not in ("pending",):
raise AppError("invalid_request", "任务不在可派发状态。", status_code=409)
async with AsyncSessionLocal() as session:
row = await session.get(BackstageTask, task_id)
row.status = "running"
await session.commit()
from app.kernel import Kernel
kernel: Kernel | None = getattr(app_state, "kernel", None)
if kernel is None:
raise AppError("service_unavailable", "Kernel 未就绪。", status_code=503)
prompt = (
f"【受托任务】{task['title']}\n"
f"委托包:{task['brief']}\n"
f"验收标准:{task['acceptance_criteria'] or '按委托包目标验收'}\n"
f"停止条件:完成验收标准即止;不得扩展使命。"
)
run = await kernel.start_backstage_run(
character_id=task["character_id"],
prompt=prompt,
owner_id=owner_scope,
on_task_final=_make_finalizer(task_id, owner_scope),
)
async with AsyncSessionLocal() as session:
row = await session.get(BackstageTask, task_id)
row.run_id = run.id
await session.commit()
await one_qi.emit(
"task.dispatched", character_id=task["character_id"],
payload={"task_id": task_id, "run_id": run.id}, source="tiangong",
)
return {"task_id": task_id, "run_id": run.id, "status": "running"}
def _make_finalizer(task_id: str, owner_scope: str):
"""Run 完成回调:回填结果、进入待验收。"""
async def _finalize(content: str, status: str) -> None:
async with AsyncSessionLocal() as session:
row = await session.get(BackstageTask, task_id)
if row is None or row.owner_scope != owner_scope:
return
row.result_digest = (content or "")[:4000]
row.status = "awaiting_acceptance" if status == "completed" else "failed"
row.completed_at = utc_now()
await session.commit()
await _cascade_parent(task_id)
return _finalize
async def _cascade_parent(task_id: str) -> None:
async with AsyncSessionLocal() as session:
task = await session.get(BackstageTask, task_id)
if task is None or task.parent_task_id is None:
return
parent_id = task.parent_task_id
children = (
await session.execute(
select(BackstageTask).where(BackstageTask.parent_task_id == parent_id)
)
).scalars().all()
terminal = {"awaiting_acceptance", "accepted", "rejected", "failed"}
if children and all(c.status in terminal for c in children):
parent = await session.get(BackstageTask, parent_id)
if parent is not None and parent.status in ("pending", "running"):
parent.status = "awaiting_acceptance"
parent.result_digest = "\n".join(
f"〔{c.title}〕{c.result_digest[:400]}" for c in children
)
parent.completed_at = utc_now()
await session.commit()
async def accept(task_id: str, owner_scope: str, *, accepted: bool) -> dict:
"""按证据验收:accepted / rejected。"""
await _owned_task(task_id, owner_scope)
async with AsyncSessionLocal() as session:
row = await session.get(BackstageTask, task_id)
if row.status not in ("awaiting_acceptance", "failed"):
raise AppError("invalid_request", "任务不在待验收状态。", status_code=409)
row.status = "accepted" if accepted else "rejected"
await session.commit()
await one_qi.emit(
"task.accepted" if accepted else "task.rejected",
character_id=None, payload={"task_id": task_id}, source="tiangong",
)
return {"task_id": task_id, "status": row.status}
async def list_tasks(owner_scope: str, *, parent: str | None = "root") -> list[dict]:
"""任务清单(默认根任务;parent=None 全量)。"""
async with AsyncSessionLocal() as session:
stmt = select(BackstageTask).where(BackstageTask.owner_scope == owner_scope)
if parent == "root":
stmt = stmt.where(BackstageTask.parent_task_id.is_(None))
elif parent is not None:
stmt = stmt.where(BackstageTask.parent_task_id == parent)
rows = (await session.execute(stmt.order_by(BackstageTask.created_at.desc()))).scalars().all()
return [_task_dict(r) for r in rows]
async def _owned(character_id: str, owner_scope: str) -> None:
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)
async def _owned_task(task_id: str, owner_scope: str) -> dict:
async with AsyncSessionLocal() as session:
row = await session.get(BackstageTask, task_id)
if row is None or row.owner_scope != owner_scope:
raise AppError("not_found", "任务不存在。", status_code=404)
session.expunge(row)
return _task_dict(row)
def _task_dict(t: BackstageTask) -> dict:
return {
"id": t.id,
"character_id": t.character_id,
"parent_task_id": t.parent_task_id,
"title": t.title,
"brief": t.brief,
"acceptance_criteria": t.acceptance_criteria,
"status": t.status,
"result_digest": t.result_digest,
"evidence": json.loads(t.evidence_json or "{}"),
"run_id": t.run_id,
"created_at": t.created_at.isoformat() if t.created_at else None,
"completed_at": t.completed_at.isoformat() if t.completed_at else None,
}
+43
View File
@@ -0,0 +1,43 @@
"""辰正:时态感知横切能力(rewrite/1.0 M8 重实现)。
计算核复用 modules/time_sense(snapshot/lunar——符文引擎依赖同源);
本模块提供投影级入口:``temporal_context()`` 产出【时态感知】提示段,
经符文展开后进入镜树/内核 prompt 组装。守一期间不得虚构等待与孤独
(identity-continuity.md §4)——时态只陈述客观时间语境。
"""
from __future__ import annotations
from datetime import UTC, datetime
from app.modules.time_sense.snapshot import SnapshotInput, compute_snapshot
_WEEKDAY_CN = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
def _default_input(now: datetime | None) -> SnapshotInput:
current = now or datetime.now(UTC)
return SnapshotInput(
now=current,
timezone="Asia/Shanghai",
anniversaries=[],
acquaintance_anchor=current,
last_active_at=current,
)
def temporal_context(now: datetime | None = None) -> str:
"""时态感知投影段:客观时间语境(不附情绪臆测——那是调节层的事)。"""
snap = compute_snapshot(_default_input(now))
weekday = _WEEKDAY_CN[snap.weekday]
parts = [
f"现在是{snap.solar_date} {weekday} {snap.day_period}(本地时区 {snap.timezone})",
]
if snap.lunar is not None:
parts.append(f"农历{snap.lunar.month}月{snap.lunar.day}日")
if snap.season:
parts.append(snap.season)
return "【时态感知】" + ",".join(parts)
__all__ = ["temporal_context"]
+45
View File
@@ -91,6 +91,49 @@ class Kernel:
assert created is not None
return created
async def start_backstage_run(
self,
*,
character_id: str | None,
prompt: str,
owner_id: str = "",
on_task_final=None,
) -> Run:
"""天工阁受托任务 Run(kind=background_task):不占前台席位、并行于前台。
无身份写入路径:结果只回呈任务(on_task_final),不触发记忆候选与身份写入
(息壤归真提取只挂在 canonize 链上,后台 Run 不归真)。
"""
profile = resolve_profile()
async with self._sessions() as session:
run = Run(
character_id=character_id,
owner_id=owner_id,
status="queued",
kind="background_task",
goal=prompt[:300] if prompt else None,
origin="tiangong_task",
model_hint=str(profile.params.get("slot", "chat")),
)
session.add(run)
await session.commit()
run_id = run.id
self._spawn(
run_id=run_id,
character_id=character_id,
owner_id=owner_id,
prompt=prompt,
trace_id=None,
seat_key=None,
system_contract=None,
on_final=on_task_final,
background=True,
)
async with self._sessions() as session:
created = await session.get(Run, run_id)
assert created is not None
return created
async def start_rehearsal_run(
self, *, embryo_id: str, prompt: str, system_contract: str, owner_id: str = ""
) -> Run:
@@ -142,6 +185,7 @@ class Kernel:
on_final: OnRunFinal | None,
temperature: float | None = None,
max_tokens: int | None = None,
background: bool = False,
) -> None:
task = asyncio.create_task(
self.loop.execute(
@@ -155,6 +199,7 @@ class Kernel:
on_final=on_final,
temperature=temperature,
max_tokens=max_tokens,
background=background,
)
)
self._tasks.add(task)
+24 -1
View File
@@ -90,8 +90,12 @@ class KernelRunLoop:
on_final: OnRunFinal | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
background: bool = False,
) -> RunOutcome:
assert character_id or seat_key, "Run 必须绑定人物或提供席位键(试演)"
if background:
assert character_id or seat_key or run_id, "Run 需至少一个归属维度"
else:
assert character_id or seat_key, "Run 必须绑定人物或提供席位键(试演)"
trace = trace_id or new_trace_id()
outcome = RunOutcome()
try:
@@ -107,6 +111,7 @@ class KernelRunLoop:
outcome=outcome,
temperature=temperature,
max_tokens=max_tokens,
background=background,
)
except TimeoutError:
outcome.status = "interrupted"
@@ -137,6 +142,7 @@ class KernelRunLoop:
outcome: RunOutcome,
temperature: float | None = None,
max_tokens: int | None = None,
background: bool = False,
) -> None:
ctx = RunContext(run_id=run_id, character_id=character_id, trace_id=trace_id)
try:
@@ -150,6 +156,22 @@ class KernelRunLoop:
return
await self._emit(run_id, character_id, trace_id, "run.queued")
if background:
# 天工阁受托任务:不占前台席位(与前台对话并行;不是第二意识)
await self._update_run(run_id, status="running")
await self._emit(run_id, character_id, trace_id, "run.started")
await self._turn(
run_id,
character_id,
owner_id,
trace_id,
prompt,
system_contract or _SYSTEM_CONTRACT_PLACEHOLDER,
outcome,
temperature=temperature,
max_tokens=max_tokens,
)
return
async with self._seats.foreground(seat_key or character_id or run_id):
await self._update_run(run_id, status="running")
await self._emit(run_id, character_id, trace_id, "run.started")
@@ -176,6 +198,7 @@ class KernelRunLoop:
outcome: RunOutcome,
temperature: float | None = None,
max_tokens: int | None = None,
background: bool = False,
) -> None:
ctx = RunContext(
run_id=run_id, character_id=character_id, trace_id=trace_id, payload={"prompt": prompt}
+5 -16
View File
@@ -1,19 +1,13 @@
"""SQLAlchemy 模型导出。"""
from app.models.big_cycle import BigCycle
from app.models.candidates import MemoryCandidate, ProfileCandidate
from app.models.character import Character
from app.models.governance import (
AgentRosterEntry,
GenerationPreferences,
Goal,
PerceptionChannel,
Plan,
)
from app.models.governance import GenerationPreferences
from app.models.idempotency import IdempotencyKey
from app.models.imports import CharacterImportJob, ImportItem
from app.domains.baigong.models import Capability, Mandate, ToolCall
from app.domains.hetu.models import PersistentWorld, WorldEntity, WorldRelation
from app.domains.kouxin.models import KnockingCandidate
from app.domains.tiangong.models import BackstageTask
from app.domains.xingyi.models import Form, FormAnchor, VoiceProfile
from app.domains.yuanqi.models import Covenant, CovenantEvent
from app.models.jingsheng import Custodianship, EmbryoDraft, IdentityRecord, RuntimeSeat, SoulForging
@@ -33,7 +27,9 @@ from app.models.safety import AuditEvent, PendingConfirmationRecord
from app.models.user import User
__all__ = [
"BackstageTask",
"Capability",
"KnockingCandidate",
"Form",
"FormAnchor",
"VoiceProfile",
@@ -45,25 +41,18 @@ __all__ = [
"Mandate",
"ToolCall",
"AgentRosterEntry",
"AuditEvent",
"BigCycle",
"Character",
"CharacterImportJob",
"Event",
"GenerationPreferences",
"Goal",
"IdempotencyKey",
"ImportItem",
"Lorebook",
"LorebookEntry",
"McpServer",
"MemoryCandidate",
"PendingConfirmationRecord",
"PerceptionChannel",
"Permission",
"Plan",
"ProfileCandidate",
"Role",
"RolePermission",
"Run",
-38
View File
@@ -1,38 +0,0 @@
"""大周天运行记录(architecture §3.2;v1-scope §3.3)。
大周天是长期演化循环:记忆积累 → 自我评估 → 演化策略 → 候选生成。
本表记录每次循环的触发、阶段与终态;演化候选 / 内驱力 / 自动抽取由后续
子阶段写入各自表,循环本身只承载编排与留痕。
"""
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_cycle_id
from app.core.time import utc_now
from app.db.base import Base
class BigCycle(Base):
"""大周天循环运行记录。"""
__tablename__ = "big_cycles"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_cycle_id)
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True)
trigger: Mapped[str] = mapped_column(String(40), nullable=False)
status: Mapped[str] = mapped_column(String(40), nullable=False, default="pending", index=True)
phase: Mapped[str | None] = mapped_column(String(40))
memory_count_at_trigger: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
evaluation_snapshot_json: Mapped[str | None] = mapped_column(Text)
summary: Mapped[str | None] = mapped_column(Text)
error_code: Mapped[str | None] = mapped_column(String(80))
error_message: Mapped[str | None] = mapped_column(Text)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
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
)
-67
View File
@@ -1,67 +0,0 @@
"""资料导入候选模型(api-spec §8;契约字典 §10.6 candidate.status)。
导入资料经模型抽取产生档案 / 记忆候选,待用户审核后应用;人格建议复用
``personality_proposals``(005)。``evidence_ids`` 指向 ``import_items.id``
(EvidenceRef source_type = ``import_item``)。
"""
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_memory_candidate_id, new_profile_candidate_id
from app.core.time import utc_now
from app.db.base import Base
class ProfileCandidate(Base):
"""人物档案候选(契约字典 §10.6)。"""
__tablename__ = "character_profile_candidates"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_profile_candidate_id)
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True)
import_job_id: Mapped[str | None] = mapped_column(
String(80), ForeignKey("character_import_jobs.id"), index=True
)
field: Mapped[str] = mapped_column(String(60), nullable=False)
value: Mapped[str] = mapped_column(Text, nullable=False)
confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
evidence_ids_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
status: Mapped[str] = mapped_column(String(40), nullable=False, default="pending", index=True)
reason: Mapped[str | None] = mapped_column(Text)
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
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 MemoryCandidate(Base):
"""记忆候选(契约字典 §10.6)。"""
__tablename__ = "memory_candidates"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_memory_candidate_id)
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True)
import_job_id: Mapped[str | None] = mapped_column(
String(80), ForeignKey("character_import_jobs.id"), index=True
)
cycle_id: Mapped[str | None] = mapped_column(
String(80), ForeignKey("big_cycles.id"), index=True
)
type: Mapped[str] = mapped_column(String(60), nullable=False)
title: Mapped[str] = mapped_column(String(200), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
importance: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
sensitivity: Mapped[str] = mapped_column(String(40), nullable=False, default="low")
evidence_ids_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
status: Mapped[str] = mapped_column(String(40), nullable=False, default="pending", index=True)
reason: Mapped[str | None] = mapped_column(Text)
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
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
)
+4 -106
View File
@@ -1,118 +1,16 @@
"""司命台治理对象模型(九枢治理视图,api-spec §19.4)。
目标(太一)/ 感知通道(烛照)/ 世界实体(河图)/ 计划(洛书)/ 天工阁角色
为可增长的开放集合;生成偏好(模型网关 §4.3)为本地单用户单行配置。
这些对象当前由司命台治理视图维护,对应枢的自动运行时暂未启用(见
docs/operations/capability-matrix.md §8)。
"""
"""治理域模型:生成偏好(其余旧枢治理表随 M8 退役)。"""
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
Float,
ForeignKey,
Integer,
String,
Text,
)
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import Boolean, DateTime, Float, Integer, String
from app.core.config import settings
from app.core.ids import new_goal_id, new_plan_id, new_world_entity_id
from sqlalchemy.orm import Mapped, mapped_column
from app.core.time import utc_now
from app.db.base import Base
class Goal(Base):
"""太一枢目标节点(契约字典 §5.11 / §5.12)。"""
__tablename__ = "goals"
def __init__(self, **kwargs: object) -> None:
# Python 端默认值:mapped_column(default=...) 仅在 ORM flush 时生效,
# __init__ 覆盖使测试在不侵入 session 时就能验证默认值。
kwargs.setdefault("type", "session")
kwargs.setdefault("progress", 0.0)
super().__init__(**kwargs)
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_goal_id)
character_id: Mapped[str | None] = mapped_column(
String(80), ForeignKey("characters.id"), nullable=True, index=True
)
title: Mapped[str] = mapped_column(String(300), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
level: Mapped[str] = mapped_column(String(40), nullable=False, default="task", index=True)
type: Mapped[str] = mapped_column(String(40), nullable=False, default="session")
status: Mapped[str] = mapped_column(String(40), nullable=False, default="pending", index=True)
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=50)
progress: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
constraints_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
parent_id: Mapped[str | None] = mapped_column(String(80), ForeignKey("goals.id"), nullable=True)
success_criteria_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 PerceptionChannel(Base):
"""烛照枢感知通道授权(契约字典 §5.7 频道级)。channel 为主键,默认六类可自定义。"""
__tablename__ = "perception_channels"
channel: Mapped[str] = mapped_column(String(80), primary_key=True)
name: Mapped[str] = mapped_column(String(120), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
consent_status: Mapped[str] = mapped_column(String(40), nullable=False, default="unknown")
privacy_level: Mapped[str] = mapped_column(String(40), nullable=False, default="normal")
risk_level: Mapped[str] = mapped_column(String(20), nullable=False, default="L2")
last_active_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now, onupdate=utc_now
)
class Plan(Base):
"""洛书枢计划(契约字典 §5.13)。goal_id 关联太一枢目标,可空。"""
__tablename__ = "plans"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_plan_id)
goal_id: Mapped[str | None] = mapped_column(String(80), ForeignKey("goals.id"), index=True)
title: Mapped[str] = mapped_column(String(300), nullable=False)
status: Mapped[str] = mapped_column(String(40), nullable=False, default="draft", index=True)
assumptions_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
risks_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
required_tools_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
success_criteria_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
step_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
completed_steps: Mapped[int] = mapped_column(Integer, 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 AgentRosterEntry(Base):
"""天工阁枢九角色在岗(契约字典 §5.14)。role 为主键,默认九角色可自定义。"""
__tablename__ = "agent_roster"
role: Mapped[str] = mapped_column(String(80), primary_key=True)
name: Mapped[str] = mapped_column(String(120), nullable=False)
responsibility: Mapped[str] = mapped_column(Text, nullable=False, default="")
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
last_active_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
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 GenerationPreferences(Base):
"""生成参数偏好(模型网关 §4.3)。本地单用户单行,按 owner_scope 固定。"""
@@ -1 +0,0 @@
"""大周天演化候选领域纯逻辑(造消息 / 解析候选草稿,禁止 IO)。"""
-114
View File
@@ -1,114 +0,0 @@
"""解析模型返回为演化候选草稿(纯函数,无 IO)。
模型可能返回纯 JSON、markdown 代码块或夹杂解释文本;本模块容错提取首个 JSON
数组,并归一化为演化候选草稿。``evidence`` 序号由 application 映射为记忆 ID。
缺省字段取**安全悲观默认**:``level=L4``(必须人工确认)、``auto_apply_allowed=False``,
避免模型漏字段时误自动应用高风险演化。
"""
import json
import re
from dataclasses import dataclass, field
from typing import Any
@dataclass
class EvolutionCandidateDraft:
type: str
title: str
proposal: str
level: str = "L4"
scope: str = "character_scope"
source: str = "big_cycle"
delta: dict[str, Any] = field(default_factory=dict)
confidence: float = 0.5
benefit_score: float = 0.5
risk_score: float = 0.5
drift_score: float = 0.0
auto_apply_allowed: bool = False
evidence: list[int] = field(default_factory=list)
def parse_evolution_candidates(raw: str) -> list[EvolutionCandidateDraft]:
drafts: list[EvolutionCandidateDraft] = []
for item in _extract_json_array(raw):
if not isinstance(item, dict):
continue
type_ = str(item.get("type", "")).strip()
title = str(item.get("title", "")).strip()
proposal = str(item.get("proposal", "")).strip()
if not type_ or not title or not proposal:
continue
drafts.append(
EvolutionCandidateDraft(
type=type_,
title=title,
proposal=proposal,
level=_level(item.get("level")),
scope=_scope(item.get("scope")),
source=str(item.get("source", "big_cycle")).strip() or "big_cycle",
delta=_dict(item.get("delta")),
confidence=_clamp_float(item.get("confidence"), 0.5),
benefit_score=_clamp_float(item.get("benefit_score"), 0.5),
risk_score=_clamp_float(item.get("risk_score"), 0.5),
drift_score=_clamp_float(item.get("drift_score"), 0.0),
auto_apply_allowed=bool(item.get("auto_apply_allowed", False)),
evidence=_int_list(item.get("evidence")),
)
)
return drafts
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 []
_VALID_LEVELS = {"L0", "L1", "L2", "L3", "L4"}
_VALID_SCOPES = {"character_scope", "user_scope", "task_scope", "global_scope"}
def _level(value: Any) -> str:
text = str(value or "").strip().upper()
return text if text in _VALID_LEVELS else "L4"
def _scope(value: Any) -> str:
text = str(value or "").strip()
return text if text in _VALID_SCOPES else "character_scope"
def _dict(value: Any) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
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))
def _int_list(value: Any) -> list[int]:
if not isinstance(value, list):
return []
result: list[int] = []
for item in value:
try:
result.append(int(item))
except (TypeError, ValueError):
continue
return result
-78
View File
@@ -1,78 +0,0 @@
"""大周天演化候选 prompt 构建(纯函数,无 IO)。
接收 ``EvaluationSnapshot``(由 application 阶段从 ORM 组装的态势快照),产出
``(system, user)`` 消息对,喂给模型网关。模型返回的 JSON 由 ``extract`` 模块解析。
枚举取值以 [契约字典](../../docs/contracts/contract-glossary.md) §12 与
[自我演化机制设计](../../docs/domain/core-domains.md) §11 为准。
"""
from dataclasses import dataclass, field
from app.modules.evolution import LEVELS as EVOLUTION_LEVELS
from app.modules.evolution import SCOPES as EVOLUTION_SCOPES
from app.modules.evolution import TYPES as EVOLUTION_TYPES
EVOLUTION_TYPES = EVOLUTION_TYPES
EVOLUTION_LEVELS = EVOLUTION_LEVELS
EVOLUTION_SCOPES = EVOLUTION_SCOPES
@dataclass
class EvaluationSnapshot:
"""大周天自我评估态势快照(application 组装,供候选生成消费)。"""
character_id: str
memory_count: int
recent_memories: list[tuple[str, str]] # (title, content)
personality_summary: str
world_entity_count: int
conclusion: str
evidence_ids: list[str] = field(default_factory=list) # 记忆 ID,供 evidence 序号映射
cumulative_drift: float = 0.0 # 最近大周天的累积漂移值
drift_budget_remaining: float = 0.0 # 剩余漂移预算
def build_evolution_extraction_messages(
snapshot: EvaluationSnapshot,
) -> tuple[str, str]:
system = (
"你是魂镜的大周天自我演化引擎。基于本轮自我评估的态势快照,生成轻量、可解释、"
"可回滚的演化候选——只调整人格表达、程序记忆、工具策略等轻量参数,"
"不改变核心使命、安全边界或高敏关系判断。"
f"type 取自:{', '.join(EVOLUTION_TYPES)}。"
f"level 取自:{', '.join(EVOLUTION_LEVELS)}"
"(L0-L3 低风险可半自动,L4 核心人格/使命/安全边界必须人工确认)。"
f"scope 取自:{', '.join(EVOLUTION_SCOPES)}。"
'以 JSON 数组返回,每个元素:{"type": str, "source": "big_cycle", '
'"level": str, "scope": str, "title": str, "proposal": str, '
'"delta": 对象, "confidence": 0~1, "benefit_score": 0~1, '
'"risk_score": 0~1, "auto_apply_allowed": bool, "evidence": [记忆序号]}。'
"delta 为具体增量(如人格字段覆盖对象、程序记忆 title/content)。"
"evidence 为下方记忆摘要的序号([0]/[1]…)列表。"
"仅在态势充分支撑时提议,宁缺毋滥。若本轮无需演化返回 []。"
"只返回 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)}"
)
# 人格权重向量:将漂移信息注入 prompt,引导 LLM 保守生成
if snapshot.cumulative_drift > 0:
user += (
f"\n\n⚠️ 人格漂移警告:累积漂移 {snapshot.cumulative_drift:.2f} / "
f"预算 {snapshot.drift_budget_remaining + snapshot.cumulative_drift:.1f}。"
f"剩余漂移预算 {snapshot.drift_budget_remaining:.1f}。"
f"请尽可能保守,减少高风险 proposal,优先微调而非大幅改动。"
)
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,69 +0,0 @@
"""太一枢(Goal Orchestrator)领域逻辑。
v1 落地 §7.1 最小闭环 + §7.2 预留扩展:
- 从用户消息提取会话目标写入 ``run.goal``,并注入运行契约。
- 活跃目标按优先级排序(``prioritize_active_goals``),供运行时注入。
目标持久化体系(``goals`` 表)、调度与冲突裁决属 §7.2 / §7.3。
"""
from __future__ import annotations
from app.modules.goal_orchestrator.extract import (
extract_session_goal,
format_goal_directive,
)
__all__ = [
"extract_session_goal",
"format_goal_directive",
"prioritize_active_goals",
"complete_session_goal",
]
def prioritize_active_goals(
goals: list[dict],
) -> list[dict]:
"""活跃目标优先级排序。
按以下规则返回排序后的目标列表(最高优先级在前):
1. ``priority`` 越小越优先(低数值 = 高优先级)
2. ``priority`` 相同时 ``progress`` 越低越优先(未完成的优先)
3. 层级越靠前越优先(L0 > L1 > L2 > L3 > L4)
Args:
goals: 活跃目标字典列表,每个字典包含 ``title`` / ``level`` / ``priority`` / ``progress``。
Returns:
排序后的目标列表。
"""
LEVEL_ORDER = {"L0": 0, "L1": 1, "L2": 2, "L3": 3, "L4": 4}
def _sort_key(g: dict) -> tuple:
priority = g.get("priority")
if priority is None:
priority = 100
progress = g.get("progress", 0) or 0
level = LEVEL_ORDER.get(str(g.get("level", "")), 99)
return (0 if priority is not None else 1, priority, level, progress)
return sorted(goals, key=_sort_key)
def complete_session_goal(
goal: dict,
) -> dict:
"""将会话目标标记为完成。
Args:
goal: 会话目标字典,包含 ``id`` / ``title`` / ``level`` / ``status``。
Returns:
更新后的目标字典(status=completed, progress=1.0)。
"""
return {
**goal,
"status": "completed",
"progress": 1.0,
}
@@ -1,25 +0,0 @@
"""太一会话目标提取(纯函数,无 IO)。
从用户消息提取会话目标(L2)。v1 采用规则提取——用户消息本身即目标的最直接
表达,清理与截断后写入 ``run.goal`` 并注入运行契约。LLM 提取为后续增强。
"""
_MAX_GOAL_CHARS = 120
def extract_session_goal(content: str) -> str:
"""从用户消息内容提取会话目标摘要。
规则:去除首尾空白,超长截断到 ``_MAX_GOAL_CHARS``;空内容返回空串。
"""
goal = (content or "").strip()
if not goal:
return ""
return goal[:_MAX_GOAL_CHARS]
def format_goal_directive(goal: str) -> str:
"""把会话目标格式化为运行契约的 system instruction 片段;空目标返回空串。"""
if not goal:
return ""
return f"当前会话目标:{goal}"
@@ -1 +0,0 @@
"""感知领域模块(纯逻辑,无 IO)。"""
@@ -1 +0,0 @@
"""视觉感知领域模块。"""
@@ -1,93 +0,0 @@
"""
OCR 结果后处理(纯逻辑,无 IO)。
对 OCR 返回的原始结果做结构化解析、置信度过滤、阅读顺序修正。
不依赖任何外部资源,可独立测试。
"""
from __future__ import annotations
from typing import Any
def filter_low_confidence(
blocks: list[dict[str, Any]],
threshold: float = 0.5,
) -> list[dict[str, Any]]:
"""过滤低置信度结果。
Args:
blocks: 原始 OCR 块列表。
threshold: 置信度阈值(默认 0.5)。
Returns:
过滤后的块列表。
"""
return [b for b in blocks if b.get("confidence", 0) >= threshold]
def merge_adjacent_lines(
blocks: list[dict[str, Any]],
y_tolerance: float = 10.0,
) -> list[dict[str, Any]]:
"""合并同一行的相邻块。
Args:
blocks: OCR 块列表(需含 bbox 坐标)。
y_tolerance: Y 轴容差(px),同一行最大差异。
Returns:
合并后的块列表。
"""
if not blocks:
return []
sorted_blocks = sorted(blocks, key=lambda b: (_center_y(b), _center_x(b)))
merged: list[dict[str, Any]] = []
current = sorted_blocks[0]
for block in sorted_blocks[1:]:
if abs(_center_y(block) - _center_y(current)) <= y_tolerance:
current["text"] += block["text"]
current["confidence"] = max(current["confidence"], block["confidence"])
else:
merged.append(current)
current = block
merged.append(current)
return merged
def extract_text(
blocks: list[dict[str, Any]],
separator: str = "",
) -> str:
"""从 OCR 块中提取纯文本。
Args:
blocks: OCR 块列表。
separator: 块之间的分隔符。
Returns:
拼接后的文本。
"""
return separator.join(b["text"] for b in blocks)
def _center_y(block: dict[str, Any]) -> float:
"""获取块的中心 Y 坐标。"""
pts = block.get("bbox", {}).get("points", block.get("bbox", []))
if pts and isinstance(pts, list) and len(pts) > 0:
ys = [p[1] for p in pts if isinstance(p, (list, tuple)) and len(p) >= 2]
return sum(ys) / len(ys) if ys else 0.0
return 0.0
def _center_x(block: dict[str, Any]) -> float:
"""获取块的中心 X 坐标。"""
pts = block.get("bbox", {}).get("points", block.get("bbox", []))
if pts and isinstance(pts, list) and len(pts) > 0:
xs = [p[0] for p in pts if isinstance(p, (list, tuple)) and len(p) >= 2]
return sum(xs) / len(xs) if xs else 0.0
return 0.0
-166
View File
@@ -1,166 +0,0 @@
"""洛书枢 deliberative 规划器领域模块。
纯数据结构和纯函数(禁止 IO),定义显式计划的数据模型与解析逻辑。
IO 相关编排在 ``app/application/planning/`` 中完成。
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
@dataclass
class PlanStep:
"""计划步骤。"""
id: int
title: str
description: str
tool: str | None = None
risk_level: str = "L2"
@dataclass
class DeliberativePlan:
"""显式计划。"""
title: str
steps: list[PlanStep]
assumptions: list[str] = field(default_factory=list)
risks: list[str] = field(default_factory=list)
def parse_plan(raw: str) -> DeliberativePlan | None:
"""解析 LLM 返回的 JSON 计划文本。
Args:
raw: LLM 原始输出(可能含 ```json 围栏)。
Returns:
解析成功的 DeliberativePlan,失败返回 None。
"""
text = raw.strip()
matched = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
if matched:
text = matched.group(1)
else:
brace_start = text.find("{")
brace_end = text.rfind("}")
if brace_start != -1 and brace_end > brace_start:
text = text[brace_start : brace_end + 1]
try:
data = json.loads(text)
except json.JSONDecodeError:
return None
if not isinstance(data, dict):
return None
title = str(data.get("title", "")).strip()
raw_steps = data.get("steps", [])
if not title or not isinstance(raw_steps, list) or not raw_steps:
return None
steps: list[PlanStep] = []
for i, s in enumerate(raw_steps):
if not isinstance(s, dict):
continue
step_title = str(s.get("title", "")).strip()
if not step_title:
continue
tool_raw = str(s.get("tool", "")).strip()
tool = tool_raw if tool_raw and tool_raw != "None" else None
steps.append(
PlanStep(
id=s.get("id", i + 1),
title=step_title,
description=str(s.get("description", "")).strip(),
tool=tool,
risk_level=str(s.get("risk_level", "L2")).upper(),
)
)
if not steps:
return None
return DeliberativePlan(
title=title,
steps=steps,
assumptions=[str(a).strip() for a in data.get("assumptions", []) if a],
risks=[str(r).strip() for r in data.get("risks", []) if r],
)
def build_planning_prompt(
request: str,
*,
goal_directive: str = "",
available_tools: list[str] | None = None,
memory_context: str = "",
) -> str:
"""构建规划 prompt。
当请求明显需要多步骤(调用多个工具、查询多处信息等)时,
要求 LLM 先生成一个显式计划,再按计划逐步执行。
Args:
request: 用户请求文本。
goal_directive: 当前活跃目标描述。
available_tools: 可用工具列表。
memory_context: 相关记忆上下文。
Returns:
规划 prompt 文本。
"""
tools_str = ""
if available_tools:
tools_str = "\n可用工具:\n" + "\n".join(f"- {t}" for t in available_tools)
return (
"请判断以下用户请求是否需要多步计划。如果需要,请输出 JSON 格式的计划。\n\n"
f"用户请求:{request}\n"
f"{f'当前目标:{goal_directive}' if goal_directive else ''}"
f"{tools_str}"
f"{f'\n上下文记忆:{memory_context}' if memory_context else ''}"
"\n\n"
"输出格式(如需多步计划):\n"
'```json\n{\n "title": "计划标题",\n'
' "assumptions": ["假设1", "假设2"],\n'
' "risks": ["风险1", "风险2"],\n'
' "steps": [\n'
' {"id": 1, "title": "步骤1", "description": "说明", '
'"tool": "工具名(可选)", "risk_level": "L2"}\n'
" ]\n"
"}\n```\n\n"
"如果不需要多步计划(简单问题),只回复:NO_PLAN_NEEDED"
)
def format_plan_for_prompt(plan: DeliberativePlan) -> str:
"""将计划格式化为 system prompt 可用文本。"""
lines = [f"## 计划:{plan.title}\n"]
if plan.assumptions:
lines.append("假设:")
lines.extend(f"- {a}" for a in plan.assumptions)
lines.append("")
if plan.risks:
lines.append("风险:")
lines.extend(f"- {r}" for r in plan.risks)
lines.append("")
lines.append("执行步骤:")
for step in plan.steps:
tool_info = f" [工具:{step.tool}]" if step.tool else ""
lines.append(f"{step.id}. {step.title}{tool_info}")
if step.description:
lines.append(f" {step.description}")
return "\n".join(lines)
def should_plan(request: str) -> bool:
"""判断请求是否需要多步计划。
简单规则:包含需要多工具的关键词时返回 True。
"""
triggers = [
"搜索", "查找", "研究", "调研", "分析", "比较",
"整理", "总结", "汇总", "规划", "安排", "计划",
"search", "research", "analyze", "compare",
"summarize", "plan", "organize",
]
return any(t in request for t in triggers)
-50
View File
@@ -1,50 +0,0 @@
"""大周天运行记录数据访问。"""
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.big_cycle import BigCycle
async def add_cycle(session: AsyncSession, cycle: BigCycle) -> BigCycle:
session.add(cycle)
await session.flush()
return cycle
async def get_cycle(session: AsyncSession, cycle_id: str) -> BigCycle | None:
return await session.get(BigCycle, cycle_id)
async def get_last_completed(session: AsyncSession, character_id: str) -> BigCycle | None:
result = await session.execute(
select(BigCycle)
.where(
BigCycle.character_id == character_id,
BigCycle.completed_at.is_not(None),
)
.order_by(BigCycle.completed_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def list_cycles(
session: AsyncSession,
character_id: str,
*,
page: int = 1,
page_size: int = 20,
) -> tuple[list[BigCycle], int]:
conditions = [BigCycle.character_id == character_id]
total = (
await session.execute(select(func.count()).select_from(BigCycle).where(*conditions))
).scalar_one()
items_result = await session.execute(
select(BigCycle)
.where(*conditions)
.order_by(BigCycle.created_at.desc())
.offset(max(page - 1, 0) * page_size)
.limit(page_size)
)
return list(items_result.scalars().all()), int(total or 0)
-70
View File
@@ -1,70 +0,0 @@
"""候选数据访问(api-spec §8;档案 / 记忆候选)。"""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.candidates import MemoryCandidate, ProfileCandidate
async def add_profile_candidates(session: AsyncSession, candidates: list[ProfileCandidate]) -> None:
session.add_all(candidates)
await session.flush()
async def add_memory_candidates(session: AsyncSession, candidates: list[MemoryCandidate]) -> None:
session.add_all(candidates)
await session.flush()
async def list_profile_candidates(
session: AsyncSession,
character_id: str,
*,
import_job_id: str | None = None,
status: str | None = None,
) -> list[ProfileCandidate]:
conditions = [ProfileCandidate.character_id == character_id]
if import_job_id is not None:
conditions.append(ProfileCandidate.import_job_id == import_job_id)
if status is not None:
conditions.append(ProfileCandidate.status == status)
result = await session.execute(
select(ProfileCandidate).where(*conditions).order_by(ProfileCandidate.created_at.desc())
)
return list(result.scalars().all())
async def list_memory_candidates(
session: AsyncSession,
character_id: str,
*,
import_job_id: str | None = None,
cycle_id: str | None = None,
status: str | None = None,
source: str | None = None,
) -> list[MemoryCandidate]:
conditions = [MemoryCandidate.character_id == character_id]
if import_job_id is not None:
conditions.append(MemoryCandidate.import_job_id == import_job_id)
if cycle_id is not None:
conditions.append(MemoryCandidate.cycle_id == cycle_id)
if status is not None:
conditions.append(MemoryCandidate.status == status)
if source == "cycle":
conditions.append(MemoryCandidate.import_job_id.is_(None))
elif source == "import":
conditions.append(MemoryCandidate.import_job_id.is_not(None))
result = await session.execute(
select(MemoryCandidate).where(*conditions).order_by(MemoryCandidate.created_at.desc())
)
return list(result.scalars().all())
async def get_profile_candidate(
session: AsyncSession, candidate_id: str
) -> ProfileCandidate | None:
return await session.get(ProfileCandidate, candidate_id)
async def get_memory_candidate(session: AsyncSession, candidate_id: str) -> MemoryCandidate | None:
return await session.get(MemoryCandidate, candidate_id)
-150
View File
@@ -1,150 +0,0 @@
"""司命台治理对象数据访问(api-spec §19.4)。
目标 / 感知通道 / 世界实体 / 计划 / 天工阁角色的 CRUD 数据访问;
均为本地单用户全局对象,不挂 character_id。
"""
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.time import utc_now
from app.models.governance import (
AgentRosterEntry,
Goal,
PerceptionChannel,
Plan,
)
# —— 太一枢 · 目标 ——
async def list_goals(session: AsyncSession) -> list[Goal]:
result = await session.execute(
select(Goal).order_by(Goal.priority.desc(), Goal.updated_at.desc())
)
return list(result.scalars().all())
async def get_goal(session: AsyncSession, goal_id: str) -> Goal | None:
return await session.get(Goal, goal_id)
async def add_goal(session: AsyncSession, goal: Goal) -> Goal:
session.add(goal)
await session.flush()
return goal
async def list_goals_by_character(
session: AsyncSession,
character_id: str,
status_filter: str | None = None,
) -> list[Goal]:
"""按 character_id 获取目标列表;可选的 status 过滤。"""
stmt = select(Goal).where(Goal.character_id == character_id)
if status_filter:
stmt = stmt.where(Goal.status == status_filter)
stmt = stmt.order_by(Goal.priority.desc(), Goal.updated_at.desc())
result = await session.execute(stmt)
return list(result.scalars().all())
async def update_goal_progress(
session: AsyncSession,
goal_id: str,
progress: float,
) -> Goal | None:
"""更新目标的 progress 与 updated_at 字段。"""
goal = await session.get(Goal, goal_id)
if goal is None:
return None
goal.progress = progress
goal.updated_at = utc_now()
await session.flush()
return goal
async def list_active_session_goal(session: AsyncSession, character_id: str) -> Goal | None:
"""查找指定人物活跃的 session 级目标(各人物至多一个活跃 session 目标)。"""
result = await session.execute(
select(Goal)
.where(
Goal.character_id == character_id,
Goal.level == "session",
Goal.status == "active",
)
.limit(1)
)
return result.scalar_one_or_none()
# —— 烛照枢 · 感知通道 ——
async def list_perception_channels(session: AsyncSession) -> list[PerceptionChannel]:
result = await session.execute(select(PerceptionChannel).order_by(PerceptionChannel.channel))
return list(result.scalars().all())
async def get_perception_channel(session: AsyncSession, channel: str) -> PerceptionChannel | None:
return await session.get(PerceptionChannel, channel)
async def add_perception_channel(
session: AsyncSession, channel: PerceptionChannel
) -> PerceptionChannel:
session.add(channel)
await session.flush()
return channel
# —— 河图枢 · 世界实体 ——
async def list_plans(session: AsyncSession) -> list[Plan]:
result = await session.execute(select(Plan).order_by(Plan.updated_at.desc()))
return list(result.scalars().all())
async def get_plan(session: AsyncSession, plan_id: str) -> Plan | None:
return await session.get(Plan, plan_id)
async def add_plan(session: AsyncSession, plan: Plan) -> Plan:
session.add(plan)
await session.flush()
return plan
# —— 天工阁枢 · 角色在岗 ——
async def list_agent_roster(session: AsyncSession) -> list[AgentRosterEntry]:
result = await session.execute(select(AgentRosterEntry).order_by(AgentRosterEntry.role))
return list(result.scalars().all())
async def get_agent(session: AsyncSession, role: str) -> AgentRosterEntry | None:
return await session.get(AgentRosterEntry, role)
async def add_agent(session: AsyncSession, agent: AgentRosterEntry) -> AgentRosterEntry:
session.add(agent)
await session.flush()
return agent
# —— 百工枢 · 工具策略(§15/§16 共享)——
# —— 计数(供九枢健康度聚合)——
async def count(session: AsyncSession, model: type) -> int:
result = await session.execute(select(func.count()).select_from(model))
return int(result.scalar_one())
-50
View File
@@ -1,50 +0,0 @@
"""候选 API schema(api-spec §8)。"""
from pydantic import BaseModel, Field
class ProfileCandidateResponse(BaseModel):
id: str = Field(description="候选 ID,前缀 profile_candidate_。")
character_id: str = Field(description="所属人物 ID。")
import_job_id: str | None = Field(default=None, description="来源导入任务 ID。")
field: str = Field(description="档案字段名。")
value: str = Field(description="候选值。")
confidence: float = Field(description="置信度。")
evidence_ids: list[str] = Field(default_factory=list, description="证据 import_item ID。")
status: str = Field(description="审核状态,契约字典 §10.6。")
reason: str | None = Field(default=None)
reviewed_at: str | None = Field(default=None)
created_at: str = Field(description="创建时间,UTC ISO 8601 字符串。")
class MemoryCandidateResponse(BaseModel):
id: str = Field(description="候选 ID,前缀 memory_candidate_。")
character_id: str = Field(description="所属人物 ID。")
import_job_id: str | None = Field(default=None, description="来源导入任务 ID。")
cycle_id: str | None = Field(default=None, description="来源大周天循环 ID。")
type: str = Field(description="记忆类型。")
title: str = Field(description="记忆标题。")
content: str = Field(description="记忆内容。")
confidence: float = Field(description="置信度。")
importance: float = Field(description="重要度。")
sensitivity: str = Field(description="敏感等级。")
evidence_ids: list[str] = Field(default_factory=list, description="证据 import_item ID。")
status: str = Field(description="审核状态,契约字典 §10.6。")
reason: str | None = Field(default=None)
reviewed_at: str | None = Field(default=None)
created_at: str = Field(description="创建时间,UTC ISO 8601 字符串。")
class CandidateReviewDecision(BaseModel):
candidate_id: str = Field(description="候选 ID。")
decision: str = Field(description="审核决定:approve / reject。")
class BatchReviewRequest(BaseModel):
decisions: list[CandidateReviewDecision] = Field(min_length=1, description="批量审核决定。")
class BatchReviewResult(BaseModel):
applied: int = Field(description="已应用(approve)数量。")
rejected: int = Field(description="已驳回数量。")
skipped: int = Field(description="因状态非 pending 跳过的数量。")
+41 -59
View File
@@ -12,19 +12,12 @@ from app.core.config import settings
from app.core.security import redact_text
from app.core.time import to_iso, utc_now
from app.db.session import AsyncSessionLocal
from app.models.governance import (
AgentRosterEntry,
Goal,
PerceptionChannel,
Plan,
)
from app.models.idempotency import IdempotencyKey
from app.models.xirang import Memory
from app.models.runtime import Event, Run
from app.models.safety import AuditEvent, PendingConfirmationRecord
from app.repositories import audit as audit_repo
from app.repositories import characters as character_repo
from app.repositories import governance as governance_repo
from app.repositories import idempotency as idempotency_repo
from app.repositories import runtime as runtime_repo
from app.schemas.admin import (
@@ -41,16 +34,14 @@ from app.schemas.common import Page
from app.services.serializers import audit_to_response
# 九枢全集(契约字典 §5.9):枢标识、中文名、工程模块名。
_PIVOT_REGISTRY: list[tuple[str, str, str]] = [
_PIVOT_REGISTRY = [
("personality_core", "灵核枢", "Personality Core"),
("goal_orchestrator", "太一枢", "Goal Orchestrator"),
("capability_registry", "百工枢", "Capability Registry"),
("safety_governor", "司命枢", "Safety Governor"),
("perception", "烛照枢", "Perception Layer"),
("world_model", "河图枢", "World Model"),
("planner", "洛书枢", "Planner"),
("memory", "息壤枢", "Memory System"),
("tool_runtime", "百工枢", "Tool Runtime"),
("agent_society", "天工阁枢", "Agent Society"),
("hetu", "河图枢", "Hetu World"),
("tiangong", "天工阁", "Tiangong Backstage"),
("kouxin", "叩心枢", "Kouxin Proactive"),
("memory", "息壤枢", "Xirang Memory"),
]
@@ -64,7 +55,15 @@ def _summarize_input(value: dict | None) -> str:
async def get_pivots_health(
*,
owner_scope: str = settings.default_owner_scope,
) -> PivotHealthReport:
):
"""九枢健康度:以事件流与域表计数呈现(旧枢治理表随 M8 退役)。"""
from app.domains.baigong.models import Capability
from app.domains.hetu.models import PersistentWorld
from app.domains.kouxin.models import KnockingCandidate
from app.domains.tiangong.models import BackstageTask
from app.models.safety import PendingConfirmationRecord
from app.models.xirang import Memory
async with AsyncSessionLocal() as session:
pending_count = len(
list(
@@ -79,64 +78,47 @@ async def get_pivots_health(
.all()
)
)
goals_n = await governance_repo.count(session, Goal)
from app.domains.hetu.models import PersistentWorld
world_n = await governance_repo.count(session, PersistentWorld)
plans_n = await governance_repo.count(session, Plan)
perception_n = await governance_repo.count(session, PerceptionChannel)
agents_n = await governance_repo.count(session, AgentRosterEntry)
memories_n = await governance_repo.count(session, Memory, Memory.status != "tombstoned")
caps_n = await _count(session, Capability)
world_n = await _count(session, PersistentWorld)
tasks_n = await _count(session, BackstageTask)
knocks_n = await _count(session, KnockingCandidate)
memories_n = await _count(session, Memory, Memory.status != "tombstoned")
character = await character_repo.get_active_character(session, owner_scope)
has_personality = bool(character and character.identity_baseline_json not in ("", "{}"))
tool_enabled = True # 百工域常驻(乾坤可用性按端点区分)
now = to_iso(utc_now())
raw: list[tuple[str, str, str, bool, str]] = [
(
"personality_core",
"steady" if has_personality else "void",
"人格版本已就绪" if has_personality else "人格未初始化",
),
("goal_orchestrator", "steady" if goals_n else "void", f"目标 {goals_n} 项"),
(
"safety_governor",
"cracked" if pending_count else "steady",
f"{pending_count} 项工具调用待裁定" if pending_count else "无待裁定",
),
("perception", "steady" if perception_n else "void", f"感知通道 {perception_n} 项"),
("world_model", "steady" if world_n else "void", f"世界实体 {world_n} 项"),
("planner", "steady" if plans_n else "void", f"计划 {plans_n} 项"),
raw: list[tuple[str, str, str]] = [
("personality_core", "steady" if has_personality else "void", "道体锚点已就绪" if has_personality else "锚点未初始化"),
("capability_registry", "steady" if caps_n else "void", f"能力 {caps_n} 项"),
("safety_governor", "cracked" if pending_count else "steady", f"{pending_count} 项待裁决" if pending_count else "无待裁决"),
("hetu", "steady" if world_n else "void", f"境界 {world_n} 个"),
("tiangong", "steady" if tasks_n else "void", f"受托任务 {tasks_n} 项"),
("kouxin", "breathing" if knocks_n else "void", f"叩心候选 {knocks_n} 条"),
("memory", "steady" if memories_n else "void", f"记忆 {memories_n} 条"),
(
"tool_runtime",
"breathing" if tool_enabled else "void",
"沙箱运行时在线" if tool_enabled else "工具运行时未启用",
),
("agent_society", "steady" if agents_n else "void", f"在岗角色 {agents_n} 位"),
]
name_map = {pivot: (name, module) for pivot, name, module in _PIVOT_REGISTRY}
pivots: list[PivotHealthSummary] = []
for pivot, health, summary in raw:
name, module = name_map[pivot]
pivots.append(
PivotHealthSummary(
pivot=pivot,
name=name,
module=module,
health=health,
summary=summary,
updated_at=now,
)
pivots = [
PivotHealthSummary(
pivot=pivot, name=name_map[pivot][0], module=name_map[pivot][1],
health=health, summary=summary, updated_at=now,
)
for pivot, health, summary in raw
]
alerts = sum(1 for item in pivots if item.health == "cracked")
return PivotHealthReport(
overall="cracked" if alerts else "steady", alerts=alerts, pivots=pivots
)
async def _count(session, model, *criteria) -> int:
from sqlalchemy import func as _func
stmt = select(_func.count()).select_from(model)
for c in criteria:
stmt = stmt.where(c)
return int((await session.execute(stmt)).scalar_one())
async def get_pending_approvals() -> list[PendingApproval]:
async with AsyncSessionLocal() as session:
records = (
-92
View File
@@ -1,92 +0,0 @@
"""会话目标自动持久化服务。
从用户消息自动持久化会话目标到 goals 表。
如果已有该 character 的活跃 session 级目标,判断内容是否显著不同:
- 内容相似 → 复用
- 内容不同 → 完成旧目标,创建新目标
"""
from app.models.governance import Goal
from app.repositories import governance as governance_repo
_TOPIC_SHIFT_MIN_LENGTH = 20
async def auto_persist_session_goal(
session,
character_id: str,
content: str,
) -> Goal | None:
"""自动持久化会话目标:复用活跃的 session 目标或新建。
内容过短(<10 字)不产生目标。
如果已有活跃 session 目标但内容显著不同,先完成旧目标再新建。
Returns:
持久化后的 Goal(可能为 None)。
"""
goal_text = (content or "").strip()
if not goal_text or len(goal_text) < 10:
return None
existing = await governance_repo.list_active_session_goal(session, character_id)
if existing is not None:
# 判断是否话题转换:内容不再相似时完成旧目标
if _is_topic_shift(existing.title, goal_text):
existing.status = "completed"
existing.progress = 1.0
# 创建新目标
goal = Goal(
character_id=character_id,
title=goal_text[:120],
description=f"从对话自动提取的会话目标:{goal_text[:500]}",
level="session",
type="mission",
status="active",
priority=50,
)
await governance_repo.add_goal(session, goal)
return goal
return existing
# 新建 session 目标
goal = Goal(
character_id=character_id,
title=goal_text[:120],
description=f"从对话自动提取的会话目标:{goal_text[:500]}",
level="session",
type="mission",
status="active",
priority=50,
)
await governance_repo.add_goal(session, goal)
return goal
def _is_topic_shift(existing_title: str, new_content: str) -> bool:
"""判断新内容与旧目标标题是否话题转换。
简单规则:如果新内容较长且与旧标题共享的关键词很少,视为话题转换。
这是启发式方法,后续可升级为 LLM 判断。
"""
if len(new_content) < _TOPIC_SHIFT_MIN_LENGTH:
return False
# 提取关键词集合(按空格/标点分割)
import re
old_words = set(re.split(r"[\s,。!?、;:,\.!?;:]+", existing_title.lower()))
new_words = set(re.split(r"[\s,。!?、;:,\.!?;:]+", new_content.lower()))
# 排除常见的助词
stop_words = {"的", "了", "是", "在", "有", "和", "就", "不", "人", "都", "一",
"一个", "上", "也", "很", "到", "说", "要", "去", "你", "我", "他",
"这", "那", "吗", "啊", "呢", "吧", "地", "得"}
old_keywords = old_words - stop_words
new_keywords = new_words - stop_words
if not old_keywords or not new_keywords:
return False
overlap = old_keywords & new_keywords
# 如果旧关键词的一个都没有出现在新内容中,或者重合率极低
if not overlap:
return True
return len(overlap) / max(len(old_keywords), len(new_keywords)) < 0.15
-453
View File
@@ -1,453 +0,0 @@
"""司命台治理对象 CRUD 服务(api-spec §19.4)。
五组开放集合(目标 / 感知通道 / 世界实体 / 计划 / 天工阁角色)的增删改查;
治理写操作一律记审计事件(audit_),并入起居注(§19.3)。pivot 由 action 前缀推断。
所有函数自管 ``AsyncSessionLocal``(路由直接调用,不传入 session)。
"""
from app.core.errors import AppError
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.models.governance import (
AgentRosterEntry,
Goal,
PerceptionChannel,
Plan,
)
from app.modules.runtime.events import Event
from app.repositories import governance as governance_repo
from app.repositories import runtime as runtime_repo
from app.repositories.json_utils import dumps, loads_dict
from app.schemas.admin import (
AgentRosterResponse,
CreateAgentRequest,
CreateGoalRequest,
CreatePerceptionChannelRequest,
CreatePlanRequest,
GoalNode,
PerceptionChannelResponse,
PlanSummaryResponse,
UpdateAgentRequest,
UpdateGoalRequest,
UpdatePerceptionChannelRequest,
UpdatePlanRequest,
)
from app.schemas.safety import (
ToolCallDetail,
)
from app.services.audit import record_audit_event
from app.services.serializers import (
agent_to_response,
goal_to_response,
perception_channel_to_response,
plan_to_response,
)
# —— 太一枢 · 目标 ——
async def list_goals() -> list[GoalNode]:
async with AsyncSessionLocal() as session:
goals = await governance_repo.list_goals(session)
return [goal_to_response(goal) for goal in goals]
async def list_goals_by_character(
character_id: str,
status_filter: str | None = None,
) -> list[GoalNode]:
async with AsyncSessionLocal() as session:
goals = await governance_repo.list_goals_by_character(
session, character_id, status_filter=status_filter
)
return [goal_to_response(goal) for goal in goals]
async def load_active_goals_context(character_id: str) -> str | None:
"""加载活跃目标并格式化为 system prompt 片段,供运行时注入。
目标按优先级排序(``prioritize_active_goals``),最高优先级在前。
"""
async with AsyncSessionLocal() as session:
goals = await governance_repo.list_goals_by_character(
session, character_id, status_filter="active"
)
if not goals:
return None
lines = [
f"- [{g.level}] {g.title}(优先级 {g.priority},进度 {g.progress}%)"
for g in sorted(goals, key=lambda g: -(g.priority or 0))
]
return "【当前目标】\n" + "\n".join(lines)
async def emit_goal_event(
goal: Goal,
event_type: str,
extra_payload: dict | None = None,
) -> None:
"""向事件存储写入 goal.* 事件。"""
from app.main import app # 惰性 import 避免启动循环
event_store = getattr(app.state, "event_store", None)
if event_store is None:
return
payload = {
"goal_id": goal.id,
"character_id": goal.character_id,
"title": goal.title,
"level": goal.level,
"type": goal.type,
"status": goal.status,
"priority": goal.priority,
"progress": goal.progress,
}
if extra_payload:
payload.update(extra_payload)
event = Event(
type=f"goal.{event_type}",
source="goal_orchestrator",
payload=payload,
character_id=goal.character_id,
visibility="audit",
)
await event_store.append(event)
async def create_goal(payload: CreateGoalRequest) -> GoalNode:
async with AsyncSessionLocal() as session:
goal = Goal(
title=payload.title,
description=payload.description,
level=payload.level,
type=payload.type,
character_id=payload.character_id,
status="pending",
priority=payload.priority,
constraints_json=dumps(payload.constraints),
parent_id=payload.parent_id,
success_criteria_json=dumps(payload.success_criteria),
)
await governance_repo.add_goal(session, goal)
await record_audit_event(
session,
action="goal.created",
actor="user",
input_summary=goal.title,
resource_type="goal",
resource_id=goal.id,
)
await session.commit()
await session.refresh(goal)
await emit_goal_event(goal, "created")
return goal_to_response(goal)
async def update_goal(goal_id: str, payload: UpdateGoalRequest) -> GoalNode:
async with AsyncSessionLocal() as session:
goal = await governance_repo.get_goal(session, goal_id)
if goal is None:
raise AppError("not_found", "目标不存在。")
old_status = goal.status
updates = payload.model_dump(exclude_unset=True)
constraints = updates.pop("constraints", None)
for key, value in updates.items():
setattr(goal, key, value)
if constraints is not None:
goal.constraints_json = dumps(constraints)
goal.updated_at = utc_now()
await record_audit_event(
session,
action="goal.updated",
actor="user",
input_summary=goal.title,
resource_type="goal",
resource_id=goal.id,
)
await session.commit()
await session.refresh(goal)
if old_status != goal.status:
if goal.status == "completed":
await emit_goal_event(goal, "completed", {"previous_status": old_status})
else:
await emit_goal_event(goal, "updated", {"previous_status": old_status})
else:
await emit_goal_event(goal, "updated", {"previous_status": old_status})
return goal_to_response(goal)
async def delete_goal(goal_id: str) -> None:
async with AsyncSessionLocal() as session:
goal = await governance_repo.get_goal(session, goal_id)
if goal is None:
raise AppError("not_found", "目标不存在。")
if goal.level == "highest":
raise AppError("invalid_request", "最高目标(使命级)不可删除。")
await session.delete(goal)
await record_audit_event(
session,
action="goal.deleted",
actor="user",
input_summary=goal.title,
resource_type="goal",
resource_id=goal.id,
)
await session.commit()
# —— 烛照枢 · 感知通道 ——
async def list_perception_channels() -> list[PerceptionChannelResponse]:
async with AsyncSessionLocal() as session:
channels = await governance_repo.list_perception_channels(session)
return [perception_channel_to_response(channel) for channel in channels]
async def create_perception_channel(
payload: CreatePerceptionChannelRequest,
) -> PerceptionChannelResponse:
async with AsyncSessionLocal() as session:
if await governance_repo.get_perception_channel(session, payload.channel) is not None:
raise AppError("conflict", "感知通道已存在。")
channel = PerceptionChannel(
channel=payload.channel,
name=payload.name,
description=payload.description,
risk_level=payload.risk_level,
)
await governance_repo.add_perception_channel(session, channel)
await record_audit_event(
session,
action="perception.created",
actor="user",
input_summary=channel.name,
resource_type="perception_channel",
resource_id=channel.channel,
)
await session.commit()
await session.refresh(channel)
return perception_channel_to_response(channel)
async def update_perception_channel(
channel_id: str, payload: UpdatePerceptionChannelRequest
) -> PerceptionChannelResponse:
async with AsyncSessionLocal() as session:
channel = await governance_repo.get_perception_channel(session, channel_id)
if channel is None:
raise AppError("not_found", "感知通道不存在。")
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(channel, key, value)
channel.updated_at = utc_now()
await record_audit_event(
session,
action="perception.updated",
actor="user",
input_summary=channel.name,
resource_type="perception_channel",
resource_id=channel.channel,
)
await session.commit()
await session.refresh(channel)
return perception_channel_to_response(channel)
async def delete_perception_channel(channel_id: str) -> None:
async with AsyncSessionLocal() as session:
channel = await governance_repo.get_perception_channel(session, channel_id)
if channel is None:
raise AppError("not_found", "感知通道不存在。")
await session.delete(channel)
await record_audit_event(
session,
action="perception.deleted",
actor="user",
input_summary=channel.name,
resource_type="perception_channel",
resource_id=channel.channel,
)
await session.commit()
# —— 河图枢 · 世界实体 ——
# —— 洛书枢 · 计划 ——
async def list_plans() -> list[PlanSummaryResponse]:
async with AsyncSessionLocal() as session:
plans = await governance_repo.list_plans(session)
return [plan_to_response(plan) for plan in plans]
async def create_plan(payload: CreatePlanRequest) -> PlanSummaryResponse:
async with AsyncSessionLocal() as session:
plan = Plan(
goal_id=payload.goal_id,
title=payload.title,
assumptions_json=dumps(payload.assumptions),
risks_json=dumps(payload.risks),
)
await governance_repo.add_plan(session, plan)
await record_audit_event(
session,
action="plan.created",
actor="user",
input_summary=plan.title,
resource_type="plan",
resource_id=plan.id,
)
await session.commit()
await session.refresh(plan)
return plan_to_response(plan)
async def update_plan(plan_id: str, payload: UpdatePlanRequest) -> PlanSummaryResponse:
async with AsyncSessionLocal() as session:
plan = await governance_repo.get_plan(session, plan_id)
if plan is None:
raise AppError("not_found", "计划不存在。")
updates = payload.model_dump(exclude_unset=True)
assumptions = updates.pop("assumptions", None)
risks = updates.pop("risks", None)
for key, value in updates.items():
setattr(plan, key, value)
if assumptions is not None:
plan.assumptions_json = dumps(assumptions)
if risks is not None:
plan.risks_json = dumps(risks)
plan.updated_at = utc_now()
await record_audit_event(
session,
action="plan.updated",
actor="user",
input_summary=plan.title,
resource_type="plan",
resource_id=plan.id,
)
await session.commit()
await session.refresh(plan)
return plan_to_response(plan)
async def delete_plan(plan_id: str) -> None:
async with AsyncSessionLocal() as session:
plan = await governance_repo.get_plan(session, plan_id)
if plan is None:
raise AppError("not_found", "计划不存在。")
await session.delete(plan)
await record_audit_event(
session,
action="plan.deleted",
actor="user",
input_summary=plan.title,
resource_type="plan",
resource_id=plan.id,
)
await session.commit()
# —— 天工阁枢 · 角色在岗 ——
async def list_agent_roster() -> list[AgentRosterResponse]:
async with AsyncSessionLocal() as session:
agents = await governance_repo.list_agent_roster(session)
return [agent_to_response(agent) for agent in agents]
async def create_agent(payload: CreateAgentRequest) -> AgentRosterResponse:
async with AsyncSessionLocal() as session:
if await governance_repo.get_agent(session, payload.role) is not None:
raise AppError("conflict", "角色已存在。")
agent = AgentRosterEntry(
role=payload.role,
name=payload.name,
responsibility=payload.responsibility,
)
await governance_repo.add_agent(session, agent)
await record_audit_event(
session,
action="agent.created",
actor="user",
input_summary=agent.name,
resource_type="agent",
resource_id=agent.role,
)
await session.commit()
await session.refresh(agent)
return agent_to_response(agent)
async def update_agent(role: str, payload: UpdateAgentRequest) -> AgentRosterResponse:
async with AsyncSessionLocal() as session:
agent = await governance_repo.get_agent(session, role)
if agent is None:
raise AppError("not_found", "角色不存在。")
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(agent, key, value)
agent.updated_at = utc_now()
await record_audit_event(
session,
action="agent.updated",
actor="user",
input_summary=agent.name,
resource_type="agent",
resource_id=agent.role,
)
await session.commit()
await session.refresh(agent)
return agent_to_response(agent)
async def delete_agent(role: str) -> None:
async with AsyncSessionLocal() as session:
agent = await governance_repo.get_agent(session, role)
if agent is None:
raise AppError("not_found", "角色不存在。")
await session.delete(agent)
await record_audit_event(
session,
action="agent.deleted",
actor="user",
input_summary=agent.name,
resource_type="agent",
resource_id=agent.role,
)
await session.commit()
# —— 百工枢 · 工具策略(§15 / §16 共享)——
async def get_tool_call(tool_call_id: str) -> ToolCallDetail:
"""GET /tools/calls/{tool_call_id}:单条 tool.* 事件详情。
tool_call_id 对应一炁事件存储的事件主键;仅返回 tool.* 类型事件,否则 404。
"""
from app.core.time import to_iso
async with AsyncSessionLocal() as session:
event = await runtime_repo.get_event(session, tool_call_id)
if event is None or not event.type.startswith("tool."):
raise AppError("not_found", f"工具调用 {tool_call_id} 不存在。")
payload = loads_dict(event.payload_json)
return ToolCallDetail(
event_id=event.id,
type=event.type,
tool=payload.get("tool"),
run_id=event.run_id,
character_id=event.character_id,
trace_id=event.trace_id,
payload=payload,
created_at=to_iso(event.created_at),
)
-168
View File
@@ -16,15 +16,8 @@ from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.models.imports import CharacterImportJob, ImportItem
from app.modules.imports import parse_import
from app.repositories import candidates as candidates_repo
from app.repositories import imports as imports_repo
from app.repositories.json_utils import dumps, loads_dict
from app.schemas.candidates import (
BatchReviewRequest,
BatchReviewResult,
MemoryCandidateResponse,
ProfileCandidateResponse,
)
from app.schemas.common import Page
from app.schemas.imports import (
CreateImportRequest,
@@ -36,8 +29,6 @@ from app.services.characters import get_owned_character
from app.services.serializers import (
import_item_to_response,
import_job_to_response,
memory_candidate_to_response,
profile_candidate_to_response,
)
_TEXT_MIME_FORMAT: dict[str, str] = {
@@ -283,24 +274,6 @@ async def _reload_job(import_id: str) -> ImportJobResponse:
return import_job_to_response(job)
async def trigger_profile_extraction(
character_id: str,
import_id: str,
owner_scope: str = settings.default_owner_scope,
) -> ImportJobResponse:
return await _trigger_extraction(
character_id, import_id, "extracting_profile", "profile", owner_scope=owner_scope
)
async def trigger_memory_extraction(
character_id: str,
import_id: str,
owner_scope: str = settings.default_owner_scope,
) -> ImportJobResponse:
return await _trigger_extraction(
character_id, import_id, "extracting_memory", "memory", owner_scope=owner_scope
)
async def trigger_personality_extraction(
@@ -317,148 +290,7 @@ async def trigger_personality_extraction(
)
async def _trigger_extraction(
character_id: str,
import_id: str,
status: str,
kind: str,
owner_scope: str = settings.default_owner_scope,
) -> ImportJobResponse:
"""抽取触发已随旧管线退役:记忆候选由息壤归真管线生成(M3),画像/人格抽取随 M6。"""
raise AppError(
"not_implemented",
"该抽取入口已退役:记忆候选经镜树归真生成(息壤 M3);画像/人格抽取随 M6 灵核重建。",
status_code=410,
)
async def list_profile_candidates(
character_id: str,
import_id: str,
*,
status: str | None = None,
owner_scope: str = settings.default_owner_scope,
) -> list[ProfileCandidateResponse]:
async with AsyncSessionLocal() as session:
character = await get_owned_character(session, character_id, owner_scope=owner_scope)
await _get_owned_job(session, character.id, import_id)
items = await candidates_repo.list_profile_candidates(
session, character.id, import_job_id=import_id, status=status
)
return [profile_candidate_to_response(c) for c in items]
async def list_memory_candidates(
character_id: str,
import_id: str,
*,
status: str | None = None,
owner_scope: str = settings.default_owner_scope,
) -> list[MemoryCandidateResponse]:
async with AsyncSessionLocal() as session:
character = await get_owned_character(session, character_id, owner_scope=owner_scope)
await _get_owned_job(session, character.id, import_id)
items = await candidates_repo.list_memory_candidates(
session, character.id, import_job_id=import_id, status=status
)
return [memory_candidate_to_response(c) for c in items]
# —— 候选审核应用(5c)——
async def batch_review_profile_candidates(
character_id: str,
payload: BatchReviewRequest,
owner_scope: str = settings.default_owner_scope,
) -> BatchReviewResult:
"""批量审核档案候选:approve 写入人物档案,reject 标记驳回,非 pending 跳过。"""
async with AsyncSessionLocal() as session:
character = await get_owned_character(session, character_id, owner_scope=owner_scope)
profile = loads_dict(character.profile_json)
applied = rejected = skipped = 0
for decision in payload.decisions:
candidate = await candidates_repo.get_profile_candidate(session, decision.candidate_id)
if (
candidate is None
or candidate.character_id != character.id
or candidate.status != "pending"
):
skipped += 1
continue
if decision.decision == "approve":
profile[candidate.field] = candidate.value
if candidate.field == "display_name":
character.display_name = candidate.value
candidate.status = "applied"
applied += 1
elif decision.decision == "reject":
candidate.status = "rejected"
rejected += 1
else:
skipped += 1
continue
candidate.reviewed_at = utc_now()
character.profile_json = dumps(profile)
await _audit(
session,
character.id,
"import.profile_candidate_reviewed",
character.id,
f"applied={applied} rejected={rejected}",
)
await session.commit()
return BatchReviewResult(applied=applied, rejected=rejected, skipped=skipped)
async def batch_review_memory_candidates(
character_id: str,
payload: BatchReviewRequest,
owner_scope: str = settings.default_owner_scope,
) -> BatchReviewResult:
"""批量审核记忆候选:approve 创建记忆,reject 标记驳回。"""
async with AsyncSessionLocal() as session:
character = await get_owned_character(session, character_id, owner_scope=owner_scope)
applied = rejected = skipped = 0
for decision in payload.decisions:
candidate = await candidates_repo.get_memory_candidate(session, decision.candidate_id)
if (
candidate is None
or candidate.character_id != character.id
or candidate.status != "pending"
):
skipped += 1
continue
if decision.decision == "approve":
session.add(
Memory(
character_id=character.id,
type=candidate.type,
title=candidate.title,
content=candidate.content,
source="user_explicit",
confidence=candidate.confidence,
importance=candidate.importance,
sensitivity=candidate.sensitivity,
evidence_ids_json=candidate.evidence_ids_json,
status="active",
)
)
candidate.status = "applied"
applied += 1
elif decision.decision == "reject":
candidate.status = "rejected"
rejected += 1
else:
skipped += 1
continue
candidate.reviewed_at = utc_now()
await _audit(
session,
character.id,
"import.memory_candidate_reviewed",
character.id,
f"applied={applied} rejected={rejected}",
)
await session.commit()
return BatchReviewResult(applied=applied, rejected=rejected, skipped=skipped)
+24
View File
@@ -213,3 +213,27 @@ async def list_run_event_responses(
session, run_id, limit=limit, offset=offset
)
return [event_to_response(event) for event in events]
async def get_tool_call_detail(tool_call_id: str):
"""GET /tools/calls/{tool_call_id}:单条 tool.* 事件详情。"""
from app.core.errors import AppError
from app.core.time import to_iso
from app.repositories.json_utils import loads_dict
from app.schemas.safety import ToolCallDetail
async with AsyncSessionLocal() as session:
event = await runtime_repo.get_event(session, tool_call_id)
if event is None or not event.type.startswith("tool."):
raise AppError("not_found", f"工具调用 {tool_call_id} 不存在。")
payload = loads_dict(event.payload_json)
return ToolCallDetail(
event_id=event.id,
type=event.type,
tool=payload.get("tool"),
run_id=event.run_id,
character_id=event.character_id,
trace_id=event.trace_id,
payload=payload,
created_at=to_iso(event.created_at),
)
+2 -105
View File
@@ -3,28 +3,14 @@
from datetime import UTC, timedelta
from typing import Any
from app.models.governance import GenerationPreferences
from app.core.time import to_iso, utc_now
from app.models.candidates import MemoryCandidate, ProfileCandidate
from app.models.character import Character
from app.models.governance import (
AgentRosterEntry,
GenerationPreferences,
Goal,
PerceptionChannel,
Plan,
)
from app.models.imports import CharacterImportJob, ImportItem
from app.models.runtime import Event, Run
from app.models.safety import AuditEvent
from app.repositories.json_utils import loads_dict, loads_list
from app.schemas.admin import (
AgentRosterResponse,
AuditEntry,
GoalNode,
PerceptionChannelResponse,
PlanSummaryResponse,
)
from app.schemas.candidates import MemoryCandidateResponse, ProfileCandidateResponse
from app.schemas.admin import AuditEntry
from app.schemas.character import CharacterResponse, CharacterSummaryResponse
from app.schemas.imports import ImportItemResponse, ImportJobResponse
from app.schemas.preferences import GenerationPreferencesResponse
@@ -103,75 +89,8 @@ def event_to_response(event: Event) -> EventResponse:
# —— 司命台治理对象 ——
def goal_to_response(goal: Goal) -> GoalNode:
return GoalNode(
id=goal.id,
character_id=goal.character_id,
title=goal.title,
description=goal.description,
level=goal.level,
type=goal.type,
status=goal.status,
priority=goal.priority,
progress=goal.progress,
constraints=loads_list(goal.constraints_json),
parent_id=goal.parent_id,
success_criteria=loads_list(goal.success_criteria_json),
updated_at=to_iso(goal.updated_at),
)
def perception_channel_to_response(channel: PerceptionChannel) -> PerceptionChannelResponse:
return PerceptionChannelResponse(
channel=channel.channel,
name=channel.name,
description=channel.description,
enabled=channel.enabled,
consent_status=channel.consent_status,
privacy_level=channel.privacy_level,
risk_level=channel.risk_level,
last_active_at=to_iso(channel.last_active_at),
)
def plan_to_response(plan: Plan) -> PlanSummaryResponse:
return PlanSummaryResponse(
id=plan.id,
goal_id=plan.goal_id or "",
title=plan.title,
status=plan.status,
assumptions=loads_list(plan.assumptions_json),
risks=loads_list(plan.risks_json),
required_tools=loads_list(plan.required_tools_json),
success_criteria=loads_list(plan.success_criteria_json),
step_count=plan.step_count,
completed_steps=plan.completed_steps,
updated_at=to_iso(plan.updated_at),
)
_ACTIVE_WINDOW = timedelta(hours=1)
def agent_to_response(agent: AgentRosterEntry) -> AgentRosterResponse:
"""presence 为派生呈现态:启用且最近一小时内活跃 → active,否则 idle。"""
presence = "idle"
if agent.enabled and agent.last_active_at:
last = agent.last_active_at
if last.tzinfo is None:
last = last.replace(tzinfo=UTC)
if utc_now() - last <= _ACTIVE_WINDOW:
presence = "active"
return AgentRosterResponse(
role=agent.role,
name=agent.name,
responsibility=agent.responsibility,
enabled=agent.enabled,
presence=presence,
last_active_at=to_iso(agent.last_active_at),
)
def generation_preferences_to_response(
@@ -236,28 +155,6 @@ def import_item_to_response(item: ImportItem) -> ImportItemResponse:
)
def profile_candidate_to_response(candidate: ProfileCandidate) -> ProfileCandidateResponse:
return ProfileCandidateResponse(
id=candidate.id,
character_id=candidate.character_id,
import_job_id=candidate.import_job_id,
field=candidate.field,
value=candidate.value,
confidence=candidate.confidence,
evidence_ids=loads_list(candidate.evidence_ids_json),
status=candidate.status,
reason=candidate.reason,
reviewed_at=to_iso(candidate.reviewed_at),
created_at=to_iso(candidate.created_at) or "",
)
def memory_candidate_to_response(candidate): # noqa: ANN001 — 旧导入候选(M6 退役)
from app.schemas.candidates import MemoryCandidateResponse
return MemoryCandidateResponse.model_validate(candidate, from_attributes=True)
def profile_candidate_to_response(candidate): # noqa: ANN001 — 旧导入候选(M6 退役)
-66
View File
@@ -1,66 +0,0 @@
"""测试 per-character goals API。"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import AsyncSessionLocal
from app.main import app
@pytest.fixture
async def db_session() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
yield session
@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
@pytest.mark.asyncio
async def test_list_goals_by_character(client: AsyncClient, db_session: AsyncSession) -> None:
"""GET /api/v1/goals?character_id=char_test 返回该人物的目标。"""
from app.models.governance import Goal
goal = Goal(
id="goal_api_test",
title="API测试目标",
level="task",
character_id="char_test",
status="active",
)
db_session.add(goal)
await db_session.commit()
resp = await client.get("/api/v1/goals?character_id=char_test")
assert resp.status_code == 200
data = resp.json()
assert any(g["id"] == "goal_api_test" for g in data)
@pytest.mark.asyncio
async def test_list_goals_filters_status(client: AsyncClient, db_session: AsyncSession) -> None:
"""GET /api/v1/goals?character_id=char_test&status=active 只返回 active。"""
from app.models.governance import Goal
db_session.add_all(
[
Goal(id="goal_a1", title="a1", level="task", character_id="char_test", status="active"),
Goal(
id="goal_c1", title="c1", level="task", character_id="char_test", status="completed"
),
]
)
await db_session.commit()
resp = await client.get("/api/v1/goals?character_id=char_test&status=active")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["id"] == "goal_a1"
@@ -1,135 +0,0 @@
"""洛书 Deliberative 规划器测试。"""
from app.application.planning import (
DeliberativePlan,
PlanStep,
build_planning_prompt,
format_plan_for_prompt,
parse_plan,
should_plan,
)
def test_parse_plan_full() -> None:
"""完整 JSON 计划解析。"""
raw = """```json
{
"title": "调研项目X",
"assumptions": ["需要模型网关RAG能力"],
"risks": ["外部API可能超时"],
"steps": [
{"id": 1, "title": "搜索文档", "description": "搜索关于项目X的文档",
"tool": "web_search", "risk_level": "L2"},
{"id": 2, "title": "阅读结果", "description": "阅读搜索结果",
"tool": "read_file", "risk_level": "L1"},
{"id": 3, "title": "汇总", "description": "汇总分析",
"tool": null, "risk_level": "L1"}
]
}
```"""
plan = parse_plan(raw)
assert plan is not None
assert plan.title == "调研项目X"
assert len(plan.assumptions) == 1
assert len(plan.risks) == 1
assert len(plan.steps) == 3
assert plan.steps[0].tool == "web_search"
assert plan.steps[2].tool is None
assert plan.steps[0].risk_level == "L2"
def test_parse_plan_no_fence() -> None:
"""无 ``` 围栏的 JSON 也能解析。"""
raw = '{"title": "简单计划", "steps": [{"id": 1, "title": "执行", "description": ""}]}'
plan = parse_plan(raw)
assert plan is not None
assert plan.title == "简单计划"
assert len(plan.steps) == 1
def test_parse_plan_missing_title() -> None:
"""缺少标题 → None。"""
raw = '{"steps": [{"id": 1, "title": "执行", "description": ""}]}'
assert parse_plan(raw) is None
def test_parse_plan_empty_steps() -> None:
"""空步骤列表 → None。"""
raw = '{"title": "空计划", "steps": []}'
assert parse_plan(raw) is None
def test_parse_plan_invalid_json() -> None:
"""非法 JSON → None。"""
assert parse_plan("根本不是JSON") is None
def test_parse_plan_not_dict() -> None:
"""返回非对象 → None。"""
assert parse_plan("[1, 2, 3]") is None
def test_should_plan_triggers() -> None:
"""包含关键词的请求应触发规划。"""
triggers = [
"搜索一下项目X的背景",
"帮我调研一下最新技术",
"分析这个方案的优缺点",
"比较A和B的差异",
"研究一下这个问题",
"search for relevant docs",
"research the market",
]
for t in triggers:
assert should_plan(t), f"should plan: {t}"
def test_should_plan_no_triggers() -> None:
"""简单请求不应触发。"""
no_triggers = [
"你好",
"再见",
"今天天气怎么样",
"帮我写首诗",
]
for t in no_triggers:
assert not should_plan(t), f"should not plan: {t}"
def test_build_planning_prompt() -> None:
"""规划 prompt 包含必要结构。"""
prompt = build_planning_prompt("搜索资料", memory_context="用户关心项目X")
assert "NO_PLAN_NEEDED" in prompt
assert "搜索资料" in prompt
assert "用户关心项目X" in prompt
def test_format_plan_for_prompt() -> None:
"""计划格式化后包含步骤和元信息。"""
plan = DeliberativePlan(
title="测试计划",
steps=[
PlanStep(id=1, title="第一步", description="做A", tool="tool1", risk_level="L2"),
PlanStep(id=2, title="第二步", description="做B", risk_level="L1"),
],
assumptions=["假设1"],
risks=["风险1"],
)
text = format_plan_for_prompt(plan)
assert "测试计划" in text
assert "第一步" in text
assert "tool1" in text
assert "第二步" in text
assert "假设1" in text
assert "风险1" in text
def test_format_plan_empty_assumptions_and_risks() -> None:
"""无假设/风险时格式化正常。"""
plan = DeliberativePlan(
title="简单",
steps=[PlanStep(id=1, title="做", description="")],
)
text = format_plan_for_prompt(plan)
assert "简单" in text
assert "做" in text
-53
View File
@@ -1,53 +0,0 @@
"""司命台治理种子数据测试。"""
from collections.abc import AsyncGenerator
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.governance_seed import _GOALS, ensure_governance_seed
from app.db.session import AsyncSessionLocal
from app.models.governance import Goal
@pytest.fixture
async def db_session() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
yield session
class TestGoalSeedsHaveCharacterId:
"""_GOALS 每条 seed 都包含 character_id 与 type 字段。"""
def test_all_goals_have_character_id_key(self) -> None:
for item in _GOALS:
assert "character_id" in item, f"{item['id']} 缺少 character_id"
def test_all_goals_have_type_key(self) -> None:
for item in _GOALS:
assert "type" in item, f"{item['id']} 缺少 type"
@pytest.mark.asyncio
async def test_seed_goals_persist_with_new_fields(
db_session: AsyncSession,
) -> None:
"""验证种子写入数据库后 character_id / type 字段被正确持久化。"""
# 先清理已有数据,确保从头写入种子
await db_session.execute(Goal.__table__.delete())
await db_session.commit()
await ensure_governance_seed(db_session)
result = await db_session.execute(select(Goal))
goals = result.scalars().all()
assert len(goals) > 0
for g in goals:
assert hasattr(g, "character_id"), f"{g.id} 缺少 character_id 属性"
assert hasattr(g, "type"), f"{g.id} 缺少 type 属性"
# character_id 在 seed 中全为 None,确保字段可空
assert g.character_id is None
# type 应与 seed 定义一致
assert g.type == "mission", f"{g.id} type 应为 mission,实际 {g.type}"
-21
View File
@@ -1,21 +0,0 @@
"""测试 Goal ORM 模型新增字段。"""
from app.models.governance import Goal
def test_goal_has_character_id():
"""Goal 模型新增 character_id 字段。"""
assert hasattr(Goal, "character_id")
def test_goal_has_type():
"""Goal 模型新增 type 字段,默认 session。"""
assert hasattr(Goal, "type")
def test_goal_has_progress():
"""Goal 模型新增 progress 字段,默认 0.0。"""
goal = Goal(title="test", level="task")
assert goal.type == "session"
assert goal.progress == 0.0
assert goal.character_id is None
@@ -1,35 +0,0 @@
"""太一会话目标提取纯函数测试。"""
from app.modules.goal_orchestrator.extract import (
extract_session_goal,
format_goal_directive,
)
def test_extract_returns_stripped_content() -> None:
assert extract_session_goal(" 帮我整理今天的笔记 ") == "帮我整理今天的笔记"
def test_extract_empty_returns_empty_string() -> None:
assert extract_session_goal("") == ""
assert extract_session_goal(" ") == ""
def test_extract_short_message_unchanged() -> None:
assert extract_session_goal("今天天气如何?") == "今天天气如何?"
def test_extract_truncates_over_limit() -> None:
# 目标摘要上限 120 字(run.goal 字段容量 300,留余量);超出截断
long = "目标描述" * 40 # 160 字
goal = extract_session_goal(long)
assert len(goal) == 120
assert goal == "目标描述" * 30 # 120 字 = 30 个「目标描述」
def test_format_goal_directive_with_goal() -> None:
assert format_goal_directive("整理笔记") == "当前会话目标:整理笔记"
def test_format_goal_directive_empty_returns_empty() -> None:
assert format_goal_directive("") == ""
@@ -1,62 +0,0 @@
"""太一枢目标数据访问测试(per-character 查询 + 进度更新)。"""
import pytest
from app.db.session import AsyncSessionLocal
from app.models.governance import Goal
from app.repositories import governance as repo
@pytest.mark.asyncio
async def test_list_goals_by_character() -> None:
"""返回指定 character_id 的目标列表。"""
async with AsyncSessionLocal() as session:
g1 = Goal(id="goal_1", title="t1", level="task", character_id="char_a")
g2 = Goal(id="goal_2", title="t2", level="task", character_id="char_b")
session.add_all([g1, g2])
await session.flush()
goals = await repo.list_goals_by_character(session, "char_a")
assert len(goals) == 1
assert goals[0].id == "goal_1"
@pytest.mark.asyncio
async def test_list_goals_by_character_filters_status() -> None:
"""按 status 过滤指定角色的目标。"""
async with AsyncSessionLocal() as session:
g1 = Goal(id="goal_3", title="t3", level="task", character_id="char_c", status="active")
g2 = Goal(
id="goal_4",
title="t4",
level="task",
character_id="char_c",
status="completed",
)
session.add_all([g1, g2])
await session.flush()
goals = await repo.list_goals_by_character(session, "char_c", status_filter="active")
assert len(goals) == 1
assert goals[0].id == "goal_3"
@pytest.mark.asyncio
async def test_update_goal_progress() -> None:
"""更新目标的 progress 字段。"""
async with AsyncSessionLocal() as session:
goal = Goal(id="goal_5", title="t5", level="task")
session.add(goal)
await session.flush()
updated = await repo.update_goal_progress(session, "goal_5", 0.75)
assert updated is not None
assert updated.progress == 0.75
@pytest.mark.asyncio
async def test_update_goal_progress_nonexistent() -> None:
"""更新不存在的目标返回 None。"""
async with AsyncSessionLocal() as session:
result = await repo.update_goal_progress(session, "goal_nonexistent", 0.5)
assert result is None
@@ -1,126 +0,0 @@
"""测试 governance service 中的 goal 事件写入与会话目标自动持久化。"""
import pytest
from app.core.errors import AppError
from app.models.governance import Goal
# ── auto_persist_session_goal 测试 ──
@pytest.mark.asyncio
async def test_auto_persist_session_goal_creates_new():
"""auto_persist_session_goal: 当没有活跃的 session 目标时,创建一个新的。"""
from app.db.session import AsyncSessionLocal
from app.services.goal_persist import auto_persist_session_goal
async with AsyncSessionLocal() as session:
goal = await auto_persist_session_goal(session, "char_c", "帮助用户解决编程问题")
assert goal is not None
assert goal.level == "session"
assert goal.character_id == "char_c"
assert "编程" in goal.title
@pytest.mark.asyncio
async def test_auto_persist_session_goal_reuses_active():
"""auto_persist_session_goal: 已有活跃 session 目标时,不创建新的。"""
from app.db.session import AsyncSessionLocal
from app.services.goal_persist import auto_persist_session_goal
async with AsyncSessionLocal() as session:
existing = Goal(
id="goal_session_existing",
character_id="char_d",
level="session",
status="active",
title="已有目标",
priority=50,
)
session.add(existing)
await session.flush()
goal = await auto_persist_session_goal(session, "char_d", "新对话内容要足够长才能通过验证")
assert goal is not None
assert goal.id == "goal_session_existing" # 复用了已有目标
@pytest.mark.asyncio
async def test_auto_persist_session_goal_short_content():
"""auto_persist_session_goal: 内容过短时返回 None。"""
from app.db.session import AsyncSessionLocal
from app.services.goal_persist import auto_persist_session_goal
async with AsyncSessionLocal() as session:
goal = await auto_persist_session_goal(session, "char_c", "你好")
assert goal is None
# ── governance service CRUD 测试(事件写入通过 emit_goal_event 静默跳过)──
@pytest.mark.asyncio
async def test_create_goal_success():
"""create_goal: 创建目标成功,返回 GoalNode。"""
from app.schemas.admin import CreateGoalRequest
from app.services.governance import create_goal
payload = CreateGoalRequest(
title="测试目标",
description="这是一个测试目标",
level="session",
priority=50,
)
result = await create_goal(payload)
assert result.title == "测试目标"
assert result.level == "session"
assert result.status == "pending"
@pytest.mark.asyncio
async def test_update_goal_success():
"""update_goal: 更新目标成功。"""
from app.schemas.admin import CreateGoalRequest, UpdateGoalRequest
from app.services.governance import create_goal, update_goal
payload = CreateGoalRequest(
title="待更新目标",
description="稍后更新",
level="task",
priority=50,
)
created = await create_goal(payload)
update = UpdateGoalRequest(title="已更新目标", status="active")
updated = await update_goal(created.id, update)
assert updated.title == "已更新目标"
assert updated.status == "active"
@pytest.mark.asyncio
async def test_update_goal_not_found():
"""update_goal: 目标不存在时抛出 AppError。"""
from app.schemas.admin import UpdateGoalRequest
from app.services.governance import update_goal
update = UpdateGoalRequest(title="不存在")
with pytest.raises(AppError) as exc:
await update_goal("goal_nonexistent", update)
assert "not_found" in str(exc.value)
@pytest.mark.asyncio
async def test_delete_goal_success():
"""delete_goal: 删除目标成功。"""
from app.schemas.admin import CreateGoalRequest
from app.services.governance import create_goal, delete_goal, list_goals
payload = CreateGoalRequest(
title="待删除目标",
description="稍后删除",
level="task",
priority=50,
)
created = await create_goal(payload)
await delete_goal(created.id)
all_goals = await list_goals()
assert not any(g.id == created.id for g in all_goals)
+226
View File
@@ -0,0 +1,226 @@
"""M8 验收测试(rewrite-plan M8 验收门)。
① 后台任务不构成第二意识(无身份写入路径:完成后记忆/魂籍/缘契零新增);
② 后台 Run 不占前台席位(并行于前台对话);
③ 叩心受总开关与静默约束;不回应不降低关系(无关系副作用断言)。
"""
from collections.abc import AsyncGenerator
from datetime import UTC, datetime, time, timedelta
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.db.session import AsyncSessionLocal
from app.domains import kouxin
from app.main import app
from app.models.jingsheng import IdentityRecord
from app.models.xirang import Memory
from app.domains.yuanqi.models import Covenant
from app.kernel import Kernel, KernelEventLog
from app.services.event_store import DbEventStore
from app.shenshi_client.client import EchoShenshi
@pytest.fixture
async def client(
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
) -> AsyncGenerator[AsyncClient, None]:
app.state.kernel = Kernel(
shenshi=EchoShenshi(),
session_factory=AsyncSessionLocal,
event_log=KernelEventLog(DbEventStore(AsyncSessionLocal)),
timeout_seconds=10.0,
)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test", headers=auth_headers) as c:
yield c
app.state.kernel = None
async def _character(client: AsyncClient) -> dict:
resp = await client.get("/api/v1/characters/active")
assert resp.status_code == 200, resp.text
return resp.json()
async def _wait_task(client: AsyncClient, task_id: str, terminal: set[str], timeout_s: float = 15.0) -> dict:
import asyncio
async def poll():
while True:
resp = await client.get("/api/v1/tasks", params={"parent": "all"})
task = next((t for t in resp.json() if t["id"] == task_id), None)
assert task is not None
if task["status"] in terminal:
return task
await asyncio.sleep(0.2)
return await asyncio.wait_for(poll(), timeout=timeout_s)
# ── ①/② 天工阁:后台 Run 无身份写入、不占前台席位 ──
@pytest.mark.asyncio
async def test_backstage_task_no_identity_writes(client: AsyncClient) -> None:
character = await _character(client)
async def _counts() -> tuple[int, int, int]:
async with AsyncSessionLocal() as session:
mem = len(list((await session.execute(select(Memory).where(Memory.character_id == character["id"]))).scalars()))
ids = len(list((await session.execute(select(IdentityRecord).where(IdentityRecord.character_id == character["id"]))).scalars()))
cov = len(list((await session.execute(select(Covenant).where(Covenant.character_id == character["id"]))).scalars()))
return mem, ids, cov
before = await _counts()
task = await client.post(
"/api/v1/tasks",
json={
"character_id": character["id"],
"title": "整理周报要点",
"brief": "把最近一周的镜树归真内容整理为十条要点",
"acceptance_criteria": "输出不超过十条要点列表",
},
)
assert task.status_code == 201, task.text
task_id = task.json()["id"]
dispatched = await client.post(f"/api/v1/tasks/{task_id}/dispatch", json={})
assert dispatched.status_code == 200, dispatched.text
assert dispatched.json()["status"] == "running"
done = await _wait_task(client, task_id, {"awaiting_acceptance", "failed"})
assert done["status"] == "awaiting_acceptance", done
assert done["run_id"], "后台 Run 已关联"
assert done["result_digest"], "结果证据已回填"
# 无身份写入路径:记忆/魂籍/缘契零新增(第二意识断言)
after = await _counts()
assert after == before, f"后台任务不得写身份路径:{before} → {after}"
# 按证据验收
accepted = await client.post(f"/api/v1/tasks/{task_id}/accept", json={"accepted": True})
assert accepted.status_code == 200
assert accepted.json()["status"] == "accepted"
@pytest.mark.asyncio
async def test_backstage_run_parallel_with_foreground(client: AsyncClient) -> None:
"""后台 Run 运行期间,前台对话不受席位阻塞(不占前台槽位)。"""
character = await _character(client)
task = await client.post(
"/api/v1/tasks",
json={"title": "长任务", "brief": "慢速整理", "acceptance_criteria": "完成即止"},
)
task_id = task.json()["id"]
await client.post(f"/api/v1/tasks/{task_id}/dispatch", json={})
# 后台任务运行中,前台 Run 立即可启动(回声桩即刻完成——若席位被占会超时)
run = await client.post("/api/v1/runs", json={"prompt": "前台照常", "character_id": character["id"]})
assert run.status_code in (200, 201), run.text
await _wait_task(client, task_id, {"awaiting_acceptance", "failed"})
@pytest.mark.asyncio
async def test_task_decompose_and_cascade(client: AsyncClient) -> None:
parent = await client.post(
"/api/v1/tasks",
json={"title": "资料三线整理", "brief": "并行整理", "acceptance_criteria": "子任务全部完成"},
)
parent_id = parent.json()["id"]
subs = await client.post(
f"/api/v1/tasks/{parent_id}/decompose",
json={"subtasks": [
{"title": "线一", "brief": "A", "acceptance_criteria": "完成"},
{"title": "线二", "brief": "B", "acceptance_criteria": "完成"},
]},
)
assert subs.status_code == 201
sub_ids = [s["id"] for s in subs.json()]
for sid in sub_ids:
await client.post(f"/api/v1/tasks/{sid}/dispatch", json={})
for sid in sub_ids:
await _wait_task(client, sid, {"awaiting_acceptance", "failed"})
# 父任务级联进入待验收
parent_done = await _wait_task(client, parent_id, {"awaiting_acceptance", "failed"})
assert parent_done["status"] == "awaiting_acceptance"
# ── ③ 叩心约束与无关系副作用 ──
@pytest.mark.asyncio
async def test_knocking_constraints(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
character = await _character(client)
# 总开关关闭 → 派发被拒
monkeypatch.setattr(kouxin.service.settings, "knocking_enabled", False)
cand = await client.post(
"/api/v1/knocking/candidates",
json={"character_id": character["id"], "draft": "最近还好吗?"},
)
assert cand.status_code == 201
blocked = await client.post(f"/api/v1/knocking/candidates/{cand.json()['id']}/dispatch", json={})
assert blocked.status_code == 429
assert "总开关" in blocked.text
monkeypatch.setattr(kouxin.service.settings, "knocking_enabled", True)
# 静默窗口(强制设为全天)→ 拒
monkeypatch.setattr(kouxin.service.settings, "knocking_quiet_start", "00:00")
monkeypatch.setattr(kouxin.service.settings, "knocking_quiet_end", "23:59")
blocked2 = await client.post(f"/api/v1/knocking/candidates/{cand.json()['id']}/dispatch", json={})
assert blocked2.status_code == 429
assert "静默" in blocked2.text
# 频率上限=0 → 拒
monkeypatch.setattr(kouxin.service.settings, "knocking_quiet_start", "03:00")
monkeypatch.setattr(kouxin.service.settings, "knocking_quiet_end", "04:00")
monkeypatch.setattr(kouxin.service.settings, "knocking_daily_limit", 0)
blocked3 = await client.post(f"/api/v1/knocking/candidates/{cand.json()['id']}/dispatch", json={})
assert blocked3.status_code == 429
# 恢复常态(当前在 03:00–04:00 之外、上限 3)→ 允许派发
monkeypatch.setattr(kouxin.service.settings, "knocking_daily_limit", 3)
ok = await client.post(f"/api/v1/knocking/candidates/{cand.json()['id']}/dispatch", json={})
assert ok.status_code == 200, ok.text
assert ok.json()["status"] == "dispatched"
@pytest.mark.asyncio
async def test_no_response_no_relationship_side_effect(client: AsyncClient) -> None:
"""不回应不降低关系:候选过期后缘契维度零变化。"""
character = await _character(client)
# 建立缘契基线
cov = await client.post(
"/api/v1/covenants",
json={"character_id": character["id"], "dimensions": {"trust": "high", "companionship": "warm"}},
)
assert cov.status_code == 201
await client.post(f"/api/v1/covenants/{cov.json()['id']}/seal", json={"dimensions": {"trust": "high", "companionship": "warm"}})
before = (await client.get(f"/api/v1/covenants/by-character/{character['id']}")).json()
# 叩心候选发出后无人回应,直至过期
cand = await client.post(
"/api/v1/knocking/candidates",
json={"character_id": character["id"], "trigger": "time_anchor", "draft": "想到你了"},
)
cid = cand.json()["id"]
await client.post(f"/api/v1/knocking/candidates/{cid}/dispatch", json={})
expired = await kouxin.service.expire_stale(max_age_hours=0) # 立即过期
assert expired >= 1
# 无关系副作用:缘契维度与状态零变化
after = (await client.get(f"/api/v1/covenants/by-character/{character['id']}")).json()
assert after["dimensions"] == before["dimensions"]
assert after["status"] == before["status"]
assert after["version"] == before["version"], "叩心不得触发缘契版本变更"
+1
View File
@@ -36,6 +36,7 @@
| 百工与司命 | 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 隔离,真界本相零污染);引文→记忆正影后继提示;前端缘契面板与世界页。验收六测试全绿;旧人格/驱动/演化/世界模块与页面全量退役 |
| 形仪域 | M7 | 已验证 | M7 收口(2026-08-27,本地开发档):真形锚点(特征/许可/可变范围);主仪化仪候选定相治理(candidate 不进呈现位、主仪唯一);声音三来源与克隆授权(用途/传播必填、可撤回立即生效);ASR 经元炁透传(多模态关闭 503 不降级);前端形仪工坊三区。验收三测试全绿;旧 avatar 全量退役。图像真实生成与 TTS 合成随神识 image/tts 槽位启用接入(候选定相与授权治理框架已就位) |
| 天工阁/叩心/辰正 | M8 | 已验证 | M8 收口(2026-08-28,本地开发档):受托任务(分解/并行/级联验收,后台 Run 不占前台席位);叩心候选四约束(总开关/静默本地时区/频率/预算,无关系副作用);辰正时态感知接入镜树投影。验收五测试全绿;旧太一/洛书/感知/大周天模块与表全量退役,admin 治理面收敛为司命+百工 |
| 天目 / 元炁 | M4/M7 | 局部实现 | 元炁 ASR 已接形仪域(M7);chat 经神识 endpoint(M4);天目待接通感 |
| 镜树前端 / 记忆治理 / 百工页 | 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 快照 |
+4 -3
View File
@@ -143,10 +143,11 @@
- 验收(tests/test_m7_xingyi.py 三用例钉死):①未定相不成为正式形仪(candidate 期呈现位为空、定相后转正、主仪唯一旧主仪 retired、废弃可用);②克隆授权可撤回(缺用途被拒、有效通过、撤回后校验 403/409、预设不受授权约束);③关闭全部多模态后文字相伴完整可用(ASR 503 时镜树/缘契/百工链路照常)。
- 证据:后端 376 passed(2 个预存环境失败不变,6 个 avatar 旧用例随模块退役);前端 type-check + 203 passed(形仪工坊 4 用例);BFF 4;神识 5 包;迁移 008 净库+±1 干净。
### M8 天工阁、叩心与辰正
### M8 天工阁、叩心与辰正 — ✅ 已完成(2026-08-28)
- 交付:功能 Agent 受托任务(分解/并行/验收,无魂籍);后台 Run 并发(不占前台槽位);叩心候选(静默/频率/预算/渠道);辰正时态感知重实现;**删除旧 agent/knocking/time_sense/goal_orchestrator/planner/perception/big_cycle 模块**。
- 验收:后台任务不构成第二意识(无身份写入路径);叩心受总开关与静默约束;不回应不降低关系(无关系副作用断言)。
- 交付:天工阁域 `domains/tiangong`——backstage_tasks(委托包+验收标准+结果证据,parent 链分解受控并行,子任务全终态级联父任务待验收,按证据 accept/reject);`kernel.start_backstage_run`(kind=background_task,**不占前台席位**与前台并行;loop 后台分支绕过席位锁);叩心域 `domains/kouxin`——knocking_candidates(触发/草稿/裁决快照;**总开关/静默窗口(本地时区)/频率/预算**四约束裁决,dispatch 未过被拒 429 且快照留痕,候选默认不自动发送);辰正 `domains/zhengchen`——temporal_context() 复用 time_sense 计算核产出【时态感知】投影段(客观时间语境不附情绪臆测),接入镜树生成 prompt;**旧 goal_orchestrator/planner/perception/big_cycle/agent(knocking M2b 已删) 模块与 goals/plans/agent_roster/perception_channels/big_cycles/candidates 表退役**(迁移 009),governance 只留 GenerationPreferences,admin 只留司命/百工两枢治理面;kernel 断言放宽容纳无人物后台任务。
- 验收(tests/test_m8_tiangong.py 五用例钉死):①后台任务不构成第二意识(完成后记忆/魂籍/缘契零新增断言);②后台 Run 与前台并行(运行中前台立即启动);③分解级联(子任务全终态→父待验收);④叩心四约束(总开关关拒/静默窗口拒/频率上限拒/常态允许);⑤不回应不降低关系(候选过期后缘契维度/状态/版本零变化)。
- 证据:后端 345 passed(2 个预存环境失败不变);前端 type-check + 203 passed;BFF 4;神识 5 包;迁移 009 净库升级干净。
### M9 桌面壳与收口
-33
View File
@@ -1,33 +0,0 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { AgentRoster } from "@/components/admin/AgentRoster";
import { ErrorState } from "@/components/shared/StateViews";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { AgentRosterEntry } from "@/types/api";
export const dynamic = "force-dynamic";
export default async function AdminAgentsPage() {
let roster: AgentRosterEntry[] = [];
let error: string | null = null;
try {
roster = await serverApi.get("/admin/agents/roster");
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="天工阁枢"
description="多智能体角色在岗管理——九角色可增删改。"
maxWidth="max-w-5xl"
>
{error ? (
<ErrorState message={error} />
) : (
<AgentRoster roster={roster ?? []} />
)}
</PageContainer>
);
}
-33
View File
@@ -1,33 +0,0 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { GoalTree } from "@/components/admin/GoalTree";
import { ErrorState } from "@/components/shared/StateViews";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { GoalNode } from "@/types/api";
export const dynamic = "force-dynamic";
export default async function AdminGoalsPage() {
let goals: GoalNode[] = [];
let error: string | null = null;
try {
goals = await serverApi.get("/admin/goals");
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="太一枢"
description="目标层级树——从使命到行动的分解,支持增删改。"
maxWidth="max-w-5xl"
>
{error ? (
<ErrorState message={error} />
) : (
<GoalTree goals={goals ?? []} />
)}
</PageContainer>
);
}
@@ -1,33 +0,0 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { PerceptionConsentMatrix } from "@/components/admin/PerceptionConsentMatrix";
import { ErrorState } from "@/components/shared/StateViews";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { PerceptionChannel } from "@/types/api";
export const dynamic = "force-dynamic";
export default async function AdminPerceptionPage() {
let channels: PerceptionChannel[] = [];
let error: string | null = null;
try {
channels = await serverApi.get("/admin/perception/channels");
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="烛照枢"
description="感知通道授权——管理六类感知源的同意状态与隐私级别。"
maxWidth="max-w-5xl"
>
{error ? (
<ErrorState message={error} />
) : (
<PerceptionConsentMatrix channels={channels ?? []} />
)}
</PageContainer>
);
}
+1 -136
View File
@@ -6,11 +6,6 @@ import { VermilionAction } from "@/components/admin/VermilionAction";
import { PolicyMatrix } from "@/components/admin/PolicyMatrix";
import { AuditConsole } from "@/components/admin/AuditConsole";
import { ToolRegistry } from "@/components/admin/ToolRegistry";
import { GoalTree } from "@/components/admin/GoalTree";
import { PerceptionConsentMatrix } from "@/components/admin/PerceptionConsentMatrix";
import { WorldEntityList } from "@/components/admin/WorldEntityList";
import { PlanConsole } from "@/components/admin/PlanConsole";
import { AgentRoster } from "@/components/admin/AgentRoster";
import { McpServerPanel } from "@/components/admin/mcp/McpServerPanel";
import { ToolRuntimeTabs } from "@/components/admin/mcp/ToolRuntimeTabs";
import { serverApi } from "@/lib/api/server-client";
@@ -34,15 +29,7 @@ import type {
export const dynamic = "force-dynamic";
const VALID_PIVOTS: Pivot[] = [
"goal_orchestrator",
"safety_governor",
"perception",
"world_model",
"planner",
"tool_runtime",
"agent_society",
];
const VALID_PIVOTS: Pivot[] = ["safety_governor", "tool_runtime"];
const PIVOT_SET = new Set<string>(VALID_PIVOTS);
@@ -211,133 +198,11 @@ async function ToolRuntimeDetail() {
}
/** 太一枢:目标层级树(最高目标 → 任务,可编辑标题/优先级/状态) */
async function GoalOrchestratorDetail() {
let goals: GoalNode[] = [];
let error: string | null = null;
try {
goals = await serverApi.get<GoalNode[]>("/admin/goals");
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="太一枢"
description="目标统御 · 最高目标与任务调度"
maxWidth="max-w-3xl"
>
{error ? (
<ErrorState message={error} />
) : goals.length === 0 ? (
<EmptyState title="暂无目标" description="太一枢尚未编排任何目标。" />
) : (
<GoalTree goals={goals} />
)}
</PageContainer>
);
}
/** 烛照枢:感知通道授权矩阵(启用 / 授权 / 隐私路由) */
async function PerceptionDetail() {
let channels: PerceptionChannel[] = [];
let error: string | null = null;
try {
channels = await serverApi.get<PerceptionChannel[]>(
"/admin/perception/channels",
);
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="烛照枢"
description="感知世界 · 通道授权与隐私边界"
maxWidth="max-w-3xl"
>
{error ? (
<ErrorState message={error} />
) : (
<PerceptionConsentMatrix channels={channels} />
)}
</PageContainer>
);
}
/** 河图枢:世界实体列表(类型 / 事实-推断 / 置信度) */
async function WorldModelDetail() {
let entities: WorldEntity[] = [];
let error: string | null = null;
try {
entities = await serverApi.get<WorldEntity[]>("/admin/world/entities");
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="河图枢"
description="世界模型 · 实体与关系图式"
maxWidth="max-w-3xl"
>
{error ? (
<ErrorState message={error} />
) : entities.length === 0 ? (
<EmptyState
title="暂无世界实体"
description="河图枢尚未建模任何实体。"
/>
) : (
<WorldEntityList entities={entities} />
)}
</PageContainer>
);
}
/** 洛书枢:计划控制台(状态 / 假设 / 风险) */
async function PlannerDetail() {
let plans: PlanSummary[] = [];
let error: string | null = null;
try {
plans = await serverApi.get<PlanSummary[]>("/admin/plans");
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="洛书枢"
description="推演变化 · 规划与流程编排"
maxWidth="max-w-3xl"
>
{error ? (
<ErrorState message={error} />
) : plans.length === 0 ? (
<EmptyState title="暂无计划" description="洛书枢尚未编排任何计划。" />
) : (
<PlanConsole plans={plans} />
)}
</PageContainer>
);
}
/** 天工阁枢:九角色在岗名册(启用 / 停用) */
async function AgentSocietyDetail() {
let roster: AgentRosterEntry[] = [];
let error: string | null = null;
try {
roster = await serverApi.get<AgentRosterEntry[]>("/admin/agents/roster");
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="天工阁枢"
description="众智能协作 · 九角色分工与协作"
maxWidth="max-w-3xl"
>
{error ? <ErrorState message={error} /> : <AgentRoster roster={roster} />}
</PageContainer>
);
}
-33
View File
@@ -1,33 +0,0 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { PlanConsole } from "@/components/admin/PlanConsole";
import { ErrorState } from "@/components/shared/StateViews";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { PlanSummary } from "@/types/api";
export const dynamic = "force-dynamic";
export default async function AdminPlansPage() {
let plans: PlanSummary[] = [];
let error: string | null = null;
try {
plans = await serverApi.get("/admin/plans");
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="洛书枢"
description="计划控制台——规划治理,支持增删改。"
maxWidth="max-w-5xl"
>
{error ? (
<ErrorState message={error} />
) : (
<PlanConsole plans={plans ?? []} />
)}
</PageContainer>
);
}
-33
View File
@@ -1,33 +0,0 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { WorldEntityList } from "@/components/admin/WorldEntityList";
import { ErrorState } from "@/components/shared/StateViews";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { WorldEntity } from "@/types/api";
export const dynamic = "force-dynamic";
export default async function AdminWorldPage() {
let entities: WorldEntity[] = [];
let error: string | null = null;
try {
entities = await serverApi.get("/admin/world/entities");
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="河图枢"
description="世界实体管理——人物对世界的认知实体,支持增删改。"
maxWidth="max-w-5xl"
>
{error ? (
<ErrorState message={error} />
) : (
<WorldEntityList entities={entities ?? []} />
)}
</PageContainer>
);
}
@@ -1,201 +0,0 @@
"use client";
import { useState } from "react";
import { adminApi } from "@/lib/api";
import type { AgentRosterEntry } from "@/types/api";
import { AGENT_PRESENCE_LABEL } from "@/lib/constants/labels";
import { cn } from "@/lib/utils/cn";
/**
* 天工阁枢角色名册 —— 多智能体治理(可增删改)。
* 默认九角色(契约字典 §5.14),亦可新增自定义角色。inline toggle 在编/停用、删除。
* POST/PATCH/DELETE /admin/agents/roster。在编 jade、停用 muted;呈现态静态点(设计 §8 克制)。
*/
export function AgentRoster({
roster: initial,
}: {
roster: AgentRosterEntry[];
}) {
const [roster, setRoster] = useState(initial);
const [savingRole, setSavingRole] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [newAgent, setNewAgent] = useState<{
role: string;
name: string;
responsibility: string;
}>({
role: "",
name: "",
responsibility: "",
});
async function toggle(entry: AgentRosterEntry) {
setSavingRole(entry.role);
setError(null);
try {
const updated = await adminApi.updateAgent(entry.role, {
enabled: !entry.enabled,
});
setRoster((prev) =>
prev.map((a) =>
a.role === entry.role ? { ...a, enabled: updated.enabled } : a,
),
);
} catch (e) {
setError(e instanceof Error ? e.message : "更新失败,请重试。");
} finally {
setSavingRole(null);
}
}
async function remove(role: string) {
setSavingRole(role);
setError(null);
try {
await adminApi.deleteAgent(role);
setRoster((prev) => prev.filter((a) => a.role !== role));
} catch (e) {
setError(e instanceof Error ? e.message : "删除失败,请重试。");
} finally {
setSavingRole(null);
}
}
async function add() {
if (!newAgent.role.trim() || !newAgent.name.trim()) {
setError("角色标识与名称必填。");
return;
}
setSavingRole("__new");
setError(null);
try {
const created = await adminApi.createAgent({
role: newAgent.role.trim(),
name: newAgent.name.trim(),
responsibility: newAgent.responsibility.trim() || "自定义角色",
});
setRoster((prev) => [...prev, created]);
setNewAgent({ role: "", name: "", responsibility: "" });
setShowAdd(false);
} catch (e) {
setError(e instanceof Error ? e.message : "新增失败,请重试。");
} finally {
setSavingRole(null);
}
}
return (
<div>
{showAdd ? (
<div className="glass mb-2 space-y-2 rounded-xl px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<input
value={newAgent.role}
onChange={(e) =>
setNewAgent((s) => ({ ...s, role: e.target.value }))
}
placeholder="角色标识(如 archivist)"
className="w-40 shrink-0 rounded-lg border border-border-glass bg-glass px-2 py-1 font-mono text-xs text-fg"
/>
<input
value={newAgent.name}
onChange={(e) =>
setNewAgent((s) => ({ ...s, name: e.target.value }))
}
placeholder="中文名(如 典藏)"
className="min-w-0 flex-1 rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
/>
</div>
<input
value={newAgent.responsibility}
onChange={(e) =>
setNewAgent((s) => ({ ...s, responsibility: e.target.value }))
}
placeholder="职责(可选)"
className="w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
/>
<div className="flex gap-2">
<button
type="button"
onClick={add}
disabled={savingRole === "__new"}
className="rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
>
新增
</button>
<button
type="button"
onClick={() => setShowAdd(false)}
className="rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
>
取消
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setShowAdd(true)}
className="mb-2 rounded-lg border border-patina/40 bg-patina/10 px-3 py-1.5 text-xs font-medium text-patina transition-colors hover:bg-patina/20"
>
+ 新增角色
</button>
)}
<ul className="grid gap-2 sm:grid-cols-2">
{roster.map((a) => (
<li
key={a.role}
className={cn(
"glass rounded-xl px-4 py-3 transition-opacity",
!a.enabled && "opacity-55",
)}
>
<div className="flex items-center gap-2">
<span
className={cn(
"h-2 w-2 shrink-0 rounded-full",
a.presence === "active" ? "bg-patina" : "bg-muted/40",
)}
/>
<span className="font-serif text-sm font-medium text-fg">
{a.name}
</span>
<span className="shrink-0 text-[10px] text-muted">
{AGENT_PRESENCE_LABEL[a.presence]}
</span>
<button
type="button"
onClick={() => toggle(a)}
disabled={savingRole === a.role}
aria-label={`${a.name} 启用`}
className={cn(
"shrink-0 rounded-full px-2.5 py-0.5 text-[10px] font-medium transition-colors disabled:opacity-50",
a.enabled
? "bg-patina/20 text-patina"
: "border border-border-glass text-muted hover:text-fg",
)}
>
{a.enabled ? "在编" : "停用"}
</button>
</div>
<div className="mt-1 flex items-center gap-2 pl-4">
<p className="flex-1 text-xs text-muted">{a.responsibility}</p>
<button
type="button"
onClick={() => remove(a.role)}
disabled={savingRole === a.role}
className="shrink-0 text-[11px] text-cinnabar hover:underline disabled:opacity-50"
>
删除
</button>
</div>
</li>
))}
</ul>
{error && <p className="mt-2 px-1 text-xs text-cinnabar">{error}</p>}
</div>
);
}
-340
View File
@@ -1,340 +0,0 @@
"use client";
import { useState } from "react";
import { adminApi } from "@/lib/api";
import type { GoalLevel, GoalStatus } from "@/types/domain";
import type { GoalNode } from "@/types/api";
import {
GOAL_LEVEL_LABEL,
GOAL_STATUS_LABEL,
GOAL_TYPE_LABEL,
} from "@/lib/constants/labels";
import { cn } from "@/lib/utils/cn";
const LEVEL_ORDER: GoalLevel[] = [
"highest",
"long_term",
"session",
"task",
"action",
];
/** 新增可选层级(不含 highest 使命级——使命不可手动创建) */
const LEVEL_OPTIONS: GoalLevel[] = ["long_term", "session", "task", "action"];
const STATUS_OPTIONS: GoalStatus[] = [
"pending",
"active",
"completed",
"blocked",
"cancelled",
];
const STATUS_DOT: Record<GoalStatus, string> = {
pending: "bg-muted/50",
active: "bg-patina",
completed: "bg-gold",
blocked: "bg-cinnabar",
cancelled: "bg-muted/30",
};
/**
* 太一枢目标层级树 —— 目标统御治理视图(可增删改)。
* 按 level 分层;新增目标(非 highest)、编辑标题/优先级、select 改状态、删除。
* highest 为使命级:状态锁定、不可删(契约字典 §5.11)。状态圆点静态着色(设计 §8 克制)。
*/
export function GoalTree({ goals: initial }: { goals: GoalNode[] }) {
const [goals, setGoals] = useState(initial);
const [editingId, setEditingId] = useState<string | null>(null);
const [draft, setDraft] = useState<{ title: string; priority: number }>({
title: "",
priority: 0,
});
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [newGoal, setNewGoal] = useState<{ level: GoalLevel; title: string }>({
level: "task",
title: "",
});
function startEdit(g: GoalNode) {
setEditingId(g.id);
setDraft({ title: g.title, priority: g.priority });
setError(null);
}
async function changeStatus(g: GoalNode, next: GoalStatus) {
setBusyId(g.id);
setError(null);
try {
const updated = await adminApi.updateGoal(g.id, { status: next });
setGoals((prev) =>
prev.map((x) => (x.id === g.id ? { ...x, status: updated.status } : x)),
);
} catch (e) {
setError(e instanceof Error ? e.message : "更新失败,请重试。");
} finally {
setBusyId(null);
}
}
async function save(id: string) {
setBusyId(id);
setError(null);
try {
const updated = await adminApi.updateGoal(id, {
title: draft.title,
priority: draft.priority,
});
setGoals((prev) =>
prev.map((x) =>
x.id === id
? { ...x, title: updated.title, priority: updated.priority }
: x,
),
);
setEditingId(null);
} catch (e) {
setError(e instanceof Error ? e.message : "保存失败,请重试。");
} finally {
setBusyId(null);
}
}
async function remove(g: GoalNode) {
setBusyId(g.id);
setError(null);
try {
await adminApi.deleteGoal(g.id);
setGoals((prev) => prev.filter((x) => x.id !== g.id));
} catch (e) {
setError(e instanceof Error ? e.message : "删除失败,请重试。");
} finally {
setBusyId(null);
}
}
async function add() {
if (!newGoal.title.trim()) {
setError("请填写目标标题。");
return;
}
setBusyId("__new");
setError(null);
try {
const created = await adminApi.createGoal({
title: newGoal.title.trim(),
level: newGoal.level,
});
setGoals((prev) => [...prev, created]);
setNewGoal({ level: "task", title: "" });
setShowAdd(false);
} catch (e) {
setError(e instanceof Error ? e.message : "新增失败,请重试。");
} finally {
setBusyId(null);
}
}
return (
<div className="space-y-5">
{showAdd ? (
<div className="glass space-y-2 rounded-xl px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<select
value={newGoal.level}
onChange={(e) =>
setNewGoal((s) => ({
...s,
level: e.target.value as GoalLevel,
}))
}
aria-label="目标层级"
className="w-24 shrink-0 rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg backdrop-blur"
>
{LEVEL_OPTIONS.map((l) => (
<option key={l} value={l}>
{GOAL_LEVEL_LABEL[l]}
</option>
))}
</select>
<input
value={newGoal.title}
onChange={(e) =>
setNewGoal((s) => ({ ...s, title: e.target.value }))
}
placeholder="目标标题…"
className="min-w-0 flex-1 rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
/>
<button
type="button"
onClick={add}
disabled={busyId === "__new"}
className="shrink-0 rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
>
新增
</button>
<button
type="button"
onClick={() => setShowAdd(false)}
className="shrink-0 rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
>
取消
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setShowAdd(true)}
className="rounded-lg border border-patina/40 bg-patina/10 px-3 py-1.5 text-xs font-medium text-patina transition-colors hover:bg-patina/20"
>
+ 新增目标
</button>
)}
{LEVEL_ORDER.map((level) => {
const items = goals.filter((g) => g.level === level);
if (items.length === 0) return null;
return (
<section key={level}>
<div className="mb-2 flex items-center gap-2">
<span className="font-serif text-xs font-medium text-gold">
{GOAL_LEVEL_LABEL[level]}
</span>
<span className="h-px flex-1 bg-gold/20" />
</div>
<ul className="space-y-2">
{items.map((g) => (
<li key={g.id} className="glass rounded-xl px-4 py-2.5">
{editingId === g.id ? (
<div className="space-y-2">
<input
value={draft.title}
onChange={(e) =>
setDraft((d) => ({ ...d, title: e.target.value }))
}
className="w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
/>
<div className="flex items-center gap-2">
<label className="text-[10px] text-muted">优先级</label>
<input
type="number"
value={draft.priority}
onChange={(e) =>
setDraft((d) => ({
...d,
priority: Number(e.target.value),
}))
}
className="w-20 rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg"
/>
<div className="ml-auto flex gap-2">
<button
type="button"
onClick={() => save(g.id)}
disabled={busyId === g.id}
className="rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
>
保存
</button>
<button
type="button"
onClick={() => setEditingId(null)}
className="rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
>
取消
</button>
</div>
</div>
</div>
) : (
<>
<div className="flex items-center gap-2">
<span
className={cn(
"h-2 w-2 shrink-0 rounded-full",
STATUS_DOT[g.status],
)}
/>
<span className="font-serif text-sm font-medium text-fg">
{g.title}
</span>
<span className="ml-auto shrink-0 font-mono text-[10px] text-muted">
P{g.priority}
</span>
<select
value={g.status}
onChange={(e) =>
changeStatus(g, e.target.value as GoalStatus)
}
disabled={busyId === g.id || g.level === "highest"}
aria-label={`${g.title} 状态`}
className="w-24 shrink-0 rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg backdrop-blur disabled:opacity-50"
>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{GOAL_STATUS_LABEL[s]}
</option>
))}
</select>
</div>
{/* 进度条 */}
<div className="mt-2 flex items-center gap-2 pl-4">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted/20">
<div
className="h-full rounded-full bg-patina transition-all"
style={{
width: `${Math.round(g.progress * 100)}%`,
}}
/>
</div>
<span className="font-mono text-[10px] text-muted">
{Math.round(g.progress * 100)}%
</span>
</div>
{/* 类型 */}
{g.type && (
<span className="mt-1 block pl-4 text-[10px] text-muted">
类型:{GOAL_TYPE_LABEL[g.type] ?? g.type}
</span>
)}
<p className="mt-1 pl-4 text-xs text-muted">
{g.description}
</p>
{g.constraints.length > 0 && (
<p className="mt-1 pl-4 text-[10px] text-muted">
约束:{g.constraints.join(" · ")}
</p>
)}
<div className="mt-1.5 flex gap-3 pl-4">
<button
type="button"
onClick={() => startEdit(g)}
className="text-[11px] text-patina hover:underline"
>
编辑标题/优先级
</button>
{g.level !== "highest" && (
<button
type="button"
onClick={() => remove(g)}
disabled={busyId === g.id}
className="text-[11px] text-cinnabar hover:underline disabled:opacity-50"
>
删除
</button>
)}
</div>
</>
)}
</li>
))}
</ul>
</section>
);
})}
{error && <p className="text-xs text-cinnabar">{error}</p>}
</div>
);
}
@@ -1,259 +0,0 @@
"use client";
import { useState } from "react";
import { adminApi } from "@/lib/api";
import type { ConsentStatus, PrivacyLevel } from "@/types/domain";
import type { PerceptionChannel } from "@/types/api";
import { CONSENT_STATUS_LABEL } from "@/lib/constants/labels";
import { cn } from "@/lib/utils/cn";
const CONSENT_OPTIONS: ConsentStatus[] = [
"unknown",
"granted",
"restricted",
"revoked",
];
const PRIVACY_OPTIONS: PrivacyLevel[] = ["normal", "sensitive", "local_only"];
/** 隐私路由中文呈现(privacy_level,契约字典 §5.4) */
const PRIVACY_LABEL: Record<PrivacyLevel, string> = {
normal: "普通",
sensitive: "敏感",
local_only: "仅本地",
};
const CONSENT_DOT: Record<ConsentStatus, string> = {
unknown: "bg-muted/40",
granted: "bg-patina",
restricted: "bg-gold",
revoked: "bg-cinnabar",
};
/**
* 烛照枢感知通道授权矩阵 —— 隐私边界治理(可增删改)。
* 默认六类通道,亦可新增自定义感知源。每通道 inline 编辑 enabled / consent / privacy、删除。
* POST/PATCH/DELETE /admin/perception/channels。授权 jade、撤销 cinnabar。
*/
export function PerceptionConsentMatrix({
channels: initial,
}: {
channels: PerceptionChannel[];
}) {
const [channels, setChannels] = useState(initial);
const [savingCh, setSavingCh] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [newCh, setNewCh] = useState<{
channel: string;
name: string;
description: string;
}>({
channel: "",
name: "",
description: "",
});
async function patch(
channel: string,
body: Partial<{
enabled: boolean;
consent_status: ConsentStatus;
privacy_level: PrivacyLevel;
}>,
) {
setSavingCh(channel);
setError(null);
try {
const updated = await adminApi.updatePerceptionChannel(channel, body);
setChannels((prev) =>
prev.map((c) => (c.channel === channel ? { ...c, ...updated } : c)),
);
} catch (e) {
setError(e instanceof Error ? e.message : "更新失败,请重试。");
} finally {
setSavingCh(null);
}
}
async function remove(channel: string) {
setSavingCh(channel);
setError(null);
try {
await adminApi.deletePerceptionChannel(channel);
setChannels((prev) => prev.filter((c) => c.channel !== channel));
} catch (e) {
setError(e instanceof Error ? e.message : "删除失败,请重试。");
} finally {
setSavingCh(null);
}
}
async function add() {
if (!newCh.channel.trim() || !newCh.name.trim()) {
setError("通道标识与名称必填。");
return;
}
setSavingCh("__new");
setError(null);
try {
const created = await adminApi.createPerceptionChannel({
channel: newCh.channel.trim(),
name: newCh.name.trim(),
description: newCh.description.trim() || "自定义感知源",
});
setChannels((prev) => [...prev, created]);
setNewCh({ channel: "", name: "", description: "" });
setShowAdd(false);
} catch (e) {
setError(e instanceof Error ? e.message : "新增失败,请重试。");
} finally {
setSavingCh(null);
}
}
return (
<div>
{showAdd ? (
<div className="glass mb-2 space-y-2 rounded-xl px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<input
value={newCh.channel}
onChange={(e) =>
setNewCh((s) => ({ ...s, channel: e.target.value }))
}
placeholder="通道标识(如 sensor_gps)"
className="w-40 shrink-0 rounded-lg border border-border-glass bg-glass px-2 py-1 font-mono text-xs text-fg"
/>
<input
value={newCh.name}
onChange={(e) =>
setNewCh((s) => ({ ...s, name: e.target.value }))
}
placeholder="中文名(如 定位)"
className="min-w-0 flex-1 rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
/>
</div>
<input
value={newCh.description}
onChange={(e) =>
setNewCh((s) => ({ ...s, description: e.target.value }))
}
placeholder="描述(可选)"
className="w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
/>
<div className="flex gap-2">
<button
type="button"
onClick={add}
disabled={savingCh === "__new"}
className="rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
>
新增
</button>
<button
type="button"
onClick={() => setShowAdd(false)}
className="rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
>
取消
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setShowAdd(true)}
className="mb-2 rounded-lg border border-patina/40 bg-patina/10 px-3 py-1.5 text-xs font-medium text-patina transition-colors hover:bg-patina/20"
>
+ 新增感知通道
</button>
)}
<div className="glass overflow-hidden rounded-2xl">
<ul className="divide-y divide-border-glass">
{channels.map((c) => (
<li key={c.channel} className="px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => patch(c.channel, { enabled: !c.enabled })}
disabled={savingCh === c.channel}
aria-label={`${c.name} 启用开关`}
className={cn(
"relative h-4 w-7 shrink-0 rounded-full transition-colors disabled:opacity-50",
c.enabled ? "bg-patina/60" : "bg-muted/30",
)}
>
<span
className={cn(
"absolute top-0.5 h-3 w-3 rounded-full bg-glass-strong transition-all",
c.enabled ? "left-3.5" : "left-0.5",
)}
/>
</button>
<span
className={cn(
"h-2 w-2 shrink-0 rounded-full",
CONSENT_DOT[c.consent_status],
)}
/>
<span className="font-serif text-sm font-medium text-fg">
{c.name}
</span>
<span className="ml-auto flex shrink-0 items-center gap-1.5">
<select
value={c.consent_status}
onChange={(e) =>
patch(c.channel, {
consent_status: e.target.value as ConsentStatus,
})
}
disabled={savingCh === c.channel}
aria-label={`${c.name} 授权状态`}
className="w-20 rounded-lg border border-border-glass bg-glass px-1.5 py-1 text-[11px] text-fg backdrop-blur disabled:opacity-50"
>
{CONSENT_OPTIONS.map((cs) => (
<option key={cs} value={cs}>
{CONSENT_STATUS_LABEL[cs]}
</option>
))}
</select>
<select
value={c.privacy_level}
onChange={(e) =>
patch(c.channel, {
privacy_level: e.target.value as PrivacyLevel,
})
}
disabled={savingCh === c.channel}
aria-label={`${c.name} 隐私路由`}
className="w-20 rounded-lg border border-border-glass bg-glass px-1.5 py-1 text-[11px] text-fg backdrop-blur disabled:opacity-50"
>
{PRIVACY_OPTIONS.map((pl) => (
<option key={pl} value={pl}>
{PRIVACY_LABEL[pl]}
</option>
))}
</select>
</span>
</div>
<div className="mt-1 flex items-center gap-2 pl-8">
<p className="flex-1 text-xs text-muted">{c.description}</p>
<button
type="button"
onClick={() => remove(c.channel)}
disabled={savingCh === c.channel}
className="shrink-0 text-[11px] text-cinnabar hover:underline disabled:opacity-50"
>
删除
</button>
</div>
</li>
))}
</ul>
</div>
{error && <p className="mt-2 px-1 text-xs text-cinnabar">{error}</p>}
</div>
);
}
@@ -1,279 +0,0 @@
"use client";
import { useState } from "react";
import { adminApi } from "@/lib/api";
import type { PlanStatus } from "@/types/domain";
import type { PlanSummary } from "@/types/api";
import { PLAN_STATUS_LABEL } from "@/lib/constants/labels";
import { cn } from "@/lib/utils/cn";
const STATUS_OPTIONS: PlanStatus[] = [
"draft",
"approved",
"running",
"completed",
"failed",
];
const STATUS_DOT: Record<PlanStatus, string> = {
draft: "bg-gold",
approved: "bg-patina",
running: "bg-patina",
completed: "bg-muted/50",
failed: "bg-cinnabar",
};
/**
* 洛书枢计划控制台 —— 规划治理(可增删改)。
* 新增计划、inline select 改状态、展开编辑假设 / 风险、删除。
* PATCH /admin/plans/{id};假设与风险以换行分隔编辑,保存时拆为数组;风险以 cinnabar 提示。
*/
export function PlanConsole({ plans: initial }: { plans: PlanSummary[] }) {
const [plans, setPlans] = useState(initial);
const [editingId, setEditingId] = useState<string | null>(null);
const [draft, setDraft] = useState<{ assumptions: string; risks: string }>({
assumptions: "",
risks: "",
});
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [newPlan, setNewPlan] = useState("");
function startEdit(p: PlanSummary) {
setEditingId(p.id);
setDraft({
assumptions: p.assumptions.join("\n"),
risks: p.risks.join("\n"),
});
setError(null);
}
async function changeStatus(p: PlanSummary, next: PlanStatus) {
setBusyId(p.id);
setError(null);
try {
const updated = await adminApi.updatePlan(p.id, { status: next });
setPlans((prev) =>
prev.map((x) => (x.id === p.id ? { ...x, status: updated.status } : x)),
);
} catch (e) {
setError(e instanceof Error ? e.message : "更新失败,请重试。");
} finally {
setBusyId(null);
}
}
async function save(id: string) {
setBusyId(id);
setError(null);
try {
const assumptions = draft.assumptions
.split("\n")
.map((s) => s.trim())
.filter(Boolean);
const risks = draft.risks
.split("\n")
.map((s) => s.trim())
.filter(Boolean);
const updated = await adminApi.updatePlan(id, { assumptions, risks });
setPlans((prev) =>
prev.map((x) =>
x.id === id
? { ...x, assumptions: updated.assumptions, risks: updated.risks }
: x,
),
);
setEditingId(null);
} catch (e) {
setError(e instanceof Error ? e.message : "保存失败,请重试。");
} finally {
setBusyId(null);
}
}
async function remove(p: PlanSummary) {
setBusyId(p.id);
setError(null);
try {
await adminApi.deletePlan(p.id);
setPlans((prev) => prev.filter((x) => x.id !== p.id));
} catch (e) {
setError(e instanceof Error ? e.message : "删除失败,请重试。");
} finally {
setBusyId(null);
}
}
async function add() {
if (!newPlan.trim()) {
setError("请填写计划标题。");
return;
}
setBusyId("__new");
setError(null);
try {
const created = await adminApi.createPlan({ title: newPlan.trim() });
setPlans((prev) => [created, ...prev]);
setNewPlan("");
setShowAdd(false);
} catch (e) {
setError(e instanceof Error ? e.message : "新增失败,请重试。");
} finally {
setBusyId(null);
}
}
return (
<>
{showAdd ? (
<div className="glass mb-2 space-y-2 rounded-xl px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<input
value={newPlan}
onChange={(e) => setNewPlan(e.target.value)}
placeholder="计划标题…"
className="min-w-0 flex-1 rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
/>
<button
type="button"
onClick={add}
disabled={busyId === "__new"}
className="shrink-0 rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
>
新增
</button>
<button
type="button"
onClick={() => setShowAdd(false)}
className="shrink-0 rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
>
取消
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setShowAdd(true)}
className="mb-2 rounded-lg border border-patina/40 bg-patina/10 px-3 py-1.5 text-xs font-medium text-patina transition-colors hover:bg-patina/20"
>
+ 新增计划
</button>
)}
<ul className="space-y-2">
{plans.map((p) => (
<li key={p.id} className="glass rounded-xl px-4 py-3">
<div className="flex items-center gap-2">
<span
className={cn(
"h-2 w-2 shrink-0 rounded-full",
STATUS_DOT[p.status],
)}
/>
<span className="truncate font-serif text-sm font-medium text-fg">
{p.title}
</span>
<span className="ml-auto shrink-0 font-mono text-[10px] text-muted">
{p.completed_steps}/{p.step_count}
</span>
<select
value={p.status}
onChange={(e) => changeStatus(p, e.target.value as PlanStatus)}
disabled={busyId === p.id}
aria-label={`${p.title} 状态`}
className="w-20 shrink-0 rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg backdrop-blur disabled:opacity-50"
>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{PLAN_STATUS_LABEL[s]}
</option>
))}
</select>
</div>
{editingId === p.id ? (
<div className="mt-2 space-y-2">
<div>
<label className="text-[10px] text-muted">
假设(每行一条)
</label>
<textarea
value={draft.assumptions}
onChange={(e) =>
setDraft((d) => ({ ...d, assumptions: e.target.value }))
}
rows={2}
className="w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg"
/>
</div>
<div>
<label className="text-[10px] text-muted">
风险(每行一条)
</label>
<textarea
value={draft.risks}
onChange={(e) =>
setDraft((d) => ({ ...d, risks: e.target.value }))
}
rows={2}
className="w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg"
/>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => save(p.id)}
disabled={busyId === p.id}
className="rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
>
保存
</button>
<button
type="button"
onClick={() => setEditingId(null)}
className="rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
>
取消
</button>
</div>
</div>
) : (
<>
<div className="mt-1 space-y-0.5 pl-4 text-[10px] text-muted">
{p.assumptions.length > 0 && (
<p>假设:{p.assumptions.join(";")}</p>
)}
{p.risks.length > 0 && (
<p className="text-cinnabar/80">
风险:{p.risks.join(";")}
</p>
)}
</div>
<div className="mt-1.5 flex gap-3 pl-4">
<button
type="button"
onClick={() => startEdit(p)}
className="text-[11px] text-patina hover:underline"
>
编辑假设/风险
</button>
<button
type="button"
onClick={() => remove(p)}
disabled={busyId === p.id}
className="text-[11px] text-cinnabar hover:underline disabled:opacity-50"
>
删除
</button>
</div>
</>
)}
</li>
))}
</ul>
{error && <p className="mt-2 text-xs text-cinnabar">{error}</p>}
</>
);
}
-29
View File
@@ -1,29 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import type { PivotHealthSummary } from "@/types/api";
import type { Pivot } from "@/types/domain";
import { Astrolabe } from "@/components/ui/Astrolabe";
/**
* 九枢星盘 · 司命台适配层。
* 渲染已抽取到 components/ui/Astrolabe(数据驱动、路由无关);
* 此处仅负责把枢点点击接到司命台枢详情路由。签名与旧实现一致,admin 页零改动。
*/
export function StarMap({
pivots,
className,
}: {
pivots: PivotHealthSummary[];
className?: string;
}) {
const router = useRouter();
return (
<Astrolabe
pivots={pivots}
className={className}
onSelectPivot={(pivot: Pivot) => router.push(`/admin/pivots/${pivot}`)}
/>
);
}
@@ -1,322 +0,0 @@
"use client";
import { useState } from "react";
import { adminApi } from "@/lib/api";
import type { WorldEntityType, WorldStateType } from "@/types/domain";
import type { WorldEntity } from "@/types/api";
import {
WORLD_ENTITY_TYPE_LABEL,
WORLD_STATE_TYPE_LABEL,
} from "@/lib/constants/labels";
import { cn } from "@/lib/utils/cn";
const STATE_OPTIONS: WorldStateType[] = [
"fact",
"observation",
"inference",
"hypothesis",
"stale",
];
/** 新增可选类型(不含 self——自身实体不可手动创建) */
const TYPE_OPTIONS: WorldEntityType[] = [
"person",
"object",
"place",
"device",
"project",
"task",
"concept",
];
const STATE_DOT: Record<WorldStateType, string> = {
fact: "bg-patina",
observation: "bg-patina",
inference: "bg-gold",
hypothesis: "bg-gold",
stale: "bg-cinnabar",
};
/**
* 河图枢世界实体列表 —— 世界模型治理(可增删改)。
* 区分事实与推断(state_type 着色);新增实体(非 self)、编辑名称/置信度、
* select 改状态类型、删除。推断与假设召回时降权,不得伪装成事实(契约字典 §11.2)。
*/
export function WorldEntityList({
entities: initial,
}: {
entities: WorldEntity[];
}) {
const [entities, setEntities] = useState(initial);
const [editingId, setEditingId] = useState<string | null>(null);
const [draft, setDraft] = useState<{ name: string; confidence: number }>({
name: "",
confidence: 0,
});
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [newEntity, setNewEntity] = useState<{
type: WorldEntityType;
name: string;
}>({
type: "concept",
name: "",
});
function startEdit(e: WorldEntity) {
setEditingId(e.id);
setDraft({ name: e.name, confidence: e.confidence });
setError(null);
}
async function changeState(e: WorldEntity, next: WorldStateType) {
setBusyId(e.id);
setError(null);
try {
const updated = await adminApi.updateWorldEntity(e.id, {
state_type: next,
});
setEntities((prev) =>
prev.map((x) =>
x.id === e.id ? { ...x, state_type: updated.state_type } : x,
),
);
} catch (err) {
setError(err instanceof Error ? err.message : "更新失败,请重试。");
} finally {
setBusyId(null);
}
}
async function save(id: string) {
setBusyId(id);
setError(null);
try {
const updated = await adminApi.updateWorldEntity(id, {
name: draft.name,
confidence: draft.confidence,
});
setEntities((prev) =>
prev.map((x) =>
x.id === id
? { ...x, name: updated.name, confidence: updated.confidence }
: x,
),
);
setEditingId(null);
} catch (err) {
setError(err instanceof Error ? err.message : "保存失败,请重试。");
} finally {
setBusyId(null);
}
}
async function remove(e: WorldEntity) {
setBusyId(e.id);
setError(null);
try {
await adminApi.deleteWorldEntity(e.id);
setEntities((prev) => prev.filter((x) => x.id !== e.id));
} catch (err) {
setError(err instanceof Error ? err.message : "删除失败,请重试。");
} finally {
setBusyId(null);
}
}
async function add() {
if (!newEntity.name.trim()) {
setError("请填写实体名称。");
return;
}
setBusyId("__new");
setError(null);
try {
const created = await adminApi.createWorldEntity({
type: newEntity.type,
name: newEntity.name.trim(),
});
setEntities((prev) => [...prev, created]);
setNewEntity({ type: "concept", name: "" });
setShowAdd(false);
} catch (e) {
setError(e instanceof Error ? e.message : "新增失败,请重试。");
} finally {
setBusyId(null);
}
}
return (
<>
{showAdd ? (
<div className="glass mb-2 space-y-2 rounded-xl px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<select
value={newEntity.type}
onChange={(e) =>
setNewEntity((s) => ({
...s,
type: e.target.value as WorldEntityType,
}))
}
aria-label="实体类型"
className="w-24 shrink-0 rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg backdrop-blur"
>
{TYPE_OPTIONS.map((t) => (
<option key={t} value={t}>
{WORLD_ENTITY_TYPE_LABEL[t]}
</option>
))}
</select>
<input
value={newEntity.name}
onChange={(e) =>
setNewEntity((s) => ({ ...s, name: e.target.value }))
}
placeholder="实体名称…"
className="min-w-0 flex-1 rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
/>
<button
type="button"
onClick={add}
disabled={busyId === "__new"}
className="shrink-0 rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
>
新增
</button>
<button
type="button"
onClick={() => setShowAdd(false)}
className="shrink-0 rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
>
取消
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setShowAdd(true)}
className="mb-2 rounded-lg border border-patina/40 bg-patina/10 px-3 py-1.5 text-xs font-medium text-patina transition-colors hover:bg-patina/20"
>
+ 新增世界实体
</button>
)}
<ul className="space-y-2">
{entities.map((e) => (
<li key={e.id} className="glass rounded-xl px-4 py-2.5">
{editingId === e.id ? (
<div className="space-y-2">
<input
value={draft.name}
onChange={(ev) =>
setDraft((d) => ({ ...d, name: ev.target.value }))
}
className="w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
/>
<div className="flex items-center gap-2">
<label className="text-[10px] text-muted">置信度</label>
<input
type="number"
min={0}
max={1}
step={0.05}
value={draft.confidence}
onChange={(ev) =>
setDraft((d) => ({
...d,
confidence: Number(ev.target.value),
}))
}
className="w-20 rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg"
/>
<div className="ml-auto flex gap-2">
<button
type="button"
onClick={() => save(e.id)}
disabled={busyId === e.id}
className="rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
>
保存
</button>
<button
type="button"
onClick={() => setEditingId(null)}
className="rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
>
取消
</button>
</div>
</div>
</div>
) : (
<>
<div className="flex items-center gap-2">
<span
className={cn(
"h-2 w-2 shrink-0 rounded-full",
STATE_DOT[e.state_type],
)}
/>
<span className="rounded bg-gold/15 px-1.5 py-0.5 text-[10px] text-gold">
{WORLD_ENTITY_TYPE_LABEL[e.type]}
</span>
<span className="truncate font-serif text-sm font-medium text-fg">
{e.name}
</span>
<span className="ml-auto shrink-0 font-mono text-[10px] text-muted">
{e.confidence.toFixed(2)}
</span>
<select
value={e.state_type}
onChange={(ev) =>
changeState(e, ev.target.value as WorldStateType)
}
disabled={busyId === e.id}
aria-label={`${e.name} 状态类型`}
className="w-20 shrink-0 rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg backdrop-blur disabled:opacity-50"
>
{STATE_OPTIONS.map((s) => (
<option key={s} value={s}>
{WORLD_STATE_TYPE_LABEL[s]}
</option>
))}
</select>
</div>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 pl-4 text-[10px] text-muted">
{Object.entries(e.attributes).map(([k, v]) => (
<span key={k}>
{k}:{v}
</span>
))}
</div>
<div className="mt-1.5 flex gap-3 pl-4">
<button
type="button"
onClick={() => startEdit(e)}
className="text-[11px] text-patina hover:underline"
>
编辑名称/置信度
</button>
{e.type !== "self" && (
<button
type="button"
onClick={() => remove(e)}
disabled={busyId === e.id}
className="text-[11px] text-cinnabar hover:underline disabled:opacity-50"
>
删除
</button>
)}
</div>
</>
)}
</li>
))}
</ul>
{error && <p className="mt-2 text-xs text-cinnabar">{error}</p>}
</>
);
}