777 lines
26 KiB
Python
777 lines
26 KiB
Python
"""
|
||
受控 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
|
||
|
||
|