1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富 统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」; 同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。 2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本), 新增《文档规整方案与开发前待决事项-2026-09-17》。 3) 客服agent 四份交付文档首次纳入本分支。
294 lines
13 KiB
Python
294 lines
13 KiB
Python
import logging
|
||
from datetime import UTC, datetime
|
||
from typing import Any
|
||
from uuid import uuid4
|
||
|
||
from sqlalchemy import select, text
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.model.audit import InteractionAudit
|
||
from app.model.configuration import ConfigRelease, PlatformConfigItem
|
||
from app.model.platform import DomainEventOutbox
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 受 `config_release` **整版本替换**影响的表,以及各自的**逻辑键**。
|
||
#
|
||
# 逻辑键用来判断"同一份配置"在新版本里还在不在:它不含 `release_id`、不含自增 id,
|
||
# 也**不含 version** —— 同名提示词在不同版本里可以用不同 version,那仍是同一份配置。
|
||
#
|
||
# 这张清单是**穷举**的:`information_schema` 里带 `release_id` 列的表只有这三张。
|
||
# 漏掉任何一张的后果都是**静默失效**,而且不会报错:客服闲聊提示词就这么失效过一次
|
||
# —— 它挂在 release 174,active 变成 181 后 `load_active_prompt` 读不到,而 Agent 侧
|
||
# 有逐字段兜底、回落到代码默认值,于是功能看着正常、没有任何人发现、也没有任何告警。
|
||
RELEASE_SCOPED_TABLES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||
("platform_config_item", ("namespace", "config_key")),
|
||
("prompt_template_version", ("prompt_code", "task_type", "agent_type")),
|
||
("model_routing_rule", ("rule_code",)),
|
||
)
|
||
|
||
# 搬运配置项时要剥掉的列:`id` 与 `release_id` 由新版本自己生成,
|
||
# 带过去要么主键冲突、要么把内容挂到旧版本上。
|
||
NOT_PORTABLE_COLUMNS = frozenset({"id", "release_id"})
|
||
|
||
|
||
class ConfigReleaseError(ValueError):
|
||
pass
|
||
|
||
|
||
class ConfigReleaseService:
|
||
def __init__(self, session: AsyncSession) -> None:
|
||
self.session = session
|
||
|
||
async def submit_for_review(self, release_id: int, actor_id: int) -> ConfigRelease:
|
||
release = await self._get(release_id)
|
||
if release.created_by != actor_id or release.status != "draft":
|
||
raise ConfigReleaseError("only draft owner can submit")
|
||
release.status = "pending_review"
|
||
release.updated_at = self._now()
|
||
self._audit("config.submit_review", actor_id, release)
|
||
await self.session.flush()
|
||
return release
|
||
|
||
async def approve(self, release_id: int, reviewer_id: int) -> ConfigRelease:
|
||
# 只保留状态机约束:必须处于待审核状态才能审核通过;不再要求审核人不同于创建人
|
||
# (单管理员部署下自审是唯一可行路径)。
|
||
#
|
||
# 自审与多人复核一样**如实写入 `reviewer_id`**:该列的含义就是"审核人",留空会让
|
||
# `admin_service` 的激活分支(要求 `reviewer_id is not None`)把已审核版本判为
|
||
# "未审核"而拒绝激活。
|
||
#
|
||
# 自审要落库,前提是数据库侧不再有 `chk_config_release_separation`
|
||
# (`reviewer_id IS NULL OR reviewer_id <> created_by`)。合并后的仓库里
|
||
# `20260910_drop_review_separation` 与 `20260911_drop_review_separation`
|
||
# 都会撤下它(两条分支各补一次,均幂等);若约束仍在,写
|
||
# `reviewer_id = created_by` 会抛
|
||
# `Check constraint 'chk_config_release_separation' is violated`,表现为审核接口
|
||
# 500 而不是业务异常。
|
||
release = await self._get(release_id)
|
||
if release.status != "pending_review":
|
||
raise ConfigReleaseError("release is not pending review")
|
||
now = self._now()
|
||
release.status = "approved"
|
||
release.reviewer_id = reviewer_id
|
||
release.reviewed_at = now
|
||
release.updated_at = now
|
||
self._audit("config.approve", reviewer_id, release)
|
||
await self.session.flush()
|
||
return release
|
||
|
||
async def active_release_id(self) -> int | None:
|
||
"""当前生效版本的 id;没有任何生效版本时返回 None。"""
|
||
found = await self.session.scalar(
|
||
select(ConfigRelease.id).where(ConfigRelease.status == "active")
|
||
)
|
||
return int(found) if found is not None else None
|
||
|
||
async def effective_snapshot(
|
||
self, release_id: int | None = None
|
||
) -> dict[str, list[dict[str, Any]]]:
|
||
"""读某个发布版本在**全部受管表**里的内容;默认读当前生效版本。
|
||
|
||
发布脚本应当**先取这份快照**,把它原样搬到新版本、再追加本次变更 —— 因为
|
||
`config_release` 是整版本替换,不搬就等于删(详见 RELEASE_SCOPED_TABLES 的说明)。
|
||
每行已剥掉 `id` 与 `release_id`(见 NOT_PORTABLE_COLUMNS),可直接作为写入载荷。
|
||
"""
|
||
if release_id is None:
|
||
release_id = await self.active_release_id()
|
||
snapshot: dict[str, list[dict[str, Any]]] = {}
|
||
for table, _keys in RELEASE_SCOPED_TABLES:
|
||
if release_id is None:
|
||
snapshot[table] = []
|
||
continue
|
||
rows = (await self.session.execute(
|
||
text(f"SELECT * FROM {table} WHERE release_id = :rid"), {"rid": release_id}
|
||
)).mappings().all()
|
||
snapshot[table] = [
|
||
{
|
||
name: value
|
||
for name, value in dict(row).items()
|
||
if name not in NOT_PORTABLE_COLUMNS
|
||
}
|
||
for row in rows
|
||
]
|
||
return snapshot
|
||
|
||
async def _table_keys(
|
||
self, table: str, keys: tuple[str, ...], release_ids: list[int]
|
||
) -> dict[int, set[tuple[Any, ...]]]:
|
||
"""按 release 分组取出某张表的逻辑键集合。"""
|
||
columns = ", ".join(keys)
|
||
grouped: dict[int, set[tuple[Any, ...]]] = {}
|
||
for release_id in release_ids:
|
||
rows = (await self.session.execute(
|
||
text(f"SELECT {columns} FROM {table} WHERE release_id = :rid"),
|
||
{"rid": release_id},
|
||
)).mappings().all()
|
||
grouped[release_id] = {tuple(row[key] for key in keys) for row in rows}
|
||
return grouped
|
||
|
||
async def _warn_dropped_items(
|
||
self, release: ConfigRelease, previous_active: list[ConfigRelease]
|
||
) -> None:
|
||
"""点名"旧版本有、新版本没有"的配置 —— 它们在激活后会静默失效。
|
||
|
||
**逐张覆盖全部受管表**。原先只比对 `platform_config_item`,于是客服闲聊提示词在
|
||
`prompt_template_version` 里被静默丢掉时,连一行告警都没有:它挂在 release 174,
|
||
active 变成 181 后读不到,而 Agent 侧有兜底、回落到代码默认值,功能看着正常,
|
||
于是没有人发现(详见 RELEASE_SCOPED_TABLES 的说明)。
|
||
|
||
不阻断激活 —— 有时确实是要主动撤下某项配置;这里要的是"事后能查到是谁弄没的"。
|
||
"""
|
||
if not previous_active:
|
||
return
|
||
release_ids = [previous.id for previous in previous_active] + [release.id]
|
||
for table, keys in RELEASE_SCOPED_TABLES:
|
||
grouped = await self._table_keys(table, keys, release_ids)
|
||
new_keys = grouped.get(release.id, set())
|
||
for previous in previous_active:
|
||
dropped = sorted(
|
||
(key for key in grouped.get(previous.id, set()) if key not in new_keys),
|
||
key=str,
|
||
)
|
||
if dropped:
|
||
logger.warning(
|
||
"配置发布 %s 取代 %s:%s 里有 %d 条配置在新版本中不存在,"
|
||
"激活后即失效 → %s",
|
||
release.release_no,
|
||
previous.release_no,
|
||
table,
|
||
len(dropped),
|
||
[dict(zip(keys, key, strict=True)) for key in dropped],
|
||
)
|
||
|
||
async def activate(self, release_id: int, actor_id: int) -> ConfigRelease:
|
||
release = await self._get(release_id)
|
||
if release.status != "approved":
|
||
raise ConfigReleaseError("only approved release can activate")
|
||
active = await self.session.scalars(
|
||
select(ConfigRelease).where(ConfigRelease.status == "active")
|
||
)
|
||
now = self._now()
|
||
previous_active = [previous for previous in active if previous.id != release.id]
|
||
# 把"本次激活会让哪些配置项失效"显式记进日志,见 _warn_dropped_items 的说明。
|
||
await self._warn_dropped_items(release, previous_active)
|
||
for previous in previous_active:
|
||
previous.status = "superseded"
|
||
previous.updated_at = now
|
||
release.status = "active"
|
||
release.activated_at = now
|
||
release.updated_at = now
|
||
# 记录"本版本取代了谁"。`supersedes_release_id` 的列含义就是被取代的版本,但此前
|
||
# 从未写入,导致版本链断裂:清理或回滚旧数据时无法知道"谁顶掉了谁",一旦某次实验
|
||
# 创建的临时版本被删掉,平台就会静默停在"零个 active 版本"的状态(工具白名单随之
|
||
# 变成空集,所有 Agent 工具按 fail-closed 被拒)。唯一键 `uk_config_release_active_one`
|
||
# 保证同时最多一个 active,所以这里最多记录一条。
|
||
if len(previous_active) == 1:
|
||
release.supersedes_release_id = previous_active[0].id
|
||
self._audit("config.activate", actor_id, release)
|
||
self.session.add(
|
||
DomainEventOutbox(
|
||
id=0,
|
||
event_id=str(uuid4()),
|
||
event_type="config.cache_invalidate_requested",
|
||
aggregate_type="config_release",
|
||
aggregate_id=str(release.id),
|
||
trace_id=release.release_no,
|
||
payload={"release_id": release.id, "release_no": release.release_no},
|
||
status="pending",
|
||
retry_count=0,
|
||
occurred_at=now,
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
)
|
||
await self.session.flush()
|
||
return release
|
||
|
||
async def rollback(self, release_id: int, actor_id: int) -> ConfigRelease:
|
||
target = await self._get(release_id)
|
||
if target.status not in {"active", "superseded"}:
|
||
raise ConfigReleaseError("release cannot be rolled back")
|
||
now = self._now()
|
||
current = await self.session.scalars(
|
||
select(ConfigRelease).where(ConfigRelease.status == "active")
|
||
)
|
||
for release in current:
|
||
release.status = "superseded"
|
||
release.updated_at = now
|
||
await self.session.flush()
|
||
rollback = ConfigRelease(
|
||
release_no=f"rollback-{uuid4().hex[:16]}",
|
||
title=f"Rollback {target.release_no}",
|
||
change_summary=f"Rollback to {target.release_no}",
|
||
status="active",
|
||
created_by=actor_id,
|
||
reviewer_id=None,
|
||
reviewed_at=None,
|
||
activated_at=now,
|
||
created_at=now,
|
||
updated_at=now,
|
||
rollback_of_release_id=target.id,
|
||
)
|
||
self.session.add(rollback)
|
||
await self.session.flush()
|
||
self.session.add(
|
||
DomainEventOutbox(
|
||
event_id=str(uuid4()),
|
||
event_type="config.cache_invalidate_requested",
|
||
aggregate_type="config_release",
|
||
aggregate_id=str(target.id),
|
||
trace_id=target.release_no,
|
||
payload={"release_id": target.id, "release_no": target.release_no},
|
||
status="pending",
|
||
retry_count=0,
|
||
occurred_at=now,
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
)
|
||
self._audit("config.rollback", actor_id, rollback)
|
||
await self.session.flush()
|
||
return rollback
|
||
|
||
async def get_value(
|
||
self, release_id: int, namespace: str, config_key: str
|
||
) -> dict[str, object] | None:
|
||
item = await self.session.scalar(
|
||
select(PlatformConfigItem).where(
|
||
PlatformConfigItem.release_id == release_id,
|
||
PlatformConfigItem.namespace == namespace,
|
||
PlatformConfigItem.config_key == config_key,
|
||
)
|
||
)
|
||
return item.value_json if item else None
|
||
|
||
@staticmethod
|
||
def assert_if_match(current_version: str, if_match: str | None) -> None:
|
||
if if_match is None or if_match.strip('"') != current_version:
|
||
raise ConfigReleaseError("CONFIG_VERSION_CONFLICT")
|
||
|
||
async def _get(self, release_id: int) -> ConfigRelease:
|
||
release = await self.session.get(ConfigRelease, release_id)
|
||
if release is None:
|
||
raise ConfigReleaseError("release not found")
|
||
return release
|
||
|
||
def _audit(self, action: str, actor_id: int, release: ConfigRelease) -> None:
|
||
self.session.add(
|
||
InteractionAudit(
|
||
actor_type="user",
|
||
actor_id=actor_id,
|
||
target_customer_id=None,
|
||
session_id=None,
|
||
portal="admin",
|
||
action_type=action,
|
||
detail={"release_id": release.id, "release_no": release.release_no},
|
||
created_at=self._now(),
|
||
)
|
||
)
|
||
|
||
@staticmethod
|
||
def _now() -> datetime:
|
||
return datetime.now(UTC).replace(tzinfo=None)
|