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

+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 退役)