646 lines
22 KiB
Python
646 lines
22 KiB
Python
"""司命台治理对象 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,
|
|
ToolPolicy,
|
|
WorldEntity,
|
|
)
|
|
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,
|
|
CreateWorldEntityRequest,
|
|
GoalNode,
|
|
PerceptionChannelResponse,
|
|
PlanSummaryResponse,
|
|
UpdateAgentRequest,
|
|
UpdateGoalRequest,
|
|
UpdatePerceptionChannelRequest,
|
|
UpdatePlanRequest,
|
|
UpdateWorldEntityRequest,
|
|
WorldEntityResponse,
|
|
)
|
|
from app.schemas.safety import (
|
|
CreateToolPolicyRequest,
|
|
SafetyPolicyItem,
|
|
ToolCallDetail,
|
|
ToolPolicyResponse,
|
|
UpdateToolPolicyRequest,
|
|
)
|
|
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,
|
|
tool_policy_to_response,
|
|
tool_policy_to_safety_item,
|
|
world_entity_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``),最高优先级在前。
|
|
"""
|
|
from app.modules.goal_orchestrator import prioritize_active_goals
|
|
from app.modules.runtime.goals import build_goals_context
|
|
|
|
async with AsyncSessionLocal() as session:
|
|
goals = await governance_repo.list_goals_by_character(
|
|
session, character_id, status_filter="active"
|
|
)
|
|
if not goals:
|
|
return None
|
|
goals_dicts = [
|
|
{
|
|
"title": g.title,
|
|
"level": g.level,
|
|
"status": g.status,
|
|
"priority": g.priority,
|
|
"progress": g.progress,
|
|
}
|
|
for g in goals
|
|
]
|
|
# 优先级排序后注入运行时,使最高优先级目标最先被看到
|
|
sorted_goals = prioritize_active_goals(goals_dicts)
|
|
return build_goals_context(sorted_goals)
|
|
|
|
|
|
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_world_entities() -> list[WorldEntityResponse]:
|
|
async with AsyncSessionLocal() as session:
|
|
entities = await governance_repo.list_world_entities(session)
|
|
return [world_entity_to_response(entity) for entity in entities]
|
|
|
|
|
|
async def create_world_entity(
|
|
payload: CreateWorldEntityRequest,
|
|
) -> WorldEntityResponse:
|
|
async with AsyncSessionLocal() as session:
|
|
entity = WorldEntity(
|
|
type=payload.type,
|
|
name=payload.name,
|
|
attributes_json=dumps(payload.attributes),
|
|
state_type=payload.state_type,
|
|
confidence=payload.confidence,
|
|
sources_json=dumps(payload.sources),
|
|
)
|
|
await governance_repo.add_world_entity(session, entity)
|
|
await record_audit_event(
|
|
session,
|
|
action="world.created",
|
|
actor="user",
|
|
input_summary=entity.name,
|
|
resource_type="world_entity",
|
|
resource_id=entity.id,
|
|
)
|
|
await session.commit()
|
|
await session.refresh(entity)
|
|
return world_entity_to_response(entity)
|
|
|
|
|
|
async def update_world_entity(
|
|
entity_id: str, payload: UpdateWorldEntityRequest
|
|
) -> WorldEntityResponse:
|
|
async with AsyncSessionLocal() as session:
|
|
entity = await governance_repo.get_world_entity(session, entity_id)
|
|
if entity is None:
|
|
raise AppError("not_found", "世界实体不存在。")
|
|
for key, value in payload.model_dump(exclude_unset=True).items():
|
|
setattr(entity, key, value)
|
|
entity.updated_at = utc_now()
|
|
await record_audit_event(
|
|
session,
|
|
action="world.updated",
|
|
actor="user",
|
|
input_summary=entity.name,
|
|
resource_type="world_entity",
|
|
resource_id=entity.id,
|
|
)
|
|
await session.commit()
|
|
await session.refresh(entity)
|
|
return world_entity_to_response(entity)
|
|
|
|
|
|
async def delete_world_entity(entity_id: str) -> None:
|
|
async with AsyncSessionLocal() as session:
|
|
entity = await governance_repo.get_world_entity(session, entity_id)
|
|
if entity is None:
|
|
raise AppError("not_found", "世界实体不存在。")
|
|
await session.delete(entity)
|
|
await record_audit_event(
|
|
session,
|
|
action="world.deleted",
|
|
actor="user",
|
|
input_summary=entity.name,
|
|
resource_type="world_entity",
|
|
resource_id=entity.id,
|
|
)
|
|
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 共享)——
|
|
|
|
|
|
def _builtin_tool_names() -> set[str]:
|
|
"""builtin 工具名集合(惰性 import,避免 application→services 循环依赖)。"""
|
|
from app.application.tool_runtime.builtins import builtin_specs
|
|
|
|
return set(builtin_specs().keys())
|
|
|
|
|
|
async def list_tool_policies() -> list[ToolPolicyResponse]:
|
|
"""GET /tools:完整元数据,executable 按 builtin 派生。"""
|
|
async with AsyncSessionLocal() as session:
|
|
policies = await governance_repo.list_tool_policies(session)
|
|
builtins = _builtin_tool_names()
|
|
return [tool_policy_to_response(p, executable=p.name in builtins) for p in policies]
|
|
|
|
|
|
async def list_safety_policies() -> list[SafetyPolicyItem]:
|
|
"""GET /safety/policies:策略子集。"""
|
|
async with AsyncSessionLocal() as session:
|
|
policies = await governance_repo.list_tool_policies(session)
|
|
return [tool_policy_to_safety_item(p) for p in policies]
|
|
|
|
|
|
async def create_tool_policy(payload: CreateToolPolicyRequest) -> ToolPolicyResponse:
|
|
"""POST /tools、POST /safety/policies:注册工具策略(source=custom)。"""
|
|
async with AsyncSessionLocal() as session:
|
|
if await governance_repo.get_tool_policy(session, payload.name) is not None:
|
|
raise AppError("invalid_request", f"工具 {payload.name} 已存在。")
|
|
policy = ToolPolicy(
|
|
name=payload.name,
|
|
description=payload.description,
|
|
risk_level=payload.risk_level,
|
|
permission_policy=payload.permission_policy,
|
|
side_effect=payload.side_effect,
|
|
audit_required=payload.audit_required,
|
|
source="custom",
|
|
enabled=payload.enabled,
|
|
)
|
|
await governance_repo.add_tool_policy(session, policy)
|
|
await record_audit_event(
|
|
session,
|
|
action="tool.policy_created",
|
|
actor="user",
|
|
risk_level=payload.risk_level,
|
|
input_summary=payload.name,
|
|
resource_type="tool_policy",
|
|
resource_id=policy.name,
|
|
)
|
|
await session.commit()
|
|
await session.refresh(policy)
|
|
return tool_policy_to_response(policy, executable=policy.name in _builtin_tool_names())
|
|
|
|
|
|
async def update_tool_policy(name: str, payload: UpdateToolPolicyRequest) -> ToolPolicyResponse:
|
|
"""PATCH /tools/{name}、PATCH /safety/policies/{tool}:编辑策略元数据。"""
|
|
async with AsyncSessionLocal() as session:
|
|
policy = await governance_repo.get_tool_policy(session, name)
|
|
if policy is None:
|
|
raise AppError("not_found", f"工具 {name} 不存在。")
|
|
updates = payload.model_dump(exclude_unset=True)
|
|
if not updates:
|
|
raise AppError("invalid_request", "至少需要提供一个待更新字段。")
|
|
for key, value in updates.items():
|
|
setattr(policy, key, value)
|
|
policy.updated_at = utc_now()
|
|
await record_audit_event(
|
|
session,
|
|
action="tool.policy_updated",
|
|
actor="user",
|
|
risk_level=policy.risk_level,
|
|
input_summary=policy.name,
|
|
resource_type="tool_policy",
|
|
resource_id=policy.name,
|
|
)
|
|
await session.commit()
|
|
await session.refresh(policy)
|
|
return tool_policy_to_response(policy, executable=policy.name in _builtin_tool_names())
|
|
|
|
|
|
async def delete_tool_policy(name: str) -> None:
|
|
"""DELETE /tools/{name}、DELETE /safety/policies/{tool}:注销策略。
|
|
|
|
builtin 工具的策略记录不可删除(执行实现仍在代码层);只允许注销 custom 工具。
|
|
"""
|
|
async with AsyncSessionLocal() as session:
|
|
policy = await governance_repo.get_tool_policy(session, name)
|
|
if policy is None:
|
|
raise AppError("not_found", f"工具 {name} 不存在。")
|
|
if policy.source == "builtin":
|
|
raise AppError("invalid_request", f"内置工具 {name} 不可删除,可改用 PATCH 禁用。")
|
|
await session.delete(policy)
|
|
await record_audit_event(
|
|
session,
|
|
action="tool.policy_deleted",
|
|
actor="user",
|
|
input_summary=policy.name,
|
|
resource_type="tool_policy",
|
|
resource_id=policy.name,
|
|
)
|
|
await session.commit()
|
|
|
|
|
|
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),
|
|
)
|