Files
hunjing/backend/app/services/governance.py
T
2026-08-21 16:00:13 +08:00

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),
)