199 lines
7.7 KiB
Python
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)))
|