Files
hunjing/backend/app/services/admin.py
T
chenxin 7e23d6fa84 M6:缘契、人格与世界——契约版本链、铸魂分灵、河图境界与三界分流
- 缘契域 domains/yuanqi:covenants(当前版)+ covenant_events(不可变历史);提议—问心—铭契—撤回全链;边界变更立即生效;多维关系并存,服务层拒绝 affinity/好感度单一键;浪漫维度成人资格标记位
- 道体铸魂与分灵 domains/jingsheng/soul.py:soul_forgings 确认流(锚点变更 pending→confirmed 应用并签发新魂籍版本,确认前人物锚点不变);分灵创建新人物(继承资料与谱系 lineage 链、不继承原身份,魂籍从 v1 起)
- 河图域 domains/hetu:persistent_worlds 境界 + world_entities/world_relations 境界作用域重建 + 世谱挂接;境界正史视图(scope=persistent_world 归真集合);canonize 后置推进正史计数
- 三界分流:reality→息壤提取(不变);persistent_world→境界实录+正史推进;simulation→只记试演实录;factual_records 增 scope 列——境界/幻境实录与真界本相隔离
- 正影打通:引文 target_type=memory 时被引记忆 superseded → 返回 successor_memory_id 提示,不静默替换
- 前端:人物详情页缘契面板(提议维度选择/铭契/撤回/事件史)+ 世界页重建(境界目录/实体/正史概览)+ covenants/worlds api 库
- 退役:旧 personality/drive/evolution/world 模块、服务、端点、仓储、schema、前端五页与组件样式;迁移 007 退役九张旧表与 characters.current_personality_version_id
- 验收(test_m6_domains 六用例):多维无单一好感度/铭契版本链/事件不可抹除/撤回立即生效/边界即时/铸魂确认流+魂籍版本/分灵谱系/境界正史不进真界(实录 scope 隔离+真界候选=0)/引文正影后继
- 四套件:后端 379、前端 199、BFF 4、神识 5 包全绿(2 个预存环境失败不变);迁移 007 净库+±1 干净
2026-08-27 20:10:11 +08:00

372 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""司命台管理聚合服务(api-spec §19.1–§19.3)。
九枢健康度由各枢运行/治理数据派生(不落长期状态,§19.1);朱批待办复用
待决请命(§19.2);起居注审计复用 audit_events(§19.3)。
"""
import json
from sqlalchemy import func, select
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 (
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(
*,
owner_scope: str = settings.default_owner_scope,
) -> PivotHealthReport:
async with AsyncSessionLocal() as session:
pending_count = len(
list(
(
await session.execute(
select(PendingConfirmationRecord).where(
PendingConfirmationRecord.status == "pending"
)
)
)
.scalars()
.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")
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} 项"),
("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() -> list[PendingApproval]:
async with AsyncSessionLocal() as session:
records = (
(
await session.execute(
select(PendingConfirmationRecord)
.where(PendingConfirmationRecord.status == "pending")
.order_by(PendingConfirmationRecord.created_at.asc())
)
)
.scalars()
.all()
)
approvals: list[PendingApproval] = []
for record in records:
try:
input_payload = json.loads(record.input_json) if record.input_json else {}
except ValueError:
input_payload = {}
approvals.append(
PendingApproval(
confirmation_id=record.id,
character_id=record.character_id,
tool=record.tool,
risk_level=record.risk_level,
permission_policy="ask",
action_summary=f"调用能力 {record.tool}",
input_summary=_summarize_input(input_payload),
possible_impact=None,
reason=record.reason,
created_at=record.created_at.isoformat() if record.created_at else "",
)
)
return approvals
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)