docs: 品牌全量口径统一为「南方基金」+ 作废文档清理
1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富 统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」; 同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。 2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本), 新增《文档规整方案与开发前待决事项-2026-09-17》。 3) 客服agent 四份交付文档首次纳入本分支。
This commit is contained in:
@@ -0,0 +1,453 @@
|
||||
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.cursor import parse_cursor
|
||||
from app.core.errors import (
|
||||
GenericResourceNotFoundError,
|
||||
InvalidStateError,
|
||||
ResourceAlreadyExistsError,
|
||||
ResourceVersionConflictError,
|
||||
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",
|
||||
}
|
||||
|
||||
# 行情 namespace 的允许字段。必须与 `FundQuoteRuntimeConfig.from_mapping` 的
|
||||
# 接受范围保持一致,否则会出现"配置能发布、运行期却被静默忽略"的假成功。
|
||||
# 该 namespace 由 `RuntimeConfigService.fund_quote` 以
|
||||
# namespace=fund_market / config_key=default 读取。
|
||||
FUND_MARKET_FIELDS = frozenset({"allowed_codes", "intraday_cache_ttl", "closing_cache_ttl"})
|
||||
|
||||
|
||||
def validate_fund_market(values: dict[str, Any]) -> None:
|
||||
"""校验行情配置取值。
|
||||
|
||||
行情配置不是 1-100 的整数表:`allowed_codes` 是六位代码字符串列表,
|
||||
TTL 上限 86400 与运行期接受范围一致(`FundQuoteRuntimeConfig`)。
|
||||
"""
|
||||
codes = values.get("allowed_codes", [])
|
||||
if not isinstance(codes, list) or not all(
|
||||
isinstance(code, str) and len(code) == 6 and code.isdigit() for code in codes
|
||||
):
|
||||
raise ValidationAgentError("行情基金代码必须为六位数字字符串列表")
|
||||
for name in ("intraday_cache_ttl", "closing_cache_ttl"):
|
||||
if name not in values:
|
||||
continue
|
||||
ttl = values[name]
|
||||
if type(ttl) is not int or not 1 <= ttl <= 86400:
|
||||
raise ValidationAgentError(f"{name} 必须为 1-86400 之间的整数")
|
||||
|
||||
|
||||
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,
|
||||
cursor: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
permission = "audit:read" if resource == "audit-records" else "config:read"
|
||||
await AuthorizationService.require(context, permission, admin=True)
|
||||
# 游标校验必须在权限闸门**之后**:未授权调用一律 403,不能因为参数格式先漏出
|
||||
# 一个 400——响应差异本身就是一条越权探测信号(文档 §4.2)。校验同时早于
|
||||
# 任何数据库访问,非法游标不会变成一次静默的全量查询。
|
||||
before = parse_cursor(cursor)
|
||||
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 GenericResourceNotFoundError("资源不存在")
|
||||
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 GenericResourceNotFoundError("资源不存在")
|
||||
if if_match is None or if_match.strip('"') != digest(existing):
|
||||
raise ResourceVersionConflictError("If-Match 版本不一致")
|
||||
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 ResourceAlreadyExistsError("资源唯一性、引用或状态约束冲突") 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 InvalidStateError("只能编辑草稿或停用版本")
|
||||
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 InvalidStateError("只能修改草稿发布批次")
|
||||
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"},
|
||||
"fund_market": set(FUND_MARKET_FIELDS),
|
||||
}
|
||||
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":
|
||||
# 允许创建人自审(不再要求 reviewer 与 created_by 不同);仍然强制审核节点。
|
||||
# 自审同样如实写入 reviewer_id:该列含义就是"审核人",迁移
|
||||
# 20260910_drop_review_separation / 20260911_drop_review_separation
|
||||
# 已撤下"审核人≠创建人"的检查约束(两条并行分支各补一次,均为幂等实现)。
|
||||
if row["status"] != "pending_review":
|
||||
raise InvalidStateError("只能驳回待审核版本")
|
||||
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 resource == "agent-intent-configs":
|
||||
return await self._transition_intent_config(repo, row, action, values, actor, now)
|
||||
if action == "reviews":
|
||||
# 审核节点不可跳过(必须先是 draft);但允许创建人自审。
|
||||
if row["status"] != "draft":
|
||||
raise InvalidStateError("只能审核草稿版本")
|
||||
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 InvalidStateError("版本未审核")
|
||||
update_values = {"status": "active"}
|
||||
elif action == "disablements":
|
||||
if row["status"] != "active":
|
||||
raise InvalidStateError("只能停用激活版本")
|
||||
update_values = {"status": "disabled"}
|
||||
else:
|
||||
raise ValidationAgentError("未知状态操作")
|
||||
update_values["updated_at"] = now
|
||||
return await repo.update(RESOURCES[resource], row["id"], update_values)
|
||||
|
||||
async def _transition_intent_config(
|
||||
self,
|
||||
repo: PlatformRepository,
|
||||
row: dict[str, Any],
|
||||
action: str,
|
||||
values: dict[str, Any],
|
||||
actor: int,
|
||||
now: datetime,
|
||||
) -> dict[str, Any]:
|
||||
"""意图配置自己的状态机:`draft -> approved -> active -> archived`。
|
||||
|
||||
`agent_intent_config.status` 有 CHECK 约束,只允许
|
||||
`draft/approved/active/archived`(**没有 `disabled`**),所以这里不能套用
|
||||
`model-endpoints` 的 `disablements` 语义,归档落到 `archived`。运行期仍以
|
||||
`status='active'` 判定生效(`RuntimeConfigService.active_intents`),口径一致。
|
||||
|
||||
同一 `agent_type:intent_code` 只能有一个 active 版本(生成列唯一键
|
||||
`uk_intent_config_active_one`),激活前先把旧版本归档,语义与
|
||||
`ConfigReleaseService.activate` 挂起上一个生效版本一致。
|
||||
"""
|
||||
if action == "reviews":
|
||||
if row["status"] != "draft":
|
||||
raise InvalidStateError("只能审核草稿意图配置")
|
||||
update_values: dict[str, Any] = {
|
||||
"status": "approved" if values["decision"] == "approved" else "draft",
|
||||
"reviewer_id": actor,
|
||||
"reviewed_at": now,
|
||||
}
|
||||
elif action == "activations":
|
||||
if row["status"] != "approved" or row["reviewer_id"] is None:
|
||||
raise InvalidStateError("意图配置未审核")
|
||||
expire_at = row.get("expire_at")
|
||||
if isinstance(expire_at, datetime) and expire_at <= now:
|
||||
raise InvalidStateError("意图配置已过期,不能激活")
|
||||
# ⚠️ 过滤条件**必须带 `intent_code`**:唯一键是生成列
|
||||
# `active_key = concat(agent_type, ':', intent_code)`,即同一 `agent_type` 下
|
||||
# **不同意图码可以同时 active**(风控的 4 个意图本就并存)。
|
||||
# 只按 `agent_type` 过滤会把同一 Agent 的**其他意图一起归档** ——
|
||||
# 曾因此让风控只剩 `general` 一条 active,另外三条被静默归档,
|
||||
# "查看风险概览 / 查询预警证据"这类问法再也分不到意图,且没有任何报错。
|
||||
for previous in await repo.rows(
|
||||
"agent_intent_config",
|
||||
{
|
||||
"agent_type": row["agent_type"],
|
||||
"intent_code": row["intent_code"],
|
||||
"status": "active",
|
||||
},
|
||||
limit=10,
|
||||
):
|
||||
if previous["id"] != row["id"]:
|
||||
await repo.update(
|
||||
"agent_intent_config",
|
||||
previous["id"],
|
||||
{"status": "archived", "updated_at": now},
|
||||
)
|
||||
update_values = {"status": "active"}
|
||||
if row.get("effective_at") is None:
|
||||
update_values["effective_at"] = now
|
||||
elif action == "archivals":
|
||||
if row["status"] != "active":
|
||||
raise InvalidStateError("只能归档生效版本")
|
||||
update_values = {"status": "archived"}
|
||||
else:
|
||||
raise ValidationAgentError("未知状态操作")
|
||||
update_values["updated_at"] = now
|
||||
return await repo.update("agent_intent_config", 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,
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user