"""司命台管理聚合服务(api-spec §19.1–§19.3)。 九枢健康度由各枢运行/治理数据派生(不落长期状态,§19.1);朱批待办复用 ConfirmationStore(§19.2);起居注审计复用 audit_events(§19.3)。 """ import json from sqlalchemy import func, select from app.application.tool_runtime.runtime import ToolRuntime 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, WorldEntity, ) 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 from app.modules.safety.confirmation import ConfirmationStore, PendingConfirmation 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 ( AuditEntry, PendingApproval, PivotHealthReport, PivotHealthSummary, TraceDetail, TraceItem, TraceListResponse, TraceSummary, ) from app.schemas.common import Page from app.services.serializers import audit_to_response # 九枢全集(契约字典 §5.9):枢标识、中文名、工程模块名。 _PIVOT_REGISTRY: list[tuple[str, str, str]] = [ ("personality_core", "灵核枢", "Personality Core"), ("goal_orchestrator", "太一枢", "Goal Orchestrator"), ("safety_governor", "司命枢", "Safety Governor"), ("perception", "烛照枢", "Perception Layer"), ("world_model", "河图枢", "World Model"), ("planner", "洛书枢", "Planner"), ("memory", "息壤枢", "Memory System"), ("tool_runtime", "百工枢", "Tool Runtime"), ("agent_society", "天工阁枢", "Agent Society"), ] def _summarize_input(value: dict | None) -> str: if not value: return "" text = json.dumps(value, ensure_ascii=False, default=str) return redact_text(text)[:200] async def get_pivots_health( *, confirmation_store: ConfirmationStore, tool_runtime: ToolRuntime | None, owner_scope: str = settings.default_owner_scope, ) -> PivotHealthReport: async with AsyncSessionLocal() as session: pending_count = len(await confirmation_store.list_pending()) goals_n = await governance_repo.count(session, Goal) world_n = await governance_repo.count(session, WorldEntity) 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") character = await character_repo.get_active_character(session, owner_scope) has_personality = bool(character and character.current_personality_version_id) tool_enabled = settings.enable_tool_runtime and tool_runtime is not None 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} 项"), ("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, ) ) 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 get_pending_approvals( *, confirmation_store: ConfirmationStore, tool_runtime: ToolRuntime | None, ) -> list[PendingApproval]: specs = {spec.name: spec for spec in tool_runtime.list_specs()} if tool_runtime else {} approvals: list[PendingApproval] = [] for pending in await confirmation_store.list_pending(): spec = specs.get(pending.tool) permission_policy = str(spec.permission_policy) if spec else "ask" possible_impact = "可能产生副作用" if (spec and spec.side_effect) else None approvals.append(_pending_to_approval(pending, permission_policy, possible_impact)) return approvals def _pending_to_approval( pending: PendingConfirmation, permission_policy: str, possible_impact: str | None ) -> PendingApproval: return PendingApproval( confirmation_id=pending.confirmation_id, character_id=pending.character_id, tool=pending.tool, risk_level=pending.risk_level, permission_policy=permission_policy, action_summary=f"调用工具 {pending.tool}", input_summary=_summarize_input(pending.input), possible_impact=possible_impact, reason=pending.reason, created_at=pending.created_at, ) async def get_audit_timeline( *, pivot: str | None = None, page: int = 1, page_size: int = 50, ) -> Page[AuditEntry]: async with AsyncSessionLocal() as session: events, total = await audit_repo.list_audit_events( session, pivot=pivot, page=page, page_size=page_size ) return Page( items=[audit_to_response(event) for event in events], page=page, page_size=page_size, total=total, ) # —— §19.5 链路追踪(按 trace_id 聚合 events / audit_events / idempotency_keys)—— def _event_to_item(event: Event) -> TraceItem: try: payload = json.loads(event.payload_json) if event.payload_json else {} except (json.JSONDecodeError, TypeError): payload = {} return TraceItem( kind="event", id=event.id, created_at=event.created_at, source_label=f"运行事件 / {event.type}", summary=event.type, detail={ "run_id": event.run_id, "source": event.source, "type": event.type, "payload": payload, "sequence": event.sequence, "parent_id": event.parent_id, }, ) def _audit_to_item(audit: AuditEvent) -> TraceItem: parts = [audit.decision, audit.risk_level] if audit.reason: parts.append(audit.reason[:60]) return TraceItem( kind="audit", id=audit.id, created_at=audit.created_at, source_label=f"安全审计 / {audit.action}", summary=" · ".join(parts), detail={ "pivot": audit.pivot, "action": audit.action, "actor": audit.actor, "decision": audit.decision, "risk_level": audit.risk_level, "reason": audit.reason, "resource_type": audit.resource_type, "resource_id": audit.resource_id, }, ) def _idempotency_to_item(idem: IdempotencyKey) -> TraceItem: return TraceItem( kind="idempotency", id=idem.id, created_at=idem.created_at, source_label=f"请求幂等 / {idem.method}", summary=f"{idem.method} {idem.path} → {idem.response_status_code}", detail={ "method": idem.method, "path": idem.path, "status": idem.status, "response_status_code": idem.response_status_code, "scope": idem.scope, }, ) async def get_trace(trace_id: str) -> TraceDetail: """按 trace_id 聚合 events / audit_events / idempotency_keys 三表为升序时间线。""" async with AsyncSessionLocal() as session: events = await runtime_repo.list_events_by_trace(session, trace_id) audits = await audit_repo.list_audit_events_by_trace(session, trace_id) idempotencies = await idempotency_repo.list_by_trace(session, trace_id) items: list[TraceItem] = [_event_to_item(event) for event in events] items.extend(_audit_to_item(audit) for audit in audits) items.extend(_idempotency_to_item(idem) for idem in idempotencies) items.sort(key=lambda item: item.created_at) run_ids = sorted({event.run_id for event in events if event.run_id}) character_ids = sorted( { *[event.character_id for event in events if event.character_id], *[audit.character_id for audit in audits if audit.character_id], } ) return TraceDetail( trace_id=trace_id, character_ids=character_ids, run_ids=run_ids, session_ids=[], items=items, ) async def list_time_events(*, limit: int = 100, offset: int = 0) -> tuple[list[Event], int]: """列出辰正 time.* 事件(source='time_sense'),供起居注时间河展示(设计 §8 司命台)。""" async with AsyncSessionLocal() as session: result = await session.execute( select(Event) .where(Event.source == "time_sense") .order_by(Event.created_at.desc()) .limit(limit) .offset(offset) ) items = list(result.scalars().all()) count_result = await session.execute( select(func.count()).select_from(Event).where(Event.source == "time_sense") ) total = int(count_result.scalar_one()) return items, total async def list_traces(*, limit: int = 50) -> TraceListResponse: """列出最近活动的 trace_id 概览(跨 events / audit_events / idempotency_keys 聚合), 含每条链路涉及的人物与对话归属。""" async with AsyncSessionLocal() as session: merged: dict[str, dict[str, object]] = {} def _entry(tid: str) -> dict[str, object]: return merged.setdefault( tid, { "trace_id": tid, "last_seen": None, "event_count": 0, "audit_count": 0, "idempotency_count": 0, "character_ids": set(), "session_ids": set(), }, ) def _absorb(stats: list[tuple[str, int, object]], count_key: str) -> None: for tid, cnt, last in stats: entry = _entry(tid) entry[count_key] = cnt if last is not None and (entry["last_seen"] is None or last > entry["last_seen"]): entry["last_seen"] = last _absorb(await runtime_repo.summarize_by_trace(session), "event_count") _absorb(await audit_repo.summarize_by_trace(session), "audit_count") _absorb(await idempotency_repo.summarize_by_trace(session), "idempotency_count") # 人物归属(events + audits 的 character_id) for tid, cid in ( await session.execute( select(Event.trace_id, Event.character_id).where( Event.trace_id.is_not(None), Event.character_id.is_not(None) ) ) ).all(): _entry(tid)["character_ids"].add(cid) for tid, cid in ( await session.execute( select(AuditEvent.trace_id, AuditEvent.character_id).where( AuditEvent.trace_id.is_not(None), AuditEvent.character_id.is_not(None), ) ) ).all(): _entry(tid)["character_ids"].add(cid) # 对话归属随线性会话模型退役;镜树归属随 M2c 司命台重设计接入(tree_id 聚合) total = len(merged) ordered = sorted(merged.values(), key=lambda e: e["last_seen"], reverse=True) items = [ TraceSummary( trace_id=str(entry["trace_id"]), last_seen=entry["last_seen"], # type: ignore[arg-type] character_ids=sorted(entry["character_ids"]), # type: ignore[arg-type] session_ids=sorted(entry["session_ids"]), # type: ignore[arg-type] event_count=int(entry["event_count"]), audit_count=int(entry["audit_count"]), idempotency_count=int(entry["idempotency_count"]), ) for entry in ordered[:limit] ] return TraceListResponse(items=items, total=total)