袁聪的最后一次完善更新

This commit is contained in:
2026-09-14 18:13:10 +08:00
parent 54055b4328
commit 2548c39c6b
50 changed files with 2818 additions and 267 deletions
+8 -1
View File
@@ -34,10 +34,15 @@ import argparse
import asyncio
import sys
from datetime import UTC, datetime
from pathlib import Path
from sqlalchemy import text
from app.infrastructure.db import SessionFactory
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.infrastructure.db import SessionFactory # noqa: E402
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr]
@@ -47,6 +52,8 @@ OPERATOR_ROLE_NAME = "运营专员"
#: 运营该有的权限码。`offsite:write` 由场外线创建;其余在本种子的 9051-9056 号段里定义。
OPERATOR_GRANTED_CODES: tuple[str, ...] = (
# Agent 公共入口:没有该权限,请求会在 Agent 类型和工具权限校验之前被拒绝。
"agent:run",
# 场外运营(角色门槛之外,这个码是场外线自己声明的)
"offsite:write",
# 金融 NL2SQL:角色白名单 {advisor, operator, admin, super_admin} 含 operator
+198
View File
@@ -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)))