- 天工阁域 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 净库干净
397 lines
12 KiB
Python
397 lines
12 KiB
Python
"""司命台管理 API(api-spec §19)。
|
||
|
||
聚合九枢治理视图:九枢健康度 / 朱批待办 / 起居注审计(§19.1–§19.3)
|
||
+ 五组治理对象 CRUD(§19.4)。治理写操作由 service 层记审计并入起居注。
|
||
|
||
路由直接调用 ``services`` 函数(函数内部自管 ``AsyncSessionLocal``),不持有 session;
|
||
聚合视图自持查询(百工域常驻),无注入运行时。
|
||
"""
|
||
|
||
import json
|
||
|
||
from fastapi import APIRouter, Depends, Query
|
||
from pydantic import BaseModel
|
||
|
||
from app.api.v1.deps import (
|
||
get_current_user,
|
||
require_admin,
|
||
)
|
||
from app.models.runtime import Event
|
||
from app.schemas.admin_character import (
|
||
AdminCreateTemplateRequest,
|
||
AdminTemplateSummaryResponse,
|
||
AdminUpdateTemplateRequest,
|
||
)
|
||
from app.schemas.admin import (
|
||
AdminCreateUserRequest,
|
||
AdminUpdateUserRequest,
|
||
AdminUserResponse,
|
||
AuditEntry,
|
||
PendingApproval,
|
||
PivotHealthReport,
|
||
TimeEventListResponse,
|
||
TimeEventResponse,
|
||
TraceDetail,
|
||
TraceListResponse,
|
||
)
|
||
from app.schemas.auth import CurrentUser
|
||
from app.schemas.common import Page
|
||
from app.services.admin import (
|
||
get_audit_timeline,
|
||
get_pending_approvals,
|
||
get_pivots_health,
|
||
get_trace,
|
||
list_time_events,
|
||
list_traces,
|
||
)
|
||
from app.services.admin_characters import (
|
||
admin_create_template,
|
||
admin_delete_template,
|
||
admin_get_template,
|
||
admin_list_templates,
|
||
admin_update_template,
|
||
)
|
||
|
||
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)])
|
||
|
||
|
||
# —— §19.1 九枢健康度聚合 ——
|
||
|
||
|
||
@router.get("/pivots/health", response_model=PivotHealthReport)
|
||
async def pivots_health(
|
||
current_user: CurrentUser = Depends(get_current_user),
|
||
) -> PivotHealthReport:
|
||
return await get_pivots_health(
|
||
owner_scope=current_user.owner_scope,
|
||
)
|
||
|
||
|
||
# —— §19.2 朱批待办 ——
|
||
|
||
|
||
@router.get("/safety/pending", response_model=Page[PendingApproval])
|
||
async def safety_pending(
|
||
) -> Page[PendingApproval]:
|
||
items = await get_pending_approvals()
|
||
return Page(items=items, page=1, page_size=len(items), total=len(items))
|
||
|
||
|
||
# —— §19.3 起居注审计 ——
|
||
|
||
|
||
@router.get("/audit/timeline", response_model=Page[AuditEntry])
|
||
async def audit_timeline(
|
||
pivot: str | None = Query(default=None, description="按枢过滤(契约字典 §5.9)。"),
|
||
page: int = Query(default=1, ge=1),
|
||
page_size: int = Query(default=50, ge=1, le=200),
|
||
) -> Page[AuditEntry]:
|
||
return await get_audit_timeline(pivot=pivot, page=page, page_size=page_size)
|
||
|
||
|
||
# —— §19.4 太一枢 · 目标 ——
|
||
|
||
|
||
|
||
|
||
|
||
|
||
# —— §19.4 烛照枢 · 感知通道 ——
|
||
|
||
|
||
|
||
|
||
|
||
|
||
# —— §19.4 洛书枢 · 计划 ——
|
||
|
||
|
||
|
||
|
||
|
||
|
||
# —— §19.4 天工阁枢 · 角色在岗 ——
|
||
|
||
|
||
|
||
|
||
|
||
|
||
# —— §19.5 链路追踪 ——
|
||
|
||
|
||
@router.get("/traces", response_model=TraceListResponse)
|
||
async def trace_list(
|
||
limit: int = Query(default=50, ge=1, le=500),
|
||
) -> TraceListResponse:
|
||
"""列出最近活动的 trace_id 概览(跨运行事件 / 安全审计 / 请求幂等聚合)。"""
|
||
return await list_traces(limit=limit)
|
||
|
||
|
||
@router.get("/traces/{trace_id}", response_model=TraceDetail)
|
||
async def trace_detail(trace_id: str) -> TraceDetail:
|
||
"""按 trace_id 聚合运行事件 / 安全审计 / 请求幂等三表为升序时间线。"""
|
||
return await get_trace(trace_id)
|
||
|
||
|
||
# —— §8 司命台 · 起居注时间河(辰正 time.* 事件)——
|
||
|
||
|
||
@router.get("/time-events", response_model=TimeEventListResponse)
|
||
async def time_event_list(
|
||
limit: int = Query(default=100, ge=1, le=200),
|
||
offset: int = Query(default=0, ge=0),
|
||
) -> TimeEventListResponse:
|
||
"""起居注时间河:辰正 time.* 事件(§8 司命台接入,source='time_sense')。"""
|
||
items, total = await list_time_events(limit=limit, offset=offset)
|
||
return TimeEventListResponse(
|
||
items=[_time_event_to_response(item) for item in items],
|
||
total=total,
|
||
)
|
||
|
||
|
||
def _time_event_to_response(event: Event) -> TimeEventResponse:
|
||
"""复用既有 payload 解析模式(json.loads + 异常回退到空 dict,与 _event_to_item 一致)。"""
|
||
try:
|
||
payload = json.loads(event.payload_json) if event.payload_json else {}
|
||
except (json.JSONDecodeError, TypeError):
|
||
payload = {}
|
||
return TimeEventResponse(
|
||
id=event.id,
|
||
type=event.type,
|
||
payload=payload,
|
||
run_id=event.run_id,
|
||
character_id=event.character_id,
|
||
sensitivity=event.sensitivity,
|
||
created_at=event.created_at.isoformat() if event.created_at else "",
|
||
)
|
||
|
||
|
||
# —— §19.6 用户管理 ——
|
||
|
||
|
||
@router.get("/users", response_model=list[AdminUserResponse])
|
||
async def admin_list_users() -> list[AdminUserResponse]:
|
||
"""列出所有用户(管理员用)。"""
|
||
from sqlalchemy import select
|
||
|
||
from app.db.session import AsyncSessionLocal
|
||
from app.models.user import User
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
result = await session.execute(select(User).order_by(User.created_at))
|
||
users = result.scalars().all()
|
||
return [AdminUserResponse.model_validate(u) for u in users]
|
||
|
||
|
||
@router.post("/users", response_model=AdminUserResponse)
|
||
async def admin_create_user(payload: AdminCreateUserRequest) -> AdminUserResponse:
|
||
"""创建用户(管理员用)。"""
|
||
from app.db.session import AsyncSessionLocal
|
||
from app.services.auth import create_user
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
user = await create_user(
|
||
session,
|
||
username=payload.username,
|
||
email=payload.email,
|
||
password=payload.password,
|
||
is_admin=payload.is_admin,
|
||
)
|
||
await session.commit()
|
||
await session.refresh(user)
|
||
return AdminUserResponse.model_validate(user)
|
||
|
||
|
||
@router.patch("/users/{user_id}", response_model=AdminUserResponse)
|
||
async def admin_update_user(user_id: str, payload: AdminUpdateUserRequest) -> AdminUserResponse:
|
||
"""更新用户(启用/禁用、管理员角色)。"""
|
||
from sqlalchemy import select
|
||
|
||
from app.db.session import AsyncSessionLocal
|
||
from app.models.user import User
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
result = await session.execute(select(User).where(User.id == user_id))
|
||
user = result.scalar_one_or_none()
|
||
if user is None:
|
||
from app.core.errors import AppError
|
||
|
||
raise AppError("not_found", "用户不存在。")
|
||
if payload.is_active is not None:
|
||
user.is_active = payload.is_active
|
||
if payload.is_admin is not None:
|
||
user.is_admin = payload.is_admin
|
||
await session.commit()
|
||
await session.refresh(user)
|
||
return AdminUserResponse.model_validate(user)
|
||
|
||
|
||
# —— §19.7 多租户管理视图 ——
|
||
|
||
|
||
class _TenantUserOverview(BaseModel):
|
||
"""租户概览(管理员跨租户视角)。"""
|
||
|
||
user_id: str
|
||
username: str
|
||
email: str
|
||
is_active: bool
|
||
is_admin: bool
|
||
character_count: int
|
||
memory_count: int
|
||
|
||
|
||
@router.get("/tenants/overview", response_model=list[_TenantUserOverview])
|
||
async def admin_tenants_overview() -> list[_TenantUserOverview]:
|
||
"""跨租户概览:每用户的人物与记忆计数(管理员用)。"""
|
||
from sqlalchemy import func, select
|
||
|
||
from app.db.session import AsyncSessionLocal
|
||
from app.models.character import Character
|
||
from app.models.xirang import Memory
|
||
from app.models.user import User
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
result = await session.execute(select(User).order_by(User.created_at))
|
||
users = result.scalars().all()
|
||
overview: list[_TenantUserOverview] = []
|
||
for user in users:
|
||
char_count = await session.scalar(
|
||
select(func.count())
|
||
.select_from(Character)
|
||
.where(
|
||
Character.owner_scope == user.id,
|
||
Character.is_system_template.is_(False),
|
||
)
|
||
)
|
||
mem_count = await session.scalar(
|
||
select(func.count())
|
||
.select_from(Memory)
|
||
.join(Character, Memory.character_id == Character.id).where(Memory.status != "tombstoned")
|
||
.where(
|
||
Character.owner_scope == user.id,
|
||
Character.is_system_template.is_(False),
|
||
)
|
||
)
|
||
overview.append(
|
||
_TenantUserOverview(
|
||
user_id=user.id,
|
||
username=user.username,
|
||
email=user.email,
|
||
is_active=user.is_active,
|
||
is_admin=user.is_admin,
|
||
character_count=char_count or 0,
|
||
memory_count=mem_count or 0,
|
||
)
|
||
)
|
||
return overview
|
||
|
||
|
||
@router.get("/tenants/{user_id}/characters")
|
||
async def admin_tenant_characters(user_id: str) -> list[dict]:
|
||
"""查看指定用户的所有人物(跨租户管理员用)。"""
|
||
from sqlalchemy import select
|
||
|
||
from app.db.session import AsyncSessionLocal
|
||
from app.models.character import Character
|
||
from app.services.serializers import character_to_response
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
result = await session.execute(
|
||
select(Character)
|
||
.where(
|
||
Character.owner_scope == user_id,
|
||
Character.is_system_template.is_(False),
|
||
)
|
||
.order_by(Character.created_at)
|
||
)
|
||
return [character_to_response(c).model_dump(mode="json") for c in result.scalars().all()]
|
||
|
||
|
||
@router.get("/tenants/{user_id}/memories")
|
||
async def admin_tenant_memories(user_id: str) -> list[dict]:
|
||
"""查看指定用户的所有记忆(跨租户管理员用)。"""
|
||
from sqlalchemy import select
|
||
|
||
from app.db.session import AsyncSessionLocal
|
||
from app.models.character import Character
|
||
from app.models.xirang import Memory
|
||
from app.services.serializers import memory_to_response
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
result = await session.execute(
|
||
select(Memory)
|
||
.join(Character, Memory.character_id == Character.id).where(Memory.status != "tombstoned")
|
||
.where(
|
||
Character.owner_scope == user_id,
|
||
Character.is_system_template.is_(False),
|
||
)
|
||
.order_by(Memory.created_at.desc())
|
||
)
|
||
return [memory_to_response(m).model_dump(mode="json") for m in result.scalars().all()]
|
||
|
||
|
||
# —— §19.8 人物模板管理(管理员专用 CRUD) ——
|
||
|
||
|
||
@router.get(
|
||
"/characters/templates",
|
||
response_model=list[AdminTemplateSummaryResponse],
|
||
)
|
||
async def list_templates() -> list[AdminTemplateSummaryResponse]:
|
||
"""列出所有系统模板(管理端完整视图,含 profile / model_policy 全文)。"""
|
||
return await admin_list_templates()
|
||
|
||
|
||
@router.get(
|
||
"/characters/templates/{key}",
|
||
response_model=AdminTemplateSummaryResponse,
|
||
)
|
||
async def get_template(key: str) -> AdminTemplateSummaryResponse:
|
||
"""获取单个系统模板详情。"""
|
||
return await admin_get_template(key)
|
||
|
||
|
||
@router.post(
|
||
"/characters/templates",
|
||
response_model=AdminTemplateSummaryResponse,
|
||
status_code=201,
|
||
)
|
||
async def create_template(
|
||
payload: AdminCreateTemplateRequest,
|
||
) -> AdminTemplateSummaryResponse:
|
||
"""创建新的系统模板。
|
||
|
||
如 key 与 YAML 中已存在的 key 相同,下次重启时会被 YAML 配置覆盖。
|
||
"""
|
||
return await admin_create_template(payload)
|
||
|
||
|
||
@router.patch(
|
||
"/characters/templates/{key}",
|
||
response_model=AdminTemplateSummaryResponse,
|
||
)
|
||
async def update_template(
|
||
key: str,
|
||
payload: AdminUpdateTemplateRequest,
|
||
) -> AdminTemplateSummaryResponse:
|
||
"""更新系统模板(部分更新)。
|
||
|
||
仅提供非 None 的字段会被更新。YAML 定义的模板字段会在下次重启时被 YAML 覆盖。
|
||
"""
|
||
return await admin_update_template(key, payload)
|
||
|
||
|
||
@router.delete(
|
||
"/characters/templates/{key}",
|
||
status_code=204,
|
||
)
|
||
async def delete_template(key: str) -> None:
|
||
"""删除系统模板。
|
||
|
||
仅允许删除未被任何用户人物引用的模板。
|
||
被引用时返回 409 conflict。
|
||
"""
|
||
await admin_delete_template(key)
|