343 lines
16 KiB
Python
343 lines
16 KiB
Python
from datetime import UTC, date, datetime
|
|||
|
|
from decimal import Decimal
|
||
|
|
from typing import Any
|
||
|
|
from uuid import uuid4
|
||
|
|
|
||
|
|
from sqlalchemy import delete
|
||
|
|
from sqlalchemy.exc import IntegrityError
|
||
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
|
|
||
|
|
from app.core.contracts import RequestContext
|
||
|
|
from app.core.errors import ConflictAgentError, ResourceNotFoundError, ValidationAgentError
|
||
|
|
from app.infrastructure.db import SessionFactory
|
||
|
|
from app.model.audit import InteractionAudit
|
||
|
|
from app.model.platform import DomainEventOutbox
|
||
|
|
from app.repository.platform_repository import PlatformRepository
|
||
|
|
from app.service.agent.bootstrap import get_agent_factory
|
||
|
|
from app.service.api_transaction_service import ApiTransactionService, digest
|
||
|
|
from app.service.authorization_service import AuthorizationService
|
||
|
|
from app.service.config_release_service import ConfigReleaseService
|
||
|
|
|
||
|
|
RESOURCES = {
|
||
|
|
"config-releases": "config_release",
|
||
|
|
"platform-config-items": "platform_config_item",
|
||
|
|
"model-endpoints": "model_endpoint_config",
|
||
|
|
"model-routing-rules": "model_routing_rule",
|
||
|
|
"prompt-templates": "prompt_template_version",
|
||
|
|
"agent-intent-configs": "agent_intent_config",
|
||
|
|
"reply-templates": "agent_reply_template",
|
||
|
|
"negative-word-rules": "agent_negative_word",
|
||
|
|
"audit-records": "interaction_audit",
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def public(value: Any, key: str = "") -> Any:
|
||
|
|
if isinstance(value, dict):
|
||
|
|
return {
|
||
|
|
("item_key" if k == "config_key" else k): public(v, k)
|
||
|
|
for k, v in value.items()
|
||
|
|
if k not in {"secret_ref", "fallback_endpoint_ids", "active_slot", "active_key"}
|
||
|
|
}
|
||
|
|
if isinstance(value, list):
|
||
|
|
return [public(item) for item in value]
|
||
|
|
if isinstance(value, datetime):
|
||
|
|
return value.isoformat() + ("Z" if value.tzinfo is None else "")
|
||
|
|
if isinstance(value, (Decimal, date)):
|
||
|
|
return str(value)
|
||
|
|
if isinstance(value, int) and (key == "id" or key.endswith("_id") or key == "created_by"):
|
||
|
|
return str(value)
|
||
|
|
return value
|
||
|
|
|
||
|
|
|
||
|
|
class AdminService:
|
||
|
|
async def query(
|
||
|
|
self,
|
||
|
|
resource: str,
|
||
|
|
context: RequestContext,
|
||
|
|
row_id: int | None = None,
|
||
|
|
release_id: int | None = None,
|
||
|
|
limit: int = 20,
|
||
|
|
before: int | None = None,
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
permission = "audit:read" if resource == "audit-records" else "config:read"
|
||
|
|
await AuthorizationService.require(context, permission, admin=True)
|
||
|
|
async with SessionFactory() as session:
|
||
|
|
repo = PlatformRepository(session)
|
||
|
|
filters = {"release_id": release_id} if release_id is not None else {}
|
||
|
|
if row_id is not None:
|
||
|
|
row = await repo.get(RESOURCES[resource], row_id)
|
||
|
|
if row is None or any(row[k] != v for k, v in filters.items()):
|
||
|
|
raise ResourceNotFoundError("资源不存在")
|
||
|
|
return {
|
||
|
|
"data": public(row),
|
||
|
|
"meta": {"trace_id": context.trace_id, "etag": digest(row)},
|
||
|
|
}
|
||
|
|
rows = await repo.rows(RESOURCES[resource], filters, before=before, limit=limit)
|
||
|
|
if resource == "audit-records" and "audit:read-sensitive" not in context.permissions:
|
||
|
|
rows = [{**row, "detail": {"redacted": True}} for row in rows]
|
||
|
|
return {"data": public(rows), "meta": {"trace_id": context.trace_id}}
|
||
|
|
|
||
|
|
async def mutate(
|
||
|
|
self,
|
||
|
|
resource: str,
|
||
|
|
context: RequestContext,
|
||
|
|
values: dict[str, Any],
|
||
|
|
key: str | None,
|
||
|
|
if_match: str | None,
|
||
|
|
*,
|
||
|
|
row_id: int | None = None,
|
||
|
|
release_id: int | None = None,
|
||
|
|
action: str = "write",
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
permission = "model-endpoint:manage" if resource == "model-endpoints" else "config:write"
|
||
|
|
if action == "reviews":
|
||
|
|
permission = "config:review"
|
||
|
|
elif action in {"activations", "rollbacks"} and resource == "config-releases":
|
||
|
|
permission = "config:activate"
|
||
|
|
await AuthorizationService.require(context, permission, admin=True)
|
||
|
|
|
||
|
|
async def operation(session: AsyncSession) -> dict[str, Any]:
|
||
|
|
repo = PlatformRepository(session)
|
||
|
|
table = RESOURCES[resource]
|
||
|
|
existing = await repo.get(table, row_id, lock=True) if row_id is not None else None
|
||
|
|
if row_id is not None:
|
||
|
|
if existing is None or (
|
||
|
|
release_id is not None and existing.get("release_id") != release_id
|
||
|
|
):
|
||
|
|
raise ResourceNotFoundError("资源不存在")
|
||
|
|
if if_match is None or if_match.strip('"') != digest(existing):
|
||
|
|
raise ConflictAgentError("RESOURCE_VERSION_CONFLICT")
|
||
|
|
if action != "write":
|
||
|
|
assert existing is not None
|
||
|
|
row = await self._transition(repo, resource, existing, action, values, context)
|
||
|
|
else:
|
||
|
|
row = await self._write(repo, resource, existing, values, release_id, context)
|
||
|
|
session.add(
|
||
|
|
InteractionAudit(
|
||
|
|
actor_type="user",
|
||
|
|
actor_id=int(context.user_id),
|
||
|
|
portal="admin",
|
||
|
|
action_type=f"platform.{resource}.{action}",
|
||
|
|
detail={
|
||
|
|
"resource_id": row["id"],
|
||
|
|
"trace_id": context.trace_id,
|
||
|
|
"before_hash": digest(existing) if existing else None,
|
||
|
|
"after_hash": digest(row),
|
||
|
|
},
|
||
|
|
created_at=datetime.now(UTC).replace(tzinfo=None),
|
||
|
|
)
|
||
|
|
)
|
||
|
|
return {
|
||
|
|
"data": public(row),
|
||
|
|
"meta": {"trace_id": context.trace_id, "etag": digest(row)},
|
||
|
|
}
|
||
|
|
|
||
|
|
try:
|
||
|
|
return await ApiTransactionService().execute(
|
||
|
|
context,
|
||
|
|
f"admin:{resource}:{row_id}:{release_id}:{action}",
|
||
|
|
key,
|
||
|
|
{"body": values, "if_match": if_match},
|
||
|
|
operation,
|
||
|
|
)
|
||
|
|
except IntegrityError as exc:
|
||
|
|
raise ConflictAgentError("资源唯一性、引用或状态约束冲突") from exc
|
||
|
|
|
||
|
|
async def _write(
|
||
|
|
self,
|
||
|
|
repo: PlatformRepository,
|
||
|
|
resource: str,
|
||
|
|
existing: dict[str, Any] | None,
|
||
|
|
values: dict[str, Any],
|
||
|
|
release_id: int | None,
|
||
|
|
context: RequestContext,
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
data = dict(values)
|
||
|
|
if existing is not None and existing.get("status", "draft") not in {"draft", "disabled"}:
|
||
|
|
raise ConflictAgentError("只能编辑草稿或停用版本")
|
||
|
|
if release_id is not None:
|
||
|
|
data["release_id"] = release_id
|
||
|
|
if "release_id" in data:
|
||
|
|
parent = await repo.get("config_release", int(data["release_id"]), lock=True)
|
||
|
|
if parent is None or parent["status"] != "draft":
|
||
|
|
raise ConflictAgentError("只能修改草稿发布批次")
|
||
|
|
if resource == "platform-config-items":
|
||
|
|
data["config_key"] = data.pop("item_key")
|
||
|
|
allowed = {
|
||
|
|
"memory": {"recall_limit", "decay_days"},
|
||
|
|
"relationship": {"max_hops", "limit"},
|
||
|
|
"runtime": {"timeout_seconds"},
|
||
|
|
"agent_tools": {"allowed_tools"},
|
||
|
|
}
|
||
|
|
if set(data["value_json"]) - allowed[data["namespace"]]:
|
||
|
|
raise ValidationAgentError("配置包含未声明字段")
|
||
|
|
if data["namespace"] == "agent_tools":
|
||
|
|
raw = data["value_json"].get("allowed_tools", [])
|
||
|
|
if not isinstance(raw, list) or not all(isinstance(v, str) for v in raw):
|
||
|
|
raise ValidationAgentError("工具白名单必须为字符串列表")
|
||
|
|
agent_type, separator, intent = data["config_key"].partition(":")
|
||
|
|
definition = get_agent_factory().definition(agent_type)
|
||
|
|
if not separator or intent not in definition.supported_intents:
|
||
|
|
raise ValidationAgentError("意图不在 Agent 声明内")
|
||
|
|
if not set(raw) <= set(definition.allowed_tools):
|
||
|
|
raise ValidationAgentError("配置超出 Agent 工具上限")
|
||
|
|
else:
|
||
|
|
for value in data["value_json"].values():
|
||
|
|
if type(value) is not int or not 1 <= value <= 100:
|
||
|
|
raise ValidationAgentError("配置值须为 1-100 整数")
|
||
|
|
data["checksum"] = digest(data["value_json"])
|
||
|
|
if resource == "model-endpoints":
|
||
|
|
if "@" in data["base_url"] or "?" in data["base_url"]:
|
||
|
|
raise ValidationAgentError("端点地址不得包含凭证或查询参数")
|
||
|
|
if resource == "agent-intent-configs":
|
||
|
|
definition = get_agent_factory().definition(data["agent_type"])
|
||
|
|
if data["intent_code"] not in definition.supported_intents:
|
||
|
|
raise ValidationAgentError("意图未注册")
|
||
|
|
if not set(data["allowed_tools"]) <= set(definition.allowed_tools):
|
||
|
|
raise ValidationAgentError("工具超出代码上限")
|
||
|
|
fallbacks = data.pop("fallbacks", [])
|
||
|
|
if resource == "model-routing-rules":
|
||
|
|
await self._validate_routing(repo, data, fallbacks)
|
||
|
|
if resource == "prompt-templates":
|
||
|
|
data["checksum"] = digest(data)
|
||
|
|
table = await repo.table(RESOURCES[resource])
|
||
|
|
if "created_by" in table.c and existing is None:
|
||
|
|
data["created_by"] = int(context.user_id)
|
||
|
|
if "status" in table.c:
|
||
|
|
data["status"] = "draft"
|
||
|
|
if "reviewer_id" in table.c:
|
||
|
|
data.update(reviewer_id=None, reviewed_at=None)
|
||
|
|
if "updated_at" in table.c:
|
||
|
|
data["updated_at"] = datetime.now(UTC).replace(tzinfo=None)
|
||
|
|
row = (
|
||
|
|
await repo.update(RESOURCES[resource], existing["id"], data)
|
||
|
|
if existing
|
||
|
|
else await repo.create(RESOURCES[resource], data)
|
||
|
|
)
|
||
|
|
if resource == "model-routing-rules":
|
||
|
|
fallback_table = await repo.table("model_routing_fallback")
|
||
|
|
# Draft child relationships may be replaced; no historical column/schema mutation.
|
||
|
|
await repo.session.execute(
|
||
|
|
delete(fallback_table).where(fallback_table.c.routing_rule_id == row["id"])
|
||
|
|
)
|
||
|
|
for fallback in fallbacks:
|
||
|
|
await repo.create(
|
||
|
|
"model_routing_fallback", {**fallback, "routing_rule_id": row["id"]}
|
||
|
|
)
|
||
|
|
row["fallbacks"] = fallbacks
|
||
|
|
return row
|
||
|
|
|
||
|
|
async def _validate_routing(
|
||
|
|
self, repo: PlatformRepository, data: dict[str, Any], fallbacks: list[dict[str, Any]]
|
||
|
|
) -> None:
|
||
|
|
get_agent_factory().definition(data["agent_type"])
|
||
|
|
ids = [data["primary_endpoint_id"], *(item["endpoint_id"] for item in fallbacks)]
|
||
|
|
orders = [item["fallback_order"] for item in fallbacks]
|
||
|
|
if len(ids) != len(set(ids)) or sorted(orders) != list(range(1, len(orders) + 1)):
|
||
|
|
raise ValidationAgentError("备用端点重复或顺序不连续")
|
||
|
|
if data["max_attempts"] > len(ids):
|
||
|
|
raise ValidationAgentError("重试次数超过端点数量")
|
||
|
|
for endpoint_id in ids:
|
||
|
|
endpoint = await repo.get("model_endpoint_config", endpoint_id, lock=True)
|
||
|
|
if endpoint is None or endpoint["status"] != "active":
|
||
|
|
raise ValidationAgentError("模型端点不存在或未激活")
|
||
|
|
|
||
|
|
async def _transition(
|
||
|
|
self,
|
||
|
|
repo: PlatformRepository,
|
||
|
|
resource: str,
|
||
|
|
row: dict[str, Any],
|
||
|
|
action: str,
|
||
|
|
values: dict[str, Any],
|
||
|
|
context: RequestContext,
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
actor = int(context.user_id)
|
||
|
|
now = datetime.now(UTC).replace(tzinfo=None)
|
||
|
|
if resource == "config-releases":
|
||
|
|
service = ConfigReleaseService(repo.session)
|
||
|
|
if action == "validations":
|
||
|
|
await self._validate_release(repo, row["id"])
|
||
|
|
result = await service.submit_for_review(row["id"], actor)
|
||
|
|
elif action == "reviews":
|
||
|
|
if values["decision"] == "rejected":
|
||
|
|
if row["status"] != "pending_review" or row["created_by"] == actor:
|
||
|
|
raise ConflictAgentError("不满足双人审核条件")
|
||
|
|
return await repo.update(
|
||
|
|
"config_release",
|
||
|
|
row["id"],
|
||
|
|
{"status": "rejected", "reviewer_id": actor, "reviewed_at": now},
|
||
|
|
)
|
||
|
|
result = await service.approve(row["id"], actor)
|
||
|
|
elif action == "activations":
|
||
|
|
await self._validate_release(repo, row["id"])
|
||
|
|
result = await service.activate(row["id"], actor)
|
||
|
|
elif action == "rollbacks":
|
||
|
|
result = await service.rollback(row["id"], actor)
|
||
|
|
await self._copy_release(repo, row["id"], result.id)
|
||
|
|
else:
|
||
|
|
raise ValidationAgentError("未知发布操作")
|
||
|
|
refreshed = await repo.get("config_release", result.id)
|
||
|
|
assert refreshed is not None
|
||
|
|
return refreshed
|
||
|
|
if action == "reviews":
|
||
|
|
if row["status"] != "draft" or row["created_by"] == actor:
|
||
|
|
raise ConflictAgentError("不满足双人审核条件")
|
||
|
|
status = "approved" if values["decision"] == "approved" else "draft"
|
||
|
|
update_values = {"status": status, "reviewer_id": actor, "reviewed_at": now}
|
||
|
|
elif action == "activations":
|
||
|
|
if row["status"] != "approved" or row["reviewer_id"] is None:
|
||
|
|
raise ConflictAgentError("版本未审核")
|
||
|
|
update_values = {"status": "active"}
|
||
|
|
elif action == "disablements":
|
||
|
|
if row["status"] != "active":
|
||
|
|
raise ConflictAgentError("只能停用激活版本")
|
||
|
|
update_values = {"status": "disabled"}
|
||
|
|
else:
|
||
|
|
raise ValidationAgentError("未知状态操作")
|
||
|
|
update_values["updated_at"] = now
|
||
|
|
return await repo.update(RESOURCES[resource], row["id"], update_values)
|
||
|
|
|
||
|
|
async def _validate_release(self, repo: PlatformRepository, release_id: int) -> None:
|
||
|
|
rules = await repo.rows("model_routing_rule", {"release_id": release_id}, limit=10000)
|
||
|
|
for rule in rules:
|
||
|
|
fallbacks = await repo.rows(
|
||
|
|
"model_routing_fallback", {"routing_rule_id": rule["id"]}, limit=3
|
||
|
|
)
|
||
|
|
await self._validate_routing(repo, rule, fallbacks)
|
||
|
|
# Checksums catch out-of-band draft edits before approval/activation.
|
||
|
|
for item in await repo.rows(
|
||
|
|
"platform_config_item", {"release_id": release_id}, limit=10000
|
||
|
|
):
|
||
|
|
if item["checksum"] != digest(item["value_json"]):
|
||
|
|
raise ValidationAgentError("配置校验和不一致")
|
||
|
|
|
||
|
|
async def _copy_release(self, repo: PlatformRepository, source: int, target: int) -> None:
|
||
|
|
for name in ("platform_config_item", "prompt_template_version", "model_routing_rule"):
|
||
|
|
for row in await repo.rows(name, {"release_id": source}, limit=10000):
|
||
|
|
old_id = row.pop("id")
|
||
|
|
row.pop("created_at", None)
|
||
|
|
row["release_id"] = target
|
||
|
|
copied = await repo.create(name, row)
|
||
|
|
if name == "model_routing_rule":
|
||
|
|
for fallback in await repo.rows(
|
||
|
|
"model_routing_fallback", {"routing_rule_id": old_id}, limit=3
|
||
|
|
):
|
||
|
|
fallback.pop("id")
|
||
|
|
fallback.pop("created_at", None)
|
||
|
|
fallback["routing_rule_id"] = copied["id"]
|
||
|
|
await repo.create("model_routing_fallback", fallback)
|
||
|
|
now = datetime.now(UTC).replace(tzinfo=None)
|
||
|
|
repo.session.add(
|
||
|
|
DomainEventOutbox(
|
||
|
|
event_id=str(uuid4()),
|
||
|
|
event_type="config.cache_invalidate_requested",
|
||
|
|
aggregate_type="config_release",
|
||
|
|
aggregate_id=str(target),
|
||
|
|
trace_id=str(uuid4()),
|
||
|
|
payload={"release_id": target},
|
||
|
|
occurred_at=now,
|
||
|
|
created_at=now,
|
||
|
|
updated_at=now,
|
||
|
|
)
|
||
|
|
)
|