""" 受控 ReAct Text2SQL 工具 核心思想:Agent负责推理,Tool负责SQL,Reviewer负责纠错 内部流程: 1. Schema Injection - 注入数据库表结构 2. SQL Generation - LLM生成SQL(使用 SQLAlchemy 连接池 + extra_info 辅助) 3. SQL Review - LLM审查SQL正确性 4. SQL Guard - 代码级安全校验(硬限制) 5. SQL Execute - 执行SQL并返回结果(使用 SQLAlchemy 连接池单例) 6. Merge - 相似结果合并(Embedding) """ import re import json import asyncio from datetime import datetime from typing import Dict, Any, Optional, List, Tuple from sqlalchemy import text from langchain_community.utilities import SQLDatabase from config import POSTGRES_CONNECTION_STRING,POSTGRES_SQLALCHEMY_URL from modelsAPI.model_api import OpenaiAPI from prompts import TEXT2SQL_PROMPTS from tools.texttosql_utils import get_extra_info # ============================================================ # 数据库连接池单例(避免重复初始化) # ============================================================ _db_instance: Optional[SQLDatabase] = None _db_lock = asyncio.Lock() async def _get_db() -> SQLDatabase: """懒加载并缓存数据库连接,避免重复初始化""" global _db_instance if _db_instance is None: async with _db_lock: if _db_instance is None: _db_instance = await asyncio.to_thread( SQLDatabase.from_uri, POSTGRES_SQLALCHEMY_URL ) return _db_instance # ============================================================ # Step 1: Schema Injection # ============================================================ FAULT_RECORDS_SCHEMA = { "table_name": "fault_records", "description": "故障维修记录表,存储所有故障诊断和维修的记录", "columns": { "id": { "type": "SERIAL", "description": "自增主键", "usable_in_query": False }, "ship_number": { "type": "TEXT", "description": "舰船舷号,如'101'、'163'、'554'等,2-4位数字", "usable_in_query": True, "examples": ["101", "163", "554", "602"] }, "device_name": { "type": "TEXT", "description": "设备名称,如'主机'、'发电机'、'分油机'、'空压机'等", "usable_in_query": True, "examples": ["主机", "发电机", "分油机", "空压机", "舵机", "锅炉"] }, "fault": { "type": "TEXT", "description": "故障现象描述,如'滑油压力低'、'异常振动'、'启动失败'等", "usable_in_query": True, "examples": ["滑油压力低", "异常振动", "启动失败", "排烟温度高"] }, "spare_parts": { "type": "JSONB", "description": "消耗的备件列表,JSON数组格式,如'[\"密封圈\", \"滤芯\"]'", "usable_in_query": True, "json_query_hint": "使用 jsonb_array_elements_text(spare_parts) 展开备件进行统计" }, "system_name": { "type": "TEXT", "description": "所属系统名称,如'动力系统'、'电气系统'、'导航系统'等", "usable_in_query": True, "examples": ["动力系统", "电气系统", "导航系统", "通信系统", "其他"] }, "created_at": { "type": "TIMESTAMP", "description": "记录创建时间,默认为当前时间", "usable_in_query": True, "examples": ["2025-01-15 10:30:00"] } } } # 字段白名单(SQL Guard 使用) ALLOWED_COLUMNS = set(FAULT_RECORDS_SCHEMA["columns"].keys()) def get_schema_prompt_section() -> str: """生成 Schema 注入的提示词部分""" lines = [f"数据库表:{FAULT_RECORDS_SCHEMA['table_name']}"] lines.append(f"说明:{FAULT_RECORDS_SCHEMA['description']}") lines.append("") lines.append("字段列表:") for col_name, col_info in FAULT_RECORDS_SCHEMA["columns"].items(): if not col_info.get("usable_in_query", True): continue line = f" - {col_name} ({col_info['type']}): {col_info['description']}" if "examples" in col_info: line += f"(示例:{', '.join(col_info['examples'][:3])})" if "json_query_hint" in col_info: line += f"\n 查询提示:{col_info['json_query_hint']}" lines.append(line) return "\n".join(lines) # ============================================================ # Step 2: SQL Generation # ============================================================ async def generate_sql(question: str, current_date: str) -> Dict[str, Any]: """ 使用 LLM 根据用户问题生成 SQL。 并发预热数据库连接与获取 extra_info,提升整体响应速度。 Returns: {"sql": "SELECT ...", "success": True/False, "error": "..."} """ schema_section = get_schema_prompt_section() # 并发:获取辅助信息 + 预热DB连接 extra_info, _ = await asyncio.gather( get_extra_info(question), _get_db(), ) prompt = TEXT2SQL_PROMPTS["sql_generation"].format( schema=schema_section, question=question, current_date=current_date, extra_info=extra_info or "" ) try: result = await OpenaiAPI.open_api_chat_without_thinking( prompt, model=None, json_output=True ) # 提取 JSON json_match = re.search(r'\{.*\}', result, re.DOTALL) if json_match: parsed = json.loads(json_match.group(0)) sql = parsed.get("sql", "").strip() if sql: return {"sql": sql, "success": True, "error": ""} # 兜底:直接提取 SELECT 语句 sql_match = re.search(r'(SELECT\s+.+?;?)$', result.strip(), re.IGNORECASE | re.DOTALL) if sql_match: sql = sql_match.group(1).strip().rstrip(';') return {"sql": sql, "success": True, "error": ""} return {"sql": "", "success": False, "error": "LLM未返回有效SQL"} except Exception as e: return {"sql": "", "success": False, "error": f"SQL生成失败: {str(e)}"} # ============================================================ # Step 3: SQL Review (LLM 审查) # ============================================================ async def review_sql(question: str, sql: str) -> Dict[str, Any]: """ 使用 LLM 审查 SQL 是否真正回答了用户问题 Returns: { "approved": True/False, "issues": ["问题1", "问题2"], "suggested_sql": "修正后的SQL(如果有问题)", "review_comment": "审查意见" } """ from modelsAPI.model_api import OpenaiAPI from prompts import TEXT2SQL_PROMPTS schema_section = get_schema_prompt_section() prompt = TEXT2SQL_PROMPTS["sql_review"].format( schema=schema_section, question=question, sql=sql ) try: result = await OpenaiAPI.open_api_chat_without_thinking( prompt, model=None, json_output=True ) json_match = re.search(r'\{.*\}', result, re.DOTALL) if json_match: parsed = json.loads(json_match.group(0)) approved = parsed.get("approved", False) issues = parsed.get("issues", []) suggested_sql = parsed.get("suggested_sql", "").strip() review_comment = parsed.get("review_comment", "") if suggested_sql: suggested_sql = suggested_sql.rstrip(';') return { "approved": approved, "issues": issues, "suggested_sql": suggested_sql, "review_comment": review_comment } return { "approved": True, "issues": [], "suggested_sql": "", "review_comment": "审查结果解析失败,默认通过" } except Exception as e: return { "approved": True, "issues": [], "suggested_sql": "", "review_comment": f"审查过程异常,默认通过: {str(e)}" } # ============================================================ # Step 4: SQL Guard (代码级安全校验) # ============================================================ class SQLGuardError(Exception): """SQL Guard 校验失败异常""" pass def guard_sql(sql: str) -> Tuple[bool, str, str]: """ SQL 安全守卫 - 代码硬限制 Returns: (passed, cleaned_sql, error_message) """ if not sql or not sql.strip(): return False, "", "SQL为空" sql_upper = sql.strip().upper() # 1. 只允许 SELECT if not sql_upper.startswith("SELECT"): return False, "", "只允许SELECT查询,禁止INSERT/UPDATE/DELETE/CREATE等操作" # 2. 禁止 UNION if "UNION" in sql_upper: return False, "", "禁止使用UNION操作" # 3. 禁止子查询嵌套过深(最多2层) subquery_count = sql_upper.count("SELECT") - 1 if subquery_count > 2: return False, "", f"子查询嵌套过深({subquery_count}层),最多允许2层" # 4. 禁止危险函数 dangerous_functions = ["PG_SLEEP", "SLEEP", "BENCHMARK", "WAITFOR", "DELAY"] for func in dangerous_functions: if func in sql_upper: return False, "", f"禁止使用危险函数: {func}" # 5. 禁止系统表访问 system_tables = ["PG_", "INFORMATION_SCHEMA", "PG_CATALOG", "PG_CLASS"] for sys_table in system_tables: if sys_table in sql_upper: return False, "", f"禁止访问系统表: {sys_table}" # 6. 禁止注释注入 if "--" in sql or "/*" in sql or "*/" in sql: return False, "", "SQL中不允许包含注释" # 7. 禁止分号(防止多语句注入) if ";" in sql.rstrip(';'): return False, "", "禁止多语句执行" # 8. 检查字段白名单 used_columns = set() dot_matches = re.findall(r'fault_records\.(\w+)', sql, re.IGNORECASE) used_columns.update(m.lower() for m in dot_matches) select_match = re.search(r'SELECT\s+(.*?)\s+FROM', sql, re.IGNORECASE | re.DOTALL) if select_match: select_part = select_match.group(1) if '*' not in select_part: for part in select_part.split(','): part = part.strip() col_match = re.search(r'(\w+)\s*(?:AS|as)?\s*$', part) if col_match: col_name = col_match.group(1).lower() if col_name not in ('count', 'sum', 'avg', 'max', 'min', 'as', 'desc', 'asc', 'null', 'distinct', 'all', 'jsonb_array_elements_text', 'jsonb_array_elements', 'coalesce', 'cast', 'date', 'timestamp', 'text'): used_columns.add(col_name) for col in used_columns: if col not in ALLOWED_COLUMNS and col != 'id': return False, "", f"使用了不允许的字段: {col}" # 9. 自动添加 LIMIT(如果没有) cleaned_sql = sql.strip().rstrip(';') if "LIMIT" not in sql_upper: cleaned_sql = f"{cleaned_sql} LIMIT 100" # 10. 强制只查 fault_records 表 from_match = re.search(r'FROM\s+(\w+)', sql, re.IGNORECASE) if from_match: table_name = from_match.group(1).lower() if table_name != "fault_records": return False, "", f"只允许查询 fault_records 表,不允许查询: {table_name}" return True, cleaned_sql, "" # ============================================================ # Step 5: SQL Execute # ============================================================ async def execute_sql(sql: str) -> Dict[str, Any]: """ 执行 SQL 并返回结果。 使用模块级 SQLAlchemy 连接池单例,避免每次重复建立连接。 Returns: {"rows": [...], "columns": [...], "row_count": int, "success": True/False, "error": ""} """ try: db = await _get_db() def _run() -> Dict[str, Any]: try: with db._engine.connect() as conn: result = conn.execute(text(sql)) columns = list(result.keys()) result_rows = [] for row in result.fetchall(): row_dict = {} for i, col in enumerate(columns): val = row[i] if isinstance(val, datetime): val = val.isoformat() elif isinstance(val, (list, dict)): val = json.dumps(val, ensure_ascii=False) elif not isinstance(val, (str, int, float, bool, type(None))): val = str(val) row_dict[col] = val result_rows.append(row_dict) conn.commit() return { "rows": result_rows, "columns": columns, "row_count": len(result_rows), "success": True, "error": "" } except Exception as e: return { "rows": [], "columns": [], "row_count": 0, "success": False, "error": f"SQL执行失败: {str(e)}" } return await asyncio.to_thread(_run) except Exception as e: return { "rows": [], "columns": [], "row_count": 0, "success": False, "error": f"获取数据库连接失败: {str(e)}" } # ============================================================ # Text2SQL 主入口 - 内部闭环 # ============================================================ MAX_RETRY = 2 # 最大重试次数(生成→审查→修正) async def text2sql_tool(question: str, current_date: Optional[str] = None) -> Dict[str, Any]: """ 受控 Text2SQL 工具 - 内部形成闭环 流程:question → SQL生成 → SQL审查 → SQL Guard → SQL执行 → 返回结果 Args: question: 用户问题 current_date: 当前日期(格式:YYYY-MM-DD),默认为今天 Returns: { "success": True/False, "sql": "最终执行的SQL", "rows": [...], "columns": [...], "row_count": int, "error": "", "review_log": [...] # 审查日志 } """ if not current_date: current_date = datetime.now().strftime("%Y-%m-%d") review_log = [] current_sql = "" for attempt in range(MAX_RETRY + 1): # Step 2: SQL Generation if attempt == 0: gen_result = await generate_sql(question, current_date) else: gen_result = await generate_sql_with_feedback( question, current_date, current_sql, review_log ) if not gen_result["success"]: return { "success": False, "sql": "", "rows": [], "columns": [], "row_count": 0, "error": gen_result["error"], "review_log": review_log } current_sql = gen_result["sql"] # Step 3: SQL Review review_result = await review_sql(question, current_sql) review_log.append({ "attempt": attempt + 1, "sql": current_sql, "approved": review_result["approved"], "issues": review_result["issues"], "comment": review_result["review_comment"] }) if not review_result["approved"]: if review_result["suggested_sql"]: current_sql = review_result["suggested_sql"] review_log.append({ "attempt": attempt + 1, "sql": current_sql, "note": "使用审查建议的修正SQL" }) elif attempt < MAX_RETRY: continue else: return { "success": False, "sql": current_sql, "rows": [], "columns": [], "row_count": 0, "error": f"SQL审查不通过: {'; '.join(review_result['issues'])}", "review_log": review_log } # Step 4: SQL Guard passed, cleaned_sql, guard_error = guard_sql(current_sql) if not passed: review_log.append({ "attempt": attempt + 1, "sql": current_sql, "guard_passed": False, "guard_error": guard_error }) if attempt < MAX_RETRY: review_log.append({"note": f"Guard拒绝: {guard_error},将重新生成"}) continue else: return { "success": False, "sql": current_sql, "rows": [], "columns": [], "row_count": 0, "error": f"SQL安全校验失败: {guard_error}", "review_log": review_log } current_sql = cleaned_sql review_log.append({ "attempt": attempt + 1, "sql": current_sql, "guard_passed": True, "note": "Guard通过" }) # Step 5: SQL Execute exec_result = await execute_sql(current_sql) if not exec_result["success"]: if attempt < MAX_RETRY: review_log.append({ "attempt": attempt + 1, "sql": current_sql, "exec_error": exec_result["error"], "note": "执行失败,将重新生成" }) continue else: return { "success": False, "sql": current_sql, "rows": [], "columns": [], "row_count": 0, "error": exec_result["error"], "review_log": review_log } # Step 6: 相似结果合并 merged_rows = await merge_similar_rows( exec_result["rows"], exec_result["columns"], question ) return { "success": True, "sql": current_sql, "rows": merged_rows, "columns": exec_result["columns"], "row_count": len(merged_rows), "raw_row_count": exec_result["row_count"], "error": "", "review_log": review_log } return { "success": False, "sql": current_sql, "rows": [], "columns": [], "row_count": 0, "error": "超过最大重试次数", "review_log": review_log } async def generate_sql_with_feedback( question: str, current_date: str, previous_sql: str, review_log: List[Dict[str, Any]] ) -> Dict[str, Any]: """ 基于审查反馈重新生成 SQL """ from modelsAPI.model_api import OpenaiAPI from prompts import TEXT2SQL_PROMPTS schema_section = get_schema_prompt_section() feedback_parts = [] for log_entry in review_log: if "issues" in log_entry and log_entry["issues"]: feedback_parts.append(f"问题: {'; '.join(log_entry['issues'])}") if "guard_error" in log_entry: feedback_parts.append(f"安全校验失败: {log_entry['guard_error']}") if "exec_error" in log_entry: feedback_parts.append(f"执行失败: {log_entry['exec_error']}") feedback = "\n".join(feedback_parts) if feedback_parts else "无具体反馈" prompt = TEXT2SQL_PROMPTS["sql_regeneration"].format( schema=schema_section, question=question, current_date=current_date, previous_sql=previous_sql, feedback=feedback ) try: result = await OpenaiAPI.open_api_chat_without_thinking( prompt, model=None, json_output=True ) json_match = re.search(r'\{.*\}', result, re.DOTALL) if json_match: parsed = json.loads(json_match.group(0)) sql = parsed.get("sql", "").strip() if sql: return {"sql": sql, "success": True, "error": ""} sql_match = re.search(r'(SELECT\s+.+?;?)$', result.strip(), re.IGNORECASE | re.DOTALL) if sql_match: sql = sql_match.group(1).strip().rstrip(';') return {"sql": sql, "success": True, "error": ""} return {"sql": "", "success": False, "error": "重新生成SQL失败"} except Exception as e: return {"sql": "", "success": False, "error": f"重新生成SQL异常: {str(e)}"} # ============================================================ # Step 6: 相似结果合并(Embedding) # ============================================================ MERGE_KEYWORDS = ["故障", "频次", "排名", "高频", "最多", "top", "发生次数"] def _should_merge(columns: List[str], question: str) -> bool: """判断是否需要合并相似行""" question_lower = question.lower() if any(kw in question_lower for kw in MERGE_KEYWORDS): col_names = [c.lower() for c in columns] has_name = any(n in col_names for n in ["name", "故障", "fault", "device_name"]) has_count = any(n in col_names for n in ["count", "次数", "频次"]) return has_name and has_count return False def _find_name_column(columns: List[str]) -> Optional[str]: """找到名称列""" priority = ["name", "故障名称", "故障", "fault", "device_name", "备件", "spare"] col_lower_map = {c.lower(): c for c in columns} for p in priority: if p in col_lower_map: return col_lower_map[p] for c in columns: if c.lower() not in ("count", "次数", "频次", "rank", "排名", "system_name", "系统"): return c return None def _find_count_column(columns: List[str]) -> Optional[str]: """找到计数列""" priority = ["count", "次数", "频次", "cnt"] col_lower_map = {c.lower(): c for c in columns} for p in priority: if p in col_lower_map: return col_lower_map[p] return None def _find_system_column(columns: List[str]) -> Optional[str]: """找到系统分类列""" priority = ["system_name", "系统", "系统分类", "system"] col_lower_map = {c.lower(): c for c in columns} for p in priority: if p in col_lower_map: return col_lower_map[p] return None async def merge_similar_rows( rows: List[Dict[str, Any]], columns: List[str], question: str, similarity_threshold: float = 0.85 ) -> List[Dict[str, Any]]: """ 使用 embedding 合并相似的故障/备件行 例如: - "连杆大端轴承发生损坏异常" (7次, 其他) + "连杆大端轴承发生损坏异常" (6次, 汽车配件) → "连杆大端轴承发生损坏异常" (13次, 其他) - "发动机中空冷器管束芯体结垢或堵塞..." + "空冷器管束芯体结垢或堵塞..." → 合并为一条 """ if not rows or len(rows) <= 1: return rows if not _should_merge(columns, question): return rows name_col = _find_name_column(columns) count_col = _find_count_column(columns) system_col = _find_system_column(columns) if not name_col or not count_col: return rows try: from modelsAPI.model_api import OpenaiAPI names = [row[name_col] for row in rows if row.get(name_col)] if len(names) <= 1: return rows embeddings = await OpenaiAPI.get_embeddings_batch_async(names) name_emb_map = {name: emb for name, emb in zip(names, embeddings)} sorted_rows = sorted(rows, key=lambda r: int(r.get(count_col, 0)), reverse=True) merged_rows = [] merged_indices = set() for i, row in enumerate(sorted_rows): if i in merged_indices: continue name_i = row.get(name_col, "") emb_i = name_emb_map.get(name_i) if not emb_i: merged_rows.append(row) continue merged_row = dict(row) total_count = int(row.get(count_col, 0)) systems = set() if system_col and row.get(system_col): systems.add(row[system_col]) for j in range(i + 1, len(sorted_rows)): if j in merged_indices: continue name_j = sorted_rows[j].get(name_col, "") emb_j = name_emb_map.get(name_j) if not emb_j: continue similarity = OpenaiAPI.cosine_similarity(emb_i, emb_j) if similarity >= similarity_threshold: total_count += int(sorted_rows[j].get(count_col, 0)) if system_col and sorted_rows[j].get(system_col): systems.add(sorted_rows[j][system_col]) merged_indices.add(j) merged_row[count_col] = total_count if system_col and systems: merged_row[system_col] = "、".join(sorted(systems)) merged_rows.append(merged_row) merged_indices.add(i) merged_rows.sort(key=lambda r: int(r.get(count_col, 0)), reverse=True) if "rank" in columns or "排名" in columns: rank_col = "rank" if "rank" in columns else "排名" for idx, row in enumerate(merged_rows): row[rank_col] = idx + 1 return merged_rows except Exception as e: print(f"Embedding合并失败,使用原始数据: {str(e)}") return rows