From cc83fe963ac458a25b444eaac25fcbf4b2a3132f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E8=BE=85=E5=85=83?= <2401926342@qq.com> Date: Tue, 14 Apr 2026 10:28:22 +0800 Subject: [PATCH] =?UTF-8?q?0.1.1=20=E6=9A=82=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 8 +- IMPACT_ANALYSIS.md | 109 ++ README.md | 101 +- __pycache__/api_server.cpython-312.pyc | Bin 0 -> 30921 bytes __pycache__/main.cpython-312.pyc | Bin 15638 -> 16490 bytes __pycache__/nl_lite_store.cpython-312.pyc | Bin 0 -> 17217 bytes .../__pycache__/orchestrator.cpython-312.pyc | Bin 22023 -> 0 bytes api_server.py | 581 ++++++++ __init__.py => backend/__init__.py | 0 backend/__pycache__/main.cpython-312.pyc | Bin 0 -> 17770 bytes .../__pycache__/nl_lite_store.cpython-312.pyc | Bin 0 -> 17262 bytes {agents => backend/agents}/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin .../__pycache__/orchestrator.cpython-312.pyc | Bin 0 -> 27443 bytes .../__pycache__/schema_linker.cpython-312.pyc | Bin .../__pycache__/sql_generator.cpython-312.pyc | Bin .../__pycache__/validator.cpython-312.pyc | Bin {agents => backend/agents}/orchestrator.py | 263 +++- {agents => backend/agents}/schema_linker.py | 0 {agents => backend/agents}/sql_generator.py | 0 {agents => backend/agents}/validator.py | 0 {config => backend/config}/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin .../__pycache__/prompts.cpython-312.pyc | Bin 16399 -> 18525 bytes .../__pycache__/settings.cpython-312.pyc | Bin {config => backend/config}/prompts.py | 31 + {config => backend/config}/settings.py | 4 + backend/db/__init__.py | 31 + .../db/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 850 bytes .../dbhub_allowed_keywords.cpython-312.pyc | Bin 0 -> 3520 bytes .../dbhub_sql_parser.cpython-312.pyc | Bin 0 -> 10150 bytes .../__pycache__/dbhub_tools.cpython-312.pyc | Bin 0 -> 53271 bytes backend/db/__pycache__/engine.cpython-312.pyc | Bin 0 -> 1985 bytes backend/db/dbhub_allowed_keywords.py | 87 ++ backend/db/dbhub_sql_parser.py | 268 ++++ backend/db/dbhub_tools.py | 1252 +++++++++++++++++ backend/db/engine.py | 46 + {llm => backend/llm}/__init__.py | 0 .../llm}/__pycache__/__init__.cpython-312.pyc | Bin .../deepseek_client.cpython-312.pyc | Bin 0 -> 13692 bytes {llm => backend/llm}/deepseek_client.py | 65 +- main.py => backend/main.py | 115 +- backend/nl_lite_store.py | 287 ++++ {schema => backend/schema}/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin .../__pycache__/indexer.cpython-312.pyc | Bin .../__pycache__/loader.cpython-312.pyc | Bin .../__pycache__/manager.cpython-312.pyc | Bin .../__pycache__/models.cpython-312.pyc | Bin {schema => backend/schema}/indexer.py | 0 {schema => backend/schema}/loader.py | 0 {schema => backend/schema}/manager.py | 0 {schema => backend/schema}/models.py | 0 {utils => backend/utils}/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin .../dialog_classifier.cpython-312.pyc | Bin 0 -> 5111 bytes .../__pycache__/embedding.cpython-312.pyc | Bin .../fewshot_selector.cpython-312.pyc | Bin 19122 -> 19105 bytes .../question_locale.cpython-312.pyc | Bin 0 -> 1600 bytes .../__pycache__/sql_parser.cpython-312.pyc | Bin .../__pycache__/validators.cpython-312.pyc | Bin 11369 -> 14236 bytes backend/utils/dialog_classifier.py | 135 ++ {utils => backend/utils}/embedding.py | 0 {utils => backend/utils}/fewshot_selector.py | 3 +- backend/utils/question_locale.py | 23 + {utils => backend/utils}/sql_parser.py | 0 {utils => backend/utils}/validators.py | 84 ++ .../deepseek_client.cpython-312.pyc | Bin 11211 -> 0 bytes requirements.txt | 8 +- scripts/integrate_fewshot.py | 11 +- scripts/patch_fewshot.py | 6 +- tech_architecture.md | 242 ++++ tools/__init__.py | 25 + tools/__pycache__/__init__.cpython-311.pyc | Bin 0 -> 654 bytes tools/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 550 bytes .../dbhub_allowed_keywords.cpython-311.pyc | Bin 0 -> 3985 bytes .../dbhub_execute_sql.cpython-311.pyc | Bin 0 -> 6455 bytes .../dbhub_sql_parser.cpython-311.pyc | Bin 0 -> 12088 bytes tools/__pycache__/dbhub_tools.cpython-311.pyc | Bin 0 -> 57601 bytes tools/__pycache__/dbhub_tools.cpython-312.pyc | Bin 0 -> 1379 bytes tools/dbhub_allowed_keywords.py | 90 ++ tools/dbhub_sql_parser.py | 268 ++++ tools/dbhub_tools.py | 41 + 83 files changed, 4005 insertions(+), 179 deletions(-) create mode 100644 IMPACT_ANALYSIS.md create mode 100644 __pycache__/api_server.cpython-312.pyc create mode 100644 __pycache__/nl_lite_store.cpython-312.pyc delete mode 100644 agents/__pycache__/orchestrator.cpython-312.pyc create mode 100644 api_server.py rename __init__.py => backend/__init__.py (100%) create mode 100644 backend/__pycache__/main.cpython-312.pyc create mode 100644 backend/__pycache__/nl_lite_store.cpython-312.pyc rename {agents => backend/agents}/__init__.py (100%) rename {agents => backend/agents}/__pycache__/__init__.cpython-312.pyc (100%) create mode 100644 backend/agents/__pycache__/orchestrator.cpython-312.pyc rename {agents => backend/agents}/__pycache__/schema_linker.cpython-312.pyc (100%) rename {agents => backend/agents}/__pycache__/sql_generator.cpython-312.pyc (100%) rename {agents => backend/agents}/__pycache__/validator.cpython-312.pyc (100%) rename {agents => backend/agents}/orchestrator.py (62%) rename {agents => backend/agents}/schema_linker.py (100%) rename {agents => backend/agents}/sql_generator.py (100%) rename {agents => backend/agents}/validator.py (100%) rename {config => backend/config}/__init__.py (100%) rename {config => backend/config}/__pycache__/__init__.cpython-312.pyc (100%) rename {config => backend/config}/__pycache__/prompts.cpython-312.pyc (84%) rename {config => backend/config}/__pycache__/settings.cpython-312.pyc (100%) rename {config => backend/config}/prompts.py (88%) rename {config => backend/config}/settings.py (87%) create mode 100644 backend/db/__init__.py create mode 100644 backend/db/__pycache__/__init__.cpython-312.pyc create mode 100644 backend/db/__pycache__/dbhub_allowed_keywords.cpython-312.pyc create mode 100644 backend/db/__pycache__/dbhub_sql_parser.cpython-312.pyc create mode 100644 backend/db/__pycache__/dbhub_tools.cpython-312.pyc create mode 100644 backend/db/__pycache__/engine.cpython-312.pyc create mode 100644 backend/db/dbhub_allowed_keywords.py create mode 100644 backend/db/dbhub_sql_parser.py create mode 100644 backend/db/dbhub_tools.py create mode 100644 backend/db/engine.py rename {llm => backend/llm}/__init__.py (100%) rename {llm => backend/llm}/__pycache__/__init__.cpython-312.pyc (100%) create mode 100644 backend/llm/__pycache__/deepseek_client.cpython-312.pyc rename {llm => backend/llm}/deepseek_client.py (77%) rename main.py => backend/main.py (82%) create mode 100644 backend/nl_lite_store.py rename {schema => backend/schema}/__init__.py (100%) rename {schema => backend/schema}/__pycache__/__init__.cpython-312.pyc (100%) rename {schema => backend/schema}/__pycache__/indexer.cpython-312.pyc (100%) rename {schema => backend/schema}/__pycache__/loader.cpython-312.pyc (100%) rename {schema => backend/schema}/__pycache__/manager.cpython-312.pyc (100%) rename {schema => backend/schema}/__pycache__/models.cpython-312.pyc (100%) rename {schema => backend/schema}/indexer.py (100%) rename {schema => backend/schema}/loader.py (100%) rename {schema => backend/schema}/manager.py (100%) rename {schema => backend/schema}/models.py (100%) rename {utils => backend/utils}/__init__.py (100%) rename {utils => backend/utils}/__pycache__/__init__.cpython-312.pyc (100%) create mode 100644 backend/utils/__pycache__/dialog_classifier.cpython-312.pyc rename {utils => backend/utils}/__pycache__/embedding.cpython-312.pyc (100%) rename {utils => backend/utils}/__pycache__/fewshot_selector.cpython-312.pyc (93%) create mode 100644 backend/utils/__pycache__/question_locale.cpython-312.pyc rename {utils => backend/utils}/__pycache__/sql_parser.cpython-312.pyc (100%) rename {utils => backend/utils}/__pycache__/validators.cpython-312.pyc (50%) create mode 100644 backend/utils/dialog_classifier.py rename {utils => backend/utils}/embedding.py (100%) rename {utils => backend/utils}/fewshot_selector.py (98%) create mode 100644 backend/utils/question_locale.py rename {utils => backend/utils}/sql_parser.py (100%) rename {utils => backend/utils}/validators.py (77%) delete mode 100644 llm/__pycache__/deepseek_client.cpython-312.pyc create mode 100644 tech_architecture.md create mode 100644 tools/__init__.py create mode 100644 tools/__pycache__/__init__.cpython-311.pyc create mode 100644 tools/__pycache__/__init__.cpython-312.pyc create mode 100644 tools/__pycache__/dbhub_allowed_keywords.cpython-311.pyc create mode 100644 tools/__pycache__/dbhub_execute_sql.cpython-311.pyc create mode 100644 tools/__pycache__/dbhub_sql_parser.cpython-311.pyc create mode 100644 tools/__pycache__/dbhub_tools.cpython-311.pyc create mode 100644 tools/__pycache__/dbhub_tools.cpython-312.pyc create mode 100644 tools/dbhub_allowed_keywords.py create mode 100644 tools/dbhub_sql_parser.py create mode 100644 tools/dbhub_tools.py diff --git a/.env b/.env index c353dab..a40b2fc 100644 --- a/.env +++ b/.env @@ -7,7 +7,7 @@ DEEPSEEK_BASE_URL=https://api.deepseek.com # 模型配置 # 主生成模型 -MODEL_PRIMARY=deepseek-reasoner +MODEL_PRIMARY=deepseek-chat TEMPERATURE=0 # 确定性模式:每次生成相同结果 MAX_TOKENS=4096 # 生成与校验默认 SQL Server(T-SQL);可改为 mysql / postgresql 等 @@ -41,7 +41,7 @@ ENABLE_SQL_VALIDATION=true BLOCK_DANGEROUS_SQL=true # ========== 重试配置 ========== -MAX_RETRY=1 # 确定性模式:只尝试一次,不重试 +MAX_RETRY=2 # 确定性模式:只尝试一次,不重试 RETRY_DELAY=1.0 # ========== Few-shot 配置 ========== @@ -56,4 +56,6 @@ FEWSHOT_DATA_PATH=./data/experiences/all_samples.jsonl # ========== 日志配置 ========== LOG_LEVEL=INFO -LOG_FILE=./logs/text2sql.log \ No newline at end of file +LOG_FILE=./logs/text2sql.log + +database_url = mssql+pymssql://sa:123456@192.168.3.201:1433/G3SB_PROD_HW diff --git a/IMPACT_ANALYSIS.md b/IMPACT_ANALYSIS.md new file mode 100644 index 0000000..6d470c1 --- /dev/null +++ b/IMPACT_ANALYSIS.md @@ -0,0 +1,109 @@ +# Impact Analysis Report — NL2SQL 确定性(temperature) + +## 1. 改动概览 + +- **背景与目标**:同一中文问题多次请求时,选表与生成 SQL 因 LLM 采样(默认 `temperature=0.3`)出现不一致。 +- **涉及模块**:`backend/llm/deepseek_client.py`(选表、校验、便捷 `generate_sql`)、`backend/agents/orchestrator.py`(编排器内 SQL 生成)。 +- **改动类型**:缺陷修复 / 行为稳定性增强(非功能扩展)。 + +## 2. 方法级改动 + +| 位置 | 原行为 | 新行为 | +|------|--------|--------| +| `DeepSeekClient.select_tables` | 使用配置默认 `temperature`(如 0.3) | `setdefault(temperature=0.0, top_p=1.0)` 后调用 `chat_with_json`;调用方仍可通过 `**kwargs` 覆盖 | +| `DeepSeekClient.validate_sql` | 同上 | 同上 | +| `DeepSeekClient.generate_sql` | 同上 | 同上 | +| `Text2SQLOrchestrator._generate_sql` | `chat(messages)` 使用默认温度 | 显式 `temperature=0.0, top_p=1.0` | + +与原有逻辑差异:选表、校验、SQL 文本生成在相同 prompt 下更趋确定;若上游 API 在 `temperature=0` 下仍存在极小浮动,属于服务商实现范畴。 + +## 3. 调用方与影响范围 + +- **调用方**:`orchestrator._llm_select_tables` → `deepseek.select_tables`;`orchestrator._generate_sql` → `deepseek.chat`;校验链路 → `validate_sql`;脚本或其它代码若直接调用 `generate_sql`/`select_tables`/`validate_sql` 并传入自定义 `temperature`,**仍以调用方 kwargs 为准**(`setdefault` 不覆盖已传值)。 +- **输入输出**:接口签名未变;返回 JSON/SQL 文本的**内容分布**更集中,极端情况下可能从「多种可接受 SQL」收敛到其中一种。 +- **破坏性变更**:否(未改公开方法签名;仅默认采样参数与编排器单次 `chat` 参数)。 + +## 4. 风险与回滚 + +- **风险级别**:低。可能略微降低「多解探索」多样性;对 Text2SQL 通常可接受。 +- **回滚**:恢复上述文件中的 `setdefault` / 显式 `temperature` 改动,或于环境变量/配置层为 DeepSeek 提高默认 `temperature`(若未来抽到配置项)。 +- **回滚方式是否简单**:是(单提交回退即可)。 + +## 5. 验证与测试 + +- **建议**:对固定中文问题连续请求 5~10 次,比对日志中 `LLM精筛选中表` 与最终 SQL 是否一致。 +- **说明**:Few-shot / 向量粗筛若存在非确定性实现,仍可能导致差异;本次改动仅消除 **LLM 采样** 主导的不一致。 + +## 6. 配置变更(temperature 项) + +- 无新增环境变量或配置文件项;未改 `.env`。 + +--- + +# 增补:英文问句先译中文再走 Text2SQL(2026-04) + +## 1. 改动概览 + +- **目标**:缓解「同一意图中英文提问选表/SQL 不一致」——在无 CJK 的英文问句进入粗筛、选表、few-shot、SQL 生成前,先经 DeepSeek 译为中文。 +- **涉及模块**:`backend/utils/question_locale.py`(新建)、`backend/config/prompts.py`、`backend/llm/deepseek_client.py`、`backend/agents/orchestrator.py`、`backend/main.py`、`api_server.py`。 + +## 2. 方法级改动 + +| 符号 | 说明 | +|------|------| +| `looks_like_english_only` | 无中日韩/假名/韩文且拉丁字母≥3 时视为「可译英文」 | +| `DeepSeekClient.translate_nl_question_to_zh` | 一次 `chat(temperature=0)`,返回单行中文问句 | +| `Text2SQLOrchestrator.generate` | 开头若开启且命中启发式则译问句,后续全流程使用中文;`metadata` 含 `question_original`、`question_zh_normalized` | +| `_nl_dict_from_generation` | 若有译句,响应增加 `query_normalization: { original, zh }` | + +## 3. 调用方与破坏性变更 + +- **调用方**:所有 `orchestrator.generate()`;API 层无签名变更。 +- **破坏性变更**:否。默认开启;关闭后行为与旧版一致。英文请求多一次 LLM 调用(延迟与费用略增)。 + +## 4. 配置变更 + +| 变量 | 含义 | 默认 | +|------|------|------| +| `TRANSLATE_EN_TO_ZH` | `false`/`0`/`off` 关闭英译中 | 开启 | + +CLI:`python backend/main.py --no-translate-en` 关闭。 + +## 5. 风险与回滚 + +- **风险**:翻译偏差导致表意偏移;专有名词若未保留可能被误译。 +- **回滚**:`TRANSLATE_EN_TO_ZH=false` 或 `--no-translate-en`;或回退相关提交。 +- **回滚方式是否简单**:是。 + +--- + +# 增补:库探针状态码 0 / 1 / -1 语义对齐(2026-04) + +## 1. 改动概览 + +- **目标**:与业务约定一致——**1** 表示查询返回至少一行数据;**0** 表示执行成功但**行数为 0**(含「有列无行」的 SELECT);**-1** 表示执行失败并触发重新生成,且重试提示携带数据库错误摘要。 +- **涉及模块**:`backend/db/dbhub_tools.py`、`backend/agents/orchestrator.py`、`backend/llm/deepseek_client.py`(注释)、`backend/main.py`(CLI 展示无行说明)、`backend/db/__init__.py`、`tools/dbhub_tools.py`、`tools/__init__.py`。 + +## 2. 方法级改动 + +| 符号 | 说明 | +|------|------| +| `probe_sql_execution_status_ex` | 新增;返回 `(status, error_message)`,`-1` 时第二项为异常字符串 | +| `probe_sql_execution_status` | 仍返回 `int \| None`,内部委托 `_ex`,**语义变更**:由「有列或行即 1」改为「**至少一行数据**为 1」 | +| `Text2SQLOrchestrator._validate_sql` | `-1` 错误信息附带库端详情;状态 `0` 时在 LLM 反馈外增加固定前缀,引导用户核对 SQL 并补充条件 | +| `single_query` | 验证通过且存在 `db_empty_feedback` 时打印「探针 0」说明块 | + +## 3. 调用方与破坏性变更 + +- **调用方**:仅编排器直接调用探针;对外 API 仍通过 `GenerationResult.metadata` 的 `db_execution_status` / `db_empty_feedback` 表达结果。 +- **破坏性变更**:是(**语义**)。此前「SELECT 返回 0 行但有列名」会被判为 **1**;现改为 **0**,会走无数据说明分支而非「直接可交付」。 + +## 4. 风险与回滚 + +- **风险**:低~中。更多查询会落入「无数据行」分支,多一次 `empty_result_user_feedback` LLM 调用;但更符合「无数据则请用户补充」的产品逻辑。 +- **回滚**:恢复 `probe_sql_execution_status` 内基于「列或行非空」判定 1 的旧实现,并回退编排器与 CLI 改动。 +- **回滚方式是否简单**:是。 + +## 5. 配置变更 + +- 无。 diff --git a/README.md b/README.md index c7097ca..92fdb44 100644 --- a/README.md +++ b/README.md @@ -95,26 +95,30 @@ ``` text2sql_agent_camel/ -├── agents/ # Agent 层 -│ ├── orchestrator.py # 主编排器(协调 Schema Linker → Generator → Validator) -│ ├── schema_linker.py # 表筛选 Agent(粗筛 + LLM 精筛 + 外键扩展) -│ ├── sql_generator.py # SQL 生成 Agent(集成 Few-Shot 注入) -│ └── validator.py # SQL 验证 Agent(程序 + LLM 双重验证) -├── config/ -│ ├── prompts.py # 系统与用户 Prompt 模板 -│ └── settings.py # 配置类(基于 Pydantic) -├── schema/ # Schema 管理层 -│ ├── models.py # 数据模型(Table, Column, ForeignKey, DatabaseSchema) -│ ├── loader.py # Schema 加载器(JSON / G3SB 格式解析) -│ ├── manager.py # Schema 管理器(查询、过滤、转字符串) -│ └── indexer.py # 向量索引构建器(ChromaDB 封装) -├── llm/ -│ └── deepseek_client.py # DeepSeek API 客户端(chat / validate_sql / select_tables) -├── utils/ -│ ├── embedding.py # Qwen3-Embedding 封装(本地 / 远程 API) -│ ├── sql_parser.py # sqlglot 工具(语法验证、方言转换、规范化) -│ ├── validators.py # 验证逻辑(危险操作检测、完整验证流水线) -│ └── fewshot_selector.py # Few-Shot 示例选择器(语义/关键词检索) +├── api_server.py # FastAPI NL 网关(根目录入口;启动前将 backend 加入 sys.path) +├── backend/ # Python 后端(除 api_server 外的编排与工具) +│ ├── main.py # CLI 入口(交互模式:python backend/main.py) +│ ├── nl_lite_store.py # 轻量会话/收藏内存存储(API 演示用) +│ ├── agents/ # Agent 层 +│ │ ├── orchestrator.py # 主编排器(协调 Schema Linker → Generator → Validator) +│ │ ├── schema_linker.py # 表筛选 Agent(粗筛 + LLM 精筛 + 外键扩展) +│ │ ├── sql_generator.py # SQL 生成 Agent(集成 Few-Shot 注入) +│ │ └── validator.py # SQL 验证 Agent(程序 + LLM 双重验证) +│ ├── config/ +│ │ ├── prompts.py # 系统与用户 Prompt 模板 +│ │ └── settings.py # 配置类(基于 Pydantic) +│ ├── schema/ # Schema 管理层 +│ │ ├── models.py # 数据模型(Table, Column, ForeignKey, DatabaseSchema) +│ │ ├── loader.py # Schema 加载器(JSON / G3SB 格式解析) +│ │ ├── manager.py # Schema 管理器(查询、过滤、转字符串) +│ │ └── indexer.py # 向量索引构建器(ChromaDB 封装) +│ ├── llm/ +│ │ └── deepseek_client.py # DeepSeek API 客户端(chat / validate_sql / select_tables) +│ └── utils/ +│ ├── embedding.py # Qwen3-Embedding 封装(本地 / 远程 API) +│ ├── sql_parser.py # sqlglot 工具(语法验证、方言转换、规范化) +│ ├── validators.py # 验证逻辑(危险操作检测、完整验证流水线) +│ └── fewshot_selector.py # Few-Shot 示例选择器(语义/关键词检索) ├── data/ │ ├── schemas/ # Schema JSON 文件 │ │ ├── G3SB_MCDataDictionary_table_structure.json # 表结构 @@ -134,7 +138,6 @@ text2sql_agent_camel/ ├── logs/ │ └── text2sql.log # 运行日志(默认) ├── .env # 环境变量配置(API Key、路径、参数) -├── main.py # CLI 入口(单次查询 / 批量 / 交互模式) ├── pyproject.toml # 项目元数据与依赖 ├── README.md # 本文档 └── SQL_GENERATION_LOGIC.md # SQL 生成逻辑详解(流程图、示例) @@ -230,11 +233,14 @@ Schema JSON 格式示例: ### 4. 构建向量索引(首次运行) ```bash -# 自动构建:首次查询时会自动创建 -python main.py "测试查询" +# 自动构建:首次在交互模式里提问时会自动创建 +python backend/main.py -# 或手动预构建(推荐,加速首次查询) +# 或手动预构建(推荐,加速首次查询;请在仓库根目录执行) python -c " +import sys +from pathlib import Path +sys.path.insert(0, str(Path.cwd() / 'backend')) from agents.orchestrator import Text2SQLOrchestrator from schema.manager import SchemaManager @@ -247,30 +253,13 @@ orch.build_vector_index(force_rebuild=True) ### 5. 运行演示 ```bash -# 单次查询(默认 T-SQL / SQL Server) -python main.py "查询2024年1月的销售额" +# 启动后进入交互式问答(默认 T-SQL / SQL Server) +python backend/main.py -# 交互模式 -python main.py --interactive - -# 批量查询(每行一个问题) -python main.py --batch queries.txt - -# 指定 Schema 和方言 -python main.py "统计每个市场的未结算交易量" \ - --schema ./data/schemas/custom_schema.json \ - --dialect postgresql - -# 禁用 Few-Shot(对比实验) -python main.py "查询活跃账户数" --no-fewshot - -# 调整 Few-Shot 参数 -python main.py "查询2024年1月的销售额" \ - --fewshot-top-k 5 \ - --fewshot-min-rating 8 - -# 详细日志(调试用) -python main.py "查询持仓余额" --verbose +# 可选:启动时附带参数(均在进入交互前生效),例如指定 Schema、方言、Few-Shot、日志等 +python backend/main.py --schema ./data/schemas/custom_schema.json --dialect postgresql --verbose +python backend/main.py --no-fewshot +python backend/main.py --fewshot-top-k 5 --fewshot-min-rating 8 ``` ## 🎯 使用示例 @@ -279,6 +268,12 @@ python main.py "查询持仓余额" --verbose ```python import os +import sys +from pathlib import Path + +# 在仓库根目录运行时,将 backend 加入模块搜索路径(请先 cd 到项目根) +sys.path.insert(0, str(Path.cwd() / "backend")) + from agents.orchestrator import Text2SQLOrchestrator from schema.manager import SchemaManager from llm.deepseek_client import DeepSeekConfig @@ -376,7 +371,7 @@ VECTOR_DB_PATH=./data/embeddings/chroma ### CLI 参数速查 ```bash -python main.py [问题] [选项] +python backend/main.py [选项] # 核心选项 --schema PATH Schema 文件路径 @@ -398,8 +393,6 @@ python main.py [问题] [选项] --embedding-model PATH Embedding 模型路径 # 其他 ---interactive, -i 交互模式 ---batch FILE 批量文件路径 --verbose, -v 详细日志 ``` @@ -564,8 +557,11 @@ WHERE i.AccountID = 'ACC001' # 检查索引文件 ls -la data/embeddings/chroma/ -# 强制重建索引 +# 强制重建索引(在仓库根目录执行) python -c " +import sys +from pathlib import Path +sys.path.insert(0, str(Path.cwd() / 'backend')) from agents.orchestrator import Text2SQLOrchestrator from schema.manager import SchemaManager @@ -587,6 +583,9 @@ orch.build_vector_index(force_rebuild=True) ```bash # 检查数据文件 python -c " +import sys +from pathlib import Path +sys.path.insert(0, str(Path.cwd() / 'backend')) from utils.fewshot_selector import FewShotSelector s = FewShotSelector('./data/experiences/all_samples.jsonl') print(f'样本数: {len(s.samples)}') diff --git a/__pycache__/api_server.cpython-312.pyc b/__pycache__/api_server.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7db6e4980eb16f5d29b76007d4f9103abc078edd GIT binary patch literal 30921 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 Mi40E2XO{2(4@QAZ@c;k- literal 0 HcmV?d00001 diff --git a/__pycache__/main.cpython-312.pyc b/__pycache__/main.cpython-312.pyc index 5a6ff4ec4182765640b4b56859de5494c8e2868b..93f4861b074b14862ff36f9d6979f5f07e00bb4d 100644 GIT binary patch delta 6457 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 U-xH91x4M}mSIq1~|6Az%f0Z3O<^TWy delta 6019 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 K28Jo}y8a)Gt5e+o diff --git a/__pycache__/nl_lite_store.cpython-312.pyc b/__pycache__/nl_lite_store.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..42118b792d823b57f3044a59de2f7563e5c3f0c0 GIT binary patch literal 17217 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 KHa=@45BPtY9{WZB literal 0 HcmV?d00001 diff --git a/agents/__pycache__/orchestrator.cpython-312.pyc b/agents/__pycache__/orchestrator.cpython-312.pyc deleted file mode 100644 index cfbc8ced83a021ed7e7b12b4389cd633284d0266..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22023 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 bytes: + return f"data: {json.dumps(obj, ensure_ascii=False)}\n\n".encode("utf-8") + + +def _conversation_nl_dict(reply: str) -> Dict[str, Any]: + """寒暄/元问题等:与前端 BUSINESS_MANUAL + branch_result.answer 一致。""" + r = (reply or "").strip() or "您好。" + return { + "intent": {"intent": "BUSINESS_MANUAL", "confidence": 1.0, "reason": "conversation"}, + "branch_result": {"answer": r}, + } + + +async def _append_session_if_needed(request: NLChatRequest, user_text: str, data: Dict[str, Any]) -> None: + if not (request.session_id and request.session_id.strip()): + return + try: + assistant_json = json.dumps(data, ensure_ascii=False) + await lite_nl_store.append_exchange( + request.user_id, + request.visitor_biz_id, + request.session_id.strip(), + user_text, + assistant_json, + ) + except Exception as e: + logger.warning(f"[API] 会话落库跳过: {e}") + + +def _nl_dict_from_generation(result: GenerationResult) -> Dict[str, Any]: + sql = (result.sql or "").strip() + explain_parts: List[str] = [] + if result.metadata.get("db_empty_feedback"): + explain_parts.append(str(result.metadata["db_empty_feedback"])) + if result.warnings: + explain_parts.extend(str(w) for w in result.warnings) + if result.errors: + explain_parts.extend(str(e) for e in result.errors) + explain = "; ".join(explain_parts) if explain_parts else None + branch_result: Dict[str, Any] = { + "sql": sql, + "columns": [], + "rows": [], + "row_count": 0, + "truncated": False, + } + if explain: + branch_result["sql_explain"] = explain + dbs = result.metadata.get("db_execution_status") + if dbs is not None: + branch_result["db_execution_status"] = dbs + dbe = result.metadata.get("db_empty_feedback") + if dbe: + branch_result["db_empty_feedback"] = dbe + conf = 1.0 if result.valid else 0.0 + if result.valid: + reason = f"使用了 {len(result.tables_used)} 张表" + else: + reason = result.errors[0] if result.errors else "SQL生成未通过验证" + payload: Dict[str, Any] = { + "intent": {"intent": "DATA_QUERY", "confidence": conf, "reason": reason}, + "branch_result": branch_result, + } + qo = result.metadata.get("question_original") + qz = result.metadata.get("question_zh_normalized") + if qo and qz: + payload["query_normalization"] = {"original": qo, "zh": qz} + 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"{inner}"] + explain = "" + 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'

{html_lib.escape(explain)}
') + return "".join(parts) + + +async def _run_generate(question: str, top_k: int = 20) -> GenerationResult: + orch = get_orchestrator() + dialect = resolve_sql_dialect(os.getenv("TEXT2SQL_DIALECT", "sqlserver")) + + def _call() -> GenerationResult: + return orch.generate( + question=question.strip(), + dialect=dialect, + top_k_candidates=top_k, + ) + + return await asyncio.to_thread(_call) + + +async def _chat_stream_events(request: NLChatRequest) -> AsyncIterator[bytes]: + if not request.message or not request.message.strip(): + yield _sse_data({"code": 400, "msg": "消息内容不能为空", "data": None}) + return + text = request.message.strip() + classified = classify_dialog(text) + if classified.intent == DialogIntent.CONVERSATION: + reply = classified.reply_suggestion or "" + logger.info("[API/stream] 对话意图: conversation(跳过 Text2SQL,与 CLI single_query 一致)") + yield _sse_data({"stage": "orchestrator", "stream_kind": "content", "content": "CHAT"}) + yield _sse_data({"stage": "chat", "stream_kind": "content", "content": reply}) + data_dict = _conversation_nl_dict(reply) + yield _sse_data({"code": 200, "msg": "success", "data": data_dict}) + await _append_session_if_needed(request, text, data_dict) + return + + yield _sse_data({"stage": "orchestrator", "stream_kind": "content", "content": "DATA_QUERY"}) + try: + result = await _run_generate(text) + except Exception as e: + logger.error(f"[API/stream] 生成异常: {e}") + yield _sse_data({"code": 500, "msg": str(e), "data": None}) + return + yield _sse_data({"stage": "sql_gen", "stream_kind": "content", "content": _sql_gen_stream_html(result)}) + data_dict = _nl_dict_from_generation(result) + msg = "success" if result.valid else "partial" + yield _sse_data({"code": 200, "msg": msg, "data": data_dict}) + await _append_session_if_needed(request, text, data_dict) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info("=" * 60) + logger.info("Text2SQL API Server 启动中...") + logger.info("=" * 60) + + try: + get_orchestrator() + logger.info("[OK] 服务已就绪") + except Exception as e: + logger.error(f"服务初始化失败: {e}") + raise + + yield + + logger.info("Text2SQL API Server 已关闭") + + +app = FastAPI( + title="Text2SQL NL Chat API", + description="自然语言转SQL的对话接口", + version="1.0.0", + lifespan=lifespan +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/health") +async def health_check(): + """健康检查接口""" + return {"status": "ok", "message": "Text2SQL API is running"} + + +@app.post("/g3sb/api/nl/chat") +async def nl_chat(request: NLChatRequest): + """ + 自然语言对话接口(非流式),响应结构与流式结束包一致。 + 寒暄/致谢等先经 classify_dialog,与 CLI 一致。 + """ + try: + if not request.message or not request.message.strip(): + raise HTTPException(status_code=400, detail="消息内容不能为空") + text = request.message.strip() + logger.info(f"[API] 问题: {text[:100]}...") + classified = classify_dialog(text) + if classified.intent == DialogIntent.CONVERSATION: + reply = classified.reply_suggestion or "" + logger.info("[API/chat] 对话意图: conversation(跳过 Text2SQL)") + data_dict = _conversation_nl_dict(reply) + await _append_session_if_needed(request, text, data_dict) + return {"code": 200, "msg": "success", "data": data_dict} + + result = await _run_generate(text) + data_dict = _nl_dict_from_generation(result) + response_data = NLChatSuccessData.model_validate(data_dict) + msg = "success" if result.valid else "partial" + if result.valid: + logger.info(f"[API] 生成成功: {(result.sql or '')[:80]}...") + else: + logger.warning(f"[API] 生成失败: {result.errors}") + await _append_session_if_needed(request, text, data_dict) + return ApiEnvelope(code=200, msg=msg, data=response_data) + except HTTPException: + raise + except Exception as e: + logger.error(f"[API] 异常: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/g3sb/api/nl/chat/stream") +async def nl_chat_stream(request: NLChatRequest): + """ + 自然语言对话流式接口(SSE)。 + 事件体为 JSON:分片 delta `{stage, stream_kind, content}` 或结束包 `{code, msg, data}`。 + """ + return StreamingResponse( + _chat_stream_events(request), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + +@app.post("/g3sb/api/nl/sessions") +async def nl_create_session(body: SessionCreateBody): + row = await lite_nl_store.create_session(body.user_id, body.visitor_biz_id, body.title) + return {"code": 200, "msg": "success", "data": row} + + +@app.get("/g3sb/api/nl/sessions") +async def nl_list_sessions( + user_id: Optional[str] = None, + visitor_biz_id: Optional[str] = None, + limit: int = 100, + offset: int = 0, +): + data = await lite_nl_store.list_sessions(user_id, visitor_biz_id, limit, offset) + return {"code": 200, "msg": "success", "data": data} + + +@app.get("/g3sb/api/nl/sessions/{session_id}/messages") +async def nl_get_messages( + session_id: str = FPath(..., description="会话ID"), + user_id: Optional[str] = None, + visitor_biz_id: Optional[str] = None, + limit: int = 500, + offset: int = 0, +): + data = await lite_nl_store.get_messages(user_id, visitor_biz_id, session_id, limit, offset) + if data is None: + return JSONResponse(status_code=200, content={"code": 404, "msg": "session not found", "data": None}) + return {"code": 200, "msg": "success", "data": data} + + +@app.patch("/g3sb/api/nl/sessions/{session_id}/title") +async def nl_patch_session_title(session_id: str, body: SessionTitlePatchBody): + row = await lite_nl_store.update_session_title( + body.user_id, body.visitor_biz_id, session_id, body.title + ) + if not row: + return JSONResponse(status_code=200, content={"code": 404, "msg": "session not found", "data": None}) + return {"code": 200, "msg": "success", "data": row} + + +@app.patch("/g3sb/api/nl/sessions/{session_id}/messages/{message_id}") +async def nl_patch_session_message( + session_id: str, + message_id: int, + body: SessionMessagePatchBody, +): + row = await lite_nl_store.patch_message( + body.user_id, body.visitor_biz_id, session_id, message_id, body.content + ) + if not row: + return JSONResponse(status_code=200, content={"code": 404, "msg": "message not found", "data": None}) + return {"code": 200, "msg": "success", "data": row} + + +@app.delete("/g3sb/api/nl/sessions/{session_id}") +async def nl_delete_session( + session_id: str, + user_id: Optional[str] = None, + visitor_biz_id: Optional[str] = None, +): + row = await lite_nl_store.delete_session(user_id, visitor_biz_id, session_id) + if not row: + return JSONResponse(status_code=200, content={"code": 404, "msg": "session not found", "data": None}) + return {"code": 200, "msg": "success", "data": row} + + +@app.post("/g3sb/api/nl/sql/execute") +async def nl_sql_execute(_body: SqlExecuteBody): + return { + "code": 501, + "msg": "本机 Text2SQL 演示服务未接数据库,无法执行 SQL。请配置独立 NL 网关或移除「运行 SQL」依赖。", + "data": None, + } + + +@app.post("/g3sb/api/nl/operation-logs") +async def nl_post_operation_log(_body: Dict[str, Any] = Body(...)): + return {"code": 200, "msg": "success", "data": None} + + +@app.get("/g3sb/api/nl/operation-logs") +async def nl_list_operation_logs( + user_id: Optional[str] = None, + visitor_biz_id: Optional[str] = None, + limit: int = 50, + offset: int = 0, + q: Optional[str] = None, + op_type: Optional[str] = None, + result: Optional[str] = None, + date_from: Optional[str] = None, + date_to: Optional[str] = None, +): + _ = (user_id, visitor_biz_id, q, op_type, result, date_from, date_to) + return {"code": 200, "msg": "success", "data": {"items": [], "limit": limit, "offset": offset}} + + +@app.get("/g3sb/api/nl/favorites") +async def nl_list_favorites( + user_id: Optional[str] = None, + visitor_biz_id: Optional[str] = None, +): + grouped = await lite_nl_store.get_favorites_grouped(user_id, visitor_biz_id) + return {"code": 200, "msg": "success", "data": grouped} + + +@app.post("/g3sb/api/nl/favorites") +async def nl_create_favorite(body: FavoriteCreateBody): + row = await lite_nl_store.add_favorite(body.user_id, body.visitor_biz_id, body.model_dump(exclude_none=True)) + return {"code": 200, "msg": "success", "data": row} + + +@app.patch("/g3sb/api/nl/favorites/{fav_id}") +async def nl_patch_favorite( + fav_id: str, + body: Dict[str, Any] = Body(default_factory=dict), + user_id: Optional[str] = None, + visitor_biz_id: Optional[str] = None, +): + patch = {k: v for k, v in body.items() if k not in ("user_id", "visitor_biz_id")} + ok = await lite_nl_store.patch_favorite(user_id, visitor_biz_id, fav_id, patch) + if not ok: + return JSONResponse(status_code=200, content={"code": 404, "msg": "favorite not found", "data": None}) + return {"code": 200, "msg": "success", "data": {}} + + +@app.delete("/g3sb/api/nl/favorites/{fav_id}") +async def nl_delete_favorite( + fav_id: str, + user_id: Optional[str] = None, + visitor_biz_id: Optional[str] = None, +): + ok = await lite_nl_store.delete_favorite(user_id, visitor_biz_id, fav_id) + if not ok: + return JSONResponse(status_code=200, content={"code": 404, "msg": "favorite not found", "data": None}) + return {"code": 200, "msg": "success", "data": {"ok": True}} + + +@app.get("/g3sb/api/nl/knowledge-docs/uploads") +async def nl_list_knowledge_uploads( + doc_type: Optional[str] = None, + limit: int = 50, + offset: int = 0, +): + _ = doc_type + return {"code": 200, "msg": "success", "data": {"items": [], "limit": limit, "offset": offset}} + + +@app.post("/g3sb/api/nl/knowledge-docs/upload") +async def nl_upload_knowledge_doc(): + return JSONResponse( + status_code=200, + content={"code": 501, "msg": "knowledge upload not supported on lite API", "data": None}, + ) + + +@app.get("/g3sb/api/nl/admin/visibility") +async def admin_visibility(): + """管理端可见性接口""" + return { + "code": 200, + "msg": "success", + "data": { + "admin_visible": True + } + } + + +if __name__ == "__main__": + import uvicorn + + port = int(os.getenv("API_PORT", "8041")) + host = os.getenv("API_HOST", "0.0.0.0") + + logger.info(f"启动服务: http://{host}:{port}") + logger.info(f"API文档: http://{host}:{port}/docs") + + uvicorn.run( + "api_server:app", + host=host, + port=port, + reload=False, + log_level="info" + ) diff --git a/__init__.py b/backend/__init__.py similarity index 100% rename from __init__.py rename to backend/__init__.py diff --git a/backend/__pycache__/main.cpython-312.pyc b/backend/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..947ec4bb5060cdf0a8e2df7a96b0c9de2093f7d9 GIT binary patch literal 17770 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 literal 0 HcmV?d00001 diff --git a/backend/__pycache__/nl_lite_store.cpython-312.pyc b/backend/__pycache__/nl_lite_store.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f01ca6abe81fe75f0099e01d3ebfa1eba90114cc GIT binary patch literal 17262 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 F{|}M@3)cVu literal 0 HcmV?d00001 diff --git a/agents/__init__.py b/backend/agents/__init__.py similarity index 100% rename from agents/__init__.py rename to backend/agents/__init__.py diff --git a/agents/__pycache__/__init__.cpython-312.pyc b/backend/agents/__pycache__/__init__.cpython-312.pyc similarity index 100% rename from agents/__pycache__/__init__.cpython-312.pyc rename to backend/agents/__pycache__/__init__.cpython-312.pyc diff --git a/backend/agents/__pycache__/orchestrator.cpython-312.pyc b/backend/agents/__pycache__/orchestrator.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cfa80672d2ab03da7471326e87638d6596bc47ef GIT binary patch literal 27443 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 literal 0 HcmV?d00001 diff --git a/agents/__pycache__/schema_linker.cpython-312.pyc b/backend/agents/__pycache__/schema_linker.cpython-312.pyc similarity index 100% rename from agents/__pycache__/schema_linker.cpython-312.pyc rename to backend/agents/__pycache__/schema_linker.cpython-312.pyc diff --git a/agents/__pycache__/sql_generator.cpython-312.pyc b/backend/agents/__pycache__/sql_generator.cpython-312.pyc similarity index 100% rename from agents/__pycache__/sql_generator.cpython-312.pyc rename to backend/agents/__pycache__/sql_generator.cpython-312.pyc diff --git a/agents/__pycache__/validator.cpython-312.pyc b/backend/agents/__pycache__/validator.cpython-312.pyc similarity index 100% rename from agents/__pycache__/validator.cpython-312.pyc rename to backend/agents/__pycache__/validator.cpython-312.pyc diff --git a/agents/orchestrator.py b/backend/agents/orchestrator.py similarity index 62% rename from agents/orchestrator.py rename to backend/agents/orchestrator.py index cb50967..05887ef 100644 --- a/agents/orchestrator.py +++ b/backend/agents/orchestrator.py @@ -34,10 +34,11 @@ class Text2SQLOrchestrator: Text2SQL 多智能体编排器 工作流程: - 1. Schema Linker:粗筛 + LLM精筛,选出相关表 - 2. 外键扩展:自动包含关联表 - 3. SQL Generator:生成SQL - 4. Validator:验证SQL,不通过则重试(最多max_retry次) + 1. 粗筛候选表 + 2. Schema Linker:LLM 精筛表 + 3. 外键扩展 → 拼 Schema 子集 + 4. SQL Generator:生成 SQL + 5. Validator:验证 SQL """ def __init__( @@ -54,6 +55,7 @@ class Text2SQLOrchestrator: fewshot_samples_path: Optional[str] = None, fewshot_top_k: int = 3, fewshot_min_rating: int = 7, + translate_english_to_zh: bool = True, ): """ 初始化编排器 @@ -66,10 +68,12 @@ class Text2SQLOrchestrator: vector_db_path: 向量数据库路径 max_retry: 最大重试次数(包含首次生成) use_vector_search: 是否使用向量检索粗筛 + translate_english_to_zh: 无中日韩字符的英文问句是否先译为中文再走检索与生成 """ self.schema_manager = schema_manager self.max_retry = max_retry self.use_vector_search = use_vector_search + self.translate_english_to_zh = translate_english_to_zh # 初始化DeepSeek客户端 if deepseek_config: @@ -112,6 +116,7 @@ class Text2SQLOrchestrator: f"[OK] Text2SQLOrchestrator初始化完成: " f"max_retry={max_retry}, use_vector_search={use_vector_search}" + (f", fewshot=on" if self.fewshot_enabled else "") + + (", en→zh=on" if self.translate_english_to_zh else ", en→zh=off") ) def _get_vector_index(self) -> SchemaIndexer: @@ -167,7 +172,7 @@ class Text2SQLOrchestrator: self, question: str, candidate_tables: List[str], - max_tables: int = 5 + max_tables: int = 5, ) -> Tuple[List[str], str]: """ 阶段2:LLM精筛(Schema Linker Agent) @@ -193,7 +198,7 @@ class Text2SQLOrchestrator: # 使用DeepSeek客户端调用(而非CAMEL Agent,更直接可控) response = self.deepseek.select_tables( question=question, - table_list=table_list_str + table_list=table_list_str, ) relevant_tables = response.get("relevant_tables", []) @@ -284,7 +289,8 @@ class Text2SQLOrchestrator: self, question: str, schema_str: str, - dialect: str = "tsql" + dialect: str = "tsql", + validation_feedback: Optional[str] = None, ) -> str: """ SQL生成(SQL Generator Agent) @@ -293,6 +299,7 @@ class Text2SQLOrchestrator: question: 用户问题 schema_str: Schema描述字符串 dialect: SQL方言 + validation_feedback: 非空时附加到用户提示(重试时传入上次校验错误) Returns: SQL语句 @@ -336,6 +343,15 @@ class Text2SQLOrchestrator: "**禁止** `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 = [ @@ -343,7 +359,8 @@ class Text2SQLOrchestrator: {"role": "user", "content": user_content}, ] - response = self.deepseek.chat(messages) + # 与选表一致:生成阶段默认贪心解码,减少同一中文问题多次 SQL 不一致 + response = self.deepseek.chat(messages, temperature=0.0, top_p=1.0) sql = response.content.strip() # 清理可能的markdown代码块 @@ -362,20 +379,25 @@ class Text2SQLOrchestrator: sql: str, schema_str: str, dialect: str = "tsql", - ) -> Tuple[bool, List[str], List[str]]: + question: str = "", + ) -> Tuple[bool, List[str], List[str], Optional[int], Optional[str]]: """ - SQL验证(Validator Agent + 程序验证) + SQL验证(程序验证 + 库探针 + 按探针分支的 LLM) Args: sql: SQL语句 schema_str: Schema描述 dialect: 与生成一致的 SQL 方言(sqlglot 名,默认 tsql) + question: 用户自然语言(探针为 0 时用于生成补充说明) Returns: - (是否通过, 错误列表, 警告列表) + (是否通过, 错误列表, 警告列表, 库执行探针状态, 无数据时的用户说明) + 探针:``1`` 至少一行数据;``0`` 执行成功但行数为 0;``-1`` 执行失败;``None`` 未配置库或未跑探针 """ errors = [] warnings = [] + db_execution_status: Optional[int] = None + empty_feedback: Optional[str] = None # === 阶段1:程序验证(确定性规则) === from utils.sql_parser import validate_sql_syntax, validate_schema_consistency @@ -393,41 +415,92 @@ class Text2SQLOrchestrator: errors.extend(schema_errors) # 危险操作检查 - from utils.validators import check_dangerous_operations + from utils.validators import check_dangerous_operations, check_no_cjk_in_sql_string_literals danger_ok, danger_errors = check_dangerous_operations(sql) if not danger_ok: errors.extend(danger_errors) - # === 阶段2:LLM语义验证 === - try: - llm_result = self.deepseek.validate_sql(sql=sql, schema=schema_str) + # T-SQL:禁止中文等业务词出现在字符串字面量(如 FeeNatureID = '过户费') + if dialect == "tsql": + cjk_ok, cjk_errors = check_no_cjk_in_sql_string_literals(sql) + if not cjk_ok: + errors.extend(cjk_errors) - llm_errors = list(llm_result.get("errors", [])) - # 程序校验已通过表/列(含别名解析)时,LLM 仍常误报 unknown_*,避免误杀整次生成 - if schema_ok: - llm_errors = [ - e - for e in llm_errors - if isinstance(e, str) - and not ( - e.startswith("unknown_table:") - or e.startswith("unknown_column:") - ) - ] + # === 阶段1.5:数据库试执行(仅程序校验全部通过时;需配置 database_url) === + if len(errors) == 0: + from db.dbhub_tools import probe_sql_execution_status_ex - if not llm_result.get("valid", True): - errors.extend(llm_errors) + db_execution_status, db_probe_err = probe_sql_execution_status_ex(sql) + if db_execution_status == -1: + msg = ( + "数据库执行验证失败:SQL 在目标库执行报错(探针状态 -1)," + "将据此重新生成 SQL。" + ) + if db_probe_err: + msg += f" 数据库返回:{db_probe_err}" + errors.append(msg) + elif db_execution_status is None: + warnings.append( + "未配置 database_url,已跳过数据库执行探针" + ) - warnings.extend(llm_result.get("warnings", [])) - suggestions = llm_result.get("suggestions", []) - if suggestions: - logger.debug(f"优化建议:{suggestions}") + # 探针 1:库上至少有一行数据,跳过 Validator LLM,直接将 SQL 视为可交付 + # 探针 0:执行成功但行数为 0,跳过 Validator LLM,另调 LLM 生成说明并引导用户补充条件 + # 探针 -1:执行失败,跳过 Validator LLM,走重试 + # 探针 None:走完整 Validator LLM + skip_validator_llm = db_execution_status in (-1, 0, 1) - except Exception as e: - logger.warning(f"LLM验证失败(降级为仅程序验证): {e}") + # === 阶段2:LLM 语义验证(仅未命中库探针 0/1/-1 时) === + if not skip_validator_llm: + try: + llm_result = self.deepseek.validate_sql(sql=sql, schema=schema_str) + + llm_errors = list(llm_result.get("errors", [])) + # 程序校验已通过表/列(含别名解析)时,LLM 仍常误报 unknown_*,避免误杀整次生成 + if schema_ok: + llm_errors = [ + e + for e in llm_errors + if isinstance(e, str) + and not ( + e.startswith("unknown_table:") + or e.startswith("unknown_column:") + ) + ] + + if not llm_result.get("valid", True): + errors.extend(llm_errors) + + warnings.extend(llm_result.get("warnings", [])) + suggestions = llm_result.get("suggestions", []) + if suggestions: + logger.debug(f"优化建议:{suggestions}") + + except Exception as e: + logger.warning(f"LLM验证失败(降级为仅程序验证): {e}") + + # === 阶段2b:探针 0 时生成用户可读补充说明(仍返回 SQL,由 API/CLI 一并展示) === + if db_execution_status == 0 and len(errors) == 0: + prefix = ( + "该 SQL 已在数据库成功执行,但返回的数据行数为 0(未查到匹配记录)。" + "请将下方 SQL 与说明一并核对;若不符合预期,请补充或调整条件后再次提问。" + ) + try: + llm_fb = self.deepseek.empty_result_user_feedback( + question=question, + sql=sql, + schema=schema_str, + ) + empty_feedback = f"{prefix}\n\n【分析与建议】\n{llm_fb}" + except Exception as e: + logger.warning(f"无数据说明生成失败: {e}") + empty_feedback = ( + f"{prefix}\n\n【分析与建议】\n" + "未能自动生成详细分析。请补充时间范围、筛选条件或业务对象后重新提问。" + ) is_valid = len(errors) == 0 - return is_valid, errors, warnings + return is_valid, errors, warnings, db_execution_status, empty_feedback def generate( self, @@ -448,11 +521,34 @@ class Text2SQLOrchestrator: Returns: GenerationResult对象 """ + from utils.question_locale import looks_like_english_only + + original_question = (question or "").strip() + translation_meta: Dict = {} + work_question = original_question + if self.translate_english_to_zh and looks_like_english_only(original_question): + try: + zh = self.deepseek.translate_nl_question_to_zh(original_question).strip() + if zh and len(zh) >= 2: + work_question = zh + translation_meta["question_original"] = original_question + translation_meta["question_zh_normalized"] = zh + logger.info( + "[GEN] 英文已译为中文:%s", + zh[:120] + ("…" if len(zh) > 120 else ""), + ) + else: + logger.warning("[GEN] 英译中结果为空或过短,使用原文") + except Exception as e: + logger.warning("[GEN] 英译中失败,使用原文: %s", e) + + question = work_question logger.info(f"[GEN] 开始生成SQL:{question[:50]}...") attempt = 0 last_sql = None last_errors = [] + last_db_execution_status: Optional[int] = None filtered_schema_str = "" tables_used = [] @@ -465,7 +561,10 @@ class Text2SQLOrchestrator: candidate_tables = self._coarse_filter(question, top_k=top_k_candidates) # 1.2 LLM精筛 - relevant_tables, reasoning = self._llm_select_tables(question, candidate_tables) + relevant_tables, reasoning = self._llm_select_tables( + question, + candidate_tables, + ) relevant_tables = self._prioritize_broker_tables(question, relevant_tables) # 1.3 外键扩展 @@ -480,12 +579,41 @@ class Text2SQLOrchestrator: ) logger.info(f" 选中表:{relevant_tables},扩展后:{expanded_tables}") else: - # 重试时复用之前的Schema + # 重试:在子 Schema 中并入「上次失败 SQL」实际引用到的表,并对齐程序校验与生成上下文 logger.info(f" 重试使用之前的Schema({len(tables_used)}张表)") + if last_sql: + from utils.sql_parser import extract_tables_from_sql + + extra = [ + t + for t in extract_tables_from_sql(last_sql, dialect=dialect) + if self.schema_manager.get_table(t) + ] + merged = list(dict.fromkeys([*(tables_used or []), *extra])) + tables_used = self._expand_relations(merged) + filtered_schema_str = self.schema_manager.to_compact_string( + table_names=tables_used, + include_columns=True, + max_columns_per_table=20, + ) + if extra: + logger.info( + " 重试:合并失败SQL中的表 %s,外键扩展后:%s", + extra, + tables_used, + ) # === Step 2: SQL生成 === try: - sql = self._generate_sql(question, filtered_schema_str, dialect) + feedback: Optional[str] = None + if attempt > 0 and last_errors: + feedback = "\n".join(f"- {e}" for e in last_errors[:20]) + sql = self._generate_sql( + question, + filtered_schema_str, + dialect, + validation_feedback=feedback, + ) last_sql = sql except Exception as e: last_errors = [f"SQL生成失败: {str(e)}"] @@ -493,38 +621,55 @@ class Text2SQLOrchestrator: continue # === Step 3: 验证 === - is_valid, errors, warnings = self._validate_sql( - sql, filtered_schema_str, dialect=dialect + is_valid, errors, warnings, db_probe, empty_feedback = self._validate_sql( + sql, + filtered_schema_str, + dialect=dialect, + question=question, ) + if db_probe is not None: + last_db_execution_status = db_probe - if is_valid: - logger.info(f"[OK] SQL生成并验证通过({attempt + 1}次尝试)") - result = GenerationResult( - sql=sql, - valid=True, - errors=[], - warnings=warnings, - tables_used=tables_used, - attempts=attempt + 1, - reasoning=reasoning if attempt == 0 else None, - ) - if include_schema_in_result: - result.metadata["schema"] = filtered_schema_str - return result + if not is_valid: + last_errors = errors + logger.warning(f" [FAIL] 验证失败:{errors}") + attempt += 1 + continue - # 验证失败,准备重试 - last_errors = errors - logger.warning(f" [FAIL] 验证失败:{errors}") - attempt += 1 + logger.info(f"[OK] SQL生成与验证通过({attempt + 1}次尝试)") + + meta: Dict = dict(translation_meta) + if db_probe is not None: + meta["db_execution_status"] = db_probe + if empty_feedback: + meta["db_empty_feedback"] = empty_feedback + + result = GenerationResult( + sql=sql, + valid=True, + errors=[], + warnings=warnings, + tables_used=tables_used, + attempts=attempt + 1, + reasoning=reasoning if attempt == 0 else None, + metadata=meta, + ) + if include_schema_in_result: + result.metadata["schema"] = filtered_schema_str + return result # 达到最大重试次数 logger.error(f"[FAIL] 达到最大重试次数({self.max_retry}),生成失败") + fail_meta: Dict = dict(translation_meta) + if last_db_execution_status is not None: + fail_meta["db_execution_status"] = last_db_execution_status return GenerationResult( sql=last_sql or "", valid=False, errors=last_errors, tables_used=tables_used, attempts=attempt, + metadata=fail_meta, ) def build_vector_index(self, force_rebuild: bool = False) -> bool: diff --git a/agents/schema_linker.py b/backend/agents/schema_linker.py similarity index 100% rename from agents/schema_linker.py rename to backend/agents/schema_linker.py diff --git a/agents/sql_generator.py b/backend/agents/sql_generator.py similarity index 100% rename from agents/sql_generator.py rename to backend/agents/sql_generator.py diff --git a/agents/validator.py b/backend/agents/validator.py similarity index 100% rename from agents/validator.py rename to backend/agents/validator.py diff --git a/config/__init__.py b/backend/config/__init__.py similarity index 100% rename from config/__init__.py rename to backend/config/__init__.py diff --git a/config/__pycache__/__init__.cpython-312.pyc b/backend/config/__pycache__/__init__.cpython-312.pyc similarity index 100% rename from config/__pycache__/__init__.cpython-312.pyc rename to backend/config/__pycache__/__init__.cpython-312.pyc diff --git a/config/__pycache__/prompts.cpython-312.pyc b/backend/config/__pycache__/prompts.cpython-312.pyc similarity index 84% rename from config/__pycache__/prompts.cpython-312.pyc rename to backend/config/__pycache__/prompts.cpython-312.pyc index 2c21da700e1f9bdbab6b373fdae06eef14ebae85..e2d8fe2e13341bbde400a28e70d2a5bebd39e8b9 100644 GIT binary patch delta 2321 zcmZuyTTl~M7!Ik5Mn#K`x1zIsu#9;?u{z^StJErvS8NS-MqUy$+scSq1zH_zlYqHE z5&{O~5^h1ccu5u{g(Vxv^tHa&SEilm=$^BiCz$cAFYR}7kj_|{Fga(>zyJTA@A4<) z%=xn=C-24DwI&1pESllNReX0TL zY-Zk**-SiL)Yl@|3ST+V6DyBJp-rH^2ZF8a-KV5kA@>8S@=zOakv>21b@1wf?U*Q* zRo2$s6i*zry>_~67O}Rr+96tM@vw$J!BVuLIF;J;Su|2l((X)Dp##%27za$2OP9r~ zl?)#m|AkJBX1q;iv%ni9$uRM_wVr+w?=zcu1G`DEodrw5-L!oSD~1=tLa?#2qHxe5 z6do@8ubstigo8ri`9jvod@7>*hnA2e(lkl7X-Yzm0`mvBBNp?i03M>flV?wq3H%Uk zffnMP%(TUU0b+IkIG`gfL2MZh$|JmI-eFedAelvD2l(%p6fHu7=6$c>Zx)YM5aaJ zbifP1Xyb?;4d9qG^|R9%59mhu#vo)t`+5+IZXK%Jj4)MhBE4Q!PHK;nSsZD{00~K` z&Vw@%4*)|yCS97sQ(^D?Oy7Qigod=ZlO|mJcDj_Hoy~YpZwwM%{(98*J6>3vyoTBmNUzfW^)D9*|eH$R%JhZ7NwyU;JATC658`wEgEN+Luq~3 z75-<-E(gTHcNuSxJPPWwVVIP*Cm>Fa_u?@>5KdaomStUYrt;<$`}H5M35z!Ah5zd7 z9%D@KrWr0He$F8LZMt)#POPb|x_<4h$pl6@Yg)JC9sd00zISks8wJWV!6Xc_ zBc!ESQ(}wOYiY%>r4OB2Vi2Mi%Eeo?d)Y)*XVPer(Qnd6?h#)Xqoue=&?QGqoo*!Q z0Zg5&gIF9BmB&qFIlE>P6H`J@k8!07S3$p6EBsJdT_?Ws+$VB9ai*ZfnyPExRg1#q z>(zFw*h#P*m!Sa{AQZULSqpoM1U22OwRdGY)6@Z6Qfw{&8swlILx3L(BD=JaAQu?6 zoEpA`Y%%jytsTB<19R_W%L6yDy_o8^u zo;?^2Y=;D_wNC+we~9TfgrT|kn+3naJ4?g@3rs=QVs?yU&45ey;+~v&<|(O_rQ64T z8%gL17b|P)|MuhYmK>m?N3%LsLl0x9E0JX~Xp}Gj9=tjnSQQB6(dA{Kh+d z*JG;Uep``AiPuF?*{=0-xmK(F`X#G%Zbj|w??F?z>(HJ3a|NYmzAC?9Jy&Wuf4bay zthDs#mxqs>vRW=!%1h78ZF}!&cwLEbaBge)xx;0a(}&ATt!1aJ>MJm-c&$d0w%Iv4fGda+$N2Q1ZsO}bX zaY@lF{`mOByuAF9#FEVXyyE!yl?= '2024-01-01' AND ValueDate < '2024-02-01'`),或使用Schema中常见的枚举值(如状态字段的`A/D/X`),**不要**留空或写占位符。 +2b. **禁止在 SQL 字符串字面量中出现中文(CJK)**:用户问题里的中文业务词(如「过户费」「未结算」「活跃」)**禁止**写成 `'…中文…'` 或 `N'…中文…'` 去和代码型列(如 `FeeNatureID`、`SettleStatus`、`State`)比较。必须根据 **Schema 字段注释** 写成库内真实**代码/单字母/数字**(如 `State = 'A'`、`SettleStatus = 'U'`);若业务词对应维表或码表,应 **JOIN 维表** 用其键列或英文名列过滤,**不得**用中文当字面量。 3. **输出版式与别名风格(统一规范)**:除遵守目标方言语法外,SQL **排版与命名**须与下方「标准版式范例」一致: - **关键字**:`SELECT`、`FROM`、`JOIN`/`LEFT JOIN`、`ON`、`WHERE`、`AND`、`GROUP BY`、`ORDER BY`、`HAVING` 等使用**大写**。 - **换行与缩进**:`SELECT` 后换行;每个输出列**独占一行**,行首 **4 个空格**,列表达式之间用**行尾逗号**分隔(最后一列无逗号)。 @@ -267,6 +268,21 @@ VALIDATOR_USER = """需要验证的SQL: 请输出验证结果JSON:""" +EMPTY_RESULT_FEEDBACK_SYSTEM = """你是数据分析助手。用户的自然语言问题已转成 SQL,且在目标库执行成功,但**当前结果在列与行上均为空**(无可用结果集)。 +请用 2~5 句简洁中文说明可能原因(如条件过严、时间范围无数据、对象不存在等),并**友好引导用户补充**时间、筛选条件、业务对象等,便于下次提问更精确。 +不要编造 Schema 中不存在的表或字段;不要重复输出整段 SQL;不要输出 JSON 或 Markdown 代码块。""" + +EMPTY_RESULT_FEEDBACK_USER = """用户原始问题: +{question} + +已执行的 SQL: +{sql} + +相关 Schema(节选): +{schema} + +请直接输出给终端用户阅读的说明文字(纯文本)。""" + # ========== Few-Shot 示例 ========== FEW_SHOT_EXAMPLES: Dict[str, str] = { @@ -319,3 +335,18 @@ WHERE ValueDate >= '2024-01-01' AND ValueDate < '2024-02-01'; """, } + +# ========== NL 英译中(检索 / Text2SQL 前归一化)========== +TRANSLATE_NL_TO_ZH_SYSTEM = """你是证券/期货类数据仓库领域的翻译助手。 +将用户给出的英文(或主要为拉丁字母的)分析需求翻译成**一句简洁的中文自然语言问题**,供后续中文向量检索与 Text2SQL 使用。 + +规则: +1. 语义忠实,使用业内常用中文表述(如 market value→市值、single holding→单一持仓 等)。 +2. 保留阿拉伯数字、日期、币种代码、证券代码;「10 million」等与中文习惯一致时可译为「一千万」「1000万」等。 +3. 若原句中出现明确的英文表名、字段名,保持英文不译。 +4. **只输出中文问句本身**,不要引号、不要「翻译如下」等前后缀。""" + +TRANSLATE_NL_TO_ZH_USER = """原句: +{question} + +仅输出一句中文:""" diff --git a/config/settings.py b/backend/config/settings.py similarity index 87% rename from config/settings.py rename to backend/config/settings.py index dbe793e..80fec32 100644 --- a/config/settings.py +++ b/backend/config/settings.py @@ -39,6 +39,10 @@ class Settings(BaseSettings): log_level: str = "INFO" log_file: Optional[str] = None + # 业务库(backend/db:execute_sql / search_objects);与 .env 中 database_url 一致 + database_url: Optional[str] = None + sql_max_rows: int = 10000 + class Config: env_file = ".env" case_sensitive = False diff --git a/backend/db/__init__.py b/backend/db/__init__.py new file mode 100644 index 0000000..49cc81c --- /dev/null +++ b/backend/db/__init__.py @@ -0,0 +1,31 @@ +""" +数据库工具:对齐 DBHub 的 ``execute_sql`` / ``search_objects``。 + +使用前请将 ``backend`` 目录加入 ``sys.path``(与 ``api_server.py`` / ``backend/main.py`` 一致),并已在进程内加载根目录 ``.env``。 +""" + +from __future__ import annotations + +from db.dbhub_tools import ( + DbHubTools, + dbhub_tools, + execute_sql, + execute_sql_all, + execute_sql_count_only, + probe_sql_execution_status, + probe_sql_execution_status_ex, + search_objects, +) +from db.engine import get_engine + +__all__ = [ + "DbHubTools", + "dbhub_tools", + "execute_sql", + "execute_sql_all", + "execute_sql_count_only", + "get_engine", + "probe_sql_execution_status", + "probe_sql_execution_status_ex", + "search_objects", +] diff --git a/backend/db/__pycache__/__init__.cpython-312.pyc b/backend/db/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ceb063a2758c1b22bf0a6d2f9420c6f81dd40153 GIT binary patch literal 850 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 literal 0 HcmV?d00001 diff --git a/backend/db/__pycache__/dbhub_tools.cpython-312.pyc b/backend/db/__pycache__/dbhub_tools.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8d8938e9ca9ce27d9637dc7e55e18102c4f4450b GIT binary patch literal 53271 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 CQ|ZD0 literal 0 HcmV?d00001 diff --git a/backend/db/__pycache__/engine.cpython-312.pyc b/backend/db/__pycache__/engine.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..61960b5c760f7d94413644e8109b766e74288d3e GIT binary patch literal 1985 zcmb7FTWl0n7(O$zJA2=jR;#p@43P)dD3`_>X)2}&8Um@&7EIQK+3C(ux6EERGmB}n z#N}qUv=mG!Eff_9B*;BRv6L-+@y(ZNNyK4ENZ8r7Ppvlk;FJHE-RW-PlP8&T{{LM5 z%lBXAXOG8;VEit~HKt89zd{dR+9Wk6zjD(BMMmTyAn|_Vm@+ z!Q1+!Bl^kF`QDRUFenPD&><)ipTKf3$gP8|EiLUWn_Jqp@;g7;5ezP5(z)@$`HS~v z$8O~>kXfG`hFfc;=mB_;8&A#ke!r0EdzL!voG0e`D9t5AqcK%b!?CE+3cUS;IH3g@ zXio@Bf-3P+v@;x)V1wOiP(7Z)$|bqhSX9z%#Ty|a(;OBcAO_UZH6TU+?Ja0`D?$g6 zT0{z=F50|YT9e2`@*tI@Qq(}53Xv_0BxRm;+04Dtdc8<0)Ij6^eG`IwXhRm%P5YQu zAFa8|vDVz>Fl)$X(dd|>v7M3%z-o-5V$BLuI1VqFeNwkZ9Z-mC9QV)0cbft`6$vYW zhBwx4*cjL%Df`q|JkTM8_CmeZ`$p$Ay#emomC}6!d-mTT}-L`9*0=3csJ}H$J#xAG-ag-UVa3?4ZvtlnU+u|O*PPlv^yWzO zm(4?)es!;2w4uuNi+1GpE`bR29x)qoO0!}~k<{fV5*L=E_yCv@l%y~%QUXe%LmstQ zVF);pzQJ5CMzam6M5-|ku_YSzmm_J$>L8t@6_>gkP0?aT_%Kx_pDvd#Ak}Wl?{b+` zX%(%e464kMHsoUyt0DEA9E`cC)B4!g^Y=&RZe7lepVNPzbN4ou38U(z#2^dG;Q%?#-`M`zC+HQgD=jrZqoT-3ifkss(Q?=5F4gk8 z%boSUIK6VijJILh-jMayjka8Ef9P#Uy`Qx>d$%9jK3&=J(6Tw}s!Fxu=RgG%42vZ- zfuq<sZvuo!JdcTMnvFOQ zaAP;&ShbsAk4Xt?PG!F=6#fzha@va02DEq5pAGq%17O)vz|hX~QD`q9y%t0msB0BG zU)J#9h;$MIzi*M@S^->pb0j7vWN8zA70yY+R<^^m$WRpZCrwk9zwC&5`5E#)Mvf<_ zPDg8=pfyh&wdogrbJYIgs2zQ4#<3>FK3!R#vZd>1Q2i2XrM!!k=#|&T8~;YtRJEb( EU#Lr6zyJUM literal 0 HcmV?d00001 diff --git a/backend/db/dbhub_allowed_keywords.py b/backend/db/dbhub_allowed_keywords.py new file mode 100644 index 0000000..35f8a59 --- /dev/null +++ b/backend/db/dbhub_allowed_keywords.py @@ -0,0 +1,87 @@ +""" +从 DBHub allowed-keywords.ts 等价移植:只读 SQL 判定。 +参见 dbhub/src/utils/allowed-keywords.ts +""" + +from __future__ import annotations + +import re +from typing import Literal + +from db.dbhub_sql_parser import ConnectorType, strip_comments_and_strings + +ALLOWED_KEYWORDS: dict[ConnectorType, list[str]] = { + "postgres": ["select", "with", "explain", "show"], + "mysql": ["select", "with", "explain", "show", "describe", "desc"], + "mariadb": ["select", "with", "explain", "show", "describe", "desc"], + "sqlite": ["select", "with", "explain", "pragma"], + "sqlserver": ["select", "with", "explain", "showplan"], +} + +_MUTATING = [ + "insert", + "update", + "delete", + "drop", + "alter", + "create", + "truncate", + "merge", + "grant", + "revoke", + "rename", +] +_mutating_pattern = re.compile(rf"\b(?:{'|'.join(_MUTATING)})\b", re.IGNORECASE) +_mutating_pattern_with_replace = re.compile( + rf"\b(?:{'|'.join(_MUTATING)}|replace\s+(?:(?:low_priority|delayed)\s+)?into)\b", + re.IGNORECASE, +) + +_MUTATING_PATTERNS: dict[ConnectorType, re.Pattern[str]] = { + "postgres": _mutating_pattern, + "mysql": _mutating_pattern_with_replace, + "mariadb": _mutating_pattern_with_replace, + "sqlite": _mutating_pattern_with_replace, + "sqlserver": _mutating_pattern, +} + +_SELECT_INTO_PATTERN = re.compile(r"\bselect\b[\s\S]+\binto\b", re.IGNORECASE) + +_EXPLAIN_ANALYZE_PATTERN = re.compile( + r"^explain\s+(?:\([^)]*\banalyze\b(?!\s*(?:=\s*)?(?:false|off|0)\b)[^)]*\)|\banalyze\b(?!\s*(?:=\s*)?(?:false|off|0)\b)(?:\s+verbose\b)?)", + re.IGNORECASE, +) + + +def _check_read_only(cleaned_sql: str, connector_type: ConnectorType | str) -> bool: + if not cleaned_sql: + return False + m = re.search(r"\S+", cleaned_sql) + first_word = m.group(0) if m else "" + keyword_list = ALLOWED_KEYWORDS.get(connector_type, []) # type: ignore[arg-type] + if first_word not in keyword_list: + return False + if first_word == "with": + pat = _MUTATING_PATTERNS.get(connector_type, _mutating_pattern) # type: ignore[arg-type] + if pat.search(cleaned_sql): + return False + if first_word in ("select", "with") and _SELECT_INTO_PATTERN.search(cleaned_sql): + return False + if first_word == "explain": + em = _EXPLAIN_ANALYZE_PATTERN.match(cleaned_sql) + if em: + after_explain = cleaned_sql[em.end() :].strip() + if after_explain and not _check_read_only(after_explain, connector_type): + return False + return True + + +def is_read_only_sql(sql: str, connector_type: ConnectorType | str) -> bool: + """Check if a SQL query is read-only (DBHub-compatible).""" + cleaned = strip_comments_and_strings(sql, connector_type if connector_type in ALLOWED_KEYWORDS else None) + cleaned = cleaned.strip().lower() + return _check_read_only(cleaned, connector_type) + + +def allowed_keywords_list(connector_type: ConnectorType | str) -> list[str]: + return ALLOWED_KEYWORDS.get(connector_type, []) # type: ignore[arg-type] diff --git a/backend/db/dbhub_sql_parser.py b/backend/db/dbhub_sql_parser.py new file mode 100644 index 0000000..cdf8b3a --- /dev/null +++ b/backend/db/dbhub_sql_parser.py @@ -0,0 +1,268 @@ +""" +从 DBHub sql-parser.ts 等价移植:按方言剥离注释/字符串、切分语句。 +参见 dbhub/src/utils/sql-parser.ts +""" + +from __future__ import annotations + +import re +from typing import Callable, Literal, TypedDict + +ConnectorType = Literal["postgres", "mysql", "mariadb", "sqlite", "sqlserver"] + + +class _Token(TypedDict): + type: int # 0 Plain, 1 Comment, 2 QuotedBlock + end: int + + +_TOKEN_PLAIN = 0 +_TOKEN_COMMENT = 1 +_TOKEN_QUOTED = 2 + + +def _plain_token(i: int) -> _Token: + return {"type": _TOKEN_PLAIN, "end": i + 1} + + +def _scan_single_line_comment(sql: str, i: int) -> _Token | None: + if i + 1 >= len(sql) or sql[i] != "-" or sql[i + 1] != "-": + return None + j = i + while j < len(sql) and sql[j] != "\n": + j += 1 + return {"type": _TOKEN_COMMENT, "end": j} + + +def _scan_multi_line_comment(sql: str, i: int) -> _Token | None: + if i + 1 >= len(sql) or sql[i] != "/" or sql[i + 1] != "*": + return None + j = i + 2 + while j + 1 < len(sql) and not (sql[j] == "*" and sql[j + 1] == "/"): + j += 1 + if j + 1 < len(sql): + j += 2 + return {"type": _TOKEN_COMMENT, "end": j} + + +def _scan_multi_line_comment_mysql(sql: str, i: int) -> _Token | None: + if i + 1 >= len(sql) or sql[i] != "/" or sql[i + 1] != "*": + return None + nxt = sql[i + 2] if i + 2 < len(sql) else "" + nxt2 = sql[i + 3] if i + 3 < len(sql) else "" + if nxt == "!" or (nxt == "M" and nxt2 == "!"): + return None + return _scan_multi_line_comment(sql, i) + + +def _scan_nested_multi_line_comment(sql: str, i: int) -> _Token | None: + if i + 1 >= len(sql) or sql[i] != "/" or sql[i + 1] != "*": + return None + j = i + 2 + depth = 1 + while j < len(sql) and depth > 0: + if j + 1 < len(sql) and sql[j] == "/" and sql[j + 1] == "*": + depth += 1 + j += 2 + elif j + 1 < len(sql) and sql[j] == "*" and sql[j + 1] == "/": + depth -= 1 + j += 2 + else: + j += 1 + return {"type": _TOKEN_COMMENT, "end": j} + + +def _scan_single_quoted_string(sql: str, i: int) -> _Token | None: + if sql[i] != "'": + return None + j = i + 1 + while j < len(sql): + if j + 1 < len(sql) and sql[j] == "'" and sql[j + 1] == "'": + j += 2 + elif sql[j] == "'": + j += 1 + break + else: + j += 1 + return {"type": _TOKEN_QUOTED, "end": j} + + +def _scan_double_quoted_string(sql: str, i: int) -> _Token | None: + if sql[i] != '"': + return None + j = i + 1 + while j < len(sql): + if j + 1 < len(sql) and sql[j] == '"' and sql[j + 1] == '"': + j += 2 + elif sql[j] == '"': + j += 1 + break + else: + j += 1 + return {"type": _TOKEN_QUOTED, "end": j} + + +_dollar_quote_open_regex = re.compile(r"^\$([a-zA-Z_]\w*)?\$") + + +def _scan_dollar_quoted_block(sql: str, i: int) -> _Token | None: + if sql[i] != "$": + return None + nxt = sql[i + 1] if i + 1 < len(sql) else "" + if nxt.isdigit(): + return None + remaining = sql[i:] + m = _dollar_quote_open_regex.match(remaining) + if not m: + return None + tag = m.group(0) + body_start = i + len(tag) + close_idx = sql.find(tag, body_start) + end = close_idx + len(tag) if close_idx != -1 else len(sql) + return {"type": _TOKEN_QUOTED, "end": end} + + +def _scan_backtick_quoted_identifier(sql: str, i: int) -> _Token | None: + if sql[i] != "`": + return None + j = i + 1 + while j < len(sql): + if j + 1 < len(sql) and sql[j] == "`" and sql[j + 1] == "`": + j += 2 + elif sql[j] == "`": + j += 1 + break + else: + j += 1 + return {"type": _TOKEN_QUOTED, "end": j} + + +def _scan_bracket_quoted_identifier(sql: str, i: int) -> _Token | None: + if sql[i] != "[": + return None + j = i + 1 + while j < len(sql): + if j + 1 < len(sql) and sql[j] == "]" and sql[j + 1] == "]": + j += 2 + elif sql[j] == "]": + j += 1 + break + else: + j += 1 + return {"type": _TOKEN_QUOTED, "end": j} + + +def _scan_token_ansi(sql: str, i: int) -> _Token: + return ( + _scan_single_line_comment(sql, i) + or _scan_multi_line_comment(sql, i) + or _scan_single_quoted_string(sql, i) + or _scan_double_quoted_string(sql, i) + or _plain_token(i) + ) + + +def _scan_token_postgres(sql: str, i: int) -> _Token: + return ( + _scan_single_line_comment(sql, i) + or _scan_nested_multi_line_comment(sql, i) + or _scan_single_quoted_string(sql, i) + or _scan_double_quoted_string(sql, i) + or _scan_dollar_quoted_block(sql, i) + or _plain_token(i) + ) + + +def _scan_token_mysql(sql: str, i: int) -> _Token: + return ( + _scan_single_line_comment(sql, i) + or _scan_multi_line_comment_mysql(sql, i) + or _scan_single_quoted_string(sql, i) + or _scan_double_quoted_string(sql, i) + or _scan_backtick_quoted_identifier(sql, i) + or _plain_token(i) + ) + + +def _scan_token_sqlite(sql: str, i: int) -> _Token: + return ( + _scan_single_line_comment(sql, i) + or _scan_multi_line_comment(sql, i) + or _scan_single_quoted_string(sql, i) + or _scan_double_quoted_string(sql, i) + or _scan_backtick_quoted_identifier(sql, i) + or _scan_bracket_quoted_identifier(sql, i) + or _plain_token(i) + ) + + +def _scan_token_sqlserver(sql: str, i: int) -> _Token: + return ( + _scan_single_line_comment(sql, i) + or _scan_multi_line_comment(sql, i) + or _scan_single_quoted_string(sql, i) + or _scan_double_quoted_string(sql, i) + or _scan_bracket_quoted_identifier(sql, i) + or _plain_token(i) + ) + + +_DIALECT_SCANNERS: dict[ConnectorType, Callable[[str, int], _Token]] = { + "postgres": _scan_token_postgres, + "mysql": _scan_token_mysql, + "mariadb": _scan_token_mysql, + "sqlite": _scan_token_sqlite, + "sqlserver": _scan_token_sqlserver, +} + + +def _get_scanner(dialect: ConnectorType | None) -> Callable[[str, int], _Token]: + if dialect and dialect in _DIALECT_SCANNERS: + return _DIALECT_SCANNERS[dialect] + return _scan_token_ansi + + +def strip_comments_and_strings(sql: str, dialect: ConnectorType | None = None) -> str: + """Replace comments, string literals, and dialect-specific quoted blocks with a single space each.""" + scan_token = _get_scanner(dialect) + parts: list[str] = [] + plain_start = -1 + i = 0 + n = len(sql) + while i < n: + token = scan_token(sql, i) + if token["type"] == _TOKEN_PLAIN: + if plain_start == -1: + plain_start = i + else: + if plain_start != -1: + parts.append(sql[plain_start:i]) + plain_start = -1 + parts.append(" ") + i = token["end"] + if plain_start != -1: + parts.append(sql[plain_start:]) + return "".join(parts) + + +def split_sql_statements(sql: str, dialect: ConnectorType | None = None) -> list[str]: + """Split SQL into individual statements, handling semicolons inside quoted contexts.""" + scan_token = _get_scanner(dialect) + statements: list[str] = [] + stmt_start = 0 + i = 0 + n = len(sql) + while i < n: + if sql[i] == ";": + trimmed = sql[stmt_start:i].strip() + if trimmed: + statements.append(trimmed) + stmt_start = i + 1 + i += 1 + continue + token = scan_token(sql, i) + i = token["end"] + trimmed = sql[stmt_start:].strip() + if trimmed: + statements.append(trimmed) + return statements diff --git a/backend/db/dbhub_tools.py b/backend/db/dbhub_tools.py new file mode 100644 index 0000000..1499041 --- /dev/null +++ b/backend/db/dbhub_tools.py @@ -0,0 +1,1252 @@ +""" +与 DBHub 对齐的数据库工具:search_objects(元数据探索)、execute_sql(SQL 执行,对齐 execute-sql.ts)、 +execute_sql_all(同 execute_sql 但不截断行数)、execute_sql_count_only(仅统计行数,不返回明细)。 +Text2SQL Validator 使用 ``probe_sql_execution_status`` / ``probe_sql_execution_status_ex`` 做库上探针。 + +- 模块级 ``execute_sql`` / ``execute_sql_all`` / ``execute_sql_count_only`` / ``search_objects``:便捷入口,委托给 ``default_dbhub_tools``。 +- ``DbHubTools``:可实例化的工具类,方法语义与上述模块函数相同。 +- ``default_dbhub_tools``:默认单例实例,与模块级函数共用。 +""" + +from __future__ import annotations + +import json +import logging +import os +import re +from decimal import Decimal +from typing import Any, Literal + +from sqlalchemy import inspect, text +from sqlalchemy.engine import Engine +from sqlalchemy.exc import SQLAlchemyError + +from db.dbhub_allowed_keywords import allowed_keywords_list, is_read_only_sql +from db.dbhub_sql_parser import ( + ConnectorType, + _get_scanner, + _TOKEN_PLAIN, + split_sql_statements, + strip_comments_and_strings, +) +from db.engine import get_engine + +log = logging.getLogger(__name__) + + +def _get_optional_str(key: str) -> str | None: + """从环境变量读取(与根目录 .env / load_dotenv 一致);键名大小写不敏感。""" + v = os.getenv(key) + if v is not None and str(v).strip(): + return str(v).strip() + v2 = os.getenv(key.upper()) + if v2 is not None and str(v2).strip(): + return str(v2).strip() + return None + + +ObjectType = Literal["schema", "table", "column", "procedure", "function", "index"] +DetailLevel = Literal["names", "summary", "full"] + +_SKIP_SCHEMAS = frozenset( + { + "information_schema", + "pg_catalog", + "pg_toast", + "mysql", + "performance_schema", + "sys", + }, +) + + +def _like_pattern_to_regex(pattern: str) -> re.Pattern[str]: + """SQL LIKE → regex,与 DBHub likePatternToRegex 一致(% / _,其余字符 re.escape)。""" + parts: list[str] = [] + for c in pattern: + if c == "%": + parts.append(".*") + elif c == "_": + parts.append(".") + else: + parts.append(re.escape(c)) + return re.compile("^" + "".join(parts) + "$", re.IGNORECASE) + + +def _bare_table_name(name: str) -> str: + return name.strip().rsplit(".", 1)[-1].strip() + + +def _normalize_user_object_name(name: str) -> str: + """去掉首尾空白;若整段为 [Name] 形式则去括号(单层)。""" + t = name.strip() + if len(t) >= 2 and t[0] == "[" and t[-1] == "]": + return t[1:-1].replace("]]", "]") + return t + + +def _resolve_table_or_view_bare_name(insp: Any, schema: str | None, user_table: str) -> str: + """ + 将用户输入的表/视图名解析为与 SQLAlchemy inspect 一致、可在 get_columns/get_indexes 中使用的名称。 + + SQL Server 等库对标识符大小写不敏感,但反射时传入与系统目录不一致的大小写可能导致 get_columns 返回空; + 视图不在 get_table_names 中,仅传表名会漏匹配。本函数在指定 schema 下对表名与视图名做不区分大小写对齐。 + """ + want = _normalize_user_object_name(user_table) + if not want: + return user_table.strip() + key = want.lower() + for lister in (insp.get_table_names, insp.get_view_names): + try: + for raw in lister(schema=schema): + bare = _bare_table_name(str(raw)) + if bare.lower() == key: + return bare + except Exception: # noqa: BLE001 + continue + return want + + +def _filter_schemas(raw: list[str]) -> list[str]: + return [s for s in raw if s and s.lower() not in _SKIP_SCHEMAS] + + +def _qualified_table_sql(engine: Any, schema: str | None, bare_table: str) -> str: + prep = engine.dialect.identifier_preparer + tq = prep.quote(bare_table) + if schema: + return f"{prep.quote_schema(schema)}.{tq}" + return tq + + +def _table_row_count(engine: Any, schema: str | None, bare_table: str) -> int | None: + q = _qualified_table_sql(engine, schema, bare_table) + try: + with engine.connect() as conn: + r = conn.execute(text(f"SELECT COUNT(*) AS c FROM {q}")) + row = r.fetchone() + if row is None: + return None + return int(row[0]) + except Exception: # noqa: BLE001 + return None + + +def _pk_columns(insp: Any, schema: str | None, bare: str) -> tuple[str, ...]: + try: + pk = insp.get_pk_constraint(bare, schema=schema) + cols = pk.get("constrained_columns") or [] + return tuple(str(c) for c in cols) + except Exception: # noqa: BLE001 + return () + + +def _index_dicts(insp: Any, schema: str | None, bare: str) -> list[dict[str, Any]]: + pk_cols = _pk_columns(insp, schema, bare) + try: + raw_idx = list(insp.get_indexes(bare, schema=schema)) + except Exception: # noqa: BLE001 + return [] + out: list[dict[str, Any]] = [] + for idx in raw_idx: + cols = list(idx.get("column_names") or []) + unique = bool(idx.get("unique")) + primary = bool(pk_cols) and tuple(cols) == pk_cols + out.append( + { + "name": str(idx.get("name") or ""), + "columns": cols, + "unique": unique, + "primary": primary, + }, + ) + return out + + +def _table_comment(insp: Any, schema: str | None, bare: str) -> str | None: + """获取表注释,若不支持或无注释则返回 None。""" + try: + tc = insp.get_table_comment(bare, schema=schema) + if isinstance(tc, dict): + text = (tc.get("text") or "").strip() + return text if text else None + except (NotImplementedError, AttributeError, TypeError): + pass + except Exception: # noqa: BLE001 + pass + return None + + +def _column_dicts(insp: Any, schema: str | None, bare: str) -> list[dict[str, Any]]: + try: + cols = list(insp.get_columns(bare, schema=schema)) + except Exception: # noqa: BLE001 + return [] + out: list[dict[str, Any]] = [] + for c in cols: + if not isinstance(c, dict): + continue + fname = str(c.get("name") or "").strip() + if not fname: + continue + ftype = c.get("type") + dtype = str(ftype).strip() if ftype is not None else "" + nullable = c.get("nullable") + null_b = nullable is True or (isinstance(nullable, str) and nullable.upper() == "YES") + entry: dict[str, Any] = { + "column_name": fname, + "data_type": dtype, + "is_nullable": "YES" if null_b else "NO", + "column_default": c.get("default"), + } + # 添加列描述(如果存在) + comment = c.get("comment") + if comment is not None: + comment_str = str(comment).strip() + if comment_str: + entry["description"] = comment_str + out.append(entry) + return out + + +def _fetch_routines( + engine: Any, + dialect: str, + *, + schema_filter: str | None, + routine_sql_type: str | None, +) -> list[tuple[str, str, str, str | None, str | None, str | None]]: + """ + 返回 (schema, name, routine_type, definition_or_none, language_or_none, return_type_or_none)。 + routine_sql_type: 'PROCEDURE' | 'FUNCTION' | None(两者都要) + + 注意:INFORMATION_SCHEMA.ROUTINES 在不同数据库中包含的字段不同: + - SQL Server: 有 EXTERNAL_LANGUAGE(但通常为 NULL),无 RETURN_TYPE + - MySQL/MariaDB: 有 EXTERNAL_LANGUAGE、DTD_IDENTIFIER(返回类型) + - PostgreSQL: INFORMATION_SCHEMA 中存储过程支持有限,通常需要通过 pg_proc 查询 + """ + rows: list[tuple[str, str, str, str | None, str | None, str | None]] = [] + if dialect not in ("mssql", "postgresql", "mysql", "mariadb"): + return rows + + # 基础查询:所有数据库都支持的字段 + sql = """ + SELECT ROUTINE_SCHEMA, ROUTINE_NAME, ROUTINE_TYPE, ROUTINE_DEFINITION + FROM INFORMATION_SCHEMA.ROUTINES + WHERE 1=1 + """ + params: dict[str, Any] = {} + if schema_filter: + sql += " AND ROUTINE_SCHEMA = :sch" + params["sch"] = schema_filter + if routine_sql_type: + sql += " AND ROUTINE_TYPE = :rt" + params["rt"] = routine_sql_type + + with engine.connect() as conn: + for r in conn.execute(text(sql), params): + defn = r[3] + rows.append( + ( + str(r[0]), + str(r[1]), + str(r[2]), + None if defn is None else str(defn), + None, # language - INFORMATION_SCHEMA 中通常不可用 + None, # return_type - 需要额外查询 + ), + ) + + # 对于 MySQL/MariaDB,尝试获取更多信息 + if dialect in ("mysql", "mariadb") and rows: + try: + extra_sql = """ + SELECT ROUTINE_SCHEMA, ROUTINE_NAME, EXTERNAL_LANGUAGE, DTD_IDENTIFIER + FROM INFORMATION_SCHEMA.ROUTINES + WHERE 1=1 + """ + extra_params: dict[str, Any] = {} + if schema_filter: + extra_sql += " AND ROUTINE_SCHEMA = :sch" + extra_params["sch"] = schema_filter + if routine_sql_type: + extra_sql += " AND ROUTINE_TYPE = :rt" + extra_params["rt"] = routine_sql_type + + extra_map: dict[tuple[str, str], tuple[str | None, str | None]] = {} + for r in conn.execute(text(extra_sql), extra_params): + key = (str(r[0]), str(r[1])) + lang = str(r[2]).strip() if r[2] else None + ret_type = str(r[3]).strip() if r[3] else None + extra_map[key] = (lang, ret_type) + + # 更新已有行 + updated_rows: list[tuple[str, str, str, str | None, str | None, str | None]] = [] + for sch, name, rtype, defn, _, _ in rows: + key = (sch, name) + if key in extra_map: + lang, ret_type = extra_map[key] + updated_rows.append((sch, name, rtype, defn, lang, ret_type)) + else: + updated_rows.append((sch, name, rtype, defn, None, None)) + rows = updated_rows + except Exception: # noqa: BLE001 + # 如果额外查询失败,保持原有数据 + pass + + return rows + + +def sqlalchemy_dialect_to_connector(dialect_name: str) -> ConnectorType: + """ + 将 SQLAlchemy dialect.name 映射到 DBHub ConnectorType。 + 未知方言按 postgres 规则做只读校验(与 DBHub 移植约定一致)。 + """ + m: dict[str, ConnectorType] = { + "postgresql": "postgres", + "mysql": "mysql", + "mariadb": "mariadb", + "sqlite": "sqlite", + "mssql": "sqlserver", + } + return m.get(dialect_name, "postgres") + + +_COUNT_WRAP_FIRST_WORDS = frozenset({"select", "with", "explain"}) + +_AGGREGATE_SCALAR_PREFIX = re.compile( + r"(?is)^(?:distinct\s+)?(?:count|sum|avg|min|max|stdev|stdevp|string_agg|group_concat|" + r"variance|var_pop|var_samp|stddev|stddev_pop|stddev_samp)\s*\(", +) + + +def _parse_top_level_select_list(sql: str, connector: ConnectorType) -> tuple[int, str] | None: + """ + 定位最外层 ``SELECT`` 与深度 0 上首个 ``FROM`` 之间的选择列表,返回 (列数, 列表原文断片)。 + ``WITH`` 开头、``SELECT *``、或无法配对到 ``FROM`` 时返回 None。 + """ + if _first_sql_keyword(sql, connector) == "with": + return None + scan = _get_scanner(connector) + n = len(sql) + i = 0 + list_paren = 0 + col_commas = 0 + state: Literal["LEADING", "IN_LIST"] = "LEADING" + list_start = 0 + while i < n: + tok = scan(sql, i) + if tok["type"] != _TOKEN_PLAIN: + i = tok["end"] + continue + if state == "LEADING": + m = re.match(r"(?is)select\s+", sql[i:n]) + if m: + state = "IN_LIST" + list_start = i + m.end() + i += m.end() + continue + i += 1 + continue + if list_paren == 0: + mf = re.match(r"(?is)\bfrom\b", sql[i:n]) + if mf: + frag = sql[list_start:i] + if re.match(r"(?is)^\s*\*\s*$", frag.strip()): + return None + return (col_commas + 1, frag) + c = sql[i] + if c == "(": + list_paren += 1 + elif c == ")": + list_paren = max(0, list_paren - 1) + elif c == "," and list_paren == 0: + col_commas += 1 + i += 1 + return None + + +def _has_top_level_group_by(sql: str, connector: ConnectorType) -> bool: + scan = _get_scanner(connector) + n = len(sql) + i = 0 + depth = 0 + while i < n: + tok = scan(sql, i) + if tok["type"] != _TOKEN_PLAIN: + i = tok["end"] + continue + if depth == 0: + m = re.match(r"(?is)\bgroup\s+by\b", sql[i:n]) + if m: + return True + c = sql[i] + if c == "(": + depth += 1 + elif c == ")": + depth = max(0, depth - 1) + i += 1 + return False + + +def _is_aggregate_scalar_fast_path(inner: str, connector: ConnectorType) -> bool: + """单列、无 GROUP BY、且选择列表以常见聚合函数开头时,结果集为标量,宜直接执行内层而非 COUNT(*) 包裹。""" + parsed = _parse_top_level_select_list(inner, connector) + if parsed is None: + return False + ncols, frag = parsed + if ncols != 1: + return False + if _has_top_level_group_by(inner, connector): + return False + fs = frag.strip() + if not fs or re.match(r"(?is)^\*\s*$", fs): + return False + return bool(_AGGREGATE_SCALAR_PREFIX.match(fs)) + + +def _strip_outer_trailing_order_by(sql: str, connector: ConnectorType) -> str: + """ + SQL Server / MySQL / MariaDB:派生表内 ``ORDER BY`` 若无 TOP/OFFSET/LIMIT 会报语法错误。 + 在 COUNT 子查询包装前去掉最外层(括号深度为 0)最后一次出现的 ``ORDER BY`` 及其后内容; + 不改变行数统计语义。注释与字符串内的括号/关键字不参与解析。 + """ + if connector not in ("sqlserver", "mysql", "mariadb"): + return sql + scan = _get_scanner(connector) + n = len(sql) + i = 0 + depth = 0 + last_order_by_start = -1 + while i < n: + tok = scan(sql, i) + if tok["type"] != _TOKEN_PLAIN: + i = tok["end"] + continue + # 方言扫描器对「普通字符」常一次只前进一个字符;ORDER BY 必须从当前位置看到串尾才匹配得到 + if depth == 0: + m = re.match(r"(?is)order\s+by\b", sql[i:n]) + if m: + last_order_by_start = i + i += m.end() + continue + c = sql[i] + if c == "(": + depth += 1 + elif c == ")": + depth = max(0, depth - 1) + i += 1 + if last_order_by_start < 0: + return sql + return sql[:last_order_by_start].rstrip().rstrip(";").rstrip() + + +def _first_sql_keyword(sql: str, connector: ConnectorType) -> str: + """剥离注释与字符串字面量后,取首个非空白词(小写)。""" + cleaned = strip_comments_and_strings(sql, connector) + cleaned = cleaned.strip().lower() + m = re.search(r"\S+", cleaned) + return m.group(0) if m else "" + + +def _sql_wrapped_count( + inner_sql: str, + connector: ConnectorType, + ncols: int | None, +) -> str: + inner = inner_sql.strip() + if not inner: + raise ValueError("SQL 不能为空") + inner = _strip_outer_trailing_order_by(inner, connector) + core = ( + "SELECT COUNT(*) AS __dbhub_cnt FROM (\n" + f"{inner}\n" + ") AS __dbhub_subq" + ) + if ncols is not None and connector in ("sqlserver", "mysql", "mariadb"): + names = ", ".join(f"__dbhub_c{k}" for k in range(ncols)) + return f"{core} ({names})" + return core + + +def _execute_sql_statements_count_only( + engine: Engine, + statements: list[str], + connector: ConnectorType, +) -> int: + """ + 顺序执行多条语句;对最后一条:优先用 COUNT 子查询只取标量(select/with/explain), + 否则单次执行后在 Python 侧逐行计数(不组装 rows 明细)。 + """ + with engine.connect() as conn: + for i, stmt in enumerate(statements): + s = stmt.strip() + if i < len(statements) - 1: + r = conn.execute(text(s)) + if r.returns_rows: + r.fetchall() + continue + if not s: + raise ValueError("SQL 为空") + first = _first_sql_keyword(s, connector) + if first in _COUNT_WRAP_FIRST_WORDS: + inner = _strip_outer_trailing_order_by(s.strip(), connector) + if _is_aggregate_scalar_fast_path(inner, connector): + r = conn.execute(text(inner)) + row = r.fetchone() + if row is None or row[0] is None: + return 0 + cell = row[0] + if isinstance(cell, Decimal): + return int(cell) + return int(cell) + parsed = _parse_top_level_select_list(inner, connector) + if parsed is None: + r = conn.execute(text(inner)) + if not r.returns_rows: + return 0 + n = 0 + for _ in r: + n += 1 + return n + ncols, _frag = parsed + wrapped = _sql_wrapped_count(inner, connector, ncols) + r = conn.execute(text(wrapped)) + row = r.fetchone() + if row is None: + return 0 + return int(row[0]) + r = conn.execute(text(s)) + if not r.returns_rows: + return 0 + n = 0 + for _ in r: + n += 1 + return n + + +def _serialize_sql_cell(val: object) -> object: + if isinstance(val, Decimal): + return str(val) + if isinstance(val, (bytes, bytearray)): + return bytes(val).decode("utf-8", errors="replace") + if hasattr(val, "isoformat"): + try: + return val.isoformat() + except Exception: # noqa: BLE001 + return val + return val + + +def _execute_sql_statements( + engine: Engine, + statements: list[str], + *, + max_rows: int | None, +) -> tuple[list[str], list[dict[str, object]], bool]: + """顺序执行多条语句,仅返回最后一条有结果集语句的列与行。``max_rows`` 为 None 时对最后一条 ``fetchall``,不截断。""" + last_cols: list[str] = [] + last_rows: list[dict[str, object]] = [] + truncated = False + with engine.connect() as conn: + result = None + for i, stmt in enumerate(statements): + s = stmt.strip() + result = conn.execute(text(s)) + if i < len(statements) - 1: + if result.returns_rows: + result.fetchall() + continue + if not result.returns_rows: + last_cols = [] + last_rows = [] + truncated = False + break + last_cols = list(result.keys()) + if max_rows is None: + rows_data = result.fetchall() + truncated = False + else: + mr = max(1, int(max_rows)) + fetched = result.fetchmany(mr + 1) + truncated = len(fetched) > mr + rows_data = fetched[:mr] + last_rows = [] + for row in rows_data: + row_map: dict[str, object] = {} + for j, col in enumerate(last_cols): + row_map[col] = _serialize_sql_cell(row[j]) + last_rows.append(row_map) + return last_cols, last_rows, truncated + + +def _execute_sql( + sql: str, + *, + readonly: bool = True, + max_rows: int | None = None, +) -> dict[str, object]: + """ + 在已配置的业务库上执行 SQL(可多语句,分号分隔)。 + :param sql: 待执行 SQL 字符串。多条语句用分号分隔; + :param readonly: 是否启用只读校验,默认 True。为 True 时,任一条语句不符合 G3SB 允许的首关键字 + (如 select、with、explain 等,随方言而异)或含变更类关键字则抛出 ValueError。 + 为 False 时不做上述校验,可执行 DML/DDL(风险自负,勿用于不可信输入)。 + :param max_rows: 对「最后一条返回结果集的语句」最多取多少行;None 时使用配置项 sql_max_rows, + 并在实现侧夹紧到 1~10000。前面的语句若产生结果集会被消费掉但不返回。 + :return: 字典,含 rows、count、source_id(固定 ``default``)、columns、truncated。 + 最后一条语句无结果集时 rows、columns 为空,count 为 0。未配置 database_url、SQL 为空或执行失败时 + 抛出 ValueError(或包装后的底层异常信息)。 + """ + raw = (sql or "").strip() + if not raw: + raise ValueError("SQL 不能为空") + + url = (_get_optional_str("database_url") or "").strip() + if not url: + raise ValueError("database_url 未配置") + + engine = get_engine() + connector = sqlalchemy_dialect_to_connector(engine.dialect.name) + statements = split_sql_statements(raw, connector) + + if not statements: + raise ValueError("SQL 为空") + + if readonly: + for st in statements: + if not is_read_only_sql(st, connector): + kw = ", ".join(allowed_keywords_list(connector)) or "none" + raise ValueError( + f"Read-only mode is enabled. Only the following SQL operations are allowed: {kw}", + ) + + mr = max_rows if max_rows is not None else int(_get_optional_str("sql_max_rows") or 10000) + mr = max(1, min(10000, int(mr))) + + log.info(f"_execute_sql 执行语句数={len(statements)} readonly={readonly} max_rows={mr}") + + try: + cols, rows, truncated = _execute_sql_statements(engine, statements, max_rows=mr) + except Exception as e: # noqa: BLE001 + log.error(f"_execute_sql 执行失败: {e}") + raise ValueError(str(e)) from e + + count = len(rows) + return { + "rows": rows, + "count": count, + "source_id": "default", + "columns": cols, + "truncated": truncated, + } + + +def _execute_sql_all( + sql: str, + *, + readonly: bool = True, +) -> dict[str, object]: + """ + 与 ``_execute_sql`` 相同校验与返回结构,但对最后一条有结果集的语句不做 ``max_rows`` 截断,全部 ``fetchall``。 + 超大结果集会占用大量内存,仅用于可信 SQL / 已自行 LIMIT 的场景。 + """ + raw = (sql or "").strip() + if not raw: + raise ValueError("SQL 不能为空") + + url = (_get_optional_str("database_url") or "").strip() + if not url: + raise ValueError("database_url 未配置") + + engine = get_engine() + connector = sqlalchemy_dialect_to_connector(engine.dialect.name) + statements = split_sql_statements(raw, connector) + + if not statements: + raise ValueError("SQL 为空") + + if readonly: + for st in statements: + if not is_read_only_sql(st, connector): + kw = ", ".join(allowed_keywords_list(connector)) or "none" + raise ValueError( + f"Read-only mode is enabled. Only the following SQL operations are allowed: {kw}", + ) + + log.info(f"_execute_sql_all 执行语句数={len(statements)} readonly={readonly}") + + try: + cols, rows, truncated = _execute_sql_statements(engine, statements, max_rows=None) + except Exception as e: # noqa: BLE001 + log.error(f"_execute_sql_all 执行失败: {e}") + raise ValueError(str(e)) from e + + count = len(rows) + return { + "rows": rows, + "count": count, + "source_id": "default", + "columns": cols, + "truncated": truncated, + } + + +def _execute_sql_count_only( + sql: str, + *, + readonly: bool = True, +) -> dict[str, int]: + """ + 在已配置的业务库上执行 SQL,仅返回「最后一条有结果集语句」的行数,不返回单元格明细。 + + 对以 select / with / explain 开头(注释剥离后)的最后一条语句:若已为单列常见聚合(如 ``count(1)``)且无 ``GROUP BY``, + 则直接执行该语句并取标量,避免 ``COUNT(*)`` 外包一层只得到 1 行。否则在库侧用 ``SELECT COUNT(*) FROM ( ... )``; + 对 SQL Server / MySQL / MariaDB 会为派生表补上 ``(__dbhub_c0, ...)`` 列名(避免匿名列 8155 等),并在包装前去掉 + 最外层无意义的 ``ORDER BY``。无法解析选择列表时(如 ``WITH``、``SELECT *``)改为拉全结果在 Python 侧逐行计数。 + 其它只读语句(如部分 show/pragma)仍单次执行后逐行计数,不组装为 dict。 + + 多条语句用分号分隔时,前面的语句照常执行并消费结果集;仅对最后一条给出计数。 + 最后一条无结果集时 count 为 0。进入执行阶段后若任一步失败(含语法、权限、连接中断等),返回 ``{"count": -1}``; + SQL 为空、未配置 ``database_url``、只读校验不通过仍抛 ``ValueError``。 + """ + raw = (sql or "").strip() + if not raw: + raise ValueError("SQL 不能为空") + + url = (_get_optional_str("database_url") or "").strip() + if not url: + raise ValueError("database_url 未配置") + + engine = get_engine() + connector = sqlalchemy_dialect_to_connector(engine.dialect.name) + statements = split_sql_statements(raw, connector) + + if not statements: + raise ValueError("SQL 为空") + + if readonly: + for st in statements: + if not is_read_only_sql(st, connector): + kw = ", ".join(allowed_keywords_list(connector)) or "none" + raise ValueError( + f"Read-only mode is enabled. Only the following SQL operations are allowed: {kw}", + ) + + log.info(f"_execute_sql_count_only 执行语句数={len(statements)} readonly={readonly}") + + try: + n = _execute_sql_statements_count_only(engine, statements, connector) + except Exception as e: # noqa: BLE001 + log.error(f"_execute_sql_count_only 执行失败: {e}") + return {"count": -1} + + return {"count": n} + + +def probe_sql_execution_status_ex( + sql: str, *, max_rows: int = 1 +) -> tuple[int | None, str | None]: + """ + 在已配置的业务库上对 SQL 做只读试执行,返回状态码与失败时的错误摘要。 + + Returns: + 二元组 ``(status, error_message)``: + + - ``(None, None)``:未配置 ``database_url``,跳过探针。 + - ``(1, None)``:执行成功,且**至少返回一行数据**(与「有列无行」的 SELECT 区分)。 + - ``(0, None)``:执行成功,但**数据行数为 0**(可有列名而无行,或无非空结果集)。 + - ``(-1, msg)``:执行失败;``msg`` 为异常信息摘要(便于生成阶段重试)。 + """ + url = (_get_optional_str("database_url") or "").strip() + if not url: + return None, None + try: + out = _execute_sql(sql, readonly=True, max_rows=max_rows) + except Exception as e: # noqa: BLE001 + log.warning(f"probe_sql_execution_status 执行失败: {e}") + detail = str(e).strip() or "unknown error" + return -1, detail + rows = list(out.get("rows") or []) + if len(rows) > 0: + return 1, None + return 0, None + + +def probe_sql_execution_status(sql: str, *, max_rows: int = 1) -> int | None: + """ + 在已配置的业务库上对 SQL 做只读试执行,返回紧凑状态码(用于 Validator 探针)。 + + 语义与 :func:`probe_sql_execution_status_ex` 的首个返回值一致;失败细节请用 ``_ex``。 + """ + status, _ = probe_sql_execution_status_ex(sql, max_rows=max_rows) + return status + + +def _search_objects( + object_type: ObjectType, + pattern: str = "%", + schema: str | None = None, + table: str | None = None, + detail_level: DetailLevel = "names", + limit: int = 100, +) -> dict[str, Any]: + """ + 按对象类型在已配置的业务库中探索 schema、表、列、索引、存储过程/函数等元数据(对齐 DBHub search_objects)。 + 会过滤系统 schema(如information_schema、sys、pg_catalog 等),再按 object_type 与 pattern 做 SQL LIKE 风格匹配; + :param object_type: 要探索的对象类别。schema-模式名;table-数据表;column-列(可与 table、schema 联用 + 限定单表);index-索引(同上);procedure-存储过程;function-函数(标量/表值等,视库而定)。 + :param pattern: SQL LIKE 模式,默认 "%" 表示不过滤名称。"%" 匹配任意长度子串,"_" 匹配单个字符;对表名、 + 列名、索引名、例程名等做大小写不敏感匹配。 + :param schema: 限定在某个 schema 内查找;None 表示在多个非系统 schema 上依次查找。若给出具体名称, + 必须是库中已存在的 schema,否则抛 ValueError。使用参数 table 时必须同时指定 schema。 + :param table: 仅在 object_type 为 column 或 index 时允许传入,与 schema 共同限定「只查这一张表或视图」上的列 + 或索引;用于其它 object_type 时会抛 ValueError。名称会在该 schema 下与反射得到的表名、视图名做 + 不区分大小写匹配(并支持 [Name] 写法),以兼容 SQL Server 等对目录大小写不敏感但 API 需真实写法的情况。 + :param detail_level: 返回粒度。names-仅对象名及定位字段(如 schema、table);summary-增加简要元数据 + (如表的 column_count、row_count,列的类型、可空等);full-表级返回列列表、索引列表等完整结构。 + :param limit: 最多返回的结果条数,默认 100,有效范围 1~1000(传入值会被夹紧到该区间)。 + :return: 包含 object_type、pattern、schema、table、detail_level、count、results、truncated 的字典。 + 未配置 database_url、连接失败或内省失败时抛出 ValueError(或其它底层异常)。 + """ + if table and not schema: + raise ValueError("The 'table' parameter requires 'schema' to be specified") + if table and object_type not in ("column", "index"): + raise ValueError( + f"The 'table' parameter only applies to object_type 'column' or 'index', not '{object_type}'", + ) + + lim = max(1, min(1000, int(limit))) + pat = pattern if pattern is not None else "%" + rx = _like_pattern_to_regex(pat) + + url = (_get_optional_str("database_url") or "").strip() + if not url: + raise ValueError("database_url 未配置") + + try: + engine = get_engine() + except ValueError as e: + raise ValueError(str(e)) from e + + try: + insp = inspect(engine) + except SQLAlchemyError as e: + log.error(f"_search_objects inspect 失败: {e}") + raise ValueError(f"数据库内省失败: {e}") from e + + try: + all_schema_names = list(insp.get_schema_names()) + except Exception: # noqa: BLE001 + all_schema_names = [] + filtered_schemas = _filter_schemas(all_schema_names) + + if schema: + if schema not in all_schema_names: + avail = ", ".join(all_schema_names) or "(none)" + raise ValueError(f"Schema '{schema}' does not exist. Available schemas: {avail}") + schemas_to_search = [schema] + else: + schemas_to_search = filtered_schemas if filtered_schemas else [None] + + dialect = engine.dialect.name + results: list[Any] = [] + + if object_type == "schema": + base_schemas = filtered_schemas if filtered_schemas else all_schema_names + candidates = [s for s in base_schemas if rx.match(s)] + for schema_name in candidates: + if len(results) >= lim: + break + if detail_level == "names": + results.append({"name": schema_name}) + else: + try: + tbls = list(insp.get_table_names(schema=schema_name)) + except Exception: # noqa: BLE001 + tbls = [] + results.append({"name": schema_name, "table_count": len(tbls)}) + + elif object_type == "table": + for schema_name in schemas_to_search: + if len(results) >= lim: + break + try: + tbls = list(insp.get_table_names(schema=schema_name)) + except Exception: # noqa: BLE001 + continue + for table_name in tbls: + if len(results) >= lim: + break + bare = _bare_table_name(str(table_name)) + if not rx.match(bare): + continue + if detail_level == "names": + results.append({"name": bare, "schema": schema_name}) + elif detail_level == "summary": + cols = _column_dicts(insp, schema_name, bare) + rc = _table_row_count(engine, schema_name, bare) + comment = _table_comment(insp, schema_name, bare) + result_dict: dict[str, Any] = { + "name": bare, + "schema": schema_name, + "column_count": len(cols), + "row_count": rc, + } + if comment: + result_dict["comment"] = comment + results.append(result_dict) + else: + cols = _column_dicts(insp, schema_name, bare) + idxs = _index_dicts(insp, schema_name, bare) + rc = _table_row_count(engine, schema_name, bare) + comment = _table_comment(insp, schema_name, bare) + result_dict_full: dict[str, Any] = { + "name": bare, + "schema": schema_name, + "column_count": len(cols), + "row_count": rc, + "columns": [ + { + "name": c["column_name"], + "type": c["data_type"], + "nullable": c["is_nullable"] == "YES", + "default": c["column_default"], + **({"description": c["description"]} if "description" in c else {}), + } + for c in cols + ], + "indexes": idxs, + } + if comment: + result_dict_full["comment"] = comment + results.append(result_dict_full) + + elif object_type in ("column", "index"): + for schema_name in schemas_to_search: + if len(results) >= lim: + break + try: + if table: + tables_to_search = [_resolve_table_or_view_bare_name(insp, schema_name, table)] + else: + tables_to_search = [_bare_table_name(str(t)) for t in insp.get_table_names(schema=schema_name)] + except Exception: # noqa: BLE001 + continue + for table_name in tables_to_search: + if len(results) >= lim: + break + bare = _bare_table_name(str(table_name)) + if object_type == "column": + cols = _column_dicts(insp, schema_name, bare) + for c in cols: + if len(results) >= lim: + break + if not rx.match(c["column_name"]): + continue + if detail_level == "names": + results.append({"name": c["column_name"], "table": bare, "schema": schema_name}) + else: + result_dict_col: dict[str, Any] = { + "name": c["column_name"], + "table": bare, + "schema": schema_name, + "type": c["data_type"], + "nullable": c["is_nullable"] == "YES", + "default": c["column_default"], + } + if "description" in c: + result_dict_col["description"] = c["description"] + results.append(result_dict_col) + else: + idxs = _index_dicts(insp, schema_name, bare) + for idx in idxs: + if len(results) >= lim: + break + iname = str(idx.get("name") or "") + if not rx.match(iname): + continue + if detail_level == "names": + results.append({"name": iname, "table": bare, "schema": schema_name}) + else: + results.append( + { + "name": iname, + "table": bare, + "schema": schema_name, + "columns": idx.get("columns"), + "unique": idx.get("unique"), + "primary": idx.get("primary"), + }, + ) + + elif object_type in ("procedure", "function"): + rt = "PROCEDURE" if object_type == "procedure" else "FUNCTION" + try: + routine_rows = _fetch_routines(engine, dialect, schema_filter=schema, routine_sql_type=rt) + except Exception as e: # noqa: BLE001 + log.warning(f"_search_objects 例程列表失败 dialect={dialect}: {e}") + routine_rows = [] + for sch, name, rtype, defn in routine_rows: + if len(results) >= lim: + break + if not rx.match(name): + continue + if detail_level == "names": + results.append({"name": name, "schema": sch}) + elif detail_level == "summary": + results.append( + { + "name": name, + "schema": sch, + "type": rtype, + "language": None, + "return_type": None, + }, + ) + else: + results.append( + { + "name": name, + "schema": sch, + "type": rtype, + "language": None, + "parameters": None, + "return_type": None, + "definition": defn, + }, + ) + + else: + raise ValueError(f"Unsupported object_type: {object_type}") + + return { + "object_type": object_type, + "pattern": pat, + "schema": schema, + "table": table, + "detail_level": detail_level, + "count": len(results), + "results": results, + "truncated": len(results) == lim, + } + + +class DbHubTools: + """ + 与 DBHub 对齐的数据库工具封装:SQL 执行与元数据探索。 + + 方法 execute_sql 行为对齐 dbhub execute-sql.ts; + 方法 search_objects 对齐 DBHub search_objects。 + execute_sql_all 为不截断行的全量拉取; + execute_sql_count_only 为仅统计行数; + 实现委托至模块内 ``_execute_sql`` / ``_execute_sql_all`` / ``_execute_sql_count_only`` / ``_search_objects``。 + """ + + def execute_sql( + self, + sql: str, + *, + readonly: bool = True, + max_rows: int | None = None, + ) -> dict[str, object]: + """ + 在已配置的业务库上执行 SQL(可多语句,分号分隔)。 + :param sql: 待执行 SQL 字符串。多条语句用分号分隔; + :param readonly: 是否启用只读校验,默认 True。为 True 时,任一条语句不符合 G3SB 允许的首关键字 + (如 select、with、explain 等,随方言而异)或含变更类关键字则抛出 ValueError + 为 False 时不做上述校验,可执行 DML/DDL(风险自负,勿用于不可信输入)。 + :param max_rows: 对「最后一条返回结果集的语句」最多取多少行;None 时使用配置项 sql_max_rows, + 并在实现侧夹紧到 1~10000。前面的语句若产生结果集会被消费掉但不返回。 + :return: 字典,含 rows(每行一个 dict,列名到值的映射)、count(等于 rows 长度,等同 DBHub rowCount)、 + columns(列名列表)、truncated(是否因超过 max_rows 而截断)。 + 最后一条语句无结果集时 rows、columns 为空,count 为 0。未配置 database_url、SQL 为空或执行失败时 + 抛出 ValueError(或包装后的底层异常信息)。 + """ + return _execute_sql(sql, readonly=readonly, max_rows=max_rows) + + def search_objects( + self, + object_type: ObjectType, + pattern: str = "%", + schema: str | None = None, + table: str | None = None, + detail_level: DetailLevel = "names", + limit: int = 100, + ) -> dict[str, Any]: + """ + 按对象类型在已配置的业务库中探索 schema、表、列、索引、存储过程/函数等元数据(对齐 DBHub search_objects)。 + 会过滤系统 schema(如information_schema、sys、pg_catalog 等),再按 object_type 与 pattern 做 SQL LIKE 风格匹配; + :param object_type: 要探索的对象类别。schema-模式名;table-数据表;column-列(可与 table、schema 联用 + 限定单表);index-索引(同上);procedure-存储过程;function-函数(标量/表值等,视库而定)。 + :param pattern: SQL LIKE 模式,默认 "%" 表示不过滤名称。"%" 匹配任意长度子串,"_" 匹配单个字符;对表名、 + 列名、索引名、例程名等做大小写不敏感匹配。 + :param schema: 限定在某个 schema 内查找;None 表示在多个非系统 schema 上依次查找。若给出具体名称, + 必须是库中已存在的 schema,否则抛 ValueError。使用参数 table 时必须同时指定 schema。 + :param table: 仅在 object_type 为 column 或 index 时允许传入,与 schema 共同限定「只查这一张表或视图」上的列 + 或索引;用于其它 object_type 时会抛 ValueError。名称会在该 schema 下与反射得到的表名、视图名做 + 不区分大小写匹配(并支持 [Name] 写法),以兼容 SQL Server 等对目录大小写不敏感但 API 需真实写法的情况。 + :param detail_level: 返回粒度。names-仅对象名及定位字段(如 schema、table);summary-增加简要元数据 + (如表的 column_count、row_count,列的类型、可空等);full-表级返回列列表、索引列表等完整结构。 + :param limit: 最多返回的结果条数,默认 100,有效范围 1~1000(传入值会被夹紧到该区间)。 + :return: 包含 object_type、pattern、schema、table、detail_level、count、results、truncated 的字典。 + 未配置 database_url、连接失败或内省失败时抛出 ValueError(或其它底层异常)。 + """ + return _search_objects( + object_type, + pattern=pattern, + schema=schema, + table=table, + detail_level=detail_level, + limit=limit, + ) + + def execute_sql_all( + self, + sql: str, + *, + readonly: bool = True, + ) -> dict[str, object]: + """ + 在已配置的业务库上执行 SQL,对最后一条产生结果集的语句 fetchall 全量取行,不做行数上限截断。(4.SQL 执行接口) + :param sql: 待执行 SQL。多条语句用分号分隔;仅返回最后一条有结果集语句的 columns/rows, + 前面语句若产生结果集会被执行并消费掉但不返回。 + :param readonly: 是否启用只读校验,默认 True。为 True 时任一条不符合 G3SB 允许的首关键字(如 select、with、explain 等, + 随方言而异)或含变更类关键字则抛出 ValueError;为 False 时不做该校验(风险自负,勿用于不可信输入)。 + :return: 字典含 rows、count(等于 rows 长度)、columns、truncated。本路径下 truncated 恒为 False。 + 最后一条语句无结果集时 rows、columns 为空,count 为 0。未配置 database_url、SQL 为空、校验失败或执行失败时 + 抛出 ValueError(或带底层信息的包装异常)。 + """ + return _execute_sql_all(sql, readonly=readonly) + + def execute_sql_count_only(self, sql: str) -> dict[str, int]: + """ + 仅返回结果集语句的行数,不返回 rows/columns 明细;(3.SQL 检验接口) + :param sql: 待执行 SQL 字符串。多条语句用分号分隔; + :return: {count: int} + """ + return _execute_sql_count_only(sql, readonly=True) + + +# 默认工具实例;下方模块级 execute_sql / execute_sql_all / execute_sql_count_only / search_objects 均委托至此。 +dbhub_tools = DbHubTools() + + +def search_objects(**kwargs: Any) -> dict[str, Any]: + return dbhub_tools.search_objects(**kwargs) + + +def execute_sql(sql: str, **kwargs: Any) -> dict[str, object]: + return dbhub_tools.execute_sql(sql, **kwargs) + + +def execute_sql_all(sql: str, **kwargs: Any) -> dict[str, object]: + return dbhub_tools.execute_sql_all(sql, **kwargs) + + +def execute_sql_count_only(sql: str, **kwargs: Any) -> dict[str, int]: + return dbhub_tools.execute_sql_count_only(sql, **kwargs) + + +def main() -> None: + """ + 命令行演示:需已配置 database_url。SQL Server 示例默认 schema 为 dbo; + PostgreSQL 可将下面示例中的 schema 改为 public。 + """ + demos: list[tuple[str, dict[str, Any]]] = [ + ( + "1) 列出 schema(detail_level=names)", + {"object_type": "schema", "detail_level": "names", "limit": 5}, + ), + ( + "2) 列出某 schema 下的表名", + {"object_type": "table", "schema": "dbo", "detail_level": "names", "limit": 5}, + ), + ( + "3) 表名 LIKE 模糊匹配(%Account%)", + { + "object_type": "table", + "schema": "dbo", + "pattern": "%Account%", + "detail_level": "names", + "limit": 20, + }, + ), + ( + "4) 表级摘要(列数、行数 COUNT)", + {"object_type": "table", "schema": "dbo", "detail_level": "summary", "limit": 5}, + ), + ( + "6) 表级返回列列表、索引列表等完整结构", + {"object_type": "table", "schema": "dbo", "detail_level": "full", "limit": 5}, + ), + ( + "5) 某表或视图的列(支持大小写/视图;无则改 table 为库中真实对象名)", + { + "object_type": "column", + "schema": "dbo", + "table": "BCAccountAccruedCustodianFee", + "pattern": "%", + "detail_level": "names", + "limit": 5, + }, + ), + ] + + # for title, kwargs in demos: + # print(f"\n=== {title} ===") + # try: + # out = dbhub_tools.search_objects(**kwargs) + # print(json.dumps(out, ensure_ascii=False, indent=2, default=str)) + # except Exception as e: # noqa: BLE001 + # log.warning(f"示例跳过或失败: {title} err={e}") + # print(f"(失败) {e}") + sql = """ + SELECT + b.BrokerID, + m.Name AS BrokerName, + b.CurrencyID, + b.Settled AS OwedToUsAmount, + ABS(b.Settled) AS OutstandingAmount +FROM BCBrokerCash b +INNER JOIN MCBroker m ON b.BrokerID = m.BrokerID +WHERE b.Settled < 0 +ORDER BY ABS(b.Settled) DESC; + + """ + out = dbhub_tools.execute_sql(sql) + print(json.dumps(out, ensure_ascii=False, indent=2, default=str)) + sql = """ + SELECT + b.BrokerID, + m.Name AS BrokerName, + b.CurrencyID, + b.Settled AS OwedToUsAmount, + ABS(b.Settled) AS OutstandingAmount +FROM BCBrokerCash b +INNER JOIN MCBroker m ON b.BrokerID = m.BrokerID +WHERE b.Settled < 0 +ORDER BY ABS(b.Settled) DESC; + + """ + out = dbhub_tools.execute_sql_count_only(sql) + print(json.dumps(out, ensure_ascii=False, indent=2, default=str)) + sql = """ + SELECT + b.BrokerID, + m.Name AS BrokerName, + b.CurrencyID, + b.Settled AS OwedToUsAmount, + ABS(b.Settled) AS OutstandingAmount +FROM BCBrokerCash b +INNER JOIN MCBroker m ON b.BrokerID = m.BrokerID +WHERE b.Settled < 0 +ORDER BY ABS(b.Settled) DESC; + + """ + out = dbhub_tools.execute_sql_all(sql) + print(json.dumps(out, ensure_ascii=False, indent=2, default=str)) + + +if __name__ == "__main__": + main() diff --git a/backend/db/engine.py b/backend/db/engine.py new file mode 100644 index 0000000..defb173 --- /dev/null +++ b/backend/db/engine.py @@ -0,0 +1,46 @@ +""" +SQLAlchemy 引擎:使用环境变量 ``database_url`` / ``DATABASE_URL``(与项目根目录 ``.env`` 一致)。 +""" + +from __future__ import annotations + +import logging +import os +from typing import Optional + +from sqlalchemy import create_engine +from sqlalchemy.engine import Engine + +logger = logging.getLogger(__name__) + +_engine: Engine | None = None + + +def _database_url_from_env() -> str: + for key in ("database_url", "DATABASE_URL"): + v = os.getenv(key) + if v is not None and str(v).strip(): + return str(v).strip() + return "" + + +def get_engine(*, url: Optional[str] = None, reset: bool = False) -> Engine: + """ + 返回默认业务库引擎(单例)。未配置 ``database_url`` 时抛出 ``ValueError``。 + + :param url: 若传入,则忽略单例并为此 URL 新建引擎(便于测试)。 + :param reset: 为 True 时丢弃已缓存的单例,下次再按环境变量创建。 + """ + global _engine + if reset: + _engine = None + if url is not None: + return create_engine(url, pool_pre_ping=True) + if _engine is not None: + return _engine + u = _database_url_from_env() + if not u: + raise ValueError("database_url 未配置") + _engine = create_engine(u, pool_pre_ping=True) + logger.info("SQLAlchemy engine initialized from database_url") + return _engine diff --git a/llm/__init__.py b/backend/llm/__init__.py similarity index 100% rename from llm/__init__.py rename to backend/llm/__init__.py diff --git a/llm/__pycache__/__init__.cpython-312.pyc b/backend/llm/__pycache__/__init__.cpython-312.pyc similarity index 100% rename from llm/__pycache__/__init__.cpython-312.pyc rename to backend/llm/__pycache__/__init__.cpython-312.pyc diff --git a/backend/llm/__pycache__/deepseek_client.cpython-312.pyc b/backend/llm/__pycache__/deepseek_client.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..08a18949c689351af52e80c58866b5fc1dd93445 GIT binary patch literal 13692 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 E2e`;ie*gdg literal 0 HcmV?d00001 diff --git a/llm/deepseek_client.py b/backend/llm/deepseek_client.py similarity index 77% rename from llm/deepseek_client.py rename to backend/llm/deepseek_client.py index 5f3069c..1862f0e 100644 --- a/llm/deepseek_client.py +++ b/backend/llm/deepseek_client.py @@ -138,7 +138,8 @@ class DeepSeekClient: return json.loads(content) except json.JSONDecodeError as e: logger.warning(f"JSON解析失败,返回原始内容: {e}") - return {"raw_content": content} + # 勿仅用 raw_content 判失败:空串时下游 `not raw.get("raw_content")` 会误判为成功 + return {"_json_decode_failed": True, "raw_content": content} def generate_sql( self, @@ -173,6 +174,8 @@ class DeepSeekClient: } ] + kwargs.setdefault("temperature", 0.0) + kwargs.setdefault("top_p", 1.0) response = self.chat(messages, **kwargs) content = response.content.strip() @@ -216,8 +219,40 @@ class DeepSeekClient: } ] + kwargs.setdefault("temperature", 0.0) + kwargs.setdefault("top_p", 1.0) return self.chat_with_json(messages, **kwargs) + def empty_result_user_feedback( + self, + question: str, + sql: str, + schema: str, + *, + max_schema_chars: int = 8000, + **kwargs: Any, + ) -> str: + """ + 库探针为 0(执行成功但结果行数为 0)时,生成面向用户的中文补充说明,引导用户完善问题。 + """ + from config.prompts import EMPTY_RESULT_FEEDBACK_SYSTEM, EMPTY_RESULT_FEEDBACK_USER + + schema_snip = (schema or "")[:max_schema_chars] + messages = [ + {"role": "system", "content": EMPTY_RESULT_FEEDBACK_SYSTEM}, + { + "role": "user", + "content": EMPTY_RESULT_FEEDBACK_USER.format( + question=question or "(无)", + sql=sql, + schema=schema_snip, + ), + }, + ] + msg = self.chat(messages, temperature=0.4, max_tokens=512, **kwargs) + text = (msg.content or "").strip() + return text + def select_tables( self, question: str, @@ -243,13 +278,35 @@ class DeepSeekClient: "role": "user", "content": SCHEMA_LINKER_USER.format( question=question, - table_list=table_list - ) - } + table_list=table_list, + ), + }, ] + # 选表为结构化决策:默认 temperature=0,避免同一问题多次选不同表/SQL 上下文 + kwargs.setdefault("temperature", 0.0) + kwargs.setdefault("top_p", 1.0) return self.chat_with_json(messages, **kwargs) + def translate_nl_question_to_zh(self, question: str) -> str: + """ + 将主要为英文的自然语言分析问题译为中文,便于与中文 Schema 注释 / 向量索引对齐。 + """ + from config.prompts import TRANSLATE_NL_TO_ZH_SYSTEM, TRANSLATE_NL_TO_ZH_USER + + q = (question or "").strip() + if not q: + return "" + messages = [ + {"role": "system", "content": TRANSLATE_NL_TO_ZH_SYSTEM}, + {"role": "user", "content": TRANSLATE_NL_TO_ZH_USER.format(question=q)}, + ] + msg = self.chat(messages, temperature=0.0, top_p=1.0, max_tokens=512) + text = (msg.content or "").strip() + # 只取首行,避免模型附加说明 + line = text.splitlines()[0].strip() if text else "" + return line.strip("「」\"'“”") + class AsyncDeepSeekClient: """ diff --git a/main.py b/backend/main.py similarity index 82% rename from main.py rename to backend/main.py index 5ae9cb7..4aca88b 100644 --- a/main.py +++ b/backend/main.py @@ -1,11 +1,8 @@ #!/usr/bin/env python3 """ -Text2SQL 多智能体系统 - CLI演示入口 +Text2SQL 多智能体系统 - CLI 入口 -用法: - python main.py "查询2024年1月销售额最高的前5个产品" - python main.py --question "查询所有状态为Active的账户数量" - python main.py --interactive # 交互式模式 +用法:在项目根目录执行 python backend/main.py """ import os @@ -17,21 +14,30 @@ from typing import Optional from dotenv import load_dotenv +# 从仓库根目录运行 python backend/main.py 时,将 backend 加入模块搜索路径 +_backend_dir = Path(__file__).resolve().parent +if str(_backend_dir) not in sys.path: + sys.path.insert(0, str(_backend_dir)) + # 配置日志 logging.basicConfig( level=logging.INFO, format='%(asctime)s [%(levelname)s] %(name)s: %(message)s', datefmt='%Y-%m-%d %H:%M:%S' ) - logger = logging.getLogger(__name__) _DEFAULT_EMBEDDING_PATH = "./data/models/Qwen3-Embedding-0.6B" +def _repo_root() -> Path: + """仓库根目录(含 data/、.env、api_server.py 的目录)。""" + return Path(__file__).resolve().parent.parent + + def _load_project_env(): - """加载项目根目录 .env(与 main.py 同目录),供后续 os.getenv 使用。""" - load_dotenv(Path(__file__).resolve().parent / ".env") + """加载项目根目录 .env,供后续 os.getenv 使用。""" + load_dotenv(_repo_root() / ".env") def _embedding_model_path() -> str: @@ -149,6 +155,15 @@ def create_orchestrator(schema_mgr, args): from agents.orchestrator import Text2SQLOrchestrator from llm.deepseek_client import DeepSeekConfig + translate_en = os.getenv("TRANSLATE_EN_TO_ZH", "true").strip().lower() not in ( + "0", + "false", + "no", + "off", + ) + if getattr(args, "no_translate_en", False): + translate_en = False + api_key = (args.api_key or os.getenv("DEEPSEEK_API_KEY") or "").strip() if not api_key: raise ValueError( @@ -176,6 +191,7 @@ def create_orchestrator(schema_mgr, args): fewshot_enabled=not args.no_fewshot, fewshot_top_k=args.fewshot_top_k, fewshot_min_rating=args.fewshot_min_rating, + translate_english_to_zh=translate_en, ) return orchestrator @@ -185,8 +201,30 @@ def single_query(orchestrator, question: str, dialect: str = "tsql"): """单次查询""" import time + from agents.orchestrator import GenerationResult + from utils.dialog_classifier import DialogIntent, classify_dialog + logger.info(f"[Q] 问题: {question}") + classified = classify_dialog(question) + if classified.intent == DialogIntent.CONVERSATION: + reply = classified.reply_suggestion or "" + logger.info("[Q] 意图: conversation(跳过 SQL 生成)") + print("\n" + "=" * 60) + print("对话 / 非查询输入(未触发 SQL 生成)") + print("=" * 60) + print(reply) + print(f"\n使用表: []") + return GenerationResult( + sql="", + valid=False, + errors=[], + warnings=[], + tables_used=[], + attempts=0, + metadata={"dialog_intent": DialogIntent.CONVERSATION.value}, + ) + start = time.time() result = orchestrator.generate( question=question, @@ -212,6 +250,11 @@ def single_query(orchestrator, question: str, dialect: str = "tsql"): for w in result.warnings: print(f" - {w}") + dbe = result.metadata.get("db_empty_feedback") + if result.valid and dbe: + print("\n[DB 探针 0 — 无数据行] 说明:") + print(dbe) + print(f"\n使用表: {result.tables_used}") return result @@ -221,11 +264,12 @@ def interactive_mode(orchestrator, dialect: str = "tsql"): """交互式模式""" print("\n" + "=" * 60) print("Text2SQL 交互模式(输入 'quit' 或 'exit' 退出)") + print("提示:请描述业务数据查询需求;寒暄或「你是谁」等会由对话分类处理,不生成 SQL。") print("=" * 60 + "\n") while True: try: - question = input("❓ 请输入问题: ").strip() + question = input("❓ 请输入业务查询问题: ").strip() if question.lower() in ('quit', 'exit', 'q'): print("再见!") break @@ -243,34 +287,9 @@ def interactive_mode(orchestrator, dialect: str = "tsql"): logger.error(f"查询失败: {e}") -def batch_mode(orchestrator, questions: list, dialect: str = "tsql"): - """批量查询模式""" - print(f"\n批量模式:共 {len(questions)} 个问题\n") - - results = [] - for i, question in enumerate(questions, 1): - print(f"[{i}/{len(questions)}] {question}") - result = single_query(orchestrator, question, dialect) - results.append(result) - print() - - # 统计 - success_count = sum(1 for r in results if r.valid) - print("=" * 60) - print(f"统计: {success_count}/{len(questions)} 成功 " - f"({success_count/len(questions)*100:.1f}%)") - - return results - - def main(): parser = argparse.ArgumentParser( - description="Text2SQL 多智能体系统 - 自然语言生成SQL" - ) - parser.add_argument( - "question", - nargs="?", - help="自然语言问题(如不提供则进入交互模式)" + description="Text2SQL:运行后进入交互式自然语言生成 SQL(python backend/main.py)" ) parser.add_argument( "--schema", "-s", @@ -349,20 +368,16 @@ def main(): default=int(os.getenv("FEWSHOT_MIN_RATING", "7")), help="few-shot示例最低评分(默认: 7)" ) - parser.add_argument( - "--interactive", "-i", - action="store_true", - help="交互模式" - ) - parser.add_argument( - "--batch", "-b", - help="批量文件路径(每行一个问题)" - ) parser.add_argument( "--verbose", "-v", action="store_true", help="详细日志" ) + parser.add_argument( + "--no-translate-en", + action="store_true", + help="关闭英文问句自动译为中文(默认开启;也可用 TRANSLATE_EN_TO_ZH=false)", + ) args = parser.parse_args() args.dialect = resolve_sql_dialect(args.dialect) @@ -389,17 +404,7 @@ def main(): logger.error(f"Orchestrator创建失败: {e}") sys.exit(1) - # 根据参数选择模式 - if args.interactive or (not args.question and not args.batch): - interactive_mode(orchestrator, args.dialect) - elif args.batch: - with open(args.batch, 'r', encoding='utf-8') as f: - questions = [line.strip() for line in f if line.strip()] - batch_mode(orchestrator, questions, args.dialect) - elif args.question: - single_query(orchestrator, args.question, args.dialect) - else: - parser.print_help() + interactive_mode(orchestrator, args.dialect) if __name__ == "__main__": diff --git a/backend/nl_lite_store.py b/backend/nl_lite_store.py new file mode 100644 index 0000000..0e9f649 --- /dev/null +++ b/backend/nl_lite_store.py @@ -0,0 +1,287 @@ +""" +进程内 NL 附属数据(会话 / 消息 / 收藏),字段与 web 端 ApiEnvelope、SessionRow、MessageRow 对齐。 +仅用于本地或 demo 联调,重启后数据丢失。 +""" + +from __future__ import annotations + +import asyncio +import uuid +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Tuple + + +def utc_ts() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + + +class LiteNlStore: + def __init__(self) -> None: + self._lock = asyncio.Lock() + self._sessions: Dict[Tuple[str, str], List[Dict[str, Any]]] = {} + self._messages: Dict[Tuple[str, str, str], List[Dict[str, Any]]] = {} + self._msg_counters: Dict[Tuple[str, str, str], int] = {} + self._fav: Dict[Tuple[str, str], Dict[str, List[Any]]] = {} + + @staticmethod + def _visitor_key(vid: Optional[str]) -> str: + return (vid or "").strip() + + def _scope(self, user_id: Optional[str], visitor_biz_id: Optional[str]) -> Tuple[str, str]: + uid = (user_id or "anonymous").strip() or "anonymous" + return uid, self._visitor_key(visitor_biz_id) + + async def create_session( + self, + user_id: Optional[str], + visitor_biz_id: Optional[str], + title: Optional[str], + ) -> Dict[str, Any]: + async with self._lock: + sk = self._scope(user_id, visitor_biz_id) + sid = str(uuid.uuid4()) + now = utc_ts() + row: Dict[str, Any] = { + "session_id": sid, + "title": (title or "").strip() or None, + "user_id": sk[0], + "visitor_biz_id": visitor_biz_id or None, + "created_at": now, + "updated_at": now, + } + self._sessions.setdefault(sk, []).insert(0, row) + self._messages[(*sk, sid)] = [] + self._msg_counters[(*sk, sid)] = 0 + return {"session_id": sid, "title": row["title"]} + + async def list_sessions( + self, + user_id: Optional[str], + visitor_biz_id: Optional[str], + limit: int, + offset: int, + ) -> Dict[str, Any]: + async with self._lock: + sk = self._scope(user_id, visitor_biz_id) + items = list(self._sessions.get(sk, [])) + items.sort(key=lambda r: str(r.get("updated_at") or ""), reverse=True) + sl = items[offset : offset + limit] + return {"items": sl, "limit": limit, "offset": offset} + + def _bump_session(self, sk: Tuple[str, str], session_id: str, user_first_line: str) -> None: + now = utc_ts() + for r in self._sessions.get(sk, []): + if r.get("session_id") != session_id: + continue + r["updated_at"] = now + if not (r.get("title") or "").strip() and user_first_line.strip(): + r["title"] = user_first_line.strip()[:80] + break + + async def append_exchange( + self, + user_id: Optional[str], + visitor_biz_id: Optional[str], + session_id: str, + user_text: str, + assistant_content_json: str, + ) -> None: + async with self._lock: + sk = self._scope(user_id, visitor_biz_id) + sk3 = (*sk, session_id) + if sk3 not in self._messages: + self._messages[sk3] = [] + self._msg_counters[sk3] = 0 + now = utc_ts() + + def next_id() -> int: + n = self._msg_counters.get(sk3, 0) + 1 + self._msg_counters[sk3] = n + return n + + self._messages[sk3].append( + { + "id": next_id(), + "role": "user", + "content": user_text, + "created_at": now, + "llm_total_tokens": None, + } + ) + self._messages[sk3].append( + { + "id": next_id(), + "role": "assistant", + "content": assistant_content_json, + "created_at": now, + "llm_total_tokens": None, + } + ) + self._bump_session(sk, session_id, user_text) + + async def get_messages( + self, + user_id: Optional[str], + visitor_biz_id: Optional[str], + session_id: str, + limit: int, + offset: int, + ) -> Optional[Dict[str, Any]]: + async with self._lock: + sk = self._scope(user_id, visitor_biz_id) + sk3 = (*sk, session_id) + if sk3 not in self._messages: + found = any( + r.get("session_id") == session_id for r in self._sessions.get(sk, []) + ) + if not found: + return None + self._messages[sk3] = [] + self._msg_counters[sk3] = 0 + rows = list(self._messages[sk3]) + rows = rows[offset : offset + limit] + return {"session_id": session_id, "items": rows, "limit": limit, "offset": offset} + + async def update_session_title( + self, + user_id: Optional[str], + visitor_biz_id: Optional[str], + session_id: str, + title: Optional[str], + ) -> Optional[Dict[str, Any]]: + async with self._lock: + sk = self._scope(user_id, visitor_biz_id) + for r in self._sessions.get(sk, []): + if r.get("session_id") == session_id: + r["title"] = title + r["updated_at"] = utc_ts() + return {"session_id": session_id, "title": r.get("title")} + return None + + async def delete_session( + self, + user_id: Optional[str], + visitor_biz_id: Optional[str], + session_id: str, + ) -> Optional[Dict[str, Any]]: + async with self._lock: + sk = self._scope(user_id, visitor_biz_id) + lst = self._sessions.get(sk, []) + sk3 = (*sk, session_id) + n = len(self._messages.get(sk3, [])) + new_lst = [x for x in lst if x.get("session_id") != session_id] + if len(new_lst) == len(lst): + return None + self._sessions[sk] = new_lst + self._messages.pop(sk3, None) + self._msg_counters.pop(sk3, None) + return {"session_id": session_id, "deleted_messages": n} + + async def patch_message( + self, + user_id: Optional[str], + visitor_biz_id: Optional[str], + session_id: str, + message_id: int, + content: str, + ) -> Optional[Dict[str, Any]]: + async with self._lock: + sk3 = (*self._scope(user_id, visitor_biz_id), session_id) + for m in self._messages.get(sk3, []): + if m.get("id") == message_id: + m["content"] = content + m["created_at"] = utc_ts() + return { + "id": message_id, + "session_id": session_id, + "role": m.get("role"), + "content": content, + "created_at": m.get("created_at"), + "llm_total_tokens": m.get("llm_total_tokens"), + } + return None + + async def get_favorites_grouped( + self, user_id: Optional[str], visitor_biz_id: Optional[str] + ) -> Dict[str, List[Any]]: + async with self._lock: + sk = self._scope(user_id, visitor_biz_id) + g = self._fav.get(sk) or {"sql": [], "function": [], "report": []} + return {"sql": list(g["sql"]), "function": list(g["function"]), "report": list(g["report"])} + + async def add_favorite( + self, user_id: Optional[str], visitor_biz_id: Optional[str], body: Dict[str, Any] + ) -> Any: + async with self._lock: + sk = self._scope(user_id, visitor_biz_id) + g = self._fav.setdefault(sk, {"sql": [], "function": [], "report": []}) + fav_type = str(body.get("fav_type") or "sql") + fid = f"{uuid.uuid4().hex[:12]}" + if fav_type == "sql": + row = { + "id": fid, + "name": str(body.get("name") or ""), + "desc": str(body.get("desc") or ""), + "sql": str(body.get("sql") or ""), + } + if body.get("sql_explain"): + row["sql_explain"] = str(body["sql_explain"]) + g["sql"].insert(0, row) + return row + if fav_type == "function": + row = { + "id": fid, + "name": str(body.get("name") or ""), + "desc": str(body.get("desc") or ""), + "path": str(body.get("path") or ""), + } + g["function"].insert(0, row) + return row + row = { + "id": fid, + "name": str(body.get("name") or ""), + "desc": str(body.get("desc") or ""), + "reportPath": str(body.get("reportPath") or ""), + "params": str(body.get("params") or ""), + } + g["report"].insert(0, row) + return row + + async def patch_favorite( + self, + user_id: Optional[str], + visitor_biz_id: Optional[str], + fav_id: str, + patch: Dict[str, Any], + ) -> bool: + async with self._lock: + sk = self._scope(user_id, visitor_biz_id) + g = self._fav.get(sk) + if not g: + return False + for bucket in ("sql", "function", "report"): + lst = g.get(bucket, []) + for i, it in enumerate(lst): + if str(it.get("id")) == fav_id: + lst[i] = {**it, **patch} + return True + return False + + async def delete_favorite( + self, user_id: Optional[str], visitor_biz_id: Optional[str], fav_id: str + ) -> bool: + async with self._lock: + sk = self._scope(user_id, visitor_biz_id) + g = self._fav.get(sk) + if not g: + return False + removed = False + for bucket in ("sql", "function", "report"): + before = len(g.get(bucket, [])) + g[bucket] = [x for x in g.get(bucket, []) if str(x.get("id")) != fav_id] + if len(g[bucket]) < before: + removed = True + return removed + + +lite_nl_store = LiteNlStore() diff --git a/schema/__init__.py b/backend/schema/__init__.py similarity index 100% rename from schema/__init__.py rename to backend/schema/__init__.py diff --git a/schema/__pycache__/__init__.cpython-312.pyc b/backend/schema/__pycache__/__init__.cpython-312.pyc similarity index 100% rename from schema/__pycache__/__init__.cpython-312.pyc rename to backend/schema/__pycache__/__init__.cpython-312.pyc diff --git a/schema/__pycache__/indexer.cpython-312.pyc b/backend/schema/__pycache__/indexer.cpython-312.pyc similarity index 100% rename from schema/__pycache__/indexer.cpython-312.pyc rename to backend/schema/__pycache__/indexer.cpython-312.pyc diff --git a/schema/__pycache__/loader.cpython-312.pyc b/backend/schema/__pycache__/loader.cpython-312.pyc similarity index 100% rename from schema/__pycache__/loader.cpython-312.pyc rename to backend/schema/__pycache__/loader.cpython-312.pyc diff --git a/schema/__pycache__/manager.cpython-312.pyc b/backend/schema/__pycache__/manager.cpython-312.pyc similarity index 100% rename from schema/__pycache__/manager.cpython-312.pyc rename to backend/schema/__pycache__/manager.cpython-312.pyc diff --git a/schema/__pycache__/models.cpython-312.pyc b/backend/schema/__pycache__/models.cpython-312.pyc similarity index 100% rename from schema/__pycache__/models.cpython-312.pyc rename to backend/schema/__pycache__/models.cpython-312.pyc diff --git a/schema/indexer.py b/backend/schema/indexer.py similarity index 100% rename from schema/indexer.py rename to backend/schema/indexer.py diff --git a/schema/loader.py b/backend/schema/loader.py similarity index 100% rename from schema/loader.py rename to backend/schema/loader.py diff --git a/schema/manager.py b/backend/schema/manager.py similarity index 100% rename from schema/manager.py rename to backend/schema/manager.py diff --git a/schema/models.py b/backend/schema/models.py similarity index 100% rename from schema/models.py rename to backend/schema/models.py diff --git a/utils/__init__.py b/backend/utils/__init__.py similarity index 100% rename from utils/__init__.py rename to backend/utils/__init__.py diff --git a/utils/__pycache__/__init__.cpython-312.pyc b/backend/utils/__pycache__/__init__.cpython-312.pyc similarity index 100% rename from utils/__pycache__/__init__.cpython-312.pyc rename to backend/utils/__pycache__/__init__.cpython-312.pyc diff --git a/backend/utils/__pycache__/dialog_classifier.cpython-312.pyc b/backend/utils/__pycache__/dialog_classifier.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2941fa4c4903293df9f08f8ca478cf3b424f7893 GIT binary patch literal 5111 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 delta 217 zcmZ2Dm2uNlM!wU$yj%=GFl*zD%oq9_`BJ$UgD018sfv`OR+JbO7v{t#rl;nW#3v`_ zrsiy3z{O(0IB)V&r@+aNoWyvlxEUByn7}0SWG-iYMwZD|&T5lWo$XkQ;(>-vUgK*qc2U^rKtGXOwJTdu&O911^$(F8H7$Z0HxtTLE9-Zvw{+RL6WG#f=CDe diff --git a/backend/utils/__pycache__/question_locale.cpython-312.pyc b/backend/utils/__pycache__/question_locale.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..830c7fd2ea84827b7e8eb9ccce2e7af3e20dda15 GIT binary patch literal 1600 zcmah{Z%A8L6u%Q#Vr?G|McHy3L?>WDF z&bjAze#_6dAXsfPFMWSg2qnoSyYzd9oiRAX5QAKZ!3@P=7p4%4JG1^Q9yFty!Mj(N z?m&!wAEQ4IW7x+S7$a+BOsuJ0&zOhxE^{ws%L!hBEa{P-H-{I}bF=BVoQeICxjPBx z&7Z<*bWV-jQkNrJ%c0Hj_{QU}Q%X{nexN+^vnJ*`yp+Z-%Xx*dD2- z?ZUaO<_N2NP>qP@NSie{j_nt}O*-ZRuX>IkCN0SHAeK_JB_ zuu@P6c=x)X^@8+C2Ycrgh#2ZeJy@hL^s@6!lmG7c^+{tIyUtK=o6M!S99% zLpEdZcfeb$u+-3WYGQVmv|VyGmY$^V4{j_y27Xc#BS2j$HU%uDV)ruNKS=)&PYp-F zuFl4QsIBF(j69@9L+XtXkeLca2@O5IB>1`&9mfU~ zljr2w7SF{d#VE40;Jv0;JZGG(&6m#+o7vOiY;}^8P7HS0bOH&WU{Nt}wB!qj??5VK%YpBC7~bpgD3rh|CNJOB?c-R1^iU`z;yn=XIZ06TS9zY(Y848I z=Y1lykC_{~cqYiPjY0+3$WVxFAhvZF;}^)4L^<0=lv^A+8nKVsSBgJOSdPo4_54Gj uixJnTYo*kl$p1*TtOKGZQV-RY98cuhWb?K$50^|G+eV<`6@L(=CH)6j3+c50 literal 0 HcmV?d00001 diff --git a/utils/__pycache__/sql_parser.cpython-312.pyc b/backend/utils/__pycache__/sql_parser.cpython-312.pyc similarity index 100% rename from utils/__pycache__/sql_parser.cpython-312.pyc rename to backend/utils/__pycache__/sql_parser.cpython-312.pyc diff --git a/utils/__pycache__/validators.cpython-312.pyc b/backend/utils/__pycache__/validators.cpython-312.pyc similarity index 50% rename from utils/__pycache__/validators.cpython-312.pyc rename to backend/utils/__pycache__/validators.cpython-312.pyc index 1ecc9c6e3b541f8f427d78597567192749f8a178..f77392b732e42b64798e7ad88ce778d6c32ad7b1 100644 GIT binary patch delta 4280 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 delta 1574 zcmZ`(U2Gdg5cWB~^S=}44})l$l#NJT3eEfPPfB7qR7eX8WQ_5lghCmwldwJ*FDA%RrhkdT-;$E8H7C69N$Z+7;Z znO$FXetRSFb37jL;b&swuDLw4nMf)7H;3ldDn29djgpVxuZq7>Lt`-`$O0_RR2F+2 zH!S)+8!$rafqdd2KK6hKBh$Xkv~~rI2%;TE(!-;V$XeEn*t(ie{x7VRLNnsXb%uS9 zU=zr8g?-3(J~*VE#ZkVt7&1DL?Oye#yTq0<*7zkh;*ZNV{+1;*U~V1kMqB$n|30a4 zR)q4B)QYtlvklp1FieJ!IdJ|rd>&EFe&De)49J-OutxojWghd%yT0LCJHl* zpL3mBp;RoNs}}9ya7hWR>haeUFs=p za=*BhyzKwW*O=|Rr0kWxbP&P_Lx@a}j7Ekg2r)8I@lV&}>+`rwt5W4Im^GL}57#xz zbb*q}z_Mf7X%!}E$1EX{MH9HIuQ*`9NdkgK(Z)(SSJCUI59Gv^?jALd{y}lQ`?50E zc%f(A-#3i_WNA<2=#i7T)7j~%`79hp!0V2DES)@o4kw{rBFfaA+M}8XoDv69E@rxs zx~nS7;$q)c!lvLhFZcpRj_p3ATx!&JhZU`gAlPEPe@vtFa76sj-}}T8E@dzW1_Nfu zfV*UJ0HV40vwv{!t3;~?E7dFM_GZP)#8NV;F#zni7#}!#pibxqgoetFrQ0|#sW9;5 zv?JWq-m)*Fv>O9^)JtS|CGK%F7*RQF07%Es#`2s47`?yE6mkBUk0&Z z^{UOA)$ORL!>dRpczKCci#k3Ay!-&$LpX-1sm1UG8Lz&pbW#v+A`KL1EHdPlniGLf zDQt@{r7Xj(L2<8mabdLgX|Lkc_G@CgmqstG>We0`3=ZGn)_cCj;P4-Tr#8IQ+fSLx zD6iw==G0Jr7_QMl8{+xQF6|60uZw)_YDJ%sl>ixgY~2$FaQE4S5Xnl)w&kbm}!9(2329 h5qhcW7 str: + t = unicodedata.normalize("NFKC", text or "").strip() + t = re.sub(r"\s+", " ", t) + return t + + +def _strip_trailing_punct(t: str) -> str: + return re.sub(r"[!!。.??,,;;:~~…、]+$", "", t).strip() + + +def classify_dialog(user_text: str) -> DialogClassifyResult: + """ + 对用户一轮输入做粗分类。 + + 策略:优先用「查询/业务」关键词锁定 TEXT2SQL;否则对短寒暄、致谢、元问题判为 CONVERSATION; + 其余默认 TEXT2SQL,避免漏判真实查询。 + """ + t = _normalize(user_text) + if not t: + return DialogClassifyResult( + DialogIntent.CONVERSATION, reply_suggestion=_EMPTY_INPUT_REPLY + ) + + if _SQL_OR_QUERY_HINT_RE.search(t): + logger.debug("[dialog] intent=text2sql (query/business hint)") + return DialogClassifyResult(DialogIntent.TEXT2SQL, None) + + core = _strip_trailing_punct(t) + if core.casefold() in _CHITCHAT_KEYS: + logger.debug("[dialog] intent=conversation (chitchat phrase)") + return DialogClassifyResult( + DialogIntent.CONVERSATION, reply_suggestion=DEFAULT_CONVERSATION_REPLY + ) + + if _META_QUESTION_RE.search(t): + logger.debug("[dialog] intent=conversation (meta question)") + return DialogClassifyResult( + DialogIntent.CONVERSATION, + reply_suggestion=DEFAULT_CONVERSATION_REPLY, + ) + + logger.debug("[dialog] intent=text2sql (default)") + return DialogClassifyResult(DialogIntent.TEXT2SQL, None) diff --git a/utils/embedding.py b/backend/utils/embedding.py similarity index 100% rename from utils/embedding.py rename to backend/utils/embedding.py diff --git a/utils/fewshot_selector.py b/backend/utils/fewshot_selector.py similarity index 98% rename from utils/fewshot_selector.py rename to backend/utils/fewshot_selector.py index 229f6f1..129ba54 100644 --- a/utils/fewshot_selector.py +++ b/backend/utils/fewshot_selector.py @@ -317,7 +317,8 @@ class FewShotSelector: def load_fewshot_selector() -> FewShotSelector: """加载默认的few-shot选择器""" - default_path = Path(__file__).resolve().parent.parent / "data" / "experiences" / "all_samples.jsonl" + # __file__ = backend/utils/fewshot_selector.py → 仓库根为 parents[2] + default_path = Path(__file__).resolve().parents[2] / "data" / "experiences" / "all_samples.jsonl" return FewShotSelector(str(default_path)) diff --git a/backend/utils/question_locale.py b/backend/utils/question_locale.py new file mode 100644 index 0000000..ff5e6fc --- /dev/null +++ b/backend/utils/question_locale.py @@ -0,0 +1,23 @@ +"""自然语言问题语种启发式(用于是否走「英译中」再 Text2SQL)。""" + +import re + + +_CJK_RE = re.compile(r"[\u4e00-\u9fff]") +_HANGUL_RE = re.compile(r"[\uac00-\ud7af]") +_KANA_RE = re.compile(r"[\u3040-\u30ff]") + + +def looks_like_english_only(text: str) -> bool: + """ + 判断问题是否主要为英文(无中日韩表意文字),适合先译成中文再走检索/选表。 + + 含中文、日文假名、韩文时不翻译,避免破坏中英混合问句。 + """ + s = (text or "").strip() + if not s or len(s) < 2: + return False + if _CJK_RE.search(s) or _HANGUL_RE.search(s) or _KANA_RE.search(s): + return False + latin = sum(1 for c in s if ("a" <= c <= "z") or ("A" <= c <= "Z")) + return latin >= 3 diff --git a/utils/sql_parser.py b/backend/utils/sql_parser.py similarity index 100% rename from utils/sql_parser.py rename to backend/utils/sql_parser.py diff --git a/utils/validators.py b/backend/utils/validators.py similarity index 77% rename from utils/validators.py rename to backend/utils/validators.py index 75d93e1..f683a2b 100644 --- a/utils/validators.py +++ b/backend/utils/validators.py @@ -8,6 +8,90 @@ from typing import Tuple, List, Dict logger = logging.getLogger(__name__) +# CJK Unified Ideographs + 兼容扩展(用于禁止中文业务词出现在 SQL 字符串字面量中) +_CJK_IN_STRING_RE = re.compile( + r"[\u3000-\u303f\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff00-\uffef]" +) + + +def check_no_cjk_in_sql_string_literals(sql: str) -> Tuple[bool, List[str]]: + """ + 扫描 SQL 中单引号字符串(含 T-SQL N'…'),若字面量内出现 CJK 则判失败。 + + 跳过 ``--`` 行注释与 ``/* */`` 块注释内的文本,避免误报。 + """ + errors: List[str] = [] + i = 0 + n = len(sql) + in_line_comment = False + in_block_comment = False + + def _read_single_quoted_string(start: int) -> Tuple[str, int]: + """从 start 指向的 opening `'` 之后开始读,返回 (内容, 闭合引号后下标)。""" + j = start + parts: List[str] = [] + while j < n: + ch = sql[j] + if ch == "'": + if j + 1 < n and sql[j + 1] == "'": + parts.append("'") + j += 2 + continue + return "".join(parts), j + 1 + parts.append(ch) + j += 1 + return "".join(parts), j + + while i < n: + if in_line_comment: + if sql[i] == "\n": + in_line_comment = False + i += 1 + continue + if in_block_comment: + if i + 1 < n and sql[i : i + 2] == "*/": + in_block_comment = False + i += 2 + else: + i += 1 + continue + + two = sql[i : i + 2] + if two == "--": + in_line_comment = True + i += 2 + continue + if two == "/*": + in_block_comment = True + i += 2 + continue + + # N' 或 n' 前缀的 Unicode 字面量 + if i + 1 < n and sql[i] in "Nn" and sql[i + 1] == "'": + body, i = _read_single_quoted_string(i + 2) + if _CJK_IN_STRING_RE.search(body): + prev = body[:48] + ("…" if len(body) > 48 else "") + errors.append( + "SQL 字符串字面量中含中文或与业务中文直接作为比对值(禁止)。" + f"片段近似: …'{prev}'… — 请改用 Schema 注释中的代码/枚举,或通过维表 JOIN 用键列过滤。" + ) + continue + + if sql[i] == "'": + body, i = _read_single_quoted_string(i + 1) + if _CJK_IN_STRING_RE.search(body): + prev = body[:48] + ("…" if len(body) > 48 else "") + errors.append( + "SQL 字符串字面量中含中文或与业务中文直接作为比对值(禁止)。" + f"片段近似: …'{prev}'… — 请改用 Schema 注释中的代码/枚举,或通过维表 JOIN 用键列过滤。" + ) + continue + + i += 1 + + return len(errors) == 0, errors + + # 危险操作关键词(除非明确允许) DANGEROUS_KEYWORDS = [ "DROP", "DELETE", "UPDATE", "INSERT", "ALTER", "TRUNCATE", diff --git a/llm/__pycache__/deepseek_client.cpython-312.pyc b/llm/__pycache__/deepseek_client.cpython-312.pyc deleted file mode 100644 index e1b90d565eaa9d022ac4f11da48396cd6929a7f9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11211 zcmds73s75UcD|DC)%yWLh&LA4fQ2!3j2|&h*1>?Em^`pE8{t%urE3h9fLsYX5Rqah z&dQBrU^g4c+H2!%vc_>@oNl{uHr^z&+v)Z_Xtg_1@1_%X7RfNvnaIU$w&_gUbN;I< zfh?RfeN3nSNay~~^Z)n1=X~ef^WVSJ>C_aYI~O9I)#ViRTP%o)No3~ZkQt*~l#Oyp zTvE5hCLvd;O-imZn+&cpH|=3;j7M&hdlWW>M`=@fR5q0a$Ix!IM`P1?v^Fg%Gj5$n zZ_|4WHUlZk-C3S&Tein&GfF5a#mc%W#YLp9tm%qGDA{sZjg;C!xfCx@F69-eP2k&z*v`_%{xT93@&jI0+VR;RoK(kNnTnl09vwLyd*{tH(Hke=GJEa$Tdz!h zcH{KS^)rnPdz)J<4ZETzZ$z)Yub#sNwptmUZt8UUc)F$2>*JLV_xd_}x*cv_*3jM0 zGyA)tWR>zNm&509x*c9G&m8Jx-7ct;Kitc9H|*jy4c`84r%-@uedA$=ud&C|>t>M^ zpS!rcm-Tub9jp^)7ZaVx%)bShF^Z+&ky1A4et1|m8B4Q_OZot`xMVgtDbb|F*c7BB zCnbeV$*Q2Il9W_7HLHP=nv^s)Eosw|lFp_hC4Hi#CnWRkT+dq(h*yro@ z)~{Iu6}2vSm|l38way+7uRQ4Rvi3gCJ)jfoS36-Rd9|m<#k%d?4iDQ=M^b1juko>- zUY2wC`Z%_e%AzRf=6J^L>p8-9dprL6uQ2_?R-W8tu z=WzS_`K(@!^+F9R)+BnjKEx}0ogTKQ&)3T9*k^p4!+w}`xLD3>&Eb`HvLL&iSKIBd zz&p|qEGnqQH2)!xl(L|zxTGkkF4+sTjd9Ve9I!;mDpR8CVptVmiX7V2OSIz% zjW7awDxjwpaLMQbJ}wpf)$nJ4r_!YXWYW2`iF$_2C61K4bTCp67^ZX?$cTk}<*qDf zHS}kLAf*(DY<}NCxGA*-(n2jtA>C&;0s_8$cH@+q^qcwVM}Tk^0e&n3w#Cl886EzC zfPE=OvB`_k^DmJ>yxd9j)tLZm+)qjle-x|vUjiAU{1QK9hn$dn*g}$D(k0@RSOr(8 z#qntgYL#hKP<&f*CBY455X~Cp(~3R2Q1a4}OA5R|&(qUcK*h{+pQXM}izZ4!^;1^5 z^|FjZ7J}-bLiMcx(*E$CCzHF>NFGBp@crnem!hX%t+x>Ld2m2eV-W%BL92?ZLN;it z(-^|W5(d27-P6&*ay;GHeW-`1`DH0jds+7(GS-V5Z?SMVKY~eTcc;&8AIKA*y0DB| zu>q&^J_%%qikOQ7^l8lp~wNR`QjUZ0auH?f`jc|dOW#o}ToJ3z?2~N@_IAsvQFX1Fx zf)hURwDcvM=p{HMg+0aYOE@u0aMGtZh`BG}BwvD42K5p-xfJJ=gG=oS8nLv*6)Z7F zhQ5B8;Iyc~*Mw1M0n<_!n!&Wxh5DP8x(d@Opy)DmQhwPRk~5M)+D~^CXXv3qv)Wa9 zcgr9HBPeBfYz(tzkass^TLB(^5W0GUkGqNYBV-jmtp9S1`VjR%= zM2AP`hK|o3|AXiomuIKmi@ke7ur}D%z;$@*lRdFUM2vb%?8kxF@u_HFZ1y60-LZ)q z(Hp}v*MDMZf3}?KakJ(1mhwJuRmy8D+Yj&=B0Ug134)9GZrydohG zJky8oI@~H8$hg>peI2}R(M|E$DMpDK;I#{HhgUT}<7CN88PBjB*TY$rTrKb;d(6uP z`QYWf4$k56@=9>#d%?eExy{(89w(NtUbFz@0pc@6o6BtgGLR!~Q^MTht!;z`?{`5L zhNyTsWiA|RA8ilkt_tR^isV}&MP(Q2U#p++T-*0v`@8Mo4K2Y9Euo^lkwVLb>es3# z4qRLR-j;W_gzI(%>vn|-ccW#tjoKy@lcuSn%SB;peb8DT%H0wvtGZNpu`rUgd}80F z_KWS|ifzG)ZSyi|xjrtZpizBM9my)6Xu7oL;-2t|hk`2}g2pmz%r6}q7#*0fPVSp( zzuX>P{ZMfAL!rEe+XXtKIv|giQrfKHEvL4GH7kCpSrMtMzI5c`k#ObKVCB}p_K~eY z?eaLCtujWcc2D>&J$v!laMh+@)uwP&W3Z|*T(vuhzkPr9IFP{3h`BUut_+$hC!P+P ztzq+hLGyiq9k7Pn5_CHP&7WwE!!4&;;!;UYS){BoT(&+~wtm{O0bGoY6_JYSaK!_` ziU+1wJO~xvutY2sVavLpW!?0$y0}7?z2T0G(dpxQ%2X6+{oRZq{~sx-%9!9eU9{nv z<67Hv!RB9S>m%Cie~mNH_=Ojt`cGDFGj88P{pl9fj&j*A=z<+3vR{-ikYD7fpsztZ zm4AkZIVRW^s7gbH?4=*X&W}V# z&VXsd^+ewpi=MomZzx?ygl7>C_G=tpfrc?QGHcFuzzAa0eUdL!i_}FT)NE;Kv=@%>-f%~am}P0NI(%W7L2VKT@yA|1&vi9qcxz2(^;yT zn}+=H9YMqLoB7KwXkXJ#JQ2!YbFD0xUpK-;%q3&)QTN1#$%>G9?T9Q=ST<2HDVr{= z2^X#l7Oo2wt{+uIvd!aF!R#_|{Qgk>s!2yMf6Z4KKcbk)$s4O5tsn0Qz0Sj|$H6OHoenqa^f_r5D<+g70*?~} z9!cn!@<;|5k91HDVPF6w7ky3&YA4SGYJPYh_siavi*NGSw?jIpATE4T*~Q5qKA<2l zOB0w?`jwd=qf3|SFXoakt8yv)Dge4IepSZ!fFcQWN&)NLon#$a|D=Zd=atAxc$J5S3+3Wi=kQWaVQSR z0bC(gI)_0BZU>T`K)}J*15`qwyH?muFZWGsdjbeAhd7T1BDjdCWCCsv=30PQwSvpK z4=ZpwGKm0`Ne048LnF50^0-HlJO;#z787?+OhVL13t?Q0fU|a3=s-~#WG-wRMkfCP zWQh8_l2Wxuz=boHPFH^;sNH%qzcf;`I$X3qShOCz+epdsNb&Nxf-2rHPtnE3+l&HS zG)8Sw2bA%A%2YD4er)sT=J9U^bJk9pfdrHhV`11>9yFGRjVpu3l_6s_z^$CpnnsQV zHKh?#(OAo9%S3+2R6Y4<&{P{}Ms%(jtr_o|U_-{5KqFx9I6KieZC)8R*9OhC0Knj7 z>Zz=}fIhCH^f@CPL0!qs1f$%LscNz;Xj=6Z8QnCP19~B{C4-G}CL)ip8$!gBU|=^C z;7ARR(+~q`z(9szAf3QKCL;zyNDB}Ur4dpB0p&{~pqwBe2}QI~Rz=^R!=R*0r)l)u zTc>BIj)NrMOQYd-h%U}fy%QaNGsz0Y^cf6%aT@+gpL&Kds?N#Vc4 z(vqT+E`>v@B-m_cSlCCyQz`%P>A*?l2Hh zhP$xh2$H*GIN4W_;Z)DeGOU9`1909FcQR0%2K0+s{|+YfqACeeyfo66R793p;|zg$ z8N$3AWm!dJ*?dn~Zna8y+6T~M&Eg92{)&)bSd}Y5f@e1zMj<)$Hpw|9hdwKZE|y>% ziBUzZ<_;q1K;lM%ktso8R$#7DBo@ecXMwyx-ImgfGOpDz>hb61DY(Q->lourqj|(P z-Z+snzH{`sps_N}KxJG>sh7>GFxMq1XKL>51eMrHA8?M;1rDDoomXMaBN7O*+*Yk& z@@{73#2L7PIm836#&cHR%7jx=`tTk9uUkV+8 z0Rva)n42G0U~XZ@I~u4FHUaM+0WX;_q=9K3&)>iP(PzH`9GM*Q)xu(7>N^j72^fg= zi@&qLNGbWOB6<24`_W|Vg<)_(lOI?7^gGd0FW)*9h@E?T=K2S-KRmwhtBeLbdWKh1 zGKat_`4Pw5&|CG=n_Q%I>O}_Nop%Y{f30vM3=Ru=B?<1S? zzkO^82aD2H&I)r|Rs&gh@JG~Spg6;WNFMybn;7NA!O4TaPt0Za zI`(^6BghVkK;H)g7wr`q$d}+he-!xcp(OtgPVii^bM&B$I9+PEv$T{c+SHYdGo?Cb zI$Cxy;>pqT@U_*mAY-pe$7C-u3`I_oGMr?&XbewgI>BnAHc_Wb-jTsci@Y@zYq!!| z9gIjnFF=n{2-9l>k;1vS^+WA}<>lvgS4+xgW`9AYg`)L2|*&Y{RBTAYMcf z;w3GJgy&}xLX67jQ2<|o5wq0qhNzpl1^=aZ_-z$KqSRWd@WFA@g`(Gr!Ufh~fi+yP zAy}{>T<|~;f7`Dg1rpBL4lfsmys%+e(6DSm0-jaaP#rW>Pb;hAGRm|=@`V@o<;1qU zZJQ-OE0Ap4ME|U?6!NWB&DS+eX%?sl_;X194gOwy0BC_2ut0jbV1eKg&r&k0Ce}~V zlWo_QPk!^VKDc~SoPxUP?d^B)Drh9(m4yt6TEf026Fw309)ht0JX9Yl_a)mi1*B6U zVxR;s|%w9DSvO_X`k>38G3Ex*iTr&U1xmaN)z92Ts=SYVd7Jltn`Wd zw!lrs&b<{I9+~;@Y-05@*I&8UVxjx&)eqrQHn2kiwxguL$Ai)yIRAq4Gq(FEuYTO& z?n{J64bV&+7zi+shu(k{RY1IGtU&*SHx#pQc4l`azJ*JCgvA|$PKBs@yCDOC&SFYw z82bHBj3$hAP8ZjPFwXh62J+glD;JFbW4uy*&P8$jouStFj z$$B{q^>KI*z{`8^le$jfs~D7KK3k}%1%|A*7SV*mPzLb?c@=D03;9My(A8DglH4_- zbb{ZAJCWuY^2!HyOQRNbf4 z%1^1vPpQV6s=`2CSXCHQ6^`E@Qmq)0gCi!@d@j{U>f#iTc^MSrSp2-WT_)Ldn?m}z oU!E^nb(;bbucu1OZ_CV*@`x^X#Cht;IOc(i+;m3)IkLz90rd0Dp#T5? diff --git a/requirements.txt b/requirements.txt index d507cf5..d8298c7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,6 +16,8 @@ safetensors>=0.4.0 # SQL 处理 sqlglot>=20.0.0 sqlparse>=0.4.0 +sqlalchemy>=2.0.0 +pymssql>=2.2.0 # 配置与验证 pydantic>=2.0.0 @@ -27,4 +29,8 @@ tenacity>=8.0.0 structlog>=23.0.0 tqdm>=4.65.0 jieba>=0.42.0 -numpy>=1.24.0,<2 \ No newline at end of file +numpy>=1.24.0,<2 + +# API框架 +fastapi>=0.100.0 +uvicorn>=0.20.0 \ No newline at end of file diff --git a/scripts/integrate_fewshot.py b/scripts/integrate_fewshot.py index aa06480..2e3352e 100644 --- a/scripts/integrate_fewshot.py +++ b/scripts/integrate_fewshot.py @@ -4,6 +4,13 @@ Text2SQL Few-shot 集成示例 展示如何在现有系统中集成经验数据集few-shot功能 """ +import sys +from pathlib import Path + +_root = Path(__file__).resolve().parent.parent +if str(_root / "backend") not in sys.path: + sys.path.insert(0, str(_root / "backend")) + from typing import Optional, List @@ -143,7 +150,7 @@ def integrate_fewshot(): ``` 生成: data/experiences/all_samples.jsonl - **步骤2:修改 agents/orchestrator.py** + **步骤2:修改 backend/agents/orchestrator.py** 在文件开头添加: ```python @@ -198,7 +205,7 @@ def integrate_fewshot(): **步骤4:测试效果** ```bash # 对比测试 - python main.py "查询2024年1月的销售额" --verbose + python backend/main.py "查询2024年1月的销售额" --verbose # 观察日志中的 "Few-shot已选择: Q31, Q6, Q44" ``` diff --git a/scripts/patch_fewshot.py b/scripts/patch_fewshot.py index 3939c93..aae3f81 100644 --- a/scripts/patch_fewshot.py +++ b/scripts/patch_fewshot.py @@ -17,9 +17,9 @@ def patch_orchestrator( data_path: str = "data/experiences/all_samples.jsonl" ): """ - 修改 agents/orchestrator.py 添加few-shot支持 + 修改 backend/agents/orchestrator.py 添加few-shot支持 """ - orch_path = Path(__file__).parent.parent / "agents" / "orchestrator.py" + orch_path = Path(__file__).parent.parent / "backend" / "agents" / "orchestrator.py" if not orch_path.exists(): print(f"❌ 文件不存在: {orch_path}") @@ -224,7 +224,7 @@ def main(): print(f"1. 确保经验数据集已生成:") print(f" python scripts/parse_examples.py") print(f"\n2. 测试系统:") - print(f" python main.py \"查询2024年1月的销售额\" --verbose") + print(f" python backend/main.py \"查询2024年1月的销售额\" --verbose") print(f"\n3. 查看日志中的few-shot选择:") print(f" Few-shot已选择: Q31, Q6, Q44") print(f"\n4. 调整参数:") diff --git a/tech_architecture.md b/tech_architecture.md new file mode 100644 index 0000000..d6a2126 --- /dev/null +++ b/tech_architecture.md @@ -0,0 +1,242 @@ +# Text2SQL 算法技术方案 + +## 算法架构概述 + +Text2SQL 是一个将自然语言转换为 SQL 查询语句的智能算法系统,采用多智能体协作架构,结合向量检索和大语言模型技术,实现高效准确的 SQL 生成。 + +## 智能体组成 + +Text2SQL 系统由以下三个核心智能体组成: + +| 智能体名称 | 主要职责 | 核心功能 | +| ------------- | ------ | ------------------------ | +| Schema Linker | 表选择 | 向量检索粗筛、LLM 精筛、外键扩展 | +| SQL Generator | SQL 生成 | Few-shot 示例增强、LLM 生成 SQL | +| Validator | SQL 验证 | 程序验证、库执行探针(0/1/-1)、LLM 语义验证、结果评估 | + +这些智能体协同工作,形成完整的自然语言到 SQL 的转换流程。 + +## 核心算法流程 + +```mermaid +flowchart TD + subgraph 输入层 + A[自然语言问题] + end + + subgraph 核心处理层 + A --> B[意图分类 +Dialog Classifier] + B -->|非查询意图| C[返回对话回复] + + subgraph "智能体 1: Schema Linker" + D[表选择] + D1[向量检索粗筛 +SchemaIndexer] + D2[LLM 精筛 +DeepSeek] + D3[外键扩展 +关联表处理] + D --> D1 + D1 --> D2 + D2 --> D3 + end + + B -->|查询意图| D + + subgraph "智能体 2: SQL Generator" + E[SQL 生成] + E1[Few-shot 示例增强] + E2[LLM 生成 SQL +DeepSeek] + E --> E1 + E1 --> E2 + end + + D3 --> E + + subgraph "智能体 3: Validator" + F[SQL 验证] + F1[程序验证 +语法检查] + F1b[数据库试执行 +行列探针 0/1/-1] + F2[LLM 语义验证 +未探针时] + F3[结果评估] + F --> F1 + F1 --> F1b + F1b --> F2 + F2 --> F3 + end + + E2 --> F + F3 -->|验证通过| H[返回有效 SQL] + F3 -->|验证失败| I[重试逻辑 +最多 max_retry 次] + I -->|重试| E + end + + subgraph 数据层 + J[Schema 管理 +SchemaManager] + K[向量数据库 +Chroma] + L[Few-shot 示例库 +JSONL] + M[DeepSeek API +大语言模型] + N[业务数据库 +database_url] + J --> D + K --> D1 + L --> E1 + M --> D2 + M --> E2 + M --> F2 + N --> F1b + end +``` + +## 详细算法流程说明 + +### 1. 意图分类 + +- **Dialog Classifier**:对用户输入的自然语言进行意图分类 +- **分类结果**:非查询意图直接返回对话回复,查询意图进入 SQL 生成流程 + +### 2. Schema Linker 算法 + +- **向量检索粗筛**:使用 `SchemaIndexer` 对用户问题进行向量检索,获取相关表的候选列表 + - 利用预训练的嵌入模型将表名和描述转换为向量 + - 使用 Chroma DB 进行相似度搜索 + - 返回 top-k 个最相关的表 + +* **LLM 精筛**:调用 DeepSeek 模型对候选表进行精筛,选择最相关的表 + - 构造包含表名和描述的提示 + - 让 LLM 基于用户问题选择最相关的表 +* **外键扩展**:自动添加与选中表相关的关联表,确保查询完整性 + - 分析表之间的外键关系 + - 自动添加被引用和引用当前表的关联表 + +### 3. SQL Generator 算法 + +- **Few-shot 示例增强**:根据用户问题从示例库中选择相似的示例,增强 SQL 生成质量 + - 计算用户问题与示例库中问题的相似度 + - 选择 top-k 个最相似的高质量示例 + - 将示例注入到提示中,指导 SQL 生成 +- **LLM 生成 SQL**:调用 DeepSeek 模型,根据选中的表结构和示例生成 SQL 语句 + - 构造包含表结构、用户问题和示例的提示 + - 指导 LLM 生成符合特定 SQL 方言的语句 + - 清理和规范化生成的 SQL + +### 4. Validator 算法 + +- **程序验证**:检查 SQL 语法是否正确,表和列是否存在于 Schema 中,是否包含危险操作 + - 使用 sqlglot 进行语法检查 + - 验证表和列是否存在于 Schema 中 + - 检查是否包含危险操作(如 DROP、DELETE 等) +- **数据库试执行(探针)**:在程序验证全部通过后,于已配置的业务库上对 SQL 做只读试执行,结果用单一状态码表示,**不把具体行列数据交给 Validator LLM**: + - **1**:执行成功,且**有返回行或有返回列**(列名或数据行至少其一非空)→ **跳过** Validator 的语义审核 LLM,**直接将 SQL 交付用户** + - **0**:执行成功,但**既无列也无行** → 仍交付 SQL,**跳过** Validator 语义审核 LLM,另调 LLM 生成简短中文说明(可能原因 + 请用户补充条件),随响应一并返回 + - **-1**:执行失败 → 视为本次 SQL 不可用,**不交付**,进入重试;**不调用** Validator 语义审核 LLM + - 未配置 `database_url` 时跳过探针(可选告警),此时走 **LLM 语义验证** 作为兜底 +- **LLM 语义验证**:在未命中上述探针结果(即未配置库、未执行探针)时调用 DeepSeek,验证 SQL 语义是否符合用户意图 + - 构造包含 SQL、表结构和用户问题的提示 + - 让 LLM 评估 SQL 是否正确回答了用户问题 + - 收集错误和警告信息 +- **结果评估**:验证通过则返回有效 SQL,失败则进入重试逻辑 + - 最多重试 max\_retry 次 + - 每次重试使用相同的表结构,但重新生成 SQL + +### 5. 数据层支持 + +- **Schema 管理**:管理数据库表结构和元数据 + - 从 JSON 文件加载表结构 + - 提供表和列的查询接口 + - 分析表之间的外键关系 +- **向量数据库**:存储表和列的向量表示,用于快速检索 + - 使用 Chroma DB 存储向量 + - 支持增量更新和查询 +- **Few-shot 示例库**:存储高质量的自然语言到 SQL 的示例 + - 从 JSONL 文件加载示例 + - 支持基于相似度的示例检索 +- **DeepSeek API**:提供大语言模型能力,用于表选择、SQL 生成和验证 + - 调用 DeepSeek 聊天模型 + - 支持不同的模型参数配置 + +## 技术栈 + +| 类别 | 技术/库 | 用途 | +| ------ | ------------ | ----------- | +| 编程语言 | Python | 算法实现 | +| LLM | DeepSeek API | 提供大语言模型能力 | +| 向量检索 | Chroma DB | 存储和检索表的向量表示 | +| SQL 处理 | sqlglot | SQL 语法解析和验证 | +| 环境管理 | dotenv | 管理环境变量 | + +## 算法特点 + +1. **多智能体协作**:Schema Linker、SQL Generator、Validator 三个智能体协同工作,各负责专门任务,形成完整的处理流程 +2. **向量检索增强**:Schema Linker 使用向量数据库快速筛选相关表,提高表选择效率 +3. **Few-shot 学习**:SQL Generator 利用示例库增强 SQL 生成质量,学习最佳实践 +4. **多层验证**:程序校验 + 库上探针;探针为 1/0/-1 时不再走 Validator 语义 LLM(1 直接交付、0 附带无数据说明、-1 重试),未配置库时仍以 LLM 语义验证兜底 +5. **自动关联表扩展**:Schema Linker 通过外键关系自动扩展相关表,提高查询完整性 +6. **可配置性**:支持多种配置参数,如温度、最大重试次数、向量检索开关等,适应不同场景需求 + +## Few-shot 学习详细说明 + +### 概念介绍 + +Few-shot 学习是一种机器学习方法,指通过少量示例来指导模型学习和执行任务。与传统的监督学习需要大量标注数据不同,Few-shot 学习仅需提供少量(通常为个位数)的示例,就能让模型理解任务的模式和要求。 + +### 在 Text2SQL 系统中的应用 + +在 Text2SQL 系统中,Few-shot 学习主要应用于 SQL Generator 智能体,具体流程如下: + +1. **示例库构建**:系统维护一个存储高质量自然语言到 SQL 示例的库,从 JSONL 文件加载。这些示例包含各种类型的 SQL 查询场景,如简单查询、复杂连接、聚合操作等。 +2. **相似度匹配**:当用户提出自然语言问题时,系统会计算该问题与示例库中问题的语义相似度。 +3. **示例选择**:基于相似度排序,选择最相关的 top-k 个高质量示例。 +4. **提示增强**:将这些示例注入到给大语言模型的提示中,指导模型生成更准确的 SQL 语句。 +5. **生成指导**:模型参考示例的结构和风格,结合用户问题和表结构,生成符合要求的 SQL 语句。 + +### 技术实现 + +- **示例存储**:使用 JSONL 格式存储示例,每条示例包含自然语言问题和对应的 SQL 语句。 +- **相似度计算**:利用预训练的嵌入模型将问题转换为向量,计算向量相似度。 +- **示例选择**:根据相似度得分选择最相关的示例。 +- **提示构造**:将选中的示例与用户问题、表结构一起构造提示,确保模型能理解任务要求。 + +### 优势 + +1. **减少数据需求**:不需要大量的标注数据,仅需少量高质量示例。 +2. **提高生成质量**:通过示例指导,模型能生成更符合特定场景的 SQL 语句。 +3. **学习最佳实践**:示例库可以包含领域专家编写的高质量 SQL,使模型学习到最佳实践。 +4. **适应不同场景**:通过扩展示例库,可以适应不同领域和复杂度的 SQL 生成需求。 +5. **灵活性**:可以根据具体应用场景调整示例库,提高系统的适应性。 + +### 应用效果 + +通过 Few-shot 学习,Text2SQL 系统能够: + +- 处理更复杂的查询场景 +- 生成更符合用户意图的 SQL 语句 +- 减少生成错误 +- 提高系统的泛化能力 + +## 性能优化 + +1. **向量索引预构建**:提前构建向量索引,加速首次查询 +2. **缓存机制**:缓存常见查询的结果,提高响应速度 +3. **并行处理**:对多个查询进行并行处理,提高系统吞吐量 +4. **模型调优**:调整 LLM 参数,平衡生成质量和速度 + +## 扩展性 + +1. **支持多种数据库**:通过配置支持不同的 SQL 方言 +2. **可插拔的 LLM**:支持替换不同的大语言模型 +3. **自定义示例库**:可根据特定领域扩展示例库 + +## 总结 + +Text2SQL 算法通过多智能体协作、向量检索和大语言模型技术,实现了从自然语言到 SQL 的高效准确转换。算法流程清晰,逻辑完善,具有良好的可扩展性和可配置性,能够满足不同场景下的 SQL 生成需求。 diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..62c2373 --- /dev/null +++ b/tools/__init__.py @@ -0,0 +1,25 @@ +"""DBHub 风格数据库工具;实现位于 ``backend/db``,经 ``dbhub_tools`` 转发。""" + +from __future__ import annotations + +from .dbhub_tools import ( + DbHubTools, + dbhub_tools, + execute_sql, + execute_sql_all, + execute_sql_count_only, + probe_sql_execution_status, + probe_sql_execution_status_ex, + search_objects, +) + +__all__ = [ + "DbHubTools", + "dbhub_tools", + "execute_sql", + "execute_sql_all", + "execute_sql_count_only", + "probe_sql_execution_status", + "probe_sql_execution_status_ex", + "search_objects", +] diff --git a/tools/__pycache__/__init__.cpython-311.pyc b/tools/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2b482013cf7417129401fc55cb9797988ba37a04 GIT binary patch literal 654 zcmb`EziS&Y6vw5zv(Gzjij*JJ?%XHw;eDUreZQdnxw#rLwbwt3e_@2q zt++4ew!xPxrs*Sw2x6Ey2^J1?3sb^+>BrN9zgBvFHuKA7UHbq`MgY17uwJMl;{cg!!sthN7PW8O+EdP% zXWp`sMcKl)s901D>^c(w-~e43iLRb@o(0b(<7=BV;sIUfEgA_7*UscxywD`ahJHfK^nYCn%pNo4@kgUq=nI4M7?*I zw1re_l38nl4l+ltSbxjypd#KGn<5HXlBon&zmBb(j=|D3h`;3N^(x z=j(PaHB)a|iw7$K02GoL{*d^ihQV#4Wvl-J@Bu>%mQU* zR!SdGERop)i1Z%IN_C?fY9D}#3_!O5?o?$Z8Gv-nX0mC~u<$Jci)#y7~N7#s_5MLGiNVEJ*E~*F^KG`cv76q63+5y|2h#szv!0$rv^hET;hUO qIlC+mn>O|?>bSvA7i0VlL5)uAc>#9zkijpD-$#PbdF0DOrs^+YNVUZP literal 0 HcmV?d00001 diff --git a/tools/__pycache__/dbhub_allowed_keywords.cpython-311.pyc b/tools/__pycache__/dbhub_allowed_keywords.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a42f15a31b42e1da5ad70654bdb3a0ed62cf979c GIT binary patch literal 3985 zcmbtWTWl2989p&sx!Xzug$IKy#=~`1^;6C0}^Wf$iQCz`p7LB=HXu~ zlhywR=fEpD*ND|(jrFUxtO^LVU{xKlYKzSJSLj!}Wo^SN*47ETgy1K($$FthXoj{y zXca=xz9ujeh~1r*LA2-F`@anh9eytz3-O7Ba#0kz=EV7n3KrCEO$~kb=TE=A|JUzs z-~ZRm-~8wApFg>B=li?&LlZwA4Lw=9`Q$I3f3x_p=gF0izrX#KY;xyU-O6Qp4mA#Os5Cz~!o zRO48RiKdP8SlgCWrXz*JK4ChOJeGJN1}dFT zfH9^UT2;gsM7+=}NOA2>3P&6>f{(Xo$)hq(8LPk%Iaa%h6}nhr^Z z-J0o0rvx6@IRvm2TAP5ClxgP^a9*Y(jzu!*(r{Xilb$^(;u&aWFfYSqEM8FNU^5nF zJ}EBPqp|jb{m&?UfnlSug*SC9f<19Ds&>FQRB!+;g(U?`+Pn@D`FT-bVU|58$(lm; z?0%6gSnM9>HPA>dSyaoN_#iSEITjw_#s?#j;gjKsh3(N;@wTF|Gf_1w`2O_>+Z;D;E&JhDy}i4hsYv0UoDNn?_!FQ zh?l_x+|34m1|jNcH;i6GI#OwYnm}3^kPtdo?hCYzD&go7t<$(cr!$**0?T3%m`ZSy z0cI~TS_ziPnhcTuT+sn;x5%;SjBdL?VM{xm5$to8MEo<&g8w3mmvPg|K#aEXwd?jw z*}lz+fTb|L0nBB|jtd9}wK6<|4L@$bv!wZyRvm4&8wd>qvKGHd(!~ z?G|*uDt`*-SM>JsSH@fJZt7I-xa8EGxK`WPgmkA)Ro+C=KLi&5v^#usfr(CZU~&~^ z5y)723tUB5pGji~aaH7Te3nGMl1|}zvW1xuHN1=T0$egp;8vdDQpG?u>$!>H(cvQz z?pQc7wjQPCHg5RT_~_uVFgF+;96dcbT%NHfc@6k8qAZwpg1{-WhKtXN@i`7aNZ=GX zG0(bi3y8zJiA*YKdZs0;Y8-h@%&O9}!V%yz8BoKd#iZ%urvW#(Qv9m~tV5;pY-FfE zdPEV#i4?&oep>9}r6@0T&Ge|Tt{5MmOY(9Q9-OEKVW&o|2Z$>@J6!n{OU>h6q7)%- z!C!3$hgw92_q76|>`$*co3qa5N4~Z-U&pGiW99vv@2$M=ttHy<`U;3~zitHg zuF!WKcN|~3{^VNmta$Rlp0(iHtHHN(!GU~mVCjTW9R$g~*B`d^-Ag{0%C$xEZ4qn4 zXbdi=4R7^Q?^8H1a3p}k3Rttls0C{`p>wSQ&LDg!Z4+EUIMX@>(6q(T3ADW!)+&Q0 z19F}(m1{21+NJ>4L0)B0K`_w^eMgA1hNS5bZzVA2(;}V^Not6=Zx?ajP`i}~bwRQL z?kU9*BHJBi?breGt%$-s&@=5MO~RHxu?&8POj-`_EIl47(HMLf50KS0lDZKfVIN z&fk{zw=Ir5^6t1kl9|eRd-LAjtgRQ5O!Aq#oL9s*MN0>V<3-71k&GPYrB^jXo4hhW8r@K?71Swx`uns?`_cW1_bkIs2}^4^}T zt;fkpIt{P2$Z?ol z6y68Kbim^d=!nTVLC%Te-4>>F7cm8H4R8}fLWYUwZ#aZm%|S+5PGy0;yPWx$PU1&W z0&=!<3M8eTV_Cx&r15!3t}D03u@1#hvEG7Gu!ej@J;LM#v?zGEHdm&iIV z%g3I%4kQ&JoeYeK!KlTYxIhb}k>jdOW-H&#kh=c)lJ~3>dpQGPO6G z3|nMr;>xMZrI%vbIRE8aBr#Cw{OqE%bj8kSuC0RG~nN;p;G}y9|G)5oiG| z4-mx;z-;Y?f>y7jCEC`qjIS`KyYqxuS{lK3gA7ZCWx#JceR0XT>0zW@LL literal 0 HcmV?d00001 diff --git a/tools/__pycache__/dbhub_execute_sql.cpython-311.pyc b/tools/__pycache__/dbhub_execute_sql.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..36aefccb3a9b4fade04b9a54920d927bd38b97c1 GIT binary patch literal 6455 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 literal 0 HcmV?d00001 diff --git a/tools/__pycache__/dbhub_sql_parser.cpython-311.pyc b/tools/__pycache__/dbhub_sql_parser.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ebd2faa4c3211873e13852c50480fda8d687f4dc GIT binary patch literal 12088 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 literal 0 HcmV?d00001 diff --git a/tools/__pycache__/dbhub_tools.cpython-312.pyc b/tools/__pycache__/dbhub_tools.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d668696e80fc2a72d87e977f982af419ebad9391 GIT binary patch literal 1379 zcmZ`(U2NM_6uyp=+I9N7X5EiiWr2{^BG!S>v?9dB=tCb612Lq@igXjl7n015i|tGl z2`L%My0JFPR0e|$)OCv1siW(|M7JefA9>&@V$v!L9#CU9c`9QbVB^KUZ z)vNg=AA>xI_((n!R;h@$CUcYA5YV+h=~z1R}(DZO3hK7>6KU z2NAa75jMVeJNgu3JpbQ2@qOFT3e(ED;HhJ3^>S*r)+6<3y;85XL)xMBNqt&C3TQi} zom#)t&!CTy!p$+#t_gOKPyP&T5Jr!VC1tUCJ?qXE#Sh+pM|98Ks9c*W-_1IiFWgH@ z?)0LYo!huGx1KsJ2tz~8;tw0yC1?6l`EJVnKJVVo4GnD;zYB%raO5OOD8q^jPc$iG zOV?HN!1zQcv{^jsMi%{$#UT*XXy-tZX^r@&X==J zCJ!!i0ul~eQR>TO%Y`iX6q#C6naav7=fcI!;#4)g>SU&2r&i!F>_W4i@j>X*JgICVy%PX_a)pJDKHbs!(}ALsV`r zZ(OK){4VN`~p zAE)!N{Y}HN{Y?Y1yPFI;8@s>R@oX&i-v4jD-PJm+!QD28v7$#XwgrqoN?J*SU~I57 zLl)X^bF}|zOg7-QBbx_IOa+9^8N}4pQ-lHxlCVtMhYek~0MaPTZi|^%i5b9S8Mc@5 zfGc4V!?L>+`9M>l1_$H@PenTw^dDoug+@jP6`dexo=`N#H_;O?(LW<^zaIP$$jsoY zuN{14BsfaUla@Xnq|vo-;^jzKBWkdACW1|&-~!nE8=9^pRr01G0u!BvnFjGE%P`Di z56^i24j|@*btL?bcK^Y5oIP>A>k&Ut;s<`^#Yg=962E`Gx5U4c@~!!Maw8@Gb18m} z^-ms2A9=_IzU{kw;>y1H$nEIOXlc)(H7_?gmL8itmOhqy>!Nx2@XY5Ay)QoX@{H#x S0`ZuNzXH79lje*8$l_nP^Uz=b literal 0 HcmV?d00001 diff --git a/tools/dbhub_allowed_keywords.py b/tools/dbhub_allowed_keywords.py new file mode 100644 index 0000000..bad66dd --- /dev/null +++ b/tools/dbhub_allowed_keywords.py @@ -0,0 +1,90 @@ +""" +从 DBHub allowed-keywords.ts 等价移植:只读 SQL 判定。 +参见 dbhub/src/utils/allowed-keywords.ts + +与 ``backend/db/dbhub_allowed_keywords.py`` 保持一致;此处使用同目录 ``dbhub_sql_parser`` 导入, +便于在仅将 ``tools/`` 加入 ``sys.path`` 的脚本中单独使用。 +""" + +from __future__ import annotations + +import re +from typing import Literal + +from dbhub_sql_parser import ConnectorType, strip_comments_and_strings + +ALLOWED_KEYWORDS: dict[ConnectorType, list[str]] = { + "postgres": ["select", "with", "explain", "show"], + "mysql": ["select", "with", "explain", "show", "describe", "desc"], + "mariadb": ["select", "with", "explain", "show", "describe", "desc"], + "sqlite": ["select", "with", "explain", "pragma"], + "sqlserver": ["select", "with", "explain", "showplan"], +} + +_MUTATING = [ + "insert", + "update", + "delete", + "drop", + "alter", + "create", + "truncate", + "merge", + "grant", + "revoke", + "rename", +] +_mutating_pattern = re.compile(rf"\b(?:{'|'.join(_MUTATING)})\b", re.IGNORECASE) +_mutating_pattern_with_replace = re.compile( + rf"\b(?:{'|'.join(_MUTATING)}|replace\s+(?:(?:low_priority|delayed)\s+)?into)\b", + re.IGNORECASE, +) + +_MUTATING_PATTERNS: dict[ConnectorType, re.Pattern[str]] = { + "postgres": _mutating_pattern, + "mysql": _mutating_pattern_with_replace, + "mariadb": _mutating_pattern_with_replace, + "sqlite": _mutating_pattern_with_replace, + "sqlserver": _mutating_pattern, +} + +_SELECT_INTO_PATTERN = re.compile(r"\bselect\b[\s\S]+\binto\b", re.IGNORECASE) + +_EXPLAIN_ANALYZE_PATTERN = re.compile( + r"^explain\s+(?:\([^)]*\banalyze\b(?!\s*(?:=\s*)?(?:false|off|0)\b)[^)]*\)|\banalyze\b(?!\s*(?:=\s*)?(?:false|off|0)\b)(?:\s+verbose\b)?)", + re.IGNORECASE, +) + + +def _check_read_only(cleaned_sql: str, connector_type: ConnectorType | str) -> bool: + if not cleaned_sql: + return False + m = re.search(r"\S+", cleaned_sql) + first_word = m.group(0) if m else "" + keyword_list = ALLOWED_KEYWORDS.get(connector_type, []) # type: ignore[arg-type] + if first_word not in keyword_list: + return False + if first_word == "with": + pat = _MUTATING_PATTERNS.get(connector_type, _mutating_pattern) # type: ignore[arg-type] + if pat.search(cleaned_sql): + return False + if first_word in ("select", "with") and _SELECT_INTO_PATTERN.search(cleaned_sql): + return False + if first_word == "explain": + em = _EXPLAIN_ANALYZE_PATTERN.match(cleaned_sql) + if em: + after_explain = cleaned_sql[em.end() :].strip() + if after_explain and not _check_read_only(after_explain, connector_type): + return False + return True + + +def is_read_only_sql(sql: str, connector_type: ConnectorType | str) -> bool: + """Check if a SQL query is read-only (DBHub-compatible).""" + cleaned = strip_comments_and_strings(sql, connector_type if connector_type in ALLOWED_KEYWORDS else None) + cleaned = cleaned.strip().lower() + return _check_read_only(cleaned, connector_type) + + +def allowed_keywords_list(connector_type: ConnectorType | str) -> list[str]: + return ALLOWED_KEYWORDS.get(connector_type, []) # type: ignore[arg-type] diff --git a/tools/dbhub_sql_parser.py b/tools/dbhub_sql_parser.py new file mode 100644 index 0000000..cdf8b3a --- /dev/null +++ b/tools/dbhub_sql_parser.py @@ -0,0 +1,268 @@ +""" +从 DBHub sql-parser.ts 等价移植:按方言剥离注释/字符串、切分语句。 +参见 dbhub/src/utils/sql-parser.ts +""" + +from __future__ import annotations + +import re +from typing import Callable, Literal, TypedDict + +ConnectorType = Literal["postgres", "mysql", "mariadb", "sqlite", "sqlserver"] + + +class _Token(TypedDict): + type: int # 0 Plain, 1 Comment, 2 QuotedBlock + end: int + + +_TOKEN_PLAIN = 0 +_TOKEN_COMMENT = 1 +_TOKEN_QUOTED = 2 + + +def _plain_token(i: int) -> _Token: + return {"type": _TOKEN_PLAIN, "end": i + 1} + + +def _scan_single_line_comment(sql: str, i: int) -> _Token | None: + if i + 1 >= len(sql) or sql[i] != "-" or sql[i + 1] != "-": + return None + j = i + while j < len(sql) and sql[j] != "\n": + j += 1 + return {"type": _TOKEN_COMMENT, "end": j} + + +def _scan_multi_line_comment(sql: str, i: int) -> _Token | None: + if i + 1 >= len(sql) or sql[i] != "/" or sql[i + 1] != "*": + return None + j = i + 2 + while j + 1 < len(sql) and not (sql[j] == "*" and sql[j + 1] == "/"): + j += 1 + if j + 1 < len(sql): + j += 2 + return {"type": _TOKEN_COMMENT, "end": j} + + +def _scan_multi_line_comment_mysql(sql: str, i: int) -> _Token | None: + if i + 1 >= len(sql) or sql[i] != "/" or sql[i + 1] != "*": + return None + nxt = sql[i + 2] if i + 2 < len(sql) else "" + nxt2 = sql[i + 3] if i + 3 < len(sql) else "" + if nxt == "!" or (nxt == "M" and nxt2 == "!"): + return None + return _scan_multi_line_comment(sql, i) + + +def _scan_nested_multi_line_comment(sql: str, i: int) -> _Token | None: + if i + 1 >= len(sql) or sql[i] != "/" or sql[i + 1] != "*": + return None + j = i + 2 + depth = 1 + while j < len(sql) and depth > 0: + if j + 1 < len(sql) and sql[j] == "/" and sql[j + 1] == "*": + depth += 1 + j += 2 + elif j + 1 < len(sql) and sql[j] == "*" and sql[j + 1] == "/": + depth -= 1 + j += 2 + else: + j += 1 + return {"type": _TOKEN_COMMENT, "end": j} + + +def _scan_single_quoted_string(sql: str, i: int) -> _Token | None: + if sql[i] != "'": + return None + j = i + 1 + while j < len(sql): + if j + 1 < len(sql) and sql[j] == "'" and sql[j + 1] == "'": + j += 2 + elif sql[j] == "'": + j += 1 + break + else: + j += 1 + return {"type": _TOKEN_QUOTED, "end": j} + + +def _scan_double_quoted_string(sql: str, i: int) -> _Token | None: + if sql[i] != '"': + return None + j = i + 1 + while j < len(sql): + if j + 1 < len(sql) and sql[j] == '"' and sql[j + 1] == '"': + j += 2 + elif sql[j] == '"': + j += 1 + break + else: + j += 1 + return {"type": _TOKEN_QUOTED, "end": j} + + +_dollar_quote_open_regex = re.compile(r"^\$([a-zA-Z_]\w*)?\$") + + +def _scan_dollar_quoted_block(sql: str, i: int) -> _Token | None: + if sql[i] != "$": + return None + nxt = sql[i + 1] if i + 1 < len(sql) else "" + if nxt.isdigit(): + return None + remaining = sql[i:] + m = _dollar_quote_open_regex.match(remaining) + if not m: + return None + tag = m.group(0) + body_start = i + len(tag) + close_idx = sql.find(tag, body_start) + end = close_idx + len(tag) if close_idx != -1 else len(sql) + return {"type": _TOKEN_QUOTED, "end": end} + + +def _scan_backtick_quoted_identifier(sql: str, i: int) -> _Token | None: + if sql[i] != "`": + return None + j = i + 1 + while j < len(sql): + if j + 1 < len(sql) and sql[j] == "`" and sql[j + 1] == "`": + j += 2 + elif sql[j] == "`": + j += 1 + break + else: + j += 1 + return {"type": _TOKEN_QUOTED, "end": j} + + +def _scan_bracket_quoted_identifier(sql: str, i: int) -> _Token | None: + if sql[i] != "[": + return None + j = i + 1 + while j < len(sql): + if j + 1 < len(sql) and sql[j] == "]" and sql[j + 1] == "]": + j += 2 + elif sql[j] == "]": + j += 1 + break + else: + j += 1 + return {"type": _TOKEN_QUOTED, "end": j} + + +def _scan_token_ansi(sql: str, i: int) -> _Token: + return ( + _scan_single_line_comment(sql, i) + or _scan_multi_line_comment(sql, i) + or _scan_single_quoted_string(sql, i) + or _scan_double_quoted_string(sql, i) + or _plain_token(i) + ) + + +def _scan_token_postgres(sql: str, i: int) -> _Token: + return ( + _scan_single_line_comment(sql, i) + or _scan_nested_multi_line_comment(sql, i) + or _scan_single_quoted_string(sql, i) + or _scan_double_quoted_string(sql, i) + or _scan_dollar_quoted_block(sql, i) + or _plain_token(i) + ) + + +def _scan_token_mysql(sql: str, i: int) -> _Token: + return ( + _scan_single_line_comment(sql, i) + or _scan_multi_line_comment_mysql(sql, i) + or _scan_single_quoted_string(sql, i) + or _scan_double_quoted_string(sql, i) + or _scan_backtick_quoted_identifier(sql, i) + or _plain_token(i) + ) + + +def _scan_token_sqlite(sql: str, i: int) -> _Token: + return ( + _scan_single_line_comment(sql, i) + or _scan_multi_line_comment(sql, i) + or _scan_single_quoted_string(sql, i) + or _scan_double_quoted_string(sql, i) + or _scan_backtick_quoted_identifier(sql, i) + or _scan_bracket_quoted_identifier(sql, i) + or _plain_token(i) + ) + + +def _scan_token_sqlserver(sql: str, i: int) -> _Token: + return ( + _scan_single_line_comment(sql, i) + or _scan_multi_line_comment(sql, i) + or _scan_single_quoted_string(sql, i) + or _scan_double_quoted_string(sql, i) + or _scan_bracket_quoted_identifier(sql, i) + or _plain_token(i) + ) + + +_DIALECT_SCANNERS: dict[ConnectorType, Callable[[str, int], _Token]] = { + "postgres": _scan_token_postgres, + "mysql": _scan_token_mysql, + "mariadb": _scan_token_mysql, + "sqlite": _scan_token_sqlite, + "sqlserver": _scan_token_sqlserver, +} + + +def _get_scanner(dialect: ConnectorType | None) -> Callable[[str, int], _Token]: + if dialect and dialect in _DIALECT_SCANNERS: + return _DIALECT_SCANNERS[dialect] + return _scan_token_ansi + + +def strip_comments_and_strings(sql: str, dialect: ConnectorType | None = None) -> str: + """Replace comments, string literals, and dialect-specific quoted blocks with a single space each.""" + scan_token = _get_scanner(dialect) + parts: list[str] = [] + plain_start = -1 + i = 0 + n = len(sql) + while i < n: + token = scan_token(sql, i) + if token["type"] == _TOKEN_PLAIN: + if plain_start == -1: + plain_start = i + else: + if plain_start != -1: + parts.append(sql[plain_start:i]) + plain_start = -1 + parts.append(" ") + i = token["end"] + if plain_start != -1: + parts.append(sql[plain_start:]) + return "".join(parts) + + +def split_sql_statements(sql: str, dialect: ConnectorType | None = None) -> list[str]: + """Split SQL into individual statements, handling semicolons inside quoted contexts.""" + scan_token = _get_scanner(dialect) + statements: list[str] = [] + stmt_start = 0 + i = 0 + n = len(sql) + while i < n: + if sql[i] == ";": + trimmed = sql[stmt_start:i].strip() + if trimmed: + statements.append(trimmed) + stmt_start = i + 1 + i += 1 + continue + token = scan_token(sql, i) + i = token["end"] + trimmed = sql[stmt_start:].strip() + if trimmed: + statements.append(trimmed) + return statements diff --git a/tools/dbhub_tools.py b/tools/dbhub_tools.py new file mode 100644 index 0000000..d264091 --- /dev/null +++ b/tools/dbhub_tools.py @@ -0,0 +1,41 @@ +""" +DBHub 风格 SQL 执行与元数据探索。 + +**实现单一来源**:``backend/db/dbhub_tools.py``(含 ``execute_sql`` / ``execute_sql_all`` / +``execute_sql_count_only`` / ``search_objects`` / ``probe_sql_execution_status`` / ``probe_sql_execution_status_ex``)。 + +本文件将 ``backend`` 加入 ``sys.path`` 后从 ``db`` 包转发,避免 ``tools/`` 与 ``backend/db`` 双份漂移。 +若你在本仓库内迭代 DB 工具逻辑,请直接修改 ``backend/db/dbhub_tools.py``。 +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_root = Path(__file__).resolve().parents[1] +_backend = _root / "backend" +if _backend.is_dir() and str(_backend) not in sys.path: + sys.path.insert(0, str(_backend)) + +from db.dbhub_tools import ( # noqa: E402 + DbHubTools, + dbhub_tools, + execute_sql, + execute_sql_all, + execute_sql_count_only, + probe_sql_execution_status, + probe_sql_execution_status_ex, + search_objects, +) + +__all__ = [ + "DbHubTools", + "dbhub_tools", + "execute_sql", + "execute_sql_all", + "execute_sql_count_only", + "probe_sql_execution_status", + "probe_sql_execution_status_ex", + "search_objects", +]