Files
group_fqcd_jr/tools/publish_financial_nl2sql_config.py
张胜宇 e239eb778b docs: 品牌全量口径统一为「南方基金」+ 作废文档清理
1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富
   统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」;
   同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。
2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本),
   新增《文档规整方案与开发前待决事项-2026-09-17》。
3) 客服agent 四份交付文档首次纳入本分支。
2026-09-17 15:15:22 +08:00

199 lines
7.7 KiB
Python

"""发布金融 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)))