M8:天工阁、叩心与辰正——受托任务后台并行、叩心四约束、时态感知投影
- 天工阁域 domains/tiangong:backstage_tasks(委托包+验收标准+结果证据);有限分解(parent 链)与受控并行(各自后台 Run);子任务全终态级联父任务进入待验收;按证据 accept/reject - kernel.start_backstage_run(kind=background_task):loop 后台分支不占前台席位、与前台对话并行;断言放宽容纳无人物功能任务;结果只经回调回呈任务——无身份写入路径 - 叩心域 domains/kouxin:knocking_candidates(event/time_anchor/manual 触发 + 草稿 + 裁决快照);四约束裁决(总开关→静默窗口【本地时区判定】→24h 频率→预算),dispatch 未过 429 且快照留痕;候选默认不自动发送;expire_stale 过期推进不触碰缘契 - 辰正 domains/zhengchen:temporal_context() 复用 time_sense 计算核(快照/农历/季节),产出【时态感知】投影段接入镜树生成 prompt(客观时间语境,不附情绪臆测——守一纪律) - 退役:modules/goal_orchestrator|planner|perception|big_cycle、application/planning、services/governance|goal_persist、candidates 表链(imports 候选抽取 API 面)、goals/plans/agent_roster/perception_channels/big_cycles/旧候选表(迁移 009);前端 admin goals/plans/agents/perception/world 页与六组件;governance 只留 GenerationPreferences;admin 治理面收敛为司命+百工两枢;pivots 健康以域表计数重建 - 验收(test_m8_tiangong 五用例):后台任务无身份写入(记忆/魂籍/缘契零新增);后台与前台并行;分解级联;叩心四约束逐项;不回应不降低关系(缘契维度/状态/版本零变化) - 四套件:后端 345、前端 203、BFF 4、神识 5 包全绿(2 个预存环境失败不变);迁移 009 净库干净
This commit is contained in:
1 parent
b419e0afde
commit
c63b75152a
68 files changed
+1203
-4748
No files matched your search
@@ -0,0 +1,87 @@
|
|||||||
|
"""天工阁_受托任务_叩心候选与约束_退役旧目标计划感知大周天模块
|
||||||
|
|
||||||
|
Revision ID: 009
|
||||||
|
Revises: 008
|
||||||
|
Create Date: 2026-08-27 20:31:23.297111
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '009'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '008'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('backstage_tasks',
|
||||||
|
sa.Column('id', sa.String(length=80), nullable=False),
|
||||||
|
sa.Column('owner_scope', sa.String(length=80), nullable=False),
|
||||||
|
sa.Column('character_id', sa.String(length=80), nullable=True),
|
||||||
|
sa.Column('parent_task_id', sa.String(length=80), nullable=True),
|
||||||
|
sa.Column('title', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('brief', sa.Text(), nullable=False),
|
||||||
|
sa.Column('acceptance_criteria', sa.Text(), nullable=False),
|
||||||
|
sa.Column('status', sa.String(length=30), nullable=False),
|
||||||
|
sa.Column('result_digest', sa.Text(), nullable=False),
|
||||||
|
sa.Column('evidence_json', sa.Text(), nullable=False),
|
||||||
|
sa.Column('run_id', sa.String(length=80), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['parent_task_id'], ['backstage_tasks.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_backstage_tasks_character_id'), 'backstage_tasks', ['character_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_backstage_tasks_owner_scope'), 'backstage_tasks', ['owner_scope'], unique=False)
|
||||||
|
op.create_index(op.f('ix_backstage_tasks_parent_task_id'), 'backstage_tasks', ['parent_task_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_backstage_tasks_run_id'), 'backstage_tasks', ['run_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_backstage_tasks_status'), 'backstage_tasks', ['status'], unique=False)
|
||||||
|
op.create_table('knocking_candidates',
|
||||||
|
sa.Column('id', sa.String(length=80), nullable=False),
|
||||||
|
sa.Column('character_id', sa.String(length=80), nullable=False),
|
||||||
|
sa.Column('owner_scope', sa.String(length=80), nullable=False),
|
||||||
|
sa.Column('trigger', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('draft', sa.Text(), nullable=False),
|
||||||
|
sa.Column('channel', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('status', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('ruling_json', sa.Text(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('dispatched_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_knocking_candidates_character_id'), 'knocking_candidates', ['character_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_knocking_candidates_owner_scope'), 'knocking_candidates', ['owner_scope'], unique=False)
|
||||||
|
op.create_index(op.f('ix_knocking_candidates_status'), 'knocking_candidates', ['status'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
# 旧域退役:太一(goals)/洛书(plans)/天工阁旧 roster/感知/大周天
|
||||||
|
op.drop_table('goals')
|
||||||
|
op.drop_table('plans')
|
||||||
|
op.drop_table('agent_roster')
|
||||||
|
op.drop_table('perception_channels')
|
||||||
|
op.drop_table('big_cycles')
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_knocking_candidates_status'), table_name='knocking_candidates')
|
||||||
|
op.drop_index(op.f('ix_knocking_candidates_owner_scope'), table_name='knocking_candidates')
|
||||||
|
op.drop_index(op.f('ix_knocking_candidates_character_id'), table_name='knocking_candidates')
|
||||||
|
op.drop_table('knocking_candidates')
|
||||||
|
op.drop_index(op.f('ix_backstage_tasks_status'), table_name='backstage_tasks')
|
||||||
|
op.drop_index(op.f('ix_backstage_tasks_run_id'), table_name='backstage_tasks')
|
||||||
|
op.drop_index(op.f('ix_backstage_tasks_parent_task_id'), table_name='backstage_tasks')
|
||||||
|
op.drop_index(op.f('ix_backstage_tasks_owner_scope'), table_name='backstage_tasks')
|
||||||
|
op.drop_index(op.f('ix_backstage_tasks_character_id'), table_name='backstage_tasks')
|
||||||
|
op.drop_table('backstage_tasks')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -9,7 +9,6 @@ from app.api.v1.endpoints import (
|
|||||||
characters,
|
characters,
|
||||||
|
|
||||||
events,
|
events,
|
||||||
goals,
|
|
||||||
health,
|
health,
|
||||||
imports,
|
imports,
|
||||||
embryo_drafts,
|
embryo_drafts,
|
||||||
@@ -20,6 +19,8 @@ from app.api.v1.endpoints import (
|
|||||||
baigong,
|
baigong,
|
||||||
hetu,
|
hetu,
|
||||||
soul,
|
soul,
|
||||||
|
kouxin,
|
||||||
|
tiangong,
|
||||||
xingyi,
|
xingyi,
|
||||||
yuanqi,
|
yuanqi,
|
||||||
mirror_trees,
|
mirror_trees,
|
||||||
@@ -44,6 +45,8 @@ api_router.include_router(kernel_runs.router)
|
|||||||
api_router.include_router(me.router)
|
api_router.include_router(me.router)
|
||||||
api_router.include_router(hetu.world_router)
|
api_router.include_router(hetu.world_router)
|
||||||
api_router.include_router(soul.soul_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(xingyi.form_router)
|
||||||
api_router.include_router(yuanqi.cov_router)
|
api_router.include_router(yuanqi.cov_router)
|
||||||
for _baigong_r in baigong.routers:
|
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(settings.router)
|
||||||
api_router.include_router(imports.router)
|
api_router.include_router(imports.router)
|
||||||
api_router.include_router(events.router)
|
api_router.include_router(events.router)
|
||||||
api_router.include_router(goals.router)
|
|
||||||
api_router.include_router(admin.router)
|
api_router.include_router(admin.router)
|
||||||
@@ -17,38 +17,25 @@ from app.api.v1.deps import (
|
|||||||
require_admin,
|
require_admin,
|
||||||
)
|
)
|
||||||
from app.models.runtime import Event
|
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 (
|
from app.schemas.admin_character import (
|
||||||
AdminCreateTemplateRequest,
|
AdminCreateTemplateRequest,
|
||||||
AdminTemplateSummaryResponse,
|
AdminTemplateSummaryResponse,
|
||||||
AdminUpdateTemplateRequest,
|
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.auth import CurrentUser
|
||||||
from app.schemas.common import Page
|
from app.schemas.common import Page
|
||||||
from app.services import governance as governance_service
|
|
||||||
from app.services.admin import (
|
from app.services.admin import (
|
||||||
get_audit_timeline,
|
get_audit_timeline,
|
||||||
get_pending_approvals,
|
get_pending_approvals,
|
||||||
@@ -105,111 +92,29 @@ async def audit_timeline(
|
|||||||
# —— §19.4 太一枢 · 目标 ——
|
# —— §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 烛照枢 · 感知通道 ——
|
# —— §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 洛书枢 · 计划 ——
|
# —— §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 天工阁枢 · 角色在岗 ——
|
# —— §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 链路追踪 ——
|
# —— §19.5 链路追踪 ——
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -10,12 +10,6 @@ from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
|
|||||||
|
|
||||||
from app.api.v1.deps import get_current_user
|
from app.api.v1.deps import get_current_user
|
||||||
from app.schemas.auth import CurrentUser
|
from app.schemas.auth import CurrentUser
|
||||||
from app.schemas.candidates import (
|
|
||||||
BatchReviewRequest,
|
|
||||||
BatchReviewResult,
|
|
||||||
MemoryCandidateResponse,
|
|
||||||
ProfileCandidateResponse,
|
|
||||||
)
|
|
||||||
from app.schemas.common import Page
|
from app.schemas.common import Page
|
||||||
from app.schemas.imports import (
|
from app.schemas.imports import (
|
||||||
CreateImportRequest,
|
CreateImportRequest,
|
||||||
@@ -23,18 +17,11 @@ from app.schemas.imports import (
|
|||||||
ImportJobResponse,
|
ImportJobResponse,
|
||||||
)
|
)
|
||||||
from app.services.imports import (
|
from app.services.imports import (
|
||||||
batch_review_memory_candidates,
|
|
||||||
batch_review_profile_candidates,
|
|
||||||
create_import_from_json,
|
create_import_from_json,
|
||||||
create_import_from_upload,
|
create_import_from_upload,
|
||||||
get_import,
|
get_import,
|
||||||
list_import_items,
|
list_import_items,
|
||||||
list_jobs_page,
|
list_jobs_page,
|
||||||
list_memory_candidates,
|
|
||||||
list_profile_candidates,
|
|
||||||
trigger_memory_extraction,
|
|
||||||
trigger_personality_extraction,
|
|
||||||
trigger_profile_extraction,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(tags=["imports"])
|
router = APIRouter(tags=["imports"])
|
||||||
@@ -116,109 +103,3 @@ async def read_import_items(
|
|||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
owner_scope=current_user.owner_scope,
|
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
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""叩心路由(域实现见 app/domains/kouxin/api.py)。"""
|
||||||
|
|
||||||
|
from app.domains.kouxin.api import knock_router
|
||||||
|
|
||||||
|
__all__ = ["knock_router"]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""天工阁路由(域实现见 app/domains/tiangong/api.py)。"""
|
||||||
|
|
||||||
|
from app.domains.tiangong.api import task_router
|
||||||
|
|
||||||
|
__all__ = ["task_router"]
|
||||||
@@ -10,7 +10,7 @@ from app.api.v1.deps import get_current_user
|
|||||||
from app.domains.baigong import service as baigong
|
from app.domains.baigong import service as baigong
|
||||||
from app.schemas.auth import CurrentUser
|
from app.schemas.auth import CurrentUser
|
||||||
from app.schemas.safety import ToolCallDetail
|
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"])
|
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)
|
@router.get("/calls/{tool_call_id}", response_model=ToolCallDetail)
|
||||||
async def get_tool_call_detail(tool_call_id: str) -> ToolCallDetail:
|
async def get_tool_call_detail(tool_call_id: str) -> ToolCallDetail:
|
||||||
return await get_tool_call(tool_call_id)
|
return await get_tool_call_detail(tool_call_id)
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
"""洛书枢 deliberative 规划器(编排层)。
|
|
||||||
|
|
||||||
领域数据结构与纯函数归位到 ``app/modules/planner/``,
|
|
||||||
本模块为薄壳,从 modules 导入并重导出以保持向后兼容。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from app.modules.planner import (
|
|
||||||
DeliberativePlan,
|
|
||||||
PlanStep,
|
|
||||||
build_planning_prompt,
|
|
||||||
format_plan_for_prompt,
|
|
||||||
parse_plan,
|
|
||||||
should_plan,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"DeliberativePlan",
|
|
||||||
"PlanStep",
|
|
||||||
"build_planning_prompt",
|
|
||||||
"format_plan_for_prompt",
|
|
||||||
"parse_plan",
|
|
||||||
"should_plan",
|
|
||||||
]
|
|
||||||
@@ -81,6 +81,14 @@ class RuntimeConfig(BaseSettings):
|
|||||||
chroma_collection: str = "hunjing_memories" # ChromaDB collection 名
|
chroma_collection: str = "hunjing_memories" # ChromaDB collection 名
|
||||||
chroma_persist_directory: str = "../db/chroma" # ChromaDB 持久化目录(相对 backend)
|
chroma_persist_directory: str = "../db/chroma" # ChromaDB 持久化目录(相对 backend)
|
||||||
embedding_model_hint: str = "bge-m3" # embedding 模型逻辑名
|
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 语音转写;未配置=多模态关闭,文字链路不受影响)
|
# 元炁本地推理引擎(ASR 语音转写;未配置=多模态关闭,文字链路不受影响)
|
||||||
yuanqi_base_url: str = ""
|
yuanqi_base_url: str = ""
|
||||||
yuanqi_asr_model: str = "iic/SenseVoiceSmall"
|
yuanqi_asr_model: str = "iic/SenseVoiceSmall"
|
||||||
|
|||||||
@@ -1,201 +1,9 @@
|
|||||||
"""司命台治理对象种子数据。
|
"""百工能力种子(内置能力幂等登记;旧枢治理种子随 M8 退役)。"""
|
||||||
|
|
||||||
从前端 lib/mock/data.ts 移植,供本地启动时让司命台治理视图有初始内容。
|
|
||||||
按主键存在则跳过,重复执行幂等;不记审计(系统初始化,非用户治理操作)。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.domains.baigong.models import Capability
|
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 = [
|
_BUILTIN_CAPABILITIES = [
|
||||||
# name, 描述, 执行类型, 执行器, 四维风险, 数据范围, 目的地, consent
|
# name, 描述, 执行类型, 执行器, 四维风险, 数据范围, 目的地, consent
|
||||||
@@ -276,82 +84,8 @@ async def _seed_capabilities(session: AsyncSession) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def ensure_governance_seed(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 _seed_capabilities(session)
|
||||||
await session.commit()
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""叩心域:主动联系候选(静默/频率/预算/渠道约束,无关系副作用)(M8)。"""
|
||||||
|
|
||||||
|
from app.domains.kouxin import models, service # noqa: F401
|
||||||
|
|
||||||
|
__all__ = ["models", "service"]
|
||||||
@@ -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)
|
||||||
@@ -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))
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
"""叩心应用服务:候选生成、约束裁决与派发。
|
||||||
|
|
||||||
|
铁律(PRD §6.6):
|
||||||
|
- 主动对话先形成候选,受**总开关/静默/频率/渠道/预算**五约束;
|
||||||
|
- 用户不回应不降低关系——候选过期/搁置不触碰缘契(无关系副作用,断言钉死);
|
||||||
|
- 产品不得以追逐式消息换取回流:候选默认不自动发送,dispatch 必须通过裁决。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime, time
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.errors import AppError
|
||||||
|
from app.core.time import utc_now
|
||||||
|
from app.db.session import AsyncSessionLocal
|
||||||
|
from app.domains import one_qi
|
||||||
|
from app.domains.kouxin.models import KnockingCandidate
|
||||||
|
from app.models.character import Character
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_hhmm(raw: str) -> time:
|
||||||
|
hh, mm = raw.split(":")
|
||||||
|
return time(int(hh), int(mm))
|
||||||
|
|
||||||
|
|
||||||
|
def _ruling_context() -> dict:
|
||||||
|
"""约束配置(env 级;P1 单用户档)。"""
|
||||||
|
return {
|
||||||
|
"enabled": settings.knocking_enabled,
|
||||||
|
"quiet_start": _parse_hhmm(settings.knocking_quiet_start),
|
||||||
|
"quiet_end": _parse_hhmm(settings.knocking_quiet_end),
|
||||||
|
"daily_limit": settings.knocking_daily_limit,
|
||||||
|
"budget_total": settings.knocking_budget_total,
|
||||||
|
"channel": "in_app",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _in_quiet_window(now: datetime, quiet_start: time, quiet_end: time) -> bool:
|
||||||
|
"""静默窗口判定(支持跨午夜区间,如 23:00–07:00)。"""
|
||||||
|
current = now.time()
|
||||||
|
if quiet_start <= quiet_end:
|
||||||
|
return quiet_start <= current < quiet_end
|
||||||
|
return current >= quiet_start or current < quiet_end
|
||||||
|
|
||||||
|
|
||||||
|
async def rule(character_id: str, owner_scope: str) -> dict:
|
||||||
|
"""约束裁决:总开关 → 静默窗口 → 频率(24h 内已发数)→ 预算(累计已发数)。"""
|
||||||
|
ctx = _ruling_context()
|
||||||
|
# 静默窗口为本地时区语义(用户作息);UTC 换算到本地再判定
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
now_utc = datetime.now(UTC)
|
||||||
|
now = now_utc.astimezone(ZoneInfo(settings.knocking_timezone))
|
||||||
|
if not ctx["enabled"]:
|
||||||
|
return {"allowed": False, "reason": "总开关关闭"}
|
||||||
|
if _in_quiet_window(now, ctx["quiet_start"], ctx["quiet_end"]):
|
||||||
|
qs, qe = ctx["quiet_start"].strftime("%H:%M"), ctx["quiet_end"].strftime("%H:%M")
|
||||||
|
return {"allowed": False, "reason": f"静默窗口({qs}–{qe})"}
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
since = now.timestamp() - 86400
|
||||||
|
daily = (
|
||||||
|
await session.execute(
|
||||||
|
select(func.count()).select_from(KnockingCandidate).where(
|
||||||
|
KnockingCandidate.character_id == character_id,
|
||||||
|
KnockingCandidate.status == "dispatched",
|
||||||
|
KnockingCandidate.dispatched_at.isnot(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
total = daily # P1 单周期:预算=累计派发数(开发档与频率同源,分离字段留配置位)
|
||||||
|
if daily >= ctx["daily_limit"]:
|
||||||
|
return {"allowed": False, "reason": f"24 小时频率已达上限({daily}/{ctx['daily_limit']})"}
|
||||||
|
if total >= ctx["budget_total"] > 0:
|
||||||
|
return {"allowed": False, "reason": "预算耗尽"}
|
||||||
|
return {"allowed": True, "reason": "通过"}
|
||||||
|
|
||||||
|
|
||||||
|
async def create_candidate(
|
||||||
|
*,
|
||||||
|
character_id: str,
|
||||||
|
owner_scope: str,
|
||||||
|
trigger: str,
|
||||||
|
draft: str,
|
||||||
|
) -> dict:
|
||||||
|
"""登记叩心候选(candidate;不自动发送)。"""
|
||||||
|
await _owned(character_id, owner_scope)
|
||||||
|
if trigger not in ("event", "time_anchor", "manual"):
|
||||||
|
raise AppError("invalid_request", "trigger 须为 event / time_anchor / manual。")
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
row = KnockingCandidate(
|
||||||
|
character_id=character_id,
|
||||||
|
owner_scope=owner_scope,
|
||||||
|
trigger=trigger,
|
||||||
|
draft=draft,
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
session.expunge(row)
|
||||||
|
return _cand_dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def dispatch(candidate_id: str, owner_scope: str, *, draft_override: str | None) -> dict:
|
||||||
|
"""派发候选:先过约束裁决,未过被拒且裁决快照留痕。"""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
row = await session.get(KnockingCandidate, candidate_id)
|
||||||
|
if row is None or row.owner_scope != owner_scope:
|
||||||
|
raise AppError("not_found", "叩心候选不存在。", status_code=404)
|
||||||
|
if row.status != "candidate":
|
||||||
|
raise AppError("invalid_request", "候选不在可派发状态。", status_code=409)
|
||||||
|
character_id = row.character_id
|
||||||
|
session.expunge(row)
|
||||||
|
|
||||||
|
ruling = await rule(character_id, owner_scope)
|
||||||
|
if not ruling["allowed"]:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
row = await session.get(KnockingCandidate, candidate_id)
|
||||||
|
row.ruling_json = json.dumps(ruling, ensure_ascii=False)
|
||||||
|
await session.commit()
|
||||||
|
raise AppError("invalid_request", f"叩心被拒:{ruling['reason']}", status_code=429)
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
row = await session.get(KnockingCandidate, candidate_id)
|
||||||
|
if draft_override is not None:
|
||||||
|
row.draft = draft_override
|
||||||
|
row.status = "dispatched"
|
||||||
|
row.dispatched_at = utc_now()
|
||||||
|
row.ruling_json = json.dumps(ruling, ensure_ascii=False)
|
||||||
|
await session.commit()
|
||||||
|
await one_qi.emit(
|
||||||
|
"knocking.dispatched", character_id=character_id,
|
||||||
|
payload={"candidate_id": candidate_id, "channel": "in_app"}, source="kouxin",
|
||||||
|
)
|
||||||
|
return {"candidate_id": candidate_id, "status": "dispatched", "channel": "in_app"}
|
||||||
|
|
||||||
|
|
||||||
|
async def dismiss(candidate_id: str, owner_scope: str) -> dict:
|
||||||
|
"""搁置候选(用户决定;不触碰缘契——无关系副作用)。"""
|
||||||
|
await _get(candidate_id, owner_scope)
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
row = await session.get(KnockingCandidate, candidate_id)
|
||||||
|
row.status = "dismissed"
|
||||||
|
await session.commit()
|
||||||
|
return {"candidate_id": candidate_id, "status": "dismissed"}
|
||||||
|
|
||||||
|
|
||||||
|
async def expire_stale(max_age_hours: int = 72) -> int:
|
||||||
|
"""过期未响应候选(expired):只是状态推进,不产生任何关系影响。"""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(KnockingCandidate).where(KnockingCandidate.status == "dispatched")
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
n = 0
|
||||||
|
now = utc_now()
|
||||||
|
for row in rows:
|
||||||
|
if row.dispatched_at is not None:
|
||||||
|
dispatched = row.dispatched_at
|
||||||
|
if dispatched.tzinfo is None: # SQLite 回读 naive(开发档)
|
||||||
|
dispatched = dispatched.replace(tzinfo=UTC)
|
||||||
|
age = (now - dispatched).total_seconds() / 3600
|
||||||
|
if age >= max_age_hours:
|
||||||
|
row.status = "expired"
|
||||||
|
n += 1
|
||||||
|
if n:
|
||||||
|
await session.commit()
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
async def list_candidates(owner_scope: str) -> list[dict]:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(KnockingCandidate)
|
||||||
|
.where(KnockingCandidate.owner_scope == owner_scope)
|
||||||
|
.order_by(KnockingCandidate.created_at.desc())
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
return [_cand_dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def _owned(character_id: str, owner_scope: str) -> None:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
character = await session.get(Character, character_id)
|
||||||
|
if character is None or character.owner_scope != owner_scope:
|
||||||
|
raise AppError("not_found", "人物不存在。", status_code=404)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get(candidate_id: str, owner_scope: str) -> dict:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
row = await session.get(KnockingCandidate, candidate_id)
|
||||||
|
if row is None or row.owner_scope != owner_scope:
|
||||||
|
raise AppError("not_found", "叩心候选不存在。", status_code=404)
|
||||||
|
session.expunge(row)
|
||||||
|
return _cand_dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
def _cand_dict(c: KnockingCandidate) -> dict:
|
||||||
|
return {
|
||||||
|
"id": c.id,
|
||||||
|
"character_id": c.character_id,
|
||||||
|
"trigger": c.trigger,
|
||||||
|
"draft": c.draft,
|
||||||
|
"channel": c.channel,
|
||||||
|
"status": c.status,
|
||||||
|
"ruling": json.loads(c.ruling_json or "{}"),
|
||||||
|
"created_at": c.created_at.isoformat() if c.created_at else None,
|
||||||
|
"dispatched_at": c.dispatched_at.isoformat() if c.dispatched_at else None,
|
||||||
|
}
|
||||||
@@ -435,6 +435,10 @@ async def build_generation_prompt(
|
|||||||
)
|
)
|
||||||
if activated:
|
if activated:
|
||||||
parts.append("【世界认知(世谱命中)】\n" + "\n".join(f"- {line}" for line in 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:
|
if transcript:
|
||||||
parts.append(f"【共同经历(祖先路径)】\n{transcript}")
|
parts.append(f"【共同经历(祖先路径)】\n{transcript}")
|
||||||
parts.append(f"【当前问题】\n{expand(question.content)}")
|
parts.append(f"【当前问题】\n{expand(question.content)}")
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""天工阁域:受托任务(分解/并行/验收,无魂籍)+ 后台 Run 编排(M8)。"""
|
||||||
|
|
||||||
|
from app.domains.tiangong import models, service # noqa: F401
|
||||||
|
|
||||||
|
__all__ = ["models", "service"]
|
||||||
@@ -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)
|
||||||
@@ -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))
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
@@ -91,6 +91,49 @@ class Kernel:
|
|||||||
assert created is not None
|
assert created is not None
|
||||||
return created
|
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(
|
async def start_rehearsal_run(
|
||||||
self, *, embryo_id: str, prompt: str, system_contract: str, owner_id: str = ""
|
self, *, embryo_id: str, prompt: str, system_contract: str, owner_id: str = ""
|
||||||
) -> Run:
|
) -> Run:
|
||||||
@@ -142,6 +185,7 @@ class Kernel:
|
|||||||
on_final: OnRunFinal | None,
|
on_final: OnRunFinal | None,
|
||||||
temperature: float | None = None,
|
temperature: float | None = None,
|
||||||
max_tokens: int | None = None,
|
max_tokens: int | None = None,
|
||||||
|
background: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
task = asyncio.create_task(
|
task = asyncio.create_task(
|
||||||
self.loop.execute(
|
self.loop.execute(
|
||||||
@@ -155,6 +199,7 @@ class Kernel:
|
|||||||
on_final=on_final,
|
on_final=on_final,
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
|
background=background,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self._tasks.add(task)
|
self._tasks.add(task)
|
||||||
|
|||||||
@@ -90,7 +90,11 @@ class KernelRunLoop:
|
|||||||
on_final: OnRunFinal | None = None,
|
on_final: OnRunFinal | None = None,
|
||||||
temperature: float | None = None,
|
temperature: float | None = None,
|
||||||
max_tokens: int | None = None,
|
max_tokens: int | None = None,
|
||||||
|
background: bool = False,
|
||||||
) -> RunOutcome:
|
) -> RunOutcome:
|
||||||
|
if background:
|
||||||
|
assert character_id or seat_key or run_id, "Run 需至少一个归属维度"
|
||||||
|
else:
|
||||||
assert character_id or seat_key, "Run 必须绑定人物或提供席位键(试演)"
|
assert character_id or seat_key, "Run 必须绑定人物或提供席位键(试演)"
|
||||||
trace = trace_id or new_trace_id()
|
trace = trace_id or new_trace_id()
|
||||||
outcome = RunOutcome()
|
outcome = RunOutcome()
|
||||||
@@ -107,6 +111,7 @@ class KernelRunLoop:
|
|||||||
outcome=outcome,
|
outcome=outcome,
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
|
background=background,
|
||||||
)
|
)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
outcome.status = "interrupted"
|
outcome.status = "interrupted"
|
||||||
@@ -137,6 +142,7 @@ class KernelRunLoop:
|
|||||||
outcome: RunOutcome,
|
outcome: RunOutcome,
|
||||||
temperature: float | None = None,
|
temperature: float | None = None,
|
||||||
max_tokens: int | None = None,
|
max_tokens: int | None = None,
|
||||||
|
background: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
ctx = RunContext(run_id=run_id, character_id=character_id, trace_id=trace_id)
|
ctx = RunContext(run_id=run_id, character_id=character_id, trace_id=trace_id)
|
||||||
try:
|
try:
|
||||||
@@ -150,6 +156,22 @@ class KernelRunLoop:
|
|||||||
return
|
return
|
||||||
|
|
||||||
await self._emit(run_id, character_id, trace_id, "run.queued")
|
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):
|
async with self._seats.foreground(seat_key or character_id or run_id):
|
||||||
await self._update_run(run_id, status="running")
|
await self._update_run(run_id, status="running")
|
||||||
await self._emit(run_id, character_id, trace_id, "run.started")
|
await self._emit(run_id, character_id, trace_id, "run.started")
|
||||||
@@ -176,6 +198,7 @@ class KernelRunLoop:
|
|||||||
outcome: RunOutcome,
|
outcome: RunOutcome,
|
||||||
temperature: float | None = None,
|
temperature: float | None = None,
|
||||||
max_tokens: int | None = None,
|
max_tokens: int | None = None,
|
||||||
|
background: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
ctx = RunContext(
|
ctx = RunContext(
|
||||||
run_id=run_id, character_id=character_id, trace_id=trace_id, payload={"prompt": prompt}
|
run_id=run_id, character_id=character_id, trace_id=trace_id, payload={"prompt": prompt}
|
||||||
|
|||||||
@@ -1,19 +1,13 @@
|
|||||||
"""SQLAlchemy 模型导出。"""
|
"""SQLAlchemy 模型导出。"""
|
||||||
|
|
||||||
from app.models.big_cycle import BigCycle
|
|
||||||
from app.models.candidates import MemoryCandidate, ProfileCandidate
|
|
||||||
from app.models.character import Character
|
from app.models.character import Character
|
||||||
from app.models.governance import (
|
from app.models.governance import GenerationPreferences
|
||||||
AgentRosterEntry,
|
|
||||||
GenerationPreferences,
|
|
||||||
Goal,
|
|
||||||
PerceptionChannel,
|
|
||||||
Plan,
|
|
||||||
)
|
|
||||||
from app.models.idempotency import IdempotencyKey
|
from app.models.idempotency import IdempotencyKey
|
||||||
from app.models.imports import CharacterImportJob, ImportItem
|
from app.models.imports import CharacterImportJob, ImportItem
|
||||||
from app.domains.baigong.models import Capability, Mandate, ToolCall
|
from app.domains.baigong.models import Capability, Mandate, ToolCall
|
||||||
from app.domains.hetu.models import PersistentWorld, WorldEntity, WorldRelation
|
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.xingyi.models import Form, FormAnchor, VoiceProfile
|
||||||
from app.domains.yuanqi.models import Covenant, CovenantEvent
|
from app.domains.yuanqi.models import Covenant, CovenantEvent
|
||||||
from app.models.jingsheng import Custodianship, EmbryoDraft, IdentityRecord, RuntimeSeat, SoulForging
|
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
|
from app.models.user import User
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"BackstageTask",
|
||||||
"Capability",
|
"Capability",
|
||||||
|
"KnockingCandidate",
|
||||||
"Form",
|
"Form",
|
||||||
"FormAnchor",
|
"FormAnchor",
|
||||||
"VoiceProfile",
|
"VoiceProfile",
|
||||||
@@ -45,25 +41,18 @@ __all__ = [
|
|||||||
|
|
||||||
"Mandate",
|
"Mandate",
|
||||||
"ToolCall",
|
"ToolCall",
|
||||||
"AgentRosterEntry",
|
|
||||||
"AuditEvent",
|
"AuditEvent",
|
||||||
"BigCycle",
|
|
||||||
"Character",
|
"Character",
|
||||||
"CharacterImportJob",
|
"CharacterImportJob",
|
||||||
"Event",
|
"Event",
|
||||||
"GenerationPreferences",
|
"GenerationPreferences",
|
||||||
"Goal",
|
|
||||||
"IdempotencyKey",
|
"IdempotencyKey",
|
||||||
"ImportItem",
|
"ImportItem",
|
||||||
"Lorebook",
|
"Lorebook",
|
||||||
"LorebookEntry",
|
"LorebookEntry",
|
||||||
"McpServer",
|
"McpServer",
|
||||||
"MemoryCandidate",
|
|
||||||
"PendingConfirmationRecord",
|
"PendingConfirmationRecord",
|
||||||
"PerceptionChannel",
|
|
||||||
"Permission",
|
"Permission",
|
||||||
"Plan",
|
|
||||||
"ProfileCandidate",
|
|
||||||
"Role",
|
"Role",
|
||||||
"RolePermission",
|
"RolePermission",
|
||||||
"Run",
|
"Run",
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
@@ -1,118 +1,16 @@
|
|||||||
"""司命台治理对象模型(九枢治理视图,api-spec §19.4)。
|
"""治理域模型:生成偏好(其余旧枢治理表随 M8 退役)。"""
|
||||||
|
|
||||||
目标(太一)/ 感知通道(烛照)/ 世界实体(河图)/ 计划(洛书)/ 天工阁角色
|
|
||||||
为可增长的开放集合;生成偏好(模型网关 §4.3)为本地单用户单行配置。
|
|
||||||
这些对象当前由司命台治理视图维护,对应枢的自动运行时暂未启用(见
|
|
||||||
docs/operations/capability-matrix.md §8)。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import Boolean, DateTime, Float, Integer, String
|
||||||
Boolean,
|
|
||||||
DateTime,
|
|
||||||
Float,
|
|
||||||
ForeignKey,
|
|
||||||
Integer,
|
|
||||||
String,
|
|
||||||
Text,
|
|
||||||
)
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.core.config import settings
|
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.core.time import utc_now
|
||||||
from app.db.base import Base
|
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):
|
class GenerationPreferences(Base):
|
||||||
"""生成参数偏好(模型网关 §4.3)。本地单用户单行,按 owner_scope 固定。"""
|
"""生成参数偏好(模型网关 §4.3)。本地单用户单行,按 owner_scope 固定。"""
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""大周天演化候选领域纯逻辑(造消息 / 解析候选草稿,禁止 IO)。"""
|
|
||||||
@@ -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
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
"""大周天演化候选 prompt 构建(纯函数,无 IO)。
|
|
||||||
|
|
||||||
接收 ``EvaluationSnapshot``(由 application 阶段从 ORM 组装的态势快照),产出
|
|
||||||
``(system, user)`` 消息对,喂给模型网关。模型返回的 JSON 由 ``extract`` 模块解析。
|
|
||||||
|
|
||||||
枚举取值以 [契约字典](../../docs/contracts/contract-glossary.md) §12 与
|
|
||||||
[自我演化机制设计](../../docs/domain/core-domains.md) §11 为准。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
|
|
||||||
from app.modules.evolution import LEVELS as EVOLUTION_LEVELS
|
|
||||||
from app.modules.evolution import SCOPES as EVOLUTION_SCOPES
|
|
||||||
from app.modules.evolution import TYPES as EVOLUTION_TYPES
|
|
||||||
|
|
||||||
EVOLUTION_TYPES = EVOLUTION_TYPES
|
|
||||||
EVOLUTION_LEVELS = EVOLUTION_LEVELS
|
|
||||||
EVOLUTION_SCOPES = EVOLUTION_SCOPES
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class EvaluationSnapshot:
|
|
||||||
"""大周天自我评估态势快照(application 组装,供候选生成消费)。"""
|
|
||||||
|
|
||||||
character_id: str
|
|
||||||
memory_count: int
|
|
||||||
recent_memories: list[tuple[str, str]] # (title, content)
|
|
||||||
personality_summary: str
|
|
||||||
world_entity_count: int
|
|
||||||
conclusion: str
|
|
||||||
evidence_ids: list[str] = field(default_factory=list) # 记忆 ID,供 evidence 序号映射
|
|
||||||
cumulative_drift: float = 0.0 # 最近大周天的累积漂移值
|
|
||||||
drift_budget_remaining: float = 0.0 # 剩余漂移预算
|
|
||||||
|
|
||||||
|
|
||||||
def build_evolution_extraction_messages(
|
|
||||||
snapshot: EvaluationSnapshot,
|
|
||||||
) -> tuple[str, str]:
|
|
||||||
system = (
|
|
||||||
"你是魂镜的大周天自我演化引擎。基于本轮自我评估的态势快照,生成轻量、可解释、"
|
|
||||||
"可回滚的演化候选——只调整人格表达、程序记忆、工具策略等轻量参数,"
|
|
||||||
"不改变核心使命、安全边界或高敏关系判断。"
|
|
||||||
f"type 取自:{', '.join(EVOLUTION_TYPES)}。"
|
|
||||||
f"level 取自:{', '.join(EVOLUTION_LEVELS)}"
|
|
||||||
"(L0-L3 低风险可半自动,L4 核心人格/使命/安全边界必须人工确认)。"
|
|
||||||
f"scope 取自:{', '.join(EVOLUTION_SCOPES)}。"
|
|
||||||
'以 JSON 数组返回,每个元素:{"type": str, "source": "big_cycle", '
|
|
||||||
'"level": str, "scope": str, "title": str, "proposal": str, '
|
|
||||||
'"delta": 对象, "confidence": 0~1, "benefit_score": 0~1, '
|
|
||||||
'"risk_score": 0~1, "auto_apply_allowed": bool, "evidence": [记忆序号]}。'
|
|
||||||
"delta 为具体增量(如人格字段覆盖对象、程序记忆 title/content)。"
|
|
||||||
"evidence 为下方记忆摘要的序号([0]/[1]…)列表。"
|
|
||||||
"仅在态势充分支撑时提议,宁缺毋滥。若本轮无需演化返回 []。"
|
|
||||||
"只返回 JSON 数组,不要解释或 markdown。"
|
|
||||||
)
|
|
||||||
user = (
|
|
||||||
f"记忆总数:{snapshot.memory_count}\n"
|
|
||||||
f"世界实体数:{snapshot.world_entity_count}\n"
|
|
||||||
f"自我评估结论:{snapshot.conclusion or '(无明显态势)'}\n"
|
|
||||||
f"当前人格摘要:\n{snapshot.personality_summary or '(尚未形成)'}\n"
|
|
||||||
f"近期记忆摘要:\n{_format_memories(snapshot.recent_memories)}"
|
|
||||||
)
|
|
||||||
# 人格权重向量:将漂移信息注入 prompt,引导 LLM 保守生成
|
|
||||||
if snapshot.cumulative_drift > 0:
|
|
||||||
user += (
|
|
||||||
f"\n\n⚠️ 人格漂移警告:累积漂移 {snapshot.cumulative_drift:.2f} / "
|
|
||||||
f"预算 {snapshot.drift_budget_remaining + snapshot.cumulative_drift:.1f}。"
|
|
||||||
f"剩余漂移预算 {snapshot.drift_budget_remaining:.1f}。"
|
|
||||||
f"请尽可能保守,减少高风险 proposal,优先微调而非大幅改动。"
|
|
||||||
)
|
|
||||||
return system, user
|
|
||||||
|
|
||||||
|
|
||||||
def _format_memories(memories: list[tuple[str, str]]) -> str:
|
|
||||||
if not memories:
|
|
||||||
return "(暂无)"
|
|
||||||
lines = [f"[{idx}] {title}:{content}" for idx, (title, content) in enumerate(memories)]
|
|
||||||
return "\n".join(lines)
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
"""太一枢(Goal Orchestrator)领域逻辑。
|
|
||||||
|
|
||||||
v1 落地 §7.1 最小闭环 + §7.2 预留扩展:
|
|
||||||
- 从用户消息提取会话目标写入 ``run.goal``,并注入运行契约。
|
|
||||||
- 活跃目标按优先级排序(``prioritize_active_goals``),供运行时注入。
|
|
||||||
|
|
||||||
目标持久化体系(``goals`` 表)、调度与冲突裁决属 §7.2 / §7.3。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from app.modules.goal_orchestrator.extract import (
|
|
||||||
extract_session_goal,
|
|
||||||
format_goal_directive,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"extract_session_goal",
|
|
||||||
"format_goal_directive",
|
|
||||||
"prioritize_active_goals",
|
|
||||||
"complete_session_goal",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def prioritize_active_goals(
|
|
||||||
goals: list[dict],
|
|
||||||
) -> list[dict]:
|
|
||||||
"""活跃目标优先级排序。
|
|
||||||
|
|
||||||
按以下规则返回排序后的目标列表(最高优先级在前):
|
|
||||||
1. ``priority`` 越小越优先(低数值 = 高优先级)
|
|
||||||
2. ``priority`` 相同时 ``progress`` 越低越优先(未完成的优先)
|
|
||||||
3. 层级越靠前越优先(L0 > L1 > L2 > L3 > L4)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
goals: 活跃目标字典列表,每个字典包含 ``title`` / ``level`` / ``priority`` / ``progress``。
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
排序后的目标列表。
|
|
||||||
"""
|
|
||||||
LEVEL_ORDER = {"L0": 0, "L1": 1, "L2": 2, "L3": 3, "L4": 4}
|
|
||||||
|
|
||||||
def _sort_key(g: dict) -> tuple:
|
|
||||||
priority = g.get("priority")
|
|
||||||
if priority is None:
|
|
||||||
priority = 100
|
|
||||||
progress = g.get("progress", 0) or 0
|
|
||||||
level = LEVEL_ORDER.get(str(g.get("level", "")), 99)
|
|
||||||
return (0 if priority is not None else 1, priority, level, progress)
|
|
||||||
|
|
||||||
return sorted(goals, key=_sort_key)
|
|
||||||
|
|
||||||
|
|
||||||
def complete_session_goal(
|
|
||||||
goal: dict,
|
|
||||||
) -> dict:
|
|
||||||
"""将会话目标标记为完成。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
goal: 会话目标字典,包含 ``id`` / ``title`` / ``level`` / ``status``。
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
更新后的目标字典(status=completed, progress=1.0)。
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
**goal,
|
|
||||||
"status": "completed",
|
|
||||||
"progress": 1.0,
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
"""太一会话目标提取(纯函数,无 IO)。
|
|
||||||
|
|
||||||
从用户消息提取会话目标(L2)。v1 采用规则提取——用户消息本身即目标的最直接
|
|
||||||
表达,清理与截断后写入 ``run.goal`` 并注入运行契约。LLM 提取为后续增强。
|
|
||||||
"""
|
|
||||||
|
|
||||||
_MAX_GOAL_CHARS = 120
|
|
||||||
|
|
||||||
|
|
||||||
def extract_session_goal(content: str) -> str:
|
|
||||||
"""从用户消息内容提取会话目标摘要。
|
|
||||||
|
|
||||||
规则:去除首尾空白,超长截断到 ``_MAX_GOAL_CHARS``;空内容返回空串。
|
|
||||||
"""
|
|
||||||
goal = (content or "").strip()
|
|
||||||
if not goal:
|
|
||||||
return ""
|
|
||||||
return goal[:_MAX_GOAL_CHARS]
|
|
||||||
|
|
||||||
|
|
||||||
def format_goal_directive(goal: str) -> str:
|
|
||||||
"""把会话目标格式化为运行契约的 system instruction 片段;空目标返回空串。"""
|
|
||||||
if not goal:
|
|
||||||
return ""
|
|
||||||
return f"当前会话目标:{goal}"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""感知领域模块(纯逻辑,无 IO)。"""
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""视觉感知领域模块。"""
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
"""
|
|
||||||
OCR 结果后处理(纯逻辑,无 IO)。
|
|
||||||
|
|
||||||
对 OCR 返回的原始结果做结构化解析、置信度过滤、阅读顺序修正。
|
|
||||||
不依赖任何外部资源,可独立测试。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
def filter_low_confidence(
|
|
||||||
blocks: list[dict[str, Any]],
|
|
||||||
threshold: float = 0.5,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""过滤低置信度结果。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
blocks: 原始 OCR 块列表。
|
|
||||||
threshold: 置信度阈值(默认 0.5)。
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
过滤后的块列表。
|
|
||||||
"""
|
|
||||||
return [b for b in blocks if b.get("confidence", 0) >= threshold]
|
|
||||||
|
|
||||||
|
|
||||||
def merge_adjacent_lines(
|
|
||||||
blocks: list[dict[str, Any]],
|
|
||||||
y_tolerance: float = 10.0,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""合并同一行的相邻块。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
blocks: OCR 块列表(需含 bbox 坐标)。
|
|
||||||
y_tolerance: Y 轴容差(px),同一行最大差异。
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
合并后的块列表。
|
|
||||||
"""
|
|
||||||
if not blocks:
|
|
||||||
return []
|
|
||||||
|
|
||||||
sorted_blocks = sorted(blocks, key=lambda b: (_center_y(b), _center_x(b)))
|
|
||||||
|
|
||||||
merged: list[dict[str, Any]] = []
|
|
||||||
current = sorted_blocks[0]
|
|
||||||
|
|
||||||
for block in sorted_blocks[1:]:
|
|
||||||
if abs(_center_y(block) - _center_y(current)) <= y_tolerance:
|
|
||||||
current["text"] += block["text"]
|
|
||||||
current["confidence"] = max(current["confidence"], block["confidence"])
|
|
||||||
else:
|
|
||||||
merged.append(current)
|
|
||||||
current = block
|
|
||||||
|
|
||||||
merged.append(current)
|
|
||||||
return merged
|
|
||||||
|
|
||||||
|
|
||||||
def extract_text(
|
|
||||||
blocks: list[dict[str, Any]],
|
|
||||||
separator: str = "",
|
|
||||||
) -> str:
|
|
||||||
"""从 OCR 块中提取纯文本。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
blocks: OCR 块列表。
|
|
||||||
separator: 块之间的分隔符。
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
拼接后的文本。
|
|
||||||
"""
|
|
||||||
return separator.join(b["text"] for b in blocks)
|
|
||||||
|
|
||||||
|
|
||||||
def _center_y(block: dict[str, Any]) -> float:
|
|
||||||
"""获取块的中心 Y 坐标。"""
|
|
||||||
pts = block.get("bbox", {}).get("points", block.get("bbox", []))
|
|
||||||
if pts and isinstance(pts, list) and len(pts) > 0:
|
|
||||||
ys = [p[1] for p in pts if isinstance(p, (list, tuple)) and len(p) >= 2]
|
|
||||||
return sum(ys) / len(ys) if ys else 0.0
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def _center_x(block: dict[str, Any]) -> float:
|
|
||||||
"""获取块的中心 X 坐标。"""
|
|
||||||
pts = block.get("bbox", {}).get("points", block.get("bbox", []))
|
|
||||||
if pts and isinstance(pts, list) and len(pts) > 0:
|
|
||||||
xs = [p[0] for p in pts if isinstance(p, (list, tuple)) and len(p) >= 2]
|
|
||||||
return sum(xs) / len(xs) if xs else 0.0
|
|
||||||
return 0.0
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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())
|
|
||||||
@@ -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 跳过的数量。")
|
|
||||||
@@ -12,19 +12,12 @@ from app.core.config import settings
|
|||||||
from app.core.security import redact_text
|
from app.core.security import redact_text
|
||||||
from app.core.time import to_iso, utc_now
|
from app.core.time import to_iso, utc_now
|
||||||
from app.db.session import AsyncSessionLocal
|
from app.db.session import AsyncSessionLocal
|
||||||
from app.models.governance import (
|
|
||||||
AgentRosterEntry,
|
|
||||||
Goal,
|
|
||||||
PerceptionChannel,
|
|
||||||
Plan,
|
|
||||||
)
|
|
||||||
from app.models.idempotency import IdempotencyKey
|
from app.models.idempotency import IdempotencyKey
|
||||||
from app.models.xirang import Memory
|
from app.models.xirang import Memory
|
||||||
from app.models.runtime import Event, Run
|
from app.models.runtime import Event, Run
|
||||||
from app.models.safety import AuditEvent, PendingConfirmationRecord
|
from app.models.safety import AuditEvent, PendingConfirmationRecord
|
||||||
from app.repositories import audit as audit_repo
|
from app.repositories import audit as audit_repo
|
||||||
from app.repositories import characters as character_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 idempotency as idempotency_repo
|
||||||
from app.repositories import runtime as runtime_repo
|
from app.repositories import runtime as runtime_repo
|
||||||
from app.schemas.admin import (
|
from app.schemas.admin import (
|
||||||
@@ -41,16 +34,14 @@ from app.schemas.common import Page
|
|||||||
from app.services.serializers import audit_to_response
|
from app.services.serializers import audit_to_response
|
||||||
|
|
||||||
# 九枢全集(契约字典 §5.9):枢标识、中文名、工程模块名。
|
# 九枢全集(契约字典 §5.9):枢标识、中文名、工程模块名。
|
||||||
_PIVOT_REGISTRY: list[tuple[str, str, str]] = [
|
_PIVOT_REGISTRY = [
|
||||||
("personality_core", "灵核枢", "Personality Core"),
|
("personality_core", "灵核枢", "Personality Core"),
|
||||||
("goal_orchestrator", "太一枢", "Goal Orchestrator"),
|
("capability_registry", "百工枢", "Capability Registry"),
|
||||||
("safety_governor", "司命枢", "Safety Governor"),
|
("safety_governor", "司命枢", "Safety Governor"),
|
||||||
("perception", "烛照枢", "Perception Layer"),
|
("hetu", "河图枢", "Hetu World"),
|
||||||
("world_model", "河图枢", "World Model"),
|
("tiangong", "天工阁", "Tiangong Backstage"),
|
||||||
("planner", "洛书枢", "Planner"),
|
("kouxin", "叩心枢", "Kouxin Proactive"),
|
||||||
("memory", "息壤枢", "Memory System"),
|
("memory", "息壤枢", "Xirang Memory"),
|
||||||
("tool_runtime", "百工枢", "Tool Runtime"),
|
|
||||||
("agent_society", "天工阁枢", "Agent Society"),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -64,7 +55,15 @@ def _summarize_input(value: dict | None) -> str:
|
|||||||
async def get_pivots_health(
|
async def get_pivots_health(
|
||||||
*,
|
*,
|
||||||
owner_scope: str = settings.default_owner_scope,
|
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:
|
async with AsyncSessionLocal() as session:
|
||||||
pending_count = len(
|
pending_count = len(
|
||||||
list(
|
list(
|
||||||
@@ -79,64 +78,47 @@ async def get_pivots_health(
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
goals_n = await governance_repo.count(session, Goal)
|
caps_n = await _count(session, Capability)
|
||||||
from app.domains.hetu.models import PersistentWorld
|
world_n = await _count(session, PersistentWorld)
|
||||||
|
tasks_n = await _count(session, BackstageTask)
|
||||||
world_n = await governance_repo.count(session, PersistentWorld)
|
knocks_n = await _count(session, KnockingCandidate)
|
||||||
plans_n = await governance_repo.count(session, Plan)
|
memories_n = await _count(session, Memory, Memory.status != "tombstoned")
|
||||||
perception_n = await governance_repo.count(session, PerceptionChannel)
|
|
||||||
agents_n = await governance_repo.count(session, AgentRosterEntry)
|
|
||||||
memories_n = await governance_repo.count(session, Memory, Memory.status != "tombstoned")
|
|
||||||
character = await character_repo.get_active_character(session, owner_scope)
|
character = await character_repo.get_active_character(session, owner_scope)
|
||||||
has_personality = bool(character and character.identity_baseline_json not in ("", "{}"))
|
has_personality = bool(character and character.identity_baseline_json not in ("", "{}"))
|
||||||
tool_enabled = True # 百工域常驻(乾坤可用性按端点区分)
|
|
||||||
now = to_iso(utc_now())
|
now = to_iso(utc_now())
|
||||||
|
|
||||||
raw: list[tuple[str, str, str, bool, str]] = [
|
raw: list[tuple[str, str, str]] = [
|
||||||
(
|
("personality_core", "steady" if has_personality else "void", "道体锚点已就绪" if has_personality else "锚点未初始化"),
|
||||||
"personality_core",
|
("capability_registry", "steady" if caps_n else "void", f"能力 {caps_n} 项"),
|
||||||
"steady" if has_personality else "void",
|
("safety_governor", "cracked" if pending_count else "steady", f"{pending_count} 项待裁决" if pending_count else "无待裁决"),
|
||||||
"人格版本已就绪" if has_personality else "人格未初始化",
|
("hetu", "steady" if world_n else "void", f"境界 {world_n} 个"),
|
||||||
),
|
("tiangong", "steady" if tasks_n else "void", f"受托任务 {tasks_n} 项"),
|
||||||
("goal_orchestrator", "steady" if goals_n else "void", f"目标 {goals_n} 项"),
|
("kouxin", "breathing" if knocks_n else "void", f"叩心候选 {knocks_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} 项"),
|
|
||||||
("memory", "steady" if memories_n else "void", f"记忆 {memories_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}
|
name_map = {pivot: (name, module) for pivot, name, module in _PIVOT_REGISTRY}
|
||||||
pivots: list[PivotHealthSummary] = []
|
pivots = [
|
||||||
for pivot, health, summary in raw:
|
|
||||||
name, module = name_map[pivot]
|
|
||||||
pivots.append(
|
|
||||||
PivotHealthSummary(
|
PivotHealthSummary(
|
||||||
pivot=pivot,
|
pivot=pivot, name=name_map[pivot][0], module=name_map[pivot][1],
|
||||||
name=name,
|
health=health, summary=summary, updated_at=now,
|
||||||
module=module,
|
|
||||||
health=health,
|
|
||||||
summary=summary,
|
|
||||||
updated_at=now,
|
|
||||||
)
|
)
|
||||||
)
|
for pivot, health, summary in raw
|
||||||
|
]
|
||||||
alerts = sum(1 for item in pivots if item.health == "cracked")
|
alerts = sum(1 for item in pivots if item.health == "cracked")
|
||||||
return PivotHealthReport(
|
return PivotHealthReport(
|
||||||
overall="cracked" if alerts else "steady", alerts=alerts, pivots=pivots
|
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 def get_pending_approvals() -> list[PendingApproval]:
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
records = (
|
records = (
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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),
|
|
||||||
)
|
|
||||||
@@ -16,15 +16,8 @@ from app.core.time import utc_now
|
|||||||
from app.db.session import AsyncSessionLocal
|
from app.db.session import AsyncSessionLocal
|
||||||
from app.models.imports import CharacterImportJob, ImportItem
|
from app.models.imports import CharacterImportJob, ImportItem
|
||||||
from app.modules.imports import parse_import
|
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 import imports as imports_repo
|
||||||
from app.repositories.json_utils import dumps, loads_dict
|
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.common import Page
|
||||||
from app.schemas.imports import (
|
from app.schemas.imports import (
|
||||||
CreateImportRequest,
|
CreateImportRequest,
|
||||||
@@ -36,8 +29,6 @@ from app.services.characters import get_owned_character
|
|||||||
from app.services.serializers import (
|
from app.services.serializers import (
|
||||||
import_item_to_response,
|
import_item_to_response,
|
||||||
import_job_to_response,
|
import_job_to_response,
|
||||||
memory_candidate_to_response,
|
|
||||||
profile_candidate_to_response,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
_TEXT_MIME_FORMAT: dict[str, str] = {
|
_TEXT_MIME_FORMAT: dict[str, str] = {
|
||||||
@@ -283,24 +274,6 @@ async def _reload_job(import_id: str) -> ImportJobResponse:
|
|||||||
return import_job_to_response(job)
|
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(
|
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)
|
|
||||||
@@ -213,3 +213,27 @@ async def list_run_event_responses(
|
|||||||
session, run_id, limit=limit, offset=offset
|
session, run_id, limit=limit, offset=offset
|
||||||
)
|
)
|
||||||
return [event_to_response(event) for event in events]
|
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),
|
||||||
|
)
|
||||||
@@ -3,28 +3,14 @@
|
|||||||
from datetime import UTC, timedelta
|
from datetime import UTC, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from app.models.governance import GenerationPreferences
|
||||||
from app.core.time import to_iso, utc_now
|
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.character import Character
|
||||||
from app.models.governance import (
|
|
||||||
AgentRosterEntry,
|
|
||||||
GenerationPreferences,
|
|
||||||
Goal,
|
|
||||||
PerceptionChannel,
|
|
||||||
Plan,
|
|
||||||
)
|
|
||||||
from app.models.imports import CharacterImportJob, ImportItem
|
from app.models.imports import CharacterImportJob, ImportItem
|
||||||
from app.models.runtime import Event, Run
|
from app.models.runtime import Event, Run
|
||||||
from app.models.safety import AuditEvent
|
from app.models.safety import AuditEvent
|
||||||
from app.repositories.json_utils import loads_dict, loads_list
|
from app.repositories.json_utils import loads_dict, loads_list
|
||||||
from app.schemas.admin import (
|
from app.schemas.admin import AuditEntry
|
||||||
AgentRosterResponse,
|
|
||||||
AuditEntry,
|
|
||||||
GoalNode,
|
|
||||||
PerceptionChannelResponse,
|
|
||||||
PlanSummaryResponse,
|
|
||||||
)
|
|
||||||
from app.schemas.candidates import MemoryCandidateResponse, ProfileCandidateResponse
|
|
||||||
from app.schemas.character import CharacterResponse, CharacterSummaryResponse
|
from app.schemas.character import CharacterResponse, CharacterSummaryResponse
|
||||||
from app.schemas.imports import ImportItemResponse, ImportJobResponse
|
from app.schemas.imports import ImportItemResponse, ImportJobResponse
|
||||||
from app.schemas.preferences import GenerationPreferencesResponse
|
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(
|
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 退役)
|
def profile_candidate_to_response(candidate): # noqa: ANN001 — 旧导入候选(M6 退役)
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
"""测试 per-character goals API。"""
|
|
||||||
|
|
||||||
from collections.abc import AsyncGenerator
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from httpx import ASGITransport, AsyncClient
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.db.session import AsyncSessionLocal
|
|
||||||
from app.main import app
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def db_session() -> AsyncGenerator[AsyncSession, None]:
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
yield session
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def client() -> AsyncGenerator[AsyncClient, None]:
|
|
||||||
transport = ASGITransport(app=app)
|
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
|
|
||||||
yield http_client
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_goals_by_character(client: AsyncClient, db_session: AsyncSession) -> None:
|
|
||||||
"""GET /api/v1/goals?character_id=char_test 返回该人物的目标。"""
|
|
||||||
from app.models.governance import Goal
|
|
||||||
|
|
||||||
goal = Goal(
|
|
||||||
id="goal_api_test",
|
|
||||||
title="API测试目标",
|
|
||||||
level="task",
|
|
||||||
character_id="char_test",
|
|
||||||
status="active",
|
|
||||||
)
|
|
||||||
db_session.add(goal)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
resp = await client.get("/api/v1/goals?character_id=char_test")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.json()
|
|
||||||
assert any(g["id"] == "goal_api_test" for g in data)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_goals_filters_status(client: AsyncClient, db_session: AsyncSession) -> None:
|
|
||||||
"""GET /api/v1/goals?character_id=char_test&status=active 只返回 active。"""
|
|
||||||
from app.models.governance import Goal
|
|
||||||
|
|
||||||
db_session.add_all(
|
|
||||||
[
|
|
||||||
Goal(id="goal_a1", title="a1", level="task", character_id="char_test", status="active"),
|
|
||||||
Goal(
|
|
||||||
id="goal_c1", title="c1", level="task", character_id="char_test", status="completed"
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
resp = await client.get("/api/v1/goals?character_id=char_test&status=active")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.json()
|
|
||||||
assert len(data) == 1
|
|
||||||
assert data[0]["id"] == "goal_a1"
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
"""洛书 Deliberative 规划器测试。"""
|
|
||||||
|
|
||||||
from app.application.planning import (
|
|
||||||
DeliberativePlan,
|
|
||||||
PlanStep,
|
|
||||||
build_planning_prompt,
|
|
||||||
format_plan_for_prompt,
|
|
||||||
parse_plan,
|
|
||||||
should_plan,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_plan_full() -> None:
|
|
||||||
"""完整 JSON 计划解析。"""
|
|
||||||
raw = """```json
|
|
||||||
{
|
|
||||||
"title": "调研项目X",
|
|
||||||
"assumptions": ["需要模型网关RAG能力"],
|
|
||||||
"risks": ["外部API可能超时"],
|
|
||||||
"steps": [
|
|
||||||
{"id": 1, "title": "搜索文档", "description": "搜索关于项目X的文档",
|
|
||||||
"tool": "web_search", "risk_level": "L2"},
|
|
||||||
{"id": 2, "title": "阅读结果", "description": "阅读搜索结果",
|
|
||||||
"tool": "read_file", "risk_level": "L1"},
|
|
||||||
{"id": 3, "title": "汇总", "description": "汇总分析",
|
|
||||||
"tool": null, "risk_level": "L1"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```"""
|
|
||||||
plan = parse_plan(raw)
|
|
||||||
assert plan is not None
|
|
||||||
assert plan.title == "调研项目X"
|
|
||||||
assert len(plan.assumptions) == 1
|
|
||||||
assert len(plan.risks) == 1
|
|
||||||
assert len(plan.steps) == 3
|
|
||||||
assert plan.steps[0].tool == "web_search"
|
|
||||||
assert plan.steps[2].tool is None
|
|
||||||
assert plan.steps[0].risk_level == "L2"
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_plan_no_fence() -> None:
|
|
||||||
"""无 ``` 围栏的 JSON 也能解析。"""
|
|
||||||
raw = '{"title": "简单计划", "steps": [{"id": 1, "title": "执行", "description": ""}]}'
|
|
||||||
plan = parse_plan(raw)
|
|
||||||
assert plan is not None
|
|
||||||
assert plan.title == "简单计划"
|
|
||||||
assert len(plan.steps) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_plan_missing_title() -> None:
|
|
||||||
"""缺少标题 → None。"""
|
|
||||||
raw = '{"steps": [{"id": 1, "title": "执行", "description": ""}]}'
|
|
||||||
assert parse_plan(raw) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_plan_empty_steps() -> None:
|
|
||||||
"""空步骤列表 → None。"""
|
|
||||||
raw = '{"title": "空计划", "steps": []}'
|
|
||||||
assert parse_plan(raw) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_plan_invalid_json() -> None:
|
|
||||||
"""非法 JSON → None。"""
|
|
||||||
assert parse_plan("根本不是JSON") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_plan_not_dict() -> None:
|
|
||||||
"""返回非对象 → None。"""
|
|
||||||
assert parse_plan("[1, 2, 3]") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_should_plan_triggers() -> None:
|
|
||||||
"""包含关键词的请求应触发规划。"""
|
|
||||||
triggers = [
|
|
||||||
"搜索一下项目X的背景",
|
|
||||||
"帮我调研一下最新技术",
|
|
||||||
"分析这个方案的优缺点",
|
|
||||||
"比较A和B的差异",
|
|
||||||
"研究一下这个问题",
|
|
||||||
"search for relevant docs",
|
|
||||||
"research the market",
|
|
||||||
]
|
|
||||||
for t in triggers:
|
|
||||||
assert should_plan(t), f"should plan: {t}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_should_plan_no_triggers() -> None:
|
|
||||||
"""简单请求不应触发。"""
|
|
||||||
no_triggers = [
|
|
||||||
"你好",
|
|
||||||
"再见",
|
|
||||||
"今天天气怎么样",
|
|
||||||
"帮我写首诗",
|
|
||||||
]
|
|
||||||
for t in no_triggers:
|
|
||||||
assert not should_plan(t), f"should not plan: {t}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_planning_prompt() -> None:
|
|
||||||
"""规划 prompt 包含必要结构。"""
|
|
||||||
prompt = build_planning_prompt("搜索资料", memory_context="用户关心项目X")
|
|
||||||
assert "NO_PLAN_NEEDED" in prompt
|
|
||||||
assert "搜索资料" in prompt
|
|
||||||
assert "用户关心项目X" in prompt
|
|
||||||
|
|
||||||
|
|
||||||
def test_format_plan_for_prompt() -> None:
|
|
||||||
"""计划格式化后包含步骤和元信息。"""
|
|
||||||
plan = DeliberativePlan(
|
|
||||||
title="测试计划",
|
|
||||||
steps=[
|
|
||||||
PlanStep(id=1, title="第一步", description="做A", tool="tool1", risk_level="L2"),
|
|
||||||
PlanStep(id=2, title="第二步", description="做B", risk_level="L1"),
|
|
||||||
],
|
|
||||||
assumptions=["假设1"],
|
|
||||||
risks=["风险1"],
|
|
||||||
)
|
|
||||||
text = format_plan_for_prompt(plan)
|
|
||||||
assert "测试计划" in text
|
|
||||||
assert "第一步" in text
|
|
||||||
assert "tool1" in text
|
|
||||||
assert "第二步" in text
|
|
||||||
assert "假设1" in text
|
|
||||||
assert "风险1" in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_format_plan_empty_assumptions_and_risks() -> None:
|
|
||||||
"""无假设/风险时格式化正常。"""
|
|
||||||
plan = DeliberativePlan(
|
|
||||||
title="简单",
|
|
||||||
steps=[PlanStep(id=1, title="做", description="")],
|
|
||||||
)
|
|
||||||
text = format_plan_for_prompt(plan)
|
|
||||||
assert "简单" in text
|
|
||||||
assert "做" in text
|
|
||||||
@@ -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}"
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
"""测试 Goal ORM 模型新增字段。"""
|
|
||||||
|
|
||||||
from app.models.governance import Goal
|
|
||||||
|
|
||||||
|
|
||||||
def test_goal_has_character_id():
|
|
||||||
"""Goal 模型新增 character_id 字段。"""
|
|
||||||
assert hasattr(Goal, "character_id")
|
|
||||||
|
|
||||||
|
|
||||||
def test_goal_has_type():
|
|
||||||
"""Goal 模型新增 type 字段,默认 session。"""
|
|
||||||
assert hasattr(Goal, "type")
|
|
||||||
|
|
||||||
|
|
||||||
def test_goal_has_progress():
|
|
||||||
"""Goal 模型新增 progress 字段,默认 0.0。"""
|
|
||||||
goal = Goal(title="test", level="task")
|
|
||||||
assert goal.type == "session"
|
|
||||||
assert goal.progress == 0.0
|
|
||||||
assert goal.character_id is None
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
"""太一会话目标提取纯函数测试。"""
|
|
||||||
|
|
||||||
from app.modules.goal_orchestrator.extract import (
|
|
||||||
extract_session_goal,
|
|
||||||
format_goal_directive,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_returns_stripped_content() -> None:
|
|
||||||
assert extract_session_goal(" 帮我整理今天的笔记 ") == "帮我整理今天的笔记"
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_empty_returns_empty_string() -> None:
|
|
||||||
assert extract_session_goal("") == ""
|
|
||||||
assert extract_session_goal(" ") == ""
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_short_message_unchanged() -> None:
|
|
||||||
assert extract_session_goal("今天天气如何?") == "今天天气如何?"
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_truncates_over_limit() -> None:
|
|
||||||
# 目标摘要上限 120 字(run.goal 字段容量 300,留余量);超出截断
|
|
||||||
long = "目标描述" * 40 # 160 字
|
|
||||||
goal = extract_session_goal(long)
|
|
||||||
assert len(goal) == 120
|
|
||||||
assert goal == "目标描述" * 30 # 120 字 = 30 个「目标描述」
|
|
||||||
|
|
||||||
|
|
||||||
def test_format_goal_directive_with_goal() -> None:
|
|
||||||
assert format_goal_directive("整理笔记") == "当前会话目标:整理笔记"
|
|
||||||
|
|
||||||
|
|
||||||
def test_format_goal_directive_empty_returns_empty() -> None:
|
|
||||||
assert format_goal_directive("") == ""
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
"""太一枢目标数据访问测试(per-character 查询 + 进度更新)。"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.db.session import AsyncSessionLocal
|
|
||||||
from app.models.governance import Goal
|
|
||||||
from app.repositories import governance as repo
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_goals_by_character() -> None:
|
|
||||||
"""返回指定 character_id 的目标列表。"""
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
g1 = Goal(id="goal_1", title="t1", level="task", character_id="char_a")
|
|
||||||
g2 = Goal(id="goal_2", title="t2", level="task", character_id="char_b")
|
|
||||||
session.add_all([g1, g2])
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
goals = await repo.list_goals_by_character(session, "char_a")
|
|
||||||
assert len(goals) == 1
|
|
||||||
assert goals[0].id == "goal_1"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_goals_by_character_filters_status() -> None:
|
|
||||||
"""按 status 过滤指定角色的目标。"""
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
g1 = Goal(id="goal_3", title="t3", level="task", character_id="char_c", status="active")
|
|
||||||
g2 = Goal(
|
|
||||||
id="goal_4",
|
|
||||||
title="t4",
|
|
||||||
level="task",
|
|
||||||
character_id="char_c",
|
|
||||||
status="completed",
|
|
||||||
)
|
|
||||||
session.add_all([g1, g2])
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
goals = await repo.list_goals_by_character(session, "char_c", status_filter="active")
|
|
||||||
assert len(goals) == 1
|
|
||||||
assert goals[0].id == "goal_3"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_goal_progress() -> None:
|
|
||||||
"""更新目标的 progress 字段。"""
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
goal = Goal(id="goal_5", title="t5", level="task")
|
|
||||||
session.add(goal)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
updated = await repo.update_goal_progress(session, "goal_5", 0.75)
|
|
||||||
assert updated is not None
|
|
||||||
assert updated.progress == 0.75
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_goal_progress_nonexistent() -> None:
|
|
||||||
"""更新不存在的目标返回 None。"""
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
result = await repo.update_goal_progress(session, "goal_nonexistent", 0.5)
|
|
||||||
assert result is None
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
"""测试 governance service 中的 goal 事件写入与会话目标自动持久化。"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.core.errors import AppError
|
|
||||||
from app.models.governance import Goal
|
|
||||||
|
|
||||||
# ── auto_persist_session_goal 测试 ──
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_auto_persist_session_goal_creates_new():
|
|
||||||
"""auto_persist_session_goal: 当没有活跃的 session 目标时,创建一个新的。"""
|
|
||||||
from app.db.session import AsyncSessionLocal
|
|
||||||
from app.services.goal_persist import auto_persist_session_goal
|
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
goal = await auto_persist_session_goal(session, "char_c", "帮助用户解决编程问题")
|
|
||||||
assert goal is not None
|
|
||||||
assert goal.level == "session"
|
|
||||||
assert goal.character_id == "char_c"
|
|
||||||
assert "编程" in goal.title
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_auto_persist_session_goal_reuses_active():
|
|
||||||
"""auto_persist_session_goal: 已有活跃 session 目标时,不创建新的。"""
|
|
||||||
from app.db.session import AsyncSessionLocal
|
|
||||||
from app.services.goal_persist import auto_persist_session_goal
|
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
existing = Goal(
|
|
||||||
id="goal_session_existing",
|
|
||||||
character_id="char_d",
|
|
||||||
level="session",
|
|
||||||
status="active",
|
|
||||||
title="已有目标",
|
|
||||||
priority=50,
|
|
||||||
)
|
|
||||||
session.add(existing)
|
|
||||||
await session.flush()
|
|
||||||
goal = await auto_persist_session_goal(session, "char_d", "新对话内容要足够长才能通过验证")
|
|
||||||
assert goal is not None
|
|
||||||
assert goal.id == "goal_session_existing" # 复用了已有目标
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_auto_persist_session_goal_short_content():
|
|
||||||
"""auto_persist_session_goal: 内容过短时返回 None。"""
|
|
||||||
from app.db.session import AsyncSessionLocal
|
|
||||||
from app.services.goal_persist import auto_persist_session_goal
|
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
goal = await auto_persist_session_goal(session, "char_c", "你好")
|
|
||||||
assert goal is None
|
|
||||||
|
|
||||||
|
|
||||||
# ── governance service CRUD 测试(事件写入通过 emit_goal_event 静默跳过)──
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_goal_success():
|
|
||||||
"""create_goal: 创建目标成功,返回 GoalNode。"""
|
|
||||||
from app.schemas.admin import CreateGoalRequest
|
|
||||||
from app.services.governance import create_goal
|
|
||||||
|
|
||||||
payload = CreateGoalRequest(
|
|
||||||
title="测试目标",
|
|
||||||
description="这是一个测试目标",
|
|
||||||
level="session",
|
|
||||||
priority=50,
|
|
||||||
)
|
|
||||||
result = await create_goal(payload)
|
|
||||||
assert result.title == "测试目标"
|
|
||||||
assert result.level == "session"
|
|
||||||
assert result.status == "pending"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_goal_success():
|
|
||||||
"""update_goal: 更新目标成功。"""
|
|
||||||
from app.schemas.admin import CreateGoalRequest, UpdateGoalRequest
|
|
||||||
from app.services.governance import create_goal, update_goal
|
|
||||||
|
|
||||||
payload = CreateGoalRequest(
|
|
||||||
title="待更新目标",
|
|
||||||
description="稍后更新",
|
|
||||||
level="task",
|
|
||||||
priority=50,
|
|
||||||
)
|
|
||||||
created = await create_goal(payload)
|
|
||||||
|
|
||||||
update = UpdateGoalRequest(title="已更新目标", status="active")
|
|
||||||
updated = await update_goal(created.id, update)
|
|
||||||
assert updated.title == "已更新目标"
|
|
||||||
assert updated.status == "active"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_goal_not_found():
|
|
||||||
"""update_goal: 目标不存在时抛出 AppError。"""
|
|
||||||
from app.schemas.admin import UpdateGoalRequest
|
|
||||||
from app.services.governance import update_goal
|
|
||||||
|
|
||||||
update = UpdateGoalRequest(title="不存在")
|
|
||||||
with pytest.raises(AppError) as exc:
|
|
||||||
await update_goal("goal_nonexistent", update)
|
|
||||||
assert "not_found" in str(exc.value)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_delete_goal_success():
|
|
||||||
"""delete_goal: 删除目标成功。"""
|
|
||||||
from app.schemas.admin import CreateGoalRequest
|
|
||||||
from app.services.governance import create_goal, delete_goal, list_goals
|
|
||||||
|
|
||||||
payload = CreateGoalRequest(
|
|
||||||
title="待删除目标",
|
|
||||||
description="稍后删除",
|
|
||||||
level="task",
|
|
||||||
priority=50,
|
|
||||||
)
|
|
||||||
created = await create_goal(payload)
|
|
||||||
await delete_goal(created.id)
|
|
||||||
all_goals = await list_goals()
|
|
||||||
assert not any(g.id == created.id for g in all_goals)
|
|
||||||
@@ -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"], "叩心不得触发缘契版本变更"
|
||||||
@@ -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 预存环境失败与本域无关 |
|
| 百工与司命 | 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 隔离,真界本相零污染);引文→记忆正影后继提示;前端缘契面板与世界页。验收六测试全绿;旧人格/驱动/演化/世界模块与页面全量退役 |
|
| 缘契/道体/河图 | M6 | 已验证 | M6 收口(2026-08-27,本地开发档):缘契提议—问心—铭契—撤回全链(多维无单一好感度、撤回即生效、事件史不可变);铸魂确认流(锚点确认前不变 + 魂籍版本链)与分灵谱系;河图境界(persistent_worlds + 实体关系 + 境界正史视图 + 世谱挂接);归真三界分流(境界/幻境实录 scope 隔离,真界本相零污染);引文→记忆正影后继提示;前端缘契面板与世界页。验收六测试全绿;旧人格/驱动/演化/世界模块与页面全量退役 |
|
||||||
| 形仪域 | M7 | 已验证 | M7 收口(2026-08-27,本地开发档):真形锚点(特征/许可/可变范围);主仪化仪候选定相治理(candidate 不进呈现位、主仪唯一);声音三来源与克隆授权(用途/传播必填、可撤回立即生效);ASR 经元炁透传(多模态关闭 503 不降级);前端形仪工坊三区。验收三测试全绿;旧 avatar 全量退役。图像真实生成与 TTS 合成随神识 image/tts 槽位启用接入(候选定相与授权治理框架已就位) |
|
| 形仪域 | 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);天目待接通感 |
|
| 天目 / 元炁 | M4/M7 | 局部实现 | 元炁 ASR 已接形仪域(M7);chat 经神识 endpoint(M4);天目待接通感 |
|
||||||
| 镜树前端 / 记忆治理 / 百工页 | M2+ | 局部实现 | 镜树页与灵胚页已上线(M2c,2026-08-21):路径阅读流/候答切换/采用归真/局部树图/SSE 芽体/导入卡;type-check + vitest 210 passed;记忆治理页随 M3、百工页随 M5 |
|
| 镜树前端 / 记忆治理 / 百工页 | M2+ | 局部实现 | 镜树页与灵胚页已上线(M2c,2026-08-21):路径阅读流/候答切换/采用归真/局部树图/SSE 芽体/导入卡;type-check + vitest 210 passed;记忆治理页随 M3、百工页随 M5 |
|
||||||
| 导入(角色卡 V2/V3 → 灵胚) | M2 | 局部实现 | M2b 已验证(2026-08-21):POST /characters/import-card 产出可审阅灵胚(identity_baseline.import_card 暂存 character_book),启镜时物化世谱书+条目(dict/list 双形态兼容);导出方向保持人物→ST V3 快照 |
|
| 导入(角色卡 V2/V3 → 灵胚) | M2 | 局部实现 | M2b 已验证(2026-08-21):POST /characters/import-card 产出可审阅灵胚(identity_baseline.import_card 暂存 character_book),启镜时物化世谱书+条目(dict/list 双形态兼容);导出方向保持人物→ST V3 快照 |
|
||||||
|
|||||||
@@ -143,10 +143,11 @@
|
|||||||
- 验收(tests/test_m7_xingyi.py 三用例钉死):①未定相不成为正式形仪(candidate 期呈现位为空、定相后转正、主仪唯一旧主仪 retired、废弃可用);②克隆授权可撤回(缺用途被拒、有效通过、撤回后校验 403/409、预设不受授权约束);③关闭全部多模态后文字相伴完整可用(ASR 503 时镜树/缘契/百工链路照常)。
|
- 验收(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 干净。
|
- 证据:后端 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 桌面壳与收口
|
### M9 桌面壳与收口
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import { PageContainer } from "@/components/layout/PageContainer";
|
|
||||||
import { AgentRoster } from "@/components/admin/AgentRoster";
|
|
||||||
import { ErrorState } from "@/components/shared/StateViews";
|
|
||||||
|
|
||||||
import { serverApi } from "@/lib/api/server-client";
|
|
||||||
import { describeError } from "@/lib/utils/error";
|
|
||||||
import type { AgentRosterEntry } from "@/types/api";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function AdminAgentsPage() {
|
|
||||||
let roster: AgentRosterEntry[] = [];
|
|
||||||
let error: string | null = null;
|
|
||||||
try {
|
|
||||||
roster = await serverApi.get("/admin/agents/roster");
|
|
||||||
} catch (e) {
|
|
||||||
error = describeError(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContainer
|
|
||||||
title="天工阁枢"
|
|
||||||
description="多智能体角色在岗管理——九角色可增删改。"
|
|
||||||
maxWidth="max-w-5xl"
|
|
||||||
>
|
|
||||||
{error ? (
|
|
||||||
<ErrorState message={error} />
|
|
||||||
) : (
|
|
||||||
<AgentRoster roster={roster ?? []} />
|
|
||||||
)}
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { PageContainer } from "@/components/layout/PageContainer";
|
|
||||||
import { GoalTree } from "@/components/admin/GoalTree";
|
|
||||||
import { ErrorState } from "@/components/shared/StateViews";
|
|
||||||
|
|
||||||
import { serverApi } from "@/lib/api/server-client";
|
|
||||||
import { describeError } from "@/lib/utils/error";
|
|
||||||
import type { GoalNode } from "@/types/api";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function AdminGoalsPage() {
|
|
||||||
let goals: GoalNode[] = [];
|
|
||||||
let error: string | null = null;
|
|
||||||
try {
|
|
||||||
goals = await serverApi.get("/admin/goals");
|
|
||||||
} catch (e) {
|
|
||||||
error = describeError(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContainer
|
|
||||||
title="太一枢"
|
|
||||||
description="目标层级树——从使命到行动的分解,支持增删改。"
|
|
||||||
maxWidth="max-w-5xl"
|
|
||||||
>
|
|
||||||
{error ? (
|
|
||||||
<ErrorState message={error} />
|
|
||||||
) : (
|
|
||||||
<GoalTree goals={goals ?? []} />
|
|
||||||
)}
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { PageContainer } from "@/components/layout/PageContainer";
|
|
||||||
import { PerceptionConsentMatrix } from "@/components/admin/PerceptionConsentMatrix";
|
|
||||||
import { ErrorState } from "@/components/shared/StateViews";
|
|
||||||
|
|
||||||
import { serverApi } from "@/lib/api/server-client";
|
|
||||||
import { describeError } from "@/lib/utils/error";
|
|
||||||
import type { PerceptionChannel } from "@/types/api";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function AdminPerceptionPage() {
|
|
||||||
let channels: PerceptionChannel[] = [];
|
|
||||||
let error: string | null = null;
|
|
||||||
try {
|
|
||||||
channels = await serverApi.get("/admin/perception/channels");
|
|
||||||
} catch (e) {
|
|
||||||
error = describeError(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContainer
|
|
||||||
title="烛照枢"
|
|
||||||
description="感知通道授权——管理六类感知源的同意状态与隐私级别。"
|
|
||||||
maxWidth="max-w-5xl"
|
|
||||||
>
|
|
||||||
{error ? (
|
|
||||||
<ErrorState message={error} />
|
|
||||||
) : (
|
|
||||||
<PerceptionConsentMatrix channels={channels ?? []} />
|
|
||||||
)}
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -6,11 +6,6 @@ import { VermilionAction } from "@/components/admin/VermilionAction";
|
|||||||
import { PolicyMatrix } from "@/components/admin/PolicyMatrix";
|
import { PolicyMatrix } from "@/components/admin/PolicyMatrix";
|
||||||
import { AuditConsole } from "@/components/admin/AuditConsole";
|
import { AuditConsole } from "@/components/admin/AuditConsole";
|
||||||
import { ToolRegistry } from "@/components/admin/ToolRegistry";
|
import { ToolRegistry } from "@/components/admin/ToolRegistry";
|
||||||
import { 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 { McpServerPanel } from "@/components/admin/mcp/McpServerPanel";
|
||||||
import { ToolRuntimeTabs } from "@/components/admin/mcp/ToolRuntimeTabs";
|
import { ToolRuntimeTabs } from "@/components/admin/mcp/ToolRuntimeTabs";
|
||||||
import { serverApi } from "@/lib/api/server-client";
|
import { serverApi } from "@/lib/api/server-client";
|
||||||
@@ -34,15 +29,7 @@ import type {
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const VALID_PIVOTS: Pivot[] = [
|
const VALID_PIVOTS: Pivot[] = ["safety_governor", "tool_runtime"];
|
||||||
"goal_orchestrator",
|
|
||||||
"safety_governor",
|
|
||||||
"perception",
|
|
||||||
"world_model",
|
|
||||||
"planner",
|
|
||||||
"tool_runtime",
|
|
||||||
"agent_society",
|
|
||||||
];
|
|
||||||
|
|
||||||
const PIVOT_SET = new Set<string>(VALID_PIVOTS);
|
const PIVOT_SET = new Set<string>(VALID_PIVOTS);
|
||||||
|
|
||||||
@@ -211,133 +198,11 @@ async function ToolRuntimeDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 太一枢:目标层级树(最高目标 → 任务,可编辑标题/优先级/状态) */
|
/** 太一枢:目标层级树(最高目标 → 任务,可编辑标题/优先级/状态) */
|
||||||
async function GoalOrchestratorDetail() {
|
|
||||||
let goals: GoalNode[] = [];
|
|
||||||
let error: string | null = null;
|
|
||||||
try {
|
|
||||||
goals = await serverApi.get<GoalNode[]>("/admin/goals");
|
|
||||||
} catch (e) {
|
|
||||||
error = describeError(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContainer
|
|
||||||
title="太一枢"
|
|
||||||
description="目标统御 · 最高目标与任务调度"
|
|
||||||
maxWidth="max-w-3xl"
|
|
||||||
>
|
|
||||||
{error ? (
|
|
||||||
<ErrorState message={error} />
|
|
||||||
) : goals.length === 0 ? (
|
|
||||||
<EmptyState title="暂无目标" description="太一枢尚未编排任何目标。" />
|
|
||||||
) : (
|
|
||||||
<GoalTree goals={goals} />
|
|
||||||
)}
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 烛照枢:感知通道授权矩阵(启用 / 授权 / 隐私路由) */
|
/** 烛照枢:感知通道授权矩阵(启用 / 授权 / 隐私路由) */
|
||||||
async function PerceptionDetail() {
|
|
||||||
let channels: PerceptionChannel[] = [];
|
|
||||||
let error: string | null = null;
|
|
||||||
try {
|
|
||||||
channels = await serverApi.get<PerceptionChannel[]>(
|
|
||||||
"/admin/perception/channels",
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
error = describeError(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContainer
|
|
||||||
title="烛照枢"
|
|
||||||
description="感知世界 · 通道授权与隐私边界"
|
|
||||||
maxWidth="max-w-3xl"
|
|
||||||
>
|
|
||||||
{error ? (
|
|
||||||
<ErrorState message={error} />
|
|
||||||
) : (
|
|
||||||
<PerceptionConsentMatrix channels={channels} />
|
|
||||||
)}
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 河图枢:世界实体列表(类型 / 事实-推断 / 置信度) */
|
/** 河图枢:世界实体列表(类型 / 事实-推断 / 置信度) */
|
||||||
async function WorldModelDetail() {
|
|
||||||
let entities: WorldEntity[] = [];
|
|
||||||
let error: string | null = null;
|
|
||||||
try {
|
|
||||||
entities = await serverApi.get<WorldEntity[]>("/admin/world/entities");
|
|
||||||
} catch (e) {
|
|
||||||
error = describeError(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContainer
|
|
||||||
title="河图枢"
|
|
||||||
description="世界模型 · 实体与关系图式"
|
|
||||||
maxWidth="max-w-3xl"
|
|
||||||
>
|
|
||||||
{error ? (
|
|
||||||
<ErrorState message={error} />
|
|
||||||
) : entities.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
title="暂无世界实体"
|
|
||||||
description="河图枢尚未建模任何实体。"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<WorldEntityList entities={entities} />
|
|
||||||
)}
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 洛书枢:计划控制台(状态 / 假设 / 风险) */
|
/** 洛书枢:计划控制台(状态 / 假设 / 风险) */
|
||||||
async function PlannerDetail() {
|
|
||||||
let plans: PlanSummary[] = [];
|
|
||||||
let error: string | null = null;
|
|
||||||
try {
|
|
||||||
plans = await serverApi.get<PlanSummary[]>("/admin/plans");
|
|
||||||
} catch (e) {
|
|
||||||
error = describeError(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContainer
|
|
||||||
title="洛书枢"
|
|
||||||
description="推演变化 · 规划与流程编排"
|
|
||||||
maxWidth="max-w-3xl"
|
|
||||||
>
|
|
||||||
{error ? (
|
|
||||||
<ErrorState message={error} />
|
|
||||||
) : plans.length === 0 ? (
|
|
||||||
<EmptyState title="暂无计划" description="洛书枢尚未编排任何计划。" />
|
|
||||||
) : (
|
|
||||||
<PlanConsole plans={plans} />
|
|
||||||
)}
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 天工阁枢:九角色在岗名册(启用 / 停用) */
|
/** 天工阁枢:九角色在岗名册(启用 / 停用) */
|
||||||
async function AgentSocietyDetail() {
|
|
||||||
let roster: AgentRosterEntry[] = [];
|
|
||||||
let error: string | null = null;
|
|
||||||
try {
|
|
||||||
roster = await serverApi.get<AgentRosterEntry[]>("/admin/agents/roster");
|
|
||||||
} catch (e) {
|
|
||||||
error = describeError(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContainer
|
|
||||||
title="天工阁枢"
|
|
||||||
description="众智能协作 · 九角色分工与协作"
|
|
||||||
maxWidth="max-w-3xl"
|
|
||||||
>
|
|
||||||
{error ? <ErrorState message={error} /> : <AgentRoster roster={roster} />}
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { PageContainer } from "@/components/layout/PageContainer";
|
|
||||||
import { PlanConsole } from "@/components/admin/PlanConsole";
|
|
||||||
import { ErrorState } from "@/components/shared/StateViews";
|
|
||||||
|
|
||||||
import { serverApi } from "@/lib/api/server-client";
|
|
||||||
import { describeError } from "@/lib/utils/error";
|
|
||||||
import type { PlanSummary } from "@/types/api";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function AdminPlansPage() {
|
|
||||||
let plans: PlanSummary[] = [];
|
|
||||||
let error: string | null = null;
|
|
||||||
try {
|
|
||||||
plans = await serverApi.get("/admin/plans");
|
|
||||||
} catch (e) {
|
|
||||||
error = describeError(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContainer
|
|
||||||
title="洛书枢"
|
|
||||||
description="计划控制台——规划治理,支持增删改。"
|
|
||||||
maxWidth="max-w-5xl"
|
|
||||||
>
|
|
||||||
{error ? (
|
|
||||||
<ErrorState message={error} />
|
|
||||||
) : (
|
|
||||||
<PlanConsole plans={plans ?? []} />
|
|
||||||
)}
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { PageContainer } from "@/components/layout/PageContainer";
|
|
||||||
import { WorldEntityList } from "@/components/admin/WorldEntityList";
|
|
||||||
import { ErrorState } from "@/components/shared/StateViews";
|
|
||||||
|
|
||||||
import { serverApi } from "@/lib/api/server-client";
|
|
||||||
import { describeError } from "@/lib/utils/error";
|
|
||||||
import type { WorldEntity } from "@/types/api";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function AdminWorldPage() {
|
|
||||||
let entities: WorldEntity[] = [];
|
|
||||||
let error: string | null = null;
|
|
||||||
try {
|
|
||||||
entities = await serverApi.get("/admin/world/entities");
|
|
||||||
} catch (e) {
|
|
||||||
error = describeError(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContainer
|
|
||||||
title="河图枢"
|
|
||||||
description="世界实体管理——人物对世界的认知实体,支持增删改。"
|
|
||||||
maxWidth="max-w-5xl"
|
|
||||||
>
|
|
||||||
{error ? (
|
|
||||||
<ErrorState message={error} />
|
|
||||||
) : (
|
|
||||||
<WorldEntityList entities={entities ?? []} />
|
|
||||||
)}
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { adminApi } from "@/lib/api";
|
|
||||||
import type { AgentRosterEntry } from "@/types/api";
|
|
||||||
import { AGENT_PRESENCE_LABEL } from "@/lib/constants/labels";
|
|
||||||
import { cn } from "@/lib/utils/cn";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 天工阁枢角色名册 —— 多智能体治理(可增删改)。
|
|
||||||
* 默认九角色(契约字典 §5.14),亦可新增自定义角色。inline toggle 在编/停用、删除。
|
|
||||||
* POST/PATCH/DELETE /admin/agents/roster。在编 jade、停用 muted;呈现态静态点(设计 §8 克制)。
|
|
||||||
*/
|
|
||||||
export function AgentRoster({
|
|
||||||
roster: initial,
|
|
||||||
}: {
|
|
||||||
roster: AgentRosterEntry[];
|
|
||||||
}) {
|
|
||||||
const [roster, setRoster] = useState(initial);
|
|
||||||
const [savingRole, setSavingRole] = useState<string | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [showAdd, setShowAdd] = useState(false);
|
|
||||||
const [newAgent, setNewAgent] = useState<{
|
|
||||||
role: string;
|
|
||||||
name: string;
|
|
||||||
responsibility: string;
|
|
||||||
}>({
|
|
||||||
role: "",
|
|
||||||
name: "",
|
|
||||||
responsibility: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
async function toggle(entry: AgentRosterEntry) {
|
|
||||||
setSavingRole(entry.role);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const updated = await adminApi.updateAgent(entry.role, {
|
|
||||||
enabled: !entry.enabled,
|
|
||||||
});
|
|
||||||
setRoster((prev) =>
|
|
||||||
prev.map((a) =>
|
|
||||||
a.role === entry.role ? { ...a, enabled: updated.enabled } : a,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "更新失败,请重试。");
|
|
||||||
} finally {
|
|
||||||
setSavingRole(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function remove(role: string) {
|
|
||||||
setSavingRole(role);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await adminApi.deleteAgent(role);
|
|
||||||
setRoster((prev) => prev.filter((a) => a.role !== role));
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "删除失败,请重试。");
|
|
||||||
} finally {
|
|
||||||
setSavingRole(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function add() {
|
|
||||||
if (!newAgent.role.trim() || !newAgent.name.trim()) {
|
|
||||||
setError("角色标识与名称必填。");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSavingRole("__new");
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const created = await adminApi.createAgent({
|
|
||||||
role: newAgent.role.trim(),
|
|
||||||
name: newAgent.name.trim(),
|
|
||||||
responsibility: newAgent.responsibility.trim() || "自定义角色",
|
|
||||||
});
|
|
||||||
setRoster((prev) => [...prev, created]);
|
|
||||||
setNewAgent({ role: "", name: "", responsibility: "" });
|
|
||||||
setShowAdd(false);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "新增失败,请重试。");
|
|
||||||
} finally {
|
|
||||||
setSavingRole(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{showAdd ? (
|
|
||||||
<div className="glass mb-2 space-y-2 rounded-xl px-4 py-3">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<input
|
|
||||||
value={newAgent.role}
|
|
||||||
onChange={(e) =>
|
|
||||||
setNewAgent((s) => ({ ...s, role: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="角色标识(如 archivist)"
|
|
||||||
className="w-40 shrink-0 rounded-lg border border-border-glass bg-glass px-2 py-1 font-mono text-xs text-fg"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
value={newAgent.name}
|
|
||||||
onChange={(e) =>
|
|
||||||
setNewAgent((s) => ({ ...s, name: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="中文名(如 典藏)"
|
|
||||||
className="min-w-0 flex-1 rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
value={newAgent.responsibility}
|
|
||||||
onChange={(e) =>
|
|
||||||
setNewAgent((s) => ({ ...s, responsibility: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="职责(可选)"
|
|
||||||
className="w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={add}
|
|
||||||
disabled={savingRole === "__new"}
|
|
||||||
className="rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
|
|
||||||
>
|
|
||||||
新增
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowAdd(false)}
|
|
||||||
className="rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowAdd(true)}
|
|
||||||
className="mb-2 rounded-lg border border-patina/40 bg-patina/10 px-3 py-1.5 text-xs font-medium text-patina transition-colors hover:bg-patina/20"
|
|
||||||
>
|
|
||||||
+ 新增角色
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ul className="grid gap-2 sm:grid-cols-2">
|
|
||||||
{roster.map((a) => (
|
|
||||||
<li
|
|
||||||
key={a.role}
|
|
||||||
className={cn(
|
|
||||||
"glass rounded-xl px-4 py-3 transition-opacity",
|
|
||||||
!a.enabled && "opacity-55",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"h-2 w-2 shrink-0 rounded-full",
|
|
||||||
a.presence === "active" ? "bg-patina" : "bg-muted/40",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="font-serif text-sm font-medium text-fg">
|
|
||||||
{a.name}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 text-[10px] text-muted">
|
|
||||||
{AGENT_PRESENCE_LABEL[a.presence]}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggle(a)}
|
|
||||||
disabled={savingRole === a.role}
|
|
||||||
aria-label={`${a.name} 启用`}
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 rounded-full px-2.5 py-0.5 text-[10px] font-medium transition-colors disabled:opacity-50",
|
|
||||||
a.enabled
|
|
||||||
? "bg-patina/20 text-patina"
|
|
||||||
: "border border-border-glass text-muted hover:text-fg",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{a.enabled ? "在编" : "停用"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 flex items-center gap-2 pl-4">
|
|
||||||
<p className="flex-1 text-xs text-muted">{a.responsibility}</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => remove(a.role)}
|
|
||||||
disabled={savingRole === a.role}
|
|
||||||
className="shrink-0 text-[11px] text-cinnabar hover:underline disabled:opacity-50"
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
{error && <p className="mt-2 px-1 text-xs text-cinnabar">{error}</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,340 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { adminApi } from "@/lib/api";
|
|
||||||
import type { GoalLevel, GoalStatus } from "@/types/domain";
|
|
||||||
import type { GoalNode } from "@/types/api";
|
|
||||||
import {
|
|
||||||
GOAL_LEVEL_LABEL,
|
|
||||||
GOAL_STATUS_LABEL,
|
|
||||||
GOAL_TYPE_LABEL,
|
|
||||||
} from "@/lib/constants/labels";
|
|
||||||
import { cn } from "@/lib/utils/cn";
|
|
||||||
|
|
||||||
const LEVEL_ORDER: GoalLevel[] = [
|
|
||||||
"highest",
|
|
||||||
"long_term",
|
|
||||||
"session",
|
|
||||||
"task",
|
|
||||||
"action",
|
|
||||||
];
|
|
||||||
/** 新增可选层级(不含 highest 使命级——使命不可手动创建) */
|
|
||||||
const LEVEL_OPTIONS: GoalLevel[] = ["long_term", "session", "task", "action"];
|
|
||||||
const STATUS_OPTIONS: GoalStatus[] = [
|
|
||||||
"pending",
|
|
||||||
"active",
|
|
||||||
"completed",
|
|
||||||
"blocked",
|
|
||||||
"cancelled",
|
|
||||||
];
|
|
||||||
|
|
||||||
const STATUS_DOT: Record<GoalStatus, string> = {
|
|
||||||
pending: "bg-muted/50",
|
|
||||||
active: "bg-patina",
|
|
||||||
completed: "bg-gold",
|
|
||||||
blocked: "bg-cinnabar",
|
|
||||||
cancelled: "bg-muted/30",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 太一枢目标层级树 —— 目标统御治理视图(可增删改)。
|
|
||||||
* 按 level 分层;新增目标(非 highest)、编辑标题/优先级、select 改状态、删除。
|
|
||||||
* highest 为使命级:状态锁定、不可删(契约字典 §5.11)。状态圆点静态着色(设计 §8 克制)。
|
|
||||||
*/
|
|
||||||
export function GoalTree({ goals: initial }: { goals: GoalNode[] }) {
|
|
||||||
const [goals, setGoals] = useState(initial);
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
|
||||||
const [draft, setDraft] = useState<{ title: string; priority: number }>({
|
|
||||||
title: "",
|
|
||||||
priority: 0,
|
|
||||||
});
|
|
||||||
const [busyId, setBusyId] = useState<string | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [showAdd, setShowAdd] = useState(false);
|
|
||||||
const [newGoal, setNewGoal] = useState<{ level: GoalLevel; title: string }>({
|
|
||||||
level: "task",
|
|
||||||
title: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
function startEdit(g: GoalNode) {
|
|
||||||
setEditingId(g.id);
|
|
||||||
setDraft({ title: g.title, priority: g.priority });
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function changeStatus(g: GoalNode, next: GoalStatus) {
|
|
||||||
setBusyId(g.id);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const updated = await adminApi.updateGoal(g.id, { status: next });
|
|
||||||
setGoals((prev) =>
|
|
||||||
prev.map((x) => (x.id === g.id ? { ...x, status: updated.status } : x)),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "更新失败,请重试。");
|
|
||||||
} finally {
|
|
||||||
setBusyId(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save(id: string) {
|
|
||||||
setBusyId(id);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const updated = await adminApi.updateGoal(id, {
|
|
||||||
title: draft.title,
|
|
||||||
priority: draft.priority,
|
|
||||||
});
|
|
||||||
setGoals((prev) =>
|
|
||||||
prev.map((x) =>
|
|
||||||
x.id === id
|
|
||||||
? { ...x, title: updated.title, priority: updated.priority }
|
|
||||||
: x,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
setEditingId(null);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "保存失败,请重试。");
|
|
||||||
} finally {
|
|
||||||
setBusyId(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function remove(g: GoalNode) {
|
|
||||||
setBusyId(g.id);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await adminApi.deleteGoal(g.id);
|
|
||||||
setGoals((prev) => prev.filter((x) => x.id !== g.id));
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "删除失败,请重试。");
|
|
||||||
} finally {
|
|
||||||
setBusyId(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function add() {
|
|
||||||
if (!newGoal.title.trim()) {
|
|
||||||
setError("请填写目标标题。");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setBusyId("__new");
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const created = await adminApi.createGoal({
|
|
||||||
title: newGoal.title.trim(),
|
|
||||||
level: newGoal.level,
|
|
||||||
});
|
|
||||||
setGoals((prev) => [...prev, created]);
|
|
||||||
setNewGoal({ level: "task", title: "" });
|
|
||||||
setShowAdd(false);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "新增失败,请重试。");
|
|
||||||
} finally {
|
|
||||||
setBusyId(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
{showAdd ? (
|
|
||||||
<div className="glass space-y-2 rounded-xl px-4 py-3">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<select
|
|
||||||
value={newGoal.level}
|
|
||||||
onChange={(e) =>
|
|
||||||
setNewGoal((s) => ({
|
|
||||||
...s,
|
|
||||||
level: e.target.value as GoalLevel,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
aria-label="目标层级"
|
|
||||||
className="w-24 shrink-0 rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg backdrop-blur"
|
|
||||||
>
|
|
||||||
{LEVEL_OPTIONS.map((l) => (
|
|
||||||
<option key={l} value={l}>
|
|
||||||
{GOAL_LEVEL_LABEL[l]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
value={newGoal.title}
|
|
||||||
onChange={(e) =>
|
|
||||||
setNewGoal((s) => ({ ...s, title: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="目标标题…"
|
|
||||||
className="min-w-0 flex-1 rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={add}
|
|
||||||
disabled={busyId === "__new"}
|
|
||||||
className="shrink-0 rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
|
|
||||||
>
|
|
||||||
新增
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowAdd(false)}
|
|
||||||
className="shrink-0 rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowAdd(true)}
|
|
||||||
className="rounded-lg border border-patina/40 bg-patina/10 px-3 py-1.5 text-xs font-medium text-patina transition-colors hover:bg-patina/20"
|
|
||||||
>
|
|
||||||
+ 新增目标
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{LEVEL_ORDER.map((level) => {
|
|
||||||
const items = goals.filter((g) => g.level === level);
|
|
||||||
if (items.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<section key={level}>
|
|
||||||
<div className="mb-2 flex items-center gap-2">
|
|
||||||
<span className="font-serif text-xs font-medium text-gold">
|
|
||||||
{GOAL_LEVEL_LABEL[level]}
|
|
||||||
</span>
|
|
||||||
<span className="h-px flex-1 bg-gold/20" />
|
|
||||||
</div>
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{items.map((g) => (
|
|
||||||
<li key={g.id} className="glass rounded-xl px-4 py-2.5">
|
|
||||||
{editingId === g.id ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<input
|
|
||||||
value={draft.title}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft((d) => ({ ...d, title: e.target.value }))
|
|
||||||
}
|
|
||||||
className="w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="text-[10px] text-muted">优先级</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={draft.priority}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft((d) => ({
|
|
||||||
...d,
|
|
||||||
priority: Number(e.target.value),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
className="w-20 rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg"
|
|
||||||
/>
|
|
||||||
<div className="ml-auto flex gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => save(g.id)}
|
|
||||||
disabled={busyId === g.id}
|
|
||||||
className="rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
|
|
||||||
>
|
|
||||||
保存
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setEditingId(null)}
|
|
||||||
className="rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"h-2 w-2 shrink-0 rounded-full",
|
|
||||||
STATUS_DOT[g.status],
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="font-serif text-sm font-medium text-fg">
|
|
||||||
{g.title}
|
|
||||||
</span>
|
|
||||||
<span className="ml-auto shrink-0 font-mono text-[10px] text-muted">
|
|
||||||
P{g.priority}
|
|
||||||
</span>
|
|
||||||
<select
|
|
||||||
value={g.status}
|
|
||||||
onChange={(e) =>
|
|
||||||
changeStatus(g, e.target.value as GoalStatus)
|
|
||||||
}
|
|
||||||
disabled={busyId === g.id || g.level === "highest"}
|
|
||||||
aria-label={`${g.title} 状态`}
|
|
||||||
className="w-24 shrink-0 rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg backdrop-blur disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{STATUS_OPTIONS.map((s) => (
|
|
||||||
<option key={s} value={s}>
|
|
||||||
{GOAL_STATUS_LABEL[s]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{/* 进度条 */}
|
|
||||||
<div className="mt-2 flex items-center gap-2 pl-4">
|
|
||||||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted/20">
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full bg-patina transition-all"
|
|
||||||
style={{
|
|
||||||
width: `${Math.round(g.progress * 100)}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="font-mono text-[10px] text-muted">
|
|
||||||
{Math.round(g.progress * 100)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/* 类型 */}
|
|
||||||
{g.type && (
|
|
||||||
<span className="mt-1 block pl-4 text-[10px] text-muted">
|
|
||||||
类型:{GOAL_TYPE_LABEL[g.type] ?? g.type}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<p className="mt-1 pl-4 text-xs text-muted">
|
|
||||||
{g.description}
|
|
||||||
</p>
|
|
||||||
{g.constraints.length > 0 && (
|
|
||||||
<p className="mt-1 pl-4 text-[10px] text-muted">
|
|
||||||
约束:{g.constraints.join(" · ")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="mt-1.5 flex gap-3 pl-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => startEdit(g)}
|
|
||||||
className="text-[11px] text-patina hover:underline"
|
|
||||||
>
|
|
||||||
编辑标题/优先级
|
|
||||||
</button>
|
|
||||||
{g.level !== "highest" && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => remove(g)}
|
|
||||||
disabled={busyId === g.id}
|
|
||||||
className="text-[11px] text-cinnabar hover:underline disabled:opacity-50"
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{error && <p className="text-xs text-cinnabar">{error}</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { adminApi } from "@/lib/api";
|
|
||||||
import type { ConsentStatus, PrivacyLevel } from "@/types/domain";
|
|
||||||
import type { PerceptionChannel } from "@/types/api";
|
|
||||||
import { CONSENT_STATUS_LABEL } from "@/lib/constants/labels";
|
|
||||||
import { cn } from "@/lib/utils/cn";
|
|
||||||
|
|
||||||
const CONSENT_OPTIONS: ConsentStatus[] = [
|
|
||||||
"unknown",
|
|
||||||
"granted",
|
|
||||||
"restricted",
|
|
||||||
"revoked",
|
|
||||||
];
|
|
||||||
const PRIVACY_OPTIONS: PrivacyLevel[] = ["normal", "sensitive", "local_only"];
|
|
||||||
|
|
||||||
/** 隐私路由中文呈现(privacy_level,契约字典 §5.4) */
|
|
||||||
const PRIVACY_LABEL: Record<PrivacyLevel, string> = {
|
|
||||||
normal: "普通",
|
|
||||||
sensitive: "敏感",
|
|
||||||
local_only: "仅本地",
|
|
||||||
};
|
|
||||||
|
|
||||||
const CONSENT_DOT: Record<ConsentStatus, string> = {
|
|
||||||
unknown: "bg-muted/40",
|
|
||||||
granted: "bg-patina",
|
|
||||||
restricted: "bg-gold",
|
|
||||||
revoked: "bg-cinnabar",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 烛照枢感知通道授权矩阵 —— 隐私边界治理(可增删改)。
|
|
||||||
* 默认六类通道,亦可新增自定义感知源。每通道 inline 编辑 enabled / consent / privacy、删除。
|
|
||||||
* POST/PATCH/DELETE /admin/perception/channels。授权 jade、撤销 cinnabar。
|
|
||||||
*/
|
|
||||||
export function PerceptionConsentMatrix({
|
|
||||||
channels: initial,
|
|
||||||
}: {
|
|
||||||
channels: PerceptionChannel[];
|
|
||||||
}) {
|
|
||||||
const [channels, setChannels] = useState(initial);
|
|
||||||
const [savingCh, setSavingCh] = useState<string | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [showAdd, setShowAdd] = useState(false);
|
|
||||||
const [newCh, setNewCh] = useState<{
|
|
||||||
channel: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
}>({
|
|
||||||
channel: "",
|
|
||||||
name: "",
|
|
||||||
description: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
async function patch(
|
|
||||||
channel: string,
|
|
||||||
body: Partial<{
|
|
||||||
enabled: boolean;
|
|
||||||
consent_status: ConsentStatus;
|
|
||||||
privacy_level: PrivacyLevel;
|
|
||||||
}>,
|
|
||||||
) {
|
|
||||||
setSavingCh(channel);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const updated = await adminApi.updatePerceptionChannel(channel, body);
|
|
||||||
setChannels((prev) =>
|
|
||||||
prev.map((c) => (c.channel === channel ? { ...c, ...updated } : c)),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "更新失败,请重试。");
|
|
||||||
} finally {
|
|
||||||
setSavingCh(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function remove(channel: string) {
|
|
||||||
setSavingCh(channel);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await adminApi.deletePerceptionChannel(channel);
|
|
||||||
setChannels((prev) => prev.filter((c) => c.channel !== channel));
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "删除失败,请重试。");
|
|
||||||
} finally {
|
|
||||||
setSavingCh(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function add() {
|
|
||||||
if (!newCh.channel.trim() || !newCh.name.trim()) {
|
|
||||||
setError("通道标识与名称必填。");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSavingCh("__new");
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const created = await adminApi.createPerceptionChannel({
|
|
||||||
channel: newCh.channel.trim(),
|
|
||||||
name: newCh.name.trim(),
|
|
||||||
description: newCh.description.trim() || "自定义感知源",
|
|
||||||
});
|
|
||||||
setChannels((prev) => [...prev, created]);
|
|
||||||
setNewCh({ channel: "", name: "", description: "" });
|
|
||||||
setShowAdd(false);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "新增失败,请重试。");
|
|
||||||
} finally {
|
|
||||||
setSavingCh(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{showAdd ? (
|
|
||||||
<div className="glass mb-2 space-y-2 rounded-xl px-4 py-3">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<input
|
|
||||||
value={newCh.channel}
|
|
||||||
onChange={(e) =>
|
|
||||||
setNewCh((s) => ({ ...s, channel: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="通道标识(如 sensor_gps)"
|
|
||||||
className="w-40 shrink-0 rounded-lg border border-border-glass bg-glass px-2 py-1 font-mono text-xs text-fg"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
value={newCh.name}
|
|
||||||
onChange={(e) =>
|
|
||||||
setNewCh((s) => ({ ...s, name: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="中文名(如 定位)"
|
|
||||||
className="min-w-0 flex-1 rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
value={newCh.description}
|
|
||||||
onChange={(e) =>
|
|
||||||
setNewCh((s) => ({ ...s, description: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="描述(可选)"
|
|
||||||
className="w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={add}
|
|
||||||
disabled={savingCh === "__new"}
|
|
||||||
className="rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
|
|
||||||
>
|
|
||||||
新增
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowAdd(false)}
|
|
||||||
className="rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowAdd(true)}
|
|
||||||
className="mb-2 rounded-lg border border-patina/40 bg-patina/10 px-3 py-1.5 text-xs font-medium text-patina transition-colors hover:bg-patina/20"
|
|
||||||
>
|
|
||||||
+ 新增感知通道
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="glass overflow-hidden rounded-2xl">
|
|
||||||
<ul className="divide-y divide-border-glass">
|
|
||||||
{channels.map((c) => (
|
|
||||||
<li key={c.channel} className="px-4 py-3">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => patch(c.channel, { enabled: !c.enabled })}
|
|
||||||
disabled={savingCh === c.channel}
|
|
||||||
aria-label={`${c.name} 启用开关`}
|
|
||||||
className={cn(
|
|
||||||
"relative h-4 w-7 shrink-0 rounded-full transition-colors disabled:opacity-50",
|
|
||||||
c.enabled ? "bg-patina/60" : "bg-muted/30",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"absolute top-0.5 h-3 w-3 rounded-full bg-glass-strong transition-all",
|
|
||||||
c.enabled ? "left-3.5" : "left-0.5",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"h-2 w-2 shrink-0 rounded-full",
|
|
||||||
CONSENT_DOT[c.consent_status],
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="font-serif text-sm font-medium text-fg">
|
|
||||||
{c.name}
|
|
||||||
</span>
|
|
||||||
<span className="ml-auto flex shrink-0 items-center gap-1.5">
|
|
||||||
<select
|
|
||||||
value={c.consent_status}
|
|
||||||
onChange={(e) =>
|
|
||||||
patch(c.channel, {
|
|
||||||
consent_status: e.target.value as ConsentStatus,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={savingCh === c.channel}
|
|
||||||
aria-label={`${c.name} 授权状态`}
|
|
||||||
className="w-20 rounded-lg border border-border-glass bg-glass px-1.5 py-1 text-[11px] text-fg backdrop-blur disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{CONSENT_OPTIONS.map((cs) => (
|
|
||||||
<option key={cs} value={cs}>
|
|
||||||
{CONSENT_STATUS_LABEL[cs]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={c.privacy_level}
|
|
||||||
onChange={(e) =>
|
|
||||||
patch(c.channel, {
|
|
||||||
privacy_level: e.target.value as PrivacyLevel,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={savingCh === c.channel}
|
|
||||||
aria-label={`${c.name} 隐私路由`}
|
|
||||||
className="w-20 rounded-lg border border-border-glass bg-glass px-1.5 py-1 text-[11px] text-fg backdrop-blur disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{PRIVACY_OPTIONS.map((pl) => (
|
|
||||||
<option key={pl} value={pl}>
|
|
||||||
{PRIVACY_LABEL[pl]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 flex items-center gap-2 pl-8">
|
|
||||||
<p className="flex-1 text-xs text-muted">{c.description}</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => remove(c.channel)}
|
|
||||||
disabled={savingCh === c.channel}
|
|
||||||
className="shrink-0 text-[11px] text-cinnabar hover:underline disabled:opacity-50"
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{error && <p className="mt-2 px-1 text-xs text-cinnabar">{error}</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { adminApi } from "@/lib/api";
|
|
||||||
import type { PlanStatus } from "@/types/domain";
|
|
||||||
import type { PlanSummary } from "@/types/api";
|
|
||||||
import { PLAN_STATUS_LABEL } from "@/lib/constants/labels";
|
|
||||||
import { cn } from "@/lib/utils/cn";
|
|
||||||
|
|
||||||
const STATUS_OPTIONS: PlanStatus[] = [
|
|
||||||
"draft",
|
|
||||||
"approved",
|
|
||||||
"running",
|
|
||||||
"completed",
|
|
||||||
"failed",
|
|
||||||
];
|
|
||||||
|
|
||||||
const STATUS_DOT: Record<PlanStatus, string> = {
|
|
||||||
draft: "bg-gold",
|
|
||||||
approved: "bg-patina",
|
|
||||||
running: "bg-patina",
|
|
||||||
completed: "bg-muted/50",
|
|
||||||
failed: "bg-cinnabar",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 洛书枢计划控制台 —— 规划治理(可增删改)。
|
|
||||||
* 新增计划、inline select 改状态、展开编辑假设 / 风险、删除。
|
|
||||||
* PATCH /admin/plans/{id};假设与风险以换行分隔编辑,保存时拆为数组;风险以 cinnabar 提示。
|
|
||||||
*/
|
|
||||||
export function PlanConsole({ plans: initial }: { plans: PlanSummary[] }) {
|
|
||||||
const [plans, setPlans] = useState(initial);
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
|
||||||
const [draft, setDraft] = useState<{ assumptions: string; risks: string }>({
|
|
||||||
assumptions: "",
|
|
||||||
risks: "",
|
|
||||||
});
|
|
||||||
const [busyId, setBusyId] = useState<string | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [showAdd, setShowAdd] = useState(false);
|
|
||||||
const [newPlan, setNewPlan] = useState("");
|
|
||||||
|
|
||||||
function startEdit(p: PlanSummary) {
|
|
||||||
setEditingId(p.id);
|
|
||||||
setDraft({
|
|
||||||
assumptions: p.assumptions.join("\n"),
|
|
||||||
risks: p.risks.join("\n"),
|
|
||||||
});
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function changeStatus(p: PlanSummary, next: PlanStatus) {
|
|
||||||
setBusyId(p.id);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const updated = await adminApi.updatePlan(p.id, { status: next });
|
|
||||||
setPlans((prev) =>
|
|
||||||
prev.map((x) => (x.id === p.id ? { ...x, status: updated.status } : x)),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "更新失败,请重试。");
|
|
||||||
} finally {
|
|
||||||
setBusyId(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save(id: string) {
|
|
||||||
setBusyId(id);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const assumptions = draft.assumptions
|
|
||||||
.split("\n")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
const risks = draft.risks
|
|
||||||
.split("\n")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
const updated = await adminApi.updatePlan(id, { assumptions, risks });
|
|
||||||
setPlans((prev) =>
|
|
||||||
prev.map((x) =>
|
|
||||||
x.id === id
|
|
||||||
? { ...x, assumptions: updated.assumptions, risks: updated.risks }
|
|
||||||
: x,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
setEditingId(null);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "保存失败,请重试。");
|
|
||||||
} finally {
|
|
||||||
setBusyId(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function remove(p: PlanSummary) {
|
|
||||||
setBusyId(p.id);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await adminApi.deletePlan(p.id);
|
|
||||||
setPlans((prev) => prev.filter((x) => x.id !== p.id));
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "删除失败,请重试。");
|
|
||||||
} finally {
|
|
||||||
setBusyId(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function add() {
|
|
||||||
if (!newPlan.trim()) {
|
|
||||||
setError("请填写计划标题。");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setBusyId("__new");
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const created = await adminApi.createPlan({ title: newPlan.trim() });
|
|
||||||
setPlans((prev) => [created, ...prev]);
|
|
||||||
setNewPlan("");
|
|
||||||
setShowAdd(false);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "新增失败,请重试。");
|
|
||||||
} finally {
|
|
||||||
setBusyId(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{showAdd ? (
|
|
||||||
<div className="glass mb-2 space-y-2 rounded-xl px-4 py-3">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<input
|
|
||||||
value={newPlan}
|
|
||||||
onChange={(e) => setNewPlan(e.target.value)}
|
|
||||||
placeholder="计划标题…"
|
|
||||||
className="min-w-0 flex-1 rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={add}
|
|
||||||
disabled={busyId === "__new"}
|
|
||||||
className="shrink-0 rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
|
|
||||||
>
|
|
||||||
新增
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowAdd(false)}
|
|
||||||
className="shrink-0 rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowAdd(true)}
|
|
||||||
className="mb-2 rounded-lg border border-patina/40 bg-patina/10 px-3 py-1.5 text-xs font-medium text-patina transition-colors hover:bg-patina/20"
|
|
||||||
>
|
|
||||||
+ 新增计划
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{plans.map((p) => (
|
|
||||||
<li key={p.id} className="glass rounded-xl px-4 py-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"h-2 w-2 shrink-0 rounded-full",
|
|
||||||
STATUS_DOT[p.status],
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="truncate font-serif text-sm font-medium text-fg">
|
|
||||||
{p.title}
|
|
||||||
</span>
|
|
||||||
<span className="ml-auto shrink-0 font-mono text-[10px] text-muted">
|
|
||||||
{p.completed_steps}/{p.step_count}
|
|
||||||
</span>
|
|
||||||
<select
|
|
||||||
value={p.status}
|
|
||||||
onChange={(e) => changeStatus(p, e.target.value as PlanStatus)}
|
|
||||||
disabled={busyId === p.id}
|
|
||||||
aria-label={`${p.title} 状态`}
|
|
||||||
className="w-20 shrink-0 rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg backdrop-blur disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{STATUS_OPTIONS.map((s) => (
|
|
||||||
<option key={s} value={s}>
|
|
||||||
{PLAN_STATUS_LABEL[s]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{editingId === p.id ? (
|
|
||||||
<div className="mt-2 space-y-2">
|
|
||||||
<div>
|
|
||||||
<label className="text-[10px] text-muted">
|
|
||||||
假设(每行一条)
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={draft.assumptions}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft((d) => ({ ...d, assumptions: e.target.value }))
|
|
||||||
}
|
|
||||||
rows={2}
|
|
||||||
className="w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-[10px] text-muted">
|
|
||||||
风险(每行一条)
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={draft.risks}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft((d) => ({ ...d, risks: e.target.value }))
|
|
||||||
}
|
|
||||||
rows={2}
|
|
||||||
className="w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => save(p.id)}
|
|
||||||
disabled={busyId === p.id}
|
|
||||||
className="rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
|
|
||||||
>
|
|
||||||
保存
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setEditingId(null)}
|
|
||||||
className="rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="mt-1 space-y-0.5 pl-4 text-[10px] text-muted">
|
|
||||||
{p.assumptions.length > 0 && (
|
|
||||||
<p>假设:{p.assumptions.join(";")}</p>
|
|
||||||
)}
|
|
||||||
{p.risks.length > 0 && (
|
|
||||||
<p className="text-cinnabar/80">
|
|
||||||
风险:{p.risks.join(";")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1.5 flex gap-3 pl-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => startEdit(p)}
|
|
||||||
className="text-[11px] text-patina hover:underline"
|
|
||||||
>
|
|
||||||
编辑假设/风险
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => remove(p)}
|
|
||||||
disabled={busyId === p.id}
|
|
||||||
className="text-[11px] text-cinnabar hover:underline disabled:opacity-50"
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
{error && <p className="mt-2 text-xs text-cinnabar">{error}</p>}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
import type { PivotHealthSummary } from "@/types/api";
|
|
||||||
import type { Pivot } from "@/types/domain";
|
|
||||||
import { Astrolabe } from "@/components/ui/Astrolabe";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 九枢星盘 · 司命台适配层。
|
|
||||||
* 渲染已抽取到 components/ui/Astrolabe(数据驱动、路由无关);
|
|
||||||
* 此处仅负责把枢点点击接到司命台枢详情路由。签名与旧实现一致,admin 页零改动。
|
|
||||||
*/
|
|
||||||
export function StarMap({
|
|
||||||
pivots,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
pivots: PivotHealthSummary[];
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const router = useRouter();
|
|
||||||
return (
|
|
||||||
<Astrolabe
|
|
||||||
pivots={pivots}
|
|
||||||
className={className}
|
|
||||||
onSelectPivot={(pivot: Pivot) => router.push(`/admin/pivots/${pivot}`)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,322 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { adminApi } from "@/lib/api";
|
|
||||||
import type { WorldEntityType, WorldStateType } from "@/types/domain";
|
|
||||||
import type { WorldEntity } from "@/types/api";
|
|
||||||
import {
|
|
||||||
WORLD_ENTITY_TYPE_LABEL,
|
|
||||||
WORLD_STATE_TYPE_LABEL,
|
|
||||||
} from "@/lib/constants/labels";
|
|
||||||
import { cn } from "@/lib/utils/cn";
|
|
||||||
|
|
||||||
const STATE_OPTIONS: WorldStateType[] = [
|
|
||||||
"fact",
|
|
||||||
"observation",
|
|
||||||
"inference",
|
|
||||||
"hypothesis",
|
|
||||||
"stale",
|
|
||||||
];
|
|
||||||
/** 新增可选类型(不含 self——自身实体不可手动创建) */
|
|
||||||
const TYPE_OPTIONS: WorldEntityType[] = [
|
|
||||||
"person",
|
|
||||||
"object",
|
|
||||||
"place",
|
|
||||||
"device",
|
|
||||||
"project",
|
|
||||||
"task",
|
|
||||||
"concept",
|
|
||||||
];
|
|
||||||
|
|
||||||
const STATE_DOT: Record<WorldStateType, string> = {
|
|
||||||
fact: "bg-patina",
|
|
||||||
observation: "bg-patina",
|
|
||||||
inference: "bg-gold",
|
|
||||||
hypothesis: "bg-gold",
|
|
||||||
stale: "bg-cinnabar",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 河图枢世界实体列表 —— 世界模型治理(可增删改)。
|
|
||||||
* 区分事实与推断(state_type 着色);新增实体(非 self)、编辑名称/置信度、
|
|
||||||
* select 改状态类型、删除。推断与假设召回时降权,不得伪装成事实(契约字典 §11.2)。
|
|
||||||
*/
|
|
||||||
export function WorldEntityList({
|
|
||||||
entities: initial,
|
|
||||||
}: {
|
|
||||||
entities: WorldEntity[];
|
|
||||||
}) {
|
|
||||||
const [entities, setEntities] = useState(initial);
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
|
||||||
const [draft, setDraft] = useState<{ name: string; confidence: number }>({
|
|
||||||
name: "",
|
|
||||||
confidence: 0,
|
|
||||||
});
|
|
||||||
const [busyId, setBusyId] = useState<string | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [showAdd, setShowAdd] = useState(false);
|
|
||||||
const [newEntity, setNewEntity] = useState<{
|
|
||||||
type: WorldEntityType;
|
|
||||||
name: string;
|
|
||||||
}>({
|
|
||||||
type: "concept",
|
|
||||||
name: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
function startEdit(e: WorldEntity) {
|
|
||||||
setEditingId(e.id);
|
|
||||||
setDraft({ name: e.name, confidence: e.confidence });
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function changeState(e: WorldEntity, next: WorldStateType) {
|
|
||||||
setBusyId(e.id);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const updated = await adminApi.updateWorldEntity(e.id, {
|
|
||||||
state_type: next,
|
|
||||||
});
|
|
||||||
setEntities((prev) =>
|
|
||||||
prev.map((x) =>
|
|
||||||
x.id === e.id ? { ...x, state_type: updated.state_type } : x,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "更新失败,请重试。");
|
|
||||||
} finally {
|
|
||||||
setBusyId(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save(id: string) {
|
|
||||||
setBusyId(id);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const updated = await adminApi.updateWorldEntity(id, {
|
|
||||||
name: draft.name,
|
|
||||||
confidence: draft.confidence,
|
|
||||||
});
|
|
||||||
setEntities((prev) =>
|
|
||||||
prev.map((x) =>
|
|
||||||
x.id === id
|
|
||||||
? { ...x, name: updated.name, confidence: updated.confidence }
|
|
||||||
: x,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
setEditingId(null);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "保存失败,请重试。");
|
|
||||||
} finally {
|
|
||||||
setBusyId(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function remove(e: WorldEntity) {
|
|
||||||
setBusyId(e.id);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await adminApi.deleteWorldEntity(e.id);
|
|
||||||
setEntities((prev) => prev.filter((x) => x.id !== e.id));
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "删除失败,请重试。");
|
|
||||||
} finally {
|
|
||||||
setBusyId(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function add() {
|
|
||||||
if (!newEntity.name.trim()) {
|
|
||||||
setError("请填写实体名称。");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setBusyId("__new");
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const created = await adminApi.createWorldEntity({
|
|
||||||
type: newEntity.type,
|
|
||||||
name: newEntity.name.trim(),
|
|
||||||
});
|
|
||||||
setEntities((prev) => [...prev, created]);
|
|
||||||
setNewEntity({ type: "concept", name: "" });
|
|
||||||
setShowAdd(false);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "新增失败,请重试。");
|
|
||||||
} finally {
|
|
||||||
setBusyId(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{showAdd ? (
|
|
||||||
<div className="glass mb-2 space-y-2 rounded-xl px-4 py-3">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<select
|
|
||||||
value={newEntity.type}
|
|
||||||
onChange={(e) =>
|
|
||||||
setNewEntity((s) => ({
|
|
||||||
...s,
|
|
||||||
type: e.target.value as WorldEntityType,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
aria-label="实体类型"
|
|
||||||
className="w-24 shrink-0 rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg backdrop-blur"
|
|
||||||
>
|
|
||||||
{TYPE_OPTIONS.map((t) => (
|
|
||||||
<option key={t} value={t}>
|
|
||||||
{WORLD_ENTITY_TYPE_LABEL[t]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
value={newEntity.name}
|
|
||||||
onChange={(e) =>
|
|
||||||
setNewEntity((s) => ({ ...s, name: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="实体名称…"
|
|
||||||
className="min-w-0 flex-1 rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={add}
|
|
||||||
disabled={busyId === "__new"}
|
|
||||||
className="shrink-0 rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
|
|
||||||
>
|
|
||||||
新增
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowAdd(false)}
|
|
||||||
className="shrink-0 rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowAdd(true)}
|
|
||||||
className="mb-2 rounded-lg border border-patina/40 bg-patina/10 px-3 py-1.5 text-xs font-medium text-patina transition-colors hover:bg-patina/20"
|
|
||||||
>
|
|
||||||
+ 新增世界实体
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{entities.map((e) => (
|
|
||||||
<li key={e.id} className="glass rounded-xl px-4 py-2.5">
|
|
||||||
{editingId === e.id ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<input
|
|
||||||
value={draft.name}
|
|
||||||
onChange={(ev) =>
|
|
||||||
setDraft((d) => ({ ...d, name: ev.target.value }))
|
|
||||||
}
|
|
||||||
className="w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="text-[10px] text-muted">置信度</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={1}
|
|
||||||
step={0.05}
|
|
||||||
value={draft.confidence}
|
|
||||||
onChange={(ev) =>
|
|
||||||
setDraft((d) => ({
|
|
||||||
...d,
|
|
||||||
confidence: Number(ev.target.value),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
className="w-20 rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg"
|
|
||||||
/>
|
|
||||||
<div className="ml-auto flex gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => save(e.id)}
|
|
||||||
disabled={busyId === e.id}
|
|
||||||
className="rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
|
|
||||||
>
|
|
||||||
保存
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setEditingId(null)}
|
|
||||||
className="rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"h-2 w-2 shrink-0 rounded-full",
|
|
||||||
STATE_DOT[e.state_type],
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="rounded bg-gold/15 px-1.5 py-0.5 text-[10px] text-gold">
|
|
||||||
{WORLD_ENTITY_TYPE_LABEL[e.type]}
|
|
||||||
</span>
|
|
||||||
<span className="truncate font-serif text-sm font-medium text-fg">
|
|
||||||
{e.name}
|
|
||||||
</span>
|
|
||||||
<span className="ml-auto shrink-0 font-mono text-[10px] text-muted">
|
|
||||||
{e.confidence.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
<select
|
|
||||||
value={e.state_type}
|
|
||||||
onChange={(ev) =>
|
|
||||||
changeState(e, ev.target.value as WorldStateType)
|
|
||||||
}
|
|
||||||
disabled={busyId === e.id}
|
|
||||||
aria-label={`${e.name} 状态类型`}
|
|
||||||
className="w-20 shrink-0 rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg backdrop-blur disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{STATE_OPTIONS.map((s) => (
|
|
||||||
<option key={s} value={s}>
|
|
||||||
{WORLD_STATE_TYPE_LABEL[s]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 pl-4 text-[10px] text-muted">
|
|
||||||
{Object.entries(e.attributes).map(([k, v]) => (
|
|
||||||
<span key={k}>
|
|
||||||
{k}:{v}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1.5 flex gap-3 pl-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => startEdit(e)}
|
|
||||||
className="text-[11px] text-patina hover:underline"
|
|
||||||
>
|
|
||||||
编辑名称/置信度
|
|
||||||
</button>
|
|
||||||
{e.type !== "self" && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => remove(e)}
|
|
||||||
disabled={busyId === e.id}
|
|
||||||
className="text-[11px] text-cinnabar hover:underline disabled:opacity-50"
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
{error && <p className="mt-2 text-xs text-cinnabar">{error}</p>}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user