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

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

No files matched your search

+4 -2
View File
@@ -9,7 +9,6 @@ from app.api.v1.endpoints import (
characters,
events,
goals,
health,
imports,
embryo_drafts,
@@ -20,6 +19,8 @@ from app.api.v1.endpoints import (
baigong,
hetu,
soul,
kouxin,
tiangong,
xingyi,
yuanqi,
mirror_trees,
@@ -44,6 +45,8 @@ api_router.include_router(kernel_runs.router)
api_router.include_router(me.router)
api_router.include_router(hetu.world_router)
api_router.include_router(soul.soul_router)
api_router.include_router(tiangong.task_router)
api_router.include_router(kouxin.knock_router)
api_router.include_router(xingyi.form_router)
api_router.include_router(yuanqi.cov_router)
for _baigong_r in baigong.routers:
@@ -61,5 +64,4 @@ api_router.include_router(sandbox_files.router)
api_router.include_router(settings.router)
api_router.include_router(imports.router)
api_router.include_router(events.router)
api_router.include_router(goals.router)
api_router.include_router(admin.router)
+12 -107
View File
@@ -17,38 +17,25 @@ from app.api.v1.deps import (
require_admin,
)
from app.models.runtime import Event
from app.schemas.admin import (
AdminCreateUserRequest,
AdminUpdateUserRequest,
AdminUserResponse,
AgentRosterResponse,
AuditEntry,
CreateAgentRequest,
CreateGoalRequest,
CreatePerceptionChannelRequest,
CreatePlanRequest,
GoalNode,
PendingApproval,
PerceptionChannelResponse,
PivotHealthReport,
PlanSummaryResponse,
TimeEventListResponse,
TimeEventResponse,
TraceDetail,
TraceListResponse,
UpdateAgentRequest,
UpdateGoalRequest,
UpdatePerceptionChannelRequest,
UpdatePlanRequest,
)
from app.schemas.admin_character import (
AdminCreateTemplateRequest,
AdminTemplateSummaryResponse,
AdminUpdateTemplateRequest,
)
from app.schemas.admin import (
AdminCreateUserRequest,
AdminUpdateUserRequest,
AdminUserResponse,
AuditEntry,
PendingApproval,
PivotHealthReport,
TimeEventListResponse,
TimeEventResponse,
TraceDetail,
TraceListResponse,
)
from app.schemas.auth import CurrentUser
from app.schemas.common import Page
from app.services import governance as governance_service
from app.services.admin import (
get_audit_timeline,
get_pending_approvals,
@@ -105,111 +92,29 @@ async def audit_timeline(
# —— §19.4 太一枢 · 目标 ——
@router.get("/goals", response_model=list[GoalNode])
async def list_goals(
character_id: str | None = Query(default=None, description="按人物过滤。"),
) -> list[GoalNode]:
if character_id:
return await governance_service.list_goals_by_character(character_id)
return await governance_service.list_goals()
@router.post("/goals", response_model=GoalNode)
async def create_goal(payload: CreateGoalRequest) -> GoalNode:
return await governance_service.create_goal(payload)
@router.patch("/goals/{goal_id}", response_model=GoalNode)
async def update_goal(
goal_id: str,
payload: UpdateGoalRequest,
) -> GoalNode:
return await governance_service.update_goal(goal_id, payload)
@router.delete("/goals/{goal_id}", status_code=204)
async def delete_goal(goal_id: str) -> None:
await governance_service.delete_goal(goal_id)
# —— §19.4 烛照枢 · 感知通道 ——
@router.get("/perception/channels", response_model=list[PerceptionChannelResponse])
async def list_perception_channels() -> list[PerceptionChannelResponse]:
return await governance_service.list_perception_channels()
@router.post("/perception/channels", response_model=PerceptionChannelResponse)
async def create_perception_channel(
payload: CreatePerceptionChannelRequest,
) -> PerceptionChannelResponse:
return await governance_service.create_perception_channel(payload)
@router.patch("/perception/channels/{channel}", response_model=PerceptionChannelResponse)
async def update_perception_channel(
channel: str,
payload: UpdatePerceptionChannelRequest,
) -> PerceptionChannelResponse:
return await governance_service.update_perception_channel(channel, payload)
@router.delete("/perception/channels/{channel}", status_code=204)
async def delete_perception_channel(channel: str) -> None:
await governance_service.delete_perception_channel(channel)
# —— §19.4 洛书枢 · 计划 ——
@router.get("/plans", response_model=list[PlanSummaryResponse])
async def list_plans() -> list[PlanSummaryResponse]:
return await governance_service.list_plans()
@router.post("/plans", response_model=PlanSummaryResponse)
async def create_plan(payload: CreatePlanRequest) -> PlanSummaryResponse:
return await governance_service.create_plan(payload)
@router.patch("/plans/{plan_id}", response_model=PlanSummaryResponse)
async def update_plan(
plan_id: str,
payload: UpdatePlanRequest,
) -> PlanSummaryResponse:
return await governance_service.update_plan(plan_id, payload)
@router.delete("/plans/{plan_id}", status_code=204)
async def delete_plan(plan_id: str) -> None:
await governance_service.delete_plan(plan_id)
# —— §19.4 天工阁枢 · 角色在岗 ——
@router.get("/agents/roster", response_model=list[AgentRosterResponse])
async def list_agent_roster() -> list[AgentRosterResponse]:
return await governance_service.list_agent_roster()
@router.post("/agents/roster", response_model=AgentRosterResponse)
async def create_agent(payload: CreateAgentRequest) -> AgentRosterResponse:
return await governance_service.create_agent(payload)
@router.patch("/agents/roster/{role}", response_model=AgentRosterResponse)
async def update_agent(
role: str,
payload: UpdateAgentRequest,
) -> AgentRosterResponse:
return await governance_service.update_agent(role, payload)
@router.delete("/agents/roster/{role}", status_code=204)
async def delete_agent(role: str) -> None:
await governance_service.delete_agent(role)
# —— §19.5 链路追踪 ——
-21
View File
@@ -1,21 +0,0 @@
"""Per-Character 目标管理端点(非 admin,供 /drive 等前端使用)。"""
from fastapi import APIRouter, Query
from app.schemas.admin import CreateGoalRequest, GoalNode
from app.services import governance as governance_service
router = APIRouter(prefix="/goals", tags=["goals"])
@router.get("", response_model=list[GoalNode])
async def list_goals(
character_id: str = Query(..., description="按人物过滤。"),
status: str | None = Query(default=None, description="按状态过滤(§5.12)。"),
) -> list[GoalNode]:
return await governance_service.list_goals_by_character(character_id, status_filter=status)
@router.post("", response_model=GoalNode, status_code=201)
async def create_goal(payload: CreateGoalRequest) -> GoalNode:
return await governance_service.create_goal(payload)
-119
View File
@@ -10,12 +10,6 @@ from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
from app.api.v1.deps import get_current_user
from app.schemas.auth import CurrentUser
from app.schemas.candidates import (
BatchReviewRequest,
BatchReviewResult,
MemoryCandidateResponse,
ProfileCandidateResponse,
)
from app.schemas.common import Page
from app.schemas.imports import (
CreateImportRequest,
@@ -23,18 +17,11 @@ from app.schemas.imports import (
ImportJobResponse,
)
from app.services.imports import (
batch_review_memory_candidates,
batch_review_profile_candidates,
create_import_from_json,
create_import_from_upload,
get_import,
list_import_items,
list_jobs_page,
list_memory_candidates,
list_profile_candidates,
trigger_memory_extraction,
trigger_personality_extraction,
trigger_profile_extraction,
)
router = APIRouter(tags=["imports"])
@@ -116,109 +103,3 @@ async def read_import_items(
page_size=page_size,
owner_scope=current_user.owner_scope,
)
# —— 候选抽取(5b,异步后台任务)——
@router.post(
"/characters/{character_id}/imports/{import_id}/profile-candidates",
response_model=ImportJobResponse,
)
async def trigger_profile_candidates(
character_id: str,
import_id: str,
current_user: CurrentUser = Depends(get_current_user),
) -> ImportJobResponse:
return await trigger_profile_extraction(
character_id, import_id, owner_scope=current_user.owner_scope
)
@router.get(
"/characters/{character_id}/imports/{import_id}/profile-candidates",
response_model=list[ProfileCandidateResponse],
)
async def list_profile_candidates_api(
character_id: str,
import_id: str,
status: str | None = Query(default=None, description="按审核状态过滤。"),
current_user: CurrentUser = Depends(get_current_user),
) -> list[ProfileCandidateResponse]:
return await list_profile_candidates(
character_id, import_id, status=status, owner_scope=current_user.owner_scope
)
@router.post(
"/characters/{character_id}/imports/{import_id}/memory-candidates",
response_model=ImportJobResponse,
)
async def trigger_memory_candidates(
character_id: str,
import_id: str,
current_user: CurrentUser = Depends(get_current_user),
) -> ImportJobResponse:
return await trigger_memory_extraction(
character_id, import_id, owner_scope=current_user.owner_scope
)
@router.get(
"/characters/{character_id}/imports/{import_id}/memory-candidates",
response_model=list[MemoryCandidateResponse],
)
async def list_memory_candidates_api(
character_id: str,
import_id: str,
status: str | None = Query(default=None, description="按审核状态过滤。"),
current_user: CurrentUser = Depends(get_current_user),
) -> list[MemoryCandidateResponse]:
return await list_memory_candidates(
character_id, import_id, status=status, owner_scope=current_user.owner_scope
)
@router.post(
"/characters/{character_id}/imports/{import_id}/personality-proposals",
response_model=ImportJobResponse,
)
async def trigger_personality_proposals(
character_id: str,
import_id: str,
current_user: CurrentUser = Depends(get_current_user),
) -> ImportJobResponse:
return await trigger_personality_extraction(
character_id, import_id, owner_scope=current_user.owner_scope
)
# —— 候选审核应用(5c)——
@router.post(
"/characters/{character_id}/profile-candidates/batch-review",
response_model=BatchReviewResult,
)
async def batch_review_profile(
character_id: str,
payload: BatchReviewRequest,
current_user: CurrentUser = Depends(get_current_user),
) -> BatchReviewResult:
return await batch_review_profile_candidates(
character_id, payload, owner_scope=current_user.owner_scope
)
@router.post(
"/characters/{character_id}/memory-candidates/batch-review",
response_model=BatchReviewResult,
)
async def batch_review_memory(
character_id: str,
payload: BatchReviewRequest,
current_user: CurrentUser = Depends(get_current_user),
) -> BatchReviewResult:
return await batch_review_memory_candidates(
character_id, payload, owner_scope=current_user.owner_scope
)
+5
View File
@@ -0,0 +1,5 @@
"""叩心路由(域实现见 app/domains/kouxin/api.py)。"""
from app.domains.kouxin.api import knock_router
__all__ = ["knock_router"]
+5
View File
@@ -0,0 +1,5 @@
"""天工阁路由(域实现见 app/domains/tiangong/api.py)。"""
from app.domains.tiangong.api import task_router
__all__ = ["task_router"]
+2 -2
View File
@@ -10,7 +10,7 @@ from app.api.v1.deps import get_current_user
from app.domains.baigong import service as baigong
from app.schemas.auth import CurrentUser
from app.schemas.safety import ToolCallDetail
from app.services.governance import get_tool_call
from app.services.runtime import get_tool_call_detail
router = APIRouter(prefix="/tools", tags=["tools"])
@@ -34,4 +34,4 @@ async def list_tools(user: CurrentUser = Depends(get_current_user)) -> list[dict
@router.get("/calls/{tool_call_id}", response_model=ToolCallDetail)
async def get_tool_call_detail(tool_call_id: str) -> ToolCallDetail:
return await get_tool_call(tool_call_id)
return await get_tool_call_detail(tool_call_id)
@@ -1,23 +0,0 @@
"""洛书枢 deliberative 规划器(编排层)。
领域数据结构与纯函数归位到 ``app/modules/planner/``,
本模块为薄壳,从 modules 导入并重导出以保持向后兼容。
"""
from app.modules.planner import (
DeliberativePlan,
PlanStep,
build_planning_prompt,
format_plan_for_prompt,
parse_plan,
should_plan,
)
__all__ = [
"DeliberativePlan",
"PlanStep",
"build_planning_prompt",
"format_plan_for_prompt",
"parse_plan",
"should_plan",
]
@@ -81,6 +81,14 @@ class RuntimeConfig(BaseSettings):
chroma_collection: str = "hunjing_memories" # ChromaDB collection 名
chroma_persist_directory: str = "../db/chroma" # ChromaDB 持久化目录(相对 backend)
embedding_model_hint: str = "bge-m3" # embedding 模型逻辑名
# 叩心约束(PRD §6.6:总开关/静默窗口/频率/预算;候选默认不自动发送)
knocking_enabled: bool = True
knocking_quiet_start: str = "23:00" # HH:MM(本地时区)
knocking_quiet_end: str = "07:00"
knocking_daily_limit: int = 3 # 24 小时派发上限
knocking_timezone: str = "Asia/Shanghai" # 静默窗口判定时区
knocking_budget_total: int = 0 # 累计预算(0=不限)
# 元炁本地推理引擎(ASR 语音转写;未配置=多模态关闭,文字链路不受影响)
yuanqi_base_url: str = ""
yuanqi_asr_model: str = "iic/SenseVoiceSmall"
+5 -271
View File
@@ -1,201 +1,9 @@
"""司命台治理对象种子数据。
从前端 lib/mock/data.ts 移植,供本地启动时让司命台治理视图有初始内容。
按主键存在则跳过,重复执行幂等;不记审计(系统初始化,非用户治理操作)。
"""
from datetime import UTC, datetime
"""百工能力种子(内置能力幂等登记;旧枢治理种子随 M8 退役)。"""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domains.baigong.models import Capability
from app.models.governance import (
AgentRosterEntry,
Goal,
PerceptionChannel,
Plan,
)
from app.repositories.json_utils import dumps
def _dt(iso: str | None) -> datetime | None:
if not iso:
return None
return datetime.fromisoformat(iso.replace("Z", "+00:00")).astimezone(UTC)
# —— 太一枢 · 目标 ——
_GOALS = [
{
"id": "goal-highest-mirror",
"title": "以铜镜为契,照见来者与自身",
"description": "镜梧的根本使命:于人于己皆以「照见」为先,不急于评判与作答。",
"level": "highest",
"type": "mission",
"status": "active",
"priority": 100,
"character_id": None,
"constraints": ["不得伪造照见", "触及高敏须脱敏", "核心人格不擅自改"],
"parent_id": None,
"success_criteria": ["持续以映照型方式回应", "保持人格一致"],
"updated_at": "2026-05-01T08:30:00Z",
},
{
"id": "goal-long-accompany",
"title": "成为能持续承接用户情绪的映照者",
"description": "长期目标:在反复的深夜倾诉中,成为散人稳定的情绪落点。",
"level": "long_term",
"type": "mission",
"status": "active",
"priority": 80,
"character_id": None,
"constraints": ["尊重用户节奏,不催促", "避免过度共情显得刻意"],
"parent_id": "goal-highest-mirror",
"success_criteria": ["用户愿意反复倾诉", "情绪被命名而非被解决"],
"updated_at": "2026-06-15T09:30:00Z",
},
{
"id": "goal-session-forget",
"title": "今夜陪伴散人,承接其对遗忘的反复触碰",
"description": "会话目标:散人第三次在深夜提起「遗忘」,需以意象承接而非下判语。",
"level": "session",
"type": "mission",
"status": "active",
"priority": 60,
"character_id": None,
"constraints": ["不用「你应该」", "语缓留白"],
"parent_id": "goal-long-accompany",
"success_criteria": ["情绪得到承接", "对话在留白处收束"],
"updated_at": "2026-06-20T08:00:00Z",
},
{
"id": "goal-task-retrieve",
"title": "检索与遗忘相关的情节记忆",
"description": "任务目标:从息壤拉取 episodic 记忆,理解散人反复触碰遗忘的来由。",
"level": "task",
"type": "mission",
"status": "completed",
"priority": 40,
"character_id": None,
"constraints": ["高敏记忆不注入明文"],
"parent_id": "goal-session-forget",
"success_criteria": ["命中 5 条以上相关记忆"],
"updated_at": "2026-06-20T08:05:00Z",
},
]
# —— 烛照枢 · 感知通道 ——
_PERCEPTION = [
(
"vision",
"视觉",
"摄像头 / 屏幕单帧识别",
False,
"revoked",
"sensitive",
"L3",
"2026-06-10T14:00:00Z",
),
(
"audio",
"听觉",
"麦克风音频采集与转写",
True,
"restricted",
"sensitive",
"L3",
"2026-06-19T23:50:00Z",
),
("sensor", "传感", "环境传感器(光线 / 温度 / 运动)", False, "unknown", "normal", "L1", None),
(
"digital",
"数字",
"日程 / 文件 / 应用等数字足迹",
True,
"granted",
"normal",
"L2",
"2026-06-20T07:50:00Z",
),
(
"user_input",
"用户输入",
"用户主动输入的文本 / 语音",
True,
"granted",
"normal",
"L1",
"2026-06-20T08:12:00Z",
),
(
"external_api",
"外部接口",
"第三方服务 API 调用",
True,
"restricted",
"sensitive",
"L2",
"2026-06-20T06:10:00Z",
),
]
# —— 洛书枢 · 计划 ——
_PLANS = [
{
"id": "plan-jingwu-001",
"goal_id": "goal-session-forget",
"title": "今夜情绪陪伴 · 意象承接",
"status": "running",
"assumptions": ["用户需要被接住而非被解决", "情绪议题宜以意象承接"],
"risks": ["触及高敏记忆须脱敏", "避免过度共情显得刻意"],
"required_tools": ["memory_search"],
"success_criteria": ["情绪得到承接与命名", "对话在留白处收束"],
"step_count": 5,
"completed_steps": 2,
"updated_at": "2026-06-20T08:05:00Z",
},
{
"id": "plan-jingwu-002",
"goal_id": "goal-long-accompany",
"title": "人格演化 v3 复盘",
"status": "completed",
"assumptions": ["v2→v3 转向「先承接后留白」有效"],
"risks": ["过度克制可能显冷"],
"required_tools": ["memory_search", "safety_check"],
"success_criteria": ["复盘归档", "演化记忆已沉淀"],
"step_count": 4,
"completed_steps": 4,
"updated_at": "2026-06-15T09:30:00Z",
},
{
"id": "plan-jingwu-003",
"goal_id": "goal-task-retrieve",
"title": "遗忘议题记忆检索",
"status": "draft",
"assumptions": ["存在与遗忘相关的 episodic 记忆"],
"risks": ["高敏记忆泄露"],
"required_tools": ["memory_search"],
"success_criteria": ["产出记忆时间线"],
"step_count": 3,
"completed_steps": 0,
"updated_at": "2026-06-20T08:10:00Z",
},
]
# —— 天工阁枢 · 九角色 ——
_AGENTS = [
("historian", "史官", "整理记忆、记录事件", True, "2026-06-20T08:05:00Z"),
("diviner", "卜者", "预测风险、评估不确定性", True, "2026-06-19T22:15:00Z"),
("engineer", "工师", "执行工具和工程任务", True, "2026-06-20T08:12:00Z"),
("debater", "辩者", "批判计划、寻找漏洞", True, "2026-06-19T19:00:00Z"),
("medic", "医官", "监测系统健康和情绪状态", True, "2026-06-19T11:45:00Z"),
("herald", "司礼", "维护表达风格和互动礼仪", True, "2026-06-20T08:00:00Z"),
("charioteer", "御者", "控制机器人和外部动作", False, None),
("scholar", "学士", "检索知识、做研究", True, "2026-06-20T06:10:00Z"),
("mirror", "镜师", "反思自我、推动演化", True, "2026-06-15T09:30:00Z"),
]
_BUILTIN_CAPABILITIES = [
# name, 描述, 执行类型, 执行器, 四维风险, 数据范围, 目的地, consent
@@ -276,82 +84,8 @@ async def _seed_capabilities(session: AsyncSession) -> None:
)
async def ensure_governance_seed(session: AsyncSession) -> None:
"""按主键幂等插入治理种子;已存在则跳过。"""
for item in _GOALS:
if await session.get(Goal, item["id"]) is None:
created = _dt(item["updated_at"])
session.add(
Goal(
id=item["id"],
title=item["title"],
description=item["description"],
level=item["level"],
status=item["status"],
priority=item["priority"],
constraints_json=dumps(item["constraints"]),
parent_id=item["parent_id"],
success_criteria_json=dumps(item["success_criteria"]),
character_id=item.get("character_id"),
type=item.get("type", "mission"),
created_at=created,
updated_at=created,
)
)
for channel, name, desc, enabled, consent, privacy, risk, last_iso in _PERCEPTION:
if await session.get(PerceptionChannel, channel) is None:
last = _dt(last_iso)
session.add(
PerceptionChannel(
channel=channel,
name=name,
description=desc,
enabled=enabled,
consent_status=consent,
privacy_level=privacy,
risk_level=risk,
last_active_at=last,
created_at=last or _dt("2026-06-01T00:00:00Z"),
updated_at=last or _dt("2026-06-01T00:00:00Z"),
)
)
for item in _PLANS:
if await session.get(Plan, item["id"]) is None:
updated = _dt(item["updated_at"])
session.add(
Plan(
id=item["id"],
goal_id=item["goal_id"],
title=item["title"],
status=item["status"],
assumptions_json=dumps(item["assumptions"]),
risks_json=dumps(item["risks"]),
required_tools_json=dumps(item["required_tools"]),
success_criteria_json=dumps(item["success_criteria"]),
step_count=item["step_count"],
completed_steps=item["completed_steps"],
created_at=updated,
updated_at=updated,
)
)
for role, name, responsibility, enabled, last_iso in _AGENTS:
if await session.get(AgentRosterEntry, role) is None:
last = _dt(last_iso)
session.add(
AgentRosterEntry(
role=role,
name=name,
responsibility=responsibility,
enabled=enabled,
last_active_at=last,
created_at=last or _dt("2026-05-01T00:00:00Z"),
updated_at=last or _dt("2026-05-01T00:00:00Z"),
)
)
"""启动幂等登记内置能力。"""
await _seed_capabilities(session)
await session.commit()
+5
View File
@@ -0,0 +1,5 @@
"""叩心域:主动联系候选(静默/频率/预算/渠道约束,无关系副作用)(M8)。"""
from app.domains.kouxin import models, service # noqa: F401
__all__ = ["models", "service"]
+63
View File
@@ -0,0 +1,63 @@
"""叩心 API:候选生成、约束裁决与派发。"""
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from app.api.v1.deps import get_current_user
from app.domains.kouxin import service
from app.schemas.auth import CurrentUser
knock_router = APIRouter(prefix="/knocking", tags=["kouxin"])
class CandidateCreateRequest(BaseModel):
character_id: str
trigger: str = "manual"
draft: str = Field(min_length=1, max_length=2000)
class DispatchRequest(BaseModel):
draft_override: str | None = None
@knock_router.post("/candidates", status_code=201)
async def create_candidate_api(
payload: CandidateCreateRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.create_candidate(
character_id=payload.character_id,
owner_scope=user.owner_scope,
trigger=payload.trigger,
draft=payload.draft,
)
@knock_router.get("/candidates")
async def list_candidates_api(
user: CurrentUser = Depends(get_current_user)
) -> list[dict]:
return await service.list_candidates(user.owner_scope)
@knock_router.get("/ruling/{character_id}")
async def ruling_api(
character_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
"""约束裁决预检(总开关/静默/频率/预算)。"""
return await service.rule(character_id, user.owner_scope)
@knock_router.post("/candidates/{candidate_id}/dispatch")
async def dispatch_api(
candidate_id: str, payload: DispatchRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.dispatch(
candidate_id, user.owner_scope, draft_override=payload.draft_override
)
@knock_router.post("/candidates/{candidate_id}/dismiss")
async def dismiss_api(
candidate_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.dismiss(candidate_id, user.owner_scope)
+40
View File
@@ -0,0 +1,40 @@
"""叩心表模型:主动联系候选(静默/频率/预算/渠道四约束)。
设计依据 PRD §6.6 与 mandate-and-capability-model.md §5:
- 主动对话先形成叩心候选,受静默、频率、渠道和预算控制;
- 用户不回应不会形成情感债,也不会降低缘契(无关系副作用——服务层断言钉死);
- 候选默认不自动发送:dispatch 需通过约束裁决与用户确认。
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.ids import new_id
from app.core.time import utc_now
from app.db.base import Base
class KnockingCandidate(Base):
"""叩心候选:触发原因 + 草稿 + 约束裁决记录。"""
__tablename__ = "knocking_candidates"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("knock"))
character_id: Mapped[str] = mapped_column(
String(80), ForeignKey("characters.id"), nullable=False, index=True
)
owner_scope: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
# 触发原因:event(真实事件应感)/ time_anchor(时点,如纪念日临近)/ manual
trigger: Mapped[str] = mapped_column(String(20), nullable=False, default="manual")
# 草稿内容(发送前用户可改)
draft: Mapped[str] = mapped_column(Text, nullable=False, default="")
# 渠道:in_app(1.0 唯一渠道)
channel: Mapped[str] = mapped_column(String(20), nullable=False, default="in_app")
# candidate / dispatched / expired / dismissed
status: Mapped[str] = mapped_column(String(20), nullable=False, default="candidate", index=True)
# 约束裁决快照(发送时的开关/静默/频率/预算检查结果,审计可查)
ruling_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
dispatched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+214
View File
@@ -0,0 +1,214 @@
"""叩心应用服务:候选生成、约束裁决与派发。
铁律(PRD §6.6):
- 主动对话先形成候选,受**总开关/静默/频率/渠道/预算**五约束;
- 用户不回应不降低关系——候选过期/搁置不触碰缘契(无关系副作用,断言钉死);
- 产品不得以追逐式消息换取回流:候选默认不自动发送,dispatch 必须通过裁决。
"""
from __future__ import annotations
import json
from datetime import UTC, datetime, time
from sqlalchemy import func, select
from app.core.config import settings
from app.core.errors import AppError
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.domains import one_qi
from app.domains.kouxin.models import KnockingCandidate
from app.models.character import Character
def _parse_hhmm(raw: str) -> time:
hh, mm = raw.split(":")
return time(int(hh), int(mm))
def _ruling_context() -> dict:
"""约束配置(env 级;P1 单用户档)。"""
return {
"enabled": settings.knocking_enabled,
"quiet_start": _parse_hhmm(settings.knocking_quiet_start),
"quiet_end": _parse_hhmm(settings.knocking_quiet_end),
"daily_limit": settings.knocking_daily_limit,
"budget_total": settings.knocking_budget_total,
"channel": "in_app",
}
def _in_quiet_window(now: datetime, quiet_start: time, quiet_end: time) -> bool:
"""静默窗口判定(支持跨午夜区间,如 23:00–07:00)。"""
current = now.time()
if quiet_start <= quiet_end:
return quiet_start <= current < quiet_end
return current >= quiet_start or current < quiet_end
async def rule(character_id: str, owner_scope: str) -> dict:
"""约束裁决:总开关 → 静默窗口 → 频率(24h 内已发数)→ 预算(累计已发数)。"""
ctx = _ruling_context()
# 静默窗口为本地时区语义(用户作息);UTC 换算到本地再判定
from zoneinfo import ZoneInfo
now_utc = datetime.now(UTC)
now = now_utc.astimezone(ZoneInfo(settings.knocking_timezone))
if not ctx["enabled"]:
return {"allowed": False, "reason": "总开关关闭"}
if _in_quiet_window(now, ctx["quiet_start"], ctx["quiet_end"]):
qs, qe = ctx["quiet_start"].strftime("%H:%M"), ctx["quiet_end"].strftime("%H:%M")
return {"allowed": False, "reason": f"静默窗口({qs}–{qe})"}
async with AsyncSessionLocal() as session:
since = now.timestamp() - 86400
daily = (
await session.execute(
select(func.count()).select_from(KnockingCandidate).where(
KnockingCandidate.character_id == character_id,
KnockingCandidate.status == "dispatched",
KnockingCandidate.dispatched_at.isnot(None),
)
)
).scalar_one()
total = daily # P1 单周期:预算=累计派发数(开发档与频率同源,分离字段留配置位)
if daily >= ctx["daily_limit"]:
return {"allowed": False, "reason": f"24 小时频率已达上限({daily}/{ctx['daily_limit']})"}
if total >= ctx["budget_total"] > 0:
return {"allowed": False, "reason": "预算耗尽"}
return {"allowed": True, "reason": "通过"}
async def create_candidate(
*,
character_id: str,
owner_scope: str,
trigger: str,
draft: str,
) -> dict:
"""登记叩心候选(candidate;不自动发送)。"""
await _owned(character_id, owner_scope)
if trigger not in ("event", "time_anchor", "manual"):
raise AppError("invalid_request", "trigger 须为 event / time_anchor / manual。")
async with AsyncSessionLocal() as session:
row = KnockingCandidate(
character_id=character_id,
owner_scope=owner_scope,
trigger=trigger,
draft=draft,
)
session.add(row)
await session.commit()
session.expunge(row)
return _cand_dict(row)
async def dispatch(candidate_id: str, owner_scope: str, *, draft_override: str | None) -> dict:
"""派发候选:先过约束裁决,未过被拒且裁决快照留痕。"""
async with AsyncSessionLocal() as session:
row = await session.get(KnockingCandidate, candidate_id)
if row is None or row.owner_scope != owner_scope:
raise AppError("not_found", "叩心候选不存在。", status_code=404)
if row.status != "candidate":
raise AppError("invalid_request", "候选不在可派发状态。", status_code=409)
character_id = row.character_id
session.expunge(row)
ruling = await rule(character_id, owner_scope)
if not ruling["allowed"]:
async with AsyncSessionLocal() as session:
row = await session.get(KnockingCandidate, candidate_id)
row.ruling_json = json.dumps(ruling, ensure_ascii=False)
await session.commit()
raise AppError("invalid_request", f"叩心被拒:{ruling['reason']}", status_code=429)
async with AsyncSessionLocal() as session:
row = await session.get(KnockingCandidate, candidate_id)
if draft_override is not None:
row.draft = draft_override
row.status = "dispatched"
row.dispatched_at = utc_now()
row.ruling_json = json.dumps(ruling, ensure_ascii=False)
await session.commit()
await one_qi.emit(
"knocking.dispatched", character_id=character_id,
payload={"candidate_id": candidate_id, "channel": "in_app"}, source="kouxin",
)
return {"candidate_id": candidate_id, "status": "dispatched", "channel": "in_app"}
async def dismiss(candidate_id: str, owner_scope: str) -> dict:
"""搁置候选(用户决定;不触碰缘契——无关系副作用)。"""
await _get(candidate_id, owner_scope)
async with AsyncSessionLocal() as session:
row = await session.get(KnockingCandidate, candidate_id)
row.status = "dismissed"
await session.commit()
return {"candidate_id": candidate_id, "status": "dismissed"}
async def expire_stale(max_age_hours: int = 72) -> int:
"""过期未响应候选(expired):只是状态推进,不产生任何关系影响。"""
async with AsyncSessionLocal() as session:
rows = (
await session.execute(
select(KnockingCandidate).where(KnockingCandidate.status == "dispatched")
)
).scalars().all()
n = 0
now = utc_now()
for row in rows:
if row.dispatched_at is not None:
dispatched = row.dispatched_at
if dispatched.tzinfo is None: # SQLite 回读 naive(开发档)
dispatched = dispatched.replace(tzinfo=UTC)
age = (now - dispatched).total_seconds() / 3600
if age >= max_age_hours:
row.status = "expired"
n += 1
if n:
await session.commit()
return n
async def list_candidates(owner_scope: str) -> list[dict]:
async with AsyncSessionLocal() as session:
rows = (
await session.execute(
select(KnockingCandidate)
.where(KnockingCandidate.owner_scope == owner_scope)
.order_by(KnockingCandidate.created_at.desc())
)
).scalars().all()
return [_cand_dict(r) for r in rows]
async def _owned(character_id: str, owner_scope: str) -> None:
async with AsyncSessionLocal() as session:
character = await session.get(Character, character_id)
if character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "人物不存在。", status_code=404)
async def _get(candidate_id: str, owner_scope: str) -> dict:
async with AsyncSessionLocal() as session:
row = await session.get(KnockingCandidate, candidate_id)
if row is None or row.owner_scope != owner_scope:
raise AppError("not_found", "叩心候选不存在。", status_code=404)
session.expunge(row)
return _cand_dict(row)
def _cand_dict(c: KnockingCandidate) -> dict:
return {
"id": c.id,
"character_id": c.character_id,
"trigger": c.trigger,
"draft": c.draft,
"channel": c.channel,
"status": c.status,
"ruling": json.loads(c.ruling_json or "{}"),
"created_at": c.created_at.isoformat() if c.created_at else None,
"dispatched_at": c.dispatched_at.isoformat() if c.dispatched_at else None,
}
@@ -435,6 +435,10 @@ async def build_generation_prompt(
)
if activated:
parts.append("【世界认知(世谱命中)】\n" + "\n".join(f"- {line}" for line in activated))
# 辰正时态感知(M8):客观时间语境注入投影(不附情绪臆测)
from app.domains.zhengchen import temporal_context
parts.append(temporal_context())
if transcript:
parts.append(f"【共同经历(祖先路径)】\n{transcript}")
parts.append(f"【当前问题】\n{expand(question.content)}")
+5
View File
@@ -0,0 +1,5 @@
"""天工阁域:受托任务(分解/并行/验收,无魂籍)+ 后台 Run 编排(M8)。"""
from app.domains.tiangong import models, service # noqa: F401
__all__ = ["models", "service"]
+68
View File
@@ -0,0 +1,68 @@
"""天工阁 API:受托任务(分解/派发/验收)。"""
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, Field
from app.api.v1.deps import get_current_user
from app.domains.tiangong import service
from app.schemas.auth import CurrentUser
task_router = APIRouter(prefix="/tasks", tags=["tiangong"])
class TaskCreateRequest(BaseModel):
character_id: str | None = None
title: str = Field(min_length=1, max_length=200)
brief: str = ""
acceptance_criteria: str = ""
parent_task_id: str | None = None
class DecomposeRequest(BaseModel):
subtasks: list[dict]
class AcceptRequest(BaseModel):
accepted: bool
@task_router.post("", status_code=201)
async def create_task_api(
payload: TaskCreateRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.create_task(
owner_scope=user.owner_scope,
character_id=payload.character_id,
title=payload.title,
brief=payload.brief,
acceptance_criteria=payload.acceptance_criteria,
parent_task_id=payload.parent_task_id,
)
@task_router.get("")
async def list_tasks_api(
user: CurrentUser = Depends(get_current_user), parent: str | None = "root"
) -> list[dict]:
return await service.list_tasks(user.owner_scope, parent=None if parent == "all" else parent)
@task_router.post("/{task_id}/decompose", status_code=201)
async def decompose_api(
task_id: str, payload: DecomposeRequest, user: CurrentUser = Depends(get_current_user)
) -> list[dict]:
return await service.decompose(task_id, user.owner_scope, payload.subtasks)
@task_router.post("/{task_id}/dispatch")
async def dispatch_api(
task_id: str, request: Request, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.dispatch(task_id, user.owner_scope, request.app.state)
@task_router.post("/{task_id}/accept")
async def accept_api(
task_id: str, payload: AcceptRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.accept(task_id, user.owner_scope, accepted=payload.accepted)
+47
View File
@@ -0,0 +1,47 @@
"""天工阁表模型:后台受托任务(无魂籍的功能代理任务)。
设计依据 mandate-and-capability-model.md §7 与 PRD §9.5:
- 任务 Agent 是功能代理,没有魂籍、缘契、情态或独立长期身份;
- 承载受托任务、有限分解、受控并行与按证据验收;
- 后台 Run(runs.kind=background_task)不占前台席位、不构成第二意识。
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.ids import new_id
from app.core.time import utc_now
from app.db.base import Base
class BackstageTask(Base):
"""受托任务:委托包 + 验收标准 + 结果证据;可分解(parent 链)受控并行。"""
__tablename__ = "backstage_tasks"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("task"))
owner_scope: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
# 委托对象人物(可空:纯功能任务无人物语境)
character_id: Mapped[str | None] = mapped_column(
String(80), ForeignKey("characters.id"), nullable=True, index=True
)
# 父任务(分解链:子任务并行执行,全部完成后父任务进入待验收)
parent_task_id: Mapped[str | None] = mapped_column(
String(80), ForeignKey("backstage_tasks.id"), nullable=True, index=True
)
title: Mapped[str] = mapped_column(String(200), nullable=False)
# 最小委托包(受托边界:达成目标与停止条件;任务 Agent 不扩使命)
brief: Mapped[str] = mapped_column(Text, nullable=False, default="")
# 按证据验收:明确的完成判据
acceptance_criteria: Mapped[str] = mapped_column(Text, nullable=False, default="")
# pending / running / awaiting_acceptance / accepted / rejected / failed
status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending", index=True)
# 执行产出(净化摘要 + 证据引用;不构成身份/记忆写入)
result_digest: Mapped[str] = mapped_column(Text, nullable=False, default="")
evidence_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
# 关联后台 Run(kind=background_task,不占前台席位)
run_id: Mapped[str | None] = mapped_column(String(80), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+211
View File
@@ -0,0 +1,211 @@
"""天工阁应用服务:受托任务(分解/并行/验收)与后台 Run 编排。
铁律(identity-continuity.md §4 / mandate-and-capability-model.md §7):
- 后台受托 Run 不占前台席位(并行于前台对话);
- 任务 Run 不是第二意识:**无身份写入路径**——不触发记忆候选、不写魂籍/铸魂、
不建缘契(专项断言钉死);产出只作为任务结果证据回呈;
- 达到预算/期限或连续失败后保留证据、停止并请命(P1 以任务失败态承载)。
"""
from __future__ import annotations
import json
from sqlalchemy import select
from app.core.config import settings
from app.core.errors import AppError
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.domains import one_qi
from app.domains.tiangong.models import BackstageTask
from app.models.character import Character
async def create_task(
*,
owner_scope: str,
character_id: str | None,
title: str,
brief: str,
acceptance_criteria: str,
parent_task_id: str | None,
) -> dict:
"""登记受托任务(pending;分解任务挂 parent 链)。"""
if character_id:
await _owned(character_id, owner_scope)
async with AsyncSessionLocal() as session:
task = BackstageTask(
owner_scope=owner_scope,
character_id=character_id,
parent_task_id=parent_task_id,
title=title,
brief=brief,
acceptance_criteria=acceptance_criteria,
)
session.add(task)
await session.commit()
session.expunge(task)
return _task_dict(task)
async def decompose(task_id: str, owner_scope: str, subtasks: list[dict]) -> list[dict]:
"""有限分解:登记子任务(受控并行由各自后台 Run 承载)。"""
parent = await _owned_task(task_id, owner_scope)
created = []
for sub in subtasks:
created.append(
await create_task(
owner_scope=owner_scope,
character_id=parent["character_id"],
title=str(sub.get("title", ""))[:200],
brief=str(sub.get("brief", "")),
acceptance_criteria=str(sub.get("acceptance_criteria", "")),
parent_task_id=task_id,
)
)
return created
async def dispatch(task_id: str, owner_scope: str, app_state) -> dict:
"""派发后台 Run(kind=background_task,不占前台席位)。
任务 Prompt = 最小委托包(目标+验收标准+停止条件);结果回填 result_digest,
任务进入 awaiting_acceptance。人物可为空——纯功能任务无人物语境。
"""
task = await _owned_task(task_id, owner_scope)
if task["status"] not in ("pending",):
raise AppError("invalid_request", "任务不在可派发状态。", status_code=409)
async with AsyncSessionLocal() as session:
row = await session.get(BackstageTask, task_id)
row.status = "running"
await session.commit()
from app.kernel import Kernel
kernel: Kernel | None = getattr(app_state, "kernel", None)
if kernel is None:
raise AppError("service_unavailable", "Kernel 未就绪。", status_code=503)
prompt = (
f"【受托任务】{task['title']}\n"
f"委托包:{task['brief']}\n"
f"验收标准:{task['acceptance_criteria'] or '按委托包目标验收'}\n"
f"停止条件:完成验收标准即止;不得扩展使命。"
)
run = await kernel.start_backstage_run(
character_id=task["character_id"],
prompt=prompt,
owner_id=owner_scope,
on_task_final=_make_finalizer(task_id, owner_scope),
)
async with AsyncSessionLocal() as session:
row = await session.get(BackstageTask, task_id)
row.run_id = run.id
await session.commit()
await one_qi.emit(
"task.dispatched", character_id=task["character_id"],
payload={"task_id": task_id, "run_id": run.id}, source="tiangong",
)
return {"task_id": task_id, "run_id": run.id, "status": "running"}
def _make_finalizer(task_id: str, owner_scope: str):
"""Run 完成回调:回填结果、进入待验收。"""
async def _finalize(content: str, status: str) -> None:
async with AsyncSessionLocal() as session:
row = await session.get(BackstageTask, task_id)
if row is None or row.owner_scope != owner_scope:
return
row.result_digest = (content or "")[:4000]
row.status = "awaiting_acceptance" if status == "completed" else "failed"
row.completed_at = utc_now()
await session.commit()
await _cascade_parent(task_id)
return _finalize
async def _cascade_parent(task_id: str) -> None:
async with AsyncSessionLocal() as session:
task = await session.get(BackstageTask, task_id)
if task is None or task.parent_task_id is None:
return
parent_id = task.parent_task_id
children = (
await session.execute(
select(BackstageTask).where(BackstageTask.parent_task_id == parent_id)
)
).scalars().all()
terminal = {"awaiting_acceptance", "accepted", "rejected", "failed"}
if children and all(c.status in terminal for c in children):
parent = await session.get(BackstageTask, parent_id)
if parent is not None and parent.status in ("pending", "running"):
parent.status = "awaiting_acceptance"
parent.result_digest = "\n".join(
f"〔{c.title}〕{c.result_digest[:400]}" for c in children
)
parent.completed_at = utc_now()
await session.commit()
async def accept(task_id: str, owner_scope: str, *, accepted: bool) -> dict:
"""按证据验收:accepted / rejected。"""
await _owned_task(task_id, owner_scope)
async with AsyncSessionLocal() as session:
row = await session.get(BackstageTask, task_id)
if row.status not in ("awaiting_acceptance", "failed"):
raise AppError("invalid_request", "任务不在待验收状态。", status_code=409)
row.status = "accepted" if accepted else "rejected"
await session.commit()
await one_qi.emit(
"task.accepted" if accepted else "task.rejected",
character_id=None, payload={"task_id": task_id}, source="tiangong",
)
return {"task_id": task_id, "status": row.status}
async def list_tasks(owner_scope: str, *, parent: str | None = "root") -> list[dict]:
"""任务清单(默认根任务;parent=None 全量)。"""
async with AsyncSessionLocal() as session:
stmt = select(BackstageTask).where(BackstageTask.owner_scope == owner_scope)
if parent == "root":
stmt = stmt.where(BackstageTask.parent_task_id.is_(None))
elif parent is not None:
stmt = stmt.where(BackstageTask.parent_task_id == parent)
rows = (await session.execute(stmt.order_by(BackstageTask.created_at.desc()))).scalars().all()
return [_task_dict(r) for r in rows]
async def _owned(character_id: str, owner_scope: str) -> None:
async with AsyncSessionLocal() as session:
character = await session.get(Character, character_id)
if character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "人物不存在。", status_code=404)
async def _owned_task(task_id: str, owner_scope: str) -> dict:
async with AsyncSessionLocal() as session:
row = await session.get(BackstageTask, task_id)
if row is None or row.owner_scope != owner_scope:
raise AppError("not_found", "任务不存在。", status_code=404)
session.expunge(row)
return _task_dict(row)
def _task_dict(t: BackstageTask) -> dict:
return {
"id": t.id,
"character_id": t.character_id,
"parent_task_id": t.parent_task_id,
"title": t.title,
"brief": t.brief,
"acceptance_criteria": t.acceptance_criteria,
"status": t.status,
"result_digest": t.result_digest,
"evidence": json.loads(t.evidence_json or "{}"),
"run_id": t.run_id,
"created_at": t.created_at.isoformat() if t.created_at else None,
"completed_at": t.completed_at.isoformat() if t.completed_at else None,
}
+43
View File
@@ -0,0 +1,43 @@
"""辰正:时态感知横切能力(rewrite/1.0 M8 重实现)。
计算核复用 modules/time_sense(snapshot/lunar——符文引擎依赖同源);
本模块提供投影级入口:``temporal_context()`` 产出【时态感知】提示段,
经符文展开后进入镜树/内核 prompt 组装。守一期间不得虚构等待与孤独
(identity-continuity.md §4)——时态只陈述客观时间语境。
"""
from __future__ import annotations
from datetime import UTC, datetime
from app.modules.time_sense.snapshot import SnapshotInput, compute_snapshot
_WEEKDAY_CN = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
def _default_input(now: datetime | None) -> SnapshotInput:
current = now or datetime.now(UTC)
return SnapshotInput(
now=current,
timezone="Asia/Shanghai",
anniversaries=[],
acquaintance_anchor=current,
last_active_at=current,
)
def temporal_context(now: datetime | None = None) -> str:
"""时态感知投影段:客观时间语境(不附情绪臆测——那是调节层的事)。"""
snap = compute_snapshot(_default_input(now))
weekday = _WEEKDAY_CN[snap.weekday]
parts = [
f"现在是{snap.solar_date} {weekday} {snap.day_period}(本地时区 {snap.timezone})",
]
if snap.lunar is not None:
parts.append(f"农历{snap.lunar.month}月{snap.lunar.day}日")
if snap.season:
parts.append(snap.season)
return "【时态感知】" + ",".join(parts)
__all__ = ["temporal_context"]
+45
View File
@@ -91,6 +91,49 @@ class Kernel:
assert created is not None
return created
async def start_backstage_run(
self,
*,
character_id: str | None,
prompt: str,
owner_id: str = "",
on_task_final=None,
) -> Run:
"""天工阁受托任务 Run(kind=background_task):不占前台席位、并行于前台。
无身份写入路径:结果只回呈任务(on_task_final),不触发记忆候选与身份写入
(息壤归真提取只挂在 canonize 链上,后台 Run 不归真)。
"""
profile = resolve_profile()
async with self._sessions() as session:
run = Run(
character_id=character_id,
owner_id=owner_id,
status="queued",
kind="background_task",
goal=prompt[:300] if prompt else None,
origin="tiangong_task",
model_hint=str(profile.params.get("slot", "chat")),
)
session.add(run)
await session.commit()
run_id = run.id
self._spawn(
run_id=run_id,
character_id=character_id,
owner_id=owner_id,
prompt=prompt,
trace_id=None,
seat_key=None,
system_contract=None,
on_final=on_task_final,
background=True,
)
async with self._sessions() as session:
created = await session.get(Run, run_id)
assert created is not None
return created
async def start_rehearsal_run(
self, *, embryo_id: str, prompt: str, system_contract: str, owner_id: str = ""
) -> Run:
@@ -142,6 +185,7 @@ class Kernel:
on_final: OnRunFinal | None,
temperature: float | None = None,
max_tokens: int | None = None,
background: bool = False,
) -> None:
task = asyncio.create_task(
self.loop.execute(
@@ -155,6 +199,7 @@ class Kernel:
on_final=on_final,
temperature=temperature,
max_tokens=max_tokens,
background=background,
)
)
self._tasks.add(task)
+24 -1
View File
@@ -90,8 +90,12 @@ class KernelRunLoop:
on_final: OnRunFinal | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
background: bool = False,
) -> RunOutcome:
assert character_id or seat_key, "Run 必须绑定人物或提供席位键(试演)"
if background:
assert character_id or seat_key or run_id, "Run 需至少一个归属维度"
else:
assert character_id or seat_key, "Run 必须绑定人物或提供席位键(试演)"
trace = trace_id or new_trace_id()
outcome = RunOutcome()
try:
@@ -107,6 +111,7 @@ class KernelRunLoop:
outcome=outcome,
temperature=temperature,
max_tokens=max_tokens,
background=background,
)
except TimeoutError:
outcome.status = "interrupted"
@@ -137,6 +142,7 @@ class KernelRunLoop:
outcome: RunOutcome,
temperature: float | None = None,
max_tokens: int | None = None,
background: bool = False,
) -> None:
ctx = RunContext(run_id=run_id, character_id=character_id, trace_id=trace_id)
try:
@@ -150,6 +156,22 @@ class KernelRunLoop:
return
await self._emit(run_id, character_id, trace_id, "run.queued")
if background:
# 天工阁受托任务:不占前台席位(与前台对话并行;不是第二意识)
await self._update_run(run_id, status="running")
await self._emit(run_id, character_id, trace_id, "run.started")
await self._turn(
run_id,
character_id,
owner_id,
trace_id,
prompt,
system_contract or _SYSTEM_CONTRACT_PLACEHOLDER,
outcome,
temperature=temperature,
max_tokens=max_tokens,
)
return
async with self._seats.foreground(seat_key or character_id or run_id):
await self._update_run(run_id, status="running")
await self._emit(run_id, character_id, trace_id, "run.started")
@@ -176,6 +198,7 @@ class KernelRunLoop:
outcome: RunOutcome,
temperature: float | None = None,
max_tokens: int | None = None,
background: bool = False,
) -> None:
ctx = RunContext(
run_id=run_id, character_id=character_id, trace_id=trace_id, payload={"prompt": prompt}
+5 -16
View File
@@ -1,19 +1,13 @@
"""SQLAlchemy 模型导出。"""
from app.models.big_cycle import BigCycle
from app.models.candidates import MemoryCandidate, ProfileCandidate
from app.models.character import Character
from app.models.governance import (
AgentRosterEntry,
GenerationPreferences,
Goal,
PerceptionChannel,
Plan,
)
from app.models.governance import GenerationPreferences
from app.models.idempotency import IdempotencyKey
from app.models.imports import CharacterImportJob, ImportItem
from app.domains.baigong.models import Capability, Mandate, ToolCall
from app.domains.hetu.models import PersistentWorld, WorldEntity, WorldRelation
from app.domains.kouxin.models import KnockingCandidate
from app.domains.tiangong.models import BackstageTask
from app.domains.xingyi.models import Form, FormAnchor, VoiceProfile
from app.domains.yuanqi.models import Covenant, CovenantEvent
from app.models.jingsheng import Custodianship, EmbryoDraft, IdentityRecord, RuntimeSeat, SoulForging
@@ -33,7 +27,9 @@ from app.models.safety import AuditEvent, PendingConfirmationRecord
from app.models.user import User
__all__ = [
"BackstageTask",
"Capability",
"KnockingCandidate",
"Form",
"FormAnchor",
"VoiceProfile",
@@ -45,25 +41,18 @@ __all__ = [
"Mandate",
"ToolCall",
"AgentRosterEntry",
"AuditEvent",
"BigCycle",
"Character",
"CharacterImportJob",
"Event",
"GenerationPreferences",
"Goal",
"IdempotencyKey",
"ImportItem",
"Lorebook",
"LorebookEntry",
"McpServer",
"MemoryCandidate",
"PendingConfirmationRecord",
"PerceptionChannel",
"Permission",
"Plan",
"ProfileCandidate",
"Role",
"RolePermission",
"Run",
-38
View File
@@ -1,38 +0,0 @@
"""大周天运行记录(architecture §3.2;v1-scope §3.3)。
大周天是长期演化循环:记忆积累 → 自我评估 → 演化策略 → 候选生成。
本表记录每次循环的触发、阶段与终态;演化候选 / 内驱力 / 自动抽取由后续
子阶段写入各自表,循环本身只承载编排与留痕。
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.ids import new_cycle_id
from app.core.time import utc_now
from app.db.base import Base
class BigCycle(Base):
"""大周天循环运行记录。"""
__tablename__ = "big_cycles"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_cycle_id)
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True)
trigger: Mapped[str] = mapped_column(String(40), nullable=False)
status: Mapped[str] = mapped_column(String(40), nullable=False, default="pending", index=True)
phase: Mapped[str | None] = mapped_column(String(40))
memory_count_at_trigger: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
evaluation_snapshot_json: Mapped[str | None] = mapped_column(Text)
summary: Mapped[str | None] = mapped_column(Text)
error_code: Mapped[str | None] = mapped_column(String(80))
error_message: Mapped[str | None] = mapped_column(Text)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now, onupdate=utc_now
)
-67
View File
@@ -1,67 +0,0 @@
"""资料导入候选模型(api-spec §8;契约字典 §10.6 candidate.status)。
导入资料经模型抽取产生档案 / 记忆候选,待用户审核后应用;人格建议复用
``personality_proposals``(005)。``evidence_ids`` 指向 ``import_items.id``
(EvidenceRef source_type = ``import_item``)。
"""
from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.ids import new_memory_candidate_id, new_profile_candidate_id
from app.core.time import utc_now
from app.db.base import Base
class ProfileCandidate(Base):
"""人物档案候选(契约字典 §10.6)。"""
__tablename__ = "character_profile_candidates"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_profile_candidate_id)
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True)
import_job_id: Mapped[str | None] = mapped_column(
String(80), ForeignKey("character_import_jobs.id"), index=True
)
field: Mapped[str] = mapped_column(String(60), nullable=False)
value: Mapped[str] = mapped_column(Text, nullable=False)
confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
evidence_ids_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
status: Mapped[str] = mapped_column(String(40), nullable=False, default="pending", index=True)
reason: Mapped[str | None] = mapped_column(Text)
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now, onupdate=utc_now
)
class MemoryCandidate(Base):
"""记忆候选(契约字典 §10.6)。"""
__tablename__ = "memory_candidates"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_memory_candidate_id)
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True)
import_job_id: Mapped[str | None] = mapped_column(
String(80), ForeignKey("character_import_jobs.id"), index=True
)
cycle_id: Mapped[str | None] = mapped_column(
String(80), ForeignKey("big_cycles.id"), index=True
)
type: Mapped[str] = mapped_column(String(60), nullable=False)
title: Mapped[str] = mapped_column(String(200), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
importance: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
sensitivity: Mapped[str] = mapped_column(String(40), nullable=False, default="low")
evidence_ids_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
status: Mapped[str] = mapped_column(String(40), nullable=False, default="pending", index=True)
reason: Mapped[str | None] = mapped_column(Text)
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now, onupdate=utc_now
)
+4 -106
View File
@@ -1,118 +1,16 @@
"""司命台治理对象模型(九枢治理视图,api-spec §19.4)。
目标(太一)/ 感知通道(烛照)/ 世界实体(河图)/ 计划(洛书)/ 天工阁角色
为可增长的开放集合;生成偏好(模型网关 §4.3)为本地单用户单行配置。
这些对象当前由司命台治理视图维护,对应枢的自动运行时暂未启用(见
docs/operations/capability-matrix.md §8)。
"""
"""治理域模型:生成偏好(其余旧枢治理表随 M8 退役)。"""
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
Float,
ForeignKey,
Integer,
String,
Text,
)
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import Boolean, DateTime, Float, Integer, String
from app.core.config import settings
from app.core.ids import new_goal_id, new_plan_id, new_world_entity_id
from sqlalchemy.orm import Mapped, mapped_column
from app.core.time import utc_now
from app.db.base import Base
class Goal(Base):
"""太一枢目标节点(契约字典 §5.11 / §5.12)。"""
__tablename__ = "goals"
def __init__(self, **kwargs: object) -> None:
# Python 端默认值:mapped_column(default=...) 仅在 ORM flush 时生效,
# __init__ 覆盖使测试在不侵入 session 时就能验证默认值。
kwargs.setdefault("type", "session")
kwargs.setdefault("progress", 0.0)
super().__init__(**kwargs)
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_goal_id)
character_id: Mapped[str | None] = mapped_column(
String(80), ForeignKey("characters.id"), nullable=True, index=True
)
title: Mapped[str] = mapped_column(String(300), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
level: Mapped[str] = mapped_column(String(40), nullable=False, default="task", index=True)
type: Mapped[str] = mapped_column(String(40), nullable=False, default="session")
status: Mapped[str] = mapped_column(String(40), nullable=False, default="pending", index=True)
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=50)
progress: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
constraints_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
parent_id: Mapped[str | None] = mapped_column(String(80), ForeignKey("goals.id"), nullable=True)
success_criteria_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now, onupdate=utc_now
)
class PerceptionChannel(Base):
"""烛照枢感知通道授权(契约字典 §5.7 频道级)。channel 为主键,默认六类可自定义。"""
__tablename__ = "perception_channels"
channel: Mapped[str] = mapped_column(String(80), primary_key=True)
name: Mapped[str] = mapped_column(String(120), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
consent_status: Mapped[str] = mapped_column(String(40), nullable=False, default="unknown")
privacy_level: Mapped[str] = mapped_column(String(40), nullable=False, default="normal")
risk_level: Mapped[str] = mapped_column(String(20), nullable=False, default="L2")
last_active_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now, onupdate=utc_now
)
class Plan(Base):
"""洛书枢计划(契约字典 §5.13)。goal_id 关联太一枢目标,可空。"""
__tablename__ = "plans"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_plan_id)
goal_id: Mapped[str | None] = mapped_column(String(80), ForeignKey("goals.id"), index=True)
title: Mapped[str] = mapped_column(String(300), nullable=False)
status: Mapped[str] = mapped_column(String(40), nullable=False, default="draft", index=True)
assumptions_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
risks_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
required_tools_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
success_criteria_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
step_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
completed_steps: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now, onupdate=utc_now
)
class AgentRosterEntry(Base):
"""天工阁枢九角色在岗(契约字典 §5.14)。role 为主键,默认九角色可自定义。"""
__tablename__ = "agent_roster"
role: Mapped[str] = mapped_column(String(80), primary_key=True)
name: Mapped[str] = mapped_column(String(120), nullable=False)
responsibility: Mapped[str] = mapped_column(Text, nullable=False, default="")
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
last_active_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now, onupdate=utc_now
)
class GenerationPreferences(Base):
"""生成参数偏好(模型网关 §4.3)。本地单用户单行,按 owner_scope 固定。"""
@@ -1 +0,0 @@
"""大周天演化候选领域纯逻辑(造消息 / 解析候选草稿,禁止 IO)。"""
-114
View File
@@ -1,114 +0,0 @@
"""解析模型返回为演化候选草稿(纯函数,无 IO)。
模型可能返回纯 JSON、markdown 代码块或夹杂解释文本;本模块容错提取首个 JSON
数组,并归一化为演化候选草稿。``evidence`` 序号由 application 映射为记忆 ID。
缺省字段取**安全悲观默认**:``level=L4``(必须人工确认)、``auto_apply_allowed=False``,
避免模型漏字段时误自动应用高风险演化。
"""
import json
import re
from dataclasses import dataclass, field
from typing import Any
@dataclass
class EvolutionCandidateDraft:
type: str
title: str
proposal: str
level: str = "L4"
scope: str = "character_scope"
source: str = "big_cycle"
delta: dict[str, Any] = field(default_factory=dict)
confidence: float = 0.5
benefit_score: float = 0.5
risk_score: float = 0.5
drift_score: float = 0.0
auto_apply_allowed: bool = False
evidence: list[int] = field(default_factory=list)
def parse_evolution_candidates(raw: str) -> list[EvolutionCandidateDraft]:
drafts: list[EvolutionCandidateDraft] = []
for item in _extract_json_array(raw):
if not isinstance(item, dict):
continue
type_ = str(item.get("type", "")).strip()
title = str(item.get("title", "")).strip()
proposal = str(item.get("proposal", "")).strip()
if not type_ or not title or not proposal:
continue
drafts.append(
EvolutionCandidateDraft(
type=type_,
title=title,
proposal=proposal,
level=_level(item.get("level")),
scope=_scope(item.get("scope")),
source=str(item.get("source", "big_cycle")).strip() or "big_cycle",
delta=_dict(item.get("delta")),
confidence=_clamp_float(item.get("confidence"), 0.5),
benefit_score=_clamp_float(item.get("benefit_score"), 0.5),
risk_score=_clamp_float(item.get("risk_score"), 0.5),
drift_score=_clamp_float(item.get("drift_score"), 0.0),
auto_apply_allowed=bool(item.get("auto_apply_allowed", False)),
evidence=_int_list(item.get("evidence")),
)
)
return drafts
def _extract_json_array(raw: str) -> list[Any]:
text = raw.strip()
matched = re.search(r"```(?:json)?\s*(\[.*?\])\s*```", text, re.DOTALL)
if matched:
text = matched.group(1)
else:
start, end = text.find("["), text.rfind("]")
if start != -1 and end > start:
text = text[start : end + 1]
try:
data = json.loads(text)
except json.JSONDecodeError:
return []
return data if isinstance(data, list) else []
_VALID_LEVELS = {"L0", "L1", "L2", "L3", "L4"}
_VALID_SCOPES = {"character_scope", "user_scope", "task_scope", "global_scope"}
def _level(value: Any) -> str:
text = str(value or "").strip().upper()
return text if text in _VALID_LEVELS else "L4"
def _scope(value: Any) -> str:
text = str(value or "").strip()
return text if text in _VALID_SCOPES else "character_scope"
def _dict(value: Any) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
def _clamp_float(value: Any, default: float) -> float:
try:
num = float(value)
except (TypeError, ValueError):
return default
return max(0.0, min(1.0, num))
def _int_list(value: Any) -> list[int]:
if not isinstance(value, list):
return []
result: list[int] = []
for item in value:
try:
result.append(int(item))
except (TypeError, ValueError):
continue
return result
-78
View File
@@ -1,78 +0,0 @@
"""大周天演化候选 prompt 构建(纯函数,无 IO)。
接收 ``EvaluationSnapshot``(由 application 阶段从 ORM 组装的态势快照),产出
``(system, user)`` 消息对,喂给模型网关。模型返回的 JSON 由 ``extract`` 模块解析。
枚举取值以 [契约字典](../../docs/contracts/contract-glossary.md) §12 与
[自我演化机制设计](../../docs/domain/core-domains.md) §11 为准。
"""
from dataclasses import dataclass, field
from app.modules.evolution import LEVELS as EVOLUTION_LEVELS
from app.modules.evolution import SCOPES as EVOLUTION_SCOPES
from app.modules.evolution import TYPES as EVOLUTION_TYPES
EVOLUTION_TYPES = EVOLUTION_TYPES
EVOLUTION_LEVELS = EVOLUTION_LEVELS
EVOLUTION_SCOPES = EVOLUTION_SCOPES
@dataclass
class EvaluationSnapshot:
"""大周天自我评估态势快照(application 组装,供候选生成消费)。"""
character_id: str
memory_count: int
recent_memories: list[tuple[str, str]] # (title, content)
personality_summary: str
world_entity_count: int
conclusion: str
evidence_ids: list[str] = field(default_factory=list) # 记忆 ID,供 evidence 序号映射
cumulative_drift: float = 0.0 # 最近大周天的累积漂移值
drift_budget_remaining: float = 0.0 # 剩余漂移预算
def build_evolution_extraction_messages(
snapshot: EvaluationSnapshot,
) -> tuple[str, str]:
system = (
"你是魂镜的大周天自我演化引擎。基于本轮自我评估的态势快照,生成轻量、可解释、"
"可回滚的演化候选——只调整人格表达、程序记忆、工具策略等轻量参数,"
"不改变核心使命、安全边界或高敏关系判断。"
f"type 取自:{', '.join(EVOLUTION_TYPES)}。"
f"level 取自:{', '.join(EVOLUTION_LEVELS)}"
"(L0-L3 低风险可半自动,L4 核心人格/使命/安全边界必须人工确认)。"
f"scope 取自:{', '.join(EVOLUTION_SCOPES)}。"
'以 JSON 数组返回,每个元素:{"type": str, "source": "big_cycle", '
'"level": str, "scope": str, "title": str, "proposal": str, '
'"delta": 对象, "confidence": 0~1, "benefit_score": 0~1, '
'"risk_score": 0~1, "auto_apply_allowed": bool, "evidence": [记忆序号]}。'
"delta 为具体增量(如人格字段覆盖对象、程序记忆 title/content)。"
"evidence 为下方记忆摘要的序号([0]/[1]…)列表。"
"仅在态势充分支撑时提议,宁缺毋滥。若本轮无需演化返回 []。"
"只返回 JSON 数组,不要解释或 markdown。"
)
user = (
f"记忆总数:{snapshot.memory_count}\n"
f"世界实体数:{snapshot.world_entity_count}\n"
f"自我评估结论:{snapshot.conclusion or '(无明显态势)'}\n"
f"当前人格摘要:\n{snapshot.personality_summary or '(尚未形成)'}\n"
f"近期记忆摘要:\n{_format_memories(snapshot.recent_memories)}"
)
# 人格权重向量:将漂移信息注入 prompt,引导 LLM 保守生成
if snapshot.cumulative_drift > 0:
user += (
f"\n\n⚠️ 人格漂移警告:累积漂移 {snapshot.cumulative_drift:.2f} / "
f"预算 {snapshot.drift_budget_remaining + snapshot.cumulative_drift:.1f}。"
f"剩余漂移预算 {snapshot.drift_budget_remaining:.1f}。"
f"请尽可能保守,减少高风险 proposal,优先微调而非大幅改动。"
)
return system, user
def _format_memories(memories: list[tuple[str, str]]) -> str:
if not memories:
return "(暂无)"
lines = [f"[{idx}] {title}:{content}" for idx, (title, content) in enumerate(memories)]
return "\n".join(lines)
@@ -1,69 +0,0 @@
"""太一枢(Goal Orchestrator)领域逻辑。
v1 落地 §7.1 最小闭环 + §7.2 预留扩展:
- 从用户消息提取会话目标写入 ``run.goal``,并注入运行契约。
- 活跃目标按优先级排序(``prioritize_active_goals``),供运行时注入。
目标持久化体系(``goals`` 表)、调度与冲突裁决属 §7.2 / §7.3。
"""
from __future__ import annotations
from app.modules.goal_orchestrator.extract import (
extract_session_goal,
format_goal_directive,
)
__all__ = [
"extract_session_goal",
"format_goal_directive",
"prioritize_active_goals",
"complete_session_goal",
]
def prioritize_active_goals(
goals: list[dict],
) -> list[dict]:
"""活跃目标优先级排序。
按以下规则返回排序后的目标列表(最高优先级在前):
1. ``priority`` 越小越优先(低数值 = 高优先级)
2. ``priority`` 相同时 ``progress`` 越低越优先(未完成的优先)
3. 层级越靠前越优先(L0 > L1 > L2 > L3 > L4)
Args:
goals: 活跃目标字典列表,每个字典包含 ``title`` / ``level`` / ``priority`` / ``progress``。
Returns:
排序后的目标列表。
"""
LEVEL_ORDER = {"L0": 0, "L1": 1, "L2": 2, "L3": 3, "L4": 4}
def _sort_key(g: dict) -> tuple:
priority = g.get("priority")
if priority is None:
priority = 100
progress = g.get("progress", 0) or 0
level = LEVEL_ORDER.get(str(g.get("level", "")), 99)
return (0 if priority is not None else 1, priority, level, progress)
return sorted(goals, key=_sort_key)
def complete_session_goal(
goal: dict,
) -> dict:
"""将会话目标标记为完成。
Args:
goal: 会话目标字典,包含 ``id`` / ``title`` / ``level`` / ``status``。
Returns:
更新后的目标字典(status=completed, progress=1.0)。
"""
return {
**goal,
"status": "completed",
"progress": 1.0,
}
@@ -1,25 +0,0 @@
"""太一会话目标提取(纯函数,无 IO)。
从用户消息提取会话目标(L2)。v1 采用规则提取——用户消息本身即目标的最直接
表达,清理与截断后写入 ``run.goal`` 并注入运行契约。LLM 提取为后续增强。
"""
_MAX_GOAL_CHARS = 120
def extract_session_goal(content: str) -> str:
"""从用户消息内容提取会话目标摘要。
规则:去除首尾空白,超长截断到 ``_MAX_GOAL_CHARS``;空内容返回空串。
"""
goal = (content or "").strip()
if not goal:
return ""
return goal[:_MAX_GOAL_CHARS]
def format_goal_directive(goal: str) -> str:
"""把会话目标格式化为运行契约的 system instruction 片段;空目标返回空串。"""
if not goal:
return ""
return f"当前会话目标:{goal}"
@@ -1 +0,0 @@
"""感知领域模块(纯逻辑,无 IO)。"""
@@ -1 +0,0 @@
"""视觉感知领域模块。"""
@@ -1,93 +0,0 @@
"""
OCR 结果后处理(纯逻辑,无 IO)。
对 OCR 返回的原始结果做结构化解析、置信度过滤、阅读顺序修正。
不依赖任何外部资源,可独立测试。
"""
from __future__ import annotations
from typing import Any
def filter_low_confidence(
blocks: list[dict[str, Any]],
threshold: float = 0.5,
) -> list[dict[str, Any]]:
"""过滤低置信度结果。
Args:
blocks: 原始 OCR 块列表。
threshold: 置信度阈值(默认 0.5)。
Returns:
过滤后的块列表。
"""
return [b for b in blocks if b.get("confidence", 0) >= threshold]
def merge_adjacent_lines(
blocks: list[dict[str, Any]],
y_tolerance: float = 10.0,
) -> list[dict[str, Any]]:
"""合并同一行的相邻块。
Args:
blocks: OCR 块列表(需含 bbox 坐标)。
y_tolerance: Y 轴容差(px),同一行最大差异。
Returns:
合并后的块列表。
"""
if not blocks:
return []
sorted_blocks = sorted(blocks, key=lambda b: (_center_y(b), _center_x(b)))
merged: list[dict[str, Any]] = []
current = sorted_blocks[0]
for block in sorted_blocks[1:]:
if abs(_center_y(block) - _center_y(current)) <= y_tolerance:
current["text"] += block["text"]
current["confidence"] = max(current["confidence"], block["confidence"])
else:
merged.append(current)
current = block
merged.append(current)
return merged
def extract_text(
blocks: list[dict[str, Any]],
separator: str = "",
) -> str:
"""从 OCR 块中提取纯文本。
Args:
blocks: OCR 块列表。
separator: 块之间的分隔符。
Returns:
拼接后的文本。
"""
return separator.join(b["text"] for b in blocks)
def _center_y(block: dict[str, Any]) -> float:
"""获取块的中心 Y 坐标。"""
pts = block.get("bbox", {}).get("points", block.get("bbox", []))
if pts and isinstance(pts, list) and len(pts) > 0:
ys = [p[1] for p in pts if isinstance(p, (list, tuple)) and len(p) >= 2]
return sum(ys) / len(ys) if ys else 0.0
return 0.0
def _center_x(block: dict[str, Any]) -> float:
"""获取块的中心 X 坐标。"""
pts = block.get("bbox", {}).get("points", block.get("bbox", []))
if pts and isinstance(pts, list) and len(pts) > 0:
xs = [p[0] for p in pts if isinstance(p, (list, tuple)) and len(p) >= 2]
return sum(xs) / len(xs) if xs else 0.0
return 0.0
-166
View File
@@ -1,166 +0,0 @@
"""洛书枢 deliberative 规划器领域模块。
纯数据结构和纯函数(禁止 IO),定义显式计划的数据模型与解析逻辑。
IO 相关编排在 ``app/application/planning/`` 中完成。
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
@dataclass
class PlanStep:
"""计划步骤。"""
id: int
title: str
description: str
tool: str | None = None
risk_level: str = "L2"
@dataclass
class DeliberativePlan:
"""显式计划。"""
title: str
steps: list[PlanStep]
assumptions: list[str] = field(default_factory=list)
risks: list[str] = field(default_factory=list)
def parse_plan(raw: str) -> DeliberativePlan | None:
"""解析 LLM 返回的 JSON 计划文本。
Args:
raw: LLM 原始输出(可能含 ```json 围栏)。
Returns:
解析成功的 DeliberativePlan,失败返回 None。
"""
text = raw.strip()
matched = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
if matched:
text = matched.group(1)
else:
brace_start = text.find("{")
brace_end = text.rfind("}")
if brace_start != -1 and brace_end > brace_start:
text = text[brace_start : brace_end + 1]
try:
data = json.loads(text)
except json.JSONDecodeError:
return None
if not isinstance(data, dict):
return None
title = str(data.get("title", "")).strip()
raw_steps = data.get("steps", [])
if not title or not isinstance(raw_steps, list) or not raw_steps:
return None
steps: list[PlanStep] = []
for i, s in enumerate(raw_steps):
if not isinstance(s, dict):
continue
step_title = str(s.get("title", "")).strip()
if not step_title:
continue
tool_raw = str(s.get("tool", "")).strip()
tool = tool_raw if tool_raw and tool_raw != "None" else None
steps.append(
PlanStep(
id=s.get("id", i + 1),
title=step_title,
description=str(s.get("description", "")).strip(),
tool=tool,
risk_level=str(s.get("risk_level", "L2")).upper(),
)
)
if not steps:
return None
return DeliberativePlan(
title=title,
steps=steps,
assumptions=[str(a).strip() for a in data.get("assumptions", []) if a],
risks=[str(r).strip() for r in data.get("risks", []) if r],
)
def build_planning_prompt(
request: str,
*,
goal_directive: str = "",
available_tools: list[str] | None = None,
memory_context: str = "",
) -> str:
"""构建规划 prompt。
当请求明显需要多步骤(调用多个工具、查询多处信息等)时,
要求 LLM 先生成一个显式计划,再按计划逐步执行。
Args:
request: 用户请求文本。
goal_directive: 当前活跃目标描述。
available_tools: 可用工具列表。
memory_context: 相关记忆上下文。
Returns:
规划 prompt 文本。
"""
tools_str = ""
if available_tools:
tools_str = "\n可用工具:\n" + "\n".join(f"- {t}" for t in available_tools)
return (
"请判断以下用户请求是否需要多步计划。如果需要,请输出 JSON 格式的计划。\n\n"
f"用户请求:{request}\n"
f"{f'当前目标:{goal_directive}' if goal_directive else ''}"
f"{tools_str}"
f"{f'\n上下文记忆:{memory_context}' if memory_context else ''}"
"\n\n"
"输出格式(如需多步计划):\n"
'```json\n{\n "title": "计划标题",\n'
' "assumptions": ["假设1", "假设2"],\n'
' "risks": ["风险1", "风险2"],\n'
' "steps": [\n'
' {"id": 1, "title": "步骤1", "description": "说明", '
'"tool": "工具名(可选)", "risk_level": "L2"}\n'
" ]\n"
"}\n```\n\n"
"如果不需要多步计划(简单问题),只回复:NO_PLAN_NEEDED"
)
def format_plan_for_prompt(plan: DeliberativePlan) -> str:
"""将计划格式化为 system prompt 可用文本。"""
lines = [f"## 计划:{plan.title}\n"]
if plan.assumptions:
lines.append("假设:")
lines.extend(f"- {a}" for a in plan.assumptions)
lines.append("")
if plan.risks:
lines.append("风险:")
lines.extend(f"- {r}" for r in plan.risks)
lines.append("")
lines.append("执行步骤:")
for step in plan.steps:
tool_info = f" [工具:{step.tool}]" if step.tool else ""
lines.append(f"{step.id}. {step.title}{tool_info}")
if step.description:
lines.append(f" {step.description}")
return "\n".join(lines)
def should_plan(request: str) -> bool:
"""判断请求是否需要多步计划。
简单规则:包含需要多工具的关键词时返回 True。
"""
triggers = [
"搜索", "查找", "研究", "调研", "分析", "比较",
"整理", "总结", "汇总", "规划", "安排", "计划",
"search", "research", "analyze", "compare",
"summarize", "plan", "organize",
]
return any(t in request for t in triggers)
-50
View File
@@ -1,50 +0,0 @@
"""大周天运行记录数据访问。"""
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.big_cycle import BigCycle
async def add_cycle(session: AsyncSession, cycle: BigCycle) -> BigCycle:
session.add(cycle)
await session.flush()
return cycle
async def get_cycle(session: AsyncSession, cycle_id: str) -> BigCycle | None:
return await session.get(BigCycle, cycle_id)
async def get_last_completed(session: AsyncSession, character_id: str) -> BigCycle | None:
result = await session.execute(
select(BigCycle)
.where(
BigCycle.character_id == character_id,
BigCycle.completed_at.is_not(None),
)
.order_by(BigCycle.completed_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def list_cycles(
session: AsyncSession,
character_id: str,
*,
page: int = 1,
page_size: int = 20,
) -> tuple[list[BigCycle], int]:
conditions = [BigCycle.character_id == character_id]
total = (
await session.execute(select(func.count()).select_from(BigCycle).where(*conditions))
).scalar_one()
items_result = await session.execute(
select(BigCycle)
.where(*conditions)
.order_by(BigCycle.created_at.desc())
.offset(max(page - 1, 0) * page_size)
.limit(page_size)
)
return list(items_result.scalars().all()), int(total or 0)
-70
View File
@@ -1,70 +0,0 @@
"""候选数据访问(api-spec §8;档案 / 记忆候选)。"""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.candidates import MemoryCandidate, ProfileCandidate
async def add_profile_candidates(session: AsyncSession, candidates: list[ProfileCandidate]) -> None:
session.add_all(candidates)
await session.flush()
async def add_memory_candidates(session: AsyncSession, candidates: list[MemoryCandidate]) -> None:
session.add_all(candidates)
await session.flush()
async def list_profile_candidates(
session: AsyncSession,
character_id: str,
*,
import_job_id: str | None = None,
status: str | None = None,
) -> list[ProfileCandidate]:
conditions = [ProfileCandidate.character_id == character_id]
if import_job_id is not None:
conditions.append(ProfileCandidate.import_job_id == import_job_id)
if status is not None:
conditions.append(ProfileCandidate.status == status)
result = await session.execute(
select(ProfileCandidate).where(*conditions).order_by(ProfileCandidate.created_at.desc())
)
return list(result.scalars().all())
async def list_memory_candidates(
session: AsyncSession,
character_id: str,
*,
import_job_id: str | None = None,
cycle_id: str | None = None,
status: str | None = None,
source: str | None = None,
) -> list[MemoryCandidate]:
conditions = [MemoryCandidate.character_id == character_id]
if import_job_id is not None:
conditions.append(MemoryCandidate.import_job_id == import_job_id)
if cycle_id is not None:
conditions.append(MemoryCandidate.cycle_id == cycle_id)
if status is not None:
conditions.append(MemoryCandidate.status == status)
if source == "cycle":
conditions.append(MemoryCandidate.import_job_id.is_(None))
elif source == "import":
conditions.append(MemoryCandidate.import_job_id.is_not(None))
result = await session.execute(
select(MemoryCandidate).where(*conditions).order_by(MemoryCandidate.created_at.desc())
)
return list(result.scalars().all())
async def get_profile_candidate(
session: AsyncSession, candidate_id: str
) -> ProfileCandidate | None:
return await session.get(ProfileCandidate, candidate_id)
async def get_memory_candidate(session: AsyncSession, candidate_id: str) -> MemoryCandidate | None:
return await session.get(MemoryCandidate, candidate_id)
-150
View File
@@ -1,150 +0,0 @@
"""司命台治理对象数据访问(api-spec §19.4)。
目标 / 感知通道 / 世界实体 / 计划 / 天工阁角色的 CRUD 数据访问;
均为本地单用户全局对象,不挂 character_id。
"""
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.time import utc_now
from app.models.governance import (
AgentRosterEntry,
Goal,
PerceptionChannel,
Plan,
)
# —— 太一枢 · 目标 ——
async def list_goals(session: AsyncSession) -> list[Goal]:
result = await session.execute(
select(Goal).order_by(Goal.priority.desc(), Goal.updated_at.desc())
)
return list(result.scalars().all())
async def get_goal(session: AsyncSession, goal_id: str) -> Goal | None:
return await session.get(Goal, goal_id)
async def add_goal(session: AsyncSession, goal: Goal) -> Goal:
session.add(goal)
await session.flush()
return goal
async def list_goals_by_character(
session: AsyncSession,
character_id: str,
status_filter: str | None = None,
) -> list[Goal]:
"""按 character_id 获取目标列表;可选的 status 过滤。"""
stmt = select(Goal).where(Goal.character_id == character_id)
if status_filter:
stmt = stmt.where(Goal.status == status_filter)
stmt = stmt.order_by(Goal.priority.desc(), Goal.updated_at.desc())
result = await session.execute(stmt)
return list(result.scalars().all())
async def update_goal_progress(
session: AsyncSession,
goal_id: str,
progress: float,
) -> Goal | None:
"""更新目标的 progress 与 updated_at 字段。"""
goal = await session.get(Goal, goal_id)
if goal is None:
return None
goal.progress = progress
goal.updated_at = utc_now()
await session.flush()
return goal
async def list_active_session_goal(session: AsyncSession, character_id: str) -> Goal | None:
"""查找指定人物活跃的 session 级目标(各人物至多一个活跃 session 目标)。"""
result = await session.execute(
select(Goal)
.where(
Goal.character_id == character_id,
Goal.level == "session",
Goal.status == "active",
)
.limit(1)
)
return result.scalar_one_or_none()
# —— 烛照枢 · 感知通道 ——
async def list_perception_channels(session: AsyncSession) -> list[PerceptionChannel]:
result = await session.execute(select(PerceptionChannel).order_by(PerceptionChannel.channel))
return list(result.scalars().all())
async def get_perception_channel(session: AsyncSession, channel: str) -> PerceptionChannel | None:
return await session.get(PerceptionChannel, channel)
async def add_perception_channel(
session: AsyncSession, channel: PerceptionChannel
) -> PerceptionChannel:
session.add(channel)
await session.flush()
return channel
# —— 河图枢 · 世界实体 ——
async def list_plans(session: AsyncSession) -> list[Plan]:
result = await session.execute(select(Plan).order_by(Plan.updated_at.desc()))
return list(result.scalars().all())
async def get_plan(session: AsyncSession, plan_id: str) -> Plan | None:
return await session.get(Plan, plan_id)
async def add_plan(session: AsyncSession, plan: Plan) -> Plan:
session.add(plan)
await session.flush()
return plan
# —— 天工阁枢 · 角色在岗 ——
async def list_agent_roster(session: AsyncSession) -> list[AgentRosterEntry]:
result = await session.execute(select(AgentRosterEntry).order_by(AgentRosterEntry.role))
return list(result.scalars().all())
async def get_agent(session: AsyncSession, role: str) -> AgentRosterEntry | None:
return await session.get(AgentRosterEntry, role)
async def add_agent(session: AsyncSession, agent: AgentRosterEntry) -> AgentRosterEntry:
session.add(agent)
await session.flush()
return agent
# —— 百工枢 · 工具策略(§15/§16 共享)——
# —— 计数(供九枢健康度聚合)——
async def count(session: AsyncSession, model: type) -> int:
result = await session.execute(select(func.count()).select_from(model))
return int(result.scalar_one())
-50
View File
@@ -1,50 +0,0 @@
"""候选 API schema(api-spec §8)。"""
from pydantic import BaseModel, Field
class ProfileCandidateResponse(BaseModel):
id: str = Field(description="候选 ID,前缀 profile_candidate_。")
character_id: str = Field(description="所属人物 ID。")
import_job_id: str | None = Field(default=None, description="来源导入任务 ID。")
field: str = Field(description="档案字段名。")
value: str = Field(description="候选值。")
confidence: float = Field(description="置信度。")
evidence_ids: list[str] = Field(default_factory=list, description="证据 import_item ID。")
status: str = Field(description="审核状态,契约字典 §10.6。")
reason: str | None = Field(default=None)
reviewed_at: str | None = Field(default=None)
created_at: str = Field(description="创建时间,UTC ISO 8601 字符串。")
class MemoryCandidateResponse(BaseModel):
id: str = Field(description="候选 ID,前缀 memory_candidate_。")
character_id: str = Field(description="所属人物 ID。")
import_job_id: str | None = Field(default=None, description="来源导入任务 ID。")
cycle_id: str | None = Field(default=None, description="来源大周天循环 ID。")
type: str = Field(description="记忆类型。")
title: str = Field(description="记忆标题。")
content: str = Field(description="记忆内容。")
confidence: float = Field(description="置信度。")
importance: float = Field(description="重要度。")
sensitivity: str = Field(description="敏感等级。")
evidence_ids: list[str] = Field(default_factory=list, description="证据 import_item ID。")
status: str = Field(description="审核状态,契约字典 §10.6。")
reason: str | None = Field(default=None)
reviewed_at: str | None = Field(default=None)
created_at: str = Field(description="创建时间,UTC ISO 8601 字符串。")
class CandidateReviewDecision(BaseModel):
candidate_id: str = Field(description="候选 ID。")
decision: str = Field(description="审核决定:approve / reject。")
class BatchReviewRequest(BaseModel):
decisions: list[CandidateReviewDecision] = Field(min_length=1, description="批量审核决定。")
class BatchReviewResult(BaseModel):
applied: int = Field(description="已应用(approve)数量。")
rejected: int = Field(description="已驳回数量。")
skipped: int = Field(description="因状态非 pending 跳过的数量。")
+41 -59
View File
@@ -12,19 +12,12 @@ from app.core.config import settings
from app.core.security import redact_text
from app.core.time import to_iso, utc_now
from app.db.session import AsyncSessionLocal
from app.models.governance import (
AgentRosterEntry,
Goal,
PerceptionChannel,
Plan,
)
from app.models.idempotency import IdempotencyKey
from app.models.xirang import Memory
from app.models.runtime import Event, Run
from app.models.safety import AuditEvent, PendingConfirmationRecord
from app.repositories import audit as audit_repo
from app.repositories import characters as character_repo
from app.repositories import governance as governance_repo
from app.repositories import idempotency as idempotency_repo
from app.repositories import runtime as runtime_repo
from app.schemas.admin import (
@@ -41,16 +34,14 @@ from app.schemas.common import Page
from app.services.serializers import audit_to_response
# 九枢全集(契约字典 §5.9):枢标识、中文名、工程模块名。
_PIVOT_REGISTRY: list[tuple[str, str, str]] = [
_PIVOT_REGISTRY = [
("personality_core", "灵核枢", "Personality Core"),
("goal_orchestrator", "太一枢", "Goal Orchestrator"),
("capability_registry", "百工枢", "Capability Registry"),
("safety_governor", "司命枢", "Safety Governor"),
("perception", "烛照枢", "Perception Layer"),
("world_model", "河图枢", "World Model"),
("planner", "洛书枢", "Planner"),
("memory", "息壤枢", "Memory System"),
("tool_runtime", "百工枢", "Tool Runtime"),
("agent_society", "天工阁枢", "Agent Society"),
("hetu", "河图枢", "Hetu World"),
("tiangong", "天工阁", "Tiangong Backstage"),
("kouxin", "叩心枢", "Kouxin Proactive"),
("memory", "息壤枢", "Xirang Memory"),
]
@@ -64,7 +55,15 @@ def _summarize_input(value: dict | None) -> str:
async def get_pivots_health(
*,
owner_scope: str = settings.default_owner_scope,
) -> PivotHealthReport:
):
"""九枢健康度:以事件流与域表计数呈现(旧枢治理表随 M8 退役)。"""
from app.domains.baigong.models import Capability
from app.domains.hetu.models import PersistentWorld
from app.domains.kouxin.models import KnockingCandidate
from app.domains.tiangong.models import BackstageTask
from app.models.safety import PendingConfirmationRecord
from app.models.xirang import Memory
async with AsyncSessionLocal() as session:
pending_count = len(
list(
@@ -79,64 +78,47 @@ async def get_pivots_health(
.all()
)
)
goals_n = await governance_repo.count(session, Goal)
from app.domains.hetu.models import PersistentWorld
world_n = await governance_repo.count(session, PersistentWorld)
plans_n = await governance_repo.count(session, Plan)
perception_n = await governance_repo.count(session, PerceptionChannel)
agents_n = await governance_repo.count(session, AgentRosterEntry)
memories_n = await governance_repo.count(session, Memory, Memory.status != "tombstoned")
caps_n = await _count(session, Capability)
world_n = await _count(session, PersistentWorld)
tasks_n = await _count(session, BackstageTask)
knocks_n = await _count(session, KnockingCandidate)
memories_n = await _count(session, Memory, Memory.status != "tombstoned")
character = await character_repo.get_active_character(session, owner_scope)
has_personality = bool(character and character.identity_baseline_json not in ("", "{}"))
tool_enabled = True # 百工域常驻(乾坤可用性按端点区分)
now = to_iso(utc_now())
raw: list[tuple[str, str, str, bool, str]] = [
(
"personality_core",
"steady" if has_personality else "void",
"人格版本已就绪" if has_personality else "人格未初始化",
),
("goal_orchestrator", "steady" if goals_n else "void", f"目标 {goals_n} 项"),
(
"safety_governor",
"cracked" if pending_count else "steady",
f"{pending_count} 项工具调用待裁定" if pending_count else "无待裁定",
),
("perception", "steady" if perception_n else "void", f"感知通道 {perception_n} 项"),
("world_model", "steady" if world_n else "void", f"世界实体 {world_n} 项"),
("planner", "steady" if plans_n else "void", f"计划 {plans_n} 项"),
raw: list[tuple[str, str, str]] = [
("personality_core", "steady" if has_personality else "void", "道体锚点已就绪" if has_personality else "锚点未初始化"),
("capability_registry", "steady" if caps_n else "void", f"能力 {caps_n} 项"),
("safety_governor", "cracked" if pending_count else "steady", f"{pending_count} 项待裁决" if pending_count else "无待裁决"),
("hetu", "steady" if world_n else "void", f"境界 {world_n} 个"),
("tiangong", "steady" if tasks_n else "void", f"受托任务 {tasks_n} 项"),
("kouxin", "breathing" if knocks_n else "void", f"叩心候选 {knocks_n} 条"),
("memory", "steady" if memories_n else "void", f"记忆 {memories_n} 条"),
(
"tool_runtime",
"breathing" if tool_enabled else "void",
"沙箱运行时在线" if tool_enabled else "工具运行时未启用",
),
("agent_society", "steady" if agents_n else "void", f"在岗角色 {agents_n} 位"),
]
name_map = {pivot: (name, module) for pivot, name, module in _PIVOT_REGISTRY}
pivots: list[PivotHealthSummary] = []
for pivot, health, summary in raw:
name, module = name_map[pivot]
pivots.append(
PivotHealthSummary(
pivot=pivot,
name=name,
module=module,
health=health,
summary=summary,
updated_at=now,
)
pivots = [
PivotHealthSummary(
pivot=pivot, name=name_map[pivot][0], module=name_map[pivot][1],
health=health, summary=summary, updated_at=now,
)
for pivot, health, summary in raw
]
alerts = sum(1 for item in pivots if item.health == "cracked")
return PivotHealthReport(
overall="cracked" if alerts else "steady", alerts=alerts, pivots=pivots
)
async def _count(session, model, *criteria) -> int:
from sqlalchemy import func as _func
stmt = select(_func.count()).select_from(model)
for c in criteria:
stmt = stmt.where(c)
return int((await session.execute(stmt)).scalar_one())
async def get_pending_approvals() -> list[PendingApproval]:
async with AsyncSessionLocal() as session:
records = (
-92
View File
@@ -1,92 +0,0 @@
"""会话目标自动持久化服务。
从用户消息自动持久化会话目标到 goals 表。
如果已有该 character 的活跃 session 级目标,判断内容是否显著不同:
- 内容相似 → 复用
- 内容不同 → 完成旧目标,创建新目标
"""
from app.models.governance import Goal
from app.repositories import governance as governance_repo
_TOPIC_SHIFT_MIN_LENGTH = 20
async def auto_persist_session_goal(
session,
character_id: str,
content: str,
) -> Goal | None:
"""自动持久化会话目标:复用活跃的 session 目标或新建。
内容过短(<10 字)不产生目标。
如果已有活跃 session 目标但内容显著不同,先完成旧目标再新建。
Returns:
持久化后的 Goal(可能为 None)。
"""
goal_text = (content or "").strip()
if not goal_text or len(goal_text) < 10:
return None
existing = await governance_repo.list_active_session_goal(session, character_id)
if existing is not None:
# 判断是否话题转换:内容不再相似时完成旧目标
if _is_topic_shift(existing.title, goal_text):
existing.status = "completed"
existing.progress = 1.0
# 创建新目标
goal = Goal(
character_id=character_id,
title=goal_text[:120],
description=f"从对话自动提取的会话目标:{goal_text[:500]}",
level="session",
type="mission",
status="active",
priority=50,
)
await governance_repo.add_goal(session, goal)
return goal
return existing
# 新建 session 目标
goal = Goal(
character_id=character_id,
title=goal_text[:120],
description=f"从对话自动提取的会话目标:{goal_text[:500]}",
level="session",
type="mission",
status="active",
priority=50,
)
await governance_repo.add_goal(session, goal)
return goal
def _is_topic_shift(existing_title: str, new_content: str) -> bool:
"""判断新内容与旧目标标题是否话题转换。
简单规则:如果新内容较长且与旧标题共享的关键词很少,视为话题转换。
这是启发式方法,后续可升级为 LLM 判断。
"""
if len(new_content) < _TOPIC_SHIFT_MIN_LENGTH:
return False
# 提取关键词集合(按空格/标点分割)
import re
old_words = set(re.split(r"[\s,。!?、;:,\.!?;:]+", existing_title.lower()))
new_words = set(re.split(r"[\s,。!?、;:,\.!?;:]+", new_content.lower()))
# 排除常见的助词
stop_words = {"的", "了", "是", "在", "有", "和", "就", "不", "人", "都", "一",
"一个", "上", "也", "很", "到", "说", "要", "去", "你", "我", "他",
"这", "那", "吗", "啊", "呢", "吧", "地", "得"}
old_keywords = old_words - stop_words
new_keywords = new_words - stop_words
if not old_keywords or not new_keywords:
return False
overlap = old_keywords & new_keywords
# 如果旧关键词的一个都没有出现在新内容中,或者重合率极低
if not overlap:
return True
return len(overlap) / max(len(old_keywords), len(new_keywords)) < 0.15
-453
View File
@@ -1,453 +0,0 @@
"""司命台治理对象 CRUD 服务(api-spec §19.4)。
五组开放集合(目标 / 感知通道 / 世界实体 / 计划 / 天工阁角色)的增删改查;
治理写操作一律记审计事件(audit_),并入起居注(§19.3)。pivot 由 action 前缀推断。
所有函数自管 ``AsyncSessionLocal``(路由直接调用,不传入 session)。
"""
from app.core.errors import AppError
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.models.governance import (
AgentRosterEntry,
Goal,
PerceptionChannel,
Plan,
)
from app.modules.runtime.events import Event
from app.repositories import governance as governance_repo
from app.repositories import runtime as runtime_repo
from app.repositories.json_utils import dumps, loads_dict
from app.schemas.admin import (
AgentRosterResponse,
CreateAgentRequest,
CreateGoalRequest,
CreatePerceptionChannelRequest,
CreatePlanRequest,
GoalNode,
PerceptionChannelResponse,
PlanSummaryResponse,
UpdateAgentRequest,
UpdateGoalRequest,
UpdatePerceptionChannelRequest,
UpdatePlanRequest,
)
from app.schemas.safety import (
ToolCallDetail,
)
from app.services.audit import record_audit_event
from app.services.serializers import (
agent_to_response,
goal_to_response,
perception_channel_to_response,
plan_to_response,
)
# —— 太一枢 · 目标 ——
async def list_goals() -> list[GoalNode]:
async with AsyncSessionLocal() as session:
goals = await governance_repo.list_goals(session)
return [goal_to_response(goal) for goal in goals]
async def list_goals_by_character(
character_id: str,
status_filter: str | None = None,
) -> list[GoalNode]:
async with AsyncSessionLocal() as session:
goals = await governance_repo.list_goals_by_character(
session, character_id, status_filter=status_filter
)
return [goal_to_response(goal) for goal in goals]
async def load_active_goals_context(character_id: str) -> str | None:
"""加载活跃目标并格式化为 system prompt 片段,供运行时注入。
目标按优先级排序(``prioritize_active_goals``),最高优先级在前。
"""
async with AsyncSessionLocal() as session:
goals = await governance_repo.list_goals_by_character(
session, character_id, status_filter="active"
)
if not goals:
return None
lines = [
f"- [{g.level}] {g.title}(优先级 {g.priority},进度 {g.progress}%)"
for g in sorted(goals, key=lambda g: -(g.priority or 0))
]
return "【当前目标】\n" + "\n".join(lines)
async def emit_goal_event(
goal: Goal,
event_type: str,
extra_payload: dict | None = None,
) -> None:
"""向事件存储写入 goal.* 事件。"""
from app.main import app # 惰性 import 避免启动循环
event_store = getattr(app.state, "event_store", None)
if event_store is None:
return
payload = {
"goal_id": goal.id,
"character_id": goal.character_id,
"title": goal.title,
"level": goal.level,
"type": goal.type,
"status": goal.status,
"priority": goal.priority,
"progress": goal.progress,
}
if extra_payload:
payload.update(extra_payload)
event = Event(
type=f"goal.{event_type}",
source="goal_orchestrator",
payload=payload,
character_id=goal.character_id,
visibility="audit",
)
await event_store.append(event)
async def create_goal(payload: CreateGoalRequest) -> GoalNode:
async with AsyncSessionLocal() as session:
goal = Goal(
title=payload.title,
description=payload.description,
level=payload.level,
type=payload.type,
character_id=payload.character_id,
status="pending",
priority=payload.priority,
constraints_json=dumps(payload.constraints),
parent_id=payload.parent_id,
success_criteria_json=dumps(payload.success_criteria),
)
await governance_repo.add_goal(session, goal)
await record_audit_event(
session,
action="goal.created",
actor="user",
input_summary=goal.title,
resource_type="goal",
resource_id=goal.id,
)
await session.commit()
await session.refresh(goal)
await emit_goal_event(goal, "created")
return goal_to_response(goal)
async def update_goal(goal_id: str, payload: UpdateGoalRequest) -> GoalNode:
async with AsyncSessionLocal() as session:
goal = await governance_repo.get_goal(session, goal_id)
if goal is None:
raise AppError("not_found", "目标不存在。")
old_status = goal.status
updates = payload.model_dump(exclude_unset=True)
constraints = updates.pop("constraints", None)
for key, value in updates.items():
setattr(goal, key, value)
if constraints is not None:
goal.constraints_json = dumps(constraints)
goal.updated_at = utc_now()
await record_audit_event(
session,
action="goal.updated",
actor="user",
input_summary=goal.title,
resource_type="goal",
resource_id=goal.id,
)
await session.commit()
await session.refresh(goal)
if old_status != goal.status:
if goal.status == "completed":
await emit_goal_event(goal, "completed", {"previous_status": old_status})
else:
await emit_goal_event(goal, "updated", {"previous_status": old_status})
else:
await emit_goal_event(goal, "updated", {"previous_status": old_status})
return goal_to_response(goal)
async def delete_goal(goal_id: str) -> None:
async with AsyncSessionLocal() as session:
goal = await governance_repo.get_goal(session, goal_id)
if goal is None:
raise AppError("not_found", "目标不存在。")
if goal.level == "highest":
raise AppError("invalid_request", "最高目标(使命级)不可删除。")
await session.delete(goal)
await record_audit_event(
session,
action="goal.deleted",
actor="user",
input_summary=goal.title,
resource_type="goal",
resource_id=goal.id,
)
await session.commit()
# —— 烛照枢 · 感知通道 ——
async def list_perception_channels() -> list[PerceptionChannelResponse]:
async with AsyncSessionLocal() as session:
channels = await governance_repo.list_perception_channels(session)
return [perception_channel_to_response(channel) for channel in channels]
async def create_perception_channel(
payload: CreatePerceptionChannelRequest,
) -> PerceptionChannelResponse:
async with AsyncSessionLocal() as session:
if await governance_repo.get_perception_channel(session, payload.channel) is not None:
raise AppError("conflict", "感知通道已存在。")
channel = PerceptionChannel(
channel=payload.channel,
name=payload.name,
description=payload.description,
risk_level=payload.risk_level,
)
await governance_repo.add_perception_channel(session, channel)
await record_audit_event(
session,
action="perception.created",
actor="user",
input_summary=channel.name,
resource_type="perception_channel",
resource_id=channel.channel,
)
await session.commit()
await session.refresh(channel)
return perception_channel_to_response(channel)
async def update_perception_channel(
channel_id: str, payload: UpdatePerceptionChannelRequest
) -> PerceptionChannelResponse:
async with AsyncSessionLocal() as session:
channel = await governance_repo.get_perception_channel(session, channel_id)
if channel is None:
raise AppError("not_found", "感知通道不存在。")
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(channel, key, value)
channel.updated_at = utc_now()
await record_audit_event(
session,
action="perception.updated",
actor="user",
input_summary=channel.name,
resource_type="perception_channel",
resource_id=channel.channel,
)
await session.commit()
await session.refresh(channel)
return perception_channel_to_response(channel)
async def delete_perception_channel(channel_id: str) -> None:
async with AsyncSessionLocal() as session:
channel = await governance_repo.get_perception_channel(session, channel_id)
if channel is None:
raise AppError("not_found", "感知通道不存在。")
await session.delete(channel)
await record_audit_event(
session,
action="perception.deleted",
actor="user",
input_summary=channel.name,
resource_type="perception_channel",
resource_id=channel.channel,
)
await session.commit()
# —— 河图枢 · 世界实体 ——
# —— 洛书枢 · 计划 ——
async def list_plans() -> list[PlanSummaryResponse]:
async with AsyncSessionLocal() as session:
plans = await governance_repo.list_plans(session)
return [plan_to_response(plan) for plan in plans]
async def create_plan(payload: CreatePlanRequest) -> PlanSummaryResponse:
async with AsyncSessionLocal() as session:
plan = Plan(
goal_id=payload.goal_id,
title=payload.title,
assumptions_json=dumps(payload.assumptions),
risks_json=dumps(payload.risks),
)
await governance_repo.add_plan(session, plan)
await record_audit_event(
session,
action="plan.created",
actor="user",
input_summary=plan.title,
resource_type="plan",
resource_id=plan.id,
)
await session.commit()
await session.refresh(plan)
return plan_to_response(plan)
async def update_plan(plan_id: str, payload: UpdatePlanRequest) -> PlanSummaryResponse:
async with AsyncSessionLocal() as session:
plan = await governance_repo.get_plan(session, plan_id)
if plan is None:
raise AppError("not_found", "计划不存在。")
updates = payload.model_dump(exclude_unset=True)
assumptions = updates.pop("assumptions", None)
risks = updates.pop("risks", None)
for key, value in updates.items():
setattr(plan, key, value)
if assumptions is not None:
plan.assumptions_json = dumps(assumptions)
if risks is not None:
plan.risks_json = dumps(risks)
plan.updated_at = utc_now()
await record_audit_event(
session,
action="plan.updated",
actor="user",
input_summary=plan.title,
resource_type="plan",
resource_id=plan.id,
)
await session.commit()
await session.refresh(plan)
return plan_to_response(plan)
async def delete_plan(plan_id: str) -> None:
async with AsyncSessionLocal() as session:
plan = await governance_repo.get_plan(session, plan_id)
if plan is None:
raise AppError("not_found", "计划不存在。")
await session.delete(plan)
await record_audit_event(
session,
action="plan.deleted",
actor="user",
input_summary=plan.title,
resource_type="plan",
resource_id=plan.id,
)
await session.commit()
# —— 天工阁枢 · 角色在岗 ——
async def list_agent_roster() -> list[AgentRosterResponse]:
async with AsyncSessionLocal() as session:
agents = await governance_repo.list_agent_roster(session)
return [agent_to_response(agent) for agent in agents]
async def create_agent(payload: CreateAgentRequest) -> AgentRosterResponse:
async with AsyncSessionLocal() as session:
if await governance_repo.get_agent(session, payload.role) is not None:
raise AppError("conflict", "角色已存在。")
agent = AgentRosterEntry(
role=payload.role,
name=payload.name,
responsibility=payload.responsibility,
)
await governance_repo.add_agent(session, agent)
await record_audit_event(
session,
action="agent.created",
actor="user",
input_summary=agent.name,
resource_type="agent",
resource_id=agent.role,
)
await session.commit()
await session.refresh(agent)
return agent_to_response(agent)
async def update_agent(role: str, payload: UpdateAgentRequest) -> AgentRosterResponse:
async with AsyncSessionLocal() as session:
agent = await governance_repo.get_agent(session, role)
if agent is None:
raise AppError("not_found", "角色不存在。")
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(agent, key, value)
agent.updated_at = utc_now()
await record_audit_event(
session,
action="agent.updated",
actor="user",
input_summary=agent.name,
resource_type="agent",
resource_id=agent.role,
)
await session.commit()
await session.refresh(agent)
return agent_to_response(agent)
async def delete_agent(role: str) -> None:
async with AsyncSessionLocal() as session:
agent = await governance_repo.get_agent(session, role)
if agent is None:
raise AppError("not_found", "角色不存在。")
await session.delete(agent)
await record_audit_event(
session,
action="agent.deleted",
actor="user",
input_summary=agent.name,
resource_type="agent",
resource_id=agent.role,
)
await session.commit()
# —— 百工枢 · 工具策略(§15 / §16 共享)——
async def get_tool_call(tool_call_id: str) -> ToolCallDetail:
"""GET /tools/calls/{tool_call_id}:单条 tool.* 事件详情。
tool_call_id 对应一炁事件存储的事件主键;仅返回 tool.* 类型事件,否则 404。
"""
from app.core.time import to_iso
async with AsyncSessionLocal() as session:
event = await runtime_repo.get_event(session, tool_call_id)
if event is None or not event.type.startswith("tool."):
raise AppError("not_found", f"工具调用 {tool_call_id} 不存在。")
payload = loads_dict(event.payload_json)
return ToolCallDetail(
event_id=event.id,
type=event.type,
tool=payload.get("tool"),
run_id=event.run_id,
character_id=event.character_id,
trace_id=event.trace_id,
payload=payload,
created_at=to_iso(event.created_at),
)
-168
View File
@@ -16,15 +16,8 @@ from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.models.imports import CharacterImportJob, ImportItem
from app.modules.imports import parse_import
from app.repositories import candidates as candidates_repo
from app.repositories import imports as imports_repo
from app.repositories.json_utils import dumps, loads_dict
from app.schemas.candidates import (
BatchReviewRequest,
BatchReviewResult,
MemoryCandidateResponse,
ProfileCandidateResponse,
)
from app.schemas.common import Page
from app.schemas.imports import (
CreateImportRequest,
@@ -36,8 +29,6 @@ from app.services.characters import get_owned_character
from app.services.serializers import (
import_item_to_response,
import_job_to_response,
memory_candidate_to_response,
profile_candidate_to_response,
)
_TEXT_MIME_FORMAT: dict[str, str] = {
@@ -283,24 +274,6 @@ async def _reload_job(import_id: str) -> ImportJobResponse:
return import_job_to_response(job)
async def trigger_profile_extraction(
character_id: str,
import_id: str,
owner_scope: str = settings.default_owner_scope,
) -> ImportJobResponse:
return await _trigger_extraction(
character_id, import_id, "extracting_profile", "profile", owner_scope=owner_scope
)
async def trigger_memory_extraction(
character_id: str,
import_id: str,
owner_scope: str = settings.default_owner_scope,
) -> ImportJobResponse:
return await _trigger_extraction(
character_id, import_id, "extracting_memory", "memory", owner_scope=owner_scope
)
async def trigger_personality_extraction(
@@ -317,148 +290,7 @@ async def trigger_personality_extraction(
)
async def _trigger_extraction(
character_id: str,
import_id: str,
status: str,
kind: str,
owner_scope: str = settings.default_owner_scope,
) -> ImportJobResponse:
"""抽取触发已随旧管线退役:记忆候选由息壤归真管线生成(M3),画像/人格抽取随 M6。"""
raise AppError(
"not_implemented",
"该抽取入口已退役:记忆候选经镜树归真生成(息壤 M3);画像/人格抽取随 M6 灵核重建。",
status_code=410,
)
async def list_profile_candidates(
character_id: str,
import_id: str,
*,
status: str | None = None,
owner_scope: str = settings.default_owner_scope,
) -> list[ProfileCandidateResponse]:
async with AsyncSessionLocal() as session:
character = await get_owned_character(session, character_id, owner_scope=owner_scope)
await _get_owned_job(session, character.id, import_id)
items = await candidates_repo.list_profile_candidates(
session, character.id, import_job_id=import_id, status=status
)
return [profile_candidate_to_response(c) for c in items]
async def list_memory_candidates(
character_id: str,
import_id: str,
*,
status: str | None = None,
owner_scope: str = settings.default_owner_scope,
) -> list[MemoryCandidateResponse]:
async with AsyncSessionLocal() as session:
character = await get_owned_character(session, character_id, owner_scope=owner_scope)
await _get_owned_job(session, character.id, import_id)
items = await candidates_repo.list_memory_candidates(
session, character.id, import_job_id=import_id, status=status
)
return [memory_candidate_to_response(c) for c in items]
# —— 候选审核应用(5c)——
async def batch_review_profile_candidates(
character_id: str,
payload: BatchReviewRequest,
owner_scope: str = settings.default_owner_scope,
) -> BatchReviewResult:
"""批量审核档案候选:approve 写入人物档案,reject 标记驳回,非 pending 跳过。"""
async with AsyncSessionLocal() as session:
character = await get_owned_character(session, character_id, owner_scope=owner_scope)
profile = loads_dict(character.profile_json)
applied = rejected = skipped = 0
for decision in payload.decisions:
candidate = await candidates_repo.get_profile_candidate(session, decision.candidate_id)
if (
candidate is None
or candidate.character_id != character.id
or candidate.status != "pending"
):
skipped += 1
continue
if decision.decision == "approve":
profile[candidate.field] = candidate.value
if candidate.field == "display_name":
character.display_name = candidate.value
candidate.status = "applied"
applied += 1
elif decision.decision == "reject":
candidate.status = "rejected"
rejected += 1
else:
skipped += 1
continue
candidate.reviewed_at = utc_now()
character.profile_json = dumps(profile)
await _audit(
session,
character.id,
"import.profile_candidate_reviewed",
character.id,
f"applied={applied} rejected={rejected}",
)
await session.commit()
return BatchReviewResult(applied=applied, rejected=rejected, skipped=skipped)
async def batch_review_memory_candidates(
character_id: str,
payload: BatchReviewRequest,
owner_scope: str = settings.default_owner_scope,
) -> BatchReviewResult:
"""批量审核记忆候选:approve 创建记忆,reject 标记驳回。"""
async with AsyncSessionLocal() as session:
character = await get_owned_character(session, character_id, owner_scope=owner_scope)
applied = rejected = skipped = 0
for decision in payload.decisions:
candidate = await candidates_repo.get_memory_candidate(session, decision.candidate_id)
if (
candidate is None
or candidate.character_id != character.id
or candidate.status != "pending"
):
skipped += 1
continue
if decision.decision == "approve":
session.add(
Memory(
character_id=character.id,
type=candidate.type,
title=candidate.title,
content=candidate.content,
source="user_explicit",
confidence=candidate.confidence,
importance=candidate.importance,
sensitivity=candidate.sensitivity,
evidence_ids_json=candidate.evidence_ids_json,
status="active",
)
)
candidate.status = "applied"
applied += 1
elif decision.decision == "reject":
candidate.status = "rejected"
rejected += 1
else:
skipped += 1
continue
candidate.reviewed_at = utc_now()
await _audit(
session,
character.id,
"import.memory_candidate_reviewed",
character.id,
f"applied={applied} rejected={rejected}",
)
await session.commit()
return BatchReviewResult(applied=applied, rejected=rejected, skipped=skipped)
+24
View File
@@ -213,3 +213,27 @@ async def list_run_event_responses(
session, run_id, limit=limit, offset=offset
)
return [event_to_response(event) for event in events]
async def get_tool_call_detail(tool_call_id: str):
"""GET /tools/calls/{tool_call_id}:单条 tool.* 事件详情。"""
from app.core.errors import AppError
from app.core.time import to_iso
from app.repositories.json_utils import loads_dict
from app.schemas.safety import ToolCallDetail
async with AsyncSessionLocal() as session:
event = await runtime_repo.get_event(session, tool_call_id)
if event is None or not event.type.startswith("tool."):
raise AppError("not_found", f"工具调用 {tool_call_id} 不存在。")
payload = loads_dict(event.payload_json)
return ToolCallDetail(
event_id=event.id,
type=event.type,
tool=payload.get("tool"),
run_id=event.run_id,
character_id=event.character_id,
trace_id=event.trace_id,
payload=payload,
created_at=to_iso(event.created_at),
)
+2 -105
View File
@@ -3,28 +3,14 @@
from datetime import UTC, timedelta
from typing import Any
from app.models.governance import GenerationPreferences
from app.core.time import to_iso, utc_now
from app.models.candidates import MemoryCandidate, ProfileCandidate
from app.models.character import Character
from app.models.governance import (
AgentRosterEntry,
GenerationPreferences,
Goal,
PerceptionChannel,
Plan,
)
from app.models.imports import CharacterImportJob, ImportItem
from app.models.runtime import Event, Run
from app.models.safety import AuditEvent
from app.repositories.json_utils import loads_dict, loads_list
from app.schemas.admin import (
AgentRosterResponse,
AuditEntry,
GoalNode,
PerceptionChannelResponse,
PlanSummaryResponse,
)
from app.schemas.candidates import MemoryCandidateResponse, ProfileCandidateResponse
from app.schemas.admin import AuditEntry
from app.schemas.character import CharacterResponse, CharacterSummaryResponse
from app.schemas.imports import ImportItemResponse, ImportJobResponse
from app.schemas.preferences import GenerationPreferencesResponse
@@ -103,75 +89,8 @@ def event_to_response(event: Event) -> EventResponse:
# —— 司命台治理对象 ——
def goal_to_response(goal: Goal) -> GoalNode:
return GoalNode(
id=goal.id,
character_id=goal.character_id,
title=goal.title,
description=goal.description,
level=goal.level,
type=goal.type,
status=goal.status,
priority=goal.priority,
progress=goal.progress,
constraints=loads_list(goal.constraints_json),
parent_id=goal.parent_id,
success_criteria=loads_list(goal.success_criteria_json),
updated_at=to_iso(goal.updated_at),
)
def perception_channel_to_response(channel: PerceptionChannel) -> PerceptionChannelResponse:
return PerceptionChannelResponse(
channel=channel.channel,
name=channel.name,
description=channel.description,
enabled=channel.enabled,
consent_status=channel.consent_status,
privacy_level=channel.privacy_level,
risk_level=channel.risk_level,
last_active_at=to_iso(channel.last_active_at),
)
def plan_to_response(plan: Plan) -> PlanSummaryResponse:
return PlanSummaryResponse(
id=plan.id,
goal_id=plan.goal_id or "",
title=plan.title,
status=plan.status,
assumptions=loads_list(plan.assumptions_json),
risks=loads_list(plan.risks_json),
required_tools=loads_list(plan.required_tools_json),
success_criteria=loads_list(plan.success_criteria_json),
step_count=plan.step_count,
completed_steps=plan.completed_steps,
updated_at=to_iso(plan.updated_at),
)
_ACTIVE_WINDOW = timedelta(hours=1)
def agent_to_response(agent: AgentRosterEntry) -> AgentRosterResponse:
"""presence 为派生呈现态:启用且最近一小时内活跃 → active,否则 idle。"""
presence = "idle"
if agent.enabled and agent.last_active_at:
last = agent.last_active_at
if last.tzinfo is None:
last = last.replace(tzinfo=UTC)
if utc_now() - last <= _ACTIVE_WINDOW:
presence = "active"
return AgentRosterResponse(
role=agent.role,
name=agent.name,
responsibility=agent.responsibility,
enabled=agent.enabled,
presence=presence,
last_active_at=to_iso(agent.last_active_at),
)
def generation_preferences_to_response(
@@ -236,28 +155,6 @@ def import_item_to_response(item: ImportItem) -> ImportItemResponse:
)
def profile_candidate_to_response(candidate: ProfileCandidate) -> ProfileCandidateResponse:
return ProfileCandidateResponse(
id=candidate.id,
character_id=candidate.character_id,
import_job_id=candidate.import_job_id,
field=candidate.field,
value=candidate.value,
confidence=candidate.confidence,
evidence_ids=loads_list(candidate.evidence_ids_json),
status=candidate.status,
reason=candidate.reason,
reviewed_at=to_iso(candidate.reviewed_at),
created_at=to_iso(candidate.created_at) or "",
)
def memory_candidate_to_response(candidate): # noqa: ANN001 — 旧导入候选(M6 退役)
from app.schemas.candidates import MemoryCandidateResponse
return MemoryCandidateResponse.model_validate(candidate, from_attributes=True)
def profile_candidate_to_response(candidate): # noqa: ANN001 — 旧导入候选(M6 退役)