From c63b75152aee6a2793a30badccbb08b53604e324 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=AD=86?= <2685475410@qq.com> Date: Fri, 28 Aug 2026 09:13:39 +0800 Subject: [PATCH] =?UTF-8?q?M8=EF=BC=9A=E5=A4=A9=E5=B7=A5=E9=98=81=E3=80=81?= =?UTF-8?q?=E5=8F=A9=E5=BF=83=E4=B8=8E=E8=BE=B0=E6=AD=A3=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=8F=97=E6=89=98=E4=BB=BB=E5=8A=A1=E5=90=8E=E5=8F=B0=E5=B9=B6?= =?UTF-8?q?=E8=A1=8C=E3=80=81=E5=8F=A9=E5=BF=83=E5=9B=9B=E7=BA=A6=E6=9D=9F?= =?UTF-8?q?=E3=80=81=E6=97=B6=E6=80=81=E6=84=9F=E7=9F=A5=E6=8A=95=E5=BD=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 天工阁域 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 净库干净 --- ...候选与约束_退役旧目标计划感知大周天模块.py | 87 ++++ backend/app/api/router.py | 6 +- backend/app/api/v1/endpoints/admin.py | 119 +---- backend/app/api/v1/endpoints/goals.py | 21 - backend/app/api/v1/endpoints/imports.py | 119 ----- backend/app/api/v1/endpoints/kouxin.py | 5 + backend/app/api/v1/endpoints/tiangong.py | 5 + backend/app/api/v1/endpoints/tools.py | 4 +- backend/app/application/planning/__init__.py | 23 - backend/app/core/config/runtime_config.py | 8 + backend/app/db/governance_seed.py | 276 +---------- backend/app/domains/kouxin/__init__.py | 5 + backend/app/domains/kouxin/api.py | 63 +++ backend/app/domains/kouxin/models.py | 40 ++ backend/app/domains/kouxin/service.py | 214 +++++++++ backend/app/domains/mirror_tree/service.py | 4 + backend/app/domains/tiangong/__init__.py | 5 + backend/app/domains/tiangong/api.py | 68 +++ backend/app/domains/tiangong/models.py | 47 ++ backend/app/domains/tiangong/service.py | 211 ++++++++ backend/app/domains/zhengchen/__init__.py | 43 ++ backend/app/kernel/__init__.py | 45 ++ backend/app/kernel/loop.py | 25 +- backend/app/models/__init__.py | 21 +- backend/app/models/big_cycle.py | 38 -- backend/app/models/candidates.py | 67 --- backend/app/models/governance.py | 110 +---- backend/app/modules/big_cycle/__init__.py | 1 - backend/app/modules/big_cycle/extract.py | 114 ----- backend/app/modules/big_cycle/prompts.py | 78 --- .../app/modules/goal_orchestrator/__init__.py | 69 --- .../app/modules/goal_orchestrator/extract.py | 25 - backend/app/modules/perception/__init__.py | 1 - .../app/modules/perception/vision/__init__.py | 1 - backend/app/modules/perception/vision/ocr.py | 93 ---- backend/app/modules/planner/__init__.py | 166 ------- backend/app/repositories/big_cycle.py | 50 -- backend/app/repositories/candidates.py | 70 --- backend/app/repositories/governance.py | 150 ------ backend/app/schemas/candidates.py | 50 -- backend/app/services/admin.py | 100 ++-- backend/app/services/goal_persist.py | 92 ---- backend/app/services/governance.py | 453 ------------------ backend/app/services/imports.py | 168 ------- backend/app/services/runtime.py | 24 + backend/app/services/serializers.py | 107 +---- backend/tests/api/test_goals.py | 66 --- .../application/planning/test_planning.py | 135 ------ backend/tests/db/test_governance_seed.py | 53 -- backend/tests/models/test_governance.py | 21 - .../goal_orchestrator/test_goal_extract.py | 35 -- .../repositories/test_governance_repo.py | 62 --- .../tests/services/test_governance_service.py | 126 ----- backend/tests/test_m8_tiangong.py | 226 +++++++++ docs/operations/capability-matrix.md | 1 + docs/operations/rewrite-plan.md | 7 +- frontend/src/app/admin/agents/page.tsx | 33 -- frontend/src/app/admin/goals/page.tsx | 33 -- frontend/src/app/admin/perception/page.tsx | 33 -- .../src/app/admin/pivots/[pivot]/page.tsx | 137 +----- frontend/src/app/admin/plans/page.tsx | 33 -- frontend/src/app/admin/world/page.tsx | 33 -- frontend/src/components/admin/AgentRoster.tsx | 201 -------- frontend/src/components/admin/GoalTree.tsx | 340 ------------- .../admin/PerceptionConsentMatrix.tsx | 259 ---------- frontend/src/components/admin/PlanConsole.tsx | 279 ----------- frontend/src/components/admin/StarMap.tsx | 29 -- .../src/components/admin/WorldEntityList.tsx | 322 ------------- 68 files changed, 1205 insertions(+), 4750 deletions(-) create mode 100644 backend/alembic/versions/009_天工阁_受托任务_叩心候选与约束_退役旧目标计划感知大周天模块.py delete mode 100644 backend/app/api/v1/endpoints/goals.py create mode 100644 backend/app/api/v1/endpoints/kouxin.py create mode 100644 backend/app/api/v1/endpoints/tiangong.py delete mode 100644 backend/app/application/planning/__init__.py create mode 100644 backend/app/domains/kouxin/__init__.py create mode 100644 backend/app/domains/kouxin/api.py create mode 100644 backend/app/domains/kouxin/models.py create mode 100644 backend/app/domains/kouxin/service.py create mode 100644 backend/app/domains/tiangong/__init__.py create mode 100644 backend/app/domains/tiangong/api.py create mode 100644 backend/app/domains/tiangong/models.py create mode 100644 backend/app/domains/tiangong/service.py create mode 100644 backend/app/domains/zhengchen/__init__.py delete mode 100644 backend/app/models/big_cycle.py delete mode 100644 backend/app/models/candidates.py delete mode 100644 backend/app/modules/big_cycle/__init__.py delete mode 100644 backend/app/modules/big_cycle/extract.py delete mode 100644 backend/app/modules/big_cycle/prompts.py delete mode 100644 backend/app/modules/goal_orchestrator/__init__.py delete mode 100644 backend/app/modules/goal_orchestrator/extract.py delete mode 100644 backend/app/modules/perception/__init__.py delete mode 100644 backend/app/modules/perception/vision/__init__.py delete mode 100644 backend/app/modules/perception/vision/ocr.py delete mode 100644 backend/app/modules/planner/__init__.py delete mode 100644 backend/app/repositories/big_cycle.py delete mode 100644 backend/app/repositories/candidates.py delete mode 100644 backend/app/repositories/governance.py delete mode 100644 backend/app/schemas/candidates.py delete mode 100644 backend/app/services/goal_persist.py delete mode 100644 backend/app/services/governance.py delete mode 100644 backend/tests/api/test_goals.py delete mode 100644 backend/tests/application/planning/test_planning.py delete mode 100644 backend/tests/db/test_governance_seed.py delete mode 100644 backend/tests/models/test_governance.py delete mode 100644 backend/tests/modules/goal_orchestrator/test_goal_extract.py delete mode 100644 backend/tests/repositories/test_governance_repo.py delete mode 100644 backend/tests/services/test_governance_service.py create mode 100644 backend/tests/test_m8_tiangong.py delete mode 100644 frontend/src/app/admin/agents/page.tsx delete mode 100644 frontend/src/app/admin/goals/page.tsx delete mode 100644 frontend/src/app/admin/perception/page.tsx delete mode 100644 frontend/src/app/admin/plans/page.tsx delete mode 100644 frontend/src/app/admin/world/page.tsx delete mode 100644 frontend/src/components/admin/AgentRoster.tsx delete mode 100644 frontend/src/components/admin/GoalTree.tsx delete mode 100644 frontend/src/components/admin/PerceptionConsentMatrix.tsx delete mode 100644 frontend/src/components/admin/PlanConsole.tsx delete mode 100644 frontend/src/components/admin/StarMap.tsx delete mode 100644 frontend/src/components/admin/WorldEntityList.tsx diff --git a/backend/alembic/versions/009_天工阁_受托任务_叩心候选与约束_退役旧目标计划感知大周天模块.py b/backend/alembic/versions/009_天工阁_受托任务_叩心候选与约束_退役旧目标计划感知大周天模块.py new file mode 100644 index 0000000..24d03db --- /dev/null +++ b/backend/alembic/versions/009_天工阁_受托任务_叩心候选与约束_退役旧目标计划感知大周天模块.py @@ -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 ### diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 46166da..c614d17 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -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) diff --git a/backend/app/api/v1/endpoints/admin.py b/backend/app/api/v1/endpoints/admin.py index 94b0229..3f09b86 100644 --- a/backend/app/api/v1/endpoints/admin.py +++ b/backend/app/api/v1/endpoints/admin.py @@ -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 链路追踪 —— diff --git a/backend/app/api/v1/endpoints/goals.py b/backend/app/api/v1/endpoints/goals.py deleted file mode 100644 index 69ca78b..0000000 --- a/backend/app/api/v1/endpoints/goals.py +++ /dev/null @@ -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) diff --git a/backend/app/api/v1/endpoints/imports.py b/backend/app/api/v1/endpoints/imports.py index 910e83b..0a7cff8 100644 --- a/backend/app/api/v1/endpoints/imports.py +++ b/backend/app/api/v1/endpoints/imports.py @@ -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 - ) diff --git a/backend/app/api/v1/endpoints/kouxin.py b/backend/app/api/v1/endpoints/kouxin.py new file mode 100644 index 0000000..fe96888 --- /dev/null +++ b/backend/app/api/v1/endpoints/kouxin.py @@ -0,0 +1,5 @@ +"""叩心路由(域实现见 app/domains/kouxin/api.py)。""" + +from app.domains.kouxin.api import knock_router + +__all__ = ["knock_router"] diff --git a/backend/app/api/v1/endpoints/tiangong.py b/backend/app/api/v1/endpoints/tiangong.py new file mode 100644 index 0000000..276cfec --- /dev/null +++ b/backend/app/api/v1/endpoints/tiangong.py @@ -0,0 +1,5 @@ +"""天工阁路由(域实现见 app/domains/tiangong/api.py)。""" + +from app.domains.tiangong.api import task_router + +__all__ = ["task_router"] diff --git a/backend/app/api/v1/endpoints/tools.py b/backend/app/api/v1/endpoints/tools.py index 41431e6..f238032 100644 --- a/backend/app/api/v1/endpoints/tools.py +++ b/backend/app/api/v1/endpoints/tools.py @@ -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) diff --git a/backend/app/application/planning/__init__.py b/backend/app/application/planning/__init__.py deleted file mode 100644 index be1f72e..0000000 --- a/backend/app/application/planning/__init__.py +++ /dev/null @@ -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", -] diff --git a/backend/app/core/config/runtime_config.py b/backend/app/core/config/runtime_config.py index ec34334..a912dc6 100644 --- a/backend/app/core/config/runtime_config.py +++ b/backend/app/core/config/runtime_config.py @@ -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" diff --git a/backend/app/db/governance_seed.py b/backend/app/db/governance_seed.py index 2699f2f..caa6c3a 100644 --- a/backend/app/db/governance_seed.py +++ b/backend/app/db/governance_seed.py @@ -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() diff --git a/backend/app/domains/kouxin/__init__.py b/backend/app/domains/kouxin/__init__.py new file mode 100644 index 0000000..a5ff993 --- /dev/null +++ b/backend/app/domains/kouxin/__init__.py @@ -0,0 +1,5 @@ +"""叩心域:主动联系候选(静默/频率/预算/渠道约束,无关系副作用)(M8)。""" + +from app.domains.kouxin import models, service # noqa: F401 + +__all__ = ["models", "service"] diff --git a/backend/app/domains/kouxin/api.py b/backend/app/domains/kouxin/api.py new file mode 100644 index 0000000..a76ce0b --- /dev/null +++ b/backend/app/domains/kouxin/api.py @@ -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) diff --git a/backend/app/domains/kouxin/models.py b/backend/app/domains/kouxin/models.py new file mode 100644 index 0000000..ec07fe0 --- /dev/null +++ b/backend/app/domains/kouxin/models.py @@ -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)) diff --git a/backend/app/domains/kouxin/service.py b/backend/app/domains/kouxin/service.py new file mode 100644 index 0000000..6c28acc --- /dev/null +++ b/backend/app/domains/kouxin/service.py @@ -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, + } diff --git a/backend/app/domains/mirror_tree/service.py b/backend/app/domains/mirror_tree/service.py index 201b495..1b94d1a 100644 --- a/backend/app/domains/mirror_tree/service.py +++ b/backend/app/domains/mirror_tree/service.py @@ -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)}") diff --git a/backend/app/domains/tiangong/__init__.py b/backend/app/domains/tiangong/__init__.py new file mode 100644 index 0000000..d9ef656 --- /dev/null +++ b/backend/app/domains/tiangong/__init__.py @@ -0,0 +1,5 @@ +"""天工阁域:受托任务(分解/并行/验收,无魂籍)+ 后台 Run 编排(M8)。""" + +from app.domains.tiangong import models, service # noqa: F401 + +__all__ = ["models", "service"] diff --git a/backend/app/domains/tiangong/api.py b/backend/app/domains/tiangong/api.py new file mode 100644 index 0000000..f4baee8 --- /dev/null +++ b/backend/app/domains/tiangong/api.py @@ -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) diff --git a/backend/app/domains/tiangong/models.py b/backend/app/domains/tiangong/models.py new file mode 100644 index 0000000..d1e3286 --- /dev/null +++ b/backend/app/domains/tiangong/models.py @@ -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)) diff --git a/backend/app/domains/tiangong/service.py b/backend/app/domains/tiangong/service.py new file mode 100644 index 0000000..9c4be59 --- /dev/null +++ b/backend/app/domains/tiangong/service.py @@ -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, + } diff --git a/backend/app/domains/zhengchen/__init__.py b/backend/app/domains/zhengchen/__init__.py new file mode 100644 index 0000000..d31f113 --- /dev/null +++ b/backend/app/domains/zhengchen/__init__.py @@ -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"] diff --git a/backend/app/kernel/__init__.py b/backend/app/kernel/__init__.py index 7a150be..4712be7 100644 --- a/backend/app/kernel/__init__.py +++ b/backend/app/kernel/__init__.py @@ -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) diff --git a/backend/app/kernel/loop.py b/backend/app/kernel/loop.py index abfa76e..c60ff32 100644 --- a/backend/app/kernel/loop.py +++ b/backend/app/kernel/loop.py @@ -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} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index ac2fc29..a9dcb51 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", diff --git a/backend/app/models/big_cycle.py b/backend/app/models/big_cycle.py deleted file mode 100644 index a558e1c..0000000 --- a/backend/app/models/big_cycle.py +++ /dev/null @@ -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 - ) diff --git a/backend/app/models/candidates.py b/backend/app/models/candidates.py deleted file mode 100644 index 4925153..0000000 --- a/backend/app/models/candidates.py +++ /dev/null @@ -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 - ) diff --git a/backend/app/models/governance.py b/backend/app/models/governance.py index 8b2b884..781dd04 100644 --- a/backend/app/models/governance.py +++ b/backend/app/models/governance.py @@ -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 固定。""" diff --git a/backend/app/modules/big_cycle/__init__.py b/backend/app/modules/big_cycle/__init__.py deleted file mode 100644 index ebca8e5..0000000 --- a/backend/app/modules/big_cycle/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""大周天演化候选领域纯逻辑(造消息 / 解析候选草稿,禁止 IO)。""" diff --git a/backend/app/modules/big_cycle/extract.py b/backend/app/modules/big_cycle/extract.py deleted file mode 100644 index b126a63..0000000 --- a/backend/app/modules/big_cycle/extract.py +++ /dev/null @@ -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 diff --git a/backend/app/modules/big_cycle/prompts.py b/backend/app/modules/big_cycle/prompts.py deleted file mode 100644 index 0a7ef31..0000000 --- a/backend/app/modules/big_cycle/prompts.py +++ /dev/null @@ -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) diff --git a/backend/app/modules/goal_orchestrator/__init__.py b/backend/app/modules/goal_orchestrator/__init__.py deleted file mode 100644 index 7d8a92f..0000000 --- a/backend/app/modules/goal_orchestrator/__init__.py +++ /dev/null @@ -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, - } diff --git a/backend/app/modules/goal_orchestrator/extract.py b/backend/app/modules/goal_orchestrator/extract.py deleted file mode 100644 index d9b8336..0000000 --- a/backend/app/modules/goal_orchestrator/extract.py +++ /dev/null @@ -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}" diff --git a/backend/app/modules/perception/__init__.py b/backend/app/modules/perception/__init__.py deleted file mode 100644 index b60a61b..0000000 --- a/backend/app/modules/perception/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""感知领域模块(纯逻辑,无 IO)。""" diff --git a/backend/app/modules/perception/vision/__init__.py b/backend/app/modules/perception/vision/__init__.py deleted file mode 100644 index 21533b3..0000000 --- a/backend/app/modules/perception/vision/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""视觉感知领域模块。""" diff --git a/backend/app/modules/perception/vision/ocr.py b/backend/app/modules/perception/vision/ocr.py deleted file mode 100644 index 4b6c17e..0000000 --- a/backend/app/modules/perception/vision/ocr.py +++ /dev/null @@ -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 diff --git a/backend/app/modules/planner/__init__.py b/backend/app/modules/planner/__init__.py deleted file mode 100644 index 2883990..0000000 --- a/backend/app/modules/planner/__init__.py +++ /dev/null @@ -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) diff --git a/backend/app/repositories/big_cycle.py b/backend/app/repositories/big_cycle.py deleted file mode 100644 index 489c5a5..0000000 --- a/backend/app/repositories/big_cycle.py +++ /dev/null @@ -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) diff --git a/backend/app/repositories/candidates.py b/backend/app/repositories/candidates.py deleted file mode 100644 index 5e24f1a..0000000 --- a/backend/app/repositories/candidates.py +++ /dev/null @@ -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) diff --git a/backend/app/repositories/governance.py b/backend/app/repositories/governance.py deleted file mode 100644 index e749a2f..0000000 --- a/backend/app/repositories/governance.py +++ /dev/null @@ -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()) diff --git a/backend/app/schemas/candidates.py b/backend/app/schemas/candidates.py deleted file mode 100644 index 6ce0fcf..0000000 --- a/backend/app/schemas/candidates.py +++ /dev/null @@ -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 跳过的数量。") diff --git a/backend/app/services/admin.py b/backend/app/services/admin.py index 8e8f166..0370da9 100644 --- a/backend/app/services/admin.py +++ b/backend/app/services/admin.py @@ -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 = ( diff --git a/backend/app/services/goal_persist.py b/backend/app/services/goal_persist.py deleted file mode 100644 index 60526ab..0000000 --- a/backend/app/services/goal_persist.py +++ /dev/null @@ -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 diff --git a/backend/app/services/governance.py b/backend/app/services/governance.py deleted file mode 100644 index 999e537..0000000 --- a/backend/app/services/governance.py +++ /dev/null @@ -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), - ) diff --git a/backend/app/services/imports.py b/backend/app/services/imports.py index f915e36..565f203 100644 --- a/backend/app/services/imports.py +++ b/backend/app/services/imports.py @@ -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) diff --git a/backend/app/services/runtime.py b/backend/app/services/runtime.py index cca72f7..bdcc02d 100644 --- a/backend/app/services/runtime.py +++ b/backend/app/services/runtime.py @@ -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), + ) diff --git a/backend/app/services/serializers.py b/backend/app/services/serializers.py index fe09246..5c91dcb 100644 --- a/backend/app/services/serializers.py +++ b/backend/app/services/serializers.py @@ -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 退役) diff --git a/backend/tests/api/test_goals.py b/backend/tests/api/test_goals.py deleted file mode 100644 index a691963..0000000 --- a/backend/tests/api/test_goals.py +++ /dev/null @@ -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" diff --git a/backend/tests/application/planning/test_planning.py b/backend/tests/application/planning/test_planning.py deleted file mode 100644 index ed67f9b..0000000 --- a/backend/tests/application/planning/test_planning.py +++ /dev/null @@ -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 diff --git a/backend/tests/db/test_governance_seed.py b/backend/tests/db/test_governance_seed.py deleted file mode 100644 index 6c91cac..0000000 --- a/backend/tests/db/test_governance_seed.py +++ /dev/null @@ -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}" diff --git a/backend/tests/models/test_governance.py b/backend/tests/models/test_governance.py deleted file mode 100644 index e3b1612..0000000 --- a/backend/tests/models/test_governance.py +++ /dev/null @@ -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 diff --git a/backend/tests/modules/goal_orchestrator/test_goal_extract.py b/backend/tests/modules/goal_orchestrator/test_goal_extract.py deleted file mode 100644 index f397268..0000000 --- a/backend/tests/modules/goal_orchestrator/test_goal_extract.py +++ /dev/null @@ -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("") == "" diff --git a/backend/tests/repositories/test_governance_repo.py b/backend/tests/repositories/test_governance_repo.py deleted file mode 100644 index 15e0758..0000000 --- a/backend/tests/repositories/test_governance_repo.py +++ /dev/null @@ -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 diff --git a/backend/tests/services/test_governance_service.py b/backend/tests/services/test_governance_service.py deleted file mode 100644 index c949333..0000000 --- a/backend/tests/services/test_governance_service.py +++ /dev/null @@ -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) diff --git a/backend/tests/test_m8_tiangong.py b/backend/tests/test_m8_tiangong.py new file mode 100644 index 0000000..cb7629c --- /dev/null +++ b/backend/tests/test_m8_tiangong.py @@ -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"], "叩心不得触发缘契版本变更" diff --git a/docs/operations/capability-matrix.md b/docs/operations/capability-matrix.md index 2dbe134..8bfe722 100644 --- a/docs/operations/capability-matrix.md +++ b/docs/operations/capability-matrix.md @@ -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 快照 | diff --git a/docs/operations/rewrite-plan.md b/docs/operations/rewrite-plan.md index 36c14eb..277ac46 100644 --- a/docs/operations/rewrite-plan.md +++ b/docs/operations/rewrite-plan.md @@ -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 桌面壳与收口 diff --git a/frontend/src/app/admin/agents/page.tsx b/frontend/src/app/admin/agents/page.tsx deleted file mode 100644 index 0f9702f..0000000 --- a/frontend/src/app/admin/agents/page.tsx +++ /dev/null @@ -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 ( - - {error ? ( - - ) : ( - - )} - - ); -} diff --git a/frontend/src/app/admin/goals/page.tsx b/frontend/src/app/admin/goals/page.tsx deleted file mode 100644 index 6f5d06c..0000000 --- a/frontend/src/app/admin/goals/page.tsx +++ /dev/null @@ -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 ( - - {error ? ( - - ) : ( - - )} - - ); -} diff --git a/frontend/src/app/admin/perception/page.tsx b/frontend/src/app/admin/perception/page.tsx deleted file mode 100644 index 679edfb..0000000 --- a/frontend/src/app/admin/perception/page.tsx +++ /dev/null @@ -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 ( - - {error ? ( - - ) : ( - - )} - - ); -} diff --git a/frontend/src/app/admin/pivots/[pivot]/page.tsx b/frontend/src/app/admin/pivots/[pivot]/page.tsx index 51c1bc8..2910464 100644 --- a/frontend/src/app/admin/pivots/[pivot]/page.tsx +++ b/frontend/src/app/admin/pivots/[pivot]/page.tsx @@ -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(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("/admin/goals"); - } catch (e) { - error = describeError(e); - } - - return ( - - {error ? ( - - ) : goals.length === 0 ? ( - - ) : ( - - )} - - ); -} /** 烛照枢:感知通道授权矩阵(启用 / 授权 / 隐私路由) */ -async function PerceptionDetail() { - let channels: PerceptionChannel[] = []; - let error: string | null = null; - try { - channels = await serverApi.get( - "/admin/perception/channels", - ); - } catch (e) { - error = describeError(e); - } - - return ( - - {error ? ( - - ) : ( - - )} - - ); -} /** 河图枢:世界实体列表(类型 / 事实-推断 / 置信度) */ -async function WorldModelDetail() { - let entities: WorldEntity[] = []; - let error: string | null = null; - try { - entities = await serverApi.get("/admin/world/entities"); - } catch (e) { - error = describeError(e); - } - - return ( - - {error ? ( - - ) : entities.length === 0 ? ( - - ) : ( - - )} - - ); -} /** 洛书枢:计划控制台(状态 / 假设 / 风险) */ -async function PlannerDetail() { - let plans: PlanSummary[] = []; - let error: string | null = null; - try { - plans = await serverApi.get("/admin/plans"); - } catch (e) { - error = describeError(e); - } - - return ( - - {error ? ( - - ) : plans.length === 0 ? ( - - ) : ( - - )} - - ); -} /** 天工阁枢:九角色在岗名册(启用 / 停用) */ -async function AgentSocietyDetail() { - let roster: AgentRosterEntry[] = []; - let error: string | null = null; - try { - roster = await serverApi.get("/admin/agents/roster"); - } catch (e) { - error = describeError(e); - } - - return ( - - {error ? : } - - ); -} diff --git a/frontend/src/app/admin/plans/page.tsx b/frontend/src/app/admin/plans/page.tsx deleted file mode 100644 index 44e904d..0000000 --- a/frontend/src/app/admin/plans/page.tsx +++ /dev/null @@ -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 ( - - {error ? ( - - ) : ( - - )} - - ); -} diff --git a/frontend/src/app/admin/world/page.tsx b/frontend/src/app/admin/world/page.tsx deleted file mode 100644 index afe8535..0000000 --- a/frontend/src/app/admin/world/page.tsx +++ /dev/null @@ -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 ( - - {error ? ( - - ) : ( - - )} - - ); -} diff --git a/frontend/src/components/admin/AgentRoster.tsx b/frontend/src/components/admin/AgentRoster.tsx deleted file mode 100644 index 54093fa..0000000 --- a/frontend/src/components/admin/AgentRoster.tsx +++ /dev/null @@ -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(null); - const [error, setError] = useState(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 ( -
- {showAdd ? ( -
-
- - 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" - /> - - 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" - /> -
- - 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" - /> -
- - -
-
- ) : ( - - )} - -
    - {roster.map((a) => ( -
  • -
    - - - {a.name} - - - {AGENT_PRESENCE_LABEL[a.presence]} - - -
    -
    -

    {a.responsibility}

    - -
    -
  • - ))} -
- {error &&

{error}

} -
- ); -} diff --git a/frontend/src/components/admin/GoalTree.tsx b/frontend/src/components/admin/GoalTree.tsx deleted file mode 100644 index 10384a6..0000000 --- a/frontend/src/components/admin/GoalTree.tsx +++ /dev/null @@ -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 = { - 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(null); - const [draft, setDraft] = useState<{ title: string; priority: number }>({ - title: "", - priority: 0, - }); - const [busyId, setBusyId] = useState(null); - const [error, setError] = useState(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 ( -
- {showAdd ? ( -
-
- - - 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" - /> - - -
-
- ) : ( - - )} - - {LEVEL_ORDER.map((level) => { - const items = goals.filter((g) => g.level === level); - if (items.length === 0) return null; - return ( -
-
- - {GOAL_LEVEL_LABEL[level]} - - -
-
    - {items.map((g) => ( -
  • - {editingId === g.id ? ( -
    - - 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" - /> -
    - - - 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" - /> -
    - - -
    -
    -
    - ) : ( - <> -
    - - - {g.title} - - - P{g.priority} - - -
    - {/* 进度条 */} -
    -
    -
    -
    - - {Math.round(g.progress * 100)}% - -
    - {/* 类型 */} - {g.type && ( - - 类型:{GOAL_TYPE_LABEL[g.type] ?? g.type} - - )} -

    - {g.description} -

    - {g.constraints.length > 0 && ( -

    - 约束:{g.constraints.join(" · ")} -

    - )} -
    - - {g.level !== "highest" && ( - - )} -
    - - )} -
  • - ))} -
-
- ); - })} - {error &&

{error}

} -
- ); -} diff --git a/frontend/src/components/admin/PerceptionConsentMatrix.tsx b/frontend/src/components/admin/PerceptionConsentMatrix.tsx deleted file mode 100644 index 2d76116..0000000 --- a/frontend/src/components/admin/PerceptionConsentMatrix.tsx +++ /dev/null @@ -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 = { - normal: "普通", - sensitive: "敏感", - local_only: "仅本地", -}; - -const CONSENT_DOT: Record = { - 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(null); - const [error, setError] = useState(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 ( -
- {showAdd ? ( -
-
- - 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" - /> - - 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" - /> -
- - 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" - /> -
- - -
-
- ) : ( - - )} - -
-
    - {channels.map((c) => ( -
  • -
    - - - - {c.name} - - - - - -
    -
    -

    {c.description}

    - -
    -
  • - ))} -
-
- {error &&

{error}

} -
- ); -} diff --git a/frontend/src/components/admin/PlanConsole.tsx b/frontend/src/components/admin/PlanConsole.tsx deleted file mode 100644 index 38c8c4b..0000000 --- a/frontend/src/components/admin/PlanConsole.tsx +++ /dev/null @@ -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 = { - 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(null); - const [draft, setDraft] = useState<{ assumptions: string; risks: string }>({ - assumptions: "", - risks: "", - }); - const [busyId, setBusyId] = useState(null); - const [error, setError] = useState(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 ? ( -
-
- 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" - /> - - -
-
- ) : ( - - )} - -
    - {plans.map((p) => ( -
  • -
    - - - {p.title} - - - {p.completed_steps}/{p.step_count} - - -
    - {editingId === p.id ? ( -
    -
    - -