Enhance SQL generation and streaming capabilities in the API server. Introduce optional parameters for streaming throttle and SQL stream granularity in NLChatRequest. Implement new functions for iterating SQL generation content pieces and adjusting streaming behavior based on user-defined settings. Update prompts for few-shot SQL adaptation and improve logging for SQL generation processes. Refactor orchestrator methods to support streaming responses and integrate few-shot SQL conditions. Update impact analysis documentation to reflect these changes.

This commit is contained in:
陈辅元
2026-04-16 13:48:44 +08:00
parent 695356a496
commit bff5f85d60
16 changed files with 586 additions and 103 deletions
+80
View File
@@ -592,3 +592,83 @@
## 6. 配置变更 ## 6. 配置变更
- 无新增环境变量;日志路径固定为仓库根下 `logs/text2sql_api.log`。 - 无新增环境变量;日志路径固定为仓库根下 `logs/text2sql_api.log`。
---
# Impact Analysis Report — Chroma Few-shot 黄金 SQL 条件适配(追加)
## 1. 改动概览
- **背景与目标**:`data/embeddings/chroma_fewshot` 中存储的问答-SQL 视为已校验正确答案;当向量检索与当前问题足够相似时,不再仅作「风格示例」,而是以该 SQL 为骨架,仅让 LLM 调整 WHERE/HAVING 等与用户问题相关的**特殊条件**。
- **涉及模块**:`backend/config/prompts.py`(`GOLDEN_SQL_ADAPT_*`)、`backend/utils/fewshot_selector.py`(`select_best_with_score`、`is_chroma_backend`)、`backend/agents/orchestrator.py`(`_generate_sql_golden_adapt`、`_generate_sql` 分支)、`api_server.py`(响应中可选 `fewshot_golden`)。
- **改动类型**:生成策略增强(默认开启,可用环境变量关闭或调阈值)。
## 2. 方法级改动
| 位置 | 变更 |
|------|------|
| `FewShotSelector` | 新增 `_passes_filters`、`select_best_with_score`;`is_chroma_backend` 属性。 |
| `Text2SQLOrchestrator._generate_sql` | 在 Chroma 且满足阈值时调用 `_generate_sql_golden_adapt` 并设置 `_last_fewshot_golden`;否则沿用原 few-shot 注入 + 全量生成。 |
| `_generate_sql_golden_adapt` | 使用 `GOLDEN_SQL_ADAPT_SYSTEM/USER`,强调保留 JOIN/SELECT 结构、仅改条件。 |
| `generate` | `metadata` 增加 `fewshot_golden_reuse`、`fewshot_golden_qid`、`fewshot_golden_score`;失败路径亦回传(若曾走黄金分支)。 |
| `_nl_dict_from_generation` | 若存在黄金复用,增加 `fewshot_golden` 字段供前端展示/调试。 |
## 3. 破坏性变更
- **否**。未命中阈值时行为与原先「多示例风格参考」一致。
## 4. 配置变更
| 环境变量 | 含义 | 默认 |
|----------|------|------|
| `FEWSHOT_GOLDEN_REUSE` | 是否启用黄金 SQL 条件适配 | `true` |
| `FEWSHOT_GOLDEN_ONLY_CHROMA` | 是否仅在 Chroma 后端启用(与 `chroma_fewshot` 策略一致) | `true` |
| `FEWSHOT_GOLDEN_MIN_SCORE` | 最低相似度(1-距离,约等于余弦相似度) | `0.88` |
| `FEWSHOT_GOLDEN_SQL_PROMPT_MAX` | 写入提示词的标准答案 SQL 最大字符数 | `16000` |
## 5. 风险与回滚
- **风险**:阈值过低可能把不太相似的问题强行套在同一 SQL 上;过高则很少触发黄金分支。
- **回滚**:设 `FEWSHOT_GOLDEN_REUSE=false` 或回退相关提交。
**回滚方式是否简单**:是。
---
# Impact Analysis Report — 流式 SSE sql_gen 分片与节流
## 1. 改动概览
- **背景与目标**:前端约定每条 SSE 为 `data: {"stage":"sql_gen","stream_kind":"content","content":"..."}`;需在服务端控制拆成「逐字符多条」或「与 LLM delta 一致」,并支持可选分片间隔。
- **涉及模块**:`api_server.py`(`/g3sb/api/nl/chat/stream`、`_iter_sql_gen_content_pieces`、`_sse_stream_text_chunks`)。
- **改动类型**:行为调整(默认分片粒度默认更贴近前端的 `char`;可配置回 `delta`)。
## 2. 方法级改动
| 位置 | 变更 |
|------|------|
| `NLChatRequest` | 新增可选 `sql_stream_granularity`(`sqlStreamGranularity`);明确 `streaming_throttle` 为相邻 content 间隔毫秒。 |
| `_iter_sql_gen_content_pieces` | 支持传入 `mode`;未设置时 `SSE_SQL_GEN_SPLIT` 默认 `char`。 |
| `_chat_stream_events` | sql_gen 循环按粒度拆分后对每条 `data` 可选 `asyncio.sleep(throttle)`;寒暄分支 `_sse_stream_text_chunks` 支持相同节流。 |
## 3. 调用方与影响范围
- **调用方**:仅 SSE 流式客户端;非流式 `/g3sb/api/nl/chat` 不变。
- **破坏性变更**:否。未传新字段时:粒度由 `SSE_SQL_GEN_SPLIT` 决定(默认 `char`,事件条数多于旧版 `delta`);若需旧行为可设 `SSE_SQL_GEN_SPLIT=delta` 或请求体 `sqlStreamGranularity: "delta"`。
## 4. 配置变更
| 环境变量 | 含义 | 默认 |
|----------|------|------|
| `SSE_SQL_GEN_SPLIT` | `char`:逐 Unicode 标量多条 SSE;`delta`:与 LLM 增量一致 | `char`(未设置 env 时由代码默认) |
## 5. 风险与回滚
- **风险级别**:低。`char` 模式下 SSE 条数增加,带宽与前端拼接次数上升。
- **回滚**:设 `SSE_SQL_GEN_SPLIT=delta` 或请求传 `sqlStreamGranularity: "delta"`。
**回滚方式是否简单**:是。
## 6. 验证与测试
- 已执行:`python -m py_compile api_server.py`。
Binary file not shown.
+101 -28
View File
@@ -9,7 +9,6 @@ from __future__ import annotations
import os import os
import sys import sys
import json import json
import html as html_lib
import asyncio import asyncio
import logging import logging
from pathlib import Path from pathlib import Path
@@ -151,7 +150,15 @@ class NLChatRequest(BaseModel):
session_id: Optional[str] = Field(None, description="会话ID") session_id: Optional[str] = Field(None, description="会话ID")
visitor_biz_id: Optional[str] = Field(None, description="访客业务ID") visitor_biz_id: Optional[str] = Field(None, description="访客业务ID")
user_id: Optional[str] = Field(None, description="用户ID") user_id: Optional[str] = Field(None, description="用户ID")
streaming_throttle: Optional[int] = Field(None, description="流式节流参数") streaming_throttle: Optional[int] = Field(
None,
description="流式节流:相邻 content 分片之间的间隔毫秒数(0/None 表示不延迟)",
)
sql_stream_granularity: Optional[str] = Field(
None,
validation_alias=AliasChoices("sql_stream_granularity", "sqlStreamGranularity"),
description="SQL 生成流式分片:delta | char;不传则使用环境变量 SSE_SQL_GEN_SPLIT(默认 char)",
)
class IntentPayload(BaseModel): class IntentPayload(BaseModel):
@@ -247,6 +254,23 @@ def _sse_data(obj: Dict[str, Any]) -> bytes:
return f"data: {json.dumps(obj, ensure_ascii=False)}\n\n".encode("utf-8") return f"data: {json.dumps(obj, ensure_ascii=False)}\n\n".encode("utf-8")
def _iter_sql_gen_content_pieces(text: str, mode: Optional[str] = None) -> List[str]:
"""
将 LLM 流式片段再拆成前端期望的多条 SSE(与 chatStore onDelta 累加一致)。
每条均为:{"stage": "sql_gen", "stream_kind": "content", "content": "..."}
mode / 环境变量 SSE_SQL_GEN_SPLIT:
- char(请求默认):按 Unicode 标量逐字符发送(与「用户」「问题」逐条 data 一致)
- delta:与上游 LLM 每次 delta 一致(块更大、事件更少)
"""
if not text:
return []
raw = (mode or os.getenv("SSE_SQL_GEN_SPLIT", "char") or "char").strip().lower()
if raw in ("delta", "none", "0", "false"):
return [text]
return list(text)
# 与前端 chatStore onDelta 一致:多次 { stage, stream_kind: content, content } 累加 # 与前端 chatStore onDelta 一致:多次 { stage, stream_kind: content, content } 累加
_DEFAULT_SSE_CHUNK_CHARS = int(os.getenv("SSE_STREAM_CHUNK_CHARS", "64")) _DEFAULT_SSE_CHUNK_CHARS = int(os.getenv("SSE_STREAM_CHUNK_CHARS", "64"))
@@ -256,15 +280,20 @@ async def _sse_stream_text_chunks(
content: str, content: str,
*, *,
chunk_size: Optional[int] = None, chunk_size: Optional[int] = None,
throttle_ms: Optional[int] = None,
) -> AsyncIterator[bytes]: ) -> AsyncIterator[bytes]:
"""将长文本拆成多段 SSE,便于浏览器逐段渲染(流式)。""" """将长文本拆成多段 SSE,便于浏览器逐段渲染(流式)。"""
if not content: if not content:
return return
size = max(8, chunk_size or _DEFAULT_SSE_CHUNK_CHARS) size = max(8, chunk_size or _DEFAULT_SSE_CHUNK_CHARS)
delay = (throttle_ms or 0) / 1000.0 if throttle_ms and throttle_ms > 0 else 0.0
for i in range(0, len(content), size): for i in range(0, len(content), size):
yield _sse_data( yield _sse_data(
{"stage": stage, "stream_kind": "content", "content": content[i : i + size]} {"stage": stage, "stream_kind": "content", "content": content[i : i + size]}
) )
if delay:
await asyncio.sleep(delay)
else:
await asyncio.sleep(0) await asyncio.sleep(0)
@@ -584,29 +613,14 @@ def _nl_dict_from_generation(result: GenerationResult) -> Dict[str, Any]:
qz = result.metadata.get("question_zh_normalized") qz = result.metadata.get("question_zh_normalized")
if qo and qz: if qo and qz:
payload["query_normalization"] = {"original": qo, "zh": qz} payload["query_normalization"] = {"original": qo, "zh": qz}
if result.metadata.get("fewshot_golden_reuse"):
payload["fewshot_golden"] = {
"qid": result.metadata.get("fewshot_golden_qid"),
"score": result.metadata.get("fewshot_golden_score"),
}
return payload return payload
def _sql_gen_stream_html(result: GenerationResult) -> str:
sql = (result.sql or "").strip()
inner = json.dumps({"sql": sql}, ensure_ascii=False)
parts = [f"<data>{inner}</data>"]
explain = ""
if result.metadata.get("sql_delivery_message"):
parts.append(
f'<div class="sql-delivery-body">{html_lib.escape(str(result.metadata["sql_delivery_message"]))}</div>'
)
if result.metadata.get("db_empty_feedback"):
explain = str(result.metadata["db_empty_feedback"])
elif result.warnings:
explain = str(result.warnings[0])
elif result.errors:
explain = "; ".join(str(e) for e in result.errors[:5])
if explain.strip():
parts.append(f'<div class="sql-explain-body">{html_lib.escape(explain)}</div>')
return "".join(parts)
async def _run_generate( async def _run_generate(
question: str, question: str,
top_k: int = 20, top_k: int = 20,
@@ -684,7 +698,9 @@ async def _chat_stream_events(request: NLChatRequest) -> AsyncIterator[bytes]:
reply[:500] + ("…" if len(reply) > 500 else ""), reply[:500] + ("…" if len(reply) > 500 else ""),
) )
yield _sse_data({"stage": "orchestrator", "stream_kind": "content", "content": "CHAT"}) yield _sse_data({"stage": "orchestrator", "stream_kind": "content", "content": "CHAT"})
async for pkt in _sse_stream_text_chunks("chat", reply): async for pkt in _sse_stream_text_chunks(
"chat", reply, throttle_ms=request.streaming_throttle
):
yield pkt yield pkt
data_dict = _conversation_nl_dict(reply) data_dict = _conversation_nl_dict(reply)
yield _sse_data({"code": 200, "msg": "success", "data": data_dict}) yield _sse_data({"code": 200, "msg": "success", "data": data_dict})
@@ -692,8 +708,65 @@ async def _chat_stream_events(request: NLChatRequest) -> AsyncIterator[bytes]:
return return
yield _sse_data({"stage": "orchestrator", "stream_kind": "content", "content": "DATA_QUERY"}) yield _sse_data({"stage": "orchestrator", "stream_kind": "content", "content": "DATA_QUERY"})
dialect = resolve_sql_dialect(os.getenv("TEXT2SQL_DIALECT", "sqlserver"))
dc_ctx = (dialog_block or "").strip() or None
top_k = 20
loop = asyncio.get_running_loop()
logger.info(
"[GEN/API/stream] dialect=%s top_k=%s dialog_context_chars=%s question_len=%s preview=%r",
dialect,
top_k,
len(dc_ctx) if dc_ctx else 0,
len(text),
text[:300] + ("…" if len(text) > 300 else ""),
)
try: try:
result = await _run_generate(text, dialog_context=dialog_block or None, request=request) async with _maybe_override_orch_llm(orch, request) as o:
chunk_queue: asyncio.Queue[Optional[str]] = asyncio.Queue()
holder: Dict[str, Any] = {}
def _run_generate_sync() -> None:
try:
holder["result"] = o.generate(
question=text.strip(),
dialect=dialect,
top_k_candidates=top_k,
dialog_context=dc_ctx,
sql_stream_callback=lambda c: loop.call_soon_threadsafe(
chunk_queue.put_nowait, c
),
)
except Exception as e:
holder["error"] = e
finally:
loop.call_soon_threadsafe(chunk_queue.put_nowait, None)
gen_task = asyncio.create_task(asyncio.to_thread(_run_generate_sync))
throttle_ms = request.streaming_throttle or 0
sql_chunk_delay = throttle_ms / 1000.0 if throttle_ms > 0 else 0.0
gran = (
(request.sql_stream_granularity or "").strip()
or os.getenv("SSE_SQL_GEN_SPLIT", "char")
or "char"
).lower()
while True:
piece = await chunk_queue.get()
if piece is None:
break
for frag in _iter_sql_gen_content_pieces(piece, mode=gran):
yield _sse_data(
{
"stage": "sql_gen",
"stream_kind": "content",
"content": frag,
}
)
if sql_chunk_delay:
await asyncio.sleep(sql_chunk_delay)
await gen_task
if holder.get("error"):
raise holder["error"]
result = holder["result"]
except Exception as e: except Exception as e:
logger.error(f"[API/stream] 生成异常: {e}", exc_info=True) logger.error(f"[API/stream] 生成异常: {e}", exc_info=True)
yield _sse_data({"code": 500, "msg": str(e), "data": None}) yield _sse_data({"code": 500, "msg": str(e), "data": None})
@@ -727,9 +800,6 @@ async def _chat_stream_events(request: NLChatRequest) -> AsyncIterator[bytes]:
except Exception: except Exception:
pass pass
html = _sql_gen_stream_html(result)
async for pkt in _sse_stream_text_chunks("sql_gen", html):
yield pkt
data_dict = _nl_dict_from_generation(result) data_dict = _nl_dict_from_generation(result)
msg = "success" if result.valid else "partial" msg = "success" if result.valid else "partial"
yield _sse_data({"code": 200, "msg": msg, "data": data_dict}) yield _sse_data({"code": 200, "msg": msg, "data": data_dict})
@@ -856,7 +926,10 @@ async def nl_chat(request: NLChatRequest):
async def nl_chat_stream(request: NLChatRequest): async def nl_chat_stream(request: NLChatRequest):
""" """
自然语言对话流式接口(SSE)。 自然语言对话流式接口(SSE)。
事件体为 JSON:分片 delta `{stage, stream_kind, content}` 或结束包 `{code, msg, data}`。 事件体为 JSON:分片 `data: {"stage","stream_kind","content"}`(例:sql_gen 时
`stream_kind` 为 `content`)或结束包 `{code, msg, data}`。
可选:`sql_stream_granularity` / `sqlStreamGranularity`(delta|char,未传则 `SSE_SQL_GEN_SPLIT`,默认 char)、
`streaming_throttle`(相邻 content 分片间隔毫秒)。
""" """
return StreamingResponse( return StreamingResponse(
_chat_stream_events(request), _chat_stream_events(request),
+182 -4
View File
@@ -5,13 +5,13 @@ Text2SQL 多智能体编排器
import logging import logging
import os # 新增 import os # 新增
from typing import Dict, List, Optional, Tuple from typing import Callable, Dict, List, Optional, Tuple
from dataclasses import dataclass, field from dataclasses import dataclass, field
from schema.manager import SchemaManager from schema.manager import SchemaManager
from schema.indexer import SchemaIndexer from schema.indexer import SchemaIndexer
from llm.deepseek_client import DeepSeekClient, DeepSeekConfig from llm.deepseek_client import DeepSeekClient, DeepSeekConfig
from utils.fewshot_selector import FewShotSelector # 新增 from utils.fewshot_selector import ExperienceSample, FewShotSelector # 新增
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -121,6 +121,9 @@ class Text2SQLOrchestrator:
logger.warning(f"Few-shot加载失败: {e},将使用标准生成") logger.warning(f"Few-shot加载失败: {e},将使用标准生成")
self.fewshot_enabled = False self.fewshot_enabled = False
# 若本轮走「Chroma 黄金 SQL 条件适配」,在 metadata 中回传 qid/分数
self._last_fewshot_golden: Optional[Tuple[str, float]] = None
logger.info( logger.info(
f"[OK] Text2SQLOrchestrator初始化完成: " f"[OK] Text2SQLOrchestrator初始化完成: "
f"max_retry={max_retry}, use_vector_search={use_vector_search}" f"max_retry={max_retry}, use_vector_search={use_vector_search}"
@@ -335,6 +338,118 @@ class Text2SQLOrchestrator:
return expanded return expanded
def _sql_chat_completion_text(
self,
messages: List[Dict[str, str]],
*,
sql_stream_callback: Optional[Callable[[str], None]] = None,
) -> str:
"""
SQL 生成相关的一次 LLM 调用:可选流式回调(将原始 completion 文本分片回传)。
无回调或非流式失败时与非流式 chat 行为一致。
"""
ds = self.deepseek
if sql_stream_callback is not None and hasattr(ds, "chat_stream"):
try:
parts: List[str] = []
for piece in ds.chat_stream(messages, temperature=0.0, top_p=1.0):
if piece:
parts.append(piece)
sql_stream_callback(piece)
return "".join(parts).strip()
except Exception as e:
logger.warning("[GEN] chat_stream 失败,回退非流式: %s", e)
msg = ds.chat(messages, temperature=0.0, top_p=1.0)
return (msg.content or "").strip()
def _generate_sql_golden_adapt(
self,
question: str,
schema_str: str,
dialect: str,
golden: ExperienceSample,
golden_score: float,
validation_feedback: Optional[str],
dialog_context: Optional[str],
sql_stream_callback: Optional[Callable[[str], None]] = None,
) -> str:
"""
Chroma few-shot 库内 SQL 视为正确答案:在高分相似命中下,仅让模型调整条件/字面量以匹配当前问题。
"""
from config.prompts import GOLDEN_SQL_ADAPT_SYSTEM, GOLDEN_SQL_ADAPT_USER
from utils.sql_parser import normalize_sql_for_dialect
gsql = (golden.sql or "").strip()
max_sql = int(os.getenv("FEWSHOT_GOLDEN_SQL_PROMPT_MAX", "16000"))
if len(gsql) > max_sql:
gsql = gsql[:max_sql] + "\n-- …(标准答案过长,已截断)"
dialect_label = dialect
if dialect == "tsql":
dialect_label = "Microsoft SQL Server (T-SQL)"
dc = (dialog_context or "").strip()
prefix = ""
if dc:
prefix = (
"【对话上文】(用于理解指代与续问条件;请结合「当前用户问题」调整 WHERE 等。)\n"
f"{dc}\n\n"
)
user_content = prefix + GOLDEN_SQL_ADAPT_USER.format(
golden_score=golden_score,
golden_question=(golden.question_zh or "").strip(),
golden_sql=gsql,
schema=schema_str,
question=question,
dialect=dialect_label,
)
if dialect == "tsql":
user_content += (
"\n\n【硬性要求】目标库为 SQL Server(T-SQL):禁止使用 MySQL 反引号 `;"
"标识符如需引用请使用方括号,例如 [TableName]、[ColumnName]。"
"字符串连接使用 `+`(与系统提示中的标准版式范例一致)。"
"「今日」「当天」等与日期列比较时,使用 `CAST(GETDATE() AS DATE)`,"
"**禁止** `CURDATE()`、`NOW()`、`CURRENT_DATE`(MySQL)。"
"条件请使用 T-SQL 惯用写法(例如 IS NOT NULL)。"
"排版仍须遵守:关键字大写、SELECT 每列一行缩进、WHERE 续行以 AND 开头、PascalCase 英文别名。"
"\n**禁止**在单引号字符串字面量或 `N'…'` 中出现任何中日韩文字;"
"业务中文须映射为 Schema 注释中的代码或通过维表 JOIN,勿写 `= '过户费'` 这类比对。"
)
if validation_feedback:
user_content += (
"\n\n【上次校验未通过】请在保留标准答案主干的前提下修正 SQL;"
"表名、列名必须与「当前 Schema 片段」中完全一致。\n"
f"{validation_feedback}"
)
messages = [
{"role": "system", "content": GOLDEN_SQL_ADAPT_SYSTEM},
{"role": "user", "content": user_content},
]
logger.info(
"[GEN] 黄金 few-shot 条件适配: qid=%s score=%.4f",
golden.qid,
golden_score,
)
sql = self._sql_chat_completion_text(
messages, sql_stream_callback=sql_stream_callback
)
if "```sql" in sql:
sql = sql[sql.find("```sql") + 6 : sql.find("```", sql.find("```sql") + 6)].strip()
elif "```" in sql:
sql = sql[sql.find("```") + 3 : sql.find("```", sql.find("```") + 3)].strip()
sql = normalize_sql_for_dialect(sql, dialect)
lim = 12000
body = sql if len(sql) <= lim else sql[:lim] + "\n…(日志已截断)"
logger.info("生成的SQL(黄金适配,chars=%s):\n%s", len(sql), body)
return sql
def _generate_sql( def _generate_sql(
self, self,
question: str, question: str,
@@ -342,6 +457,7 @@ class Text2SQLOrchestrator:
dialect: str = "tsql", dialect: str = "tsql",
validation_feedback: Optional[str] = None, validation_feedback: Optional[str] = None,
dialog_context: Optional[str] = None, dialog_context: Optional[str] = None,
sql_stream_callback: Optional[Callable[[str], None]] = None,
) -> str: ) -> str:
""" """
SQL生成(SQL Generator Agent) SQL生成(SQL Generator Agent)
@@ -353,6 +469,7 @@ class Text2SQLOrchestrator:
validation_feedback: 非空时附加到用户提示(重试时传入上次校验错误) validation_feedback: 非空时附加到用户提示(重试时传入上次校验错误)
dialog_context: 前几轮对话摘要;与 ``question`` 一并供指代消解与续问。 dialog_context: 前几轮对话摘要;与 ``question`` 一并供指代消解与续问。
sql_stream_callback: 若提供且 LLM 支持 chat_stream,则在 SQL 主生成/黄金适配时流式回传原始文本分片。
Returns: Returns:
SQL语句 SQL语句
@@ -360,11 +477,57 @@ class Text2SQLOrchestrator:
from config.prompts import SQL_GENERATOR_SYSTEM, SQL_GENERATOR_USER from config.prompts import SQL_GENERATOR_SYSTEM, SQL_GENERATOR_USER
from utils.sql_parser import normalize_sql_for_dialect from utils.sql_parser import normalize_sql_for_dialect
self._last_fewshot_golden = None
dc = (dialog_context or "").strip() dc = (dialog_context or "").strip()
fewshot_question = question fewshot_question = question
if dc: if dc:
fewshot_question = f"{dc}\n\n【当前问】{question}" fewshot_question = f"{dc}\n\n【当前问】{question}"
golden_reuse = os.getenv("FEWSHOT_GOLDEN_REUSE", "true").lower() in (
"1",
"true",
"yes",
)
only_chroma = os.getenv("FEWSHOT_GOLDEN_ONLY_CHROMA", "true").lower() in (
"1",
"true",
"yes",
)
golden_min = float(os.getenv("FEWSHOT_GOLDEN_MIN_SCORE", "0.88"))
chroma_ok = bool(
self.fewshot_selector and self.fewshot_selector.is_chroma_backend
)
if only_chroma and not chroma_ok:
golden_reuse = False
if (
golden_reuse
and self.fewshot_enabled
and self.fewshot_selector
):
best = self.fewshot_selector.select_best_with_score(
fewshot_question,
min_rating=self.fewshot_min_rating,
)
if (
best
and best[1] >= golden_min
and (best[0].sql or "").strip()
):
ex, sc = best
sql_out = self._generate_sql_golden_adapt(
question=question,
schema_str=schema_str,
dialect=dialect,
golden=ex,
golden_score=sc,
validation_feedback=validation_feedback,
dialog_context=dialog_context,
sql_stream_callback=sql_stream_callback,
)
self._last_fewshot_golden = (ex.qid, sc)
return sql_out
# Few-shot 增强 # Few-shot 增强
if self.fewshot_enabled and self.fewshot_selector: if self.fewshot_enabled and self.fewshot_selector:
try: try:
@@ -431,8 +594,9 @@ class Text2SQLOrchestrator:
] ]
# 与选表一致:生成阶段默认贪心解码,减少同一中文问题多次 SQL 不一致 # 与选表一致:生成阶段默认贪心解码,减少同一中文问题多次 SQL 不一致
response = self.deepseek.chat(messages, temperature=0.0, top_p=1.0) sql = self._sql_chat_completion_text(
sql = response.content.strip() messages, sql_stream_callback=sql_stream_callback
)
# 清理可能的markdown代码块 # 清理可能的markdown代码块
if "```sql" in sql: if "```sql" in sql:
@@ -609,6 +773,7 @@ class Text2SQLOrchestrator:
top_k_candidates: int = 20, top_k_candidates: int = 20,
include_schema_in_result: bool = False, include_schema_in_result: bool = False,
dialog_context: Optional[str] = None, dialog_context: Optional[str] = None,
sql_stream_callback: Optional[Callable[[str], None]] = None,
) -> GenerationResult: ) -> GenerationResult:
""" """
主生成流程 主生成流程
@@ -619,10 +784,12 @@ class Text2SQLOrchestrator:
top_k_candidates: 粗筛候选表数量 top_k_candidates: 粗筛候选表数量
include_schema_in_result: 结果中是否包含使用的Schema字符串 include_schema_in_result: 结果中是否包含使用的Schema字符串
dialog_context: 前几轮对话可读摘要;选表、向量粗筛、SQL 生成与无数据说明会参考 dialog_context: 前几轮对话可读摘要;选表、向量粗筛、SQL 生成与无数据说明会参考
sql_stream_callback: 可选;SQL 主生成 LLM 输出分片回调(用于 API SSE)
Returns: Returns:
GenerationResult对象 GenerationResult对象
""" """
self._last_fewshot_golden = None
original_question = (question or "").strip() original_question = (question or "").strip()
translation_meta: Dict = {} translation_meta: Dict = {}
work_question = original_question work_question = original_question
@@ -726,6 +893,7 @@ class Text2SQLOrchestrator:
dialect, dialect,
validation_feedback=feedback, validation_feedback=feedback,
dialog_context=dc_raw or None, dialog_context=dc_raw or None,
sql_stream_callback=sql_stream_callback,
) )
last_sql = sql last_sql = sql
except Exception as e: except Exception as e:
@@ -770,6 +938,11 @@ class Text2SQLOrchestrator:
meta["sql_delivery_message"] = None meta["sql_delivery_message"] = None
if dc_raw: if dc_raw:
meta["dialog_context_chars"] = len(dc_raw) meta["dialog_context_chars"] = len(dc_raw)
if self._last_fewshot_golden:
gq, gsc = self._last_fewshot_golden
meta["fewshot_golden_reuse"] = True
meta["fewshot_golden_qid"] = gq
meta["fewshot_golden_score"] = gsc
result = GenerationResult( result = GenerationResult(
sql=sql, sql=sql,
@@ -792,6 +965,11 @@ class Text2SQLOrchestrator:
fail_meta["db_execution_status"] = last_db_execution_status fail_meta["db_execution_status"] = last_db_execution_status
if dc_raw: if dc_raw:
fail_meta["dialog_context_chars"] = len(dc_raw) fail_meta["dialog_context_chars"] = len(dc_raw)
if self._last_fewshot_golden:
gq, gsc = self._last_fewshot_golden
fail_meta["fewshot_golden_reuse"] = True
fail_meta["fewshot_golden_qid"] = gq
fail_meta["fewshot_golden_score"] = gsc
return GenerationResult( return GenerationResult(
sql=last_sql or "", sql=last_sql or "",
valid=False, valid=False,
Binary file not shown.
+32
View File
@@ -225,6 +225,38 @@ SQL_GENERATOR_USER = """Schema信息:
请生成**有用 SQL**(见系统提示定义):必须与「业务级黄金范例」**同构**——大写关键字、多行缩进版式、PascalCase 别名、该展示对手方/账户等名称时须 LEFT JOIN 维表;禁止输出挤成一行的「极简 SQL」。""" 请生成**有用 SQL**(见系统提示定义):必须与「业务级黄金范例」**同构**——大写关键字、多行缩进版式、PascalCase 别名、该展示对手方/账户等名称时须 LEFT JOIN 维表;禁止输出挤成一行的「极简 SQL」。"""
# ========== Few-shot 黄金 SQL 条件适配(Chroma 库内为已校验正确答案)==========
GOLDEN_SQL_ADAPT_SYSTEM = """你是精通 Microsoft SQL Server (T-SQL) 的数据库专家。
**任务背景**:下方「标准答案 SQL」来自向量库中**已校验通过**的业务范例,结构与写法正确。当前用户问题与范例问题**语义高度相似**,仅时间、账户、状态、代码、筛选口径等「特殊条件」可能不同。
**你必须遵守**:
1. **以标准答案为主干**:优先保留其 `FROM`/`JOIN`/`ON`、主 `SELECT` 列清单与聚合/分组逻辑;**不要随意更换主表、不要拆掉必要 JOIN**,除非当前 Schema 片段中已不存在该表(此时在 Schema 内做最小替换并说明等价关系仅在脑中完成)。
2. **只改「条件类」内容**:重点调整 `WHERE`/`HAVING`/`ORDER BY`/`TOP` 中的字面量、日期区间、状态码、账户/合约/代码等过滤;将用户问题中的时间范围、业务对象、筛选口径反映到这些条件中。
3. **Schema 绝对优先**:表名、列名必须来自下方「当前 Schema 片段」;禁止臆造字段。若标准答案中某列在片段中不存在,按片段改写为合法列。
4. **T-SQL 与版式**:与常规生成一致——关键字大写、多行缩进、`WHERE` 续行以 `AND` 开头、需要时 PascalCase 英文别名;禁止 MySQL 反引号与 `CURDATE()` 等。
5. **禁止在字符串字面量中写中日韩文字**去匹配代码列;须用 Schema 注释中的代码或 JOIN 维表(与系统提示 SQL_GENERATOR 一致)。
**输出**:仅输出一条完整可执行 SQL,不要解释。"""
GOLDEN_SQL_ADAPT_USER = """【标准答案 SQL】(向量库中的已校验正确答案;相似度 {golden_score:.4f},越高越应保留结构)
对应范例问题:{golden_question}
```sql
{golden_sql}
```
【当前 Schema 片段】(标识符必须与此一致)
{schema}
【当前用户问题】
{question}
【数据库方言】{dialect}
请输出一条完整 T-SQL:**在标准答案基础上仅调整条件/字面量/排序等与当前问题相关的部分**,保留正确的 JOIN 与整体查询意图;版式与 SQL_GENERATOR 黄金范例同构。"""
# ========== Validator Agent Prompt ========== # ========== Validator Agent Prompt ==========
VALIDATOR_SYSTEM = """你是一个严谨的SQL审核员,负责验证SQL语句的正确性和安全性。 VALIDATOR_SYSTEM = """你是一个严谨的SQL审核员,负责验证SQL语句的正确性和安全性。
+38 -1
View File
@@ -6,7 +6,7 @@ DeepSeek API 客户端封装
import os import os
import json import json
import logging import logging
from typing import Dict, List, Optional, Any, Union from typing import Any, Dict, Iterator, List, Optional, Union
from dataclasses import dataclass, field from dataclasses import dataclass, field
from openai import OpenAI, AsyncOpenAI from openai import OpenAI, AsyncOpenAI
from openai.types.chat import ChatCompletion, ChatCompletionMessage from openai.types.chat import ChatCompletion, ChatCompletionMessage
@@ -104,6 +104,43 @@ class DeepSeekClient:
logger.error(f"DeepSeek API调用失败: {e}") logger.error(f"DeepSeek API调用失败: {e}")
raise raise
def chat_stream(
self,
messages: List[Dict[str, str]],
**kwargs: Any,
) -> Iterator[str]:
"""
流式聊天:按 completion 增量产出文本片段(与 chat 同参,固定 stream=True)。
"""
params: Dict[str, Any] = {
"model": self.config.model_name,
"messages": messages,
"temperature": kwargs.get("temperature", self.config.temperature),
"max_tokens": kwargs.get("max_tokens", self.config.max_tokens),
"top_p": kwargs.get("top_p", self.config.top_p),
"frequency_penalty": kwargs.get(
"frequency_penalty", self.config.frequency_penalty
),
"presence_penalty": kwargs.get(
"presence_penalty", self.config.presence_penalty
),
"stream": True,
"timeout": kwargs.get("timeout", self.config.timeout),
}
if self.config.extra_headers:
params["extra_headers"] = self.config.extra_headers
try:
stream = self.client.chat.completions.create(**params)
for chunk in stream:
if not chunk.choices:
continue
delta = chunk.choices[0].delta
if delta and getattr(delta, "content", None):
yield delta.content
except Exception as e:
logger.error(f"DeepSeek API流式调用失败: {e}")
raise
def chat_with_json( def chat_with_json(
self, self,
messages: List[Dict[str, str]], messages: List[Dict[str, str]],
+64 -1
View File
@@ -10,7 +10,7 @@ import json
import logging import logging
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Dict, List, Optional from typing import Any, Dict, Iterator, List, Optional
from openai import AsyncOpenAI, OpenAI # type: ignore[import-not-found] from openai import AsyncOpenAI, OpenAI # type: ignore[import-not-found]
from openai.types.chat import ( # type: ignore[import-not-found] from openai.types.chat import ( # type: ignore[import-not-found]
@@ -119,6 +119,69 @@ class OpenAIClient:
logger.error("OpenAI API调用失败: %s", e) logger.error("OpenAI API调用失败: %s", e)
raise raise
def chat_stream(
self,
messages: List[Dict[str, str]],
**kwargs: Any,
) -> Iterator[str]:
"""流式聊天:按 completion 增量产出文本片段(与 chat 同参,固定 stream=True)。"""
max_tokens = kwargs.get("max_tokens", self.config.max_tokens)
max_completion_tokens = kwargs.get("max_completion_tokens", None)
params: Dict[str, Any] = {
"model": self.config.model_name,
"messages": messages,
"temperature": kwargs.get("temperature", self.config.temperature),
"top_p": kwargs.get("top_p", self.config.top_p),
"frequency_penalty": kwargs.get(
"frequency_penalty", self.config.frequency_penalty
),
"presence_penalty": kwargs.get(
"presence_penalty", self.config.presence_penalty
),
"stream": True,
"timeout": kwargs.get("timeout", self.config.timeout),
}
if max_completion_tokens is not None:
params["max_completion_tokens"] = max_completion_tokens
else:
params["max_tokens"] = max_tokens
if self.config.extra_headers:
params["extra_headers"] = self.config.extra_headers
try:
stream = self.client.chat.completions.create(**params)
for chunk in stream:
if not chunk.choices:
continue
delta = chunk.choices[0].delta
if delta and getattr(delta, "content", None):
yield delta.content
except Exception as e:
msg = str(e)
if (
"Unsupported parameter" in msg
and "max_tokens" in msg
and "max_completion_tokens" in msg
and "max_completion_tokens" not in params
):
params.pop("max_tokens", None)
params["max_completion_tokens"] = max_tokens
try:
stream = self.client.chat.completions.create(**params)
for chunk in stream:
if not chunk.choices:
continue
delta = chunk.choices[0].delta
if delta and getattr(delta, "content", None):
yield delta.content
return
except Exception:
pass
logger.error("OpenAI API流式调用失败: %s", e)
raise
def chat_with_json( def chat_with_json(
self, messages: List[Dict[str, str]], **kwargs self, messages: List[Dict[str, str]], **kwargs
) -> Dict[str, Any]: ) -> Dict[str, Any]:
+87 -1
View File
@@ -19,7 +19,7 @@ Few-shot示例选择器 - 基于经验数据集动态选择相关示例
import json import json
import os import os
from pathlib import Path from pathlib import Path
from typing import List, Dict, Optional from typing import List, Dict, Optional, Tuple
from dataclasses import dataclass from dataclasses import dataclass
import numpy as np import numpy as np
import logging import logging
@@ -158,6 +158,92 @@ class FewShotSelector:
self.cache_path = None self.cache_path = None
logger.info("[OK] Few-shot 使用 Chroma(%s 条)", self._chroma_store.count()) logger.info("[OK] Few-shot 使用 Chroma(%s 条)", self._chroma_store.count())
@property
def is_chroma_backend(self) -> bool:
"""是否使用 Chroma 持久化库(`data/embeddings/chroma_fewshot` 等)。"""
return self._chroma_store is not None
def _passes_filters(
self,
sample: "ExperienceSample",
min_rating: Optional[int],
required_tags: Optional[List[str]],
max_difficulty: str,
exclude_qids: Optional[List[str]],
) -> bool:
if exclude_qids and sample.qid in exclude_qids:
return False
if min_rating and sample.rating is not None and sample.rating < min_rating:
return False
if max_difficulty == "easy" and sample.difficulty != "easy":
return False
if max_difficulty == "medium" and sample.difficulty == "hard":
return False
if required_tags and not all(tag in sample.tags for tag in required_tags):
return False
return True
def select_best_with_score(
self,
question: str,
min_rating: Optional[int] = None,
required_tags: Optional[List[str]] = None,
max_difficulty: str = "hard",
exclude_qids: Optional[List[str]] = None,
) -> Optional[Tuple[ExperienceSample, float]]:
"""
返回通过筛选的**相似度最高**一条样本及分数 ``[0,1]``(与向量余弦一致:1 - distance)。
无命中时返回 ``None``。
"""
if self._chroma_store is not None:
from utils.fewshot_chroma_store import sample_from_chroma_metadata
over_fetch = 96
rows = self._chroma_store.search_raw(question, top_k=over_fetch)
for score, meta, doc in rows:
s = sample_from_chroma_metadata(meta, doc)
if not self._passes_filters(
s, min_rating, required_tags, max_difficulty, exclude_qids
):
continue
logger.info(
"[Few-shot] best_with_score: qid=%s score=%.4f preview=%r",
s.qid,
float(score),
(s.question_zh or "")[:100],
)
return (s, float(score))
return None
if self._embedder is None or self.embeddings is None or len(self.samples) == 0:
return None
q_emb = self._embedder.encode(
[question],
batch_size=1,
normalize=True,
show_progress=False,
)[0]
scores = np.dot(self.embeddings, q_emb)
best: Optional[Tuple[float, ExperienceSample]] = None
for idx, (score, sample) in enumerate(zip(scores, self.samples)):
if not self._passes_filters(
sample, min_rating, required_tags, max_difficulty, exclude_qids
):
continue
s = float(score)
if best is None or s > best[0]:
best = (s, sample)
if best is None:
return None
logger.info(
"[Few-shot] best_with_score(numpy): qid=%s score=%.4f preview=%r",
best[1].qid,
best[0],
(best[1].question_zh or "")[:100],
)
return (best[1], best[0])
def _load_samples(self): def _load_samples(self):
"""从 JSONL 加载样本到内存(非 Chroma 模式必需;Chroma 空库时用于首次灌库)。""" """从 JSONL 加载样本到内存(非 Chroma 模式必需;Chroma 空库时用于首次灌库)。"""
if self.samples_path is None: if self.samples_path is None:
+1 -67
View File
@@ -1,67 +1 @@
2026-04-16 10:32:14 INFO [utils.repo_logging] repo_logging.py:79 configure_text2sql_api_logging() | 日志文件: C:\Users\24019\Desktop\backman-camel\logs\text2sql_api.log 2026-04-16 13:35:24 INFO [utils.repo_logging] repo_logging.py:79 configure_text2sql_api_logging() | 日志文件: C:\Users\24019\Desktop\backman-camel\logs\text2sql_api.log
2026-04-16 10:32:17 INFO [__main__] api_server.py:86 <module>() | [OK] 已加载配置文件: C:\Users\24019\Desktop\backman-camel\.env
2026-04-16 10:32:17 INFO [__main__] api_server.py:1047 <module>() | 启动服务: http://0.0.0.0:8041
2026-04-16 10:32:17 INFO [__main__] api_server.py:1048 <module>() | API文档: http://0.0.0.0:8041/docs
2026-04-16 10:32:17 INFO [uvicorn.error] server.py:92 _serve() | Started server process [6728]
2026-04-16 10:32:17 INFO [uvicorn.error] on.py:48 startup() | Waiting for application startup.
2026-04-16 10:32:17 INFO [__main__] api_server.py:741 lifespan() | ============================================================
2026-04-16 10:32:17 INFO [__main__] api_server.py:742 lifespan() | Text2SQL API Server 启动中...
2026-04-16 10:32:17 INFO [__main__] api_server.py:743 lifespan() | ============================================================
2026-04-16 10:32:17 INFO [main] main.py:126 setup_environment() | [OK] 环境检查通过
2026-04-16 10:32:17 INFO [main] main.py:127 setup_environment() | - Schema: data\schemas\G3SB_MCDataDictionary_table_structure.json
2026-04-16 10:32:17 INFO [main] main.py:128 setup_environment() | - LLM: openai
2026-04-16 10:32:17 INFO [main] main.py:154 load_schema() | 加载Schema: ./data/schemas/G3SB_MCDataDictionary_table_structure.json
2026-04-16 10:32:17 INFO [main] main.py:156 load_schema() | 表注释(meta): ./data/schemas/G3SB_MCDataDictionary_table_meta.json
2026-04-16 10:32:18 INFO [schema.loader] loader.py:107 load_from_json() | [OK] 加载Schema完成(G3SB schemas): G3SB_MCDataDictionary_table_structure, 共2516张表
2026-04-16 10:32:18 INFO [main] main.py:163 load_schema() | [OK] Schema加载完成: G3SB_MCDataDictionary_table_structure, 共2516张表, 59196个字段
2026-04-16 10:32:21 INFO [llm.openai_client] openai_client.py:55 __init__() | [OK] OpenAIClient初始化: model=gpt-5.4, base_url=http://113.192.49.54:9080/v1
2026-04-16 10:32:23 INFO [utils.embedding] embedding.py:144 __init__() | 使用 OpenAI Embedding API:model=text-embedding-ada-002,base_url=http://113.192.49.54:9080/v1,max_batch=100
2026-04-16 10:32:25 INFO [utils.fewshot_chroma_store] fewshot_chroma_store.py:86 __init__() | [OK] FewShotChromaStore: 磁盘 data\embeddings\chroma_fewshot collection=fewshot_samples count=50
2026-04-16 10:32:25 INFO [utils.fewshot_selector] fewshot_selector.py:128 _init_chroma_mode() | [Few-shot] 已从向量库加载(Chroma 50 条,data\embeddings\chroma_fewshot)
2026-04-16 10:32:25 INFO [utils.fewshot_selector] fewshot_selector.py:159 _init_chroma_mode() | [OK] Few-shot 使用 Chroma(50 条)
2026-04-16 10:32:25 INFO [agents.orchestrator] orchestrator.py:116 __init__() | Few-shot已启用: top_k=3, min_rating=7
2026-04-16 10:32:25 INFO [agents.orchestrator] orchestrator.py:124 __init__() | [OK] Text2SQLOrchestrator初始化完成: max_retry=2, use_vector_search=True, fewshot=on, nl→zh_norm=on
2026-04-16 10:32:25 INFO [__main__] api_server.py:133 get_orchestrator() | [OK] Orchestrator 初始化完成
2026-04-16 10:32:25 INFO [__main__] api_server.py:747 lifespan() | [OK] 服务已就绪
2026-04-16 10:32:25 INFO [uvicorn.error] on.py:62 startup() | Application startup complete.
2026-04-16 10:32:25 INFO [uvicorn.error] server.py:224 _log_started_message() | Uvicorn running on http://0.0.0.0:8041 (Press CTRL+C to quit)
2026-04-16 10:32:42 INFO [uvicorn.access] httptools_impl.py:483 send() | 127.0.0.1:56978 - "POST /g3sb/api/nl/chat/stream HTTP/1.1" 200
2026-04-16 10:32:42 INFO [__main__] api_server.py:654 _chat_stream_events() | [API/stream] 开始: user_id='anonymous' visitor_biz_id=None session_id=None service_code=None model='gpt-4o-mini' lang_code='auto' msg_chars=15 preview='所有客户账户之间的股票转移记录' dialog_context_chars=0 last_turn_was_data_query=False
2026-04-16 10:32:45 INFO [llm.openai_client] openai_client.py:55 __init__() | [OK] OpenAIClient初始化: model=gpt-4o-mini, base_url=http://113.192.49.54:9080/v1
2026-04-16 10:32:45 INFO [utils.dialog_classifier] dialog_classifier.py:261 classify_dialog() | [dialog] intent=text2sql (hybrid fast: query hint) preview='所有客户账户之间的股票转移记录'
2026-04-16 10:32:45 INFO [__main__] api_server.py:620 _run_generate() | [GEN/API] dialect=tsql top_k=20 dialog_context_chars=0 question_len=15 preview='所有客户账户之间的股票转移记录'
2026-04-16 10:32:48 INFO [llm.openai_client] openai_client.py:55 __init__() | [OK] OpenAIClient初始化: model=gpt-4o-mini, base_url=http://113.192.49.54:9080/v1
2026-04-16 10:32:51 INFO [agents.orchestrator] orchestrator.py:636 generate() | [GEN] 问句已归一中文:所有客户账户的股票转移记录
2026-04-16 10:32:51 INFO [agents.orchestrator] orchestrator.py:649 generate() | [GEN] 开始生成SQL: question_chars=13 preview='所有客户账户的股票转移记录' dialog_context_chars=0
2026-04-16 10:32:51 INFO [agents.orchestrator] orchestrator.py:664 generate() | 尝试 #1
2026-04-16 10:32:51 INFO [schema.indexer] indexer.py:82 __init__() | [OK] 初始化SchemaIndexer(Chroma磁盘 path=data\embeddings\chroma): collection=schema_tables, count=2516
2026-04-16 10:32:51 INFO [schema.indexer] indexer.py:106 ensure_index_for_schema() | Schema 向量索引已就绪(2516 张表),跳过向量化
2026-04-16 10:32:51 INFO [agents.orchestrator] orchestrator.py:185 _coarse_filter() | [Orchestrator] 开始向量检索: query_chars=13 query_preview='所有客户账户的股票转移记录'
2026-04-16 10:32:53 INFO [utils.embedding] embedding.py:163 _set_dim_from_vector() | [OK] Embedding 向量维度:1536
2026-04-16 10:32:53 INFO [schema.indexer] indexer.py:244 search() | Schema 向量检索: query_chars=13 命中=20(阈值=0.1)top=[('VSBHKRpt0430', 0.8264), ('VSBHKRpt0431', 0.8188), ('TSBTransferInstruction', 0.8147), ('VSBTransferInstruction', 0.8134), ('VSBHKRpt0397', 0.8126), ('VSBHKRpt0672', 0.8117), ('VSBHKRpt0090C', 0.81), ('VSBHKRpt0570', 0.8084), ('VSBHKRpt1030A', 0.8077), ('VSBRpt0999E', 0.8065), ('VSBRpt0999C', 0.8064), ('TSBAccountEntitlementRelease', 0.8063), ('VSBRpt0999A', 0.8059), ('TSBAccountInstrumentMovement', 0.8038), ('VSBTransferInstructionGenerationByAccountContract', 0.8033)]
2026-04-16 10:32:53 INFO [agents.orchestrator] orchestrator.py:201 _coarse_filter() | [Orchestrator] 向量粗筛: 命中=20 张(阈值内),表名+分: [('VSBHKRpt0430', 0.8264), ('VSBHKRpt0431', 0.8188), ('TSBTransferInstruction', 0.8147), ('VSBTransferInstruction', 0.8134), ('VSBHKRpt0397', 0.8126), ('VSBHKRpt0672', 0.8117), ('VSBHKRpt0090C', 0.81), ('VSBHKRpt0570', 0.8084), ('VSBHKRpt1030A', 0.8077), ('VSBRpt0999E', 0.8065), ('VSBRpt0999C', 0.8064), ('TSBAccountEntitlementRelease', 0.8063), ('VSBRpt0999A', 0.8059), ('TSBAccountInstrumentMovement', 0.8038), ('VSBTransferInstructionGenerationByAccountContract', 0.8033), ('VSBRpt0060', 0.8021), ('XCGatewayStockReconciliationReport', 0.802), ('TSBAccountEntitlementHold', 0.8013), ('WSBBatchLocationTransferDetail', 0.8012), ('VCAccountCashMovement', 0.8012)]
2026-04-16 10:32:57 INFO [agents.orchestrator] orchestrator.py:255 _llm_select_tables() | LLM精筛选中表:['VSBHKRpt0430', 'VSBHKRpt0431', 'TSBTransferInstruction', 'VSBTransferInstruction', 'TSBAccountInstrumentMovement'] | reasoning_chars=175 reasoning_preview='问题涉及客户账户的股票转移记录,VSBHKRpt0430和VSBHKRpt0431提供了客户股票转移的日报信息,TSBTransferInstruction和VSBTransferInstruction记录账户转移指令,TSBAccountInstrumentMovement则详细记录账户的股票移动交易。这些表共同涵盖了客户账户的股票转移相关信息。'
2026-04-16 10:32:57 INFO [agents.orchestrator] orchestrator.py:692 generate() | 选中表:['VSBHKRpt0430', 'VSBHKRpt0431', 'TSBTransferInstruction', 'VSBTransferInstruction', 'TSBAccountInstrumentMovement'],扩展后:['VSBHKRpt0430', 'VSBHKRpt0431', 'TSBTransferInstruction', 'TSBAccountInstrumentMovement', 'VSBTransferInstruction']
2026-04-16 10:32:58 INFO [utils.fewshot_selector] fewshot_selector.py:271 _select_chroma() | Few-shot(Chroma)选择: 问题='所有客户账户的股票转移记录...' → 选中3个示例 (top_k=3, min_rating=7)
2026-04-16 10:32:58 INFO [agents.orchestrator] orchestrator.py:382 _generate_sql() | 已注入 3 个 few-shot 示例: qid=['Q2', 'Q40', 'Q22'] question_zh_preview=['列出今日所有客户账户之间的股票转移记录。', '列出今日执行的所有股票转移/移动。', '列出今日所有公司账户与客户账户之间的现金划转。']
2026-04-16 10:33:07 INFO [agents.orchestrator] orchestrator.py:447 _generate_sql() | 生成的SQL(chars=476):
SELECT
m.MovementID,
m.AccountID AS FromAccountID,
m.TransferToAccountID,
m.InstrumentID,
i.Name AS InstrumentSymbol,
m.MovementType,
m.Quantity AS TransferQuantity,
m.ValueDate AS TransferDate
FROM TSBAccountInstrumentMovement m
LEFT JOIN MCInstrument i ON m.InstrumentID = i.InstrumentID
WHERE m.MovementType = 'T'
AND m.ValueDate >= CAST(GETDATE() AS DATE)
AND m.ValueDate < DATEADD(DAY, 1, CAST(GETDATE() AS DATE))
ORDER BY m.ValueDate;
2026-04-16 10:33:07 INFO [db.engine] engine.py:45 get_engine() | SQLAlchemy engine initialized from database_url
2026-04-16 10:33:07 INFO [db.dbhub_tools] dbhub_tools.py:627 _execute_sql() | _execute_sql 执行语句数=1 readonly=True max_rows=1
2026-04-16 10:33:10 INFO [agents.orchestrator] orchestrator.py:590 _validate_sql() | [validate] 程序+探针+LLM 汇总: valid=True err_count=0 warn_count=0 db_execution_status=0 sql_chars=476
2026-04-16 10:33:10 INFO [agents.orchestrator] orchestrator.py:753 generate() | [OK] SQL生成与验证通过(1次尝试)
2026-04-16 10:33:10 INFO [__main__] api_server.py:703 _chat_stream_events() | [API/stream] Text2SQL 完成: valid=True attempts=1 tables_used=['VSBHKRpt0430', 'VSBHKRpt0431', 'TSBTransferInstruction', 'TSBAccountInstrumentMovement', 'VSBTransferInstruction'] sql_chars=476 sql_head="SELECT\n m.MovementID,\n m.AccountID AS FromAccountID,\n m.TransferToAccountID,\n m.InstrumentID,\n i.Name AS InstrumentSymbol,\n m.MovementType,\n m.Quantity AS TransferQuantity,\n m.ValueDate AS TransferDate\nFROM TSBAccountInstrumentMovement m\nLEFT JOIN MCInstrument i ON m.InstrumentID = i.InstrumentID\nWHERE m.MovementType = 'T'\n AND m.ValueDate >= CAST(GETDATE() AS DATE)\n AND m.ValueDate < DATEADD(DAY, 1, CAST(GETDATE() AS DATE))\nORDER BY m.ValueDate;"