Files
group_fqcd_jr/tools/publish_financial_nl2sql_config.py
T

199 lines
7.7 KiB
Python
Raw Normal View History

2026-09-14 18:13:10 +08:00
"""发布金融 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)))