Initial commit: 初始化项目代码
BIN
__pycache__/app.cpython-312.pyc
Normal file
BIN
__pycache__/checkpointer_config.cpython-312.pyc
Normal file
BIN
__pycache__/chunk_text.cpython-312.pyc
Normal file
BIN
__pycache__/config.cpython-312.pyc
Normal file
BIN
__pycache__/doc2pdf.cpython-312.pyc
Normal file
BIN
__pycache__/documents_prompt.cpython-312.pyc
Normal file
BIN
__pycache__/documents_prompt.cpython-313.pyc
Normal file
BIN
__pycache__/fileparse_util.cpython-312.pyc
Normal file
BIN
__pycache__/main_agent.cpython-312.pyc
Normal file
BIN
__pycache__/prompts.cpython-312.pyc
Normal file
BIN
__pycache__/workflow_registry.cpython-312.pyc
Normal file
361
agent_create_request.py
Normal file
@ -0,0 +1,361 @@
|
||||
# create_agents.py
|
||||
import requests
|
||||
import urllib3
|
||||
from uuid import uuid4
|
||||
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
BASE_URL = "http://192.168.0.46:22000"
|
||||
AGENT_API = f"{BASE_URL}/api/v1/agents/"
|
||||
HEADERS = {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
TIMEOUT = 10
|
||||
OUTPUT_FILE = "agent_ids.txt"
|
||||
|
||||
AGENT_META = {
|
||||
str(uuid4()): {
|
||||
"icon_index": 1,
|
||||
"name": "修理工程管理",
|
||||
"description": "对舰船修理工程实施全过程管理,包括任务分解、进度控制、工序协调和资源调配。",
|
||||
"prompt": "助理员您好,我是修理工程管理助手,我已经绑定了技术资料、通用知识、故障知识、法规标准知识库。我可以为您提供舰船设备的标准修理内容范围、作业工序清单以及安全施工要求,请直接告诉我您需要查询的设备名称。",
|
||||
"default_query": [
|
||||
{"title": "查询设备标准 →", "content": "查询设备标准"},
|
||||
{"title": "查看作业工序 →", "content": "查看作业工序"}
|
||||
],
|
||||
},
|
||||
str(uuid4()): {
|
||||
"icon_index": 2,
|
||||
"name": "器材统计与分析",
|
||||
"description": "对舰船维修保障过程中涉及的器材进行统计分析与趋势研判。",
|
||||
"prompt": "助理员您好,我是器材统计与分析助手。我已经绑定了技术资料、通用知识、故障知识、法规标准知识库。为您提供多维度的器材消耗统计与库存分析服务。请告诉我您需要统计的具体业务场景。",
|
||||
"default_query": [
|
||||
{"title": "统计第一季度器材消耗 →", "content": "统计第一季度器材消耗"},
|
||||
{"title": "分析当前库存周转率 →", "content": "分析当前库存周转率"},
|
||||
{"title": "查看特定型号历史记录 →", "content": "查看特定型号历史记录"}
|
||||
],
|
||||
},
|
||||
str(uuid4()): {
|
||||
"icon_index": 3,
|
||||
"name": "修理法规知识查询",
|
||||
"description": "提供舰船修理相关法规、标准和制度的查询与解读。",
|
||||
"prompt": "您好!我是修理法规知识查询助手。我已经绑定了技术资料、通用知识、故障知识、法规标准知识库。请告诉我需要查询的主题。",
|
||||
"default_query": [
|
||||
{"title": "查询焊接工艺规程 →", "content": "查询焊接工艺规程"},
|
||||
{"title": "查看设备拆装安全规程 →", "content": "查看设备拆装安全规程"},
|
||||
{"title": "搜索环保作业规定 →", "content": "搜索环保作业规定"}
|
||||
],
|
||||
},
|
||||
str(uuid4()): {
|
||||
"icon_index": 4,
|
||||
"name": "船舰知识查询",
|
||||
"description": "提供舰船结构、系统组成与功能原理等知识。",
|
||||
"prompt": "您好!我是舰船知识查询助手。我已经绑定了技术资料、通用知识、故障知识、法规标准知识库。请告诉我需要了解的主题。",
|
||||
"default_query": [
|
||||
{"title": "了解舰船电力系统组成 →", "content": "了解舰船电力系统组成"},
|
||||
{"title": "查询舰船推进装置类型 →", "content": "查询舰船推进装置类型"},
|
||||
{"title": "搜索舰船雷达系统原理 →", "content": "搜索舰船雷达系统原理"}
|
||||
],
|
||||
},
|
||||
str(uuid4()): {
|
||||
"icon_index": 5,
|
||||
"name": "故障排查与修理",
|
||||
"description": "辅助进行舰船设备故障分析、原因定位与修理方案制定。",
|
||||
"prompt": "您好!我是智能故障排查与修理助手。我已经绑定了技术资料、通用知识、故障知识、法规标准知识库。提供舷号、设备名称和故障现象,我可以协助您进行复杂故障的研判诊断。",
|
||||
"default_query": [
|
||||
{"title": "多模态输出与校验 →", "content": "多模态输出与校验"},
|
||||
{"title": "诊断研判与可能性排序 →", "content": "诊断研判与可能性排序"},
|
||||
{"title": "GT-25000燃气轮机的日常维护保养方案 →", "content": "GT-25000燃气轮机的日常维护保养方案"}
|
||||
],
|
||||
},
|
||||
str(uuid4()): {
|
||||
"icon_index": 6,
|
||||
"name": "舰船机电知识",
|
||||
"description": "提供舰船动力、电气、液压等机电系统相关知识。",
|
||||
"prompt": "机电长您好,我是舰船机电知识查询助手。我已经绑定了技术资料、通用知识、故障知识、法规标准知识库。请告诉我需了解的主题。",
|
||||
"default_query": [
|
||||
{"title": "了解主推进系统构成 →", "content": "了解主推进系统构成"},
|
||||
{"title": "查看舰艇液压机参数 →", "content": "查看舰艇液压机参数"},
|
||||
{"title": "查看舰机液压系统参数 →", "content": "查看舰机液压系统参数"}
|
||||
],
|
||||
},
|
||||
str(uuid4()): {
|
||||
"icon_index": 7,
|
||||
"name": "现场维修作业向导",
|
||||
"description": "为一线维修人员提供现场操作流程与安全指导。",
|
||||
"prompt": "您好!我是现场维修作业向导。我已经绑定了技术资料、通用知识、故障知识、法规标准知识库。您可以询问具体的维修步骤、拆装工艺或查询安全操作规程。",
|
||||
"default_query": [
|
||||
{"title": "离心泵汽蚀的典型特征 →", "content": "离心泵汽蚀的典型特征"},
|
||||
{"title": "海水泵汽蚀的维修步骤 →", "content": "海水泵汽蚀的维修步骤"},
|
||||
{"title": "GT-25000燃气轮机的日常维护步骤 →", "content": "GT-25000燃气轮机的日常维护步骤"}
|
||||
],
|
||||
},
|
||||
str(uuid4()): {
|
||||
"icon_index": 8,
|
||||
"name": "舰船技术知识百科",
|
||||
"description": "综合性舰船技术知识库。",
|
||||
"prompt": "您好,我是舰船技术知识百科助手。我已经绑定了技术资料、通用知识、故障知识、法规标准知识库。请告诉我您需要查询的技术主题。",
|
||||
"default_query": [
|
||||
{"title": "了解船体高强钢性能 →", "content": "了解船体高强钢性能"},
|
||||
{"title": "查询焊接工艺评定标准 →", "content": "查询焊接工艺评定标准"},
|
||||
{"title": "查看防腐涂装技术规范 →", "content": "查看防腐涂装技术规范"}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_agent_json(agent_id: str) -> dict:
|
||||
meta = AGENT_META[agent_id]
|
||||
return {
|
||||
"id": agent_id,
|
||||
"name": meta["name"],
|
||||
"description": meta["description"],
|
||||
"prompt": meta["prompt"],
|
||||
"background": "string",
|
||||
"access_control": {},
|
||||
"default_query": meta["default_query"],
|
||||
}
|
||||
|
||||
|
||||
def create_agent(agent_id: str) -> bool:
|
||||
resp = requests.post(
|
||||
AGENT_API,
|
||||
headers=HEADERS,
|
||||
json=build_agent_json(agent_id),
|
||||
verify=False,
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
print(resp.json())
|
||||
print(f"[CREATE] {agent_id} -> {resp.status_code}")
|
||||
|
||||
return resp.status_code in (200, 201, 409)
|
||||
|
||||
|
||||
def main():
|
||||
created_ids = []
|
||||
|
||||
for agent_id in AGENT_META.keys():
|
||||
if create_agent(agent_id):
|
||||
created_ids.append(agent_id)
|
||||
|
||||
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
|
||||
for aid in created_ids:
|
||||
f.write(aid + "\n")
|
||||
|
||||
print(f"\n✅ 已写入 {len(created_ids)} 个 Agent UUID 到 {OUTPUT_FILE}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
|
||||
|
||||
# import requests
|
||||
# import urllib3
|
||||
# from uuid import uuid4
|
||||
# from typing import List, Tuple
|
||||
|
||||
# # =========================
|
||||
# # 0. 关闭 HTTPS 警告(开发环境)
|
||||
# # =========================
|
||||
# urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
# # =========================
|
||||
# # 1. 基础配置
|
||||
# # =========================
|
||||
|
||||
# BASE_URL = "https://devb.zdht.hjkl01.cn:65432"
|
||||
|
||||
# AGENT_API = f"{BASE_URL}/api/v1/agents/"
|
||||
# ROLE_BIND_API = f"{BASE_URL}/api/v1/agent-roles/association"
|
||||
|
||||
# HEADERS = {
|
||||
# "Accept": "application/json",
|
||||
# "Content-Type": "application/json",
|
||||
# }
|
||||
|
||||
# TIMEOUT = 10
|
||||
|
||||
# # =========================
|
||||
# # 2. 角色 ID(数据库真实存在)
|
||||
# # =========================
|
||||
|
||||
# AGENT_ROLE_1 = "2" # 助理员助手 (1-4)
|
||||
# AGENT_ROLE_2 = "3" # 机电长助手 (5-6)
|
||||
# AGENT_ROLE_3 = "4" # 维修员助手 (7-8)
|
||||
|
||||
# ROLE_NAME_MAP = {
|
||||
# AGENT_ROLE_1: "助理员助手",
|
||||
# AGENT_ROLE_2: "机电长助手",
|
||||
# AGENT_ROLE_3: "维修员助手",
|
||||
# }
|
||||
|
||||
# # =========================
|
||||
# # 3. Agent 元数据
|
||||
# # =========================
|
||||
|
||||
# AGENT_META = {
|
||||
# str(uuid4()): {
|
||||
# "icon_index": 1,
|
||||
# "name": "修理工程管理",
|
||||
# "description": "对舰船修理工程实施全过程管理,包括任务分解、进度控制、工序协调和资源调配。",
|
||||
# "prompt": "您好!我是一名舰船修理工程管理专家。",
|
||||
# },
|
||||
# str(uuid4()): {
|
||||
# "icon_index": 2,
|
||||
# "name": "器材统计与分析",
|
||||
# "description": "对舰船维修保障过程中涉及的器材进行统计分析与趋势研判。",
|
||||
# "prompt": "您好!我是一名舰船器材统计与分析专家。",
|
||||
# },
|
||||
# str(uuid4()): {
|
||||
# "icon_index": 3,
|
||||
# "name": "修理法规知识查询",
|
||||
# "description": "提供舰船修理相关法规、标准和制度的查询与解读。",
|
||||
# "prompt": "您好!我是一名舰船修理法规专家。",
|
||||
# },
|
||||
# str(uuid4()): {
|
||||
# "icon_index": 4,
|
||||
# "name": "船舰知识查询",
|
||||
# "description": "提供舰船结构、系统组成与功能原理等知识。",
|
||||
# "prompt": "您好!我是一名舰船专业知识顾问。",
|
||||
# },
|
||||
# str(uuid4()): {
|
||||
# "icon_index": 5,
|
||||
# "name": "故障排查与修理",
|
||||
# "description": "辅助进行舰船设备故障分析、原因定位与修理方案制定。",
|
||||
# "prompt": "您好!我是一名舰船设备故障诊断专家。",
|
||||
# },
|
||||
# str(uuid4()): {
|
||||
# "icon_index": 6,
|
||||
# "name": "舰船机电知识",
|
||||
# "description": "提供舰船动力、电气、液压等机电系统相关知识。",
|
||||
# "prompt": "您好!我是一名舰船机电系统专家。",
|
||||
# },
|
||||
# str(uuid4()): {
|
||||
# "icon_index": 7,
|
||||
# "name": "现场维修作业向导",
|
||||
# "description": "为一线维修人员提供现场操作流程与安全指导。",
|
||||
# "prompt": "您好!我是一名现场维修作业向导。",
|
||||
# },
|
||||
# str(uuid4()): {
|
||||
# "icon_index": 8,
|
||||
# "name": "舰船技术知识百科",
|
||||
# "description": "综合性舰船技术知识库。",
|
||||
# "prompt": "您好!我是一部舰船技术知识百科。",
|
||||
# },
|
||||
# }
|
||||
|
||||
# # =========================
|
||||
# # 4. 构建 Agent JSON
|
||||
# # =========================
|
||||
|
||||
# def build_agent_json(agent_id: str) -> dict:
|
||||
# meta = AGENT_META[agent_id]
|
||||
# return {
|
||||
# "id": agent_id,
|
||||
# "name": meta["name"],
|
||||
# "description": meta["description"],
|
||||
# "prompt": meta["prompt"],
|
||||
# "background": "string",
|
||||
# "access_control": {},
|
||||
# }
|
||||
|
||||
# # =========================
|
||||
# # 5. 创建 Agent
|
||||
# # =========================
|
||||
|
||||
# def create_agent(agent_id: str) -> bool:
|
||||
# resp = requests.post(
|
||||
# AGENT_API,
|
||||
# headers=HEADERS,
|
||||
# json=build_agent_json(agent_id),
|
||||
# verify=False,
|
||||
# timeout=TIMEOUT,
|
||||
# )
|
||||
|
||||
# print(f"[CREATE] Agent {agent_id} -> {resp.status_code}")
|
||||
|
||||
# if resp.status_code in (200, 201, 409):
|
||||
# return True
|
||||
|
||||
# print(" ↳ 返回值:", resp.text)
|
||||
# return False
|
||||
|
||||
# # =========================
|
||||
# # 6. 绑定 Agent 到角色
|
||||
# # =========================
|
||||
|
||||
# def bind_agent_to_role(agent_id: str, role_id: str) -> bool:
|
||||
# payload = [
|
||||
# {
|
||||
# "agent_id": agent_id,
|
||||
# "agent_role_id": role_id,
|
||||
# "is_active": True,
|
||||
# }
|
||||
# ]
|
||||
|
||||
# resp = requests.post(
|
||||
# ROLE_BIND_API,
|
||||
# headers=HEADERS,
|
||||
# json=payload,
|
||||
# verify=False,
|
||||
# timeout=TIMEOUT,
|
||||
# )
|
||||
|
||||
# print(
|
||||
# f"[BIND] Agent {agent_id} -> Role {role_id} "
|
||||
# f"({ROLE_NAME_MAP.get(role_id)}) => {resp.status_code}"
|
||||
# )
|
||||
|
||||
# if resp.status_code in (200, 201):
|
||||
# return True
|
||||
|
||||
# print(" ↳ 返回值:", resp.text)
|
||||
# return False
|
||||
|
||||
# # =========================
|
||||
# # 7. 主流程(先创建 → 再统一绑定)
|
||||
# # =========================
|
||||
|
||||
# def main():
|
||||
# pending_bindings: List[Tuple[str, str]] = []
|
||||
|
||||
# print("=== 开始创建 Agent ===")
|
||||
|
||||
# for agent_id, meta in AGENT_META.items():
|
||||
# try:
|
||||
# if not create_agent(agent_id):
|
||||
# continue
|
||||
|
||||
# idx = meta["icon_index"]
|
||||
|
||||
# if 1 <= idx <= 4:
|
||||
# role_id = AGENT_ROLE_1
|
||||
# elif 5 <= idx <= 6:
|
||||
# role_id = AGENT_ROLE_2
|
||||
# elif 7 <= idx <= 8:
|
||||
# role_id = AGENT_ROLE_3
|
||||
# else:
|
||||
# continue
|
||||
|
||||
# pending_bindings.append((agent_id, role_id))
|
||||
|
||||
# except Exception as e:
|
||||
# print(f"[ERROR] Agent {agent_id} -> {e}")
|
||||
|
||||
# print("\n=== 开始统一绑定角色 ===")
|
||||
|
||||
# for agent_id, role_id in pending_bindings:
|
||||
# bind_agent_to_role(agent_id, role_id)
|
||||
|
||||
# print("\n=== 全部完成 ===")
|
||||
|
||||
# # =========================
|
||||
# # 8. 入口
|
||||
# # =========================
|
||||
|
||||
# if __name__ == "__main__":
|
||||
# main()
|
||||
8
agent_ids.txt
Normal file
@ -0,0 +1,8 @@
|
||||
17cb4f33-f84d-473a-9299-d8138e8ea9d6
|
||||
a8352abb-900d-468a-97b1-ac93f012ccb2
|
||||
f6363563-fd44-4c11-aa24-d2d97abbaa96
|
||||
2fc6a72e-69a9-497e-b7f4-12cd6833c781
|
||||
16c1320c-444a-4fb7-9e2e-34748f16d361
|
||||
c742915b-4bf1-47e3-b64f-903a4ce60de8
|
||||
6089a40f-a1b3-4514-a229-25bc8f42789e
|
||||
9cc84831-747e-4cdf-b941-736dcdc9514c
|
||||
156
agent_role_request.py
Normal file
@ -0,0 +1,156 @@
|
||||
# # bind_agents.py
|
||||
# import requests
|
||||
# import urllib3
|
||||
|
||||
# urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
# BASE_URL = "https://devb.zdht.hjkl01.cn:65432"
|
||||
# ROLE_BIND_API = f"{BASE_URL}/api/v1/agent-roles/association"
|
||||
|
||||
# HEADERS = {
|
||||
# "Accept": "application/json",
|
||||
# "Content-Type": "application/json",
|
||||
# }
|
||||
|
||||
# TIMEOUT = 10
|
||||
# INPUT_FILE = "agent_ids.txt"
|
||||
|
||||
# # 角色 ID
|
||||
# AGENT_ROLE_0 = "1" # 1-8
|
||||
# AGENT_ROLE_1 = "2" # 1-4
|
||||
# AGENT_ROLE_2 = "3" # 5-6
|
||||
# AGENT_ROLE_3 = "4" # 7-8
|
||||
|
||||
|
||||
# def calc_role_id_by_index(index: int) -> str:
|
||||
# if 1 <= index <= 4:
|
||||
# return AGENT_ROLE_1
|
||||
# elif 5 <= index <= 6:
|
||||
# return AGENT_ROLE_2
|
||||
# elif 7 <= index <= 8:
|
||||
# return AGENT_ROLE_3
|
||||
# else:
|
||||
# raise ValueError("非法 icon_index")
|
||||
|
||||
|
||||
# def bind_agent(agent_id: str, role_id: str):
|
||||
# payload = [
|
||||
# {
|
||||
# "agent_id": agent_id,
|
||||
# "agent_role_id": role_id,
|
||||
# "is_active": True,
|
||||
# }
|
||||
# ]
|
||||
|
||||
# resp = requests.post(
|
||||
# ROLE_BIND_API,
|
||||
# headers=HEADERS,
|
||||
# json=payload,
|
||||
# verify=False,
|
||||
# timeout=TIMEOUT,
|
||||
# )
|
||||
|
||||
# print(f"[BIND] {agent_id} -> role {role_id} => {resp.status_code}")
|
||||
# if resp.status_code not in (200, 201):
|
||||
# print(" ↳", resp.text)
|
||||
|
||||
|
||||
# def main():
|
||||
# with open(INPUT_FILE, "r", encoding="utf-8") as f:
|
||||
# agent_ids = [line.strip() for line in f if line.strip()]
|
||||
|
||||
# print(f"=== 读取到 {len(agent_ids)} 个 Agent ===")
|
||||
|
||||
# for idx, agent_id in enumerate(agent_ids, start=1):
|
||||
# role_id = calc_role_id_by_index(idx)
|
||||
# bind_agent(agent_id, role_id)
|
||||
|
||||
# print("\n✅ 绑定完成")
|
||||
|
||||
|
||||
# if __name__ == "__main__":
|
||||
# main()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
import requests
|
||||
import urllib3
|
||||
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
BASE_URL = "http://192.168.0.46:22000"
|
||||
ROLE_BIND_API = f"{BASE_URL}/api/v1/agent-roles/association"
|
||||
HEADERS = {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
TIMEOUT = 10
|
||||
INPUT_FILE = "agent_ids.txt"
|
||||
|
||||
# 角色 ID
|
||||
AGENT_ROLE_0 = "1" # 所有 Agent 必绑
|
||||
AGENT_ROLE_1 = "2" # 1-4
|
||||
AGENT_ROLE_2 = "3" # 5-6
|
||||
AGENT_ROLE_3 = "4" # 7-8
|
||||
|
||||
|
||||
def calc_role_id_by_index(index: int) -> str:
|
||||
if 1 <= index <= 4:
|
||||
return AGENT_ROLE_1
|
||||
elif 5 <= index <= 6:
|
||||
return AGENT_ROLE_2
|
||||
elif 7 <= index <= 8:
|
||||
return AGENT_ROLE_3
|
||||
else:
|
||||
raise ValueError("非法 index")
|
||||
|
||||
|
||||
def bind_agent(agent_id: str, role_id: str):
|
||||
payload = [
|
||||
{
|
||||
"agent_id": agent_id,
|
||||
"agent_role_id": role_id,
|
||||
"is_active": True,
|
||||
}
|
||||
]
|
||||
|
||||
resp = requests.post(
|
||||
ROLE_BIND_API,
|
||||
headers=HEADERS,
|
||||
json=payload,
|
||||
verify=False,
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
|
||||
print(f"[BIND] {agent_id} -> role {role_id} => {resp.status_code}")
|
||||
if resp.status_code not in (200, 201):
|
||||
print(" ↳", resp.text)
|
||||
|
||||
|
||||
def main():
|
||||
with open(INPUT_FILE, "r", encoding="utf-8") as f:
|
||||
agent_ids = [line.strip() for line in f if line.strip()]
|
||||
|
||||
print(f"=== 读取到 {len(agent_ids)} 个 Agent ===")
|
||||
|
||||
for idx, agent_id in enumerate(agent_ids, start=1):
|
||||
# 1️⃣ 所有 Agent 统一绑定角色 1
|
||||
bind_agent(agent_id, AGENT_ROLE_0)
|
||||
|
||||
# 2️⃣ 再绑定分组角色
|
||||
group_role = calc_role_id_by_index(idx)
|
||||
|
||||
# 防止重复绑定
|
||||
if group_role != AGENT_ROLE_0:
|
||||
bind_agent(agent_id, group_role)
|
||||
|
||||
print("\n✅ 绑定完成(角色 1 + 分组角色)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1605
app copy.py
Normal file
498
checkpointer_config.py
Normal file
@ -0,0 +1,498 @@
|
||||
"""
|
||||
LangGraph Checkpointer 配置模块
|
||||
|
||||
支持多种持久化后端:
|
||||
1. MemorySaver - 内存存储(仅用于测试)
|
||||
2. PostgresSaver - PostgreSQL 数据库(生产环境)
|
||||
|
||||
使用 message_id 和 chat_id 作为 thread_id 来恢复工作流状态
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
from config import POSTGRES_CONNECTION_STRING
|
||||
|
||||
CHECKPOINTER_TYPE = "postgres"
|
||||
|
||||
|
||||
def get_thread_id(chat_id: str, message_id: str) -> str:
|
||||
"""
|
||||
生成唯一的 thread_id
|
||||
|
||||
Args:
|
||||
chat_id: 聊天会话 ID
|
||||
message_id: 消息 ID
|
||||
|
||||
Returns:
|
||||
组合后的唯一 thread_id
|
||||
"""
|
||||
return f"{chat_id}:{message_id}"
|
||||
|
||||
|
||||
def parse_thread_id(thread_id: str) -> tuple[str, str]:
|
||||
"""
|
||||
解析 thread_id 获取 chat_id 和 message_id
|
||||
|
||||
Args:
|
||||
thread_id: 组合的 thread_id
|
||||
|
||||
Returns:
|
||||
(chat_id, message_id) 元组
|
||||
"""
|
||||
parts = thread_id.split(":", 1)
|
||||
if len(parts) == 2:
|
||||
return parts[0], parts[1]
|
||||
return parts[0], ""
|
||||
|
||||
|
||||
async def verify_checkpointer_tables_exist() -> bool:
|
||||
"""
|
||||
验证 checkpointer 所需的数据库表是否存在且结构正确
|
||||
|
||||
Returns:
|
||||
True 如果所有必需的表都存在且结构正确,否则 False
|
||||
"""
|
||||
try:
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
try:
|
||||
async with AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
kwargs={"autocommit": True}
|
||||
) as pool:
|
||||
async with pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
# 检查 checkpoints 表
|
||||
await cur.execute("""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'checkpoints'
|
||||
)
|
||||
""")
|
||||
checkpoints_exists = (await cur.fetchone())[0]
|
||||
|
||||
# 检查 checkpoint_writes 表
|
||||
await cur.execute("""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'checkpoint_writes'
|
||||
)
|
||||
""")
|
||||
writes_exists = (await cur.fetchone())[0]
|
||||
|
||||
# 检查 checkpoint_blobs 表
|
||||
await cur.execute("""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'checkpoint_blobs'
|
||||
)
|
||||
""")
|
||||
blobs_exists = (await cur.fetchone())[0]
|
||||
|
||||
if not (checkpoints_exists and writes_exists and blobs_exists):
|
||||
return False
|
||||
|
||||
# 检查 checkpoints 表的 checkpoint 字段是否为 JSONB 类型
|
||||
await cur.execute("""
|
||||
SELECT data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'checkpoints'
|
||||
AND column_name = 'checkpoint'
|
||||
""")
|
||||
result = await cur.fetchone()
|
||||
if not result or result[0] != 'jsonb':
|
||||
print(f"[Checkpointer] checkpoints.checkpoint column type is {result[0] if result else 'NULL'}, expected 'jsonb'")
|
||||
return False
|
||||
|
||||
# 检查 checkpoint_writes 表是否有 task_path 列
|
||||
await cur.execute("""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.columns
|
||||
WHERE table_name = 'checkpoint_writes'
|
||||
AND column_name = 'task_path'
|
||||
)
|
||||
""")
|
||||
task_path_exists = (await cur.fetchone())[0]
|
||||
if not task_path_exists:
|
||||
print(f"[Checkpointer] checkpoint_writes.task_path column does not exist")
|
||||
return False
|
||||
|
||||
# 检查 checkpoint_writes 表的主键是否正确
|
||||
await cur.execute("""
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
WHERE tc.table_name = 'checkpoint_writes'
|
||||
AND tc.constraint_type = 'PRIMARY KEY'
|
||||
""")
|
||||
pk_count = (await cur.fetchone())[0]
|
||||
if pk_count != 5: # 应该有 5 个主键列
|
||||
print(f"[Checkpointer] checkpoint_writes has {pk_count} primary key columns, expected 5")
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[Checkpointer] Error verifying tables: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def init_checkpointer_db():
|
||||
"""
|
||||
初始化 PostgreSQL checkpointer 数据库表
|
||||
使用 AsyncPostgresSaver 的 setup 方法创建正确的表结构
|
||||
"""
|
||||
if CHECKPOINTER_TYPE != "postgres":
|
||||
return
|
||||
|
||||
try:
|
||||
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
import psycopg
|
||||
except ImportError as e:
|
||||
print(f"[Checkpointer] ERROR: Missing required package: {e}")
|
||||
print(f"[Checkpointer] Please install: pip install langgraph-checkpoint-postgres psycopg-pool")
|
||||
raise ImportError(
|
||||
"langgraph-checkpoint-postgres or psycopg-pool not installed. "
|
||||
"Run: pip install langgraph-checkpoint-postgres psycopg-pool"
|
||||
) from e
|
||||
|
||||
# 首先检查表是否已存在
|
||||
tables_exist = await verify_checkpointer_tables_exist()
|
||||
if tables_exist:
|
||||
print(f"[Checkpointer] Database tables already exist, skipping initialization")
|
||||
return
|
||||
|
||||
print(f"[Checkpointer] Initializing PostgreSQL database tables...")
|
||||
|
||||
# 尝试使用 AsyncPostgresSaver 的 setup 方法
|
||||
try:
|
||||
async with AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
kwargs={"autocommit": True}
|
||||
) as pool:
|
||||
checkpointer = AsyncPostgresSaver(pool)
|
||||
await checkpointer.setup()
|
||||
print(f"[Checkpointer] AsyncPostgresSaver.setup() executed")
|
||||
except Exception as e:
|
||||
print(f"[Checkpointer] AsyncPostgresSaver.setup() failed: {e}")
|
||||
|
||||
# 验证表是否真的被创建
|
||||
tables_exist_after = await verify_checkpointer_tables_exist()
|
||||
|
||||
if not tables_exist_after:
|
||||
print(f"[Checkpointer] Tables not created by setup(), using direct SQL...")
|
||||
try:
|
||||
await create_checkpointer_tables_directly()
|
||||
except Exception as e2:
|
||||
print(f"[Checkpointer] Direct SQL initialization also failed: {e2}")
|
||||
raise
|
||||
else:
|
||||
print(f"[Checkpointer] PostgreSQL database tables verified successfully")
|
||||
|
||||
|
||||
async def create_checkpointer_tables_directly():
|
||||
"""
|
||||
直接通过 SQL 语句创建 checkpointer 所需的数据库表
|
||||
这是备用方案,当 AsyncPostgresSaver.setup() 失败时使用
|
||||
|
||||
注意:LangGraph AsyncPostgresSaver 使用 JSONB 类型存储数据
|
||||
表名必须是: checkpoints, checkpoint_writes, checkpoint_blobs
|
||||
"""
|
||||
try:
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
except ImportError as e:
|
||||
print(f"[Checkpointer] ERROR: Missing psycopg-pool: {e}")
|
||||
raise
|
||||
|
||||
print(f"[Checkpointer] Creating checkpointer tables using direct SQL...")
|
||||
|
||||
async with AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
kwargs={"autocommit": True}
|
||||
) as pool:
|
||||
async with pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
# 先删除旧表(如果存在错误的表结构)
|
||||
await cur.execute("DROP TABLE IF EXISTS checkpoints CASCADE")
|
||||
await cur.execute("DROP TABLE IF EXISTS writes CASCADE")
|
||||
await cur.execute("DROP TABLE IF EXISTS checkpoint_blobs CASCADE")
|
||||
await cur.execute("DROP TABLE IF EXISTS checkpoint_writes CASCADE")
|
||||
await cur.execute("DROP TABLE IF EXISTS blobs CASCADE")
|
||||
await cur.execute("DROP TABLE IF EXISTS checkpoint_migrations CASCADE")
|
||||
print(f"[Checkpointer] Dropped existing tables")
|
||||
|
||||
# 创建 checkpoint_migrations 表(用于跟踪迁移版本)
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS checkpoint_migrations (
|
||||
v INTEGER PRIMARY KEY
|
||||
)
|
||||
""")
|
||||
|
||||
# 创建 checkpoints 表 - 使用 JSONB 类型
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS checkpoints (
|
||||
thread_id TEXT NOT NULL,
|
||||
checkpoint_ns TEXT NOT NULL DEFAULT '',
|
||||
checkpoint_id TEXT NOT NULL,
|
||||
parent_checkpoint_id TEXT,
|
||||
type TEXT,
|
||||
checkpoint JSONB NOT NULL DEFAULT '{}',
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# 创建 checkpoint_writes 表 - 使用 BYTEA 类型
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS checkpoint_writes (
|
||||
thread_id TEXT NOT NULL,
|
||||
checkpoint_ns TEXT NOT NULL DEFAULT '',
|
||||
checkpoint_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
task_path TEXT NOT NULL DEFAULT '',
|
||||
idx INTEGER NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
type TEXT,
|
||||
blob BYTEA,
|
||||
PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id, task_id, idx)
|
||||
)
|
||||
""")
|
||||
|
||||
# 创建 checkpoint_blobs 表 - 使用 BYTEA 类型存储二进制数据
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS checkpoint_blobs (
|
||||
thread_id TEXT NOT NULL,
|
||||
checkpoint_ns TEXT NOT NULL DEFAULT '',
|
||||
channel TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
blob BYTEA,
|
||||
PRIMARY KEY (thread_id, checkpoint_ns, channel, version)
|
||||
)
|
||||
""")
|
||||
|
||||
# 创建索引以提高查询性能
|
||||
await cur.execute("""
|
||||
CREATE INDEX IF NOT EXISTS checkpoints_thread_id_idx ON checkpoints(thread_id)
|
||||
""")
|
||||
await cur.execute("""
|
||||
CREATE INDEX IF NOT EXISTS checkpoint_writes_thread_id_idx ON checkpoint_writes(thread_id)
|
||||
""")
|
||||
await cur.execute("""
|
||||
CREATE INDEX IF NOT EXISTS checkpoint_blobs_thread_id_idx ON checkpoint_blobs(thread_id)
|
||||
""")
|
||||
|
||||
# 插入迁移版本记录
|
||||
await cur.execute("""
|
||||
INSERT INTO checkpoint_migrations (v) VALUES (1) ON CONFLICT DO NOTHING
|
||||
""")
|
||||
|
||||
print(f"[Checkpointer] Checkpointer tables created successfully via direct SQL")
|
||||
|
||||
|
||||
def create_checkpointer(checkpointer_type: str = None):
|
||||
"""
|
||||
创建同步 checkpointer 实例
|
||||
|
||||
Args:
|
||||
checkpointer_type: checkpointer 类型,可选 "memory", "postgres"
|
||||
|
||||
Returns:
|
||||
checkpointer 实例
|
||||
"""
|
||||
checkpointer_type = checkpointer_type or CHECKPOINTER_TYPE
|
||||
|
||||
if checkpointer_type == "memory":
|
||||
return MemorySaver()
|
||||
|
||||
elif checkpointer_type == "postgres":
|
||||
try:
|
||||
from langgraph.checkpoint.postgres import PostgresSaver
|
||||
from psycopg import Connection
|
||||
if not POSTGRES_CONNECTION_STRING:
|
||||
raise ValueError("POSTGRES_CONNECTION_STRING 环境变量未设置")
|
||||
conn = Connection.connect(POSTGRES_CONNECTION_STRING)
|
||||
return PostgresSaver(conn)
|
||||
except ImportError:
|
||||
print("[WARNING] psycopg 或 langgraph.checkpoint.postgres 未安装,回退到 memory")
|
||||
return MemorySaver()
|
||||
|
||||
else:
|
||||
print(f"[WARNING] 未知的 checkpointer 类型: {checkpointer_type},使用 memory")
|
||||
return MemorySaver()
|
||||
|
||||
|
||||
def create_async_checkpointer(checkpointer_type: str = None):
|
||||
"""
|
||||
创建异步 checkpointer 实例
|
||||
|
||||
Args:
|
||||
checkpointer_type: checkpointer 类型
|
||||
|
||||
Returns:
|
||||
异步 checkpointer 实例
|
||||
"""
|
||||
checkpointer_type = checkpointer_type or CHECKPOINTER_TYPE
|
||||
print(f"[Checkpointer] 创建异步 checkpointer, 类型配置: {checkpointer_type}")
|
||||
|
||||
if checkpointer_type == "memory":
|
||||
print("[Checkpointer] 使用 MemorySaver")
|
||||
return MemorySaver()
|
||||
|
||||
elif checkpointer_type == "postgres":
|
||||
try:
|
||||
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
if not POSTGRES_CONNECTION_STRING:
|
||||
raise ValueError("POSTGRES_CONNECTION_STRING 环境变量未设置")
|
||||
pool = AsyncConnectionPool(POSTGRES_CONNECTION_STRING)
|
||||
return AsyncPostgresSaver(pool)
|
||||
except ImportError:
|
||||
print("[WARNING] psycopg_pool 或 langgraph.checkpoint.postgres 未安装,回退到 memory")
|
||||
return MemorySaver()
|
||||
|
||||
else:
|
||||
print(f"[WARNING] 未知的 checkpointer 类型: {checkpointer_type},使用 memory")
|
||||
return MemorySaver()
|
||||
|
||||
|
||||
async def get_or_create_async_checkpointer(checkpointer_type: str = None):
|
||||
"""
|
||||
获取或创建异步 checkpointer 实例(正确处理上下文管理器)
|
||||
|
||||
Args:
|
||||
checkpointer_type: checkpointer 类型
|
||||
|
||||
Returns:
|
||||
已初始化的异步 checkpointer 实例
|
||||
"""
|
||||
checkpointer = create_async_checkpointer(checkpointer_type)
|
||||
|
||||
if hasattr(checkpointer, '__aenter__'):
|
||||
checkpointer = await checkpointer.__aenter__()
|
||||
|
||||
return checkpointer
|
||||
|
||||
|
||||
class CheckpointerManager:
|
||||
"""
|
||||
Checkpointer 管理器 - 单例模式
|
||||
|
||||
用于管理多个工作流的 checkpointer 实例
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_sync_checkpointer = None
|
||||
_async_conn_pool = None
|
||||
_async_checkpointer = None
|
||||
_initialized = False
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def get_checkpointer(self):
|
||||
"""获取同步 checkpointer 实例"""
|
||||
if self._sync_checkpointer is None:
|
||||
self._sync_checkpointer = create_checkpointer()
|
||||
return self._sync_checkpointer
|
||||
|
||||
async def get_async_checkpointer(self):
|
||||
"""
|
||||
获取异步 checkpointer 实例
|
||||
"""
|
||||
if self._async_checkpointer is None:
|
||||
if CHECKPOINTER_TYPE == "postgres":
|
||||
try:
|
||||
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
if not POSTGRES_CONNECTION_STRING:
|
||||
raise ValueError("POSTGRES_CONNECTION_STRING not set")
|
||||
|
||||
self._async_conn_pool = AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
open=False
|
||||
)
|
||||
await self._async_conn_pool.open()
|
||||
self._async_checkpointer = AsyncPostgresSaver(self._async_conn_pool)
|
||||
|
||||
# 检查表是否存在
|
||||
tables_exist = await verify_checkpointer_tables_exist()
|
||||
|
||||
if not tables_exist:
|
||||
# 尝试使用 setup() 方法
|
||||
try:
|
||||
await self._async_checkpointer.setup()
|
||||
print(f"[Checkpointer] AsyncPostgresSaver.setup() executed")
|
||||
except Exception as setup_e:
|
||||
print(f"[Checkpointer] AsyncPostgresSaver.setup() failed: {setup_e}")
|
||||
|
||||
# 再次验证表是否被创建
|
||||
tables_exist_after = await verify_checkpointer_tables_exist()
|
||||
|
||||
if not tables_exist_after:
|
||||
print(f"[Checkpointer] Tables not created by setup(), using direct SQL...")
|
||||
await create_checkpointer_tables_directly()
|
||||
else:
|
||||
print(f"[Checkpointer] Database tables already exist")
|
||||
|
||||
print(f"[Checkpointer] AsyncPostgresSaver created, connection: {POSTGRES_CONNECTION_STRING.split('@')[1] if '@' in POSTGRES_CONNECTION_STRING else POSTGRES_CONNECTION_STRING}")
|
||||
except ImportError as e:
|
||||
print(f"[WARNING] psycopg_pool or langgraph.checkpoint.postgres not installed: {e}, fallback to memory")
|
||||
self._async_checkpointer = MemorySaver()
|
||||
except Exception as e:
|
||||
print(f"[Checkpointer] ERROR: Failed to create AsyncPostgresSaver: {e}")
|
||||
raise
|
||||
elif CHECKPOINTER_TYPE == "memory":
|
||||
self._async_checkpointer = MemorySaver()
|
||||
print("[Checkpointer] Using MemorySaver")
|
||||
else:
|
||||
self._async_checkpointer = MemorySaver()
|
||||
print(f"[Checkpointer] Unknown type {CHECKPOINTER_TYPE}, using MemorySaver")
|
||||
|
||||
print(f"[Checkpointer] Async checkpointer type: {type(self._async_checkpointer).__name__}")
|
||||
return self._async_checkpointer
|
||||
|
||||
async def setup(self):
|
||||
"""
|
||||
初始化 checkpointer(创建数据库表等)
|
||||
"""
|
||||
if not self._initialized:
|
||||
await init_checkpointer_db()
|
||||
try:
|
||||
from tools.fault_record_db import init_fault_records_table
|
||||
await init_fault_records_table()
|
||||
except Exception as e:
|
||||
print(f"[Checkpointer] 初始化 fault_records 表失败: {e}")
|
||||
try:
|
||||
from tools.ship_model_db import init_ship_model_mapping_table
|
||||
await init_ship_model_mapping_table()
|
||||
except Exception as e:
|
||||
print(f"[Checkpointer] 初始化 ship_model_mapping 表失败: {e}")
|
||||
self._initialized = True
|
||||
|
||||
def reset(self):
|
||||
"""重置 checkpointer(主要用于测试)"""
|
||||
self._sync_checkpointer = None
|
||||
self._async_checkpointer = None
|
||||
self._async_conn_pool = None
|
||||
self._initialized = False
|
||||
|
||||
|
||||
checkpointer_manager = CheckpointerManager()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Checkpointer 配置测试:")
|
||||
print(f" 类型: {CHECKPOINTER_TYPE}")
|
||||
print(f" PostgreSQL 连接: {'已配置' if POSTGRES_CONNECTION_STRING else '未配置'}")
|
||||
|
||||
1071
chunk_text.py
Normal file
230
config.py
Normal file
@ -0,0 +1,230 @@
|
||||
"""
|
||||
项目配置文件
|
||||
从.env文件读取配置
|
||||
"""
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from contextvars import ContextVar
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
# 加载.env文件
|
||||
load_dotenv()
|
||||
|
||||
# ==================== 大模型配置 ====================
|
||||
LLM_CONFIG = {
|
||||
# "model": "Qwen3.5-35B-A3B",
|
||||
"model": "46-qwen3.5-35B",
|
||||
# "model": "Qwen3-14B",
|
||||
"base_url": "http://192.168.0.46:59800/v1",
|
||||
"api_key": "gpustack_dee9ca823290886c_5edfc86aeeeceb1e9ee5162941cb2cb5",
|
||||
"temperature": float("0.1"),
|
||||
"max_tokens": int("8192"),
|
||||
"timeout": int("30")
|
||||
}
|
||||
|
||||
# ==================== 嵌入模型配置 ====================
|
||||
EMBEDDING_CONFIG = {
|
||||
"model": "bge-m3",
|
||||
"base_url": "http://192.168.0.46:59700/v1/embeddings",
|
||||
"api_key": "gpustack_dee9ca823290886c_5edfc86aeeeceb1e9ee5162941cb2cb5",
|
||||
"base_url_v2" : "http://192.168.0.46:59700/v1",
|
||||
}
|
||||
|
||||
|
||||
# ==================== 线程安全的请求上下文配置 ====================
|
||||
# 使用 contextvars 存储每个请求的用户配置,确保多用户并发时不会相互干扰
|
||||
_request_user_config: ContextVar[Optional[Dict[str, str]]] = ContextVar('request_user_config', default=None)
|
||||
|
||||
# RAG配置的默认值
|
||||
_RAG_CONFIG_DEFAULT = {
|
||||
"search_endpoint": "http://192.168.0.46:11000/api/v1/knowledge/search/",
|
||||
"x-user-id": "1",
|
||||
"x-user-name": "testuser",
|
||||
"x-role": "admin",
|
||||
"kb-id": None,
|
||||
"file-id": None
|
||||
}
|
||||
|
||||
class DynamicRAGConfig(dict):
|
||||
"""
|
||||
动态RAG配置类,支持线程安全的请求级配置
|
||||
当访问用户相关配置时,自动从当前请求上下文读取
|
||||
其他配置项直接返回默认值
|
||||
兼容字典的所有操作方式(如 get(), [], in 等)
|
||||
"""
|
||||
def __getitem__(self, key: str):
|
||||
# 如果是用户相关配置或知识库参数,从当前请求上下文读取
|
||||
if key in ["x-user-id", "x-user-name", "x-role", "kb-id", "file-id"]:
|
||||
request_config = _request_user_config.get()
|
||||
if request_config and key in request_config:
|
||||
return request_config[key]
|
||||
# 如果没有设置,返回默认值
|
||||
return _RAG_CONFIG_DEFAULT[key]
|
||||
# 其他配置项直接返回默认值
|
||||
return _RAG_CONFIG_DEFAULT.get(key)
|
||||
|
||||
def get(self, key: str, default=None):
|
||||
"""支持 get() 方法"""
|
||||
try:
|
||||
return self[key]
|
||||
except KeyError:
|
||||
return default
|
||||
|
||||
def __contains__(self, key: str):
|
||||
"""支持 in 操作符"""
|
||||
return key in _RAG_CONFIG_DEFAULT
|
||||
|
||||
def keys(self):
|
||||
"""返回所有键"""
|
||||
return _RAG_CONFIG_DEFAULT.keys()
|
||||
|
||||
def values(self):
|
||||
"""返回所有值(动态读取用户配置)"""
|
||||
return [self[key] for key in _RAG_CONFIG_DEFAULT.keys()]
|
||||
|
||||
def items(self):
|
||||
"""返回所有键值对(动态读取用户配置)"""
|
||||
return [(key, self[key]) for key in _RAG_CONFIG_DEFAULT.keys()]
|
||||
|
||||
# ==================== RAG服务配置 ====================
|
||||
# 使用动态配置类,支持多用户并发时自动读取各自的配置
|
||||
RAG_CONFIG = DynamicRAGConfig()
|
||||
|
||||
def set_request_user_config(x_user_id: Optional[str] = None,
|
||||
x_user_name: Optional[str] = None,
|
||||
x_role: Optional[str] = None,
|
||||
text_kb_id: Optional[str] = None,
|
||||
text_file_id: Optional[str] = None):
|
||||
"""
|
||||
设置当前请求的用户配置和知识库参数(线程安全)
|
||||
|
||||
Args:
|
||||
x_user_id: 用户ID
|
||||
x_user_name: 用户名称
|
||||
x_role: 角色
|
||||
text_kb_id: 文本检索知识库ID
|
||||
text_file_id: 文本检索文件ID
|
||||
"""
|
||||
config = {}
|
||||
if x_user_id is not None:
|
||||
config["x-user-id"] = x_user_id
|
||||
if x_user_name is not None:
|
||||
config["x-user-name"] = x_user_name
|
||||
if x_role is not None:
|
||||
config["x-role"] = x_role
|
||||
# 仅当非空字符串时才设置,空字符串时不传入 RAG 接口
|
||||
if text_kb_id is not None and str(text_kb_id).strip():
|
||||
config["kb-id"] = str(text_kb_id).strip()
|
||||
if text_file_id is not None and str(text_file_id).strip():
|
||||
config["file-id"] = str(text_file_id).strip()
|
||||
_request_user_config.set(config if config else None)
|
||||
|
||||
# ==================== 图谱检索服务配置 ====================
|
||||
GRAPH_SEARCH_CONFIG = {
|
||||
"search_endpoint": "http://192.168.0.46:59085/search_graph",
|
||||
"graph_timeout": float("30.0"),
|
||||
"default_top_k": int("10"),
|
||||
"operation_search_endpoint": "http://192.168.0.46:59085/operation_graph_search"
|
||||
}
|
||||
|
||||
# ==================== 设备到系统检索服务配置 ====================
|
||||
DEVICE_SYSTEM_CONFIG = {
|
||||
"endpoint": "http://192.168.0.46:59085/device_to_system",
|
||||
"timeout": float("30.0"),
|
||||
"default_min_hops": int("2"),
|
||||
"default_max_hops": int("8"),
|
||||
"default_top_k": int("10")
|
||||
}
|
||||
|
||||
# ==================== 图册检索服务配置 ====================
|
||||
ATLAS_CONFIG = {
|
||||
"endpoint": "http://192.168.0.46:59085/atlas_retrieval",
|
||||
"timeout": float("30.0"),
|
||||
"default_top_k": int("10")
|
||||
}
|
||||
|
||||
# ==================== 工作流配置 ====================
|
||||
WORKFLOW_CONFIG = {
|
||||
"max_retry_count": int("2"), # 最大重试次数
|
||||
"image_clarity_threshold": float("0.7"), # 图片清晰度阈值
|
||||
"default_timeout": int("30") # 默认超时时间(秒)
|
||||
}
|
||||
|
||||
# ==================== 服务器配置 ====================
|
||||
SERVER_CONFIG = {
|
||||
"host": "192.168.0.46",
|
||||
"port": "59088",
|
||||
"base_url": "http://192.168.0.46:59088"
|
||||
}
|
||||
|
||||
# ==================== ASR服务配置 ====================
|
||||
ASR_CONFIG = {
|
||||
"api_base": "http://192.168.0.46:59900/v1",
|
||||
"model": "base",
|
||||
"language": "zh",
|
||||
"timeout": int("30")
|
||||
}
|
||||
|
||||
# ==================== VLM服务配置 ====================
|
||||
VLM_CONFIG = {
|
||||
"api_base" : "http://192.168.0.46:59801/v1",
|
||||
"model" : "Qwen3-VL-8B"
|
||||
}
|
||||
|
||||
# ==================== Neo4J配置 ====================
|
||||
NEO4J_CONFIG = {
|
||||
"uri": "neo4j://192.168.0.46:57687",
|
||||
"user": "neo4j",
|
||||
"password": "zdht123@"
|
||||
}
|
||||
|
||||
# ==================== 知识库匹配配置 ====================
|
||||
KB_TREE_CONFIG = {
|
||||
"url": "http://192.168.0.46:11000/api/v1/knowledge/knowledge_base/tree"
|
||||
}
|
||||
|
||||
# ==================== 维修反馈统计配置 ====================
|
||||
REPAIR_FEEDBACK_CONFIG = {
|
||||
"url": "http://192.168.0.46:22000",
|
||||
"email": "15088888888@163.com",
|
||||
"password": "11223344"
|
||||
}
|
||||
|
||||
# ==================== 数据库配置 ====================
|
||||
POSTGRES_CONNECTION_STRING = "postgresql://postgres:password@192.168.0.46:5432/an_webui"
|
||||
#POSTGRES_CONNECTION_STRING = "postgresql+psycopg://postgres:password@192.168.0.46:5432/an_webui"
|
||||
POSTGRES_SQLALCHEMY_URL = "postgresql+psycopg://postgres:password@192.168.0.46:5432/an_webui"
|
||||
|
||||
|
||||
# ==================== 索引配置 ====================
|
||||
INDEX_CONFIG = {
|
||||
"NAME_PROPERTY" : "名称",
|
||||
"FULLTEXT_PROPERTY" :"fulltext",
|
||||
"EMBEDDING_PROPERTY" : "embedding",
|
||||
"SEARCH_LABEL" : "Searchable",
|
||||
"VECTOR_INDEX_NAME" : "global_searchable_embedding",
|
||||
"FULLTEXT_INDEX_NAME" : "global_searchable_content_search"
|
||||
}
|
||||
|
||||
# ==================== sql表格字段与neo4j节点映射 ====================
|
||||
MAP_INS={
|
||||
"故障模式" : "fault",
|
||||
"系统" : "system_name",
|
||||
"设备" : "device_name",
|
||||
"舷号" : "ship_number",
|
||||
}
|
||||
|
||||
# ==================== neo4j配置 ====================
|
||||
NEO4J_CONFIG = {
|
||||
"uri": "neo4j://192.168.0.46:57687",
|
||||
"user": "neo4j",
|
||||
"password": "zdht123@"
|
||||
}
|
||||
API_URLS = [
|
||||
"http://192.168.0.46:59988/analyze-pdf",
|
||||
# "http://192.168.0.111:9977/analyze-pdf",
|
||||
# "http://192.168.0.111:9978/analyze-pdf",
|
||||
#"http://192.168.0.111:9979/analyze-pdf",
|
||||
#"http://192.168.0.111:9980/analyze-pdf",
|
||||
# "http://192.168.0.111:9975/analyze-pdf",
|
||||
]
|
||||
263
doc2pdf.py
Normal file
@ -0,0 +1,263 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
文档转 PDF 工具
|
||||
使用 LibreOffice 将各种文档格式转换为 PDF
|
||||
|
||||
支持格式:
|
||||
- Word: .doc, .docx, .rtf
|
||||
- Excel: .xls, .xlsx
|
||||
- PowerPoint: .ppt, .pptx
|
||||
- OpenDocument: .odt, .ods, .odp
|
||||
- 文本: .txt, .csv
|
||||
- 图片: .jpg, .png (会嵌入PDF)
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
import concurrent.futures
|
||||
|
||||
|
||||
class Doc2PDF:
|
||||
"""文档转 PDF 转换器"""
|
||||
|
||||
SUPPORTED_FORMATS = {
|
||||
# 文档
|
||||
'.doc', '.docx', '.rtf', '.odt', '.txt',
|
||||
# 表格
|
||||
'.xls', '.xlsx', '.ods', '.csv',
|
||||
# 演示文稿
|
||||
'.ppt', '.pptx', '.odp',
|
||||
# 图片
|
||||
'.jpg', '.jpeg', '.png', '.bmp', '.gif',
|
||||
# 其他
|
||||
'.html', '.htm', '.xml'
|
||||
}
|
||||
|
||||
def __init__(self, libreoffice_path: str = 'libreoffice'):
|
||||
self.libreoffice_path = libreoffice_path
|
||||
self._verify_libreoffice()
|
||||
|
||||
def _verify_libreoffice(self):
|
||||
"""验证 LibreOffice 是否可用"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[self.libreoffice_path, '--version'],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError("LibreOffice 不可用")
|
||||
print(f"✓ 检测到 {result.stdout.strip().split(chr(10))[0]}")
|
||||
except FileNotFoundError:
|
||||
raise RuntimeError("未找到 LibreOffice,请先安装")
|
||||
|
||||
def convert(
|
||||
self,
|
||||
input_file: str,
|
||||
output_dir: Optional[str] = None,
|
||||
timeout: int = 120
|
||||
) -> str:
|
||||
"""
|
||||
转换单个文件为 PDF
|
||||
|
||||
Args:
|
||||
input_file: 输入文件路径
|
||||
output_dir: 输出目录(默认为输入文件所在目录)
|
||||
timeout: 超时时间(秒)
|
||||
|
||||
Returns:
|
||||
生成的 PDF 文件路径
|
||||
"""
|
||||
input_path = Path(input_file).resolve()
|
||||
|
||||
if not input_path.exists():
|
||||
raise FileNotFoundError(f"文件不存在: {input_file}")
|
||||
|
||||
suffix = input_path.suffix.lower()
|
||||
if suffix not in self.SUPPORTED_FORMATS:
|
||||
raise ValueError(f"不支持的格式: {suffix}")
|
||||
|
||||
if output_dir is None:
|
||||
output_dir = str(input_path.parent)
|
||||
else:
|
||||
output_dir = str(Path(output_dir).resolve())
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
cmd = [
|
||||
self.libreoffice_path,
|
||||
'--headless',
|
||||
'--invisible',
|
||||
'--nologo',
|
||||
'--nofirststartwizard',
|
||||
'--convert-to', 'pdf',
|
||||
'--outdir', output_dir,
|
||||
str(input_path)
|
||||
]
|
||||
|
||||
print(f" 转换中: {input_path.name} ...", end=' ', flush=True)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
print("✗")
|
||||
raise RuntimeError(f"转换失败: {result.stderr}")
|
||||
|
||||
output_file = os.path.join(output_dir, f"{input_path.stem}.pdf")
|
||||
|
||||
if os.path.exists(output_file):
|
||||
print("✓")
|
||||
return output_file
|
||||
else:
|
||||
print("✗")
|
||||
raise RuntimeError("转换完成但未找到输出文件")
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
print("✗ (超时)")
|
||||
raise RuntimeError(f"转换超时 ({timeout}秒)")
|
||||
|
||||
def convert_batch(
|
||||
self,
|
||||
input_files: List[str],
|
||||
output_dir: Optional[str] = None,
|
||||
max_workers: int = 1 # LibreOffice 并发可能有问题,建议设为1
|
||||
) -> List[dict]:
|
||||
"""
|
||||
批量转换文件
|
||||
|
||||
Args:
|
||||
input_files: 输入文件列表
|
||||
output_dir: 输出目录
|
||||
max_workers: 并发数
|
||||
|
||||
Returns:
|
||||
转换结果列表
|
||||
"""
|
||||
results = []
|
||||
|
||||
print(f"\n开始批量转换 {len(input_files)} 个文件...\n")
|
||||
|
||||
for i, input_file in enumerate(input_files, 1):
|
||||
print(f"[{i}/{len(input_files)}]", end='')
|
||||
try:
|
||||
output_file = self.convert(input_file, output_dir)
|
||||
results.append({
|
||||
'input': input_file,
|
||||
'output': output_file,
|
||||
'success': True,
|
||||
'error': None
|
||||
})
|
||||
except Exception as e:
|
||||
results.append({
|
||||
'input': input_file,
|
||||
'output': None,
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
# 打印统计
|
||||
success_count = sum(1 for r in results if r['success'])
|
||||
print(f"\n转换完成: {success_count}/{len(results)} 成功")
|
||||
|
||||
return results
|
||||
|
||||
def convert_directory(
|
||||
self,
|
||||
input_dir: str,
|
||||
output_dir: Optional[str] = None,
|
||||
recursive: bool = False
|
||||
) -> List[dict]:
|
||||
"""
|
||||
转换目录中的所有支持文件
|
||||
|
||||
Args:
|
||||
input_dir: 输入目录
|
||||
output_dir: 输出目录
|
||||
recursive: 是否递归处理子目录
|
||||
"""
|
||||
input_path = Path(input_dir)
|
||||
|
||||
if not input_path.is_dir():
|
||||
raise NotADirectoryError(f"不是目录: {input_dir}")
|
||||
|
||||
# 收集所有支持的文件
|
||||
files = []
|
||||
pattern = '**/*' if recursive else '*'
|
||||
|
||||
for file_path in input_path.glob(pattern):
|
||||
if file_path.is_file() and file_path.suffix.lower() in self.SUPPORTED_FORMATS:
|
||||
files.append(str(file_path))
|
||||
|
||||
if not files:
|
||||
print("未找到支持的文件")
|
||||
return []
|
||||
|
||||
return self.convert_batch(files, output_dir)
|
||||
|
||||
|
||||
def main():
|
||||
"""命令行入口"""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description='使用 LibreOffice 将文档转换为 PDF',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog='''
|
||||
示例:
|
||||
python doc2pdf.py document.docx
|
||||
python doc2pdf.py document.xlsx -o ./output
|
||||
python doc2pdf.py ./documents/ -o ./pdfs -r
|
||||
'''
|
||||
)
|
||||
|
||||
parser.add_argument('input', help='输入文件或目录')
|
||||
parser.add_argument('-o', '--output', help='输出目录')
|
||||
parser.add_argument('-r', '--recursive', action='store_true',
|
||||
help='递归处理子目录')
|
||||
parser.add_argument('-t', '--timeout', type=int, default=120,
|
||||
help='单个文件转换超时时间(秒)')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
converter = Doc2PDF()
|
||||
|
||||
input_path = Path(args.input)
|
||||
|
||||
if input_path.is_file():
|
||||
# 单文件转换
|
||||
try:
|
||||
output = converter.convert(args.input, args.output, args.timeout)
|
||||
print(f"\n输出文件: {output}")
|
||||
except Exception as e:
|
||||
print(f"\n错误: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
elif input_path.is_dir():
|
||||
# 目录转换
|
||||
results = converter.convert_directory(
|
||||
args.input,
|
||||
args.output,
|
||||
args.recursive
|
||||
)
|
||||
|
||||
# 显示失败的文件
|
||||
failures = [r for r in results if not r['success']]
|
||||
if failures:
|
||||
print("\n失败的文件:")
|
||||
for f in failures:
|
||||
print(f" - {f['input']}: {f['error']}")
|
||||
|
||||
else:
|
||||
print(f"错误: 路径不存在 - {args.input}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
1683
documents_prompt.py
Normal file
168
fileparse_util.py
Normal file
@ -0,0 +1,168 @@
|
||||
import os
|
||||
import json
|
||||
import base64
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional, Tuple, Dict
|
||||
import aiofiles # pip install aiofiles
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def find_and_read_content_list(
|
||||
directory: str,
|
||||
original_filename: str,
|
||||
encoding: str = 'utf-8'
|
||||
) -> Tuple[Optional[list], Optional[str]]:
|
||||
"""根据原始文件名查找并读取对应的 _content_list.json 文件。"""
|
||||
file_name, _ = os.path.splitext(original_filename)
|
||||
target_filename = f"{file_name}_content_list.json"
|
||||
logger.info(f"正在查找文件: {target_filename}")
|
||||
|
||||
# os.walk 没有原生 async 版本,放到线程里执行避免阻塞事件循环
|
||||
def _walk_for_file():
|
||||
for root, _, files in os.walk(directory):
|
||||
if target_filename in files:
|
||||
return os.path.join(root, target_filename)
|
||||
return None
|
||||
|
||||
file_path = await asyncio.to_thread(_walk_for_file)
|
||||
if file_path is None:
|
||||
return None, None
|
||||
|
||||
try:
|
||||
async with aiofiles.open(file_path, 'r', encoding=encoding) as f:
|
||||
text = await f.read()
|
||||
# JSON 解析是 CPU 操作,大文件可考虑放线程
|
||||
return json.loads(text), file_path
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"文件 {file_path} 不是有效的 JSON 格式。")
|
||||
return None, None
|
||||
except Exception as e:
|
||||
logger.error(f"读取文件 {file_path} 时发生错误: {e}")
|
||||
return None, None
|
||||
|
||||
|
||||
def extract_all_jpg_filenames(doc_data: list) -> set:
|
||||
"""从文档数据中提取所有 .jpg 图片的纯文件名。纯 CPU 操作,不需要 async。"""
|
||||
result_set = set()
|
||||
for item in doc_data:
|
||||
if 'img_path' in item and item['img_path']:
|
||||
img_path = item['img_path']
|
||||
if img_path.endswith('.jpg'):
|
||||
result_set.add(os.path.basename(img_path))
|
||||
return result_set
|
||||
|
||||
|
||||
async def _encode_one_image(
|
||||
img_file: str,
|
||||
local_image_dir: str,
|
||||
mime_map: dict,
|
||||
) -> Tuple[str, Optional[str]]:
|
||||
"""编码单张图片,返回 (文件名, data_uri 或 None)。"""
|
||||
img_path = os.path.join(local_image_dir, img_file)
|
||||
if not os.path.isfile(img_path):
|
||||
logger.warning(f"图片不存在: {img_path}")
|
||||
return img_file, None
|
||||
|
||||
try:
|
||||
async with aiofiles.open(img_path, "rb") as f:
|
||||
img_bytes = await f.read()
|
||||
# base64 编码是 CPU 操作,小文件直接做即可;大文件可放线程
|
||||
img_base64 = base64.b64encode(img_bytes).decode('utf-8')
|
||||
ext = img_file.lower().rsplit('.', 1)[-1]
|
||||
mime_type = f"image/{mime_map.get(ext, 'png')}"
|
||||
return img_file, f"data:{mime_type};base64,{img_base64}"
|
||||
except Exception as e:
|
||||
logger.error(f"编码图片失败 {img_file}: {str(e)}")
|
||||
return img_file, None
|
||||
|
||||
|
||||
async def encode_images_to_base64(
|
||||
local_image_dir: str,
|
||||
referenced_images: set,
|
||||
concurrency: int = 32,
|
||||
) -> Dict[str, str]:
|
||||
"""并发将引用的图片编码为 base64。"""
|
||||
if not os.path.exists(local_image_dir) or not referenced_images:
|
||||
return {}
|
||||
|
||||
mime_map = {'jpg': 'jpeg', 'jpeg': 'jpeg', 'png': 'png', 'gif': 'gif', 'webp': 'webp'}
|
||||
|
||||
# 用 Semaphore 限制并发数,避免一次性打开几百个文件句柄
|
||||
sem = asyncio.Semaphore(concurrency)
|
||||
|
||||
async def _bounded(img_file: str):
|
||||
async with sem:
|
||||
return await _encode_one_image(img_file, local_image_dir, mime_map)
|
||||
|
||||
results = await asyncio.gather(*(_bounded(f) for f in referenced_images))
|
||||
return {name: data for name, data in results if data is not None}
|
||||
|
||||
|
||||
async def process_document(
|
||||
input_file_name: str,
|
||||
search_dir: Optional[str] = "/app/mineru_output",
|
||||
local_image_dir: Optional[str] = None,
|
||||
concurrency: int = 32,
|
||||
) -> Optional[Tuple[list, Dict[str, str]]]:
|
||||
"""
|
||||
异步处理文档:查找 content_list.json,提取图片引用,并发编码为 base64。
|
||||
|
||||
Args:
|
||||
input_file_name: 原始文件名(如 "xxx.pdf")
|
||||
search_dir: 搜索 _content_list.json 的根目录
|
||||
local_image_dir: 图片所在目录,None 则自动推断
|
||||
concurrency: 图片编码的并发数上限,默认 32
|
||||
|
||||
Returns:
|
||||
成功: (content_list, images_dict)
|
||||
失败: None
|
||||
"""
|
||||
content, content_path = await find_and_read_content_list(search_dir, input_file_name)
|
||||
if content is None:
|
||||
logger.error("❌ 未找到指定的 _content_list.json 文件。")
|
||||
return None
|
||||
|
||||
logger.info(f"✅ 找到文件: {content_path}")
|
||||
logger.info(f"内容类型: {type(content).__name__}, 长度: {len(str(content))}")
|
||||
|
||||
if local_image_dir is None:
|
||||
local_image_dir = os.path.join(os.path.dirname(content_path), "images")
|
||||
logger.info(f"图片目录: {local_image_dir}")
|
||||
|
||||
referenced_images = extract_all_jpg_filenames(content)
|
||||
logger.info(f"引用图片数量: {len(referenced_images)}")
|
||||
|
||||
images_dict = await encode_images_to_base64(
|
||||
local_image_dir, referenced_images, concurrency=concurrency
|
||||
)
|
||||
logger.info(f"成功编码 {len(images_dict)} 张图片")
|
||||
|
||||
return content, images_dict
|
||||
|
||||
|
||||
async def main():
|
||||
result = await process_document(
|
||||
input_file_name="163-06A0014-B01001_发动机-维修手册.pdf",
|
||||
search_dir="/app/mineru_output",
|
||||
concurrency=32,
|
||||
)
|
||||
|
||||
if result is None:
|
||||
return 1
|
||||
|
||||
content, images_dict = result
|
||||
print(f"\n=== 处理完成 ===")
|
||||
print(f"文档段落数: {len(content)}")
|
||||
print(f"图片数量: {len(images_dict)}")
|
||||
if images_dict:
|
||||
first_key = next(iter(images_dict))
|
||||
print(f"示例图片 [{first_key}]: {images_dict[first_key][:80]}...")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = asyncio.run(main())
|
||||
exit(exit_code)
|
||||
249
main_agent.py
Normal file
@ -0,0 +1,249 @@
|
||||
"""
|
||||
主脑Agent - 简化版(无意图分类)
|
||||
|
||||
职责:
|
||||
1. 输入处理:图片、音频、文本提取
|
||||
2. 直接路由:根据外部传入的 route_flag 决定调用哪个工作流
|
||||
|
||||
工作流列表(来自 workflow_registry):
|
||||
- 故障排查与修理
|
||||
- 舰船百科
|
||||
- 操作使用
|
||||
"""
|
||||
|
||||
from typing import TypedDict, Literal, Optional, Dict, Any, List
|
||||
from langgraph.graph import StateGraph, START, END
|
||||
from tools.function_tool import (
|
||||
vlm_image_to_text,
|
||||
asr_audio_to_text,
|
||||
detect_input_type
|
||||
)
|
||||
from modelsAPI.model_api import OpenaiAPI
|
||||
from utils.function_tracker import track_function_calls
|
||||
from workflow_registry import VALID_ROUTE_FLAGS
|
||||
import json
|
||||
import re
|
||||
|
||||
|
||||
class MainAgentState(TypedDict):
|
||||
"""主脑Agent状态"""
|
||||
raw_input: Dict[str, Any]
|
||||
has_image: bool
|
||||
has_audio: bool
|
||||
has_query: bool
|
||||
has_file_text: bool
|
||||
input_type: str
|
||||
|
||||
history_message: str
|
||||
extracted_text: str
|
||||
image_description: str
|
||||
asr_text: str
|
||||
file_text: str
|
||||
combined_query: str
|
||||
|
||||
workflow_result: Optional[Dict[str, Any]]
|
||||
final_response: str
|
||||
error_message: str
|
||||
|
||||
route_flag: str
|
||||
route_params: Dict[str, Any]
|
||||
|
||||
|
||||
def detect_input_type_node(state: MainAgentState) -> Dict[str, Any]:
|
||||
"""检测输入类型"""
|
||||
raw = state["raw_input"]
|
||||
print(raw)
|
||||
print(22222222222222222222222222)
|
||||
result = detect_input_type.invoke({"raw_input": raw})
|
||||
|
||||
has_image = result.get("has_image", False)
|
||||
has_audio = result.get("has_audio", False)
|
||||
has_query = result.get("has_query", False)
|
||||
|
||||
has_file_text = False
|
||||
file_text_v = raw.get("file_text")
|
||||
if isinstance(file_text_v, str) and file_text_v.strip():
|
||||
has_file_text = True
|
||||
|
||||
input_type = "mixed"
|
||||
count = sum([has_image, has_audio, has_query, has_file_text])
|
||||
if count == 1:
|
||||
if has_image:
|
||||
input_type = "image"
|
||||
elif has_audio:
|
||||
input_type = "audio"
|
||||
elif has_query:
|
||||
input_type = "text"
|
||||
elif has_file_text:
|
||||
input_type = "file"
|
||||
|
||||
return {
|
||||
"has_image": has_image,
|
||||
"has_audio": has_audio,
|
||||
"has_query": has_query,
|
||||
"has_file_text": has_file_text,
|
||||
"input_type": input_type,
|
||||
}
|
||||
|
||||
|
||||
async def process_multimodal_input(state: MainAgentState) -> Dict[str, Any]:
|
||||
"""处理多模态输入"""
|
||||
has_image = state.get("has_image", False)
|
||||
has_audio = state.get("has_audio", False)
|
||||
has_query = state.get("has_query", False)
|
||||
has_file_text = state.get("has_file_text", False)
|
||||
raw = state["raw_input"]
|
||||
|
||||
image_description = ""
|
||||
asr_text = ""
|
||||
extracted_text = ""
|
||||
|
||||
try:
|
||||
if has_image:
|
||||
image_path = raw.get("image", "")
|
||||
if image_path:
|
||||
try:
|
||||
image_description = await vlm_image_to_text.ainvoke({
|
||||
"image_path": image_path
|
||||
})
|
||||
if not image_description or image_description.startswith("VLM 调用失败"):
|
||||
image_description = ""
|
||||
except Exception:
|
||||
image_description = ""
|
||||
|
||||
if has_audio:
|
||||
audio_path = raw.get("audio", "")
|
||||
if audio_path:
|
||||
try:
|
||||
asr_text = await asr_audio_to_text.ainvoke({
|
||||
"audio_path": audio_path
|
||||
})
|
||||
if not asr_text or asr_text.startswith("ASR 调用失败"):
|
||||
asr_text = ""
|
||||
except Exception:
|
||||
asr_text = ""
|
||||
|
||||
text_parts = []
|
||||
if raw.get("query"):
|
||||
text_parts.append(raw["query"])
|
||||
if image_description:
|
||||
text_parts.append(f"[图片描述] {image_description}")
|
||||
if asr_text:
|
||||
text_parts.append(f"[语音转文字] {asr_text}")
|
||||
if raw.get("file_text"):
|
||||
text_parts.append(f"[文件内容] {raw['file_text']}")
|
||||
|
||||
extracted_text = "\n".join(text_parts) if text_parts else ""
|
||||
combined_query = raw.get("query", "") or extracted_text
|
||||
|
||||
return {
|
||||
"extracted_text": extracted_text,
|
||||
"image_description": image_description,
|
||||
"asr_text": asr_text,
|
||||
"combined_query": combined_query,
|
||||
"file_text": raw.get("file_text", ""),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"error_message": f"输入处理失败: {str(e)}",
|
||||
"extracted_text": "",
|
||||
}
|
||||
|
||||
# @track_function_calls
|
||||
def route_to_workflow(state: MainAgentState) -> Dict[str, Any]:
|
||||
"""
|
||||
🤖分析意图,调用相关智能体
|
||||
"""
|
||||
route_flag = state.get("route_flag", "问题排查")
|
||||
|
||||
if route_flag not in VALID_ROUTE_FLAGS:
|
||||
route_flag = "问题排查"
|
||||
|
||||
query_text = state.get("extracted_text", "")
|
||||
combined_query = state.get("combined_query", "") or query_text
|
||||
|
||||
raw_input = state.get("raw_input", {})
|
||||
history_message = raw_input.get("history_message", "")
|
||||
|
||||
route_params = {
|
||||
"extracted_text": query_text,
|
||||
"combined_query": combined_query,
|
||||
"history_message": history_message,
|
||||
}
|
||||
|
||||
if raw_input.get("file_text"):
|
||||
route_params["file_text"] = raw_input["file_text"]
|
||||
|
||||
print(f"[主脑Agent] 路由到: {route_flag}")
|
||||
print(f"[主脑Agent] 参数: combined_query={combined_query[:100]}...")
|
||||
|
||||
if route_flag == "问题排查":
|
||||
detail = "正在调用问题排查智能体"
|
||||
elif route_flag == "百科问答":
|
||||
detail = "正在调用百科问答智能体"
|
||||
elif route_flag == "操作使用":
|
||||
detail = "正在调用操作使用智能体"
|
||||
elif route_flag == "统计":
|
||||
detail = "正在调用统计智能体"
|
||||
else:
|
||||
detail = "正在调用通用智能体"
|
||||
|
||||
return {
|
||||
"route_flag": route_flag,
|
||||
"route_params": route_params,
|
||||
"__detail__": detail,
|
||||
}
|
||||
|
||||
|
||||
def create_main_agent():
|
||||
"""创建主脑Agent"""
|
||||
workflow = StateGraph(MainAgentState)
|
||||
|
||||
workflow.add_node("detect_input_type", detect_input_type_node)
|
||||
workflow.add_node("process_input", process_multimodal_input)
|
||||
workflow.add_node("route", route_to_workflow)
|
||||
|
||||
workflow.add_edge(START, "detect_input_type")
|
||||
workflow.add_edge("detect_input_type", "process_input")
|
||||
workflow.add_edge("process_input", "route")
|
||||
workflow.add_edge("route", END)
|
||||
|
||||
return workflow.compile()
|
||||
|
||||
|
||||
from workflows.history_manager import filter_image_urls
|
||||
|
||||
|
||||
async def extract_conversation_title(content: str) -> str:
|
||||
"""提取对话标题"""
|
||||
try:
|
||||
prompt = f"请从以下对话内容中提取一个简短的标题(不超过20字):\n\n{content[:500]}"
|
||||
response = await OpenaiAPI.open_api_chat_without_thinking(
|
||||
query=prompt,
|
||||
model=None,
|
||||
system_prompt="你是一个标题提取助手,请从对话内容中提取一个简短的标题。",
|
||||
messages=[]
|
||||
)
|
||||
title = response.strip() if response else "新对话"
|
||||
if len(title) > 30:
|
||||
title = title[:30] + "..."
|
||||
return title
|
||||
except Exception:
|
||||
return "新对话"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
|
||||
async def test():
|
||||
print("测试主脑Agent")
|
||||
agent = create_main_agent()
|
||||
result = await agent.ainvoke({
|
||||
"raw_input": {"query": "101舰主机发生异响"},
|
||||
"route_flag": "故障排查与修理"
|
||||
})
|
||||
print(f"结果: {result}")
|
||||
|
||||
asyncio.run(test())
|
||||
|
||||
0
modelsAPI/__init__.py
Normal file
BIN
modelsAPI/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
modelsAPI/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
modelsAPI/__pycache__/model_api.cpython-310.pyc
Normal file
BIN
modelsAPI/__pycache__/model_api.cpython-312.pyc
Normal file
386
modelsAPI/model_api.py
Normal file
@ -0,0 +1,386 @@
|
||||
import asyncio
|
||||
import httpx
|
||||
from typing import List, Union
|
||||
import os
|
||||
import numpy as np
|
||||
from openai import OpenAI
|
||||
from config import LLM_CONFIG, EMBEDDING_CONFIG
|
||||
from neo4j_graphrag.embeddings.base import Embedder
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
# 模块级单例客户端,避免每次调用都创建新连接
|
||||
_async_client: AsyncOpenAI = None
|
||||
|
||||
|
||||
def _get_async_client() -> AsyncOpenAI:
|
||||
"""获取或创建 AsyncOpenAI 单例客户端"""
|
||||
global _async_client
|
||||
if _async_client is None:
|
||||
_async_client = AsyncOpenAI(
|
||||
api_key=LLM_CONFIG["api_key"],
|
||||
base_url=LLM_CONFIG["base_url"],
|
||||
)
|
||||
return _async_client
|
||||
|
||||
|
||||
class OpenaiAPI:
|
||||
@staticmethod
|
||||
async def open_api_chat_without_thinking(
|
||||
query: str = None,
|
||||
model: str = None,
|
||||
json_output: bool = False,
|
||||
system_prompt: str = None,
|
||||
messages: list = None,
|
||||
enable_thinking: bool = True,
|
||||
temperature: float = 0.1
|
||||
) -> str:
|
||||
if model is None:
|
||||
model = LLM_CONFIG["model"]
|
||||
if system_prompt is None:
|
||||
system_prompt = "我是一个专业的AI助手,请用中文回答问题。请直接给出最终答案,不要包含任何推理步骤、思考过程、解释性文字或前缀。"
|
||||
|
||||
|
||||
# 构建消息
|
||||
message_list = [{"role": "system", "content": system_prompt}]
|
||||
|
||||
if messages is not None:
|
||||
message_list.extend(messages)
|
||||
if query is not None:
|
||||
message_list.append({"role": "user", "content": query})
|
||||
|
||||
client = _get_async_client()
|
||||
|
||||
kwargs = {
|
||||
"model": model,
|
||||
"messages": message_list,
|
||||
"temperature": temperature,
|
||||
"stream": False,
|
||||
"max_tokens":LLM_CONFIG["max_tokens"]
|
||||
}
|
||||
|
||||
kwargs["extra_body"] = {"chat_template_kwargs": {"enable_thinking": False}}
|
||||
|
||||
if json_output:
|
||||
kwargs["response_format"] = {"type": "json_object"}
|
||||
|
||||
print("model API. history message", message_list)
|
||||
response = await client.chat.completions.create(**kwargs)
|
||||
return response.choices[0].message.content
|
||||
|
||||
@staticmethod
|
||||
async def open_api_chat_stream(
|
||||
query: str = None,
|
||||
model: str = None,
|
||||
json_output: bool = False,
|
||||
system_prompt: str = None,
|
||||
messages: list = None,
|
||||
enable_thinking: bool = True,
|
||||
temperature: float = 0.1
|
||||
):
|
||||
"""
|
||||
流式输出大模型响应
|
||||
|
||||
Yields:
|
||||
str: 流式内容片段
|
||||
"""
|
||||
if model is None:
|
||||
model = LLM_CONFIG["model"]
|
||||
if system_prompt is None:
|
||||
system_prompt = "我是一个专业的AI助手,请用中文回答问题。请直接给出最终答案,不要包含任何推理步骤、思考过程、解释性文字或前缀。"
|
||||
|
||||
# 构建消息
|
||||
message_list = [{"role": "system", "content": system_prompt}]
|
||||
|
||||
if messages is not None:
|
||||
message_list.extend(messages)
|
||||
if query is not None:
|
||||
message_list.append({"role": "user", "content": query})
|
||||
|
||||
client = _get_async_client()
|
||||
|
||||
kwargs = {
|
||||
"model": model,
|
||||
"messages": message_list,
|
||||
"temperature": temperature,
|
||||
"stream": True,
|
||||
"max_tokens":LLM_CONFIG["max_tokens"]
|
||||
}
|
||||
|
||||
kwargs["extra_body"] = {"chat_template_kwargs": {"enable_thinking": False}}
|
||||
|
||||
if json_output:
|
||||
kwargs["response_format"] = {"type": "json_object"}
|
||||
|
||||
print("model API stream. history message", message_list)
|
||||
|
||||
stream = await client.chat.completions.create(**kwargs)
|
||||
async for chunk in stream:
|
||||
if chunk.choices and len(chunk.choices) > 0:
|
||||
delta = chunk.choices[0].delta
|
||||
if delta.content:
|
||||
yield delta.content
|
||||
|
||||
@staticmethod
|
||||
async def open_api_vl_without_thinking(
|
||||
image_url: str
|
||||
) -> str:
|
||||
"""
|
||||
专门用于从图片中提取【设备名称】和【故障现象】。
|
||||
自动过滤思考过程,仅返回核心结果。
|
||||
|
||||
参数:
|
||||
image_url: Base64 格式的图片数据 (data:image/...;base64,...)
|
||||
model: 模型名称,默认为配置中的模型
|
||||
custom_instruction: 额外的特定指令 (可选)
|
||||
"""
|
||||
|
||||
# 1. 确定模型名称
|
||||
model = LLM_CONFIG.get("model")
|
||||
|
||||
# 2. 构建强约束的 System Prompt
|
||||
# 核心目标:禁止思考标签,禁止废话,只给结果
|
||||
system_prompt = (
|
||||
"你是一个工业视觉分析专家。你的任务是从图片中识别设备并诊断故障。\n"
|
||||
"【严格约束】\n"
|
||||
"1. 绝对禁止输出 <think>, <think>, <reasoning> 等任何思考过程标签。\n"
|
||||
"2. 绝对禁止输出'好的'、'根据图片'、'分析如下'等开场白或结束语。\n"
|
||||
"3. 直接输出最终结论,格式必须严格遵守下面的模板。"
|
||||
)
|
||||
|
||||
# 3. 构建针对性的 User Prompt
|
||||
default_task = (
|
||||
"请分析这张图片,描述图片内容,重点关注以下信息\n"
|
||||
"1. 设备识别:图中是什么设备?型号或标签是否可见?\n"
|
||||
"2. 状态评估:仪表盘数值是否在正常区间?指示灯颜色是什么?\n"
|
||||
"3. 异常检测:是否存在漏油、生锈、电缆破损、螺丝松动或烟雾?\n"
|
||||
"4. 环境风险:周围是否有杂物堆放或安全隐患?\n"
|
||||
"请用专业、客观、简短的语言描述。"
|
||||
)
|
||||
|
||||
final_user_prompt = default_task
|
||||
|
||||
# 4. 初始化客户端
|
||||
client = _get_async_client()
|
||||
|
||||
# 5. 构建请求参数
|
||||
kwargs = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": final_user_prompt},
|
||||
{"type": "image_url", "image_url": {"url": image_url}}
|
||||
]
|
||||
}
|
||||
],
|
||||
"temperature": 0.1, # 低温度以保证事实准确性
|
||||
"stream": False,
|
||||
"max_tokens": LLM_CONFIG.get("max_tokens"),
|
||||
}
|
||||
|
||||
# 尝试通过参数关闭思考 (取决于后端支持情况)
|
||||
kwargs["extra_body"] = {"chat_template_kwargs": {"enable_thinking": False}}
|
||||
|
||||
try:
|
||||
print(f"🚀 正在调用 {model} 进行设备故障分析...")
|
||||
response = await client.chat.completions.create(**kwargs)
|
||||
raw_content = response.choices[0].message.content or ""
|
||||
|
||||
print(f"✅ 分析完成:\n{raw_content}")
|
||||
return raw_content
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
error_msg = f"❌ 设备故障分析失败:{str(e)}\n{traceback.format_exc()}"
|
||||
print(error_msg)
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
@staticmethod
|
||||
def get_embeddings(embedding_text: str):
|
||||
embedding_base_url = EMBEDDING_CONFIG["base_url"]
|
||||
|
||||
with httpx.Client(timeout=60) as client:
|
||||
response = client.post(
|
||||
embedding_base_url,
|
||||
json={"model": EMBEDDING_CONFIG["model"], "input": embedding_text},
|
||||
headers={"Authorization": EMBEDDING_CONFIG["api_key"]},
|
||||
)
|
||||
embedding_text = response.json()["data"][0]["embedding"]
|
||||
return embedding_text
|
||||
|
||||
@staticmethod
|
||||
async def get_embeddings_async(embedding_text: str) -> List[float]:
|
||||
embedding_base_url = EMBEDDING_CONFIG["base_url"]
|
||||
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
response = await client.post(
|
||||
embedding_base_url,
|
||||
json={"model": EMBEDDING_CONFIG["model"], "input": embedding_text},
|
||||
headers={"Authorization": EMBEDDING_CONFIG["api_key"]},
|
||||
)
|
||||
embedding = response.json()["data"][0]["embedding"]
|
||||
return embedding
|
||||
|
||||
@staticmethod
|
||||
async def get_embeddings_batch_async(texts: List[str]) -> List[List[float]]:
|
||||
embedding_base_url = EMBEDDING_CONFIG["base_url"]
|
||||
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
response = await client.post(
|
||||
embedding_base_url,
|
||||
json={"model": EMBEDDING_CONFIG["model"], "input": texts},
|
||||
headers={"Authorization": EMBEDDING_CONFIG["api_key"]},
|
||||
)
|
||||
data = response.json()["data"]
|
||||
sorted_data = sorted(data, key=lambda x: x["index"])
|
||||
embeddings = [item["embedding"] for item in sorted_data]
|
||||
return embeddings
|
||||
|
||||
@staticmethod
|
||||
def cosine_similarity(vec1: Union[List[float], np.ndarray], vec2: Union[List[float], np.ndarray]) -> float:
|
||||
v1 = np.array(vec1) if not isinstance(vec1, np.ndarray) else vec1
|
||||
v2 = np.array(vec2) if not isinstance(vec2, np.ndarray) else vec2
|
||||
|
||||
norm1 = np.linalg.norm(v1)
|
||||
norm2 = np.linalg.norm(v2)
|
||||
|
||||
if norm1 == 0 or norm2 == 0:
|
||||
return 0.0
|
||||
|
||||
return float(np.dot(v1, v2) / (norm1 * norm2))
|
||||
|
||||
@staticmethod
|
||||
def cosine_similarity_batch(query_vec: Union[List[float], np.ndarray],
|
||||
candidates_vecs: List[Union[List[float], np.ndarray]]) -> List[float]:
|
||||
query = np.array(query_vec) if not isinstance(query_vec, np.ndarray) else query_vec
|
||||
candidates = [np.array(v) if not isinstance(v, np.ndarray) else v for v in candidates_vecs]
|
||||
|
||||
query_norm = np.linalg.norm(query)
|
||||
if query_norm == 0:
|
||||
return [0.0] * len(candidates)
|
||||
|
||||
similarities = []
|
||||
for candidate in candidates:
|
||||
cand_norm = np.linalg.norm(candidate)
|
||||
if cand_norm == 0:
|
||||
similarities.append(0.0)
|
||||
else:
|
||||
sim = float(np.dot(query, candidate) / (query_norm * cand_norm))
|
||||
similarities.append(sim)
|
||||
|
||||
return similarities
|
||||
|
||||
@staticmethod
|
||||
async def hybrid_match_with_embeddings(
|
||||
query_text: str,
|
||||
candidates: List[dict],
|
||||
name_key: str = "display_name",
|
||||
embedding_key: str = "embedding",
|
||||
text_weight: float = 0.3,
|
||||
semantic_weight: float = 0.7,
|
||||
text_threshold: float = 0.3,
|
||||
semantic_threshold: float = 0.5
|
||||
) -> tuple:
|
||||
if not candidates:
|
||||
return None, 0.0
|
||||
|
||||
query_embedding = await OpenaiAPI.get_embeddings_async(query_text)
|
||||
|
||||
scored_candidates = []
|
||||
|
||||
for idx, candidate in enumerate(candidates):
|
||||
name = candidate.get(name_key, "")
|
||||
if not name:
|
||||
props = candidate.get("props", {})
|
||||
name = props.get("name") or props.get("名称") or props.get("设备名称") or ""
|
||||
|
||||
text_score = OpenaiAPI._compute_text_similarity(query_text, name)
|
||||
|
||||
props = candidate.get("props", {})
|
||||
candidate_embedding = props.get(embedding_key) or candidate.get(embedding_key)
|
||||
|
||||
if candidate_embedding:
|
||||
semantic_score = OpenaiAPI.cosine_similarity(query_embedding, candidate_embedding)
|
||||
else:
|
||||
semantic_score = 0.0
|
||||
|
||||
combined_score = text_weight * text_score + semantic_weight * semantic_score
|
||||
|
||||
scored_candidates.append({
|
||||
"candidate": candidate,
|
||||
"text_score": text_score,
|
||||
"semantic_score": semantic_score,
|
||||
"combined_score": combined_score,
|
||||
"index": idx
|
||||
})
|
||||
|
||||
scored_candidates.sort(key=lambda x: x["combined_score"], reverse=True)
|
||||
|
||||
best = scored_candidates[0]
|
||||
best_candidate = best["candidate"]
|
||||
best_score = best["combined_score"]
|
||||
|
||||
min_threshold = max(text_threshold * text_weight + semantic_threshold * semantic_weight, 0.3)
|
||||
|
||||
if best_score < min_threshold:
|
||||
return None, best_score
|
||||
|
||||
return best_candidate, best_score
|
||||
|
||||
@staticmethod
|
||||
def _compute_text_similarity(a: str, b: str) -> float:
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
a = str(a).strip().lower()
|
||||
b = str(b).strip().lower()
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
if a == b:
|
||||
return 1.0
|
||||
if a in b or b in a:
|
||||
return 0.9
|
||||
|
||||
set_a, set_b = set(a), set(b)
|
||||
inter = len(set_a & set_b)
|
||||
union = len(set_a | set_b) or 1
|
||||
jaccard = inter / union
|
||||
|
||||
len_diff = abs(len(a) - len(b))
|
||||
len_penalty = max(0.0, 1.0 - len_diff / max(len(a), len(b), 1))
|
||||
|
||||
return 0.5 * jaccard + 0.5 * len_penalty
|
||||
|
||||
class LocalBgeM3Embeddings(Embedder):
|
||||
def __init__(self, base_url: str, api_key: str, model_name: str = "bge-m3"):
|
||||
self.base_url = base_url
|
||||
self.api_key = api_key
|
||||
self.model_name = model_name
|
||||
|
||||
def embed_query(self, text: str) -> List[float]:
|
||||
return self.embed_documents([text])[0]
|
||||
|
||||
def embed_documents(self, texts: List[str]) -> List[List[float]]:
|
||||
url = self.base_url
|
||||
with httpx.Client(timeout=60) as client:
|
||||
response = client.post(
|
||||
url,
|
||||
json={"model": self.model_name, "input": texts},
|
||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||
)
|
||||
return [item["embedding"] for item in response.json()["data"]]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
answer = asyncio.run(OpenaiAPI.open_api_chat_without_thinking(
|
||||
query="你好",
|
||||
json_output=True,
|
||||
system_prompt="你是一个助手,请用中文回答问题。请直接给出最终答案,不要包含任何推理步骤、思考过程、解释性文字或前缀。",
|
||||
enable_thinking=True
|
||||
))
|
||||
print(answer)
|
||||
|
||||
|
||||
|
||||
BIN
picture/015ebb33d2a3_20260324072409.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
picture/08f8efd6f1d4_20260324110619.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
picture/1ee23229170a_20260324082344.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
picture/22f06245ed56_20260601022623.png
Normal file
|
After Width: | Height: | Size: 1023 KiB |
BIN
picture/334ee511347b_20260323061707.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
picture/4341.png
Normal file
|
After Width: | Height: | Size: 242 KiB |
BIN
picture/44b07d453030_20260323024633.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
picture/50da933dcb11_20260323062013.jpg
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
picture/58a85f24f7cf_20260323024053.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
picture/79493c5be2ac_20260324081251.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
picture/95f8928fb88c_20260324083528.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
picture/OIP-C.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
picture/bbeb197ac231_20260323060515.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
picture/d345fbf33f95_20260325031818.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
picture/dcd6a33ef2b0_20260324071427.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
picture/e0bb42e29c58_20260323062055.jpg
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
picture/f233ccc07264_20260323060328.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
picture/f38267265a8a_20260323032735.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
98
picture/image_records.json
Normal file
@ -0,0 +1,98 @@
|
||||
[
|
||||
{
|
||||
"image_name": "58a85f24f7cf_20260323024053.png",
|
||||
"image_path": "picture/58a85f24f7cf_20260323024053.png",
|
||||
"description": "设备名称:未知设备\n故障现象:未发现明显异常",
|
||||
"timestamp": "2026-03-23T02:40:53.157120"
|
||||
},
|
||||
{
|
||||
"image_name": "44b07d453030_20260323024633.png",
|
||||
"image_path": "picture/44b07d453030_20260323024633.png",
|
||||
"description": "设备名称:V型发动机(剖视图)\n故障现象:正常",
|
||||
"timestamp": "2026-03-23T02:46:33.821095"
|
||||
},
|
||||
{
|
||||
"image_name": "f38267265a8a_20260323032735.png",
|
||||
"image_path": "picture/f38267265a8a_20260323032735.png",
|
||||
"description": "设备名称:京东红途(Logo标识)\n故障现象:图片中未显示任何工业设备,仅包含“京东红途”的品牌Logo标识,无法进行设备故障诊断。",
|
||||
"timestamp": "2026-03-23T03:27:35.305548"
|
||||
},
|
||||
{
|
||||
"image_name": "f233ccc07264_20260323060328.png",
|
||||
"image_path": "picture/f233ccc07264_20260323060328.png",
|
||||
"description": "1. 设备识别:图中展示的是一个汽车发动机(内燃机)的3D渲染剖面图,具体型号或标签不可见。\n2. 状态评估:该图为静态结构展示,无仪表盘数值或指示灯,无法评估运行状态。\n3. 异常检测:图中无漏油、生锈、电缆破损、螺丝松动或烟雾等异常现象,部件表面光洁,结构完整。\n4. 环境风险:背景为纯色,无杂物堆放或安全隐患。",
|
||||
"timestamp": "2026-03-23T06:03:28.810875"
|
||||
},
|
||||
{
|
||||
"image_name": "bbeb197ac231_20260323060515.png",
|
||||
"image_path": "picture/bbeb197ac231_20260323060515.png",
|
||||
"description": "1. 设备识别:图中展示的是一个汽车发动机(内燃机)的3D渲染剖面图,具体型号或标签不可见。\n2. 状态评估:该图为静态结构展示,无仪表盘数值或指示灯,无法评估运行状态。\n3. 异常检测:图中无漏油、生锈、电缆破损、螺丝松动或烟雾等异常现象,部件表面光洁,结构完整。\n4. 环境风险:背景为纯色,无杂物堆放或安全隐患。",
|
||||
"timestamp": "2026-03-23T06:05:15.445638"
|
||||
},
|
||||
{
|
||||
"image_name": "334ee511347b_20260323061707.png",
|
||||
"image_path": "picture/334ee511347b_20260323061707.png",
|
||||
"description": "1. 设备识别:图中展示的是一个V型多缸内燃机(V-type Internal Combustion Engine)的3D剖面模型,非实物照片。由于是计算机生成的渲染图,无法识别具体型号,且无可见标签。\n2. 状态评估:该设备为静态模型,无仪表盘、数值或指示灯,无法进行状态评估。\n3. 异常检测:作为3D渲染图,设备表面呈现完美的金属质感,无漏油、生锈、电缆破损、螺丝松动或烟雾等物理缺陷。\n4. 环境风险:背景为纯色渐变,无杂物堆放或安全隐患。",
|
||||
"timestamp": "2026-03-23T06:17:07.234182"
|
||||
},
|
||||
{
|
||||
"image_name": "50da933dcb11_20260323062013.jpg",
|
||||
"image_path": "picture/50da933dcb11_20260323062013.jpg",
|
||||
"description": "1. **设备识别**:图中显示为一台笔记本电脑(品牌标识模糊,疑似HP),屏幕处于开启状态,键盘区域可见。左下角叠加有“马兆帅 警圣测试专用”字样及多组编号(如999997、440300999999等),表明该设备可能用于特定测试或监控用途。\n\n2. **状态评估**:右下角显示“Sto:20.7GB”(存储剩余)和“Pow:91%”(电量),数值均在正常区间;无仪表盘或指示灯可见,无法判断颜色状态。\n\n3. **异常检测**:未发现漏油、生锈、电缆破损、螺丝松动或烟雾等物理异常;画面存在手指遮挡与运动模糊,属拍摄干扰,非设备故障。\n\n4. **环境风险**:背景可见货架、包装盒等杂物,桌面略显杂乱,存在轻微安全隐患(如物品倾倒或绊倒风险),但无即时危险。\n\n结论:设备运行状态正常,环境需整理以消除潜在风险。",
|
||||
"timestamp": "2026-03-23T06:20:13.124244"
|
||||
},
|
||||
{
|
||||
"image_name": "e0bb42e29c58_20260323062055.jpg",
|
||||
"image_path": "picture/e0bb42e29c58_20260323062055.jpg",
|
||||
"description": "1. 设备识别:图中显示一台笔记本电脑(品牌标识模糊,疑似HP),屏幕处于开启状态,键盘区域可见。设备标签及序列号不可见。\n2. 状态评估:屏幕显示内容模糊,无法读取仪表盘数值;无明确指示灯颜色可辨。\n3. 异常检测:未发现漏油、生锈、电缆破损、螺丝松动或烟雾。\n4. 环境风险:画面左侧有手指遮挡镜头,背景杂乱,存在杂物堆放,可能影响操作安全。",
|
||||
"timestamp": "2026-03-23T06:20:55.600490"
|
||||
},
|
||||
{
|
||||
"image_name": "dcd6a33ef2b0_20260324071427.png",
|
||||
"image_path": "picture/dcd6a33ef2b0_20260324071427.png",
|
||||
"description": "1. 设备识别:图中为一辆汽车的发动机舱,引擎盖处于开启状态。由于拍摄角度和遮挡,具体的车辆型号及发动机标签不可见。\n2. 状态评估:仪表盘不可见,无法评估数值或指示灯状态。\n3. 异常检测:发动机舱内存在明显的白色烟雾(或蒸汽),主要集中在发动机左侧及中部区域,表明存在严重的过热或冷却液泄漏故障。\n4. 环境风险:背景中可见堆放的纸箱及杂物,地面有蓝色垫子,存在杂物堆放情况,可能影响维修作业安全。",
|
||||
"timestamp": "2026-03-24T07:14:27.522730"
|
||||
},
|
||||
{
|
||||
"image_name": "015ebb33d2a3_20260324072409.png",
|
||||
"image_path": "picture/015ebb33d2a3_20260324072409.png",
|
||||
"description": "1. 设备识别:图中设备为一辆汽车的发动机舱,引擎盖处于开启状态。由于拍摄角度和遮挡,具体的车辆型号及发动机标签不可见。\n2. 状态评估:仪表盘不可见,无法评估数值或指示灯状态。\n3. 异常检测:存在明显的白色烟雾(或蒸汽)从发动机舱内部升起,主要集中在发动机左侧及中部区域,表明存在过热或冷却液泄漏等故障。\n4. 环境风险:背景中可见堆放的纸箱及杂物,地面有蓝色垫子,属于维修车间环境,存在杂物堆放带来的潜在安全隐患。",
|
||||
"timestamp": "2026-03-24T07:24:09.812908"
|
||||
},
|
||||
{
|
||||
"image_name": "79493c5be2ac_20260324081251.png",
|
||||
"image_path": "picture/79493c5be2ac_20260324081251.png",
|
||||
"description": "1. 设备识别:图中为一辆白色汽车的发动机舱,引擎盖处于开启状态。具体车型或设备型号不可见。\n2. 状态评估:仪表盘不可见,无法评估数值或指示灯状态。\n3. 异常检测:发动机舱内存在明显的白色烟雾(或蒸汽),表明可能存在冷却液泄漏、过热或密封失效等故障。\n4. 环境风险:背景区域可见堆放的纸箱及杂物,存在一定程度的杂乱,可能影响维修作业安全。",
|
||||
"timestamp": "2026-03-24T08:12:51.902164"
|
||||
},
|
||||
{
|
||||
"image_name": "1ee23229170a_20260324082344.png",
|
||||
"image_path": "picture/1ee23229170a_20260324082344.png",
|
||||
"description": "1. 设备识别:图中设备为一辆汽车的发动机舱,引擎盖处于开启状态。由于拍摄角度和遮挡,具体的车辆型号或标签不可见。\n2. 状态评估:仪表盘不可见,无法评估数值或指示灯状态。\n3. 异常检测:存在明显的白色烟雾(或蒸汽)从发动机舱内部冒出,主要集中在左侧进气歧管区域,表明发动机存在过热或冷却系统泄漏等严重故障。\n4. 环境风险:背景中可见堆放的纸箱和杂物,且车辆处于非正常行驶状态,存在一定安全隐患。",
|
||||
"timestamp": "2026-03-24T08:23:44.780598"
|
||||
},
|
||||
{
|
||||
"image_name": "95f8928fb88c_20260324083528.png",
|
||||
"image_path": "picture/95f8928fb88c_20260324083528.png",
|
||||
"description": "1. 设备识别:图中为一辆汽车的发动机舱,引擎盖处于开启状态。由于拍摄角度和遮挡,无法识别具体的车辆型号或发动机标签。\n2. 状态评估:仪表盘不可见,无法评估数值或指示灯状态。\n3. 异常检测:发动机舱内存在明显的白色烟雾(或蒸汽),主要集中在发动机左侧区域,表明可能存在冷却液泄漏、高温或机械故障。\n4. 环境风险:背景中可见堆放的纸箱和杂物,地面有蓝色垫子,存在杂物堆放情况,可能影响维修作业安全。",
|
||||
"timestamp": "2026-03-24T08:35:28.036819"
|
||||
},
|
||||
{
|
||||
"image_name": "08f8efd6f1d4_20260324110619.png",
|
||||
"image_path": "picture/08f8efd6f1d4_20260324110619.png",
|
||||
"description": "1. 设备识别:图中是一辆汽车的发动机舱,引擎盖处于开启状态。由于拍摄角度和遮挡,具体的车辆型号或标签不可见。\n2. 状态评估:仪表盘不可见,无法评估数值或指示灯状态。\n3. 异常检测:发动机舱内存在明显的白色烟雾(或蒸汽),主要集中在发动机左侧区域,表明可能存在冷却液泄漏、过热或管路破裂等严重故障。\n4. 环境风险:背景中可见堆放的纸箱和杂物,地面有蓝色垫子,存在杂物堆放情况,可能影响维修操作或造成绊倒隐患。",
|
||||
"timestamp": "2026-03-24T11:06:19.237516"
|
||||
},
|
||||
{
|
||||
"image_name": "d345fbf33f95_20260325031818.png",
|
||||
"image_path": "picture/d345fbf33f95_20260325031818.png",
|
||||
"description": "1. 设备识别:图中为一辆白色轿车的发动机舱,引擎盖处于开启状态。由于拍摄角度和遮挡,具体的车辆型号及发动机标签不可见。\n2. 状态评估:仪表盘不可见,无法评估数值或指示灯状态。\n3. 异常检测:发动机舱内存在明显的白色烟雾(或蒸汽),主要集中在发动机左侧及中部区域,表明存在高温泄漏或冷却系统故障。\n4. 环境风险:背景中可见堆放的纸箱及杂物,地面有蓝色垫子,存在杂物堆放情况,可能影响维修作业安全。",
|
||||
"timestamp": "2026-03-25T03:18:18.243134"
|
||||
},
|
||||
{
|
||||
"image_name": "22f06245ed56_20260601022623.png",
|
||||
"image_path": "picture/22f06245ed56_20260601022623.png",
|
||||
"description": "Error: Error code: 400 - {'error': {'message': 'Qwen3-14B is not a multimodal model', 'type': 'BadRequestError', 'param': None, 'code': 400}}",
|
||||
"timestamp": "2026-06-01T02:26:23.900253"
|
||||
}
|
||||
]
|
||||
66
prompt.py
Normal file
@ -0,0 +1,66 @@
|
||||
from openai import OpenAI
|
||||
query_type_prompt = """
|
||||
你是一个专业的装备维修领域用户查询判断助手,能够根据以下规则准确判断用户问题属于哪种类型。
|
||||
|
||||
### 判断规则
|
||||
- **简单问答类型(simple)**:问题属于日常生活常识、通用知识、非技术性提问,**不涉及**装备、设备、机械、电子、维修、故障诊断、零部件、操作流程等专业领域内容。例如问候、自我介绍、天气、时间等。
|
||||
- **复杂问答类型(complex)**:问题**涉及**装备、设备、机械系统(如发动机、液压系统)、电子装置、维修流程、故障排查、零部件组成、技术参数、操作规范等专业领域,即使只是询问定义、组成或功能,也视为 complex。
|
||||
|
||||
### 输出要求
|
||||
- 仅输出 JSON 格式:{"query_type":"simple"} 或 {"query_type":"complex"}
|
||||
- 不得包含任何其他文字、解释或格式。
|
||||
|
||||
### 示例
|
||||
输入:今天心情怎么样
|
||||
输出:{"query_type":"simple"}
|
||||
|
||||
输入:请问你是谁
|
||||
输出:{"query_type":"simple"}
|
||||
|
||||
输入:发动机是什么
|
||||
输出:{"query_type":"complex"}
|
||||
|
||||
输入:缸盖组包含哪些组件
|
||||
输出:{"query_type":"complex"}
|
||||
|
||||
输入:手机屏幕碎了怎么办
|
||||
输出:{"query_type":"complex"}
|
||||
|
||||
输入:水的沸点是多少
|
||||
输出:{"query_type":"simple"}
|
||||
|
||||
输入:如何更换坦克履带
|
||||
输出:{"query_type":"complex"}
|
||||
|
||||
### 用户查询:
|
||||
text
|
||||
"""
|
||||
def open_api_chat(query: str, model: str = None):
|
||||
"""
|
||||
同步调用Chat API
|
||||
|
||||
Args:
|
||||
query: 查询文本
|
||||
model: 模型名称(可选,默认使用环境变量OPENAI_MODEL)
|
||||
"""
|
||||
if model is None:
|
||||
model = "Qwen3.5-35B-A3B"
|
||||
response = OpenAI(
|
||||
api_key = "none",
|
||||
base_url = "http://192.168.0.46:59800/v1"
|
||||
).chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{"role": "user", "content": query},
|
||||
],
|
||||
temperature=0.6,
|
||||
stream=False,
|
||||
response_format = {"type": "json_object"},
|
||||
|
||||
)
|
||||
return response.choices[0].message.content
|
||||
# querycontent = "发动机异响"
|
||||
# query = query_type_prompt.replace("text",querycontent)
|
||||
|
||||
# res = open_api_chat(query=query)
|
||||
# print(res)
|
||||
1240
prompts.py
Normal file
44
requirements.txt
Normal file
@ -0,0 +1,44 @@
|
||||
# Agent任务划分开发项目依赖
|
||||
|
||||
# LangChain相关
|
||||
langchain
|
||||
langchain-core
|
||||
langchain-openai
|
||||
langgraph
|
||||
langgraph-checkpoint
|
||||
langgraph-checkpoint-postgres
|
||||
|
||||
# HTTP客户端
|
||||
httpx
|
||||
requests
|
||||
|
||||
# 数据处理
|
||||
pydantic
|
||||
typing-extensions
|
||||
numpy
|
||||
|
||||
# 其他工具
|
||||
python-dotenv
|
||||
|
||||
# FastAPI 相关
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
|
||||
# Neo4j 图数据库
|
||||
neo4j
|
||||
|
||||
# PostgreSQL 支持
|
||||
psycopg[binary]
|
||||
psycopg-pool
|
||||
|
||||
# OpenAI API
|
||||
openai
|
||||
tiktoken
|
||||
|
||||
# Word 文档生成
|
||||
python-docx
|
||||
|
||||
# PDF 生成相关
|
||||
matplotlib
|
||||
Pillow
|
||||
|
||||
47
template.tex
Normal file
@ -0,0 +1,47 @@
|
||||
|
||||
\documentclass[10pt]{article}
|
||||
\usepackage[UTF8]{ctex}
|
||||
\usepackage{geometry}
|
||||
\usepackage{graphicx}
|
||||
\usepackage{amsmath}
|
||||
\usepackage{array}
|
||||
\usepackage{booktabs}
|
||||
\usepackage{longtable}
|
||||
\usepackage{setspace}
|
||||
\usepackage{fancyhdr}
|
||||
\usepackage{titlesec}
|
||||
\usepackage{tabularx}
|
||||
\usepackage{makecell}
|
||||
\usepackage{adjustbox}
|
||||
\usepackage{hyperref}
|
||||
% 页面设置
|
||||
\geometry{margin=2cm}
|
||||
\setlength{\parskip}{1em}
|
||||
\setlength{\parindent}{0em}
|
||||
|
||||
% 字体
|
||||
\setCJKmainfont{Noto Serif CJK SC}
|
||||
|
||||
% 标题样式
|
||||
\title{\Large \textbf{$title$}}
|
||||
\author{}
|
||||
\date{}
|
||||
|
||||
% 章节标题样式
|
||||
\titleformat{\section}{\large\bfseries\raggedright}{\thesection}{1em}{}
|
||||
\titleformat{\subsection}{\normalsize\bfseries\raggedright}{\thesubsection}{1em}{}
|
||||
|
||||
|
||||
% ===== Pandoc compatibility =====
|
||||
\usepackage{hyperref}
|
||||
\providecommand{\tightlist}{%
|
||||
\setlength{\itemsep}{0pt}\setlength{\parskip}{0pt}}
|
||||
|
||||
|
||||
\begin{document}
|
||||
|
||||
\maketitle
|
||||
|
||||
$body$
|
||||
|
||||
\end{document}
|
||||
124
test_ship_mapping.py
Normal file
@ -0,0 +1,124 @@
|
||||
"""
|
||||
智能舷号映射 + RAG检索 完整测试脚本
|
||||
直接调用 search_with_ship_number_strategy,展示真实检索结果
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from utils.ship_number_search import search_with_ship_number_strategy, smart_ship_number_mapping
|
||||
|
||||
|
||||
async def interactive_test():
|
||||
"""交互式完整测试"""
|
||||
print("=" * 60)
|
||||
print("智能舷号映射 + RAG检索 完整测试")
|
||||
print("=" * 60)
|
||||
print("输入 'exit' 或 'quit' 退出")
|
||||
print("输入 'help' 查看说明")
|
||||
print("=" * 60)
|
||||
|
||||
while True:
|
||||
print("\n" + "-" * 60)
|
||||
ship_input = input("请输入舷号/型号/舰名: ").strip()
|
||||
|
||||
if ship_input.lower() in ['exit', 'quit', 'q']:
|
||||
print("退出测试")
|
||||
break
|
||||
|
||||
if ship_input.lower() == 'help':
|
||||
print("\n使用说明:")
|
||||
print(" 先输入舷号/型号/舰名(如 163、南昌舰、055驱逐舰)")
|
||||
print(" 再输入查询内容(如 主机排烟温度高)")
|
||||
print(" 系统会执行完整的智能映射 + RAG三步检索")
|
||||
continue
|
||||
|
||||
if not ship_input:
|
||||
print("请输入有效内容")
|
||||
continue
|
||||
|
||||
query_input = input("请输入查询内容: ").strip()
|
||||
if not query_input:
|
||||
query_input = "主机排烟温度高的维修方案"
|
||||
print(f"使用默认查询: {query_input}")
|
||||
|
||||
search_type = input("搜索类型 (fault/operate,回车默认fault): ").strip()
|
||||
if search_type not in ["fault", "operate"]:
|
||||
search_type = "fault"
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("第一步:智能映射")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
matched_ship_number, matched_model, all_numbers, match_type = await smart_ship_number_mapping(ship_input)
|
||||
print(f"输入: '{ship_input}'")
|
||||
print(f"匹配类型: {match_type}")
|
||||
print(f"匹配舷号: {matched_ship_number}")
|
||||
print(f"匹配型号: {matched_model}")
|
||||
print(f"该型号所有舷号: {all_numbers}")
|
||||
|
||||
if match_type == "none":
|
||||
print("未匹配到,将使用原始输入进行检索")
|
||||
except Exception as e:
|
||||
print(f"映射失败: {str(e)}")
|
||||
matched_ship_number = None
|
||||
matched_model = None
|
||||
all_numbers = None
|
||||
match_type = "none"
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("第二步:RAG检索(三步策略)")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
rag_results, matched_kb_name, matched_kb_id = await search_with_ship_number_strategy(
|
||||
base_query=query_input,
|
||||
ship_number=ship_input,
|
||||
top_k=5,
|
||||
search_type=search_type
|
||||
)
|
||||
|
||||
print(f"\n检索完成!")
|
||||
print(f"知识库: {matched_kb_name or '未匹配'}")
|
||||
print(f"知识库ID: {matched_kb_id or '未匹配'}")
|
||||
print(f"结果数量: {len(rag_results)}")
|
||||
|
||||
if rag_results:
|
||||
print(f"\n{'=' * 60}")
|
||||
print("第三步:检索结果详情")
|
||||
print("=" * 60)
|
||||
for i, item in enumerate(rag_results, 1):
|
||||
if isinstance(item, dict):
|
||||
text = item.get("text", "")
|
||||
kb_name = item.get("kb_name", "")
|
||||
score = item.get("score", "")
|
||||
source = item.get("source", "")
|
||||
|
||||
print(f"\n--- 结果 {i} ---")
|
||||
if kb_name:
|
||||
print(f"知识库: {kb_name}")
|
||||
if score:
|
||||
print(f"相似度: {score}")
|
||||
if source:
|
||||
print(f"来源: {source}")
|
||||
if text:
|
||||
preview = text[:300] + "..." if len(text) > 300 else text
|
||||
print(f"内容预览:\n{preview}")
|
||||
elif isinstance(item, str):
|
||||
preview = item[:300] + "..." if len(item) > 300 else item
|
||||
print(f"\n--- 结果 {i} ---")
|
||||
print(f"内容预览:\n{preview}")
|
||||
else:
|
||||
print("\n未检索到任何结果")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n检索失败: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(interactive_test())
|
||||
41
tools/__init__.py
Normal file
@ -0,0 +1,41 @@
|
||||
|
||||
"""
|
||||
工具模块
|
||||
包含RAG、GraphRAG、VLM、ASR、TTS等工具
|
||||
"""
|
||||
from .function_tool import (
|
||||
detect_input_type,
|
||||
graph_rag_search,
|
||||
fault_graph_rag_search,
|
||||
operation_graph_rag_search,
|
||||
rag_search_tool,
|
||||
vlm_image_to_text,
|
||||
asr_audio_to_text,
|
||||
extract_device_and_fault,
|
||||
image_rag_describe,
|
||||
file_to_text_tool,
|
||||
fault_type_statistics_tool,
|
||||
fault_frequency_statistics_tool,
|
||||
spare_parts_statistics_tool,
|
||||
ALL_TOOLS,
|
||||
TOOL_CATEGORIES
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"detect_input_type",
|
||||
"graph_rag_search",
|
||||
"fault_graph_rag_search",
|
||||
"operation_graph_rag_search",
|
||||
"rag_search_tool",
|
||||
"vlm_image_to_text",
|
||||
"asr_audio_to_text",
|
||||
"extract_device_and_fault",
|
||||
"image_rag_describe",
|
||||
"file_to_text_tool",
|
||||
"fault_type_statistics_tool",
|
||||
"fault_frequency_statistics_tool",
|
||||
"spare_parts_statistics_tool",
|
||||
"ALL_TOOLS",
|
||||
"TOOL_CATEGORIES"
|
||||
]
|
||||
|
||||
BIN
tools/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
tools/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
tools/__pycache__/agent_usage_statistics.cpython-312.pyc
Normal file
BIN
tools/__pycache__/fault_record_db.cpython-312.pyc
Normal file
BIN
tools/__pycache__/fault_statistics.cpython-312.pyc
Normal file
BIN
tools/__pycache__/function_tool.cpython-310.pyc
Normal file
BIN
tools/__pycache__/function_tool.cpython-312.pyc
Normal file
BIN
tools/__pycache__/graph_tools.cpython-312.pyc
Normal file
BIN
tools/__pycache__/rag_tools.cpython-312.pyc
Normal file
BIN
tools/__pycache__/ship_model_db.cpython-312.pyc
Normal file
BIN
tools/__pycache__/text2sql_tool.cpython-312.pyc
Normal file
BIN
tools/__pycache__/texttosql_utils.cpython-312.pyc
Normal file
BIN
tools/__pycache__/vlm_tools.cpython-312.pyc
Normal file
237
tools/agent_usage_statistics.py
Normal file
@ -0,0 +1,237 @@
|
||||
"""
|
||||
智能体使用统计工具
|
||||
记录和统计智能体的调用次数(PostgreSQL数据库存储)
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any
|
||||
|
||||
from config import POSTGRES_CONNECTION_STRING
|
||||
|
||||
|
||||
ROUTE_FLAG_DISPLAY_NAMES = {
|
||||
"问题排查": "问题排查",
|
||||
"操作使用": "操作使用",
|
||||
"百科问答": "百科问答"
|
||||
}
|
||||
|
||||
ROUTE_FLAG_ICONS = {
|
||||
"问题排查": "detection",
|
||||
"操作使用": "repair",
|
||||
"百科问答": "doc"
|
||||
}
|
||||
|
||||
ROUTE_FLAG_COLORS = {
|
||||
"问题排查": "#FFB800",
|
||||
"操作使用": "#00C2FF",
|
||||
"百科问答": "#9153FF"
|
||||
}
|
||||
|
||||
|
||||
async def init_agent_usage_table():
|
||||
"""
|
||||
初始化 agent_usage_records 表
|
||||
"""
|
||||
try:
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
except ImportError:
|
||||
print("[agent_usage_statistics] psycopg_pool 未安装,无法初始化表")
|
||||
return
|
||||
|
||||
async with AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
kwargs={"autocommit": True}
|
||||
) as pool:
|
||||
async with pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS agent_usage_records (
|
||||
id SERIAL PRIMARY KEY,
|
||||
chat_id TEXT NOT NULL DEFAULT '',
|
||||
route_flag TEXT NOT NULL DEFAULT '',
|
||||
message_id TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(chat_id, route_flag)
|
||||
)
|
||||
""")
|
||||
await cur.execute("""
|
||||
CREATE INDEX IF NOT EXISTS agent_usage_records_route_flag_idx ON agent_usage_records(route_flag)
|
||||
""")
|
||||
await cur.execute("""
|
||||
CREATE INDEX IF NOT EXISTS agent_usage_records_created_at_idx ON agent_usage_records(created_at)
|
||||
""")
|
||||
print("[agent_usage_statistics] agent_usage_records 表初始化完成")
|
||||
|
||||
|
||||
async def record_agent_usage(chat_id: str, route_flag: str, message_id: str = "") -> bool:
|
||||
"""
|
||||
记录智能体使用情况到数据库
|
||||
|
||||
Args:
|
||||
chat_id: 会话ID
|
||||
route_flag: 路由标志(智能体类型)
|
||||
message_id: 消息ID
|
||||
|
||||
Returns:
|
||||
是否记录成功
|
||||
"""
|
||||
if not chat_id or not route_flag:
|
||||
return False
|
||||
|
||||
try:
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
except ImportError:
|
||||
print("[agent_usage_statistics] psycopg_pool 未安装,无法记录")
|
||||
return False
|
||||
|
||||
try:
|
||||
async with AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
kwargs={"autocommit": True}
|
||||
) as pool:
|
||||
async with pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_usage_records (chat_id, route_flag, message_id, created_at)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON CONFLICT (chat_id, route_flag) DO UPDATE SET
|
||||
message_id = EXCLUDED.message_id,
|
||||
created_at = EXCLUDED.created_at
|
||||
""",
|
||||
(chat_id, route_flag, message_id or "", datetime.now())
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[agent_usage_statistics] 记录智能体使用失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _format_count(count: int) -> str:
|
||||
if count >= 10000:
|
||||
return f"{count / 10000:.1f}万"
|
||||
else:
|
||||
return f"{count:,}"
|
||||
|
||||
|
||||
async def get_agent_usage_statistics() -> Dict[str, Any]:
|
||||
"""
|
||||
从数据库查询智能体使用统计
|
||||
|
||||
统计逻辑:
|
||||
- 同一 chat_id + route_flag 只保留一条记录
|
||||
- 统计各类型的总使用次数
|
||||
- 统计近6个月的月度使用次数
|
||||
|
||||
Returns:
|
||||
统计结果字典
|
||||
"""
|
||||
try:
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
except ImportError:
|
||||
print("[agent_usage_statistics] psycopg_pool 未安装,无法查询")
|
||||
return {"success": False, "data": None}
|
||||
|
||||
required_routes = ["操作使用", "问题排查", "百科问答"]
|
||||
|
||||
now = datetime.now()
|
||||
last_6_months = []
|
||||
for i in range(5, -1, -1):
|
||||
year = now.year
|
||||
month = now.month - i
|
||||
while month <= 0:
|
||||
month += 12
|
||||
year -= 1
|
||||
last_6_months.append({
|
||||
"year": year,
|
||||
"month": month,
|
||||
"key": f"{year}-{month:02d}",
|
||||
"label": f"{month}月"
|
||||
})
|
||||
|
||||
try:
|
||||
async with AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
kwargs={"autocommit": True}
|
||||
) as pool:
|
||||
async with pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
# 查询各类型总使用次数
|
||||
route_counts = {}
|
||||
for route_flag in required_routes:
|
||||
await cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*) FROM agent_usage_records
|
||||
WHERE route_flag = %s
|
||||
""",
|
||||
(route_flag,)
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
route_counts[route_flag] = row[0] if row else 0
|
||||
|
||||
# 查询近6个月各类型月度使用次数
|
||||
monthly_counts = {route: {m["key"]: 0 for m in last_6_months} for route in required_routes}
|
||||
|
||||
for route_flag in required_routes:
|
||||
for m in last_6_months:
|
||||
month_start = datetime(m["year"], m["month"], 1)
|
||||
if m["month"] == 12:
|
||||
month_end = datetime(m["year"] + 1, 1, 1)
|
||||
else:
|
||||
month_end = datetime(m["year"], m["month"] + 1, 1)
|
||||
|
||||
await cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*) FROM agent_usage_records
|
||||
WHERE route_flag = %s AND created_at >= %s AND created_at < %s
|
||||
""",
|
||||
(route_flag, month_start, month_end)
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
monthly_counts[route_flag][m["key"]] = row[0] if row else 0
|
||||
|
||||
except Exception as e:
|
||||
print(f"[agent_usage_statistics] 查询统计数据失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {"success": False, "data": None}
|
||||
|
||||
total = sum(route_counts.get(route, 0) for route in required_routes)
|
||||
|
||||
types_data = []
|
||||
monthly_stats = []
|
||||
for route_flag in required_routes:
|
||||
count = route_counts.get(route_flag, 0)
|
||||
display_name = ROUTE_FLAG_DISPLAY_NAMES.get(route_flag, route_flag)
|
||||
icon = ROUTE_FLAG_ICONS.get(route_flag, "info")
|
||||
color = ROUTE_FLAG_COLORS.get(route_flag, "#8C8C8C")
|
||||
|
||||
types_data.append({
|
||||
"name": display_name,
|
||||
"count": _format_count(count),
|
||||
"icon": icon,
|
||||
"color": color
|
||||
})
|
||||
|
||||
monthly_data = []
|
||||
for m in last_6_months:
|
||||
monthly_data.append({
|
||||
"month": m["label"],
|
||||
"count": monthly_counts[route_flag][m["key"]]
|
||||
})
|
||||
|
||||
monthly_stats.append({
|
||||
"name": display_name,
|
||||
"color": color,
|
||||
"monthlyCounts": monthly_data
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"total": _format_count(total),
|
||||
"types": types_data,
|
||||
"monthlyStats": monthly_stats,
|
||||
"months": [m["label"] for m in last_6_months]
|
||||
}
|
||||
}
|
||||
|
||||
330
tools/fault_record_db.py
Normal file
@ -0,0 +1,330 @@
|
||||
"""
|
||||
故障记录数据库模块
|
||||
用于在故障诊断智能体生成方案后自动记录故障信息到PostgreSQL数据库
|
||||
记录内容:舷号、设备、故障、备件、系统
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
from config import POSTGRES_CONNECTION_STRING
|
||||
|
||||
|
||||
async def init_fault_records_table():
|
||||
"""
|
||||
初始化 fault_records 表
|
||||
"""
|
||||
try:
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
except ImportError:
|
||||
print("[fault_record_db] psycopg_pool 未安装,无法初始化表")
|
||||
return
|
||||
|
||||
async with AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
kwargs={"autocommit": True}
|
||||
) as pool:
|
||||
async with pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS fault_records (
|
||||
id SERIAL PRIMARY KEY,
|
||||
ship_number TEXT NOT NULL DEFAULT '',
|
||||
device_name TEXT NOT NULL DEFAULT '',
|
||||
fault TEXT NOT NULL DEFAULT '',
|
||||
spare_parts JSONB NOT NULL DEFAULT '[]',
|
||||
system_name TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
await cur.execute("""
|
||||
CREATE INDEX IF NOT EXISTS fault_records_ship_number_idx ON fault_records(ship_number)
|
||||
""")
|
||||
await cur.execute("""
|
||||
CREATE INDEX IF NOT EXISTS fault_records_device_name_idx ON fault_records(device_name)
|
||||
""")
|
||||
await cur.execute("""
|
||||
CREATE INDEX IF NOT EXISTS fault_records_system_name_idx ON fault_records(system_name)
|
||||
""")
|
||||
await cur.execute("""
|
||||
CREATE INDEX IF NOT EXISTS fault_records_created_at_idx ON fault_records(created_at)
|
||||
""")
|
||||
print("[fault_record_db] fault_records 表初始化完成")
|
||||
|
||||
|
||||
|
||||
async def extract_spare_parts_from_scheme(scheme_content: str) -> List[str]:
|
||||
"""
|
||||
从方案内容中用大模型抽取备件名称列表
|
||||
|
||||
Args:
|
||||
scheme_content: 方案内容(Markdown格式)
|
||||
|
||||
Returns:
|
||||
备件名称列表
|
||||
"""
|
||||
if not scheme_content or len(scheme_content.strip()) < 10:
|
||||
return []
|
||||
|
||||
prompt = f"""从以下维修方案内容中提取所有提到的备品备件名称。
|
||||
|
||||
方案内容:
|
||||
{scheme_content[:3000]}
|
||||
|
||||
提取规则:
|
||||
1. 找到"备品备件"、"备件"等相关章节,提取其中的项目名称
|
||||
2. 只提取名称,不要提取型号、规格、数量、材质、用途等
|
||||
3. 如果没有专门的章节,也从全文中识别备件名称
|
||||
|
||||
以JSON数组格式返回:
|
||||
{{
|
||||
"备品备件": [备件名称1, 备件名称2, ...]
|
||||
}}
|
||||
|
||||
如果没有提到备品备件,请返回空数组 []"""
|
||||
|
||||
try:
|
||||
from modelsAPI.model_api import OpenaiAPI
|
||||
result_text = await OpenaiAPI.open_api_chat_without_thinking(
|
||||
query=prompt,
|
||||
json_output=True
|
||||
)
|
||||
print("抽取出来的备品备件", result_text)
|
||||
|
||||
result_text = result_text.strip()
|
||||
result_text = re.sub(r'^```json\s*', '', result_text, flags=re.IGNORECASE)
|
||||
result_text = re.sub(r'^```\s*', '', result_text)
|
||||
result_text = re.sub(r'```$', '', result_text)
|
||||
|
||||
try:
|
||||
parsed = json.loads(result_text)
|
||||
except json.JSONDecodeError:
|
||||
json_match = re.search(r'\{.*\}', result_text, re.DOTALL)
|
||||
if json_match:
|
||||
parsed = json.loads(json_match.group(0))
|
||||
else:
|
||||
return []
|
||||
|
||||
if isinstance(parsed, dict):
|
||||
if "备品备件" in parsed and isinstance(parsed["备品备件"], list):
|
||||
return [str(part).strip() for part in parsed["备品备件"] if part]
|
||||
all_parts = []
|
||||
for v in parsed.values():
|
||||
if isinstance(v, list):
|
||||
all_parts.extend([str(part).strip() for part in v if part])
|
||||
return all_parts
|
||||
elif isinstance(parsed, list):
|
||||
return [str(part).strip() for part in parsed if part]
|
||||
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
print(f"[fault_record_db] 从方案提取备件失败: {str(e)}")
|
||||
return []
|
||||
|
||||
|
||||
async def save_fault_record(
|
||||
ship_number: str,
|
||||
device_name: str,
|
||||
fault: str,
|
||||
spare_parts: List[str],
|
||||
system_name: str
|
||||
) -> bool:
|
||||
"""
|
||||
保存故障记录到数据库
|
||||
|
||||
Args:
|
||||
ship_number: 舷号
|
||||
device_name: 设备名称
|
||||
fault: 故障现象
|
||||
spare_parts: 备件列表
|
||||
system_name: 系统名称
|
||||
|
||||
Returns:
|
||||
是否保存成功
|
||||
"""
|
||||
if not ship_number and not device_name and not fault:
|
||||
print("[fault_record_db] 舷号、设备、故障均为空,跳过保存")
|
||||
return False
|
||||
|
||||
try:
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
except ImportError:
|
||||
print("[fault_record_db] psycopg_pool 未安装,无法保存")
|
||||
return False
|
||||
|
||||
try:
|
||||
async with AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
kwargs={"autocommit": True}
|
||||
) as pool:
|
||||
async with pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""
|
||||
INSERT INTO fault_records (ship_number, device_name, fault, spare_parts, system_name, created_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
ship_number or "",
|
||||
device_name or "",
|
||||
fault or "",
|
||||
json.dumps(spare_parts, ensure_ascii=False),
|
||||
system_name or "",
|
||||
datetime.now()
|
||||
)
|
||||
)
|
||||
print(f"[fault_record_db] 故障记录已保存: 舷号={ship_number}, 设备={device_name}, 故障={fault}, 系统={system_name}, 备件数={len(spare_parts)}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[fault_record_db] 保存故障记录失败: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
async def record_fault_from_state(state: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
从工作流状态中提取故障信息并记录到数据库
|
||||
在故障诊断智能体生成方案后调用
|
||||
|
||||
流程:
|
||||
1. 从状态中获取舷号、设备、故障
|
||||
2. 从方案内容中用大模型抽取备件
|
||||
3. 通过设备名称反推系统
|
||||
4. 保存到数据库
|
||||
|
||||
Args:
|
||||
state: 工作流状态字典
|
||||
|
||||
Returns:
|
||||
是否记录成功
|
||||
"""
|
||||
ship_number = state.get("ship_number", "") or ""
|
||||
device_name = state.get("device_name", "") or ""
|
||||
fault = state.get("fault", "") or ""
|
||||
|
||||
if not device_name and not fault:
|
||||
print("[fault_record_db] 设备和故障均为空,跳过记录")
|
||||
return False
|
||||
|
||||
scheme_content = state.get("last_generated_scheme", "") or state.get("response", "") or ""
|
||||
|
||||
spare_parts = await extract_spare_parts_from_scheme(scheme_content)
|
||||
|
||||
system_name = "其他"
|
||||
if device_name:
|
||||
from tools.fault_statistics import get_device_system_from_neo4j
|
||||
system_name = await get_device_system_from_neo4j(device_name)
|
||||
|
||||
return await save_fault_record(
|
||||
ship_number=ship_number,
|
||||
device_name=device_name,
|
||||
fault=fault,
|
||||
spare_parts=spare_parts,
|
||||
system_name=system_name
|
||||
)
|
||||
|
||||
|
||||
async def query_fault_records(
|
||||
ship_number: Optional[str] = None,
|
||||
device_name: Optional[str] = None,
|
||||
system_name: Optional[str] = None,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
limit: int = 1000
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
从数据库查询故障记录
|
||||
|
||||
Args:
|
||||
ship_number: 舷号过滤
|
||||
device_name: 设备名称过滤
|
||||
system_name: 系统名称过滤
|
||||
start_date: 开始日期(格式:YYYY-MM-DD)
|
||||
end_date: 结束日期(格式:YYYY-MM-DD)
|
||||
limit: 返回记录数上限
|
||||
|
||||
Returns:
|
||||
故障记录列表
|
||||
"""
|
||||
try:
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
except ImportError:
|
||||
print("[fault_record_db] psycopg_pool 未安装,无法查询")
|
||||
return []
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
conditions = []
|
||||
params = []
|
||||
idx = 1
|
||||
|
||||
if ship_number:
|
||||
conditions.append(f"ship_number = %s")
|
||||
params.append(ship_number)
|
||||
|
||||
if device_name:
|
||||
conditions.append(f"device_name = %s")
|
||||
params.append(device_name)
|
||||
|
||||
if system_name:
|
||||
conditions.append(f"system_name = %s")
|
||||
params.append(system_name)
|
||||
|
||||
if start_date:
|
||||
try:
|
||||
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
conditions.append(f"created_at >= %s")
|
||||
params.append(start_dt)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if end_date:
|
||||
try:
|
||||
end_dt = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) - timedelta(seconds=1)
|
||||
conditions.append(f"created_at <= %s")
|
||||
params.append(end_dt)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
||||
|
||||
try:
|
||||
async with AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
kwargs={"autocommit": True}
|
||||
) as pool:
|
||||
async with pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
sql = f"""
|
||||
SELECT id, ship_number, device_name, fault, spare_parts, system_name, created_at
|
||||
FROM fault_records
|
||||
WHERE {where_clause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %s
|
||||
"""
|
||||
params.append(limit)
|
||||
await cur.execute(sql, params)
|
||||
rows = await cur.fetchall()
|
||||
columns = ["id", "ship_number", "device_name", "fault", "spare_parts", "system_name", "created_at"]
|
||||
results = []
|
||||
for row in rows:
|
||||
record = dict(zip(columns, row))
|
||||
if isinstance(record.get("spare_parts"), str):
|
||||
try:
|
||||
record["spare_parts"] = json.loads(record["spare_parts"])
|
||||
except Exception:
|
||||
record["spare_parts"] = []
|
||||
if isinstance(record.get("created_at"), datetime):
|
||||
record["created_at"] = record["created_at"].isoformat()
|
||||
results.append(record)
|
||||
return results
|
||||
except Exception as e:
|
||||
print(f"[fault_record_db] 查询故障记录失败: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
969
tools/fault_statistics.py
Normal file
@ -0,0 +1,969 @@
|
||||
"""
|
||||
故障类型统计工具模块
|
||||
用于统计故障诊断智能体记录的故障信息
|
||||
数据来源:PostgreSQL fault_records 表
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import httpx
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Any, Optional, List
|
||||
from difflib import SequenceMatcher
|
||||
import asyncio
|
||||
|
||||
from langchain_core.tools import tool
|
||||
from utils.function_tracker import track_function_calls
|
||||
from config import REPAIR_FEEDBACK_CONFIG
|
||||
|
||||
neo4j_cache = {}
|
||||
|
||||
|
||||
def fuzzy_match_system(user_input: str, system_name: str, threshold: float = 0.6) -> bool:
|
||||
if not user_input or not system_name:
|
||||
return False
|
||||
|
||||
user_input = user_input.strip().lower()
|
||||
system_name = system_name.strip().lower()
|
||||
|
||||
if user_input == system_name:
|
||||
return True
|
||||
|
||||
if user_input in system_name or system_name in user_input:
|
||||
return True
|
||||
|
||||
similarity = SequenceMatcher(None, user_input, system_name).ratio()
|
||||
|
||||
return similarity >= threshold
|
||||
|
||||
|
||||
async def get_device_system_from_neo4j(device_name: str) -> str:
|
||||
"""
|
||||
从 Neo4j 图谱中查询设备对应的系统(调用外部接口),带缓存机制
|
||||
"""
|
||||
if not device_name or not device_name.strip():
|
||||
return "其他"
|
||||
|
||||
device_name_clean = device_name.strip().lower()
|
||||
|
||||
if device_name_clean in neo4j_cache:
|
||||
return neo4j_cache[device_name_clean]
|
||||
|
||||
from config import DEVICE_SYSTEM_CONFIG
|
||||
|
||||
endpoint = DEVICE_SYSTEM_CONFIG.get("endpoint")
|
||||
timeout = DEVICE_SYSTEM_CONFIG.get("timeout", 30.0)
|
||||
min_hops = DEVICE_SYSTEM_CONFIG.get("default_min_hops", 2)
|
||||
max_hops = DEVICE_SYSTEM_CONFIG.get("default_max_hops", 8)
|
||||
top_k = DEVICE_SYSTEM_CONFIG.get("default_top_k", 10)
|
||||
|
||||
if not endpoint:
|
||||
result = "其他"
|
||||
neo4j_cache[device_name_clean] = result
|
||||
return result
|
||||
|
||||
try:
|
||||
payload = {
|
||||
"device_name": device_name.strip(),
|
||||
"min_hops": min_hops,
|
||||
"max_hops": max_hops,
|
||||
"top_k": top_k
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.post(
|
||||
endpoint,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
result = "其他"
|
||||
neo4j_cache[device_name_clean] = result
|
||||
return result
|
||||
|
||||
result_json = response.json()
|
||||
|
||||
if not result_json.get("success", False):
|
||||
result = "其他"
|
||||
neo4j_cache[device_name_clean] = result
|
||||
return result
|
||||
|
||||
systems = result_json.get("systems", [])
|
||||
if not systems or not isinstance(systems, list):
|
||||
result = "其他"
|
||||
neo4j_cache[device_name_clean] = result
|
||||
return result
|
||||
|
||||
first_system = systems[0]
|
||||
if isinstance(first_system, dict):
|
||||
system_name = first_system.get("name") or first_system.get("系统名称") or first_system.get("名称")
|
||||
if system_name:
|
||||
result = system_name.strip()
|
||||
neo4j_cache[device_name_clean] = result
|
||||
return result
|
||||
|
||||
result = "其他"
|
||||
neo4j_cache[device_name_clean] = result
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
print(f"查询设备系统失败: {str(e)}")
|
||||
result = "其他"
|
||||
neo4j_cache[device_name_clean] = result
|
||||
return result
|
||||
|
||||
|
||||
def generate_color_palette(n: int) -> List[str]:
|
||||
predefined_colors = [
|
||||
"#00C2FF", "#4E7CFE", "#9153FF", "#FFB800", "#00D16B",
|
||||
"#FF6B6B", "#4ECDC4", "#45B7D1", "#FFA07A", "#98D8C8",
|
||||
"#F7DC6F", "#BB8FCE", "#85C1E9", "#F8B500", "#00E676",
|
||||
"#FF4081", "#7C4DFF", "#18FFFF", "#69F0AE", "#FFD740"
|
||||
]
|
||||
|
||||
if n <= len(predefined_colors):
|
||||
return predefined_colors[:n]
|
||||
|
||||
colors = predefined_colors.copy()
|
||||
|
||||
import colorsys
|
||||
for i in range(len(predefined_colors), n):
|
||||
hue = (i * 0.618033988749895) % 1.0
|
||||
saturation = 0.7 + (i % 3) * 0.1
|
||||
value = 0.8 + (i % 2) * 0.1
|
||||
|
||||
rgb = colorsys.hsv_to_rgb(hue, saturation, value)
|
||||
color = "#{:02X}{:02X}{:02X}".format(
|
||||
int(rgb[0] * 255),
|
||||
int(rgb[1] * 255),
|
||||
int(rgb[2] * 255)
|
||||
)
|
||||
colors.append(color)
|
||||
|
||||
return colors
|
||||
|
||||
|
||||
async def _query_fault_records_from_db(
|
||||
ship_number: Optional[str] = None,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
system_name: Optional[str] = None,
|
||||
limit: int = 5000
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
从数据库查询故障记录(内部共用方法)
|
||||
"""
|
||||
from tools.fault_record_db import query_fault_records
|
||||
return await query_fault_records(
|
||||
ship_number=ship_number,
|
||||
system_name=system_name,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
|
||||
async def statistics_fault_types(
|
||||
ship_number_filter: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
统计故障类型(从数据库读取),按系统分类计算百分比
|
||||
|
||||
Args:
|
||||
ship_number_filter: 舷号过滤(可选)
|
||||
|
||||
Returns:
|
||||
包含统计结果的字典
|
||||
"""
|
||||
try:
|
||||
records = await _query_fault_records_from_db(ship_number=ship_number_filter)
|
||||
|
||||
if not records:
|
||||
return {
|
||||
"success": True,
|
||||
"faultTypeData": []
|
||||
}
|
||||
|
||||
system_totals = {}
|
||||
valid_count = 0
|
||||
|
||||
for record in records:
|
||||
system_name = record.get("system_name", "其他")
|
||||
if not system_name:
|
||||
system_name = "其他"
|
||||
valid_count += 1
|
||||
|
||||
if system_name not in system_totals:
|
||||
system_totals[system_name] = 0
|
||||
system_totals[system_name] += 1
|
||||
|
||||
fault_type_data = []
|
||||
if valid_count > 0:
|
||||
unique_systems = sorted(system_totals.keys())
|
||||
colors = generate_color_palette(len(unique_systems))
|
||||
system_color_map = {system: color for system, color in zip(unique_systems, colors)}
|
||||
|
||||
for system_name, count in system_totals.items():
|
||||
percentage = round((count / valid_count) * 100)
|
||||
color = system_color_map.get(system_name, "#6E7D91")
|
||||
fault_type_data.append({
|
||||
"value": percentage,
|
||||
"name": system_name,
|
||||
"itemStyle": {
|
||||
"color": color
|
||||
}
|
||||
})
|
||||
|
||||
fault_type_data.sort(key=lambda x: x["value"], reverse=True)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"faultTypeData": fault_type_data
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"[故障类型统计错误] {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {
|
||||
"success": False,
|
||||
"faultTypeData": []
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
async def fault_type_statistics_tool(
|
||||
ship_number_filter: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
故障类型统计工具(供工作流调用)
|
||||
统计故障类型,按系统分类并计算百分比
|
||||
|
||||
Args:
|
||||
ship_number_filter: 舷号过滤(可选)
|
||||
|
||||
Returns:
|
||||
统计结果,包含 success 和 faultTypeData 字段
|
||||
"""
|
||||
result = await statistics_fault_types(ship_number_filter=ship_number_filter)
|
||||
return result
|
||||
|
||||
|
||||
async def merge_similar_faults_with_embedding(
|
||||
fault_counter: Dict[str, int],
|
||||
fault_system_map: Dict[str, str],
|
||||
similarity_threshold: float = 0.85
|
||||
) -> tuple:
|
||||
"""
|
||||
使用embedding合并相似的故障
|
||||
"""
|
||||
if not fault_counter:
|
||||
return {}, {}, {}
|
||||
|
||||
fault_keys = list(fault_counter.keys())
|
||||
|
||||
if len(fault_keys) <= 1:
|
||||
return fault_counter.copy(), fault_system_map.copy(), {}
|
||||
|
||||
try:
|
||||
from modelsAPI.model_api import OpenaiAPI
|
||||
|
||||
embeddings = await OpenaiAPI.get_embeddings_batch_async(fault_keys)
|
||||
|
||||
fault_embedding_map = {key: emb for key, emb in zip(fault_keys, embeddings)}
|
||||
|
||||
merged_counter = {}
|
||||
merged_system_map = {}
|
||||
merge_mapping = {}
|
||||
merged_keys = set()
|
||||
|
||||
sorted_faults = sorted(fault_counter.items(), key=lambda x: x[1], reverse=True)
|
||||
|
||||
for fault_key, count in sorted_faults:
|
||||
if fault_key in merged_keys:
|
||||
continue
|
||||
|
||||
merged_keys.add(fault_key)
|
||||
merged_counter[fault_key] = count
|
||||
merged_system_map[fault_key] = fault_system_map.get(fault_key, "其他")
|
||||
|
||||
for other_key, other_count in sorted_faults:
|
||||
if other_key in merged_keys or other_key == fault_key:
|
||||
continue
|
||||
|
||||
if fault_embedding_map.get(fault_key) and fault_embedding_map.get(other_key):
|
||||
similarity = OpenaiAPI.cosine_similarity(
|
||||
fault_embedding_map[fault_key],
|
||||
fault_embedding_map[other_key]
|
||||
)
|
||||
|
||||
if similarity >= similarity_threshold:
|
||||
merged_counter[fault_key] += other_count
|
||||
merged_keys.add(other_key)
|
||||
merge_mapping[other_key] = fault_key
|
||||
|
||||
return merged_counter, merged_system_map, merge_mapping
|
||||
|
||||
except Exception as e:
|
||||
print(f"Embedding合并失败,使用原始数据: {str(e)}")
|
||||
return fault_counter.copy(), fault_system_map.copy(), {}
|
||||
|
||||
|
||||
async def statistics_fault_frequency(
|
||||
top_n: int = 10,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
system_filter: Optional[str] = None,
|
||||
use_embedding_merge: bool = True,
|
||||
similarity_threshold: float = 0.85
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
统计故障发生次数(从数据库读取),按频率排序
|
||||
统计所有故障的数据,但只返回top N展示在表格中
|
||||
|
||||
Args:
|
||||
top_n: 返回前 N 个高频故障(默认为 10)
|
||||
start_date: 开始日期(格式:YYYY-MM-DD,可选)
|
||||
end_date: 结束日期(格式:YYYY-MM-DD,可选)
|
||||
system_filter: 系统名称过滤(可选)
|
||||
use_embedding_merge: 是否使用embedding合并相似故障(默认True)
|
||||
similarity_threshold: 相似度阈值(默认0.85)
|
||||
|
||||
Returns:
|
||||
包含统计结果的字典
|
||||
"""
|
||||
try:
|
||||
records = await _query_fault_records_from_db(
|
||||
start_date=start_date,
|
||||
end_date=end_date
|
||||
)
|
||||
|
||||
if not records:
|
||||
return {
|
||||
"success": True,
|
||||
"totalCount": 0,
|
||||
"totalTypes": 0,
|
||||
"highFreqFaults": []
|
||||
}
|
||||
|
||||
fault_counter = {}
|
||||
fault_system_map = {}
|
||||
|
||||
for record in records:
|
||||
device_name = record.get("device_name", "")
|
||||
fault = record.get("fault", "")
|
||||
system_name = record.get("system_name", "其他")
|
||||
|
||||
if not device_name or not fault:
|
||||
continue
|
||||
|
||||
if system_filter and not fuzzy_match_system(system_filter, system_name):
|
||||
continue
|
||||
|
||||
fault_key = f"{device_name}{fault}"
|
||||
|
||||
if fault_key not in fault_counter:
|
||||
fault_counter[fault_key] = 0
|
||||
fault_system_map[fault_key] = system_name
|
||||
|
||||
fault_counter[fault_key] += 1
|
||||
|
||||
if use_embedding_merge and len(fault_counter) > 1:
|
||||
fault_counter, fault_system_map, merge_mapping = await merge_similar_faults_with_embedding(
|
||||
fault_counter,
|
||||
fault_system_map,
|
||||
similarity_threshold
|
||||
)
|
||||
|
||||
if merge_mapping:
|
||||
print(f"合并了 {len(merge_mapping)} 个相似故障")
|
||||
|
||||
# 先计算全部数据的统计量
|
||||
total_count = sum(fault_counter.values())
|
||||
total_types = len(fault_counter)
|
||||
|
||||
# 对全部数据排序
|
||||
sorted_faults_all = sorted(fault_counter.items(), key=lambda x: x[1], reverse=True)
|
||||
|
||||
# 只取top N用于展示表格
|
||||
sorted_faults = sorted_faults_all[:top_n]
|
||||
|
||||
high_freq_faults = []
|
||||
for rank, (fault_key, count) in enumerate(sorted_faults, start=1):
|
||||
system_name = fault_system_map.get(fault_key, "其他")
|
||||
|
||||
high_freq_faults.append({
|
||||
"rank": rank,
|
||||
"name": fault_key,
|
||||
"system": system_name,
|
||||
"count": count
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"totalCount": total_count,
|
||||
"totalTypes": total_types,
|
||||
"highFreqFaults": high_freq_faults
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"[故障频率统计错误] {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {
|
||||
"success": False,
|
||||
"highFreqFaults": []
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
async def fault_frequency_statistics_tool(
|
||||
top_n: Optional[int] = 6,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
system_filter: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
故障发生次数统计工具(供工作流调用)
|
||||
统计故障发生次数,按频率排序
|
||||
|
||||
Args:
|
||||
top_n: 返回前 N 个高频故障(默认为 6)
|
||||
start_date: 开始日期(格式:YYYY-MM-DD,可选)
|
||||
end_date: 结束日期(格式:YYYY-MM-DD,可选)
|
||||
system_filter: 系统名称过滤(可选)
|
||||
|
||||
Returns:
|
||||
统计结果,包含 success 和 highFreqFaults 字段
|
||||
"""
|
||||
result = await statistics_fault_frequency(
|
||||
top_n=top_n or 10,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
system_filter=system_filter
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def statistics_spare_parts(
|
||||
ship_number_filter: Optional[str] = None,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
top_n: int = 6
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
统计备件消耗(从数据库读取)
|
||||
统计所有备件的数据,但只返回top N展示在表格中
|
||||
|
||||
Args:
|
||||
ship_number_filter: 舷号过滤(可选)
|
||||
start_date: 开始日期(格式:YYYY-MM-DD,可选)
|
||||
end_date: 结束日期(格式:YYYY-MM-DD,可选)
|
||||
top_n: 表格中展示的前 N 个备件(默认为 5)
|
||||
|
||||
Returns:
|
||||
包含统计结果的字典
|
||||
"""
|
||||
try:
|
||||
records = await _query_fault_records_from_db(
|
||||
ship_number=ship_number_filter,
|
||||
start_date=start_date,
|
||||
end_date=end_date
|
||||
)
|
||||
|
||||
if not records:
|
||||
return {
|
||||
"success": True,
|
||||
"totalCount": 0,
|
||||
"totalTypes": 0,
|
||||
"sparePartData": {
|
||||
"categories": [],
|
||||
"system": [],
|
||||
"counter": []
|
||||
}
|
||||
}
|
||||
|
||||
spare_part_counter = {}
|
||||
spare_part_system_map = {}
|
||||
|
||||
for record in records:
|
||||
spare_parts = record.get("spare_parts", [])
|
||||
system_name = record.get("system_name", "其他")
|
||||
|
||||
if not spare_parts:
|
||||
continue
|
||||
|
||||
for part in spare_parts:
|
||||
if not part or not part.strip():
|
||||
continue
|
||||
|
||||
part_key = part.strip()
|
||||
|
||||
if part_key not in spare_part_counter:
|
||||
spare_part_counter[part_key] = 0
|
||||
spare_part_system_map[part_key] = system_name
|
||||
|
||||
spare_part_counter[part_key] += 1
|
||||
|
||||
# 先计算全部数据的统计量
|
||||
total_count = sum(spare_part_counter.values())
|
||||
total_types = len(spare_part_counter)
|
||||
|
||||
# 对全部数据排序
|
||||
sorted_parts_all = sorted(spare_part_counter.items(), key=lambda x: x[1], reverse=True)
|
||||
|
||||
# 只取top N用于展示表格
|
||||
sorted_parts = sorted_parts_all[:top_n]
|
||||
|
||||
categories = []
|
||||
systems = []
|
||||
counters = []
|
||||
|
||||
for part_key, count in sorted_parts:
|
||||
categories.append(part_key)
|
||||
systems.append(spare_part_system_map.get(part_key, "其他"))
|
||||
counters.append(count)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"totalCount": total_count,
|
||||
"totalTypes": total_types,
|
||||
"sparePartData": {
|
||||
"categories": categories,
|
||||
"system": systems,
|
||||
"counter": counters
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"[备件统计错误] {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {
|
||||
"success": False,
|
||||
"sparePartData": {
|
||||
"categories": [],
|
||||
"system": [],
|
||||
"counter": []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
async def spare_parts_statistics_tool(
|
||||
ship_number: Optional[str] = None,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
top_n: Optional[int] = 5
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
备件消耗统计工具(供工作流调用)
|
||||
统计备件消耗情况
|
||||
|
||||
Args:
|
||||
ship_number: 舷号过滤(可选)
|
||||
start_date: 开始日期(格式:YYYY-MM-DD,可选)
|
||||
end_date: 结束日期(格式:YYYY-MM-DD,可选)
|
||||
top_n: 返回前 N 个备件(默认为 5)
|
||||
|
||||
Returns:
|
||||
统计结果,包含 success 和 sparePartData 字段
|
||||
"""
|
||||
result = await statistics_spare_parts(
|
||||
ship_number_filter=ship_number,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
top_n=top_n or 5
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def extract_device_from_text(text: str) -> Optional[str]:
|
||||
"""
|
||||
从文本中提取设备名称(使用大模型)
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return None
|
||||
|
||||
prompt = f"""从以下文本中提取设备名称:
|
||||
|
||||
文本内容:
|
||||
{text}
|
||||
|
||||
请识别文本中提到的具体设备名称,如:发动机、压缩机、水泵、发电机、空调机组、液压泵等。
|
||||
|
||||
请以JSON格式返回:
|
||||
{{"device": "设备名称"}}
|
||||
|
||||
如果文本中没有提到任何设备,请返回 {{"device": null}}
|
||||
只返回JSON,不要有其他内容。"""
|
||||
|
||||
try:
|
||||
from modelsAPI.model_api import OpenaiAPI
|
||||
result_text = await OpenaiAPI.open_api_chat_without_thinking(
|
||||
query=prompt,
|
||||
json_output=True
|
||||
)
|
||||
|
||||
result_text = result_text.strip()
|
||||
result_text = re.sub(r'^```json\s*', '', result_text, flags=re.IGNORECASE)
|
||||
result_text = re.sub(r'^```\s*', '', result_text)
|
||||
result_text = re.sub(r'```$', '', result_text)
|
||||
|
||||
json_match = re.search(r'\{.*\}', result_text, re.DOTALL)
|
||||
if json_match:
|
||||
result = json.loads(json_match.group(0))
|
||||
device = result.get("device")
|
||||
if device and device != "null":
|
||||
return str(device).strip()
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"大模型提取设备名称失败: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
async def classify_feedback_content(text: str) -> str:
|
||||
"""
|
||||
对反馈内容进行分类(使用大模型)
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return "其他"
|
||||
|
||||
prompt = f"""请对以下反馈内容进行分类。
|
||||
|
||||
反馈内容:
|
||||
{text}
|
||||
|
||||
分类类别(单选,选择最匹配的一个):
|
||||
1. 信息过时 - 反映文档或手册中的信息已经过时,不再适用
|
||||
2. 步骤错误 - 反映操作步骤或流程存在错误
|
||||
3. 步骤缺失 - 反映缺少必要的操作步骤或流程说明
|
||||
4. 备件信息有误 - 反映备件型号、规格、数量等信息有误
|
||||
5. 安全提示不足 - 反映缺少必要的安全警示或注意事项
|
||||
6. 图示不清 - 反映图片、示意图不清晰或难以理解
|
||||
|
||||
请分析反馈内容,判断属于哪个类别,以JSON格式返回:
|
||||
{{"category": "类别名称"}}
|
||||
|
||||
如果反馈内容不属于以上任何类别,请返回 {{"category": "其他"}}
|
||||
只返回JSON,不要有其他内容。"""
|
||||
|
||||
try:
|
||||
from modelsAPI.model_api import OpenaiAPI
|
||||
result_text = await OpenaiAPI.open_api_chat_without_thinking(
|
||||
query=prompt,
|
||||
json_output=True
|
||||
)
|
||||
|
||||
result_text = result_text.strip()
|
||||
result_text = re.sub(r'^```json\s*', '', result_text, flags=re.IGNORECASE)
|
||||
result_text = re.sub(r'^```\s*', '', result_text)
|
||||
result_text = re.sub(r'```$', '', result_text)
|
||||
|
||||
json_match = re.search(r'\{.*\}', result_text, re.DOTALL)
|
||||
if json_match:
|
||||
result = json.loads(json_match.group(0))
|
||||
category = result.get("category")
|
||||
valid_categories = ["信息过时", "步骤错误", "步骤缺失", "备件信息有误", "安全提示不足", "图示不清", "其他"]
|
||||
if category in valid_categories:
|
||||
return category
|
||||
|
||||
return "其他"
|
||||
|
||||
except Exception as e:
|
||||
print(f"大模型分类反馈内容失败: {str(e)}")
|
||||
return "其他"
|
||||
|
||||
|
||||
async def analyze_feedback_and_classify(feedback_text: str) -> Dict[str, Any]:
|
||||
"""
|
||||
分析反馈内容并分类(主接口)
|
||||
"""
|
||||
if not feedback_text or not feedback_text.strip():
|
||||
return {
|
||||
"success": False,
|
||||
"device": None,
|
||||
"system": None,
|
||||
"category": "其他",
|
||||
"error": "反馈内容为空"
|
||||
}
|
||||
|
||||
try:
|
||||
device = await extract_device_from_text(feedback_text)
|
||||
|
||||
system = "其他"
|
||||
if device:
|
||||
system = await get_device_system_from_neo4j(device)
|
||||
|
||||
category = await classify_feedback_content(feedback_text)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"system": system,
|
||||
"category": category
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"system": None,
|
||||
"category": "其他",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
_feedback_token_cache = {"token": None, "expires_at": 0}
|
||||
|
||||
|
||||
async def _get_feedback_api_token() -> Optional[str]:
|
||||
"""
|
||||
获取维修反馈API的认证token(带缓存,避免每次请求都认证)
|
||||
"""
|
||||
import time
|
||||
|
||||
if _feedback_token_cache["token"] and time.time() < _feedback_token_cache["expires_at"]:
|
||||
return _feedback_token_cache["token"]
|
||||
|
||||
base_url = REPAIR_FEEDBACK_CONFIG["url"]
|
||||
email = REPAIR_FEEDBACK_CONFIG.get("email", "")
|
||||
password = REPAIR_FEEDBACK_CONFIG.get("password", "")
|
||||
|
||||
if not email or not password:
|
||||
print("[维修反馈认证错误] 未配置认证凭据")
|
||||
return None
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
signin_resp = await client.post(
|
||||
f"{base_url}/api/v1/auths/signin",
|
||||
json={"email": email, "password": password}
|
||||
)
|
||||
|
||||
if signin_resp.status_code != 200:
|
||||
print(f"[维修反馈认证错误] 登录失败,状态码: {signin_resp.status_code}")
|
||||
return None
|
||||
|
||||
token = signin_resp.json().get("token", "")
|
||||
if not token:
|
||||
print("[维修反馈认证错误] 响应中未找到token")
|
||||
return None
|
||||
|
||||
_feedback_token_cache["token"] = token
|
||||
_feedback_token_cache["expires_at"] = time.time() + 3600
|
||||
|
||||
return token
|
||||
|
||||
except Exception as e:
|
||||
print(f"[维修反馈认证错误] {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
async def statistics_repair_feedbacks(
|
||||
source: Optional[str] = None,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
top_n: int = 10
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
统计维修反馈数据(从外部API获取,带认证)
|
||||
|
||||
Args:
|
||||
source: 来源筛选(好评/差评/反馈),可选
|
||||
start_date: 开始日期(格式:YYYY-MM-DD),可选
|
||||
end_date: 结束日期(格式:YYYY-MM-DD),可选
|
||||
top_n: 返回前 N 条记录(默认为 10)
|
||||
|
||||
Returns:
|
||||
包含统计结果的字典
|
||||
"""
|
||||
try:
|
||||
token = await _get_feedback_api_token()
|
||||
if not token:
|
||||
return {
|
||||
"success": False,
|
||||
"totalCount": 0,
|
||||
"feedbackData": [],
|
||||
"error": "API认证失败,无法获取token"
|
||||
}
|
||||
|
||||
base_url = REPAIR_FEEDBACK_CONFIG["url"]
|
||||
endpoint = f"{base_url}/api/v1/repair-feedbacks/"
|
||||
|
||||
params = {}
|
||||
if source and source.strip():
|
||||
params["source"] = source.strip()
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(
|
||||
endpoint,
|
||||
params=params,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {token}"
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 401 or response.status_code == 403:
|
||||
_feedback_token_cache["token"] = None
|
||||
_feedback_token_cache["expires_at"] = 0
|
||||
|
||||
token = await _get_feedback_api_token()
|
||||
if token:
|
||||
response = await client.get(
|
||||
endpoint,
|
||||
params=params,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {token}"
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
print(f"[维修反馈统计错误] API返回状态码: {response.status_code}")
|
||||
return {
|
||||
"success": False,
|
||||
"totalCount": 0,
|
||||
"feedbackData": [],
|
||||
"error": f"API请求失败,状态码: {response.status_code}"
|
||||
}
|
||||
|
||||
result_json = response.json()
|
||||
|
||||
if isinstance(result_json, list):
|
||||
feedback_list = result_json
|
||||
elif isinstance(result_json, dict):
|
||||
feedback_list = result_json.get("results", result_json.get("data", []))
|
||||
else:
|
||||
feedback_list = []
|
||||
|
||||
if not feedback_list:
|
||||
return {
|
||||
"success": True,
|
||||
"totalCount": 0,
|
||||
"totalTypes": 0,
|
||||
"feedbackData": [],
|
||||
"sourceDistribution": {}
|
||||
}
|
||||
|
||||
start_dt = None
|
||||
end_dt = None
|
||||
if start_date:
|
||||
try:
|
||||
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
pass
|
||||
if end_date:
|
||||
try:
|
||||
end_dt = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) - timedelta(seconds=1)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if start_dt or end_dt:
|
||||
filtered_list = []
|
||||
for item in feedback_list:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
raw_created_at = item.get("created_at", "")
|
||||
if not raw_created_at:
|
||||
continue
|
||||
try:
|
||||
item_dt = datetime.fromtimestamp(int(raw_created_at))
|
||||
if start_dt and item_dt < start_dt:
|
||||
continue
|
||||
if end_dt and item_dt > end_dt:
|
||||
continue
|
||||
filtered_list.append(item)
|
||||
except (ValueError, TypeError, OSError):
|
||||
continue
|
||||
feedback_list = filtered_list
|
||||
|
||||
total_count = len(feedback_list)
|
||||
|
||||
processed_data = []
|
||||
for idx, item in enumerate(feedback_list[:top_n], start=1):
|
||||
if isinstance(item, dict):
|
||||
raw_title = item.get("title", "")
|
||||
raw_content = item.get("content", "")
|
||||
raw_source = item.get("source", source or "")
|
||||
raw_created_at = item.get("created_at", "")
|
||||
raw_user_name = item.get("user_name", "")
|
||||
|
||||
created_at_str = ""
|
||||
if raw_created_at:
|
||||
try:
|
||||
ts = int(raw_created_at)
|
||||
created_at_str = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")
|
||||
except (ValueError, TypeError, OSError):
|
||||
created_at_str = str(raw_created_at)
|
||||
|
||||
processed_item = {
|
||||
"rank": idx,
|
||||
"title": str(raw_title) if raw_title else "",
|
||||
"content": str(raw_content)[:200] if raw_content else "",
|
||||
"source": str(raw_source) if raw_source else "未知",
|
||||
"created_at": created_at_str,
|
||||
"user_name": str(raw_user_name) if raw_user_name else ""
|
||||
}
|
||||
processed_data.append(processed_item)
|
||||
|
||||
source_counter = {}
|
||||
for item in processed_data:
|
||||
s = item.get("source", "未知")
|
||||
source_counter[s] = source_counter.get(s, 0) + 1
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"totalCount": total_count,
|
||||
"totalTypes": len(feedback_list),
|
||||
"sourceDistribution": source_counter,
|
||||
"feedbackData": processed_data
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"[维修反馈统计错误] {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {
|
||||
"success": False,
|
||||
"totalCount": 0,
|
||||
"feedbackData": [],
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
async def repair_feedback_statistics_tool(
|
||||
source: Optional[str] = None,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
top_n: Optional[int] = 10
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
维修反馈统计工具(供工作流调用)
|
||||
统计维修反馈情况,支持按来源和时间筛选
|
||||
|
||||
Args:
|
||||
source: 来源筛选(可选),如:好评、差评、反馈
|
||||
start_date: 开始日期(格式:YYYY-MM-DD),可选
|
||||
end_date: 结束日期(格式:YYYY-MM-DD),可选
|
||||
top_n: 返回前 N 条记录(默认为 10)
|
||||
|
||||
Returns:
|
||||
统计结果,包含 success 和 feedbackData 字段
|
||||
"""
|
||||
result = await statistics_repair_feedbacks(
|
||||
source=source,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
top_n=top_n or 10
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
253
tools/function_tool.py
Normal file
@ -0,0 +1,253 @@
|
||||
"""
|
||||
工具函数定义
|
||||
包含RAG、GraphRAG、VLM、ASR、TTS等工具的function call封装
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
import httpx
|
||||
import asyncio
|
||||
from typing import Optional, Dict, Any, List
|
||||
from langchain_core.tools import tool
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from config import RAG_CONFIG, ASR_CONFIG
|
||||
from modelsAPI.model_api import OpenaiAPI
|
||||
from utils.function_tracker import track_function_calls
|
||||
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
from .graph_tools import (
|
||||
graph_rag_search,
|
||||
fault_graph_rag_search,
|
||||
operation_graph_rag_search,
|
||||
)
|
||||
|
||||
from .rag_tools import (
|
||||
rag_search_tool,
|
||||
file_to_text_tool,
|
||||
)
|
||||
|
||||
from .vlm_tools import (
|
||||
vlm_image_to_text,
|
||||
image_rag_describe,
|
||||
)
|
||||
|
||||
from .fault_statistics import fault_type_statistics_tool, fault_frequency_statistics_tool, spare_parts_statistics_tool
|
||||
|
||||
|
||||
@tool
|
||||
def detect_input_type(raw_input: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
判断输入类型工具(异步版本):严格对齐 MainAgentState 字段。
|
||||
识别:image, audio/asr, file, query (text)
|
||||
"""
|
||||
|
||||
if not isinstance(raw_input, dict):
|
||||
return {
|
||||
"has_image": False, "has_audio": False, "has_file": False, "has_query": False,
|
||||
"input_type": "unknown", "error": "raw_input 必须是 dict"
|
||||
}
|
||||
|
||||
has_image = False
|
||||
img_v = raw_input.get("image")
|
||||
if img_v:
|
||||
has_image = True
|
||||
|
||||
has_audio = False
|
||||
aud_v = raw_input.get("audio")
|
||||
if aud_v:
|
||||
has_audio = True
|
||||
|
||||
has_file = False
|
||||
file_v = raw_input.get("file_text")
|
||||
if file_v:
|
||||
has_file = True
|
||||
|
||||
has_query = False
|
||||
query_v = raw_input.get("query")
|
||||
if isinstance(query_v, str) and query_v.strip():
|
||||
has_query = True
|
||||
|
||||
return {
|
||||
"has_image": has_image,
|
||||
"has_audio": has_audio,
|
||||
"has_file": has_file,
|
||||
"has_query": has_query,
|
||||
}
|
||||
|
||||
|
||||
async def asr_audio_to_text_async(
|
||||
audio_path: str,
|
||||
model: Optional[str] = None,
|
||||
language: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
ASR语音转文本工具(异步实现)
|
||||
将音频文件转换为文本内容
|
||||
|
||||
Args:
|
||||
audio_path: 音频文件路径(如:audio/test.wav)
|
||||
model: ASR模型名称(可选,默认从配置读取,如"base")
|
||||
language: 语言代码(可选,默认从配置读取,如"zh"表示中文)
|
||||
|
||||
Returns:
|
||||
包含success和text字段的字典
|
||||
"""
|
||||
if not os.path.exists(audio_path):
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"音频文件不存在: {audio_path}",
|
||||
"text": ""
|
||||
}
|
||||
|
||||
api_base = ASR_CONFIG["api_base"]
|
||||
asr_model = model or ASR_CONFIG["model"]
|
||||
asr_language = language or ASR_CONFIG["language"]
|
||||
timeout = ASR_CONFIG["timeout"]
|
||||
|
||||
api_endpoint = f"{api_base}/audio/transcriptions"
|
||||
|
||||
try:
|
||||
with open(audio_path, "rb") as f:
|
||||
audio_data = f.read()
|
||||
|
||||
files = {
|
||||
"file": (os.path.basename(audio_path), audio_data, "audio/wav")
|
||||
}
|
||||
data = {
|
||||
"model": asr_model,
|
||||
"language": asr_language,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.post(api_endpoint, files=files, data=data)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
text = result.get("text", "")
|
||||
return {
|
||||
"success": True,
|
||||
"text": text if text else "",
|
||||
"error": None
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"HTTP {response.status_code}: {response.text}",
|
||||
"text": ""
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"text": ""
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
async def asr_audio_to_text(
|
||||
audio_path: str,
|
||||
model: Optional[str] = None,
|
||||
language: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
ASR语音转文本工具(异步版本)
|
||||
将音频文件转换为文本内容
|
||||
|
||||
Args:
|
||||
audio_path: 音频文件路径(如:audio/test.wav)
|
||||
model: ASR模型名称(可选,默认从配置读取,如"base")
|
||||
language: 语言代码(可选,默认从配置读取,如"zh"表示中文)
|
||||
|
||||
Returns:
|
||||
识别出的文本内容
|
||||
"""
|
||||
result = await asr_audio_to_text_async(audio_path, model, language)
|
||||
|
||||
if not result.get("success", False):
|
||||
return f"语音识别失败: {result.get('error', '未知错误')}"
|
||||
|
||||
return result.get("text", "")
|
||||
|
||||
|
||||
@tool
|
||||
async def extract_device_and_fault(query: str) -> Dict[str, Any]:
|
||||
"""
|
||||
从用户查询中提取设备名称和故障现象(使用大模型)
|
||||
|
||||
Args:
|
||||
query: 用户查询文本
|
||||
|
||||
Returns:
|
||||
包含device和fault字段的字典
|
||||
"""
|
||||
if not query or not query.strip():
|
||||
return {"device": "", "fault": ""}
|
||||
|
||||
prompt = f"""从以下用户查询中提取设备名称和故障现象:
|
||||
查询:{query}
|
||||
|
||||
请以JSON格式返回:
|
||||
{{"device": "设备名称", "fault": "故障现象"}}
|
||||
|
||||
如果无法提取,请返回空字符串。"""
|
||||
|
||||
try:
|
||||
result_text = await OpenaiAPI.open_api_chat_without_thinking(
|
||||
query=prompt,
|
||||
json_output=True
|
||||
)
|
||||
|
||||
result_text = result_text.strip()
|
||||
result_text = re.sub(r'^```json\s*', '', result_text, flags=re.IGNORECASE)
|
||||
result_text = re.sub(r'^```\s*', '', result_text)
|
||||
result_text = re.sub(r'```$', '', result_text)
|
||||
|
||||
json_match = re.search(r'\{.*\}', result_text, re.DOTALL)
|
||||
if json_match:
|
||||
result = json.loads(json_match.group(0))
|
||||
return {
|
||||
"device": result.get("device", ""),
|
||||
"fault": result.get("fault", "")
|
||||
}
|
||||
|
||||
return {"device": "", "fault": ""}
|
||||
|
||||
except Exception as e:
|
||||
print(f"提取设备和故障失败: {str(e)}")
|
||||
return {"device": "", "fault": ""}
|
||||
|
||||
|
||||
# 工具列表导出
|
||||
ALL_TOOLS = [
|
||||
detect_input_type,
|
||||
graph_rag_search,
|
||||
fault_graph_rag_search,
|
||||
operation_graph_rag_search,
|
||||
rag_search_tool,
|
||||
vlm_image_to_text,
|
||||
asr_audio_to_text,
|
||||
extract_device_and_fault,
|
||||
image_rag_describe,
|
||||
file_to_text_tool,
|
||||
fault_type_statistics_tool,
|
||||
fault_frequency_statistics_tool,
|
||||
spare_parts_statistics_tool
|
||||
]
|
||||
|
||||
# 按类别分组的工具
|
||||
TOOL_CATEGORIES = {
|
||||
"input_detection": [detect_input_type],
|
||||
"rag": [graph_rag_search, rag_search_tool, fault_graph_rag_search, operation_graph_rag_search],
|
||||
"vlm": [vlm_image_to_text],
|
||||
"asr": [asr_audio_to_text],
|
||||
"extraction": [extract_device_and_fault],
|
||||
"image_rag": [image_rag_describe],
|
||||
"file_process": [file_to_text_tool],
|
||||
"statistics": [fault_type_statistics_tool, fault_frequency_statistics_tool, spare_parts_statistics_tool]
|
||||
}
|
||||
1146
tools/graph_tools.py
Normal file
182
tools/pdf_generator.py
Normal file
@ -0,0 +1,182 @@
|
||||
"""
|
||||
PDF 生成工具模块
|
||||
使用 Pandoc 将 Markdown 文本转换为 PDF 文件
|
||||
"""
|
||||
import os
|
||||
import base64
|
||||
import subprocess
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class PDFGenerator:
|
||||
"""PDF 生成器类(Pandoc 版)"""
|
||||
|
||||
def __init__(self, main_font: str = "Noto Serif CJK SC"):
|
||||
"""
|
||||
初始化 PDF 生成器
|
||||
|
||||
Args:
|
||||
main_font: Pandoc / XeLaTeX 使用的中文字体名
|
||||
"""
|
||||
self.main_font = main_font
|
||||
|
||||
def generate_pdf_from_text(
|
||||
self,
|
||||
text_content: str,
|
||||
output_path: str = None,
|
||||
title: str = "维修报告",
|
||||
return_base64: bool = True
|
||||
) -> dict:
|
||||
"""
|
||||
将 Markdown 文本内容生成 PDF 文件(方法名不变)
|
||||
|
||||
Args:
|
||||
text_content: Markdown 文本内容
|
||||
output_path: PDF 输出路径(可选)
|
||||
title: PDF 标题
|
||||
return_base64: 是否返回 base64 编码内容
|
||||
"""
|
||||
try:
|
||||
# 输出路径
|
||||
if output_path is None:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
output_path = f"temp_report_{timestamp}.pdf"
|
||||
|
||||
# 生成临时 Markdown 文件
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
suffix=".md",
|
||||
delete=False,
|
||||
encoding="utf-8"
|
||||
) as md_file:
|
||||
md_path = md_file.name
|
||||
|
||||
# 写入标题 + 内容(Pandoc 标准)
|
||||
if title:
|
||||
md_file.write(f"# {title}\n\n")
|
||||
md_file.write(text_content.strip())
|
||||
|
||||
# Pandoc 命令
|
||||
cmd = [
|
||||
"pandoc",
|
||||
md_path,
|
||||
"--pdf-engine=xelatex",
|
||||
"-V", f"mainfont={self.main_font}",
|
||||
"-o", output_path
|
||||
]
|
||||
|
||||
# 执行转换
|
||||
subprocess.run(
|
||||
cmd,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
"file_path": output_path
|
||||
}
|
||||
|
||||
# 返回 base64
|
||||
if return_base64:
|
||||
with open(output_path, "rb") as f:
|
||||
result["base64_content"] = base64.b64encode(
|
||||
f.read()
|
||||
).decode("utf-8")
|
||||
|
||||
return result
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": e.stderr.decode("utf-8")
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
finally:
|
||||
# 清理临时 md 文件
|
||||
if "md_path" in locals() and os.path.exists(md_path):
|
||||
os.remove(md_path)
|
||||
|
||||
|
||||
def generate_report_pdf(
|
||||
report_content: str,
|
||||
device_name: str = "未知设备",
|
||||
output_dir: str = None
|
||||
) -> dict:
|
||||
"""
|
||||
生成维修报告 PDF(方法名不变)
|
||||
"""
|
||||
generator = PDFGenerator()
|
||||
|
||||
# 文件名规则不变
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
safe_device_name = device_name.replace(" ", "_").replace("/", "_")
|
||||
file_name = f"{safe_device_name}_维修报告_{timestamp}.pdf"
|
||||
|
||||
if output_dir:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
output_path = os.path.join(output_dir, file_name)
|
||||
else:
|
||||
output_path = file_name
|
||||
|
||||
result = generator.generate_pdf_from_text(
|
||||
text_content=report_content,
|
||||
output_path=output_path,
|
||||
title="设备维修报告",
|
||||
return_base64=True
|
||||
)
|
||||
|
||||
if result.get("success"):
|
||||
result["file_name"] = file_name
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# 测试代码
|
||||
if __name__ == "__main__":
|
||||
test_md = """
|
||||
|
||||
爱因斯坦质能方程:
|
||||
|
||||
$$E = mc^2$$
|
||||
|
||||
该公式揭示了质量与能量的等价性。在核反应中,微小的质量亏损 $\Delta m$ 会释放巨大能量:
|
||||
|
||||
$$\Delta E = \Delta m \cdot c^2$$
|
||||
|
||||
其中 $c = 3 \times 10^8 \, \text{m/s}$ 为真空光速。
|
||||
|
||||
## 实验数据对比
|
||||
|
||||
下表展示了不同材料在相同条件下的能量释放效率:
|
||||
|
||||
| 材料类型 | 质量亏损 (kg) | 释放能量 (J) | 能量密度 (J/kg) |
|
||||
|------------|----------------|---------------------|----------------------|
|
||||
| 铀-235 | $1.0 \times 10^{-3}$ | $9.0 \times 10^{13}$ | $9.0 \times 10^{16}$ |
|
||||
| 氘氚聚变 | $3.1 \times 10^{-3}$ | $2.8 \times 10^{14}$ | $9.0 \times 10^{16}$ |
|
||||
| 化学燃烧 | $1.0 \times 10^{-3}$ | $3.0 \times 10^{4}$ | $3.0 \times 10^{7}$ |
|
||||
|
||||
|
||||
## 结论
|
||||
|
||||
- 核反应的能量密度远高于化学反应(约 $10^9$ 倍)。
|
||||
- 公式 $a^2 + b^2 = c^2$ 虽为几何关系,但在相对论四维时空中有深刻类比。
|
||||
"""
|
||||
|
||||
result = generate_report_pdf(
|
||||
test_md,
|
||||
device_name="测试设备"
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
print(f"✓ PDF 生成成功:{result['file_name']}")
|
||||
print(f"✓ 文件路径:{result['file_path']}")
|
||||
print(f"✓ Base64 长度:{len(result['base64_content'])}")
|
||||
else:
|
||||
print(f"✗ PDF 生成失败:{result['error']}")
|
||||
274
tools/rag_tools.py
Normal file
@ -0,0 +1,274 @@
|
||||
"""
|
||||
RAG搜索工具模块
|
||||
包含知识库搜索、文件处理等功能
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import base64
|
||||
import asyncio
|
||||
import httpx
|
||||
from typing import Optional, Dict, Any
|
||||
from langchain_core.tools import tool
|
||||
|
||||
from config import RAG_CONFIG
|
||||
from utils.function_tracker import track_function_calls
|
||||
|
||||
|
||||
@track_function_calls
|
||||
async def rag_search(
|
||||
query: str,
|
||||
top_k: Optional[int] = 5,
|
||||
file_id: Optional[int] = None,
|
||||
kb_id: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
知识库搜索工具
|
||||
调用知识库搜索API,检索与query相关的文档内容
|
||||
|
||||
Args:
|
||||
query: 搜索关键词(必填)
|
||||
top_k: 返回结果数量(默认5)
|
||||
file_id: 文件ID(可选)
|
||||
kb_id: 知识库ID(可选)
|
||||
|
||||
Returns:
|
||||
搜索结果,包含sourceCitation格式的数据
|
||||
"""
|
||||
|
||||
if not query or not query.strip():
|
||||
return {"success": False, "error": "查询文本不能为空"}
|
||||
|
||||
SEARCH_URL = RAG_CONFIG["search_endpoint"]
|
||||
|
||||
params = {"query": query.strip()}
|
||||
|
||||
if file_id is None:
|
||||
config_file_id = RAG_CONFIG.get("file-id")
|
||||
if config_file_id is not None:
|
||||
try:
|
||||
file_id = int(config_file_id) if isinstance(config_file_id, str) else config_file_id
|
||||
except (ValueError, TypeError):
|
||||
file_id = None
|
||||
|
||||
if kb_id is None:
|
||||
config_kb_id = RAG_CONFIG.get("kb-id")
|
||||
if config_kb_id is not None:
|
||||
kb_id = str(config_kb_id) if config_kb_id is not None else None
|
||||
|
||||
if file_id is not None and file_id != "":
|
||||
params["file_id"] = file_id
|
||||
if kb_id is not None and kb_id != "":
|
||||
params["kb_id"] = kb_id
|
||||
|
||||
print(111111111111111111)
|
||||
print(RAG_CONFIG["x-user-id"])
|
||||
print(RAG_CONFIG["x-user-name"])
|
||||
print(RAG_CONFIG["x-role"])
|
||||
|
||||
headers = {
|
||||
"accept": "application/json",
|
||||
"x-user-id": RAG_CONFIG["x-user-id"],
|
||||
"x-role": RAG_CONFIG["x-role"]
|
||||
}
|
||||
|
||||
print(f"RAG params: {params}")
|
||||
|
||||
max_retries = 1
|
||||
retry_delay = 2
|
||||
|
||||
response = None
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=180.0, verify=False) as client:
|
||||
response = await client.get(SEARCH_URL, params=params, headers=headers)
|
||||
break
|
||||
|
||||
except (httpx.ReadTimeout, httpx.ConnectTimeout, httpx.RequestError) as e:
|
||||
last_exception = e
|
||||
if attempt < max_retries:
|
||||
print(f"RAG请求超时,第 {attempt+1} 次重试,2秒后重新请求...")
|
||||
await asyncio.sleep(retry_delay)
|
||||
else:
|
||||
return {"success": False, "error": f"请求超时: {str(e)}"}
|
||||
|
||||
try:
|
||||
if response.status_code != 200:
|
||||
return {"success": False, "error": f"HTTP {response.status_code}"}
|
||||
|
||||
result = response.json()
|
||||
data = result.get("results", [])
|
||||
|
||||
filtered_data = [item for item in data if item.get("score", 0.0) >= 0.4]
|
||||
|
||||
sorted_data = sorted(
|
||||
filtered_data,
|
||||
key=lambda x: x.get("score", 0.0),
|
||||
reverse=True
|
||||
)[:top_k]
|
||||
|
||||
source_citation = {}
|
||||
|
||||
for idx, item in enumerate(sorted_data, start=1):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
file_info = item.get("file")
|
||||
kb_info = item.get("kb")
|
||||
|
||||
filename = file_info.get("filename", "") if file_info else ""
|
||||
resource = filename or (kb_info.get("name", "未分组文档") if kb_info else "未分组文档")
|
||||
|
||||
positions = item.get("positions", [])
|
||||
page_idx = positions[0].get("page_idx", 0) if positions else 0
|
||||
|
||||
citation_item = {
|
||||
"index": idx,
|
||||
"id": item.get("id", ""),
|
||||
"text": item.get("content", ""),
|
||||
"resource": resource,
|
||||
"filename": filename,
|
||||
"score": item.get("score", 0.0),
|
||||
"page_idx": page_idx,
|
||||
"file_id": item.get("file_id", ""),
|
||||
"kb_id": kb_info.get("id", "") if kb_info else "",
|
||||
"kb_name": kb_info.get("name", "") if kb_info else "",
|
||||
"positions": positions
|
||||
}
|
||||
|
||||
if resource not in source_citation:
|
||||
source_citation[resource] = []
|
||||
source_citation[resource].append(citation_item)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"sourceCitation": source_citation,
|
||||
"total": len(sorted_data),
|
||||
"total_available": len(data)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
@tool
|
||||
async def rag_search_tool(query: str, top_k: Optional[int] = 5) -> str:
|
||||
"""
|
||||
RAG检索工具(供工作流调用)
|
||||
在知识库中检索与query相关的文档内容
|
||||
|
||||
Args:
|
||||
query: 检索查询
|
||||
top_k: 返回结果数量
|
||||
|
||||
Returns:
|
||||
检索结果文本
|
||||
"""
|
||||
result = await rag_search(query, top_k=top_k)
|
||||
|
||||
if not result.get("success", False):
|
||||
return ""
|
||||
|
||||
source_citation = result.get("sourceCitation", {})
|
||||
if not source_citation:
|
||||
return ""
|
||||
|
||||
results_parts = []
|
||||
for resource, items in source_citation.items():
|
||||
for item in items:
|
||||
text = item.get("text", "")
|
||||
if text:
|
||||
results_parts.append(f"【{resource}】{text}")
|
||||
|
||||
results_text = "\n\n".join(results_parts) if results_parts else ""
|
||||
if len(results_text) > 2000:
|
||||
results_text = results_text[:2000] + "..."
|
||||
return results_text
|
||||
|
||||
|
||||
@track_function_calls
|
||||
async def file_to_text(file_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
文件转文本工具
|
||||
支持多种文件格式:PDF、Word、TXT、图片等
|
||||
|
||||
Args:
|
||||
file_path: 文件路径
|
||||
|
||||
Returns:
|
||||
转换结果,包含文本内容
|
||||
"""
|
||||
if not file_path or not file_path.strip():
|
||||
return {"success": False, "error": "文件路径不能为空"}
|
||||
|
||||
file_path = file_path.strip()
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
return {"success": False, "error": f"文件不存在: {file_path}"}
|
||||
|
||||
try:
|
||||
file_ext = os.path.splitext(file_path)[1].lower()
|
||||
|
||||
if file_ext == ".txt":
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
return {"success": True, "text": text}
|
||||
|
||||
elif file_ext == ".pdf":
|
||||
import fitz
|
||||
doc = fitz.open(file_path)
|
||||
text_parts = []
|
||||
for page in doc:
|
||||
text_parts.append(page.get_text())
|
||||
text = "\n".join(text_parts)
|
||||
doc.close()
|
||||
return {"success": True, "text": text}
|
||||
|
||||
elif file_ext in [".docx", ".doc"]:
|
||||
from docx import Document
|
||||
doc = Document(file_path)
|
||||
text_parts = []
|
||||
for para in doc.paragraphs:
|
||||
text_parts.append(para.text)
|
||||
text = "\n".join(text_parts)
|
||||
return {"success": True, "text": text}
|
||||
|
||||
elif file_ext in [".jpg", ".jpeg", ".png", ".bmp", ".gif"]:
|
||||
with open(file_path, "rb") as f:
|
||||
image_data = f.read()
|
||||
base64_image = base64.b64encode(image_data).decode("utf-8")
|
||||
base64_url = f"data:image/{file_ext[1:]};base64,{base64_image}"
|
||||
|
||||
from modelsAPI.model_api import OpenaiAPI
|
||||
text = await OpenaiAPI.vlm_chat(
|
||||
image_path=base64_url,
|
||||
prompt="请描述这张图片的内容"
|
||||
)
|
||||
return {"success": True, "text": text}
|
||||
|
||||
else:
|
||||
return {"success": False, "error": f"不支持的文件格式: {file_ext}"}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"文件处理失败: {str(e)}"}
|
||||
|
||||
|
||||
@tool
|
||||
async def file_to_text_tool(file_path: str) -> str:
|
||||
"""
|
||||
文件转文本工具(供工作流调用)
|
||||
支持多种文件格式:PDF、Word、TXT、图片等
|
||||
|
||||
Args:
|
||||
file_path: 文件路径
|
||||
|
||||
Returns:
|
||||
文件文本内容
|
||||
"""
|
||||
result = await file_to_text(file_path)
|
||||
|
||||
if not result.get("success", False):
|
||||
return f"文件处理失败: {result.get('error', '未知错误')}"
|
||||
|
||||
return result.get("text", "")
|
||||
200
tools/ship_model_db.py
Normal file
@ -0,0 +1,200 @@
|
||||
"""
|
||||
舷号-型号映射数据库模块
|
||||
用于管理舰船型号、舷号、舰名的映射关系
|
||||
数据存储在 PostgreSQL 的 ship_model_mapping 表中
|
||||
每艘舰一条记录
|
||||
"""
|
||||
import json
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
from config import POSTGRES_CONNECTION_STRING
|
||||
|
||||
|
||||
async def init_ship_model_mapping_table():
|
||||
"""
|
||||
初始化 ship_model_mapping 表并插入初始数据
|
||||
每艘舰一条记录,包含型号、舷号、舰名
|
||||
"""
|
||||
try:
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
except ImportError:
|
||||
print("[ship_model_db] psycopg_pool 未安装,无法初始化表")
|
||||
return
|
||||
|
||||
async with AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
kwargs={"autocommit": True}
|
||||
) as pool:
|
||||
async with pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS ship_model_mapping (
|
||||
id SERIAL PRIMARY KEY,
|
||||
model_name TEXT NOT NULL,
|
||||
hull_number TEXT NOT NULL UNIQUE,
|
||||
ship_name TEXT NOT NULL DEFAULT ''
|
||||
)
|
||||
""")
|
||||
await cur.execute("""
|
||||
CREATE INDEX IF NOT EXISTS ship_model_mapping_model_name_idx ON ship_model_mapping(model_name)
|
||||
""")
|
||||
await cur.execute("""
|
||||
CREATE INDEX IF NOT EXISTS ship_model_mapping_hull_number_idx ON ship_model_mapping(hull_number)
|
||||
""")
|
||||
print("[ship_model_db] ship_model_mapping 表初始化完成")
|
||||
|
||||
await cur.execute("SELECT COUNT(*) FROM ship_model_mapping")
|
||||
count = (await cur.fetchone())[0]
|
||||
if count == 0:
|
||||
initial_data = [
|
||||
("055型驱逐舰", "102", "拉萨舰"),
|
||||
("055型驱逐舰", "103", "鞍山舰"),
|
||||
("055型驱逐舰", "104", "无锡舰"),
|
||||
("055型驱逐舰", "105", "大连舰"),
|
||||
("055型驱逐舰", "106", "延安舰"),
|
||||
("055型驱逐舰", "107", "遵义舰"),
|
||||
("055型驱逐舰", "108", "咸阳舰"),
|
||||
("052D型驱逐舰", "172", "昆明舰"),
|
||||
("052D型驱逐舰", "173", "长沙舰"),
|
||||
("052D型驱逐舰", "174", "合肥舰"),
|
||||
("052D型驱逐舰", "175", "银川舰"),
|
||||
("052D型驱逐舰", "163", "南昌舰"),
|
||||
("054A型护卫舰", "529", "舟山舰"),
|
||||
("054A型护卫舰", "530", "徐州舰"),
|
||||
("054A型护卫舰", "547", "临沂舰"),
|
||||
("054A型护卫舰", "568", "衡阳舰"),
|
||||
("054A型护卫舰", "570", "黄山舰"),
|
||||
]
|
||||
for model_name, hull_number, ship_name in initial_data:
|
||||
await cur.execute(
|
||||
"""
|
||||
INSERT INTO ship_model_mapping (model_name, hull_number, ship_name)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (hull_number) DO NOTHING
|
||||
""",
|
||||
(model_name, hull_number, ship_name)
|
||||
)
|
||||
print(f"[ship_model_db] 已插入 {len(initial_data)} 条初始数据")
|
||||
|
||||
|
||||
async def load_ship_model_mapping() -> List[Dict[str, str]]:
|
||||
"""
|
||||
从数据库加载全部舰船映射数据
|
||||
|
||||
Returns:
|
||||
每艘舰一条记录的列表
|
||||
[
|
||||
{"model_name": "055型驱逐舰", "hull_number": "102", "ship_name": "拉萨舰"},
|
||||
...
|
||||
]
|
||||
"""
|
||||
try:
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
except ImportError:
|
||||
print("[ship_model_db] psycopg_pool 未安装,返回空映射")
|
||||
return []
|
||||
|
||||
try:
|
||||
async with AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
kwargs={"autocommit": True}
|
||||
) as pool:
|
||||
async with pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"SELECT model_name, hull_number, ship_name FROM ship_model_mapping ORDER BY id"
|
||||
)
|
||||
rows = await cur.fetchall()
|
||||
result = []
|
||||
for row in rows:
|
||||
model_name, hull_number, ship_name = row
|
||||
result.append({
|
||||
"model_name": model_name,
|
||||
"hull_number": hull_number,
|
||||
"ship_name": ship_name or "",
|
||||
})
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"[ship_model_db] 加载映射数据失败: {str(e)}")
|
||||
return []
|
||||
|
||||
|
||||
async def add_ship(
|
||||
model_name: str,
|
||||
hull_number: str,
|
||||
ship_name: str = ""
|
||||
) -> bool:
|
||||
"""
|
||||
添加单艘舰船
|
||||
|
||||
Args:
|
||||
model_name: 型号名称
|
||||
hull_number: 舷号
|
||||
ship_name: 舰名
|
||||
|
||||
Returns:
|
||||
是否添加成功
|
||||
"""
|
||||
try:
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
except ImportError:
|
||||
print("[ship_model_db] psycopg_pool 未安装,无法添加")
|
||||
return False
|
||||
|
||||
try:
|
||||
async with AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
kwargs={"autocommit": True}
|
||||
) as pool:
|
||||
async with pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""
|
||||
INSERT INTO ship_model_mapping (model_name, hull_number, ship_name)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (hull_number) DO UPDATE SET
|
||||
model_name = EXCLUDED.model_name,
|
||||
ship_name = EXCLUDED.ship_name
|
||||
""",
|
||||
(model_name, hull_number, ship_name)
|
||||
)
|
||||
print(f"[ship_model_db] 舰船 {hull_number}({ship_name}) 已保存")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[ship_model_db] 添加舰船失败: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
async def delete_ship(hull_number: str) -> bool:
|
||||
"""
|
||||
删除单艘舰船
|
||||
|
||||
Args:
|
||||
hull_number: 舷号
|
||||
|
||||
Returns:
|
||||
是否删除成功
|
||||
"""
|
||||
try:
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
except ImportError:
|
||||
print("[ship_model_db] psycopg_pool 未安装,无法删除")
|
||||
return False
|
||||
|
||||
try:
|
||||
async with AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
kwargs={"autocommit": True}
|
||||
) as pool:
|
||||
async with pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"DELETE FROM ship_model_mapping WHERE hull_number = %s",
|
||||
(hull_number,)
|
||||
)
|
||||
print(f"[ship_model_db] 舰船 {hull_number} 已删除")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[ship_model_db] 删除舰船失败: {str(e)}")
|
||||
return False
|
||||
|
||||
777
tools/text2sql_tool.py
Normal file
@ -0,0 +1,777 @@
|
||||
"""
|
||||
受控 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 ""
|
||||
)
|
||||
print("输出最终sql的提示词内容")
|
||||
print(prompt)
|
||||
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
|
||||
|
||||
|
||||
184
tools/texttosql_utils.py
Normal file
@ -0,0 +1,184 @@
|
||||
import os
|
||||
import ast
|
||||
import re
|
||||
import asyncio
|
||||
import httpx
|
||||
from typing import List, Dict
|
||||
|
||||
from neo4j import GraphDatabase
|
||||
from neo4j_graphrag.retrievers import HybridCypherRetriever
|
||||
from neo4j_graphrag.embeddings.base import Embedder
|
||||
from modelsAPI.model_api import LocalBgeM3Embeddings, OpenaiAPI
|
||||
from config import MAP_INS, NEO4J_CONFIG, INDEX_CONFIG, EMBEDDING_CONFIG
|
||||
|
||||
# ================== 配置 ==================
|
||||
URI = NEO4J_CONFIG["uri"]
|
||||
AUTH = (NEO4J_CONFIG["user"], NEO4J_CONFIG["password"])
|
||||
NAME_PROPERTY = INDEX_CONFIG["NAME_PROPERTY"]
|
||||
FULLTEXT_PROPERTY = INDEX_CONFIG["FULLTEXT_PROPERTY"]
|
||||
EMBEDDING_PROPERTY = INDEX_CONFIG["EMBEDDING_PROPERTY"]
|
||||
SEARCH_LABEL = INDEX_CONFIG["SEARCH_LABEL"]
|
||||
VECTOR_INDEX_NAME = INDEX_CONFIG["VECTOR_INDEX_NAME"]
|
||||
FULLTEXT_INDEX_NAME = INDEX_CONFIG["FULLTEXT_INDEX_NAME"]
|
||||
map_ins = MAP_INS
|
||||
|
||||
# ================== 全局复用:driver / embedder 只初始化一次 ==================
|
||||
_driver = None
|
||||
_embedder = None
|
||||
|
||||
def get_driver():
|
||||
global _driver
|
||||
if _driver is None:
|
||||
_driver = GraphDatabase.driver(URI, auth=AUTH)
|
||||
return _driver
|
||||
|
||||
def get_embedder():
|
||||
global _embedder
|
||||
if _embedder is None:
|
||||
_embedder = LocalBgeM3Embeddings(
|
||||
base_url=EMBEDDING_CONFIG["base_url"],
|
||||
api_key=EMBEDDING_CONFIG["api_key"],
|
||||
)
|
||||
return _embedder
|
||||
|
||||
|
||||
# ================== 辅助:解析 Record 字符串 ==================
|
||||
def parse_record_string(s: str):
|
||||
name_match = re.search(r"name='([^']*)'", s)
|
||||
labels_match = re.search(r"labels=(\[[^\]]*\])", s)
|
||||
score_match = re.search(r"score=([\d.]+)", s)
|
||||
|
||||
name = name_match.group(1) if name_match else "未知名称"
|
||||
labels = ["未知标签"]
|
||||
if labels_match:
|
||||
try:
|
||||
labels = ast.literal_eval(labels_match.group(1))
|
||||
except (ValueError, SyntaxError):
|
||||
pass
|
||||
score = float(score_match.group(1)) if score_match else 0.0
|
||||
return name, labels, score
|
||||
|
||||
|
||||
# ================== 构建 Retriever ==================
|
||||
def build_retriever(sync_driver, embedder: Embedder) -> HybridCypherRetriever:
|
||||
return HybridCypherRetriever(
|
||||
driver=sync_driver,
|
||||
vector_index_name=VECTOR_INDEX_NAME,
|
||||
fulltext_index_name=FULLTEXT_INDEX_NAME,
|
||||
embedder=embedder,
|
||||
retrieval_query=f"""
|
||||
RETURN
|
||||
node.`{NAME_PROPERTY}` AS name,
|
||||
[lbl IN labels(node) WHERE lbl <> '{SEARCH_LABEL}'] AS labels,
|
||||
score
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
# ================== 同步检索(在线程池中执行,不阻塞事件循环)==================
|
||||
def _hybrid_search_sync(sentence: str, top_k: int = 10) -> List[Dict]:
|
||||
retriever = build_retriever(get_driver(), get_embedder())
|
||||
results_raw = retriever.search(query_text=sentence, top_k=top_k)
|
||||
|
||||
hybrid_results: List[Dict] = []
|
||||
for item in results_raw.items:
|
||||
content = item.content
|
||||
name = "未知名称"
|
||||
labels = ["未知标签"]
|
||||
score = 0.0
|
||||
|
||||
if hasattr(content, "keys"):
|
||||
name = content.get("name", name)
|
||||
labels = content.get("labels", labels)
|
||||
score = content.get("score", score)
|
||||
elif isinstance(content, str):
|
||||
if content.startswith("<Record"):
|
||||
name, labels, score = parse_record_string(content)
|
||||
else:
|
||||
try:
|
||||
parsed = ast.literal_eval(content)
|
||||
if isinstance(parsed, dict):
|
||||
name = parsed.get("name", name)
|
||||
labels = parsed.get("labels", labels)
|
||||
score = parsed.get("score", score)
|
||||
except (ValueError, SyntaxError):
|
||||
name = content
|
||||
|
||||
if score == 0.0 and hasattr(item, "metadata") and item.metadata:
|
||||
score = item.metadata.get("score", score)
|
||||
|
||||
hybrid_results.append({
|
||||
"标签": labels,
|
||||
"名称": name,
|
||||
"得分": score,
|
||||
"来源": "hybrid",
|
||||
"fulltext_snippet": "",
|
||||
})
|
||||
|
||||
return hybrid_results
|
||||
|
||||
|
||||
# ================== Prompt 模板 ==================
|
||||
PROMPT_TEMPLATE = """
|
||||
请根据给出的问题,以及问题相关的检索结果,以及实体类型和sql表字段映射表,筛选出与问题相关的信息并输出。
|
||||
要求:
|
||||
1. 请仔细分析问题和检索结果,确定问题中出现的实体类型和sql表字段。
|
||||
2. 输出内容可以为充分分析后的一段文本,即你对这个问题中所用到的实体类型和sql表字段的描述。
|
||||
3. 给出的信息尽量精简,不能省略任何重要信息。
|
||||
实体类型和sql表字段映射表:
|
||||
{map_ins}
|
||||
其中键表示实体类型,值表示sql表字段。
|
||||
|
||||
检索结果:
|
||||
{results}
|
||||
|
||||
问题:{user_question}
|
||||
|
||||
|
||||
案例1:
|
||||
用户问题为:扫气箱内发现润滑油泄漏发生了多少次?
|
||||
检索知识库为:
|
||||
[ 1] score=1.0000 | 活塞杆填料函密封失效导致扫气箱内发现润滑油泄漏 标签=['故障模式']
|
||||
[ 2] score=0.9298 | 排除喷油器雾化不良故障 标签=['维修项目']
|
||||
[ 3] score=0.9115 | 执行发动机燃油泄漏应急封堵操作 标签=['操作项目']
|
||||
[ 4] score=0.9082 | 活塞环磨损或断裂导致机座与机架组件润滑油消耗异常增加 标签=['故障模式']
|
||||
[ 5] score=0.9058 | 检测气缸套内径磨损量 标签=['维修项目']
|
||||
[ 6] score=0.9049 | 拆检活塞头磨损情况 标签=['维修项目']
|
||||
[ 7] score=0.9048 | 检查排气阀杆密封面磨损 标签=['维修项目']
|
||||
[ 8] score=0.9015 | 润滑油压力骤降导致轴承组件紧急停机 标签=['故障模式']
|
||||
[ 9] score=0.8985 | 喷油器组件的维修工作 标签=['维修工作']
|
||||
[10] score=0.8979 | 发动机的安全警告 标签=['安全警告']
|
||||
输出为:
|
||||
问题中扫气箱内发现润滑油泄漏是一个故障现象,属于表中fault字段。
|
||||
"""
|
||||
|
||||
SYSTEM_PROMPT = "你是一个专业信息筛选助手,根据给出的问题和问题检索到的相关实体关系,筛选出与问题相关的信息。"
|
||||
|
||||
|
||||
# ================== 主函数(传参调用)==================
|
||||
async def get_extra_info(user_question: str, top_k: int = 20) -> str:
|
||||
"""
|
||||
参数:
|
||||
user_question: 用户问题
|
||||
top_k: 检索返回条数
|
||||
返回:
|
||||
LLM 筛选结果字符串
|
||||
"""
|
||||
# 同步检索放入线程池,不阻塞事件循环
|
||||
results = await asyncio.to_thread(_hybrid_search_sync, user_question, top_k)
|
||||
|
||||
# 构建 prompt 并异步调用 LLM
|
||||
res = await OpenaiAPI.open_api_chat_without_thinking(
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
query=PROMPT_TEMPLATE.format(results=results, map_ins=map_ins,user_question=user_question),
|
||||
)
|
||||
|
||||
return res.strip()
|
||||
|
||||
|
||||
# ================== 入口 ==================
|
||||
if __name__ == "__main__":
|
||||
answer = asyncio.run(get_extra_info("发动机这个月发生了多少次故障"))
|
||||
print(111111111111111111)
|
||||
print(answer)
|
||||
|
||||
298
tools/vlm_tools.py
Normal file
@ -0,0 +1,298 @@
|
||||
"""
|
||||
VLM图像处理工具模块
|
||||
包含图像识别、图像压缩、图像保存等功能
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import math
|
||||
import base64
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
import tiktoken
|
||||
from langchain_core.tools import tool
|
||||
|
||||
from config import VLM_CONFIG, SERVER_CONFIG
|
||||
from modelsAPI.model_api import OpenaiAPI
|
||||
from utils.function_tracker import track_function_calls
|
||||
|
||||
|
||||
VLM_API_BASE = VLM_CONFIG["api_base"]
|
||||
VLM_MODEL_NAME = VLM_CONFIG["model"]
|
||||
MAX_MODEL_LEN = 32768
|
||||
INPUT_TOKEN_SAFETY_RATIO = 0.8
|
||||
MIN_IMAGE_SIZE = 224
|
||||
JPEG_QUALITY = 85
|
||||
|
||||
SYSTEM_PROMPT_VLM = """你是一个专业的工业设备巡检专家助手。
|
||||
你的任务是协助工程师分析现场情况。
|
||||
|
||||
当你遇到以下情况时:
|
||||
1. 用户提供了图片路径(如 .png, .jpg, /app/data/... 等)。
|
||||
2. 用户询问有关设备外观、颜色、仪表读数或物理损坏的问题。
|
||||
|
||||
你应该:
|
||||
- 调用 `vlm_image_to_text` 工具。
|
||||
- 在 `prompt` 参数中,根据用户的具体意图(如:检查漏油、读仪表、看指示灯)量身定制分析指令。
|
||||
- 获取 VLM 结果后,结合专业知识给出建议。
|
||||
"""
|
||||
|
||||
|
||||
def encode_image(image_path: str) -> str:
|
||||
"""将本地图片转换为 Base64 编码"""
|
||||
with open(image_path, "rb") as f:
|
||||
return base64.b64encode(f.read()).decode("utf-8")
|
||||
|
||||
|
||||
def estimate_qwen_vl_visual_tokens(width: int, height: int) -> int:
|
||||
"""
|
||||
根据 Qwen-VL 的图像分块策略估算视觉 tokens。
|
||||
公式来源:Qwen-VL 官方技术文档(Naive resizing + 192 tokens per unit)
|
||||
"""
|
||||
units_h = math.ceil(height / 336)
|
||||
units_w = math.ceil(width / 336)
|
||||
total_units = units_h * units_w + 1
|
||||
return total_units * 192
|
||||
|
||||
|
||||
def count_text_tokens(text: str) -> int:
|
||||
"""使用 tiktoken 估算文本 token 数(兼容中英文)"""
|
||||
enc = tiktoken.get_encoding("cl100k_base")
|
||||
return len(enc.encode(text))
|
||||
|
||||
|
||||
def smart_resize_base64_image(
|
||||
base64_image: str,
|
||||
prompt_text: str,
|
||||
max_input_tokens: int = int(MAX_MODEL_LEN * INPUT_TOKEN_SAFETY_RATIO),
|
||||
min_size: int = MIN_IMAGE_SIZE,
|
||||
jpeg_quality: int = JPEG_QUALITY
|
||||
) -> str:
|
||||
"""
|
||||
智能压缩 base64 图像,确保 (文本 tokens + 视觉 tokens) <= max_input_tokens
|
||||
"""
|
||||
if not base64_image.startswith("data:image"):
|
||||
raise ValueError("Invalid base64 image format. Must start with 'data:image'.")
|
||||
|
||||
try:
|
||||
header, b64_data = base64_image.split(",", 1)
|
||||
image_data = base64.b64decode(b64_data)
|
||||
img = Image.open(BytesIO(image_data)).convert("RGB")
|
||||
orig_w, orig_h = img.size
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to decode base64 image: {e}")
|
||||
|
||||
text_tokens = count_text_tokens(prompt_text)
|
||||
visual_tokens = estimate_qwen_vl_visual_tokens(orig_w, orig_h)
|
||||
total_tokens = text_tokens + visual_tokens
|
||||
|
||||
print(f"[VLM Preprocess] 原始尺寸: {orig_w}x{orig_h} | 文本 tokens: {text_tokens} | 视觉 tokens: {visual_tokens} | 总计: {total_tokens}")
|
||||
|
||||
if total_tokens <= max_input_tokens:
|
||||
print("[VLM Preprocess] ✅ 无需压缩")
|
||||
return base64_image
|
||||
|
||||
scale = 1.0
|
||||
target_tokens = total_tokens
|
||||
while target_tokens > max_input_tokens and scale > 0.1:
|
||||
scale *= 0.8
|
||||
new_w = max(min_size, int(orig_w * scale))
|
||||
new_h = max(min_size, int(orig_h * scale))
|
||||
target_tokens = text_tokens + estimate_qwen_vl_visual_tokens(new_w, new_h)
|
||||
|
||||
final_w = max(min_size, int(orig_w * scale))
|
||||
final_h = max(min_size, int(orig_h * scale))
|
||||
|
||||
print(f"[VLM Preprocess] ⚠️ 需压缩 → 新尺寸: {final_w}x{final_h} | 预估总 tokens: {target_tokens}")
|
||||
|
||||
resized_img = img.resize((final_w, final_h), Image.Resampling.LANCZOS)
|
||||
buffer = BytesIO()
|
||||
resized_img.save(buffer, format="JPEG", quality=jpeg_quality)
|
||||
compressed_b64 = base64.b64encode(buffer.getvalue()).decode("utf-8")
|
||||
return f"data:image/jpeg;base64,{compressed_b64}"
|
||||
|
||||
|
||||
def save_base64_image_and_record(
|
||||
base64_image: str,
|
||||
description: str,
|
||||
output_dir: str = "image_input",
|
||||
record_file: str = "image_input/image_records.json"
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
保存 base64 图片到指定目录,并记录图片名称和描述到 JSON 文件。
|
||||
|
||||
Args:
|
||||
base64_image: base64 编码的图片数据(data:image/xxx;base64,... 格式)
|
||||
description: 图片的描述文本
|
||||
output_dir: 图片保存目录
|
||||
record_file: 记录 JSON 文件路径
|
||||
|
||||
Returns:
|
||||
包含保存结果的字典:{"success": bool, "image_name": str, "image_path": str, "error": str}
|
||||
"""
|
||||
try:
|
||||
if not base64_image.startswith("data:image"):
|
||||
return {"success": False, "error": "Invalid base64 image format"}
|
||||
|
||||
header, b64_data = base64_image.split(",", 1)
|
||||
|
||||
if "jpeg" in header or "jpg" in header:
|
||||
ext = "jpg"
|
||||
elif "png" in header:
|
||||
ext = "png"
|
||||
elif "gif" in header:
|
||||
ext = "gif"
|
||||
elif "webp" in header:
|
||||
ext = "webp"
|
||||
else:
|
||||
ext = "jpg"
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
image_name = f"{uuid.uuid4().hex[:12]}_{datetime.now().strftime('%Y%m%d%H%M%S')}.{ext}"
|
||||
image_path = os.path.join(output_dir, image_name)
|
||||
|
||||
image_data = base64.b64decode(b64_data)
|
||||
with open(image_path, "wb") as f:
|
||||
f.write(image_data)
|
||||
|
||||
record_entry = {
|
||||
"image_name": image_name,
|
||||
"image_path": image_path,
|
||||
"description": description,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
os.makedirs(os.path.dirname(record_file), exist_ok=True)
|
||||
|
||||
existing_records = []
|
||||
if os.path.exists(record_file):
|
||||
try:
|
||||
with open(record_file, "r", encoding="utf-8") as f:
|
||||
existing_records = json.load(f)
|
||||
if not isinstance(existing_records, list):
|
||||
existing_records = []
|
||||
except (json.JSONDecodeError, IOError):
|
||||
existing_records = []
|
||||
|
||||
existing_records.append(record_entry)
|
||||
|
||||
with open(record_file, "w", encoding="utf-8") as f:
|
||||
json.dump(existing_records, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"[ImageSaved] 图片已保存: {image_path}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"image_name": image_name,
|
||||
"image_path": image_path,
|
||||
"error": None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"image_name": None,
|
||||
"image_path": None,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
async def vlm_image_to_text(
|
||||
image_path: str,
|
||||
prompt: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
调用 Qwen3.5-35B 进行工业巡检分析。
|
||||
- 自动移除所有 <think...</think 包裹的内容(包括多行)
|
||||
- 仅返回最终结果文本
|
||||
- 支持 40K token 上下文,自动压缩大图
|
||||
"""
|
||||
|
||||
default_prompt = (
|
||||
"【重要指令】禁止输出任何形式的 <think...</think、<reasoning></reasoning> 或其他内部思考过程。"
|
||||
"仅输出最终的专业分析结果。\n\n"
|
||||
"你是一位资深的工业巡检专家。请仔细观察这张图片并按以下结构报告:\n"
|
||||
"1. 设备识别:图中是什么设备?型号或标签是否可见?\n"
|
||||
"2. 状态评估:仪表盘数值是否在正常区间?指示灯颜色是什么?\n"
|
||||
"3. 异常检测:是否存在漏油、生锈、电缆破损、螺丝松动或烟雾?\n"
|
||||
"4. 环境风险:周围是否有杂物堆放或安全隐患?\n"
|
||||
"请用专业、客观、简短的语言描述。"
|
||||
)
|
||||
|
||||
final_prompt = f"{default_prompt}\n特别注意:{prompt}" if prompt else default_prompt
|
||||
|
||||
safe_image = smart_resize_base64_image(
|
||||
base64_image=image_path,
|
||||
prompt_text=final_prompt,
|
||||
max_input_tokens=int(MAX_MODEL_LEN * INPUT_TOKEN_SAFETY_RATIO)
|
||||
)
|
||||
|
||||
try:
|
||||
result = await OpenaiAPI.open_api_vl_without_thinking(safe_image)
|
||||
|
||||
print(f"✅ 最终输出:\n{result}")
|
||||
|
||||
save_result = save_base64_image_and_record(
|
||||
base64_image=image_path,
|
||||
description=result,
|
||||
output_dir="picture",
|
||||
record_file="picture/image_records.json"
|
||||
)
|
||||
if save_result["success"]:
|
||||
print(f"📷 图片已保存: {save_result['image_name']}")
|
||||
image_url = f"{SERVER_CONFIG['base_url']}/picture/{save_result['image_name']}"
|
||||
result = f"{result}\n\n"
|
||||
else:
|
||||
print(f"⚠️ 图片保存失败: {save_result['error']}")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
error_msg = f"VLM 调用失败: {str(e)}\n{traceback.format_exc()}"
|
||||
print(error_msg)
|
||||
return error_msg
|
||||
|
||||
|
||||
@track_function_calls
|
||||
async def image_rag_describe(
|
||||
image_path: str,
|
||||
query: str,
|
||||
top_k: int = 5
|
||||
) -> str:
|
||||
"""
|
||||
图像RAG描述工具
|
||||
先用VLM分析图像,然后用RAG检索相关知识
|
||||
|
||||
Args:
|
||||
image_path: 图像路径(base64格式)
|
||||
query: 查询文本
|
||||
top_k: RAG检索数量
|
||||
|
||||
Returns:
|
||||
分析结果文本
|
||||
"""
|
||||
try:
|
||||
vlm_result = await vlm_image_to_text(image_path, prompt=query)
|
||||
|
||||
from .rag_tools import rag_search
|
||||
rag_result = await rag_search(query=f"{query} {vlm_result}", top_k=top_k)
|
||||
|
||||
if rag_result.get("success", False):
|
||||
source_citation = rag_result.get("sourceCitation", {})
|
||||
rag_text = ""
|
||||
for resource, items in source_citation.items():
|
||||
for item in items:
|
||||
rag_text += f"【{resource}】{item.get('text', '')}\n\n"
|
||||
|
||||
return f"图像分析结果:\n{vlm_result}\n\n相关知识:\n{rag_text}"
|
||||
else:
|
||||
return vlm_result
|
||||
|
||||
except Exception as e:
|
||||
return f"图像RAG分析失败: {str(e)}"
|
||||
4728
utils/MML2OMML.XSL
Normal file
4
utils/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
"""
|
||||
工具模块
|
||||
"""
|
||||
|
||||
BIN
utils/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
utils/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
utils/__pycache__/function_tracker.cpython-310.pyc
Normal file
BIN
utils/__pycache__/function_tracker.cpython-312.pyc
Normal file
BIN
utils/__pycache__/image_utils.cpython-312.pyc
Normal file
BIN
utils/__pycache__/intent_matcher.cpython-312.pyc
Normal file
BIN
utils/__pycache__/pdf_generator.cpython-310.pyc
Normal file
BIN
utils/__pycache__/ship_number_search.cpython-312.pyc
Normal file
BIN
utils/__pycache__/word_generator.cpython-310.pyc
Normal file
BIN
utils/__pycache__/word_generator.cpython-312.pyc
Normal file
360
utils/function_tracker.py
Normal file
@ -0,0 +1,360 @@
|
||||
"""
|
||||
使用装饰器实现全栈函数调用追踪
|
||||
解决LangGraph无法捕获子函数内部状态的问题
|
||||
"""
|
||||
|
||||
import functools
|
||||
import inspect
|
||||
import asyncio
|
||||
import contextvars
|
||||
from typing import Callable, Any, Dict, Optional, List
|
||||
from datetime import datetime
|
||||
from collections import deque
|
||||
|
||||
# 使用 contextvars 实现请求级别的隔离,避免多个并发请求互相干扰
|
||||
_event_callbacks: contextvars.ContextVar[List[Callable]] = contextvars.ContextVar('event_callbacks', default=None)
|
||||
_current_stream_handler: contextvars.ContextVar[Optional[Any]] = contextvars.ContextVar('current_stream_handler',
|
||||
default=None)
|
||||
|
||||
|
||||
def register_event_callback(callback: Callable[[Dict[str, Any]], None]):
|
||||
"""
|
||||
注册事件回调函数(使用 contextvars 实现请求隔离)
|
||||
"""
|
||||
callbacks = _event_callbacks.get()
|
||||
if callbacks is None:
|
||||
callbacks = []
|
||||
_event_callbacks.set(callbacks)
|
||||
callbacks.append(callback)
|
||||
|
||||
|
||||
def unregister_event_callback(callback: Callable[[Dict[str, Any]], None]):
|
||||
"""
|
||||
移除事件回调函数(使用 contextvars 实现请求隔离)
|
||||
"""
|
||||
callbacks = _event_callbacks.get()
|
||||
if callbacks and callback in callbacks:
|
||||
callbacks.remove(callback)
|
||||
|
||||
|
||||
def clear_event_callbacks():
|
||||
"""
|
||||
清空所有事件回调(使用 contextvars 实现请求隔离)
|
||||
"""
|
||||
callbacks = _event_callbacks.get()
|
||||
if callbacks:
|
||||
callbacks.clear()
|
||||
|
||||
|
||||
def get_all_callbacks():
|
||||
"""
|
||||
获取所有注册的回调函数(使用 contextvars 实现请求隔离)
|
||||
"""
|
||||
callbacks = _event_callbacks.get()
|
||||
return callbacks.copy() if callbacks else []
|
||||
|
||||
|
||||
def extract_function_description(func: Callable) -> str:
|
||||
"""
|
||||
提取函数的描述(docstring的第一行或前100个字符)
|
||||
"""
|
||||
if func.__doc__:
|
||||
doc = func.__doc__.strip()
|
||||
# 取第一行或前100个字符
|
||||
first_line = doc.split('\n')[0].strip()
|
||||
return first_line[:100] if len(first_line) > 100 else first_line
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_details_from_result(result: Any) -> str:
|
||||
"""
|
||||
从函数返回值中提取 details,顺序固定为:先 details 再中文。
|
||||
1) 先看返回的 dict 里是否有「details 类」字段(__details__、_details、details 或键名含 "details"),
|
||||
且其值为非空,则用该值;
|
||||
2) 若没有或为空,再走原本的中文兜底:从返回结构中找一段包含中文的内容作为 details。
|
||||
"""
|
||||
default = "完成"
|
||||
if not result:
|
||||
return default
|
||||
|
||||
# 第一步:优先用 details 类字段(仅当有值且非空时采用)
|
||||
if isinstance(result, dict):
|
||||
for key in ("__details__", "_details", "details"):
|
||||
if key in result and result[key] is not None:
|
||||
s = str(result[key]).strip()
|
||||
if s:
|
||||
return str(result[key])
|
||||
for k, v in result.items():
|
||||
if "details" in k.lower() and v is not None:
|
||||
s = str(v).strip()
|
||||
if s:
|
||||
return str(v)
|
||||
|
||||
# 第二步:保留原本的中文兜底
|
||||
def _contains_chinese(text: str) -> bool:
|
||||
if not text:
|
||||
return False
|
||||
return any("\u4e00" <= c <= "\u9fff" for c in str(text))
|
||||
|
||||
def _find_chinese(obj: Any, max_length: int = 200) -> Optional[str]:
|
||||
if isinstance(obj, str):
|
||||
return (obj[:max_length] + "...") if _contains_chinese(obj) and len(obj) > max_length else (
|
||||
obj if _contains_chinese(obj) else None)
|
||||
if isinstance(obj, dict):
|
||||
for key, value in obj.items():
|
||||
if "details" in key.lower():
|
||||
continue
|
||||
found = _find_chinese(value, max_length)
|
||||
if found:
|
||||
return found
|
||||
if isinstance(obj, list):
|
||||
for item in obj:
|
||||
found = _find_chinese(item, max_length)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
chinese = _find_chinese(result)
|
||||
return chinese if chinese else default
|
||||
|
||||
|
||||
def track_function_calls(func: Callable) -> Callable:
|
||||
"""
|
||||
装饰器:追踪函数调用过程,支持同步和异步函数
|
||||
|
||||
Args:
|
||||
func: 被装饰的函数
|
||||
"""
|
||||
|
||||
@functools.wraps(func)
|
||||
def sync_wrapper(*args, **kwargs):
|
||||
# 获取函数签名信息
|
||||
sig = inspect.signature(func)
|
||||
bound_args = sig.bind(*args, **kwargs)
|
||||
bound_args.apply_defaults()
|
||||
|
||||
func_name = func.__name__
|
||||
module_name = func.__module__
|
||||
|
||||
# 尝试从函数参数中获取 state
|
||||
state = None
|
||||
if args and isinstance(args[0], dict):
|
||||
state = args[0]
|
||||
elif 'state' in bound_args.arguments:
|
||||
state = bound_args.arguments.get('state')
|
||||
|
||||
func_description = extract_function_description(func)
|
||||
|
||||
# 准备函数参数信息
|
||||
params = {k: v for k, v in bound_args.arguments.items()}
|
||||
|
||||
try:
|
||||
# 执行原函数
|
||||
result = func(*args, **kwargs)
|
||||
|
||||
# 构建 details:优先用返回值中的 details 类字段(__details__、_details、details 或键名含 details)
|
||||
details = _extract_details_from_result(result)
|
||||
|
||||
# 合并函数执行事件(只在执行完成后发送一次)
|
||||
execution_event = {
|
||||
"type": "function_execution",
|
||||
"title": func_description,
|
||||
"details": details,
|
||||
"result": result, # 添加函数返回结果
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# 调用所有注册的回调(从上下文变量获取)
|
||||
callbacks = _event_callbacks.get() or []
|
||||
for callback in callbacks:
|
||||
try:
|
||||
callback(execution_event)
|
||||
except Exception as e:
|
||||
print(f"回调执行出错: {e}")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
# 发送函数执行失败事件
|
||||
error_event = {
|
||||
"type": "function_error",
|
||||
"title": func_description,
|
||||
"details": f"错误: {str(e)}",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
callbacks = _event_callbacks.get() or []
|
||||
for callback in callbacks:
|
||||
try:
|
||||
callback(error_event)
|
||||
except Exception as ex:
|
||||
print(f"回调执行出错: {ex}")
|
||||
|
||||
raise
|
||||
|
||||
@functools.wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
# 获取函数签名信息
|
||||
sig = inspect.signature(func)
|
||||
bound_args = sig.bind(*args, **kwargs)
|
||||
bound_args.apply_defaults()
|
||||
|
||||
func_name = func.__name__
|
||||
module_name = func.__module__
|
||||
|
||||
# 尝试从函数参数中获取 state
|
||||
state = None
|
||||
if args and isinstance(args[0], dict):
|
||||
state = args[0]
|
||||
elif 'state' in bound_args.arguments:
|
||||
state = bound_args.arguments.get('state')
|
||||
|
||||
func_description = extract_function_description(func)
|
||||
|
||||
# 准备函数参数信息
|
||||
params = {k: v for k, v in bound_args.arguments.items()}
|
||||
|
||||
try:
|
||||
# 执行原函数
|
||||
result = await func(*args, **kwargs)
|
||||
|
||||
# 构建 details:优先用返回值中的 details 类字段(__details__、_details、details 或键名含 details)
|
||||
details = _extract_details_from_result(result)
|
||||
|
||||
# 合并函数执行事件(只在执行完成后发送一次)
|
||||
execution_event = {
|
||||
"type": "function_execution",
|
||||
"title": func_description,
|
||||
"details": details,
|
||||
"result": result, # 添加函数返回结果
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# 调用所有注册的回调(从上下文变量获取)
|
||||
callbacks = _event_callbacks.get() or []
|
||||
for callback in callbacks:
|
||||
try:
|
||||
callback(execution_event)
|
||||
except Exception as e:
|
||||
print(f"回调执行出错: {e}")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
# 发送函数执行失败事件
|
||||
error_event = {
|
||||
"type": "function_error",
|
||||
"title": func_description,
|
||||
"details": f"错误: {str(e)}",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
callbacks = _event_callbacks.get() or []
|
||||
for callback in callbacks:
|
||||
try:
|
||||
callback(error_event)
|
||||
except Exception as ex:
|
||||
print(f"回调执行出错: {ex}")
|
||||
|
||||
raise
|
||||
|
||||
# 根据函数是否为协程函数返回对应的装饰器
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
return async_wrapper
|
||||
else:
|
||||
return sync_wrapper
|
||||
|
||||
|
||||
# 事件处理器
|
||||
def console_event_handler(event: Dict[str, Any]):
|
||||
"""
|
||||
控制台事件处理器
|
||||
"""
|
||||
event_type = event["type"]
|
||||
title = event.get("title", "")
|
||||
details = event.get("details", "")
|
||||
|
||||
if event_type == "function_execution":
|
||||
print(f"✅ 函数执行: {title}")
|
||||
if details:
|
||||
print(f" 详情: {details}")
|
||||
|
||||
elif event_type == "function_error":
|
||||
print(f"❌ 函数错误: {title}")
|
||||
if details:
|
||||
print(f" 详情: {details}")
|
||||
|
||||
|
||||
class StreamEventHandler:
|
||||
"""
|
||||
流式事件处理器,将装饰器事件放入队列供流式输出使用
|
||||
"""
|
||||
def __init__(self, queue: asyncio.Queue):
|
||||
self.queue = queue
|
||||
|
||||
def __call__(self, event: Dict[str, Any]):
|
||||
"""
|
||||
处理事件,将事件放入队列
|
||||
注意:这是同步方法,但队列是异步的,需要特殊处理
|
||||
"""
|
||||
try:
|
||||
# 使用线程安全的方式将事件放入队列
|
||||
# 如果队列已满,使用 put_nowait 并忽略错误
|
||||
try:
|
||||
self.queue.put_nowait(event)
|
||||
except asyncio.QueueFull:
|
||||
# 队列已满,跳过此事件(不应该发生,因为队列大小足够)
|
||||
pass
|
||||
except Exception as e:
|
||||
# 忽略错误,避免影响主流程
|
||||
pass
|
||||
|
||||
def send_stream_content(self, content: str, title: str = ""):
|
||||
"""
|
||||
发送流式内容事件
|
||||
|
||||
Args:
|
||||
content: 流式内容片段
|
||||
title: 函数描述/标题(可选)
|
||||
"""
|
||||
try:
|
||||
event = {
|
||||
"type": "stream_content",
|
||||
"content": content,
|
||||
"title": title,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
self.queue.put_nowait(event)
|
||||
except Exception as e:
|
||||
# 忽略错误,避免影响主流程
|
||||
pass
|
||||
|
||||
|
||||
def create_stream_event_handler() -> tuple[StreamEventHandler, asyncio.Queue]:
|
||||
"""
|
||||
创建流式事件处理器和对应的队列(使用 contextvars 实现请求隔离)
|
||||
|
||||
Returns:
|
||||
(handler, queue): 事件处理器和事件队列
|
||||
"""
|
||||
queue = asyncio.Queue(maxsize=1000) # 设置足够大的队列大小
|
||||
handler = StreamEventHandler(queue)
|
||||
_current_stream_handler.set(handler) # 保存到上下文变量
|
||||
return handler, queue
|
||||
|
||||
|
||||
def get_current_stream_handler() -> Optional[StreamEventHandler]:
|
||||
"""
|
||||
获取当前活动的流式事件处理器(使用 contextvars 实现请求隔离)
|
||||
|
||||
Returns:
|
||||
当前流式事件处理器,如果没有则返回 None
|
||||
"""
|
||||
return _current_stream_handler.get()
|
||||
|
||||
|
||||
def clear_current_stream_handler():
|
||||
"""
|
||||
清除当前活动的流式事件处理器(使用 contextvars 实现请求隔离)
|
||||
"""
|
||||
_current_stream_handler.set(None)
|
||||
201
utils/image_utils.py
Normal file
@ -0,0 +1,201 @@
|
||||
"""
|
||||
图片处理工具模块 - 最终版 v4
|
||||
核心思路(最简单直接):
|
||||
1. 找到所有 .jpg/.png 文件名及其周围的 
|
||||
"""
|
||||
import re
|
||||
from typing import List
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
|
||||
def extract_image_paths(text: str) -> List[str]:
|
||||
"""从文本中提取图片路径"""
|
||||
pattern = r'images/[a-zA-Z0-9_\-./]+\.(?:jpg|jpeg|png|gif|bmp|webp)'
|
||||
matches = re.findall(pattern, text, re.IGNORECASE)
|
||||
return list(set(matches))
|
||||
|
||||
|
||||
def find_closest_image_path(generated_path: str, original_paths: List[str]) -> str:
|
||||
"""
|
||||
找到与生成路径最接近的原始图片路径
|
||||
|
||||
即使模型输出的是错误/近似/截断的文件名,也能匹配到正确的原始路径
|
||||
"""
|
||||
if not original_paths:
|
||||
return generated_path
|
||||
|
||||
generated_filename = generated_path.split('/')[-1].lower()
|
||||
best_match = None
|
||||
best_score = 0
|
||||
|
||||
for orig_path in original_paths:
|
||||
orig_filename = orig_path.split('/')[-1].lower()
|
||||
score = SequenceMatcher(None, generated_filename, orig_filename).ratio()
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_match = orig_path
|
||||
|
||||
if best_match and best_score > 0.5:
|
||||
return best_match
|
||||
return generated_path
|
||||
|
||||
|
||||
def normalize_markdown_images(text: str, base_prefix: str = "/api/v1/knowledge/files/", original_paths: List[str] = None) -> str:
|
||||
"""
|
||||
规范化 Markdown 图片链接 - 两轮处理版
|
||||
|
||||
算法:
|
||||
第一轮:检查和修改格式,确保是  格式
|
||||
第二轮:只检查和替换最后文件名部分(xxx.jpg),与原始路径匹配,匹配度低的删除
|
||||
"""
|
||||
if not text or original_paths is None:
|
||||
return text
|
||||
|
||||
# ========== 第一轮:检查和修改格式(包括固定URL前缀) ==========
|
||||
result = _normalize_image_format(text, base_prefix)
|
||||
|
||||
# ========== 第二轮:只检查和替换最后文件名部分 ==========
|
||||
result = _normalize_image_paths(result, base_prefix, original_paths)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _normalize_image_format(text: str, base_prefix: str) -> str:
|
||||
"""
|
||||
第一轮:规范化图片格式,确保所有图片都是正确的  格式
|
||||
|
||||
处理各种格式破损的情况,并确保有正确的URL前缀
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
IMG_EXTENSIONS = r'(?:jpg|jpeg|png|gif|bmp|webp)'
|
||||
img_pattern = rf'([a-zA-Z0-9_\-./]+)\.({IMG_EXTENSIONS})'
|
||||
|
||||
result = text
|
||||
matches = list(re.finditer(img_pattern, result, re.IGNORECASE))
|
||||
|
||||
if not matches:
|
||||
return result
|
||||
|
||||
# 从后往前处理,避免位置偏移
|
||||
for match in reversed(matches):
|
||||
filename_with_ext = match.group(0)
|
||||
file_start = match.start()
|
||||
file_end = match.end()
|
||||
|
||||
mark_start = file_start
|
||||
mark_end = file_end
|
||||
|
||||
# 向前查找是否已经有 
|
||||
before = result[lookback_limit:file_start]
|
||||
open_marker_pos = before.rfind('
|
||||
|
||||
if open_marker_pos != -1:
|
||||
actual_open_pos = lookback_limit + open_marker_pos
|
||||
check_segment = result[actual_open_pos:file_start]
|
||||
if check_segment.startswith(':
|
||||
mark_start = actual_open_pos
|
||||
|
||||
# 向后查找是否有 )
|
||||
after = result[file_end:]
|
||||
close_paren_pos = after.find(')')
|
||||
if close_paren_pos != -1 and close_paren_pos < 20:
|
||||
middle = after[:close_paren_pos]
|
||||
if '\n' not in middle and '
|
||||
pure_filename = filename_with_ext.split('/')[-1]
|
||||
|
||||
# 构建完整的标准格式(包括固定URL前缀)
|
||||
full_url = base_prefix.rstrip('/') + '/images/' + pure_filename
|
||||
standard_format = f''
|
||||
|
||||
result = result[:mark_start] + standard_format + result[mark_end:]
|
||||
|
||||
# 清理嵌套格式
|
||||
nested_pattern = r'!\[图片\]\([^)]*!\[图片\]\(([^)]+\.(?:jpg|jpeg|png|gif|bmp|webp))\)[^)]*\)'
|
||||
while re.search(nested_pattern, result, re.IGNORECASE):
|
||||
result = re.sub(nested_pattern, r'', result, flags=re.IGNORECASE)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _normalize_image_paths(text: str, base_prefix: str, original_paths: List[str]) -> str:
|
||||
"""
|
||||
第二轮:只规范化最后文件名部分(xxx.jpg),与原始路径匹配,匹配度低的删除
|
||||
|
||||
步骤:
|
||||
1. 找到所有  格式的图片
|
||||
2. 对每个图片:
|
||||
a. 提取最后文件名部分(xxx.jpg)
|
||||
b. 只与 original_paths 中的最后文件名部分匹配
|
||||
c. 如果匹配度高(>0.5),替换为正确路径
|
||||
d. 如果匹配度低(<=0.5),删除整个图片
|
||||
"""
|
||||
if not text or not original_paths:
|
||||
return text
|
||||
|
||||
# 匹配所有  格式
|
||||
img_tag_pattern = r'!\[图片\]\(([^)]+)\)'
|
||||
matches = list(re.finditer(img_tag_pattern, text, re.IGNORECASE))
|
||||
|
||||
if not matches:
|
||||
return text
|
||||
|
||||
result = text
|
||||
|
||||
# 从后往前处理,避免位置偏移
|
||||
for match in reversed(matches):
|
||||
full_tag = match.group(0)
|
||||
url = match.group(1)
|
||||
tag_start = match.start()
|
||||
tag_end = match.end()
|
||||
|
||||
# 从 URL 中只提取最后的文件名部分(xxx.jpg)
|
||||
filename = url.split('/')[-1]
|
||||
|
||||
# 尝试匹配 - 只匹配最后文件名部分
|
||||
best_match = None
|
||||
best_score = 0
|
||||
|
||||
generated_filename = filename.lower()
|
||||
|
||||
for orig_path in original_paths:
|
||||
orig_filename = orig_path.split('/')[-1].lower()
|
||||
score = SequenceMatcher(None, generated_filename, orig_filename).ratio()
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_match = orig_path
|
||||
|
||||
if best_match and best_score > 0.5:
|
||||
# 匹配成功,替换为正确路径(保持固定URL前缀)
|
||||
# 只替换最后文件名部分,保持前缀不变
|
||||
pure_orig_filename = best_match.split('/')[-1]
|
||||
full_url = base_prefix.rstrip('/') + '/images/' + pure_orig_filename
|
||||
|
||||
standard_format = f''
|
||||
result = result[:tag_start] + standard_format + result[tag_end:]
|
||||
else:
|
||||
# 匹配度低,删除整个图片
|
||||
result = result[:tag_start] + result[tag_end:]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_image_prompt_guidance() -> str:
|
||||
"""获取图片格式处理的提示词指导"""
|
||||
return """【图片格式特别要求】
|
||||
- 检索结果中的图片链接格式通常为:``
|
||||
- 输出时必须严格保留此格式,不得修改、省略或截断
|
||||
- 图片的 alt 文本必须保持为「图片」
|
||||
- 图片 URL 必须完整包含 `/api/v1/knowledge/files/images` 前缀
|
||||
- 不要将图片链接转换为纯文本描述
|
||||
- 如果有多个图片,在对应位置分别引用,保持原始顺序
|
||||
- 严禁修改图片文件名或路径结构
|
||||
- 如果需要展示图片,直接使用原始的 `` 格式即可"""
|
||||
|
||||
73
utils/intent_matcher.py
Normal file
@ -0,0 +1,73 @@
|
||||
"""
|
||||
意图匹配工具:基于关键词 + embedding 语义相似度的两层确认意图判断
|
||||
|
||||
策略:
|
||||
第一层:关键词子串匹配(零延迟,覆盖 90%+ 常见表达)
|
||||
第二层:embedding 语义相似度匹配(兜底,捕获罕见/创意表达)
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 确认意图短语表(信息充足 / 开始行动)
|
||||
# ──────────────────────────────────────────────
|
||||
CONFIRM_PHRASES: List[str] = [
|
||||
# 直接确认
|
||||
"确认", "是的", "对", "好的", "没问题", "行", "可以", "OK",
|
||||
# 信息充足
|
||||
"足够", "够了", "齐了", "信息完整", "不用补充", "不需要补充",
|
||||
"就这些", "没有了", "信息够了", "不用再加了", "信息齐了",
|
||||
# 开始行动
|
||||
"开始诊断", "开始操作", "开始吧", "继续", "开始",
|
||||
# 复合确认
|
||||
"确认并继续", "正确", "没错", "对的", "是的开始", "就这样",
|
||||
]
|
||||
|
||||
# 预计算的短语 embedding 缓存
|
||||
_phrase_embeddings: Optional[List[List[float]]] = None
|
||||
|
||||
|
||||
async def _ensure_phrase_embeddings() -> None:
|
||||
"""确保确认短语的 embedding 已预计算(懒加载,仅首次调用时执行)"""
|
||||
global _phrase_embeddings
|
||||
if _phrase_embeddings is not None:
|
||||
return
|
||||
from modelsAPI.model_api import OpenaiAPI
|
||||
_phrase_embeddings = await OpenaiAPI.get_embeddings_batch_async(CONFIRM_PHRASES)
|
||||
print(f"[intent_matcher] 确认短语 embedding 预计算完成,共 {len(CONFIRM_PHRASES)} 条")
|
||||
|
||||
|
||||
async def is_confirmation_intent(user_query: str, threshold: float = 0.78) -> bool:
|
||||
"""
|
||||
判断用户输入是否为确认意图
|
||||
|
||||
Args:
|
||||
user_query: 用户输入文本
|
||||
threshold: embedding 语义相似度阈值(默认 0.78)
|
||||
|
||||
Returns:
|
||||
True 表示用户表达了确认/信息充足的意图
|
||||
"""
|
||||
if not user_query or not user_query.strip():
|
||||
return True
|
||||
|
||||
# 第一层:关键词子串匹配(零延迟)
|
||||
if any(kw in user_query for kw in CONFIRM_PHRASES):
|
||||
return True
|
||||
|
||||
# 第二层:embedding 语义相似度匹配(兜底)
|
||||
await _ensure_phrase_embeddings()
|
||||
from modelsAPI.model_api import OpenaiAPI
|
||||
|
||||
query_embedding = await OpenaiAPI.get_embeddings_async(user_query)
|
||||
similarities = OpenaiAPI.cosine_similarity_batch(query_embedding, _phrase_embeddings)
|
||||
max_sim = max(similarities) if similarities else 0.0
|
||||
|
||||
if max_sim >= threshold:
|
||||
matched_phrase = CONFIRM_PHRASES[similarities.index(max_sim)]
|
||||
print(f"[intent_matcher] embedding 兜底命中: sim={max_sim:.3f}, "
|
||||
f"matched='{matched_phrase}', query='{user_query}'")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
280
utils/pdf_generator.py
Normal file
@ -0,0 +1,280 @@
|
||||
import os
|
||||
import base64
|
||||
import subprocess
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class PDFGenerator:
|
||||
"""PDF 生成器类(Pandoc 版)"""
|
||||
|
||||
def __init__(self, main_font: str = "Noto Serif CJK SC"):
|
||||
"""
|
||||
初始化 PDF 生成器
|
||||
|
||||
Args:
|
||||
main_font: Pandoc / XeLaTeX 使用的中文字体名
|
||||
"""
|
||||
self.main_font = main_font
|
||||
|
||||
def generate_pdf_from_text(
|
||||
self,
|
||||
text_content: str,
|
||||
output_path: str = None,
|
||||
title: str = "维修报告",
|
||||
return_base64: bool = True
|
||||
) -> dict:
|
||||
"""
|
||||
将 Markdown 文本内容生成 PDF 文件
|
||||
|
||||
Args:
|
||||
text_content: Markdown 文本内容
|
||||
output_path: PDF 输出路径(可选)
|
||||
title: PDF 标题
|
||||
return_base64: 是否返回 base64 编码内容
|
||||
"""
|
||||
try:
|
||||
# 输出路径
|
||||
if output_path is None:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
output_path = f"temp_report_{timestamp}.pdf"
|
||||
|
||||
# 生成临时 Markdown 文件
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
suffix=".md",
|
||||
delete=False,
|
||||
encoding="utf-8"
|
||||
) as md_file:
|
||||
md_path = md_file.name
|
||||
|
||||
# 写入标题 + 内容
|
||||
if title:
|
||||
md_file.write(f"# {title}\n\n")
|
||||
md_file.write(text_content.strip())
|
||||
|
||||
# Pandoc 命令:使用自定义 LaTeX 模板解决排版问题
|
||||
cmd = [
|
||||
"pandoc",
|
||||
md_path,
|
||||
"--pdf-engine=xelatex",
|
||||
"-V", f"mainfont={self.main_font}",
|
||||
"-V", "geometry:margin=2cm", # 增加页边距
|
||||
"-V", "fontsize=10pt", # 字体大小
|
||||
"-V", "lineskip=1.5", # 行距
|
||||
"--template=template.tex", # 使用自定义模板
|
||||
"-o", output_path
|
||||
]
|
||||
|
||||
# 执行转换
|
||||
subprocess.run(
|
||||
cmd,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
"file_path": output_path
|
||||
}
|
||||
|
||||
# 返回 base64
|
||||
if return_base64:
|
||||
with open(output_path, "rb") as f:
|
||||
result["base64_content"] = base64.b64encode(
|
||||
f.read()
|
||||
).decode("utf-8")
|
||||
|
||||
return result
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": e.stderr.decode("utf-8")
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
finally:
|
||||
# 清理临时 md 文件
|
||||
if "md_path" in locals() and os.path.exists(md_path):
|
||||
os.remove(md_path)
|
||||
|
||||
|
||||
# 创建自定义 LaTeX 模板文件 template.tex
|
||||
def create_latex_template():
|
||||
"""创建一个用于中文排版的 LaTeX 模板(Pandoc 正确版)"""
|
||||
template = r"""
|
||||
\documentclass[10pt]{article}
|
||||
\usepackage[UTF8]{ctex}
|
||||
\usepackage{geometry}
|
||||
\usepackage{graphicx}
|
||||
\usepackage{amsmath}
|
||||
\usepackage{array}
|
||||
\usepackage{booktabs}
|
||||
\usepackage{longtable}
|
||||
\usepackage{setspace}
|
||||
\usepackage{fancyhdr}
|
||||
\usepackage{titlesec}
|
||||
\usepackage{tabularx}
|
||||
\usepackage{makecell}
|
||||
\usepackage{adjustbox}
|
||||
\usepackage{hyperref}
|
||||
% 页面设置
|
||||
\geometry{margin=2cm}
|
||||
\setlength{\parskip}{1em}
|
||||
\setlength{\parindent}{0em}
|
||||
|
||||
% 字体
|
||||
\setCJKmainfont{Noto Serif CJK SC}
|
||||
|
||||
% 标题样式
|
||||
\title{\Large \textbf{$title$}}
|
||||
\author{}
|
||||
\date{}
|
||||
|
||||
% 章节标题样式
|
||||
\titleformat{\section}{\large\bfseries\raggedright}{\thesection}{1em}{}
|
||||
\titleformat{\subsection}{\normalsize\bfseries\raggedright}{\thesubsection}{1em}{}
|
||||
|
||||
|
||||
% ===== Pandoc compatibility =====
|
||||
\usepackage{hyperref}
|
||||
\providecommand{\tightlist}{%
|
||||
\setlength{\itemsep}{0pt}\setlength{\parskip}{0pt}}
|
||||
|
||||
|
||||
\begin{document}
|
||||
|
||||
\maketitle
|
||||
|
||||
$body$
|
||||
|
||||
\end{document}
|
||||
"""
|
||||
with open("template.tex", "w", encoding="utf-8") as f:
|
||||
f.write(template)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# 生成报告函数不变,但调用前先创建模板
|
||||
def generate_report_pdf(
|
||||
report_content: str,
|
||||
device_name: str = "未知设备",
|
||||
output_dir: str = None
|
||||
) -> dict:
|
||||
"""
|
||||
生成维修报告 PDF
|
||||
"""
|
||||
# 先创建模板文件
|
||||
create_latex_template()
|
||||
|
||||
generator = PDFGenerator()
|
||||
|
||||
# 文件名规则不变
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
safe_device_name = device_name.replace(" ", "_").replace("/", "_")
|
||||
file_name = f"{safe_device_name}_维修报告_{timestamp}.pdf"
|
||||
|
||||
if output_dir:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
output_path = os.path.join(output_dir, file_name)
|
||||
else:
|
||||
output_path = file_name
|
||||
|
||||
result = generator.generate_pdf_from_text(
|
||||
text_content=report_content,
|
||||
output_path=output_path,
|
||||
title="设备维修报告",
|
||||
return_base64=True
|
||||
)
|
||||
|
||||
if result.get("success"):
|
||||
result["file_name"] = file_name
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# 测试代码
|
||||
if __name__ == "__main__":
|
||||
test_md = """
|
||||
三、根本原因判断
|
||||
|
||||
根据设备当前状态、维修方案参考和知识库检索结果,最可能的根本原因如下:
|
||||
|
||||
**根本原因**:缸盖密封环老化或失效
|
||||
**次级原因**:启动阀卡滞或内部泄漏
|
||||
|
||||
---
|
||||
|
||||
四、原因分析说明
|
||||
|
||||
1. **缸盖密封环老化或失效**
|
||||
- 缸盖密封环是气缸密封的关键部件,其老化或损坏会导致气缸压缩气体泄漏,压缩压力下降。
|
||||
- 启动时,压缩压力不足会导致点火困难,主机启动延迟或失败。
|
||||
- 此类问题在长期运行或未定期保养的设备中较为常见。
|
||||
|
||||
2. **启动阀卡滞或内部泄漏**
|
||||
- 启动阀的功能是控制启动气进入气缸,若其卡滞或内部泄漏,将导致启动气供应不足或不及时,影响启动成功率。
|
||||
- 内部泄漏还会造成启动气浪费,进一步降低启动成功率。
|
||||
- 启动阀若未定期维护,容易因灰尘、杂质或润滑不良而卡滞。
|
||||
|
||||
3. **气缸套磨损**
|
||||
- 气缸套磨损会导致气缸密封性下降,压缩气体泄漏,压缩压力不足。
|
||||
- 该问题通常发生在长期运行、润滑不良或未及时更换气缸套的设备中。
|
||||
|
||||
4. **活塞环磨损或断裂**
|
||||
- 活塞环磨损或断裂会导致气缸密封性下降,压缩气体泄漏,从而影响压缩压力。
|
||||
- 该问题通常与长期运行、润滑不良或材料疲劳有关。
|
||||
|
||||
5. **调速系统响应异常**
|
||||
- 调速系统异常可能导致主机启动时转速控制不稳,但其对压缩压力的影响相对较小,通常不是导致压缩压力不足的主要原因。
|
||||
|
||||
---
|
||||
|
||||
五、预防建议
|
||||
|
||||
1. 定期更换缸盖密封环
|
||||
- 根据设备运行时间和使用情况,制定合理的密封环更换计划,避免因老化导致密封性能下降。
|
||||
- 建议每运行 5000 小时进行一次检查,必要时更换。
|
||||
|
||||
2. 加强启动阀维护
|
||||
- 定期清理启动阀内部积碳和杂质,保持润滑良好。
|
||||
- 每 1000 小时进行一次功能测试。
|
||||
|
||||
3. 监控气缸套和活塞环状态
|
||||
- 通过内窥镜或压力测试定期检测气缸套与活塞环磨损情况。
|
||||
- 发现异常及时更换。
|
||||
|
||||
4. 优化润滑系统
|
||||
- 使用符合标准的润滑油,确保润滑充分。
|
||||
- 定期更换滤芯,防止杂质进入关键部件。
|
||||
|
||||
5. 定期校准调速系统
|
||||
- 每季度进行一次调速系统校准,确保响应灵敏。
|
||||
- 记录历史数据,建立趋势分析模型。
|
||||
"""
|
||||
|
||||
result = generate_report_pdf(
|
||||
test_md,
|
||||
device_name="压缩机设备"
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
print(f"✓ PDF 生成成功:{result['file_name']}")
|
||||
print(f"✓ 文件路径:{result['file_path']}")
|
||||
print(f"✓ Base64 长度:{len(result['base64_content'])}")
|
||||
else:
|
||||
print(f"✗ PDF 生成失败:{result['error']}")
|
||||
463
utils/ship_number_search.py
Normal file
@ -0,0 +1,463 @@
|
||||
"""
|
||||
舷号搜索策略模块
|
||||
提供三级搜索策略:
|
||||
1. 指定舷号搜索
|
||||
2. 同型号所有舷号搜索
|
||||
3. 全局搜索
|
||||
|
||||
新增功能:
|
||||
- 智能舷号映射:支持舷号、舰名的智能识别和映射
|
||||
"""
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
import random
|
||||
import re
|
||||
import httpx
|
||||
from utils.function_tracker import get_all_callbacks
|
||||
from tools.ship_model_db import load_ship_model_mapping
|
||||
from tools.rag_tools import rag_search
|
||||
from modelsAPI.model_api import OpenaiAPI
|
||||
from config import KB_TREE_CONFIG
|
||||
|
||||
|
||||
_kb_name_to_id_cache: Optional[Dict[str, str]] = None
|
||||
|
||||
|
||||
async def _load_kb_tree() -> Dict[str, str]:
|
||||
"""
|
||||
从知识库树 API 加载所有知识库,返回 {名称: kb_id} 的映射
|
||||
递归遍历树结构,带缓存,只请求一次
|
||||
"""
|
||||
global _kb_name_to_id_cache
|
||||
if _kb_name_to_id_cache is not None:
|
||||
return _kb_name_to_id_cache
|
||||
|
||||
url = KB_TREE_CONFIG.get("url", "")
|
||||
if not url:
|
||||
print("[ship_number_search] KB_TREE_CONFIG.url 未配置")
|
||||
return {}
|
||||
|
||||
headers = {
|
||||
"accept": "application/json",
|
||||
"x-user-id": "1",
|
||||
"x-user-name": "testuser",
|
||||
"x-role": "admin",
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0, verify=False) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
if response.status_code != 200:
|
||||
print(f"[ship_number_search] 知识库树 API 请求失败: HTTP {response.status_code}")
|
||||
return {}
|
||||
|
||||
result = response.json()
|
||||
|
||||
if isinstance(result, list):
|
||||
top_nodes = result
|
||||
elif isinstance(result, dict):
|
||||
kb_data = result.get("data") or result.get("list") or {}
|
||||
top_nodes = kb_data.get("children", []) if isinstance(kb_data, dict) else []
|
||||
else:
|
||||
top_nodes = []
|
||||
|
||||
name_to_id = {}
|
||||
|
||||
def _collect(nodes):
|
||||
for node in nodes:
|
||||
kb_name = node.get("name") or ""
|
||||
kb_id = str(node.get("id", "")) if node.get("id") is not None else ""
|
||||
if kb_name and kb_id:
|
||||
name_to_id[kb_name] = kb_id
|
||||
children = node.get("children", [])
|
||||
if children:
|
||||
_collect(children)
|
||||
|
||||
_collect(top_nodes)
|
||||
|
||||
_kb_name_to_id_cache = name_to_id
|
||||
return name_to_id
|
||||
except Exception as e:
|
||||
print(f"[ship_number_search] 加载知识库树失败: {str(e)}")
|
||||
return {}
|
||||
|
||||
|
||||
async def resolve_kb_id(ship_number: str) -> Optional[str]:
|
||||
"""
|
||||
根据舷号从知识库树中查找对应的真实 kb_id
|
||||
|
||||
匹配策略:在知识库名称中查找包含该舷号的条目
|
||||
例如 ship_number="163",知识库名称为 "163舰资料",则匹配成功
|
||||
|
||||
Args:
|
||||
ship_number: 舷号(如 "163")
|
||||
|
||||
Returns:
|
||||
匹配到的 kb_id,未找到返回 None
|
||||
"""
|
||||
if not ship_number:
|
||||
return None
|
||||
|
||||
name_to_id = await _load_kb_tree()
|
||||
|
||||
if ship_number in name_to_id:
|
||||
return name_to_id[ship_number]
|
||||
|
||||
for kb_name, kb_id in name_to_id.items():
|
||||
if re.search(rf'(?<!\d){re.escape(ship_number)}(?!\d)', kb_name):
|
||||
return kb_id
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def get_ship_numbers_by_model(ship_number: str) -> list:
|
||||
"""
|
||||
根据舷号获取同型号的所有舷号
|
||||
|
||||
Args:
|
||||
ship_number: 舷号
|
||||
|
||||
Returns:
|
||||
list: 同型号的所有舷号列表,如果未找到则返回只包含输入舷号的列表
|
||||
"""
|
||||
ship_number = str(ship_number).strip()
|
||||
ships = await load_ship_model_mapping()
|
||||
target_model = None
|
||||
for ship in ships:
|
||||
if ship["hull_number"] == ship_number:
|
||||
target_model = ship["model_name"]
|
||||
break
|
||||
if target_model:
|
||||
return [s["hull_number"] for s in ships if s["model_name"] == target_model]
|
||||
return [ship_number]
|
||||
|
||||
|
||||
async def _build_ship_mapping_candidates() -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
构建舷号映射候选词库
|
||||
从每艘舰的记录中构建候选词,支持舷号和舰名匹配
|
||||
"""
|
||||
candidates = {}
|
||||
|
||||
ships = await load_ship_model_mapping()
|
||||
|
||||
model_numbers: Dict[str, List[str]] = {}
|
||||
for ship in ships:
|
||||
model = ship["model_name"]
|
||||
if model not in model_numbers:
|
||||
model_numbers[model] = []
|
||||
model_numbers[model].append(ship["hull_number"])
|
||||
|
||||
for ship in ships:
|
||||
model = ship["model_name"]
|
||||
hull_number = ship["hull_number"]
|
||||
ship_name = ship.get("ship_name", "")
|
||||
numbers = model_numbers.get(model, [])
|
||||
|
||||
mapping_info = {
|
||||
"model": model,
|
||||
"numbers": numbers.copy()
|
||||
}
|
||||
|
||||
candidates[hull_number] = {
|
||||
"type": "number",
|
||||
"ship_number": hull_number,
|
||||
**mapping_info
|
||||
}
|
||||
|
||||
if ship_name:
|
||||
candidates[ship_name] = {
|
||||
"type": "name",
|
||||
"ship_number": hull_number,
|
||||
**mapping_info
|
||||
}
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
_SHIP_MAPPING_CANDIDATES = None
|
||||
|
||||
async def get_ship_mapping_candidates() -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
获取舷号映射候选词库(带缓存)
|
||||
"""
|
||||
global _SHIP_MAPPING_CANDIDATES
|
||||
if _SHIP_MAPPING_CANDIDATES is None:
|
||||
_SHIP_MAPPING_CANDIDATES = await _build_ship_mapping_candidates()
|
||||
return _SHIP_MAPPING_CANDIDATES
|
||||
|
||||
async def smart_ship_number_mapping(
|
||||
user_input: str,
|
||||
similarity_threshold: float = 0.8
|
||||
) -> Tuple[Optional[str], Optional[str], Optional[List[str]], str]:
|
||||
"""
|
||||
智能舷号映射函数
|
||||
将用户输入的舷号/舰名映射到准确的舷号或型号
|
||||
|
||||
Returns:
|
||||
Tuple: (matched_ship_number, matched_model, all_numbers, match_type)
|
||||
- matched_ship_number: 匹配到的具体舷号(舰名也会映射到对应舷号)
|
||||
- matched_model: 匹配到的型号名称
|
||||
- all_numbers: 该型号下的所有舷号列表
|
||||
- match_type: 匹配类型 ("exact_number", "exact_name", "embedding", "none")
|
||||
"""
|
||||
if not user_input or not user_input.strip():
|
||||
return None, None, None, "none"
|
||||
|
||||
user_input = user_input.strip()
|
||||
candidates = await get_ship_mapping_candidates()
|
||||
|
||||
if user_input in candidates:
|
||||
info = candidates[user_input]
|
||||
match_type = f"exact_{info['type']}"
|
||||
model = info["model"]
|
||||
numbers = info["numbers"]
|
||||
ship_number = info.get("ship_number")
|
||||
|
||||
return ship_number, model, numbers, match_type
|
||||
|
||||
try:
|
||||
candidate_texts = list(candidates.keys())
|
||||
query_embedding = await OpenaiAPI.get_embeddings_async(user_input)
|
||||
candidate_embeddings = await OpenaiAPI.get_embeddings_batch_async(candidate_texts)
|
||||
|
||||
best_score = -1
|
||||
best_idx = -1
|
||||
|
||||
for idx, emb in enumerate(candidate_embeddings):
|
||||
score = OpenaiAPI.cosine_similarity(query_embedding, emb)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_idx = idx
|
||||
|
||||
if best_score >= similarity_threshold and best_idx >= 0:
|
||||
matched_text = candidate_texts[best_idx]
|
||||
info = candidates[matched_text]
|
||||
model = info["model"]
|
||||
numbers = info["numbers"]
|
||||
ship_number = info.get("ship_number")
|
||||
|
||||
print(f"[智能映射] 用户输入 '{user_input}' -> 匹配到 '{matched_text}' (相似度: {best_score:.3f}, 类型: {info['type']})")
|
||||
|
||||
return ship_number, model, numbers, "embedding"
|
||||
except Exception as e:
|
||||
print(f"[智能映射] Embedding 匹配失败: {str(e)}")
|
||||
|
||||
return None, None, None, "none"
|
||||
|
||||
def send_search_status(title: str, details: str):
|
||||
"""发送搜索状态事件"""
|
||||
title_options = [f"🔍 {title}", f"🔎 {title}", f"📚 {title}"]
|
||||
start_event = {
|
||||
"type": "function_execution",
|
||||
"title": random.choice(title_options),
|
||||
"details": details
|
||||
}
|
||||
for callback in get_all_callbacks():
|
||||
try:
|
||||
callback(start_event)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def convert_rag_result(rag_result):
|
||||
"""将 rag_search 的返回结果转换为原来的格式"""
|
||||
if not rag_result or not rag_result.get("success", False):
|
||||
return []
|
||||
source_citation = rag_result.get("sourceCitation", {})
|
||||
flat_results = []
|
||||
for resource, items in source_citation.items():
|
||||
for item in items:
|
||||
flat_results.append(item)
|
||||
return flat_results
|
||||
|
||||
async def search_with_ship_number_strategy(
|
||||
base_query: str,
|
||||
ship_number: str,
|
||||
top_k: int = 5,
|
||||
search_type: str = "fault"
|
||||
) -> Tuple[List[Dict[str, Any]], str, str, str]:
|
||||
"""
|
||||
使用舷号三级搜索策略进行RAG检索
|
||||
返回: (rag_results, matched_kb_name, matched_kb_id, matched_ship_number)
|
||||
matched_ship_number: 映射后的舷号,未映射到具体舷号时为空字符串
|
||||
"""
|
||||
rag_results = []
|
||||
matched_kb_name = ""
|
||||
matched_kb_id = ""
|
||||
|
||||
matched_ship_number = None
|
||||
matched_model = None
|
||||
model_all_numbers = None
|
||||
match_type = "none"
|
||||
|
||||
if ship_number and ship_number.strip():
|
||||
send_search_status(
|
||||
"正在智能识别",
|
||||
f"识别 '{ship_number}' 的含义(舷号/舰名)..."
|
||||
)
|
||||
|
||||
matched_ship_number, matched_model, model_all_numbers, match_type = await smart_ship_number_mapping(ship_number)
|
||||
|
||||
if match_type != "none":
|
||||
print(f"[智能映射结果] 输入='{ship_number}' -> 舷号={matched_ship_number}, 型号={matched_model}, 所有舷号={model_all_numbers}, 匹配类型={match_type}")
|
||||
else:
|
||||
print(f"[智能映射结果] 输入='{ship_number}' -> 未找到匹配,将使用原始值进行检索")
|
||||
|
||||
if match_type in ["exact_number", "exact_name", "embedding"] and matched_ship_number:
|
||||
real_kb_id = await resolve_kb_id(matched_ship_number)
|
||||
if real_kb_id:
|
||||
send_search_status(
|
||||
"正在指定舷号检索",
|
||||
f"使用舷号 {matched_ship_number} 进行检索..."
|
||||
)
|
||||
|
||||
rag_query = f"{base_query}"
|
||||
|
||||
try:
|
||||
rag_result_raw = await rag_search(rag_query, top_k=top_k, kb_id=real_kb_id)
|
||||
rag_results = convert_rag_result(rag_result_raw)
|
||||
|
||||
if rag_results:
|
||||
print(f"步骤1 - 舷号 {matched_ship_number} 检索完成,返回 {len(rag_results)} 条结果")
|
||||
for item in rag_results:
|
||||
if isinstance(item, dict):
|
||||
found_kb_name = item.get("kb_name", "")
|
||||
found_kb_id = item.get("kb_id", "")
|
||||
if found_kb_name and not matched_kb_name:
|
||||
matched_kb_name = str(found_kb_name).strip()
|
||||
if found_kb_id and not matched_kb_id:
|
||||
matched_kb_id = str(found_kb_id).strip()
|
||||
return rag_results, matched_kb_name, matched_kb_id, matched_ship_number or ""
|
||||
except Exception as e:
|
||||
print(f"步骤1 - 舷号 {matched_ship_number} 检索失败: {str(e)}")
|
||||
else:
|
||||
print(f"步骤1 - 舷号 {matched_ship_number} 无对应知识库,跳过指定舷号检索")
|
||||
|
||||
if match_type in ["exact_number", "exact_name", "embedding"] and model_all_numbers:
|
||||
send_search_status(
|
||||
"正在型号检索",
|
||||
f"识别为型号 '{matched_model}',使用该型号所有舷号 {', '.join(model_all_numbers)} 进行检索..."
|
||||
)
|
||||
|
||||
for num in model_all_numbers:
|
||||
rag_query = f"{base_query}"
|
||||
|
||||
try:
|
||||
real_kb_id = await resolve_kb_id(num)
|
||||
if not real_kb_id:
|
||||
continue
|
||||
rag_result_raw = await rag_search(rag_query, top_k=top_k, kb_id=real_kb_id)
|
||||
current_results = convert_rag_result(rag_result_raw)
|
||||
|
||||
if current_results:
|
||||
rag_results.extend(current_results)
|
||||
print(f"步骤2 - 舷号 {num} 检索完成,返回 {len(current_results)} 条结果")
|
||||
|
||||
if not matched_kb_name:
|
||||
for item in current_results:
|
||||
if isinstance(item, dict):
|
||||
found_kb_name = item.get("kb_name", "")
|
||||
found_kb_id = item.get("kb_id", "")
|
||||
if found_kb_name and not matched_kb_name:
|
||||
matched_kb_name = str(found_kb_name).strip()
|
||||
if found_kb_id and not matched_kb_id:
|
||||
matched_kb_id = str(found_kb_id).strip()
|
||||
except Exception as e:
|
||||
print(f"步骤2 - 舷号 {num} 检索失败: {str(e)}")
|
||||
|
||||
if rag_results:
|
||||
return rag_results, matched_kb_name, matched_kb_id, matched_ship_number or ""
|
||||
|
||||
if ship_number and ship_number.strip() and match_type == "none":
|
||||
real_kb_id = await resolve_kb_id(ship_number)
|
||||
if real_kb_id:
|
||||
send_search_status(
|
||||
"正在指定舰名检索",
|
||||
f"使用舰名 {ship_number} 进行检索..."
|
||||
)
|
||||
|
||||
rag_query = f"{base_query}"
|
||||
|
||||
try:
|
||||
rag_result_raw = await rag_search(rag_query, top_k=top_k, kb_id=real_kb_id)
|
||||
rag_results = convert_rag_result(rag_result_raw)
|
||||
|
||||
if rag_results:
|
||||
print(f"步骤1 - 舷号 {ship_number} 检索完成,返回 {len(rag_results)} 条结果")
|
||||
for item in rag_results:
|
||||
if isinstance(item, dict):
|
||||
found_kb_name = item.get("kb_name", "")
|
||||
found_kb_id = item.get("kb_id", "")
|
||||
if found_kb_name and not matched_kb_name:
|
||||
matched_kb_name = str(found_kb_name).strip()
|
||||
if found_kb_id and not matched_kb_id:
|
||||
matched_kb_id = str(found_kb_id).strip()
|
||||
return rag_results, matched_kb_name, matched_kb_id, matched_ship_number or ""
|
||||
except Exception as e:
|
||||
print(f"步骤1 - 舷号 {ship_number} 检索失败: {str(e)}")
|
||||
else:
|
||||
print(f"步骤1 - 舷号 {ship_number} 无对应知识库,跳过指定舰名检索")
|
||||
|
||||
model_ship_numbers = await get_ship_numbers_by_model(ship_number)
|
||||
if len(model_ship_numbers) > 1 or (len(model_ship_numbers) == 1 and ship_number not in model_ship_numbers):
|
||||
send_search_status(
|
||||
"正在同型号检索",
|
||||
f"使用同型号舷号 {', '.join(model_ship_numbers)} 进行检索..."
|
||||
)
|
||||
|
||||
for num in model_ship_numbers:
|
||||
if num == ship_number:
|
||||
continue
|
||||
|
||||
rag_query = f"{base_query}"
|
||||
|
||||
try:
|
||||
real_kb_id = await resolve_kb_id(num)
|
||||
if not real_kb_id:
|
||||
continue
|
||||
rag_result_raw = await rag_search(rag_query, top_k=top_k, kb_id=real_kb_id)
|
||||
current_results = convert_rag_result(rag_result_raw)
|
||||
|
||||
if current_results:
|
||||
rag_results.extend(current_results)
|
||||
print(f"步骤2 - 舷号 {num} 检索完成,返回 {len(current_results)} 条结果")
|
||||
|
||||
if not matched_kb_name:
|
||||
for item in current_results:
|
||||
if isinstance(item, dict):
|
||||
found_kb_name = item.get("kb_name", "")
|
||||
found_kb_id = item.get("kb_id", "")
|
||||
if found_kb_name and not matched_kb_name:
|
||||
matched_kb_name = str(found_kb_name).strip()
|
||||
if found_kb_id and not matched_kb_id:
|
||||
matched_kb_id = str(found_kb_id).strip()
|
||||
except Exception as e:
|
||||
print(f"步骤2 - 舷号 {num} 检索失败: {str(e)}")
|
||||
|
||||
if rag_results:
|
||||
return rag_results, matched_kb_name, matched_kb_id, matched_ship_number or ""
|
||||
|
||||
send_search_status(
|
||||
"正在全局检索",
|
||||
"未找到舷号相关资料,进行全局检索..."
|
||||
)
|
||||
|
||||
try:
|
||||
rag_result_raw = await rag_search(base_query, top_k=top_k)
|
||||
rag_results = convert_rag_result(rag_result_raw)
|
||||
|
||||
if rag_results:
|
||||
print(f"步骤3 - 全局检索完成,返回 {len(rag_results)} 条结果")
|
||||
for item in rag_results:
|
||||
if isinstance(item, dict):
|
||||
found_kb_name = item.get("kb_name", "")
|
||||
found_kb_id = item.get("kb_id", "")
|
||||
if found_kb_name and not matched_kb_name:
|
||||
matched_kb_name = str(found_kb_name).strip()
|
||||
if found_kb_id and not matched_kb_id:
|
||||
matched_kb_id = str(found_kb_id).strip()
|
||||
except Exception as e:
|
||||
print(f"步骤3 - 全局检索失败: {str(e)}")
|
||||
rag_results = []
|
||||
|
||||
return rag_results, matched_kb_name, matched_kb_id, matched_ship_number or ""
|
||||
|
||||
47
utils/template.tex
Normal file
@ -0,0 +1,47 @@
|
||||
|
||||
\documentclass[10pt]{article}
|
||||
\usepackage[UTF8]{ctex}
|
||||
\usepackage{geometry}
|
||||
\usepackage{graphicx}
|
||||
\usepackage{amsmath}
|
||||
\usepackage{array}
|
||||
\usepackage{booktabs}
|
||||
\usepackage{longtable}
|
||||
\usepackage{setspace}
|
||||
\usepackage{fancyhdr}
|
||||
\usepackage{titlesec}
|
||||
\usepackage{tabularx}
|
||||
\usepackage{makecell}
|
||||
\usepackage{adjustbox}
|
||||
\usepackage{hyperref}
|
||||
% 页面设置
|
||||
\geometry{margin=2cm}
|
||||
\setlength{\parskip}{1em}
|
||||
\setlength{\parindent}{0em}
|
||||
|
||||
% 字体
|
||||
\setCJKmainfont{Noto Serif CJK SC}
|
||||
|
||||
% 标题样式
|
||||
\title{\Large \textbf{$title$}}
|
||||
\author{}
|
||||
\date{}
|
||||
|
||||
% 章节标题样式
|
||||
\titleformat{\section}{\large\bfseries\raggedright}{\thesection}{1em}{}
|
||||
\titleformat{\subsection}{\normalsize\bfseries\raggedright}{\thesubsection}{1em}{}
|
||||
|
||||
|
||||
% ===== Pandoc compatibility =====
|
||||
\usepackage{hyperref}
|
||||
\providecommand{\tightlist}{%
|
||||
\setlength{\itemsep}{0pt}\setlength{\parskip}{0pt}}
|
||||
|
||||
|
||||
\begin{document}
|
||||
|
||||
\maketitle
|
||||
|
||||
$body$
|
||||
|
||||
\end{document}
|
||||
790
utils/word_generator.py
Normal file
@ -0,0 +1,790 @@
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
import logging
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
|
||||
from docx import Document
|
||||
from docx.shared import Pt, RGBColor, Inches
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
from docx.oxml.ns import qn
|
||||
from lxml import etree
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MML2OMML_XSLT = None
|
||||
_MML2OMML_LOADED = False
|
||||
|
||||
|
||||
def _load_mml2omml_xslt():
|
||||
global _MML2OMML_XSLT, _MML2OMML_LOADED
|
||||
if _MML2OMML_LOADED:
|
||||
return _MML2OMML_XSLT
|
||||
_MML2OMML_LOADED = True
|
||||
|
||||
_this_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
search_paths = [
|
||||
os.path.join(_this_dir, "MML2OMML.XSL"),
|
||||
r"C:\Program Files\Microsoft Office\root\Office16\MML2OMML.XSL",
|
||||
r"C:\Program Files (x86)\Microsoft Office\root\Office16\MML2OMML.XSL",
|
||||
r"C:\Program Files\Microsoft Office\Office16\MML2OMML.XSL",
|
||||
r"C:\Program Files (x86)\Microsoft Office\Office16\MML2OMML.XSL",
|
||||
"/usr/share/mml2omml/MML2OMML.XSL",
|
||||
"/app/utils/MML2OMML.XSL",
|
||||
]
|
||||
logger.info(f"[公式] 开始搜索 MML2OMML.XSL,共 {len(search_paths)} 个路径")
|
||||
for path in search_paths:
|
||||
exists = os.path.isfile(path)
|
||||
logger.info(f"[公式] 检查路径: {path} -> {'存在' if exists else '不存在'}")
|
||||
if exists:
|
||||
try:
|
||||
_MML2OMML_XSLT = etree.parse(path)
|
||||
logger.info(f"[公式] ✓ MML2OMML.XSL 加载成功: {path}")
|
||||
return _MML2OMML_XSLT
|
||||
except Exception as e:
|
||||
logger.error(f"[公式] ✗ MML2OMML.XSL 加载失败: {path}, 错误: {e}")
|
||||
|
||||
logger.warning("[公式] ✗ 未找到 MML2OMML.XSL,公式将使用 matplotlib 图片回退方式渲染")
|
||||
return None
|
||||
|
||||
|
||||
class MarkdownWordGenerator:
|
||||
|
||||
def __init__(self):
|
||||
try:
|
||||
self.doc = Document()
|
||||
self.cache_dir = os.path.join(tempfile.gettempdir(), "latex_formula_cache")
|
||||
os.makedirs(self.cache_dir, exist_ok=True)
|
||||
self.formula_cache = {}
|
||||
self._omml_available = _load_mml2omml_xslt() is not None
|
||||
logger.info(f"[公式] MarkdownWordGenerator 初始化完成, OMML可用: {self._omml_available}")
|
||||
except Exception as e:
|
||||
logger.error(f"初始化 MarkdownWordGenerator 失败: {e}")
|
||||
raise
|
||||
|
||||
def set_font(self, run, size=11, bold=False):
|
||||
try:
|
||||
run.font.name = "微软雅黑"
|
||||
rPr = run._element.get_or_add_rPr()
|
||||
rFonts = rPr.find(qn('w:rFonts'))
|
||||
if rFonts is None:
|
||||
rFonts = run._element.makeelement(qn('w:rFonts'), {})
|
||||
rPr.insert(0, rFonts)
|
||||
rFonts.set(qn('w:eastAsia'), "微软雅黑")
|
||||
run.font.size = Pt(size)
|
||||
run.font.bold = True if bold else False
|
||||
except Exception as e:
|
||||
logger.warning(f"设置字体时出错: {e}")
|
||||
|
||||
def add_heading(self, text, level):
|
||||
try:
|
||||
p = self.doc.add_heading(level=level)
|
||||
size_map = {1: 18, 2: 16, 3: 14}
|
||||
font_size = size_map.get(level, 12)
|
||||
self._add_rich_text(p, text, default_size=font_size, default_bold=True)
|
||||
except Exception as e:
|
||||
logger.error(f"添加标题失败 '{text}': {e}")
|
||||
|
||||
def add_paragraph(self, text):
|
||||
try:
|
||||
if not text.strip():
|
||||
return
|
||||
p = self.doc.add_paragraph()
|
||||
|
||||
indent_chars = 0
|
||||
temp_text = text
|
||||
while temp_text.startswith('\u3000'):
|
||||
indent_chars += 1
|
||||
temp_text = temp_text[1:]
|
||||
|
||||
if indent_chars >= 2:
|
||||
p.paragraph_format.first_line_indent = Inches(0.5)
|
||||
text = temp_text
|
||||
elif indent_chars == 1:
|
||||
p.paragraph_format.first_line_indent = Inches(0.25)
|
||||
text = temp_text
|
||||
|
||||
self._add_rich_text(p, text)
|
||||
except Exception as e:
|
||||
logger.error(f"添加段落失败: {e}")
|
||||
|
||||
def _add_rich_text(self, paragraph, text, default_size=11, default_bold=False):
|
||||
if not text:
|
||||
return
|
||||
|
||||
pattern = r'\*\*(.+?)\*\*'
|
||||
last_end = 0
|
||||
|
||||
for match in re.finditer(pattern, text):
|
||||
start, end = match.span()
|
||||
if start > last_end:
|
||||
plain = text[last_end:start]
|
||||
if plain:
|
||||
run = paragraph.add_run(plain)
|
||||
self.set_font(run, size=default_size, bold=default_bold)
|
||||
|
||||
bold_content = match.group(1)
|
||||
if bold_content:
|
||||
run = paragraph.add_run(bold_content)
|
||||
self.set_font(run, size=default_size, bold=True)
|
||||
|
||||
last_end = end
|
||||
|
||||
if last_end < len(text):
|
||||
remaining = text[last_end:]
|
||||
if remaining:
|
||||
run = paragraph.add_run(remaining)
|
||||
self.set_font(run, size=default_size, bold=default_bold)
|
||||
|
||||
@staticmethod
|
||||
def _preprocess_latex_for_omml(latex: str) -> str:
|
||||
replacements = {
|
||||
r'\leqslant': r'\leq',
|
||||
r'\geqslant': r'\geq',
|
||||
r'\nleqslant': r'\nleq',
|
||||
r'\ngeqslant': r'\ngeq',
|
||||
r'\eqslantless': r'\leq',
|
||||
r'\eqslantgtr': r'\geq',
|
||||
r'\varnothing': r'\emptyset',
|
||||
r'\dfrac': r'\frac',
|
||||
r'\tfrac': r'\frac',
|
||||
r'\boldsymbol': r'\mathbf',
|
||||
r'\bm': r'\mathbf',
|
||||
}
|
||||
for old, new in replacements.items():
|
||||
latex = latex.replace(old, new)
|
||||
|
||||
latex = re.sub(r'\\mathrm\s*\{([^}]*)\}', lambda m: r'\mathrm{' + m.group(1) + '}', latex)
|
||||
latex = re.sub(r'\\text\s*\{([^}]*)\}', lambda m: r'\mathrm{' + m.group(1) + '}', latex)
|
||||
latex = re.sub(r'\\textbf\s*\{([^}]*)\}', lambda m: r'\mathbf{' + m.group(1) + '}', latex)
|
||||
latex = re.sub(r'\\textit\s*\{([^}]*)\}', lambda m: r'\mathit{' + m.group(1) + '}', latex)
|
||||
|
||||
return latex
|
||||
|
||||
def _latex_to_omml(self, latex_str):
|
||||
logger.info(f"[公式] LaTeX->OMML 开始, 原始: '{latex_str}'")
|
||||
try:
|
||||
import latex2mathml.converter
|
||||
processed = self._preprocess_latex_for_omml(latex_str)
|
||||
logger.info(f"[公式] LaTeX 预处理后: '{processed}'")
|
||||
mathml = latex2mathml.converter.convert(processed)
|
||||
logger.info(f"[公式] MathML 生成成功, 长度: {len(mathml)}")
|
||||
mathml_tree = etree.fromstring(mathml.encode('utf-8'))
|
||||
xslt = _load_mml2omml_xslt()
|
||||
if xslt is None:
|
||||
logger.warning("[公式] XSLT 不可用,OMML 转换跳过")
|
||||
return None
|
||||
transform = etree.XSLT(xslt)
|
||||
omml_tree = transform(mathml_tree)
|
||||
omml_root = omml_tree.getroot()
|
||||
logger.info(f"[公式] ✓ OMML 转换成功: '{latex_str}'")
|
||||
return omml_root
|
||||
except Exception as e:
|
||||
logger.error(f"[公式] ✗ LaTeX->OMML 转换失败 '{latex_str}': {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
def _add_omml_to_paragraph(self, paragraph, latex_str):
|
||||
omml = self._latex_to_omml(latex_str)
|
||||
if omml is not None:
|
||||
paragraph._element.append(omml)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _latex_to_image(self, latex_str, inline=True):
|
||||
"""matplotlib 回退方案:将公式渲染为 PNG 图片"""
|
||||
logger.info(f"[公式] LaTeX->图片 回退, 原始: '{latex_str}'")
|
||||
try:
|
||||
import matplotlib
|
||||
matplotlib.use('Agg')
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
processed = self._preprocess_latex_for_omml(latex_str)
|
||||
logger.info(f"[公式] LaTeX 预处理后: '{processed}'")
|
||||
|
||||
if latex_str in self.formula_cache:
|
||||
cached = self.formula_cache[latex_str]
|
||||
if os.path.isfile(cached):
|
||||
logger.info(f"[公式] 使用缓存: {cached}")
|
||||
return cached
|
||||
|
||||
filename = os.path.join(self.cache_dir, f"{uuid.uuid4()}.png")
|
||||
fig_width = 6 if not inline else 4
|
||||
fig_height = 1.2 if not inline else 0.8
|
||||
font_size = 20 if not inline else 16
|
||||
|
||||
fig = plt.figure(figsize=(fig_width, fig_height))
|
||||
fig.text(0.5, 0.5, f"${processed}$", fontsize=font_size, ha='center', va='center')
|
||||
plt.axis('off')
|
||||
fig.savefig(filename, dpi=300, bbox_inches="tight", pad_inches=0.05)
|
||||
plt.close(fig)
|
||||
|
||||
self.formula_cache[latex_str] = filename
|
||||
logger.info(f"[公式] ✓ 图片渲染成功: {filename}")
|
||||
return filename
|
||||
except Exception as e:
|
||||
logger.error(f"[公式] ✗ LaTeX->图片 渲染失败 '{latex_str}': {e}", exc_info=True)
|
||||
try:
|
||||
plt.close('all')
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _insert_formula(self, paragraph, latex_str, inline=True):
|
||||
"""统一公式插入方法:优先 OMML,回退 matplotlib 图片"""
|
||||
if self._omml_available:
|
||||
if self._add_omml_to_paragraph(paragraph, latex_str):
|
||||
return True
|
||||
logger.warning(f"[公式] OMML 失败,回退 matplotlib: '{latex_str}'")
|
||||
|
||||
img_path = self._latex_to_image(latex_str, inline=inline)
|
||||
if img_path:
|
||||
try:
|
||||
width = Inches(0.7) if inline else Inches(2.5)
|
||||
run = paragraph.add_run()
|
||||
run.add_picture(img_path, width=width)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[公式] ✗ 图片插入失败: {e}")
|
||||
|
||||
run = paragraph.add_run(f" {latex_str} ")
|
||||
self.set_font(run, size=9)
|
||||
logger.warning(f"[公式] ✗ 所有方式失败,以纯文本插入: '{latex_str}'")
|
||||
return False
|
||||
|
||||
def _add_rich_text_with_formulas(self, paragraph, text, default_size=11, default_bold=False):
|
||||
if not text:
|
||||
return
|
||||
|
||||
has_formula = bool(re.search(r'\$[^$]+?\$', text))
|
||||
has_image = bool(self._extract_image_urls(text))
|
||||
|
||||
if not has_formula and not has_image:
|
||||
self._add_rich_text(paragraph, text, default_size=default_size, default_bold=default_bold)
|
||||
return
|
||||
|
||||
if has_image:
|
||||
img_urls = self._extract_image_urls(text)
|
||||
last_end = 0
|
||||
for start, end, url in img_urls:
|
||||
before_text = text[last_end:start]
|
||||
if before_text and before_text.strip():
|
||||
self._add_rich_text_with_formulas(paragraph, before_text, default_size=default_size, default_bold=default_bold)
|
||||
self.add_image(url, width_inches=4.5)
|
||||
last_end = end
|
||||
after_text = text[last_end:]
|
||||
if after_text and after_text.strip():
|
||||
p = self.doc.add_paragraph()
|
||||
self._add_rich_text_with_formulas(p, after_text, default_size=default_size, default_bold=default_bold)
|
||||
return
|
||||
|
||||
segments = re.split(r'(\$[^$]+?\$)', text)
|
||||
for segment in segments:
|
||||
if not segment:
|
||||
continue
|
||||
if segment.startswith('$') and segment.endswith('$') and len(segment) > 2:
|
||||
latex_content = segment[1:-1].strip()
|
||||
self._insert_formula(paragraph, latex_content, inline=True)
|
||||
else:
|
||||
self._add_rich_text(paragraph, segment, default_size=default_size, default_bold=default_bold)
|
||||
|
||||
def add_list(self, text, numbered=False, number=None):
|
||||
try:
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return
|
||||
|
||||
if re.match(r'^[-_*]{2,}$', text):
|
||||
return
|
||||
|
||||
if numbered and number is not None:
|
||||
p = self.doc.add_paragraph()
|
||||
p.paragraph_format.left_indent = Pt(18)
|
||||
p.paragraph_format.first_line_indent = Pt(-18)
|
||||
num_run = p.add_run(f"{number}. ")
|
||||
self.set_font(num_run, bold=False)
|
||||
self._add_rich_text_with_formulas(p, text)
|
||||
else:
|
||||
p = self.doc.add_paragraph(style="List Bullet")
|
||||
self._add_rich_text_with_formulas(p, text)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"添加列表失败: {e}")
|
||||
|
||||
def add_sub_list(self, text):
|
||||
try:
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return
|
||||
if re.match(r'^[-_*]{2,}$', text):
|
||||
return
|
||||
|
||||
try:
|
||||
p = self.doc.add_paragraph(style="List Bullet 2")
|
||||
except KeyError:
|
||||
p = self.doc.add_paragraph(style="List Bullet")
|
||||
p.paragraph_format.left_indent = Pt(36)
|
||||
self._add_rich_text_with_formulas(p, text)
|
||||
except Exception as e:
|
||||
logger.error(f"添加子列表失败: {e}")
|
||||
|
||||
def _convert_url_to_local_path(self, url: str) -> str:
|
||||
if not url:
|
||||
return None
|
||||
from urllib.parse import urlparse
|
||||
path = urlparse(url).path
|
||||
if path.startswith('/picture/'):
|
||||
docker_path = f"/app/picture/{path[len('/picture/'):]}"
|
||||
if os.path.isfile(docker_path):
|
||||
return docker_path
|
||||
try:
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
local_path = os.path.join(project_root, "picture", path[len('/picture/'):])
|
||||
if os.path.isfile(local_path):
|
||||
return local_path
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_http_headers(url: str) -> dict:
|
||||
headers = {}
|
||||
if "/api/v1/knowledge/" in url:
|
||||
try:
|
||||
from config import RAG_CONFIG
|
||||
headers["x-user-id"] = str(RAG_CONFIG.get("x-user-id", "1"))
|
||||
headers["x-user-name"] = str(RAG_CONFIG.get("x-user-name", "testuser"))
|
||||
headers["x-role"] = str(RAG_CONFIG.get("x-role", "admin"))
|
||||
except Exception:
|
||||
headers["x-user-id"] = "1"
|
||||
headers["x-user-name"] = "testuser"
|
||||
headers["x-role"] = "admin"
|
||||
return headers
|
||||
|
||||
def add_image(self, url_or_path: str, width_inches: float = 5.5):
|
||||
if not url_or_path:
|
||||
return
|
||||
|
||||
try:
|
||||
import io
|
||||
image_stream = None
|
||||
|
||||
if os.path.isfile(url_or_path):
|
||||
image_stream = url_or_path
|
||||
elif url_or_path.startswith("http://") or url_or_path.startswith("https://"):
|
||||
local_path = self._convert_url_to_local_path(url_or_path)
|
||||
if local_path and os.path.isfile(local_path):
|
||||
image_stream = local_path
|
||||
else:
|
||||
import requests
|
||||
headers = self._get_http_headers(url_or_path)
|
||||
resp = requests.get(url_or_path, timeout=15, headers=headers, proxies={"http": None, "https": None})
|
||||
resp.raise_for_status()
|
||||
content_type = resp.headers.get("Content-Type", "")
|
||||
logger.info(f"HTTP 下载完成, Content-Type: {content_type}, 大小: {len(resp.content)} bytes")
|
||||
if not content_type.startswith("image/"):
|
||||
logger.error(f"响应不是图片类型: {content_type}")
|
||||
self.add_paragraph(f"[图片加载失败: {url_or_path}]")
|
||||
return
|
||||
image_stream = io.BytesIO(resp.content)
|
||||
else:
|
||||
logger.warning(f"图片路径无效: {url_or_path}")
|
||||
return
|
||||
|
||||
from PIL import Image as PilImage
|
||||
if isinstance(image_stream, str):
|
||||
pil_img = PilImage.open(image_stream)
|
||||
else:
|
||||
pil_img = PilImage.open(image_stream)
|
||||
png_stream = io.BytesIO()
|
||||
pil_img.convert("RGBA" if pil_img.mode in ("RGBA", "P") else "RGB").save(png_stream, format="PNG")
|
||||
png_stream.seek(0)
|
||||
|
||||
p = self.doc.add_paragraph()
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
run = p.add_run()
|
||||
run.add_picture(png_stream, width=Inches(width_inches))
|
||||
logger.info(f"图片插入成功: {url_or_path}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"插入图片失败 '{url_or_path}': {e!r}", exc_info=True)
|
||||
self.add_paragraph(f"[图片加载失败: {url_or_path}]")
|
||||
|
||||
def _extract_image_urls(self, line_content):
|
||||
"""
|
||||
从一行文本中提取所有图片URL,支持多种格式:
|
||||
- 
|
||||
- ![alt]`url`
|
||||
- ! `url`
|
||||
返回 [(start, end, url), ...]
|
||||
"""
|
||||
results = []
|
||||
|
||||
# 格式1: 
|
||||
for m in re.finditer(r'!\[([^\]]*)\]\(([^)]+)\)', line_content):
|
||||
url = m.group(2).strip()
|
||||
if url.startswith('`') and url.endswith('`'):
|
||||
url = url[1:-1]
|
||||
results.append((m.start(), m.end(), url))
|
||||
|
||||
# 格式2: ![alt]`url` (未被格式1匹配的)
|
||||
for m in re.finditer(r'!\[([^\]]*)\]\s*`([^`]+)`', line_content):
|
||||
url = m.group(2).strip()
|
||||
already = any(s <= m.start() and e >= m.end() for s, e, _ in results)
|
||||
if not already:
|
||||
results.append((m.start(), m.end(), url))
|
||||
|
||||
# 格式3: ! `url` (模型常见的破损格式)
|
||||
for m in re.finditer(r'!\s*`([^`]+\.(?:jpg|jpeg|png|gif|bmp|webp))`', line_content, re.IGNORECASE):
|
||||
url = m.group(1).strip()
|
||||
already = any(s <= m.start() and e >= m.end() for s, e, _ in results)
|
||||
if not already:
|
||||
results.append((m.start(), m.end(), url))
|
||||
|
||||
results.sort(key=lambda x: x[0])
|
||||
return results
|
||||
|
||||
def add_table(self, lines):
|
||||
try:
|
||||
rows = []
|
||||
for line in lines:
|
||||
line = line.strip("|")
|
||||
cells = [c.strip() for c in line.split("|")]
|
||||
rows.append(cells)
|
||||
|
||||
if not rows:
|
||||
return
|
||||
|
||||
header = rows[0]
|
||||
data_start_index = 1
|
||||
if len(rows) > 1 and all(c.startswith('-') or c == '' for c in rows[1]):
|
||||
data_start_index = 2
|
||||
|
||||
data = rows[data_start_index:]
|
||||
max_cols = len(header)
|
||||
|
||||
table = self.doc.add_table(rows=len(data) + 1, cols=max_cols)
|
||||
table.style = "Table Grid"
|
||||
|
||||
for i, cell in enumerate(header):
|
||||
if i < max_cols:
|
||||
table.rows[0].cells[i].text = cell
|
||||
|
||||
for r, row in enumerate(data):
|
||||
for c, val in enumerate(row):
|
||||
if c < max_cols:
|
||||
table.rows[r + 1].cells[c].text = val
|
||||
|
||||
logger.info("表格添加成功")
|
||||
except Exception as e:
|
||||
logger.error(f"添加表格失败: {e}")
|
||||
|
||||
def _add_horizontal_rule(self):
|
||||
p = self.doc.add_paragraph()
|
||||
pPr = p._element.get_or_add_pPr()
|
||||
pBdr = p._element.makeelement(qn('w:pBdr'), {})
|
||||
bottom = p._element.makeelement(qn('w:bottom'), {
|
||||
qn('w:val'): 'single',
|
||||
qn('w:sz'): '6',
|
||||
qn('w:space'): '1',
|
||||
qn('w:color'): 'CCCCCC',
|
||||
})
|
||||
pBdr.append(bottom)
|
||||
pPr.append(pBdr)
|
||||
|
||||
def parse(self, markdown):
|
||||
lines = markdown.split("\n")
|
||||
table_buffer = []
|
||||
block_formula = []
|
||||
in_block_formula = False
|
||||
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
line_stripped = line.rstrip()
|
||||
line_content = line_stripped.lstrip()
|
||||
|
||||
# 1. 处理块级公式
|
||||
if line_content.startswith("$$"):
|
||||
if not in_block_formula:
|
||||
remaining = line_content[2:]
|
||||
close_idx = remaining.find("$$")
|
||||
if close_idx >= 0:
|
||||
formula = remaining[:close_idx].strip()
|
||||
after_formula = remaining[close_idx + 2:].strip()
|
||||
p = self.doc.add_paragraph()
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
self._insert_formula(p, formula, inline=False)
|
||||
if after_formula:
|
||||
self.add_paragraph(after_formula)
|
||||
else:
|
||||
in_block_formula = True
|
||||
block_formula = []
|
||||
if remaining.strip():
|
||||
block_formula.append(remaining.strip())
|
||||
else:
|
||||
remaining = line_content[2:]
|
||||
in_block_formula = False
|
||||
formula = "".join(block_formula).strip()
|
||||
p = self.doc.add_paragraph()
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
self._insert_formula(p, formula, inline=False)
|
||||
if remaining.strip():
|
||||
self.add_paragraph(remaining.strip())
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if in_block_formula:
|
||||
block_formula.append(line)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 2. 处理表格
|
||||
if line_content.startswith("|") and line_content.endswith("|"):
|
||||
table_buffer.append(line_stripped)
|
||||
i += 1
|
||||
continue
|
||||
else:
|
||||
if table_buffer:
|
||||
self.add_table(table_buffer)
|
||||
table_buffer = []
|
||||
|
||||
# 空行跳过
|
||||
if not line_stripped:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 3. 水平分隔线
|
||||
if re.match(r'^\s*[-*_]{3,}\s*$', line_stripped):
|
||||
self._add_horizontal_rule()
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 4. 标题
|
||||
heading_match = re.match(r'^(#{1,3})\s+(.*)', line_content)
|
||||
if heading_match:
|
||||
level = len(heading_match.group(1))
|
||||
text = heading_match.group(2)
|
||||
self.add_heading(text, level)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 5. 有序列表(带子列表支持)
|
||||
list_match_ordered = re.match(r'^(\d+)\.\s*(.*)', line_content)
|
||||
if list_match_ordered:
|
||||
number = int(list_match_ordered.group(1))
|
||||
text = list_match_ordered.group(2)
|
||||
self.add_list(text, numbered=True, number=number)
|
||||
|
||||
i += 1
|
||||
while i < len(lines):
|
||||
next_line = lines[i]
|
||||
next_stripped = next_line.rstrip()
|
||||
sub_match = re.match(r'^[\s\t]+[-*]\s*(.*)', next_stripped)
|
||||
if sub_match:
|
||||
sub_text = sub_match.group(1)
|
||||
self.add_sub_list(sub_text)
|
||||
i += 1
|
||||
elif (next_stripped.startswith(' ') or next_stripped.startswith('\t')):
|
||||
sub_content = next_stripped.strip()
|
||||
if sub_content and not re.match(r'^\d+\.\s', sub_content):
|
||||
self.add_sub_list(sub_content)
|
||||
i += 1
|
||||
else:
|
||||
break
|
||||
else:
|
||||
break
|
||||
continue
|
||||
|
||||
# 6. 无序列表
|
||||
is_bold_line = line_content.startswith('**')
|
||||
if not is_bold_line:
|
||||
list_match_unordered = re.match(r'^\s*[-*]\s+(.*)', line_stripped)
|
||||
if not list_match_unordered:
|
||||
m = re.match(r'^\s*-([^-].*)$', line_stripped)
|
||||
if m and m.group(1).strip():
|
||||
list_match_unordered = m
|
||||
|
||||
if list_match_unordered:
|
||||
text = list_match_unordered.group(1).strip()
|
||||
if text:
|
||||
self.add_list(text, numbered=False)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 7. 图片链接 - 统一提取所有格式的图片URL
|
||||
img_urls = self._extract_image_urls(line_content)
|
||||
|
||||
if img_urls:
|
||||
has_only_img = (len(img_urls) == 1 and
|
||||
line_content.strip().startswith('!') and
|
||||
img_urls[0][0] == 0)
|
||||
|
||||
if has_only_img:
|
||||
_, _, url = img_urls[0]
|
||||
self.add_image(url)
|
||||
else:
|
||||
p = self.doc.add_paragraph()
|
||||
last_end = 0
|
||||
for start, end, url in img_urls:
|
||||
before_text = line_content[last_end:start].strip()
|
||||
if before_text:
|
||||
self._add_rich_text_with_formulas(p, before_text)
|
||||
self.add_image(url, width_inches=4.5)
|
||||
p = self.doc.add_paragraph()
|
||||
last_end = end
|
||||
after_text = line_content[last_end:].strip()
|
||||
if after_text:
|
||||
self._add_rich_text_with_formulas(p, after_text)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 8. 普通链接
|
||||
if line_content.startswith("["):
|
||||
match = re.match(r'\[(.*?)\]\((.*?)\)', line_content)
|
||||
if match:
|
||||
p = self.doc.add_paragraph()
|
||||
run = p.add_run(match.group(1))
|
||||
run.font.color.rgb = RGBColor(0, 0, 255)
|
||||
run.font.underline = True
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 9. 普通段落
|
||||
p = self.doc.add_paragraph()
|
||||
|
||||
indent_chars = 0
|
||||
temp_line = line_stripped
|
||||
while temp_line.startswith('\u3000'):
|
||||
indent_chars += 1
|
||||
temp_line = temp_line[1:]
|
||||
|
||||
if indent_chars >= 2:
|
||||
p.paragraph_format.first_line_indent = Inches(0.5)
|
||||
elif indent_chars == 1:
|
||||
p.paragraph_format.first_line_indent = Inches(0.25)
|
||||
|
||||
self._add_rich_text_with_formulas(p, line_content)
|
||||
|
||||
i += 1
|
||||
|
||||
if table_buffer:
|
||||
self.add_table(table_buffer)
|
||||
|
||||
def generate(self, markdown, title=None, output=None, images=None):
|
||||
try:
|
||||
if title:
|
||||
logger.info(f"添加文档标题: {title}")
|
||||
p = self.doc.add_paragraph()
|
||||
run = p.add_run(title)
|
||||
self.set_font(run, 20, True)
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
|
||||
logger.info("开始解析 Markdown 内容")
|
||||
self.parse(markdown)
|
||||
|
||||
if images:
|
||||
self._append_images_section(images)
|
||||
|
||||
if not output:
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
output = f"report_{timestamp}.docx"
|
||||
|
||||
logger.info(f"正在保存文件: {output}")
|
||||
self.doc.save(output)
|
||||
|
||||
if os.path.exists(output):
|
||||
file_size = os.path.getsize(output)
|
||||
logger.info(f"文件保存成功: {output}, 大小: {file_size} bytes")
|
||||
return output
|
||||
else:
|
||||
logger.error(f"文件保存后未找到: {output}")
|
||||
raise FileNotFoundError(f"File {output} was not created.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"生成报告过程中发生严重错误: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
def _append_images_section(self, images):
|
||||
try:
|
||||
self.add_heading("附件图片", level=2)
|
||||
|
||||
for idx, img in enumerate(images, 1):
|
||||
if isinstance(img, dict):
|
||||
url = img.get('url', '')
|
||||
summary = img.get('summary', '')
|
||||
else:
|
||||
url = str(img)
|
||||
summary = ''
|
||||
|
||||
p = self.doc.add_paragraph()
|
||||
run = p.add_run(f"图片{idx}:")
|
||||
self.set_font(run, bold=True)
|
||||
|
||||
if url:
|
||||
self.add_image(url, width_inches=4)
|
||||
|
||||
if summary:
|
||||
p = self.doc.add_paragraph()
|
||||
run = p.add_run(f"说明:{summary}")
|
||||
self.set_font(run, size=10)
|
||||
|
||||
self.doc.add_paragraph()
|
||||
|
||||
logger.info(f"图片附件部分添加完成,共 {len(images)} 张图片")
|
||||
except Exception as e:
|
||||
logger.error(f"添加图片附件部分失败: {e}")
|
||||
|
||||
|
||||
def generate_report_word(markdown, title=None, output=None, images=None):
|
||||
logger.info("=== 开始生成报告 ===")
|
||||
try:
|
||||
generator = MarkdownWordGenerator()
|
||||
|
||||
if images:
|
||||
logger.info(f"准备插入 {len(images)} 张图片")
|
||||
|
||||
file_path = generator.generate(markdown, title=title, output=output, images=images)
|
||||
logger.info(f"=== 报告生成完成: {file_path} ===")
|
||||
return file_path
|
||||
except Exception as e:
|
||||
logger.critical(f"报告生成失败: {e}")
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
md = r"""
|
||||
## **测试报告 - OMML公式和图片**
|
||||
|
||||
### **一、行内公式**
|
||||
|
||||
温度要求 $ \leqslant 40 ^ { \circ } \mathrm { C } $,不得超过。
|
||||
|
||||
公差范围 $ \geqslant 0.05 \ \mathrm { mm } $ 以内合格。
|
||||
|
||||
### **二、列表中的公式**
|
||||
|
||||
1. 温度 $ \leqslant 40 ^ { \circ } \mathrm { C } $ 要求
|
||||
2. 间隙 $ \leqslant 0.05 \ \mathrm { mm } $ 标准
|
||||
|
||||
- 清洁轴颈 $ \geqslant 0.05 \ \mathrm { mm } $
|
||||
|
||||
|
||||
### **四、图片测试**
|
||||
|
||||

|
||||
"""
|
||||
|
||||
try:
|
||||
result_path = generate_report_word(md)
|
||||
print(f"报告已生成,路径: {result_path}")
|
||||
except Exception as e:
|
||||
print(f"程序执行出错: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
158
workflow_registry.py
Normal file
@ -0,0 +1,158 @@
|
||||
"""
|
||||
工作流注册表 - 主代理与 app 共用
|
||||
主代理阶段四根据此配置路由到所有注册的子 agent;
|
||||
app 据此执行对应工作流。
|
||||
添加新工作流时,在此配置 WORKFLOW_CONFIG 与 ROUTE_DESCRIPTIONS。
|
||||
"""
|
||||
# ============================================================================
|
||||
# =============== 工作流配置区域 - 添加新工作流时只需修改这里 ===============
|
||||
# ============================================================================
|
||||
# 格式:
|
||||
# "route_flag": {
|
||||
# "module": "workflows.workflow_xxx",
|
||||
# "function": "run_xxx_workflow",
|
||||
# "is_async": True/False,
|
||||
# }
|
||||
# ============================================================================
|
||||
|
||||
WORKFLOW_CONFIG = {
|
||||
"fault_diagnosis": {
|
||||
"module": "workflows.workflow_fault_diagnosis",
|
||||
"function": "run_fault_diagnosis_workflow",
|
||||
"is_async": True,
|
||||
},
|
||||
"qa": {
|
||||
"module": "workflows.workflow_qa",
|
||||
"function": "run_qa_workflow",
|
||||
"is_async": True,
|
||||
},
|
||||
"问题排查": {
|
||||
"module": "workflows.workflow_fault_diagnosis",
|
||||
"function": "run_fault_diagnosis_workflow",
|
||||
"is_async": True,
|
||||
},
|
||||
"spare_parts_statistics": {
|
||||
"module": "workflows.workflow_spare_parts_statistics",
|
||||
"function": "run_spare_parts_statistics_workflow",
|
||||
"is_async": True,
|
||||
},
|
||||
"rules": {
|
||||
"module": "workflows.workflow_qa",
|
||||
"function": "run_qa_workflow",
|
||||
"is_async": True,
|
||||
},
|
||||
"other": {
|
||||
"module": "workflows.workflow_fault_diagnosis",
|
||||
"function": "run_fault_diagnosis_workflow",
|
||||
"is_async": True,
|
||||
#
|
||||
# "module": "workflows.workflow_other",
|
||||
# "function": "run_other_workflow",
|
||||
# "is_async": True,
|
||||
},
|
||||
"report":{
|
||||
# "module": "workflows.workflow_qa_report",
|
||||
# "function": "run_qa_report_agent",
|
||||
# "is_async": True,
|
||||
"module": "workflows.workflow_fault_diagnosis",
|
||||
"function": "run_fault_diagnosis_workflow",
|
||||
"is_async": True,
|
||||
},
|
||||
"百科问答":{
|
||||
"module": "workflows.workflow_baike",
|
||||
"function": "run_baike_workflow",
|
||||
"is_async": True,
|
||||
},
|
||||
"操作使用":{
|
||||
"module": "workflows.workflow_operate",
|
||||
"function": "run_operate_workflow",
|
||||
"is_async": True,
|
||||
},
|
||||
"统计":{
|
||||
"module": "workflows.workflow_statistics",
|
||||
"function": "run_statistics_workflow",
|
||||
"is_async": True,
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
# 路由标志 -> 任务分类描述(供主代理阶段四 LLM 使用)
|
||||
ROUTE_DESCRIPTIONS = {
|
||||
"fault_diagnosis": """
|
||||
- 特征:用户描述**当前或近期发生的设备异常、报警、性能下降等具体故障现象**,需要分析原因或定位问题。
|
||||
- 关键词:报警、异常、异响、冒烟、过热、无法启动、突然停机、振动大、压力低、温度高、故障代码、不工作
|
||||
- 示例:
|
||||
• 主机滑油压力突然降到0.2MPa并报警
|
||||
• 柴油机启动后冒黑烟且转速不稳
|
||||
• 舵机发出异常噪音,疑似齿轮损坏
|
||||
- 注意:若历史记录中包含“请补充”、“【需要更多信息】”,通常属于此类,因已聚焦具体故障场景。
|
||||
当用户问题中只有舷号、设备或故障现象时属于此类,舷号一般为连续的阿拉伯数字如“101”,"111","122","163","16","17","554","602","545","985","31","3261","799","905","781"等,或者数字后加舰,比如“101舰","199舰","18舰"等。
|
||||
""",
|
||||
|
||||
"qa": """
|
||||
- 特征:询问定义、原理、结构、功能、分类、标准配置、常见类型、安全规范等**一般性知识**;也包括对专业术语、现象、部件作用的解释。
|
||||
**即使问题表述模糊、不完整、缺少上下文,只要涉及船舶、机械、电气、动力等技术内容,均优先归为此类。**
|
||||
- 关键词:什么是、如何工作、为什么、请问、介绍、解释、有哪些、常见、典型、一般、作用、组成、是不是、叫什么
|
||||
- 示例:
|
||||
• 船舶主发动机有哪些常见故障类型?
|
||||
• 涡轮增压器的工作原理是什么?
|
||||
• 主机由哪些主要部件组成?
|
||||
• 什么是MAN B&W柴油机?
|
||||
- 注意:
|
||||
• **本类作为技术相关问题的默认兜底类别**——凡涉及设备、系统、部件、工况、参数等技术语境的问题,若无法明确归入 fault_diagnosis / repair / spare_parts_statistics / rules,则优先视为 qa。
|
||||
""",
|
||||
|
||||
"repair": """
|
||||
- 特征:用户明确请求**维修方法、操作步骤、检修流程、更换方案或现场处理建议**,通常在已知故障或需预防性维护时提出。
|
||||
- 关键词:怎么修、如何维修、维修步骤、检修方法、更换、拆卸、安装、处理、修复、维护、保养、调整
|
||||
- 示例:
|
||||
• 主机喷油器漏油,怎么更换?
|
||||
• 给出主机缸套磨损的维修方案
|
||||
• 如何拆卸增压器进行检修?
|
||||
""",
|
||||
|
||||
"spare_parts_statistics": """
|
||||
- 特征:用户需要对**备品备件的数量、使用记录、库存、消耗趋势、预测需求或成本分析**进行查询或统计。
|
||||
- 关键词:备件、备品、库存、数量、统计、预测、分析、消耗、需求、清单、台账、寿命、更换周期、用了多少、还剩多少
|
||||
- 示例:
|
||||
• 统计过去一年主机喷油嘴的使用数量
|
||||
• 预测下季度所需空冷器备件数量
|
||||
• 主机活塞环的平均更换周期是多少?
|
||||
- 注意:若仅问“某个部件是不是备件”或“这个备件叫什么名字”,属于知识问答(qa),而非本类。
|
||||
""",
|
||||
|
||||
"rules": """
|
||||
- 特征:用户询问与船舶设备维修、检验、安全、环保、保密等相关的**国家法规、军用标准(如GJB)、行业规范、船级社要求或管理制度**。
|
||||
- 关键词:法规、标准、规范、规定、合规、条例、依据、要求、准则、制度、认证、资质、船级社、CCS、IMO、SOLAS、MARPOL、资格
|
||||
- 示例:
|
||||
• 船舶主机修理需遵循哪些GJB标准?
|
||||
• IMO对主机排放有哪些强制要求?
|
||||
• 舰船维修单位需要哪些资质?
|
||||
• 保密资格申请流程是什么?
|
||||
- 注意:若问题提到“标准里有没有…”但未指明具体法规/标准名称,且无“法规”“标准”等关键词,可先归为 qa;一旦明确提及“法规”“标准”“规范”“SOLAS”等,则归为 rules。
|
||||
""",
|
||||
"other": """
|
||||
- 特征:**完全不涉及新的技术问题、故障诊断、维修请求、备件统计、法规查询或报告生成**,而是针对**已有对话内容、方案、步骤或文本的反馈、修正、补充、顺序调整或格式优化**。典型场景包括:
|
||||
• 指出之前提供的操作步骤有误(如“异物清理步骤有误”)
|
||||
• 要求调整操作顺序(如“这一步应当先怎样,然后再怎么操作,最后……”)
|
||||
• 对生成内容提出修改意见(如“步骤有误/有补充”“请重写第三点”)
|
||||
• 请求优化语言、格式或结构(如“请用更简洁的方式表达”)
|
||||
• 闲聊、非技术性确认或与当前技术任务无关的交互
|
||||
|
||||
- 注意:
|
||||
• **只要用户意图是修正、补充或调整已有内容,而非提出新问题,即使涉及技术术语,也应归为 other**。
|
||||
• 不要将此类反馈误判为 repair 或 qa——它们不是在问“该怎么修”或“原理是什么”,而是在说“你刚才说的不对/不全”。
|
||||
• 此类通常出现在多轮对话中,是对前文输出的回应。
|
||||
""",
|
||||
"report": """
|
||||
- 特征:用户明确要求**生成、整理、导出或汇总**某种形式的文档、报表、清单或总结材料。通常涉及将分散的故障记录、维修历史、备件数据或问答内容整合成结构化文本(如日报、周报、故障分析报告、维修总结、备件消耗表等)。
|
||||
- 关键词:生成报告、写总结、导出表格、整理清单、汇总数据、形成文档、日报、周报、月报、分析报告、维修记录单、台账报表
|
||||
- 示例:
|
||||
• 请根据本周的故障记录生成一份主机系统运行分析报告
|
||||
• 总结这次对话
|
||||
• 导出过去一年的备件消耗统计报告
|
||||
• 帮我写一份关于此次主机停机事故的详细处理总结
|
||||
"""
|
||||
}
|
||||
|
||||
VALID_ROUTE_FLAGS = tuple(WORKFLOW_CONFIG.keys())
|
||||