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,198 @@
|
||||
"""发布金融 NL2SQL 的只读工具白名单,默认仅预检。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import datetime as dt
|
||||
import json
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from sqlalchemy import func, select
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from app.core.config import get_settings # noqa: E402
|
||||
from app.infrastructure.db import SessionFactory # noqa: E402
|
||||
from app.main import create_app # noqa: E402
|
||||
from app.model.configuration import PromptTemplateVersion # noqa: E402
|
||||
from app.service.config_release_service import ConfigReleaseService # noqa: E402
|
||||
|
||||
ADMIN_ID = "9003"
|
||||
AGENT_TYPE = "financial_nl2sql"
|
||||
INTENT = "financial_query"
|
||||
TOOL = "query_financial_data"
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="发布金融 NL2SQL 工具白名单")
|
||||
parser.add_argument("--apply", action="store_true", help="创建并激活新的配置版本")
|
||||
parser.add_argument(
|
||||
"--allow-empty-baseline",
|
||||
action="store_true",
|
||||
help="确认当前数据库不存在生效配置时,以空配置作为首个发布版本的基线",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def issue_token(subject: str) -> str:
|
||||
settings = get_settings()
|
||||
private_key = Path(settings.jwt_private_key_path).read_text(encoding="utf-8")
|
||||
now = dt.datetime.now(dt.UTC)
|
||||
return jwt.encode(
|
||||
{
|
||||
"sub": subject,
|
||||
"iss": settings.jwt_issuer,
|
||||
"aud": settings.jwt_audience,
|
||||
"exp": now + dt.timedelta(minutes=30),
|
||||
"nbf": now - dt.timedelta(seconds=5),
|
||||
"jti": str(uuid.uuid4()),
|
||||
},
|
||||
private_key,
|
||||
algorithm="RS256",
|
||||
)
|
||||
|
||||
|
||||
def as_json(value: Any) -> Any:
|
||||
return json.loads(value) if isinstance(value, str) else value
|
||||
|
||||
|
||||
async def active_snapshot() -> dict[str, list[dict[str, Any]]]:
|
||||
async with SessionFactory() as session:
|
||||
return await ConfigReleaseService(session).effective_snapshot()
|
||||
|
||||
|
||||
def item_payloads(snapshot: dict[str, list[dict[str, Any]]]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"namespace": row["namespace"],
|
||||
"item_key": row["config_key"],
|
||||
"value_json": as_json(row["value_json"]),
|
||||
"schema_version": row["schema_version"],
|
||||
}
|
||||
for row in snapshot["platform_config_item"]
|
||||
]
|
||||
|
||||
|
||||
async def prompt_payloads(
|
||||
prompts: list[dict[str, Any]], release_id: int
|
||||
) -> list[dict[str, Any]]:
|
||||
payloads: list[dict[str, Any]] = []
|
||||
async with SessionFactory() as session:
|
||||
for row in prompts:
|
||||
version = await session.scalar(
|
||||
select(func.max(PromptTemplateVersion.version)).where(
|
||||
PromptTemplateVersion.prompt_code == row["prompt_code"]
|
||||
)
|
||||
)
|
||||
payloads.append(
|
||||
{
|
||||
"release_id": release_id,
|
||||
"prompt_code": row["prompt_code"],
|
||||
"task_type": row["task_type"],
|
||||
"agent_type": row["agent_type"],
|
||||
"version": int(version or 0) + 1,
|
||||
"system_prompt": row["system_prompt"],
|
||||
"user_prompt_template": row["user_prompt_template"],
|
||||
"input_schema": as_json(row["input_schema"]),
|
||||
"output_schema": as_json(row["output_schema"]),
|
||||
}
|
||||
)
|
||||
return payloads
|
||||
|
||||
|
||||
async def post(
|
||||
client: httpx.AsyncClient, path: str, auth: dict[str, str], payload: dict[str, Any],
|
||||
if_match: str | None = None,
|
||||
) -> httpx.Response:
|
||||
headers = {**auth, "Idempotency-Key": uuid.uuid4().hex}
|
||||
if if_match:
|
||||
headers["If-Match"] = if_match
|
||||
return await client.post(path, json=payload, headers=headers)
|
||||
|
||||
|
||||
async def etag(client: httpx.AsyncClient, path: str, auth: dict[str, str]) -> str | None:
|
||||
return (await client.get(path, headers=auth)).headers.get("ETag")
|
||||
|
||||
|
||||
async def publish(apply: bool, allow_empty_baseline: bool) -> int:
|
||||
snapshot = await active_snapshot()
|
||||
inherited = item_payloads(snapshot)
|
||||
target_key = f"{AGENT_TYPE}:{INTENT}"
|
||||
existing = next((item for item in inherited if item["item_key"] == target_key), None)
|
||||
print(f"当前生效配置项:{len(inherited)} 条;提示词:{len(snapshot['prompt_template_version'])} 条")
|
||||
if apply and not inherited and not snapshot["prompt_template_version"] and not allow_empty_baseline:
|
||||
print("当前生效配置为空,拒绝发布以避免覆盖或清空已有配置。请先核对数据库连接和生效版本。")
|
||||
return 1
|
||||
if existing is not None:
|
||||
print(f"{target_key} 已存在,当前值:{existing['value_json']}")
|
||||
return 0
|
||||
if snapshot["model_routing_rule"]:
|
||||
print("当前版本含模型路由规则,本脚本拒绝发布,避免遗漏继承。")
|
||||
return 1
|
||||
if not apply:
|
||||
print(f"预检通过;将新增 agent_tools/{target_key} -> [{TOOL}]。传入 --apply 执行发布。")
|
||||
return 0
|
||||
|
||||
auth = {"Authorization": f"Bearer {issue_token(ADMIN_ID)}"}
|
||||
app = create_app()
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
||||
created = await post(client, "/api/v1/admin/config-releases", auth, {
|
||||
"release_no": f"financial-nl2sql-{uuid.uuid4().hex[:12]}",
|
||||
"title": "金融 NL2SQL 工具白名单",
|
||||
"change_summary": "为运营与投顾的金融只读自然语言查询启用工具白名单",
|
||||
})
|
||||
if created.status_code != 201:
|
||||
print(f"创建配置版本失败:{created.status_code} {created.text[:200]}")
|
||||
return 1
|
||||
release_id = int(created.json()["data"]["id"])
|
||||
new_item = {
|
||||
"namespace": "agent_tools",
|
||||
"item_key": target_key,
|
||||
"value_json": {"allowed_tools": [TOOL]},
|
||||
"schema_version": "1",
|
||||
}
|
||||
for item in [*inherited, new_item]:
|
||||
response = await post(
|
||||
client,
|
||||
f"/api/v1/admin/config-releases/{release_id}/platform-config-items",
|
||||
auth,
|
||||
item,
|
||||
)
|
||||
if response.status_code != 201:
|
||||
print(f"写入 {item['namespace']}/{item['item_key']} 失败:{response.status_code}")
|
||||
return 1
|
||||
for payload in await prompt_payloads(snapshot["prompt_template_version"], release_id):
|
||||
response = await post(client, "/api/v1/admin/prompt-templates", auth, payload)
|
||||
if response.status_code not in (200, 201):
|
||||
print(f"继承提示词失败:{response.status_code} {response.text[:200]}")
|
||||
return 1
|
||||
base = f"/api/v1/admin/config-releases/{release_id}"
|
||||
for suffix, payload in (
|
||||
("validations", {}),
|
||||
("reviews", {"decision": "approved", "comment": "金融 NL2SQL 只读白名单"}),
|
||||
("activations", {}),
|
||||
):
|
||||
response = await post(client, f"{base}/{suffix}", auth, payload, await etag(client, base, auth))
|
||||
if response.status_code not in (200, 201):
|
||||
print(f"{suffix} 失败:{response.status_code} {response.text[:200]}")
|
||||
return 1
|
||||
after = await active_snapshot()
|
||||
keys = {row["config_key"] for row in after["platform_config_item"]}
|
||||
if target_key not in keys or len(keys) != len(inherited) + 1:
|
||||
print("发布后配置项校验失败,未确认白名单已生效。")
|
||||
return 1
|
||||
print(f"发布成功:{target_key} 已生效,配置项 {len(keys)} 条。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parse_args()
|
||||
sys.exit(asyncio.run(publish(args.apply, args.allow_empty_baseline)))
|
||||
Reference in New Issue
Block a user