90 lines
3.1 KiB
Python
90 lines
3.1 KiB
Python
"""NL2SQL SQL 生成器:只负责调用 Chat 模型并清理模型输出。"""
|
||||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import json
|
|||
|
|
import re
|
|||
|
|
from dataclasses import dataclass
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
from sqlglot import exp, parse
|
|||
|
|
from sqlglot.errors import ParseError
|
|||
|
|
|
|||
|
|
from tool.llm import llm
|
|||
|
|
from nl2sql.semantics import build_semantic_context
|
|||
|
|
|
|||
|
|
|
|||
|
|
class SqlGenerationError(ValueError):
|
|||
|
|
"""模型未返回可用的只读 SQL。"""
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass(frozen=True)
|
|||
|
|
class GeneratedSql:
|
|||
|
|
sql: str
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _clean_model_output(output: str) -> str:
|
|||
|
|
value = (output or "").strip()
|
|||
|
|
value = re.sub(r"^```(?:sql)?\s*", "", value, flags=re.IGNORECASE)
|
|||
|
|
value = re.sub(r"\s*```$", "", value).strip()
|
|||
|
|
return value.removesuffix(";").strip()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _build_prompt(
|
|||
|
|
question: str,
|
|||
|
|
schema: dict[str, Any],
|
|||
|
|
few_shot: list[dict[str, Any]],
|
|||
|
|
semantic_context: dict[str, Any] | None = None,
|
|||
|
|
conversation_context: str = "",
|
|||
|
|
) -> str:
|
|||
|
|
return (
|
|||
|
|
"你是基金业务数据库 SQL 生成器。\n"
|
|||
|
|
"只根据提供的 Schema 生成一条 MySQL SELECT,禁止写操作、跨库访问和未提供的表字段。\n"
|
|||
|
|
"只输出 SQL,不要输出解释、Markdown 或代码围栏。\n"
|
|||
|
|
f"用户问题:{question}\n"
|
|||
|
|
f"Schema:{json.dumps(schema, ensure_ascii=False, sort_keys=True)}\n"
|
|||
|
|
f"业务语义:{json.dumps(semantic_context or {}, ensure_ascii=False, sort_keys=True)}\n"
|
|||
|
|
f"会话上下文:{conversation_context}\n"
|
|||
|
|
f"Few-shot:{json.dumps(few_shot or [], ensure_ascii=False, sort_keys=True)}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def generate_sql(
|
|||
|
|
question: str,
|
|||
|
|
schema: dict[str, Any],
|
|||
|
|
*,
|
|||
|
|
llm_client=llm,
|
|||
|
|
few_shot: list[dict[str, Any]] | None = None,
|
|||
|
|
semantic_context: dict[str, Any] | None = None,
|
|||
|
|
conversation_context: str = "",
|
|||
|
|
) -> GeneratedSql:
|
|||
|
|
"""调用 Chat 模型生成 SQL,并在返回前确认其为单条 SELECT。"""
|
|||
|
|
if not question or not question.strip():
|
|||
|
|
raise SqlGenerationError("用户问题不能为空")
|
|||
|
|
messages = [
|
|||
|
|
{"role": "system", "content": "你必须严格遵守只输出单条 SELECT SQL。"},
|
|||
|
|
{
|
|||
|
|
"role": "user",
|
|||
|
|
"content": _build_prompt(
|
|||
|
|
question,
|
|||
|
|
schema,
|
|||
|
|
few_shot or [],
|
|||
|
|
semantic_context or build_semantic_context(question, schema),
|
|||
|
|
conversation_context,
|
|||
|
|
),
|
|||
|
|
},
|
|||
|
|
]
|
|||
|
|
try:
|
|||
|
|
output = await llm_client.chat(messages, temperature=0)
|
|||
|
|
except Exception as exc: # noqa: BLE001 统一收敛模型异常
|
|||
|
|
raise SqlGenerationError("SQL 模型调用失败") from exc
|
|||
|
|
sql = _clean_model_output(output)
|
|||
|
|
if not sql:
|
|||
|
|
raise SqlGenerationError("模型未返回 SQL")
|
|||
|
|
try:
|
|||
|
|
statements = parse(sql, read="mysql")
|
|||
|
|
except ParseError as exc:
|
|||
|
|
raise SqlGenerationError("模型返回的 SQL 无法解析") from exc
|
|||
|
|
if len(statements) != 1 or not isinstance(statements[0], exp.Select):
|
|||
|
|
raise SqlGenerationError("模型只允许返回单条 SELECT")
|
|||
|
|
return GeneratedSql(sql=statements[0].sql(dialect="mysql"))
|