M8:天工阁、叩心与辰正——受托任务后台并行、叩心四约束、时态感知投影
- 天工阁域 domains/tiangong:backstage_tasks(委托包+验收标准+结果证据);有限分解(parent 链)与受控并行(各自后台 Run);子任务全终态级联父任务进入待验收;按证据 accept/reject - kernel.start_backstage_run(kind=background_task):loop 后台分支不占前台席位、与前台对话并行;断言放宽容纳无人物功能任务;结果只经回调回呈任务——无身份写入路径 - 叩心域 domains/kouxin:knocking_candidates(event/time_anchor/manual 触发 + 草稿 + 裁决快照);四约束裁决(总开关→静默窗口【本地时区判定】→24h 频率→预算),dispatch 未过 429 且快照留痕;候选默认不自动发送;expire_stale 过期推进不触碰缘契 - 辰正 domains/zhengchen:temporal_context() 复用 time_sense 计算核(快照/农历/季节),产出【时态感知】投影段接入镜树生成 prompt(客观时间语境,不附情绪臆测——守一纪律) - 退役:modules/goal_orchestrator|planner|perception|big_cycle、application/planning、services/governance|goal_persist、candidates 表链(imports 候选抽取 API 面)、goals/plans/agent_roster/perception_channels/big_cycles/旧候选表(迁移 009);前端 admin goals/plans/agents/perception/world 页与六组件;governance 只留 GenerationPreferences;admin 治理面收敛为司命+百工两枢;pivots 健康以域表计数重建 - 验收(test_m8_tiangong 五用例):后台任务无身份写入(记忆/魂籍/缘契零新增);后台与前台并行;分解级联;叩心四约束逐项;不回应不降低关系(缘契维度/状态/版本零变化) - 四套件:后端 345、前端 203、BFF 4、神识 5 包全绿(2 个预存环境失败不变);迁移 009 净库干净
This commit is contained in:
1 parent
b419e0afde
commit
c63b75152a
68 files changed
+1205
-4750
No files matched your search
@@ -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 = (
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
"""会话目标自动持久化服务。
|
||||
|
||||
从用户消息自动持久化会话目标到 goals 表。
|
||||
如果已有该 character 的活跃 session 级目标,判断内容是否显著不同:
|
||||
- 内容相似 → 复用
|
||||
- 内容不同 → 完成旧目标,创建新目标
|
||||
"""
|
||||
|
||||
from app.models.governance import Goal
|
||||
from app.repositories import governance as governance_repo
|
||||
|
||||
_TOPIC_SHIFT_MIN_LENGTH = 20
|
||||
|
||||
|
||||
async def auto_persist_session_goal(
|
||||
session,
|
||||
character_id: str,
|
||||
content: str,
|
||||
) -> Goal | None:
|
||||
"""自动持久化会话目标:复用活跃的 session 目标或新建。
|
||||
|
||||
内容过短(<10 字)不产生目标。
|
||||
如果已有活跃 session 目标但内容显著不同,先完成旧目标再新建。
|
||||
|
||||
Returns:
|
||||
持久化后的 Goal(可能为 None)。
|
||||
"""
|
||||
goal_text = (content or "").strip()
|
||||
if not goal_text or len(goal_text) < 10:
|
||||
return None
|
||||
|
||||
existing = await governance_repo.list_active_session_goal(session, character_id)
|
||||
if existing is not None:
|
||||
# 判断是否话题转换:内容不再相似时完成旧目标
|
||||
if _is_topic_shift(existing.title, goal_text):
|
||||
existing.status = "completed"
|
||||
existing.progress = 1.0
|
||||
# 创建新目标
|
||||
goal = Goal(
|
||||
character_id=character_id,
|
||||
title=goal_text[:120],
|
||||
description=f"从对话自动提取的会话目标:{goal_text[:500]}",
|
||||
level="session",
|
||||
type="mission",
|
||||
status="active",
|
||||
priority=50,
|
||||
)
|
||||
await governance_repo.add_goal(session, goal)
|
||||
return goal
|
||||
return existing
|
||||
|
||||
# 新建 session 目标
|
||||
goal = Goal(
|
||||
character_id=character_id,
|
||||
title=goal_text[:120],
|
||||
description=f"从对话自动提取的会话目标:{goal_text[:500]}",
|
||||
level="session",
|
||||
type="mission",
|
||||
status="active",
|
||||
priority=50,
|
||||
)
|
||||
await governance_repo.add_goal(session, goal)
|
||||
return goal
|
||||
|
||||
|
||||
def _is_topic_shift(existing_title: str, new_content: str) -> bool:
|
||||
"""判断新内容与旧目标标题是否话题转换。
|
||||
|
||||
简单规则:如果新内容较长且与旧标题共享的关键词很少,视为话题转换。
|
||||
这是启发式方法,后续可升级为 LLM 判断。
|
||||
"""
|
||||
if len(new_content) < _TOPIC_SHIFT_MIN_LENGTH:
|
||||
return False
|
||||
# 提取关键词集合(按空格/标点分割)
|
||||
import re
|
||||
old_words = set(re.split(r"[\s,。!?、;:,\.!?;:]+", existing_title.lower()))
|
||||
new_words = set(re.split(r"[\s,。!?、;:,\.!?;:]+", new_content.lower()))
|
||||
# 排除常见的助词
|
||||
stop_words = {"的", "了", "是", "在", "有", "和", "就", "不", "人", "都", "一",
|
||||
"一个", "上", "也", "很", "到", "说", "要", "去", "你", "我", "他",
|
||||
"这", "那", "吗", "啊", "呢", "吧", "地", "得"}
|
||||
old_keywords = old_words - stop_words
|
||||
new_keywords = new_words - stop_words
|
||||
|
||||
if not old_keywords or not new_keywords:
|
||||
return False
|
||||
|
||||
overlap = old_keywords & new_keywords
|
||||
# 如果旧关键词的一个都没有出现在新内容中,或者重合率极低
|
||||
if not overlap:
|
||||
return True
|
||||
return len(overlap) / max(len(old_keywords), len(new_keywords)) < 0.15
|
||||
@@ -1,453 +0,0 @@
|
||||
"""司命台治理对象 CRUD 服务(api-spec §19.4)。
|
||||
|
||||
五组开放集合(目标 / 感知通道 / 世界实体 / 计划 / 天工阁角色)的增删改查;
|
||||
治理写操作一律记审计事件(audit_),并入起居注(§19.3)。pivot 由 action 前缀推断。
|
||||
|
||||
所有函数自管 ``AsyncSessionLocal``(路由直接调用,不传入 session)。
|
||||
"""
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.core.time import utc_now
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.governance import (
|
||||
AgentRosterEntry,
|
||||
Goal,
|
||||
PerceptionChannel,
|
||||
Plan,
|
||||
)
|
||||
from app.modules.runtime.events import Event
|
||||
from app.repositories import governance as governance_repo
|
||||
from app.repositories import runtime as runtime_repo
|
||||
from app.repositories.json_utils import dumps, loads_dict
|
||||
from app.schemas.admin import (
|
||||
AgentRosterResponse,
|
||||
CreateAgentRequest,
|
||||
CreateGoalRequest,
|
||||
CreatePerceptionChannelRequest,
|
||||
CreatePlanRequest,
|
||||
GoalNode,
|
||||
PerceptionChannelResponse,
|
||||
PlanSummaryResponse,
|
||||
UpdateAgentRequest,
|
||||
UpdateGoalRequest,
|
||||
UpdatePerceptionChannelRequest,
|
||||
UpdatePlanRequest,
|
||||
)
|
||||
from app.schemas.safety import (
|
||||
ToolCallDetail,
|
||||
)
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.serializers import (
|
||||
agent_to_response,
|
||||
goal_to_response,
|
||||
perception_channel_to_response,
|
||||
plan_to_response,
|
||||
)
|
||||
|
||||
# —— 太一枢 · 目标 ——
|
||||
|
||||
|
||||
async def list_goals() -> list[GoalNode]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
goals = await governance_repo.list_goals(session)
|
||||
return [goal_to_response(goal) for goal in goals]
|
||||
|
||||
|
||||
async def list_goals_by_character(
|
||||
character_id: str,
|
||||
status_filter: str | None = None,
|
||||
) -> list[GoalNode]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
goals = await governance_repo.list_goals_by_character(
|
||||
session, character_id, status_filter=status_filter
|
||||
)
|
||||
return [goal_to_response(goal) for goal in goals]
|
||||
|
||||
|
||||
async def load_active_goals_context(character_id: str) -> str | None:
|
||||
"""加载活跃目标并格式化为 system prompt 片段,供运行时注入。
|
||||
|
||||
目标按优先级排序(``prioritize_active_goals``),最高优先级在前。
|
||||
"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
goals = await governance_repo.list_goals_by_character(
|
||||
session, character_id, status_filter="active"
|
||||
)
|
||||
if not goals:
|
||||
return None
|
||||
lines = [
|
||||
f"- [{g.level}] {g.title}(优先级 {g.priority},进度 {g.progress}%)"
|
||||
for g in sorted(goals, key=lambda g: -(g.priority or 0))
|
||||
]
|
||||
return "【当前目标】\n" + "\n".join(lines)
|
||||
|
||||
|
||||
async def emit_goal_event(
|
||||
goal: Goal,
|
||||
event_type: str,
|
||||
extra_payload: dict | None = None,
|
||||
) -> None:
|
||||
"""向事件存储写入 goal.* 事件。"""
|
||||
from app.main import app # 惰性 import 避免启动循环
|
||||
|
||||
event_store = getattr(app.state, "event_store", None)
|
||||
if event_store is None:
|
||||
return
|
||||
|
||||
payload = {
|
||||
"goal_id": goal.id,
|
||||
"character_id": goal.character_id,
|
||||
"title": goal.title,
|
||||
"level": goal.level,
|
||||
"type": goal.type,
|
||||
"status": goal.status,
|
||||
"priority": goal.priority,
|
||||
"progress": goal.progress,
|
||||
}
|
||||
if extra_payload:
|
||||
payload.update(extra_payload)
|
||||
|
||||
event = Event(
|
||||
type=f"goal.{event_type}",
|
||||
source="goal_orchestrator",
|
||||
payload=payload,
|
||||
character_id=goal.character_id,
|
||||
visibility="audit",
|
||||
)
|
||||
await event_store.append(event)
|
||||
|
||||
|
||||
async def create_goal(payload: CreateGoalRequest) -> GoalNode:
|
||||
async with AsyncSessionLocal() as session:
|
||||
goal = Goal(
|
||||
title=payload.title,
|
||||
description=payload.description,
|
||||
level=payload.level,
|
||||
type=payload.type,
|
||||
character_id=payload.character_id,
|
||||
status="pending",
|
||||
priority=payload.priority,
|
||||
constraints_json=dumps(payload.constraints),
|
||||
parent_id=payload.parent_id,
|
||||
success_criteria_json=dumps(payload.success_criteria),
|
||||
)
|
||||
await governance_repo.add_goal(session, goal)
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="goal.created",
|
||||
actor="user",
|
||||
input_summary=goal.title,
|
||||
resource_type="goal",
|
||||
resource_id=goal.id,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(goal)
|
||||
await emit_goal_event(goal, "created")
|
||||
return goal_to_response(goal)
|
||||
|
||||
|
||||
async def update_goal(goal_id: str, payload: UpdateGoalRequest) -> GoalNode:
|
||||
async with AsyncSessionLocal() as session:
|
||||
goal = await governance_repo.get_goal(session, goal_id)
|
||||
if goal is None:
|
||||
raise AppError("not_found", "目标不存在。")
|
||||
old_status = goal.status
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
constraints = updates.pop("constraints", None)
|
||||
for key, value in updates.items():
|
||||
setattr(goal, key, value)
|
||||
if constraints is not None:
|
||||
goal.constraints_json = dumps(constraints)
|
||||
goal.updated_at = utc_now()
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="goal.updated",
|
||||
actor="user",
|
||||
input_summary=goal.title,
|
||||
resource_type="goal",
|
||||
resource_id=goal.id,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(goal)
|
||||
if old_status != goal.status:
|
||||
if goal.status == "completed":
|
||||
await emit_goal_event(goal, "completed", {"previous_status": old_status})
|
||||
else:
|
||||
await emit_goal_event(goal, "updated", {"previous_status": old_status})
|
||||
else:
|
||||
await emit_goal_event(goal, "updated", {"previous_status": old_status})
|
||||
return goal_to_response(goal)
|
||||
|
||||
|
||||
async def delete_goal(goal_id: str) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
goal = await governance_repo.get_goal(session, goal_id)
|
||||
if goal is None:
|
||||
raise AppError("not_found", "目标不存在。")
|
||||
if goal.level == "highest":
|
||||
raise AppError("invalid_request", "最高目标(使命级)不可删除。")
|
||||
await session.delete(goal)
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="goal.deleted",
|
||||
actor="user",
|
||||
input_summary=goal.title,
|
||||
resource_type="goal",
|
||||
resource_id=goal.id,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
# —— 烛照枢 · 感知通道 ——
|
||||
|
||||
|
||||
async def list_perception_channels() -> list[PerceptionChannelResponse]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
channels = await governance_repo.list_perception_channels(session)
|
||||
return [perception_channel_to_response(channel) for channel in channels]
|
||||
|
||||
|
||||
async def create_perception_channel(
|
||||
payload: CreatePerceptionChannelRequest,
|
||||
) -> PerceptionChannelResponse:
|
||||
async with AsyncSessionLocal() as session:
|
||||
if await governance_repo.get_perception_channel(session, payload.channel) is not None:
|
||||
raise AppError("conflict", "感知通道已存在。")
|
||||
channel = PerceptionChannel(
|
||||
channel=payload.channel,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
risk_level=payload.risk_level,
|
||||
)
|
||||
await governance_repo.add_perception_channel(session, channel)
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="perception.created",
|
||||
actor="user",
|
||||
input_summary=channel.name,
|
||||
resource_type="perception_channel",
|
||||
resource_id=channel.channel,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(channel)
|
||||
return perception_channel_to_response(channel)
|
||||
|
||||
|
||||
async def update_perception_channel(
|
||||
channel_id: str, payload: UpdatePerceptionChannelRequest
|
||||
) -> PerceptionChannelResponse:
|
||||
async with AsyncSessionLocal() as session:
|
||||
channel = await governance_repo.get_perception_channel(session, channel_id)
|
||||
if channel is None:
|
||||
raise AppError("not_found", "感知通道不存在。")
|
||||
for key, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(channel, key, value)
|
||||
channel.updated_at = utc_now()
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="perception.updated",
|
||||
actor="user",
|
||||
input_summary=channel.name,
|
||||
resource_type="perception_channel",
|
||||
resource_id=channel.channel,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(channel)
|
||||
return perception_channel_to_response(channel)
|
||||
|
||||
|
||||
async def delete_perception_channel(channel_id: str) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
channel = await governance_repo.get_perception_channel(session, channel_id)
|
||||
if channel is None:
|
||||
raise AppError("not_found", "感知通道不存在。")
|
||||
await session.delete(channel)
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="perception.deleted",
|
||||
actor="user",
|
||||
input_summary=channel.name,
|
||||
resource_type="perception_channel",
|
||||
resource_id=channel.channel,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
# —— 河图枢 · 世界实体 ——
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# —— 洛书枢 · 计划 ——
|
||||
|
||||
|
||||
async def list_plans() -> list[PlanSummaryResponse]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
plans = await governance_repo.list_plans(session)
|
||||
return [plan_to_response(plan) for plan in plans]
|
||||
|
||||
|
||||
async def create_plan(payload: CreatePlanRequest) -> PlanSummaryResponse:
|
||||
async with AsyncSessionLocal() as session:
|
||||
plan = Plan(
|
||||
goal_id=payload.goal_id,
|
||||
title=payload.title,
|
||||
assumptions_json=dumps(payload.assumptions),
|
||||
risks_json=dumps(payload.risks),
|
||||
)
|
||||
await governance_repo.add_plan(session, plan)
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="plan.created",
|
||||
actor="user",
|
||||
input_summary=plan.title,
|
||||
resource_type="plan",
|
||||
resource_id=plan.id,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(plan)
|
||||
return plan_to_response(plan)
|
||||
|
||||
|
||||
async def update_plan(plan_id: str, payload: UpdatePlanRequest) -> PlanSummaryResponse:
|
||||
async with AsyncSessionLocal() as session:
|
||||
plan = await governance_repo.get_plan(session, plan_id)
|
||||
if plan is None:
|
||||
raise AppError("not_found", "计划不存在。")
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
assumptions = updates.pop("assumptions", None)
|
||||
risks = updates.pop("risks", None)
|
||||
for key, value in updates.items():
|
||||
setattr(plan, key, value)
|
||||
if assumptions is not None:
|
||||
plan.assumptions_json = dumps(assumptions)
|
||||
if risks is not None:
|
||||
plan.risks_json = dumps(risks)
|
||||
plan.updated_at = utc_now()
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="plan.updated",
|
||||
actor="user",
|
||||
input_summary=plan.title,
|
||||
resource_type="plan",
|
||||
resource_id=plan.id,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(plan)
|
||||
return plan_to_response(plan)
|
||||
|
||||
|
||||
async def delete_plan(plan_id: str) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
plan = await governance_repo.get_plan(session, plan_id)
|
||||
if plan is None:
|
||||
raise AppError("not_found", "计划不存在。")
|
||||
await session.delete(plan)
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="plan.deleted",
|
||||
actor="user",
|
||||
input_summary=plan.title,
|
||||
resource_type="plan",
|
||||
resource_id=plan.id,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
# —— 天工阁枢 · 角色在岗 ——
|
||||
|
||||
|
||||
async def list_agent_roster() -> list[AgentRosterResponse]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
agents = await governance_repo.list_agent_roster(session)
|
||||
return [agent_to_response(agent) for agent in agents]
|
||||
|
||||
|
||||
async def create_agent(payload: CreateAgentRequest) -> AgentRosterResponse:
|
||||
async with AsyncSessionLocal() as session:
|
||||
if await governance_repo.get_agent(session, payload.role) is not None:
|
||||
raise AppError("conflict", "角色已存在。")
|
||||
agent = AgentRosterEntry(
|
||||
role=payload.role,
|
||||
name=payload.name,
|
||||
responsibility=payload.responsibility,
|
||||
)
|
||||
await governance_repo.add_agent(session, agent)
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="agent.created",
|
||||
actor="user",
|
||||
input_summary=agent.name,
|
||||
resource_type="agent",
|
||||
resource_id=agent.role,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(agent)
|
||||
return agent_to_response(agent)
|
||||
|
||||
|
||||
async def update_agent(role: str, payload: UpdateAgentRequest) -> AgentRosterResponse:
|
||||
async with AsyncSessionLocal() as session:
|
||||
agent = await governance_repo.get_agent(session, role)
|
||||
if agent is None:
|
||||
raise AppError("not_found", "角色不存在。")
|
||||
for key, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(agent, key, value)
|
||||
agent.updated_at = utc_now()
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="agent.updated",
|
||||
actor="user",
|
||||
input_summary=agent.name,
|
||||
resource_type="agent",
|
||||
resource_id=agent.role,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(agent)
|
||||
return agent_to_response(agent)
|
||||
|
||||
|
||||
async def delete_agent(role: str) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
agent = await governance_repo.get_agent(session, role)
|
||||
if agent is None:
|
||||
raise AppError("not_found", "角色不存在。")
|
||||
await session.delete(agent)
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="agent.deleted",
|
||||
actor="user",
|
||||
input_summary=agent.name,
|
||||
resource_type="agent",
|
||||
resource_id=agent.role,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
# —— 百工枢 · 工具策略(§15 / §16 共享)——
|
||||
|
||||
|
||||
async def get_tool_call(tool_call_id: str) -> ToolCallDetail:
|
||||
"""GET /tools/calls/{tool_call_id}:单条 tool.* 事件详情。
|
||||
|
||||
tool_call_id 对应一炁事件存储的事件主键;仅返回 tool.* 类型事件,否则 404。
|
||||
"""
|
||||
from app.core.time import to_iso
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
event = await runtime_repo.get_event(session, tool_call_id)
|
||||
if event is None or not event.type.startswith("tool."):
|
||||
raise AppError("not_found", f"工具调用 {tool_call_id} 不存在。")
|
||||
payload = loads_dict(event.payload_json)
|
||||
return ToolCallDetail(
|
||||
event_id=event.id,
|
||||
type=event.type,
|
||||
tool=payload.get("tool"),
|
||||
run_id=event.run_id,
|
||||
character_id=event.character_id,
|
||||
trace_id=event.trace_id,
|
||||
payload=payload,
|
||||
created_at=to_iso(event.created_at),
|
||||
)
|
||||
@@ -16,15 +16,8 @@ from app.core.time import utc_now
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.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)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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 退役)
|
||||
|
||||
Reference in new issue
Block a user