Initial commit
30
.gitignore
vendored
Normal file
@ -0,0 +1,30 @@
|
||||
# Python caches
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Runtime data and generated outputs
|
||||
data/*.db
|
||||
data/*.db-*
|
||||
created_pdf/
|
||||
created_word/
|
||||
image_output/
|
||||
|
||||
# Logs and temporary files
|
||||
*.log
|
||||
*.tmp
|
||||
.pytest_cache/
|
||||
|
||||
# OS/editor files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
138
1.html
Normal 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
@ -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)
|
||||
361
agent_create_request.py
Normal file
@ -0,0 +1,361 @@
|
||||
# create_agents.py
|
||||
import requests
|
||||
import urllib3
|
||||
from uuid import uuid4
|
||||
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
BASE_URL = "http://192.168.0.46:22000"
|
||||
AGENT_API = f"{BASE_URL}/api/v1/agents/"
|
||||
HEADERS = {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
TIMEOUT = 10
|
||||
OUTPUT_FILE = "agent_ids.txt"
|
||||
|
||||
AGENT_META = {
|
||||
str(uuid4()): {
|
||||
"icon_index": 1,
|
||||
"name": "修理工程管理",
|
||||
"description": "对舰船修理工程实施全过程管理,包括任务分解、进度控制、工序协调和资源调配。",
|
||||
"prompt": "助理员您好,我是修理工程管理助手,我已经绑定了技术资料、通用知识、故障知识、法规标准知识库。我可以为您提供舰船设备的标准修理内容范围、作业工序清单以及安全施工要求,请直接告诉我您需要查询的设备名称。",
|
||||
"default_query": [
|
||||
{"title": "查询设备标准 →", "content": "查询设备标准"},
|
||||
{"title": "查看作业工序 →", "content": "查看作业工序"}
|
||||
],
|
||||
},
|
||||
str(uuid4()): {
|
||||
"icon_index": 2,
|
||||
"name": "器材统计与分析",
|
||||
"description": "对舰船维修保障过程中涉及的器材进行统计分析与趋势研判。",
|
||||
"prompt": "助理员您好,我是器材统计与分析助手。我已经绑定了技术资料、通用知识、故障知识、法规标准知识库。为您提供多维度的器材消耗统计与库存分析服务。请告诉我您需要统计的具体业务场景。",
|
||||
"default_query": [
|
||||
{"title": "统计第一季度器材消耗 →", "content": "统计第一季度器材消耗"},
|
||||
{"title": "分析当前库存周转率 →", "content": "分析当前库存周转率"},
|
||||
{"title": "查看特定型号历史记录 →", "content": "查看特定型号历史记录"}
|
||||
],
|
||||
},
|
||||
str(uuid4()): {
|
||||
"icon_index": 3,
|
||||
"name": "修理法规知识查询",
|
||||
"description": "提供舰船修理相关法规、标准和制度的查询与解读。",
|
||||
"prompt": "您好!我是修理法规知识查询助手。我已经绑定了技术资料、通用知识、故障知识、法规标准知识库。请告诉我需要查询的主题。",
|
||||
"default_query": [
|
||||
{"title": "查询焊接工艺规程 →", "content": "查询焊接工艺规程"},
|
||||
{"title": "查看设备拆装安全规程 →", "content": "查看设备拆装安全规程"},
|
||||
{"title": "搜索环保作业规定 →", "content": "搜索环保作业规定"}
|
||||
],
|
||||
},
|
||||
str(uuid4()): {
|
||||
"icon_index": 4,
|
||||
"name": "船舰知识查询",
|
||||
"description": "提供舰船结构、系统组成与功能原理等知识。",
|
||||
"prompt": "您好!我是舰船知识查询助手。我已经绑定了技术资料、通用知识、故障知识、法规标准知识库。请告诉我需要了解的主题。",
|
||||
"default_query": [
|
||||
{"title": "了解舰船电力系统组成 →", "content": "了解舰船电力系统组成"},
|
||||
{"title": "查询舰船推进装置类型 →", "content": "查询舰船推进装置类型"},
|
||||
{"title": "搜索舰船雷达系统原理 →", "content": "搜索舰船雷达系统原理"}
|
||||
],
|
||||
},
|
||||
str(uuid4()): {
|
||||
"icon_index": 5,
|
||||
"name": "故障排查与修理",
|
||||
"description": "辅助进行舰船设备故障分析、原因定位与修理方案制定。",
|
||||
"prompt": "您好!我是智能故障排查与修理助手。我已经绑定了技术资料、通用知识、故障知识、法规标准知识库。提供舷号、设备名称和故障现象,我可以协助您进行复杂故障的研判诊断。",
|
||||
"default_query": [
|
||||
{"title": "多模态输出与校验 →", "content": "多模态输出与校验"},
|
||||
{"title": "诊断研判与可能性排序 →", "content": "诊断研判与可能性排序"},
|
||||
{"title": "GT-25000燃气轮机的日常维护保养方案 →", "content": "GT-25000燃气轮机的日常维护保养方案"}
|
||||
],
|
||||
},
|
||||
str(uuid4()): {
|
||||
"icon_index": 6,
|
||||
"name": "舰船机电知识",
|
||||
"description": "提供舰船动力、电气、液压等机电系统相关知识。",
|
||||
"prompt": "机电长您好,我是舰船机电知识查询助手。我已经绑定了技术资料、通用知识、故障知识、法规标准知识库。请告诉我需了解的主题。",
|
||||
"default_query": [
|
||||
{"title": "了解主推进系统构成 →", "content": "了解主推进系统构成"},
|
||||
{"title": "查看舰艇液压机参数 →", "content": "查看舰艇液压机参数"},
|
||||
{"title": "查看舰机液压系统参数 →", "content": "查看舰机液压系统参数"}
|
||||
],
|
||||
},
|
||||
str(uuid4()): {
|
||||
"icon_index": 7,
|
||||
"name": "现场维修作业向导",
|
||||
"description": "为一线维修人员提供现场操作流程与安全指导。",
|
||||
"prompt": "您好!我是现场维修作业向导。我已经绑定了技术资料、通用知识、故障知识、法规标准知识库。您可以询问具体的维修步骤、拆装工艺或查询安全操作规程。",
|
||||
"default_query": [
|
||||
{"title": "离心泵汽蚀的典型特征 →", "content": "离心泵汽蚀的典型特征"},
|
||||
{"title": "海水泵汽蚀的维修步骤 →", "content": "海水泵汽蚀的维修步骤"},
|
||||
{"title": "GT-25000燃气轮机的日常维护步骤 →", "content": "GT-25000燃气轮机的日常维护步骤"}
|
||||
],
|
||||
},
|
||||
str(uuid4()): {
|
||||
"icon_index": 8,
|
||||
"name": "舰船技术知识百科",
|
||||
"description": "综合性舰船技术知识库。",
|
||||
"prompt": "您好,我是舰船技术知识百科助手。我已经绑定了技术资料、通用知识、故障知识、法规标准知识库。请告诉我您需要查询的技术主题。",
|
||||
"default_query": [
|
||||
{"title": "了解船体高强钢性能 →", "content": "了解船体高强钢性能"},
|
||||
{"title": "查询焊接工艺评定标准 →", "content": "查询焊接工艺评定标准"},
|
||||
{"title": "查看防腐涂装技术规范 →", "content": "查看防腐涂装技术规范"}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_agent_json(agent_id: str) -> dict:
|
||||
meta = AGENT_META[agent_id]
|
||||
return {
|
||||
"id": agent_id,
|
||||
"name": meta["name"],
|
||||
"description": meta["description"],
|
||||
"prompt": meta["prompt"],
|
||||
"background": "string",
|
||||
"access_control": {},
|
||||
"default_query": meta["default_query"],
|
||||
}
|
||||
|
||||
|
||||
def create_agent(agent_id: str) -> bool:
|
||||
resp = requests.post(
|
||||
AGENT_API,
|
||||
headers=HEADERS,
|
||||
json=build_agent_json(agent_id),
|
||||
verify=False,
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
print(resp.json())
|
||||
print(f"[CREATE] {agent_id} -> {resp.status_code}")
|
||||
|
||||
return resp.status_code in (200, 201, 409)
|
||||
|
||||
|
||||
def main():
|
||||
created_ids = []
|
||||
|
||||
for agent_id in AGENT_META.keys():
|
||||
if create_agent(agent_id):
|
||||
created_ids.append(agent_id)
|
||||
|
||||
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
|
||||
for aid in created_ids:
|
||||
f.write(aid + "\n")
|
||||
|
||||
print(f"\n✅ 已写入 {len(created_ids)} 个 Agent UUID 到 {OUTPUT_FILE}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
|
||||
|
||||
# import requests
|
||||
# import urllib3
|
||||
# from uuid import uuid4
|
||||
# from typing import List, Tuple
|
||||
|
||||
# # =========================
|
||||
# # 0. 关闭 HTTPS 警告(开发环境)
|
||||
# # =========================
|
||||
# urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
# # =========================
|
||||
# # 1. 基础配置
|
||||
# # =========================
|
||||
|
||||
# BASE_URL = "https://devb.zdht.hjkl01.cn:65432"
|
||||
|
||||
# AGENT_API = f"{BASE_URL}/api/v1/agents/"
|
||||
# ROLE_BIND_API = f"{BASE_URL}/api/v1/agent-roles/association"
|
||||
|
||||
# HEADERS = {
|
||||
# "Accept": "application/json",
|
||||
# "Content-Type": "application/json",
|
||||
# }
|
||||
|
||||
# TIMEOUT = 10
|
||||
|
||||
# # =========================
|
||||
# # 2. 角色 ID(数据库真实存在)
|
||||
# # =========================
|
||||
|
||||
# AGENT_ROLE_1 = "2" # 助理员助手 (1-4)
|
||||
# AGENT_ROLE_2 = "3" # 机电长助手 (5-6)
|
||||
# AGENT_ROLE_3 = "4" # 维修员助手 (7-8)
|
||||
|
||||
# ROLE_NAME_MAP = {
|
||||
# AGENT_ROLE_1: "助理员助手",
|
||||
# AGENT_ROLE_2: "机电长助手",
|
||||
# AGENT_ROLE_3: "维修员助手",
|
||||
# }
|
||||
|
||||
# # =========================
|
||||
# # 3. Agent 元数据
|
||||
# # =========================
|
||||
|
||||
# AGENT_META = {
|
||||
# str(uuid4()): {
|
||||
# "icon_index": 1,
|
||||
# "name": "修理工程管理",
|
||||
# "description": "对舰船修理工程实施全过程管理,包括任务分解、进度控制、工序协调和资源调配。",
|
||||
# "prompt": "您好!我是一名舰船修理工程管理专家。",
|
||||
# },
|
||||
# str(uuid4()): {
|
||||
# "icon_index": 2,
|
||||
# "name": "器材统计与分析",
|
||||
# "description": "对舰船维修保障过程中涉及的器材进行统计分析与趋势研判。",
|
||||
# "prompt": "您好!我是一名舰船器材统计与分析专家。",
|
||||
# },
|
||||
# str(uuid4()): {
|
||||
# "icon_index": 3,
|
||||
# "name": "修理法规知识查询",
|
||||
# "description": "提供舰船修理相关法规、标准和制度的查询与解读。",
|
||||
# "prompt": "您好!我是一名舰船修理法规专家。",
|
||||
# },
|
||||
# str(uuid4()): {
|
||||
# "icon_index": 4,
|
||||
# "name": "船舰知识查询",
|
||||
# "description": "提供舰船结构、系统组成与功能原理等知识。",
|
||||
# "prompt": "您好!我是一名舰船专业知识顾问。",
|
||||
# },
|
||||
# str(uuid4()): {
|
||||
# "icon_index": 5,
|
||||
# "name": "故障排查与修理",
|
||||
# "description": "辅助进行舰船设备故障分析、原因定位与修理方案制定。",
|
||||
# "prompt": "您好!我是一名舰船设备故障诊断专家。",
|
||||
# },
|
||||
# str(uuid4()): {
|
||||
# "icon_index": 6,
|
||||
# "name": "舰船机电知识",
|
||||
# "description": "提供舰船动力、电气、液压等机电系统相关知识。",
|
||||
# "prompt": "您好!我是一名舰船机电系统专家。",
|
||||
# },
|
||||
# str(uuid4()): {
|
||||
# "icon_index": 7,
|
||||
# "name": "现场维修作业向导",
|
||||
# "description": "为一线维修人员提供现场操作流程与安全指导。",
|
||||
# "prompt": "您好!我是一名现场维修作业向导。",
|
||||
# },
|
||||
# str(uuid4()): {
|
||||
# "icon_index": 8,
|
||||
# "name": "舰船技术知识百科",
|
||||
# "description": "综合性舰船技术知识库。",
|
||||
# "prompt": "您好!我是一部舰船技术知识百科。",
|
||||
# },
|
||||
# }
|
||||
|
||||
# # =========================
|
||||
# # 4. 构建 Agent JSON
|
||||
# # =========================
|
||||
|
||||
# def build_agent_json(agent_id: str) -> dict:
|
||||
# meta = AGENT_META[agent_id]
|
||||
# return {
|
||||
# "id": agent_id,
|
||||
# "name": meta["name"],
|
||||
# "description": meta["description"],
|
||||
# "prompt": meta["prompt"],
|
||||
# "background": "string",
|
||||
# "access_control": {},
|
||||
# }
|
||||
|
||||
# # =========================
|
||||
# # 5. 创建 Agent
|
||||
# # =========================
|
||||
|
||||
# def create_agent(agent_id: str) -> bool:
|
||||
# resp = requests.post(
|
||||
# AGENT_API,
|
||||
# headers=HEADERS,
|
||||
# json=build_agent_json(agent_id),
|
||||
# verify=False,
|
||||
# timeout=TIMEOUT,
|
||||
# )
|
||||
|
||||
# print(f"[CREATE] Agent {agent_id} -> {resp.status_code}")
|
||||
|
||||
# if resp.status_code in (200, 201, 409):
|
||||
# return True
|
||||
|
||||
# print(" ↳ 返回值:", resp.text)
|
||||
# return False
|
||||
|
||||
# # =========================
|
||||
# # 6. 绑定 Agent 到角色
|
||||
# # =========================
|
||||
|
||||
# def bind_agent_to_role(agent_id: str, role_id: str) -> bool:
|
||||
# payload = [
|
||||
# {
|
||||
# "agent_id": agent_id,
|
||||
# "agent_role_id": role_id,
|
||||
# "is_active": True,
|
||||
# }
|
||||
# ]
|
||||
|
||||
# resp = requests.post(
|
||||
# ROLE_BIND_API,
|
||||
# headers=HEADERS,
|
||||
# json=payload,
|
||||
# verify=False,
|
||||
# timeout=TIMEOUT,
|
||||
# )
|
||||
|
||||
# print(
|
||||
# f"[BIND] Agent {agent_id} -> Role {role_id} "
|
||||
# f"({ROLE_NAME_MAP.get(role_id)}) => {resp.status_code}"
|
||||
# )
|
||||
|
||||
# if resp.status_code in (200, 201):
|
||||
# return True
|
||||
|
||||
# print(" ↳ 返回值:", resp.text)
|
||||
# return False
|
||||
|
||||
# # =========================
|
||||
# # 7. 主流程(先创建 → 再统一绑定)
|
||||
# # =========================
|
||||
|
||||
# def main():
|
||||
# pending_bindings: List[Tuple[str, str]] = []
|
||||
|
||||
# print("=== 开始创建 Agent ===")
|
||||
|
||||
# for agent_id, meta in AGENT_META.items():
|
||||
# try:
|
||||
# if not create_agent(agent_id):
|
||||
# continue
|
||||
|
||||
# idx = meta["icon_index"]
|
||||
|
||||
# if 1 <= idx <= 4:
|
||||
# role_id = AGENT_ROLE_1
|
||||
# elif 5 <= idx <= 6:
|
||||
# role_id = AGENT_ROLE_2
|
||||
# elif 7 <= idx <= 8:
|
||||
# role_id = AGENT_ROLE_3
|
||||
# else:
|
||||
# continue
|
||||
|
||||
# pending_bindings.append((agent_id, role_id))
|
||||
|
||||
# except Exception as e:
|
||||
# print(f"[ERROR] Agent {agent_id} -> {e}")
|
||||
|
||||
# print("\n=== 开始统一绑定角色 ===")
|
||||
|
||||
# for agent_id, role_id in pending_bindings:
|
||||
# bind_agent_to_role(agent_id, role_id)
|
||||
|
||||
# print("\n=== 全部完成 ===")
|
||||
|
||||
# # =========================
|
||||
# # 8. 入口
|
||||
# # =========================
|
||||
|
||||
# if __name__ == "__main__":
|
||||
# main()
|
||||
8
agent_ids.txt
Normal file
@ -0,0 +1,8 @@
|
||||
17cb4f33-f84d-473a-9299-d8138e8ea9d6
|
||||
a8352abb-900d-468a-97b1-ac93f012ccb2
|
||||
f6363563-fd44-4c11-aa24-d2d97abbaa96
|
||||
2fc6a72e-69a9-497e-b7f4-12cd6833c781
|
||||
16c1320c-444a-4fb7-9e2e-34748f16d361
|
||||
c742915b-4bf1-47e3-b64f-903a4ce60de8
|
||||
6089a40f-a1b3-4514-a229-25bc8f42789e
|
||||
9cc84831-747e-4cdf-b941-736dcdc9514c
|
||||
156
agent_role_request.py
Normal file
@ -0,0 +1,156 @@
|
||||
# # bind_agents.py
|
||||
# import requests
|
||||
# import urllib3
|
||||
|
||||
# urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
# BASE_URL = "https://devb.zdht.hjkl01.cn:65432"
|
||||
# ROLE_BIND_API = f"{BASE_URL}/api/v1/agent-roles/association"
|
||||
|
||||
# HEADERS = {
|
||||
# "Accept": "application/json",
|
||||
# "Content-Type": "application/json",
|
||||
# }
|
||||
|
||||
# TIMEOUT = 10
|
||||
# INPUT_FILE = "agent_ids.txt"
|
||||
|
||||
# # 角色 ID
|
||||
# AGENT_ROLE_0 = "1" # 1-8
|
||||
# AGENT_ROLE_1 = "2" # 1-4
|
||||
# AGENT_ROLE_2 = "3" # 5-6
|
||||
# AGENT_ROLE_3 = "4" # 7-8
|
||||
|
||||
|
||||
# def calc_role_id_by_index(index: int) -> str:
|
||||
# if 1 <= index <= 4:
|
||||
# return AGENT_ROLE_1
|
||||
# elif 5 <= index <= 6:
|
||||
# return AGENT_ROLE_2
|
||||
# elif 7 <= index <= 8:
|
||||
# return AGENT_ROLE_3
|
||||
# else:
|
||||
# raise ValueError("非法 icon_index")
|
||||
|
||||
|
||||
# def bind_agent(agent_id: str, role_id: str):
|
||||
# payload = [
|
||||
# {
|
||||
# "agent_id": agent_id,
|
||||
# "agent_role_id": role_id,
|
||||
# "is_active": True,
|
||||
# }
|
||||
# ]
|
||||
|
||||
# resp = requests.post(
|
||||
# ROLE_BIND_API,
|
||||
# headers=HEADERS,
|
||||
# json=payload,
|
||||
# verify=False,
|
||||
# timeout=TIMEOUT,
|
||||
# )
|
||||
|
||||
# print(f"[BIND] {agent_id} -> role {role_id} => {resp.status_code}")
|
||||
# if resp.status_code not in (200, 201):
|
||||
# print(" ↳", resp.text)
|
||||
|
||||
|
||||
# def main():
|
||||
# with open(INPUT_FILE, "r", encoding="utf-8") as f:
|
||||
# agent_ids = [line.strip() for line in f if line.strip()]
|
||||
|
||||
# print(f"=== 读取到 {len(agent_ids)} 个 Agent ===")
|
||||
|
||||
# for idx, agent_id in enumerate(agent_ids, start=1):
|
||||
# role_id = calc_role_id_by_index(idx)
|
||||
# bind_agent(agent_id, role_id)
|
||||
|
||||
# print("\n✅ 绑定完成")
|
||||
|
||||
|
||||
# if __name__ == "__main__":
|
||||
# main()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
import requests
|
||||
import urllib3
|
||||
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
BASE_URL = "http://192.168.0.46:22000"
|
||||
ROLE_BIND_API = f"{BASE_URL}/api/v1/agent-roles/association"
|
||||
HEADERS = {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
TIMEOUT = 10
|
||||
INPUT_FILE = "agent_ids.txt"
|
||||
|
||||
# 角色 ID
|
||||
AGENT_ROLE_0 = "1" # 所有 Agent 必绑
|
||||
AGENT_ROLE_1 = "2" # 1-4
|
||||
AGENT_ROLE_2 = "3" # 5-6
|
||||
AGENT_ROLE_3 = "4" # 7-8
|
||||
|
||||
|
||||
def calc_role_id_by_index(index: int) -> str:
|
||||
if 1 <= index <= 4:
|
||||
return AGENT_ROLE_1
|
||||
elif 5 <= index <= 6:
|
||||
return AGENT_ROLE_2
|
||||
elif 7 <= index <= 8:
|
||||
return AGENT_ROLE_3
|
||||
else:
|
||||
raise ValueError("非法 index")
|
||||
|
||||
|
||||
def bind_agent(agent_id: str, role_id: str):
|
||||
payload = [
|
||||
{
|
||||
"agent_id": agent_id,
|
||||
"agent_role_id": role_id,
|
||||
"is_active": True,
|
||||
}
|
||||
]
|
||||
|
||||
resp = requests.post(
|
||||
ROLE_BIND_API,
|
||||
headers=HEADERS,
|
||||
json=payload,
|
||||
verify=False,
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
|
||||
print(f"[BIND] {agent_id} -> role {role_id} => {resp.status_code}")
|
||||
if resp.status_code not in (200, 201):
|
||||
print(" ↳", resp.text)
|
||||
|
||||
|
||||
def main():
|
||||
with open(INPUT_FILE, "r", encoding="utf-8") as f:
|
||||
agent_ids = [line.strip() for line in f if line.strip()]
|
||||
|
||||
print(f"=== 读取到 {len(agent_ids)} 个 Agent ===")
|
||||
|
||||
for idx, agent_id in enumerate(agent_ids, start=1):
|
||||
# 1️⃣ 所有 Agent 统一绑定角色 1
|
||||
bind_agent(agent_id, AGENT_ROLE_0)
|
||||
|
||||
# 2️⃣ 再绑定分组角色
|
||||
group_role = calc_role_id_by_index(idx)
|
||||
|
||||
# 防止重复绑定
|
||||
if group_role != AGENT_ROLE_0:
|
||||
bind_agent(agent_id, group_role)
|
||||
|
||||
print("\n✅ 绑定完成(角色 1 + 分组角色)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
BIN
audio/test.wav
Normal file
498
checkpointer_config.py
Normal file
@ -0,0 +1,498 @@
|
||||
"""
|
||||
LangGraph Checkpointer 配置模块
|
||||
|
||||
支持多种持久化后端:
|
||||
1. MemorySaver - 内存存储(仅用于测试)
|
||||
2. PostgresSaver - PostgreSQL 数据库(生产环境)
|
||||
|
||||
使用 message_id 和 chat_id 作为 thread_id 来恢复工作流状态
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
from config import POSTGRES_CONNECTION_STRING
|
||||
|
||||
CHECKPOINTER_TYPE = "postgres"
|
||||
|
||||
|
||||
def get_thread_id(chat_id: str, message_id: str) -> str:
|
||||
"""
|
||||
生成唯一的 thread_id
|
||||
|
||||
Args:
|
||||
chat_id: 聊天会话 ID
|
||||
message_id: 消息 ID
|
||||
|
||||
Returns:
|
||||
组合后的唯一 thread_id
|
||||
"""
|
||||
return f"{chat_id}:{message_id}"
|
||||
|
||||
|
||||
def parse_thread_id(thread_id: str) -> tuple[str, str]:
|
||||
"""
|
||||
解析 thread_id 获取 chat_id 和 message_id
|
||||
|
||||
Args:
|
||||
thread_id: 组合的 thread_id
|
||||
|
||||
Returns:
|
||||
(chat_id, message_id) 元组
|
||||
"""
|
||||
parts = thread_id.split(":", 1)
|
||||
if len(parts) == 2:
|
||||
return parts[0], parts[1]
|
||||
return parts[0], ""
|
||||
|
||||
|
||||
async def verify_checkpointer_tables_exist() -> bool:
|
||||
"""
|
||||
验证 checkpointer 所需的数据库表是否存在且结构正确
|
||||
|
||||
Returns:
|
||||
True 如果所有必需的表都存在且结构正确,否则 False
|
||||
"""
|
||||
try:
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
try:
|
||||
async with AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
kwargs={"autocommit": True}
|
||||
) as pool:
|
||||
async with pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
# 检查 checkpoints 表
|
||||
await cur.execute("""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'checkpoints'
|
||||
)
|
||||
""")
|
||||
checkpoints_exists = (await cur.fetchone())[0]
|
||||
|
||||
# 检查 checkpoint_writes 表
|
||||
await cur.execute("""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'checkpoint_writes'
|
||||
)
|
||||
""")
|
||||
writes_exists = (await cur.fetchone())[0]
|
||||
|
||||
# 检查 checkpoint_blobs 表
|
||||
await cur.execute("""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'checkpoint_blobs'
|
||||
)
|
||||
""")
|
||||
blobs_exists = (await cur.fetchone())[0]
|
||||
|
||||
if not (checkpoints_exists and writes_exists and blobs_exists):
|
||||
return False
|
||||
|
||||
# 检查 checkpoints 表的 checkpoint 字段是否为 JSONB 类型
|
||||
await cur.execute("""
|
||||
SELECT data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'checkpoints'
|
||||
AND column_name = 'checkpoint'
|
||||
""")
|
||||
result = await cur.fetchone()
|
||||
if not result or result[0] != 'jsonb':
|
||||
print(f"[Checkpointer] checkpoints.checkpoint column type is {result[0] if result else 'NULL'}, expected 'jsonb'")
|
||||
return False
|
||||
|
||||
# 检查 checkpoint_writes 表是否有 task_path 列
|
||||
await cur.execute("""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.columns
|
||||
WHERE table_name = 'checkpoint_writes'
|
||||
AND column_name = 'task_path'
|
||||
)
|
||||
""")
|
||||
task_path_exists = (await cur.fetchone())[0]
|
||||
if not task_path_exists:
|
||||
print(f"[Checkpointer] checkpoint_writes.task_path column does not exist")
|
||||
return False
|
||||
|
||||
# 检查 checkpoint_writes 表的主键是否正确
|
||||
await cur.execute("""
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
WHERE tc.table_name = 'checkpoint_writes'
|
||||
AND tc.constraint_type = 'PRIMARY KEY'
|
||||
""")
|
||||
pk_count = (await cur.fetchone())[0]
|
||||
if pk_count != 5: # 应该有 5 个主键列
|
||||
print(f"[Checkpointer] checkpoint_writes has {pk_count} primary key columns, expected 5")
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[Checkpointer] Error verifying tables: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def init_checkpointer_db():
|
||||
"""
|
||||
初始化 PostgreSQL checkpointer 数据库表
|
||||
使用 AsyncPostgresSaver 的 setup 方法创建正确的表结构
|
||||
"""
|
||||
if CHECKPOINTER_TYPE != "postgres":
|
||||
return
|
||||
|
||||
try:
|
||||
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
import psycopg
|
||||
except ImportError as e:
|
||||
print(f"[Checkpointer] ERROR: Missing required package: {e}")
|
||||
print(f"[Checkpointer] Please install: pip install langgraph-checkpoint-postgres psycopg-pool")
|
||||
raise ImportError(
|
||||
"langgraph-checkpoint-postgres or psycopg-pool not installed. "
|
||||
"Run: pip install langgraph-checkpoint-postgres psycopg-pool"
|
||||
) from e
|
||||
|
||||
# 首先检查表是否已存在
|
||||
tables_exist = await verify_checkpointer_tables_exist()
|
||||
if tables_exist:
|
||||
print(f"[Checkpointer] Database tables already exist, skipping initialization")
|
||||
return
|
||||
|
||||
print(f"[Checkpointer] Initializing PostgreSQL database tables...")
|
||||
|
||||
# 尝试使用 AsyncPostgresSaver 的 setup 方法
|
||||
try:
|
||||
async with AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
kwargs={"autocommit": True}
|
||||
) as pool:
|
||||
checkpointer = AsyncPostgresSaver(pool)
|
||||
await checkpointer.setup()
|
||||
print(f"[Checkpointer] AsyncPostgresSaver.setup() executed")
|
||||
except Exception as e:
|
||||
print(f"[Checkpointer] AsyncPostgresSaver.setup() failed: {e}")
|
||||
|
||||
# 验证表是否真的被创建
|
||||
tables_exist_after = await verify_checkpointer_tables_exist()
|
||||
|
||||
if not tables_exist_after:
|
||||
print(f"[Checkpointer] Tables not created by setup(), using direct SQL...")
|
||||
try:
|
||||
await create_checkpointer_tables_directly()
|
||||
except Exception as e2:
|
||||
print(f"[Checkpointer] Direct SQL initialization also failed: {e2}")
|
||||
raise
|
||||
else:
|
||||
print(f"[Checkpointer] PostgreSQL database tables verified successfully")
|
||||
|
||||
|
||||
async def create_checkpointer_tables_directly():
|
||||
"""
|
||||
直接通过 SQL 语句创建 checkpointer 所需的数据库表
|
||||
这是备用方案,当 AsyncPostgresSaver.setup() 失败时使用
|
||||
|
||||
注意:LangGraph AsyncPostgresSaver 使用 JSONB 类型存储数据
|
||||
表名必须是: checkpoints, checkpoint_writes, checkpoint_blobs
|
||||
"""
|
||||
try:
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
except ImportError as e:
|
||||
print(f"[Checkpointer] ERROR: Missing psycopg-pool: {e}")
|
||||
raise
|
||||
|
||||
print(f"[Checkpointer] Creating checkpointer tables using direct SQL...")
|
||||
|
||||
async with AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
kwargs={"autocommit": True}
|
||||
) as pool:
|
||||
async with pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
# 先删除旧表(如果存在错误的表结构)
|
||||
await cur.execute("DROP TABLE IF EXISTS checkpoints CASCADE")
|
||||
await cur.execute("DROP TABLE IF EXISTS writes CASCADE")
|
||||
await cur.execute("DROP TABLE IF EXISTS checkpoint_blobs CASCADE")
|
||||
await cur.execute("DROP TABLE IF EXISTS checkpoint_writes CASCADE")
|
||||
await cur.execute("DROP TABLE IF EXISTS blobs CASCADE")
|
||||
await cur.execute("DROP TABLE IF EXISTS checkpoint_migrations CASCADE")
|
||||
print(f"[Checkpointer] Dropped existing tables")
|
||||
|
||||
# 创建 checkpoint_migrations 表(用于跟踪迁移版本)
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS checkpoint_migrations (
|
||||
v INTEGER PRIMARY KEY
|
||||
)
|
||||
""")
|
||||
|
||||
# 创建 checkpoints 表 - 使用 JSONB 类型
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS checkpoints (
|
||||
thread_id TEXT NOT NULL,
|
||||
checkpoint_ns TEXT NOT NULL DEFAULT '',
|
||||
checkpoint_id TEXT NOT NULL,
|
||||
parent_checkpoint_id TEXT,
|
||||
type TEXT,
|
||||
checkpoint JSONB NOT NULL DEFAULT '{}',
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# 创建 checkpoint_writes 表 - 使用 BYTEA 类型
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS checkpoint_writes (
|
||||
thread_id TEXT NOT NULL,
|
||||
checkpoint_ns TEXT NOT NULL DEFAULT '',
|
||||
checkpoint_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
task_path TEXT NOT NULL DEFAULT '',
|
||||
idx INTEGER NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
type TEXT,
|
||||
blob BYTEA,
|
||||
PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id, task_id, idx)
|
||||
)
|
||||
""")
|
||||
|
||||
# 创建 checkpoint_blobs 表 - 使用 BYTEA 类型存储二进制数据
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS checkpoint_blobs (
|
||||
thread_id TEXT NOT NULL,
|
||||
checkpoint_ns TEXT NOT NULL DEFAULT '',
|
||||
channel TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
blob BYTEA,
|
||||
PRIMARY KEY (thread_id, checkpoint_ns, channel, version)
|
||||
)
|
||||
""")
|
||||
|
||||
# 创建索引以提高查询性能
|
||||
await cur.execute("""
|
||||
CREATE INDEX IF NOT EXISTS checkpoints_thread_id_idx ON checkpoints(thread_id)
|
||||
""")
|
||||
await cur.execute("""
|
||||
CREATE INDEX IF NOT EXISTS checkpoint_writes_thread_id_idx ON checkpoint_writes(thread_id)
|
||||
""")
|
||||
await cur.execute("""
|
||||
CREATE INDEX IF NOT EXISTS checkpoint_blobs_thread_id_idx ON checkpoint_blobs(thread_id)
|
||||
""")
|
||||
|
||||
# 插入迁移版本记录
|
||||
await cur.execute("""
|
||||
INSERT INTO checkpoint_migrations (v) VALUES (1) ON CONFLICT DO NOTHING
|
||||
""")
|
||||
|
||||
print(f"[Checkpointer] Checkpointer tables created successfully via direct SQL")
|
||||
|
||||
|
||||
def create_checkpointer(checkpointer_type: str = None):
|
||||
"""
|
||||
创建同步 checkpointer 实例
|
||||
|
||||
Args:
|
||||
checkpointer_type: checkpointer 类型,可选 "memory", "postgres"
|
||||
|
||||
Returns:
|
||||
checkpointer 实例
|
||||
"""
|
||||
checkpointer_type = checkpointer_type or CHECKPOINTER_TYPE
|
||||
|
||||
if checkpointer_type == "memory":
|
||||
return MemorySaver()
|
||||
|
||||
elif checkpointer_type == "postgres":
|
||||
try:
|
||||
from langgraph.checkpoint.postgres import PostgresSaver
|
||||
from psycopg import Connection
|
||||
if not POSTGRES_CONNECTION_STRING:
|
||||
raise ValueError("POSTGRES_CONNECTION_STRING 环境变量未设置")
|
||||
conn = Connection.connect(POSTGRES_CONNECTION_STRING)
|
||||
return PostgresSaver(conn)
|
||||
except ImportError:
|
||||
print("[WARNING] psycopg 或 langgraph.checkpoint.postgres 未安装,回退到 memory")
|
||||
return MemorySaver()
|
||||
|
||||
else:
|
||||
print(f"[WARNING] 未知的 checkpointer 类型: {checkpointer_type},使用 memory")
|
||||
return MemorySaver()
|
||||
|
||||
|
||||
def create_async_checkpointer(checkpointer_type: str = None):
|
||||
"""
|
||||
创建异步 checkpointer 实例
|
||||
|
||||
Args:
|
||||
checkpointer_type: checkpointer 类型
|
||||
|
||||
Returns:
|
||||
异步 checkpointer 实例
|
||||
"""
|
||||
checkpointer_type = checkpointer_type or CHECKPOINTER_TYPE
|
||||
print(f"[Checkpointer] 创建异步 checkpointer, 类型配置: {checkpointer_type}")
|
||||
|
||||
if checkpointer_type == "memory":
|
||||
print("[Checkpointer] 使用 MemorySaver")
|
||||
return MemorySaver()
|
||||
|
||||
elif checkpointer_type == "postgres":
|
||||
try:
|
||||
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
if not POSTGRES_CONNECTION_STRING:
|
||||
raise ValueError("POSTGRES_CONNECTION_STRING 环境变量未设置")
|
||||
pool = AsyncConnectionPool(POSTGRES_CONNECTION_STRING)
|
||||
return AsyncPostgresSaver(pool)
|
||||
except ImportError:
|
||||
print("[WARNING] psycopg_pool 或 langgraph.checkpoint.postgres 未安装,回退到 memory")
|
||||
return MemorySaver()
|
||||
|
||||
else:
|
||||
print(f"[WARNING] 未知的 checkpointer 类型: {checkpointer_type},使用 memory")
|
||||
return MemorySaver()
|
||||
|
||||
|
||||
async def get_or_create_async_checkpointer(checkpointer_type: str = None):
|
||||
"""
|
||||
获取或创建异步 checkpointer 实例(正确处理上下文管理器)
|
||||
|
||||
Args:
|
||||
checkpointer_type: checkpointer 类型
|
||||
|
||||
Returns:
|
||||
已初始化的异步 checkpointer 实例
|
||||
"""
|
||||
checkpointer = create_async_checkpointer(checkpointer_type)
|
||||
|
||||
if hasattr(checkpointer, '__aenter__'):
|
||||
checkpointer = await checkpointer.__aenter__()
|
||||
|
||||
return checkpointer
|
||||
|
||||
|
||||
class CheckpointerManager:
|
||||
"""
|
||||
Checkpointer 管理器 - 单例模式
|
||||
|
||||
用于管理多个工作流的 checkpointer 实例
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_sync_checkpointer = None
|
||||
_async_conn_pool = None
|
||||
_async_checkpointer = None
|
||||
_initialized = False
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def get_checkpointer(self):
|
||||
"""获取同步 checkpointer 实例"""
|
||||
if self._sync_checkpointer is None:
|
||||
self._sync_checkpointer = create_checkpointer()
|
||||
return self._sync_checkpointer
|
||||
|
||||
async def get_async_checkpointer(self):
|
||||
"""
|
||||
获取异步 checkpointer 实例
|
||||
"""
|
||||
if self._async_checkpointer is None:
|
||||
if CHECKPOINTER_TYPE == "postgres":
|
||||
try:
|
||||
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
if not POSTGRES_CONNECTION_STRING:
|
||||
raise ValueError("POSTGRES_CONNECTION_STRING not set")
|
||||
|
||||
self._async_conn_pool = AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
open=False
|
||||
)
|
||||
await self._async_conn_pool.open()
|
||||
self._async_checkpointer = AsyncPostgresSaver(self._async_conn_pool)
|
||||
|
||||
# 检查表是否存在
|
||||
tables_exist = await verify_checkpointer_tables_exist()
|
||||
|
||||
if not tables_exist:
|
||||
# 尝试使用 setup() 方法
|
||||
try:
|
||||
await self._async_checkpointer.setup()
|
||||
print(f"[Checkpointer] AsyncPostgresSaver.setup() executed")
|
||||
except Exception as setup_e:
|
||||
print(f"[Checkpointer] AsyncPostgresSaver.setup() failed: {setup_e}")
|
||||
|
||||
# 再次验证表是否被创建
|
||||
tables_exist_after = await verify_checkpointer_tables_exist()
|
||||
|
||||
if not tables_exist_after:
|
||||
print(f"[Checkpointer] Tables not created by setup(), using direct SQL...")
|
||||
await create_checkpointer_tables_directly()
|
||||
else:
|
||||
print(f"[Checkpointer] Database tables already exist")
|
||||
|
||||
print(f"[Checkpointer] AsyncPostgresSaver created, connection: {POSTGRES_CONNECTION_STRING.split('@')[1] if '@' in POSTGRES_CONNECTION_STRING else POSTGRES_CONNECTION_STRING}")
|
||||
except ImportError as e:
|
||||
print(f"[WARNING] psycopg_pool or langgraph.checkpoint.postgres not installed: {e}, fallback to memory")
|
||||
self._async_checkpointer = MemorySaver()
|
||||
except Exception as e:
|
||||
print(f"[Checkpointer] ERROR: Failed to create AsyncPostgresSaver: {e}")
|
||||
raise
|
||||
elif CHECKPOINTER_TYPE == "memory":
|
||||
self._async_checkpointer = MemorySaver()
|
||||
print("[Checkpointer] Using MemorySaver")
|
||||
else:
|
||||
self._async_checkpointer = MemorySaver()
|
||||
print(f"[Checkpointer] Unknown type {CHECKPOINTER_TYPE}, using MemorySaver")
|
||||
|
||||
print(f"[Checkpointer] Async checkpointer type: {type(self._async_checkpointer).__name__}")
|
||||
return self._async_checkpointer
|
||||
|
||||
async def setup(self):
|
||||
"""
|
||||
初始化 checkpointer(创建数据库表等)
|
||||
"""
|
||||
if not self._initialized:
|
||||
await init_checkpointer_db()
|
||||
try:
|
||||
from tools.fault_record_db import init_fault_records_table
|
||||
await init_fault_records_table()
|
||||
except Exception as e:
|
||||
print(f"[Checkpointer] 初始化 fault_records 表失败: {e}")
|
||||
try:
|
||||
from tools.ship_model_db import init_ship_model_mapping_table
|
||||
await init_ship_model_mapping_table()
|
||||
except Exception as e:
|
||||
print(f"[Checkpointer] 初始化 ship_model_mapping 表失败: {e}")
|
||||
self._initialized = True
|
||||
|
||||
def reset(self):
|
||||
"""重置 checkpointer(主要用于测试)"""
|
||||
self._sync_checkpointer = None
|
||||
self._async_checkpointer = None
|
||||
self._async_conn_pool = None
|
||||
self._initialized = False
|
||||
|
||||
|
||||
checkpointer_manager = CheckpointerManager()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Checkpointer 配置测试:")
|
||||
print(f" 类型: {CHECKPOINTER_TYPE}")
|
||||
print(f" PostgreSQL 连接: {'已配置' if POSTGRES_CONNECTION_STRING else '未配置'}")
|
||||
|
||||
222
config.py
Normal file
@ -0,0 +1,222 @@
|
||||
"""
|
||||
项目配置文件
|
||||
从.env文件读取配置
|
||||
"""
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from contextvars import ContextVar
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
# 加载.env文件
|
||||
load_dotenv()
|
||||
|
||||
# ==================== 大模型配置 ====================
|
||||
LLM_CONFIG = {
|
||||
# "model": "Qwen3.5-35B-A3B",
|
||||
"model": "46-qwen3.5-35B",
|
||||
# "model": "Qwen3-14B",
|
||||
"base_url": "http://192.168.0.46:59800/v1",
|
||||
"api_key": "gpustack_dee9ca823290886c_5edfc86aeeeceb1e9ee5162941cb2cb5",
|
||||
"temperature": float("0.1"),
|
||||
"max_tokens": int("9216"),
|
||||
"timeout": int("30")
|
||||
}
|
||||
|
||||
# ==================== 嵌入模型配置 ====================
|
||||
EMBEDDING_CONFIG = {
|
||||
"model": "bge-m3",
|
||||
"base_url": "http://192.168.0.46:59700/v1/embeddings",
|
||||
"api_key": "gpustack_dee9ca823290886c_5edfc86aeeeceb1e9ee5162941cb2cb5",
|
||||
"base_url_v2" : "http://192.168.0.46:59700/v1",
|
||||
}
|
||||
|
||||
|
||||
# ==================== 线程安全的请求上下文配置 ====================
|
||||
# 使用 contextvars 存储每个请求的用户配置,确保多用户并发时不会相互干扰
|
||||
_request_user_config: ContextVar[Optional[Dict[str, str]]] = ContextVar('request_user_config', default=None)
|
||||
|
||||
# RAG配置的默认值
|
||||
_RAG_CONFIG_DEFAULT = {
|
||||
"search_endpoint": "http://192.168.0.46:11000/api/v1/knowledge/search/",
|
||||
"x-user-id": "1",
|
||||
"x-user-name": "testuser",
|
||||
"x-role": "admin",
|
||||
"kb-id": None,
|
||||
"file-id": None
|
||||
}
|
||||
|
||||
class DynamicRAGConfig(dict):
|
||||
"""
|
||||
动态RAG配置类,支持线程安全的请求级配置
|
||||
当访问用户相关配置时,自动从当前请求上下文读取
|
||||
其他配置项直接返回默认值
|
||||
兼容字典的所有操作方式(如 get(), [], in 等)
|
||||
"""
|
||||
def __getitem__(self, key: str):
|
||||
# 如果是用户相关配置或知识库参数,从当前请求上下文读取
|
||||
if key in ["x-user-id", "x-user-name", "x-role", "kb-id", "file-id"]:
|
||||
request_config = _request_user_config.get()
|
||||
if request_config and key in request_config:
|
||||
return request_config[key]
|
||||
# 如果没有设置,返回默认值
|
||||
return _RAG_CONFIG_DEFAULT[key]
|
||||
# 其他配置项直接返回默认值
|
||||
return _RAG_CONFIG_DEFAULT.get(key)
|
||||
|
||||
def get(self, key: str, default=None):
|
||||
"""支持 get() 方法"""
|
||||
try:
|
||||
return self[key]
|
||||
except KeyError:
|
||||
return default
|
||||
|
||||
def __contains__(self, key: str):
|
||||
"""支持 in 操作符"""
|
||||
return key in _RAG_CONFIG_DEFAULT
|
||||
|
||||
def keys(self):
|
||||
"""返回所有键"""
|
||||
return _RAG_CONFIG_DEFAULT.keys()
|
||||
|
||||
def values(self):
|
||||
"""返回所有值(动态读取用户配置)"""
|
||||
return [self[key] for key in _RAG_CONFIG_DEFAULT.keys()]
|
||||
|
||||
def items(self):
|
||||
"""返回所有键值对(动态读取用户配置)"""
|
||||
return [(key, self[key]) for key in _RAG_CONFIG_DEFAULT.keys()]
|
||||
|
||||
# ==================== RAG服务配置 ====================
|
||||
# 使用动态配置类,支持多用户并发时自动读取各自的配置
|
||||
RAG_CONFIG = DynamicRAGConfig()
|
||||
|
||||
def set_request_user_config(x_user_id: Optional[str] = None,
|
||||
x_user_name: Optional[str] = None,
|
||||
x_role: Optional[str] = None,
|
||||
text_kb_id: Optional[str] = None,
|
||||
text_file_id: Optional[str] = None):
|
||||
"""
|
||||
设置当前请求的用户配置和知识库参数(线程安全)
|
||||
|
||||
Args:
|
||||
x_user_id: 用户ID
|
||||
x_user_name: 用户名称
|
||||
x_role: 角色
|
||||
text_kb_id: 文本检索知识库ID
|
||||
text_file_id: 文本检索文件ID
|
||||
"""
|
||||
config = {}
|
||||
if x_user_id is not None:
|
||||
config["x-user-id"] = x_user_id
|
||||
if x_user_name is not None:
|
||||
config["x-user-name"] = x_user_name
|
||||
if x_role is not None:
|
||||
config["x-role"] = x_role
|
||||
# 仅当非空字符串时才设置,空字符串时不传入 RAG 接口
|
||||
if text_kb_id is not None and str(text_kb_id).strip():
|
||||
config["kb-id"] = str(text_kb_id).strip()
|
||||
if text_file_id is not None and str(text_file_id).strip():
|
||||
config["file-id"] = str(text_file_id).strip()
|
||||
_request_user_config.set(config if config else None)
|
||||
|
||||
# ==================== 图谱检索服务配置 ====================
|
||||
GRAPH_SEARCH_CONFIG = {
|
||||
"search_endpoint": "http://192.168.0.46:59085/search_graph",
|
||||
"graph_timeout": float("30.0"),
|
||||
"default_top_k": int("10"),
|
||||
"operation_search_endpoint": "http://192.168.0.46:59085/operation_graph_search"
|
||||
}
|
||||
|
||||
# ==================== 设备到系统检索服务配置 ====================
|
||||
DEVICE_SYSTEM_CONFIG = {
|
||||
"endpoint": "http://192.168.0.46:59085/device_to_system",
|
||||
"timeout": float("30.0"),
|
||||
"default_min_hops": int("2"),
|
||||
"default_max_hops": int("8"),
|
||||
"default_top_k": int("10")
|
||||
}
|
||||
|
||||
# ==================== 图册检索服务配置 ====================
|
||||
ATLAS_CONFIG = {
|
||||
"endpoint": "http://192.168.0.46:59085/atlas_retrieval",
|
||||
"timeout": float("30.0"),
|
||||
"default_top_k": int("10")
|
||||
}
|
||||
|
||||
# ==================== 工作流配置 ====================
|
||||
WORKFLOW_CONFIG = {
|
||||
"max_retry_count": int("2"), # 最大重试次数
|
||||
"image_clarity_threshold": float("0.7"), # 图片清晰度阈值
|
||||
"default_timeout": int("30") # 默认超时时间(秒)
|
||||
}
|
||||
|
||||
# ==================== 服务器配置 ====================
|
||||
SERVER_CONFIG = {
|
||||
"host": "192.168.0.46",
|
||||
"port": "59088",
|
||||
"base_url": "http://192.168.0.46:59088"
|
||||
}
|
||||
|
||||
# ==================== ASR服务配置 ====================
|
||||
ASR_CONFIG = {
|
||||
"api_base": "http://192.168.0.46:59900/v1",
|
||||
"model": "base",
|
||||
"language": "zh",
|
||||
"timeout": int("30")
|
||||
}
|
||||
|
||||
# ==================== VLM服务配置 ====================
|
||||
VLM_CONFIG = {
|
||||
"api_base" : "http://192.168.0.46:59801/v1",
|
||||
"model" : "Qwen3-VL-8B"
|
||||
}
|
||||
|
||||
# ==================== Neo4J配置 ====================
|
||||
NEO4J_CONFIG = {
|
||||
"uri": "neo4j://192.168.0.46:57687",
|
||||
"user": "neo4j",
|
||||
"password": "zdht123@"
|
||||
}
|
||||
|
||||
# ==================== 知识库匹配配置 ====================
|
||||
KB_TREE_CONFIG = {
|
||||
"url": "http://192.168.0.46:11000/api/v1/knowledge/knowledge_base/tree"
|
||||
}
|
||||
|
||||
# ==================== 维修反馈统计配置 ====================
|
||||
REPAIR_FEEDBACK_CONFIG = {
|
||||
"url": "http://192.168.0.46:22000",
|
||||
"email": "15088888888@163.com",
|
||||
"password": "11223344"
|
||||
}
|
||||
|
||||
# ==================== 数据库配置 ====================
|
||||
POSTGRES_CONNECTION_STRING = "postgresql://postgres:password@192.168.0.46:5432/an_webui"
|
||||
#POSTGRES_CONNECTION_STRING = "postgresql+psycopg://postgres:password@192.168.0.46:5432/an_webui"
|
||||
POSTGRES_SQLALCHEMY_URL = "postgresql+psycopg://postgres:password@192.168.0.46:5432/an_webui"
|
||||
|
||||
|
||||
# ==================== 索引配置 ====================
|
||||
INDEX_CONFIG = {
|
||||
"NAME_PROPERTY" : "名称",
|
||||
"FULLTEXT_PROPERTY" :"fulltext",
|
||||
"EMBEDDING_PROPERTY" : "embedding",
|
||||
"SEARCH_LABEL" : "Searchable",
|
||||
"VECTOR_INDEX_NAME" : "global_searchable_embedding",
|
||||
"FULLTEXT_INDEX_NAME" : "global_searchable_content_search"
|
||||
}
|
||||
|
||||
# ==================== sql表格字段与neo4j节点映射 ====================
|
||||
MAP_INS={
|
||||
"故障模式" : "fault",
|
||||
"系统" : "system_name",
|
||||
"设备" : "device_name",
|
||||
"舷号" : "ship_number",
|
||||
}
|
||||
|
||||
# ==================== neo4j配置 ====================
|
||||
NEO4J_CONFIG = {
|
||||
"uri": "neo4j://192.168.0.46:57687",
|
||||
"user": "neo4j",
|
||||
"password": "zdht123@"
|
||||
}
|
||||
247
main_agent.py
Normal 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
386
modelsAPI/model_api.py
Normal file
@ -0,0 +1,386 @@
|
||||
import asyncio
|
||||
import httpx
|
||||
from typing import List, Union
|
||||
import os
|
||||
import numpy as np
|
||||
from openai import OpenAI
|
||||
from config import LLM_CONFIG, EMBEDDING_CONFIG
|
||||
from neo4j_graphrag.embeddings.base import Embedder
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
# 模块级单例客户端,避免每次调用都创建新连接
|
||||
_async_client: AsyncOpenAI = None
|
||||
|
||||
|
||||
def _get_async_client() -> AsyncOpenAI:
|
||||
"""获取或创建 AsyncOpenAI 单例客户端"""
|
||||
global _async_client
|
||||
if _async_client is None:
|
||||
_async_client = AsyncOpenAI(
|
||||
api_key=LLM_CONFIG["api_key"],
|
||||
base_url=LLM_CONFIG["base_url"],
|
||||
)
|
||||
return _async_client
|
||||
|
||||
|
||||
class OpenaiAPI:
|
||||
@staticmethod
|
||||
async def open_api_chat_without_thinking(
|
||||
query: str = None,
|
||||
model: str = None,
|
||||
json_output: bool = False,
|
||||
system_prompt: str = None,
|
||||
messages: list = None,
|
||||
enable_thinking: bool = True,
|
||||
temperature: float = 0.1
|
||||
) -> str:
|
||||
if model is None:
|
||||
model = LLM_CONFIG["model"]
|
||||
if system_prompt is None:
|
||||
system_prompt = "我是一个专业的AI助手,请用中文回答问题。请直接给出最终答案,不要包含任何推理步骤、思考过程、解释性文字或前缀。"
|
||||
|
||||
|
||||
# 构建消息
|
||||
message_list = [{"role": "system", "content": system_prompt}]
|
||||
|
||||
if messages is not None:
|
||||
message_list.extend(messages)
|
||||
if query is not None:
|
||||
message_list.append({"role": "user", "content": query})
|
||||
|
||||
client = _get_async_client()
|
||||
|
||||
kwargs = {
|
||||
"model": model,
|
||||
"messages": message_list,
|
||||
"temperature": temperature,
|
||||
"stream": False,
|
||||
"max_tokens":LLM_CONFIG["max_tokens"]
|
||||
}
|
||||
|
||||
kwargs["extra_body"] = {"chat_template_kwargs": {"enable_thinking": False}}
|
||||
|
||||
if json_output:
|
||||
kwargs["response_format"] = {"type": "json_object"}
|
||||
|
||||
print("model API. history message", message_list)
|
||||
response = await client.chat.completions.create(**kwargs)
|
||||
return response.choices[0].message.content
|
||||
|
||||
@staticmethod
|
||||
async def open_api_chat_stream(
|
||||
query: str = None,
|
||||
model: str = None,
|
||||
json_output: bool = False,
|
||||
system_prompt: str = None,
|
||||
messages: list = None,
|
||||
enable_thinking: bool = True,
|
||||
temperature: float = 0.1
|
||||
):
|
||||
"""
|
||||
流式输出大模型响应
|
||||
|
||||
Yields:
|
||||
str: 流式内容片段
|
||||
"""
|
||||
if model is None:
|
||||
model = LLM_CONFIG["model"]
|
||||
if system_prompt is None:
|
||||
system_prompt = "我是一个专业的AI助手,请用中文回答问题。请直接给出最终答案,不要包含任何推理步骤、思考过程、解释性文字或前缀。"
|
||||
|
||||
# 构建消息
|
||||
message_list = [{"role": "system", "content": system_prompt}]
|
||||
|
||||
if messages is not None:
|
||||
message_list.extend(messages)
|
||||
if query is not None:
|
||||
message_list.append({"role": "user", "content": query})
|
||||
|
||||
client = _get_async_client()
|
||||
|
||||
kwargs = {
|
||||
"model": model,
|
||||
"messages": message_list,
|
||||
"temperature": temperature,
|
||||
"stream": True,
|
||||
"max_tokens":LLM_CONFIG["max_tokens"]
|
||||
}
|
||||
|
||||
kwargs["extra_body"] = {"chat_template_kwargs": {"enable_thinking": False}}
|
||||
|
||||
if json_output:
|
||||
kwargs["response_format"] = {"type": "json_object"}
|
||||
|
||||
print("model API stream. history message", message_list)
|
||||
|
||||
stream = await client.chat.completions.create(**kwargs)
|
||||
async for chunk in stream:
|
||||
if chunk.choices and len(chunk.choices) > 0:
|
||||
delta = chunk.choices[0].delta
|
||||
if delta.content:
|
||||
yield delta.content
|
||||
|
||||
@staticmethod
|
||||
async def open_api_vl_without_thinking(
|
||||
image_url: str
|
||||
) -> str:
|
||||
"""
|
||||
专门用于从图片中提取【设备名称】和【故障现象】。
|
||||
自动过滤思考过程,仅返回核心结果。
|
||||
|
||||
参数:
|
||||
image_url: Base64 格式的图片数据 (data:image/...;base64,...)
|
||||
model: 模型名称,默认为配置中的模型
|
||||
custom_instruction: 额外的特定指令 (可选)
|
||||
"""
|
||||
|
||||
# 1. 确定模型名称
|
||||
model = LLM_CONFIG.get("model")
|
||||
|
||||
# 2. 构建强约束的 System Prompt
|
||||
# 核心目标:禁止思考标签,禁止废话,只给结果
|
||||
system_prompt = (
|
||||
"你是一个工业视觉分析专家。你的任务是从图片中识别设备并诊断故障。\n"
|
||||
"【严格约束】\n"
|
||||
"1. 绝对禁止输出 <think>, <think>, <reasoning> 等任何思考过程标签。\n"
|
||||
"2. 绝对禁止输出'好的'、'根据图片'、'分析如下'等开场白或结束语。\n"
|
||||
"3. 直接输出最终结论,格式必须严格遵守下面的模板。"
|
||||
)
|
||||
|
||||
# 3. 构建针对性的 User Prompt
|
||||
default_task = (
|
||||
"请分析这张图片,描述图片内容,重点关注以下信息\n"
|
||||
"1. 设备识别:图中是什么设备?型号或标签是否可见?\n"
|
||||
"2. 状态评估:仪表盘数值是否在正常区间?指示灯颜色是什么?\n"
|
||||
"3. 异常检测:是否存在漏油、生锈、电缆破损、螺丝松动或烟雾?\n"
|
||||
"4. 环境风险:周围是否有杂物堆放或安全隐患?\n"
|
||||
"请用专业、客观、简短的语言描述。"
|
||||
)
|
||||
|
||||
final_user_prompt = default_task
|
||||
|
||||
# 4. 初始化客户端
|
||||
client = _get_async_client()
|
||||
|
||||
# 5. 构建请求参数
|
||||
kwargs = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": final_user_prompt},
|
||||
{"type": "image_url", "image_url": {"url": image_url}}
|
||||
]
|
||||
}
|
||||
],
|
||||
"temperature": 0.1, # 低温度以保证事实准确性
|
||||
"stream": False,
|
||||
"max_tokens": LLM_CONFIG.get("max_tokens"),
|
||||
}
|
||||
|
||||
# 尝试通过参数关闭思考 (取决于后端支持情况)
|
||||
kwargs["extra_body"] = {"chat_template_kwargs": {"enable_thinking": False}}
|
||||
|
||||
try:
|
||||
print(f"🚀 正在调用 {model} 进行设备故障分析...")
|
||||
response = await client.chat.completions.create(**kwargs)
|
||||
raw_content = response.choices[0].message.content or ""
|
||||
|
||||
print(f"✅ 分析完成:\n{raw_content}")
|
||||
return raw_content
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
error_msg = f"❌ 设备故障分析失败:{str(e)}\n{traceback.format_exc()}"
|
||||
print(error_msg)
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
@staticmethod
|
||||
def get_embeddings(embedding_text: str):
|
||||
embedding_base_url = EMBEDDING_CONFIG["base_url"]
|
||||
|
||||
with httpx.Client(timeout=60) as client:
|
||||
response = client.post(
|
||||
embedding_base_url,
|
||||
json={"model": EMBEDDING_CONFIG["model"], "input": embedding_text},
|
||||
headers={"Authorization": EMBEDDING_CONFIG["api_key"]},
|
||||
)
|
||||
embedding_text = response.json()["data"][0]["embedding"]
|
||||
return embedding_text
|
||||
|
||||
@staticmethod
|
||||
async def get_embeddings_async(embedding_text: str) -> List[float]:
|
||||
embedding_base_url = EMBEDDING_CONFIG["base_url"]
|
||||
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
response = await client.post(
|
||||
embedding_base_url,
|
||||
json={"model": EMBEDDING_CONFIG["model"], "input": embedding_text},
|
||||
headers={"Authorization": EMBEDDING_CONFIG["api_key"]},
|
||||
)
|
||||
embedding = response.json()["data"][0]["embedding"]
|
||||
return embedding
|
||||
|
||||
@staticmethod
|
||||
async def get_embeddings_batch_async(texts: List[str]) -> List[List[float]]:
|
||||
embedding_base_url = EMBEDDING_CONFIG["base_url"]
|
||||
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
response = await client.post(
|
||||
embedding_base_url,
|
||||
json={"model": EMBEDDING_CONFIG["model"], "input": texts},
|
||||
headers={"Authorization": EMBEDDING_CONFIG["api_key"]},
|
||||
)
|
||||
data = response.json()["data"]
|
||||
sorted_data = sorted(data, key=lambda x: x["index"])
|
||||
embeddings = [item["embedding"] for item in sorted_data]
|
||||
return embeddings
|
||||
|
||||
@staticmethod
|
||||
def cosine_similarity(vec1: Union[List[float], np.ndarray], vec2: Union[List[float], np.ndarray]) -> float:
|
||||
v1 = np.array(vec1) if not isinstance(vec1, np.ndarray) else vec1
|
||||
v2 = np.array(vec2) if not isinstance(vec2, np.ndarray) else vec2
|
||||
|
||||
norm1 = np.linalg.norm(v1)
|
||||
norm2 = np.linalg.norm(v2)
|
||||
|
||||
if norm1 == 0 or norm2 == 0:
|
||||
return 0.0
|
||||
|
||||
return float(np.dot(v1, v2) / (norm1 * norm2))
|
||||
|
||||
@staticmethod
|
||||
def cosine_similarity_batch(query_vec: Union[List[float], np.ndarray],
|
||||
candidates_vecs: List[Union[List[float], np.ndarray]]) -> List[float]:
|
||||
query = np.array(query_vec) if not isinstance(query_vec, np.ndarray) else query_vec
|
||||
candidates = [np.array(v) if not isinstance(v, np.ndarray) else v for v in candidates_vecs]
|
||||
|
||||
query_norm = np.linalg.norm(query)
|
||||
if query_norm == 0:
|
||||
return [0.0] * len(candidates)
|
||||
|
||||
similarities = []
|
||||
for candidate in candidates:
|
||||
cand_norm = np.linalg.norm(candidate)
|
||||
if cand_norm == 0:
|
||||
similarities.append(0.0)
|
||||
else:
|
||||
sim = float(np.dot(query, candidate) / (query_norm * cand_norm))
|
||||
similarities.append(sim)
|
||||
|
||||
return similarities
|
||||
|
||||
@staticmethod
|
||||
async def hybrid_match_with_embeddings(
|
||||
query_text: str,
|
||||
candidates: List[dict],
|
||||
name_key: str = "display_name",
|
||||
embedding_key: str = "embedding",
|
||||
text_weight: float = 0.3,
|
||||
semantic_weight: float = 0.7,
|
||||
text_threshold: float = 0.3,
|
||||
semantic_threshold: float = 0.5
|
||||
) -> tuple:
|
||||
if not candidates:
|
||||
return None, 0.0
|
||||
|
||||
query_embedding = await OpenaiAPI.get_embeddings_async(query_text)
|
||||
|
||||
scored_candidates = []
|
||||
|
||||
for idx, candidate in enumerate(candidates):
|
||||
name = candidate.get(name_key, "")
|
||||
if not name:
|
||||
props = candidate.get("props", {})
|
||||
name = props.get("name") or props.get("名称") or props.get("设备名称") or ""
|
||||
|
||||
text_score = OpenaiAPI._compute_text_similarity(query_text, name)
|
||||
|
||||
props = candidate.get("props", {})
|
||||
candidate_embedding = props.get(embedding_key) or candidate.get(embedding_key)
|
||||
|
||||
if candidate_embedding:
|
||||
semantic_score = OpenaiAPI.cosine_similarity(query_embedding, candidate_embedding)
|
||||
else:
|
||||
semantic_score = 0.0
|
||||
|
||||
combined_score = text_weight * text_score + semantic_weight * semantic_score
|
||||
|
||||
scored_candidates.append({
|
||||
"candidate": candidate,
|
||||
"text_score": text_score,
|
||||
"semantic_score": semantic_score,
|
||||
"combined_score": combined_score,
|
||||
"index": idx
|
||||
})
|
||||
|
||||
scored_candidates.sort(key=lambda x: x["combined_score"], reverse=True)
|
||||
|
||||
best = scored_candidates[0]
|
||||
best_candidate = best["candidate"]
|
||||
best_score = best["combined_score"]
|
||||
|
||||
min_threshold = max(text_threshold * text_weight + semantic_threshold * semantic_weight, 0.3)
|
||||
|
||||
if best_score < min_threshold:
|
||||
return None, best_score
|
||||
|
||||
return best_candidate, best_score
|
||||
|
||||
@staticmethod
|
||||
def _compute_text_similarity(a: str, b: str) -> float:
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
a = str(a).strip().lower()
|
||||
b = str(b).strip().lower()
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
if a == b:
|
||||
return 1.0
|
||||
if a in b or b in a:
|
||||
return 0.9
|
||||
|
||||
set_a, set_b = set(a), set(b)
|
||||
inter = len(set_a & set_b)
|
||||
union = len(set_a | set_b) or 1
|
||||
jaccard = inter / union
|
||||
|
||||
len_diff = abs(len(a) - len(b))
|
||||
len_penalty = max(0.0, 1.0 - len_diff / max(len(a), len(b), 1))
|
||||
|
||||
return 0.5 * jaccard + 0.5 * len_penalty
|
||||
|
||||
class LocalBgeM3Embeddings(Embedder):
|
||||
def __init__(self, base_url: str, api_key: str, model_name: str = "bge-m3"):
|
||||
self.base_url = base_url
|
||||
self.api_key = api_key
|
||||
self.model_name = model_name
|
||||
|
||||
def embed_query(self, text: str) -> List[float]:
|
||||
return self.embed_documents([text])[0]
|
||||
|
||||
def embed_documents(self, texts: List[str]) -> List[List[float]]:
|
||||
url = self.base_url
|
||||
with httpx.Client(timeout=60) as client:
|
||||
response = client.post(
|
||||
url,
|
||||
json={"model": self.model_name, "input": texts},
|
||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||
)
|
||||
return [item["embedding"] for item in response.json()["data"]]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
answer = asyncio.run(OpenaiAPI.open_api_chat_without_thinking(
|
||||
query="你好",
|
||||
json_output=True,
|
||||
system_prompt="你是一个助手,请用中文回答问题。请直接给出最终答案,不要包含任何推理步骤、思考过程、解释性文字或前缀。",
|
||||
enable_thinking=True
|
||||
))
|
||||
print(answer)
|
||||
|
||||
|
||||
|
||||
BIN
picture/015ebb33d2a3_20260324072409.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
picture/08f8efd6f1d4_20260324110619.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
picture/1ee23229170a_20260324082344.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
picture/22f06245ed56_20260601022623.png
Normal file
|
After Width: | Height: | Size: 1023 KiB |
BIN
picture/334ee511347b_20260323061707.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
picture/4341.png
Normal file
|
After Width: | Height: | Size: 242 KiB |
BIN
picture/44b07d453030_20260323024633.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
picture/50da933dcb11_20260323062013.jpg
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
picture/58a85f24f7cf_20260323024053.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
picture/79493c5be2ac_20260324081251.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
picture/95f8928fb88c_20260324083528.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
picture/OIP-C.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
picture/bbeb197ac231_20260323060515.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
picture/d345fbf33f95_20260325031818.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
picture/dcd6a33ef2b0_20260324071427.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
picture/e0bb42e29c58_20260323062055.jpg
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
picture/f233ccc07264_20260323060328.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
picture/f38267265a8a_20260323032735.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
98
picture/image_records.json
Normal file
@ -0,0 +1,98 @@
|
||||
[
|
||||
{
|
||||
"image_name": "58a85f24f7cf_20260323024053.png",
|
||||
"image_path": "picture/58a85f24f7cf_20260323024053.png",
|
||||
"description": "设备名称:未知设备\n故障现象:未发现明显异常",
|
||||
"timestamp": "2026-03-23T02:40:53.157120"
|
||||
},
|
||||
{
|
||||
"image_name": "44b07d453030_20260323024633.png",
|
||||
"image_path": "picture/44b07d453030_20260323024633.png",
|
||||
"description": "设备名称:V型发动机(剖视图)\n故障现象:正常",
|
||||
"timestamp": "2026-03-23T02:46:33.821095"
|
||||
},
|
||||
{
|
||||
"image_name": "f38267265a8a_20260323032735.png",
|
||||
"image_path": "picture/f38267265a8a_20260323032735.png",
|
||||
"description": "设备名称:京东红途(Logo标识)\n故障现象:图片中未显示任何工业设备,仅包含“京东红途”的品牌Logo标识,无法进行设备故障诊断。",
|
||||
"timestamp": "2026-03-23T03:27:35.305548"
|
||||
},
|
||||
{
|
||||
"image_name": "f233ccc07264_20260323060328.png",
|
||||
"image_path": "picture/f233ccc07264_20260323060328.png",
|
||||
"description": "1. 设备识别:图中展示的是一个汽车发动机(内燃机)的3D渲染剖面图,具体型号或标签不可见。\n2. 状态评估:该图为静态结构展示,无仪表盘数值或指示灯,无法评估运行状态。\n3. 异常检测:图中无漏油、生锈、电缆破损、螺丝松动或烟雾等异常现象,部件表面光洁,结构完整。\n4. 环境风险:背景为纯色,无杂物堆放或安全隐患。",
|
||||
"timestamp": "2026-03-23T06:03:28.810875"
|
||||
},
|
||||
{
|
||||
"image_name": "bbeb197ac231_20260323060515.png",
|
||||
"image_path": "picture/bbeb197ac231_20260323060515.png",
|
||||
"description": "1. 设备识别:图中展示的是一个汽车发动机(内燃机)的3D渲染剖面图,具体型号或标签不可见。\n2. 状态评估:该图为静态结构展示,无仪表盘数值或指示灯,无法评估运行状态。\n3. 异常检测:图中无漏油、生锈、电缆破损、螺丝松动或烟雾等异常现象,部件表面光洁,结构完整。\n4. 环境风险:背景为纯色,无杂物堆放或安全隐患。",
|
||||
"timestamp": "2026-03-23T06:05:15.445638"
|
||||
},
|
||||
{
|
||||
"image_name": "334ee511347b_20260323061707.png",
|
||||
"image_path": "picture/334ee511347b_20260323061707.png",
|
||||
"description": "1. 设备识别:图中展示的是一个V型多缸内燃机(V-type Internal Combustion Engine)的3D剖面模型,非实物照片。由于是计算机生成的渲染图,无法识别具体型号,且无可见标签。\n2. 状态评估:该设备为静态模型,无仪表盘、数值或指示灯,无法进行状态评估。\n3. 异常检测:作为3D渲染图,设备表面呈现完美的金属质感,无漏油、生锈、电缆破损、螺丝松动或烟雾等物理缺陷。\n4. 环境风险:背景为纯色渐变,无杂物堆放或安全隐患。",
|
||||
"timestamp": "2026-03-23T06:17:07.234182"
|
||||
},
|
||||
{
|
||||
"image_name": "50da933dcb11_20260323062013.jpg",
|
||||
"image_path": "picture/50da933dcb11_20260323062013.jpg",
|
||||
"description": "1. **设备识别**:图中显示为一台笔记本电脑(品牌标识模糊,疑似HP),屏幕处于开启状态,键盘区域可见。左下角叠加有“马兆帅 警圣测试专用”字样及多组编号(如999997、440300999999等),表明该设备可能用于特定测试或监控用途。\n\n2. **状态评估**:右下角显示“Sto:20.7GB”(存储剩余)和“Pow:91%”(电量),数值均在正常区间;无仪表盘或指示灯可见,无法判断颜色状态。\n\n3. **异常检测**:未发现漏油、生锈、电缆破损、螺丝松动或烟雾等物理异常;画面存在手指遮挡与运动模糊,属拍摄干扰,非设备故障。\n\n4. **环境风险**:背景可见货架、包装盒等杂物,桌面略显杂乱,存在轻微安全隐患(如物品倾倒或绊倒风险),但无即时危险。\n\n结论:设备运行状态正常,环境需整理以消除潜在风险。",
|
||||
"timestamp": "2026-03-23T06:20:13.124244"
|
||||
},
|
||||
{
|
||||
"image_name": "e0bb42e29c58_20260323062055.jpg",
|
||||
"image_path": "picture/e0bb42e29c58_20260323062055.jpg",
|
||||
"description": "1. 设备识别:图中显示一台笔记本电脑(品牌标识模糊,疑似HP),屏幕处于开启状态,键盘区域可见。设备标签及序列号不可见。\n2. 状态评估:屏幕显示内容模糊,无法读取仪表盘数值;无明确指示灯颜色可辨。\n3. 异常检测:未发现漏油、生锈、电缆破损、螺丝松动或烟雾。\n4. 环境风险:画面左侧有手指遮挡镜头,背景杂乱,存在杂物堆放,可能影响操作安全。",
|
||||
"timestamp": "2026-03-23T06:20:55.600490"
|
||||
},
|
||||
{
|
||||
"image_name": "dcd6a33ef2b0_20260324071427.png",
|
||||
"image_path": "picture/dcd6a33ef2b0_20260324071427.png",
|
||||
"description": "1. 设备识别:图中为一辆汽车的发动机舱,引擎盖处于开启状态。由于拍摄角度和遮挡,具体的车辆型号及发动机标签不可见。\n2. 状态评估:仪表盘不可见,无法评估数值或指示灯状态。\n3. 异常检测:发动机舱内存在明显的白色烟雾(或蒸汽),主要集中在发动机左侧及中部区域,表明存在严重的过热或冷却液泄漏故障。\n4. 环境风险:背景中可见堆放的纸箱及杂物,地面有蓝色垫子,存在杂物堆放情况,可能影响维修作业安全。",
|
||||
"timestamp": "2026-03-24T07:14:27.522730"
|
||||
},
|
||||
{
|
||||
"image_name": "015ebb33d2a3_20260324072409.png",
|
||||
"image_path": "picture/015ebb33d2a3_20260324072409.png",
|
||||
"description": "1. 设备识别:图中设备为一辆汽车的发动机舱,引擎盖处于开启状态。由于拍摄角度和遮挡,具体的车辆型号及发动机标签不可见。\n2. 状态评估:仪表盘不可见,无法评估数值或指示灯状态。\n3. 异常检测:存在明显的白色烟雾(或蒸汽)从发动机舱内部升起,主要集中在发动机左侧及中部区域,表明存在过热或冷却液泄漏等故障。\n4. 环境风险:背景中可见堆放的纸箱及杂物,地面有蓝色垫子,属于维修车间环境,存在杂物堆放带来的潜在安全隐患。",
|
||||
"timestamp": "2026-03-24T07:24:09.812908"
|
||||
},
|
||||
{
|
||||
"image_name": "79493c5be2ac_20260324081251.png",
|
||||
"image_path": "picture/79493c5be2ac_20260324081251.png",
|
||||
"description": "1. 设备识别:图中为一辆白色汽车的发动机舱,引擎盖处于开启状态。具体车型或设备型号不可见。\n2. 状态评估:仪表盘不可见,无法评估数值或指示灯状态。\n3. 异常检测:发动机舱内存在明显的白色烟雾(或蒸汽),表明可能存在冷却液泄漏、过热或密封失效等故障。\n4. 环境风险:背景区域可见堆放的纸箱及杂物,存在一定程度的杂乱,可能影响维修作业安全。",
|
||||
"timestamp": "2026-03-24T08:12:51.902164"
|
||||
},
|
||||
{
|
||||
"image_name": "1ee23229170a_20260324082344.png",
|
||||
"image_path": "picture/1ee23229170a_20260324082344.png",
|
||||
"description": "1. 设备识别:图中设备为一辆汽车的发动机舱,引擎盖处于开启状态。由于拍摄角度和遮挡,具体的车辆型号或标签不可见。\n2. 状态评估:仪表盘不可见,无法评估数值或指示灯状态。\n3. 异常检测:存在明显的白色烟雾(或蒸汽)从发动机舱内部冒出,主要集中在左侧进气歧管区域,表明发动机存在过热或冷却系统泄漏等严重故障。\n4. 环境风险:背景中可见堆放的纸箱和杂物,且车辆处于非正常行驶状态,存在一定安全隐患。",
|
||||
"timestamp": "2026-03-24T08:23:44.780598"
|
||||
},
|
||||
{
|
||||
"image_name": "95f8928fb88c_20260324083528.png",
|
||||
"image_path": "picture/95f8928fb88c_20260324083528.png",
|
||||
"description": "1. 设备识别:图中为一辆汽车的发动机舱,引擎盖处于开启状态。由于拍摄角度和遮挡,无法识别具体的车辆型号或发动机标签。\n2. 状态评估:仪表盘不可见,无法评估数值或指示灯状态。\n3. 异常检测:发动机舱内存在明显的白色烟雾(或蒸汽),主要集中在发动机左侧区域,表明可能存在冷却液泄漏、高温或机械故障。\n4. 环境风险:背景中可见堆放的纸箱和杂物,地面有蓝色垫子,存在杂物堆放情况,可能影响维修作业安全。",
|
||||
"timestamp": "2026-03-24T08:35:28.036819"
|
||||
},
|
||||
{
|
||||
"image_name": "08f8efd6f1d4_20260324110619.png",
|
||||
"image_path": "picture/08f8efd6f1d4_20260324110619.png",
|
||||
"description": "1. 设备识别:图中是一辆汽车的发动机舱,引擎盖处于开启状态。由于拍摄角度和遮挡,具体的车辆型号或标签不可见。\n2. 状态评估:仪表盘不可见,无法评估数值或指示灯状态。\n3. 异常检测:发动机舱内存在明显的白色烟雾(或蒸汽),主要集中在发动机左侧区域,表明可能存在冷却液泄漏、过热或管路破裂等严重故障。\n4. 环境风险:背景中可见堆放的纸箱和杂物,地面有蓝色垫子,存在杂物堆放情况,可能影响维修操作或造成绊倒隐患。",
|
||||
"timestamp": "2026-03-24T11:06:19.237516"
|
||||
},
|
||||
{
|
||||
"image_name": "d345fbf33f95_20260325031818.png",
|
||||
"image_path": "picture/d345fbf33f95_20260325031818.png",
|
||||
"description": "1. 设备识别:图中为一辆白色轿车的发动机舱,引擎盖处于开启状态。由于拍摄角度和遮挡,具体的车辆型号及发动机标签不可见。\n2. 状态评估:仪表盘不可见,无法评估数值或指示灯状态。\n3. 异常检测:发动机舱内存在明显的白色烟雾(或蒸汽),主要集中在发动机左侧及中部区域,表明存在高温泄漏或冷却系统故障。\n4. 环境风险:背景中可见堆放的纸箱及杂物,地面有蓝色垫子,存在杂物堆放情况,可能影响维修作业安全。",
|
||||
"timestamp": "2026-03-25T03:18:18.243134"
|
||||
},
|
||||
{
|
||||
"image_name": "22f06245ed56_20260601022623.png",
|
||||
"image_path": "picture/22f06245ed56_20260601022623.png",
|
||||
"description": "Error: Error code: 400 - {'error': {'message': 'Qwen3-14B is not a multimodal model', 'type': 'BadRequestError', 'param': None, 'code': 400}}",
|
||||
"timestamp": "2026-06-01T02:26:23.900253"
|
||||
}
|
||||
]
|
||||
1251
prompts.py
Normal file
44
requirements.txt
Normal file
@ -0,0 +1,44 @@
|
||||
# Agent任务划分开发项目依赖
|
||||
|
||||
# LangChain相关
|
||||
langchain
|
||||
langchain-core
|
||||
langchain-openai
|
||||
langgraph
|
||||
langgraph-checkpoint
|
||||
langgraph-checkpoint-postgres
|
||||
|
||||
# HTTP客户端
|
||||
httpx
|
||||
requests
|
||||
|
||||
# 数据处理
|
||||
pydantic
|
||||
typing-extensions
|
||||
numpy
|
||||
|
||||
# 其他工具
|
||||
python-dotenv
|
||||
|
||||
# FastAPI 相关
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
|
||||
# Neo4j 图数据库
|
||||
neo4j
|
||||
|
||||
# PostgreSQL 支持
|
||||
psycopg[binary]
|
||||
psycopg-pool
|
||||
|
||||
# OpenAI API
|
||||
openai
|
||||
tiktoken
|
||||
|
||||
# Word 文档生成
|
||||
python-docx
|
||||
|
||||
# PDF 生成相关
|
||||
matplotlib
|
||||
Pillow
|
||||
|
||||
47
template.tex
Normal file
@ -0,0 +1,47 @@
|
||||
|
||||
\documentclass[10pt]{article}
|
||||
\usepackage[UTF8]{ctex}
|
||||
\usepackage{geometry}
|
||||
\usepackage{graphicx}
|
||||
\usepackage{amsmath}
|
||||
\usepackage{array}
|
||||
\usepackage{booktabs}
|
||||
\usepackage{longtable}
|
||||
\usepackage{setspace}
|
||||
\usepackage{fancyhdr}
|
||||
\usepackage{titlesec}
|
||||
\usepackage{tabularx}
|
||||
\usepackage{makecell}
|
||||
\usepackage{adjustbox}
|
||||
\usepackage{hyperref}
|
||||
% 页面设置
|
||||
\geometry{margin=2cm}
|
||||
\setlength{\parskip}{1em}
|
||||
\setlength{\parindent}{0em}
|
||||
|
||||
% 字体
|
||||
\setCJKmainfont{Noto Serif CJK SC}
|
||||
|
||||
% 标题样式
|
||||
\title{\Large \textbf{$title$}}
|
||||
\author{}
|
||||
\date{}
|
||||
|
||||
% 章节标题样式
|
||||
\titleformat{\section}{\large\bfseries\raggedright}{\thesection}{1em}{}
|
||||
\titleformat{\subsection}{\normalsize\bfseries\raggedright}{\thesubsection}{1em}{}
|
||||
|
||||
|
||||
% ===== Pandoc compatibility =====
|
||||
\usepackage{hyperref}
|
||||
\providecommand{\tightlist}{%
|
||||
\setlength{\itemsep}{0pt}\setlength{\parskip}{0pt}}
|
||||
|
||||
|
||||
\begin{document}
|
||||
|
||||
\maketitle
|
||||
|
||||
$body$
|
||||
|
||||
\end{document}
|
||||
41
tools/__init__.py
Normal file
@ -0,0 +1,41 @@
|
||||
|
||||
"""
|
||||
工具模块
|
||||
包含RAG、GraphRAG、VLM、ASR、TTS等工具
|
||||
"""
|
||||
from .function_tool import (
|
||||
detect_input_type,
|
||||
graph_rag_search,
|
||||
fault_graph_rag_search,
|
||||
operation_graph_rag_search,
|
||||
rag_search_tool,
|
||||
vlm_image_to_text,
|
||||
asr_audio_to_text,
|
||||
extract_device_and_fault,
|
||||
image_rag_describe,
|
||||
file_to_text_tool,
|
||||
fault_type_statistics_tool,
|
||||
fault_frequency_statistics_tool,
|
||||
spare_parts_statistics_tool,
|
||||
ALL_TOOLS,
|
||||
TOOL_CATEGORIES
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"detect_input_type",
|
||||
"graph_rag_search",
|
||||
"fault_graph_rag_search",
|
||||
"operation_graph_rag_search",
|
||||
"rag_search_tool",
|
||||
"vlm_image_to_text",
|
||||
"asr_audio_to_text",
|
||||
"extract_device_and_fault",
|
||||
"image_rag_describe",
|
||||
"file_to_text_tool",
|
||||
"fault_type_statistics_tool",
|
||||
"fault_frequency_statistics_tool",
|
||||
"spare_parts_statistics_tool",
|
||||
"ALL_TOOLS",
|
||||
"TOOL_CATEGORIES"
|
||||
]
|
||||
|
||||
237
tools/agent_usage_statistics.py
Normal file
@ -0,0 +1,237 @@
|
||||
"""
|
||||
智能体使用统计工具
|
||||
记录和统计智能体的调用次数(PostgreSQL数据库存储)
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any
|
||||
|
||||
from config import POSTGRES_CONNECTION_STRING
|
||||
|
||||
|
||||
ROUTE_FLAG_DISPLAY_NAMES = {
|
||||
"问题排查": "问题排查",
|
||||
"操作使用": "操作使用",
|
||||
"百科问答": "百科问答"
|
||||
}
|
||||
|
||||
ROUTE_FLAG_ICONS = {
|
||||
"问题排查": "detection",
|
||||
"操作使用": "repair",
|
||||
"百科问答": "doc"
|
||||
}
|
||||
|
||||
ROUTE_FLAG_COLORS = {
|
||||
"问题排查": "#FFB800",
|
||||
"操作使用": "#00C2FF",
|
||||
"百科问答": "#9153FF"
|
||||
}
|
||||
|
||||
|
||||
async def init_agent_usage_table():
|
||||
"""
|
||||
初始化 agent_usage_records 表
|
||||
"""
|
||||
try:
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
except ImportError:
|
||||
print("[agent_usage_statistics] psycopg_pool 未安装,无法初始化表")
|
||||
return
|
||||
|
||||
async with AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
kwargs={"autocommit": True}
|
||||
) as pool:
|
||||
async with pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS agent_usage_records (
|
||||
id SERIAL PRIMARY KEY,
|
||||
chat_id TEXT NOT NULL DEFAULT '',
|
||||
route_flag TEXT NOT NULL DEFAULT '',
|
||||
message_id TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(chat_id, route_flag)
|
||||
)
|
||||
""")
|
||||
await cur.execute("""
|
||||
CREATE INDEX IF NOT EXISTS agent_usage_records_route_flag_idx ON agent_usage_records(route_flag)
|
||||
""")
|
||||
await cur.execute("""
|
||||
CREATE INDEX IF NOT EXISTS agent_usage_records_created_at_idx ON agent_usage_records(created_at)
|
||||
""")
|
||||
print("[agent_usage_statistics] agent_usage_records 表初始化完成")
|
||||
|
||||
|
||||
async def record_agent_usage(chat_id: str, route_flag: str, message_id: str = "") -> bool:
|
||||
"""
|
||||
记录智能体使用情况到数据库
|
||||
|
||||
Args:
|
||||
chat_id: 会话ID
|
||||
route_flag: 路由标志(智能体类型)
|
||||
message_id: 消息ID
|
||||
|
||||
Returns:
|
||||
是否记录成功
|
||||
"""
|
||||
if not chat_id or not route_flag:
|
||||
return False
|
||||
|
||||
try:
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
except ImportError:
|
||||
print("[agent_usage_statistics] psycopg_pool 未安装,无法记录")
|
||||
return False
|
||||
|
||||
try:
|
||||
async with AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
kwargs={"autocommit": True}
|
||||
) as pool:
|
||||
async with pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""
|
||||
INSERT INTO agent_usage_records (chat_id, route_flag, message_id, created_at)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON CONFLICT (chat_id, route_flag) DO UPDATE SET
|
||||
message_id = EXCLUDED.message_id,
|
||||
created_at = EXCLUDED.created_at
|
||||
""",
|
||||
(chat_id, route_flag, message_id or "", datetime.now())
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[agent_usage_statistics] 记录智能体使用失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _format_count(count: int) -> str:
|
||||
if count >= 10000:
|
||||
return f"{count / 10000:.1f}万"
|
||||
else:
|
||||
return f"{count:,}"
|
||||
|
||||
|
||||
async def get_agent_usage_statistics() -> Dict[str, Any]:
|
||||
"""
|
||||
从数据库查询智能体使用统计
|
||||
|
||||
统计逻辑:
|
||||
- 同一 chat_id + route_flag 只保留一条记录
|
||||
- 统计各类型的总使用次数
|
||||
- 统计近6个月的月度使用次数
|
||||
|
||||
Returns:
|
||||
统计结果字典
|
||||
"""
|
||||
try:
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
except ImportError:
|
||||
print("[agent_usage_statistics] psycopg_pool 未安装,无法查询")
|
||||
return {"success": False, "data": None}
|
||||
|
||||
required_routes = ["操作使用", "问题排查", "百科问答"]
|
||||
|
||||
now = datetime.now()
|
||||
last_6_months = []
|
||||
for i in range(5, -1, -1):
|
||||
year = now.year
|
||||
month = now.month - i
|
||||
while month <= 0:
|
||||
month += 12
|
||||
year -= 1
|
||||
last_6_months.append({
|
||||
"year": year,
|
||||
"month": month,
|
||||
"key": f"{year}-{month:02d}",
|
||||
"label": f"{month}月"
|
||||
})
|
||||
|
||||
try:
|
||||
async with AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
kwargs={"autocommit": True}
|
||||
) as pool:
|
||||
async with pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
# 查询各类型总使用次数
|
||||
route_counts = {}
|
||||
for route_flag in required_routes:
|
||||
await cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*) FROM agent_usage_records
|
||||
WHERE route_flag = %s
|
||||
""",
|
||||
(route_flag,)
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
route_counts[route_flag] = row[0] if row else 0
|
||||
|
||||
# 查询近6个月各类型月度使用次数
|
||||
monthly_counts = {route: {m["key"]: 0 for m in last_6_months} for route in required_routes}
|
||||
|
||||
for route_flag in required_routes:
|
||||
for m in last_6_months:
|
||||
month_start = datetime(m["year"], m["month"], 1)
|
||||
if m["month"] == 12:
|
||||
month_end = datetime(m["year"] + 1, 1, 1)
|
||||
else:
|
||||
month_end = datetime(m["year"], m["month"] + 1, 1)
|
||||
|
||||
await cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*) FROM agent_usage_records
|
||||
WHERE route_flag = %s AND created_at >= %s AND created_at < %s
|
||||
""",
|
||||
(route_flag, month_start, month_end)
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
monthly_counts[route_flag][m["key"]] = row[0] if row else 0
|
||||
|
||||
except Exception as e:
|
||||
print(f"[agent_usage_statistics] 查询统计数据失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {"success": False, "data": None}
|
||||
|
||||
total = sum(route_counts.get(route, 0) for route in required_routes)
|
||||
|
||||
types_data = []
|
||||
monthly_stats = []
|
||||
for route_flag in required_routes:
|
||||
count = route_counts.get(route_flag, 0)
|
||||
display_name = ROUTE_FLAG_DISPLAY_NAMES.get(route_flag, route_flag)
|
||||
icon = ROUTE_FLAG_ICONS.get(route_flag, "info")
|
||||
color = ROUTE_FLAG_COLORS.get(route_flag, "#8C8C8C")
|
||||
|
||||
types_data.append({
|
||||
"name": display_name,
|
||||
"count": _format_count(count),
|
||||
"icon": icon,
|
||||
"color": color
|
||||
})
|
||||
|
||||
monthly_data = []
|
||||
for m in last_6_months:
|
||||
monthly_data.append({
|
||||
"month": m["label"],
|
||||
"count": monthly_counts[route_flag][m["key"]]
|
||||
})
|
||||
|
||||
monthly_stats.append({
|
||||
"name": display_name,
|
||||
"color": color,
|
||||
"monthlyCounts": monthly_data
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"total": _format_count(total),
|
||||
"types": types_data,
|
||||
"monthlyStats": monthly_stats,
|
||||
"months": [m["label"] for m in last_6_months]
|
||||
}
|
||||
}
|
||||
|
||||
330
tools/fault_record_db.py
Normal file
@ -0,0 +1,330 @@
|
||||
"""
|
||||
故障记录数据库模块
|
||||
用于在故障诊断智能体生成方案后自动记录故障信息到PostgreSQL数据库
|
||||
记录内容:舷号、设备、故障、备件、系统
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
from config import POSTGRES_CONNECTION_STRING
|
||||
|
||||
|
||||
async def init_fault_records_table():
|
||||
"""
|
||||
初始化 fault_records 表
|
||||
"""
|
||||
try:
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
except ImportError:
|
||||
print("[fault_record_db] psycopg_pool 未安装,无法初始化表")
|
||||
return
|
||||
|
||||
async with AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
kwargs={"autocommit": True}
|
||||
) as pool:
|
||||
async with pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS fault_records (
|
||||
id SERIAL PRIMARY KEY,
|
||||
ship_number TEXT NOT NULL DEFAULT '',
|
||||
device_name TEXT NOT NULL DEFAULT '',
|
||||
fault TEXT NOT NULL DEFAULT '',
|
||||
spare_parts JSONB NOT NULL DEFAULT '[]',
|
||||
system_name TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
await cur.execute("""
|
||||
CREATE INDEX IF NOT EXISTS fault_records_ship_number_idx ON fault_records(ship_number)
|
||||
""")
|
||||
await cur.execute("""
|
||||
CREATE INDEX IF NOT EXISTS fault_records_device_name_idx ON fault_records(device_name)
|
||||
""")
|
||||
await cur.execute("""
|
||||
CREATE INDEX IF NOT EXISTS fault_records_system_name_idx ON fault_records(system_name)
|
||||
""")
|
||||
await cur.execute("""
|
||||
CREATE INDEX IF NOT EXISTS fault_records_created_at_idx ON fault_records(created_at)
|
||||
""")
|
||||
print("[fault_record_db] fault_records 表初始化完成")
|
||||
|
||||
|
||||
|
||||
async def extract_spare_parts_from_scheme(scheme_content: str) -> List[str]:
|
||||
"""
|
||||
从方案内容中用大模型抽取备件名称列表
|
||||
|
||||
Args:
|
||||
scheme_content: 方案内容(Markdown格式)
|
||||
|
||||
Returns:
|
||||
备件名称列表
|
||||
"""
|
||||
if not scheme_content or len(scheme_content.strip()) < 10:
|
||||
return []
|
||||
|
||||
prompt = f"""从以下维修方案内容中提取所有提到的备品备件名称。
|
||||
|
||||
方案内容:
|
||||
{scheme_content[:3000]}
|
||||
|
||||
提取规则:
|
||||
1. 找到"备品备件"、"备件"等相关章节,提取其中的项目名称
|
||||
2. 只提取名称,不要提取型号、规格、数量、材质、用途等
|
||||
3. 如果没有专门的章节,也从全文中识别备件名称
|
||||
|
||||
以JSON数组格式返回:
|
||||
{{
|
||||
"备品备件": [备件名称1, 备件名称2, ...]
|
||||
}}
|
||||
|
||||
如果没有提到备品备件,请返回空数组 []"""
|
||||
|
||||
try:
|
||||
from modelsAPI.model_api import OpenaiAPI
|
||||
result_text = await OpenaiAPI.open_api_chat_without_thinking(
|
||||
query=prompt,
|
||||
json_output=True
|
||||
)
|
||||
print("抽取出来的备品备件", result_text)
|
||||
|
||||
result_text = result_text.strip()
|
||||
result_text = re.sub(r'^```json\s*', '', result_text, flags=re.IGNORECASE)
|
||||
result_text = re.sub(r'^```\s*', '', result_text)
|
||||
result_text = re.sub(r'```$', '', result_text)
|
||||
|
||||
try:
|
||||
parsed = json.loads(result_text)
|
||||
except json.JSONDecodeError:
|
||||
json_match = re.search(r'\{.*\}', result_text, re.DOTALL)
|
||||
if json_match:
|
||||
parsed = json.loads(json_match.group(0))
|
||||
else:
|
||||
return []
|
||||
|
||||
if isinstance(parsed, dict):
|
||||
if "备品备件" in parsed and isinstance(parsed["备品备件"], list):
|
||||
return [str(part).strip() for part in parsed["备品备件"] if part]
|
||||
all_parts = []
|
||||
for v in parsed.values():
|
||||
if isinstance(v, list):
|
||||
all_parts.extend([str(part).strip() for part in v if part])
|
||||
return all_parts
|
||||
elif isinstance(parsed, list):
|
||||
return [str(part).strip() for part in parsed if part]
|
||||
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
print(f"[fault_record_db] 从方案提取备件失败: {str(e)}")
|
||||
return []
|
||||
|
||||
|
||||
async def save_fault_record(
|
||||
ship_number: str,
|
||||
device_name: str,
|
||||
fault: str,
|
||||
spare_parts: List[str],
|
||||
system_name: str
|
||||
) -> bool:
|
||||
"""
|
||||
保存故障记录到数据库
|
||||
|
||||
Args:
|
||||
ship_number: 舷号
|
||||
device_name: 设备名称
|
||||
fault: 故障现象
|
||||
spare_parts: 备件列表
|
||||
system_name: 系统名称
|
||||
|
||||
Returns:
|
||||
是否保存成功
|
||||
"""
|
||||
if not ship_number and not device_name and not fault:
|
||||
print("[fault_record_db] 舷号、设备、故障均为空,跳过保存")
|
||||
return False
|
||||
|
||||
try:
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
except ImportError:
|
||||
print("[fault_record_db] psycopg_pool 未安装,无法保存")
|
||||
return False
|
||||
|
||||
try:
|
||||
async with AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
kwargs={"autocommit": True}
|
||||
) as pool:
|
||||
async with pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""
|
||||
INSERT INTO fault_records (ship_number, device_name, fault, spare_parts, system_name, created_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
ship_number or "",
|
||||
device_name or "",
|
||||
fault or "",
|
||||
json.dumps(spare_parts, ensure_ascii=False),
|
||||
system_name or "",
|
||||
datetime.now()
|
||||
)
|
||||
)
|
||||
print(f"[fault_record_db] 故障记录已保存: 舷号={ship_number}, 设备={device_name}, 故障={fault}, 系统={system_name}, 备件数={len(spare_parts)}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[fault_record_db] 保存故障记录失败: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
async def record_fault_from_state(state: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
从工作流状态中提取故障信息并记录到数据库
|
||||
在故障诊断智能体生成方案后调用
|
||||
|
||||
流程:
|
||||
1. 从状态中获取舷号、设备、故障
|
||||
2. 从方案内容中用大模型抽取备件
|
||||
3. 通过设备名称反推系统
|
||||
4. 保存到数据库
|
||||
|
||||
Args:
|
||||
state: 工作流状态字典
|
||||
|
||||
Returns:
|
||||
是否记录成功
|
||||
"""
|
||||
ship_number = state.get("ship_number", "") or ""
|
||||
device_name = state.get("device_name", "") or ""
|
||||
fault = state.get("fault", "") or ""
|
||||
|
||||
if not device_name and not fault:
|
||||
print("[fault_record_db] 设备和故障均为空,跳过记录")
|
||||
return False
|
||||
|
||||
scheme_content = state.get("last_generated_scheme", "") or state.get("response", "") or ""
|
||||
|
||||
spare_parts = await extract_spare_parts_from_scheme(scheme_content)
|
||||
|
||||
system_name = "其他"
|
||||
if device_name:
|
||||
from tools.fault_statistics import get_device_system_from_neo4j
|
||||
system_name = await get_device_system_from_neo4j(device_name, ship_number=ship_number)
|
||||
|
||||
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 []
|
||||
|
||||
950
tools/fault_statistics.py
Normal file
@ -0,0 +1,950 @@
|
||||
"""
|
||||
故障类型统计工具模块
|
||||
用于统计故障诊断智能体记录的故障信息
|
||||
数据来源: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, ship_number: Optional[str] = None) -> str:
|
||||
"""
|
||||
从 Neo4j 图谱中查询设备对应的系统,带缓存机制。
|
||||
|
||||
实际检索由 tools.graph_tools.find_system_by_device 负责;该函数会使用
|
||||
config.INDEX_CONFIG 中配置的全局 Neo4j 全文索引和向量索引。
|
||||
"""
|
||||
if not device_name or not device_name.strip():
|
||||
return "其他"
|
||||
|
||||
device_name_clean = device_name.strip().lower()
|
||||
ship_number_clean = str(ship_number or "").strip().lower()
|
||||
cache_key = f"{ship_number_clean}|{device_name_clean}"
|
||||
|
||||
if cache_key in neo4j_cache:
|
||||
return neo4j_cache[cache_key]
|
||||
|
||||
try:
|
||||
from tools.graph_tools import find_system_by_device
|
||||
|
||||
result_json = await find_system_by_device(
|
||||
device_name=device_name.strip(),
|
||||
ship_number=ship_number,
|
||||
min_hops=2,
|
||||
max_hops=8,
|
||||
top_k=10,
|
||||
)
|
||||
|
||||
if not result_json.get("success", False):
|
||||
result = "其他"
|
||||
neo4j_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
systems = result_json.get("systems", [])
|
||||
if not systems or not isinstance(systems, list):
|
||||
result = "其他"
|
||||
neo4j_cache[cache_key] = 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[cache_key] = result
|
||||
return result
|
||||
|
||||
result = "其他"
|
||||
neo4j_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
print(f"查询设备系统失败: {str(e)}")
|
||||
result = "其他"
|
||||
neo4j_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
|
||||
def generate_color_palette(n: int) -> List[str]:
|
||||
predefined_colors = [
|
||||
"#00C2FF", "#4E7CFE", "#9153FF", "#FFB800", "#00D16B",
|
||||
"#FF6B6B", "#4ECDC4", "#45B7D1", "#FFA07A", "#98D8C8",
|
||||
"#F7DC6F", "#BB8FCE", "#85C1E9", "#F8B500", "#00E676",
|
||||
"#FF4081", "#7C4DFF", "#18FFFF", "#69F0AE", "#FFD740"
|
||||
]
|
||||
|
||||
if n <= len(predefined_colors):
|
||||
return predefined_colors[:n]
|
||||
|
||||
colors = predefined_colors.copy()
|
||||
|
||||
import colorsys
|
||||
for i in range(len(predefined_colors), n):
|
||||
hue = (i * 0.618033988749895) % 1.0
|
||||
saturation = 0.7 + (i % 3) * 0.1
|
||||
value = 0.8 + (i % 2) * 0.1
|
||||
|
||||
rgb = colorsys.hsv_to_rgb(hue, saturation, value)
|
||||
color = "#{:02X}{:02X}{:02X}".format(
|
||||
int(rgb[0] * 255),
|
||||
int(rgb[1] * 255),
|
||||
int(rgb[2] * 255)
|
||||
)
|
||||
colors.append(color)
|
||||
|
||||
return colors
|
||||
|
||||
|
||||
async def _query_fault_records_from_db(
|
||||
ship_number: Optional[str] = None,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
system_name: Optional[str] = None,
|
||||
limit: int = 5000
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
从数据库查询故障记录(内部共用方法)
|
||||
"""
|
||||
from tools.fault_record_db import query_fault_records
|
||||
return await query_fault_records(
|
||||
ship_number=ship_number,
|
||||
system_name=system_name,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
|
||||
async def statistics_fault_types(
|
||||
ship_number_filter: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
统计故障类型(从数据库读取),按系统分类计算百分比
|
||||
|
||||
Args:
|
||||
ship_number_filter: 舷号过滤(可选)
|
||||
|
||||
Returns:
|
||||
包含统计结果的字典
|
||||
"""
|
||||
try:
|
||||
records = await _query_fault_records_from_db(ship_number=ship_number_filter)
|
||||
|
||||
if not records:
|
||||
return {
|
||||
"success": True,
|
||||
"faultTypeData": []
|
||||
}
|
||||
|
||||
system_totals = {}
|
||||
valid_count = 0
|
||||
|
||||
for record in records:
|
||||
system_name = record.get("system_name", "其他")
|
||||
if not system_name:
|
||||
system_name = "其他"
|
||||
valid_count += 1
|
||||
|
||||
if system_name not in system_totals:
|
||||
system_totals[system_name] = 0
|
||||
system_totals[system_name] += 1
|
||||
|
||||
fault_type_data = []
|
||||
if valid_count > 0:
|
||||
unique_systems = sorted(system_totals.keys())
|
||||
colors = generate_color_palette(len(unique_systems))
|
||||
system_color_map = {system: color for system, color in zip(unique_systems, colors)}
|
||||
|
||||
for system_name, count in system_totals.items():
|
||||
percentage = round((count / valid_count) * 100)
|
||||
color = system_color_map.get(system_name, "#6E7D91")
|
||||
fault_type_data.append({
|
||||
"value": percentage,
|
||||
"name": system_name,
|
||||
"itemStyle": {
|
||||
"color": color
|
||||
}
|
||||
})
|
||||
|
||||
fault_type_data.sort(key=lambda x: x["value"], reverse=True)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"faultTypeData": fault_type_data
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"[故障类型统计错误] {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {
|
||||
"success": False,
|
||||
"faultTypeData": []
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
async def fault_type_statistics_tool(
|
||||
ship_number_filter: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
故障类型统计工具(供工作流调用)
|
||||
统计故障类型,按系统分类并计算百分比
|
||||
|
||||
Args:
|
||||
ship_number_filter: 舷号过滤(可选)
|
||||
|
||||
Returns:
|
||||
统计结果,包含 success 和 faultTypeData 字段
|
||||
"""
|
||||
result = await statistics_fault_types(ship_number_filter=ship_number_filter)
|
||||
return result
|
||||
|
||||
|
||||
async def merge_similar_faults_with_embedding(
|
||||
fault_counter: Dict[str, int],
|
||||
fault_system_map: Dict[str, str],
|
||||
similarity_threshold: float = 0.85
|
||||
) -> tuple:
|
||||
"""
|
||||
使用embedding合并相似的故障
|
||||
"""
|
||||
if not fault_counter:
|
||||
return {}, {}, {}
|
||||
|
||||
fault_keys = list(fault_counter.keys())
|
||||
|
||||
if len(fault_keys) <= 1:
|
||||
return fault_counter.copy(), fault_system_map.copy(), {}
|
||||
|
||||
try:
|
||||
from modelsAPI.model_api import OpenaiAPI
|
||||
|
||||
embeddings = await OpenaiAPI.get_embeddings_batch_async(fault_keys)
|
||||
|
||||
fault_embedding_map = {key: emb for key, emb in zip(fault_keys, embeddings)}
|
||||
|
||||
merged_counter = {}
|
||||
merged_system_map = {}
|
||||
merge_mapping = {}
|
||||
merged_keys = set()
|
||||
|
||||
sorted_faults = sorted(fault_counter.items(), key=lambda x: x[1], reverse=True)
|
||||
|
||||
for fault_key, count in sorted_faults:
|
||||
if fault_key in merged_keys:
|
||||
continue
|
||||
|
||||
merged_keys.add(fault_key)
|
||||
merged_counter[fault_key] = count
|
||||
merged_system_map[fault_key] = fault_system_map.get(fault_key, "其他")
|
||||
|
||||
for other_key, other_count in sorted_faults:
|
||||
if other_key in merged_keys or other_key == fault_key:
|
||||
continue
|
||||
|
||||
if fault_embedding_map.get(fault_key) and fault_embedding_map.get(other_key):
|
||||
similarity = OpenaiAPI.cosine_similarity(
|
||||
fault_embedding_map[fault_key],
|
||||
fault_embedding_map[other_key]
|
||||
)
|
||||
|
||||
if similarity >= similarity_threshold:
|
||||
merged_counter[fault_key] += other_count
|
||||
merged_keys.add(other_key)
|
||||
merge_mapping[other_key] = fault_key
|
||||
|
||||
return merged_counter, merged_system_map, merge_mapping
|
||||
|
||||
except Exception as e:
|
||||
print(f"Embedding合并失败,使用原始数据: {str(e)}")
|
||||
return fault_counter.copy(), fault_system_map.copy(), {}
|
||||
|
||||
|
||||
async def statistics_fault_frequency(
|
||||
top_n: int = 10,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
system_filter: Optional[str] = None,
|
||||
use_embedding_merge: bool = True,
|
||||
similarity_threshold: float = 0.85
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
统计故障发生次数(从数据库读取),按频率排序
|
||||
统计所有故障的数据,但只返回top N展示在表格中
|
||||
|
||||
Args:
|
||||
top_n: 返回前 N 个高频故障(默认为 10)
|
||||
start_date: 开始日期(格式:YYYY-MM-DD,可选)
|
||||
end_date: 结束日期(格式:YYYY-MM-DD,可选)
|
||||
system_filter: 系统名称过滤(可选)
|
||||
use_embedding_merge: 是否使用embedding合并相似故障(默认True)
|
||||
similarity_threshold: 相似度阈值(默认0.85)
|
||||
|
||||
Returns:
|
||||
包含统计结果的字典
|
||||
"""
|
||||
try:
|
||||
records = await _query_fault_records_from_db(
|
||||
start_date=start_date,
|
||||
end_date=end_date
|
||||
)
|
||||
|
||||
if not records:
|
||||
return {
|
||||
"success": True,
|
||||
"totalCount": 0,
|
||||
"totalTypes": 0,
|
||||
"highFreqFaults": []
|
||||
}
|
||||
|
||||
fault_counter = {}
|
||||
fault_system_map = {}
|
||||
|
||||
for record in records:
|
||||
device_name = record.get("device_name", "")
|
||||
fault = record.get("fault", "")
|
||||
system_name = record.get("system_name", "其他")
|
||||
|
||||
if not device_name or not fault:
|
||||
continue
|
||||
|
||||
if system_filter and not fuzzy_match_system(system_filter, system_name):
|
||||
continue
|
||||
|
||||
fault_key = f"{device_name}{fault}"
|
||||
|
||||
if fault_key not in fault_counter:
|
||||
fault_counter[fault_key] = 0
|
||||
fault_system_map[fault_key] = system_name
|
||||
|
||||
fault_counter[fault_key] += 1
|
||||
|
||||
if use_embedding_merge and len(fault_counter) > 1:
|
||||
fault_counter, fault_system_map, merge_mapping = await merge_similar_faults_with_embedding(
|
||||
fault_counter,
|
||||
fault_system_map,
|
||||
similarity_threshold
|
||||
)
|
||||
|
||||
if merge_mapping:
|
||||
print(f"合并了 {len(merge_mapping)} 个相似故障")
|
||||
|
||||
# 先计算全部数据的统计量
|
||||
total_count = sum(fault_counter.values())
|
||||
total_types = len(fault_counter)
|
||||
|
||||
# 对全部数据排序
|
||||
sorted_faults_all = sorted(fault_counter.items(), key=lambda x: x[1], reverse=True)
|
||||
|
||||
# 只取top N用于展示表格
|
||||
sorted_faults = sorted_faults_all[:top_n]
|
||||
|
||||
high_freq_faults = []
|
||||
for rank, (fault_key, count) in enumerate(sorted_faults, start=1):
|
||||
system_name = fault_system_map.get(fault_key, "其他")
|
||||
|
||||
high_freq_faults.append({
|
||||
"rank": rank,
|
||||
"name": fault_key,
|
||||
"system": system_name,
|
||||
"count": count
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"totalCount": total_count,
|
||||
"totalTypes": total_types,
|
||||
"highFreqFaults": high_freq_faults
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"[故障频率统计错误] {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {
|
||||
"success": False,
|
||||
"highFreqFaults": []
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
async def fault_frequency_statistics_tool(
|
||||
top_n: Optional[int] = 6,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
system_filter: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
故障发生次数统计工具(供工作流调用)
|
||||
统计故障发生次数,按频率排序
|
||||
|
||||
Args:
|
||||
top_n: 返回前 N 个高频故障(默认为 6)
|
||||
start_date: 开始日期(格式:YYYY-MM-DD,可选)
|
||||
end_date: 结束日期(格式:YYYY-MM-DD,可选)
|
||||
system_filter: 系统名称过滤(可选)
|
||||
|
||||
Returns:
|
||||
统计结果,包含 success 和 highFreqFaults 字段
|
||||
"""
|
||||
result = await statistics_fault_frequency(
|
||||
top_n=top_n or 10,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
system_filter=system_filter
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def statistics_spare_parts(
|
||||
ship_number_filter: Optional[str] = None,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
top_n: int = 6
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
统计备件消耗(从数据库读取)
|
||||
统计所有备件的数据,但只返回top N展示在表格中
|
||||
|
||||
Args:
|
||||
ship_number_filter: 舷号过滤(可选)
|
||||
start_date: 开始日期(格式:YYYY-MM-DD,可选)
|
||||
end_date: 结束日期(格式:YYYY-MM-DD,可选)
|
||||
top_n: 表格中展示的前 N 个备件(默认为 5)
|
||||
|
||||
Returns:
|
||||
包含统计结果的字典
|
||||
"""
|
||||
try:
|
||||
records = await _query_fault_records_from_db(
|
||||
ship_number=ship_number_filter,
|
||||
start_date=start_date,
|
||||
end_date=end_date
|
||||
)
|
||||
|
||||
if not records:
|
||||
return {
|
||||
"success": True,
|
||||
"totalCount": 0,
|
||||
"totalTypes": 0,
|
||||
"sparePartData": {
|
||||
"categories": [],
|
||||
"system": [],
|
||||
"counter": []
|
||||
}
|
||||
}
|
||||
|
||||
spare_part_counter = {}
|
||||
spare_part_system_map = {}
|
||||
|
||||
for record in records:
|
||||
spare_parts = record.get("spare_parts", [])
|
||||
system_name = record.get("system_name", "其他")
|
||||
|
||||
if not spare_parts:
|
||||
continue
|
||||
|
||||
for part in spare_parts:
|
||||
if not part or not part.strip():
|
||||
continue
|
||||
|
||||
part_key = part.strip()
|
||||
|
||||
if part_key not in spare_part_counter:
|
||||
spare_part_counter[part_key] = 0
|
||||
spare_part_system_map[part_key] = system_name
|
||||
|
||||
spare_part_counter[part_key] += 1
|
||||
|
||||
# 先计算全部数据的统计量
|
||||
total_count = sum(spare_part_counter.values())
|
||||
total_types = len(spare_part_counter)
|
||||
|
||||
# 对全部数据排序
|
||||
sorted_parts_all = sorted(spare_part_counter.items(), key=lambda x: x[1], reverse=True)
|
||||
|
||||
# 只取top N用于展示表格
|
||||
sorted_parts = sorted_parts_all[:top_n]
|
||||
|
||||
categories = []
|
||||
systems = []
|
||||
counters = []
|
||||
|
||||
for part_key, count in sorted_parts:
|
||||
categories.append(part_key)
|
||||
systems.append(spare_part_system_map.get(part_key, "其他"))
|
||||
counters.append(count)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"totalCount": total_count,
|
||||
"totalTypes": total_types,
|
||||
"sparePartData": {
|
||||
"categories": categories,
|
||||
"system": systems,
|
||||
"counter": counters
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"[备件统计错误] {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {
|
||||
"success": False,
|
||||
"sparePartData": {
|
||||
"categories": [],
|
||||
"system": [],
|
||||
"counter": []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
async def spare_parts_statistics_tool(
|
||||
ship_number: Optional[str] = None,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
top_n: Optional[int] = 5
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
备件消耗统计工具(供工作流调用)
|
||||
统计备件消耗情况
|
||||
|
||||
Args:
|
||||
ship_number: 舷号过滤(可选)
|
||||
start_date: 开始日期(格式:YYYY-MM-DD,可选)
|
||||
end_date: 结束日期(格式:YYYY-MM-DD,可选)
|
||||
top_n: 返回前 N 个备件(默认为 5)
|
||||
|
||||
Returns:
|
||||
统计结果,包含 success 和 sparePartData 字段
|
||||
"""
|
||||
result = await statistics_spare_parts(
|
||||
ship_number_filter=ship_number,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
top_n=top_n or 5
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def extract_device_from_text(text: str) -> Optional[str]:
|
||||
"""
|
||||
从文本中提取设备名称(使用大模型)
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return None
|
||||
|
||||
prompt = f"""从以下文本中提取设备名称:
|
||||
|
||||
文本内容:
|
||||
{text}
|
||||
|
||||
请识别文本中提到的具体设备名称,如:发动机、压缩机、水泵、发电机、空调机组、液压泵等。
|
||||
|
||||
请以JSON格式返回:
|
||||
{{"device": "设备名称"}}
|
||||
|
||||
如果文本中没有提到任何设备,请返回 {{"device": null}}
|
||||
只返回JSON,不要有其他内容。"""
|
||||
|
||||
try:
|
||||
from modelsAPI.model_api import OpenaiAPI
|
||||
result_text = await OpenaiAPI.open_api_chat_without_thinking(
|
||||
query=prompt,
|
||||
json_output=True
|
||||
)
|
||||
|
||||
result_text = result_text.strip()
|
||||
result_text = re.sub(r'^```json\s*', '', result_text, flags=re.IGNORECASE)
|
||||
result_text = re.sub(r'^```\s*', '', result_text)
|
||||
result_text = re.sub(r'```$', '', result_text)
|
||||
|
||||
json_match = re.search(r'\{.*\}', result_text, re.DOTALL)
|
||||
if json_match:
|
||||
result = json.loads(json_match.group(0))
|
||||
device = result.get("device")
|
||||
if device and device != "null":
|
||||
return str(device).strip()
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"大模型提取设备名称失败: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
async def classify_feedback_content(text: str) -> str:
|
||||
"""
|
||||
对反馈内容进行分类(使用大模型)
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return "其他"
|
||||
|
||||
prompt = f"""请对以下反馈内容进行分类。
|
||||
|
||||
反馈内容:
|
||||
{text}
|
||||
|
||||
分类类别(单选,选择最匹配的一个):
|
||||
1. 信息过时 - 反映文档或手册中的信息已经过时,不再适用
|
||||
2. 步骤错误 - 反映操作步骤或流程存在错误
|
||||
3. 步骤缺失 - 反映缺少必要的操作步骤或流程说明
|
||||
4. 备件信息有误 - 反映备件型号、规格、数量等信息有误
|
||||
5. 安全提示不足 - 反映缺少必要的安全警示或注意事项
|
||||
6. 图示不清 - 反映图片、示意图不清晰或难以理解
|
||||
|
||||
请分析反馈内容,判断属于哪个类别,以JSON格式返回:
|
||||
{{"category": "类别名称"}}
|
||||
|
||||
如果反馈内容不属于以上任何类别,请返回 {{"category": "其他"}}
|
||||
只返回JSON,不要有其他内容。"""
|
||||
|
||||
try:
|
||||
from modelsAPI.model_api import OpenaiAPI
|
||||
result_text = await OpenaiAPI.open_api_chat_without_thinking(
|
||||
query=prompt,
|
||||
json_output=True
|
||||
)
|
||||
|
||||
result_text = result_text.strip()
|
||||
result_text = re.sub(r'^```json\s*', '', result_text, flags=re.IGNORECASE)
|
||||
result_text = re.sub(r'^```\s*', '', result_text)
|
||||
result_text = re.sub(r'```$', '', result_text)
|
||||
|
||||
json_match = re.search(r'\{.*\}', result_text, re.DOTALL)
|
||||
if json_match:
|
||||
result = json.loads(json_match.group(0))
|
||||
category = result.get("category")
|
||||
valid_categories = ["信息过时", "步骤错误", "步骤缺失", "备件信息有误", "安全提示不足", "图示不清", "其他"]
|
||||
if category in valid_categories:
|
||||
return category
|
||||
|
||||
return "其他"
|
||||
|
||||
except Exception as e:
|
||||
print(f"大模型分类反馈内容失败: {str(e)}")
|
||||
return "其他"
|
||||
|
||||
|
||||
async def analyze_feedback_and_classify(feedback_text: str) -> Dict[str, Any]:
|
||||
"""
|
||||
分析反馈内容并分类(主接口)
|
||||
"""
|
||||
if not feedback_text or not feedback_text.strip():
|
||||
return {
|
||||
"success": False,
|
||||
"device": None,
|
||||
"system": None,
|
||||
"category": "其他",
|
||||
"error": "反馈内容为空"
|
||||
}
|
||||
|
||||
try:
|
||||
device = await extract_device_from_text(feedback_text)
|
||||
|
||||
system = "其他"
|
||||
if device:
|
||||
system = await get_device_system_from_neo4j(device)
|
||||
|
||||
category = await classify_feedback_content(feedback_text)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"system": system,
|
||||
"category": category
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"system": None,
|
||||
"category": "其他",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
_feedback_token_cache = {"token": None, "expires_at": 0}
|
||||
|
||||
|
||||
async def _get_feedback_api_token() -> Optional[str]:
|
||||
"""
|
||||
获取维修反馈API的认证token(带缓存,避免每次请求都认证)
|
||||
"""
|
||||
import time
|
||||
|
||||
if _feedback_token_cache["token"] and time.time() < _feedback_token_cache["expires_at"]:
|
||||
return _feedback_token_cache["token"]
|
||||
|
||||
base_url = REPAIR_FEEDBACK_CONFIG["url"]
|
||||
email = REPAIR_FEEDBACK_CONFIG.get("email", "")
|
||||
password = REPAIR_FEEDBACK_CONFIG.get("password", "")
|
||||
|
||||
if not email or not password:
|
||||
print("[维修反馈认证错误] 未配置认证凭据")
|
||||
return None
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
signin_resp = await client.post(
|
||||
f"{base_url}/api/v1/auths/signin",
|
||||
json={"email": email, "password": password}
|
||||
)
|
||||
|
||||
if signin_resp.status_code != 200:
|
||||
print(f"[维修反馈认证错误] 登录失败,状态码: {signin_resp.status_code}")
|
||||
return None
|
||||
|
||||
token = signin_resp.json().get("token", "")
|
||||
if not token:
|
||||
print("[维修反馈认证错误] 响应中未找到token")
|
||||
return None
|
||||
|
||||
_feedback_token_cache["token"] = token
|
||||
_feedback_token_cache["expires_at"] = time.time() + 3600
|
||||
|
||||
return token
|
||||
|
||||
except Exception as e:
|
||||
print(f"[维修反馈认证错误] {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
async def statistics_repair_feedbacks(
|
||||
source: Optional[str] = None,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
top_n: int = 10
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
统计维修反馈数据(从外部API获取,带认证)
|
||||
|
||||
Args:
|
||||
source: 来源筛选(好评/差评/反馈),可选
|
||||
start_date: 开始日期(格式:YYYY-MM-DD),可选
|
||||
end_date: 结束日期(格式:YYYY-MM-DD),可选
|
||||
top_n: 返回前 N 条记录(默认为 10)
|
||||
|
||||
Returns:
|
||||
包含统计结果的字典
|
||||
"""
|
||||
try:
|
||||
token = await _get_feedback_api_token()
|
||||
if not token:
|
||||
return {
|
||||
"success": False,
|
||||
"totalCount": 0,
|
||||
"feedbackData": [],
|
||||
"error": "API认证失败,无法获取token"
|
||||
}
|
||||
|
||||
base_url = REPAIR_FEEDBACK_CONFIG["url"]
|
||||
endpoint = f"{base_url}/api/v1/repair-feedbacks/"
|
||||
|
||||
params = {}
|
||||
if source and source.strip():
|
||||
params["source"] = source.strip()
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(
|
||||
endpoint,
|
||||
params=params,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {token}"
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 401 or response.status_code == 403:
|
||||
_feedback_token_cache["token"] = None
|
||||
_feedback_token_cache["expires_at"] = 0
|
||||
|
||||
token = await _get_feedback_api_token()
|
||||
if token:
|
||||
response = await client.get(
|
||||
endpoint,
|
||||
params=params,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {token}"
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
print(f"[维修反馈统计错误] API返回状态码: {response.status_code}")
|
||||
return {
|
||||
"success": False,
|
||||
"totalCount": 0,
|
||||
"feedbackData": [],
|
||||
"error": f"API请求失败,状态码: {response.status_code}"
|
||||
}
|
||||
|
||||
result_json = response.json()
|
||||
|
||||
if isinstance(result_json, list):
|
||||
feedback_list = result_json
|
||||
elif isinstance(result_json, dict):
|
||||
feedback_list = result_json.get("results", result_json.get("data", []))
|
||||
else:
|
||||
feedback_list = []
|
||||
|
||||
if not feedback_list:
|
||||
return {
|
||||
"success": True,
|
||||
"totalCount": 0,
|
||||
"totalTypes": 0,
|
||||
"feedbackData": [],
|
||||
"sourceDistribution": {}
|
||||
}
|
||||
|
||||
start_dt = None
|
||||
end_dt = None
|
||||
if start_date:
|
||||
try:
|
||||
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
pass
|
||||
if end_date:
|
||||
try:
|
||||
end_dt = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) - timedelta(seconds=1)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if start_dt or end_dt:
|
||||
filtered_list = []
|
||||
for item in feedback_list:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
raw_created_at = item.get("created_at", "")
|
||||
if not raw_created_at:
|
||||
continue
|
||||
try:
|
||||
item_dt = datetime.fromtimestamp(int(raw_created_at))
|
||||
if start_dt and item_dt < start_dt:
|
||||
continue
|
||||
if end_dt and item_dt > end_dt:
|
||||
continue
|
||||
filtered_list.append(item)
|
||||
except (ValueError, TypeError, OSError):
|
||||
continue
|
||||
feedback_list = filtered_list
|
||||
|
||||
total_count = len(feedback_list)
|
||||
|
||||
processed_data = []
|
||||
for idx, item in enumerate(feedback_list[:top_n], start=1):
|
||||
if isinstance(item, dict):
|
||||
raw_title = item.get("title", "")
|
||||
raw_content = item.get("content", "")
|
||||
raw_source = item.get("source", source or "")
|
||||
raw_created_at = item.get("created_at", "")
|
||||
raw_user_name = item.get("user_name", "")
|
||||
|
||||
created_at_str = ""
|
||||
if raw_created_at:
|
||||
try:
|
||||
ts = int(raw_created_at)
|
||||
created_at_str = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")
|
||||
except (ValueError, TypeError, OSError):
|
||||
created_at_str = str(raw_created_at)
|
||||
|
||||
processed_item = {
|
||||
"rank": idx,
|
||||
"title": str(raw_title) if raw_title else "",
|
||||
"content": str(raw_content)[:200] if raw_content else "",
|
||||
"source": str(raw_source) if raw_source else "未知",
|
||||
"created_at": created_at_str,
|
||||
"user_name": str(raw_user_name) if raw_user_name else ""
|
||||
}
|
||||
processed_data.append(processed_item)
|
||||
|
||||
source_counter = {}
|
||||
for item in processed_data:
|
||||
s = item.get("source", "未知")
|
||||
source_counter[s] = source_counter.get(s, 0) + 1
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"totalCount": total_count,
|
||||
"totalTypes": len(feedback_list),
|
||||
"sourceDistribution": source_counter,
|
||||
"feedbackData": processed_data
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"[维修反馈统计错误] {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {
|
||||
"success": False,
|
||||
"totalCount": 0,
|
||||
"feedbackData": [],
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
async def repair_feedback_statistics_tool(
|
||||
source: Optional[str] = None,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
top_n: Optional[int] = 10
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
维修反馈统计工具(供工作流调用)
|
||||
统计维修反馈情况,支持按来源和时间筛选
|
||||
|
||||
Args:
|
||||
source: 来源筛选(可选),如:好评、差评、反馈
|
||||
start_date: 开始日期(格式:YYYY-MM-DD),可选
|
||||
end_date: 结束日期(格式:YYYY-MM-DD),可选
|
||||
top_n: 返回前 N 条记录(默认为 10)
|
||||
|
||||
Returns:
|
||||
统计结果,包含 success 和 feedbackData 字段
|
||||
"""
|
||||
result = await statistics_repair_feedbacks(
|
||||
source=source,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
top_n=top_n or 10
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
253
tools/function_tool.py
Normal file
@ -0,0 +1,253 @@
|
||||
"""
|
||||
工具函数定义
|
||||
包含RAG、GraphRAG、VLM、ASR、TTS等工具的function call封装
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
import httpx
|
||||
import asyncio
|
||||
from typing import Optional, Dict, Any, List
|
||||
from langchain_core.tools import tool
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from config import RAG_CONFIG, ASR_CONFIG
|
||||
from modelsAPI.model_api import OpenaiAPI
|
||||
from utils.function_tracker import track_function_calls
|
||||
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
from .graph_tools import (
|
||||
graph_rag_search,
|
||||
fault_graph_rag_search,
|
||||
operation_graph_rag_search,
|
||||
)
|
||||
|
||||
from .rag_tools import (
|
||||
rag_search_tool,
|
||||
file_to_text_tool,
|
||||
)
|
||||
|
||||
from .vlm_tools import (
|
||||
vlm_image_to_text,
|
||||
image_rag_describe,
|
||||
)
|
||||
|
||||
from .fault_statistics import fault_type_statistics_tool, fault_frequency_statistics_tool, spare_parts_statistics_tool
|
||||
|
||||
|
||||
@tool
|
||||
def detect_input_type(raw_input: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
判断输入类型工具(异步版本):严格对齐 MainAgentState 字段。
|
||||
识别:image, audio/asr, file, query (text)
|
||||
"""
|
||||
|
||||
if not isinstance(raw_input, dict):
|
||||
return {
|
||||
"has_image": False, "has_audio": False, "has_file": False, "has_query": False,
|
||||
"input_type": "unknown", "error": "raw_input 必须是 dict"
|
||||
}
|
||||
|
||||
has_image = False
|
||||
img_v = raw_input.get("image")
|
||||
if img_v:
|
||||
has_image = True
|
||||
|
||||
has_audio = False
|
||||
aud_v = raw_input.get("audio")
|
||||
if aud_v:
|
||||
has_audio = True
|
||||
|
||||
has_file = False
|
||||
file_v = raw_input.get("file_text")
|
||||
if file_v:
|
||||
has_file = True
|
||||
|
||||
has_query = False
|
||||
query_v = raw_input.get("query")
|
||||
if isinstance(query_v, str) and query_v.strip():
|
||||
has_query = True
|
||||
|
||||
return {
|
||||
"has_image": has_image,
|
||||
"has_audio": has_audio,
|
||||
"has_file": has_file,
|
||||
"has_query": has_query,
|
||||
}
|
||||
|
||||
|
||||
async def asr_audio_to_text_async(
|
||||
audio_path: str,
|
||||
model: Optional[str] = None,
|
||||
language: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
ASR语音转文本工具(异步实现)
|
||||
将音频文件转换为文本内容
|
||||
|
||||
Args:
|
||||
audio_path: 音频文件路径(如:audio/test.wav)
|
||||
model: ASR模型名称(可选,默认从配置读取,如"base")
|
||||
language: 语言代码(可选,默认从配置读取,如"zh"表示中文)
|
||||
|
||||
Returns:
|
||||
包含success和text字段的字典
|
||||
"""
|
||||
if not os.path.exists(audio_path):
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"音频文件不存在: {audio_path}",
|
||||
"text": ""
|
||||
}
|
||||
|
||||
api_base = ASR_CONFIG["api_base"]
|
||||
asr_model = model or ASR_CONFIG["model"]
|
||||
asr_language = language or ASR_CONFIG["language"]
|
||||
timeout = ASR_CONFIG["timeout"]
|
||||
|
||||
api_endpoint = f"{api_base}/audio/transcriptions"
|
||||
|
||||
try:
|
||||
with open(audio_path, "rb") as f:
|
||||
audio_data = f.read()
|
||||
|
||||
files = {
|
||||
"file": (os.path.basename(audio_path), audio_data, "audio/wav")
|
||||
}
|
||||
data = {
|
||||
"model": asr_model,
|
||||
"language": asr_language,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.post(api_endpoint, files=files, data=data)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
text = result.get("text", "")
|
||||
return {
|
||||
"success": True,
|
||||
"text": text if text else "",
|
||||
"error": None
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"HTTP {response.status_code}: {response.text}",
|
||||
"text": ""
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"text": ""
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
async def asr_audio_to_text(
|
||||
audio_path: str,
|
||||
model: Optional[str] = None,
|
||||
language: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
ASR语音转文本工具(异步版本)
|
||||
将音频文件转换为文本内容
|
||||
|
||||
Args:
|
||||
audio_path: 音频文件路径(如:audio/test.wav)
|
||||
model: ASR模型名称(可选,默认从配置读取,如"base")
|
||||
language: 语言代码(可选,默认从配置读取,如"zh"表示中文)
|
||||
|
||||
Returns:
|
||||
识别出的文本内容
|
||||
"""
|
||||
result = await asr_audio_to_text_async(audio_path, model, language)
|
||||
|
||||
if not result.get("success", False):
|
||||
return f"语音识别失败: {result.get('error', '未知错误')}"
|
||||
|
||||
return result.get("text", "")
|
||||
|
||||
|
||||
@tool
|
||||
async def extract_device_and_fault(query: str) -> Dict[str, Any]:
|
||||
"""
|
||||
从用户查询中提取设备名称和故障现象(使用大模型)
|
||||
|
||||
Args:
|
||||
query: 用户查询文本
|
||||
|
||||
Returns:
|
||||
包含device和fault字段的字典
|
||||
"""
|
||||
if not query or not query.strip():
|
||||
return {"device": "", "fault": ""}
|
||||
|
||||
prompt = f"""从以下用户查询中提取设备名称和故障现象:
|
||||
查询:{query}
|
||||
|
||||
请以JSON格式返回:
|
||||
{{"device": "设备名称", "fault": "故障现象"}}
|
||||
|
||||
如果无法提取,请返回空字符串。"""
|
||||
|
||||
try:
|
||||
result_text = await OpenaiAPI.open_api_chat_without_thinking(
|
||||
query=prompt,
|
||||
json_output=True
|
||||
)
|
||||
|
||||
result_text = result_text.strip()
|
||||
result_text = re.sub(r'^```json\s*', '', result_text, flags=re.IGNORECASE)
|
||||
result_text = re.sub(r'^```\s*', '', result_text)
|
||||
result_text = re.sub(r'```$', '', result_text)
|
||||
|
||||
json_match = re.search(r'\{.*\}', result_text, re.DOTALL)
|
||||
if json_match:
|
||||
result = json.loads(json_match.group(0))
|
||||
return {
|
||||
"device": result.get("device", ""),
|
||||
"fault": result.get("fault", "")
|
||||
}
|
||||
|
||||
return {"device": "", "fault": ""}
|
||||
|
||||
except Exception as e:
|
||||
print(f"提取设备和故障失败: {str(e)}")
|
||||
return {"device": "", "fault": ""}
|
||||
|
||||
|
||||
# 工具列表导出
|
||||
ALL_TOOLS = [
|
||||
detect_input_type,
|
||||
graph_rag_search,
|
||||
fault_graph_rag_search,
|
||||
operation_graph_rag_search,
|
||||
rag_search_tool,
|
||||
vlm_image_to_text,
|
||||
asr_audio_to_text,
|
||||
extract_device_and_fault,
|
||||
image_rag_describe,
|
||||
file_to_text_tool,
|
||||
fault_type_statistics_tool,
|
||||
fault_frequency_statistics_tool,
|
||||
spare_parts_statistics_tool
|
||||
]
|
||||
|
||||
# 按类别分组的工具
|
||||
TOOL_CATEGORIES = {
|
||||
"input_detection": [detect_input_type],
|
||||
"rag": [graph_rag_search, rag_search_tool, fault_graph_rag_search, operation_graph_rag_search],
|
||||
"vlm": [vlm_image_to_text],
|
||||
"asr": [asr_audio_to_text],
|
||||
"extraction": [extract_device_and_fault],
|
||||
"image_rag": [image_rag_describe],
|
||||
"file_process": [file_to_text_tool],
|
||||
"statistics": [fault_type_statistics_tool, fault_frequency_statistics_tool, spare_parts_statistics_tool]
|
||||
}
|
||||
2087
tools/graph_tools.py
Normal file
182
tools/pdf_generator.py
Normal file
@ -0,0 +1,182 @@
|
||||
"""
|
||||
PDF 生成工具模块
|
||||
使用 Pandoc 将 Markdown 文本转换为 PDF 文件
|
||||
"""
|
||||
import os
|
||||
import base64
|
||||
import subprocess
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class PDFGenerator:
|
||||
"""PDF 生成器类(Pandoc 版)"""
|
||||
|
||||
def __init__(self, main_font: str = "Noto Serif CJK SC"):
|
||||
"""
|
||||
初始化 PDF 生成器
|
||||
|
||||
Args:
|
||||
main_font: Pandoc / XeLaTeX 使用的中文字体名
|
||||
"""
|
||||
self.main_font = main_font
|
||||
|
||||
def generate_pdf_from_text(
|
||||
self,
|
||||
text_content: str,
|
||||
output_path: str = None,
|
||||
title: str = "维修报告",
|
||||
return_base64: bool = True
|
||||
) -> dict:
|
||||
"""
|
||||
将 Markdown 文本内容生成 PDF 文件(方法名不变)
|
||||
|
||||
Args:
|
||||
text_content: Markdown 文本内容
|
||||
output_path: PDF 输出路径(可选)
|
||||
title: PDF 标题
|
||||
return_base64: 是否返回 base64 编码内容
|
||||
"""
|
||||
try:
|
||||
# 输出路径
|
||||
if output_path is None:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
output_path = f"temp_report_{timestamp}.pdf"
|
||||
|
||||
# 生成临时 Markdown 文件
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
suffix=".md",
|
||||
delete=False,
|
||||
encoding="utf-8"
|
||||
) as md_file:
|
||||
md_path = md_file.name
|
||||
|
||||
# 写入标题 + 内容(Pandoc 标准)
|
||||
if title:
|
||||
md_file.write(f"# {title}\n\n")
|
||||
md_file.write(text_content.strip())
|
||||
|
||||
# Pandoc 命令
|
||||
cmd = [
|
||||
"pandoc",
|
||||
md_path,
|
||||
"--pdf-engine=xelatex",
|
||||
"-V", f"mainfont={self.main_font}",
|
||||
"-o", output_path
|
||||
]
|
||||
|
||||
# 执行转换
|
||||
subprocess.run(
|
||||
cmd,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
"file_path": output_path
|
||||
}
|
||||
|
||||
# 返回 base64
|
||||
if return_base64:
|
||||
with open(output_path, "rb") as f:
|
||||
result["base64_content"] = base64.b64encode(
|
||||
f.read()
|
||||
).decode("utf-8")
|
||||
|
||||
return result
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": e.stderr.decode("utf-8")
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
finally:
|
||||
# 清理临时 md 文件
|
||||
if "md_path" in locals() and os.path.exists(md_path):
|
||||
os.remove(md_path)
|
||||
|
||||
|
||||
def generate_report_pdf(
|
||||
report_content: str,
|
||||
device_name: str = "未知设备",
|
||||
output_dir: str = None
|
||||
) -> dict:
|
||||
"""
|
||||
生成维修报告 PDF(方法名不变)
|
||||
"""
|
||||
generator = PDFGenerator()
|
||||
|
||||
# 文件名规则不变
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
safe_device_name = device_name.replace(" ", "_").replace("/", "_")
|
||||
file_name = f"{safe_device_name}_维修报告_{timestamp}.pdf"
|
||||
|
||||
if output_dir:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
output_path = os.path.join(output_dir, file_name)
|
||||
else:
|
||||
output_path = file_name
|
||||
|
||||
result = generator.generate_pdf_from_text(
|
||||
text_content=report_content,
|
||||
output_path=output_path,
|
||||
title="设备维修报告",
|
||||
return_base64=True
|
||||
)
|
||||
|
||||
if result.get("success"):
|
||||
result["file_name"] = file_name
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# 测试代码
|
||||
if __name__ == "__main__":
|
||||
test_md = """
|
||||
|
||||
爱因斯坦质能方程:
|
||||
|
||||
$$E = mc^2$$
|
||||
|
||||
该公式揭示了质量与能量的等价性。在核反应中,微小的质量亏损 $\Delta m$ 会释放巨大能量:
|
||||
|
||||
$$\Delta E = \Delta m \cdot c^2$$
|
||||
|
||||
其中 $c = 3 \times 10^8 \, \text{m/s}$ 为真空光速。
|
||||
|
||||
## 实验数据对比
|
||||
|
||||
下表展示了不同材料在相同条件下的能量释放效率:
|
||||
|
||||
| 材料类型 | 质量亏损 (kg) | 释放能量 (J) | 能量密度 (J/kg) |
|
||||
|------------|----------------|---------------------|----------------------|
|
||||
| 铀-235 | $1.0 \times 10^{-3}$ | $9.0 \times 10^{13}$ | $9.0 \times 10^{16}$ |
|
||||
| 氘氚聚变 | $3.1 \times 10^{-3}$ | $2.8 \times 10^{14}$ | $9.0 \times 10^{16}$ |
|
||||
| 化学燃烧 | $1.0 \times 10^{-3}$ | $3.0 \times 10^{4}$ | $3.0 \times 10^{7}$ |
|
||||
|
||||
|
||||
## 结论
|
||||
|
||||
- 核反应的能量密度远高于化学反应(约 $10^9$ 倍)。
|
||||
- 公式 $a^2 + b^2 = c^2$ 虽为几何关系,但在相对论四维时空中有深刻类比。
|
||||
"""
|
||||
|
||||
result = generate_report_pdf(
|
||||
test_md,
|
||||
device_name="测试设备"
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
print(f"✓ PDF 生成成功:{result['file_name']}")
|
||||
print(f"✓ 文件路径:{result['file_path']}")
|
||||
print(f"✓ Base64 长度:{len(result['base64_content'])}")
|
||||
else:
|
||||
print(f"✗ PDF 生成失败:{result['error']}")
|
||||
274
tools/rag_tools.py
Normal file
@ -0,0 +1,274 @@
|
||||
"""
|
||||
RAG搜索工具模块
|
||||
包含知识库搜索、文件处理等功能
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import base64
|
||||
import asyncio
|
||||
import httpx
|
||||
from typing import Optional, Dict, Any
|
||||
from langchain_core.tools import tool
|
||||
|
||||
from config import RAG_CONFIG
|
||||
from utils.function_tracker import track_function_calls
|
||||
|
||||
|
||||
@track_function_calls
|
||||
async def rag_search(
|
||||
query: str,
|
||||
top_k: Optional[int] = 5,
|
||||
file_id: Optional[int] = None,
|
||||
kb_id: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
知识库搜索工具
|
||||
调用知识库搜索API,检索与query相关的文档内容
|
||||
|
||||
Args:
|
||||
query: 搜索关键词(必填)
|
||||
top_k: 返回结果数量(默认5)
|
||||
file_id: 文件ID(可选)
|
||||
kb_id: 知识库ID(可选)
|
||||
|
||||
Returns:
|
||||
搜索结果,包含sourceCitation格式的数据
|
||||
"""
|
||||
|
||||
if not query or not query.strip():
|
||||
return {"success": False, "error": "查询文本不能为空"}
|
||||
|
||||
SEARCH_URL = RAG_CONFIG["search_endpoint"]
|
||||
|
||||
params = {"query": query.strip()}
|
||||
|
||||
if file_id is None:
|
||||
config_file_id = RAG_CONFIG.get("file-id")
|
||||
if config_file_id is not None:
|
||||
try:
|
||||
file_id = int(config_file_id) if isinstance(config_file_id, str) else config_file_id
|
||||
except (ValueError, TypeError):
|
||||
file_id = None
|
||||
|
||||
if kb_id is None:
|
||||
config_kb_id = RAG_CONFIG.get("kb-id")
|
||||
if config_kb_id is not None:
|
||||
kb_id = str(config_kb_id) if config_kb_id is not None else None
|
||||
|
||||
if file_id is not None and file_id != "":
|
||||
params["file_id"] = file_id
|
||||
if kb_id is not None and kb_id != "":
|
||||
params["kb_id"] = kb_id
|
||||
|
||||
print(111111111111111111)
|
||||
print(RAG_CONFIG["x-user-id"])
|
||||
print(RAG_CONFIG["x-user-name"])
|
||||
print(RAG_CONFIG["x-role"])
|
||||
|
||||
headers = {
|
||||
"accept": "application/json",
|
||||
"x-user-id": RAG_CONFIG["x-user-id"],
|
||||
"x-role": RAG_CONFIG["x-role"]
|
||||
}
|
||||
|
||||
print(f"RAG params: {params}")
|
||||
|
||||
max_retries = 1
|
||||
retry_delay = 2
|
||||
|
||||
response = None
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=180.0, verify=False) as client:
|
||||
response = await client.get(SEARCH_URL, params=params, headers=headers)
|
||||
break
|
||||
|
||||
except (httpx.ReadTimeout, httpx.ConnectTimeout, httpx.RequestError) as e:
|
||||
last_exception = e
|
||||
if attempt < max_retries:
|
||||
print(f"RAG请求超时,第 {attempt+1} 次重试,2秒后重新请求...")
|
||||
await asyncio.sleep(retry_delay)
|
||||
else:
|
||||
return {"success": False, "error": f"请求超时: {str(e)}"}
|
||||
|
||||
try:
|
||||
if response.status_code != 200:
|
||||
return {"success": False, "error": f"HTTP {response.status_code}"}
|
||||
|
||||
result = response.json()
|
||||
data = result.get("results", [])
|
||||
|
||||
filtered_data = [item for item in data if item.get("score", 0.0) >= 0.4]
|
||||
|
||||
sorted_data = sorted(
|
||||
filtered_data,
|
||||
key=lambda x: x.get("score", 0.0),
|
||||
reverse=True
|
||||
)[:top_k]
|
||||
|
||||
source_citation = {}
|
||||
|
||||
for idx, item in enumerate(sorted_data, start=1):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
file_info = item.get("file")
|
||||
kb_info = item.get("kb")
|
||||
|
||||
filename = file_info.get("filename", "") if file_info else ""
|
||||
resource = filename or (kb_info.get("name", "未分组文档") if kb_info else "未分组文档")
|
||||
|
||||
positions = item.get("positions", [])
|
||||
page_idx = positions[0].get("page_idx", 0) if positions else 0
|
||||
|
||||
citation_item = {
|
||||
"index": idx,
|
||||
"id": item.get("id", ""),
|
||||
"text": item.get("content", ""),
|
||||
"resource": resource,
|
||||
"filename": filename,
|
||||
"score": item.get("score", 0.0),
|
||||
"page_idx": page_idx,
|
||||
"file_id": item.get("file_id", ""),
|
||||
"kb_id": kb_info.get("id", "") if kb_info else "",
|
||||
"kb_name": kb_info.get("name", "") if kb_info else "",
|
||||
"positions": positions
|
||||
}
|
||||
|
||||
if resource not in source_citation:
|
||||
source_citation[resource] = []
|
||||
source_citation[resource].append(citation_item)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"sourceCitation": source_citation,
|
||||
"total": len(sorted_data),
|
||||
"total_available": len(data)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
@tool
|
||||
async def rag_search_tool(query: str, top_k: Optional[int] = 5) -> str:
|
||||
"""
|
||||
RAG检索工具(供工作流调用)
|
||||
在知识库中检索与query相关的文档内容
|
||||
|
||||
Args:
|
||||
query: 检索查询
|
||||
top_k: 返回结果数量
|
||||
|
||||
Returns:
|
||||
检索结果文本
|
||||
"""
|
||||
result = await rag_search(query, top_k=top_k)
|
||||
|
||||
if not result.get("success", False):
|
||||
return ""
|
||||
|
||||
source_citation = result.get("sourceCitation", {})
|
||||
if not source_citation:
|
||||
return ""
|
||||
|
||||
results_parts = []
|
||||
for resource, items in source_citation.items():
|
||||
for item in items:
|
||||
text = item.get("text", "")
|
||||
if text:
|
||||
results_parts.append(f"【{resource}】{text}")
|
||||
|
||||
results_text = "\n\n".join(results_parts) if results_parts else ""
|
||||
if len(results_text) > 2000:
|
||||
results_text = results_text[:2000] + "..."
|
||||
return results_text
|
||||
|
||||
|
||||
@track_function_calls
|
||||
async def file_to_text(file_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
文件转文本工具
|
||||
支持多种文件格式:PDF、Word、TXT、图片等
|
||||
|
||||
Args:
|
||||
file_path: 文件路径
|
||||
|
||||
Returns:
|
||||
转换结果,包含文本内容
|
||||
"""
|
||||
if not file_path or not file_path.strip():
|
||||
return {"success": False, "error": "文件路径不能为空"}
|
||||
|
||||
file_path = file_path.strip()
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
return {"success": False, "error": f"文件不存在: {file_path}"}
|
||||
|
||||
try:
|
||||
file_ext = os.path.splitext(file_path)[1].lower()
|
||||
|
||||
if file_ext == ".txt":
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
return {"success": True, "text": text}
|
||||
|
||||
elif file_ext == ".pdf":
|
||||
import fitz
|
||||
doc = fitz.open(file_path)
|
||||
text_parts = []
|
||||
for page in doc:
|
||||
text_parts.append(page.get_text())
|
||||
text = "\n".join(text_parts)
|
||||
doc.close()
|
||||
return {"success": True, "text": text}
|
||||
|
||||
elif file_ext in [".docx", ".doc"]:
|
||||
from docx import Document
|
||||
doc = Document(file_path)
|
||||
text_parts = []
|
||||
for para in doc.paragraphs:
|
||||
text_parts.append(para.text)
|
||||
text = "\n".join(text_parts)
|
||||
return {"success": True, "text": text}
|
||||
|
||||
elif file_ext in [".jpg", ".jpeg", ".png", ".bmp", ".gif"]:
|
||||
with open(file_path, "rb") as f:
|
||||
image_data = f.read()
|
||||
base64_image = base64.b64encode(image_data).decode("utf-8")
|
||||
base64_url = f"data:image/{file_ext[1:]};base64,{base64_image}"
|
||||
|
||||
from modelsAPI.model_api import OpenaiAPI
|
||||
text = await OpenaiAPI.vlm_chat(
|
||||
image_path=base64_url,
|
||||
prompt="请描述这张图片的内容"
|
||||
)
|
||||
return {"success": True, "text": text}
|
||||
|
||||
else:
|
||||
return {"success": False, "error": f"不支持的文件格式: {file_ext}"}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"文件处理失败: {str(e)}"}
|
||||
|
||||
|
||||
@tool
|
||||
async def file_to_text_tool(file_path: str) -> str:
|
||||
"""
|
||||
文件转文本工具(供工作流调用)
|
||||
支持多种文件格式:PDF、Word、TXT、图片等
|
||||
|
||||
Args:
|
||||
file_path: 文件路径
|
||||
|
||||
Returns:
|
||||
文件文本内容
|
||||
"""
|
||||
result = await file_to_text(file_path)
|
||||
|
||||
if not result.get("success", False):
|
||||
return f"文件处理失败: {result.get('error', '未知错误')}"
|
||||
|
||||
return result.get("text", "")
|
||||
200
tools/ship_model_db.py
Normal file
@ -0,0 +1,200 @@
|
||||
"""
|
||||
舷号-型号映射数据库模块
|
||||
用于管理舰船型号、舷号、舰名的映射关系
|
||||
数据存储在 PostgreSQL 的 ship_model_mapping 表中
|
||||
每艘舰一条记录
|
||||
"""
|
||||
import json
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
from config import POSTGRES_CONNECTION_STRING
|
||||
|
||||
|
||||
async def init_ship_model_mapping_table():
|
||||
"""
|
||||
初始化 ship_model_mapping 表并插入初始数据
|
||||
每艘舰一条记录,包含型号、舷号、舰名
|
||||
"""
|
||||
try:
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
except ImportError:
|
||||
print("[ship_model_db] psycopg_pool 未安装,无法初始化表")
|
||||
return
|
||||
|
||||
async with AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
kwargs={"autocommit": True}
|
||||
) as pool:
|
||||
async with pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS ship_model_mapping (
|
||||
id SERIAL PRIMARY KEY,
|
||||
model_name TEXT NOT NULL,
|
||||
hull_number TEXT NOT NULL UNIQUE,
|
||||
ship_name TEXT NOT NULL DEFAULT ''
|
||||
)
|
||||
""")
|
||||
await cur.execute("""
|
||||
CREATE INDEX IF NOT EXISTS ship_model_mapping_model_name_idx ON ship_model_mapping(model_name)
|
||||
""")
|
||||
await cur.execute("""
|
||||
CREATE INDEX IF NOT EXISTS ship_model_mapping_hull_number_idx ON ship_model_mapping(hull_number)
|
||||
""")
|
||||
print("[ship_model_db] ship_model_mapping 表初始化完成")
|
||||
|
||||
await cur.execute("SELECT COUNT(*) FROM ship_model_mapping")
|
||||
count = (await cur.fetchone())[0]
|
||||
if count == 0:
|
||||
initial_data = [
|
||||
("055型驱逐舰", "102", "拉萨舰"),
|
||||
("055型驱逐舰", "103", "鞍山舰"),
|
||||
("055型驱逐舰", "104", "无锡舰"),
|
||||
("055型驱逐舰", "105", "大连舰"),
|
||||
("055型驱逐舰", "106", "延安舰"),
|
||||
("055型驱逐舰", "107", "遵义舰"),
|
||||
("055型驱逐舰", "108", "咸阳舰"),
|
||||
("052D型驱逐舰", "172", "昆明舰"),
|
||||
("052D型驱逐舰", "173", "长沙舰"),
|
||||
("052D型驱逐舰", "174", "合肥舰"),
|
||||
("052D型驱逐舰", "175", "银川舰"),
|
||||
("052D型驱逐舰", "163", "南昌舰"),
|
||||
("054A型护卫舰", "529", "舟山舰"),
|
||||
("054A型护卫舰", "530", "徐州舰"),
|
||||
("054A型护卫舰", "547", "临沂舰"),
|
||||
("054A型护卫舰", "568", "衡阳舰"),
|
||||
("054A型护卫舰", "570", "黄山舰"),
|
||||
]
|
||||
for model_name, hull_number, ship_name in initial_data:
|
||||
await cur.execute(
|
||||
"""
|
||||
INSERT INTO ship_model_mapping (model_name, hull_number, ship_name)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (hull_number) DO NOTHING
|
||||
""",
|
||||
(model_name, hull_number, ship_name)
|
||||
)
|
||||
print(f"[ship_model_db] 已插入 {len(initial_data)} 条初始数据")
|
||||
|
||||
|
||||
async def load_ship_model_mapping() -> List[Dict[str, str]]:
|
||||
"""
|
||||
从数据库加载全部舰船映射数据
|
||||
|
||||
Returns:
|
||||
每艘舰一条记录的列表
|
||||
[
|
||||
{"model_name": "055型驱逐舰", "hull_number": "102", "ship_name": "拉萨舰"},
|
||||
...
|
||||
]
|
||||
"""
|
||||
try:
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
except ImportError:
|
||||
print("[ship_model_db] psycopg_pool 未安装,返回空映射")
|
||||
return []
|
||||
|
||||
try:
|
||||
async with AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
kwargs={"autocommit": True}
|
||||
) as pool:
|
||||
async with pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"SELECT model_name, hull_number, ship_name FROM ship_model_mapping ORDER BY id"
|
||||
)
|
||||
rows = await cur.fetchall()
|
||||
result = []
|
||||
for row in rows:
|
||||
model_name, hull_number, ship_name = row
|
||||
result.append({
|
||||
"model_name": model_name,
|
||||
"hull_number": hull_number,
|
||||
"ship_name": ship_name or "",
|
||||
})
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"[ship_model_db] 加载映射数据失败: {str(e)}")
|
||||
return []
|
||||
|
||||
|
||||
async def add_ship(
|
||||
model_name: str,
|
||||
hull_number: str,
|
||||
ship_name: str = ""
|
||||
) -> bool:
|
||||
"""
|
||||
添加单艘舰船
|
||||
|
||||
Args:
|
||||
model_name: 型号名称
|
||||
hull_number: 舷号
|
||||
ship_name: 舰名
|
||||
|
||||
Returns:
|
||||
是否添加成功
|
||||
"""
|
||||
try:
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
except ImportError:
|
||||
print("[ship_model_db] psycopg_pool 未安装,无法添加")
|
||||
return False
|
||||
|
||||
try:
|
||||
async with AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
kwargs={"autocommit": True}
|
||||
) as pool:
|
||||
async with pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""
|
||||
INSERT INTO ship_model_mapping (model_name, hull_number, ship_name)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (hull_number) DO UPDATE SET
|
||||
model_name = EXCLUDED.model_name,
|
||||
ship_name = EXCLUDED.ship_name
|
||||
""",
|
||||
(model_name, hull_number, ship_name)
|
||||
)
|
||||
print(f"[ship_model_db] 舰船 {hull_number}({ship_name}) 已保存")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[ship_model_db] 添加舰船失败: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
async def delete_ship(hull_number: str) -> bool:
|
||||
"""
|
||||
删除单艘舰船
|
||||
|
||||
Args:
|
||||
hull_number: 舷号
|
||||
|
||||
Returns:
|
||||
是否删除成功
|
||||
"""
|
||||
try:
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
except ImportError:
|
||||
print("[ship_model_db] psycopg_pool 未安装,无法删除")
|
||||
return False
|
||||
|
||||
try:
|
||||
async with AsyncConnectionPool(
|
||||
POSTGRES_CONNECTION_STRING,
|
||||
kwargs={"autocommit": True}
|
||||
) as pool:
|
||||
async with pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"DELETE FROM ship_model_mapping WHERE hull_number = %s",
|
||||
(hull_number,)
|
||||
)
|
||||
print(f"[ship_model_db] 舰船 {hull_number} 已删除")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[ship_model_db] 删除舰船失败: {str(e)}")
|
||||
return False
|
||||
|
||||
777
tools/text2sql_tool.py
Normal file
@ -0,0 +1,777 @@
|
||||
"""
|
||||
受控 ReAct Text2SQL 工具
|
||||
核心思想:Agent负责推理,Tool负责SQL,Reviewer负责纠错
|
||||
|
||||
内部流程:
|
||||
1. Schema Injection - 注入数据库表结构
|
||||
2. SQL Generation - LLM生成SQL(使用 SQLAlchemy 连接池 + extra_info 辅助)
|
||||
3. SQL Review - LLM审查SQL正确性
|
||||
4. SQL Guard - 代码级安全校验(硬限制)
|
||||
5. SQL Execute - 执行SQL并返回结果(使用 SQLAlchemy 连接池单例)
|
||||
6. Merge - 相似结果合并(Embedding)
|
||||
"""
|
||||
import re
|
||||
import json
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
|
||||
from sqlalchemy import text
|
||||
from langchain_community.utilities import SQLDatabase
|
||||
|
||||
from config import POSTGRES_CONNECTION_STRING,POSTGRES_SQLALCHEMY_URL
|
||||
from modelsAPI.model_api import OpenaiAPI
|
||||
from prompts import TEXT2SQL_PROMPTS
|
||||
from tools.texttosql_utils import get_extra_info
|
||||
|
||||
# ============================================================
|
||||
# 数据库连接池单例(避免重复初始化)
|
||||
# ============================================================
|
||||
|
||||
_db_instance: Optional[SQLDatabase] = None
|
||||
_db_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def _get_db() -> SQLDatabase:
|
||||
"""懒加载并缓存数据库连接,避免重复初始化"""
|
||||
global _db_instance
|
||||
if _db_instance is None:
|
||||
async with _db_lock:
|
||||
if _db_instance is None:
|
||||
_db_instance = await asyncio.to_thread(
|
||||
SQLDatabase.from_uri, POSTGRES_SQLALCHEMY_URL
|
||||
)
|
||||
return _db_instance
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Step 1: Schema Injection
|
||||
# ============================================================
|
||||
|
||||
FAULT_RECORDS_SCHEMA = {
|
||||
"table_name": "fault_records",
|
||||
"description": "故障维修记录表,存储所有故障诊断和维修的记录",
|
||||
"columns": {
|
||||
"id": {
|
||||
"type": "SERIAL",
|
||||
"description": "自增主键",
|
||||
"usable_in_query": False
|
||||
},
|
||||
"ship_number": {
|
||||
"type": "TEXT",
|
||||
"description": "舰船舷号,如'101'、'163'、'554'等,2-4位数字",
|
||||
"usable_in_query": True,
|
||||
"examples": ["101", "163", "554", "602"]
|
||||
},
|
||||
"device_name": {
|
||||
"type": "TEXT",
|
||||
"description": "设备名称,如'主机'、'发电机'、'分油机'、'空压机'等",
|
||||
"usable_in_query": True,
|
||||
"examples": ["主机", "发电机", "分油机", "空压机", "舵机", "锅炉"]
|
||||
},
|
||||
"fault": {
|
||||
"type": "TEXT",
|
||||
"description": "故障现象描述,如'滑油压力低'、'异常振动'、'启动失败'等",
|
||||
"usable_in_query": True,
|
||||
"examples": ["滑油压力低", "异常振动", "启动失败", "排烟温度高"]
|
||||
},
|
||||
"spare_parts": {
|
||||
"type": "JSONB",
|
||||
"description": "消耗的备件列表,JSON数组格式,如'[\"密封圈\", \"滤芯\"]'",
|
||||
"usable_in_query": True,
|
||||
"json_query_hint": "使用 jsonb_array_elements_text(spare_parts) 展开备件进行统计"
|
||||
},
|
||||
"system_name": {
|
||||
"type": "TEXT",
|
||||
"description": "所属系统名称,如'动力系统'、'电气系统'、'导航系统'等",
|
||||
"usable_in_query": True,
|
||||
"examples": ["动力系统", "电气系统", "导航系统", "通信系统", "其他"]
|
||||
},
|
||||
"created_at": {
|
||||
"type": "TIMESTAMP",
|
||||
"description": "记录创建时间,默认为当前时间",
|
||||
"usable_in_query": True,
|
||||
"examples": ["2025-01-15 10:30:00"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 字段白名单(SQL Guard 使用)
|
||||
ALLOWED_COLUMNS = set(FAULT_RECORDS_SCHEMA["columns"].keys())
|
||||
|
||||
|
||||
def get_schema_prompt_section() -> str:
|
||||
"""生成 Schema 注入的提示词部分"""
|
||||
lines = [f"数据库表:{FAULT_RECORDS_SCHEMA['table_name']}"]
|
||||
lines.append(f"说明:{FAULT_RECORDS_SCHEMA['description']}")
|
||||
lines.append("")
|
||||
lines.append("字段列表:")
|
||||
|
||||
for col_name, col_info in FAULT_RECORDS_SCHEMA["columns"].items():
|
||||
if not col_info.get("usable_in_query", True):
|
||||
continue
|
||||
line = f" - {col_name} ({col_info['type']}): {col_info['description']}"
|
||||
if "examples" in col_info:
|
||||
line += f"(示例:{', '.join(col_info['examples'][:3])})"
|
||||
if "json_query_hint" in col_info:
|
||||
line += f"\n 查询提示:{col_info['json_query_hint']}"
|
||||
lines.append(line)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Step 2: SQL Generation
|
||||
# ============================================================
|
||||
|
||||
async def generate_sql(question: str, current_date: str) -> Dict[str, Any]:
|
||||
"""
|
||||
使用 LLM 根据用户问题生成 SQL。
|
||||
并发预热数据库连接与获取 extra_info,提升整体响应速度。
|
||||
|
||||
Returns:
|
||||
{"sql": "SELECT ...", "success": True/False, "error": "..."}
|
||||
"""
|
||||
|
||||
|
||||
schema_section = get_schema_prompt_section()
|
||||
|
||||
# 并发:获取辅助信息 + 预热DB连接
|
||||
extra_info, _ = await asyncio.gather(
|
||||
get_extra_info(question),
|
||||
_get_db(),
|
||||
)
|
||||
|
||||
prompt = TEXT2SQL_PROMPTS["sql_generation"].format(
|
||||
schema=schema_section,
|
||||
question=question,
|
||||
current_date=current_date,
|
||||
extra_info=extra_info or ""
|
||||
)
|
||||
print("输出最终sql的提示词内容")
|
||||
print(prompt)
|
||||
try:
|
||||
result = await OpenaiAPI.open_api_chat_without_thinking(
|
||||
prompt,
|
||||
model=None,
|
||||
json_output=True
|
||||
)
|
||||
|
||||
# 提取 JSON
|
||||
json_match = re.search(r'\{.*\}', result, re.DOTALL)
|
||||
if json_match:
|
||||
parsed = json.loads(json_match.group(0))
|
||||
sql = parsed.get("sql", "").strip()
|
||||
if sql:
|
||||
return {"sql": sql, "success": True, "error": ""}
|
||||
|
||||
# 兜底:直接提取 SELECT 语句
|
||||
sql_match = re.search(r'(SELECT\s+.+?;?)$', result.strip(), re.IGNORECASE | re.DOTALL)
|
||||
if sql_match:
|
||||
sql = sql_match.group(1).strip().rstrip(';')
|
||||
return {"sql": sql, "success": True, "error": ""}
|
||||
|
||||
return {"sql": "", "success": False, "error": "LLM未返回有效SQL"}
|
||||
|
||||
except Exception as e:
|
||||
return {"sql": "", "success": False, "error": f"SQL生成失败: {str(e)}"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Step 3: SQL Review (LLM 审查)
|
||||
# ============================================================
|
||||
|
||||
async def review_sql(question: str, sql: str) -> Dict[str, Any]:
|
||||
"""
|
||||
使用 LLM 审查 SQL 是否真正回答了用户问题
|
||||
|
||||
Returns:
|
||||
{
|
||||
"approved": True/False,
|
||||
"issues": ["问题1", "问题2"],
|
||||
"suggested_sql": "修正后的SQL(如果有问题)",
|
||||
"review_comment": "审查意见"
|
||||
}
|
||||
"""
|
||||
from modelsAPI.model_api import OpenaiAPI
|
||||
from prompts import TEXT2SQL_PROMPTS
|
||||
|
||||
schema_section = get_schema_prompt_section()
|
||||
prompt = TEXT2SQL_PROMPTS["sql_review"].format(
|
||||
schema=schema_section,
|
||||
question=question,
|
||||
sql=sql
|
||||
)
|
||||
|
||||
try:
|
||||
result = await OpenaiAPI.open_api_chat_without_thinking(
|
||||
prompt,
|
||||
model=None,
|
||||
json_output=True
|
||||
)
|
||||
|
||||
json_match = re.search(r'\{.*\}', result, re.DOTALL)
|
||||
if json_match:
|
||||
parsed = json.loads(json_match.group(0))
|
||||
approved = parsed.get("approved", False)
|
||||
issues = parsed.get("issues", [])
|
||||
suggested_sql = parsed.get("suggested_sql", "").strip()
|
||||
review_comment = parsed.get("review_comment", "")
|
||||
|
||||
if suggested_sql:
|
||||
suggested_sql = suggested_sql.rstrip(';')
|
||||
|
||||
return {
|
||||
"approved": approved,
|
||||
"issues": issues,
|
||||
"suggested_sql": suggested_sql,
|
||||
"review_comment": review_comment
|
||||
}
|
||||
|
||||
return {
|
||||
"approved": True,
|
||||
"issues": [],
|
||||
"suggested_sql": "",
|
||||
"review_comment": "审查结果解析失败,默认通过"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"approved": True,
|
||||
"issues": [],
|
||||
"suggested_sql": "",
|
||||
"review_comment": f"审查过程异常,默认通过: {str(e)}"
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Step 4: SQL Guard (代码级安全校验)
|
||||
# ============================================================
|
||||
|
||||
class SQLGuardError(Exception):
|
||||
"""SQL Guard 校验失败异常"""
|
||||
pass
|
||||
|
||||
|
||||
def guard_sql(sql: str) -> Tuple[bool, str, str]:
|
||||
"""
|
||||
SQL 安全守卫 - 代码硬限制
|
||||
|
||||
Returns:
|
||||
(passed, cleaned_sql, error_message)
|
||||
"""
|
||||
if not sql or not sql.strip():
|
||||
return False, "", "SQL为空"
|
||||
|
||||
sql_upper = sql.strip().upper()
|
||||
|
||||
# 1. 只允许 SELECT
|
||||
if not sql_upper.startswith("SELECT"):
|
||||
return False, "", "只允许SELECT查询,禁止INSERT/UPDATE/DELETE/CREATE等操作"
|
||||
|
||||
# 2. 禁止 UNION
|
||||
if "UNION" in sql_upper:
|
||||
return False, "", "禁止使用UNION操作"
|
||||
|
||||
# 3. 禁止子查询嵌套过深(最多2层)
|
||||
subquery_count = sql_upper.count("SELECT") - 1
|
||||
if subquery_count > 2:
|
||||
return False, "", f"子查询嵌套过深({subquery_count}层),最多允许2层"
|
||||
|
||||
# 4. 禁止危险函数
|
||||
dangerous_functions = ["PG_SLEEP", "SLEEP", "BENCHMARK", "WAITFOR", "DELAY"]
|
||||
for func in dangerous_functions:
|
||||
if func in sql_upper:
|
||||
return False, "", f"禁止使用危险函数: {func}"
|
||||
|
||||
# 5. 禁止系统表访问
|
||||
system_tables = ["PG_", "INFORMATION_SCHEMA", "PG_CATALOG", "PG_CLASS"]
|
||||
for sys_table in system_tables:
|
||||
if sys_table in sql_upper:
|
||||
return False, "", f"禁止访问系统表: {sys_table}"
|
||||
|
||||
# 6. 禁止注释注入
|
||||
if "--" in sql or "/*" in sql or "*/" in sql:
|
||||
return False, "", "SQL中不允许包含注释"
|
||||
|
||||
# 7. 禁止分号(防止多语句注入)
|
||||
if ";" in sql.rstrip(';'):
|
||||
return False, "", "禁止多语句执行"
|
||||
|
||||
# 8. 检查字段白名单
|
||||
used_columns = set()
|
||||
dot_matches = re.findall(r'fault_records\.(\w+)', sql, re.IGNORECASE)
|
||||
used_columns.update(m.lower() for m in dot_matches)
|
||||
|
||||
select_match = re.search(r'SELECT\s+(.*?)\s+FROM', sql, re.IGNORECASE | re.DOTALL)
|
||||
if select_match:
|
||||
select_part = select_match.group(1)
|
||||
if '*' not in select_part:
|
||||
for part in select_part.split(','):
|
||||
part = part.strip()
|
||||
col_match = re.search(r'(\w+)\s*(?:AS|as)?\s*$', part)
|
||||
if col_match:
|
||||
col_name = col_match.group(1).lower()
|
||||
if col_name not in ('count', 'sum', 'avg', 'max', 'min', 'as',
|
||||
'desc', 'asc', 'null', 'distinct', 'all',
|
||||
'jsonb_array_elements_text', 'jsonb_array_elements',
|
||||
'coalesce', 'cast', 'date', 'timestamp', 'text'):
|
||||
used_columns.add(col_name)
|
||||
|
||||
for col in used_columns:
|
||||
if col not in ALLOWED_COLUMNS and col != 'id':
|
||||
return False, "", f"使用了不允许的字段: {col}"
|
||||
|
||||
# 9. 自动添加 LIMIT(如果没有)
|
||||
cleaned_sql = sql.strip().rstrip(';')
|
||||
if "LIMIT" not in sql_upper:
|
||||
cleaned_sql = f"{cleaned_sql} LIMIT 100"
|
||||
|
||||
# 10. 强制只查 fault_records 表
|
||||
from_match = re.search(r'FROM\s+(\w+)', sql, re.IGNORECASE)
|
||||
if from_match:
|
||||
table_name = from_match.group(1).lower()
|
||||
if table_name != "fault_records":
|
||||
return False, "", f"只允许查询 fault_records 表,不允许查询: {table_name}"
|
||||
|
||||
return True, cleaned_sql, ""
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Step 5: SQL Execute
|
||||
# ============================================================
|
||||
|
||||
async def execute_sql(sql: str) -> Dict[str, Any]:
|
||||
"""
|
||||
执行 SQL 并返回结果。
|
||||
使用模块级 SQLAlchemy 连接池单例,避免每次重复建立连接。
|
||||
|
||||
Returns:
|
||||
{"rows": [...], "columns": [...], "row_count": int, "success": True/False, "error": ""}
|
||||
"""
|
||||
try:
|
||||
db = await _get_db()
|
||||
|
||||
def _run() -> Dict[str, Any]:
|
||||
try:
|
||||
with db._engine.connect() as conn:
|
||||
result = conn.execute(text(sql))
|
||||
columns = list(result.keys())
|
||||
result_rows = []
|
||||
for row in result.fetchall():
|
||||
row_dict = {}
|
||||
for i, col in enumerate(columns):
|
||||
val = row[i]
|
||||
if isinstance(val, datetime):
|
||||
val = val.isoformat()
|
||||
elif isinstance(val, (list, dict)):
|
||||
val = json.dumps(val, ensure_ascii=False)
|
||||
elif not isinstance(val, (str, int, float, bool, type(None))):
|
||||
val = str(val)
|
||||
row_dict[col] = val
|
||||
result_rows.append(row_dict)
|
||||
conn.commit()
|
||||
return {
|
||||
"rows": result_rows,
|
||||
"columns": columns,
|
||||
"row_count": len(result_rows),
|
||||
"success": True,
|
||||
"error": ""
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"rows": [],
|
||||
"columns": [],
|
||||
"row_count": 0,
|
||||
"success": False,
|
||||
"error": f"SQL执行失败: {str(e)}"
|
||||
}
|
||||
|
||||
return await asyncio.to_thread(_run)
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"rows": [],
|
||||
"columns": [],
|
||||
"row_count": 0,
|
||||
"success": False,
|
||||
"error": f"获取数据库连接失败: {str(e)}"
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Text2SQL 主入口 - 内部闭环
|
||||
# ============================================================
|
||||
|
||||
MAX_RETRY = 2 # 最大重试次数(生成→审查→修正)
|
||||
|
||||
|
||||
async def text2sql_tool(question: str, current_date: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
受控 Text2SQL 工具 - 内部形成闭环
|
||||
|
||||
流程:question → SQL生成 → SQL审查 → SQL Guard → SQL执行 → 返回结果
|
||||
|
||||
Args:
|
||||
question: 用户问题
|
||||
current_date: 当前日期(格式:YYYY-MM-DD),默认为今天
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": True/False,
|
||||
"sql": "最终执行的SQL",
|
||||
"rows": [...],
|
||||
"columns": [...],
|
||||
"row_count": int,
|
||||
"error": "",
|
||||
"review_log": [...] # 审查日志
|
||||
}
|
||||
"""
|
||||
if not current_date:
|
||||
current_date = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
review_log = []
|
||||
current_sql = ""
|
||||
|
||||
for attempt in range(MAX_RETRY + 1):
|
||||
# Step 2: SQL Generation
|
||||
if attempt == 0:
|
||||
gen_result = await generate_sql(question, current_date)
|
||||
else:
|
||||
gen_result = await generate_sql_with_feedback(
|
||||
question, current_date, current_sql, review_log
|
||||
)
|
||||
|
||||
if not gen_result["success"]:
|
||||
return {
|
||||
"success": False,
|
||||
"sql": "",
|
||||
"rows": [],
|
||||
"columns": [],
|
||||
"row_count": 0,
|
||||
"error": gen_result["error"],
|
||||
"review_log": review_log
|
||||
}
|
||||
|
||||
current_sql = gen_result["sql"]
|
||||
|
||||
# Step 3: SQL Review
|
||||
review_result = await review_sql(question, current_sql)
|
||||
review_log.append({
|
||||
"attempt": attempt + 1,
|
||||
"sql": current_sql,
|
||||
"approved": review_result["approved"],
|
||||
"issues": review_result["issues"],
|
||||
"comment": review_result["review_comment"]
|
||||
})
|
||||
|
||||
if not review_result["approved"]:
|
||||
if review_result["suggested_sql"]:
|
||||
current_sql = review_result["suggested_sql"]
|
||||
review_log.append({
|
||||
"attempt": attempt + 1,
|
||||
"sql": current_sql,
|
||||
"note": "使用审查建议的修正SQL"
|
||||
})
|
||||
elif attempt < MAX_RETRY:
|
||||
continue
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"sql": current_sql,
|
||||
"rows": [],
|
||||
"columns": [],
|
||||
"row_count": 0,
|
||||
"error": f"SQL审查不通过: {'; '.join(review_result['issues'])}",
|
||||
"review_log": review_log
|
||||
}
|
||||
|
||||
# Step 4: SQL Guard
|
||||
passed, cleaned_sql, guard_error = guard_sql(current_sql)
|
||||
if not passed:
|
||||
review_log.append({
|
||||
"attempt": attempt + 1,
|
||||
"sql": current_sql,
|
||||
"guard_passed": False,
|
||||
"guard_error": guard_error
|
||||
})
|
||||
if attempt < MAX_RETRY:
|
||||
review_log.append({"note": f"Guard拒绝: {guard_error},将重新生成"})
|
||||
continue
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"sql": current_sql,
|
||||
"rows": [],
|
||||
"columns": [],
|
||||
"row_count": 0,
|
||||
"error": f"SQL安全校验失败: {guard_error}",
|
||||
"review_log": review_log
|
||||
}
|
||||
|
||||
current_sql = cleaned_sql
|
||||
review_log.append({
|
||||
"attempt": attempt + 1,
|
||||
"sql": current_sql,
|
||||
"guard_passed": True,
|
||||
"note": "Guard通过"
|
||||
})
|
||||
|
||||
# Step 5: SQL Execute
|
||||
exec_result = await execute_sql(current_sql)
|
||||
|
||||
if not exec_result["success"]:
|
||||
if attempt < MAX_RETRY:
|
||||
review_log.append({
|
||||
"attempt": attempt + 1,
|
||||
"sql": current_sql,
|
||||
"exec_error": exec_result["error"],
|
||||
"note": "执行失败,将重新生成"
|
||||
})
|
||||
continue
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"sql": current_sql,
|
||||
"rows": [],
|
||||
"columns": [],
|
||||
"row_count": 0,
|
||||
"error": exec_result["error"],
|
||||
"review_log": review_log
|
||||
}
|
||||
|
||||
# Step 6: 相似结果合并
|
||||
merged_rows = await merge_similar_rows(
|
||||
exec_result["rows"], exec_result["columns"], question
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"sql": current_sql,
|
||||
"rows": merged_rows,
|
||||
"columns": exec_result["columns"],
|
||||
"row_count": len(merged_rows),
|
||||
"raw_row_count": exec_result["row_count"],
|
||||
"error": "",
|
||||
"review_log": review_log
|
||||
}
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"sql": current_sql,
|
||||
"rows": [],
|
||||
"columns": [],
|
||||
"row_count": 0,
|
||||
"error": "超过最大重试次数",
|
||||
"review_log": review_log
|
||||
}
|
||||
|
||||
|
||||
async def generate_sql_with_feedback(
|
||||
question: str,
|
||||
current_date: str,
|
||||
previous_sql: str,
|
||||
review_log: List[Dict[str, Any]]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
基于审查反馈重新生成 SQL
|
||||
"""
|
||||
from modelsAPI.model_api import OpenaiAPI
|
||||
from prompts import TEXT2SQL_PROMPTS
|
||||
|
||||
schema_section = get_schema_prompt_section()
|
||||
|
||||
feedback_parts = []
|
||||
for log_entry in review_log:
|
||||
if "issues" in log_entry and log_entry["issues"]:
|
||||
feedback_parts.append(f"问题: {'; '.join(log_entry['issues'])}")
|
||||
if "guard_error" in log_entry:
|
||||
feedback_parts.append(f"安全校验失败: {log_entry['guard_error']}")
|
||||
if "exec_error" in log_entry:
|
||||
feedback_parts.append(f"执行失败: {log_entry['exec_error']}")
|
||||
|
||||
feedback = "\n".join(feedback_parts) if feedback_parts else "无具体反馈"
|
||||
|
||||
prompt = TEXT2SQL_PROMPTS["sql_regeneration"].format(
|
||||
schema=schema_section,
|
||||
question=question,
|
||||
current_date=current_date,
|
||||
previous_sql=previous_sql,
|
||||
feedback=feedback
|
||||
)
|
||||
|
||||
try:
|
||||
result = await OpenaiAPI.open_api_chat_without_thinking(
|
||||
prompt,
|
||||
model=None,
|
||||
json_output=True
|
||||
)
|
||||
|
||||
json_match = re.search(r'\{.*\}', result, re.DOTALL)
|
||||
if json_match:
|
||||
parsed = json.loads(json_match.group(0))
|
||||
sql = parsed.get("sql", "").strip()
|
||||
if sql:
|
||||
return {"sql": sql, "success": True, "error": ""}
|
||||
|
||||
sql_match = re.search(r'(SELECT\s+.+?;?)$', result.strip(), re.IGNORECASE | re.DOTALL)
|
||||
if sql_match:
|
||||
sql = sql_match.group(1).strip().rstrip(';')
|
||||
return {"sql": sql, "success": True, "error": ""}
|
||||
|
||||
return {"sql": "", "success": False, "error": "重新生成SQL失败"}
|
||||
|
||||
except Exception as e:
|
||||
return {"sql": "", "success": False, "error": f"重新生成SQL异常: {str(e)}"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Step 6: 相似结果合并(Embedding)
|
||||
# ============================================================
|
||||
|
||||
MERGE_KEYWORDS = ["故障", "频次", "排名", "高频", "最多", "top", "发生次数"]
|
||||
|
||||
|
||||
def _should_merge(columns: List[str], question: str) -> bool:
|
||||
"""判断是否需要合并相似行"""
|
||||
question_lower = question.lower()
|
||||
if any(kw in question_lower for kw in MERGE_KEYWORDS):
|
||||
col_names = [c.lower() for c in columns]
|
||||
has_name = any(n in col_names for n in ["name", "故障", "fault", "device_name"])
|
||||
has_count = any(n in col_names for n in ["count", "次数", "频次"])
|
||||
return has_name and has_count
|
||||
return False
|
||||
|
||||
|
||||
def _find_name_column(columns: List[str]) -> Optional[str]:
|
||||
"""找到名称列"""
|
||||
priority = ["name", "故障名称", "故障", "fault", "device_name", "备件", "spare"]
|
||||
col_lower_map = {c.lower(): c for c in columns}
|
||||
for p in priority:
|
||||
if p in col_lower_map:
|
||||
return col_lower_map[p]
|
||||
for c in columns:
|
||||
if c.lower() not in ("count", "次数", "频次", "rank", "排名", "system_name", "系统"):
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def _find_count_column(columns: List[str]) -> Optional[str]:
|
||||
"""找到计数列"""
|
||||
priority = ["count", "次数", "频次", "cnt"]
|
||||
col_lower_map = {c.lower(): c for c in columns}
|
||||
for p in priority:
|
||||
if p in col_lower_map:
|
||||
return col_lower_map[p]
|
||||
return None
|
||||
|
||||
|
||||
def _find_system_column(columns: List[str]) -> Optional[str]:
|
||||
"""找到系统分类列"""
|
||||
priority = ["system_name", "系统", "系统分类", "system"]
|
||||
col_lower_map = {c.lower(): c for c in columns}
|
||||
for p in priority:
|
||||
if p in col_lower_map:
|
||||
return col_lower_map[p]
|
||||
return None
|
||||
|
||||
|
||||
async def merge_similar_rows(
|
||||
rows: List[Dict[str, Any]],
|
||||
columns: List[str],
|
||||
question: str,
|
||||
similarity_threshold: float = 0.85
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
使用 embedding 合并相似的故障/备件行
|
||||
|
||||
例如:
|
||||
- "连杆大端轴承发生损坏异常" (7次, 其他) + "连杆大端轴承发生损坏异常" (6次, 汽车配件)
|
||||
→ "连杆大端轴承发生损坏异常" (13次, 其他)
|
||||
- "发动机中空冷器管束芯体结垢或堵塞..." + "空冷器管束芯体结垢或堵塞..."
|
||||
→ 合并为一条
|
||||
"""
|
||||
if not rows or len(rows) <= 1:
|
||||
return rows
|
||||
|
||||
if not _should_merge(columns, question):
|
||||
return rows
|
||||
|
||||
name_col = _find_name_column(columns)
|
||||
count_col = _find_count_column(columns)
|
||||
system_col = _find_system_column(columns)
|
||||
|
||||
if not name_col or not count_col:
|
||||
return rows
|
||||
|
||||
try:
|
||||
from modelsAPI.model_api import OpenaiAPI
|
||||
|
||||
names = [row[name_col] for row in rows if row.get(name_col)]
|
||||
if len(names) <= 1:
|
||||
return rows
|
||||
|
||||
embeddings = await OpenaiAPI.get_embeddings_batch_async(names)
|
||||
name_emb_map = {name: emb for name, emb in zip(names, embeddings)}
|
||||
|
||||
sorted_rows = sorted(rows, key=lambda r: int(r.get(count_col, 0)), reverse=True)
|
||||
|
||||
merged_rows = []
|
||||
merged_indices = set()
|
||||
|
||||
for i, row in enumerate(sorted_rows):
|
||||
if i in merged_indices:
|
||||
continue
|
||||
|
||||
name_i = row.get(name_col, "")
|
||||
emb_i = name_emb_map.get(name_i)
|
||||
|
||||
if not emb_i:
|
||||
merged_rows.append(row)
|
||||
continue
|
||||
|
||||
merged_row = dict(row)
|
||||
total_count = int(row.get(count_col, 0))
|
||||
systems = set()
|
||||
if system_col and row.get(system_col):
|
||||
systems.add(row[system_col])
|
||||
|
||||
for j in range(i + 1, len(sorted_rows)):
|
||||
if j in merged_indices:
|
||||
continue
|
||||
|
||||
name_j = sorted_rows[j].get(name_col, "")
|
||||
emb_j = name_emb_map.get(name_j)
|
||||
|
||||
if not emb_j:
|
||||
continue
|
||||
|
||||
similarity = OpenaiAPI.cosine_similarity(emb_i, emb_j)
|
||||
|
||||
if similarity >= similarity_threshold:
|
||||
total_count += int(sorted_rows[j].get(count_col, 0))
|
||||
if system_col and sorted_rows[j].get(system_col):
|
||||
systems.add(sorted_rows[j][system_col])
|
||||
merged_indices.add(j)
|
||||
|
||||
merged_row[count_col] = total_count
|
||||
if system_col and systems:
|
||||
merged_row[system_col] = "、".join(sorted(systems))
|
||||
|
||||
merged_rows.append(merged_row)
|
||||
merged_indices.add(i)
|
||||
|
||||
merged_rows.sort(key=lambda r: int(r.get(count_col, 0)), reverse=True)
|
||||
|
||||
if "rank" in columns or "排名" in columns:
|
||||
rank_col = "rank" if "rank" in columns else "排名"
|
||||
for idx, row in enumerate(merged_rows):
|
||||
row[rank_col] = idx + 1
|
||||
|
||||
return merged_rows
|
||||
|
||||
except Exception as e:
|
||||
print(f"Embedding合并失败,使用原始数据: {str(e)}")
|
||||
return rows
|
||||
|
||||
|
||||
184
tools/texttosql_utils.py
Normal file
@ -0,0 +1,184 @@
|
||||
import os
|
||||
import ast
|
||||
import re
|
||||
import asyncio
|
||||
import httpx
|
||||
from typing import List, Dict
|
||||
|
||||
from neo4j import GraphDatabase
|
||||
from neo4j_graphrag.retrievers import HybridCypherRetriever
|
||||
from neo4j_graphrag.embeddings.base import Embedder
|
||||
from modelsAPI.model_api import LocalBgeM3Embeddings, OpenaiAPI
|
||||
from config import MAP_INS, NEO4J_CONFIG, INDEX_CONFIG, EMBEDDING_CONFIG
|
||||
|
||||
# ================== 配置 ==================
|
||||
URI = NEO4J_CONFIG["uri"]
|
||||
AUTH = (NEO4J_CONFIG["user"], NEO4J_CONFIG["password"])
|
||||
NAME_PROPERTY = INDEX_CONFIG["NAME_PROPERTY"]
|
||||
FULLTEXT_PROPERTY = INDEX_CONFIG["FULLTEXT_PROPERTY"]
|
||||
EMBEDDING_PROPERTY = INDEX_CONFIG["EMBEDDING_PROPERTY"]
|
||||
SEARCH_LABEL = INDEX_CONFIG["SEARCH_LABEL"]
|
||||
VECTOR_INDEX_NAME = INDEX_CONFIG["VECTOR_INDEX_NAME"]
|
||||
FULLTEXT_INDEX_NAME = INDEX_CONFIG["FULLTEXT_INDEX_NAME"]
|
||||
map_ins = MAP_INS
|
||||
|
||||
# ================== 全局复用:driver / embedder 只初始化一次 ==================
|
||||
_driver = None
|
||||
_embedder = None
|
||||
|
||||
def get_driver():
|
||||
global _driver
|
||||
if _driver is None:
|
||||
_driver = GraphDatabase.driver(URI, auth=AUTH)
|
||||
return _driver
|
||||
|
||||
def get_embedder():
|
||||
global _embedder
|
||||
if _embedder is None:
|
||||
_embedder = LocalBgeM3Embeddings(
|
||||
base_url=EMBEDDING_CONFIG["base_url"],
|
||||
api_key=EMBEDDING_CONFIG["api_key"],
|
||||
)
|
||||
return _embedder
|
||||
|
||||
|
||||
# ================== 辅助:解析 Record 字符串 ==================
|
||||
def parse_record_string(s: str):
|
||||
name_match = re.search(r"name='([^']*)'", s)
|
||||
labels_match = re.search(r"labels=(\[[^\]]*\])", s)
|
||||
score_match = re.search(r"score=([\d.]+)", s)
|
||||
|
||||
name = name_match.group(1) if name_match else "未知名称"
|
||||
labels = ["未知标签"]
|
||||
if labels_match:
|
||||
try:
|
||||
labels = ast.literal_eval(labels_match.group(1))
|
||||
except (ValueError, SyntaxError):
|
||||
pass
|
||||
score = float(score_match.group(1)) if score_match else 0.0
|
||||
return name, labels, score
|
||||
|
||||
|
||||
# ================== 构建 Retriever ==================
|
||||
def build_retriever(sync_driver, embedder: Embedder) -> HybridCypherRetriever:
|
||||
return HybridCypherRetriever(
|
||||
driver=sync_driver,
|
||||
vector_index_name=VECTOR_INDEX_NAME,
|
||||
fulltext_index_name=FULLTEXT_INDEX_NAME,
|
||||
embedder=embedder,
|
||||
retrieval_query=f"""
|
||||
RETURN
|
||||
node.`{NAME_PROPERTY}` AS name,
|
||||
[lbl IN labels(node) WHERE lbl <> '{SEARCH_LABEL}'] AS labels,
|
||||
score
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
# ================== 同步检索(在线程池中执行,不阻塞事件循环)==================
|
||||
def _hybrid_search_sync(sentence: str, top_k: int = 10) -> List[Dict]:
|
||||
retriever = build_retriever(get_driver(), get_embedder())
|
||||
results_raw = retriever.search(query_text=sentence, top_k=top_k)
|
||||
|
||||
hybrid_results: List[Dict] = []
|
||||
for item in results_raw.items:
|
||||
content = item.content
|
||||
name = "未知名称"
|
||||
labels = ["未知标签"]
|
||||
score = 0.0
|
||||
|
||||
if hasattr(content, "keys"):
|
||||
name = content.get("name", name)
|
||||
labels = content.get("labels", labels)
|
||||
score = content.get("score", score)
|
||||
elif isinstance(content, str):
|
||||
if content.startswith("<Record"):
|
||||
name, labels, score = parse_record_string(content)
|
||||
else:
|
||||
try:
|
||||
parsed = ast.literal_eval(content)
|
||||
if isinstance(parsed, dict):
|
||||
name = parsed.get("name", name)
|
||||
labels = parsed.get("labels", labels)
|
||||
score = parsed.get("score", score)
|
||||
except (ValueError, SyntaxError):
|
||||
name = content
|
||||
|
||||
if score == 0.0 and hasattr(item, "metadata") and item.metadata:
|
||||
score = item.metadata.get("score", score)
|
||||
|
||||
hybrid_results.append({
|
||||
"标签": labels,
|
||||
"名称": name,
|
||||
"得分": score,
|
||||
"来源": "hybrid",
|
||||
"fulltext_snippet": "",
|
||||
})
|
||||
|
||||
return hybrid_results
|
||||
|
||||
|
||||
# ================== Prompt 模板 ==================
|
||||
PROMPT_TEMPLATE = """
|
||||
请根据给出的问题,以及问题相关的检索结果,以及实体类型和sql表字段映射表,筛选出与问题相关的信息并输出。
|
||||
要求:
|
||||
1. 请仔细分析问题和检索结果,确定问题中出现的实体类型和sql表字段。
|
||||
2. 输出内容可以为充分分析后的一段文本,即你对这个问题中所用到的实体类型和sql表字段的描述。
|
||||
3. 给出的信息尽量精简,不能省略任何重要信息。
|
||||
实体类型和sql表字段映射表:
|
||||
{map_ins}
|
||||
其中键表示实体类型,值表示sql表字段。
|
||||
|
||||
检索结果:
|
||||
{results}
|
||||
|
||||
问题:{user_question}
|
||||
|
||||
|
||||
案例1:
|
||||
用户问题为:扫气箱内发现润滑油泄漏发生了多少次?
|
||||
检索知识库为:
|
||||
[ 1] score=1.0000 | 活塞杆填料函密封失效导致扫气箱内发现润滑油泄漏 标签=['故障模式']
|
||||
[ 2] score=0.9298 | 排除喷油器雾化不良故障 标签=['维修项目']
|
||||
[ 3] score=0.9115 | 执行发动机燃油泄漏应急封堵操作 标签=['操作项目']
|
||||
[ 4] score=0.9082 | 活塞环磨损或断裂导致机座与机架组件润滑油消耗异常增加 标签=['故障模式']
|
||||
[ 5] score=0.9058 | 检测气缸套内径磨损量 标签=['维修项目']
|
||||
[ 6] score=0.9049 | 拆检活塞头磨损情况 标签=['维修项目']
|
||||
[ 7] score=0.9048 | 检查排气阀杆密封面磨损 标签=['维修项目']
|
||||
[ 8] score=0.9015 | 润滑油压力骤降导致轴承组件紧急停机 标签=['故障模式']
|
||||
[ 9] score=0.8985 | 喷油器组件的维修工作 标签=['维修工作']
|
||||
[10] score=0.8979 | 发动机的安全警告 标签=['安全警告']
|
||||
输出为:
|
||||
问题中扫气箱内发现润滑油泄漏是一个故障现象,属于表中fault字段。
|
||||
"""
|
||||
|
||||
SYSTEM_PROMPT = "你是一个专业信息筛选助手,根据给出的问题和问题检索到的相关实体关系,筛选出与问题相关的信息。"
|
||||
|
||||
|
||||
# ================== 主函数(传参调用)==================
|
||||
async def get_extra_info(user_question: str, top_k: int = 20) -> str:
|
||||
"""
|
||||
参数:
|
||||
user_question: 用户问题
|
||||
top_k: 检索返回条数
|
||||
返回:
|
||||
LLM 筛选结果字符串
|
||||
"""
|
||||
# 同步检索放入线程池,不阻塞事件循环
|
||||
results = await asyncio.to_thread(_hybrid_search_sync, user_question, top_k)
|
||||
|
||||
# 构建 prompt 并异步调用 LLM
|
||||
res = await OpenaiAPI.open_api_chat_without_thinking(
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
query=PROMPT_TEMPLATE.format(results=results, map_ins=map_ins,user_question=user_question),
|
||||
)
|
||||
|
||||
return res.strip()
|
||||
|
||||
|
||||
# ================== 入口 ==================
|
||||
if __name__ == "__main__":
|
||||
answer = asyncio.run(get_extra_info("发动机这个月发生了多少次故障"))
|
||||
print(111111111111111111)
|
||||
print(answer)
|
||||
|
||||
298
tools/vlm_tools.py
Normal file
@ -0,0 +1,298 @@
|
||||
"""
|
||||
VLM图像处理工具模块
|
||||
包含图像识别、图像压缩、图像保存等功能
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import math
|
||||
import base64
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
import tiktoken
|
||||
from langchain_core.tools import tool
|
||||
|
||||
from config import VLM_CONFIG, SERVER_CONFIG
|
||||
from modelsAPI.model_api import OpenaiAPI
|
||||
from utils.function_tracker import track_function_calls
|
||||
|
||||
|
||||
VLM_API_BASE = VLM_CONFIG["api_base"]
|
||||
VLM_MODEL_NAME = VLM_CONFIG["model"]
|
||||
MAX_MODEL_LEN = 32768
|
||||
INPUT_TOKEN_SAFETY_RATIO = 0.8
|
||||
MIN_IMAGE_SIZE = 224
|
||||
JPEG_QUALITY = 85
|
||||
|
||||
SYSTEM_PROMPT_VLM = """你是一个专业的工业设备巡检专家助手。
|
||||
你的任务是协助工程师分析现场情况。
|
||||
|
||||
当你遇到以下情况时:
|
||||
1. 用户提供了图片路径(如 .png, .jpg, /app/data/... 等)。
|
||||
2. 用户询问有关设备外观、颜色、仪表读数或物理损坏的问题。
|
||||
|
||||
你应该:
|
||||
- 调用 `vlm_image_to_text` 工具。
|
||||
- 在 `prompt` 参数中,根据用户的具体意图(如:检查漏油、读仪表、看指示灯)量身定制分析指令。
|
||||
- 获取 VLM 结果后,结合专业知识给出建议。
|
||||
"""
|
||||
|
||||
|
||||
def encode_image(image_path: str) -> str:
|
||||
"""将本地图片转换为 Base64 编码"""
|
||||
with open(image_path, "rb") as f:
|
||||
return base64.b64encode(f.read()).decode("utf-8")
|
||||
|
||||
|
||||
def estimate_qwen_vl_visual_tokens(width: int, height: int) -> int:
|
||||
"""
|
||||
根据 Qwen-VL 的图像分块策略估算视觉 tokens。
|
||||
公式来源:Qwen-VL 官方技术文档(Naive resizing + 192 tokens per unit)
|
||||
"""
|
||||
units_h = math.ceil(height / 336)
|
||||
units_w = math.ceil(width / 336)
|
||||
total_units = units_h * units_w + 1
|
||||
return total_units * 192
|
||||
|
||||
|
||||
def count_text_tokens(text: str) -> int:
|
||||
"""使用 tiktoken 估算文本 token 数(兼容中英文)"""
|
||||
enc = tiktoken.get_encoding("cl100k_base")
|
||||
return len(enc.encode(text))
|
||||
|
||||
|
||||
def smart_resize_base64_image(
|
||||
base64_image: str,
|
||||
prompt_text: str,
|
||||
max_input_tokens: int = int(MAX_MODEL_LEN * INPUT_TOKEN_SAFETY_RATIO),
|
||||
min_size: int = MIN_IMAGE_SIZE,
|
||||
jpeg_quality: int = JPEG_QUALITY
|
||||
) -> str:
|
||||
"""
|
||||
智能压缩 base64 图像,确保 (文本 tokens + 视觉 tokens) <= max_input_tokens
|
||||
"""
|
||||
if not base64_image.startswith("data:image"):
|
||||
raise ValueError("Invalid base64 image format. Must start with 'data:image'.")
|
||||
|
||||
try:
|
||||
header, b64_data = base64_image.split(",", 1)
|
||||
image_data = base64.b64decode(b64_data)
|
||||
img = Image.open(BytesIO(image_data)).convert("RGB")
|
||||
orig_w, orig_h = img.size
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to decode base64 image: {e}")
|
||||
|
||||
text_tokens = count_text_tokens(prompt_text)
|
||||
visual_tokens = estimate_qwen_vl_visual_tokens(orig_w, orig_h)
|
||||
total_tokens = text_tokens + visual_tokens
|
||||
|
||||
print(f"[VLM Preprocess] 原始尺寸: {orig_w}x{orig_h} | 文本 tokens: {text_tokens} | 视觉 tokens: {visual_tokens} | 总计: {total_tokens}")
|
||||
|
||||
if total_tokens <= max_input_tokens:
|
||||
print("[VLM Preprocess] ✅ 无需压缩")
|
||||
return base64_image
|
||||
|
||||
scale = 1.0
|
||||
target_tokens = total_tokens
|
||||
while target_tokens > max_input_tokens and scale > 0.1:
|
||||
scale *= 0.8
|
||||
new_w = max(min_size, int(orig_w * scale))
|
||||
new_h = max(min_size, int(orig_h * scale))
|
||||
target_tokens = text_tokens + estimate_qwen_vl_visual_tokens(new_w, new_h)
|
||||
|
||||
final_w = max(min_size, int(orig_w * scale))
|
||||
final_h = max(min_size, int(orig_h * scale))
|
||||
|
||||
print(f"[VLM Preprocess] ⚠️ 需压缩 → 新尺寸: {final_w}x{final_h} | 预估总 tokens: {target_tokens}")
|
||||
|
||||
resized_img = img.resize((final_w, final_h), Image.Resampling.LANCZOS)
|
||||
buffer = BytesIO()
|
||||
resized_img.save(buffer, format="JPEG", quality=jpeg_quality)
|
||||
compressed_b64 = base64.b64encode(buffer.getvalue()).decode("utf-8")
|
||||
return f"data:image/jpeg;base64,{compressed_b64}"
|
||||
|
||||
|
||||
def save_base64_image_and_record(
|
||||
base64_image: str,
|
||||
description: str,
|
||||
output_dir: str = "image_input",
|
||||
record_file: str = "image_input/image_records.json"
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
保存 base64 图片到指定目录,并记录图片名称和描述到 JSON 文件。
|
||||
|
||||
Args:
|
||||
base64_image: base64 编码的图片数据(data:image/xxx;base64,... 格式)
|
||||
description: 图片的描述文本
|
||||
output_dir: 图片保存目录
|
||||
record_file: 记录 JSON 文件路径
|
||||
|
||||
Returns:
|
||||
包含保存结果的字典:{"success": bool, "image_name": str, "image_path": str, "error": str}
|
||||
"""
|
||||
try:
|
||||
if not base64_image.startswith("data:image"):
|
||||
return {"success": False, "error": "Invalid base64 image format"}
|
||||
|
||||
header, b64_data = base64_image.split(",", 1)
|
||||
|
||||
if "jpeg" in header or "jpg" in header:
|
||||
ext = "jpg"
|
||||
elif "png" in header:
|
||||
ext = "png"
|
||||
elif "gif" in header:
|
||||
ext = "gif"
|
||||
elif "webp" in header:
|
||||
ext = "webp"
|
||||
else:
|
||||
ext = "jpg"
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
image_name = f"{uuid.uuid4().hex[:12]}_{datetime.now().strftime('%Y%m%d%H%M%S')}.{ext}"
|
||||
image_path = os.path.join(output_dir, image_name)
|
||||
|
||||
image_data = base64.b64decode(b64_data)
|
||||
with open(image_path, "wb") as f:
|
||||
f.write(image_data)
|
||||
|
||||
record_entry = {
|
||||
"image_name": image_name,
|
||||
"image_path": image_path,
|
||||
"description": description,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
os.makedirs(os.path.dirname(record_file), exist_ok=True)
|
||||
|
||||
existing_records = []
|
||||
if os.path.exists(record_file):
|
||||
try:
|
||||
with open(record_file, "r", encoding="utf-8") as f:
|
||||
existing_records = json.load(f)
|
||||
if not isinstance(existing_records, list):
|
||||
existing_records = []
|
||||
except (json.JSONDecodeError, IOError):
|
||||
existing_records = []
|
||||
|
||||
existing_records.append(record_entry)
|
||||
|
||||
with open(record_file, "w", encoding="utf-8") as f:
|
||||
json.dump(existing_records, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"[ImageSaved] 图片已保存: {image_path}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"image_name": image_name,
|
||||
"image_path": image_path,
|
||||
"error": None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"image_name": None,
|
||||
"image_path": None,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
async def vlm_image_to_text(
|
||||
image_path: str,
|
||||
prompt: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
调用 Qwen3.5-35B 进行工业巡检分析。
|
||||
- 自动移除所有 <think...</think 包裹的内容(包括多行)
|
||||
- 仅返回最终结果文本
|
||||
- 支持 40K token 上下文,自动压缩大图
|
||||
"""
|
||||
|
||||
default_prompt = (
|
||||
"【重要指令】禁止输出任何形式的 <think...</think、<reasoning></reasoning> 或其他内部思考过程。"
|
||||
"仅输出最终的专业分析结果。\n\n"
|
||||
"你是一位资深的工业巡检专家。请仔细观察这张图片并按以下结构报告:\n"
|
||||
"1. 设备识别:图中是什么设备?型号或标签是否可见?\n"
|
||||
"2. 状态评估:仪表盘数值是否在正常区间?指示灯颜色是什么?\n"
|
||||
"3. 异常检测:是否存在漏油、生锈、电缆破损、螺丝松动或烟雾?\n"
|
||||
"4. 环境风险:周围是否有杂物堆放或安全隐患?\n"
|
||||
"请用专业、客观、简短的语言描述。"
|
||||
)
|
||||
|
||||
final_prompt = f"{default_prompt}\n特别注意:{prompt}" if prompt else default_prompt
|
||||
|
||||
safe_image = smart_resize_base64_image(
|
||||
base64_image=image_path,
|
||||
prompt_text=final_prompt,
|
||||
max_input_tokens=int(MAX_MODEL_LEN * INPUT_TOKEN_SAFETY_RATIO)
|
||||
)
|
||||
|
||||
try:
|
||||
result = await OpenaiAPI.open_api_vl_without_thinking(safe_image)
|
||||
|
||||
print(f"✅ 最终输出:\n{result}")
|
||||
|
||||
save_result = save_base64_image_and_record(
|
||||
base64_image=image_path,
|
||||
description=result,
|
||||
output_dir="picture",
|
||||
record_file="picture/image_records.json"
|
||||
)
|
||||
if save_result["success"]:
|
||||
print(f"📷 图片已保存: {save_result['image_name']}")
|
||||
image_url = f"{SERVER_CONFIG['base_url']}/picture/{save_result['image_name']}"
|
||||
result = f"{result}\n\n"
|
||||
else:
|
||||
print(f"⚠️ 图片保存失败: {save_result['error']}")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
error_msg = f"VLM 调用失败: {str(e)}\n{traceback.format_exc()}"
|
||||
print(error_msg)
|
||||
return error_msg
|
||||
|
||||
|
||||
@track_function_calls
|
||||
async def image_rag_describe(
|
||||
image_path: str,
|
||||
query: str,
|
||||
top_k: int = 5
|
||||
) -> str:
|
||||
"""
|
||||
图像RAG描述工具
|
||||
先用VLM分析图像,然后用RAG检索相关知识
|
||||
|
||||
Args:
|
||||
image_path: 图像路径(base64格式)
|
||||
query: 查询文本
|
||||
top_k: RAG检索数量
|
||||
|
||||
Returns:
|
||||
分析结果文本
|
||||
"""
|
||||
try:
|
||||
vlm_result = await vlm_image_to_text(image_path, prompt=query)
|
||||
|
||||
from .rag_tools import rag_search
|
||||
rag_result = await rag_search(query=f"{query} {vlm_result}", top_k=top_k)
|
||||
|
||||
if rag_result.get("success", False):
|
||||
source_citation = rag_result.get("sourceCitation", {})
|
||||
rag_text = ""
|
||||
for resource, items in source_citation.items():
|
||||
for item in items:
|
||||
rag_text += f"【{resource}】{item.get('text', '')}\n\n"
|
||||
|
||||
return f"图像分析结果:\n{vlm_result}\n\n相关知识:\n{rag_text}"
|
||||
else:
|
||||
return vlm_result
|
||||
|
||||
except Exception as e:
|
||||
return f"图像RAG分析失败: {str(e)}"
|
||||
4728
utils/MML2OMML.XSL
Normal file
4
utils/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
"""
|
||||
工具模块
|
||||
"""
|
||||
|
||||
360
utils/function_tracker.py
Normal file
@ -0,0 +1,360 @@
|
||||
"""
|
||||
使用装饰器实现全栈函数调用追踪
|
||||
解决LangGraph无法捕获子函数内部状态的问题
|
||||
"""
|
||||
|
||||
import functools
|
||||
import inspect
|
||||
import asyncio
|
||||
import contextvars
|
||||
from typing import Callable, Any, Dict, Optional, List
|
||||
from datetime import datetime
|
||||
from collections import deque
|
||||
|
||||
# 使用 contextvars 实现请求级别的隔离,避免多个并发请求互相干扰
|
||||
_event_callbacks: contextvars.ContextVar[List[Callable]] = contextvars.ContextVar('event_callbacks', default=None)
|
||||
_current_stream_handler: contextvars.ContextVar[Optional[Any]] = contextvars.ContextVar('current_stream_handler',
|
||||
default=None)
|
||||
|
||||
|
||||
def register_event_callback(callback: Callable[[Dict[str, Any]], None]):
|
||||
"""
|
||||
注册事件回调函数(使用 contextvars 实现请求隔离)
|
||||
"""
|
||||
callbacks = _event_callbacks.get()
|
||||
if callbacks is None:
|
||||
callbacks = []
|
||||
_event_callbacks.set(callbacks)
|
||||
callbacks.append(callback)
|
||||
|
||||
|
||||
def unregister_event_callback(callback: Callable[[Dict[str, Any]], None]):
|
||||
"""
|
||||
移除事件回调函数(使用 contextvars 实现请求隔离)
|
||||
"""
|
||||
callbacks = _event_callbacks.get()
|
||||
if callbacks and callback in callbacks:
|
||||
callbacks.remove(callback)
|
||||
|
||||
|
||||
def clear_event_callbacks():
|
||||
"""
|
||||
清空所有事件回调(使用 contextvars 实现请求隔离)
|
||||
"""
|
||||
callbacks = _event_callbacks.get()
|
||||
if callbacks:
|
||||
callbacks.clear()
|
||||
|
||||
|
||||
def get_all_callbacks():
|
||||
"""
|
||||
获取所有注册的回调函数(使用 contextvars 实现请求隔离)
|
||||
"""
|
||||
callbacks = _event_callbacks.get()
|
||||
return callbacks.copy() if callbacks else []
|
||||
|
||||
|
||||
def extract_function_description(func: Callable) -> str:
|
||||
"""
|
||||
提取函数的描述(docstring的第一行或前100个字符)
|
||||
"""
|
||||
if func.__doc__:
|
||||
doc = func.__doc__.strip()
|
||||
# 取第一行或前100个字符
|
||||
first_line = doc.split('\n')[0].strip()
|
||||
return first_line[:100] if len(first_line) > 100 else first_line
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_details_from_result(result: Any) -> str:
|
||||
"""
|
||||
从函数返回值中提取 details,顺序固定为:先 details 再中文。
|
||||
1) 先看返回的 dict 里是否有「details 类」字段(__details__、_details、details 或键名含 "details"),
|
||||
且其值为非空,则用该值;
|
||||
2) 若没有或为空,再走原本的中文兜底:从返回结构中找一段包含中文的内容作为 details。
|
||||
"""
|
||||
default = "完成"
|
||||
if not result:
|
||||
return default
|
||||
|
||||
# 第一步:优先用 details 类字段(仅当有值且非空时采用)
|
||||
if isinstance(result, dict):
|
||||
for key in ("__details__", "_details", "details"):
|
||||
if key in result and result[key] is not None:
|
||||
s = str(result[key]).strip()
|
||||
if s:
|
||||
return str(result[key])
|
||||
for k, v in result.items():
|
||||
if "details" in k.lower() and v is not None:
|
||||
s = str(v).strip()
|
||||
if s:
|
||||
return str(v)
|
||||
|
||||
# 第二步:保留原本的中文兜底
|
||||
def _contains_chinese(text: str) -> bool:
|
||||
if not text:
|
||||
return False
|
||||
return any("\u4e00" <= c <= "\u9fff" for c in str(text))
|
||||
|
||||
def _find_chinese(obj: Any, max_length: int = 200) -> Optional[str]:
|
||||
if isinstance(obj, str):
|
||||
return (obj[:max_length] + "...") if _contains_chinese(obj) and len(obj) > max_length else (
|
||||
obj if _contains_chinese(obj) else None)
|
||||
if isinstance(obj, dict):
|
||||
for key, value in obj.items():
|
||||
if "details" in key.lower():
|
||||
continue
|
||||
found = _find_chinese(value, max_length)
|
||||
if found:
|
||||
return found
|
||||
if isinstance(obj, list):
|
||||
for item in obj:
|
||||
found = _find_chinese(item, max_length)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
chinese = _find_chinese(result)
|
||||
return chinese if chinese else default
|
||||
|
||||
|
||||
def track_function_calls(func: Callable) -> Callable:
|
||||
"""
|
||||
装饰器:追踪函数调用过程,支持同步和异步函数
|
||||
|
||||
Args:
|
||||
func: 被装饰的函数
|
||||
"""
|
||||
|
||||
@functools.wraps(func)
|
||||
def sync_wrapper(*args, **kwargs):
|
||||
# 获取函数签名信息
|
||||
sig = inspect.signature(func)
|
||||
bound_args = sig.bind(*args, **kwargs)
|
||||
bound_args.apply_defaults()
|
||||
|
||||
func_name = func.__name__
|
||||
module_name = func.__module__
|
||||
|
||||
# 尝试从函数参数中获取 state
|
||||
state = None
|
||||
if args and isinstance(args[0], dict):
|
||||
state = args[0]
|
||||
elif 'state' in bound_args.arguments:
|
||||
state = bound_args.arguments.get('state')
|
||||
|
||||
func_description = extract_function_description(func)
|
||||
|
||||
# 准备函数参数信息
|
||||
params = {k: v for k, v in bound_args.arguments.items()}
|
||||
|
||||
try:
|
||||
# 执行原函数
|
||||
result = func(*args, **kwargs)
|
||||
|
||||
# 构建 details:优先用返回值中的 details 类字段(__details__、_details、details 或键名含 details)
|
||||
details = _extract_details_from_result(result)
|
||||
|
||||
# 合并函数执行事件(只在执行完成后发送一次)
|
||||
execution_event = {
|
||||
"type": "function_execution",
|
||||
"title": func_description,
|
||||
"details": details,
|
||||
"result": result, # 添加函数返回结果
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# 调用所有注册的回调(从上下文变量获取)
|
||||
callbacks = _event_callbacks.get() or []
|
||||
for callback in callbacks:
|
||||
try:
|
||||
callback(execution_event)
|
||||
except Exception as e:
|
||||
print(f"回调执行出错: {e}")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
# 发送函数执行失败事件
|
||||
error_event = {
|
||||
"type": "function_error",
|
||||
"title": func_description,
|
||||
"details": f"错误: {str(e)}",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
callbacks = _event_callbacks.get() or []
|
||||
for callback in callbacks:
|
||||
try:
|
||||
callback(error_event)
|
||||
except Exception as ex:
|
||||
print(f"回调执行出错: {ex}")
|
||||
|
||||
raise
|
||||
|
||||
@functools.wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
# 获取函数签名信息
|
||||
sig = inspect.signature(func)
|
||||
bound_args = sig.bind(*args, **kwargs)
|
||||
bound_args.apply_defaults()
|
||||
|
||||
func_name = func.__name__
|
||||
module_name = func.__module__
|
||||
|
||||
# 尝试从函数参数中获取 state
|
||||
state = None
|
||||
if args and isinstance(args[0], dict):
|
||||
state = args[0]
|
||||
elif 'state' in bound_args.arguments:
|
||||
state = bound_args.arguments.get('state')
|
||||
|
||||
func_description = extract_function_description(func)
|
||||
|
||||
# 准备函数参数信息
|
||||
params = {k: v for k, v in bound_args.arguments.items()}
|
||||
|
||||
try:
|
||||
# 执行原函数
|
||||
result = await func(*args, **kwargs)
|
||||
|
||||
# 构建 details:优先用返回值中的 details 类字段(__details__、_details、details 或键名含 details)
|
||||
details = _extract_details_from_result(result)
|
||||
|
||||
# 合并函数执行事件(只在执行完成后发送一次)
|
||||
execution_event = {
|
||||
"type": "function_execution",
|
||||
"title": func_description,
|
||||
"details": details,
|
||||
"result": result, # 添加函数返回结果
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# 调用所有注册的回调(从上下文变量获取)
|
||||
callbacks = _event_callbacks.get() or []
|
||||
for callback in callbacks:
|
||||
try:
|
||||
callback(execution_event)
|
||||
except Exception as e:
|
||||
print(f"回调执行出错: {e}")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
# 发送函数执行失败事件
|
||||
error_event = {
|
||||
"type": "function_error",
|
||||
"title": func_description,
|
||||
"details": f"错误: {str(e)}",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
callbacks = _event_callbacks.get() or []
|
||||
for callback in callbacks:
|
||||
try:
|
||||
callback(error_event)
|
||||
except Exception as ex:
|
||||
print(f"回调执行出错: {ex}")
|
||||
|
||||
raise
|
||||
|
||||
# 根据函数是否为协程函数返回对应的装饰器
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
return async_wrapper
|
||||
else:
|
||||
return sync_wrapper
|
||||
|
||||
|
||||
# 事件处理器
|
||||
def console_event_handler(event: Dict[str, Any]):
|
||||
"""
|
||||
控制台事件处理器
|
||||
"""
|
||||
event_type = event["type"]
|
||||
title = event.get("title", "")
|
||||
details = event.get("details", "")
|
||||
|
||||
if event_type == "function_execution":
|
||||
print(f"✅ 函数执行: {title}")
|
||||
if details:
|
||||
print(f" 详情: {details}")
|
||||
|
||||
elif event_type == "function_error":
|
||||
print(f"❌ 函数错误: {title}")
|
||||
if details:
|
||||
print(f" 详情: {details}")
|
||||
|
||||
|
||||
class StreamEventHandler:
|
||||
"""
|
||||
流式事件处理器,将装饰器事件放入队列供流式输出使用
|
||||
"""
|
||||
def __init__(self, queue: asyncio.Queue):
|
||||
self.queue = queue
|
||||
|
||||
def __call__(self, event: Dict[str, Any]):
|
||||
"""
|
||||
处理事件,将事件放入队列
|
||||
注意:这是同步方法,但队列是异步的,需要特殊处理
|
||||
"""
|
||||
try:
|
||||
# 使用线程安全的方式将事件放入队列
|
||||
# 如果队列已满,使用 put_nowait 并忽略错误
|
||||
try:
|
||||
self.queue.put_nowait(event)
|
||||
except asyncio.QueueFull:
|
||||
# 队列已满,跳过此事件(不应该发生,因为队列大小足够)
|
||||
pass
|
||||
except Exception as e:
|
||||
# 忽略错误,避免影响主流程
|
||||
pass
|
||||
|
||||
def send_stream_content(self, content: str, title: str = ""):
|
||||
"""
|
||||
发送流式内容事件
|
||||
|
||||
Args:
|
||||
content: 流式内容片段
|
||||
title: 函数描述/标题(可选)
|
||||
"""
|
||||
try:
|
||||
event = {
|
||||
"type": "stream_content",
|
||||
"content": content,
|
||||
"title": title,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
self.queue.put_nowait(event)
|
||||
except Exception as e:
|
||||
# 忽略错误,避免影响主流程
|
||||
pass
|
||||
|
||||
|
||||
def create_stream_event_handler() -> tuple[StreamEventHandler, asyncio.Queue]:
|
||||
"""
|
||||
创建流式事件处理器和对应的队列(使用 contextvars 实现请求隔离)
|
||||
|
||||
Returns:
|
||||
(handler, queue): 事件处理器和事件队列
|
||||
"""
|
||||
queue = asyncio.Queue(maxsize=1000) # 设置足够大的队列大小
|
||||
handler = StreamEventHandler(queue)
|
||||
_current_stream_handler.set(handler) # 保存到上下文变量
|
||||
return handler, queue
|
||||
|
||||
|
||||
def get_current_stream_handler() -> Optional[StreamEventHandler]:
|
||||
"""
|
||||
获取当前活动的流式事件处理器(使用 contextvars 实现请求隔离)
|
||||
|
||||
Returns:
|
||||
当前流式事件处理器,如果没有则返回 None
|
||||
"""
|
||||
return _current_stream_handler.get()
|
||||
|
||||
|
||||
def clear_current_stream_handler():
|
||||
"""
|
||||
清除当前活动的流式事件处理器(使用 contextvars 实现请求隔离)
|
||||
"""
|
||||
_current_stream_handler.set(None)
|
||||
312
utils/image_utils.py
Normal file
@ -0,0 +1,312 @@
|
||||
"""图片处理工具模块。"""
|
||||
import re
|
||||
from typing import List, Tuple
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
|
||||
IMG_EXTENSIONS = r'(?:jpg|jpeg|png|gif|bmp|webp)'
|
||||
|
||||
|
||||
def _dedupe_keep_order(values: List[str]) -> List[str]:
|
||||
seen = set()
|
||||
result = []
|
||||
for value in values:
|
||||
cleaned = (value or "").strip().strip("`'\",,。;;")
|
||||
if not cleaned:
|
||||
continue
|
||||
key = cleaned.lower()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
result.append(cleaned)
|
||||
return result
|
||||
|
||||
|
||||
def extract_image_paths(text: str) -> List[str]:
|
||||
"""从文本中提取图片路径或 URL,并保持原始出现顺序。"""
|
||||
if not text:
|
||||
return []
|
||||
|
||||
patterns = [
|
||||
# Markdown 图片:
|
||||
rf'!\[[^\]\n]*\]\(\s*([^)\s]+?\.{IMG_EXTENSIONS}(?:[?#][^)\s]*)?)\s*\)',
|
||||
# 完整 URL。
|
||||
rf'(https?://[^\s<>"\'\]\)]+?\.{IMG_EXTENSIONS}(?:[?#][^\s<>"\'\]\)]*)?)',
|
||||
# API 绝对路径。
|
||||
rf'(?<![\w:/.%-])(/api/v1/knowledge/files/images/[^\s<>"\'\]\)]+?\.{IMG_EXTENSIONS}(?:[?#][^\s<>"\'\]\)]*)?)',
|
||||
# 相对 images 路径。
|
||||
rf'(?<![\w/.-])(images/[A-Za-z0-9_\-./%]+?\.{IMG_EXTENSIONS}(?:[?#][^\s<>"\'\]\)]*)?)',
|
||||
# 裸文件名,兼容检索结果只给文件名的情况。
|
||||
rf'(?<![\w/.-])([A-Za-z0-9_-]{{8,}}\.{IMG_EXTENSIONS})(?![\w/.-])',
|
||||
]
|
||||
|
||||
matches: List[Tuple[int, str]] = []
|
||||
for pattern in patterns:
|
||||
matches.extend((m.start(1), m.group(1)) for m in re.finditer(pattern, text, re.IGNORECASE))
|
||||
|
||||
matches.sort(key=lambda item: item[0])
|
||||
return _dedupe_keep_order([value for _, value in matches])
|
||||
|
||||
|
||||
def _strip_url_suffix(filename: str) -> str:
|
||||
return re.sub(r'[?#].*$', '', filename or '').strip()
|
||||
|
||||
|
||||
def _split_filename(filename: str) -> Tuple[str, str]:
|
||||
filename = _strip_url_suffix(filename).lower()
|
||||
if "." not in filename:
|
||||
return filename, ""
|
||||
stem, ext = filename.rsplit(".", 1)
|
||||
return stem, ext
|
||||
|
||||
|
||||
def _path_to_image_url(path: str, base_prefix: str) -> str:
|
||||
cleaned = (path or "").strip()
|
||||
if cleaned.startswith(("http://", "https://", "/")):
|
||||
return cleaned
|
||||
if cleaned.lower().startswith("images/"):
|
||||
return base_prefix.rstrip("/") + "/" + cleaned
|
||||
return base_prefix.rstrip("/") + "/images/" + cleaned.split("/")[-1]
|
||||
|
||||
|
||||
def _is_acceptable_image_match(generated_filename: str, original_filename: str, score: float) -> bool:
|
||||
"""只接受完全一致、明确截断或高度相似的文件名,避免把幻觉图片错配到真实图片。"""
|
||||
generated_stem, generated_ext = _split_filename(generated_filename)
|
||||
original_stem, original_ext = _split_filename(original_filename)
|
||||
|
||||
if not generated_stem or not original_stem or generated_ext != original_ext:
|
||||
return False
|
||||
if generated_stem == original_stem:
|
||||
return True
|
||||
if len(generated_stem) >= 12 and original_stem.startswith(generated_stem):
|
||||
return True
|
||||
if len(original_stem) >= 12 and generated_stem.startswith(original_stem):
|
||||
return True
|
||||
return score >= 0.85
|
||||
|
||||
|
||||
def _remove_untrusted_image_references(text: str) -> str:
|
||||
"""没有可信原图列表时,删除模型输出的图片标签和裸图片路径。"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
result = re.sub(
|
||||
rf'!\[[^\]\n]*\]\([^\)\n]*\.{IMG_EXTENSIONS}(?:[?#][^\)\n]*)?\)',
|
||||
'',
|
||||
text,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
result = re.sub(
|
||||
rf'(?<![\w/.-])(?:https?://[^\s<>)]+/images/[^\s<>)]+|/api/v1/knowledge/files/images/[^\s<>)]+|images/[^\s<>)]+|[A-Za-z0-9_-]+\.{IMG_EXTENSIONS})(?:[?#][^\s<>)]+)?',
|
||||
'',
|
||||
result,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
result = re.sub(r'[ \t]+\n', '\n', result)
|
||||
result = re.sub(r'\n{3,}', '\n\n', result)
|
||||
return result.strip()
|
||||
|
||||
|
||||
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 _is_acceptable_image_match(generated_filename, best_match.split('/')[-1], best_score):
|
||||
return best_match
|
||||
return generated_path
|
||||
|
||||
|
||||
def normalize_markdown_images(text: str, base_prefix: str = "/api/v1/knowledge/files/", original_paths: List[str] = None) -> str:
|
||||
"""
|
||||
规范化 Markdown 图片链接 - 两轮处理版
|
||||
|
||||
算法:
|
||||
第一轮:检查和修改格式,确保是  格式
|
||||
第二轮:只检查和替换最后文件名部分(xxx.jpg),与原始路径匹配,匹配度低的删除
|
||||
"""
|
||||
if not text or original_paths is None:
|
||||
return text
|
||||
trusted_paths = [path for path in original_paths if path]
|
||||
if not trusted_paths:
|
||||
return _remove_untrusted_image_references(text)
|
||||
|
||||
# ========== 第一轮:检查和修改格式(包括固定URL前缀) ==========
|
||||
result = _normalize_image_format(text, base_prefix)
|
||||
|
||||
# ========== 第二轮:只检查和替换最后文件名部分 ==========
|
||||
result = _normalize_image_paths(result, base_prefix, trusted_paths)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _normalize_image_format(text: str, base_prefix: str) -> str:
|
||||
"""
|
||||
第一轮:规范化图片格式,确保所有图片都是正确的  格式
|
||||
|
||||
处理各种格式破损的情况,并确保有正确的URL前缀
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
img_pattern = rf'([a-zA-Z0-9_\-./]+)\.({IMG_EXTENSIONS})'
|
||||
|
||||
result = text
|
||||
|
||||
def build_standard_tag(path: str) -> str:
|
||||
full_url = _path_to_image_url(path, base_prefix)
|
||||
return f''
|
||||
|
||||
# 先处理已经是 Markdown 图片语法的内容,包含任意 alt 文本。
|
||||
# 例如:
|
||||
markdown_img_pattern = rf'!\[[^\]\n]*\]\(([^)\n]*?\.{IMG_EXTENSIONS})\)'
|
||||
result = re.sub(
|
||||
markdown_img_pattern,
|
||||
lambda m: build_standard_tag(m.group(1)),
|
||||
result,
|
||||
flags=re.IGNORECASE
|
||||
)
|
||||
|
||||
# 再兜底清理模型或前序处理产生的嵌套图片语法。
|
||||
# 例如:
|
||||
nested_any_alt_pattern = rf'!\[[^\]\n]*\]\([^)\n]*!\[[^\]\n]*\]\(([^)\n]+\.{IMG_EXTENSIONS})\)\)?'
|
||||
while re.search(nested_any_alt_pattern, result, re.IGNORECASE):
|
||||
result = re.sub(
|
||||
nested_any_alt_pattern,
|
||||
lambda m: build_standard_tag(m.group(1)),
|
||||
result,
|
||||
flags=re.IGNORECASE
|
||||
)
|
||||
|
||||
matches = list(re.finditer(img_pattern, result, re.IGNORECASE))
|
||||
|
||||
if not matches:
|
||||
return result
|
||||
|
||||
# 从后往前处理,避免位置偏移
|
||||
for match in reversed(matches):
|
||||
filename_with_ext = match.group(0)
|
||||
file_start = match.start()
|
||||
file_end = match.end()
|
||||
|
||||
mark_start = file_start
|
||||
mark_end = file_end
|
||||
|
||||
# 向前查找是否已经有 
|
||||
before = result[lookback_limit:file_start]
|
||||
open_marker_pos = before.rfind('
|
||||
|
||||
if open_marker_pos != -1:
|
||||
actual_open_pos = lookback_limit + open_marker_pos
|
||||
check_segment = result[actual_open_pos:file_start]
|
||||
if check_segment.startswith(':
|
||||
mark_start = actual_open_pos
|
||||
|
||||
# 向后查找是否有 )
|
||||
after = result[file_end:]
|
||||
close_paren_pos = after.find(')')
|
||||
if close_paren_pos != -1 and close_paren_pos < 20:
|
||||
middle = after[:close_paren_pos]
|
||||
if '\n' not in middle and '
|
||||
standard_format = build_standard_tag(filename_with_ext)
|
||||
|
||||
result = result[:mark_start] + standard_format + result[mark_end:]
|
||||
|
||||
# 清理嵌套格式
|
||||
nested_pattern = r'!\[图片\]\([^)]*!\[图片\]\(([^)]+\.(?:jpg|jpeg|png|gif|bmp|webp))\)[^)]*\)'
|
||||
while re.search(nested_pattern, result, re.IGNORECASE):
|
||||
result = re.sub(nested_pattern, r'', result, flags=re.IGNORECASE)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _normalize_image_paths(text: str, base_prefix: str, original_paths: List[str]) -> str:
|
||||
"""
|
||||
第二轮:只规范化最后文件名部分(xxx.jpg),与原始路径匹配,匹配度低的删除
|
||||
|
||||
步骤:
|
||||
1. 找到所有  格式的图片
|
||||
2. 对每个图片:
|
||||
a. 提取最后文件名部分(xxx.jpg)
|
||||
b. 只与 original_paths 中的最后文件名部分匹配
|
||||
c. 如果匹配度高(>0.5),替换为正确路径
|
||||
d. 如果匹配度低(<=0.5),删除整个图片
|
||||
"""
|
||||
if not text or not original_paths:
|
||||
return text
|
||||
|
||||
# 匹配所有  格式
|
||||
img_tag_pattern = r'!\[图片\]\(([^)]+)\)'
|
||||
matches = list(re.finditer(img_tag_pattern, text, re.IGNORECASE))
|
||||
|
||||
if not matches:
|
||||
return text
|
||||
|
||||
result = text
|
||||
|
||||
# 从后往前处理,避免位置偏移
|
||||
for match in reversed(matches):
|
||||
full_tag = match.group(0)
|
||||
url = match.group(1)
|
||||
tag_start = match.start()
|
||||
tag_end = match.end()
|
||||
|
||||
# 从 URL 中只提取最后的文件名部分(xxx.jpg)
|
||||
filename = url.split('/')[-1]
|
||||
|
||||
# 尝试匹配 - 只匹配最后文件名部分
|
||||
best_match = None
|
||||
best_score = 0
|
||||
|
||||
generated_filename = _strip_url_suffix(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 _is_acceptable_image_match(generated_filename, best_match.split('/')[-1], best_score):
|
||||
# 匹配成功,替换为正确路径(保持固定URL前缀)
|
||||
# 只替换最后文件名部分,保持前缀不变
|
||||
full_url = _path_to_image_url(best_match, base_prefix)
|
||||
|
||||
standard_format = f''
|
||||
result = result[:tag_start] + standard_format + result[tag_end:]
|
||||
else:
|
||||
# 匹配度低,删除整个图片
|
||||
result = result[:tag_start] + result[tag_end:]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_image_prompt_guidance() -> str:
|
||||
"""获取图片格式处理的提示词指导"""
|
||||
return """【图片格式特别要求】
|
||||
- 只允许引用本次参考资料中真实出现的图片链接
|
||||
- 输出图片时使用 Markdown 图片标签:alt 文本固定为「图片」,括号内必须是参考资料里的原始完整 URL
|
||||
- 图片文件名、路径和 URL 前缀必须逐字复制,不得补全、改写、截断、转义或根据记忆/示例构造
|
||||
- 不要输出提示词中的占位符或示例文件名,不要把普通文字描述改写成图片链接
|
||||
- 图片规则只用于生成图片标签,不要围绕图片资源状态输出任何文字说明
|
||||
- 多个图片可在对应位置分别引用并保持原始顺序;同一图片只引用一次"""
|
||||
|
||||
73
utils/intent_matcher.py
Normal file
@ -0,0 +1,73 @@
|
||||
"""
|
||||
意图匹配工具:基于关键词 + embedding 语义相似度的两层确认意图判断
|
||||
|
||||
策略:
|
||||
第一层:关键词子串匹配(零延迟,覆盖 90%+ 常见表达)
|
||||
第二层:embedding 语义相似度匹配(兜底,捕获罕见/创意表达)
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 确认意图短语表(信息充足 / 开始行动)
|
||||
# ──────────────────────────────────────────────
|
||||
CONFIRM_PHRASES: List[str] = [
|
||||
# 直接确认
|
||||
"确认", "是的", "对", "好的", "没问题", "行", "可以", "OK",
|
||||
# 信息充足
|
||||
"足够", "够了", "齐了", "信息完整", "不用补充", "不需要补充",
|
||||
"就这些", "没有了", "信息够了", "不用再加了", "信息齐了",
|
||||
# 开始行动
|
||||
"开始诊断", "开始操作", "开始吧", "继续", "开始",
|
||||
# 复合确认
|
||||
"确认并继续", "正确", "没错", "对的", "是的开始", "就这样",
|
||||
]
|
||||
|
||||
# 预计算的短语 embedding 缓存
|
||||
_phrase_embeddings: Optional[List[List[float]]] = None
|
||||
|
||||
|
||||
async def _ensure_phrase_embeddings() -> None:
|
||||
"""确保确认短语的 embedding 已预计算(懒加载,仅首次调用时执行)"""
|
||||
global _phrase_embeddings
|
||||
if _phrase_embeddings is not None:
|
||||
return
|
||||
from modelsAPI.model_api import OpenaiAPI
|
||||
_phrase_embeddings = await OpenaiAPI.get_embeddings_batch_async(CONFIRM_PHRASES)
|
||||
print(f"[intent_matcher] 确认短语 embedding 预计算完成,共 {len(CONFIRM_PHRASES)} 条")
|
||||
|
||||
|
||||
async def is_confirmation_intent(user_query: str, threshold: float = 0.78) -> bool:
|
||||
"""
|
||||
判断用户输入是否为确认意图
|
||||
|
||||
Args:
|
||||
user_query: 用户输入文本
|
||||
threshold: embedding 语义相似度阈值(默认 0.78)
|
||||
|
||||
Returns:
|
||||
True 表示用户表达了确认/信息充足的意图
|
||||
"""
|
||||
if not user_query or not user_query.strip():
|
||||
return True
|
||||
|
||||
# 第一层:关键词子串匹配(零延迟)
|
||||
if any(kw in user_query for kw in CONFIRM_PHRASES):
|
||||
return True
|
||||
|
||||
# 第二层:embedding 语义相似度匹配(兜底)
|
||||
await _ensure_phrase_embeddings()
|
||||
from modelsAPI.model_api import OpenaiAPI
|
||||
|
||||
query_embedding = await OpenaiAPI.get_embeddings_async(user_query)
|
||||
similarities = OpenaiAPI.cosine_similarity_batch(query_embedding, _phrase_embeddings)
|
||||
max_sim = max(similarities) if similarities else 0.0
|
||||
|
||||
if max_sim >= threshold:
|
||||
matched_phrase = CONFIRM_PHRASES[similarities.index(max_sim)]
|
||||
print(f"[intent_matcher] embedding 兜底命中: sim={max_sim:.3f}, "
|
||||
f"matched='{matched_phrase}', query='{user_query}'")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
280
utils/pdf_generator.py
Normal file
@ -0,0 +1,280 @@
|
||||
import os
|
||||
import base64
|
||||
import subprocess
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class PDFGenerator:
|
||||
"""PDF 生成器类(Pandoc 版)"""
|
||||
|
||||
def __init__(self, main_font: str = "Noto Serif CJK SC"):
|
||||
"""
|
||||
初始化 PDF 生成器
|
||||
|
||||
Args:
|
||||
main_font: Pandoc / XeLaTeX 使用的中文字体名
|
||||
"""
|
||||
self.main_font = main_font
|
||||
|
||||
def generate_pdf_from_text(
|
||||
self,
|
||||
text_content: str,
|
||||
output_path: str = None,
|
||||
title: str = "维修报告",
|
||||
return_base64: bool = True
|
||||
) -> dict:
|
||||
"""
|
||||
将 Markdown 文本内容生成 PDF 文件
|
||||
|
||||
Args:
|
||||
text_content: Markdown 文本内容
|
||||
output_path: PDF 输出路径(可选)
|
||||
title: PDF 标题
|
||||
return_base64: 是否返回 base64 编码内容
|
||||
"""
|
||||
try:
|
||||
# 输出路径
|
||||
if output_path is None:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
output_path = f"temp_report_{timestamp}.pdf"
|
||||
|
||||
# 生成临时 Markdown 文件
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
suffix=".md",
|
||||
delete=False,
|
||||
encoding="utf-8"
|
||||
) as md_file:
|
||||
md_path = md_file.name
|
||||
|
||||
# 写入标题 + 内容
|
||||
if title:
|
||||
md_file.write(f"# {title}\n\n")
|
||||
md_file.write(text_content.strip())
|
||||
|
||||
# Pandoc 命令:使用自定义 LaTeX 模板解决排版问题
|
||||
cmd = [
|
||||
"pandoc",
|
||||
md_path,
|
||||
"--pdf-engine=xelatex",
|
||||
"-V", f"mainfont={self.main_font}",
|
||||
"-V", "geometry:margin=2cm", # 增加页边距
|
||||
"-V", "fontsize=10pt", # 字体大小
|
||||
"-V", "lineskip=1.5", # 行距
|
||||
"--template=template.tex", # 使用自定义模板
|
||||
"-o", output_path
|
||||
]
|
||||
|
||||
# 执行转换
|
||||
subprocess.run(
|
||||
cmd,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
"file_path": output_path
|
||||
}
|
||||
|
||||
# 返回 base64
|
||||
if return_base64:
|
||||
with open(output_path, "rb") as f:
|
||||
result["base64_content"] = base64.b64encode(
|
||||
f.read()
|
||||
).decode("utf-8")
|
||||
|
||||
return result
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": e.stderr.decode("utf-8")
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
finally:
|
||||
# 清理临时 md 文件
|
||||
if "md_path" in locals() and os.path.exists(md_path):
|
||||
os.remove(md_path)
|
||||
|
||||
|
||||
# 创建自定义 LaTeX 模板文件 template.tex
|
||||
def create_latex_template():
|
||||
"""创建一个用于中文排版的 LaTeX 模板(Pandoc 正确版)"""
|
||||
template = r"""
|
||||
\documentclass[10pt]{article}
|
||||
\usepackage[UTF8]{ctex}
|
||||
\usepackage{geometry}
|
||||
\usepackage{graphicx}
|
||||
\usepackage{amsmath}
|
||||
\usepackage{array}
|
||||
\usepackage{booktabs}
|
||||
\usepackage{longtable}
|
||||
\usepackage{setspace}
|
||||
\usepackage{fancyhdr}
|
||||
\usepackage{titlesec}
|
||||
\usepackage{tabularx}
|
||||
\usepackage{makecell}
|
||||
\usepackage{adjustbox}
|
||||
\usepackage{hyperref}
|
||||
% 页面设置
|
||||
\geometry{margin=2cm}
|
||||
\setlength{\parskip}{1em}
|
||||
\setlength{\parindent}{0em}
|
||||
|
||||
% 字体
|
||||
\setCJKmainfont{Noto Serif CJK SC}
|
||||
|
||||
% 标题样式
|
||||
\title{\Large \textbf{$title$}}
|
||||
\author{}
|
||||
\date{}
|
||||
|
||||
% 章节标题样式
|
||||
\titleformat{\section}{\large\bfseries\raggedright}{\thesection}{1em}{}
|
||||
\titleformat{\subsection}{\normalsize\bfseries\raggedright}{\thesubsection}{1em}{}
|
||||
|
||||
|
||||
% ===== Pandoc compatibility =====
|
||||
\usepackage{hyperref}
|
||||
\providecommand{\tightlist}{%
|
||||
\setlength{\itemsep}{0pt}\setlength{\parskip}{0pt}}
|
||||
|
||||
|
||||
\begin{document}
|
||||
|
||||
\maketitle
|
||||
|
||||
$body$
|
||||
|
||||
\end{document}
|
||||
"""
|
||||
with open("template.tex", "w", encoding="utf-8") as f:
|
||||
f.write(template)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# 生成报告函数不变,但调用前先创建模板
|
||||
def generate_report_pdf(
|
||||
report_content: str,
|
||||
device_name: str = "未知设备",
|
||||
output_dir: str = None
|
||||
) -> dict:
|
||||
"""
|
||||
生成维修报告 PDF
|
||||
"""
|
||||
# 先创建模板文件
|
||||
create_latex_template()
|
||||
|
||||
generator = PDFGenerator()
|
||||
|
||||
# 文件名规则不变
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
safe_device_name = device_name.replace(" ", "_").replace("/", "_")
|
||||
file_name = f"{safe_device_name}_维修报告_{timestamp}.pdf"
|
||||
|
||||
if output_dir:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
output_path = os.path.join(output_dir, file_name)
|
||||
else:
|
||||
output_path = file_name
|
||||
|
||||
result = generator.generate_pdf_from_text(
|
||||
text_content=report_content,
|
||||
output_path=output_path,
|
||||
title="设备维修报告",
|
||||
return_base64=True
|
||||
)
|
||||
|
||||
if result.get("success"):
|
||||
result["file_name"] = file_name
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# 测试代码
|
||||
if __name__ == "__main__":
|
||||
test_md = """
|
||||
三、根本原因判断
|
||||
|
||||
根据设备当前状态、维修方案参考和知识库检索结果,最可能的根本原因如下:
|
||||
|
||||
**根本原因**:缸盖密封环老化或失效
|
||||
**次级原因**:启动阀卡滞或内部泄漏
|
||||
|
||||
---
|
||||
|
||||
四、原因分析说明
|
||||
|
||||
1. **缸盖密封环老化或失效**
|
||||
- 缸盖密封环是气缸密封的关键部件,其老化或损坏会导致气缸压缩气体泄漏,压缩压力下降。
|
||||
- 启动时,压缩压力不足会导致点火困难,主机启动延迟或失败。
|
||||
- 此类问题在长期运行或未定期保养的设备中较为常见。
|
||||
|
||||
2. **启动阀卡滞或内部泄漏**
|
||||
- 启动阀的功能是控制启动气进入气缸,若其卡滞或内部泄漏,将导致启动气供应不足或不及时,影响启动成功率。
|
||||
- 内部泄漏还会造成启动气浪费,进一步降低启动成功率。
|
||||
- 启动阀若未定期维护,容易因灰尘、杂质或润滑不良而卡滞。
|
||||
|
||||
3. **气缸套磨损**
|
||||
- 气缸套磨损会导致气缸密封性下降,压缩气体泄漏,压缩压力不足。
|
||||
- 该问题通常发生在长期运行、润滑不良或未及时更换气缸套的设备中。
|
||||
|
||||
4. **活塞环磨损或断裂**
|
||||
- 活塞环磨损或断裂会导致气缸密封性下降,压缩气体泄漏,从而影响压缩压力。
|
||||
- 该问题通常与长期运行、润滑不良或材料疲劳有关。
|
||||
|
||||
5. **调速系统响应异常**
|
||||
- 调速系统异常可能导致主机启动时转速控制不稳,但其对压缩压力的影响相对较小,通常不是导致压缩压力不足的主要原因。
|
||||
|
||||
---
|
||||
|
||||
五、预防建议
|
||||
|
||||
1. 定期更换缸盖密封环
|
||||
- 根据设备运行时间和使用情况,制定合理的密封环更换计划,避免因老化导致密封性能下降。
|
||||
- 建议每运行 5000 小时进行一次检查,必要时更换。
|
||||
|
||||
2. 加强启动阀维护
|
||||
- 定期清理启动阀内部积碳和杂质,保持润滑良好。
|
||||
- 每 1000 小时进行一次功能测试。
|
||||
|
||||
3. 监控气缸套和活塞环状态
|
||||
- 通过内窥镜或压力测试定期检测气缸套与活塞环磨损情况。
|
||||
- 发现异常及时更换。
|
||||
|
||||
4. 优化润滑系统
|
||||
- 使用符合标准的润滑油,确保润滑充分。
|
||||
- 定期更换滤芯,防止杂质进入关键部件。
|
||||
|
||||
5. 定期校准调速系统
|
||||
- 每季度进行一次调速系统校准,确保响应灵敏。
|
||||
- 记录历史数据,建立趋势分析模型。
|
||||
"""
|
||||
|
||||
result = generate_report_pdf(
|
||||
test_md,
|
||||
device_name="压缩机设备"
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
print(f"✓ PDF 生成成功:{result['file_name']}")
|
||||
print(f"✓ 文件路径:{result['file_path']}")
|
||||
print(f"✓ Base64 长度:{len(result['base64_content'])}")
|
||||
else:
|
||||
print(f"✗ PDF 生成失败:{result['error']}")
|
||||
494
utils/ship_number_search.py
Normal file
@ -0,0 +1,494 @@
|
||||
"""
|
||||
舷号搜索策略模块
|
||||
提供三级搜索策略:
|
||||
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
|
||||
|
||||
|
||||
def _normalize_ship_lookup_text(text: str) -> str:
|
||||
value = str(text or "").strip().lower()
|
||||
value = re.sub(r"\s+", "", value)
|
||||
value = value.replace("(", "(").replace(")", ")")
|
||||
for token in ("型驱逐舰", "型护卫舰", "驱逐舰", "护卫舰", "型", "号", "舰"):
|
||||
value = value.replace(token, "")
|
||||
return value
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
normalized_ship_name = _normalize_ship_lookup_text(ship_name)
|
||||
if normalized_ship_name and normalized_ship_name not in candidates:
|
||||
candidates[normalized_ship_name] = {
|
||||
"type": "name",
|
||||
"ship_number": hull_number,
|
||||
**mapping_info
|
||||
}
|
||||
|
||||
for model, numbers in model_numbers.items():
|
||||
model_info = {
|
||||
"type": "model",
|
||||
"ship_number": None,
|
||||
"model": model,
|
||||
"numbers": numbers.copy()
|
||||
}
|
||||
candidates[model] = model_info
|
||||
normalized_model = _normalize_ship_lookup_text(model)
|
||||
if normalized_model:
|
||||
candidates[normalized_model] = model_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()
|
||||
|
||||
normalized_input = _normalize_ship_lookup_text(user_input)
|
||||
exact_key = user_input if user_input in candidates else normalized_input
|
||||
if exact_key in candidates:
|
||||
info = candidates[exact_key]
|
||||
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", "exact_model"] and model_all_numbers:
|
||||
send_search_status(
|
||||
"正在型号检索",
|
||||
f"识别为型号 '{matched_model}',使用该型号所有舷号 {', '.join(model_all_numbers)} 进行检索..."
|
||||
)
|
||||
|
||||
for num in model_all_numbers:
|
||||
rag_query = f"{base_query}"
|
||||
|
||||
try:
|
||||
real_kb_id = await resolve_kb_id(num)
|
||||
if not real_kb_id:
|
||||
continue
|
||||
rag_result_raw = await rag_search(rag_query, top_k=top_k, kb_id=real_kb_id)
|
||||
current_results = convert_rag_result(rag_result_raw)
|
||||
|
||||
if current_results:
|
||||
rag_results.extend(current_results)
|
||||
print(f"步骤2 - 舷号 {num} 检索完成,返回 {len(current_results)} 条结果")
|
||||
|
||||
if not matched_kb_name:
|
||||
for item in current_results:
|
||||
if isinstance(item, dict):
|
||||
found_kb_name = item.get("kb_name", "")
|
||||
found_kb_id = item.get("kb_id", "")
|
||||
if found_kb_name and not matched_kb_name:
|
||||
matched_kb_name = str(found_kb_name).strip()
|
||||
if found_kb_id and not matched_kb_id:
|
||||
matched_kb_id = str(found_kb_id).strip()
|
||||
except Exception as e:
|
||||
print(f"步骤2 - 舷号 {num} 检索失败: {str(e)}")
|
||||
|
||||
if rag_results:
|
||||
return rag_results, matched_kb_name, matched_kb_id, matched_ship_number or ""
|
||||
|
||||
if ship_number and ship_number.strip() and match_type == "none":
|
||||
real_kb_id = await resolve_kb_id(ship_number)
|
||||
if real_kb_id:
|
||||
send_search_status(
|
||||
"正在指定舰名检索",
|
||||
f"使用舰名 {ship_number} 进行检索..."
|
||||
)
|
||||
|
||||
rag_query = f"{base_query}"
|
||||
|
||||
try:
|
||||
rag_result_raw = await rag_search(rag_query, top_k=top_k, kb_id=real_kb_id)
|
||||
rag_results = convert_rag_result(rag_result_raw)
|
||||
|
||||
if rag_results:
|
||||
print(f"步骤1 - 舷号 {ship_number} 检索完成,返回 {len(rag_results)} 条结果")
|
||||
for item in rag_results:
|
||||
if isinstance(item, dict):
|
||||
found_kb_name = item.get("kb_name", "")
|
||||
found_kb_id = item.get("kb_id", "")
|
||||
if found_kb_name and not matched_kb_name:
|
||||
matched_kb_name = str(found_kb_name).strip()
|
||||
if found_kb_id and not matched_kb_id:
|
||||
matched_kb_id = str(found_kb_id).strip()
|
||||
return rag_results, matched_kb_name, matched_kb_id, matched_ship_number or ""
|
||||
except Exception as e:
|
||||
print(f"步骤1 - 舷号 {ship_number} 检索失败: {str(e)}")
|
||||
else:
|
||||
print(f"步骤1 - 舷号 {ship_number} 无对应知识库,跳过指定舰名检索")
|
||||
|
||||
model_ship_numbers = await get_ship_numbers_by_model(ship_number)
|
||||
if len(model_ship_numbers) > 1 or (len(model_ship_numbers) == 1 and ship_number not in model_ship_numbers):
|
||||
send_search_status(
|
||||
"正在同型号检索",
|
||||
f"使用同型号舷号 {', '.join(model_ship_numbers)} 进行检索..."
|
||||
)
|
||||
|
||||
for num in model_ship_numbers:
|
||||
if num == ship_number:
|
||||
continue
|
||||
|
||||
rag_query = f"{base_query}"
|
||||
|
||||
try:
|
||||
real_kb_id = await resolve_kb_id(num)
|
||||
if not real_kb_id:
|
||||
continue
|
||||
rag_result_raw = await rag_search(rag_query, top_k=top_k, kb_id=real_kb_id)
|
||||
current_results = convert_rag_result(rag_result_raw)
|
||||
|
||||
if current_results:
|
||||
rag_results.extend(current_results)
|
||||
print(f"步骤2 - 舷号 {num} 检索完成,返回 {len(current_results)} 条结果")
|
||||
|
||||
if not matched_kb_name:
|
||||
for item in current_results:
|
||||
if isinstance(item, dict):
|
||||
found_kb_name = item.get("kb_name", "")
|
||||
found_kb_id = item.get("kb_id", "")
|
||||
if found_kb_name and not matched_kb_name:
|
||||
matched_kb_name = str(found_kb_name).strip()
|
||||
if found_kb_id and not matched_kb_id:
|
||||
matched_kb_id = str(found_kb_id).strip()
|
||||
except Exception as e:
|
||||
print(f"步骤2 - 舷号 {num} 检索失败: {str(e)}")
|
||||
|
||||
if rag_results:
|
||||
return rag_results, matched_kb_name, matched_kb_id, matched_ship_number or ""
|
||||
|
||||
send_search_status(
|
||||
"正在全局检索",
|
||||
"未找到舷号相关资料,进行全局检索..."
|
||||
)
|
||||
|
||||
try:
|
||||
rag_result_raw = await rag_search(base_query, top_k=top_k)
|
||||
rag_results = convert_rag_result(rag_result_raw)
|
||||
|
||||
if rag_results:
|
||||
print(f"步骤3 - 全局检索完成,返回 {len(rag_results)} 条结果")
|
||||
for item in rag_results:
|
||||
if isinstance(item, dict):
|
||||
found_kb_name = item.get("kb_name", "")
|
||||
found_kb_id = item.get("kb_id", "")
|
||||
if found_kb_name and not matched_kb_name:
|
||||
matched_kb_name = str(found_kb_name).strip()
|
||||
if found_kb_id and not matched_kb_id:
|
||||
matched_kb_id = str(found_kb_id).strip()
|
||||
except Exception as e:
|
||||
print(f"步骤3 - 全局检索失败: {str(e)}")
|
||||
rag_results = []
|
||||
|
||||
return rag_results, matched_kb_name, matched_kb_id, matched_ship_number or ""
|
||||
|
||||
47
utils/template.tex
Normal file
@ -0,0 +1,47 @@
|
||||
|
||||
\documentclass[10pt]{article}
|
||||
\usepackage[UTF8]{ctex}
|
||||
\usepackage{geometry}
|
||||
\usepackage{graphicx}
|
||||
\usepackage{amsmath}
|
||||
\usepackage{array}
|
||||
\usepackage{booktabs}
|
||||
\usepackage{longtable}
|
||||
\usepackage{setspace}
|
||||
\usepackage{fancyhdr}
|
||||
\usepackage{titlesec}
|
||||
\usepackage{tabularx}
|
||||
\usepackage{makecell}
|
||||
\usepackage{adjustbox}
|
||||
\usepackage{hyperref}
|
||||
% 页面设置
|
||||
\geometry{margin=2cm}
|
||||
\setlength{\parskip}{1em}
|
||||
\setlength{\parindent}{0em}
|
||||
|
||||
% 字体
|
||||
\setCJKmainfont{Noto Serif CJK SC}
|
||||
|
||||
% 标题样式
|
||||
\title{\Large \textbf{$title$}}
|
||||
\author{}
|
||||
\date{}
|
||||
|
||||
% 章节标题样式
|
||||
\titleformat{\section}{\large\bfseries\raggedright}{\thesection}{1em}{}
|
||||
\titleformat{\subsection}{\normalsize\bfseries\raggedright}{\thesubsection}{1em}{}
|
||||
|
||||
|
||||
% ===== Pandoc compatibility =====
|
||||
\usepackage{hyperref}
|
||||
\providecommand{\tightlist}{%
|
||||
\setlength{\itemsep}{0pt}\setlength{\parskip}{0pt}}
|
||||
|
||||
|
||||
\begin{document}
|
||||
|
||||
\maketitle
|
||||
|
||||
$body$
|
||||
|
||||
\end{document}
|
||||
802
utils/word_generator.py
Normal file
@ -0,0 +1,802 @@
|
||||
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:
|
||||
def add_header(key: str, value: object, fallback: str = None):
|
||||
value = str(value if value is not None else fallback or "")
|
||||
if not value:
|
||||
return
|
||||
try:
|
||||
value.encode("latin-1")
|
||||
except UnicodeEncodeError:
|
||||
logger.warning(f"跳过非 latin-1 请求头 {key}: {value}")
|
||||
return
|
||||
headers[key] = value
|
||||
|
||||
try:
|
||||
from config import RAG_CONFIG
|
||||
add_header("x-user-id", RAG_CONFIG.get("x-user-id", "1"), "1")
|
||||
add_header("x-user-name", RAG_CONFIG.get("x-user-name", "testuser"), "testuser")
|
||||
add_header("x-role", RAG_CONFIG.get("x-role", "admin"), "admin")
|
||||
except Exception:
|
||||
headers["x-user-id"] = "1"
|
||||
headers["x-user-name"] = "testuser"
|
||||
headers["x-role"] = "admin"
|
||||
return headers
|
||||
|
||||
def add_image(self, url_or_path: str, width_inches: float = 5.5):
|
||||
if not url_or_path:
|
||||
return
|
||||
|
||||
try:
|
||||
import io
|
||||
image_stream = None
|
||||
|
||||
if os.path.isfile(url_or_path):
|
||||
image_stream = url_or_path
|
||||
elif url_or_path.startswith("http://") or url_or_path.startswith("https://"):
|
||||
local_path = self._convert_url_to_local_path(url_or_path)
|
||||
if local_path and os.path.isfile(local_path):
|
||||
image_stream = local_path
|
||||
else:
|
||||
import requests
|
||||
headers = self._get_http_headers(url_or_path)
|
||||
resp = requests.get(url_or_path, timeout=15, headers=headers, proxies={"http": None, "https": None})
|
||||
resp.raise_for_status()
|
||||
content_type = resp.headers.get("Content-Type", "")
|
||||
logger.info(f"HTTP 下载完成, Content-Type: {content_type}, 大小: {len(resp.content)} bytes")
|
||||
if not content_type.startswith("image/"):
|
||||
logger.error(f"响应不是图片类型: {content_type}")
|
||||
self.add_paragraph(f"[图片加载失败: {url_or_path}]")
|
||||
return
|
||||
image_stream = io.BytesIO(resp.content)
|
||||
else:
|
||||
logger.warning(f"图片路径无效: {url_or_path}")
|
||||
return
|
||||
|
||||
from PIL import Image as PilImage
|
||||
if isinstance(image_stream, str):
|
||||
pil_img = PilImage.open(image_stream)
|
||||
else:
|
||||
pil_img = PilImage.open(image_stream)
|
||||
png_stream = io.BytesIO()
|
||||
pil_img.convert("RGBA" if pil_img.mode in ("RGBA", "P") else "RGB").save(png_stream, format="PNG")
|
||||
png_stream.seek(0)
|
||||
|
||||
p = self.doc.add_paragraph()
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
run = p.add_run()
|
||||
run.add_picture(png_stream, width=Inches(width_inches))
|
||||
logger.info(f"图片插入成功: {url_or_path}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"插入图片失败 '{url_or_path}': {e!r}", exc_info=True)
|
||||
self.add_paragraph(f"[图片加载失败: {url_or_path}]")
|
||||
|
||||
def _extract_image_urls(self, line_content):
|
||||
"""
|
||||
从一行文本中提取所有图片URL,支持多种格式:
|
||||
- 
|
||||
- ![alt]`url`
|
||||
- ! `url`
|
||||
返回 [(start, end, url), ...]
|
||||
"""
|
||||
results = []
|
||||
|
||||
# 格式1: 
|
||||
for m in re.finditer(r'!\[([^\]]*)\]\(([^)]+)\)', line_content):
|
||||
url = m.group(2).strip()
|
||||
if url.startswith('`') and url.endswith('`'):
|
||||
url = url[1:-1]
|
||||
results.append((m.start(), m.end(), url))
|
||||
|
||||
# 格式2: ![alt]`url` (未被格式1匹配的)
|
||||
for m in re.finditer(r'!\[([^\]]*)\]\s*`([^`]+)`', line_content):
|
||||
url = m.group(2).strip()
|
||||
already = any(s <= m.start() and e >= m.end() for s, e, _ in results)
|
||||
if not already:
|
||||
results.append((m.start(), m.end(), url))
|
||||
|
||||
# 格式3: ! `url` (模型常见的破损格式)
|
||||
for m in re.finditer(r'!\s*`([^`]+\.(?:jpg|jpeg|png|gif|bmp|webp))`', line_content, re.IGNORECASE):
|
||||
url = m.group(1).strip()
|
||||
already = any(s <= m.start() and e >= m.end() for s, e, _ in results)
|
||||
if not already:
|
||||
results.append((m.start(), m.end(), url))
|
||||
|
||||
results.sort(key=lambda x: x[0])
|
||||
return results
|
||||
|
||||
def add_table(self, lines):
|
||||
try:
|
||||
rows = []
|
||||
for line in lines:
|
||||
line = line.strip("|")
|
||||
cells = [c.strip() for c in line.split("|")]
|
||||
rows.append(cells)
|
||||
|
||||
if not rows:
|
||||
return
|
||||
|
||||
header = rows[0]
|
||||
data_start_index = 1
|
||||
if len(rows) > 1 and all(c.startswith('-') or c == '' for c in rows[1]):
|
||||
data_start_index = 2
|
||||
|
||||
data = rows[data_start_index:]
|
||||
max_cols = len(header)
|
||||
|
||||
table = self.doc.add_table(rows=len(data) + 1, cols=max_cols)
|
||||
table.style = "Table Grid"
|
||||
|
||||
for i, cell in enumerate(header):
|
||||
if i < max_cols:
|
||||
table.rows[0].cells[i].text = cell
|
||||
|
||||
for r, row in enumerate(data):
|
||||
for c, val in enumerate(row):
|
||||
if c < max_cols:
|
||||
table.rows[r + 1].cells[c].text = val
|
||||
|
||||
logger.info("表格添加成功")
|
||||
except Exception as e:
|
||||
logger.error(f"添加表格失败: {e}")
|
||||
|
||||
def _add_horizontal_rule(self):
|
||||
p = self.doc.add_paragraph()
|
||||
pPr = p._element.get_or_add_pPr()
|
||||
pBdr = p._element.makeelement(qn('w:pBdr'), {})
|
||||
bottom = p._element.makeelement(qn('w:bottom'), {
|
||||
qn('w:val'): 'single',
|
||||
qn('w:sz'): '6',
|
||||
qn('w:space'): '1',
|
||||
qn('w:color'): 'CCCCCC',
|
||||
})
|
||||
pBdr.append(bottom)
|
||||
pPr.append(pBdr)
|
||||
|
||||
def parse(self, markdown):
|
||||
lines = markdown.split("\n")
|
||||
table_buffer = []
|
||||
block_formula = []
|
||||
in_block_formula = False
|
||||
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
line_stripped = line.rstrip()
|
||||
line_content = line_stripped.lstrip()
|
||||
|
||||
# 1. 处理块级公式
|
||||
if line_content.startswith("$$"):
|
||||
if not in_block_formula:
|
||||
remaining = line_content[2:]
|
||||
close_idx = remaining.find("$$")
|
||||
if close_idx >= 0:
|
||||
formula = remaining[:close_idx].strip()
|
||||
after_formula = remaining[close_idx + 2:].strip()
|
||||
p = self.doc.add_paragraph()
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
self._insert_formula(p, formula, inline=False)
|
||||
if after_formula:
|
||||
self.add_paragraph(after_formula)
|
||||
else:
|
||||
in_block_formula = True
|
||||
block_formula = []
|
||||
if remaining.strip():
|
||||
block_formula.append(remaining.strip())
|
||||
else:
|
||||
remaining = line_content[2:]
|
||||
in_block_formula = False
|
||||
formula = "".join(block_formula).strip()
|
||||
p = self.doc.add_paragraph()
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
self._insert_formula(p, formula, inline=False)
|
||||
if remaining.strip():
|
||||
self.add_paragraph(remaining.strip())
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if in_block_formula:
|
||||
block_formula.append(line)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 2. 处理表格
|
||||
if line_content.startswith("|") and line_content.endswith("|"):
|
||||
table_buffer.append(line_stripped)
|
||||
i += 1
|
||||
continue
|
||||
else:
|
||||
if table_buffer:
|
||||
self.add_table(table_buffer)
|
||||
table_buffer = []
|
||||
|
||||
# 空行跳过
|
||||
if not line_stripped:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 3. 水平分隔线
|
||||
if re.match(r'^\s*[-*_]{3,}\s*$', line_stripped):
|
||||
self._add_horizontal_rule()
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 4. 标题
|
||||
heading_match = re.match(r'^(#{1,3})\s+(.*)', line_content)
|
||||
if heading_match:
|
||||
level = len(heading_match.group(1))
|
||||
text = heading_match.group(2)
|
||||
self.add_heading(text, level)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 5. 有序列表(带子列表支持)
|
||||
list_match_ordered = re.match(r'^(\d+)\.\s*(.*)', line_content)
|
||||
if list_match_ordered:
|
||||
number = int(list_match_ordered.group(1))
|
||||
text = list_match_ordered.group(2)
|
||||
self.add_list(text, numbered=True, number=number)
|
||||
|
||||
i += 1
|
||||
while i < len(lines):
|
||||
next_line = lines[i]
|
||||
next_stripped = next_line.rstrip()
|
||||
sub_match = re.match(r'^[\s\t]+[-*]\s*(.*)', next_stripped)
|
||||
if sub_match:
|
||||
sub_text = sub_match.group(1)
|
||||
self.add_sub_list(sub_text)
|
||||
i += 1
|
||||
elif (next_stripped.startswith(' ') or next_stripped.startswith('\t')):
|
||||
sub_content = next_stripped.strip()
|
||||
if sub_content and not re.match(r'^\d+\.\s', sub_content):
|
||||
self.add_sub_list(sub_content)
|
||||
i += 1
|
||||
else:
|
||||
break
|
||||
else:
|
||||
break
|
||||
continue
|
||||
|
||||
# 6. 无序列表
|
||||
is_bold_line = line_content.startswith('**')
|
||||
if not is_bold_line:
|
||||
list_match_unordered = re.match(r'^\s*[-*]\s+(.*)', line_stripped)
|
||||
if not list_match_unordered:
|
||||
m = re.match(r'^\s*-([^-].*)$', line_stripped)
|
||||
if m and m.group(1).strip():
|
||||
list_match_unordered = m
|
||||
|
||||
if list_match_unordered:
|
||||
text = list_match_unordered.group(1).strip()
|
||||
if text:
|
||||
self.add_list(text, numbered=False)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 7. 图片链接 - 统一提取所有格式的图片URL
|
||||
img_urls = self._extract_image_urls(line_content)
|
||||
|
||||
if img_urls:
|
||||
has_only_img = (len(img_urls) == 1 and
|
||||
line_content.strip().startswith('!') and
|
||||
img_urls[0][0] == 0)
|
||||
|
||||
if has_only_img:
|
||||
_, _, url = img_urls[0]
|
||||
self.add_image(url)
|
||||
else:
|
||||
p = self.doc.add_paragraph()
|
||||
last_end = 0
|
||||
for start, end, url in img_urls:
|
||||
before_text = line_content[last_end:start].strip()
|
||||
if before_text:
|
||||
self._add_rich_text_with_formulas(p, before_text)
|
||||
self.add_image(url, width_inches=4.5)
|
||||
p = self.doc.add_paragraph()
|
||||
last_end = end
|
||||
after_text = line_content[last_end:].strip()
|
||||
if after_text:
|
||||
self._add_rich_text_with_formulas(p, after_text)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 8. 普通链接
|
||||
if line_content.startswith("["):
|
||||
match = re.match(r'\[(.*?)\]\((.*?)\)', line_content)
|
||||
if match:
|
||||
p = self.doc.add_paragraph()
|
||||
run = p.add_run(match.group(1))
|
||||
run.font.color.rgb = RGBColor(0, 0, 255)
|
||||
run.font.underline = True
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 9. 普通段落
|
||||
p = self.doc.add_paragraph()
|
||||
|
||||
indent_chars = 0
|
||||
temp_line = line_stripped
|
||||
while temp_line.startswith('\u3000'):
|
||||
indent_chars += 1
|
||||
temp_line = temp_line[1:]
|
||||
|
||||
if indent_chars >= 2:
|
||||
p.paragraph_format.first_line_indent = Inches(0.5)
|
||||
elif indent_chars == 1:
|
||||
p.paragraph_format.first_line_indent = Inches(0.25)
|
||||
|
||||
self._add_rich_text_with_formulas(p, line_content)
|
||||
|
||||
i += 1
|
||||
|
||||
if table_buffer:
|
||||
self.add_table(table_buffer)
|
||||
|
||||
def generate(self, markdown, title=None, output=None, images=None):
|
||||
try:
|
||||
if title:
|
||||
logger.info(f"添加文档标题: {title}")
|
||||
p = self.doc.add_paragraph()
|
||||
run = p.add_run(title)
|
||||
self.set_font(run, 20, True)
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
|
||||
logger.info("开始解析 Markdown 内容")
|
||||
self.parse(markdown)
|
||||
|
||||
if images:
|
||||
self._append_images_section(images)
|
||||
|
||||
if not output:
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
output = f"report_{timestamp}.docx"
|
||||
|
||||
logger.info(f"正在保存文件: {output}")
|
||||
self.doc.save(output)
|
||||
|
||||
if os.path.exists(output):
|
||||
file_size = os.path.getsize(output)
|
||||
logger.info(f"文件保存成功: {output}, 大小: {file_size} bytes")
|
||||
return output
|
||||
else:
|
||||
logger.error(f"文件保存后未找到: {output}")
|
||||
raise FileNotFoundError(f"File {output} was not created.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"生成报告过程中发生严重错误: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
def _append_images_section(self, images):
|
||||
try:
|
||||
self.add_heading("附件图片", level=2)
|
||||
|
||||
for idx, img in enumerate(images, 1):
|
||||
if isinstance(img, dict):
|
||||
url = img.get('url', '')
|
||||
summary = img.get('summary', '')
|
||||
else:
|
||||
url = str(img)
|
||||
summary = ''
|
||||
|
||||
p = self.doc.add_paragraph()
|
||||
run = p.add_run(f"图片{idx}:")
|
||||
self.set_font(run, bold=True)
|
||||
|
||||
if url:
|
||||
self.add_image(url, width_inches=4)
|
||||
|
||||
if summary:
|
||||
p = self.doc.add_paragraph()
|
||||
run = p.add_run(f"说明:{summary}")
|
||||
self.set_font(run, size=10)
|
||||
|
||||
self.doc.add_paragraph()
|
||||
|
||||
logger.info(f"图片附件部分添加完成,共 {len(images)} 张图片")
|
||||
except Exception as e:
|
||||
logger.error(f"添加图片附件部分失败: {e}")
|
||||
|
||||
|
||||
def generate_report_word(markdown, title=None, output=None, images=None):
|
||||
logger.info("=== 开始生成报告 ===")
|
||||
try:
|
||||
generator = MarkdownWordGenerator()
|
||||
|
||||
if images:
|
||||
logger.info(f"准备插入 {len(images)} 张图片")
|
||||
|
||||
file_path = generator.generate(markdown, title=title, output=output, images=images)
|
||||
logger.info(f"=== 报告生成完成: {file_path} ===")
|
||||
return file_path
|
||||
except Exception as e:
|
||||
logger.critical(f"报告生成失败: {e}")
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
md = r"""
|
||||
## **测试报告 - OMML公式和图片**
|
||||
|
||||
### **一、行内公式**
|
||||
|
||||
温度要求 $ \leqslant 40 ^ { \circ } \mathrm { C } $,不得超过。
|
||||
|
||||
公差范围 $ \geqslant 0.05 \ \mathrm { mm } $ 以内合格。
|
||||
|
||||
### **二、列表中的公式**
|
||||
|
||||
1. 温度 $ \leqslant 40 ^ { \circ } \mathrm { C } $ 要求
|
||||
2. 间隙 $ \leqslant 0.05 \ \mathrm { mm } $ 标准
|
||||
|
||||
- 清洁轴颈 $ \geqslant 0.05 \ \mathrm { mm } $
|
||||
|
||||
|
||||
### **四、图片测试**
|
||||
|
||||

|
||||
"""
|
||||
|
||||
try:
|
||||
result_path = generate_report_word(md)
|
||||
print(f"报告已生成,路径: {result_path}")
|
||||
except Exception as e:
|
||||
print(f"程序执行出错: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
158
workflow_registry.py
Normal file
@ -0,0 +1,158 @@
|
||||
"""
|
||||
工作流注册表 - 主代理与 app 共用
|
||||
主代理阶段四根据此配置路由到所有注册的子 agent;
|
||||
app 据此执行对应工作流。
|
||||
添加新工作流时,在此配置 WORKFLOW_CONFIG 与 ROUTE_DESCRIPTIONS。
|
||||
"""
|
||||
# ============================================================================
|
||||
# =============== 工作流配置区域 - 添加新工作流时只需修改这里 ===============
|
||||
# ============================================================================
|
||||
# 格式:
|
||||
# "route_flag": {
|
||||
# "module": "workflows.workflow_xxx",
|
||||
# "function": "run_xxx_workflow",
|
||||
# "is_async": True/False,
|
||||
# }
|
||||
# ============================================================================
|
||||
|
||||
WORKFLOW_CONFIG = {
|
||||
"fault_diagnosis": {
|
||||
"module": "workflows.workflow_fault_diagnosis",
|
||||
"function": "run_fault_diagnosis_workflow",
|
||||
"is_async": True,
|
||||
},
|
||||
"qa": {
|
||||
"module": "workflows.workflow_qa",
|
||||
"function": "run_qa_workflow",
|
||||
"is_async": True,
|
||||
},
|
||||
"问题排查": {
|
||||
"module": "workflows.workflow_fault_diagnosis",
|
||||
"function": "run_fault_diagnosis_workflow",
|
||||
"is_async": True,
|
||||
},
|
||||
"spare_parts_statistics": {
|
||||
"module": "workflows.workflow_spare_parts_statistics",
|
||||
"function": "run_spare_parts_statistics_workflow",
|
||||
"is_async": True,
|
||||
},
|
||||
"rules": {
|
||||
"module": "workflows.workflow_qa",
|
||||
"function": "run_qa_workflow",
|
||||
"is_async": True,
|
||||
},
|
||||
"other": {
|
||||
"module": "workflows.workflow_fault_diagnosis",
|
||||
"function": "run_fault_diagnosis_workflow",
|
||||
"is_async": True,
|
||||
#
|
||||
# "module": "workflows.workflow_other",
|
||||
# "function": "run_other_workflow",
|
||||
# "is_async": True,
|
||||
},
|
||||
"report":{
|
||||
# "module": "workflows.workflow_qa_report",
|
||||
# "function": "run_qa_report_agent",
|
||||
# "is_async": True,
|
||||
"module": "workflows.workflow_fault_diagnosis",
|
||||
"function": "run_fault_diagnosis_workflow",
|
||||
"is_async": True,
|
||||
},
|
||||
"百科问答":{
|
||||
"module": "workflows.workflow_baike",
|
||||
"function": "run_baike_workflow",
|
||||
"is_async": True,
|
||||
},
|
||||
"操作使用":{
|
||||
"module": "workflows.workflow_operate",
|
||||
"function": "run_operate_workflow",
|
||||
"is_async": True,
|
||||
},
|
||||
"统计":{
|
||||
"module": "workflows.workflow_statistics",
|
||||
"function": "run_statistics_workflow",
|
||||
"is_async": True,
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
# 路由标志 -> 任务分类描述(供主代理阶段四 LLM 使用)
|
||||
ROUTE_DESCRIPTIONS = {
|
||||
"fault_diagnosis": """
|
||||
- 特征:用户描述**当前或近期发生的设备异常、报警、性能下降等具体故障现象**,需要分析原因或定位问题。
|
||||
- 关键词:报警、异常、异响、冒烟、过热、无法启动、突然停机、振动大、压力低、温度高、故障代码、不工作
|
||||
- 示例:
|
||||
• 主机滑油压力突然降到0.2MPa并报警
|
||||
• 柴油机启动后冒黑烟且转速不稳
|
||||
• 舵机发出异常噪音,疑似齿轮损坏
|
||||
- 注意:若历史记录中包含“请补充”、“【需要更多信息】”,通常属于此类,因已聚焦具体故障场景。
|
||||
当用户问题中只有舷号、设备或故障现象时属于此类,舷号一般为连续的阿拉伯数字如“101”,"111","122","163","16","17","554","602","545","985","31","3261","799","905","781"等,或者数字后加舰,比如“101舰","199舰","18舰"等。
|
||||
""",
|
||||
|
||||
"qa": """
|
||||
- 特征:询问定义、原理、结构、功能、分类、标准配置、常见类型、安全规范等**一般性知识**;也包括对专业术语、现象、部件作用的解释。
|
||||
**即使问题表述模糊、不完整、缺少上下文,只要涉及船舶、机械、电气、动力等技术内容,均优先归为此类。**
|
||||
- 关键词:什么是、如何工作、为什么、请问、介绍、解释、有哪些、常见、典型、一般、作用、组成、是不是、叫什么
|
||||
- 示例:
|
||||
• 船舶主发动机有哪些常见故障类型?
|
||||
• 涡轮增压器的工作原理是什么?
|
||||
• 主机由哪些主要部件组成?
|
||||
• 什么是MAN B&W柴油机?
|
||||
- 注意:
|
||||
• **本类作为技术相关问题的默认兜底类别**——凡涉及设备、系统、部件、工况、参数等技术语境的问题,若无法明确归入 fault_diagnosis / repair / spare_parts_statistics / rules,则优先视为 qa。
|
||||
""",
|
||||
|
||||
"repair": """
|
||||
- 特征:用户明确请求**维修方法、操作步骤、检修流程、更换方案或现场处理建议**,通常在已知故障或需预防性维护时提出。
|
||||
- 关键词:怎么修、如何维修、维修步骤、检修方法、更换、拆卸、安装、处理、修复、维护、保养、调整
|
||||
- 示例:
|
||||
• 主机喷油器漏油,怎么更换?
|
||||
• 给出主机缸套磨损的维修方案
|
||||
• 如何拆卸增压器进行检修?
|
||||
""",
|
||||
|
||||
"spare_parts_statistics": """
|
||||
- 特征:用户需要对**备品备件的数量、使用记录、库存、消耗趋势、预测需求或成本分析**进行查询或统计。
|
||||
- 关键词:备件、备品、库存、数量、统计、预测、分析、消耗、需求、清单、台账、寿命、更换周期、用了多少、还剩多少
|
||||
- 示例:
|
||||
• 统计过去一年主机喷油嘴的使用数量
|
||||
• 预测下季度所需空冷器备件数量
|
||||
• 主机活塞环的平均更换周期是多少?
|
||||
- 注意:若仅问“某个部件是不是备件”或“这个备件叫什么名字”,属于知识问答(qa),而非本类。
|
||||
""",
|
||||
|
||||
"rules": """
|
||||
- 特征:用户询问与船舶设备维修、检验、安全、环保、保密等相关的**国家法规、军用标准(如GJB)、行业规范、船级社要求或管理制度**。
|
||||
- 关键词:法规、标准、规范、规定、合规、条例、依据、要求、准则、制度、认证、资质、船级社、CCS、IMO、SOLAS、MARPOL、资格
|
||||
- 示例:
|
||||
• 船舶主机修理需遵循哪些GJB标准?
|
||||
• IMO对主机排放有哪些强制要求?
|
||||
• 舰船维修单位需要哪些资质?
|
||||
• 保密资格申请流程是什么?
|
||||
- 注意:若问题提到“标准里有没有…”但未指明具体法规/标准名称,且无“法规”“标准”等关键词,可先归为 qa;一旦明确提及“法规”“标准”“规范”“SOLAS”等,则归为 rules。
|
||||
""",
|
||||
"other": """
|
||||
- 特征:**完全不涉及新的技术问题、故障诊断、维修请求、备件统计、法规查询或报告生成**,而是针对**已有对话内容、方案、步骤或文本的反馈、修正、补充、顺序调整或格式优化**。典型场景包括:
|
||||
• 指出之前提供的操作步骤有误(如“异物清理步骤有误”)
|
||||
• 要求调整操作顺序(如“这一步应当先怎样,然后再怎么操作,最后……”)
|
||||
• 对生成内容提出修改意见(如“步骤有误/有补充”“请重写第三点”)
|
||||
• 请求优化语言、格式或结构(如“请用更简洁的方式表达”)
|
||||
• 闲聊、非技术性确认或与当前技术任务无关的交互
|
||||
|
||||
- 注意:
|
||||
• **只要用户意图是修正、补充或调整已有内容,而非提出新问题,即使涉及技术术语,也应归为 other**。
|
||||
• 不要将此类反馈误判为 repair 或 qa——它们不是在问“该怎么修”或“原理是什么”,而是在说“你刚才说的不对/不全”。
|
||||
• 此类通常出现在多轮对话中,是对前文输出的回应。
|
||||
""",
|
||||
"report": """
|
||||
- 特征:用户明确要求**生成、整理、导出或汇总**某种形式的文档、报表、清单或总结材料。通常涉及将分散的故障记录、维修历史、备件数据或问答内容整合成结构化文本(如日报、周报、故障分析报告、维修总结、备件消耗表等)。
|
||||
- 关键词:生成报告、写总结、导出表格、整理清单、汇总数据、形成文档、日报、周报、月报、分析报告、维修记录单、台账报表
|
||||
- 示例:
|
||||
• 请根据本周的故障记录生成一份主机系统运行分析报告
|
||||
• 总结这次对话
|
||||
• 导出过去一年的备件消耗统计报告
|
||||
• 帮我写一份关于此次主机停机事故的详细处理总结
|
||||
"""
|
||||
}
|
||||
|
||||
VALID_ROUTE_FLAGS = tuple(WORKFLOW_CONFIG.keys())
|
||||
0
workflows/=0.2.0
Normal file
55
workflows/=2.0.0
Normal file
@ -0,0 +1,55 @@
|
||||
Requirement already satisfied: langgraph in /usr/local/lib/python3.10/dist-packages (1.0.3)
|
||||
Collecting langgraph
|
||||
Downloading langgraph-1.1.3-py3-none-any.whl (168 kB)
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 168.1/168.1 KB 685.8 kB/s eta 0:00:00
|
||||
Requirement already satisfied: langgraph-checkpoint-postgres in /usr/local/lib/python3.10/dist-packages (3.0.5)
|
||||
Requirement already satisfied: langchain-core>=0.1 in /usr/local/lib/python3.10/dist-packages (from langgraph) (1.1.0)
|
||||
Collecting langgraph-sdk<0.4.0,>=0.3.0
|
||||
Downloading langgraph_sdk-0.3.12-py3-none-any.whl (95 kB)
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 95.8/95.8 KB 3.9 MB/s eta 0:00:00
|
||||
Requirement already satisfied: xxhash>=3.5.0 in /usr/local/lib/python3.10/dist-packages (from langgraph) (3.6.0)
|
||||
Requirement already satisfied: pydantic>=2.7.4 in /usr/local/lib/python3.10/dist-packages (from langgraph) (2.12.4)
|
||||
Requirement already satisfied: langgraph-checkpoint<5.0.0,>=2.1.0 in /usr/local/lib/python3.10/dist-packages (from langgraph) (3.0.1)
|
||||
Collecting langgraph-prebuilt<1.1.0,>=1.0.8
|
||||
Downloading langgraph_prebuilt-1.0.8-py3-none-any.whl (35 kB)
|
||||
Requirement already satisfied: psycopg-pool>=3.2.0 in /usr/local/lib/python3.10/dist-packages (from langgraph-checkpoint-postgres) (3.3.0)
|
||||
Requirement already satisfied: orjson>=3.11.5 in /usr/local/lib/python3.10/dist-packages (from langgraph-checkpoint-postgres) (3.11.7)
|
||||
Requirement already satisfied: psycopg>=3.2.0 in /usr/local/lib/python3.10/dist-packages (from langgraph-checkpoint-postgres) (3.3.3)
|
||||
Requirement already satisfied: jsonpatch<2.0.0,>=1.33.0 in /usr/local/lib/python3.10/dist-packages (from langchain-core>=0.1->langgraph) (1.33)
|
||||
Requirement already satisfied: langsmith<1.0.0,>=0.3.45 in /usr/local/lib/python3.10/dist-packages (from langchain-core>=0.1->langgraph) (0.4.46)
|
||||
Requirement already satisfied: packaging<26.0.0,>=23.2.0 in /usr/local/lib/python3.10/dist-packages (from langchain-core>=0.1->langgraph) (25.0)
|
||||
Requirement already satisfied: pyyaml<7.0.0,>=5.3.0 in /usr/local/lib/python3.10/dist-packages (from langchain-core>=0.1->langgraph) (6.0.3)
|
||||
Requirement already satisfied: tenacity!=8.4.0,<10.0.0,>=8.1.0 in /usr/local/lib/python3.10/dist-packages (from langchain-core>=0.1->langgraph) (9.1.2)
|
||||
Requirement already satisfied: typing-extensions<5.0.0,>=4.7.0 in /usr/local/lib/python3.10/dist-packages (from langchain-core>=0.1->langgraph) (4.15.0)
|
||||
Requirement already satisfied: ormsgpack>=1.12.0 in /usr/local/lib/python3.10/dist-packages (from langgraph-checkpoint<5.0.0,>=2.1.0->langgraph) (1.12.0)
|
||||
Requirement already satisfied: httpx>=0.25.2 in /usr/local/lib/python3.10/dist-packages (from langgraph-sdk<0.4.0,>=0.3.0->langgraph) (0.28.1)
|
||||
Requirement already satisfied: typing-inspection>=0.4.2 in /usr/local/lib/python3.10/dist-packages (from pydantic>=2.7.4->langgraph) (0.4.2)
|
||||
Requirement already satisfied: annotated-types>=0.6.0 in /usr/local/lib/python3.10/dist-packages (from pydantic>=2.7.4->langgraph) (0.7.0)
|
||||
Requirement already satisfied: pydantic-core==2.41.5 in /usr/local/lib/python3.10/dist-packages (from pydantic>=2.7.4->langgraph) (2.41.5)
|
||||
Requirement already satisfied: httpcore==1.* in /usr/local/lib/python3.10/dist-packages (from httpx>=0.25.2->langgraph-sdk<0.4.0,>=0.3.0->langgraph) (1.0.9)
|
||||
Requirement already satisfied: anyio in /usr/local/lib/python3.10/dist-packages (from httpx>=0.25.2->langgraph-sdk<0.4.0,>=0.3.0->langgraph) (4.11.0)
|
||||
Requirement already satisfied: certifi in /usr/local/lib/python3.10/dist-packages (from httpx>=0.25.2->langgraph-sdk<0.4.0,>=0.3.0->langgraph) (2025.11.12)
|
||||
Requirement already satisfied: idna in /usr/local/lib/python3.10/dist-packages (from httpx>=0.25.2->langgraph-sdk<0.4.0,>=0.3.0->langgraph) (3.11)
|
||||
Requirement already satisfied: h11>=0.16 in /usr/local/lib/python3.10/dist-packages (from httpcore==1.*->httpx>=0.25.2->langgraph-sdk<0.4.0,>=0.3.0->langgraph) (0.16.0)
|
||||
Requirement already satisfied: jsonpointer>=1.9 in /usr/local/lib/python3.10/dist-packages (from jsonpatch<2.0.0,>=1.33.0->langchain-core>=0.1->langgraph) (3.0.0)
|
||||
Requirement already satisfied: requests>=2.0.0 in /usr/local/lib/python3.10/dist-packages (from langsmith<1.0.0,>=0.3.45->langchain-core>=0.1->langgraph) (2.32.5)
|
||||
Requirement already satisfied: zstandard>=0.23.0 in /usr/local/lib/python3.10/dist-packages (from langsmith<1.0.0,>=0.3.45->langchain-core>=0.1->langgraph) (0.25.0)
|
||||
Requirement already satisfied: requests-toolbelt>=1.0.0 in /usr/local/lib/python3.10/dist-packages (from langsmith<1.0.0,>=0.3.45->langchain-core>=0.1->langgraph) (1.0.0)
|
||||
Requirement already satisfied: urllib3<3,>=1.21.1 in /usr/local/lib/python3.10/dist-packages (from requests>=2.0.0->langsmith<1.0.0,>=0.3.45->langchain-core>=0.1->langgraph) (2.5.0)
|
||||
Requirement already satisfied: charset_normalizer<4,>=2 in /usr/local/lib/python3.10/dist-packages (from requests>=2.0.0->langsmith<1.0.0,>=0.3.45->langchain-core>=0.1->langgraph) (3.4.4)
|
||||
Requirement already satisfied: exceptiongroup>=1.0.2 in /usr/local/lib/python3.10/dist-packages (from anyio->httpx>=0.25.2->langgraph-sdk<0.4.0,>=0.3.0->langgraph) (1.3.1)
|
||||
Requirement already satisfied: sniffio>=1.1 in /usr/local/lib/python3.10/dist-packages (from anyio->httpx>=0.25.2->langgraph-sdk<0.4.0,>=0.3.0->langgraph) (1.3.1)
|
||||
Installing collected packages: langgraph-sdk, langgraph-prebuilt, langgraph
|
||||
Attempting uninstall: langgraph-sdk
|
||||
Found existing installation: langgraph-sdk 0.2.9
|
||||
Uninstalling langgraph-sdk-0.2.9:
|
||||
Successfully uninstalled langgraph-sdk-0.2.9
|
||||
Attempting uninstall: langgraph-prebuilt
|
||||
Found existing installation: langgraph-prebuilt 1.0.5
|
||||
Uninstalling langgraph-prebuilt-1.0.5:
|
||||
Successfully uninstalled langgraph-prebuilt-1.0.5
|
||||
Attempting uninstall: langgraph
|
||||
Found existing installation: langgraph 1.0.3
|
||||
Uninstalling langgraph-1.0.3:
|
||||
Successfully uninstalled langgraph-1.0.3
|
||||
Successfully installed langgraph-1.1.3 langgraph-prebuilt-1.0.8 langgraph-sdk-0.3.12
|
||||
20
workflows/__init__.py
Normal 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"
|
||||
]
|
||||
|
||||
220
workflows/history_manager.py
Normal file
@ -0,0 +1,220 @@
|
||||
"""
|
||||
统一历史对话管理器
|
||||
整合所有分散的 parse_history / _parse_history / build_history_str / filter_image_urls 逻辑
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
DEFAULT_HISTORY_MAX_MESSAGES = 12
|
||||
DEFAULT_ASSISTANT_TRUNCATE = 1000
|
||||
|
||||
|
||||
class HistoryManager:
|
||||
"""统一的历史对话管理器,提供解析、截断、格式化、过滤等功能"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_messages: int = DEFAULT_HISTORY_MAX_MESSAGES,
|
||||
max_assistant_chars: int = DEFAULT_ASSISTANT_TRUNCATE,
|
||||
):
|
||||
"""
|
||||
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 = DEFAULT_HISTORY_MAX_MESSAGES,
|
||||
assistant_truncate: int = DEFAULT_ASSISTANT_TRUNCATE,
|
||||
) -> 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)
|
||||
|
||||
546
workflows/workflow_baike.py
Normal file
@ -0,0 +1,546 @@
|
||||
"""
|
||||
工作流:普通问答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, DEFAULT_HISTORY_MAX_MESSAGES
|
||||
# from workflows.workflow_utils import stream_generate_and_postprocess
|
||||
from workflows.workflow_utils import dedupe_rag_results, stream_generate_and_postprocess
|
||||
|
||||
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_all_callbacks
|
||||
from utils.image_utils import extract_image_paths
|
||||
from prompts import BAIKE_PROMPTS
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import random
|
||||
|
||||
BAIKE_RAG_TOP_K = 8
|
||||
BAIKE_GRAPH_TOP_K = 240
|
||||
BAIKE_RESULT_LIMIT = 8
|
||||
|
||||
|
||||
# =============== 定义状态 ===============
|
||||
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[-DEFAULT_HISTORY_MAX_MESSAGES:] 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=BAIKE_RAG_TOP_K
|
||||
)
|
||||
else:
|
||||
return await rag_search(
|
||||
query=extracted_text,
|
||||
top_k=BAIKE_RAG_TOP_K
|
||||
)
|
||||
|
||||
async def _do_graph_search():
|
||||
return await graph_rag_search.ainvoke({
|
||||
"query": query,
|
||||
"top_k": BAIKE_GRAPH_TOP_K
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
rag_search_result = sorted(rag_search_result,key=lambda x:float(x.get("score") or 0), reverse=True)[:BAIKE_RESULT_LIMIT]
|
||||
rag_search_result = dedupe_rag_results(rag_search_result, limit=BAIKE_RESULT_LIMIT)
|
||||
|
||||
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)[:4]:
|
||||
# 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})
|
||||
|
||||
title_options = ["🤖 诊断分析中", "🤖 故障预检中"]
|
||||
start_event = {
|
||||
"type": "function_execution",
|
||||
"title": random.choice(title_options),
|
||||
"details": "正在生成回答..."
|
||||
}
|
||||
for callback in get_all_callbacks():
|
||||
try:
|
||||
callback(start_event)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
answer = await stream_generate_and_postprocess(
|
||||
system_prompt=content_system_prompt,
|
||||
messages=content_messages,
|
||||
original_image_paths=original_image_paths,
|
||||
temperature=0.5,
|
||||
fallback="抱歉,无法生成回答。"
|
||||
)
|
||||
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())
|
||||
|
||||
1770
workflows/workflow_fault_diagnosis.py
Normal file
1562
workflows/workflow_operate.py
Normal file
293
workflows/workflow_other.py
Normal 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())
|
||||
|
||||
572
workflows/workflow_other_report.py
Normal 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"" 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())
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
544
workflows/workflow_qa_report.py
Normal 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" " 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("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
|
||||
})
|
||||
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())
|
||||
|
||||
|
||||
|
||||
484
workflows/workflow_statistics.py
Normal 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())
|
||||
|
||||
476
workflows/workflow_utils.py
Normal file
@ -0,0 +1,476 @@
|
||||
"""
|
||||
工作流公共工具函数
|
||||
提取自 workflow_fault_diagnosis.py 和 workflow_operate.py 的共享代码
|
||||
"""
|
||||
|
||||
from typing import TypedDict, Optional, Dict, Any, List
|
||||
from difflib import SequenceMatcher
|
||||
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
|
||||
import inspect
|
||||
import unicodedata
|
||||
|
||||
|
||||
RAG_DEDUP_SIMILARITY_THRESHOLD = 0.86
|
||||
RAG_DEDUP_CONTAINMENT_THRESHOLD = 0.92
|
||||
RAG_DEDUP_MIN_TEXT_LENGTH = 20
|
||||
|
||||
|
||||
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 _extract_rag_text(item: Any) -> str:
|
||||
if isinstance(item, dict):
|
||||
return str(item.get("text") or item.get("content") or "")
|
||||
if isinstance(item, str):
|
||||
return item
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_rag_score(item: Any) -> float:
|
||||
if not isinstance(item, dict):
|
||||
return 0.0
|
||||
try:
|
||||
return float(item.get("score") or item.get("socre") or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _normalize_rag_text_for_dedupe(text: Any) -> str:
|
||||
value = unicodedata.normalize("NFKC", str(text or ""))
|
||||
value = re.sub(r"!\[[^\]]*\]\([^)]+\)", "", value)
|
||||
value = re.sub(r"\[([^\]]*)\]\([^)]+\)", r"\1", value)
|
||||
value = re.sub(r"https?://\S+", "", value, flags=re.IGNORECASE)
|
||||
value = re.sub(r"[/\\]?picture[/\\]\S+", "", value, flags=re.IGNORECASE)
|
||||
value = re.sub(r"\s+", "", value).lower()
|
||||
return "".join(ch for ch in value if ch.isalnum())
|
||||
|
||||
|
||||
def _char_ngrams(text: str, n: int) -> set:
|
||||
if len(text) <= n:
|
||||
return {text} if text else set()
|
||||
return {text[i:i + n] for i in range(len(text) - n + 1)}
|
||||
|
||||
|
||||
def _rag_texts_are_similar(text_a: str, text_b: str, threshold: float) -> bool:
|
||||
if not text_a or not text_b:
|
||||
return False
|
||||
if text_a == text_b:
|
||||
return True
|
||||
|
||||
min_len = min(len(text_a), len(text_b))
|
||||
max_len = max(len(text_a), len(text_b))
|
||||
if min_len < 8:
|
||||
return False
|
||||
|
||||
if min_len >= RAG_DEDUP_MIN_TEXT_LENGTH and (text_a in text_b or text_b in text_a):
|
||||
return (min_len / max_len) >= RAG_DEDUP_CONTAINMENT_THRESHOLD
|
||||
|
||||
if min_len < RAG_DEDUP_MIN_TEXT_LENGTH:
|
||||
return SequenceMatcher(None, text_a, text_b).ratio() >= 0.95
|
||||
|
||||
ngram_size = 3 if min_len >= 30 else 2
|
||||
grams_a = _char_ngrams(text_a, ngram_size)
|
||||
grams_b = _char_ngrams(text_b, ngram_size)
|
||||
if not grams_a or not grams_b:
|
||||
return False
|
||||
|
||||
jaccard = len(grams_a & grams_b) / len(grams_a | grams_b)
|
||||
if jaccard >= threshold:
|
||||
return True
|
||||
|
||||
return SequenceMatcher(None, text_a, text_b).ratio() >= threshold
|
||||
|
||||
|
||||
def dedupe_rag_results(
|
||||
items: Any,
|
||||
limit: Optional[int] = None,
|
||||
similarity_threshold: float = RAG_DEDUP_SIMILARITY_THRESHOLD,
|
||||
) -> Any:
|
||||
"""
|
||||
Deduplicate RAG result chunks while preserving the original item shape.
|
||||
|
||||
Higher-score items are kept first. Graph results are intentionally not handled
|
||||
here; callers should pass only text RAG chunks.
|
||||
"""
|
||||
if not isinstance(items, list):
|
||||
return items
|
||||
if len(items) < 2:
|
||||
return items[:limit] if limit else items
|
||||
|
||||
prepared = []
|
||||
for idx, item in enumerate(items):
|
||||
prepared.append({
|
||||
"item": item,
|
||||
"index": idx,
|
||||
"score": _extract_rag_score(item),
|
||||
"normalized_text": _normalize_rag_text_for_dedupe(_extract_rag_text(item)),
|
||||
})
|
||||
|
||||
prepared.sort(key=lambda entry: (-entry["score"], entry["index"]))
|
||||
|
||||
kept = []
|
||||
for entry in prepared:
|
||||
current_text = entry["normalized_text"]
|
||||
is_duplicate = False
|
||||
if current_text:
|
||||
for kept_entry in kept:
|
||||
if _rag_texts_are_similar(
|
||||
current_text,
|
||||
kept_entry["normalized_text"],
|
||||
similarity_threshold,
|
||||
):
|
||||
is_duplicate = True
|
||||
break
|
||||
if not is_duplicate:
|
||||
kept.append(entry)
|
||||
|
||||
deduped = [entry["item"] for entry in kept]
|
||||
return deduped[:limit] if limit else deduped
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def resolve_ship_number_for_workflow(
|
||||
ship_number: str,
|
||||
context: str = "workflow",
|
||||
) -> tuple[str, Dict[str, Any]]:
|
||||
"""
|
||||
工作流内舷号/舰名标准化。
|
||||
|
||||
能映射到具体舷号时返回映射后的舷号;失败或未匹配时返回原值。
|
||||
"""
|
||||
original_ship_number = str(ship_number or "").strip()
|
||||
if not original_ship_number:
|
||||
return original_ship_number, {}
|
||||
|
||||
try:
|
||||
from utils.ship_number_search import smart_ship_number_mapping
|
||||
|
||||
matched_ship_number, matched_model, all_numbers, match_type = await smart_ship_number_mapping(
|
||||
original_ship_number
|
||||
)
|
||||
result = {
|
||||
"matched_ship_number": matched_ship_number,
|
||||
"matched_model": matched_model,
|
||||
"all_numbers": all_numbers,
|
||||
"match_type": match_type,
|
||||
}
|
||||
if match_type in ["exact_number", "exact_name", "embedding"] and matched_ship_number:
|
||||
mapped_ship_number = str(matched_ship_number).strip()
|
||||
if mapped_ship_number and mapped_ship_number != original_ship_number:
|
||||
print(
|
||||
f"[{context}] 舷号映射: '{original_ship_number}' -> "
|
||||
f"'{mapped_ship_number}',匹配类型={match_type}"
|
||||
)
|
||||
return mapped_ship_number or original_ship_number, result
|
||||
|
||||
print(f"[{context}] 舷号未映射,继续使用原值: {original_ship_number}")
|
||||
return original_ship_number, result
|
||||
except Exception as e:
|
||||
print(f"[{context}] 舷号映射异常,继续使用原值: {original_ship_number}; 错误: {str(e)}")
|
||||
return original_ship_number, {}
|
||||
|
||||
|
||||
async def resolve_ship_scope_for_workflow(
|
||||
ship_number: str,
|
||||
context: str = "workflow",
|
||||
) -> tuple[str, List[str], Dict[str, Any]]:
|
||||
"""
|
||||
Resolve user ship input into a display hull number and a hull-number scope.
|
||||
|
||||
Hull number and ship-name matches collapse to one hull. Model matches keep the
|
||||
user's original value for display but return every hull number under that model
|
||||
so shared device normalization and system lookup can search the whole scope.
|
||||
"""
|
||||
original_ship_number = str(ship_number or "").strip()
|
||||
if not original_ship_number:
|
||||
return original_ship_number, [], {}
|
||||
|
||||
try:
|
||||
from utils.ship_number_search import smart_ship_number_mapping
|
||||
|
||||
matched_ship_number, matched_model, all_numbers, match_type = await smart_ship_number_mapping(
|
||||
original_ship_number
|
||||
)
|
||||
scope_numbers = [str(num).strip() for num in (all_numbers or []) if str(num or "").strip()]
|
||||
result = {
|
||||
"matched_ship_number": matched_ship_number,
|
||||
"matched_model": matched_model,
|
||||
"all_numbers": scope_numbers,
|
||||
"match_type": match_type,
|
||||
}
|
||||
|
||||
if match_type in ["exact_number", "exact_name", "embedding"] and matched_ship_number:
|
||||
mapped_ship_number = str(matched_ship_number).strip()
|
||||
return mapped_ship_number or original_ship_number, [mapped_ship_number], result
|
||||
|
||||
if match_type == "exact_model" and scope_numbers:
|
||||
print(
|
||||
f"[{context}] ship model scope resolved: '{original_ship_number}' -> "
|
||||
f"{matched_model}, hull_numbers={scope_numbers}"
|
||||
)
|
||||
return original_ship_number, scope_numbers, result
|
||||
|
||||
print(f"[{context}] ship scope not resolved, continuing with original value: {original_ship_number}")
|
||||
return original_ship_number, [], result
|
||||
except Exception as e:
|
||||
print(f"[{context}] ship scope resolve failed, continuing with original value: {original_ship_number}; error: {str(e)}")
|
||||
return original_ship_number, [], {}
|
||||
|
||||
|
||||
async def normalize_device_name_for_workflow(
|
||||
device_name: str,
|
||||
ship_number: str = "",
|
||||
ship_numbers: Optional[List[str]] = None,
|
||||
context: str = "workflow",
|
||||
) -> tuple[str, Dict[str, Any]]:
|
||||
"""
|
||||
工作流内设备名称标准化。
|
||||
|
||||
标准化失败时返回原设备名,避免检索流程被 Neo4j 或 embedding 服务异常阻断。
|
||||
"""
|
||||
original_device_name = str(device_name or "").strip()
|
||||
if not original_device_name:
|
||||
return original_device_name, {}
|
||||
|
||||
try:
|
||||
from tools.graph_tools import normalize_device_name_by_ship_graph
|
||||
|
||||
print(
|
||||
f"[{context}] 开始设备名称标准化: device='{original_device_name}', "
|
||||
f"ship_number='{ship_number or '未提供'}'"
|
||||
)
|
||||
call_kwargs = {}
|
||||
if "ship_number" in inspect.signature(normalize_device_name_by_ship_graph).parameters:
|
||||
call_kwargs["ship_number"] = str(ship_number or "").strip() or None
|
||||
if "ship_numbers" in inspect.signature(normalize_device_name_by_ship_graph).parameters:
|
||||
scoped_numbers = [str(num).strip() for num in (ship_numbers or []) if str(num or "").strip()]
|
||||
call_kwargs["ship_numbers"] = scoped_numbers or None
|
||||
elif ship_number:
|
||||
print(f"[{context}] 当前进程加载的设备标准化函数不支持 ship_number,请重启服务加载最新 graph_tools.py")
|
||||
|
||||
result = await normalize_device_name_by_ship_graph(original_device_name, **call_kwargs)
|
||||
if result.get("success", False):
|
||||
normalized_device_name = str(result.get("device_name") or original_device_name).strip()
|
||||
print(
|
||||
f"[{context}] 设备名称标准化完成: '{original_device_name}' -> "
|
||||
f"'{normalized_device_name or original_device_name}',舷号={ship_number or '未提供'}"
|
||||
)
|
||||
emit_callback_event(
|
||||
["🔎 设备名称标准化", "🔧 设备匹配"],
|
||||
f"{original_device_name} -> {normalized_device_name or original_device_name}"
|
||||
)
|
||||
return normalized_device_name or original_device_name, result
|
||||
|
||||
print(
|
||||
f"[{context}] 设备名称标准化失败,继续使用原设备名: {original_device_name}; "
|
||||
f"原因: {result.get('error', '未知错误')}"
|
||||
)
|
||||
return original_device_name, result
|
||||
except Exception as e:
|
||||
print(f"[{context}] 设备名称标准化异常,继续使用原设备名: {original_device_name}; 错误: {str(e)}")
|
||||
return original_device_name, {}
|
||||
|
||||
|
||||
async def resolve_device_system_for_workflow(
|
||||
device_name: str,
|
||||
ship_number: str = "",
|
||||
ship_numbers: Optional[List[str]] = None,
|
||||
context: str = "workflow",
|
||||
) -> tuple[str, Dict[str, Any]]:
|
||||
"""
|
||||
工作流内根据设备名称反推所属系统。
|
||||
|
||||
失败时返回空字符串,调用方可继续执行主流程。
|
||||
"""
|
||||
device_name = str(device_name or "").strip()
|
||||
if not device_name:
|
||||
return "", {}
|
||||
|
||||
try:
|
||||
print(
|
||||
f"[{context}] 开始设备反推系统: device='{device_name}', "
|
||||
f"ship_number='{ship_number or '未提供'}'"
|
||||
)
|
||||
from tools.graph_tools import find_system_by_device
|
||||
|
||||
call_kwargs = {"device_name": device_name}
|
||||
if "ship_number" in inspect.signature(find_system_by_device).parameters:
|
||||
call_kwargs["ship_number"] = str(ship_number or "").strip() or None
|
||||
if "ship_numbers" in inspect.signature(find_system_by_device).parameters:
|
||||
scoped_numbers = [str(num).strip() for num in (ship_numbers or []) if str(num or "").strip()]
|
||||
call_kwargs["ship_numbers"] = scoped_numbers or None
|
||||
elif ship_number:
|
||||
print(f"[{context}] 当前进程加载的设备反推系统函数不支持 ship_number,请重启服务加载最新 graph_tools.py")
|
||||
|
||||
result = await find_system_by_device(**call_kwargs)
|
||||
if not result.get("success", False):
|
||||
print(f"[{context}] 设备反推系统失败: {result.get('error', '未知错误')}")
|
||||
return "", result
|
||||
|
||||
systems = result.get("systems") or []
|
||||
first_system = systems[0] if systems else {}
|
||||
system_name = ""
|
||||
if isinstance(first_system, dict):
|
||||
system_name = str(first_system.get("name") or first_system.get("系统名称") or first_system.get("名称") or "").strip()
|
||||
|
||||
if system_name:
|
||||
print(f"[{context}] 设备反推系统完成: {device_name} -> {system_name}")
|
||||
emit_callback_event(
|
||||
["🔎 设备所属系统识别", "🧭 系统反查"],
|
||||
f"{device_name} -> {system_name}"
|
||||
)
|
||||
else:
|
||||
print(f"[{context}] 设备反推系统无结果: {device_name}")
|
||||
return system_name, result
|
||||
except Exception as e:
|
||||
print(f"[{context}] 设备反推系统异常: {str(e)}")
|
||||
return "", {}
|
||||
|
||||
|
||||
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_generate_and_postprocess(
|
||||
system_prompt: str,
|
||||
messages: List[Dict[str, Any]],
|
||||
original_image_paths: List[str],
|
||||
temperature: float = 0.3,
|
||||
fallback: str = "抱歉,无法生成回答。",
|
||||
) -> str:
|
||||
"""单次流式生成最终回复,并在本地做稳定的格式与图片校验。"""
|
||||
stream_handler = get_current_stream_handler()
|
||||
answer = ""
|
||||
accumulated_content = ""
|
||||
|
||||
async for chunk in OpenaiAPI.open_api_chat_stream(
|
||||
model=None,
|
||||
system_prompt=system_prompt,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
):
|
||||
answer += chunk
|
||||
accumulated_content += chunk
|
||||
if stream_handler:
|
||||
stream_handler.send_stream_content(accumulated_content)
|
||||
|
||||
answer = answer.strip() if answer else fallback
|
||||
answer = re.sub(r'ynchroneg>.*?ost switching>', '', answer, flags=re.DOTALL)
|
||||
answer = remove_duplicate_content(answer)
|
||||
answer = normalize_markdown_images(answer, original_paths=original_image_paths)
|
||||
answer = normalize_latex_spacing(answer)
|
||||
|
||||
return answer.strip() if answer else fallback
|
||||
|
||||