Initial project import

This commit is contained in:
zc 2026-07-02 10:29:14 +08:00
commit f0d238690f
73 changed files with 27816 additions and 0 deletions

33
.gitignore vendored Normal file
View File

@ -0,0 +1,33 @@
__pycache__/
*.py[cod]
*$py.class
.Python
.venv/
venv/
env/
ENV/
*.swp
*.bak*
*.before_rollback_*
*.tmp
*.log
config.py
config.txt
data/*.db
data/*.db-*
data/*.sqlite
data/*.sqlite-*
created_pdf/
created_word/
image_output/
workflows/=*
.pytest_cache/
.mypy_cache/
.ruff_cache/

138
1.html Normal file
View File

@ -0,0 +1,138 @@
<h1>1. 总体态势摘要</h1>
<p>根据离心式海水泵近一年2023年1月至2023年12月的备件消耗统计数据显示系统运行期间未记录到任何故障事件<strong>total_events=0</strong>),相关备件消耗数据及故障频率数据均为空。初步判断可能为数据采集系统未启用、设备运行周期未覆盖统计时段或设备处于低负荷运行状态。建议进一步核查数据采集机制完整性,并结合设备实际运行工况开展专项分析。</p>
<h2>2. 备件库存现状与统计图表</h2>
<h3>2.1 关键备件库存监控表</h3>
<table border="1" cellpadding="5" cellspacing="0">
<thead>
<tr>
<th>备件名称</th>
<th>库存数量</th>
<th>安全库存阈值</th>
<th>库存状态</th>
</tr>
</thead>
<tbody>
<tr>
<td>泵轴密封圈</td>
<td>0</td>
<td>5</td>
<td><font color="red">告警</font></td>
</tr>
<tr>
<td>叶轮组件</td>
<td>2</td>
<td>3</td>
<td><font color="orange">预警</font></td>
</tr>
<tr>
<td>轴承组6308型号</td>
<td>8</td>
<td>10</td>
<td><font color="green">正常</font></td>
</tr>
</tbody>
</table>
<h3>2.2 故障模式分布统计</h3>
<table border="1" cellpadding="5" cellspacing="0">
<thead>
<tr>
<th>故障类型</th>
<th>发生次数</th>
<th>占比(%</th>
</tr>
</thead>
<tbody>
<tr>
<td>无记录</td>
<td>0</td>
<td>0</td>
</tr>
</tbody>
</table>
<p><strong>注:</strong>当前统计时段内未发生可记录故障事件,建议结合设备巡检日志开展隐性故障排查。</p>
<h2>3. 历史消耗趋势分析</h2>
<h3>3.1 近几个月核心备件消耗曲线</h3>
<table border="1" cellpadding="5" cellspacing="0">
<thead>
<tr>
<th>统计时段</th>
<th>泵轴密封圈消耗量</th>
<th>叶轮组件消耗量</th>
<th>轴承组消耗量</th>
</tr>
</thead>
<tbody>
<tr>
<td>2023Q1</td>
<td>0</td>
<td>0</td>
<td>0</td>
</tr>
<tr>
<td>2023Q2</td>
<td>0</td>
<td>0</td>
<td>0</td>
</tr>
<tr>
<td>2023Q3</td>
<td>0</td>
<td>0</td>
<td>0</td>
</tr>
<tr>
<td>2023Q4</td>
<td>0</td>
<td>0</td>
<td>0</td>
</tr>
</tbody>
</table>
<p><strong>趋势说明:</strong>因无实际消耗数据,无法绘制趋势曲线。建议结合设备运行日志补充关键工况参数(如启停频次、海水含沙量等)开展关联分析。</p>
<h2>4. 任务周期需求预测</h2>
<h3>4.1 预测结果清单</h3>
<table border="1" cellpadding="5" cellspacing="0">
<thead>
<tr>
<th>预测维度</th>
<th>预测周期</th>
<th>预测结论</th>
</tr>
</thead>
<tbody>
<tr>
<td>备件需求量</td>
<td>2024年全年</td>
<td>基于当前零消耗数据预测需求量为0需结合设备启用计划修正</td>
</tr>
<tr>
<td>故障概率</td>
<td>2024年Q1-Q4</td>
<td>因无历史故障数据,无法进行概率建模,建议开展设备健康度评估</td>
</tr>
</tbody>
</table>
<h2>5. 保障建议与决策</h2>
<h3>5.1 采购与调拨建议</h3>
<ul>
<li>立即核查泵轴密封圈库存异常当前库存0件建议紧急调拨或启动应急采购流程。</li>
<li>叶轮组件库存低于安全阈值,建议按<strong>30%安全库存冗余率</strong>补充备件。</li>
<li>轴承组库存正常但需结合设备维护周期建议每1000小时更换制定动态补货计划。</li>
</ul>
<h3>5.2 维修策略建议</h3>
<ol>
<li><strong>数据采集优化:</strong>部署设备运行状态监测系统,实时采集转速、振动、海水腐蚀性等参数。</li>
<li><strong>预防性维护强化:</strong>针对密封件等易损件,制定<em>基于时间的维护TBM</em>策略,每季度开展预防性检查。</li>
<li><strong>故障树分析FTA</strong>针对潜在故障模式如海水腐蚀导致的密封失效开展专项FTA分析。</li>
</ol>
<p><strong>结论:</strong>当前数据缺失可能导致保障策略失准,建议尽快启动设备运行数据采集与历史故障档案重建工作。</p>

66
Prompt.py Normal file
View 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)

0
README.md Normal file
View File

361
agent_create_request.py Normal file
View 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
View 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
View 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()

1127
app.py Normal file

File diff suppressed because it is too large Load Diff

BIN
audio/test.wav Normal file

Binary file not shown.

498
checkpointer_config.py Normal file
View 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 '未配置'}")

0
config Normal file
View File

247
main_agent.py Normal file
View File

@ -0,0 +1,247 @@
"""
主脑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"]
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
View File

0
modelsAPI/model_api Normal file
View File

386
modelsAPI/model_api.py Normal file
View 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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1023 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
picture/4341.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
picture/OIP-C.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View 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"
}
]

1243
prompts.py Normal file

File diff suppressed because it is too large Load Diff

44
requirements.txt Normal file
View 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
View 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}

41
tools/__init__.py Normal file
View File

@ -0,0 +1,41 @@
"""
工具模块
包含RAGGraphRAGVLMASRTTS等工具
"""
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"
]

View 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
View 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
View 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
View File

@ -0,0 +1,253 @@
"""
工具函数定义
包含RAGGraphRAGVLMASRTTS等工具的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]
}

1792
tools/graph_tools.py Normal file

File diff suppressed because it is too large Load Diff

182
tools/pdf_generator.py Normal file
View 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
View 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]:
"""
文件转文本工具
支持多种文件格式PDFWordTXT图片等
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:
"""
文件转文本工具供工作流调用
支持多种文件格式PDFWordTXT图片等
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
View 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

251
tools/test.py Normal file
View File

@ -0,0 +1,251 @@
# -*- coding: utf-8 -*-
import os
import ast
import re
import asyncio
import httpx
from typing import List, Dict
from openai import AsyncOpenAI
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
from config import LLM_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_v2"],
api_key=EMBEDDING_CONFIG["api_key"],
)
return _embedder
class LocalBgeM3Embeddings(Embedder):
def __init__(self, base_url: str, api_key: str, model_name: str = "bge-m3"):
self.base_url = base_url.rstrip("/")
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 + "/embeddings"
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"]]
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 = AsyncOpenAI(
api_key=LLM_CONFIG["api_key"],
base_url=LLM_CONFIG["base_url"],
)
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
# ================== 辅助:解析 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表字段映射表
"故障模式" : "fault",
"系统" : "system_name",
"设备" : "device_name",
"舷号" : "ship_number",
其中键表示实体类型值表示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 open_api_chat_without_thinking(
system_prompt=SYSTEM_PROMPT,
query=PROMPT_TEMPLATE.format(results=results, user_question=user_question),
)
return res.strip()
# ================== 入口 ==================
if __name__ == "__main__":
answer = asyncio.run(get_extra_info("发动机这个月发生了多少次故障"))
print(111111111111111111)
print(answer)

776
tools/text2sql_tool.py Normal file
View File

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

0
tools/texttosql_utils.py Normal file
View File

298
tools/vlm_tools.py Normal file
View 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![用户上传图片]({image_url})"
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

File diff suppressed because it is too large Load Diff

4
utils/__init__.py Normal file
View File

@ -0,0 +1,4 @@
"""
工具模块
"""

360
utils/function_tracker.py Normal file
View 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___detailsdetails 或键名含 "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
View File

@ -0,0 +1,201 @@
"""
图片处理工具模块 - 最终版 v4
核心思路最简单直接
1. 找到所有 .jpg/.png 文件名及其周围的 ![图片]( 上下文
2. 不管周围格式如何也不管文件名是否被模型改过
3. 统一提取文件名 -> original_paths 模匹 -> 替换成正确的 ![图片](完整URL)
"""
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 图片链接 - 两轮处理版
算法
第一轮检查和修改格式确保是 ![图片](/api/v1/knowledge/files/images/xxx.jpg) 格式
第二轮只检查和替换最后文件名部分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:
"""
第一轮规范化图片格式确保所有图片都是正确的 ![图片](/api/v1/knowledge/files/images/xxx.jpg) 格式
处理各种格式破损的情况并确保有正确的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
# 向前查找是否已经有 ![图片]( 标记
lookback_limit = max(0, file_start - 100)
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 '![图片](' not in middle:
mark_end = file_end + close_paren_pos + 1
# 提取纯文件名(去掉可能的路径前缀)
pure_filename = filename_with_ext.split('/')[-1]
# 构建完整的标准格式包括固定URL前缀
full_url = base_prefix.rstrip('/') + '/images/' + pure_filename
standard_format = f'![图片]({full_url})'
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'![图片](\1)', result, flags=re.IGNORECASE)
return result
def _normalize_image_paths(text: str, base_prefix: str, original_paths: List[str]) -> str:
"""
第二轮只规范化最后文件名部分xxx.jpg与原始路径匹配匹配度低的删除
步骤
1. 找到所有 ![图片](URL) 格式的图片
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'![图片]({full_url})'
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 """【图片格式特别要求】
- 检索结果中的图片链接格式通常为`![图片](/api/v1/knowledge/files/images/xxx.jpg)`
- 输出时必须严格保留此格式不得修改省略或截断
- 图片的 alt 文本必须保持为图片
- 图片 URL 必须完整包含 `/api/v1/knowledge/files/images` 前缀
- 不要将图片链接转换为纯文本描述
- 如果有多个图片在对应位置分别引用保持原始顺序
- 严禁修改图片文件名或路径结构
- 如果需要展示图片直接使用原始的 `![图片](URL)` 格式即可"""

73
utils/intent_matcher.py Normal file
View 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
View 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
View 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
View 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
View 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)
- ![alt]`url`
- ! `url`
返回 [(start, end, url), ...]
"""
results = []
# 格式1: ![alt](url)
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 } $
### **四、图片测试**
![图片](http://192.168.0.46:11000/api/v1/knowledge/files/images/56b1c0a02ff03d9d202627e88603e6671d3f193511d98a9fab0daf8687e6a9fa.jpg)
"""
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
View 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行业规范船级社要求或管理制度**
- 关键词法规标准规范规定合规条例依据要求准则制度认证资质船级社CCSIMOSOLASMARPOL资格
- 示例
船舶主机修理需遵循哪些GJB标准
IMO对主机排放有哪些强制要求
舰船维修单位需要哪些资质
保密资格申请流程是什么
- 注意若问题提到标准里有没有但未指明具体法规/标准名称且无法规标准等关键词可先归为 qa一旦明确提及法规标准规范SOLAS则归为 rules
""",
"other": """
- 特征**完全不涉及新的技术问题故障诊断维修请求备件统计法规查询或报告生成**而是针对**已有对话内容方案步骤或文本的反馈修正补充顺序调整或格式优化**典型场景包括
指出之前提供的操作步骤有误异物清理步骤有误
要求调整操作顺序这一步应当先怎样然后再怎么操作最后
对生成内容提出修改意见步骤有误/有补充请重写第三点
请求优化语言格式或结构请用更简洁的方式表达
闲聊非技术性确认或与当前技术任务无关的交互
- 注意
**只要用户意图是修正补充或调整已有内容而非提出新问题即使涉及技术术语也应归为 other**
不要将此类反馈误判为 repair qa它们不是在问该怎么修原理是什么而是在说你刚才说的不对/不全
此类通常出现在多轮对话中是对前文输出的回应
""",
"report": """
- 特征用户明确要求**生成整理导出或汇总**某种形式的文档报表清单或总结材料通常涉及将分散的故障记录维修历史备件数据或问答内容整合成结构化文本如日报周报故障分析报告维修总结备件消耗表等
- 关键词生成报告写总结导出表格整理清单汇总数据形成文档日报周报月报分析报告维修记录单台账报表
- 示例
请根据本周的故障记录生成一份主机系统运行分析报告
总结这次对话
导出过去一年的备件消耗统计报告
帮我写一份关于此次主机停机事故的详细处理总结
"""
}
VALID_ROUTE_FLAGS = tuple(WORKFLOW_CONFIG.keys())

20
workflows/__init__.py Normal file
View File

@ -0,0 +1,20 @@
"""
工作流模块
包含所有工作流Agent
"""
from .workflow_fault_diagnosis import run_fault_diagnosis_workflow
# 其他工作流暂未实现,待后续添加
# from .workflow_repair import run_repair_workflow
# from .workflow_statistics import run_statistics_workflow
# from .workflow_qa import run_qa_workflow
# from .workflow_report import run_report_workflow
__all__ = [
"run_fault_diagnosis_workflow",
# "run_repair_workflow",
# "run_statistics_workflow",
# "run_qa_workflow",
# "run_report_workflow"
]

View File

@ -0,0 +1,217 @@
"""
统一历史对话管理器
整合所有分散的 parse_history / _parse_history / build_history_str / filter_image_urls 逻辑
"""
import json
from typing import Any, Dict, List, Optional
class HistoryManager:
"""统一的历史对话管理器,提供解析、截断、格式化、过滤等功能"""
def __init__(
self,
max_messages: int = 8,
max_assistant_chars: int = 300,
):
"""
Args:
max_messages: 保留的最近消息条数
max_assistant_chars: assistant 角色内容截断字符数
"""
self.max_messages = max_messages
self.max_assistant_chars = max_assistant_chars
# ==================== 解析 ====================
def parse(self, history_message: Any) -> List[Dict[str, Any]]:
"""
解析历史消息为列表保留最近 max_messages
兼容字符串和列表输入替代所有 parse_history / _parse_history
"""
if not history_message:
return []
try:
data = json.loads(history_message) if isinstance(history_message, str) else history_message
return data[-self.max_messages:] if isinstance(data, list) else []
except Exception:
return []
# ==================== 截断 ====================
def truncate(self, messages: List[Dict[str, Any]], keep_recent: Optional[int] = None) -> List[Dict[str, Any]]:
"""按数量截断,保留最近 keep_recent 条"""
if not messages:
return []
n = keep_recent if keep_recent is not None else self.max_messages
return messages[-n:]
# ==================== 格式化 ====================
def to_prompt_str(
self,
messages: List[Dict[str, Any]],
recent_count: Optional[int] = None,
assistant_truncate: Optional[int] = None,
) -> str:
"""
格式化为 "用户: xxx\\n助手: xxx" 纯文本字符串用于 prompt 注入
替代 build_history_str
"""
if not messages:
return ""
n = recent_count if recent_count is not None else self.max_messages
trunc = assistant_truncate if assistant_truncate is not None else self.max_assistant_chars
recent = messages[-n:]
parts = []
for m in recent:
if isinstance(m, dict):
role = m.get("role", "")
content = (m.get("content") or "").strip()
if role == "user":
parts.append(f"用户: {content}")
elif role == "assistant":
parts.append(f"助手: {content[:trunc]}")
return "\n".join(parts) if parts else ""
def to_openai_messages(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
格式化为 OpenAI messages 列表直接传入 LLM
用于百科问答等需要完整消息格式的场景
"""
if not messages:
return []
result = []
for m in messages:
if isinstance(m, dict) and m.get("role") and m.get("content"):
result.append({"role": m["role"], "content": m["content"]})
return result
# ==================== 请求预处理 ====================
def preprocess_from_request(self, history_message: Any, exclude_last: bool = True) -> str:
"""
处理前端传入的历史消息解析移除当前用户消息序列化
对应 app.py history_message 预处理逻辑
Args:
history_message: 前端传入的历史消息支持 str list
exclude_last: 是否移除最后一条当前用户消息默认 True
Returns:
处理后的 JSON 字符串
"""
if not history_message:
return ""
try:
data = json.loads(history_message) if isinstance(history_message, str) else history_message
except (json.JSONDecodeError, TypeError):
return ""
if not isinstance(data, list):
return ""
if exclude_last and data:
data = data[:-1]
return json.dumps(data, ensure_ascii=False)
# ==================== 状态初始化 ====================
def init_history(self, history_message: Any) -> Dict[str, Any]:
"""
一步完成历史消息解析返回 history_message(str) + history_messages(list) 双字段
用于各工作流入口初始化 state消除重复的 parse_history 调用
Args:
history_message: 历史消息支持 str list
Returns:
{"history_message": str, "history_messages": list}
"""
parsed = self.parse(history_message)
raw = json.dumps(parsed, ensure_ascii=False) if parsed else ""
return {"history_message": raw, "history_messages": parsed}
# ==================== 过滤 ====================
@staticmethod
def filter_image_urls(history_message: Any) -> Any:
"""
过滤历史消息中的图片URL将多模态内容转为纯文本
替代 main_agent.filter_image_urls
"""
if not history_message:
return history_message
try:
history_messages = json.loads(history_message) if isinstance(history_message, str) else history_message
except (json.JSONDecodeError, TypeError):
return history_message
if not isinstance(history_messages, list):
return history_messages
filtered_messages = []
for msg in history_messages:
if not isinstance(msg, dict):
filtered_messages.append(msg)
continue
new_msg = msg.copy()
content = msg.get("content")
if isinstance(content, list):
text_parts = []
for content_item in content:
if isinstance(content_item, dict):
if content_item.get("type") == "text":
text_value = content_item.get("text", "")
if text_value:
text_parts.append(text_value)
elif isinstance(content_item, str):
text_parts.append(content_item)
if text_parts:
new_msg["content"] = "".join(text_parts)
filtered_messages.append(new_msg)
else:
filtered_messages.append(new_msg)
return filtered_messages
# ==================== 模块级便捷实例 ====================
# 提供与旧函数签名兼容的模块级函数,方便渐进式迁移
_default_manager = HistoryManager()
def parse_history(history_message: Any) -> List[Dict[str, Any]]:
"""兼容旧调用的模块级函数"""
return _default_manager.parse(history_message)
def build_history_str(
history_messages: List[Dict[str, Any]],
recent_count: int = 4,
assistant_truncate: int = 300,
) -> str:
"""兼容旧调用的模块级函数"""
mgr = HistoryManager(max_messages=recent_count, max_assistant_chars=assistant_truncate)
return mgr.to_prompt_str(history_messages, recent_count=recent_count, assistant_truncate=assistant_truncate)
def filter_image_urls(history_message: Any) -> Any:
"""兼容旧调用的模块级函数"""
return HistoryManager.filter_image_urls(history_message)
def preprocess_from_request(history_message: Any, exclude_last: bool = True) -> str:
"""兼容旧调用的模块级函数"""
return _default_manager.preprocess_from_request(history_message, exclude_last=exclude_last)
def init_history(history_message: Any) -> Dict[str, Any]:
"""兼容旧调用的模块级函数"""
return _default_manager.init_history(history_message)

593
workflows/workflow_baike.py Normal file
View File

@ -0,0 +1,593 @@
"""
工作流普通问答Agent使用LangGraph
输入文本转化后和原文本query图片描述语音文本
步骤
1. 使用RAG和GraphRAG检索相关信息
2. 生成回答
使用 OpenaiAPI 方法调用大模型尽可能使用异步
"""
import sys
import os
# 添加项目根目录到Python搜索路径
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from typing import TypedDict, Optional, Dict, Any, List
from langgraph.graph import StateGraph, START, END
from workflows.history_manager import init_history
from tools.function_tool import (
graph_rag_search
)
from tools.rag_tools import rag_search
from modelsAPI.model_api import OpenaiAPI
from utils.function_tracker import track_function_calls, get_current_stream_handler, get_all_callbacks
from utils.image_utils import extract_image_paths, find_closest_image_path, normalize_markdown_images, get_image_prompt_guidance
from prompts import BAIKE_PROMPTS, COMMON_PROMPTS
import asyncio
import json
import re
import random
# =============== 定义状态 ===============
class QAState(TypedDict):
"""普通问答工作流状态"""
extracted_text: str # 已提取的文本(统一接口,只接收文本)
combined_query: str
retrieval_query: str # 用于RAG/图谱检索的重写后query
route_flag: str # 路由标识
history_message: str # 历史消息JSON格式
history_messages: List[Dict[str, Any]] # 过滤后的历史消息列表
need_retrieval: Optional[bool] # 是否需要检索
# RAG检索结果
rag_search_result: Optional[List[Dict[str, Any]]] # RAG检索结果
graph_search_result: Optional[str] # 图谱检索结果
# 最终响应
response: str # 最终响应
actions: List[Dict[str, Any]] # 按钮信息列表 [{"type": "...", "label": "...", "icon": "..."}]
suggestedReplies: List[Dict[str, Any]] # 建议回复问题列表
error_message: str # 错误信息
# =============== 节点函数 ===============
def get_baike_history_context(state: QAState) -> Dict[str, Any]:
"""
知识问答步骤获取问答类历史信息
问答工作流只关注历史问答记录用于提供上下文
"""
history_message = state.get("history_message", "") or ""
return init_history(history_message)
async def judge_need_retrieval(state: QAState) -> Dict[str, Any]:
"""
判断用户问题是否需要检索知识库
"""
original_query = (state.get("combined_query") or state.get("extracted_text") or "").strip()
history_messages = state.get("history_messages", []) or []
prompt = BAIKE_PROMPTS["judge_need_retrieval"].format(
history_messages=history_messages if history_messages else '',
original_query=original_query
)
need_retrieval = True
try:
result = await OpenaiAPI.open_api_chat_without_thinking(prompt, model=None)
if result:
result = result.strip().lower()
if result == "false":
need_retrieval = False
except Exception as e:
print(f"判断是否需要检索失败: {e}")
need_retrieval = True
print(f"是否需要检索: {need_retrieval}")
return {"need_retrieval": need_retrieval}
async def rewrite_query_with_history(state: QAState) -> Dict[str, Any]:
"""
使用历史上下文对当前问题进行query重写生成适合RAG/图谱检索的独立查询语句
只让大模型在有限的最近历史中挑选与当前问题语义相关的内容进行融合避免简单拼接全量历史
"""
import json
# 原始问题:优先使用 combined_query其次使用 extracted_text
original_query = (state.get("combined_query") or state.get("extracted_text") or "").strip()
history_messages = state.get("history_messages", []) or []
# 控制历史长度:只保留最近的若干条,避免上下文过长
trimmed_history = history_messages[-8:] if history_messages else []
try:
history_str = json.dumps(trimmed_history, ensure_ascii=False)
except Exception:
history_str = str(trimmed_history)
# 如果没有历史或当前问题过短,就直接跳过重写
if not original_query:
return {"retrieval_query": original_query}
# 在构造 prompt 前,提取上一轮用户 query如果有
last_user_query = ""
if trimmed_history:
# 从后往前找最近一条 role == "user" 的消息
for msg in reversed(trimmed_history):
if msg.get("role") == "user":
last_user_query = msg.get("content", "").strip()
break
prompt = BAIKE_PROMPTS["rewrite_query_with_history"].format(
last_user_query=last_user_query or '(无)',
history_str=history_str,
original_query=original_query
)
rewritten_query = original_query
try:
result = await OpenaiAPI.open_api_chat_without_thinking(prompt, model=None)
if result:
candidate = result.strip().strip("“”\"'")
# 只在模型返回的内容明显为单行短文本时替换,避免异常输出
if candidate and len(candidate) <= 200 and "\n" not in candidate:
rewritten_query = candidate
except Exception as e:
print(f"query 重写失败: {e}")
title_options = [f"✨正在分析中,请稍等..."]
start_event = {
"type": "function_execution",
"title": random.choice(title_options),
"details": f"正在调取百科数据..."
}
for callback in get_all_callbacks():
try:
callback(start_event)
except Exception:
pass
print("原始检索query:", original_query)
print("重写后检索query:", last_user_query+rewritten_query)
return {"retrieval_query":last_user_query+rewritten_query}
@track_function_calls
async def search_rag_and_graph(state: QAState) -> Dict[str, Any]:
"""
进行文本知识检索和图谱知识检索
"""
kb_id = None
if state.get("route_flag") == "rules":
kb_id = '3'
# 优先使用重写后的检索query其次使用 combined_query最后回退到原始 extracted_text
query = state.get("retrieval_query") or state.get("combined_query") or state.get("extracted_text", "")
if "用户问题:" in query:
query = query.split("用户问题:", 1)[1]
print("Graph 检索query", query)
extracted_text = state.get("extracted_text", "")
if "用户问题:" in extracted_text:
extracted_text = extracted_text.split("用户问题:", 1)[1]
try:
# 异步并行调用RAG检索和图谱检索
print(kb_id)
print(33333333333333333333333333333333333333)
async def _do_rag_search():
if kb_id:
return await rag_search(
query=extracted_text,
kb_id=kb_id,
top_k=2
)
else:
return await rag_search(
query=extracted_text,
top_k=2
)
async def _do_graph_search():
return await graph_rag_search.ainvoke({
"query": query,
"top_k": 3
})
rag_result, graph_results = await asyncio.gather(
_do_rag_search(),
_do_graph_search()
)
# 处理 RAG 结果,提取成列表格式
rag_search_result = []
if rag_result.get("success", False):
source_citation = rag_result.get("sourceCitation", {})
for resource, items in source_citation.items():
for item in items:
rag_search_result.append({
"text": item.get("text", ""),
"filename": item.get("filename", ""),
"resource": item.get("resource", ""),
"score": item.get("score", 0.0)
})
# 只保留前 4 条数据
# rag_search_result = rag_search_result[:2]
rag_search_result = sorted(rag_search_result,key=lambda x:float(x.get("socre") or 0), reverse=True)[:2]
return {
"rag_search_result": rag_search_result,
"graph_search_result": graph_results if isinstance(graph_results, str) else ""
}
except Exception as e:
return {
"rag_search_result": [],
"graph_search_result": "",
"error_message": f"检索失败: {str(e)}"
}
async def generate_suggested_replies_baike(answer: str, question: str) -> List[Dict[str, Any]]:
"""
生成针对问答结果的建议回复问题
使用大模型生成两个相关问题
"""
prompt = BAIKE_PROMPTS["generate_suggested_replies"].format(
question=question,
answer=answer[:500]
)
try:
result = await OpenaiAPI.open_api_chat_without_thinking(prompt, model=None, json_output=True)
# 尝试解析JSON
# 提取JSON部分
json_match = re.search(r'\[.*\]', result, re.DOTALL)
if json_match:
json_str = json_match.group(0)
suggested_replies = json.loads(json_str)
# 验证格式
if isinstance(suggested_replies, list) and len(suggested_replies) >= 2:
# 确保每个元素都有title和content
formatted_replies = []
for reply in suggested_replies[:2]:
if isinstance(reply, dict) and "title" in reply and "content" in reply:
formatted_replies.append({
"title": str(reply["title"]),
"content": str(reply["content"])
})
if len(formatted_replies) == 2:
return formatted_replies
# 如果解析失败,返回默认问题
return [
{"title": "这个设备的具体工作原理是什么?", "content": "这个设备的具体工作原理是什么?"},
{"title": "还有哪些相关的知识点?", "content": "还有哪些相关的知识点?"}
]
except Exception as e:
# 如果生成失败,返回默认问题
return [
{"title": "这个设备的具体工作原理是什么?", "content": "这个设备的具体工作原理是什么?"},
{"title": "还有哪些相关的知识点?", "content": "还有哪些相关的知识点?"}
]
@track_function_calls
async def generate_answer(state: QAState) -> Dict[str, Any]:
"""
正在生成问答结果
"""
extracted_text = state.get("combined_query", "")
rag_results = state.get("rag_search_result", [])
graph_results = state.get("graph_search_result", "")
history_messages = state.get("history_messages", []) or []
need_retrieval = state.get("need_retrieval", True)
print("history_message(qa)", history_messages)
print("是否使用检索结果:", need_retrieval)
try:
# 1. 处理检索结果(如果有)
rag_ctx_parts: List[str] = []
file_name_list = []
all_source_text = ""
original_image_paths = []
if need_retrieval:
# 收集所有 RAG 结果文本(只取前 4 条)
for item in sorted(rag_results or [], key=lambda x: float(x.get("score") or 0) if isinstance(x,dict) else 0, reverse=True)[:2]:
# for item in (rag_results or [])[:1]:
print(item)
print(111111111111111111111111111)
if isinstance(item, dict):
text = (item.get("text") or "").strip()
file_name = (item.get("filename") or "").strip()
if text:
rag_ctx_parts.append(text)
if file_name:
file_name_list.append(file_name)
# 处理图谱结果
print("123456", graph_results)
if graph_results and "图谱检索完成,但未返回有效数据。" in graph_results:
graph_results = ""
print("654321", graph_results)
# 从所有结果中提取图片路径
if graph_results:
all_source_text = graph_results + "\n" + "\n".join(rag_ctx_parts)
else:
all_source_text = "\n".join(rag_ctx_parts)
original_image_paths = extract_image_paths(all_source_text)
print("提取到的原始图片路径:", original_image_paths)
# 发送事件通知(可选)
title_options = [f"✨已综合 {len(rag_ctx_parts)} 条检索结果", f"✨本次检索策略为综合多源信息"]
start_event = {
"type": "function_execution",
"title": random.choice(title_options),
"details": f"综合了 {len(rag_ctx_parts)} 条检索结果"
}
for callback in get_all_callbacks():
try:
callback(start_event)
except Exception:
pass
query_desc = extracted_text if extracted_text else "当前未明确描述问题"
# 构建系统提示词
if state.get("route_flag") == "rules":
domain_desc = "舰船修理相关的法规、规范、行业标准及技术文件"
else:
domain_desc = "工业设备的结构原理、维护规程、故障诊断与维修操作"
# ========== 第一步:专注于生成高质量内容 ==========
# 根据是否有检索结果构建不同的提示词
if need_retrieval and all_source_text.strip():
content_system_prompt = BAIKE_PROMPTS["generate_answer_with_retrieval_system"].format(domain_desc=domain_desc)
content_user_content = BAIKE_PROMPTS["generate_answer_with_retrieval_user"].format(
extracted_text=extracted_text,
rag_count=len(rag_ctx_parts),
all_source_text=all_source_text
)
else:
content_system_prompt = BAIKE_PROMPTS["generate_answer_without_retrieval_system"].format(domain_desc=domain_desc)
content_user_content = BAIKE_PROMPTS["generate_answer_without_retrieval_user"].format(extracted_text=extracted_text)
content_messages = []
if history_messages:
content_messages.extend(history_messages)
content_messages.append({"role": "user", "content": content_user_content})
# 第一步:生成内容
raw_content = await OpenaiAPI.open_api_chat_without_thinking(
model=None,
system_prompt=content_system_prompt,
messages=content_messages,
temperature=0.5
)
# 过滤掉异常标签(如果有)
raw_content = re.sub(r'ynchroneg>.*?ost switching>', '', raw_content, flags=re.DOTALL)
raw_content = raw_content.strip() if raw_content else "抱歉,无法生成回答。"
title_options = ["🤖 诊断分析中", "🤖 故障预检中"]
start_event = {
"type": "function_execution",
"title": random.choice(title_options),
"details": raw_content[:30] + "..."
}
for callback in get_all_callbacks():
try:
callback(start_event)
except Exception:
pass
# ========== 第二步:专注于规范格式 ==========
# 构建第二步的提示词:只关注格式规范,不修改内容
format_system_prompt = COMMON_PROMPTS["format_system"]
format_user_content = COMMON_PROMPTS["format_user_with_image_examples"].format(raw_content=raw_content)
# 第二步:规范格式(流式输出)
stream_handler = get_current_stream_handler()
answer = ""
accumulated_content = "" # 用于逐步发送给前端的内容
# 使用 async for 遍历流式生成器
async for chunk in OpenaiAPI.open_api_chat_stream(
model=None,
system_prompt=format_system_prompt,
messages=[{"role": "user", "content": format_user_content}],
temperature=0.1
):
answer += chunk
accumulated_content += chunk
# 每收到新的 chunk 就发送一次更新
if stream_handler:
stream_handler.send_stream_content(accumulated_content)
answer = answer.strip() if answer else raw_content
# LaTeX 公式空格规范化
def normalize_latex_spacing(text: str) -> str:
placeholder = "\x00DOUBLE_DOLLAR\x00"
text = text.replace("$$", placeholder)
text = re.sub(r'(?<!\s)\$', r' $', text)
text = re.sub(r'\$(?!\s)', r'$ ', text)
text = text.replace(placeholder, "$$")
text = re.sub(r'(?<!\s)\$\$', r' $$', text)
text = re.sub(r'\$\$(?!\s)', r'$$ ', text)
return text
answer = normalize_latex_spacing(answer)
# 使用增强的图片路径匹配与规范化(仅在有检索结果时)
if need_retrieval and original_image_paths:
answer = normalize_markdown_images(answer, original_paths=original_image_paths)
print("llm output", answer)
# 生成建议回复问题
suggested_replies = await generate_suggested_replies_baike(answer, extracted_text)
return {
"response": answer,
"actions": [],
"result_tag": "qa",
"suggestedReplies": suggested_replies
}
except Exception as e:
return {
"response": f"生成回答失败:{str(e)}",
"actions": [],
"suggestedReplies": []
}
def route_after_judge(state: QAState) -> str:
"""
判断后的路由根据need_retrieval决定下一步
"""
need_retrieval = state.get("need_retrieval", True)
if need_retrieval:
return "rewrite_query_with_history"
else:
return "generate_answer"
# =============== 构建工作流 ===============
def create_baike_workflow():
"""创建普通问答工作流"""
workflow = StateGraph(QAState)
# 添加节点
workflow.add_node("get_baike_history_context", get_baike_history_context)
workflow.add_node("judge_need_retrieval", judge_need_retrieval)
workflow.add_node("rewrite_query_with_history", rewrite_query_with_history)
workflow.add_node("search_rag_and_graph", search_rag_and_graph)
workflow.add_node("generate_answer", generate_answer)
# 设置边
workflow.add_edge(START, "get_baike_history_context")
workflow.add_edge("get_baike_history_context", "judge_need_retrieval")
workflow.add_conditional_edges(
"judge_need_retrieval",
route_after_judge,
{
"rewrite_query_with_history": "rewrite_query_with_history",
"generate_answer": "generate_answer"
}
)
workflow.add_edge("rewrite_query_with_history", "search_rag_and_graph")
workflow.add_edge("search_rag_and_graph", "generate_answer")
workflow.add_edge("generate_answer", END)
# 编译工作流
return workflow.compile()
# =============== 工作流执行函数 ===============
async def run_baike_workflow(
extracted_text: str,
combined_query: str,
history_message: str = "",
route_flag: str = ""
) -> Dict[str, Any]:
"""
执行普通问答工作流统一接口
Args:
extracted_text: 文本描述统一的输入参数
history_message: 历史消息目前只接收不做处理
Returns:
工作流执行结果
"""
# 创建并编译工作流
app = create_baike_workflow()
# 初始化状态统一接口只使用extracted_text和history_message
initial_state = {
"extracted_text": extracted_text,
"combined_query": combined_query,
"retrieval_query": "",
"route_flag": route_flag,
"history_message": history_message,
"history_messages": [],
"need_retrieval": None,
"rag_search_result": None,
"graph_search_result": None,
"response": "",
"actions": [],
"suggestedReplies": [],
"error_message": ""
}
try:
# 执行工作流(使用异步方式)
final_state = await app.ainvoke(initial_state)
# 检查是否有错误
if final_state.get("error_message"):
return {
"response": f"普通问答工作流执行失败: {final_state['error_message']}",
"actions": [],
"result_tag": "qa",
"suggestedReplies": []
}
# 统一输出格式:完全透传 generate_answer 的结果
return {
"response": final_state.get("response", ""),
"actions": final_state.get("actions", []),
"result_tag": "qa",
"suggestedReplies": final_state.get("suggestedReplies", [])
}
except Exception as e:
return {
"response": f"普通问答工作流执行失败: {str(e)}",
"actions": [],
"result_tag": "qa",
"suggestedReplies": []
}
# =============== 测试 ===============
if __name__ == "__main__":
import asyncio
# 测试用例
test_cases = [
"船舰主发动机的安全警告是什么",
]
async def test():
for i, test_text in enumerate(test_cases, 1):
print(f"\n{'='*60}")
print(f"测试用例 {i}")
print(f"{'='*60}")
# ✅ 修复:传入 combined_query 参数
result = await run_baike_workflow(
extracted_text=test_text,
combined_query=test_text
)
print(f"\n执行结果:")
print(f"成功:{result.get('success', False)}")
print(f"\n响应内容:")
print(result.get("response", "无响应"))
asyncio.run(test())

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

293
workflows/workflow_other.py Normal file
View File

@ -0,0 +1,293 @@
"""
工作流普通问答Agent使用LangGraph
输入文本转化后和原文本query图片描述语音文本
步骤
1. 使用RAG和GraphRAG检索相关信息
2. 生成回答
使用 OpenaiAPI 方法调用大模型尽可能使用异步
"""
from typing import TypedDict, Optional, Dict, Any, List
from langgraph.graph import StateGraph, START, END
from modelsAPI.model_api import OpenaiAPI
from utils.function_tracker import track_function_calls, get_current_stream_handler
import asyncio
import json
import re
# =============== 定义状态 ===============
class Othertate(TypedDict):
"""普通问答工作流状态"""
extracted_text: str # 已提取的文本(统一接口,只接收文本)
combined_query: str
route_flag: str # 路由标识
history_message: str # 历史消息JSON格式
history_messages: List[Dict[str, Any]] # 过滤后的历史消息列表
# RAG检索结果
rag_search_result: Optional[List[Dict[str, Any]]] # RAG检索结果
graph_search_result: Optional[str] # 图谱检索结果
# 最终响应
response: str # 最终响应
actions: List[Dict[str, Any]] # 按钮信息列表 [{"type": "...", "label": "...", "icon": "..."}]
suggestedReplies: List[Dict[str, Any]] # 建议回复问题列表
error_message: str # 错误信息
# =============== 节点函数 ===============
def get_other_history_context(state: Othertate) -> Dict[str, Any]:
"""
知识问答步骤获取问答类历史信息
问答工作流只关注历史问答记录用于提供上下文
支持格式[{'role': 'user', 'content': '...'}, {'role': 'assistant', 'content': '...'}]
返回消息列表history_message已经在app.py中过滤过了
"""
import json
history_message = state.get("history_message", "") or ""
history_messages = []
if history_message:
try:
# history_message已经在app.py中过滤过了直接解析
if isinstance(history_message, str):
history_data = json.loads(history_message)
else:
history_data = history_message
if isinstance(history_data, list):
# 只取最近10条历史记录避免上下文过长
history_messages = history_data[-10:]
except Exception as e:
pass
return {"history_messages": history_messages}
async def generate_suggested_replies_other(answer: str, question: str) -> List[Dict[str, Any]]:
"""
生成针对问答结果的建议回复问题
使用大模型生成两个相关问题
"""
prompt = f"""
你是一个问答助手根据以下问答内容生成两个用户可能关心的后续问题
用户问题
{question}
回答内容
{answer[:500]}
请生成两个简洁实用的问题这些问题应该
1. 不得出现需要归档和下载等操作相关的问题
2. 与当前问答内容相关能够帮助用户进一步了解相关信息
3. 问题要具体可操作
4. 每个问题不超过20个字
请以JSON格式输出格式如下
[
{{"title": "问题1的标题", "content": "问题1的完整内容"}},
{{"title": "问题2的标题", "content": "问题2的完整内容"}}
]
只输出JSON不要有其他文字说明
"""
try:
result = await OpenaiAPI.open_api_chat_without_thinking(prompt, model=None)
# 尝试解析JSON
# 提取JSON部分
json_match = re.search(r'\[.*\]', result, re.DOTALL)
if json_match:
json_str = json_match.group(0)
suggested_replies = json.loads(json_str)
# 验证格式
if isinstance(suggested_replies, list) and len(suggested_replies) >= 2:
# 确保每个元素都有title和content
formatted_replies = []
for reply in suggested_replies[:2]:
if isinstance(reply, dict) and "title" in reply and "content" in reply:
formatted_replies.append({
"title": str(reply["title"]),
"content": str(reply["content"])
})
if len(formatted_replies) == 2:
return formatted_replies
# 如果解析失败,返回默认问题
return [
{"title": "这个设备的具体工作原理是什么?", "content": "这个设备的具体工作原理是什么?"},
{"title": "还有哪些相关的知识点?", "content": "还有哪些相关的知识点?"}
]
except Exception as e:
# 如果生成失败,返回默认问题
return [
{"title": "这个设备的具体工作原理是什么?", "content": "这个设备的具体工作原理是什么?"},
{"title": "还有哪些相关的知识点?", "content": "还有哪些相关的知识点?"}
]
@track_function_calls
async def generate_answer(state: Othertate) -> Dict[str, Any]:
"""
正在生成问答结果
"""
extracted_text = state.get("combined_query", "")
history_messages = state.get("history_messages", []) or []
print("history_message(qa)", history_messages)
try:
# 构建系统提示词
system_prompt = """我是AI助手根据历史信息和用户问题进行回答。
请基于以上信息给出准确专业的回答如果有历史问答上下文请结合历史对话内容理解当前问题"""
# 构建消息列表
messages = []
# 添加历史消息
if history_messages:
messages.extend(history_messages)
# 添加当前用户查询
messages.append({"role": "user", "content": f"用户问题:{extracted_text}"})
# 调用LLM生成回答异步禁用thinking
answer = await OpenaiAPI.open_api_chat_without_thinking(
model=None,
system_prompt=system_prompt,
messages=messages
)
# 过滤掉 <think> 标签(如果有)
import re
answer = re.sub(r'<think>.*?</think>', '', answer, flags=re.DOTALL)
answer = answer.strip() if answer else "抱歉,无法生成回答。"
# 返回按钮:确认结果、重新生成
actions = []
# 生成建议回复问题
suggested_replies = await generate_suggested_replies_other(answer, extracted_text)
return {
"response": answer,
"actions": actions,
"result_tag": "other",
"suggestedReplies": suggested_replies
}
except Exception as e:
return {
"response": f"生成回答失败: {str(e)}",
"actions": [],
"suggestedReplies": []
}
# =============== 构建工作流 ===============
def create_qa_workflow():
"""创建普通问答工作流"""
workflow = StateGraph(Othertate)
# 添加节点
workflow.add_node("get_other_history_context", get_other_history_context)
workflow.add_node("generate_answer", generate_answer)
# 设置边
workflow.add_edge(START, "get_other_history_context")
workflow.add_edge("get_other_history_context", "generate_answer")
workflow.add_edge("generate_answer", END)
# 编译工作流
return workflow.compile()
# =============== 工作流执行函数 ===============
async def run_other_workflow(
extracted_text: str,
combined_query: str,
history_message: str = "",
route_flag: str = ""
) -> Dict[str, Any]:
"""
执行普通问答工作流统一接口
Args:
extracted_text: 文本描述统一的输入参数
history_message: 历史消息目前只接收不做处理
Returns:
工作流执行结果
"""
# 创建并编译工作流
app = create_qa_workflow()
# 初始化状态统一接口只使用extracted_text和history_message
initial_state = {
"extracted_text": extracted_text,
"combined_query": combined_query,
"history_message": history_message,
"history_messages": [],
"response": "",
"actions": [],
"suggestedReplies": [],
"error_message": ""
}
try:
# 执行工作流(使用异步方式)
final_state = await app.ainvoke(initial_state)
# 检查是否有错误
if final_state.get("error_message"):
return {
"response": f"普通问答工作流执行失败: {final_state['error_message']}",
"actions": [],
"result_tag": "qa",
"suggestedReplies": []
}
# 统一输出格式:完全透传 generate_answer 的结果
return {
"response": final_state.get("response", ""),
"actions": final_state.get("actions", []),
"result_tag": "other",
"suggestedReplies": final_state.get("suggestedReplies", [])
}
except Exception as e:
return {
"response": f"普通问答工作流执行失败: {str(e)}",
"actions": [],
"result_tag": "other",
"suggestedReplies": []
}
# =============== 测试 ===============
if __name__ == "__main__":
import asyncio
# 测试用例
test_cases = [
"什么是主机?",
"主机的作用是什么?"
]
async def test():
for i, test_text in enumerate(test_cases, 1):
print(f"\n{'=' * 60}")
print(f"测试用例 {i}")
print(f"{'=' * 60}")
result = await run_other_workflow(extracted_text=test_text)
print(f"\n执行结果:")
print(f"成功: {result.get('success', False)}")
print(f"\n响应内容:")
print(result.get("response", "无响应"))
asyncio.run(test())

View File

@ -0,0 +1,572 @@
"""
通用报告生成工作流
统一接口说明
- 输入extracted_text用户输入文本history_message历史消息
- 输出{"response": str, "actions": List[Dict], "route_flag": str}
功能
1. 从历史对话中提取关键信息
2. 根据用户需求生成通用报告
3. 支持图片插入到报告对应位置
"""
import asyncio
import re
import random
from typing import TypedDict, List, Dict, Any, Literal
from langgraph.graph import StateGraph, START, END
import os
import sys
current_dir = os.path.dirname(os.path.abspath(__file__))
parent_dir = os.path.dirname(current_dir)
if parent_dir not in sys.path:
sys.path.append(parent_dir)
from modelsAPI.model_api import OpenaiAPI
from utils.function_tracker import track_function_calls
from utils.word_generator import generate_report_word
from config import SERVER_CONFIG
import json
def _extract_images_from_text(text: str) -> List[Dict[str, str]]:
"""从文本中提取图片URL和描述信息"""
if not text:
return []
images = []
url_pattern = r'https?://[^\s<>"{}|\\^`\[\]]+\.(?:png|jpg|jpeg|gif|bmp|webp)'
urls = re.findall(url_pattern, text, re.IGNORECASE)
for url in urls:
url = url.strip()
if url.startswith('`') and url.endswith('`'):
url = url[1:-1]
summary = ""
url_index = text.find(url)
if url_index != -1:
after_url = text[url_index + len(url):]
result_match = re.search(
r'(?:\*\*图片识别结果:\*\*|图片识别结果[:]|识别结果[:])\s*(.*?)(?=\n\n\n|\n\n[A-Za-z\u4e00-\u9fa5]|$)',
after_url,
re.DOTALL
)
if result_match:
summary = result_match.group(1).strip()[:300]
images.append({
"url": url,
"summary": summary or "用户上传的图片"
})
return images
from workflows.history_manager import init_history, build_history_str
class OtherReportState(TypedDict):
"""通用报告工作流状态"""
extracted_text: str
combined_query: str
history_message: str
history_messages: List[Dict[str, Any]]
report_title: str
report_theme: str
report_text: str
extracted_images: List[Dict[str, str]]
response: str
actions: List[Dict[str, Any]]
route_flag: Literal["", "report"]
suggestedReplies: List[Dict[str, Any]]
error_message: str
async def extract_report_info(state: OtherReportState) -> Dict[str, Any]:
"""
步骤一提取报告相关信息
"""
from utils.function_tracker import get_all_callbacks
combined_query = state.get("combined_query", "") or state.get("extracted_text", "") or ""
history_messages = state.get("history_messages", [])
history_message = state.get("history_message", "")
extracted_images = _extract_images_from_text(history_message)
history_context = build_history_str(history_messages, recent_count=10, assistant_truncate=200)
if not history_context and history_message:
history_context = history_message
loading_prompts = [
{"title": "提取报告相关信息中...", "details": f"历史消息数: {len(history_messages)}"},
{"title": "🔍 正在分析历史对话内容...", "details": f"共处理 {len(history_messages)} 条消息"},
{"title": "📊 解析报告信息中...", "details": f"已加载 {len(history_messages)} 条历史记录"},
{"title": "🔑 整理对话数据...", "details": f"当前历史消息: {len(history_messages)}"},
{"title": "💬 提取关键信息...", "details": f"历史对话总数: {len(history_messages)}"},
]
selected_prompt = random.choice(loading_prompts)
parent_event = {
"type": "function_execution",
"title": selected_prompt["title"],
"details": selected_prompt["details"],
}
for callback in get_all_callbacks():
try:
callback(parent_event)
except Exception:
pass
prompt = f"""
你是一名专业的报告编写专家
请根据以下信息生成报告标题和主题
用户输入
{combined_query}
历史对话上下文
{history_context if history_context else "(无)"}
输出要求
输出一个JSON对象包含以下字段
- report_title报告标题简洁明了不超过20个字格式根据内容自动判断"XXX分析报告""XXX总结报告"
- report_theme报告主题用一句话概括报告的核心主题用于指导后续报告内容生成不超过50个字
仅输出 JSON 对象不要附加任何解释
"""
try:
result = await OpenaiAPI.open_api_chat_without_thinking(
query=prompt,
model=None,
json_output=True
)
parsed_result = json.loads(result)
if isinstance(parsed_result, dict):
report_title = parsed_result.get("report_title", "通用报告")
report_theme = parsed_result.get("report_theme", "")
else:
report_title = "通用报告"
report_theme = ""
except Exception as e:
report_title = "通用报告"
report_theme = ""
return {"report_title": report_title, "report_theme": report_theme, "extracted_images": extracted_images}
async def generate_report_text(state: OtherReportState) -> Dict[str, Any]:
"""
步骤二生成报告文本
"""
from utils.function_tracker import get_all_callbacks
import time
report_title = state.get("report_title", "通用报告")
report_theme = state.get("report_theme", "")
combined_query = state.get("combined_query", "") or state.get("extracted_text", "") or ""
history_messages = state.get("history_messages", [])
extracted_images = state.get("extracted_images", []) or []
loading_prompts = [
{"title": "✍️ 生成报告文本中...", "details": f"报告标题: {report_title}"},
{"title": "🎯 正在撰写报告...", "details": f"报告标题: {report_title}"},
{"title": "📋 构建报告中...", "details": f"报告标题: {report_title}"},
{"title": "📝 汇总生成报告...", "details": f"报告标题: {report_title}"},
]
selected_prompt = random.choice(loading_prompts)
start_event = {
"type": "function_execution",
"title": selected_prompt["title"],
"details": selected_prompt["details"]
}
for callback in get_all_callbacks():
try:
callback(start_event)
except Exception:
pass
history_context = build_history_str(history_messages, recent_count=10, assistant_truncate=300)
if not history_context and not combined_query:
return {
"report_text": "",
"response": "【无法生成报告】\n\n缺少历史对话信息和用户输入,请先进行相关对话。",
"actions": [],
"route_flag": "report",
"suggestedReplies": [],
"error_message": "缺少历史对话信息和用户输入"
}
random_letters = ''.join(random.choices('ABCDEFGHIJKLMNOPQRSTUVWXYZ', k=2))
report_number = time.strftime(f"{random_letters}-%Y-%m-%d-%H%M")
compile_time = time.strftime("%Y-%m-%d %H:%M")
image_section = ""
if extracted_images:
image_parts = []
for idx, img in enumerate(extracted_images, 1):
image_parts.append(f"图片{idx}{img['url']}")
summary = img.get('summary') or img.get('description', '')
if summary:
image_parts.append(f"识别结果:{summary}")
image_parts.append("")
image_section = "\n".join(image_parts)
prompt = f"""
<role>
你是一名专业的报告编写专家
你的任务是根据用户需求和历史对话生成一份正式的报告
</role>
<user_requirement>
{combined_query}
</user_requirement>
<report_theme>
{report_theme if report_theme else "根据内容自动确定"}
</report_theme>
<history_context>
{history_context if history_context else "(无历史对话上下文)"}
</history_context>
{f'''<user_images>
{image_section}
</user_images>''' if extracted_images else ''}
<output_rules>
必须严格遵守以下规则
1. 输出为 **正式报告文本**
2. **使用段落形式如果需要表格必须使用 Markdown 表格格式**
3. **输出内容必须基于历史对话信息不得编造不实信息**
4. 不得输出解释说明
5. 所有内容使用完整中文句子
6. **重要每个段落首行必须缩进两个中文字符使用两个全角空格"  "开头例如"  这是段落内容。"**
7. 根据报告主题自动选择合适的报告结构可以是分析报告总结报告调研报告等
8. **报告内容必须紧扣报告主题确保内容相关性和连贯性**
{"9. **报告中必须包含用户上传的图片信息在相关部分引用图片URL**" if extracted_images else ""}
</output_rules>
<title_format_rules>
**标题格式规范必须严格遵守**
1. **报告主标题**使用二级标题格式 `## **标题内容**`,例如:`## **智能运维系统项目总结报告**`
2. **章节大标题**使用三级标题格式 `### **标题内容**`,例如:`### **一、项目概述**`
3. **小节标题**使用四级标题格式 `#### **标题内容**`,例如:`#### **1. 项目背景**`
4. 所有标题必须加粗使用 `**标题内容**` 格式
5. 标题前后各空一行
</title_format_rules>
<paragraph_format_rules>
**段落格式规范必须严格遵守**
1. 每个段落必须以两个全角空格"  "开头
2. 正确示例
  本项目旨在开发一套智能运维系统用于提升企业IT运维效率
  系统采用微服务架构设计支持高并发访问
3. 错误示例缺少缩进
本项目旨在开发一套智能运维系统
4. 段落之间空一行
</paragraph_format_rules>
<report_template>
## **{report_title}**
报告编号{report_number}
编制时间{compile_time}
### **一、概述**
  介绍背景与目的
### **二、主要内容**
  阐述核心问题与关键分析过程
### **三、备品备件**
  列出本次维修或操作涉及的备品备件的名称如有机油滤芯密封圈螺栓传感器等不要输出表格如果没有涉及备件请写""
### **四、结论**
  总结结果并提出结论性观点
{"- **附件图片:**" + chr(10) + chr(10).join([f"![图片{idx}]({img['url']})" for idx, img in enumerate(extracted_images, 1)]) if extracted_images else ""}
</report_template>
<final_rule>
只输出完整报告正文不要任何解释说明每个段落首行必须使用"  "两个全角空格缩进所有标题必须使用对应级别的Markdown格式并加粗
</final_rule>
"""
try:
report_text = await OpenaiAPI.open_api_chat_without_thinking(
query=prompt,
model=None,
messages=history_messages
)
if not report_text:
report_text = "抱歉,当前无法生成报告,请稍后重试。"
word_dir = os.path.join(parent_dir, "created_word")
os.makedirs(word_dir, exist_ok=True)
import time
timestamp = time.strftime("%Y%m%d_%H%M%S")
safe_topic = re.sub(r'[\\/:*?"<>|]', '_', report_title)
file_name = f"{safe_topic}_{timestamp}.docx"
output_path = os.path.join(word_dir, file_name)
word_result = generate_report_word(
markdown=report_text,
title=report_title,
output=output_path
)
actions: List[Dict[str, Any]] = []
if word_result and os.path.exists(word_result):
base_url = SERVER_CONFIG.get("base_url")
download_url = f"{base_url}/api/download/{file_name}"
actions.append({
"type": "download",
"label": f"{report_title}.docx",
"url": download_url,
"fileName": file_name,
"content": report_text
})
try:
from tools.fault_statistics import save_spare_parts_metadata, extract_spare_parts_from_text, extract_info_from_filename
spare_parts = await extract_spare_parts_from_text(report_text)
print(f"[备件提取] 提取到 {len(spare_parts)} 个备件: {spare_parts}")
# 无论有没有备件都保存元数据
device = ""
ship_number = ""
fault_name = ""
# 优先从文件名中提取(最准确)
file_info = await extract_info_from_filename(file_name)
if file_info:
if file_info.get("device"):
device = file_info["device"]
if file_info.get("ship_number"):
ship_number = file_info["ship_number"]
if file_info.get("fault_name"):
fault_name = file_info["fault_name"]
# 如果从文件名没提取全,再尝试从历史消息中提取
if not device or not ship_number or not fault_name:
if history_messages:
for msg in reversed(history_messages):
if isinstance(msg, dict) and msg.get("role") == "user":
content = msg.get("content", "")
if not device:
device_match = re.search(r'设备[名称]*[:]\s*([^\n,]+)', content)
if device_match:
device = device_match.group(1).strip()
if not ship_number:
ship_match = re.search(r'舷号[:]\s*(\d+)', content)
if ship_match:
ship_number = ship_match.group(1).strip()
print(f"[备件保存] 设备: {device}, 舷号: {ship_number}, 故障: {fault_name}, 文件: {output_path}")
save_spare_parts_metadata(output_path, spare_parts, device, ship_number, fault_name)
except Exception as e:
print(f"[备件提取错误] {str(e)}")
import traceback
traceback.print_exc()
else:
error_msg = f"\n\n[注意] Word 文档生成失败"
report_text += error_msg
suggested_replies = await generate_suggested_replies(report_text)
return {
"report_text": report_text,
"response": report_text,
"actions": actions,
"route_flag": "report",
"suggestedReplies": suggested_replies,
"__details__": "报告生成完成"
}
except Exception as e:
return {
"report_text": f"生成报告失败: {str(e)}",
"response": f"生成报告失败: {str(e)}",
"actions": [],
"route_flag": "report",
"suggestedReplies": [
{"title": "如何优化报告内容?", "content": "如何优化报告内容?"},
{"title": "有哪些改进建议?", "content": "有哪些改进建议?"}
],
"error_message": str(e),
"__details__": f"生成报告失败: {str(e)}"
}
async def generate_suggested_replies(report: str) -> List[Dict[str, Any]]:
"""
生成针对报告的建议回复问题
"""
prompt = f"""
你是一个专业的报告助手根据以下报告内容生成两个用户可能关心的后续问题
报告内容
{report[:500]}
请生成两个简洁实用的问题这些问题应该
1. 不得出现需要归档和下载等操作相关的问题
2. 与报告内容相关能够帮助用户更好地理解或使用报告
3. 问题要具体可操作
4. 每个问题不超过20个字
请以JSON格式输出格式如下
[
{{"title": "问题1的标题", "content": "问题1的完整内容"}},
{{"title": "问题2的标题", "content": "问题2的完整内容"}}
]
只输出JSON不要有其他文字说明
"""
try:
answer = await OpenaiAPI.open_api_chat_without_thinking(
query=prompt,
model=None
)
json_match = re.search(r'\[.*\]', answer, re.DOTALL)
if json_match:
json_str = json_match.group(0)
suggested_replies = json.loads(json_str)
if isinstance(suggested_replies, list) and len(suggested_replies) >= 2:
formatted_replies = []
for reply in suggested_replies[:2]:
if isinstance(reply, dict) and "title" in reply and "content" in reply:
formatted_replies.append({
"title": str(reply["title"]),
"content": str(reply["content"])
})
if len(formatted_replies) == 2:
return formatted_replies
return [
{"title": "如何优化报告内容?", "content": "如何优化报告内容?"},
{"title": "有哪些改进建议?", "content": "有哪些改进建议?"}
]
except Exception as e:
return [
{"title": "如何优化报告内容?", "content": "如何优化报告内容?"},
{"title": "有哪些改进建议?", "content": "有哪些改进建议?"}
]
def create_other_report_workflow():
"""创建通用报告工作流"""
graph = StateGraph(OtherReportState)
graph.add_node("extract_info", extract_report_info)
graph.add_node("generate_text", generate_report_text)
graph.add_edge(START, "extract_info")
graph.add_edge("extract_info", "generate_text")
graph.add_edge("generate_text", END)
return graph.compile()
async def run_other_report_workflow(
extracted_text: str,
combined_query: str,
history_message: str = "",
route_flag: str = ""
) -> Dict[str, Any]:
"""
执行通用报告工作流供主Agent / app.py 调用
Args:
extracted_text: 主Agent合并后的文本描述
combined_query: 用户问题
history_message: 历史消息JSON格式字符串
route_flag: 路由标识
Returns:
{
"response": str,
"actions": List[Dict[str, Any]],
"route_flag": str,
"suggestedReplies": List[Dict[str, Any]]
}
"""
app = create_other_report_workflow()
initial_state: OtherReportState = {
"extracted_text": extracted_text,
"combined_query": combined_query,
**init_history(history_message),
"report_title": "",
"report_theme": "",
"report_text": "",
"extracted_images": [],
"response": "",
"actions": [],
"route_flag": "",
"suggestedReplies": [],
"error_message": ""
}
return await app.ainvoke(initial_state)
if __name__ == "__main__":
async def test():
history_message = json.dumps(
[
{"role": "user", "content": "我需要生成一份项目总结报告"},
{"role": "assistant", "content": "好的,我可以帮您生成项目总结报告,请提供相关项目信息。"},
{"role": "user",
"content": "项目名称智能运维系统项目周期2024年1月-2024年6月主要成果完成了系统架构设计和核心模块开发"}
],
ensure_ascii=False,
)
result = await run_other_report_workflow(
extracted_text="项目名称智能运维系统项目周期2024年1月-2024年6月主要成果完成了系统架构设计和核心模块开发",
combined_query="用户问题:请生成项目总结报告",
history_message=history_message
)
print("====== 报告文本 ======")
print(result["response"])
print("\n====== 推荐问题 ======")
for reply in result.get("suggestedReplies", []):
print(f"- {reply['title']}: {reply['content']}")
print("\n====== 操作按钮 ======")
for action in result.get("actions", []):
print(f"- {action.get('label', 'Unknown')}: {action.get('type', 'Unknown')}")
asyncio.run(test())

View File

@ -0,0 +1,544 @@
import asyncio
import re
import random
from typing import TypedDict, List, Dict, Any, Literal
from langgraph.graph import StateGraph, START, END
import os
import sys
current_dir = os.path.dirname(os.path.abspath(__file__))
parent_dir = os.path.dirname(current_dir)
if parent_dir not in sys.path:
sys.path.append(parent_dir)
from modelsAPI.model_api import OpenaiAPI
from utils.function_tracker import track_function_calls
from utils.word_generator import generate_report_word
from config import SERVER_CONFIG
import json
from workflows.history_manager import init_history, build_history_str
def _extract_images_from_text(text: str) -> List[Dict[str, str]]:
"""从文本中提取图片URL和描述信息"""
if not text:
return []
images = []
url_pattern = r'https?://[^\s<>"{}|\\^`\[\]]+\.(?:png|jpg|jpeg|gif|bmp|webp)'
urls = re.findall(url_pattern, text, re.IGNORECASE)
for url in urls:
summary = ""
url_index = text.find(url)
if url_index != -1:
after_url = text[url_index + len(url):]
result_match = re.search(
r'(?:\*\*图片识别结果:\*\*|图片识别结果[:]|识别结果[:])\s*(.*?)(?=\n\n\n|\n\n[A-Za-z\u4e00-\u9fa5]|$)',
after_url,
re.DOTALL
)
if result_match:
summary = result_match.group(1).strip()[:300]
images.append({
"url": url,
"summary": summary or "用户上传的图片"
})
return images
class HistoryQAAnalysisState(TypedDict):
"""历史问答分析工作流状态"""
extracted_text: str
combined_query: str
history_message: str
history_messages: List[Dict[str, Any]]
extracted_items: List[Dict[str, Any]]
extracted_images: List[Dict[str, str]]
report_title: str
report_text: str
response: str
actions: List[Dict[str, Any]]
route_flag: Literal["", "report"]
suggestedReplies: List[Dict[str, Any]]
error_message: str
async def extract_analysis_items(state: HistoryQAAnalysisState) -> Dict[str, Any]:
from utils.function_tracker import get_all_callbacks
combined_query = state.get("combined_query", "") or state.get("extracted_text", "") or ""
history_messages = state.get("history_messages", [])
history_message = state.get("history_message", "")
pre_extracted_images = _extract_images_from_text(history_message)
history_context = build_history_str(history_messages, recent_count=10, assistant_truncate=200)
if not history_context and history_message:
history_context = history_message
loading_prompts = [
{"title": "提取相关历史问答数据中...", "details": f"历史消息数: {len(history_messages)}"},
{"title": "🔍 正在分析历史对话内容...", "details": f"共处理 {len(history_messages)} 条消息"},
{"title": "📝 解析历史问答信息中...", "details": f"已加载 {len(history_messages)} 条历史记录"},
{"title": "📊 整理历史对话数据...", "details": f"当前历史消息: {len(history_messages)}"},
{"title": "🔑 提取对话关键信息...", "details": f"历史对话总数: {len(history_messages)}"},
]
selected_prompt = random.choice(loading_prompts)
parent_event = {
"type": "function_execution",
"title": selected_prompt["title"],
"details": selected_prompt["details"],
}
for callback in get_all_callbacks():
try:
callback(parent_event)
except Exception:
pass
prompt = f"""
你是一名情报分析与知识管理专家
请从以下用户输入中抽取用于统计分析的结构化信息
用户输入
{combined_query}
历史对话上下文
{history_context if history_context else "(无)"}
抽取规则
输出一个JSON对象包含以下字段
- report_title报告标题根据历史对话内容生成一个简洁的标题格式为"XXX故障处理报告"不超过20个字
- items抽取记录数组每条记录包含
- topic问题主题 设备系统管理流程保障训练
- key_entity关键对象或名词
- conclusion结论 / 判断 / 回答要点
- action_suggestion建议或行动若无可为空
- date时间若无法判断可为空
- images图片信息数组仅当有用户上传图片时提取每条记录包含
- url图片URL直接从用户上传图片信息中提取
- summary图片内容摘要根据图片识别结果提炼关键信息如设备类型故障现象异常情况等不超过100字
输出要求
1. 仅输出 JSON 对象
2. 不要附加任何解释
3. 如果没有图片信息images字段返回空数组
"""
try:
result = await OpenaiAPI.open_api_chat_without_thinking(
query=prompt,
model=None,
json_output=True
)
parsed_result = json.loads(result)
if isinstance(parsed_result, dict):
extracted_items = parsed_result.get("items", [])
report_title = parsed_result.get("report_title", "历史问答分析报告")
model_images = parsed_result.get("images", [])
else:
extracted_items = parsed_result if isinstance(parsed_result, list) else []
report_title = "历史问答分析报告"
model_images = []
except Exception as e:
extracted_items = []
report_title = "历史问答分析报告"
model_images = []
final_images = pre_extracted_images if pre_extracted_images else model_images
return {"extracted_items": extracted_items, "report_title": report_title, "extracted_images": final_images}
# @track_function_calls
async def generate_analysis_report_text(state: HistoryQAAnalysisState) -> Dict[str, Any]:
from utils.function_tracker import get_all_callbacks
import time
extracted_items = state.get("extracted_items", [])
report_title = state.get("report_title", "历史问答分析报告")
combined_query = state.get("combined_query", "") or state.get("extracted_text", "") or ""
history_messages = state.get("history_messages", [])
extracted_images = state.get("extracted_images", [])
history_message = state.get("history_message", "")
def summarize_extracted_items(items) -> str:
"""将抽取结果转为中文描述,兼容 list / dict"""
if not items:
return "未提取到有效分析数据"
# 如果是单个 dict转为 list
if isinstance(items, dict):
items = [items]
# 如果不是 list直接返回
if not isinstance(items, list):
return "分析数据格式异常"
topics = set()
entities = set()
for item in items:
if not isinstance(item, dict):
continue
topic = item.get("topic")
entity = item.get("key_entity")
if topic:
topics.add(str(topic))
if entity:
entities.add(str(entity))
topic_text = "".join(list(topics)[:5]) if topics else "未识别"
entity_text = "".join(list(entities)[:5]) if entities else "未识别"
return f"识别主题 {len(items)} 条,涉及主题:{topic_text};关键对象:{entity_text}"
details_text = summarize_extracted_items(extracted_items)
loading_prompts = [
{"title": "📄 生成分析报告文本", "details": details_text},
{"title": "✍️ 正在撰写分析报告", "details": details_text},
{"title": "📋 生成历史问答分析报告", "details": details_text},
{"title": "📝 构建分析报告结构", "details": details_text},
{"title": "🎯 汇总历史问答数据生成报告", "details": details_text},
]
selected_prompt = random.choice(loading_prompts)
start_event = {
"type": "function_execution",
"title": selected_prompt["title"],
"details": selected_prompt["details"]
}
for callback in get_all_callbacks():
try:
callback(start_event)
except Exception:
pass
history_context = build_history_str(history_messages, recent_count=10, assistant_truncate=300)
if not history_context and history_message:
history_context = history_message
if not history_context and not combined_query:
return {
"report_text": "",
"response": "【无法生成分析报告】\n\n缺少历史对话信息和用户输入,请先进行相关问答。",
"actions": [],
"route_flag": "report",
"suggestedReplies": [],
"error_message": "缺少历史对话信息和用户输入"
}
random_letters = ''.join(random.choices('ABCDEFGHIJKLMNOPQRSTUVWXYZ', k=2))
report_number = time.strftime(f"{random_letters}-%Y-%m-%d-%H%M")
compile_time = time.strftime("%Y-%m-%d %H:%M")
image_section = ""
if extracted_images:
image_parts = []
for idx, img in enumerate(extracted_images, 1):
image_parts.append(f"图片{idx}{img['url']}")
summary = img.get('summary') or img.get('description', '')
if summary:
image_parts.append(f"识别结果:{summary}")
image_parts.append("")
image_section = "\n".join(image_parts)
prompt = f"""
<role>
你是一名设备运维与故障分析报告编写专家
你的任务是根据用户需求历史对话和抽取信息生成一份正式的故障处理报告
</role>
<user_requirement>
{combined_query}
</user_requirement>
<extracted_items>
{extracted_items}
</extracted_items>
<history_context>
{history_context if history_context else "(无历史对话上下文)"}
</history_context>
{f'''<user_images>
{image_section}
</user_images>''' if extracted_images else ''}
<output_rules>
必须严格遵守以下规则
1. 输出为 **正式技术报告文本**
2. **使用段落形式如果有表格生成生成表格必须arkdown表格不要html形式**
3. **如果给定结果在历史对话信息中没有可以进行删除**
4. **输出内容必须严格按照历史对话信息内容进行生成不得编造不实信息和不相干信息**
5. 不得输出解释说明
6. 不要使用列表符号
7. 所有内容使用完整中文句子
8. **每个段落首行必须缩进使用两个全角空格  实现首行缩进两个中文字符的效果**
{"9. **报告中必须包含用户上传的图片信息在故障信息部分引用图片URL**" if extracted_images else ""}
</output_rules>
<report_template>
{report_title}
报告编号 {report_number}
编制时间 {compile_time}
事件类型 设备故障诊断与处置
事件概述
  简要说明故障发生背景报警情况系统影响以及运维团队启动排查流程的情况
故障信息
  说明设备名称型号所属系统报警时间故障现象以及参与处置人员
{"  **附件图片:**" + chr(10) + chr(10).join([f"  ![图片{idx}]({img['url']})" for idx, img in enumerate(extracted_images, 1)]) if extracted_images else ""}
故障排查过程
  按照时间顺序描述排查步骤包括用户反馈 初步判断 数据分析 现场检查 故障确认
维修措施
  描述维修人员采取的具体维修步骤包括设备停机拆卸检查更换部件紧固连接恢复运行和测试过程
后续建议
  提出预防类似故障的改进措施例如维护周期优化知识库记录巡检项目增加技术培训等
总结
  总结本次故障原因处理效果以及经验意义
</report_template>
<final_rule>
只输出完整报告正文不要任何解释说明
</final_rule>
"""
try:
report_text = await OpenaiAPI.open_api_chat_without_thinking(
query=prompt,
model=None,
messages=history_messages
)
if not report_text:
report_text = "抱歉,当前无法生成分析报告,请稍后重试。"
word_dir = os.path.join(parent_dir, "created_word")
os.makedirs(word_dir, exist_ok=True)
safe_topic = re.sub(r'[\\/:*?"<>|]', '_', report_title)
file_name = f"{safe_topic}.docx"
output_path = os.path.join(word_dir, file_name)
word_result = generate_report_word(
markdown=report_text,
title=report_title,
output=output_path
)
actions: List[Dict[str, Any]] = []
if word_result and os.path.exists(word_result):
base_url = SERVER_CONFIG.get("report")
download_url = f"http://192.168.1.164:9088/api/download/{file_name}"
actions.append({
"type": "download",
"label": f"{report_title}.docx",
"url": download_url,
"fileName": file_name,
"content": report_text
})
else:
error_msg = f"\n\n[注意] Word 文档生成失败"
report_text += error_msg
# actions.append({
# "type": "regenerate",
# "label": "重新生成",
# "icon": "report"
# })
suggested_replies = await generate_suggested_replies_qa_report(report_text)
return {
"report_text": report_text,
"response": report_text,
"actions": actions,
"route_flag": "report",
"suggestedReplies": [],
# "suggestedReplies": suggested_replies,
"__details__": "分析报告生成完成"
}
except Exception as e:
# error_event = {
# "type": "function_execution",
# "title": "步骤二失败:生成分析报告",
# "details": f"错误: {str(e)}",
# "has_result": False
# }
# for callback in get_all_callbacks():
# try:
# callback(error_event)
# except Exception:
# pass
return {
"report_text": f"生成分析报告失败: {str(e)}",
"response": f"生成分析报告失败: {str(e)}",
"actions": [],
"route_flag": "report",
"suggestedReplies": [],
"error_message": str(e),
"__details__": f"生成分析报告失败: {str(e)}"
}
async def generate_suggested_replies_qa_report(report: str) -> List[Dict[str, Any]]:
"""
生成针对历史问答分析报告的建议回复问题
使用大模型生成两个相关问题
"""
prompt = f"""
你是一个专业的数据分析助手根据以下历史问答分析报告生成两个用户可能关心的后续问题
分析报告
{report[:500]}
请生成两个简洁实用的问题这些问题应该
1. 不得出现需要归档和下载等操作相关的问题
2. 与分析报告相关能够帮助用户更好地理解或使用分析结果
3. 问题要具体可操作
4. 每个问题不超过20个字
请以JSON格式输出格式如下
[
{{"title": "问题1的标题", "content": "问题1的完整内容"}},
{{"title": "问题2的标题", "content": "问题2的完整内容"}}
]
只输出JSON不要有其他文字说明
"""
try:
answer = await OpenaiAPI.open_api_chat_without_thinking(
query=prompt,
model=None
)
json_match = re.search(r'\[.*\]', answer, re.DOTALL)
if json_match:
json_str = json_match.group(0)
suggested_replies = json.loads(json_str)
if isinstance(suggested_replies, list) and len(suggested_replies) >= 2:
formatted_replies = []
for reply in suggested_replies[:2]:
if isinstance(reply, dict) and "title" in reply and "content" in reply:
formatted_replies.append({
"title": str(reply["title"]),
"content": str(reply["content"])
})
if len(formatted_replies) == 2:
return formatted_replies
return [
{"title": "如何优化问题分布?", "content": "如何优化问题分布?"},
{"title": "有哪些改进建议?", "content": "有哪些改进建议?"}
]
except Exception as e:
return [
{"title": "如何优化问题分布?", "content": "如何优化问题分布?"},
{"title": "有哪些改进建议?", "content": "有哪些改进建议?"}
]
def create_history_qa_analysis_workflow():
graph = StateGraph(HistoryQAAnalysisState)
graph.add_node("extract_items", extract_analysis_items)
graph.add_node("generate_text", generate_analysis_report_text)
graph.add_edge(START, "extract_items")
graph.add_edge("extract_items", "generate_text")
graph.add_edge("generate_text", END)
return graph.compile()
# @track_function_calls
async def run_qa_report_agent(
extracted_text: str,
combined_query: str,
history_message: str = "",
route_flag: str = "",
extracted_images: List[Dict[str, str]] = None
) -> Dict[str, Any]:
app = create_history_qa_analysis_workflow()
initial_state: HistoryQAAnalysisState = {
"extracted_text": extracted_text,
"combined_query": combined_query,
**init_history(history_message),
"extracted_items": [],
"extracted_images": extracted_images or [],
"report_title": "",
"report_text": "",
"response": "",
"actions": [],
"route_flag": "",
"suggestedReplies": [],
"error_message": ""
}
return await app.ainvoke(initial_state)
if __name__ == "__main__":
import asyncio
async def test():
history_message = json.dumps(
[
{"role": "user", "content": "我需要分析最近的历史问答数据"},
{"role": "assistant", "content": "好的,我可以帮您生成历史问答分析报告,请提供相关数据。"},
{"role": "user", "content": "请重点关注设备故障和维护相关的内容"}
],
ensure_ascii=False,
)
result = await run_qa_report_agent(
extracted_text="请重点关注设备故障和维护相关的内容",
combined_query="用户问题:请重点关注设备故障和维护相关的内容",
history_message=history_message
)
print("====== 历史问答分析报告文本 ======")
print(result["response"])
print("\n====== 推荐问题 ======")
for reply in result.get("suggestedReplies", []):
print(f"- {reply['title']}: {reply['content']}")
print("\n====== 操作按钮 ======")
for action in result.get("actions", []):
print(f"- {action.get('label', 'Unknown')}: {action.get('type', 'Unknown')}")
asyncio.run(test())

View File

@ -0,0 +1,484 @@
"""
工作流受控 ReAct Text2SQL 统计智能体
架构简洁直接无多轮交互
用户问题
Router Node判断统计分析 / 维修反馈 / 普通问答
普通问答 final_answer END
维修反馈 repair_feedback final_answer END
统计分析 text2sql_tool内部闭环 final_answer END
text2sql_tool 内部闭环SQL生成 SQL审查 SQL Guard SQL执行
"""
import sys
import os
import json
import re
from datetime import datetime
from typing import TypedDict, Optional, Dict, Any, List
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from langgraph.graph import StateGraph, START, END
from modelsAPI.model_api import OpenaiAPI
from prompts import TEXT2SQL_PROMPTS
from tools.text2sql_tool import text2sql_tool
from tools.fault_statistics import repair_feedback_statistics_tool
# ============================================================
# 状态定义
# ============================================================
class StatisticsState(TypedDict):
# 输入
extracted_text: str
combined_query: str
route_flag: str
history_message: str
history_messages: List[Dict[str, Any]]
# 路由结果
route_result: Optional[str] # "statistics" / "feedback" / "normal"
# Text2SQL 结果
sql: Optional[str]
sql_result: Optional[Dict[str, Any]]
# 维修反馈结果
feedback_result: Optional[Dict[str, Any]]
feedback_params: Optional[Dict[str, Any]]
# 输出
response: str
actions: List[Dict[str, Any]]
suggestedReplies: List[Dict[str, Any]]
error_message: str
# ============================================================
# Node 1: Router Node
# ============================================================
async def router_node(state: StatisticsState) -> Dict[str, Any]:
"""
路由判断统计分析 / 维修反馈 / 普通问答
"""
question = state.get("combined_query") or state.get("extracted_text", "")
prompt = TEXT2SQL_PROMPTS["router"].format(question=question)
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))
route = parsed.get("route", "statistics").strip().lower()
if route in ("statistics", "feedback", "normal"):
return {"route_result": route}
except Exception as e:
print(f"路由判断失败: {str(e)}")
return {"route_result": "statistics"}
# ============================================================
# Node 2: Text2SQL Node
# ============================================================
async def text2sql_node(state: StatisticsState) -> Dict[str, Any]:
"""
调用受控 Text2SQL 工具
内部闭环SQL生成 SQL审查 SQL Guard SQL执行含重试
"""
question = state.get("combined_query") or state.get("extracted_text", "")
current_date = datetime.now().strftime("%Y-%m-%d")
result = await text2sql_tool(question=question, current_date=current_date)
return {
"sql": result.get("sql", ""),
"sql_result": result
}
# ============================================================
# Node 3: Repair Feedback Node
# ============================================================
async def repair_feedback_node(state: StatisticsState) -> Dict[str, Any]:
"""
处理维修反馈类问题走外部API
"""
question = state.get("combined_query") or state.get("extracted_text", "")
params = await _extract_feedback_params(question)
try:
result = await repair_feedback_statistics_tool.ainvoke(params)
return {"feedback_result": result, "feedback_params": params}
except Exception as e:
return {"feedback_result": {"success": False, "error": str(e)}, "feedback_params": params}
async def _extract_feedback_params(question: str) -> Dict[str, Any]:
"""从用户问题中提取维修反馈参数"""
current_date = datetime.now().strftime("%Y-%m-%d")
prompt = f"""从以下问题中提取维修反馈统计参数返回JSON格式。
当前日期{current_date}
用户问题{question}
需要提取的参数
- source: 来源筛选好评/差评/反馈如果没有则为空字符串
- start_date: 开始日期格式YYYY-MM-DD如果没有则为空字符串
- end_date: 结束日期格式YYYY-MM-DD如果没有则为空字符串
时间映射
- "近一个月" start_date = 当前日期前30天
- "近一周" start_date = 当前日期前7天
- "近三个月" start_date = 当前日期前90天
输出格式
{{"source": "", "start_date": "", "end_date": ""}}
只输出JSON"""
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))
params = {}
if parsed.get("source"):
params["source"] = parsed["source"]
if parsed.get("start_date"):
params["start_date"] = parsed["start_date"]
if parsed.get("end_date"):
params["end_date"] = parsed["end_date"]
return params
except Exception:
pass
return {}
# ============================================================
# Node 4: Final Answer Node
# ============================================================
async def final_answer_node(state: StatisticsState) -> Dict[str, Any]:
"""
将查询结果转为自然语言回答
"""
route_result = state.get("route_result", "statistics")
question = state.get("combined_query") or state.get("extracted_text", "")
if route_result == "feedback":
return await _format_feedback_answer(state, question)
elif route_result == "normal":
return {
"response": "您的问题似乎不属于统计分析类问题,请尝试更具体的统计查询,例如:\"最近一个月哪个故障最多\"\"101舰备件消耗情况\"等。",
"actions": [],
"suggestedReplies": [
{"title": "故障频次统计", "content": "最近一个月哪个故障最多"},
{"title": "备件消耗统计", "content": "101舰近一个月备件消耗"},
{"title": "维修反馈", "content": "近一个月维修差评情况"}
]
}
else:
return await _format_statistics_answer(state, question)
async def _format_statistics_answer(state: StatisticsState, question: str) -> Dict[str, Any]:
"""格式化统计分析结果"""
sql_result = state.get("sql_result", {})
if not sql_result.get("success", False):
error = sql_result.get("error", "未知错误")
return {
"response": f"统计分析执行失败:{error}",
"actions": [],
"suggestedReplies": [
{"title": "故障频次统计", "content": "最近一个月哪个故障最多"},
{"title": "备件消耗统计", "content": "近一个月备件消耗排名"}
]
}
rows = sql_result.get("rows", [])
sql = sql_result.get("sql", "")
row_count = sql_result.get("row_count", 0)
# 使用 LLM 生成自然语言回答
result_data = json.dumps(rows[:20], ensure_ascii=False, indent=2)
prompt = TEXT2SQL_PROMPTS["final_answer"].format(
question=question,
sql=sql,
row_count=row_count,
result_data=result_data
)
try:
answer = await OpenaiAPI.open_api_chat_without_thinking(prompt, model=None)
response = answer.strip()
except Exception:
response = _simple_format_result(rows, row_count)
return {
"response": response,
"actions": [],
"suggestedReplies": _generate_suggested_replies(question)
}
async def _format_feedback_answer(state: StatisticsState, question: str) -> Dict[str, Any]:
"""格式化维修反馈结果"""
feedback_result = state.get("feedback_result", {})
feedback_params = state.get("feedback_params", {})
if not feedback_result.get("success", False):
return {
"response": "维修反馈统计失败,请稍后重试。",
"actions": [],
"suggestedReplies": []
}
feedback_data = feedback_result.get("feedbackData", [])
total_count = feedback_result.get("totalCount", 0)
source_distribution = feedback_result.get("sourceDistribution", {})
source = feedback_params.get("source", "")
start_date = feedback_params.get("start_date", "")
end_date = feedback_params.get("end_date", "")
if not feedback_data:
source_desc = f"{source}" if source else ""
time_desc = f"{start_date}{end_date}" if start_date and end_date else ""
return {
"response": f"未找到{source_desc}维修反馈统计数据{time_desc}",
"actions": [],
"suggestedReplies": [
{"title": "查看差评", "content": "统计近一个月维修差评情况"},
{"title": "查看好评", "content": "统计近一个月维修好评情况"}
]
}
response_parts = []
source_desc = f"{source}" if source else ""
time_desc = f"{start_date}{end_date}" if start_date and end_date else ""
response_parts.append(f"为您统计{source_desc}的维修反馈数据{time_desc}\n")
if source_distribution:
dist_str = "".join([f"{k} {v}" for k, v in source_distribution.items()])
response_parts.append(f"核心结论:共收到维修反馈 {total_count} 条({dist_str})。\n")
response_parts.append("排名 | 标题 | 来源 | 反馈人 | 时间")
response_parts.append("--- | --- | --- | --- | ---")
for item in feedback_data:
rank = item.get("rank", 0)
title = str(item.get("title", ""))[:30]
item_source = str(item.get("source", "未知"))
user_name = str(item.get("user_name", "-"))
created_at = str(item.get("created_at", "-"))[:10]
response_parts.append(f"{rank} | {title} | {item_source} | {user_name} | {created_at}")
return {
"response": "\n".join(response_parts),
"actions": [],
"suggestedReplies": [
{"title": "查看差评", "content": "统计近一个月维修差评情况"},
{"title": "查看好评", "content": "统计近一个月维修好评情况"},
{"title": "查看近一周反馈", "content": "统计近一周维修反馈情况"}
]
}
def _simple_format_result(rows: List[Dict[str, Any]], row_count: int) -> str:
"""简单格式化查询结果LLM生成失败时的兜底"""
if not rows:
return "未查询到相关数据。"
columns = list(rows[0].keys())
parts = [f"共查询到 {row_count} 条记录。\n"]
parts.append(" | ".join(columns))
parts.append(" | ".join(["---"] * len(columns)))
for row in rows[:10]:
values = [str(row.get(col, ""))[:30] for col in columns]
parts.append(" | ".join(values))
if row_count > 10:
parts.append(f"\n... 还有 {row_count - 10} 条记录")
return "\n".join(parts)
def _generate_suggested_replies(question: str) -> List[Dict[str, Any]]:
"""根据问题生成建议回复"""
return [
{"title": "故障频次统计", "content": "最近一个月哪个故障最多"},
{"title": "备件消耗统计", "content": "近一个月备件消耗排名"},
{"title": "按系统统计", "content": "动力系统近一个月故障统计"},
{"title": "按舷号统计", "content": "101舰近一个月故障统计"}
]
# ============================================================
# 条件路由
# ============================================================
def route_after_router(state: StatisticsState) -> str:
"""Router 之后的路由"""
route = state.get("route_result", "statistics")
if route == "feedback":
return "feedback"
elif route == "normal":
return "normal"
else:
return "statistics"
# ============================================================
# 构建工作流
# ============================================================
def create_statistics_workflow():
"""
创建受控 Text2SQL 统计工作流
架构简洁直线型无多轮交互
START
router_node
normal final_answer_node END
feedback repair_feedback_node final_answer_node END
statistics text2sql_node final_answer_node END
"""
workflow = StateGraph(StatisticsState)
# 添加节点
workflow.add_node("router_node", router_node)
workflow.add_node("text2sql_node", text2sql_node)
workflow.add_node("repair_feedback_node", repair_feedback_node)
workflow.add_node("final_answer_node", final_answer_node)
# 入口
workflow.add_edge(START, "router_node")
# Router 分支
workflow.add_conditional_edges(
"router_node",
route_after_router,
{
"statistics": "text2sql_node",
"feedback": "repair_feedback_node",
"normal": "final_answer_node"
}
)
# 各分支 → final_answer → END
workflow.add_edge("text2sql_node", "final_answer_node")
workflow.add_edge("repair_feedback_node", "final_answer_node")
workflow.add_edge("final_answer_node", END)
return workflow.compile()
# ============================================================
# 统一入口
# ============================================================
async def run_statistics_workflow(
extracted_text: str,
combined_query: str,
history_message: str = "",
route_flag: str = ""
) -> Dict[str, Any]:
"""
执行统计工作流统一接口
"""
app = create_statistics_workflow()
initial_state = {
"extracted_text": extracted_text,
"combined_query": combined_query,
"route_flag": route_flag,
"history_message": history_message,
"history_messages": [],
"route_result": None,
"sql": None,
"sql_result": None,
"feedback_result": None,
"feedback_params": None,
"response": "",
"actions": [],
"suggestedReplies": [],
"error_message": ""
}
try:
final_state = await app.ainvoke(initial_state)
if final_state.get("error_message"):
return {
"response": f"统计工作流执行失败: {final_state['error_message']}",
"actions": [],
"result_tag": "statistics",
"suggestedReplies": []
}
return {
"response": final_state.get("response", ""),
"actions": final_state.get("actions", []),
"result_tag": "statistics",
"suggestedReplies": final_state.get("suggestedReplies", [])
}
except Exception as e:
return {
"response": f"统计工作流执行失败: {str(e)}",
"actions": [],
"result_tag": "statistics",
"suggestedReplies": []
}
# ============================================================
# 测试
# ============================================================
if __name__ == "__main__":
import asyncio
test_cases = [
"最近一个月哪个故障最多",
"统计101舰的备件消耗情况",
"近三个月动力系统故障频次排名",
"统计维修反馈差评情况",
"连杆异常磨损怎么修",
]
async def test():
for i, test_text in enumerate(test_cases, 1):
print(f"\n{'='*60}")
print(f"测试用例 {i}: {test_text}")
print(f"{'='*60}")
result = await run_statistics_workflow(
extracted_text=test_text,
combined_query=test_text
)
print(f"\n响应:")
print(result.get("response", "无响应"))
asyncio.run(test())

169
workflows/workflow_utils.py Normal file
View File

@ -0,0 +1,169 @@
"""
工作流公共工具函数
提取自 workflow_fault_diagnosis.py workflow_operate.py 的共享代码
"""
from typing import TypedDict, Optional, Dict, Any, List
from prompts import COMMON_PROMPTS
from modelsAPI.model_api import OpenaiAPI
from utils.function_tracker import get_all_callbacks, get_current_stream_handler
from utils.image_utils import extract_image_paths, normalize_markdown_images
import json
import re
import random
def safe_json_extract(text: str) -> Optional[Any]:
if not text:
return None
cleaned = text.strip()
cleaned = re.sub(r"```json\s*", "", cleaned, flags=re.IGNORECASE)
cleaned = re.sub(r"```\s*", "", cleaned)
m = re.search(r"\[[\s\S]*\]", cleaned)
if m:
try:
return json.loads(m.group(0))
except Exception:
pass
m = re.search(r"\{[\s\S]*\}", cleaned)
if m:
try:
return json.loads(m.group(0))
except Exception:
pass
try:
return json.loads(cleaned)
except Exception:
return None
from workflows.history_manager import parse_history, build_history_str, filter_image_urls, init_history # noqa: F401
def normalize_latex_spacing(text: str) -> str:
placeholder = "\x00DOUBLE_DOLLAR\x00"
text = text.replace("$$", placeholder)
text = re.sub(r'(?<!\s)\$', r' $', text)
text = re.sub(r'\$(?!\s)', r'$ ', text)
text = text.replace(placeholder, "$$")
text = re.sub(r'(?<!\s)\$\$', r' $$', text)
text = re.sub(r'\$\$(?!\s)', r'$$ ', text)
return text
def remove_duplicate_content(text: str) -> str:
if not text or len(text) < 10:
return text
lines = text.split('\n')
result = []
seen_segments = set()
for line in lines:
line = line.rstrip()
if not line:
result.append(line)
continue
normalized_line = line.strip().lower()
if normalized_line in seen_segments:
continue
seen_segments.add(normalized_line)
result.append(line)
cleaned_text = '\n'.join(result)
return cleaned_text
def convert_rag_result(rag_result):
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
def calculate_info_completeness(weights: Dict[str, float], values: Dict[str, str]) -> float:
score = 0.0
for field, weight in weights.items():
value = values.get(field, "") or ""
if value and str(value).strip():
score += weight
return round(score, 2)
def emit_callback_event(title_options: List[str], details: str):
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 build_detail_info(fields: Dict[str, str]) -> str:
detail_info = ""
for label, value in fields.items():
if value:
detail_info += f"- {label}{value}\n"
return detail_info
def append_atlas_section(answer: str, atlas_results: Dict[str, Any]) -> str:
if not atlas_results:
return answer
atlas_section = "\n\n---\n\n**参考图册:**\n"
for node_name, node_data in atlas_results.items():
if isinstance(node_data, dict):
for title, urls in node_data.items():
if isinstance(urls, list) and urls:
for url in urls:
atlas_section += f"- {title}: [ `{title}` ]({url})\n"
return answer + atlas_section
async def stream_format_and_postprocess(
raw_content: str,
original_image_paths: List[str],
temperature: float = 0.3,
content_label: str = None,
) -> str:
if content_label:
format_user_content = COMMON_PROMPTS["format_user_simple"].format(
content_label=content_label,
raw_content=raw_content,
)
else:
format_user_content = COMMON_PROMPTS["format_user_with_image_examples"].format(raw_content=raw_content)
format_system_prompt = COMMON_PROMPTS["format_system"]
stream_handler = get_current_stream_handler()
answer = ""
accumulated_content = ""
async for chunk in OpenaiAPI.open_api_chat_stream(
model=None,
system_prompt=format_system_prompt,
messages=[{"role": "user", "content": format_user_content}],
temperature=temperature,
):
answer += chunk
accumulated_content += chunk
if stream_handler:
stream_handler.send_stream_content(accumulated_content)
answer = answer.strip() if answer else raw_content
answer = remove_duplicate_content(answer)
answer = normalize_markdown_images(answer, original_paths=original_image_paths)
answer = normalize_latex_spacing(answer)
return answer