commit f0d238690fd68a5b8087109a8fffa7dc913e89e7 Author: zc <2649023789@qq.com> Date: Thu Jul 2 10:29:14 2026 +0800 Initial project import diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..18b26e4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +__pycache__/ +*.py[cod] +*$py.class + +.Python +.venv/ +venv/ +env/ +ENV/ + +*.swp +*.bak* +*.before_rollback_* +*.tmp +*.log + +config.py +config.txt + +data/*.db +data/*.db-* +data/*.sqlite +data/*.sqlite-* + +created_pdf/ +created_word/ +image_output/ + +workflows/=* + +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ diff --git a/1.html b/1.html new file mode 100644 index 0000000..311b573 --- /dev/null +++ b/1.html @@ -0,0 +1,138 @@ +

1. 总体态势摘要

+

根据离心式海水泵近一年(2023年1月至2023年12月)的备件消耗统计数据显示,系统运行期间未记录到任何故障事件(total_events=0),相关备件消耗数据及故障频率数据均为空。初步判断可能为数据采集系统未启用、设备运行周期未覆盖统计时段或设备处于低负荷运行状态。建议进一步核查数据采集机制完整性,并结合设备实际运行工况开展专项分析。

+ +

2. 备件库存现状与统计图表

+ +

2.1 关键备件库存监控表

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
备件名称库存数量安全库存阈值库存状态
泵轴密封圈05告警
叶轮组件23预警
轴承组(6308型号)810正常
+ +

2.2 故障模式分布统计

+ + + + + + + + + + + + + + + +
故障类型发生次数占比(%)
无记录00
+

注:当前统计时段内未发生可记录故障事件,建议结合设备巡检日志开展隐性故障排查。

+ +

3. 历史消耗趋势分析

+ +

3.1 近几个月核心备件消耗曲线

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
统计时段泵轴密封圈消耗量叶轮组件消耗量轴承组消耗量
2023Q1000
2023Q2000
2023Q3000
2023Q4000
+

趋势说明:因无实际消耗数据,无法绘制趋势曲线。建议结合设备运行日志补充关键工况参数(如启停频次、海水含沙量等)开展关联分析。

+ +

4. 任务周期需求预测

+ +

4.1 预测结果清单

+ + + + + + + + + + + + + + + + + + + + +
预测维度预测周期预测结论
备件需求量2024年全年基于当前零消耗数据,预测需求量为0(需结合设备启用计划修正)
故障概率2024年Q1-Q4因无历史故障数据,无法进行概率建模,建议开展设备健康度评估
+ +

5. 保障建议与决策

+ +

5.1 采购与调拨建议

+ + +

5.2 维修策略建议

+
    +
  1. 数据采集优化:部署设备运行状态监测系统,实时采集转速、振动、海水腐蚀性等参数。
  2. +
  3. 预防性维护强化:针对密封件等易损件,制定基于时间的维护(TBM)策略,每季度开展预防性检查。
  4. +
  5. 故障树分析(FTA):针对潜在故障模式(如海水腐蚀导致的密封失效),开展专项FTA分析。
  6. +
+

结论:当前数据缺失可能导致保障策略失准,建议尽快启动设备运行数据采集与历史故障档案重建工作。

\ No newline at end of file diff --git a/Prompt.py b/Prompt.py new file mode 100644 index 0000000..66ca160 --- /dev/null +++ b/Prompt.py @@ -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) diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/agent_create_request.py b/agent_create_request.py new file mode 100644 index 0000000..bc657d4 --- /dev/null +++ b/agent_create_request.py @@ -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() diff --git a/agent_ids.txt b/agent_ids.txt new file mode 100644 index 0000000..c2e48f8 --- /dev/null +++ b/agent_ids.txt @@ -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 diff --git a/agent_role_request.py b/agent_role_request.py new file mode 100644 index 0000000..a70cc36 --- /dev/null +++ b/agent_role_request.py @@ -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() diff --git a/app.py b/app.py new file mode 100644 index 0000000..102fe31 --- /dev/null +++ b/app.py @@ -0,0 +1,1127 @@ +"""app.py +FastAPI 主 Agent 接口 - 修复重复执行问题 +提供主脑 Agent 的 HTTP API 服务,支持流式输出 +集成 LangGraph Checkpointer 实现状态持久化 +""" +import logging +from time import sleep + +from dotenv import load_dotenv +from fastapi import FastAPI, HTTPException +from fastapi.responses import StreamingResponse, FileResponse +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel +from typing import Optional, Dict, Any, AsyncGenerator,List +import uvicorn +import json +import os +import uuid +from main_agent import create_main_agent +from utils.function_tracker import ( + register_event_callback, + create_stream_event_handler, + unregister_event_callback, + clear_current_stream_handler +) +import asyncio +import importlib + +from workflow_registry import WORKFLOW_CONFIG, VALID_ROUTE_FLAGS +from main_agent import extract_conversation_title +from workflows.history_manager import filter_image_urls, preprocess_from_request +from config import set_request_user_config +from checkpointer_config import ( + CheckpointerManager, + checkpointer_manager +) + +# 创建 FastAPI 应用 +app = FastAPI(max_request_size=1024 * 1024 * 10) + + +load_dotenv() + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# app.mount("/picture", StaticFiles(directory="/app/picture"), name="pictures") + + +@app.on_event("startup") +async def startup_event(): + """应用启动时初始化""" + await checkpointer_manager.setup() + # 初始化智能体使用统计表 + from tools.agent_usage_statistics import init_agent_usage_table + await init_agent_usage_table() + logger.info("应用启动完成,PostgreSQL Checkpointer 已配置") + + +# =============== PDF 文件目录配置 =============== +# 创建 PDF 文件目录(用于存储生成的 PDF 等文件) +PDF_DIR = os.path.join(os.path.dirname(__file__), "created_pdf") +# 创建 word 文件目录(用于存储生成的 word 等文件) +WORD_DIR = os.path.join(os.path.dirname(__file__), "created_word") +os.makedirs(PDF_DIR, exist_ok=True) + + + +# =============== 请求模型 =============== + + +class AgentRequest(BaseModel): + """Agent 请求模型""" + query: Optional[Any] = None # 用户文本输入 + file_text: Optional[str] = None # 文件文本输入 + image: Optional[str] = None # 图片路径 + audio: Optional[str] = None # 音频路径(支持 audio 或 asr) + + # 路由标志(可选,如果提供则跳过任务分类) + route_flag: Optional[str] = None # 路由标志 + history_message: Optional[Any] = None # 历史信息(支持字符串或列表格式) + + # 接收参数 + top_k: Optional[Any] = None # rag 前top_k + rag_prompt: Optional[Any] = None # rag 提示词模板(支持字符串、列表、字典等多种类型) + image_kb_id: Optional[str] = None # 图库 知识库id + image_file_id: Optional[str] = None # 图库 文件id + text_kb_id: Optional[str] = None # 检索 知识库id + text_file_id: Optional[str] = None # 检索 文件id + + # Socket.IO 格式所需字段 + chat_id: Optional[str] = None # 聊天ID + message_id: Optional[str] = None # 消息ID + + # 知识库配置 + x_user_id: Optional[str] = "1" # 用户ID + x_user_name: Optional[str] = "testuser" # 用户名称 + x_role: Optional[str] = "admin" # 角色 + + +class AgentResponse(BaseModel): + """Agent 响应模型""" + success: bool + message: str + data: Optional[Dict[str, Any]] = None + error: Optional[str] = None + +class FeedbackClassifyRequest(BaseModel): + """反馈分类请求模型""" + feedback_text: str + + + +# =============== 辅助函数 =============== +def process_rag_prompt(rag_prompt: Any) -> str: + """ + 处理 rag_prompt 参数,将各种类型转换为字符串 + + Args: + rag_prompt: 可以是字符串、列表、字典等任意类型 + + Returns: + 处理后的字符串 + """ + if rag_prompt is None: + return "" + + # 如果是字符串,直接返回 + if isinstance(rag_prompt, str): + return rag_prompt.strip() + + # 如果是列表,转换为多行字符串 + if isinstance(rag_prompt, list): + # 将列表中的每个元素转换为字符串,并用换行符连接 + return "\n".join(str(item) for item in rag_prompt if item) + + # 如果是字典,转换为 JSON 字符串 + if isinstance(rag_prompt, dict): + try: + return json.dumps(rag_prompt, ensure_ascii=False, indent=2) + except Exception as e: + logger.warning(f"rag_prompt 字典转换失败: {e}") + return str(rag_prompt) + + # 其他类型,直接转换为字符串 + return str(rag_prompt).strip() + + +def format_stream_event(event_type: str, + data: Dict[str, Any], + chat_id: str = "", + message_id: str = "", + type: str = "chat:completion", + ) -> str: + """格式化流式事件输出为 Socket.IO 格式""" + # 生成 message_id(如果没有提供) + if not message_id: + message_id = str(uuid.uuid4()) + + # 根据事件类型构建不同的数据结构 + if event_type == "stream_content": + # stream_content 事件使用 chat:completion 格式 + content = data.get("content", "") + event_data = { + "chat_id": chat_id, + "message_id": message_id, + "data": { + "type": "chat:completion", + "data": { + "content": content + } + } + } + elif event_type == "function_end": + # function_end 事件 + event_data = { + "chat_id": chat_id, + "message_id": message_id, + "data": { + "type": "chat:completion", + "data": { + "stepsBlueprint": data + } + } + } + elif event_type == "execution_complete": + # execution_complete 事件 + + rag_result = data.get("rag_result", {}) + graph_result = data.get("graph_result", []) + final_result = data.get("final_result", {}) + title = data.get("title", "") + + # 判断是否有有效的 RAG 结果(非空 dict) + has_rag = isinstance(rag_result, dict) and bool(rag_result) + + # 判断是否有有效的图谱结果(非空 list,且至少一个元素的 nodes 非空) + has_graph = ( + isinstance(graph_result, list) + and len(graph_result) > 0 + and any(isinstance(item, dict) and item.get("nodes") for item in graph_result) + ) + + + # 只有当 RAG 或图谱有有效内容时,才构建 sourceCitation + source_citation = None + if has_rag or has_graph: + source_citation = {} + if has_rag: + source_citation.update(rag_result) + if has_graph: + source_citation["xxxx.graph"] = graph_result + + # 构建 event_data(title 由调用方异步计算后传入) + if final_result: + content = final_result.get("content", "") + event_data = { + "chat_id": chat_id, + "message_id": message_id, + "data": { + "type": "chat:completion", + "data": { + "content": content, + }, + "actions": final_result.get("actions", ""), + "suggestedReplies": final_result.get("suggestedReplies", "") + } + } + else: + event_data = { + "chat_id": chat_id, + "message_id": message_id, + "data": { + "type": "chat:completion", + "data": { + "data": {}, + "done": True, + "title": title or "新对话", + } + } + } + + # 仅在有引用来源时添加 sourceCitation 字段 + if source_citation is not None: + event_data["data"]["data"]["sourceCitation"] = source_citation + + else: + # 其他事件 + event_data = { + "chat_id": chat_id, + "message_id": message_id, + "type": event_type, + "data": { + "type": event_type, + "data": "" + } + } + + # Socket.IO 格式:["chat-events", {...}] + socket_io_message = ["chat-events", event_data] + return f"{json.dumps(socket_io_message, ensure_ascii=False)}\n" + + + + +async def execute_workflow_chain( + route_flag: str, + route_params: Dict[str, Any], + chat_id: str = "", + message_id: str = "", + checkpointer=None +) -> Dict[str, Any]: + """ + 执行工作流 + + Args: + route_flag: 路由标志 + route_params: 路由参数 + chat_id: 聊天会话 ID + message_id: 消息 ID + checkpointer: LangGraph checkpointer 实例 + + Returns: + 工作流执行结果 + """ + + print("子工作流标志:", route_flag) + print("子工作流参数:", route_params) + + if not route_flag: + return {} + + extracted_text = route_params.get("extracted_text", "") + combined_query = route_params.get("combined_query", "") + history_message = route_params.get("history_message", "") + + workflow_info = WORKFLOW_CONFIG.get(route_flag) + + if not workflow_info: + logger.warning(f"未知的路由标志: {route_flag},使用默认的qa工作流") + workflow_info = WORKFLOW_CONFIG.get("qa", WORKFLOW_CONFIG.get(list(WORKFLOW_CONFIG.keys())[0])) + + try: + module = importlib.import_module(workflow_info["module"]) + workflow_func = getattr(module, workflow_info["function"]) + except (ImportError, AttributeError) as e: + print(e) + logger.error(f"无法导入工作流 {route_flag}: {e}") + return {} + + print("zigongggggggggggggggggggggggg") + + try: + import inspect + sig = inspect.signature(workflow_func) + params = list(sig.parameters.keys()) + + kwargs = { + "extracted_text": extracted_text, + "combined_query": combined_query, + "history_message": history_message, + "route_flag": route_flag, + } + + if "chat_id" in params: + kwargs["chat_id"] = chat_id + if "message_id" in params: + kwargs["message_id"] = message_id + if "checkpointer" in params: + kwargs["checkpointer"] = checkpointer + + result = await workflow_func(**kwargs) + except Exception as e: + print(e) + logger.error(f"工作流执行失败: {e}") + return {} + + return result or {} + + +async def stream_main_agent_execution( + raw_input: Dict[str, Any], + route_flag: str = "", + rag_prompt: str = "", + chat_id: str = "", + message_id: str = "" +) -> AsyncGenerator[str, None]: + """ + 流式执行主Agent并输出函数调用信息(使用装饰器事件) + + Args: + raw_input: 原始输入数据 + route_flag: 路由标志 + rag_prompt: RAG 提示词 + chat_id: 聊天会话 ID + message_id: 消息 ID + + Yields: + 格式化的流式事件字符串 + """ + if not chat_id: + chat_id = str(uuid.uuid4()) + if not message_id: + message_id = str(uuid.uuid4()) + + stream_handler, event_queue = create_stream_event_handler() + register_event_callback(stream_handler) + + try: + execution_done = asyncio.Event() + execution_error = None + final_result = None + checkpointer = None + + async def run_agent(): + nonlocal execution_error, final_result, checkpointer + route_flag_value: str = "" + try: + from checkpointer_config import checkpointer_manager + checkpointer = await checkpointer_manager.get_async_checkpointer() + + main_agent = create_main_agent() + + initial_route_flag: str = route_flag if (route_flag and route_flag in VALID_ROUTE_FLAGS) else "" + + initial_state = { + "raw_input": raw_input, + "input_type": "", + "has_query":False, + "has_image": False, + "has_audio": False, + "has_file_text": False, + "history_message":raw_input.get("history_message", ""), + "extracted_text": "", + "image_description": "", + "asr_text": "", + "file_text": raw_input.get("file_text", ""), + "combined_query": "", + "task_type": "", + "task_classification_result": None, + "workflow_result": None, + "final_response": "", + "error_message": "", + "route_flag": initial_route_flag, + "route_params": {} + } + + main_result = await main_agent.ainvoke(initial_state) + + route_flag_value = (main_result.get("route_flag")or main_result.get("task_type")or "") + + route_params = main_result.get("route_params") or {"combined_query": main_result.get("combined_query", "") + or main_result.get("extracted_text", ""),"extracted_text": main_result.get("extracted_text", "")} + + if "file_text" in raw_input: + route_params["file_text"] = raw_input["file_text"] + + if "history_message" not in route_params and "history_message" in raw_input: + route_params["history_message"] = filter_image_urls(raw_input["history_message"]) + + print("历史记录",route_params["history_message"]) + print("用户问题", route_params["combined_query"]) + print("ttttttt", route_params["extracted_text"]) + + sub_result = await execute_workflow_chain( + route_flag=route_flag_value, + route_params=route_params, + chat_id=chat_id, + message_id=message_id, + checkpointer=checkpointer + ) + + processed_content = "" + if sub_result.get("response"): + processed_content = sub_result.get("response", "") + + else: + logger.warning("子agent没有返回response") + processed_content = "" + + final_result = { + "content": processed_content, + "actions": sub_result.get("actions", []), + "result_tag": sub_result.get("result_tag", ""), + "suggestedReplies": sub_result.get("suggestedReplies", []) + } + except Exception as e: + execution_error = e + finally: + execution_done.set() + await event_queue.put({"type": "execution_complete"}) + + agent_task = asyncio.create_task(run_agent()) + + execution_complete_received = False + rag_result = [] + graph_result = [] + while True: + try: + event = await asyncio.wait_for(event_queue.get(), timeout=0.1) + + if event.get("type") == "execution_complete": + execution_complete_received = True + continue + + event_type = event.get("type") + title = event.get("title", "") + details = event.get("details", "") + result = event.get("result") + + if event_type == "function_execution": + logger.info(f"function_execution event - title: {title}, has_result: {result is not None}, result_type: {type(result)}") + if title == "知识库搜索工具" and result and isinstance(result, dict): + print("===============================", result) + rag_result_raw = result.get("sourceCitation", {}) + rag_result = {} + for doc_name, chunks in rag_result_raw.items(): + if isinstance(chunks, list): + rag_result[doc_name] = [ + {k: v for k, v in item.items() if k != "text"} + if isinstance(item, dict) else item + for item in chunks + ] + else: + rag_result[doc_name] = chunks + elif title == "图谱检索工具" and result and isinstance(result, dict): + print("===============================", result) + graph_result = result.get("xxxx.graph", []) + else: + yield format_stream_event("function_end", { + "title": title, + "details": details + }, chat_id=chat_id, message_id=message_id) + + elif event_type == "function_error": + yield format_stream_event("function_error", { + "title": title, + "error": details, + "details": details + }, chat_id=chat_id, message_id=message_id) + + elif event_type == "stream_content": + content = event.get("content", "") + + yield format_stream_event("stream_content", { + "content": content + }, chat_id=chat_id, message_id=message_id) + + + except asyncio.TimeoutError: + if execution_complete_received: + break + if execution_done.is_set() and event_queue.empty(): + break + continue + + await agent_task + + if execution_error: + yield format_stream_event("error", { + "error": str(execution_error) + }, chat_id=chat_id, message_id=message_id) + else: + title = await extract_conversation_title(final_result.get("content", "") if final_result else "") + yield format_stream_event("execution_complete", { + "final_result": final_result + }, chat_id=chat_id, message_id=message_id) + + yield format_stream_event("execution_complete", { + "rag_result": rag_result, + "graph_result": graph_result, + "title": title + }, chat_id=chat_id, message_id=message_id) + + except Exception as e: + yield format_stream_event("error", { + "error": str(e) + }, chat_id=chat_id, message_id=message_id) + finally: + unregister_event_callback(stream_handler) + clear_current_stream_handler() + +# =============== API 接口 =============== +@app.post("/api/agent/stream_process") +async def stream_process_request(request: AgentRequest): + """ + 流式处理 Agent 请求,实时输出节点执行信息 + 不保存历史,调用端会自己保存历史对话信息 + 每次请求时创建新的主Agent实例 + """ + + print(f"🔍 [调试] 接收到的原始请求: {request.model_dump_json(indent=2)}") + + print(f"[DEBUG] 收到请求 chat_id={request.chat_id!r}, message_id={request.message_id!r}, route_flag={request.route_flag!r}, query={str(request.query)[:100]}") + # 1. 构建当前请求的输入数据 + raw_input = {} + if request.query: + if isinstance(request.query, str): + raw_input["query"] = request.query + elif isinstance(request.query, list): + for item in request.query: + if item.get("type") == "text": + raw_input["query"] = item.get("text") + print(f"当前查询: {raw_input['query']}") + if request.image: + raw_input["image"] = request.image + if request.audio: + # audio 字段支持 audio 或 asr,统一使用 audio 键 + raw_input["audio"] = request.audio + # [新增] 提取 file_text + if request.file_text: + raw_input["file_text"] = request.file_text[:5000] + + # 历史消息预处理:解析、移除当前用户消息、序列化 + if request.history_message: + raw_input["history_message"] = preprocess_from_request(request.history_message, exclude_last=True) + + # 2. 验证至少有一个输入 + if not raw_input: + raise HTTPException( + status_code=400, + detail="至少需要提供一个输入:query、image 或 audio" + ) + + # 3. 获取路由标志(如果提供,将跳过任务分类步骤) + # 始终传递 route_flag,默认值为空字符串 + route_flag = request.route_flag or "" + print("打印agent对应的route_flag:") + print(route_flag) + rag_prompt = process_rag_prompt(request.rag_prompt) if request.rag_prompt else "" + chat_id = request.chat_id or "" + message_id = request.message_id or "" + + # 记录智能体使用情况 + if route_flag and chat_id: + from tools.agent_usage_statistics import record_agent_usage + asyncio.create_task(record_agent_usage( + chat_id=chat_id, + route_flag=route_flag, + message_id=message_id + )) + logger.info(f"记录智能体使用: chat_id={chat_id}, route_flag={route_flag}") + + + + # 4. 设置当前请求的用户配置和知识库参数(线程安全,支持多用户并发) + # 如果请求中提供了这些参数,则使用请求中的值;否则使用默认值 + set_request_user_config( + x_user_id=request.x_user_id, + x_user_name=request.x_user_name, + x_role=request.x_role, + text_kb_id=request.text_kb_id, + text_file_id=request.text_file_id + ) + + #5. 执行主Agent + async def event_generator(): + async for event in stream_main_agent_execution( + raw_input=raw_input, + route_flag=route_flag, + rag_prompt=rag_prompt, + chat_id=chat_id, + message_id=message_id + ): + yield event + + return StreamingResponse(event_generator(), media_type="text/event-stream") + + +@app.get("/api/download/{file_name}") +async def download_file(file_name: str): + """ + 文件下载接口 + + Args: + file_name: 文件名 + + Returns: + 文件下载响应 + """ + pdf_path = os.path.join(PDF_DIR, file_name) + word_path = os.path.join(WORD_DIR, file_name) + file_path = pdf_path if os.path.exists(pdf_path) else word_path + print(f"下载文件:{file_path}") + # 检查文件是否存在 + ext = os.path.splitext(file_name)[1].lower() + media_type = { + ".pdf": "application/pdf", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".doc": "application/msword", + }.get(ext, "application/octet-stream") + + # 返回文件 + return FileResponse( + path=file_path, + filename=file_name, + media_type=media_type + ) + + +@app.get("/api/health") +async def health_check(): + """健康检查接口""" + return { + "status": "healthy", + "message": "服务正常运行,每次请求时创建新的Agent实例" + } + + +@app.get("/api/v1/statistics/fault-type-statistics") +async def fault_type_statistics(ship_number: Optional[str] = None): + """ + 故障类型统计接口 + 统计生成报告里面的故障类型,按舷号分类并计算百分比 + + Args: + ship_number: 舷号过滤(可选,如果提供则只统计该舷号的报告) + + Returns: + 统计结果,包含 faultTypeData 字段 + """ + print("故障类型统计街路口") + from tools.fault_statistics import statistics_fault_types + """ + result = await statistics_fault_types( + ship_number_filter=ship_number + ) + + if not result.get("success", False): + raise HTTPException( + status_code=500, + detail=result.get("error", "统计失败") + ) + """ + result = { + "success": True, + "faultTypeData": [ + { "value": 35, "name": "作战系统", "itemStyle": { "color": "#00C2FF" } }, + { "value": 25, "name": "电力系统", "itemStyle": { "color": "#FF4500" } }, + { "value": 20, "name": "推进系统", "itemStyle": { "color": "#9400D3" } }, + { "value": 10, "name": "航空保障系统", "itemStyle": { "color": "#228B22" } }, + { "value": 5, "name": "船舶保障系统", "itemStyle": { "color": "#FF1493" } }, + { "value": 5, "name": "其他", "itemStyle": { "color": "#2C2C2C" } } + ] + } + + + return result + + +@app.get("/api/v1/statistics/fault-frequency-statistics") +async def fault_frequency_statistics( + top_n: Optional[int] = 6, + time_range: Optional[str] = "全部" +): + """ + 故障发生次数统计接口 + 统计生成报告里面的故障发生次数,按频率排序 + + Args: + top_n: 返回前 N 个高频故障(默认为 10) + time_range: 时间范围(可选,默认为"全部") + - "全部": 统计所有报告 + - "近七天": 统计最近7天的报告 + - "近一个月": 统计最近30天的报告 + - "近一年": 统计最近365天的报告 + + Returns: + 统计结果,包含 highFreqFaults 字段 + """ + print("故障发生次数接口") + from datetime import datetime, timedelta + + start_date = None + end_date = None + + result = { + "success": True, + "highFreqFaults": [ + { "rank": 1, "name": "燃气轮机燃油增压泵组泵体旋塞处泄漏", "system": "推进系统", "count": 135 }, + { "rank": 2, "name": "气动蝶阀控制箱无法上电工作", "system": "推进系统", "count": 103 }, + { "rank": 3, "name": "组合报警灯柱不能正常工作", "system": "船舶保障系统", "count": 86 }, + { "rank": 4, "name": "岸电联接箱指示灯不亮", "system": "电力系统", "count": 77 }, + { "rank": 5, "name": "主机冷却水泵密封泄漏", "system": "航空保障系统", "count": 76 }, + { "rank": 6, "name": "加电后屏幕和故障指示灯没显示", "system": "作战系统", "count": 65 } + ] + } + + if time_range == "近七天": + end_date = datetime.now().strftime("%Y-%m-%d") + start_date = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d") + result = { + "success": True, + "highFreqFaults": [ + { "rank": 1, "name": "气动蝶阀控制箱无法上电工作", "system": "推进系统", "count": 10 }, + { "rank": 2, "name": "组合报警灯柱不能正常工作", "system": "船舶保障系统", "count": 6 }, + { "rank": 3, "name": "岸电联接箱指示灯不亮", "system": "电力系统", "count": 3 }, + { "rank": 4, "name": "主机冷却水泵密封泄漏", "system": "航空保障系统", "count": 3 }, + { "rank": 5, "name": "加电后屏幕和故障指示灯没显示", "system": "作战系统", "count": 1 } + ] + } + + elif time_range == "近一个月": + end_date = datetime.now().strftime("%Y-%m-%d") + start_date = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d") + + result = { + "success": True, + "highFreqFaults": [ + { "rank": 1, "name": "燃气轮机燃油增压泵组泵体旋塞处泄漏", "system": "推进系统", "count": 38 }, + { "rank": 2, "name": "气动蝶阀控制箱无法上电工作", "system": "推进系统", "count": 20 }, + { "rank": 3, "name": "组合报警灯柱不能正常工作", "system": "船舶保障系统", "count": 14 }, + { "rank": 4, "name": "岸电联接箱指示灯不亮", "system": "电力系统", "count": 13 }, + { "rank": 5, "name": "主机冷却水泵密封泄漏", "system": "航空保障系统", "count": 10 }, + { "rank": 6, "name": "加电后屏幕和故障指示灯没显示", "system": "作战系统", "count": 8 } + ] + } + + elif time_range == "近一年": + end_date = datetime.now().strftime("%Y-%m-%d") + start_date = (datetime.now() - timedelta(days=365)).strftime("%Y-%m-%d") + result = { + "success": True, + "highFreqFaults": [ + { "rank": 1, "name": "燃气轮机燃油增压泵组泵体旋塞处泄漏", "system": "推进系统", "count": 68 }, + { "rank": 2, "name": "气动蝶阀控制箱无法上电工作", "system": "推进系统", "count": 74 }, + { "rank": 3, "name": "组合报警灯柱不能正常工作", "system": "船舶保障系统", "count": 73 }, + { "rank": 4, "name": "岸电联接箱指示灯不亮", "system": "电力系统", "count": 63 }, + { "rank": 5, "name": "主机冷却水泵密封泄漏", "system": "航空保障系统", "count": 56 }, + { "rank": 6, "name": "加电后屏幕和故障指示灯没显示", "system": "作战系统", "count": 46 } + ] + } + + + """ + if time_range == "近七天": + end_date = datetime.now().strftime("%Y-%m-%d") + start_date = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d") + elif time_range == "近一个月": + end_date = datetime.now().strftime("%Y-%m-%d") + start_date = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d") + elif time_range == "近一年": + end_date = datetime.now().strftime("%Y-%m-%d") + start_date = (datetime.now() - timedelta(days=365)).strftime("%Y-%m-%d") + + from tools.fault_statistics import statistics_fault_frequency + + result = await statistics_fault_frequency( + top_n=top_n, + start_date=start_date, + end_date=end_date + ) + + if not result.get("success", False): + raise HTTPException( + status_code=500, + detail=result.get("error", "统计失败") + ) + """ + return result + + +@app.get("/api/v1/statistics/spare-parts-statistics") +async def spare_parts_statistics( + ship_number: Optional[str] = None, + time_range: Optional[str] = "全部", + top_n: Optional[int] = 6 +): + """ + 备件消耗统计接口 + 统计生成报告里面的备件消耗情况 + + Args: + ship_number: 舷号过滤(可选) + time_range: 时间范围(可选,默认为"全部") + - "全部": 统计所有报告 + - "近七天": 统计最近7天的报告 + - "近一个月": 统计最近30天的报告 + - "近一年": 统计最近365天的报告 + top_n: 返回前 N 个备件(默认为 5) + + Returns: + 统计结果,包含 sparePartData 字段 + """ + print("备件消耗统计接口") + from datetime import datetime, timedelta + + start_date = None + end_date = None + + result = { + "success": True, + "sparePartData": { + "categories": ["螺旋式熔断体", "断路器", "机械密封", "门铰链固定端", "减振器"], + "system": ["电力系统", "船舶保障系统", "推进系统", "作战系统", "推进系统"], + "counter": [86, 76, 43, 42, 33] + } + } + + if time_range == "近七天": + end_date = datetime.now().strftime("%Y-%m-%d") + start_date = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d") + result = { + "success": True, + "sparePartData": { + "categories": ["螺旋式熔断体", "断路器", "机械密封", "门铰链固定端"], + "system": ["电力系统", "船舶保障系统", "推进系统", "作战系统"], + "counter": [13, 9, 8, 5] + } + } + elif time_range == "近一个月": + end_date = datetime.now().strftime("%Y-%m-%d") + start_date = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d") + + result = { + "success": True, + "sparePartData": { + "categories": ["螺旋式熔断体", "断路器", "机械密封", "门铰链固定端", "减振器"], + "system": ["电力系统", "船舶保障系统", "推进系统", "作战系统", "推进系统"], + "counter": [32, 26, 20, 14, 10] + } + } + elif time_range == "近一年": + end_date = datetime.now().strftime("%Y-%m-%d") + start_date = (datetime.now() - timedelta(days=365)).strftime("%Y-%m-%d") + + result = { + "success": True, + "sparePartData": { + "categories": ["螺旋式熔断体", "断路器", "机械密封", "门铰链固定端", "减振器"], + "system": ["电力系统", "船舶保障系统", "推进系统", "作战系统", "推进系统"], + "counter": [64, 63, 23, 20, 19] + } + } + + + + + """ + if time_range == "近七天": + end_date = datetime.now().strftime("%Y-%m-%d") + start_date = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d") + elif time_range == "近一个月": + end_date = datetime.now().strftime("%Y-%m-%d") + start_date = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d") + elif time_range == "近一年": + end_date = datetime.now().strftime("%Y-%m-%d") + start_date = (datetime.now() - timedelta(days=365)).strftime("%Y-%m-%d") + + from tools.fault_statistics import statistics_spare_parts + + result = await statistics_spare_parts( + ship_number_filter=ship_number, + start_date=start_date, + end_date=end_date, + top_n=top_n + ) + + if not result.get("success", False): + raise HTTPException( + status_code=500, + detail=result.get("error", "统计失败") + ) + """ + return result + + +@app.get("/api/v1/statistics/ship-numbers") +async def get_all_ship_numbers(): + """ + 获取所有舷号接口 + 从知识库树形结构中提取所有可用的舷号列表 + + Returns: + { + "success": true, + "data": { + "ship_numbers": ["101", "111", "122", ...], + "total": 10 + } + } + """ + import httpx + import re + from config import KB_TREE_CONFIG + + KB_TREE_URL = KB_TREE_CONFIG['url'] + KB_TREE_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: + params = {"kb_id": 2} + response = await client.get(KB_TREE_URL, headers=KB_TREE_HEADERS, params=params) + + if response.status_code != 200: + raise HTTPException( + status_code=response.status_code, + detail=f"知识库API请求失败: {response.status_code}" + ) + + result = response.json() + + if isinstance(result, dict): + if "code" in result and result.get("code") != 200: + raise HTTPException( + status_code=500, + detail=f"知识库API返回错误: {result}" + ) + kb_data = result.get("data") or result.get("list") or {} + elif isinstance(result, list): + kb_data = result[0] if result else {} + else: + kb_data = {} + + children = kb_data.get("children", []) + + if not children: + return { + "success": True, + "data": { + "ship_numbers": [], + "total": 0 + } + } + + ship_numbers_set = set() + + for kb in children: + kb_name = kb.get("name") or "" + kb_numbers = re.findall(r"\d+", kb_name) + for num in kb_numbers: + ship_numbers_set.add(num) + + ship_numbers = sorted(list(ship_numbers_set), key=lambda x: int(x)) + + return { + "success": True, + "data": { + "ship_numbers": ship_numbers, + "total": len(ship_numbers) + } + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"获取舷号列表失败: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"获取舷号列表失败: {str(e)}" + ) + +@app.get("/api/v1/statistics/agent-usage-statistics") +async def agent_usage_statistics(): + """ + 智能体使用统计接口 + 统计智能体的调用次数,按类型分类 + + Returns: + { + "success": true, + "data": { + "total": "8,794", + "types": [ + { "name": "操作使用", "count": "2,966", "icon": "repair", "color": "#00C2FF" }, + { "name": "故障排查与修理", "count": "3,620", "icon": "detection", "color": "#FFB800" }, + { "name": "舰船百科", "count": "2,208", "icon": "doc", "color": "#9153FF" } + ], + "ratio": [ + { "name": "操作使用", "value": 34, "itemStyle": { "color": "#00C2FF" } }, + { "name": "故障排查与修理", "value": 41, "itemStyle": { "color": "#FFB800" } }, + { "name": "舰船百科", "value": 25, "itemStyle": { "color": "#9153FF" } } + ] + } + } + """ + print("智能体调用统计接口") + from tools.agent_usage_statistics import get_agent_usage_statistics + + result = await get_agent_usage_statistics() + + if not result.get("success", False): + raise HTTPException( + status_code=500, + detail="统计失败" + ) + + return result + + +@app.post("/api/v1/feedback/classify") +async def feedback_classify(request: FeedbackClassifyRequest): + """ + 反馈内容分类接口 + + 功能: + 1. 从反馈文本中提取设备名称 + 2. 根据设备名称反推系统名称 + 3. 对反馈内容进行分类 + + Args: + request: 包含 feedback_text 字段的请求体 + + Returns: + { + "success": true, + "system": "系统名称", + "category": "分类结果" + } + + 分类类别: + - 信息过时:反映文档或手册中的信息已经过时,不再适用 + - 步骤错误:反映操作步骤或流程存在错误 + - 步骤缺失:反映缺少必要的操作步骤或流程说明 + - 备件信息有误:反映备件型号、规格、数量等信息有误 + - 安全提示不足:反映缺少必要的安全警示或注意事项 + - 图示不清:反映图片、示意图不清晰或难以理解 + - 其他:不属于以上类别 + """ + from tools.fault_statistics import analyze_feedback_and_classify + + result = await analyze_feedback_and_classify(request.feedback_text) + + if not result.get("success", False): + raise HTTPException( + status_code=500, + detail=result.get("error", "分类失败") + ) + + return result + + + +@app.get("/") +async def root(): + """根路径""" + return { + "message": "Agent API 服务", + "docs": "/docs" + } + + +# =============== 主函数 =============== +if __name__ == "__main__": + uvicorn.run( + "app:app", + host="0.0.0.0", + port=9088 + ) + + + + diff --git a/audio/test.wav b/audio/test.wav new file mode 100644 index 0000000..02b336c Binary files /dev/null and b/audio/test.wav differ diff --git a/checkpointer_config.py b/checkpointer_config.py new file mode 100644 index 0000000..c5655da --- /dev/null +++ b/checkpointer_config.py @@ -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 '未配置'}") + diff --git a/config b/config new file mode 100644 index 0000000..e69de29 diff --git a/main_agent.py b/main_agent.py new file mode 100644 index 0000000..71ebfa2 --- /dev/null +++ b/main_agent.py @@ -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()) + diff --git a/modelsAPI/__init__.py b/modelsAPI/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modelsAPI/model_api b/modelsAPI/model_api new file mode 100644 index 0000000..e69de29 diff --git a/modelsAPI/model_api.py b/modelsAPI/model_api.py new file mode 100644 index 0000000..e55e56b --- /dev/null +++ b/modelsAPI/model_api.py @@ -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. 绝对禁止输出 , , 等任何思考过程标签。\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) + + + diff --git a/picture/015ebb33d2a3_20260324072409.png b/picture/015ebb33d2a3_20260324072409.png new file mode 100644 index 0000000..ca299af Binary files /dev/null and b/picture/015ebb33d2a3_20260324072409.png differ diff --git a/picture/08f8efd6f1d4_20260324110619.png b/picture/08f8efd6f1d4_20260324110619.png new file mode 100644 index 0000000..ca299af Binary files /dev/null and b/picture/08f8efd6f1d4_20260324110619.png differ diff --git a/picture/1ee23229170a_20260324082344.png b/picture/1ee23229170a_20260324082344.png new file mode 100644 index 0000000..ca299af Binary files /dev/null and b/picture/1ee23229170a_20260324082344.png differ diff --git a/picture/22f06245ed56_20260601022623.png b/picture/22f06245ed56_20260601022623.png new file mode 100644 index 0000000..decc700 Binary files /dev/null and b/picture/22f06245ed56_20260601022623.png differ diff --git a/picture/334ee511347b_20260323061707.png b/picture/334ee511347b_20260323061707.png new file mode 100644 index 0000000..d09ab36 Binary files /dev/null and b/picture/334ee511347b_20260323061707.png differ diff --git a/picture/4341.png b/picture/4341.png new file mode 100644 index 0000000..3c6853d Binary files /dev/null and b/picture/4341.png differ diff --git a/picture/44b07d453030_20260323024633.png b/picture/44b07d453030_20260323024633.png new file mode 100644 index 0000000..d09ab36 Binary files /dev/null and b/picture/44b07d453030_20260323024633.png differ diff --git a/picture/50da933dcb11_20260323062013.jpg b/picture/50da933dcb11_20260323062013.jpg new file mode 100644 index 0000000..60d1037 Binary files /dev/null and b/picture/50da933dcb11_20260323062013.jpg differ diff --git a/picture/58a85f24f7cf_20260323024053.png b/picture/58a85f24f7cf_20260323024053.png new file mode 100644 index 0000000..c35d69f Binary files /dev/null and b/picture/58a85f24f7cf_20260323024053.png differ diff --git a/picture/79493c5be2ac_20260324081251.png b/picture/79493c5be2ac_20260324081251.png new file mode 100644 index 0000000..ca299af Binary files /dev/null and b/picture/79493c5be2ac_20260324081251.png differ diff --git a/picture/95f8928fb88c_20260324083528.png b/picture/95f8928fb88c_20260324083528.png new file mode 100644 index 0000000..ca299af Binary files /dev/null and b/picture/95f8928fb88c_20260324083528.png differ diff --git a/picture/OIP-C.png b/picture/OIP-C.png new file mode 100644 index 0000000..ca299af Binary files /dev/null and b/picture/OIP-C.png differ diff --git a/picture/bbeb197ac231_20260323060515.png b/picture/bbeb197ac231_20260323060515.png new file mode 100644 index 0000000..d09ab36 Binary files /dev/null and b/picture/bbeb197ac231_20260323060515.png differ diff --git a/picture/d345fbf33f95_20260325031818.png b/picture/d345fbf33f95_20260325031818.png new file mode 100644 index 0000000..ca299af Binary files /dev/null and b/picture/d345fbf33f95_20260325031818.png differ diff --git a/picture/dcd6a33ef2b0_20260324071427.png b/picture/dcd6a33ef2b0_20260324071427.png new file mode 100644 index 0000000..ca299af Binary files /dev/null and b/picture/dcd6a33ef2b0_20260324071427.png differ diff --git a/picture/e0bb42e29c58_20260323062055.jpg b/picture/e0bb42e29c58_20260323062055.jpg new file mode 100644 index 0000000..60d1037 Binary files /dev/null and b/picture/e0bb42e29c58_20260323062055.jpg differ diff --git a/picture/f233ccc07264_20260323060328.png b/picture/f233ccc07264_20260323060328.png new file mode 100644 index 0000000..d09ab36 Binary files /dev/null and b/picture/f233ccc07264_20260323060328.png differ diff --git a/picture/f38267265a8a_20260323032735.png b/picture/f38267265a8a_20260323032735.png new file mode 100644 index 0000000..cb9a6b8 Binary files /dev/null and b/picture/f38267265a8a_20260323032735.png differ diff --git a/picture/image_records.json b/picture/image_records.json new file mode 100644 index 0000000..bd923e4 --- /dev/null +++ b/picture/image_records.json @@ -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" + } +] \ No newline at end of file diff --git a/prompts.py b/prompts.py new file mode 100644 index 0000000..38ab842 --- /dev/null +++ b/prompts.py @@ -0,0 +1,1243 @@ +""" +提示词集中管理模块 +将各工作流中的提示词统一提取到此处,方便调整和维护。 +每个提示词都附有详细注释说明其用途和所属工作流节点。 + +模块结构: +- COMMON_PROMPTS: 多个工作流共享的提示词(格式规范、友好回复、无检索建议等) +- BAIKE_PROMPTS: 百科问答工作流专用提示词 +- STATISTICS_PROMPTS: 统计工作流专用提示词 +- FAULT_DIAGNOSIS_PROMPTS: 故障诊断工作流专用提示词(仅含与操作指导不同的部分) +- OPERATE_PROMPTS: 操作指导工作流专用提示词(仅含与故障诊断不同的部分) +""" + + +# ============================================================ +# 一、共享提示词(多个工作流通用) +# ============================================================ + +COMMON_PROMPTS = { + # ------------------------------------------------------------------ + # 格式规范系统提示词 + # 用于: workflow_baike.py (generate_answer)、workflow_utils.py (stream_format_and_postprocess) + # ------------------------------------------------------------------ + "format_system": ( + "你是一个专业的格式规范助手,负责将给定的文本内容规范成美观、易读的格式。" + ), + + # ------------------------------------------------------------------ + # 格式规范用户提示词(带图片示例,用于百科问答和通用格式化) + # 输入变量: raw_content + # 用于: workflow_baike.py (generate_answer)、workflow_utils.py (stream_format_and_postprocess 无content_label时) + # ------------------------------------------------------------------ + "format_user_with_image_examples": ( + "请将以下内容规范成美观、易读的Markdown格式:\n" + "\n" + "【原始内容】\n" + "{raw_content}\n" + "\n" + "【格式规范要求】\n" + "1. 保持原始内容的完整性和准确性,**不要修改任何内容**,只调整格式\n" +# "2. 适当使用标题、段落分隔等Markdown格式,使内容更易读\n" + "2. 不得出现'''mardown'''符号\n" + "3. 确保图片链接在相关位置自然展示使用,并且只输出准确格式,`![图片](/api/v1/knowledge/files/images/xxx.jpg)` \n" + "4. 如果参考资料中没有图片,**不要输出任何关于图片的说明**\n" + "5. 确保LaTeX公式格式正确:$$...$$,并在 $$...$$ 前后加空格\n" + "6. 如果有代码片段,使用适当的代码块格式\n" + "7. 不要添加任何额外的解释或说明\n" + "\n" + "【图片输出示例】\n" + "示例一: 原始内容:`![图片](images/xxx.jpg)`\n" + " 规范后输出:`![图片](/api/v1/knowledge/files/images/xxx.jpg)`\n" + "示例二: 原始内容:`![图片](/api/v1/knowledge/files/images/xxx.jpg)`\n" + " 规范后输出:`![图片](/api/v1/knowledge/files/images/xxx.jpg)`\n" + "示例三: 原始内容:`images/xxx.jpg`\n" + " 规范后输出:`![图片](/api/v1/knowledge/files/images/xxx.jpg)`\n" + "示例四: 原始内容:`xxx.jpg`\n" + " 规范后输出:`![图片](/api/v1/knowledge/files/images/xxx.jpg)`\n" + "示例五: 原始内容:`![油污清理](/api/v1/knowledge/files/images/ce9650912c24fa0ed693228469322ec2a735f14d9ac729a1dc87427441bad0b.jpg)`\n" + " 规范后输出:`![图片](/api/v1/knowledge/files/images/ce9650912c24fa0ed693228469322ec2a735f14d9ac729a1dc87427441bad0b.jpg)`\n" + "示例六: 原始内容:`![部分断裂的螺栓及垫片](![图片](/api/v1/knowledge/files/images/e34eb1ddbe26730c84409e02365db416e74ce9e3bf2793a2605db5a085548415.jpg)`\n" + " 规范后输出:`![图片](/api/v1/knowledge/files/images/e34eb1ddbe26730c84409e02365db416e74ce9e3bf2793a2605db5a085548415.jpg)`\n" + "\n" + "请直接输出规范后的内容:" + ), + + # ------------------------------------------------------------------ + # 格式规范用户提示词(简化版,用于方案重新格式化,图片链接保持原样) + # 输入变量: content_label, raw_content + # 用于: workflow_utils.py (stream_format_and_postprocess 有content_label时) + # ------------------------------------------------------------------ + "format_user_simple": ( + "请将以下{content_label}内容规范成美观、易读的Markdown格式:\n" + "\n" + "【原始内容】\n" + "{raw_content}\n" + "\n" + "【格式规范要求】\n" + "1. 保持原始内容的完整性和准确性,**不要修改任何内容**,只调整格式\n" +# "2. 适当使用标题、列表、段落分隔等Markdown格式,使内容更易读\n" + "2. 不得出现'''markdown'''符号\n" + "3. 确保图片链接保持原样:`![图片](URL)`\n" + "4. 确保LaTeX公式格式正确:$$...$$,并在 $$...$$ 前后加空格\n" + "5. 如果有代码片段,使用适当的代码块格式\n" + "6. 不要添加任何额外的解释或说明\n" + "\n" + "请直接输出规范后的内容:" + ), + + # ------------------------------------------------------------------ + # 意图分类用户提示词(故障诊断和操作指导共用) + # 输入变量: combined_query + # 用于: workflow_fault_diagnosis.py、workflow_operate.py 的 classify_intent 节点 + # ------------------------------------------------------------------ + "classify_intent_user": ( + "\n" + "### 用户输入\n" + "\"{combined_query}\"\n" + "\n" + "请根据上述标准进行意图分类:" + ), + + # ------------------------------------------------------------------ + # 信息提取用户提示词(故障诊断和操作指导共用) + # 输入变量: existing_info_str, history_str, combined_query + # 用于: workflow_fault_diagnosis.py、workflow_operate.py 的 extract_info 节点 + # ------------------------------------------------------------------ + "extract_info_user": ( + "请从以下内容中提取{biz_label}相关信息:\n" + "\n" + "【已提取的信息】\n" + "{existing_info_str}\n" + "\n" + "【历史对话】\n" + "{history_str}\n" + "\n" + "【当前用户输入】\n" + "{combined_query}\n" + "\n" + "请提取信息(JSON格式):" + ), + + # ------------------------------------------------------------------ + # 询问用户系统提示词(故障诊断和操作指导共用) + # 用于: workflow_fault_diagnosis.py、workflow_operate.py 的 ask_user 节点 + # ------------------------------------------------------------------ + "ask_user_system": ( + "你是一个专业的{biz_label}助手,需要友好地引导用户提供缺失信息。\n" + "\n" + "要求:\n" + "1. 语气友好、专业,不要生硬\n" + "2. 说明为什么需要这些信息(更准确的{biz_label_short})\n" + "3. 鼓励用户提供更多细节" + ), + + # ------------------------------------------------------------------ + # 询问用户提示词(故障诊断和操作指导共用) + # 输入变量: info_completeness, existing_str, missing_str, combined_query + # ------------------------------------------------------------------ + "ask_user_prompt": ( + "请生成一段友好的回复,引导用户补充缺失信息。\n" + "\n" + "【已获取的信息】(完整性评分: {info_completeness})\n" + "{existing_str}\n" + "\n" + "【缺失的信息】\n" + "{missing_str}\n" + "\n" + "【用户当前输入】\n" + "{combined_query}\n" + "\n" + "请生成回复:" + ), + + # ------------------------------------------------------------------ + # 无检索结果且无设备信息时的系统提示词(故障诊断和操作指导共用) + # ------------------------------------------------------------------ + "generate_response_friendly_system": ( + "你是一个友好的{biz_label}助手。用户可能只是在打招呼或询问一般性问题。\n" + "请友好地回复,并简要介绍你能提供的帮助。" + ), + + # ------------------------------------------------------------------ + # 无检索结果且无设备信息时的用户提示词(故障诊断和操作指导共用) + # 输入变量: combined_query + # ------------------------------------------------------------------ + "generate_response_friendly_user": ( + "用户说:{combined_query}\n" + "\n" + "请友好回复:" + ), + + # ------------------------------------------------------------------ + # 无检索结果但有设备信息时的系统提示词(故障诊断和操作指导共用) + # ------------------------------------------------------------------ + "generate_response_no_rag_system": ( + "你是一个专业的{biz_label}助手。\n" + "虽然没有检索到相关资料,但可以根据用户提供的信息给出一般性建议。\n" + "注意:明确告知用户这是基于一般经验的建议。" + ), + + # ------------------------------------------------------------------ + # 无检索结果(简化版)系统提示词(故障诊断和操作指导共用) + # ------------------------------------------------------------------ + "generate_response_no_rag_simple_system": ( + "你是一个专业的{biz_label}助手。\n" + "虽然没有检索到相关资料,但可以根据用户提供的信息给出一般性建议。" + ), + + # ------------------------------------------------------------------ + # 后续交互提示词(故障诊断和操作指导共用) + # 输入变量: biz_label, ship_number, device_name, item_name, detail_info, + # rag_answer, combined_query, image_guidance + # 用于: workflow_fault_diagnosis.py、workflow_operate.py 的 generate_response 节点 + # ------------------------------------------------------------------ + "generate_response_follow_up": ( + "你是一名资深工业设备{biz_label}工程师。用户正在与你进行后续交互。\n" + "\n" + "【设备信息】\n" + "- 舷号:{ship_number}\n" + "- 设备名称:{device_name}\n" + "- {item_label}:{item_name}\n" + "{detail_info}" + "【参考资料】\n" + "{rag_answer}\n" + "\n" + "【用户当前问题】\n" + "{combined_query}\n" + "\n" + "【输出要求】\n" + "1. {image_guidance}\n" + "2. 如果参考资料包含表格,请转化成不用表格的形式输出内容\n" + "3. 如果参考资料包含 LaTeX 公式,请转为 $$...$$ 公式格式输出,并在 $$...$$ 前后加空格\n" + "4. 所有链接必须与参考资料保持一致,不得裁剪、转义或改写\n" + "5. 不能虚构参考资料中未提及的图片、表格、零件号或步骤\n" + "\n" + "请给出回复:" + ), + + # ------------------------------------------------------------------ + # 正常生成方案提示词(故障诊断和操作指导共用) + # 输入变量: biz_label, ship_number, device_name, item_name, item_label, + # detail_info, item_action, rag_answer + # ------------------------------------------------------------------ + "generate_response_normal": ( + "你是一名资深工业设备{biz_label}工程师,请根据以下信息生成{biz_label}分析与{biz_label}方案。如果参考资料中有图片,一定要把图片输出在结果中\n" + "\n" + "【设备信息】\n" + "- 舷号:{ship_number}\n" + "- 设备名称:{device_name}\n" + "- {item_label}:{item_name}\n" + "{detail_info}" + "当前的{item_label}要以{item_name}为准,{item_action}。\n" + "\n" + "【参考资料】\n" + "{rag_answer}\n" + "\n" + "【输出要求】\n" + "1. **重要**:如果参考资料中包含图片链接(格式为 `![图片](URL)` 或 `images/xxx.jpg`),请务必在相关位置自然展示使用,如果参考资料中没有图片,**不要输出任何关于图片的说明**\n" + "2. 如果参考资料包含 LaTeX 公式、连接等,请在对应位置正常使用,不得修改或删除\n" + "3. 请专注于内容的准确性、完整性和逻辑性,不要担心格式的美观性\n" + "4. 可以适当使用简单的段落分隔,但不要使用复杂的Markdown格式\n" + "5. 如果参考资料包含表格,请不要输出表格,而要直接描述表格的内容\n" + "\n" + "请给出回复:" + ), + + # ------------------------------------------------------------------ + # 基于反馈重新生成方案提示词(故障诊断和操作指导共用) + # 输入变量: biz_label, ship_number, device_name, item_name, item_label, + # detail_info, item_action, user_feedback_for_regenerate, + # rag_answer, image_guidance + # ------------------------------------------------------------------ + "generate_response_regenerating": ( + "你是一名资深工业设备{biz_label}工程师。用户对之前的方案有反馈,请根据反馈重新生成{biz_label}分析与{biz_label}方案。如果参考资料中有图片,一定要把图片输出在结果中\n" + "\n" + "【设备信息】\n" + "- 舷号:{ship_number}\n" + "- 设备名称:{device_name}\n" + "- {item_label}:{item_name}\n" + "{detail_info}" + "【用户反馈】\n" + "{user_feedback_for_regenerate}\n" + "\n" + "【参考资料】\n" + "{rag_answer}\n" + "\n" + "【输出要求】\n" + "1. 先用一句话判断{item_label}原因(包含舷号、设备、{item_label},不超过40字)\n" + "2. 然后给出{biz_label}方案,步骤清晰、可直接执行\n" + "3. 根据用户的反馈调整方案\n" + "4. 如果参考资料信息不足,明确指出缺少什么\n" + "5. {image_guidance},如果参考资料中没有图片,**不要输出任何关于图片的说明**\n" + "6. 如果参考资料包含表格,请转化成不用表格的形式输出内容\n" + "7. 如果参考资料包含 LaTeX 公式,请转为 $$...$$ 公式格式输出,并在 $$...$$ 前后加空格\n" + "8. 所有链接必须与参考资料保持一致,不得裁剪、转义或改写\n" + "9. 不能虚构参考资料中未提及的图片、表格、零件号或步骤\n" + "\n" + "请直接输出调整后的方案:" + ), + + # ------------------------------------------------------------------ + # 生成内容系统提示词(故障诊断和操作指导共用) + # ------------------------------------------------------------------ + "generate_response_content_system": ( + "你是一名资深工业设备{biz_label}工程师,严格依据检索信息生成{biz_label}方案。请专注于内容的准确性、完整性和逻辑性,如果参考资料中包含图片,请务必在回答中展示出来。" + ), + + # ------------------------------------------------------------------ + # 深度分析点选择系统提示词(故障诊断和操作指导共用) + # ------------------------------------------------------------------ + "deep_rag_analysis_system": ( + "你是一名资深工业设备{biz_label}专家。用户反馈之前的方案没有解决问题,请分析:\n" + "1. 之前方案可能存在的不足\n" + "2. 用户当前问题的关键点\n" + "3. 选择3-5个可以深入检索的分析点(如特定部件、原理、{biz_label}方法等)\n" + "\n" + "输出格式(JSON):\n" + "{\n" + " \"analysis_points\": [\"分析点1\", \"分析点2\", \"分析点3\"],\n" + " \"reason\": \"分析理由\"\n" + "}" + ), + + # ------------------------------------------------------------------ + # 深度分析点选择用户提示词(故障诊断和操作指导共用) + # 输入变量: ship_number, device_name, item_name, item_label, detail_info, + # history_str, last_generated_scheme, combined_query + # ------------------------------------------------------------------ + "deep_rag_analysis_user": ( + "【设备信息】\n" + "- 舷号:{ship_number}\n" + "- 设备名称:{device_name}\n" + "- {item_label}:{item_name}\n" + "{detail_info}" + "\n" + "【对话历史】\n" + "{history_str}\n" + "\n" + "【之前的方案】\n" + "{last_generated_scheme}\n" + "\n" + "【用户当前反馈】\n" + "{combined_query}\n" + "\n" + "请分析并选择深度分析点(JSON格式):" + ), + + # ------------------------------------------------------------------ + # 深度响应生成提示词(故障诊断和操作指导共用) + # 输入变量: biz_label, ship_number, device_name, item_name, item_label, + # detail_info, combined_query, analysis_points_prompt, + # analysis_points_text, original_rag_text, graph_results + # ------------------------------------------------------------------ + "generate_deep_response": ( + "你是一名资深工业设备{biz_label}专家。用户反馈之前的方案没有解决问题,请根据多维度深度检索的资料,进行深入的{item_label}原因分析和解决方案规划。\n" + "\n" + "【设备信息】\n" + "- 舷号:{ship_number}\n" + "- 设备名称:{device_name}\n" + "- {item_label}:{item_name}\n" + "{detail_info}" + "【用户反馈】\n" + "{combined_query}\n" + "{analysis_points_prompt}" + "【针对分析点的深度检索资料】\n" + "{analysis_points_text}\n" + "{original_rag_text}\n" + "\n" + "【图谱检索结果】\n" + "{graph_results}\n" + "\n" + "【输出要求】\n" + "1. 首先重新深入分析{item_label}原因,结合所有检索资料进行推理\n" + "2. 给出更详细、更精准的{biz_label}方案,步骤清晰、可直接执行\n" + "3. 重点关注用户反馈的问题,根据用户反馈和检索资料,进行{item_label}原因分析和解决方案规划。\n" + "4. **重要**:如果参考资料中包含图片链接(格式为 `![图片](URL)` 或 `images/xxx.jpg`),请务必在相关位置自然展示使用,如果参考资料中没有图片,**不要输出任何关于图片的说明**\n" + "5. 如果参考资料包含 LaTeX 公式、连接等,请在对应位置正常使用,不得修改或删除\n" + "6. 请专注于内容的准确性、完整性和逻辑性,不要担心格式的美观性\n" + "7. 可以适当使用简单的段落分隔,但不要使用复杂的Markdown格式\n" + "8. 如果参考资料包含表格,请不要输出表格,而要直接描述表格的内容\n" + "\n" + "请直接输出深度分析和方案:" + ), + + # ------------------------------------------------------------------ + # 深度响应内容系统提示词(故障诊断和操作指导共用) + # ------------------------------------------------------------------ + "generate_deep_response_content_system": ( + "你是一名资深工业设备{biz_label}专家,严格依据检索信息进行深度分析和方案生成。请专注于内容的准确性、完整性和逻辑性,如果参考资料中包含图片,请务必在回答中展示出来。" + ), + + # ------------------------------------------------------------------ + # 基于反馈修改方案提示词(故障诊断和操作指导共用) + # 输入变量: biz_label, ship_number, device_name, item_name, item_label, + # detail_info, item_action, last_scheme, user_feedback, image_guidance + # ------------------------------------------------------------------ + "regenerate_scheme_from_feedback": ( + "你是一名资深工业设备{biz_label}专家。用户对之前的{biz_label}方案有反馈,请根据反馈直接修改方案。\n" + "\n" + "【设备信息】\n" + "- 舷号:{ship_number}\n" + "- 设备名称:{device_name}\n" + "- {item_label}:{item_name}\n" + "{detail_info}" + "当前的{item_label}要以{item_name}为准,{item_action}。\n" + "\n" + "【之前的方案】\n" + "{last_scheme}\n" + "\n" + "【用户反馈】\n" + "{user_feedback}\n" + "\n" + "【输出要求】\n" + "1. 首先根据用户反馈,分析之前方案的问题所在\n" + "2. 然后基于之前的方案进行修改和优化,给出调整后的方案\n" + "3. 重点解决用户反馈的问题\n" + "4. {image_guidance}\n" + "5. 如果之前方案包含表格,请转化成不用表格的形式输出内容\n" + "6. 如果之前方案包含 LaTeX 公式,请转为 $$...$$ 公式格式输出,并在 $$...$$ 前后加空格\n" + "7. 所有链接必须与原方案保持一致,不得裁剪、转义或改写\n" + "8. 不能虚构原方案中未提及的图片、表格、零件号或步骤\n" + "\n" + "请直接输出修改后的完整方案:" + ), + + # ------------------------------------------------------------------ + # 基于反馈修改方案系统提示词(故障诊断和操作指导共用) + # ------------------------------------------------------------------ + "regenerate_scheme_from_feedback_system": ( + "你是一名资深工业设备{biz_label}专家,根据用户反馈直接修改和优化{biz_label}方案。请专注于内容的准确性、完整性和逻辑性。" + ), +} + + +# ============================================================ +# 二、百科问答工作流 (workflow_baike.py) 专用提示词 +# ============================================================ + +BAIKE_PROMPTS = { + # ------------------------------------------------------------------ + # judge_need_retrieval 节点:判断用户问题是否需要检索知识库 + # 输入变量: history_messages, original_query + # ------------------------------------------------------------------ + "judge_need_retrieval": ( + "你是一个专业的问题分析助手,请判断用户的问题是否需要检索知识库才能回答。\n" + "\n" + "【判断标准】\n" + "需要检索的情况:\n" + "- 询问特定设备、技术、规程、规范的具体内容\n" + "- 需要专业知识或特定数据支持的问题\n" + "- 询问故障诊断、维修方法等专业领域问题\n" + "- 询问历史事件、法规条款、技术标准等需要查证的内容\n" + "- 例如发动机的组成等\n" + "\n" + "不需要检索的情况:\n" + "- 简单的问候、寒暄\n" + "- 常识性问题(如常识性知识、简单计算等)\n" + "- 明确不需要专业知识的问题\n" + "- 对之前对话的简单追问,上下文已足够回答\n" + "\n" + "【对话历史】\n" + "{history_messages}\n" + "\n" + "【当前用户问题】\n" + "{original_query}\n" + "\n" + "请仅输出 \"true\" 或 \"false\"(不带引号),true 表示需要检索,false 表示不需要检索。" + ), + + # ------------------------------------------------------------------ + # rewrite_query_with_history 节点:使用历史上下文重写检索query + # 输入变量: last_user_query, history_str, original_query + # ------------------------------------------------------------------ + "rewrite_query_with_history": ( + "你是一个专业的检索query重写助手,负责根据对话历史和当前用户问题,生成**适合向量检索/RAG 的单条查询语句**。\n" + "\n" + "请严格遵守以下要求:\n" + "1. 只融合与当前问题语义强相关的**最近几轮对话信息**,不要机械拼接全部历史。\n" + "2. 消除指代和省略(例如:这个、刚才那个问题、上面说的故障等),补全成自洽、完整的描述。\n" + "3. 不要加入与用户问题无关的背景信息,不要过度扩展检索范围。\n" + "4. 输出必须是一句自然的中文问句或陈述句,便于检索;不要包含条目符号、编号或解释性文字。\n" + "5. 只输出改写后的query本身:\n" + " - 不要输出说明文字\n" + " - 不要包含「改写结果:」「检索query:」等前缀\n" + " - 不要添加引号\n" + "\n" + "【特别提示】\n" + "- 请优先参考**上一轮用户的问题**来重写本轮query。\n" + "- 上一轮用户的问题是:{last_user_query}\n" + "\n" + "【对话历史(JSON 格式,已按时间排序)】\n" + "{history_str}\n" + "\n" + "【当前用户问题】\n" + "{original_query}\n" + "\n" + "请给出最终用于检索的单条中文query。" + ), + + # ------------------------------------------------------------------ + # generate_suggested_replies_baike 函数:生成百科问答的建议回复问题 + # 输入变量: question, answer + # ------------------------------------------------------------------ + "generate_suggested_replies": ( + "你是一个专业的工业设备问答助手。根据以下问答内容,生成两个用户可能关心的后续问题。\n" + "\n" + "【用户问题】\n" + "{question}\n" + "\n" + "【回答内容】\n" + "{answer}\n" + "\n" + "请生成两个简洁、实用的问题,这些问题应该:\n" + "1. 不得出现需要归档和下载等操作相关的问题\n" + "2. 与当前问答内容相关,能够帮助用户进一步了解相关信息\n" + "3. 问题要具体、可操作\n" + "4. 每个问题不超过20个字\n" + "\n" + "请以JSON格式输出,格式如下:\n" + "[\n" + " {{\"title\": \"问题1的标题\", \"content\": \"问题1的完整内容\"}},\n" + " {{\"title\": \"问题2的标题\", \"content\": \"问题2的完整内容\"}}\n" + "]\n" + "\n" + "只输出JSON,不要有其他文字说明。" + ), + + # ------------------------------------------------------------------ + # generate_answer 节点 - 有检索结果时的系统提示词 + # 输入变量: domain_desc + # ------------------------------------------------------------------ + "generate_answer_with_retrieval_system": ( + "我是一个专业的{domain_desc}知识问答助手,根据用户的问题,综合所有检索结果,提供准确、专业、完整的回答内容。如果参考资料中有图片,一定要把图片输出在结果中" + ), + + # ------------------------------------------------------------------ + # generate_answer 节点 - 有检索结果时的用户提示词 + # 输入变量: extracted_text, rag_count, all_source_text + # ------------------------------------------------------------------ + "generate_answer_with_retrieval_user": ( + "{extracted_text}\n" + "\n" + "# 检索到的相关信息(共 {rag_count} 条):\n" + "\n" + "{all_source_text}\n" + "\n" + "【输出要求】\n" + "1. **重要**:如果参考资料中包含图片链接(格式为 `![图片](URL)` 或 `images/xxx.jpg`),请务必在相关位置自然展示使用,如果参考资料中没有图片,**不要输出任何关于图片的说明**\n" + "2. 如果参考资料包含 LaTeX 公式、连接等,请在对应位置正常使用,不得修改或删除\n" + "3. 请专注于内容的准确性、完整性和逻辑性,不要担心格式的美观性\n" + "4. 可以适当使用简单的段落分隔,但不要使用复杂的Markdown格式\n" + "5. 如果参考资料包含表格,请不要输出表格,而要直接描述表格的内容\n" + "\n" + "请给出回复:" + ), + + # ------------------------------------------------------------------ + # generate_answer 节点 - 无检索结果时的系统提示词 + # 输入变量: domain_desc + # ------------------------------------------------------------------ + "generate_answer_without_retrieval_system": ( + "我是一个专业的{domain_desc}知识问答助手,根据用户的问题,结合我的专业知识,提供准确、专业、完整的回答内容。" + ), + + # ------------------------------------------------------------------ + # generate_answer 节点 - 无检索结果时的用户提示词 + # 输入变量: extracted_text + # ------------------------------------------------------------------ + "generate_answer_without_retrieval_user": ( + "{extracted_text}\n" + "\n" + "【输出要求】\n" + "1. 请根据您的专业知识提供准确、完整的回答\n" + "2. 可以适当使用简单的段落分隔,但不要使用复杂的Markdown格式\n" + "3. 请专注于内容的准确性和专业性\n" + "\n" + "请给出回复:" + ), +} + + +# ============================================================ +# 三、统计工作流 (workflow_statistics.py) 专用提示词 +# ============================================================ + +STATISTICS_PROMPTS = { + # ------------------------------------------------------------------ + # _extract_info_with_llm 函数 - 故障频次统计信息提取 + # 输入变量: text + # ------------------------------------------------------------------ + "extract_fault_frequency_info": ( + "从以下文本中提取统计所需的信息,返回JSON格式。\n" + "提取内容:\n" + "- system: 系统名称(如:动力系统、电气系统等),如果没有则为空字符串\n" + "- time: 时间描述(如:近一个月、上周等),如果没有则为空字符串\n" + "\n" + "文本:\n" + "{text}\n" + "\n" + "输出格式示例:\n" + "{{\"system\": \"动力系统\", \"time\": \"近一个月\"}}\n" + "{{\"system\": \"\", \"time\": \"上周\"}}\n" + "{{\"system\": \"电气系统\", \"time\": \"\"}}\n" + "\n" + "现在请提取:" + ), + + # ------------------------------------------------------------------ + # _extract_info_with_llm 函数 - 备件消耗统计信息提取 + # 输入变量: text + # ------------------------------------------------------------------ + "extract_spare_parts_info": ( + "从以下文本中提取统计所需的信息,返回JSON格式。\n" + "提取内容:\n" + "- ship_number: 舷号数字(如:101、163等),如果没有则为空字符串\n" + "- time: 时间描述(如:近一个月、上周等),如果没有则为空字符串\n" + "\n" + "文本:\n" + "{text}\n" + "\n" + "输出格式示例:\n" + "{{\"ship_number\": \"101\", \"time\": \"近一个月\"}}\n" + "{{\"ship_number\": \"\", \"time\": \"上周\"}}\n" + "{{\"ship_number\": \"163\", \"time\": \"\"}}\n" + "\n" + "现在请提取:" + ), + + # ------------------------------------------------------------------ + # _extract_info_with_llm 函数 - 维修反馈统计信息提取 + # 输入变量: text + # ------------------------------------------------------------------ + "extract_repair_feedback_info": ( + "从以下文本中提取统计所需的信息,返回JSON格式。\n" + "提取内容:\n" + "- source: 反馈来源(如:好评、差评、反馈),如果没有则为空字符串\n" + "\n" + "文本:\n" + "{text}\n" + "\n" + "输出格式示例:\n" + "{{\"source\": \"差评\"}}\n" + "{{\"source\": \"好评\"}}\n" + "{{\"source\": \"\"}}\n" + "\n" + "现在请提取:" + ), + + # ------------------------------------------------------------------ + # extract_tool_parameters 节点:统计工具参数提取 + # 输入变量: current_date, tool_name, tool_description, params_desc_str, + # history_str, extracted_text, extra_instructions + # ------------------------------------------------------------------ + "extract_tool_parameters": ( + "你是一个参数提取助手。请从用户问题中提取统计工具所需的参数。\n" + "\n" + "当前日期:{current_date}\n" + "\n" + "工具名称:{tool_name}\n" + "工具描述:{tool_description}\n" + "\n" + "所需参数:\n" + "{params_desc_str}\n" + "\n" + "对话历史:\n" + "{history_str}\n" + "\n" + "用户问题:\n" + "{extracted_text}\n" + "\n" + "请特别注意:\n" + "1. 时间参数映射:\n" + " - \"最近一周\" -> 开始日期为当前日期前7天,结束日期为当前日期\n" + " - \"最近一个月\" -> 开始日期为当前日期前30天,结束日期为当前日期\n" + " - \"最近三个月\" -> 开始日期为当前日期前90天,结束日期为当前日期\n" + " - \"上个月\" -> 开始日期为上个月1号,结束日期为上个月最后一天\n" + " - \"今年\" -> 开始日期为今年1月1日,结束日期为当前日期\n" + " - 如果用户提到具体日期,请按原样提取\n" + "{extra_instructions}\n" + "3. top_n参数:\n" + " - 如果用户提到\"前N个\"、\"Top N\"等,提取N的值\n" + " - 否则使用默认值\n" + "\n" + "请输出JSON格式:\n" + "{{\n" + " \"parameters\": {{\n" + " \"参数名\": \"参数值\"\n" + " }},\n" + " \"missing_required\": [\"缺少的必填参数列表\"],\n" + " \"confidence\": 0.0-1.0之间的置信度\n" + "}}\n" + "\n" + "如果某个参数在用户问题中没有提到且不是必填,则不要包含在parameters中。\n" + "只输出JSON,不要有其他文字。" + ), +} + + +# ============================================================ +# 三-B、受控 Text2SQL 工作流 (workflow_statistics.py) 专用提示词 +# ============================================================ + +TEXT2SQL_PROMPTS = { + # ------------------------------------------------------------------ + # router_node: 路由判断 - 判断用户问题是统计分析还是普通问答 + # 输入变量: question + # ------------------------------------------------------------------ + "router": ( + "你是一个路由判断专家。请判断用户的问题是否需要查询数据库进行统计分析。\n" + "\n" + "判断标准:\n" + "- 如果问题涉及统计、排名、频次、数量、趋势、对比、分布等,需要查询数据库 → 回答 \"statistics\"\n" + "- 如果问题涉及维修反馈、好评、差评等,需要查询外部系统 → 回答 \"feedback\"\n" + "- 如果是一般性知识问答、维修方法、操作指导等,不需要查询数据库 → 回答 \"normal\"\n" + "\n" + "用户问题:{question}\n" + "\n" + "请输出JSON格式:\n" + "{{\"route\": \"statistics\" 或 \"feedback\" 或 \"normal\", \"reason\": \"判断理由\"}}\n" + "只输出JSON,不要有其他文字。" + ), + + # ------------------------------------------------------------------ + # SQL Generation: 根据用户问题生成SQL + # 输入变量: schema, question, current_date + # ------------------------------------------------------------------ + "sql_generation": ( + "你是舰船维修数据分析助手。请根据用户问题生成SQL查询。\n" + "\n" + "【数据库Schema】\n" + "{schema}\n" + "\n" + "【当前日期】{current_date}\n" + "\n" + "【用户问题】\n" + "{question}\n" + "\n" + "【其他有效信息】\n" + "{extra_info}\n" + "【生成规则】\n" + "1. 只能生成 SELECT 语句,禁止 INSERT/UPDATE/DELETE\n" + "2. 只能查询 fault_records 表\n" + "3. 默认 LIMIT 100(如果未指定)\n" + "4. 不允许使用 UNION、子查询嵌套超过2层\n" + "5. 不允许使用任何危险SQL(如 pg_sleep、注释注入等)\n" + "6. 时间条件使用 created_at 字段\n" + "7. 备件统计需要使用 jsonb_array_elements_text(spare_parts) 展开JSONB数组\n" + "8. 系统名称使用 system_name 字段,必须使用 LIKE 模糊匹配(如 system_name LIKE '%动力%'),不要用等号精确匹配\n" + "9. 舷号使用 ship_number 字段\n" + "\n" + "【常见统计模式】\n" + "- 故障频次排名:SELECT device_name || fault AS name, system_name, COUNT(*) AS count FROM fault_records [WHERE ...] GROUP BY name, system_name ORDER BY count DESC LIMIT N\n" + "- 备件消耗排名:SELECT sp AS name, system_name, COUNT(*) AS count FROM fault_records, jsonb_array_elements_text(spare_parts) AS sp [WHERE ...] GROUP BY sp, system_name ORDER BY count DESC LIMIT N\n" + "- 故障类型分布:SELECT system_name, COUNT(*) AS count FROM fault_records [WHERE ...] GROUP BY system_name ORDER BY count DESC\n" + "- 按舷号统计:在WHERE中添加 ship_number = 'xxx'\n" + "- 按系统筛选:在WHERE中添加 system_name LIKE '%动力%'\n" + "- 按时间范围:在WHERE中添加 created_at >= 'xxx' AND created_at <= 'xxx'\n" + "\n" + "【时间映射规则】\n" + "- \"最近一周\" → created_at >= 当前日期前7天\n" + "- \"最近一个月\" → created_at >= 当前日期前30天\n" + "- \"最近三个月\" → created_at >= 当前日期前90天\n" + "- \"今年\" → created_at >= 当年1月1日\n" + "\n" + "请输出JSON格式:\n" + "{{\"sql\": \"SELECT ...\", \"explanation\": \"SQL逻辑说明\"}}\n" + "只输出JSON,不要有其他文字。" + ), + + # ------------------------------------------------------------------ + # SQL Regeneration: 基于反馈重新生成SQL + # 输入变量: schema, question, current_date, previous_sql, feedback + # ------------------------------------------------------------------ + "sql_regeneration": ( + "你是舰船维修数据分析助手。之前的SQL存在问题,请根据反馈重新生成。\n" + "\n" + "【数据库Schema】\n" + "{schema}\n" + "\n" + "【当前日期】{current_date}\n" + "\n" + "【用户问题】\n" + "{question}\n" + "\n" + "【之前的SQL】\n" + "{previous_sql}\n" + "\n" + "【问题反馈】\n" + "{feedback}\n" + "\n" + "请根据反馈修正SQL,输出JSON格式:\n" + "{{\"sql\": \"SELECT ...\", \"explanation\": \"修正说明\"}}\n" + "只输出JSON,不要有其他文字。" + ), + + # ------------------------------------------------------------------ + # SQL Review: 审查SQL是否正确回答了用户问题 + # 输入变量: schema, question, sql + # ------------------------------------------------------------------ + "sql_review": ( + "你是SQL审查专家。请检查以下SQL是否真正回答了用户问题。\n" + "\n" + "【数据库Schema】\n" + "{schema}\n" + "\n" + "【用户问题】\n" + "{question}\n" + "\n" + "【待审查SQL】\n" + "{sql}\n" + "\n" + "【审查要点】\n" + "1. 时间条件是否正确(用户说\"近一个月\",SQL是否有对应的时间范围?)\n" + "2. 聚合逻辑是否正确(COUNT、GROUP BY是否与问题匹配?)\n" + "3. 排序是否正确(排名类问题是否ORDER BY DESC?)\n" + "4. 字段是否存在(所有字段都在Schema中?)\n" + "5. 是否符合业务语义(故障频次应统计device_name+fault,备件应展开spare_parts)\n" + "6. WHERE条件是否遗漏(用户指定了舷号/系统,SQL是否包含?)\n" + "7. 备件统计是否正确使用了 jsonb_array_elements_text\n" + "\n" + "请输出JSON格式:\n" + "{{\n" + " \"approved\": true 或 false,\n" + " \"issues\": [\"问题1\", \"问题2\"],\n" + " \"suggested_sql\": \"修正后的SQL(如果approved为false,提供修正版本)\",\n" + " \"review_comment\": \"审查意见\"\n" + "}}\n" + "只输出JSON,不要有其他文字。" + ), + + # ------------------------------------------------------------------ + # Result Reflection: 检查查询结果是否合理 + # 输入变量: question, sql, row_count, sample_rows + # ------------------------------------------------------------------ + "result_reflection": ( + "你是数据分析审查专家。请检查SQL查询结果是否合理地回答了用户问题。\n" + "\n" + "【用户问题】\n" + "{question}\n" + "\n" + "【执行的SQL】\n" + "{sql}\n" + "\n" + "【结果概况】\n" + "- 返回行数:{row_count}\n" + "- 前5行数据:\n" + "{sample_rows}\n" + "\n" + "【判断标准】\n" + "1. 如果结果为空(0行),可能SQL有问题 → 需要重试\n" + "2. 如果结果数量异常少(如统计排名只有1条),可能条件过严 → 需要重试\n" + "3. 如果结果明显不符合业务逻辑 → 需要重试\n" + "4. 如果结果合理 → 通过\n" + "\n" + "请输出JSON格式:\n" + "{{\n" + " \"reasonable\": true 或 false,\n" + " \"reason\": \"判断理由\",\n" + " \"suggestion\": \"如果不合理,给出改进建议\"\n" + "}}\n" + "只输出JSON,不要有其他文字。" + ), + + # ------------------------------------------------------------------ + # Final Answer: 将SQL结果转为自然语言 + # 输入变量: question, sql, row_count, result_data + # ------------------------------------------------------------------ + "final_answer": ( + "你是舰船维修数据分析助手。请将SQL查询结果转换为清晰的自然语言回答。\n" + "\n" + "【用户问题】\n" + "{question}\n" + "\n" + "【执行的SQL】\n" + "{sql}\n" + "\n" + "【查询结果】(共{row_count}条)\n" + "{result_data}\n" + "\n" + "【回答要求】\n" + "1. 用自然语言总结核心结论\n" + "2. 如果是排名类结果,用Markdown表格展示(列名:排名、名称、次数/数量、系统等)\n" + "3. 如果是分布类结果,说明各部分的占比\n" + "4. 数字要准确,不要编造数据\n" + "5. 语言简洁专业\n" + "6. 如果结果为空,说明可能没有相关数据\n" + ), +} + + +# ============================================================ +# 四、故障诊断工作流 (workflow_fault_diagnosis.py) 专用提示词 +# 仅包含与操作指导不同的部分,共享提示词使用 COMMON_PROMPTS +# ============================================================ + +FAULT_DIAGNOSIS_PROMPTS = { + # ------------------------------------------------------------------ + # classify_intent 节点:故障诊断意图分类系统提示词 + # 用于判断用户意图是"维修"还是"百科" + # ------------------------------------------------------------------ + "classify_intent_system": ( + "你是一个船舶设备维修诊断系统的意图识别专家。你的任务是精准识别用户的核心诉求,将其分类为\"维修\"或\"百科\"。\n" + "\n" + "### 📚 分类定义与判别标准\n" + "\n" + "#### 1. 维修\n" + "- **核心特征**:用户描述了一个具体的**故障现象**、**异常状态**、**报警代码**,或者明确询问**解决方案**、**排查步骤**。\n" + "- **关键判定规则**:\n" + " - **故障即维修**:只要用户陈述了一个设备故障现象(如\"主机排烟温度高\"),即使没有说\"怎么修\",也默认归类为维修(隐含求助意图)。\n" + " - **排查导向**:涉及\"原因分析\"、\"处理措施\"、\"应急操作\"的内容。\n" + "- **示例**:\n" + " - \"主机滑油压力低\"(隐含:怎么办?)\n" + " - \"发电机启动失败,有报警E05\"\n" + " - \"分油机震动大怎么处理\"\n" + " - \"锅炉点火失败的原因\"(针对具体故障的原因分析属于维修)\n" + "\n" + "#### 2. 百科\n" + "- **核心特征**:用户询问**理论知识**、**设备原理**、**参数定义**、**结构组成**或**常规保养规范**,且不涉及当前的具体故障。\n" + "- **关键判定规则**:\n" + " - **概念导向**:包含\"什么是\"、\"原理\"、\"作用\"、\"结构\"、\"定义\"等词汇。\n" + " - **通用性**:问题适用于所有同类设备,而非针对某一台坏掉的设备。\n" + "- **示例**:\n" + " - \"柴油机的工作原理是什么\"\n" + " - \"滑油分油机的结构图\"\n" + " - \"什么是扫气箱着火\"(询问定义)\n" + " - \"主机日常保养项目有哪些\"\n" + "\n" + "### ⚠️ 冲突处理原则\n" + "- 如果用户输入既像原理又像故障(例如\"涡轮增压器喘振\"),**优先归类为\"维修\"**,因为用户更可能是在遇到故障。\n" + "- 如果用户明确使用了\"什么是\"、\"怎么定义\",归类为\"百科\"。\n" + "\n" + "### 📤 输出格式\n" + "请仅输出一个标准的 JSON 对象,不要包含 markdown 标记或其他文本:\n" + "{\n" + " \"intent\": \"维修\" 或 \"百科\",\n" + " \"reason\": \"简短的判断理由,指出触发分类的关键特征词或逻辑\"\n" + "}" + ), + + # ------------------------------------------------------------------ + # extract_info 节点:故障诊断信息提取系统提示词 + # 用于从用户输入中提取维修相关信息(舷号、设备名称、故障现象等) + # ------------------------------------------------------------------ + "extract_info_system": ( + "你是一个信息提取专家,负责从用户输入中提取维修相关信息。\n" + "\n" + "你需要提取以下信息:\n" + "1. 舷号:船舰编号,如\"101\",\"111\",\"122\"等\n" + "2. 设备名称:故障设备的名称\n" + "3. 故障现象:故障的具体表现\n" + "4. 故障码:报警代码或故障代码\n" + "5. 故障时间:故障发生的时间\n" + "6. 故障频率:故障发生的频率\n" + "7. 已尝试措施:用户已经尝试过的维修方法\n" + "8. 运行工况:设备运行时的参数\n" + "9. 其他信息:其他补充说明\n" + "\n" + "提取规则:\n" + "- 如果某项信息没有提及,返回空字符串\n" + "- 优先使用当前输入中的信息\n" + "- 注意保留已有的信息,不要丢失\n" + "\n" + "输出格式(JSON):\n" + "{\n" + " \"ship_number\": \"舷号\",\n" + " \"device_name\": \"设备名称\",\n" + " \"fault\": \"故障现象\",\n" + " \"fault_code\": \"故障码\",\n" + " \"fault_time\": \"故障时间\",\n" + " \"fault_frequency\": \"故障频率\",\n" + " \"tried_measures\": \"已尝试措施\",\n" + " \"running_condition\": \"运行工况\",\n" + " \"additional_info\": \"其他信息\"\n" + "}" + ), + + # ------------------------------------------------------------------ + # agent_think 节点(方案已生成时):故障诊断后续意图分析系统提示词 + # 用于判断用户在方案已生成后的后续意图 + # ------------------------------------------------------------------ + "agent_think_intent_system": ( + "你是一个专业的意图分析专家,用于船舶故障诊断系统。\n" + "\n" + "根据对话历史和用户当前输入,判断用户的意图。\n" + "\n" + "【意图分类说明】\n" + "1. generate_report: 用户明确要求生成、导出、下载、保存维修报告\n" + " - 示例:\"生成报告\"、\"写报告\"、\"导出报告\"、\"下载报告\"、\"保存报告\"\n" + "\n" + "2. modify_info: 用户需要修改或补充之前提供的故障信息(舷号、设备名称、故障现象等)\n" + " - 示例:\"其实是...\"、\"不对,应该是...\"、\"我补充一下...\"、\"修改设备名称是...\"、\"更正一下,故障是...\"、\"我需要补充...\"\n" + "\n" + "3. has_error: 用户指出之前生成的维修方案有错误、需要修改或调整方案内容\n" + " - 示例:\"方案有误\"、\"这个步骤不对\"、\"需要修改方案\"、\"这个方案有问题\"\n" + "\n" + "4. not_solved: 用户反映按照之前的方案执行后,故障问题依然没有解决,需要更深入的分析\n" + " - 示例:\"没有解决\"、\"还是不行\"、\"问题依旧\"、\"还是没解决\"、\"按照方案做了但还是有问题\"、\"需要更深入的分析故障\"、\"需要详细的解决方案\"\n" + "\n" + "5. related_question: 用户询问与当前故障、设备、原理等相关的问题(非方案调整、非信息修改)\n" + " - 示例:\"为什么会出现这个故障?\"、\"这个设备的工作原理是什么?\"、\"这个部件怎么更换?\"、\"执行方案时遇到问题:...\"\n" + "\n" + "6. other: 其他无法归类到上述类别的意图\n" + " - 示例:打招呼、闲聊、与当前故障诊断无关的问题\n" + "\n" + "输出格式(JSON):\n" + "{\"intent\": \"意图类型\", \"reason\": \"判断理由\"}" + ), + + # ------------------------------------------------------------------ + # agent_think 节点:故障诊断后续意图分析用户提示词 + # 输入变量: ship_number, device_name, fault, history_str, combined_query + # ------------------------------------------------------------------ + "agent_think_intent_user": ( + "【当前故障诊断信息】\n" + "- 舷号: {ship_number}\n" + "- 设备名称: {device_name}\n" + "- 故障现象: {fault}\n" + "\n" + "【对话历史】\n" + "{history_str}\n" + "\n" + "【用户当前输入】\n" + "{combined_query}\n" + "\n" + "请分析用户意图(JSON格式):" + ), + + # ------------------------------------------------------------------ + # ask_user 节点:用户补充信息提取提示词 + # 输入变量: missing_str, user_query + # ------------------------------------------------------------------ + "ask_user_extract": ( + "从用户输入中提取缺失的信息。\n" + "\n" + "缺失的信息类型: {missing_str}\n" + "\n" + "用户输入: {user_query}\n" + "\n" + "请提取用户补充的信息,输出JSON格式:\n" + "{{\"ship_number\": \"舷号\", \"device_name\": \"设备名称\", \"fault\": \"故障现象\"}}\n" + "如果某项信息没有提及,返回空字符串。" + ), + + # ------------------------------------------------------------------ + # generate_response 节点 - 无检索结果但有设备信息时的用户提示词 + # 输入变量: ship_number, device_name, fault, fault_code, fault_time, + # fault_frequency, tried_measures, running_condition, additional_info + # ------------------------------------------------------------------ + "generate_response_no_rag_user": ( + "用户描述:\n" + "- 舷号: {ship_number}\n" + "- 设备名称: {device_name}\n" + "- 故障现象: {fault}\n" + "- 故障码: {fault_code}\n" + "- 故障时间: {fault_time}\n" + "- 故障频率: {fault_frequency}\n" + "- 已尝试措施: {tried_measures}\n" + "- 运行工况: {running_condition}\n" + "- 其他信息: {additional_info}\n" + "\n" + "请给出一般性的故障诊断建议:" + ), + + # ------------------------------------------------------------------ + # generate_response 节点 - 无检索结果(简化版)用户提示词 + # 输入变量: ship_number, device_name, fault + # ------------------------------------------------------------------ + "generate_response_no_rag_simple_user": ( + "用户描述:\n" + "- 舷号: {ship_number}\n" + "- 设备名称: {device_name}\n" + "- 故障现象: {fault}\n" + "\n" + "请给出一般性的故障诊断建议:" + ), +} + + +# ============================================================ +# 五、操作指导工作流 (workflow_operate.py) 专用提示词 +# 仅包含与故障诊断不同的部分,共享提示词使用 COMMON_PROMPTS +# ============================================================ + +OPERATE_PROMPTS = { + # ------------------------------------------------------------------ + # classify_intent 节点:操作指导意图分类系统提示词 + # 用于判断用户意图是"操作"还是"百科" + # ------------------------------------------------------------------ + "classify_intent_system": ( + "你是一个船舶设备操作指导系统的意图识别专家。你的任务是精准识别用户的核心诉求,将其分类为\"操作\"或\"百科\"。\n" + "\n" + "### 📚 分类定义与判别标准\n" + "\n" + "#### 1. 操作\n" + "- **核心特征**:用户描述了一个具体的**操作需求**、**操作步骤**、**操作代码**,或者明确询问**操作方法**、**操作流程**。\n" + "- **关键判定规则**:\n" + " - **操作即指导**:只要用户陈述了一个设备操作需求(如\"主机启动操作\"),即使没有说\"怎么操作\",也默认归类为操作(隐含求助意图)。\n" + " - **流程导向**:涉及\"操作步骤\"、\"操作方法\"、\"操作规程\"的内容。\n" + "- **示例**:\n" + " - \"主机启动操作步骤\"(隐含:怎么做?)\n" + " - \"发电机并车操作流程\"\n" + " - \"分油机操作方法\"\n" + " - \"锅炉点火操作规程\"\n" + "\n" + "#### 2. 百科\n" + "- **核心特征**:用户询问**理论知识**、**设备原理**、**参数定义**、**结构组成**或**常规保养规范**,且不涉及当前的具体操作。\n" + "- **关键判定规则**:\n" + " - **概念导向**:包含\"什么是\"、\"原理\"、\"作用\"、\"结构\"、\"定义\"等词汇。\n" + " - **通用性**:问题适用于所有同类设备,而非针对某一项具体操作。\n" + "- **示例**:\n" + " - \"柴油机的工作原理是什么\"\n" + " - \"滑油分油机的结构图\"\n" + " - \"什么是扫气箱着火\"(询问定义)\n" + " - \"主机日常保养项目有哪些\"\n" + "\n" + "### ⚠️ 冲突处理原则\n" + "- 如果用户输入既像原理又像操作(例如\"涡轮增压器操作\"),**优先归类为\"操作\"**,因为用户更可能是需要操作指导。\n" + "- 如果用户明确使用了\"什么是\"、\"怎么定义\",归类为\"百科\"。\n" + "\n" + "### 📤 输出格式\n" + "请仅输出一个标准的 JSON 对象,不要包含 markdown 标记或其他文本:\n" + "{\n" + " \"intent\": \"操作\" 或 \"百科\",\n" + " \"reason\": \"简短的判断理由,指出触发分类的关键特征词或逻辑\"\n" + "}" + ), + + # ------------------------------------------------------------------ + # extract_info 节点:操作指导信息提取系统提示词 + # 用于从用户输入中提取操作相关信息(舷号、设备名称、操作项目等) + # ------------------------------------------------------------------ + "extract_info_system": ( + "你是一个信息提取专家,负责从用户输入中提取操作相关信息。\n" + "\n" + "你需要提取以下信息:\n" + "1. 舷号:船舰编号,如\"101\",\"111\",\"122\"等\n" + "2. 设备名称:操作设备的名称\n" + "3. 操作项目:操作的具体内容\n" + "4. 操作代码:报警代码或操作代码\n" + "5. 操作时间:操作发生的时间\n" + "6. 操作频率:操作发生的频率\n" + "7. 已尝试措施:用户已经尝试过的操作方法\n" + "8. 运行工况:设备运行时的参数\n" + "9. 其他信息:其他补充说明\n" + "\n" + "提取规则:\n" + "- 如果某项信息没有提及,返回空字符串\n" + "- 优先使用当前输入中的信息\n" + "- 注意保留已有的信息,不要丢失\n" + "\n" + "输出格式(JSON):\n" + "{\n" + " \"ship_number\": \"舷号\",\n" + " \"device_name\": \"设备名称\",\n" + " \"operation_item\": \"操作项目\",\n" + " \"operation_code\": \"操作代码\",\n" + " \"operation_time\": \"操作时间\",\n" + " \"operation_frequency\": \"操作频率\",\n" + " \"tried_measures\": \"已尝试措施\",\n" + " \"running_condition\": \"运行工况\",\n" + " \"additional_info\": \"其他信息\"\n" + "}" + ), + + # ------------------------------------------------------------------ + # agent_think 节点(方案已生成时):操作指导后续意图分析系统提示词 + # 用于判断用户在方案已生成后的后续意图 + # ------------------------------------------------------------------ + "agent_think_intent_system": ( + "你是一个专业的意图分析专家,用于船舶操作指导系统。\n" + "\n" + "根据对话历史和用户当前输入,判断用户的意图。\n" + "\n" + "【意图分类说明】\n" + "1. modify_info: 用户需要修改或补充之前提供的操作信息(舷号、设备名称、操作项目等)\n" + " - 示例:\"其实是...\"、\"不对,应该是...\"、\"我补充一下...\"、\"修改设备名称是...\"、\"更正一下,操作是...\"\n" + "\n" + "2. has_error: 用户指出之前生成的操作方案有错误、需要修改或调整方案内容\n" + " - 示例:\"方案有误\"、\"这个步骤不对\"、\"需要修改方案\"、\"这个方案有问题\"\n" + "\n" + "3. not_solved: 用户反映按照之前的方案执行后,操作问题依然没有解决,需要更深入的分析\n" + " - 示例:\"没有解决\"、\"还是不行\"、\"问题依旧\"、\"还是没解决\"、\"按照方案做了但还是有问题\"、\"需要更深入的分析\"、\"需要详细的解决方案\"\n" + "\n" + "4. related_question: 用户询问与当前操作、设备、原理等相关的问题(非方案调整、非信息修改)\n" + " - 示例:\"为什么会出现这个操作问题?\"、\"这个设备的工作原理是什么?\"、\"这个部件怎么更换?\"、\"执行方案时遇到问题:...\"\n" + "\n" + "5. other: 其他无法归类到上述类别的意图\n" + " - 示例:打招呼、闲聊、与当前操作指导无关的问题\n" + "\n" + "输出格式(JSON):\n" + "{\"intent\": \"意图类型\", \"reason\": \"判断理由\"}" + ), + + # ------------------------------------------------------------------ + # agent_think 节点:操作指导后续意图分析用户提示词 + # 输入变量: ship_number, device_name, operation_item, history_str, combined_query + # ------------------------------------------------------------------ + "agent_think_intent_user": ( + "【当前操作指导信息】\n" + "- 舷号: {ship_number}\n" + "- 设备名称: {device_name}\n" + "- 操作项目: {operation_item}\n" + "\n" + "【对话历史】\n" + "{history_str}\n" + "\n" + "【用户当前输入】\n" + "{combined_query}\n" + "\n" + "请分析用户意图(JSON格式):" + ), + + # ------------------------------------------------------------------ + # ask_user 节点:用户补充信息提取提示词 + # 输入变量: missing_str, user_query + # ------------------------------------------------------------------ + "ask_user_extract": ( + "从用户输入中提取缺失的信息。\n" + "\n" + "缺失的信息类型: {missing_str}\n" + "\n" + "用户输入: {user_query}\n" + "\n" + "请提取用户补充的信息,输出JSON格式:\n" + "{{\"ship_number\": \"舷号\", \"device_name\": \"设备名称\", \"operation_item\": \"操作项目\"}}\n" + "如果某项信息没有提及,返回空字符串。" + ), + + # ------------------------------------------------------------------ + # generate_response 节点 - 无检索结果但有设备信息时的用户提示词 + # 输入变量: ship_number, device_name, operation_item, operation_code, + # operation_time, operation_frequency, tried_measures, + # running_condition, additional_info + # ------------------------------------------------------------------ + "generate_response_no_rag_user": ( + "用户描述:\n" + "- 舷号: {ship_number}\n" + "- 设备名称: {device_name}\n" + "- 操作项目: {operation_item}\n" + "- 操作代码: {operation_code}\n" + "- 操作时间: {operation_time}\n" + "- 操作频率: {operation_frequency}\n" + "- 已尝试措施: {tried_measures}\n" + "- 运行工况: {running_condition}\n" + "- 其他信息: {additional_info}\n" + "\n" + "请给出一般性的操作指导建议:" + ), + + # ------------------------------------------------------------------ + # generate_response 节点 - 无检索结果(简化版)用户提示词 + # 输入变量: ship_number, device_name, operation_item + # ------------------------------------------------------------------ + "generate_response_no_rag_simple_user": ( + "用户描述:\n" + "- 舷号: {ship_number}\n" + "- 设备名称: {device_name}\n" + "- 操作项目: {operation_item}\n" + "\n" + "请给出一般性的操作指导建议:" + ), +} + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1b7a7e6 --- /dev/null +++ b/requirements.txt @@ -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 + diff --git a/template.tex b/template.tex new file mode 100644 index 0000000..6b19132 --- /dev/null +++ b/template.tex @@ -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} diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..929ca88 --- /dev/null +++ b/tools/__init__.py @@ -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" +] + diff --git a/tools/agent_usage_statistics.py b/tools/agent_usage_statistics.py new file mode 100644 index 0000000..7f5c946 --- /dev/null +++ b/tools/agent_usage_statistics.py @@ -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] + } + } + diff --git a/tools/fault_record_db.py b/tools/fault_record_db.py new file mode 100644 index 0000000..613f73d --- /dev/null +++ b/tools/fault_record_db.py @@ -0,0 +1,330 @@ +""" +故障记录数据库模块 +用于在故障诊断智能体生成方案后自动记录故障信息到PostgreSQL数据库 +记录内容:舷号、设备、故障、备件、系统 +""" +import json +import re +import asyncio +from datetime import datetime +from typing import Dict, Any, Optional, List + +from config import POSTGRES_CONNECTION_STRING + + +async def init_fault_records_table(): + """ + 初始化 fault_records 表 + """ + try: + from psycopg_pool import AsyncConnectionPool + except ImportError: + print("[fault_record_db] psycopg_pool 未安装,无法初始化表") + return + + async with AsyncConnectionPool( + POSTGRES_CONNECTION_STRING, + kwargs={"autocommit": True} + ) as pool: + async with pool.connection() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + CREATE TABLE IF NOT EXISTS fault_records ( + id SERIAL PRIMARY KEY, + ship_number TEXT NOT NULL DEFAULT '', + device_name TEXT NOT NULL DEFAULT '', + fault TEXT NOT NULL DEFAULT '', + spare_parts JSONB NOT NULL DEFAULT '[]', + system_name TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL DEFAULT NOW() + ) + """) + await cur.execute(""" + CREATE INDEX IF NOT EXISTS fault_records_ship_number_idx ON fault_records(ship_number) + """) + await cur.execute(""" + CREATE INDEX IF NOT EXISTS fault_records_device_name_idx ON fault_records(device_name) + """) + await cur.execute(""" + CREATE INDEX IF NOT EXISTS fault_records_system_name_idx ON fault_records(system_name) + """) + await cur.execute(""" + CREATE INDEX IF NOT EXISTS fault_records_created_at_idx ON fault_records(created_at) + """) + print("[fault_record_db] fault_records 表初始化完成") + + + +async def extract_spare_parts_from_scheme(scheme_content: str) -> List[str]: + """ + 从方案内容中用大模型抽取备件名称列表 + + Args: + scheme_content: 方案内容(Markdown格式) + + Returns: + 备件名称列表 + """ + if not scheme_content or len(scheme_content.strip()) < 10: + return [] + + prompt = f"""从以下维修方案内容中提取所有提到的备品备件名称。 + +方案内容: +{scheme_content[:3000]} + +提取规则: +1. 找到"备品备件"、"备件"等相关章节,提取其中的项目名称 +2. 只提取名称,不要提取型号、规格、数量、材质、用途等 +3. 如果没有专门的章节,也从全文中识别备件名称 + +以JSON数组格式返回: +{{ + "备品备件": [备件名称1, 备件名称2, ...] +}} + +如果没有提到备品备件,请返回空数组 []""" + + try: + from modelsAPI.model_api import OpenaiAPI + result_text = await OpenaiAPI.open_api_chat_without_thinking( + query=prompt, + json_output=True + ) + print("抽取出来的备品备件", result_text) + + result_text = result_text.strip() + result_text = re.sub(r'^```json\s*', '', result_text, flags=re.IGNORECASE) + result_text = re.sub(r'^```\s*', '', result_text) + result_text = re.sub(r'```$', '', result_text) + + try: + parsed = json.loads(result_text) + except json.JSONDecodeError: + json_match = re.search(r'\{.*\}', result_text, re.DOTALL) + if json_match: + parsed = json.loads(json_match.group(0)) + else: + return [] + + if isinstance(parsed, dict): + if "备品备件" in parsed and isinstance(parsed["备品备件"], list): + return [str(part).strip() for part in parsed["备品备件"] if part] + all_parts = [] + for v in parsed.values(): + if isinstance(v, list): + all_parts.extend([str(part).strip() for part in v if part]) + return all_parts + elif isinstance(parsed, list): + return [str(part).strip() for part in parsed if part] + + return [] + + except Exception as e: + print(f"[fault_record_db] 从方案提取备件失败: {str(e)}") + return [] + + +async def save_fault_record( + ship_number: str, + device_name: str, + fault: str, + spare_parts: List[str], + system_name: str +) -> bool: + """ + 保存故障记录到数据库 + + Args: + ship_number: 舷号 + device_name: 设备名称 + fault: 故障现象 + spare_parts: 备件列表 + system_name: 系统名称 + + Returns: + 是否保存成功 + """ + if not ship_number and not device_name and not fault: + print("[fault_record_db] 舷号、设备、故障均为空,跳过保存") + return False + + try: + from psycopg_pool import AsyncConnectionPool + except ImportError: + print("[fault_record_db] psycopg_pool 未安装,无法保存") + return False + + try: + async with AsyncConnectionPool( + POSTGRES_CONNECTION_STRING, + kwargs={"autocommit": True} + ) as pool: + async with pool.connection() as conn: + async with conn.cursor() as cur: + await cur.execute( + """ + INSERT INTO fault_records (ship_number, device_name, fault, spare_parts, system_name, created_at) + VALUES (%s, %s, %s, %s, %s, %s) + """, + ( + ship_number or "", + device_name or "", + fault or "", + json.dumps(spare_parts, ensure_ascii=False), + system_name or "", + datetime.now() + ) + ) + print(f"[fault_record_db] 故障记录已保存: 舷号={ship_number}, 设备={device_name}, 故障={fault}, 系统={system_name}, 备件数={len(spare_parts)}") + return True + except Exception as e: + print(f"[fault_record_db] 保存故障记录失败: {str(e)}") + import traceback + traceback.print_exc() + return False + + +async def record_fault_from_state(state: Dict[str, Any]) -> bool: + """ + 从工作流状态中提取故障信息并记录到数据库 + 在故障诊断智能体生成方案后调用 + + 流程: + 1. 从状态中获取舷号、设备、故障 + 2. 从方案内容中用大模型抽取备件 + 3. 通过设备名称反推系统 + 4. 保存到数据库 + + Args: + state: 工作流状态字典 + + Returns: + 是否记录成功 + """ + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + fault = state.get("fault", "") or "" + + if not device_name and not fault: + print("[fault_record_db] 设备和故障均为空,跳过记录") + return False + + scheme_content = state.get("last_generated_scheme", "") or state.get("response", "") or "" + + spare_parts = await extract_spare_parts_from_scheme(scheme_content) + + system_name = "其他" + if device_name: + from tools.fault_statistics import get_device_system_from_neo4j + system_name = await get_device_system_from_neo4j(device_name) + + return await save_fault_record( + ship_number=ship_number, + device_name=device_name, + fault=fault, + spare_parts=spare_parts, + system_name=system_name + ) + + +async def query_fault_records( + ship_number: Optional[str] = None, + device_name: Optional[str] = None, + system_name: Optional[str] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + limit: int = 1000 +) -> List[Dict[str, Any]]: + """ + 从数据库查询故障记录 + + Args: + ship_number: 舷号过滤 + device_name: 设备名称过滤 + system_name: 系统名称过滤 + start_date: 开始日期(格式:YYYY-MM-DD) + end_date: 结束日期(格式:YYYY-MM-DD) + limit: 返回记录数上限 + + Returns: + 故障记录列表 + """ + try: + from psycopg_pool import AsyncConnectionPool + except ImportError: + print("[fault_record_db] psycopg_pool 未安装,无法查询") + return [] + + from datetime import timedelta + + conditions = [] + params = [] + idx = 1 + + if ship_number: + conditions.append(f"ship_number = %s") + params.append(ship_number) + + if device_name: + conditions.append(f"device_name = %s") + params.append(device_name) + + if system_name: + conditions.append(f"system_name = %s") + params.append(system_name) + + if start_date: + try: + start_dt = datetime.strptime(start_date, "%Y-%m-%d") + conditions.append(f"created_at >= %s") + params.append(start_dt) + except ValueError: + pass + + if end_date: + try: + end_dt = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) - timedelta(seconds=1) + conditions.append(f"created_at <= %s") + params.append(end_dt) + except ValueError: + pass + + where_clause = " AND ".join(conditions) if conditions else "1=1" + + try: + async with AsyncConnectionPool( + POSTGRES_CONNECTION_STRING, + kwargs={"autocommit": True} + ) as pool: + async with pool.connection() as conn: + async with conn.cursor() as cur: + sql = f""" + SELECT id, ship_number, device_name, fault, spare_parts, system_name, created_at + FROM fault_records + WHERE {where_clause} + ORDER BY created_at DESC + LIMIT %s + """ + params.append(limit) + await cur.execute(sql, params) + rows = await cur.fetchall() + columns = ["id", "ship_number", "device_name", "fault", "spare_parts", "system_name", "created_at"] + results = [] + for row in rows: + record = dict(zip(columns, row)) + if isinstance(record.get("spare_parts"), str): + try: + record["spare_parts"] = json.loads(record["spare_parts"]) + except Exception: + record["spare_parts"] = [] + if isinstance(record.get("created_at"), datetime): + record["created_at"] = record["created_at"].isoformat() + results.append(record) + return results + except Exception as e: + print(f"[fault_record_db] 查询故障记录失败: {str(e)}") + import traceback + traceback.print_exc() + return [] + diff --git a/tools/fault_statistics.py b/tools/fault_statistics.py new file mode 100644 index 0000000..5b74be3 --- /dev/null +++ b/tools/fault_statistics.py @@ -0,0 +1,969 @@ +""" +故障类型统计工具模块 +用于统计故障诊断智能体记录的故障信息 +数据来源:PostgreSQL fault_records 表 +""" +import os +import re +import json +import httpx +from datetime import datetime, timedelta +from typing import Dict, Any, Optional, List +from difflib import SequenceMatcher +import asyncio + +from langchain_core.tools import tool +from utils.function_tracker import track_function_calls +from config import REPAIR_FEEDBACK_CONFIG + +neo4j_cache = {} + + +def fuzzy_match_system(user_input: str, system_name: str, threshold: float = 0.6) -> bool: + if not user_input or not system_name: + return False + + user_input = user_input.strip().lower() + system_name = system_name.strip().lower() + + if user_input == system_name: + return True + + if user_input in system_name or system_name in user_input: + return True + + similarity = SequenceMatcher(None, user_input, system_name).ratio() + + return similarity >= threshold + + +async def get_device_system_from_neo4j(device_name: str) -> str: + """ + 从 Neo4j 图谱中查询设备对应的系统(调用外部接口),带缓存机制 + """ + if not device_name or not device_name.strip(): + return "其他" + + device_name_clean = device_name.strip().lower() + + if device_name_clean in neo4j_cache: + return neo4j_cache[device_name_clean] + + from config import DEVICE_SYSTEM_CONFIG + + endpoint = DEVICE_SYSTEM_CONFIG.get("endpoint") + timeout = DEVICE_SYSTEM_CONFIG.get("timeout", 30.0) + min_hops = DEVICE_SYSTEM_CONFIG.get("default_min_hops", 2) + max_hops = DEVICE_SYSTEM_CONFIG.get("default_max_hops", 8) + top_k = DEVICE_SYSTEM_CONFIG.get("default_top_k", 10) + + if not endpoint: + result = "其他" + neo4j_cache[device_name_clean] = result + return result + + try: + payload = { + "device_name": device_name.strip(), + "min_hops": min_hops, + "max_hops": max_hops, + "top_k": top_k + } + + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.post( + endpoint, + json=payload, + headers={"Content-Type": "application/json"} + ) + + if response.status_code != 200: + result = "其他" + neo4j_cache[device_name_clean] = result + return result + + result_json = response.json() + + if not result_json.get("success", False): + result = "其他" + neo4j_cache[device_name_clean] = result + return result + + systems = result_json.get("systems", []) + if not systems or not isinstance(systems, list): + result = "其他" + neo4j_cache[device_name_clean] = result + return result + + first_system = systems[0] + if isinstance(first_system, dict): + system_name = first_system.get("name") or first_system.get("系统名称") or first_system.get("名称") + if system_name: + result = system_name.strip() + neo4j_cache[device_name_clean] = result + return result + + result = "其他" + neo4j_cache[device_name_clean] = result + return result + + except Exception as e: + print(f"查询设备系统失败: {str(e)}") + result = "其他" + neo4j_cache[device_name_clean] = result + return result + + +def generate_color_palette(n: int) -> List[str]: + predefined_colors = [ + "#00C2FF", "#4E7CFE", "#9153FF", "#FFB800", "#00D16B", + "#FF6B6B", "#4ECDC4", "#45B7D1", "#FFA07A", "#98D8C8", + "#F7DC6F", "#BB8FCE", "#85C1E9", "#F8B500", "#00E676", + "#FF4081", "#7C4DFF", "#18FFFF", "#69F0AE", "#FFD740" + ] + + if n <= len(predefined_colors): + return predefined_colors[:n] + + colors = predefined_colors.copy() + + import colorsys + for i in range(len(predefined_colors), n): + hue = (i * 0.618033988749895) % 1.0 + saturation = 0.7 + (i % 3) * 0.1 + value = 0.8 + (i % 2) * 0.1 + + rgb = colorsys.hsv_to_rgb(hue, saturation, value) + color = "#{:02X}{:02X}{:02X}".format( + int(rgb[0] * 255), + int(rgb[1] * 255), + int(rgb[2] * 255) + ) + colors.append(color) + + return colors + + +async def _query_fault_records_from_db( + ship_number: Optional[str] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + system_name: Optional[str] = None, + limit: int = 5000 +) -> List[Dict[str, Any]]: + """ + 从数据库查询故障记录(内部共用方法) + """ + from tools.fault_record_db import query_fault_records + return await query_fault_records( + ship_number=ship_number, + system_name=system_name, + start_date=start_date, + end_date=end_date, + limit=limit + ) + + +async def statistics_fault_types( + ship_number_filter: Optional[str] = None +) -> Dict[str, Any]: + """ + 统计故障类型(从数据库读取),按系统分类计算百分比 + + Args: + ship_number_filter: 舷号过滤(可选) + + Returns: + 包含统计结果的字典 + """ + try: + records = await _query_fault_records_from_db(ship_number=ship_number_filter) + + if not records: + return { + "success": True, + "faultTypeData": [] + } + + system_totals = {} + valid_count = 0 + + for record in records: + system_name = record.get("system_name", "其他") + if not system_name: + system_name = "其他" + valid_count += 1 + + if system_name not in system_totals: + system_totals[system_name] = 0 + system_totals[system_name] += 1 + + fault_type_data = [] + if valid_count > 0: + unique_systems = sorted(system_totals.keys()) + colors = generate_color_palette(len(unique_systems)) + system_color_map = {system: color for system, color in zip(unique_systems, colors)} + + for system_name, count in system_totals.items(): + percentage = round((count / valid_count) * 100) + color = system_color_map.get(system_name, "#6E7D91") + fault_type_data.append({ + "value": percentage, + "name": system_name, + "itemStyle": { + "color": color + } + }) + + fault_type_data.sort(key=lambda x: x["value"], reverse=True) + + return { + "success": True, + "faultTypeData": fault_type_data + } + + except Exception as e: + print(f"[故障类型统计错误] {str(e)}") + import traceback + traceback.print_exc() + return { + "success": False, + "faultTypeData": [] + } + + +@tool +async def fault_type_statistics_tool( + ship_number_filter: Optional[str] = None +) -> Dict[str, Any]: + """ + 故障类型统计工具(供工作流调用) + 统计故障类型,按系统分类并计算百分比 + + Args: + ship_number_filter: 舷号过滤(可选) + + Returns: + 统计结果,包含 success 和 faultTypeData 字段 + """ + result = await statistics_fault_types(ship_number_filter=ship_number_filter) + return result + + +async def merge_similar_faults_with_embedding( + fault_counter: Dict[str, int], + fault_system_map: Dict[str, str], + similarity_threshold: float = 0.85 +) -> tuple: + """ + 使用embedding合并相似的故障 + """ + if not fault_counter: + return {}, {}, {} + + fault_keys = list(fault_counter.keys()) + + if len(fault_keys) <= 1: + return fault_counter.copy(), fault_system_map.copy(), {} + + try: + from modelsAPI.model_api import OpenaiAPI + + embeddings = await OpenaiAPI.get_embeddings_batch_async(fault_keys) + + fault_embedding_map = {key: emb for key, emb in zip(fault_keys, embeddings)} + + merged_counter = {} + merged_system_map = {} + merge_mapping = {} + merged_keys = set() + + sorted_faults = sorted(fault_counter.items(), key=lambda x: x[1], reverse=True) + + for fault_key, count in sorted_faults: + if fault_key in merged_keys: + continue + + merged_keys.add(fault_key) + merged_counter[fault_key] = count + merged_system_map[fault_key] = fault_system_map.get(fault_key, "其他") + + for other_key, other_count in sorted_faults: + if other_key in merged_keys or other_key == fault_key: + continue + + if fault_embedding_map.get(fault_key) and fault_embedding_map.get(other_key): + similarity = OpenaiAPI.cosine_similarity( + fault_embedding_map[fault_key], + fault_embedding_map[other_key] + ) + + if similarity >= similarity_threshold: + merged_counter[fault_key] += other_count + merged_keys.add(other_key) + merge_mapping[other_key] = fault_key + + return merged_counter, merged_system_map, merge_mapping + + except Exception as e: + print(f"Embedding合并失败,使用原始数据: {str(e)}") + return fault_counter.copy(), fault_system_map.copy(), {} + + +async def statistics_fault_frequency( + top_n: int = 10, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + system_filter: Optional[str] = None, + use_embedding_merge: bool = True, + similarity_threshold: float = 0.85 +) -> Dict[str, Any]: + """ + 统计故障发生次数(从数据库读取),按频率排序 + 统计所有故障的数据,但只返回top N展示在表格中 + + Args: + top_n: 返回前 N 个高频故障(默认为 10) + start_date: 开始日期(格式:YYYY-MM-DD,可选) + end_date: 结束日期(格式:YYYY-MM-DD,可选) + system_filter: 系统名称过滤(可选) + use_embedding_merge: 是否使用embedding合并相似故障(默认True) + similarity_threshold: 相似度阈值(默认0.85) + + Returns: + 包含统计结果的字典 + """ + try: + records = await _query_fault_records_from_db( + start_date=start_date, + end_date=end_date + ) + + if not records: + return { + "success": True, + "totalCount": 0, + "totalTypes": 0, + "highFreqFaults": [] + } + + fault_counter = {} + fault_system_map = {} + + for record in records: + device_name = record.get("device_name", "") + fault = record.get("fault", "") + system_name = record.get("system_name", "其他") + + if not device_name or not fault: + continue + + if system_filter and not fuzzy_match_system(system_filter, system_name): + continue + + fault_key = f"{device_name}{fault}" + + if fault_key not in fault_counter: + fault_counter[fault_key] = 0 + fault_system_map[fault_key] = system_name + + fault_counter[fault_key] += 1 + + if use_embedding_merge and len(fault_counter) > 1: + fault_counter, fault_system_map, merge_mapping = await merge_similar_faults_with_embedding( + fault_counter, + fault_system_map, + similarity_threshold + ) + + if merge_mapping: + print(f"合并了 {len(merge_mapping)} 个相似故障") + + # 先计算全部数据的统计量 + total_count = sum(fault_counter.values()) + total_types = len(fault_counter) + + # 对全部数据排序 + sorted_faults_all = sorted(fault_counter.items(), key=lambda x: x[1], reverse=True) + + # 只取top N用于展示表格 + sorted_faults = sorted_faults_all[:top_n] + + high_freq_faults = [] + for rank, (fault_key, count) in enumerate(sorted_faults, start=1): + system_name = fault_system_map.get(fault_key, "其他") + + high_freq_faults.append({ + "rank": rank, + "name": fault_key, + "system": system_name, + "count": count + }) + + return { + "success": True, + "totalCount": total_count, + "totalTypes": total_types, + "highFreqFaults": high_freq_faults + } + + except Exception as e: + print(f"[故障频率统计错误] {str(e)}") + import traceback + traceback.print_exc() + return { + "success": False, + "highFreqFaults": [] + } + + +@tool +async def fault_frequency_statistics_tool( + top_n: Optional[int] = 6, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + system_filter: Optional[str] = None +) -> Dict[str, Any]: + """ + 故障发生次数统计工具(供工作流调用) + 统计故障发生次数,按频率排序 + + Args: + top_n: 返回前 N 个高频故障(默认为 6) + start_date: 开始日期(格式:YYYY-MM-DD,可选) + end_date: 结束日期(格式:YYYY-MM-DD,可选) + system_filter: 系统名称过滤(可选) + + Returns: + 统计结果,包含 success 和 highFreqFaults 字段 + """ + result = await statistics_fault_frequency( + top_n=top_n or 10, + start_date=start_date, + end_date=end_date, + system_filter=system_filter + ) + + return result + + +async def statistics_spare_parts( + ship_number_filter: Optional[str] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + top_n: int = 6 +) -> Dict[str, Any]: + """ + 统计备件消耗(从数据库读取) + 统计所有备件的数据,但只返回top N展示在表格中 + + Args: + ship_number_filter: 舷号过滤(可选) + start_date: 开始日期(格式:YYYY-MM-DD,可选) + end_date: 结束日期(格式:YYYY-MM-DD,可选) + top_n: 表格中展示的前 N 个备件(默认为 5) + + Returns: + 包含统计结果的字典 + """ + try: + records = await _query_fault_records_from_db( + ship_number=ship_number_filter, + start_date=start_date, + end_date=end_date + ) + + if not records: + return { + "success": True, + "totalCount": 0, + "totalTypes": 0, + "sparePartData": { + "categories": [], + "system": [], + "counter": [] + } + } + + spare_part_counter = {} + spare_part_system_map = {} + + for record in records: + spare_parts = record.get("spare_parts", []) + system_name = record.get("system_name", "其他") + + if not spare_parts: + continue + + for part in spare_parts: + if not part or not part.strip(): + continue + + part_key = part.strip() + + if part_key not in spare_part_counter: + spare_part_counter[part_key] = 0 + spare_part_system_map[part_key] = system_name + + spare_part_counter[part_key] += 1 + + # 先计算全部数据的统计量 + total_count = sum(spare_part_counter.values()) + total_types = len(spare_part_counter) + + # 对全部数据排序 + sorted_parts_all = sorted(spare_part_counter.items(), key=lambda x: x[1], reverse=True) + + # 只取top N用于展示表格 + sorted_parts = sorted_parts_all[:top_n] + + categories = [] + systems = [] + counters = [] + + for part_key, count in sorted_parts: + categories.append(part_key) + systems.append(spare_part_system_map.get(part_key, "其他")) + counters.append(count) + + return { + "success": True, + "totalCount": total_count, + "totalTypes": total_types, + "sparePartData": { + "categories": categories, + "system": systems, + "counter": counters + } + } + + except Exception as e: + print(f"[备件统计错误] {str(e)}") + import traceback + traceback.print_exc() + return { + "success": False, + "sparePartData": { + "categories": [], + "system": [], + "counter": [] + } + } + + +@tool +async def spare_parts_statistics_tool( + ship_number: Optional[str] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + top_n: Optional[int] = 5 +) -> Dict[str, Any]: + """ + 备件消耗统计工具(供工作流调用) + 统计备件消耗情况 + + Args: + ship_number: 舷号过滤(可选) + start_date: 开始日期(格式:YYYY-MM-DD,可选) + end_date: 结束日期(格式:YYYY-MM-DD,可选) + top_n: 返回前 N 个备件(默认为 5) + + Returns: + 统计结果,包含 success 和 sparePartData 字段 + """ + result = await statistics_spare_parts( + ship_number_filter=ship_number, + start_date=start_date, + end_date=end_date, + top_n=top_n or 5 + ) + + return result + + +async def extract_device_from_text(text: str) -> Optional[str]: + """ + 从文本中提取设备名称(使用大模型) + """ + if not text or not text.strip(): + return None + + prompt = f"""从以下文本中提取设备名称: + +文本内容: +{text} + +请识别文本中提到的具体设备名称,如:发动机、压缩机、水泵、发电机、空调机组、液压泵等。 + +请以JSON格式返回: +{{"device": "设备名称"}} + +如果文本中没有提到任何设备,请返回 {{"device": null}} +只返回JSON,不要有其他内容。""" + + try: + from modelsAPI.model_api import OpenaiAPI + result_text = await OpenaiAPI.open_api_chat_without_thinking( + query=prompt, + json_output=True + ) + + result_text = result_text.strip() + result_text = re.sub(r'^```json\s*', '', result_text, flags=re.IGNORECASE) + result_text = re.sub(r'^```\s*', '', result_text) + result_text = re.sub(r'```$', '', result_text) + + json_match = re.search(r'\{.*\}', result_text, re.DOTALL) + if json_match: + result = json.loads(json_match.group(0)) + device = result.get("device") + if device and device != "null": + return str(device).strip() + + return None + + except Exception as e: + print(f"大模型提取设备名称失败: {str(e)}") + return None + + +async def classify_feedback_content(text: str) -> str: + """ + 对反馈内容进行分类(使用大模型) + """ + if not text or not text.strip(): + return "其他" + + prompt = f"""请对以下反馈内容进行分类。 + +反馈内容: +{text} + +分类类别(单选,选择最匹配的一个): +1. 信息过时 - 反映文档或手册中的信息已经过时,不再适用 +2. 步骤错误 - 反映操作步骤或流程存在错误 +3. 步骤缺失 - 反映缺少必要的操作步骤或流程说明 +4. 备件信息有误 - 反映备件型号、规格、数量等信息有误 +5. 安全提示不足 - 反映缺少必要的安全警示或注意事项 +6. 图示不清 - 反映图片、示意图不清晰或难以理解 + +请分析反馈内容,判断属于哪个类别,以JSON格式返回: +{{"category": "类别名称"}} + +如果反馈内容不属于以上任何类别,请返回 {{"category": "其他"}} +只返回JSON,不要有其他内容。""" + + try: + from modelsAPI.model_api import OpenaiAPI + result_text = await OpenaiAPI.open_api_chat_without_thinking( + query=prompt, + json_output=True + ) + + result_text = result_text.strip() + result_text = re.sub(r'^```json\s*', '', result_text, flags=re.IGNORECASE) + result_text = re.sub(r'^```\s*', '', result_text) + result_text = re.sub(r'```$', '', result_text) + + json_match = re.search(r'\{.*\}', result_text, re.DOTALL) + if json_match: + result = json.loads(json_match.group(0)) + category = result.get("category") + valid_categories = ["信息过时", "步骤错误", "步骤缺失", "备件信息有误", "安全提示不足", "图示不清", "其他"] + if category in valid_categories: + return category + + return "其他" + + except Exception as e: + print(f"大模型分类反馈内容失败: {str(e)}") + return "其他" + + +async def analyze_feedback_and_classify(feedback_text: str) -> Dict[str, Any]: + """ + 分析反馈内容并分类(主接口) + """ + if not feedback_text or not feedback_text.strip(): + return { + "success": False, + "device": None, + "system": None, + "category": "其他", + "error": "反馈内容为空" + } + + try: + device = await extract_device_from_text(feedback_text) + + system = "其他" + if device: + system = await get_device_system_from_neo4j(device) + + category = await classify_feedback_content(feedback_text) + + return { + "success": True, + "system": system, + "category": category + } + + except Exception as e: + return { + "success": False, + "system": None, + "category": "其他", + "error": str(e) + } + + +_feedback_token_cache = {"token": None, "expires_at": 0} + + +async def _get_feedback_api_token() -> Optional[str]: + """ + 获取维修反馈API的认证token(带缓存,避免每次请求都认证) + """ + import time + + if _feedback_token_cache["token"] and time.time() < _feedback_token_cache["expires_at"]: + return _feedback_token_cache["token"] + + base_url = REPAIR_FEEDBACK_CONFIG["url"] + email = REPAIR_FEEDBACK_CONFIG.get("email", "") + password = REPAIR_FEEDBACK_CONFIG.get("password", "") + + if not email or not password: + print("[维修反馈认证错误] 未配置认证凭据") + return None + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + signin_resp = await client.post( + f"{base_url}/api/v1/auths/signin", + json={"email": email, "password": password} + ) + + if signin_resp.status_code != 200: + print(f"[维修反馈认证错误] 登录失败,状态码: {signin_resp.status_code}") + return None + + token = signin_resp.json().get("token", "") + if not token: + print("[维修反馈认证错误] 响应中未找到token") + return None + + _feedback_token_cache["token"] = token + _feedback_token_cache["expires_at"] = time.time() + 3600 + + return token + + except Exception as e: + print(f"[维修反馈认证错误] {str(e)}") + return None + + +async def statistics_repair_feedbacks( + source: Optional[str] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + top_n: int = 10 +) -> Dict[str, Any]: + """ + 统计维修反馈数据(从外部API获取,带认证) + + Args: + source: 来源筛选(好评/差评/反馈),可选 + start_date: 开始日期(格式:YYYY-MM-DD),可选 + end_date: 结束日期(格式:YYYY-MM-DD),可选 + top_n: 返回前 N 条记录(默认为 10) + + Returns: + 包含统计结果的字典 + """ + try: + token = await _get_feedback_api_token() + if not token: + return { + "success": False, + "totalCount": 0, + "feedbackData": [], + "error": "API认证失败,无法获取token" + } + + base_url = REPAIR_FEEDBACK_CONFIG["url"] + endpoint = f"{base_url}/api/v1/repair-feedbacks/" + + params = {} + if source and source.strip(): + params["source"] = source.strip() + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + endpoint, + params=params, + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + "Authorization": f"Bearer {token}" + } + ) + + if response.status_code == 401 or response.status_code == 403: + _feedback_token_cache["token"] = None + _feedback_token_cache["expires_at"] = 0 + + token = await _get_feedback_api_token() + if token: + response = await client.get( + endpoint, + params=params, + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + "Authorization": f"Bearer {token}" + } + ) + + if response.status_code != 200: + print(f"[维修反馈统计错误] API返回状态码: {response.status_code}") + return { + "success": False, + "totalCount": 0, + "feedbackData": [], + "error": f"API请求失败,状态码: {response.status_code}" + } + + result_json = response.json() + + if isinstance(result_json, list): + feedback_list = result_json + elif isinstance(result_json, dict): + feedback_list = result_json.get("results", result_json.get("data", [])) + else: + feedback_list = [] + + if not feedback_list: + return { + "success": True, + "totalCount": 0, + "totalTypes": 0, + "feedbackData": [], + "sourceDistribution": {} + } + + start_dt = None + end_dt = None + if start_date: + try: + start_dt = datetime.strptime(start_date, "%Y-%m-%d") + except ValueError: + pass + if end_date: + try: + end_dt = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) - timedelta(seconds=1) + except ValueError: + pass + + if start_dt or end_dt: + filtered_list = [] + for item in feedback_list: + if not isinstance(item, dict): + continue + raw_created_at = item.get("created_at", "") + if not raw_created_at: + continue + try: + item_dt = datetime.fromtimestamp(int(raw_created_at)) + if start_dt and item_dt < start_dt: + continue + if end_dt and item_dt > end_dt: + continue + filtered_list.append(item) + except (ValueError, TypeError, OSError): + continue + feedback_list = filtered_list + + total_count = len(feedback_list) + + processed_data = [] + for idx, item in enumerate(feedback_list[:top_n], start=1): + if isinstance(item, dict): + raw_title = item.get("title", "") + raw_content = item.get("content", "") + raw_source = item.get("source", source or "") + raw_created_at = item.get("created_at", "") + raw_user_name = item.get("user_name", "") + + created_at_str = "" + if raw_created_at: + try: + ts = int(raw_created_at) + created_at_str = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") + except (ValueError, TypeError, OSError): + created_at_str = str(raw_created_at) + + processed_item = { + "rank": idx, + "title": str(raw_title) if raw_title else "", + "content": str(raw_content)[:200] if raw_content else "", + "source": str(raw_source) if raw_source else "未知", + "created_at": created_at_str, + "user_name": str(raw_user_name) if raw_user_name else "" + } + processed_data.append(processed_item) + + source_counter = {} + for item in processed_data: + s = item.get("source", "未知") + source_counter[s] = source_counter.get(s, 0) + 1 + + return { + "success": True, + "totalCount": total_count, + "totalTypes": len(feedback_list), + "sourceDistribution": source_counter, + "feedbackData": processed_data + } + + except Exception as e: + print(f"[维修反馈统计错误] {str(e)}") + import traceback + traceback.print_exc() + return { + "success": False, + "totalCount": 0, + "feedbackData": [], + "error": str(e) + } + + +@tool +async def repair_feedback_statistics_tool( + source: Optional[str] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + top_n: Optional[int] = 10 +) -> Dict[str, Any]: + """ + 维修反馈统计工具(供工作流调用) + 统计维修反馈情况,支持按来源和时间筛选 + + Args: + source: 来源筛选(可选),如:好评、差评、反馈 + start_date: 开始日期(格式:YYYY-MM-DD),可选 + end_date: 结束日期(格式:YYYY-MM-DD),可选 + top_n: 返回前 N 条记录(默认为 10) + + Returns: + 统计结果,包含 success 和 feedbackData 字段 + """ + result = await statistics_repair_feedbacks( + source=source, + start_date=start_date, + end_date=end_date, + top_n=top_n or 10 + ) + + return result + diff --git a/tools/function_tool.py b/tools/function_tool.py new file mode 100644 index 0000000..8e08a74 --- /dev/null +++ b/tools/function_tool.py @@ -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] +} diff --git a/tools/graph_tools.py b/tools/graph_tools.py new file mode 100644 index 0000000..8d17a1a --- /dev/null +++ b/tools/graph_tools.py @@ -0,0 +1,1792 @@ +""" +图谱检索工具模块 +包含图谱搜索、故障图谱检索、操作图谱检索等功能 +""" +import re +import json +import asyncio +import httpx +from difflib import SequenceMatcher +from typing import Optional, Dict, Any, List +from neo4j import AsyncGraphDatabase +from langchain_core.tools import tool + +from config import ATLAS_CONFIG, GRAPH_SEARCH_CONFIG, NEO4J_CONFIG +from modelsAPI.model_api import OpenaiAPI +from utils.function_tracker import track_function_calls + + +GRAPH_SEARCH_ENDPOINT = GRAPH_SEARCH_CONFIG.get("search_endpoint") + + +@track_function_calls +async def graph_search( + query: str, + top_k: Optional[int] = 120, + use_rerank: Optional[bool] = False, + fulltext_top_k: Optional[int] = 15, + rerank_top_k: Optional[int] = 15, + hops: Optional[int] = 3, + verbose: Optional[bool] = False +) -> Dict[str, Any]: + """ + 图谱检索工具 + """ + if not query or not query.strip(): + return {"success": False, "error": "查询文本不能为空"} + + query = query.strip() + + payload = { + "query": query, + "use_rerank": use_rerank, + "top_k": top_k, + "fulltext_top_k": fulltext_top_k, + "rerank_top_k": rerank_top_k, + "hops": hops, + "verbose": verbose + } + + headers = { + "accept": "application/json", + "Content-Type": "application/json" + } + + try: + async with httpx.AsyncClient(timeout=180.0, verify=False) as client: + response = await client.post(GRAPH_SEARCH_ENDPOINT, json=payload, headers=headers) + except (httpx.ReadTimeout, httpx.ConnectTimeout, httpx.RequestError) as e: + return {"success": False, "error": f"请求超时: {str(e)}"} + + if response.status_code != 200: + return {"success": False, "error": f"HTTP {response.status_code}"} + + result = response.json() + if result.get("code") != 200: + msg = result.get("message", "未知错误") + return {"success": False, "error": msg} + + data = result.get("data", {}) or {} + + try: + graph_data = [] + results_text = "" + + if isinstance(data, dict): + raw_nodes = data.get("nodes", []) + _filter_searchable_labels(raw_nodes) + # 过滤 nodes 的 properties 字段,仅保留"名称"属性 + for node in raw_nodes: + if isinstance(node, dict): + props = node.get("properties", {}) or node.get("props", {}) + name_value = props.get("名称", props.get("name", "")) + node.pop("properties", None) + node.pop("props", None) + if name_value: + node["properties"] = {"名称": name_value} + graph_item = { + "nodes": raw_nodes, + "links": data.get("links", []), + } + graph_item = apply_styles(graph_item) + graph_data.append(graph_item) + results_text = data.get("results", "") + elif isinstance(data, list): + parts = [] + for item in data: + if isinstance(item, dict): + raw_nodes = item.get("nodes", []) + _filter_searchable_labels(raw_nodes) + # 过滤 nodes 的 properties 字段,仅保留"名称"属性 + for node in raw_nodes: + if isinstance(node, dict): + props = node.get("properties", {}) or node.get("props", {}) + name_value = props.get("名称", props.get("name", "")) + node.pop("properties", None) + node.pop("props", None) + if name_value: + node["properties"] = {"名称": name_value} + graph_item = { + "nodes": raw_nodes, + "links": item.get("links", []), + } + graph_item = apply_styles(graph_item) + graph_data.append(graph_item) + if item.get("results"): + parts.append(item["results"]) + results_text = "\n".join(parts) + + for graph_item in graph_data: + links = graph_item.get("links", []) + for link in links: + link.pop("properties", None) + + return { + "success": True, + "xxxx.graph": graph_data, + "results": results_text, + } + + except Exception as e: + return {"success": False, "error": str(e)} + + +_MATCH_PUNCT_RE = re.compile(r"[\s\-_·/\\|,,。;;::()()\[\]【】{}<>《》\"'“”‘’]+") +_DEVICE_SUFFIX_RE = re.compile(r"(设备|装置|系统|组件|部件|总成|单元|机构|模块|装配|船用|舰用)$") + + +def _normalize_match_text(text: str) -> str: + """归一化节点名称,降低空格、标点、常见后缀造成的误差。""" + if not text: + return "" + normalized = _MATCH_PUNCT_RE.sub("", str(text).strip().lower()) + prev = None + while normalized and normalized != prev: + prev = normalized + normalized = _DEVICE_SUFFIX_RE.sub("", normalized) + return normalized + + +def _string_similarity(a: str, b: str) -> float: + """ + 简单字符串相似度:综合长度差、包含关系和公共字符比例。 + 0~1 之间,越大越相似。 + """ + if not a or not b: + return 0.0 + a = _normalize_match_text(a) + b = _normalize_match_text(b) + 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.95 + + 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 + seq_ratio = SequenceMatcher(None, a, b).ratio() + + len_diff = abs(len(a) - len(b)) + len_penalty = max(0.0, 1.0 - len_diff / max(len(a), len(b), 1)) + + return 0.45 * seq_ratio + 0.35 * jaccard + 0.20 * len_penalty + + +def _coerce_embedding_vector(value: Any) -> Optional[List[float]]: + """把 Neo4j 里的 embedding 统一成 float 列表;无法解析时返回 None。""" + if value is None: + return None + if isinstance(value, str): + try: + value = json.loads(value) + except json.JSONDecodeError: + return None + if not isinstance(value, (list, tuple)) or not value: + return None + try: + return [float(v) for v in value] + except (TypeError, ValueError): + return None + + +def _candidate_display_name(candidate: Dict[str, Any], name_key: str = "display_name") -> str: + name = candidate.get(name_key) or "" + if name: + return str(name) + props = candidate.get("props") or {} + return str(props.get("name") or props.get("名称") or props.get("设备名称") or "") + + +async def _safe_hybrid_match_with_embeddings( + query_text: str, + candidates: List[Dict[str, Any]], + name_key: str = "display_name", + embedding_key: str = "embedding", + text_weight: float = 0.45, + semantic_weight: float = 0.55, + min_text_score: float = 0.36, + min_combined_score: float = 0.43, +) -> tuple: + """ + 先用名称相似度兜底,再叠加 embedding。 + embedding 缺失、维度不一致或服务异常时,不让匹配流程失败。 + """ + if not candidates: + return None, 0.0 + + query_embedding = None + try: + query_embedding = _coerce_embedding_vector(await OpenaiAPI.get_embeddings_async(query_text)) + except Exception: + query_embedding = None + + scored_candidates = [] + has_usable_semantic = False + for idx, candidate in enumerate(candidates): + name = _candidate_display_name(candidate, name_key=name_key) + text_score = _string_similarity(query_text, name) + + semantic_score = None + candidate_embedding = _coerce_embedding_vector( + (candidate.get("props") or {}).get(embedding_key) or candidate.get(embedding_key) + ) + if query_embedding and candidate_embedding and len(query_embedding) == len(candidate_embedding): + semantic_score = OpenaiAPI.cosine_similarity(query_embedding, candidate_embedding) + has_usable_semantic = True + + if semantic_score is None: + combined_score = text_score + else: + combined_score = text_weight * text_score + semantic_weight * semantic_score + if text_score >= 0.82: + combined_score = max(combined_score, text_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"], + x["text_score"], + x["semantic_score"] if x["semantic_score"] is not None else -1.0, + -x["index"], + ), + reverse=True, + ) + + best = scored_candidates[0] + if best["text_score"] >= 0.82: + return best["candidate"], best["combined_score"] + if has_usable_semantic and best["combined_score"] >= min_combined_score: + return best["candidate"], best["combined_score"] + if best["text_score"] >= min_text_score: + return best["candidate"], best["text_score"] + return None, best["combined_score"] + + +async def _match_scoped_nodes_with_graph_indexes( + session, + query_text: str, + fulltext_index: str, + vector_index: str, + candidate_ids: Optional[List[Any]] = None, + fallback_candidates: Optional[List[Dict[str, Any]]] = None, + fulltext_top_k: int = 50, + vector_top_k: int = 50, + text_weight: float = 0.20, + fulltext_weight: float = 0.35, + vector_weight: float = 0.45, + min_text_score: float = 0.34, + min_combined_score: float = 0.42, + fallback_text_weight: float = 0.3, + fallback_semantic_weight: float = 0.7, +) -> tuple: + """ + 使用 Neo4j 全文索引和向量索引在候选范围内匹配节点。 + 索引无结果或得分不足时,退回到原有 OpenaiAPI.hybrid_match_with_embeddings。 + """ + query_text = str(query_text or "").strip() + if not query_text: + return None, 0.0, {"match_source": "empty_query"} + + ids_filter = candidate_ids if candidate_ids else None + indexed_candidates: Dict[Any, Dict[str, Any]] = {} + + try: + fulltext_query = query_text.replace("\\", "\\\\").replace('"', '\\"') + fulltext_cypher = """ + CALL db.index.fulltext.queryNodes($fulltext_index, $query) + YIELD node, score + WHERE $candidate_ids IS NULL OR id(node) IN $candidate_ids + WITH node, score + ORDER BY score DESC + LIMIT $top_k + RETURN + id(node) AS id, + labels(node) AS labels, + properties(node) AS props, + coalesce(node.名称, node.name, node.设备名称) AS display_name, + node.embedding AS embedding, + score AS fulltext_score + """ + fulltext_records = await session.run( + fulltext_cypher, + fulltext_index=fulltext_index, + query=fulltext_query.strip() or query_text, + candidate_ids=ids_filter, + top_k=fulltext_top_k, + ) + async for record in fulltext_records: + item = _node_record_to_dict(record) + item["fulltext_score"] = float(record.get("fulltext_score") or 0.0) + indexed_candidates[item["id"]] = item + except Exception as e: + print(f"[图谱索引匹配] 全文索引 {fulltext_index} 检索失败: {str(e)}") + + query_embedding = None + try: + query_embedding = _coerce_embedding_vector(await OpenaiAPI.get_embeddings_async(query_text)) + except Exception: + query_embedding = None + + if query_embedding: + try: + vector_cypher = """ + CALL db.index.vector.queryNodes($vector_index, $top_k, $embedding) + YIELD node, score + WHERE $candidate_ids IS NULL OR id(node) IN $candidate_ids + RETURN + id(node) AS id, + labels(node) AS labels, + properties(node) AS props, + coalesce(node.名称, node.name, node.设备名称) AS display_name, + node.embedding AS embedding, + score AS vector_score + """ + vector_records = await session.run( + vector_cypher, + vector_index=vector_index, + top_k=vector_top_k, + embedding=query_embedding, + candidate_ids=ids_filter, + ) + async for record in vector_records: + item = _node_record_to_dict(record) + item["vector_score"] = float(record.get("vector_score") or 0.0) + existing = indexed_candidates.get(item["id"], item) + existing["vector_score"] = item["vector_score"] + if not existing.get("display_name") and item.get("display_name"): + existing["display_name"] = item["display_name"] + indexed_candidates[item["id"]] = existing + except Exception as e: + print(f"[图谱索引匹配] 向量索引 {vector_index} 检索失败: {str(e)}") + + if fallback_candidates and indexed_candidates: + fallback_by_id = { + item.get("id"): item + for item in fallback_candidates + if item.get("id") is not None + } + for candidate_id, candidate in indexed_candidates.items(): + fallback_item = fallback_by_id.get(candidate_id) + if not fallback_item: + continue + if not candidate.get("display_name") and fallback_item.get("display_name"): + candidate["display_name"] = fallback_item["display_name"] + if "real_device_id" in fallback_item and "real_device_id" not in candidate: + candidate["real_device_id"] = fallback_item["real_device_id"] + if "real_device_name" in fallback_item and "real_device_name" not in candidate: + candidate["real_device_name"] = fallback_item["real_device_name"] + + if indexed_candidates: + max_fulltext_score = max( + [c.get("fulltext_score", 0.0) for c in indexed_candidates.values()] or [0.0] + ) + scored = [] + for candidate in indexed_candidates.values(): + name = _candidate_display_name(candidate) + text_score = _string_similarity(query_text, name) + fulltext_score = candidate.get("fulltext_score", 0.0) + fulltext_norm = fulltext_score / max_fulltext_score if max_fulltext_score > 0 else 0.0 + vector_score = candidate.get("vector_score", 0.0) + combined_score = ( + text_weight * text_score + + fulltext_weight * fulltext_norm + + vector_weight * vector_score + ) + if text_score >= 0.82: + combined_score = max(combined_score, text_score) + scored.append({ + "candidate": candidate, + "text_score": text_score, + "fulltext_score": fulltext_score, + "fulltext_norm": fulltext_norm, + "vector_score": vector_score, + "combined_score": combined_score, + }) + + scored.sort( + key=lambda x: ( + x["combined_score"], + x["text_score"], + x["vector_score"], + x["fulltext_norm"], + ), + reverse=True, + ) + best = scored[0] + if ( + best["text_score"] >= 0.82 + or best["combined_score"] >= min_combined_score + or best["text_score"] >= min_text_score + ): + return best["candidate"], best["combined_score"], { + "match_source": "neo4j_index", + "text_score": best["text_score"], + "fulltext_score": best["fulltext_score"], + "vector_score": best["vector_score"], + } + + if fallback_candidates: + node, score = await OpenaiAPI.hybrid_match_with_embeddings( + query_text=query_text, + candidates=fallback_candidates, + name_key="display_name", + embedding_key="embedding", + text_weight=fallback_text_weight, + semantic_weight=fallback_semantic_weight, + ) + return node, score, {"match_source": "fallback"} + + best_score = max([c.get("combined_score", 0.0) for c in indexed_candidates.values()] or [0.0]) + return None, best_score, {"match_source": "none"} + + +def _pick_best_by_name(candidates: List[Dict[str, Any]], target_name: str) -> Optional[Dict[str, Any]]: + """ + 在候选节点中,根据名称相似度选择最优节点。 + 约定每个节点为:{id, labels, props, display_name} + """ + if not candidates or not target_name: + return None + + best = None + best_score = 0.0 + for item in candidates: + name = (item.get("display_name") or "").strip() + if not name: + props = item.get("props") or {} + name = props.get("name") or props.get("名称") or props.get("设备名称") or "" + score = _string_similarity(target_name, name) + if score > best_score: + best_score = score + best = item + + return best if best_score >= 0.3 else None + + +def _extract_mt_codes(text: str) -> List[str]: + """ + 从文本中抽取形如 MT007 的维修项目编号(MT + 3位数字)。 + """ + if not text: + return [] + codes = re.findall(r"\bMT\d{3}\b", str(text)) + seen = set() + ordered: List[str] = [] + for c in codes: + if c not in seen: + seen.add(c) + ordered.append(c) + return ordered + + +def _node_record_to_dict(record: Any) -> Dict[str, Any]: + """ + 将 Cypher 返回的 id/labels/props 结构统一为字典。 + """ + if hasattr(record, "data"): + data = record.data() + else: + data = record or {} + result = { + "id": data.get("id"), + "labels": data.get("labels") or [], + "props": data.get("props") or {}, + "display_name": data.get("display_name") + if "display_name" in data + else None, + } + if "embedding" in data: + result["embedding"] = data.get("embedding") + if "real_device_id" in data: + result["real_device_id"] = data.get("real_device_id") + if "real_device_name" in data: + result["real_device_name"] = data.get("real_device_name") + return result + + +@track_function_calls +async def normalize_device_name_by_ship_graph( + device_name: str, + fulltext_index: str = "设备_fulltext", + vector_index: str = "设备_vector", + fulltext_top_k: int = 50, + vector_top_k: int = 50, + min_text_score: float = 0.34, + min_combined_score: float = 0.42, +) -> Dict[str, Any]: + """ + 标准化设备名称 + 标准化失败时返回 success=False,由调用方继续使用原始设备名。 + """ + original_device_name = str(device_name or "").strip() + if not original_device_name: + return {"success": False, "device_name": original_device_name, "error": "设备名称不能为空"} + + uri = NEO4J_CONFIG.get("uri") + user = NEO4J_CONFIG.get("user") + password = NEO4J_CONFIG.get("password") + if not uri or not user or not password: + return { + "success": False, + "device_name": original_device_name, + "error": "Neo4j 配置不完整,请检查 NEO4J_CONFIG", + } + + try: + async with AsyncGraphDatabase.driver(uri, auth=(user, password)) as driver: + async with driver.session() as session: + ship_query = """ + MATCH (s:舰艇) + RETURN DISTINCT + id(s) AS id, + labels(s) AS labels, + properties(s) AS props, + coalesce(s.名称, s.name, s.舷号) AS display_name + LIMIT 2 + """ + ship_records = await session.run(ship_query) + ship_candidates = [_node_record_to_dict(r) async for r in ship_records] + if len(ship_candidates) != 1: + return { + "success": False, + "device_name": original_device_name, + "error": f"当前图谱舰艇节点数量不是唯一值: {len(ship_candidates)}", + } + + ship_node = ship_candidates[0] + ship_id = ship_node["id"] + ship_name = _candidate_display_name(ship_node) + + scope_query = """ + MATCH (ship:舰艇) + WHERE id(ship) = $ship_id + MATCH (ship)-[:包含*1..6]->(n:设备) + WITH DISTINCT n, coalesce(n.名称, n.name, n.设备名称) AS nm + WHERE nm IS NOT NULL + RETURN collect(id(n)) AS device_ids + """ + scope_records = await session.run(scope_query, ship_id=ship_id) + scope_row = await scope_records.single() + device_ids = scope_row.get("device_ids") if scope_row else [] + if not device_ids: + return { + "success": False, + "device_name": original_device_name, + "ship_name": ship_name, + "error": "唯一舰艇节点下未找到设备候选节点", + } + + async def _fallback_match(reason: str = "") -> Dict[str, Any]: + device_query = """ + MATCH (n:设备) + WHERE id(n) IN $device_ids + WITH DISTINCT n, coalesce(n.名称, n.name, n.设备名称) AS nm + WHERE nm IS NOT NULL + RETURN + id(n) AS id, + labels(n) AS labels, + properties(n) AS props, + nm AS display_name, + n.embedding AS embedding + LIMIT 2000 + """ + device_records = await session.run(device_query, device_ids=device_ids) + device_candidates = [_node_record_to_dict(r) async for r in device_records] + if not device_candidates: + return { + "success": False, + "device_name": original_device_name, + "ship_name": ship_name, + "error": "唯一舰艇节点下未找到设备候选节点", + } + + device_node, device_score = await _safe_hybrid_match_with_embeddings( + query_text=original_device_name, + candidates=device_candidates, + name_key="display_name", + embedding_key="embedding", + text_weight=0.45, + semantic_weight=0.55, + min_text_score=min_text_score, + min_combined_score=min_combined_score, + ) + if not device_node: + return { + "success": False, + "device_name": original_device_name, + "ship_name": ship_name, + "best_score": device_score, + "error": f"未找到足够匹配的设备节点(最高得分: {device_score:.3f})", + } + + matched_name = _candidate_display_name(device_node) or original_device_name + return { + "success": True, + "device_name": matched_name, + "original_device_name": original_device_name, + "ship_name": ship_name, + "score": device_score, + "matched_node_id": device_node.get("id"), + "match_source": "fallback", + "fallback_reason": reason, + } + + query_embedding = None + try: + query_embedding = _coerce_embedding_vector( + await OpenaiAPI.get_embeddings_async(original_device_name) + ) + except Exception: + query_embedding = None + + indexed_candidates: Dict[Any, Dict[str, Any]] = {} + + try: + fulltext_query = original_device_name.replace("\\", "\\\\").replace('"', '\\"') + fulltext_query = fulltext_query.strip() or original_device_name + fulltext_cypher = """ + CALL db.index.fulltext.queryNodes($fulltext_index, $query) + YIELD node, score + WHERE id(node) IN $device_ids + WITH node, score + ORDER BY score DESC + LIMIT $top_k + RETURN + id(node) AS id, + labels(node) AS labels, + properties(node) AS props, + coalesce(node.名称, node.name, node.设备名称) AS display_name, + node.embedding AS embedding, + score AS fulltext_score + """ + fulltext_records = await session.run( + fulltext_cypher, + fulltext_index=fulltext_index, + query=fulltext_query, + device_ids=device_ids, + top_k=fulltext_top_k, + ) + async for record in fulltext_records: + item = _node_record_to_dict(record) + item["fulltext_score"] = float(record.get("fulltext_score") or 0.0) + indexed_candidates[item["id"]] = item + except Exception as e: + print(f"[设备标准化] 全文索引检索失败: {str(e)}") + + if query_embedding: + try: + vector_cypher = """ + CALL db.index.vector.queryNodes($vector_index, $top_k, $embedding) + YIELD node, score + WHERE id(node) IN $device_ids + RETURN + id(node) AS id, + labels(node) AS labels, + properties(node) AS props, + coalesce(node.名称, node.name, node.设备名称) AS display_name, + node.embedding AS embedding, + score AS vector_score + """ + vector_records = await session.run( + vector_cypher, + vector_index=vector_index, + top_k=vector_top_k, + embedding=query_embedding, + device_ids=device_ids, + ) + async for record in vector_records: + item = _node_record_to_dict(record) + item["vector_score"] = float(record.get("vector_score") or 0.0) + existing = indexed_candidates.get(item["id"], item) + existing["vector_score"] = item["vector_score"] + if not existing.get("display_name") and item.get("display_name"): + existing["display_name"] = item["display_name"] + indexed_candidates[item["id"]] = existing + except Exception as e: + print(f"[设备标准化] 向量索引检索失败: {str(e)}") + + if not indexed_candidates: + return await _fallback_match("索引未召回设备候选") + + max_fulltext_score = max( + [c.get("fulltext_score", 0.0) for c in indexed_candidates.values()] or [0.0] + ) + scored = [] + for candidate in indexed_candidates.values(): + name = _candidate_display_name(candidate) + text_score = _string_similarity(original_device_name, name) + fulltext_score = candidate.get("fulltext_score", 0.0) + fulltext_norm = fulltext_score / max_fulltext_score if max_fulltext_score > 0 else 0.0 + vector_score = candidate.get("vector_score", 0.0) + combined_score = ( + 0.20 * text_score + + 0.35 * fulltext_norm + + 0.45 * vector_score + ) + if text_score >= 0.82: + combined_score = max(combined_score, text_score) + scored.append({ + "candidate": candidate, + "text_score": text_score, + "fulltext_score": fulltext_score, + "fulltext_norm": fulltext_norm, + "vector_score": vector_score, + "combined_score": combined_score, + }) + + scored.sort( + key=lambda x: ( + x["combined_score"], + x["text_score"], + x["vector_score"], + x["fulltext_norm"], + ), + reverse=True, + ) + best = scored[0] + best_candidate = best["candidate"] + if ( + best["text_score"] < 0.82 + and best["combined_score"] < min_combined_score + and best["text_score"] < min_text_score + ): + return await _fallback_match( + f"索引最高得分不足: {best['combined_score']:.3f}" + ) + + matched_name = _candidate_display_name(best_candidate) or original_device_name + return { + "success": True, + "device_name": matched_name, + "original_device_name": original_device_name, + "ship_name": ship_name, + "score": best["combined_score"], + "matched_node_id": best_candidate.get("id"), + "match_source": "neo4j_index", + "text_score": best["text_score"], + "fulltext_score": best["fulltext_score"], + "vector_score": best["vector_score"], + } + except Exception as e: + return { + "success": False, + "device_name": original_device_name, + "error": f"设备名称标准化失败: {str(e)}", + } + + +def _to_graph_display_node(node_dict: Dict[str, Any]) -> Dict[str, Any]: + """将内部节点格式转为 graph_search 展示用节点:id、name、label。""" + nid = node_dict.get("id") + props = node_dict.get("props") or {} + name = ( + node_dict.get("display_name") + or props.get("名称") + or props.get("name") + or str(nid) + ) + labels = [l for l in (node_dict.get("labels") or []) if l != "Searchable"] + label = labels[0] if labels else "" + return {"id": nid, "name": name, "label": label} + + +def _build_fault_graph_display( + ship_node: Optional[Dict[str, Any]], + device_node: Optional[Dict[str, Any]], + fault_mode_node: Optional[Dict[str, Any]], + repair_projects: List[Dict[str, Any]], +) -> tuple: + """ + 构建与 graph_search 一致的图谱展示结构:nodes 与 links。 + """ + nodes: List[Dict[str, Any]] = [] + links: List[Dict[str, Any]] = [] + seen_ids = set() + + def add_node(nd: Optional[Dict[str, Any]]): + if not nd or nd.get("id") in seen_ids: + return + seen_ids.add(nd["id"]) + nodes.append(_to_graph_display_node(nd)) + + def add_link(source_id: Any, target_id: Any, rel_type: str = ""): + links.append({"source": source_id, "target": target_id, "type": rel_type}) + + if ship_node: + add_node(ship_node) + if device_node: + add_node(device_node) + if ship_node: + add_link(ship_node["id"], device_node["id"], "包含") + if fault_mode_node: + add_node(fault_mode_node) + if device_node: + add_link(device_node["id"], fault_mode_node["id"], "存在") + for p in repair_projects or []: + add_node(p) + if device_node: + add_link(device_node["id"], p["id"], "维修时使用") + + return nodes, links + + +def _build_fault_graph_results_text( + ship_node: Optional[Dict[str, Any]], + device_node: Optional[Dict[str, Any]], + fault_mode_node: Optional[Dict[str, Any]], + fault_reason: str, + repair_plan: str, + mt_codes: List[str], + repair_projects: List[Dict[str, Any]], + ship_number: str, + device_name: str, + fault_symptom: str, +) -> str: + """生成与 graph_rag_search 类似的一段话,供展示与下游使用。""" + ship_name = "" + if ship_node: + ship_name = (ship_node.get("display_name") or (ship_node.get("props") or {}).get("名称") or "").strip() + device_name_d = "" + if device_node: + device_name_d = (device_node.get("display_name") or (device_node.get("props") or {}).get("名称") or "").strip() + fault_name = "" + if fault_mode_node: + fault_name = (fault_mode_node.get("display_name") or (fault_mode_node.get("props") or {}).get("名称") or "").strip() + + parts = [ + f"根据舷号「{ship_number}」、设备「{device_name}」、故障现象「{fault_symptom}」进行图谱检索:", + f"定位到舰艇「{ship_name or '未知'}」、设备节点「{device_name_d or '未知'}」、故障模式「{fault_name or '未知'}」。", + ] + if fault_reason: + parts.append(f"故障原因:{fault_reason}") + if repair_plan: + parts.append(f"维修方案摘要:{repair_plan[:500]}{'…' if len(repair_plan) > 500 else ''}") + if mt_codes: + parts.append(f"涉及维修项目编号:{'、'.join(mt_codes)}。") + if repair_projects: + proj_names = [] + for p in repair_projects: + nm = (p.get("display_name") or (p.get("props") or {}).get("名称") or (p.get("props") or {}).get("维修项目编号") or "").strip() + if nm: + proj_names.append(nm) + if proj_names: + parts.append(f"匹配到的维修项目:{'、'.join(proj_names)}。") + return "\n".join(parts) + + +@track_function_calls +async def fault_graph_reason_and_projects( + ship_number: Optional[str] = None, + device_name: str = "", + fault_symptom: str = "", +) -> Dict[str, Any]: + """ + 图谱检索工具 + """ + + if not device_name or not str(device_name).strip(): + return {"success": False, "error": "设备名称不能为空"} + if not fault_symptom or not str(fault_symptom).strip(): + return {"success": False, "error": "故障现象不能为空"} + + uri = NEO4J_CONFIG.get("uri") + user = NEO4J_CONFIG.get("user") + password = NEO4J_CONFIG.get("password") + if not uri or not user or not password: + return {"success": False, "error": "Neo4j 配置不完整,请检查 NEO4J_CONFIG"} + + try: + async with AsyncGraphDatabase.driver(uri, auth=(user, password)) as driver: + async with driver.session() as session: + device_key = str(device_name).strip() + ship_id = None + ship_node = None + + # 有舷号时,先定位舰艇节点 + if ship_number and str(ship_number).strip(): + ship_key = str(ship_number).strip() + ship_query = """ + MATCH (s:舰艇) + WHERE s.舷号 IN [$ship_key] + RETURN DISTINCT + id(s) AS id, + labels(s) AS labels, + properties(s) AS props, + coalesce(s.名称, s.name, s.舷号) AS display_name + LIMIT 10 + """ + ship_records = await session.run(ship_query, ship_key=ship_key) + ship_candidates = [_node_record_to_dict(r) async for r in ship_records] + if ship_candidates: + ship_node = ship_candidates[0] + ship_id = ship_node["id"] + + if ship_id is not None: + device_scope_query = """ + MATCH (ship:舰艇) + WHERE id(ship) = $ship_id + MATCH (ship)-[:包含*0..8]->(n:设备) + WITH n, coalesce(n.名称, n.name, n.设备名称) AS nm + WHERE nm IS NOT NULL + RETURN collect(DISTINCT id(n)) AS device_ids + """ + scope_records = await session.run(device_scope_query, ship_id=ship_id) + scope_row = await scope_records.single() + device_candidate_ids = scope_row.get("device_ids") if scope_row else [] + else: + device_candidate_ids = None + + if ship_id is not None and not device_candidate_ids: + return {"success": False, "error": "未找到匹配的设备节点"} + + device_node, device_score, device_match_meta = await _match_scoped_nodes_with_graph_indexes( + session=session, + query_text=device_key, + fulltext_index="设备_fulltext", + vector_index="设备_vector", + candidate_ids=device_candidate_ids if ship_id is not None else None, + fallback_candidates=None, + fulltext_top_k=80, + vector_top_k=80, + min_text_score=0.34, + min_combined_score=0.42, + fallback_text_weight=0.3, + fallback_semantic_weight=0.7, + ) + if not device_node: + if ship_id is not None: + device_fallback_query = """ + MATCH (n:设备) + WHERE id(n) IN $device_ids + WITH n, coalesce(n.名称, n.name, n.设备名称) AS nm + WHERE nm IS NOT NULL + RETURN DISTINCT + id(n) AS id, + labels(n) AS labels, + properties(n) AS props, + nm AS display_name, + n.embedding AS embedding + LIMIT 1000 + """ + dev_records = await session.run( + device_fallback_query, + device_ids=device_candidate_ids, + ) + else: + device_fallback_query = """ + MATCH (n:设备) + WITH n, coalesce(n.名称, n.name, n.设备名称) AS nm + WHERE nm IS NOT NULL + RETURN DISTINCT + id(n) AS id, + labels(n) AS labels, + properties(n) AS props, + nm AS display_name, + n.embedding AS embedding + LIMIT 2000 + """ + dev_records = await session.run(device_fallback_query) + device_candidates = [_node_record_to_dict(r) async for r in dev_records] + device_node, device_score, device_match_meta = await _match_scoped_nodes_with_graph_indexes( + session=session, + query_text=device_key, + fulltext_index="设备_fulltext", + vector_index="设备_vector", + candidate_ids=device_candidate_ids if ship_id is not None else None, + fallback_candidates=device_candidates, + fulltext_top_k=80, + vector_top_k=80, + min_text_score=0.34, + min_combined_score=0.42, + fallback_text_weight=0.3, + fallback_semantic_weight=0.7, + ) + if not device_node: + return {"success": False, "error": f"未找到匹配的设备节点(最高得分: {device_score:.3f})"} + device_id = device_node["id"] + + fault_mode_scope_query = """ + MATCH (start_e:设备) + WHERE id(start_e) = $device_id + MATCH (start_e)-[:包含*0..3]-(real_e:设备) + MATCH (real_e)-[:维修]-(w:维修工作)-[:表征]-(fm:故障模式) + RETURN DISTINCT + id(fm) AS id, + labels(fm) AS labels, + fm.名称 AS display_name, + id(real_e) AS real_device_id, + real_e.名称 AS real_device_name + """ + fm_records = await session.run(fault_mode_scope_query, device_id=device_id) + fault_mode_scope = [_node_record_to_dict(r) async for r in fm_records] + fault_modes = fault_mode_scope + if not fault_modes: + return {"success": False, "error": "在该设备节点下未找到故障模式节点"} + + fault_mode_ids = [ + item.get("id") for item in fault_modes if item.get("id") is not None + ] + fault_mode_node, fault_score, fault_mode_match_meta = await _match_scoped_nodes_with_graph_indexes( + session=session, + query_text=fault_symptom, + fulltext_index="故障模式_fulltext", + vector_index="故障模式_vector", + candidate_ids=fault_mode_ids, + fallback_candidates=None, + fulltext_top_k=80, + vector_top_k=80, + min_text_score=0.30, + min_combined_score=0.40, + fallback_text_weight=0.3, + fallback_semantic_weight=0.7, + ) + if fault_mode_node: + scope_by_id = { + item.get("id"): item + for item in fault_mode_scope + if item.get("id") is not None + } + scope_item = scope_by_id.get(fault_mode_node.get("id")) + if scope_item: + if "real_device_id" in scope_item and "real_device_id" not in fault_mode_node: + fault_mode_node["real_device_id"] = scope_item["real_device_id"] + if "real_device_name" in scope_item and "real_device_name" not in fault_mode_node: + fault_mode_node["real_device_name"] = scope_item["real_device_name"] + if not fault_mode_node: + fault_mode_fallback_query = """ + MATCH (start_e:设备) + WHERE id(start_e) = $device_id + MATCH (start_e)-[:包含*0..3]-(real_e:设备) + MATCH (real_e)-[:维修]-(w:维修工作)-[:表征]-(fm:故障模式) + RETURN DISTINCT + id(fm) AS id, + labels(fm) AS labels, + properties(fm) AS props, + fm.名称 AS display_name, + fm.embedding AS embedding, + id(real_e) AS real_device_id, + real_e.名称 AS real_device_name + """ + fm_records = await session.run(fault_mode_fallback_query, device_id=device_id) + fault_modes = [_node_record_to_dict(r) async for r in fm_records] + fault_mode_node, fault_score, fault_mode_match_meta = await _match_scoped_nodes_with_graph_indexes( + session=session, + query_text=fault_symptom, + fulltext_index="故障模式_fulltext", + vector_index="故障模式_vector", + candidate_ids=fault_mode_ids, + fallback_candidates=fault_modes, + fulltext_top_k=80, + vector_top_k=80, + min_text_score=0.30, + min_combined_score=0.40, + fallback_text_weight=0.3, + fallback_semantic_weight=0.7, + ) + if not fault_mode_node: + return {"success": False, "error": f"未能在故障模式节点中找到与故障现象足够匹配的节点(最高得分: {fault_score:.3f})"} + + fault_mode_id = fault_mode_node.get("id") + if fault_mode_id is None: + return {"success": False, "error": "故障模式节点缺少 id,无法继续检索维修项目"} + + real_device_id = fault_mode_node.get("real_device_id") + + fm_props = fault_mode_node.get("props") or {} + fault_reason = fm_props.get("故障原因") or "" + repair_plan = fm_props.get("维修方案") or "" + mt_codes = _extract_mt_codes(repair_plan) + + repair_project_query = """ + MATCH (fm:故障模式) + WHERE id(fm) = $fault_mode_id + OPTIONAL MATCH (fm)<-[:修理]-(p:维修项目) + RETURN DISTINCT + id(p) AS id, + labels(p) AS labels, + properties(p) AS props, + p.名称 AS display_name + """ + rp_records = await session.run( + repair_project_query, fault_mode_id=fault_mode_id + ) + all_projects = [_node_record_to_dict(r) async for r in rp_records] + + repair_projects: List[Dict[str, Any]] = [] + if mt_codes: + for proj in all_projects: + props = proj.get("props") or {} + proj_code = (props.get("维修项目编号") or "").strip() + if proj_code and ( + proj_code in mt_codes or any(c in proj_code for c in mt_codes) + ): + repair_projects.append(proj) + elif not proj_code: + values_text = " ".join( + [str(v) for v in props.values() if v is not None] + ) + if any(code in values_text for code in mt_codes): + repair_projects.append(proj) + else: + repair_projects = all_projects + + repair_ids = [ + p.get("id") for p in repair_projects if p.get("id") is not None + ] + + exclude_keys = { + "last_updated", + "created_at", + "knowledge_source", + "fulltext", + "embedding", + } + + if ship_id is not None: + graph_nodes_query = """ + MATCH (ship:舰艇) + WHERE id(ship) = $ship_id + MATCH (real_e:设备) + WHERE id(real_e) = $real_device_id + MATCH path0 = (ship)-[:包含*0..8]->(real_e) + MATCH path1 = (real_e)-[:维修]-(w:维修工作)-[:表征]-(fm:故障模式) + WHERE id(fm) = $fault_mode_id + OPTIONAL MATCH (fm)<-[:修理]-(rp:维修项目) + WHERE id(rp) IN $repair_ids + WITH path0, path1, collect(DISTINCT rp) AS rps + WITH nodes(path0) + nodes(path1) + rps AS ns + UNWIND ns AS n + WITH DISTINCT n + WHERE n IS NOT NULL + RETURN elementId(n) AS id, + labels(n) AS labels, + coalesce(n.名称, n.name) AS name, + properties(n) AS props + """ + node_records = await session.run( + graph_nodes_query, + ship_id=ship_id, + real_device_id=real_device_id, + fault_mode_id=fault_mode_id, + repair_ids=repair_ids or [-1], + ) + else: + # 无舷号:不查舰艇路径,只查设备→故障模式→维修项目 + graph_nodes_query = """ + MATCH (real_e:设备) + WHERE id(real_e) = $real_device_id + MATCH path1 = (real_e)-[:维修]-(w:维修工作)-[:表征]-(fm:故障模式) + WHERE id(fm) = $fault_mode_id + OPTIONAL MATCH (fm)<-[:修理]-(rp:维修项目) + WHERE id(rp) IN $repair_ids + WITH path1, collect(DISTINCT rp) AS rps + WITH nodes(path1) + rps AS ns + UNWIND ns AS n + WITH DISTINCT n + WHERE n IS NOT NULL + RETURN elementId(n) AS id, + labels(n) AS labels, + coalesce(n.名称, n.name) AS name, + properties(n) AS props + """ + node_records = await session.run( + graph_nodes_query, + real_device_id=real_device_id, + fault_mode_id=fault_mode_id, + repair_ids=repair_ids or [-1], + ) + + nodes_g: List[Dict[str, Any]] = [] + async for r in node_records: + data_r = r.data() + nid = data_r.get("id") + if nid is None: + continue + labels = data_r.get("labels") or [] + labels = [l for l in labels if l != "Searchable"] + name = data_r.get("name") or str(nid) + props = data_r.get("props") or {} + # 仅保留"名称"属性 + name_value = props.get("名称", props.get("name", "")) + properties = {"名称": name_value} if name_value else {} + nodes_g.append( + { + "id": nid, + "name": name, + "labels": labels, + "properties": properties, + } + ) + + if ship_id is not None: + graph_links_query = """ + MATCH (ship:舰艇) + WHERE id(ship) = $ship_id + MATCH (real_e:设备) + WHERE id(real_e) = $real_device_id + MATCH path0 = (ship)-[:包含*0..8]->(real_e) + MATCH path1 = (real_e)-[:维修]-(w:维修工作)-[:表征]-(fm:故障模式) + WHERE id(fm) = $fault_mode_id + WITH relationships(path0) + relationships(path1) AS rs + UNWIND rs AS r + RETURN elementId(r) AS id, + type(r) AS label, + elementId(startNode(r)) AS source, + elementId(endNode(r)) AS target, + properties(r) AS props + UNION + MATCH (fm:故障模式) + WHERE id(fm) = $fault_mode_id + MATCH (fm)<-[r:修理]-(rp:维修项目) + WHERE id(rp) IN $repair_ids + RETURN elementId(r) AS id, + type(r) AS label, + elementId(startNode(r)) AS source, + elementId(endNode(r)) AS target, + properties(r) AS props + """ + link_records = await session.run( + graph_links_query, + ship_id=ship_id, + real_device_id=real_device_id, + fault_mode_id=fault_mode_id, + repair_ids=repair_ids or [-1], + ) + else: + # 无舷号:只查设备→故障模式的边 + graph_links_query = """ + MATCH (real_e:设备) + WHERE id(real_e) = $real_device_id + MATCH path1 = (real_e)-[:维修]-(w:维修工作)-[:表征]-(fm:故障模式) + WHERE id(fm) = $fault_mode_id + WITH relationships(path1) AS rs + UNWIND rs AS r + RETURN elementId(r) AS id, + type(r) AS label, + elementId(startNode(r)) AS source, + elementId(endNode(r)) AS target, + properties(r) AS props + UNION + MATCH (fm:故障模式) + WHERE id(fm) = $fault_mode_id + MATCH (fm)<-[r:修理]-(rp:维修项目) + WHERE id(rp) IN $repair_ids + RETURN elementId(r) AS id, + type(r) AS label, + elementId(startNode(r)) AS source, + elementId(endNode(r)) AS target, + properties(r) AS props + """ + link_records = await session.run( + graph_links_query, + real_device_id=real_device_id, + fault_mode_id=fault_mode_id, + repair_ids=repair_ids or [-1], + ) + + links_g: List[Dict[str, Any]] = [] + async for r in link_records: + data_r = r.data() + src = data_r.get("source") + tgt = data_r.get("target") + if src is None or tgt is None: + continue + links_g.append( + { + "id": data_r.get("id"), + "label": data_r.get("label", ""), + "source": src, + "target": tgt, + } + ) + + node_ids = {n["id"] for n in nodes_g} + links_g = [ + l + for l in links_g + if l.get("source") in node_ids and l.get("target") in node_ids + ] + + repair_plans_data: List[Dict[str, Any]] = [] + for p in repair_projects: + props = p.get("props") or {} + filtered = {k: v for k, v in props.items() if k not in exclude_keys} + if filtered: + repair_plans_data.append(filtered) + + parts = [] + + if fault_reason and str(fault_reason).strip(): + parts.append(str(fault_reason).strip()) + + if repair_plans_data: + for plan in repair_plans_data: + if plan is not None: + plan_str = str(plan).strip() + if plan_str: + parts.append(plan_str) + + if parts: + combined_text = "\n".join(parts) + else: + combined_text = "暂无故障信息或修复方案" + + results_obj = combined_text + + results_text = json.dumps(results_obj, ensure_ascii=False) + + graph_item = {"nodes": nodes_g, "links": links_g} + graph_item = apply_styles(graph_item) + return {"success": True, "xxxx.graph": [graph_item], "results": results_text} + + except Exception as e: + return {"success": False, "error": f"Neo4j 查询失败: {str(e)}"} + + +def add_node_style(node): + node_type = node.get("labels", []) + node_type = [l for l in node_type if l != "Searchable"] + if node_type and len(node_type) > 0: + node_type = node_type[0] + else: + node_type = "" + + if node_type == "舰艇": + node["color"] = "#F89A64" + node["size"] = 86 + elif node_type == "系统": + node["color"] = "#C25C21" + node["size"] = 86 + elif node_type == "子系统": + node["color"] = "#7D2D00" + node["size"] = 86 + elif node_type == "设备": + node["color"] = "#540802" + node["size"] = 86 + elif node_type in ["功能", "环境条件", "接口", "安全警告", "调试", "器材保障", "图册", "设计单位", "维修项目", "操作项目"]: + node["color"] = "#2261DF" + node["size"] = 52 + else: + node["color"] = "#68C3FF" + node["size"] = 53 + + return node + + +def add_link_style(link, nodes_dict): + source_id = link.get("source") + target_id = link.get("target") + + source_type = "" + target_type = "" + + if source_id and source_id in nodes_dict: + source_labels = [l for l in nodes_dict[source_id].get("labels", []) if l != "Searchable"] + if source_labels and len(source_labels) > 0: + source_type = source_labels[0] + + if target_id and target_id in nodes_dict: + target_labels = [l for l in nodes_dict[target_id].get("labels", []) if l != "Searchable"] + if target_labels and len(target_labels) > 0: + target_type = target_labels[0] + + hierarchy_types = ["舰艇", "系统", "子系统", "设备"] + if source_type in hierarchy_types and target_type in hierarchy_types: + link["color"] = "#F59E0B" + else: + link["color"] = "#FFFFFF" + + return link + + +def _filter_searchable_labels(nodes: List[Dict[str, Any]]): + for node in nodes: + if isinstance(node, dict) and "labels" in node: + node["labels"] = [l for l in node["labels"] if l != "Searchable"] + + +def apply_styles(graph_data): + nodes = graph_data.get("nodes", []) + links = graph_data.get("links", []) + + nodes_dict = {n.get("id"): n for n in nodes if n.get("id")} + + for node in nodes: + add_node_style(node) + + for link in links: + add_link_style(link, nodes_dict) + + return graph_data + + +@tool +async def fault_graph_rag_search( + ship_number: Optional[str] = None, + device_name: str = "", + fault_symptom: str = "", +) -> str: + """ + 故障诊断图谱检索工具(供工作流调用):仅返回一段话摘要,与 graph_rag_search 一致。 + 根据设备名称、故障现象在图谱中定位设备→故障模式→维修项目,并生成文字摘要。舷号为可选参数,提供时可缩小搜索范围。 + 工作流中用于与 RAG 结果一起作为上下文,不返回节点/关系(展示用 fault_graph_reason_and_projects)。 + """ + result = await fault_graph_reason_and_projects( + ship_number=ship_number, + device_name=device_name, + fault_symptom=fault_symptom, + ) + if not result.get("success", False): + return result.get("error", "检索失败") + + + return result.get("results", "") + + +@track_function_calls +async def operation_graph_search_tool( + ship_number: str, + device_name: str, + operation_item: str, +) -> Dict[str, Any]: + """ + 图谱检索工具 + """ + if not device_name or not str(device_name).strip(): + return {"success": False, "error": "设备名称不能为空"} + if not operation_item or not str(operation_item).strip(): + return {"success": False, "error": "操作项目不能为空"} + + endpoint = GRAPH_SEARCH_CONFIG.get("operation_search_endpoint") + timeout = GRAPH_SEARCH_CONFIG.get("graph_timeout") + + if not endpoint: + return {"success": False, "error": "操作图谱检索服务配置不完整"} + + payload = { + "ship_number": str(ship_number).strip(), + "device_name": str(device_name).strip(), + "operation_item": str(operation_item).strip(), + } + + try: + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.post( + endpoint, + json=payload, + headers={"Content-Type": "application/json"}, + ) + response.raise_for_status() + result = response.json() + + if not result.get("success", False): + return {"success": False, "error": result.get("error", "检索失败")} + + graph_list = result.get("xxxx.graph") or [] + results_parts = [] + if graph_list: + for i, graph_item in enumerate(graph_list): + if isinstance(graph_item, dict): + _filter_searchable_labels(graph_item.get("nodes", [])) + if "results" in graph_item: + r = graph_item.pop("results") + if r: + results_parts.append(r) + graph_list[i] = apply_styles(graph_item) + result["xxxx.graph"] = graph_list + + if results_parts: + result["results"] = "\n".join(results_parts) + elif "results" not in result: + result["results"] = "" + + return result + + except httpx.ReadTimeout: + return {"success": False, "error": "操作图谱检索服务超时"} + except httpx.ConnectError: + return {"success": False, "error": "无法连接操作图谱检索服务"} + except httpx.HTTPStatusError as e: + return {"success": False, "error": f"操作图谱检索服务返回错误: {e.response.status_code}"} + except Exception as e: + return {"success": False, "error": f"操作图谱检索失败: {str(e)}"} + + +@tool +async def operation_graph_rag_search( + ship_number: str, + device_name: str, + operation_item: str, +) -> str: + """ + 操作图谱检索工具(供工作流调用):仅返回一段话摘要,与 graph_rag_search 一致。 + 根据舷号、设备名称、操作项目在图谱中定位舰艇→设备→操作项目,并生成文字摘要。 + 工作流中用于与 RAG 结果一起作为上下文,不返回节点/关系(展示用 operation_graph_search_tool)。 + """ + result = await operation_graph_search_tool( + ship_number=ship_number, + device_name=device_name, + operation_item=operation_item, + ) + if not result.get("success", False): + return result.get("error", "检索失败") + return result.get("results", "") + + +@tool +async def graph_rag_search(query: str, entity_type: Optional[str] = None, top_k: int = 120) -> str: + """ + GraphRAG检索工具(异步版本) + 在知识图谱中检索与query相关的实体和关系 + + Args: + query: 检索查询 + entity_type: 实体类型过滤(如:设备、故障、维修项目)- 注意:此参数在当前实现中暂未使用 + top_k: 返回结果数量(保留参数以兼容接口) + + Returns: + 图谱检索结果文本字符串(旧格式,保持兼容) + """ + print(f"graph_rag_query+++++++++++{query}") + result = await graph_search(query) + + if not result.get("success", False): + return "" + + results_text = result.get("results", "") + if not results_text: + return "" + if len(results_text) > 1000: + results_text = results_text[:1000] + "..." + return results_text + + + +async def atlas_retrieval( + node_names: List[str], + top_k: int = 10, + timeout: int = 30 +) -> Dict[str, Any]: + """ + 图册检索工具:从图谱中找到节点并关联图册PDF + + Args: + node_names: 节点名称列表,如 ["主发动机", "压缩机"] + top_k: 返回的结果数量 + timeout: 请求超时时间(秒) + + Returns: + 包含success和data字段的字典 + data格式示例: + { + "船舶主发动机": { + "船舶主发动机的原理图": [ + "http://192.168.0.46:59085/files/163-06A0015-B01001_船舶主发动机原理图.pdf" + ] + } + } + """ + api_url = ATLAS_CONFIG["endpoint"] + + if not node_names: + return { + "success": False, + "error": "node_names不能为空", + "data": {} + } + + payload = { + "node_names": node_names, + "top_k": top_k + } + + try: + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.post( + api_url, + json=payload, + headers={ + "accept": "application/json", + "Content-Type": "application/json" + } + ) + + if response.status_code == 200: + result = response.json() + if result.get("success", False): + return { + "success": True, + "data": result.get("data", {}), + "error": None + } + else: + return { + "success": False, + "error": result.get("message", "API返回失败"), + "data": {} + } + else: + return { + "success": False, + "error": f"HTTP {response.status_code}: {response.text}", + "data": {} + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "data": {} + } + + +def format_atlas_results(atlas_data: Dict[str, Any]) -> str: + """ + 格式化图册检索结果为文本,便于在提示词中使用 + + Args: + atlas_data: atlas_retrieval返回的data字段 + + Returns: + 格式化后的文本 + """ + if not atlas_data: + return "" + + parts = [] + for node_name, node_data in atlas_data.items(): + if isinstance(node_data, dict): + for title, urls in node_data.items(): + if isinstance(urls, list) and urls: + parts.append(f"\n【{node_name} - {title}】") + for url in urls: + # 确保URL后面有明确的换行,避免和其他文字连在一起 + parts.append(f"- {url}\n") + + return "\n".join(parts) if parts else "" + + +async def extract_entity_names_from_history( + history_messages: List[Dict[str, Any]], + device_name: str = "", + operation_item: str = "", + fault: str = "", + last_generated_scheme: str = "" +) -> List[str]: + """ + 从历史记录中抽取部件/设备/零部件名称 + + Args: + history_messages: 历史对话记录 + device_name: 设备名称 + operation_item: 操作项目名称 + fault: 故障现象 + last_generated_scheme: 最后一次生成的方案(用于重点抽取) + + Returns: + 抽取的实体名称列表 + """ + entity_names = [] + + # 添加明确提供的名称 + if device_name: + entity_names.append(device_name) + if operation_item: + entity_names.append(operation_item) + if fault: + entity_names.append(fault) + + # 去重 + entity_names = list(dict.fromkeys(entity_names)) + + # 调用大模型进一步抽取和补充 + if entity_names or last_generated_scheme: + try: + extraction_prompt = f"""从以下信息中抽取尽可能多的设备、部件、零部件、机构、系统名称(用于图册检索)。 +注意: +- 请尽可能多抽取,宁多勿少,准不准没关系 +- 包括所有提及的机械部件、电子设备、机构系统等 +- 即使是重复提到的也可以保留,我们会去重 +- 重点从【生成的方案】中抽取,这里信息最全面 + +【示例 - 输入】 +船舶主发动机(舷号:101)故障分析与维修方案 +1. 故障概况 +设备名称:船舶主发动机 +舷号:101 +故障现象:主机运行中出现异响 +2. 故障原因分析 +主轴承或连杆大端轴承磨损:轴承间隙过大或磨损严重会导致金属撞击声。 +曲轴主轴颈跳动超差:曲轴旋转不平衡或跳动量超出标准。 +飞轮连接螺栓松动:飞轮与曲轴连接处的螺栓松动。 +活塞头烧蚀或活塞环断裂:活塞环断裂或活塞头部损坏可能导致敲缸声。 +排气阀密封面磨损或阀座烧蚀:气门间隙异常或密封失效。 +涡轮增压器轴承损坏:增压器内部轴承损坏。 +3. 维修方案 +执行项目:MT003 检测曲轴组件(含飞轮紧固状态检查)。 +执行项目:MTO03 检测曲轴主轴颈跳动量。 + +【示例 - 输出】 +{{ + "entity_names": ["船舶主发动机", "主轴承", "连杆大端轴承", "曲轴", "飞轮", "飞轮连接螺栓", "活塞头", "活塞环", "排气阀", "排气阀座", "涡轮增压器", "涡轮增压器轴承", "曲轴组件"] +}} + +【设备/操作】 +- 设备名: {device_name or '无'} +- 操作项: {operation_item or '无'} +- 故障: {fault or '无'} + +【生成的方案】 +{last_generated_scheme or '无'} + +请输出JSON格式,格式如下: +{{ + "entity_names": ["名称1", "名称2", "名称3", "..."] +}} + +仅输出JSON,不要其他内容。""" + + response = await OpenaiAPI.open_api_chat_without_thinking( + query=extraction_prompt, + json_output=True, + system_prompt="你是一个专业的实体抽取助手,负责从文本中抽取尽可能多的设备、部件、零部件、机构、系统名称。请尽量多抽取,宁多勿少。重点从生成的维修方案中抽取。" + ) + + parsed = _safe_json_extract(response) + if isinstance(parsed, dict): + extracted = parsed.get("entity_names", []) + if extracted: + entity_names.extend(extracted) + entity_names = list(dict.fromkeys(entity_names)) + except Exception as e: + print(f"[图册检索] 实体抽取失败: {str(e)}") + + # 过滤空值和太短的名称 + entity_names = [name.strip() for name in entity_names if name and len(name.strip()) >= 2] + return entity_names[:30] # 最多30个,尽量多找图册 + + +def _safe_json_extract(text: str) -> Any: + """安全地从文本中提取JSON""" + try: + # 尝试直接解析 + return json.loads(text) + except json.JSONDecodeError: + # 尝试用正则提取 + match = re.search(r'\{[\s\S]*\}', text) + if match: + try: + return json.loads(match.group(0)) + except json.JSONDecodeError: + pass + return None diff --git a/tools/pdf_generator.py b/tools/pdf_generator.py new file mode 100644 index 0000000..53d6aef --- /dev/null +++ b/tools/pdf_generator.py @@ -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']}") diff --git a/tools/rag_tools.py b/tools/rag_tools.py new file mode 100644 index 0000000..c5b9c00 --- /dev/null +++ b/tools/rag_tools.py @@ -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", "") diff --git a/tools/ship_model_db.py b/tools/ship_model_db.py new file mode 100644 index 0000000..78ce20e --- /dev/null +++ b/tools/ship_model_db.py @@ -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 + diff --git a/tools/test.py b/tools/test.py new file mode 100644 index 0000000..fdb23c2 --- /dev/null +++ b/tools/test.py @@ -0,0 +1,251 @@ +# -*- coding: utf-8 -*- +import os +import ast +import re +import asyncio +import httpx +from typing import List, Dict +from openai import AsyncOpenAI + +from neo4j import GraphDatabase +from neo4j_graphrag.retrievers import HybridCypherRetriever +from neo4j_graphrag.embeddings.base import Embedder +#from modelsAPI.model_api import LocalBgeM3Embeddings, OpenaiAPI +from config import MAP_INS, NEO4J_CONFIG, INDEX_CONFIG, EMBEDDING_CONFIG +from config import LLM_CONFIG, EMBEDDING_CONFIG + +# ================== 配置 ================== +URI = NEO4J_CONFIG["uri"] +AUTH = (NEO4J_CONFIG["user"], NEO4J_CONFIG["password"]) +NAME_PROPERTY = INDEX_CONFIG["NAME_PROPERTY"] +FULLTEXT_PROPERTY = INDEX_CONFIG["FULLTEXT_PROPERTY"] +EMBEDDING_PROPERTY = INDEX_CONFIG["EMBEDDING_PROPERTY"] +SEARCH_LABEL = INDEX_CONFIG["SEARCH_LABEL"] +VECTOR_INDEX_NAME = INDEX_CONFIG["VECTOR_INDEX_NAME"] +FULLTEXT_INDEX_NAME = INDEX_CONFIG["FULLTEXT_INDEX_NAME"] +map_ins = MAP_INS + +# ================== 全局复用:driver / embedder 只初始化一次 ================== +_driver = None +_embedder = None + +def get_driver(): + global _driver + if _driver is None: + _driver = GraphDatabase.driver(URI, auth=AUTH) + return _driver + +def get_embedder(): + global _embedder + if _embedder is None: + _embedder = LocalBgeM3Embeddings( + base_url=EMBEDDING_CONFIG["base_url_v2"], + api_key=EMBEDDING_CONFIG["api_key"], + ) + return _embedder + +class LocalBgeM3Embeddings(Embedder): + def __init__(self, base_url: str, api_key: str, model_name: str = "bge-m3"): + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.model_name = model_name + + def embed_query(self, text: str) -> List[float]: + return self.embed_documents([text])[0] + + def embed_documents(self, texts: List[str]) -> List[List[float]]: + url = self.base_url + "/embeddings" + with httpx.Client(timeout=60) as client: + response = client.post( + url, + json={"model": self.model_name, "input": texts}, + headers={"Authorization": f"Bearer {self.api_key}"}, + ) + return [item["embedding"] for item in response.json()["data"]] +async def open_api_chat_without_thinking( + query: str = None, + model: str = None, + json_output: bool = False, + system_prompt: str = None, + messages: list = None, + enable_thinking: bool = True, + temperature: float = 0.1 + ) -> str: + if model is None: + model = LLM_CONFIG["model"] + if system_prompt is None: + system_prompt = "我是一个专业的AI助手,请用中文回答问题。请直接给出最终答案,不要包含任何推理步骤、思考过程、解释性文字或前缀。" + + + # 构建消息 + message_list = [{"role": "system", "content": system_prompt}] + + if messages is not None: + message_list.extend(messages) + if query is not None: + message_list.append({"role": "user", "content": query}) + + client = AsyncOpenAI( + api_key=LLM_CONFIG["api_key"], + base_url=LLM_CONFIG["base_url"], + ) + + kwargs = { + "model": model, + "messages": message_list, + "temperature": temperature, + "stream": False, + "max_tokens":LLM_CONFIG["max_tokens"] + } + + kwargs["extra_body"] = {"chat_template_kwargs": {"enable_thinking": False}} + + if json_output: + kwargs["response_format"] = {"type": "json_object"} + + print("model API. history message", message_list) + response = await client.chat.completions.create(**kwargs) + return response.choices[0].message.content +# ================== 辅助:解析 Record 字符串 ================== +def parse_record_string(s: str): + name_match = re.search(r"name='([^']*)'", s) + labels_match = re.search(r"labels=(\[[^\]]*\])", s) + score_match = re.search(r"score=([\d.]+)", s) + + name = name_match.group(1) if name_match else "未知名称" + labels = ["未知标签"] + if labels_match: + try: + labels = ast.literal_eval(labels_match.group(1)) + except (ValueError, SyntaxError): + pass + score = float(score_match.group(1)) if score_match else 0.0 + return name, labels, score + + +# ================== 构建 Retriever ================== +def build_retriever(sync_driver, embedder: Embedder) -> HybridCypherRetriever: + return HybridCypherRetriever( + driver=sync_driver, + vector_index_name=VECTOR_INDEX_NAME, + fulltext_index_name=FULLTEXT_INDEX_NAME, + embedder=embedder, + retrieval_query=f""" + RETURN + node.`{NAME_PROPERTY}` AS name, + [lbl IN labels(node) WHERE lbl <> '{SEARCH_LABEL}'] AS labels, + score + """, + ) + + +# ================== 同步检索(在线程池中执行,不阻塞事件循环)================== +def _hybrid_search_sync(sentence: str, top_k: int = 10) -> List[Dict]: + retriever = build_retriever(get_driver(), get_embedder()) + results_raw = retriever.search(query_text=sentence, top_k=top_k) + + hybrid_results: List[Dict] = [] + for item in results_raw.items: + content = item.content + name = "未知名称" + labels = ["未知标签"] + score = 0.0 + + if hasattr(content, "keys"): + name = content.get("name", name) + labels = content.get("labels", labels) + score = content.get("score", score) + elif isinstance(content, str): + if content.startswith(" str: + """ + 参数: + user_question: 用户问题 + top_k: 检索返回条数 + 返回: + LLM 筛选结果字符串 + """ + # 同步检索放入线程池,不阻塞事件循环 + results = await asyncio.to_thread(_hybrid_search_sync, user_question, top_k) + + # 构建 prompt 并异步调用 LLM + res = await open_api_chat_without_thinking( + system_prompt=SYSTEM_PROMPT, + query=PROMPT_TEMPLATE.format(results=results, user_question=user_question), + ) + + return res.strip() + + +# ================== 入口 ================== +if __name__ == "__main__": + answer = asyncio.run(get_extra_info("发动机这个月发生了多少次故障")) + print(111111111111111111) + print(answer) + diff --git a/tools/text2sql_tool.py b/tools/text2sql_tool.py new file mode 100644 index 0000000..0e977cf --- /dev/null +++ b/tools/text2sql_tool.py @@ -0,0 +1,776 @@ +""" +受控 ReAct Text2SQL 工具 +核心思想:Agent负责推理,Tool负责SQL,Reviewer负责纠错 + +内部流程: +1. Schema Injection - 注入数据库表结构 +2. SQL Generation - LLM生成SQL(使用 SQLAlchemy 连接池 + extra_info 辅助) +3. SQL Review - LLM审查SQL正确性 +4. SQL Guard - 代码级安全校验(硬限制) +5. SQL Execute - 执行SQL并返回结果(使用 SQLAlchemy 连接池单例) +6. Merge - 相似结果合并(Embedding) +""" +import re +import json +import asyncio +from datetime import datetime +from typing import Dict, Any, Optional, List, Tuple + +from sqlalchemy import text +from langchain_community.utilities import SQLDatabase + +from config import POSTGRES_CONNECTION_STRING,POSTGRES_SQLALCHEMY_URL +from modelsAPI.model_api import OpenaiAPI +from prompts import TEXT2SQL_PROMPTS +from tools.texttosql_utils import get_extra_info + +# ============================================================ +# 数据库连接池单例(避免重复初始化) +# ============================================================ + +_db_instance: Optional[SQLDatabase] = None +_db_lock = asyncio.Lock() + + +async def _get_db() -> SQLDatabase: + """懒加载并缓存数据库连接,避免重复初始化""" + global _db_instance + if _db_instance is None: + async with _db_lock: + if _db_instance is None: + _db_instance = await asyncio.to_thread( + SQLDatabase.from_uri, POSTGRES_SQLALCHEMY_URL + ) + return _db_instance + + +# ============================================================ +# Step 1: Schema Injection +# ============================================================ + +FAULT_RECORDS_SCHEMA = { + "table_name": "fault_records", + "description": "故障维修记录表,存储所有故障诊断和维修的记录", + "columns": { + "id": { + "type": "SERIAL", + "description": "自增主键", + "usable_in_query": False + }, + "ship_number": { + "type": "TEXT", + "description": "舰船舷号,如'101'、'163'、'554'等,2-4位数字", + "usable_in_query": True, + "examples": ["101", "163", "554", "602"] + }, + "device_name": { + "type": "TEXT", + "description": "设备名称,如'主机'、'发电机'、'分油机'、'空压机'等", + "usable_in_query": True, + "examples": ["主机", "发电机", "分油机", "空压机", "舵机", "锅炉"] + }, + "fault": { + "type": "TEXT", + "description": "故障现象描述,如'滑油压力低'、'异常振动'、'启动失败'等", + "usable_in_query": True, + "examples": ["滑油压力低", "异常振动", "启动失败", "排烟温度高"] + }, + "spare_parts": { + "type": "JSONB", + "description": "消耗的备件列表,JSON数组格式,如'[\"密封圈\", \"滤芯\"]'", + "usable_in_query": True, + "json_query_hint": "使用 jsonb_array_elements_text(spare_parts) 展开备件进行统计" + }, + "system_name": { + "type": "TEXT", + "description": "所属系统名称,如'动力系统'、'电气系统'、'导航系统'等", + "usable_in_query": True, + "examples": ["动力系统", "电气系统", "导航系统", "通信系统", "其他"] + }, + "created_at": { + "type": "TIMESTAMP", + "description": "记录创建时间,默认为当前时间", + "usable_in_query": True, + "examples": ["2025-01-15 10:30:00"] + } + } +} + +# 字段白名单(SQL Guard 使用) +ALLOWED_COLUMNS = set(FAULT_RECORDS_SCHEMA["columns"].keys()) + + +def get_schema_prompt_section() -> str: + """生成 Schema 注入的提示词部分""" + lines = [f"数据库表:{FAULT_RECORDS_SCHEMA['table_name']}"] + lines.append(f"说明:{FAULT_RECORDS_SCHEMA['description']}") + lines.append("") + lines.append("字段列表:") + + for col_name, col_info in FAULT_RECORDS_SCHEMA["columns"].items(): + if not col_info.get("usable_in_query", True): + continue + line = f" - {col_name} ({col_info['type']}): {col_info['description']}" + if "examples" in col_info: + line += f"(示例:{', '.join(col_info['examples'][:3])})" + if "json_query_hint" in col_info: + line += f"\n 查询提示:{col_info['json_query_hint']}" + lines.append(line) + + return "\n".join(lines) + + +# ============================================================ +# Step 2: SQL Generation +# ============================================================ + +async def generate_sql(question: str, current_date: str) -> Dict[str, Any]: + """ + 使用 LLM 根据用户问题生成 SQL。 + 并发预热数据库连接与获取 extra_info,提升整体响应速度。 + + Returns: + {"sql": "SELECT ...", "success": True/False, "error": "..."} + """ + + + schema_section = get_schema_prompt_section() + + # 并发:获取辅助信息 + 预热DB连接 + extra_info, _ = await asyncio.gather( + get_extra_info(question), + _get_db(), + ) + + prompt = TEXT2SQL_PROMPTS["sql_generation"].format( + schema=schema_section, + question=question, + current_date=current_date, + extra_info=extra_info or "" + ) + + try: + result = await OpenaiAPI.open_api_chat_without_thinking( + prompt, + model=None, + json_output=True + ) + + # 提取 JSON + json_match = re.search(r'\{.*\}', result, re.DOTALL) + if json_match: + parsed = json.loads(json_match.group(0)) + sql = parsed.get("sql", "").strip() + if sql: + return {"sql": sql, "success": True, "error": ""} + + # 兜底:直接提取 SELECT 语句 + sql_match = re.search(r'(SELECT\s+.+?;?)$', result.strip(), re.IGNORECASE | re.DOTALL) + if sql_match: + sql = sql_match.group(1).strip().rstrip(';') + return {"sql": sql, "success": True, "error": ""} + + return {"sql": "", "success": False, "error": "LLM未返回有效SQL"} + + except Exception as e: + return {"sql": "", "success": False, "error": f"SQL生成失败: {str(e)}"} + + +# ============================================================ +# Step 3: SQL Review (LLM 审查) +# ============================================================ + +async def review_sql(question: str, sql: str) -> Dict[str, Any]: + """ + 使用 LLM 审查 SQL 是否真正回答了用户问题 + + Returns: + { + "approved": True/False, + "issues": ["问题1", "问题2"], + "suggested_sql": "修正后的SQL(如果有问题)", + "review_comment": "审查意见" + } + """ + from modelsAPI.model_api import OpenaiAPI + from prompts import TEXT2SQL_PROMPTS + + schema_section = get_schema_prompt_section() + prompt = TEXT2SQL_PROMPTS["sql_review"].format( + schema=schema_section, + question=question, + sql=sql + ) + + try: + result = await OpenaiAPI.open_api_chat_without_thinking( + prompt, + model=None, + json_output=True + ) + + json_match = re.search(r'\{.*\}', result, re.DOTALL) + if json_match: + parsed = json.loads(json_match.group(0)) + approved = parsed.get("approved", False) + issues = parsed.get("issues", []) + suggested_sql = parsed.get("suggested_sql", "").strip() + review_comment = parsed.get("review_comment", "") + + if suggested_sql: + suggested_sql = suggested_sql.rstrip(';') + + return { + "approved": approved, + "issues": issues, + "suggested_sql": suggested_sql, + "review_comment": review_comment + } + + return { + "approved": True, + "issues": [], + "suggested_sql": "", + "review_comment": "审查结果解析失败,默认通过" + } + + except Exception as e: + return { + "approved": True, + "issues": [], + "suggested_sql": "", + "review_comment": f"审查过程异常,默认通过: {str(e)}" + } + + +# ============================================================ +# Step 4: SQL Guard (代码级安全校验) +# ============================================================ + +class SQLGuardError(Exception): + """SQL Guard 校验失败异常""" + pass + + +def guard_sql(sql: str) -> Tuple[bool, str, str]: + """ + SQL 安全守卫 - 代码硬限制 + + Returns: + (passed, cleaned_sql, error_message) + """ + if not sql or not sql.strip(): + return False, "", "SQL为空" + + sql_upper = sql.strip().upper() + + # 1. 只允许 SELECT + if not sql_upper.startswith("SELECT"): + return False, "", "只允许SELECT查询,禁止INSERT/UPDATE/DELETE/CREATE等操作" + + # 2. 禁止 UNION + if "UNION" in sql_upper: + return False, "", "禁止使用UNION操作" + + # 3. 禁止子查询嵌套过深(最多2层) + subquery_count = sql_upper.count("SELECT") - 1 + if subquery_count > 2: + return False, "", f"子查询嵌套过深({subquery_count}层),最多允许2层" + + # 4. 禁止危险函数 + dangerous_functions = ["PG_SLEEP", "SLEEP", "BENCHMARK", "WAITFOR", "DELAY"] + for func in dangerous_functions: + if func in sql_upper: + return False, "", f"禁止使用危险函数: {func}" + + # 5. 禁止系统表访问 + system_tables = ["PG_", "INFORMATION_SCHEMA", "PG_CATALOG", "PG_CLASS"] + for sys_table in system_tables: + if sys_table in sql_upper: + return False, "", f"禁止访问系统表: {sys_table}" + + # 6. 禁止注释注入 + if "--" in sql or "/*" in sql or "*/" in sql: + return False, "", "SQL中不允许包含注释" + + # 7. 禁止分号(防止多语句注入) + if ";" in sql.rstrip(';'): + return False, "", "禁止多语句执行" + + # 8. 检查字段白名单 + used_columns = set() + dot_matches = re.findall(r'fault_records\.(\w+)', sql, re.IGNORECASE) + used_columns.update(m.lower() for m in dot_matches) + + select_match = re.search(r'SELECT\s+(.*?)\s+FROM', sql, re.IGNORECASE | re.DOTALL) + if select_match: + select_part = select_match.group(1) + if '*' not in select_part: + for part in select_part.split(','): + part = part.strip() + col_match = re.search(r'(\w+)\s*(?:AS|as)?\s*$', part) + if col_match: + col_name = col_match.group(1).lower() + if col_name not in ('count', 'sum', 'avg', 'max', 'min', 'as', + 'desc', 'asc', 'null', 'distinct', 'all', + 'jsonb_array_elements_text', 'jsonb_array_elements', + 'coalesce', 'cast', 'date', 'timestamp', 'text'): + used_columns.add(col_name) + + for col in used_columns: + if col not in ALLOWED_COLUMNS and col != 'id': + return False, "", f"使用了不允许的字段: {col}" + + # 9. 自动添加 LIMIT(如果没有) + cleaned_sql = sql.strip().rstrip(';') + if "LIMIT" not in sql_upper: + cleaned_sql = f"{cleaned_sql} LIMIT 100" + + # 10. 强制只查 fault_records 表 + from_match = re.search(r'FROM\s+(\w+)', sql, re.IGNORECASE) + if from_match: + table_name = from_match.group(1).lower() + if table_name != "fault_records": + return False, "", f"只允许查询 fault_records 表,不允许查询: {table_name}" + + return True, cleaned_sql, "" + + +# ============================================================ +# Step 5: SQL Execute +# ============================================================ + +async def execute_sql(sql: str) -> Dict[str, Any]: + """ + 执行 SQL 并返回结果。 + 使用模块级 SQLAlchemy 连接池单例,避免每次重复建立连接。 + + Returns: + {"rows": [...], "columns": [...], "row_count": int, "success": True/False, "error": ""} + """ + try: + db = await _get_db() + + def _run() -> Dict[str, Any]: + try: + with db._engine.connect() as conn: + result = conn.execute(text(sql)) + columns = list(result.keys()) + result_rows = [] + for row in result.fetchall(): + row_dict = {} + for i, col in enumerate(columns): + val = row[i] + if isinstance(val, datetime): + val = val.isoformat() + elif isinstance(val, (list, dict)): + val = json.dumps(val, ensure_ascii=False) + elif not isinstance(val, (str, int, float, bool, type(None))): + val = str(val) + row_dict[col] = val + result_rows.append(row_dict) + conn.commit() + return { + "rows": result_rows, + "columns": columns, + "row_count": len(result_rows), + "success": True, + "error": "" + } + except Exception as e: + return { + "rows": [], + "columns": [], + "row_count": 0, + "success": False, + "error": f"SQL执行失败: {str(e)}" + } + + return await asyncio.to_thread(_run) + + except Exception as e: + return { + "rows": [], + "columns": [], + "row_count": 0, + "success": False, + "error": f"获取数据库连接失败: {str(e)}" + } + + +# ============================================================ +# Text2SQL 主入口 - 内部闭环 +# ============================================================ + +MAX_RETRY = 2 # 最大重试次数(生成→审查→修正) + + +async def text2sql_tool(question: str, current_date: Optional[str] = None) -> Dict[str, Any]: + """ + 受控 Text2SQL 工具 - 内部形成闭环 + + 流程:question → SQL生成 → SQL审查 → SQL Guard → SQL执行 → 返回结果 + + Args: + question: 用户问题 + current_date: 当前日期(格式:YYYY-MM-DD),默认为今天 + + Returns: + { + "success": True/False, + "sql": "最终执行的SQL", + "rows": [...], + "columns": [...], + "row_count": int, + "error": "", + "review_log": [...] # 审查日志 + } + """ + if not current_date: + current_date = datetime.now().strftime("%Y-%m-%d") + + review_log = [] + current_sql = "" + + for attempt in range(MAX_RETRY + 1): + # Step 2: SQL Generation + if attempt == 0: + gen_result = await generate_sql(question, current_date) + else: + gen_result = await generate_sql_with_feedback( + question, current_date, current_sql, review_log + ) + + if not gen_result["success"]: + return { + "success": False, + "sql": "", + "rows": [], + "columns": [], + "row_count": 0, + "error": gen_result["error"], + "review_log": review_log + } + + current_sql = gen_result["sql"] + + # Step 3: SQL Review + review_result = await review_sql(question, current_sql) + review_log.append({ + "attempt": attempt + 1, + "sql": current_sql, + "approved": review_result["approved"], + "issues": review_result["issues"], + "comment": review_result["review_comment"] + }) + + if not review_result["approved"]: + if review_result["suggested_sql"]: + current_sql = review_result["suggested_sql"] + review_log.append({ + "attempt": attempt + 1, + "sql": current_sql, + "note": "使用审查建议的修正SQL" + }) + elif attempt < MAX_RETRY: + continue + else: + return { + "success": False, + "sql": current_sql, + "rows": [], + "columns": [], + "row_count": 0, + "error": f"SQL审查不通过: {'; '.join(review_result['issues'])}", + "review_log": review_log + } + + # Step 4: SQL Guard + passed, cleaned_sql, guard_error = guard_sql(current_sql) + if not passed: + review_log.append({ + "attempt": attempt + 1, + "sql": current_sql, + "guard_passed": False, + "guard_error": guard_error + }) + if attempt < MAX_RETRY: + review_log.append({"note": f"Guard拒绝: {guard_error},将重新生成"}) + continue + else: + return { + "success": False, + "sql": current_sql, + "rows": [], + "columns": [], + "row_count": 0, + "error": f"SQL安全校验失败: {guard_error}", + "review_log": review_log + } + + current_sql = cleaned_sql + review_log.append({ + "attempt": attempt + 1, + "sql": current_sql, + "guard_passed": True, + "note": "Guard通过" + }) + + # Step 5: SQL Execute + exec_result = await execute_sql(current_sql) + + if not exec_result["success"]: + if attempt < MAX_RETRY: + review_log.append({ + "attempt": attempt + 1, + "sql": current_sql, + "exec_error": exec_result["error"], + "note": "执行失败,将重新生成" + }) + continue + else: + return { + "success": False, + "sql": current_sql, + "rows": [], + "columns": [], + "row_count": 0, + "error": exec_result["error"], + "review_log": review_log + } + + # Step 6: 相似结果合并 + merged_rows = await merge_similar_rows( + exec_result["rows"], exec_result["columns"], question + ) + + return { + "success": True, + "sql": current_sql, + "rows": merged_rows, + "columns": exec_result["columns"], + "row_count": len(merged_rows), + "raw_row_count": exec_result["row_count"], + "error": "", + "review_log": review_log + } + + return { + "success": False, + "sql": current_sql, + "rows": [], + "columns": [], + "row_count": 0, + "error": "超过最大重试次数", + "review_log": review_log + } + + +async def generate_sql_with_feedback( + question: str, + current_date: str, + previous_sql: str, + review_log: List[Dict[str, Any]] +) -> Dict[str, Any]: + """ + 基于审查反馈重新生成 SQL + """ + from modelsAPI.model_api import OpenaiAPI + from prompts import TEXT2SQL_PROMPTS + + schema_section = get_schema_prompt_section() + + feedback_parts = [] + for log_entry in review_log: + if "issues" in log_entry and log_entry["issues"]: + feedback_parts.append(f"问题: {'; '.join(log_entry['issues'])}") + if "guard_error" in log_entry: + feedback_parts.append(f"安全校验失败: {log_entry['guard_error']}") + if "exec_error" in log_entry: + feedback_parts.append(f"执行失败: {log_entry['exec_error']}") + + feedback = "\n".join(feedback_parts) if feedback_parts else "无具体反馈" + + prompt = TEXT2SQL_PROMPTS["sql_regeneration"].format( + schema=schema_section, + question=question, + current_date=current_date, + previous_sql=previous_sql, + feedback=feedback + ) + + try: + result = await OpenaiAPI.open_api_chat_without_thinking( + prompt, + model=None, + json_output=True + ) + + json_match = re.search(r'\{.*\}', result, re.DOTALL) + if json_match: + parsed = json.loads(json_match.group(0)) + sql = parsed.get("sql", "").strip() + if sql: + return {"sql": sql, "success": True, "error": ""} + + sql_match = re.search(r'(SELECT\s+.+?;?)$', result.strip(), re.IGNORECASE | re.DOTALL) + if sql_match: + sql = sql_match.group(1).strip().rstrip(';') + return {"sql": sql, "success": True, "error": ""} + + return {"sql": "", "success": False, "error": "重新生成SQL失败"} + + except Exception as e: + return {"sql": "", "success": False, "error": f"重新生成SQL异常: {str(e)}"} + + +# ============================================================ +# Step 6: 相似结果合并(Embedding) +# ============================================================ + +MERGE_KEYWORDS = ["故障", "频次", "排名", "高频", "最多", "top", "发生次数"] + + +def _should_merge(columns: List[str], question: str) -> bool: + """判断是否需要合并相似行""" + question_lower = question.lower() + if any(kw in question_lower for kw in MERGE_KEYWORDS): + col_names = [c.lower() for c in columns] + has_name = any(n in col_names for n in ["name", "故障", "fault", "device_name"]) + has_count = any(n in col_names for n in ["count", "次数", "频次"]) + return has_name and has_count + return False + + +def _find_name_column(columns: List[str]) -> Optional[str]: + """找到名称列""" + priority = ["name", "故障名称", "故障", "fault", "device_name", "备件", "spare"] + col_lower_map = {c.lower(): c for c in columns} + for p in priority: + if p in col_lower_map: + return col_lower_map[p] + for c in columns: + if c.lower() not in ("count", "次数", "频次", "rank", "排名", "system_name", "系统"): + return c + return None + + +def _find_count_column(columns: List[str]) -> Optional[str]: + """找到计数列""" + priority = ["count", "次数", "频次", "cnt"] + col_lower_map = {c.lower(): c for c in columns} + for p in priority: + if p in col_lower_map: + return col_lower_map[p] + return None + + +def _find_system_column(columns: List[str]) -> Optional[str]: + """找到系统分类列""" + priority = ["system_name", "系统", "系统分类", "system"] + col_lower_map = {c.lower(): c for c in columns} + for p in priority: + if p in col_lower_map: + return col_lower_map[p] + return None + + +async def merge_similar_rows( + rows: List[Dict[str, Any]], + columns: List[str], + question: str, + similarity_threshold: float = 0.85 +) -> List[Dict[str, Any]]: + """ + 使用 embedding 合并相似的故障/备件行 + + 例如: + - "连杆大端轴承发生损坏异常" (7次, 其他) + "连杆大端轴承发生损坏异常" (6次, 汽车配件) + → "连杆大端轴承发生损坏异常" (13次, 其他) + - "发动机中空冷器管束芯体结垢或堵塞..." + "空冷器管束芯体结垢或堵塞..." + → 合并为一条 + """ + if not rows or len(rows) <= 1: + return rows + + if not _should_merge(columns, question): + return rows + + name_col = _find_name_column(columns) + count_col = _find_count_column(columns) + system_col = _find_system_column(columns) + + if not name_col or not count_col: + return rows + + try: + from modelsAPI.model_api import OpenaiAPI + + names = [row[name_col] for row in rows if row.get(name_col)] + if len(names) <= 1: + return rows + + embeddings = await OpenaiAPI.get_embeddings_batch_async(names) + name_emb_map = {name: emb for name, emb in zip(names, embeddings)} + + sorted_rows = sorted(rows, key=lambda r: int(r.get(count_col, 0)), reverse=True) + + merged_rows = [] + merged_indices = set() + + for i, row in enumerate(sorted_rows): + if i in merged_indices: + continue + + name_i = row.get(name_col, "") + emb_i = name_emb_map.get(name_i) + + if not emb_i: + merged_rows.append(row) + continue + + merged_row = dict(row) + total_count = int(row.get(count_col, 0)) + systems = set() + if system_col and row.get(system_col): + systems.add(row[system_col]) + + for j in range(i + 1, len(sorted_rows)): + if j in merged_indices: + continue + + name_j = sorted_rows[j].get(name_col, "") + emb_j = name_emb_map.get(name_j) + + if not emb_j: + continue + + similarity = OpenaiAPI.cosine_similarity(emb_i, emb_j) + + if similarity >= similarity_threshold: + total_count += int(sorted_rows[j].get(count_col, 0)) + if system_col and sorted_rows[j].get(system_col): + systems.add(sorted_rows[j][system_col]) + merged_indices.add(j) + + merged_row[count_col] = total_count + if system_col and systems: + merged_row[system_col] = "、".join(sorted(systems)) + + merged_rows.append(merged_row) + merged_indices.add(i) + + merged_rows.sort(key=lambda r: int(r.get(count_col, 0)), reverse=True) + + if "rank" in columns or "排名" in columns: + rank_col = "rank" if "rank" in columns else "排名" + for idx, row in enumerate(merged_rows): + row[rank_col] = idx + 1 + + return merged_rows + + except Exception as e: + print(f"Embedding合并失败,使用原始数据: {str(e)}") + return rows + + diff --git a/tools/texttosql_utils.py b/tools/texttosql_utils.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/vlm_tools.py b/tools/vlm_tools.py new file mode 100644 index 0000000..168a42e --- /dev/null +++ b/tools/vlm_tools.py @@ -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 进行工业巡检分析。 + - 自动移除所有 或其他内部思考过程。" + "仅输出最终的专业分析结果。\n\n" + "你是一位资深的工业巡检专家。请仔细观察这张图片并按以下结构报告:\n" + "1. 设备识别:图中是什么设备?型号或标签是否可见?\n" + "2. 状态评估:仪表盘数值是否在正常区间?指示灯颜色是什么?\n" + "3. 异常检测:是否存在漏油、生锈、电缆破损、螺丝松动或烟雾?\n" + "4. 环境风险:周围是否有杂物堆放或安全隐患?\n" + "请用专业、客观、简短的语言描述。" + ) + + final_prompt = f"{default_prompt}\n特别注意:{prompt}" if prompt else default_prompt + + safe_image = smart_resize_base64_image( + base64_image=image_path, + prompt_text=final_prompt, + max_input_tokens=int(MAX_MODEL_LEN * INPUT_TOKEN_SAFETY_RATIO) + ) + + try: + result = await OpenaiAPI.open_api_vl_without_thinking(safe_image) + + print(f"✅ 最终输出:\n{result}") + + save_result = save_base64_image_and_record( + base64_image=image_path, + description=result, + output_dir="picture", + record_file="picture/image_records.json" + ) + if save_result["success"]: + print(f"📷 图片已保存: {save_result['image_name']}") + image_url = f"{SERVER_CONFIG['base_url']}/picture/{save_result['image_name']}" + result = f"{result}\n\n![用户上传图片]({image_url})" + else: + print(f"⚠️ 图片保存失败: {save_result['error']}") + + return result + + except Exception as e: + import traceback + error_msg = f"VLM 调用失败: {str(e)}\n{traceback.format_exc()}" + print(error_msg) + return error_msg + + +@track_function_calls +async def image_rag_describe( + image_path: str, + query: str, + top_k: int = 5 +) -> str: + """ + 图像RAG描述工具 + 先用VLM分析图像,然后用RAG检索相关知识 + + Args: + image_path: 图像路径(base64格式) + query: 查询文本 + top_k: RAG检索数量 + + Returns: + 分析结果文本 + """ + try: + vlm_result = await vlm_image_to_text(image_path, prompt=query) + + from .rag_tools import rag_search + rag_result = await rag_search(query=f"{query} {vlm_result}", top_k=top_k) + + if rag_result.get("success", False): + source_citation = rag_result.get("sourceCitation", {}) + rag_text = "" + for resource, items in source_citation.items(): + for item in items: + rag_text += f"【{resource}】{item.get('text', '')}\n\n" + + return f"图像分析结果:\n{vlm_result}\n\n相关知识:\n{rag_text}" + else: + return vlm_result + + except Exception as e: + return f"图像RAG分析失败: {str(e)}" diff --git a/utils/MML2OMML.XSL b/utils/MML2OMML.XSL new file mode 100644 index 0000000..943e0f7 --- /dev/null +++ b/utils/MML2OMML.XSL @@ -0,0 +1,4728 @@ + + + + + + ABCDEFGHIJKLMNOPQRSTUVWXYZ + abcdefghijklmnopqrstuvwxyz + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 0 + + + + + + + + + 1 + 0 + + + + + + + + + + + + + + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + p + + + + + b + + + + + + + script + + + + + script + + + b + + + + + double-struck + + + p + + + + + fraktur + + + p + + + + + fraktur + + + b + + + + + sans-serif + + + p + + + + + sans-serif + + + b + + + + + sans-serif + + + + + sans-serif + + + bi + + + + + monospace + + + p + + + + + b + + + + + bi + + + + + + + + + + 1 + + + + + + + + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + noBar + skw + bar + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + on + + + + + + + + + + + + + + + + + + + + + 1 + 0 + + + + + 1 + 0 + + + + + 1 + 0 + + + + + 1 + 0 + + + + + 1 + 0 + + + + + 1 + 0 + + + + + 1 + 0 + + + + + 1 + 0 + + + + + 1 + 0 + + + + + + + + + + on + + + + + on + + + + + on + + + + + on + + + + + + on + + + + + on + + + + + on + + + + + on + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + off + + + + + + + + + + + + + + + + + + + + + + + 1 + 0 + + + + + + ([{<⌊⌈⟦]|‖ + )]}>⌋⌉⟧[|‖ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 0 + + + + + + + + + + + + + + + + + + 1 + 0 + + + + + + + + + + + + + 1 + 0 + + + + + + + + + + + lin + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 0 + + + + + + + + + + + + + + 1 + 0 + + + + + + 1 + 0 + + + + + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 0 + + + + + + + + + + + + 1 + + 0 + + + + 0 + + + + + + + + + + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 0 + + + + + + + + + + 1 + 0 + + + + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + bot + top + + + top + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ̆ + + ̒ + + ̀ + + ̅ + + ̅ + + ̇ + + ̇ + + ̋ + + ́ + + ̃ + + ̃ + + ̈ + + ̌ + + ̂ + + ̅ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + top + bot + + + bot + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + " + + + + + + + + + + + + + + + + + + + " + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 0 + + + + + + + + + + + + + + + 1 + 0 + + + + + + + + + 1 + + + + + + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 0 + + + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + & + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 0 + + + + + + + + + + + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + 1 + + 0 + + 1 + + + + + + + + + + + + + + + + off + + + + + on + + + + + on + + + + + on + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + undOvr + + + subSup + + + + + + + + 1 + 0 + 1 + 0 + + + + + + + + on + + + off + + + + + + + + + on + + + off + + + + + + + \ No newline at end of file diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..82c3782 --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1,4 @@ +""" +工具模块 +""" + diff --git a/utils/function_tracker.py b/utils/function_tracker.py new file mode 100644 index 0000000..29b5a2f --- /dev/null +++ b/utils/function_tracker.py @@ -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) diff --git a/utils/image_utils.py b/utils/image_utils.py new file mode 100644 index 0000000..ac0bc37 --- /dev/null +++ b/utils/image_utils.py @@ -0,0 +1,201 @@ +""" +图片处理工具模块 - 最终版 v4 +核心思路(最简单直接): +1. 找到所有 .jpg/.png 文件名及其周围的 ![图片]( 上下文 +2. 不管周围格式如何,也不管文件名是否被模型改过 +3. 统一提取文件名 -> 与 original_paths 模匹 -> 替换成正确的 ![图片](完整URL) +""" +import re +from typing import List +from difflib import SequenceMatcher + + +def extract_image_paths(text: str) -> List[str]: + """从文本中提取图片路径""" + pattern = r'images/[a-zA-Z0-9_\-./]+\.(?:jpg|jpeg|png|gif|bmp|webp)' + matches = re.findall(pattern, text, re.IGNORECASE) + return list(set(matches)) + + +def find_closest_image_path(generated_path: str, original_paths: List[str]) -> str: + """ + 找到与生成路径最接近的原始图片路径 + + 即使模型输出的是错误/近似/截断的文件名,也能匹配到正确的原始路径 + """ + if not original_paths: + return generated_path + + generated_filename = generated_path.split('/')[-1].lower() + best_match = None + best_score = 0 + + for orig_path in original_paths: + orig_filename = orig_path.split('/')[-1].lower() + score = SequenceMatcher(None, generated_filename, orig_filename).ratio() + if score > best_score: + best_score = score + best_match = orig_path + + if best_match and best_score > 0.5: + return best_match + return generated_path + + +def normalize_markdown_images(text: str, base_prefix: str = "/api/v1/knowledge/files/", original_paths: List[str] = None) -> str: + """ + 规范化 Markdown 图片链接 - 两轮处理版 + + 算法: + 第一轮:检查和修改格式,确保是 ![图片](/api/v1/knowledge/files/images/xxx.jpg) 格式 + 第二轮:只检查和替换最后文件名部分(xxx.jpg),与原始路径匹配,匹配度低的删除 + """ + if not text or original_paths is None: + return text + + # ========== 第一轮:检查和修改格式(包括固定URL前缀) ========== + result = _normalize_image_format(text, base_prefix) + + # ========== 第二轮:只检查和替换最后文件名部分 ========== + result = _normalize_image_paths(result, base_prefix, original_paths) + + return result + + +def _normalize_image_format(text: str, base_prefix: str) -> str: + """ + 第一轮:规范化图片格式,确保所有图片都是正确的 ![图片](/api/v1/knowledge/files/images/xxx.jpg) 格式 + + 处理各种格式破损的情况,并确保有正确的URL前缀 + """ + if not text: + return text + + IMG_EXTENSIONS = r'(?:jpg|jpeg|png|gif|bmp|webp)' + img_pattern = rf'([a-zA-Z0-9_\-./]+)\.({IMG_EXTENSIONS})' + + result = text + matches = list(re.finditer(img_pattern, result, re.IGNORECASE)) + + if not matches: + return result + + # 从后往前处理,避免位置偏移 + for match in reversed(matches): + filename_with_ext = match.group(0) + file_start = match.start() + file_end = match.end() + + mark_start = file_start + mark_end = file_end + + # 向前查找是否已经有 ![图片]( 标记 + lookback_limit = max(0, file_start - 100) + before = result[lookback_limit:file_start] + open_marker_pos = before.rfind('![图片](') + + if open_marker_pos != -1: + actual_open_pos = lookback_limit + open_marker_pos + check_segment = result[actual_open_pos:file_start] + if check_segment.startswith('![图片]('): + mark_start = actual_open_pos + + # 向后查找是否有 ) + after = result[file_end:] + close_paren_pos = after.find(')') + if close_paren_pos != -1 and close_paren_pos < 20: + middle = after[:close_paren_pos] + if '\n' not in middle and '![图片](' not in middle: + mark_end = file_end + close_paren_pos + 1 + + # 提取纯文件名(去掉可能的路径前缀) + pure_filename = filename_with_ext.split('/')[-1] + + # 构建完整的标准格式(包括固定URL前缀) + full_url = base_prefix.rstrip('/') + '/images/' + pure_filename + standard_format = f'![图片]({full_url})' + + result = result[:mark_start] + standard_format + result[mark_end:] + + # 清理嵌套格式 + nested_pattern = r'!\[图片\]\([^)]*!\[图片\]\(([^)]+\.(?:jpg|jpeg|png|gif|bmp|webp))\)[^)]*\)' + while re.search(nested_pattern, result, re.IGNORECASE): + result = re.sub(nested_pattern, r'![图片](\1)', result, flags=re.IGNORECASE) + + return result + + +def _normalize_image_paths(text: str, base_prefix: str, original_paths: List[str]) -> str: + """ + 第二轮:只规范化最后文件名部分(xxx.jpg),与原始路径匹配,匹配度低的删除 + + 步骤: + 1. 找到所有 ![图片](URL) 格式的图片 + 2. 对每个图片: + a. 提取最后文件名部分(xxx.jpg) + b. 只与 original_paths 中的最后文件名部分匹配 + c. 如果匹配度高(>0.5),替换为正确路径 + d. 如果匹配度低(<=0.5),删除整个图片 + """ + if not text or not original_paths: + return text + + # 匹配所有 ![图片](...) 格式 + img_tag_pattern = r'!\[图片\]\(([^)]+)\)' + matches = list(re.finditer(img_tag_pattern, text, re.IGNORECASE)) + + if not matches: + return text + + result = text + + # 从后往前处理,避免位置偏移 + for match in reversed(matches): + full_tag = match.group(0) + url = match.group(1) + tag_start = match.start() + tag_end = match.end() + + # 从 URL 中只提取最后的文件名部分(xxx.jpg) + filename = url.split('/')[-1] + + # 尝试匹配 - 只匹配最后文件名部分 + best_match = None + best_score = 0 + + generated_filename = filename.lower() + + for orig_path in original_paths: + orig_filename = orig_path.split('/')[-1].lower() + score = SequenceMatcher(None, generated_filename, orig_filename).ratio() + if score > best_score: + best_score = score + best_match = orig_path + + if best_match and best_score > 0.5: + # 匹配成功,替换为正确路径(保持固定URL前缀) + # 只替换最后文件名部分,保持前缀不变 + pure_orig_filename = best_match.split('/')[-1] + full_url = base_prefix.rstrip('/') + '/images/' + pure_orig_filename + + standard_format = f'![图片]({full_url})' + result = result[:tag_start] + standard_format + result[tag_end:] + else: + # 匹配度低,删除整个图片 + result = result[:tag_start] + result[tag_end:] + + return result + + +def get_image_prompt_guidance() -> str: + """获取图片格式处理的提示词指导""" + return """【图片格式特别要求】 +- 检索结果中的图片链接格式通常为:`![图片](/api/v1/knowledge/files/images/xxx.jpg)` +- 输出时必须严格保留此格式,不得修改、省略或截断 +- 图片的 alt 文本必须保持为「图片」 +- 图片 URL 必须完整包含 `/api/v1/knowledge/files/images` 前缀 +- 不要将图片链接转换为纯文本描述 +- 如果有多个图片,在对应位置分别引用,保持原始顺序 +- 严禁修改图片文件名或路径结构 +- 如果需要展示图片,直接使用原始的 `![图片](URL)` 格式即可""" + diff --git a/utils/intent_matcher.py b/utils/intent_matcher.py new file mode 100644 index 0000000..7e15614 --- /dev/null +++ b/utils/intent_matcher.py @@ -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 + diff --git a/utils/pdf_generator.py b/utils/pdf_generator.py new file mode 100644 index 0000000..016e739 --- /dev/null +++ b/utils/pdf_generator.py @@ -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']}") diff --git a/utils/ship_number_search.py b/utils/ship_number_search.py new file mode 100644 index 0000000..fcbb3bd --- /dev/null +++ b/utils/ship_number_search.py @@ -0,0 +1,463 @@ +""" +舷号搜索策略模块 +提供三级搜索策略: +1. 指定舷号搜索 +2. 同型号所有舷号搜索 +3. 全局搜索 + +新增功能: +- 智能舷号映射:支持舷号、舰名的智能识别和映射 +""" +from typing import List, Dict, Any, Optional, Tuple +import random +import re +import httpx +from utils.function_tracker import get_all_callbacks +from tools.ship_model_db import load_ship_model_mapping +from tools.rag_tools import rag_search +from modelsAPI.model_api import OpenaiAPI +from config import KB_TREE_CONFIG + + +_kb_name_to_id_cache: Optional[Dict[str, str]] = None + + +async def _load_kb_tree() -> Dict[str, str]: + """ + 从知识库树 API 加载所有知识库,返回 {名称: kb_id} 的映射 + 递归遍历树结构,带缓存,只请求一次 + """ + global _kb_name_to_id_cache + if _kb_name_to_id_cache is not None: + return _kb_name_to_id_cache + + url = KB_TREE_CONFIG.get("url", "") + if not url: + print("[ship_number_search] KB_TREE_CONFIG.url 未配置") + return {} + + headers = { + "accept": "application/json", + "x-user-id": "1", + "x-user-name": "testuser", + "x-role": "admin", + } + + try: + async with httpx.AsyncClient(timeout=30.0, verify=False) as client: + response = await client.get(url, headers=headers) + if response.status_code != 200: + print(f"[ship_number_search] 知识库树 API 请求失败: HTTP {response.status_code}") + return {} + + result = response.json() + + if isinstance(result, list): + top_nodes = result + elif isinstance(result, dict): + kb_data = result.get("data") or result.get("list") or {} + top_nodes = kb_data.get("children", []) if isinstance(kb_data, dict) else [] + else: + top_nodes = [] + + name_to_id = {} + + def _collect(nodes): + for node in nodes: + kb_name = node.get("name") or "" + kb_id = str(node.get("id", "")) if node.get("id") is not None else "" + if kb_name and kb_id: + name_to_id[kb_name] = kb_id + children = node.get("children", []) + if children: + _collect(children) + + _collect(top_nodes) + + _kb_name_to_id_cache = name_to_id + return name_to_id + except Exception as e: + print(f"[ship_number_search] 加载知识库树失败: {str(e)}") + return {} + + +async def resolve_kb_id(ship_number: str) -> Optional[str]: + """ + 根据舷号从知识库树中查找对应的真实 kb_id + + 匹配策略:在知识库名称中查找包含该舷号的条目 + 例如 ship_number="163",知识库名称为 "163舰资料",则匹配成功 + + Args: + ship_number: 舷号(如 "163") + + Returns: + 匹配到的 kb_id,未找到返回 None + """ + if not ship_number: + return None + + name_to_id = await _load_kb_tree() + + if ship_number in name_to_id: + return name_to_id[ship_number] + + for kb_name, kb_id in name_to_id.items(): + if re.search(rf'(? list: + """ + 根据舷号获取同型号的所有舷号 + + Args: + ship_number: 舷号 + + Returns: + list: 同型号的所有舷号列表,如果未找到则返回只包含输入舷号的列表 + """ + ship_number = str(ship_number).strip() + ships = await load_ship_model_mapping() + target_model = None + for ship in ships: + if ship["hull_number"] == ship_number: + target_model = ship["model_name"] + break + if target_model: + return [s["hull_number"] for s in ships if s["model_name"] == target_model] + return [ship_number] + + +async def _build_ship_mapping_candidates() -> Dict[str, Dict[str, Any]]: + """ + 构建舷号映射候选词库 + 从每艘舰的记录中构建候选词,支持舷号和舰名匹配 + """ + candidates = {} + + ships = await load_ship_model_mapping() + + model_numbers: Dict[str, List[str]] = {} + for ship in ships: + model = ship["model_name"] + if model not in model_numbers: + model_numbers[model] = [] + model_numbers[model].append(ship["hull_number"]) + + for ship in ships: + model = ship["model_name"] + hull_number = ship["hull_number"] + ship_name = ship.get("ship_name", "") + numbers = model_numbers.get(model, []) + + mapping_info = { + "model": model, + "numbers": numbers.copy() + } + + candidates[hull_number] = { + "type": "number", + "ship_number": hull_number, + **mapping_info + } + + if ship_name: + candidates[ship_name] = { + "type": "name", + "ship_number": hull_number, + **mapping_info + } + + return candidates + + +_SHIP_MAPPING_CANDIDATES = None + +async def get_ship_mapping_candidates() -> Dict[str, Dict[str, Any]]: + """ + 获取舷号映射候选词库(带缓存) + """ + global _SHIP_MAPPING_CANDIDATES + if _SHIP_MAPPING_CANDIDATES is None: + _SHIP_MAPPING_CANDIDATES = await _build_ship_mapping_candidates() + return _SHIP_MAPPING_CANDIDATES + +async def smart_ship_number_mapping( + user_input: str, + similarity_threshold: float = 0.8 +) -> Tuple[Optional[str], Optional[str], Optional[List[str]], str]: + """ + 智能舷号映射函数 + 将用户输入的舷号/舰名映射到准确的舷号或型号 + + Returns: + Tuple: (matched_ship_number, matched_model, all_numbers, match_type) + - matched_ship_number: 匹配到的具体舷号(舰名也会映射到对应舷号) + - matched_model: 匹配到的型号名称 + - all_numbers: 该型号下的所有舷号列表 + - match_type: 匹配类型 ("exact_number", "exact_name", "embedding", "none") + """ + if not user_input or not user_input.strip(): + return None, None, None, "none" + + user_input = user_input.strip() + candidates = await get_ship_mapping_candidates() + + if user_input in candidates: + info = candidates[user_input] + match_type = f"exact_{info['type']}" + model = info["model"] + numbers = info["numbers"] + ship_number = info.get("ship_number") + + return ship_number, model, numbers, match_type + + try: + candidate_texts = list(candidates.keys()) + query_embedding = await OpenaiAPI.get_embeddings_async(user_input) + candidate_embeddings = await OpenaiAPI.get_embeddings_batch_async(candidate_texts) + + best_score = -1 + best_idx = -1 + + for idx, emb in enumerate(candidate_embeddings): + score = OpenaiAPI.cosine_similarity(query_embedding, emb) + if score > best_score: + best_score = score + best_idx = idx + + if best_score >= similarity_threshold and best_idx >= 0: + matched_text = candidate_texts[best_idx] + info = candidates[matched_text] + model = info["model"] + numbers = info["numbers"] + ship_number = info.get("ship_number") + + print(f"[智能映射] 用户输入 '{user_input}' -> 匹配到 '{matched_text}' (相似度: {best_score:.3f}, 类型: {info['type']})") + + return ship_number, model, numbers, "embedding" + except Exception as e: + print(f"[智能映射] Embedding 匹配失败: {str(e)}") + + return None, None, None, "none" + +def send_search_status(title: str, details: str): + """发送搜索状态事件""" + title_options = [f"🔍 {title}", f"🔎 {title}", f"📚 {title}"] + start_event = { + "type": "function_execution", + "title": random.choice(title_options), + "details": details + } + for callback in get_all_callbacks(): + try: + callback(start_event) + except Exception: + pass + +def convert_rag_result(rag_result): + """将 rag_search 的返回结果转换为原来的格式""" + if not rag_result or not rag_result.get("success", False): + return [] + source_citation = rag_result.get("sourceCitation", {}) + flat_results = [] + for resource, items in source_citation.items(): + for item in items: + flat_results.append(item) + return flat_results + +async def search_with_ship_number_strategy( + base_query: str, + ship_number: str, + top_k: int = 5, + search_type: str = "fault" +) -> Tuple[List[Dict[str, Any]], str, str, str]: + """ + 使用舷号三级搜索策略进行RAG检索 + 返回: (rag_results, matched_kb_name, matched_kb_id, matched_ship_number) + matched_ship_number: 映射后的舷号,未映射到具体舷号时为空字符串 + """ + rag_results = [] + matched_kb_name = "" + matched_kb_id = "" + + matched_ship_number = None + matched_model = None + model_all_numbers = None + match_type = "none" + + if ship_number and ship_number.strip(): + send_search_status( + "正在智能识别", + f"识别 '{ship_number}' 的含义(舷号/舰名)..." + ) + + matched_ship_number, matched_model, model_all_numbers, match_type = await smart_ship_number_mapping(ship_number) + + if match_type != "none": + print(f"[智能映射结果] 输入='{ship_number}' -> 舷号={matched_ship_number}, 型号={matched_model}, 所有舷号={model_all_numbers}, 匹配类型={match_type}") + else: + print(f"[智能映射结果] 输入='{ship_number}' -> 未找到匹配,将使用原始值进行检索") + + if match_type in ["exact_number", "exact_name", "embedding"] and matched_ship_number: + real_kb_id = await resolve_kb_id(matched_ship_number) + if real_kb_id: + send_search_status( + "正在指定舷号检索", + f"使用舷号 {matched_ship_number} 进行检索..." + ) + + rag_query = f"{base_query}" + + try: + rag_result_raw = await rag_search(rag_query, top_k=top_k, kb_id=real_kb_id) + rag_results = convert_rag_result(rag_result_raw) + + if rag_results: + print(f"步骤1 - 舷号 {matched_ship_number} 检索完成,返回 {len(rag_results)} 条结果") + for item in rag_results: + if isinstance(item, dict): + found_kb_name = item.get("kb_name", "") + found_kb_id = item.get("kb_id", "") + if found_kb_name and not matched_kb_name: + matched_kb_name = str(found_kb_name).strip() + if found_kb_id and not matched_kb_id: + matched_kb_id = str(found_kb_id).strip() + return rag_results, matched_kb_name, matched_kb_id, matched_ship_number or "" + except Exception as e: + print(f"步骤1 - 舷号 {matched_ship_number} 检索失败: {str(e)}") + else: + print(f"步骤1 - 舷号 {matched_ship_number} 无对应知识库,跳过指定舷号检索") + + if match_type in ["exact_number", "exact_name", "embedding"] and model_all_numbers: + send_search_status( + "正在型号检索", + f"识别为型号 '{matched_model}',使用该型号所有舷号 {', '.join(model_all_numbers)} 进行检索..." + ) + + for num in model_all_numbers: + rag_query = f"{base_query}" + + try: + real_kb_id = await resolve_kb_id(num) + if not real_kb_id: + continue + rag_result_raw = await rag_search(rag_query, top_k=top_k, kb_id=real_kb_id) + current_results = convert_rag_result(rag_result_raw) + + if current_results: + rag_results.extend(current_results) + print(f"步骤2 - 舷号 {num} 检索完成,返回 {len(current_results)} 条结果") + + if not matched_kb_name: + for item in current_results: + if isinstance(item, dict): + found_kb_name = item.get("kb_name", "") + found_kb_id = item.get("kb_id", "") + if found_kb_name and not matched_kb_name: + matched_kb_name = str(found_kb_name).strip() + if found_kb_id and not matched_kb_id: + matched_kb_id = str(found_kb_id).strip() + except Exception as e: + print(f"步骤2 - 舷号 {num} 检索失败: {str(e)}") + + if rag_results: + return rag_results, matched_kb_name, matched_kb_id, matched_ship_number or "" + + if ship_number and ship_number.strip() and match_type == "none": + real_kb_id = await resolve_kb_id(ship_number) + if real_kb_id: + send_search_status( + "正在指定舰名检索", + f"使用舰名 {ship_number} 进行检索..." + ) + + rag_query = f"{base_query}" + + try: + rag_result_raw = await rag_search(rag_query, top_k=top_k, kb_id=real_kb_id) + rag_results = convert_rag_result(rag_result_raw) + + if rag_results: + print(f"步骤1 - 舷号 {ship_number} 检索完成,返回 {len(rag_results)} 条结果") + for item in rag_results: + if isinstance(item, dict): + found_kb_name = item.get("kb_name", "") + found_kb_id = item.get("kb_id", "") + if found_kb_name and not matched_kb_name: + matched_kb_name = str(found_kb_name).strip() + if found_kb_id and not matched_kb_id: + matched_kb_id = str(found_kb_id).strip() + return rag_results, matched_kb_name, matched_kb_id, matched_ship_number or "" + except Exception as e: + print(f"步骤1 - 舷号 {ship_number} 检索失败: {str(e)}") + else: + print(f"步骤1 - 舷号 {ship_number} 无对应知识库,跳过指定舰名检索") + + model_ship_numbers = await get_ship_numbers_by_model(ship_number) + if len(model_ship_numbers) > 1 or (len(model_ship_numbers) == 1 and ship_number not in model_ship_numbers): + send_search_status( + "正在同型号检索", + f"使用同型号舷号 {', '.join(model_ship_numbers)} 进行检索..." + ) + + for num in model_ship_numbers: + if num == ship_number: + continue + + rag_query = f"{base_query}" + + try: + real_kb_id = await resolve_kb_id(num) + if not real_kb_id: + continue + rag_result_raw = await rag_search(rag_query, top_k=top_k, kb_id=real_kb_id) + current_results = convert_rag_result(rag_result_raw) + + if current_results: + rag_results.extend(current_results) + print(f"步骤2 - 舷号 {num} 检索完成,返回 {len(current_results)} 条结果") + + if not matched_kb_name: + for item in current_results: + if isinstance(item, dict): + found_kb_name = item.get("kb_name", "") + found_kb_id = item.get("kb_id", "") + if found_kb_name and not matched_kb_name: + matched_kb_name = str(found_kb_name).strip() + if found_kb_id and not matched_kb_id: + matched_kb_id = str(found_kb_id).strip() + except Exception as e: + print(f"步骤2 - 舷号 {num} 检索失败: {str(e)}") + + if rag_results: + return rag_results, matched_kb_name, matched_kb_id, matched_ship_number or "" + + send_search_status( + "正在全局检索", + "未找到舷号相关资料,进行全局检索..." + ) + + try: + rag_result_raw = await rag_search(base_query, top_k=top_k) + rag_results = convert_rag_result(rag_result_raw) + + if rag_results: + print(f"步骤3 - 全局检索完成,返回 {len(rag_results)} 条结果") + for item in rag_results: + if isinstance(item, dict): + found_kb_name = item.get("kb_name", "") + found_kb_id = item.get("kb_id", "") + if found_kb_name and not matched_kb_name: + matched_kb_name = str(found_kb_name).strip() + if found_kb_id and not matched_kb_id: + matched_kb_id = str(found_kb_id).strip() + except Exception as e: + print(f"步骤3 - 全局检索失败: {str(e)}") + rag_results = [] + + return rag_results, matched_kb_name, matched_kb_id, matched_ship_number or "" + diff --git a/utils/template.tex b/utils/template.tex new file mode 100644 index 0000000..6b19132 --- /dev/null +++ b/utils/template.tex @@ -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} diff --git a/utils/word_generator.py b/utils/word_generator.py new file mode 100644 index 0000000..c3a2097 --- /dev/null +++ b/utils/word_generator.py @@ -0,0 +1,790 @@ +import os +import re +import uuid +import logging +import tempfile +from datetime import datetime + +from docx import Document +from docx.shared import Pt, RGBColor, Inches +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.oxml.ns import qn +from lxml import etree + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +) +logger = logging.getLogger(__name__) + +_MML2OMML_XSLT = None +_MML2OMML_LOADED = False + + +def _load_mml2omml_xslt(): + global _MML2OMML_XSLT, _MML2OMML_LOADED + if _MML2OMML_LOADED: + return _MML2OMML_XSLT + _MML2OMML_LOADED = True + + _this_dir = os.path.dirname(os.path.abspath(__file__)) + search_paths = [ + os.path.join(_this_dir, "MML2OMML.XSL"), + r"C:\Program Files\Microsoft Office\root\Office16\MML2OMML.XSL", + r"C:\Program Files (x86)\Microsoft Office\root\Office16\MML2OMML.XSL", + r"C:\Program Files\Microsoft Office\Office16\MML2OMML.XSL", + r"C:\Program Files (x86)\Microsoft Office\Office16\MML2OMML.XSL", + "/usr/share/mml2omml/MML2OMML.XSL", + "/app/utils/MML2OMML.XSL", + ] + logger.info(f"[公式] 开始搜索 MML2OMML.XSL,共 {len(search_paths)} 个路径") + for path in search_paths: + exists = os.path.isfile(path) + logger.info(f"[公式] 检查路径: {path} -> {'存在' if exists else '不存在'}") + if exists: + try: + _MML2OMML_XSLT = etree.parse(path) + logger.info(f"[公式] ✓ MML2OMML.XSL 加载成功: {path}") + return _MML2OMML_XSLT + except Exception as e: + logger.error(f"[公式] ✗ MML2OMML.XSL 加载失败: {path}, 错误: {e}") + + logger.warning("[公式] ✗ 未找到 MML2OMML.XSL,公式将使用 matplotlib 图片回退方式渲染") + return None + + +class MarkdownWordGenerator: + + def __init__(self): + try: + self.doc = Document() + self.cache_dir = os.path.join(tempfile.gettempdir(), "latex_formula_cache") + os.makedirs(self.cache_dir, exist_ok=True) + self.formula_cache = {} + self._omml_available = _load_mml2omml_xslt() is not None + logger.info(f"[公式] MarkdownWordGenerator 初始化完成, OMML可用: {self._omml_available}") + except Exception as e: + logger.error(f"初始化 MarkdownWordGenerator 失败: {e}") + raise + + def set_font(self, run, size=11, bold=False): + try: + run.font.name = "微软雅黑" + rPr = run._element.get_or_add_rPr() + rFonts = rPr.find(qn('w:rFonts')) + if rFonts is None: + rFonts = run._element.makeelement(qn('w:rFonts'), {}) + rPr.insert(0, rFonts) + rFonts.set(qn('w:eastAsia'), "微软雅黑") + run.font.size = Pt(size) + run.font.bold = True if bold else False + except Exception as e: + logger.warning(f"设置字体时出错: {e}") + + def add_heading(self, text, level): + try: + p = self.doc.add_heading(level=level) + size_map = {1: 18, 2: 16, 3: 14} + font_size = size_map.get(level, 12) + self._add_rich_text(p, text, default_size=font_size, default_bold=True) + except Exception as e: + logger.error(f"添加标题失败 '{text}': {e}") + + def add_paragraph(self, text): + try: + if not text.strip(): + return + p = self.doc.add_paragraph() + + indent_chars = 0 + temp_text = text + while temp_text.startswith('\u3000'): + indent_chars += 1 + temp_text = temp_text[1:] + + if indent_chars >= 2: + p.paragraph_format.first_line_indent = Inches(0.5) + text = temp_text + elif indent_chars == 1: + p.paragraph_format.first_line_indent = Inches(0.25) + text = temp_text + + self._add_rich_text(p, text) + except Exception as e: + logger.error(f"添加段落失败: {e}") + + def _add_rich_text(self, paragraph, text, default_size=11, default_bold=False): + if not text: + return + + pattern = r'\*\*(.+?)\*\*' + last_end = 0 + + for match in re.finditer(pattern, text): + start, end = match.span() + if start > last_end: + plain = text[last_end:start] + if plain: + run = paragraph.add_run(plain) + self.set_font(run, size=default_size, bold=default_bold) + + bold_content = match.group(1) + if bold_content: + run = paragraph.add_run(bold_content) + self.set_font(run, size=default_size, bold=True) + + last_end = end + + if last_end < len(text): + remaining = text[last_end:] + if remaining: + run = paragraph.add_run(remaining) + self.set_font(run, size=default_size, bold=default_bold) + + @staticmethod + def _preprocess_latex_for_omml(latex: str) -> str: + replacements = { + r'\leqslant': r'\leq', + r'\geqslant': r'\geq', + r'\nleqslant': r'\nleq', + r'\ngeqslant': r'\ngeq', + r'\eqslantless': r'\leq', + r'\eqslantgtr': r'\geq', + r'\varnothing': r'\emptyset', + r'\dfrac': r'\frac', + r'\tfrac': r'\frac', + r'\boldsymbol': r'\mathbf', + r'\bm': r'\mathbf', + } + for old, new in replacements.items(): + latex = latex.replace(old, new) + + latex = re.sub(r'\\mathrm\s*\{([^}]*)\}', lambda m: r'\mathrm{' + m.group(1) + '}', latex) + latex = re.sub(r'\\text\s*\{([^}]*)\}', lambda m: r'\mathrm{' + m.group(1) + '}', latex) + latex = re.sub(r'\\textbf\s*\{([^}]*)\}', lambda m: r'\mathbf{' + m.group(1) + '}', latex) + latex = re.sub(r'\\textit\s*\{([^}]*)\}', lambda m: r'\mathit{' + m.group(1) + '}', latex) + + return latex + + def _latex_to_omml(self, latex_str): + logger.info(f"[公式] LaTeX->OMML 开始, 原始: '{latex_str}'") + try: + import latex2mathml.converter + processed = self._preprocess_latex_for_omml(latex_str) + logger.info(f"[公式] LaTeX 预处理后: '{processed}'") + mathml = latex2mathml.converter.convert(processed) + logger.info(f"[公式] MathML 生成成功, 长度: {len(mathml)}") + mathml_tree = etree.fromstring(mathml.encode('utf-8')) + xslt = _load_mml2omml_xslt() + if xslt is None: + logger.warning("[公式] XSLT 不可用,OMML 转换跳过") + return None + transform = etree.XSLT(xslt) + omml_tree = transform(mathml_tree) + omml_root = omml_tree.getroot() + logger.info(f"[公式] ✓ OMML 转换成功: '{latex_str}'") + return omml_root + except Exception as e: + logger.error(f"[公式] ✗ LaTeX->OMML 转换失败 '{latex_str}': {e}", exc_info=True) + return None + + def _add_omml_to_paragraph(self, paragraph, latex_str): + omml = self._latex_to_omml(latex_str) + if omml is not None: + paragraph._element.append(omml) + return True + return False + + def _latex_to_image(self, latex_str, inline=True): + """matplotlib 回退方案:将公式渲染为 PNG 图片""" + logger.info(f"[公式] LaTeX->图片 回退, 原始: '{latex_str}'") + try: + import matplotlib + matplotlib.use('Agg') + import matplotlib.pyplot as plt + + processed = self._preprocess_latex_for_omml(latex_str) + logger.info(f"[公式] LaTeX 预处理后: '{processed}'") + + if latex_str in self.formula_cache: + cached = self.formula_cache[latex_str] + if os.path.isfile(cached): + logger.info(f"[公式] 使用缓存: {cached}") + return cached + + filename = os.path.join(self.cache_dir, f"{uuid.uuid4()}.png") + fig_width = 6 if not inline else 4 + fig_height = 1.2 if not inline else 0.8 + font_size = 20 if not inline else 16 + + fig = plt.figure(figsize=(fig_width, fig_height)) + fig.text(0.5, 0.5, f"${processed}$", fontsize=font_size, ha='center', va='center') + plt.axis('off') + fig.savefig(filename, dpi=300, bbox_inches="tight", pad_inches=0.05) + plt.close(fig) + + self.formula_cache[latex_str] = filename + logger.info(f"[公式] ✓ 图片渲染成功: {filename}") + return filename + except Exception as e: + logger.error(f"[公式] ✗ LaTeX->图片 渲染失败 '{latex_str}': {e}", exc_info=True) + try: + plt.close('all') + except Exception: + pass + return None + + def _insert_formula(self, paragraph, latex_str, inline=True): + """统一公式插入方法:优先 OMML,回退 matplotlib 图片""" + if self._omml_available: + if self._add_omml_to_paragraph(paragraph, latex_str): + return True + logger.warning(f"[公式] OMML 失败,回退 matplotlib: '{latex_str}'") + + img_path = self._latex_to_image(latex_str, inline=inline) + if img_path: + try: + width = Inches(0.7) if inline else Inches(2.5) + run = paragraph.add_run() + run.add_picture(img_path, width=width) + return True + except Exception as e: + logger.error(f"[公式] ✗ 图片插入失败: {e}") + + run = paragraph.add_run(f" {latex_str} ") + self.set_font(run, size=9) + logger.warning(f"[公式] ✗ 所有方式失败,以纯文本插入: '{latex_str}'") + return False + + def _add_rich_text_with_formulas(self, paragraph, text, default_size=11, default_bold=False): + if not text: + return + + has_formula = bool(re.search(r'\$[^$]+?\$', text)) + has_image = bool(self._extract_image_urls(text)) + + if not has_formula and not has_image: + self._add_rich_text(paragraph, text, default_size=default_size, default_bold=default_bold) + return + + if has_image: + img_urls = self._extract_image_urls(text) + last_end = 0 + for start, end, url in img_urls: + before_text = text[last_end:start] + if before_text and before_text.strip(): + self._add_rich_text_with_formulas(paragraph, before_text, default_size=default_size, default_bold=default_bold) + self.add_image(url, width_inches=4.5) + last_end = end + after_text = text[last_end:] + if after_text and after_text.strip(): + p = self.doc.add_paragraph() + self._add_rich_text_with_formulas(p, after_text, default_size=default_size, default_bold=default_bold) + return + + segments = re.split(r'(\$[^$]+?\$)', text) + for segment in segments: + if not segment: + continue + if segment.startswith('$') and segment.endswith('$') and len(segment) > 2: + latex_content = segment[1:-1].strip() + self._insert_formula(paragraph, latex_content, inline=True) + else: + self._add_rich_text(paragraph, segment, default_size=default_size, default_bold=default_bold) + + def add_list(self, text, numbered=False, number=None): + try: + text = text.strip() + if not text: + return + + if re.match(r'^[-_*]{2,}$', text): + return + + if numbered and number is not None: + p = self.doc.add_paragraph() + p.paragraph_format.left_indent = Pt(18) + p.paragraph_format.first_line_indent = Pt(-18) + num_run = p.add_run(f"{number}. ") + self.set_font(num_run, bold=False) + self._add_rich_text_with_formulas(p, text) + else: + p = self.doc.add_paragraph(style="List Bullet") + self._add_rich_text_with_formulas(p, text) + + except Exception as e: + logger.error(f"添加列表失败: {e}") + + def add_sub_list(self, text): + try: + text = text.strip() + if not text: + return + if re.match(r'^[-_*]{2,}$', text): + return + + try: + p = self.doc.add_paragraph(style="List Bullet 2") + except KeyError: + p = self.doc.add_paragraph(style="List Bullet") + p.paragraph_format.left_indent = Pt(36) + self._add_rich_text_with_formulas(p, text) + except Exception as e: + logger.error(f"添加子列表失败: {e}") + + def _convert_url_to_local_path(self, url: str) -> str: + if not url: + return None + from urllib.parse import urlparse + path = urlparse(url).path + if path.startswith('/picture/'): + docker_path = f"/app/picture/{path[len('/picture/'):]}" + if os.path.isfile(docker_path): + return docker_path + try: + project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + local_path = os.path.join(project_root, "picture", path[len('/picture/'):]) + if os.path.isfile(local_path): + return local_path + except Exception: + pass + return None + return None + + @staticmethod + def _get_http_headers(url: str) -> dict: + headers = {} + if "/api/v1/knowledge/" in url: + try: + from config import RAG_CONFIG + headers["x-user-id"] = str(RAG_CONFIG.get("x-user-id", "1")) + headers["x-user-name"] = str(RAG_CONFIG.get("x-user-name", "testuser")) + headers["x-role"] = str(RAG_CONFIG.get("x-role", "admin")) + except Exception: + headers["x-user-id"] = "1" + headers["x-user-name"] = "testuser" + headers["x-role"] = "admin" + return headers + + def add_image(self, url_or_path: str, width_inches: float = 5.5): + if not url_or_path: + return + + try: + import io + image_stream = None + + if os.path.isfile(url_or_path): + image_stream = url_or_path + elif url_or_path.startswith("http://") or url_or_path.startswith("https://"): + local_path = self._convert_url_to_local_path(url_or_path) + if local_path and os.path.isfile(local_path): + image_stream = local_path + else: + import requests + headers = self._get_http_headers(url_or_path) + resp = requests.get(url_or_path, timeout=15, headers=headers, proxies={"http": None, "https": None}) + resp.raise_for_status() + content_type = resp.headers.get("Content-Type", "") + logger.info(f"HTTP 下载完成, Content-Type: {content_type}, 大小: {len(resp.content)} bytes") + if not content_type.startswith("image/"): + logger.error(f"响应不是图片类型: {content_type}") + self.add_paragraph(f"[图片加载失败: {url_or_path}]") + return + image_stream = io.BytesIO(resp.content) + else: + logger.warning(f"图片路径无效: {url_or_path}") + return + + from PIL import Image as PilImage + if isinstance(image_stream, str): + pil_img = PilImage.open(image_stream) + else: + pil_img = PilImage.open(image_stream) + png_stream = io.BytesIO() + pil_img.convert("RGBA" if pil_img.mode in ("RGBA", "P") else "RGB").save(png_stream, format="PNG") + png_stream.seek(0) + + p = self.doc.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + run = p.add_run() + run.add_picture(png_stream, width=Inches(width_inches)) + logger.info(f"图片插入成功: {url_or_path}") + + except Exception as e: + logger.error(f"插入图片失败 '{url_or_path}': {e!r}", exc_info=True) + self.add_paragraph(f"[图片加载失败: {url_or_path}]") + + def _extract_image_urls(self, line_content): + """ + 从一行文本中提取所有图片URL,支持多种格式: + - ![alt](url) + - ![alt]`url` + - ! `url` + 返回 [(start, end, url), ...] + """ + results = [] + + # 格式1: ![alt](url) + for m in re.finditer(r'!\[([^\]]*)\]\(([^)]+)\)', line_content): + url = m.group(2).strip() + if url.startswith('`') and url.endswith('`'): + url = url[1:-1] + results.append((m.start(), m.end(), url)) + + # 格式2: ![alt]`url` (未被格式1匹配的) + for m in re.finditer(r'!\[([^\]]*)\]\s*`([^`]+)`', line_content): + url = m.group(2).strip() + already = any(s <= m.start() and e >= m.end() for s, e, _ in results) + if not already: + results.append((m.start(), m.end(), url)) + + # 格式3: ! `url` (模型常见的破损格式) + for m in re.finditer(r'!\s*`([^`]+\.(?:jpg|jpeg|png|gif|bmp|webp))`', line_content, re.IGNORECASE): + url = m.group(1).strip() + already = any(s <= m.start() and e >= m.end() for s, e, _ in results) + if not already: + results.append((m.start(), m.end(), url)) + + results.sort(key=lambda x: x[0]) + return results + + def add_table(self, lines): + try: + rows = [] + for line in lines: + line = line.strip("|") + cells = [c.strip() for c in line.split("|")] + rows.append(cells) + + if not rows: + return + + header = rows[0] + data_start_index = 1 + if len(rows) > 1 and all(c.startswith('-') or c == '' for c in rows[1]): + data_start_index = 2 + + data = rows[data_start_index:] + max_cols = len(header) + + table = self.doc.add_table(rows=len(data) + 1, cols=max_cols) + table.style = "Table Grid" + + for i, cell in enumerate(header): + if i < max_cols: + table.rows[0].cells[i].text = cell + + for r, row in enumerate(data): + for c, val in enumerate(row): + if c < max_cols: + table.rows[r + 1].cells[c].text = val + + logger.info("表格添加成功") + except Exception as e: + logger.error(f"添加表格失败: {e}") + + def _add_horizontal_rule(self): + p = self.doc.add_paragraph() + pPr = p._element.get_or_add_pPr() + pBdr = p._element.makeelement(qn('w:pBdr'), {}) + bottom = p._element.makeelement(qn('w:bottom'), { + qn('w:val'): 'single', + qn('w:sz'): '6', + qn('w:space'): '1', + qn('w:color'): 'CCCCCC', + }) + pBdr.append(bottom) + pPr.append(pBdr) + + def parse(self, markdown): + lines = markdown.split("\n") + table_buffer = [] + block_formula = [] + in_block_formula = False + + i = 0 + while i < len(lines): + line = lines[i] + line_stripped = line.rstrip() + line_content = line_stripped.lstrip() + + # 1. 处理块级公式 + if line_content.startswith("$$"): + if not in_block_formula: + remaining = line_content[2:] + close_idx = remaining.find("$$") + if close_idx >= 0: + formula = remaining[:close_idx].strip() + after_formula = remaining[close_idx + 2:].strip() + p = self.doc.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + self._insert_formula(p, formula, inline=False) + if after_formula: + self.add_paragraph(after_formula) + else: + in_block_formula = True + block_formula = [] + if remaining.strip(): + block_formula.append(remaining.strip()) + else: + remaining = line_content[2:] + in_block_formula = False + formula = "".join(block_formula).strip() + p = self.doc.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + self._insert_formula(p, formula, inline=False) + if remaining.strip(): + self.add_paragraph(remaining.strip()) + i += 1 + continue + + if in_block_formula: + block_formula.append(line) + i += 1 + continue + + # 2. 处理表格 + if line_content.startswith("|") and line_content.endswith("|"): + table_buffer.append(line_stripped) + i += 1 + continue + else: + if table_buffer: + self.add_table(table_buffer) + table_buffer = [] + + # 空行跳过 + if not line_stripped: + i += 1 + continue + + # 3. 水平分隔线 + if re.match(r'^\s*[-*_]{3,}\s*$', line_stripped): + self._add_horizontal_rule() + i += 1 + continue + + # 4. 标题 + heading_match = re.match(r'^(#{1,3})\s+(.*)', line_content) + if heading_match: + level = len(heading_match.group(1)) + text = heading_match.group(2) + self.add_heading(text, level) + i += 1 + continue + + # 5. 有序列表(带子列表支持) + list_match_ordered = re.match(r'^(\d+)\.\s*(.*)', line_content) + if list_match_ordered: + number = int(list_match_ordered.group(1)) + text = list_match_ordered.group(2) + self.add_list(text, numbered=True, number=number) + + i += 1 + while i < len(lines): + next_line = lines[i] + next_stripped = next_line.rstrip() + sub_match = re.match(r'^[\s\t]+[-*]\s*(.*)', next_stripped) + if sub_match: + sub_text = sub_match.group(1) + self.add_sub_list(sub_text) + i += 1 + elif (next_stripped.startswith(' ') or next_stripped.startswith('\t')): + sub_content = next_stripped.strip() + if sub_content and not re.match(r'^\d+\.\s', sub_content): + self.add_sub_list(sub_content) + i += 1 + else: + break + else: + break + continue + + # 6. 无序列表 + is_bold_line = line_content.startswith('**') + if not is_bold_line: + list_match_unordered = re.match(r'^\s*[-*]\s+(.*)', line_stripped) + if not list_match_unordered: + m = re.match(r'^\s*-([^-].*)$', line_stripped) + if m and m.group(1).strip(): + list_match_unordered = m + + if list_match_unordered: + text = list_match_unordered.group(1).strip() + if text: + self.add_list(text, numbered=False) + i += 1 + continue + + # 7. 图片链接 - 统一提取所有格式的图片URL + img_urls = self._extract_image_urls(line_content) + + if img_urls: + has_only_img = (len(img_urls) == 1 and + line_content.strip().startswith('!') and + img_urls[0][0] == 0) + + if has_only_img: + _, _, url = img_urls[0] + self.add_image(url) + else: + p = self.doc.add_paragraph() + last_end = 0 + for start, end, url in img_urls: + before_text = line_content[last_end:start].strip() + if before_text: + self._add_rich_text_with_formulas(p, before_text) + self.add_image(url, width_inches=4.5) + p = self.doc.add_paragraph() + last_end = end + after_text = line_content[last_end:].strip() + if after_text: + self._add_rich_text_with_formulas(p, after_text) + i += 1 + continue + + # 8. 普通链接 + if line_content.startswith("["): + match = re.match(r'\[(.*?)\]\((.*?)\)', line_content) + if match: + p = self.doc.add_paragraph() + run = p.add_run(match.group(1)) + run.font.color.rgb = RGBColor(0, 0, 255) + run.font.underline = True + i += 1 + continue + + # 9. 普通段落 + p = self.doc.add_paragraph() + + indent_chars = 0 + temp_line = line_stripped + while temp_line.startswith('\u3000'): + indent_chars += 1 + temp_line = temp_line[1:] + + if indent_chars >= 2: + p.paragraph_format.first_line_indent = Inches(0.5) + elif indent_chars == 1: + p.paragraph_format.first_line_indent = Inches(0.25) + + self._add_rich_text_with_formulas(p, line_content) + + i += 1 + + if table_buffer: + self.add_table(table_buffer) + + def generate(self, markdown, title=None, output=None, images=None): + try: + if title: + logger.info(f"添加文档标题: {title}") + p = self.doc.add_paragraph() + run = p.add_run(title) + self.set_font(run, 20, True) + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + + logger.info("开始解析 Markdown 内容") + self.parse(markdown) + + if images: + self._append_images_section(images) + + if not output: + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + output = f"report_{timestamp}.docx" + + logger.info(f"正在保存文件: {output}") + self.doc.save(output) + + if os.path.exists(output): + file_size = os.path.getsize(output) + logger.info(f"文件保存成功: {output}, 大小: {file_size} bytes") + return output + else: + logger.error(f"文件保存后未找到: {output}") + raise FileNotFoundError(f"File {output} was not created.") + + except Exception as e: + logger.error(f"生成报告过程中发生严重错误: {e}", exc_info=True) + raise + + def _append_images_section(self, images): + try: + self.add_heading("附件图片", level=2) + + for idx, img in enumerate(images, 1): + if isinstance(img, dict): + url = img.get('url', '') + summary = img.get('summary', '') + else: + url = str(img) + summary = '' + + p = self.doc.add_paragraph() + run = p.add_run(f"图片{idx}:") + self.set_font(run, bold=True) + + if url: + self.add_image(url, width_inches=4) + + if summary: + p = self.doc.add_paragraph() + run = p.add_run(f"说明:{summary}") + self.set_font(run, size=10) + + self.doc.add_paragraph() + + logger.info(f"图片附件部分添加完成,共 {len(images)} 张图片") + except Exception as e: + logger.error(f"添加图片附件部分失败: {e}") + + +def generate_report_word(markdown, title=None, output=None, images=None): + logger.info("=== 开始生成报告 ===") + try: + generator = MarkdownWordGenerator() + + if images: + logger.info(f"准备插入 {len(images)} 张图片") + + file_path = generator.generate(markdown, title=title, output=output, images=images) + logger.info(f"=== 报告生成完成: {file_path} ===") + return file_path + except Exception as e: + logger.critical(f"报告生成失败: {e}") + raise + + +if __name__ == "__main__": + md = r""" +## **测试报告 - OMML公式和图片** + +### **一、行内公式** + +温度要求 $ \leqslant 40 ^ { \circ } \mathrm { C } $,不得超过。 + +公差范围 $ \geqslant 0.05 \ \mathrm { mm } $ 以内合格。 + +### **二、列表中的公式** + +1. 温度 $ \leqslant 40 ^ { \circ } \mathrm { C } $ 要求 +2. 间隙 $ \leqslant 0.05 \ \mathrm { mm } $ 标准 + +- 清洁轴颈 $ \geqslant 0.05 \ \mathrm { mm } $ + + +### **四、图片测试** + +![图片](http://192.168.0.46:11000/api/v1/knowledge/files/images/56b1c0a02ff03d9d202627e88603e6671d3f193511d98a9fab0daf8687e6a9fa.jpg) +""" + + try: + result_path = generate_report_word(md) + print(f"报告已生成,路径: {result_path}") + except Exception as e: + print(f"程序执行出错: {e}") + import traceback + traceback.print_exc() diff --git a/workflow_registry.py b/workflow_registry.py new file mode 100644 index 0000000..fa63c79 --- /dev/null +++ b/workflow_registry.py @@ -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()) diff --git a/workflows/__init__.py b/workflows/__init__.py new file mode 100644 index 0000000..12a2821 --- /dev/null +++ b/workflows/__init__.py @@ -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" +] + diff --git a/workflows/history_manager.py b/workflows/history_manager.py new file mode 100644 index 0000000..42c40f9 --- /dev/null +++ b/workflows/history_manager.py @@ -0,0 +1,217 @@ +""" +统一历史对话管理器 +整合所有分散的 parse_history / _parse_history / build_history_str / filter_image_urls 逻辑 +""" + +import json +from typing import Any, Dict, List, Optional + + +class HistoryManager: + """统一的历史对话管理器,提供解析、截断、格式化、过滤等功能""" + + def __init__( + self, + max_messages: int = 8, + max_assistant_chars: int = 300, + ): + """ + Args: + max_messages: 保留的最近消息条数 + max_assistant_chars: assistant 角色内容截断字符数 + """ + self.max_messages = max_messages + self.max_assistant_chars = max_assistant_chars + + # ==================== 解析 ==================== + + def parse(self, history_message: Any) -> List[Dict[str, Any]]: + """ + 解析历史消息为列表,保留最近 max_messages 条。 + 兼容字符串和列表输入,替代所有 parse_history / _parse_history。 + """ + if not history_message: + return [] + try: + data = json.loads(history_message) if isinstance(history_message, str) else history_message + return data[-self.max_messages:] if isinstance(data, list) else [] + except Exception: + return [] + + # ==================== 截断 ==================== + + def truncate(self, messages: List[Dict[str, Any]], keep_recent: Optional[int] = None) -> List[Dict[str, Any]]: + """按数量截断,保留最近 keep_recent 条""" + if not messages: + return [] + n = keep_recent if keep_recent is not None else self.max_messages + return messages[-n:] + + # ==================== 格式化 ==================== + + def to_prompt_str( + self, + messages: List[Dict[str, Any]], + recent_count: Optional[int] = None, + assistant_truncate: Optional[int] = None, + ) -> str: + """ + 格式化为 "用户: xxx\\n助手: xxx" 纯文本字符串,用于 prompt 注入。 + 替代 build_history_str。 + """ + if not messages: + return "" + n = recent_count if recent_count is not None else self.max_messages + trunc = assistant_truncate if assistant_truncate is not None else self.max_assistant_chars + recent = messages[-n:] + parts = [] + for m in recent: + if isinstance(m, dict): + role = m.get("role", "") + content = (m.get("content") or "").strip() + if role == "user": + parts.append(f"用户: {content}") + elif role == "assistant": + parts.append(f"助手: {content[:trunc]}") + return "\n".join(parts) if parts else "" + + def to_openai_messages(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + 格式化为 OpenAI messages 列表,直接传入 LLM。 + 用于百科问答等需要完整消息格式的场景。 + """ + if not messages: + return [] + result = [] + for m in messages: + if isinstance(m, dict) and m.get("role") and m.get("content"): + result.append({"role": m["role"], "content": m["content"]}) + return result + + # ==================== 请求预处理 ==================== + + def preprocess_from_request(self, history_message: Any, exclude_last: bool = True) -> str: + """ + 处理前端传入的历史消息:解析、移除当前用户消息、序列化。 + 对应 app.py 中 history_message 预处理逻辑。 + + Args: + history_message: 前端传入的历史消息,支持 str 或 list + exclude_last: 是否移除最后一条(当前用户消息),默认 True + + Returns: + 处理后的 JSON 字符串 + """ + if not history_message: + return "" + try: + data = json.loads(history_message) if isinstance(history_message, str) else history_message + except (json.JSONDecodeError, TypeError): + return "" + if not isinstance(data, list): + return "" + if exclude_last and data: + data = data[:-1] + return json.dumps(data, ensure_ascii=False) + + # ==================== 状态初始化 ==================== + + def init_history(self, history_message: Any) -> Dict[str, Any]: + """ + 一步完成历史消息解析,返回 history_message(str) + history_messages(list) 双字段。 + 用于各工作流入口初始化 state,消除重复的 parse_history 调用。 + + Args: + history_message: 历史消息,支持 str 或 list + + Returns: + {"history_message": str, "history_messages": list} + """ + parsed = self.parse(history_message) + raw = json.dumps(parsed, ensure_ascii=False) if parsed else "" + return {"history_message": raw, "history_messages": parsed} + + # ==================== 过滤 ==================== + + @staticmethod + def filter_image_urls(history_message: Any) -> Any: + """ + 过滤历史消息中的图片URL,将多模态内容转为纯文本。 + 替代 main_agent.filter_image_urls。 + """ + if not history_message: + return history_message + + try: + history_messages = json.loads(history_message) if isinstance(history_message, str) else history_message + except (json.JSONDecodeError, TypeError): + return history_message + + if not isinstance(history_messages, list): + return history_messages + + filtered_messages = [] + for msg in history_messages: + if not isinstance(msg, dict): + filtered_messages.append(msg) + continue + + new_msg = msg.copy() + content = msg.get("content") + + if isinstance(content, list): + text_parts = [] + for content_item in content: + if isinstance(content_item, dict): + if content_item.get("type") == "text": + text_value = content_item.get("text", "") + if text_value: + text_parts.append(text_value) + elif isinstance(content_item, str): + text_parts.append(content_item) + + if text_parts: + new_msg["content"] = "".join(text_parts) + filtered_messages.append(new_msg) + else: + filtered_messages.append(new_msg) + + return filtered_messages + + +# ==================== 模块级便捷实例 ==================== +# 提供与旧函数签名兼容的模块级函数,方便渐进式迁移 + +_default_manager = HistoryManager() + + +def parse_history(history_message: Any) -> List[Dict[str, Any]]: + """兼容旧调用的模块级函数""" + return _default_manager.parse(history_message) + + +def build_history_str( + history_messages: List[Dict[str, Any]], + recent_count: int = 4, + assistant_truncate: int = 300, +) -> str: + """兼容旧调用的模块级函数""" + mgr = HistoryManager(max_messages=recent_count, max_assistant_chars=assistant_truncate) + return mgr.to_prompt_str(history_messages, recent_count=recent_count, assistant_truncate=assistant_truncate) + + +def filter_image_urls(history_message: Any) -> Any: + """兼容旧调用的模块级函数""" + return HistoryManager.filter_image_urls(history_message) + + +def preprocess_from_request(history_message: Any, exclude_last: bool = True) -> str: + """兼容旧调用的模块级函数""" + return _default_manager.preprocess_from_request(history_message, exclude_last=exclude_last) + + +def init_history(history_message: Any) -> Dict[str, Any]: + """兼容旧调用的模块级函数""" + return _default_manager.init_history(history_message) + + diff --git a/workflows/workflow_baike.py b/workflows/workflow_baike.py new file mode 100644 index 0000000..9185426 --- /dev/null +++ b/workflows/workflow_baike.py @@ -0,0 +1,593 @@ +""" +工作流:普通问答Agent(使用LangGraph) +输入:文本(转化后和原文本query)、图片描述、语音文本 +步骤: +1. 使用RAG和GraphRAG检索相关信息 +2. 生成回答 +使用 OpenaiAPI 方法调用大模型,尽可能使用异步 +""" +import sys +import os + +# 添加项目根目录到Python搜索路径 +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from typing import TypedDict, Optional, Dict, Any, List +from langgraph.graph import StateGraph, START, END +from workflows.history_manager import init_history + +from tools.function_tool import ( + graph_rag_search +) +from tools.rag_tools import rag_search +from modelsAPI.model_api import OpenaiAPI +from utils.function_tracker import track_function_calls, get_current_stream_handler, get_all_callbacks +from utils.image_utils import extract_image_paths, find_closest_image_path, normalize_markdown_images, get_image_prompt_guidance +from prompts import BAIKE_PROMPTS, COMMON_PROMPTS +import asyncio +import json +import re +import random + + +# =============== 定义状态 =============== +class QAState(TypedDict): + """普通问答工作流状态""" + extracted_text: str # 已提取的文本(统一接口,只接收文本) + combined_query: str + retrieval_query: str # 用于RAG/图谱检索的重写后query + route_flag: str # 路由标识 + history_message: str # 历史消息(JSON格式) + history_messages: List[Dict[str, Any]] # 过滤后的历史消息列表 + need_retrieval: Optional[bool] # 是否需要检索 + + # RAG检索结果 + rag_search_result: Optional[List[Dict[str, Any]]] # RAG检索结果 + graph_search_result: Optional[str] # 图谱检索结果 + + # 最终响应 + response: str # 最终响应 + actions: List[Dict[str, Any]] # 按钮信息列表 [{"type": "...", "label": "...", "icon": "..."}] + suggestedReplies: List[Dict[str, Any]] # 建议回复问题列表 + error_message: str # 错误信息 + + +# =============== 节点函数 =============== + +def get_baike_history_context(state: QAState) -> Dict[str, Any]: + """ + 知识问答步骤:获取问答类历史信息 + 问答工作流只关注历史问答记录,用于提供上下文 + """ + history_message = state.get("history_message", "") or "" + return init_history(history_message) + + +async def judge_need_retrieval(state: QAState) -> Dict[str, Any]: + """ + 判断用户问题是否需要检索知识库 + """ + original_query = (state.get("combined_query") or state.get("extracted_text") or "").strip() + history_messages = state.get("history_messages", []) or [] + + prompt = BAIKE_PROMPTS["judge_need_retrieval"].format( + history_messages=history_messages if history_messages else '无', + original_query=original_query + ) + + need_retrieval = True + try: + result = await OpenaiAPI.open_api_chat_without_thinking(prompt, model=None) + if result: + result = result.strip().lower() + if result == "false": + need_retrieval = False + except Exception as e: + print(f"判断是否需要检索失败: {e}") + need_retrieval = True + + + + print(f"是否需要检索: {need_retrieval}") + return {"need_retrieval": need_retrieval} + + +async def rewrite_query_with_history(state: QAState) -> Dict[str, Any]: + """ + 使用历史上下文对当前问题进行query重写,生成适合RAG/图谱检索的独立查询语句。 + 只让大模型在有限的最近历史中挑选与当前问题语义相关的内容进行融合,避免简单拼接全量历史。 + """ + import json + + # 原始问题:优先使用 combined_query,其次使用 extracted_text + original_query = (state.get("combined_query") or state.get("extracted_text") or "").strip() + + history_messages = state.get("history_messages", []) or [] + # 控制历史长度:只保留最近的若干条,避免上下文过长 + trimmed_history = history_messages[-8:] if history_messages else [] + + try: + history_str = json.dumps(trimmed_history, ensure_ascii=False) + except Exception: + history_str = str(trimmed_history) + + # 如果没有历史或当前问题过短,就直接跳过重写 + if not original_query: + return {"retrieval_query": original_query} + # 在构造 prompt 前,提取上一轮用户 query(如果有) + last_user_query = "" + if trimmed_history: + # 从后往前找最近一条 role == "user" 的消息 + for msg in reversed(trimmed_history): + if msg.get("role") == "user": + last_user_query = msg.get("content", "").strip() + break + prompt = BAIKE_PROMPTS["rewrite_query_with_history"].format( + last_user_query=last_user_query or '(无)', + history_str=history_str, + original_query=original_query + ) + + rewritten_query = original_query + try: + result = await OpenaiAPI.open_api_chat_without_thinking(prompt, model=None) + if result: + candidate = result.strip().strip("“”\"'") + # 只在模型返回的内容明显为单行短文本时替换,避免异常输出 + if candidate and len(candidate) <= 200 and "\n" not in candidate: + rewritten_query = candidate + except Exception as e: + print(f"query 重写失败: {e}") + + + title_options = [f"✨正在分析中,请稍等..."] + start_event = { + "type": "function_execution", + "title": random.choice(title_options), + "details": f"正在调取百科数据..." + } + for callback in get_all_callbacks(): + try: + callback(start_event) + except Exception: + pass + + print("原始检索query:", original_query) + print("重写后检索query:", last_user_query+rewritten_query) + + return {"retrieval_query":last_user_query+rewritten_query} + + +@track_function_calls +async def search_rag_and_graph(state: QAState) -> Dict[str, Any]: + """ + 进行文本知识检索和图谱知识检索 + """ + kb_id = None + if state.get("route_flag") == "rules": + kb_id = '3' + + # 优先使用重写后的检索query,其次使用 combined_query,最后回退到原始 extracted_text + query = state.get("retrieval_query") or state.get("combined_query") or state.get("extracted_text", "") + if "用户问题:" in query: + query = query.split("用户问题:", 1)[1] + + print("Graph 检索query", query) + extracted_text = state.get("extracted_text", "") + if "用户问题:" in extracted_text: + extracted_text = extracted_text.split("用户问题:", 1)[1] + + try: + # 异步并行调用RAG检索和图谱检索 + print(kb_id) + print(33333333333333333333333333333333333333) + + async def _do_rag_search(): + if kb_id: + return await rag_search( + query=extracted_text, + kb_id=kb_id, + top_k=2 + ) + else: + return await rag_search( + query=extracted_text, + top_k=2 + ) + + async def _do_graph_search(): + return await graph_rag_search.ainvoke({ + "query": query, + "top_k": 3 + }) + + rag_result, graph_results = await asyncio.gather( + _do_rag_search(), + _do_graph_search() + ) + + # 处理 RAG 结果,提取成列表格式 + rag_search_result = [] + if rag_result.get("success", False): + source_citation = rag_result.get("sourceCitation", {}) + for resource, items in source_citation.items(): + for item in items: + rag_search_result.append({ + "text": item.get("text", ""), + "filename": item.get("filename", ""), + "resource": item.get("resource", ""), + "score": item.get("score", 0.0) + }) + + # 只保留前 4 条数据 +# rag_search_result = rag_search_result[:2] + rag_search_result = sorted(rag_search_result,key=lambda x:float(x.get("socre") or 0), reverse=True)[:2] + + return { + "rag_search_result": rag_search_result, + "graph_search_result": graph_results if isinstance(graph_results, str) else "" + } + except Exception as e: + return { + "rag_search_result": [], + "graph_search_result": "", + "error_message": f"检索失败: {str(e)}" + } + + + +async def generate_suggested_replies_baike(answer: str, question: str) -> List[Dict[str, Any]]: + """ + 生成针对问答结果的建议回复问题 + 使用大模型生成两个相关问题 + """ + prompt = BAIKE_PROMPTS["generate_suggested_replies"].format( + question=question, + answer=answer[:500] + ) + try: + result = await OpenaiAPI.open_api_chat_without_thinking(prompt, model=None, json_output=True) + # 尝试解析JSON + # 提取JSON部分 + json_match = re.search(r'\[.*\]', result, re.DOTALL) + if json_match: + json_str = json_match.group(0) + suggested_replies = json.loads(json_str) + # 验证格式 + if isinstance(suggested_replies, list) and len(suggested_replies) >= 2: + # 确保每个元素都有title和content + formatted_replies = [] + for reply in suggested_replies[:2]: + if isinstance(reply, dict) and "title" in reply and "content" in reply: + formatted_replies.append({ + "title": str(reply["title"]), + "content": str(reply["content"]) + }) + if len(formatted_replies) == 2: + return formatted_replies + + # 如果解析失败,返回默认问题 + return [ + {"title": "这个设备的具体工作原理是什么?", "content": "这个设备的具体工作原理是什么?"}, + {"title": "还有哪些相关的知识点?", "content": "还有哪些相关的知识点?"} + ] + except Exception as e: + # 如果生成失败,返回默认问题 + return [ + {"title": "这个设备的具体工作原理是什么?", "content": "这个设备的具体工作原理是什么?"}, + {"title": "还有哪些相关的知识点?", "content": "还有哪些相关的知识点?"} + ] + + +@track_function_calls +async def generate_answer(state: QAState) -> Dict[str, Any]: + """ + 正在生成问答结果 + """ + extracted_text = state.get("combined_query", "") + rag_results = state.get("rag_search_result", []) + graph_results = state.get("graph_search_result", "") + history_messages = state.get("history_messages", []) or [] + need_retrieval = state.get("need_retrieval", True) + print("history_message(qa)", history_messages) + print("是否使用检索结果:", need_retrieval) + + try: + # 1. 处理检索结果(如果有) + rag_ctx_parts: List[str] = [] + file_name_list = [] + all_source_text = "" + original_image_paths = [] + + if need_retrieval: + # 收集所有 RAG 结果文本(只取前 4 条) + for item in sorted(rag_results or [], key=lambda x: float(x.get("score") or 0) if isinstance(x,dict) else 0, reverse=True)[:2]: +# for item in (rag_results or [])[:1]: + print(item) + print(111111111111111111111111111) + if isinstance(item, dict): + text = (item.get("text") or "").strip() + file_name = (item.get("filename") or "").strip() + if text: + rag_ctx_parts.append(text) + if file_name: + file_name_list.append(file_name) + + # 处理图谱结果 + print("123456", graph_results) + if graph_results and "图谱检索完成,但未返回有效数据。" in graph_results: + graph_results = "" + print("654321", graph_results) + + # 从所有结果中提取图片路径 + if graph_results: + all_source_text = graph_results + "\n" + "\n".join(rag_ctx_parts) + else: + all_source_text = "\n".join(rag_ctx_parts) + original_image_paths = extract_image_paths(all_source_text) + print("提取到的原始图片路径:", original_image_paths) + + # 发送事件通知(可选) + title_options = [f"✨已综合 {len(rag_ctx_parts)} 条检索结果", f"✨本次检索策略为综合多源信息"] + start_event = { + "type": "function_execution", + "title": random.choice(title_options), + "details": f"综合了 {len(rag_ctx_parts)} 条检索结果" + } + for callback in get_all_callbacks(): + try: + callback(start_event) + except Exception: + pass + + query_desc = extracted_text if extracted_text else "当前未明确描述问题" + + # 构建系统提示词 + if state.get("route_flag") == "rules": + domain_desc = "舰船修理相关的法规、规范、行业标准及技术文件" + else: + domain_desc = "工业设备的结构原理、维护规程、故障诊断与维修操作" + + # ========== 第一步:专注于生成高质量内容 ========== + # 根据是否有检索结果构建不同的提示词 + if need_retrieval and all_source_text.strip(): + content_system_prompt = BAIKE_PROMPTS["generate_answer_with_retrieval_system"].format(domain_desc=domain_desc) + content_user_content = BAIKE_PROMPTS["generate_answer_with_retrieval_user"].format( + extracted_text=extracted_text, + rag_count=len(rag_ctx_parts), + all_source_text=all_source_text + ) + else: + content_system_prompt = BAIKE_PROMPTS["generate_answer_without_retrieval_system"].format(domain_desc=domain_desc) + content_user_content = BAIKE_PROMPTS["generate_answer_without_retrieval_user"].format(extracted_text=extracted_text) + + content_messages = [] + if history_messages: + content_messages.extend(history_messages) + content_messages.append({"role": "user", "content": content_user_content}) + + # 第一步:生成内容 + raw_content = await OpenaiAPI.open_api_chat_without_thinking( + model=None, + system_prompt=content_system_prompt, + messages=content_messages, + temperature=0.5 + ) + + # 过滤掉异常标签(如果有) + raw_content = re.sub(r'ynchroneg>.*?ost switching>', '', raw_content, flags=re.DOTALL) + raw_content = raw_content.strip() if raw_content else "抱歉,无法生成回答。" + + title_options = ["🤖 诊断分析中", "🤖 故障预检中"] + start_event = { + "type": "function_execution", + "title": random.choice(title_options), + "details": raw_content[:30] + "..." + } + for callback in get_all_callbacks(): + try: + callback(start_event) + except Exception: + pass + + + # ========== 第二步:专注于规范格式 ========== + # 构建第二步的提示词:只关注格式规范,不修改内容 + format_system_prompt = COMMON_PROMPTS["format_system"] + + format_user_content = COMMON_PROMPTS["format_user_with_image_examples"].format(raw_content=raw_content) + + # 第二步:规范格式(流式输出) + stream_handler = get_current_stream_handler() + answer = "" + accumulated_content = "" # 用于逐步发送给前端的内容 + + # 使用 async for 遍历流式生成器 + async for chunk in OpenaiAPI.open_api_chat_stream( + model=None, + system_prompt=format_system_prompt, + messages=[{"role": "user", "content": format_user_content}], + temperature=0.1 + ): + answer += chunk + accumulated_content += chunk + # 每收到新的 chunk 就发送一次更新 + if stream_handler: + stream_handler.send_stream_content(accumulated_content) + + answer = answer.strip() if answer else raw_content + + # LaTeX 公式空格规范化 + def normalize_latex_spacing(text: str) -> str: + placeholder = "\x00DOUBLE_DOLLAR\x00" + text = text.replace("$$", placeholder) + text = re.sub(r'(? 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()) + + + + diff --git a/workflows/workflow_fault_diagnosis.py b/workflows/workflow_fault_diagnosis.py new file mode 100644 index 0000000..80b99e4 --- /dev/null +++ b/workflows/workflow_fault_diagnosis.py @@ -0,0 +1,1691 @@ +""" +故障诊断工作流 checkpointer 版本 +""" +from typing import TypedDict, Optional, Dict, Any, List +from langgraph.graph import StateGraph, START, END +from langgraph.types import interrupt, Command +from tools.function_tool import graph_rag_search, fault_graph_rag_search +from tools.rag_tools import rag_search +from tools.graph_tools import atlas_retrieval, format_atlas_results, extract_entity_names_from_history, normalize_device_name_by_ship_graph +from modelsAPI.model_api import OpenaiAPI +from utils.function_tracker import get_all_callbacks +from utils.image_utils import extract_image_paths, normalize_markdown_images, get_image_prompt_guidance +from workflows.workflow_baike import run_baike_workflow +from workflows.workflow_utils import ( + safe_json_extract, parse_history, normalize_latex_spacing, + remove_duplicate_content, convert_rag_result, calculate_info_completeness, + emit_callback_event, build_history_str, build_detail_info, + append_atlas_section, stream_format_and_postprocess, +) +from utils.word_generator import generate_report_word +from utils.intent_matcher import is_confirmation_intent +from config import KB_TREE_CONFIG, SERVER_CONFIG +from prompts import FAULT_DIAGNOSIS_PROMPTS, COMMON_PROMPTS +from tools.fault_record_db import record_fault_from_state +import asyncio +import json +import re +import random +import os +import time + +class FaultDiagnosisState(TypedDict): + """故障诊断工作流状态""" + extracted_text: str + combined_query: str + history_messages: List[Dict[str, Any]] + + ship_number: str + device_name: str + fault: str + additional_info: str + fault_code: str + fault_time: str + fault_frequency: str + tried_measures: str + running_condition: str + + rag_search_result: Optional[List[Dict[str, Any]]] + graph_search_result: Optional[str] + deep_rag_results: Optional[Dict[str, Any]] + + response: str + suggestedReplies: List[Dict[str, Any]] + actions: List[Dict[str, Any]] + error_message: str + + agent_decision: str + agent_reasoning: str + tools_to_call: List[str] + missing_info: List[str] + info_completeness: float + + matched_kb_id: str + matched_kb_name: str + + iteration_count: int + max_iterations: int + scheme_generated: bool + waiting_for_supplement: bool + + extracted_info: Dict[str, Any] + has_rag_result: bool + has_graph_result: bool + ask_round: int + user_confirmed_info: bool + + need_model_confirm: bool + user_confirmed_model: bool + + user_feedback: str + waiting_report_confirm: bool + report_confirmed: bool + + initialized: bool + current_node: str + + # 意图分类相关字段 + user_intent: str # 用户初始意图: '维修', '百科' + + # 后续处理相关字段 + follow_up_intent: str # 用户后续意图: 'not_solved', 'related_question', 'has_error', 'modify_info', 'other' + user_feedback_for_regenerate: str # 用户用于重新生成方案的反馈 + is_regenerating: bool # 是否正在重新生成方案 + last_generated_scheme: str # 最后一次生成的方案(用于基于反馈修改) + scheme_type: str # 方案类型:'normal' 普通方案,'deep' 深度方案 + deep_analysis_points: Optional[List[str]] # 深度分析点(用于针对性检索) + atlas_results: Optional[Dict[str, Any]] # 图册检索结果 + last_combined_query: str # 上一轮生成方案时的用户输入 + +def _preprocess_scheme_for_word(content: str) -> str: + """ + 预处理方案内容:将相对路径图片URL转为绝对URL + 图片格式解析和公式渲染由 word_generator.py 直接处理 + """ + if not content: + return content + + base_url = SERVER_CONFIG.get("base_url", "").rstrip("/") + + kb_base_url = KB_TREE_CONFIG.get("url", "").rstrip("/") + kb_host = "" + if kb_base_url: + from urllib.parse import urlparse + kb_parsed = urlparse(kb_base_url) + kb_host = f"{kb_parsed.scheme}://{kb_parsed.netloc}" + + # 知识库图片由知识库服务提供 + if kb_host: + content = re.sub( + r'(/api/v1/knowledge/files/[^)\s]+)', + lambda m: f"{kb_host}{m.group(1)}" if not m.group(1).startswith("http") else m.group(1), + content + ) + elif base_url: + content = re.sub( + r'(/api/v1/knowledge/files/[^)\s]+)', + lambda m: f"{base_url}{m.group(1)}" if not m.group(1).startswith("http") else m.group(1), + content + ) + + # /picture/ 图片由本应用提供 + if base_url: + content = re.sub( + r'(/picture/[^)\s]+)', + lambda m: f"{base_url}{m.group(1)}" if not m.group(1).startswith("http") else m.group(1), + content + ) + + return content + + +def _generate_report_from_scheme(scheme_content: str, ship_number: str = "", device_name: str = "", fault: str = "") -> List[Dict[str, Any]]: + """ + 从方案内容直接生成报告并返回下载按钮 + + Args: + scheme_content: 方案内容(Markdown 格式) + ship_number: 舷号 + device_name: 设备名称 + fault: 故障现象 + + Returns: + actions 列表,包含下载按钮(如果生成成功) + """ + try: + # 构建报告标题 + report_title = "维修方案报告" + if ship_number: + report_title = f"{ship_number} - {report_title}" + if device_name: + report_title = f"{report_title} - {device_name}" + + # 预处理方案内容:解析图片URL和公式格式 + processed_content = _preprocess_scheme_for_word(scheme_content) + + # 准备报告内容 - 直接使用方案内容,添加标题信息 + 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") + + # 构建报告内容 + report_content = f"""## **{report_title}** + +报告编号:{report_number} +编制时间:{compile_time} + +--- + +{processed_content} +""" + + # 确保 created_word 目录存在 + current_dir = os.path.dirname(os.path.abspath(__file__)) + parent_dir = os.path.dirname(current_dir) + word_dir = os.path.join(parent_dir, "created_word") + os.makedirs(word_dir, exist_ok=True) + + # 生成 Word 文档 + timestamp = time.strftime("%Y%m%d_%H%M%S") + safe_title = re.sub(r'[\\/:*?"<>|]', '_', report_title) + file_name = f"{safe_title}_{timestamp}.docx" + output_path = os.path.join(word_dir, file_name) + + word_result = generate_report_word(markdown=report_content,title=report_title,output=output_path) + + # 构建下载按钮 + actions = [] + if word_result and os.path.exists(word_result): + base_url = SERVER_CONFIG.get("report", "") + 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_content}) + + print(f"[_generate_report_from_scheme] 报告生成成功: {output_path}") + + return actions + + except Exception as e: + print(f"[_generate_report_from_scheme] 生成报告失败: {e}") + import traceback + traceback.print_exc() + return [] + +FAULT_INFO_WEIGHTS = {"ship_number": 0.20, "device_name": 0.20, "fault": 0.25, "fault_code": 0.08, "fault_time": 0.06, "fault_frequency": 0.06, "tried_measures": 0.06, "running_condition": 0.05, "additional_info": 0.04} + +def _calculate_info_completeness(ship_number: str = "", device_name: str = "", fault: str = "", fault_code: str = "", fault_time: str = "", fault_frequency: str = "", tried_measures: str = "", running_condition: str = "", additional_info: str = "") -> float: + values = {"ship_number": ship_number, "device_name": device_name, "fault": fault, "fault_code": fault_code, "fault_time": fault_time, "fault_frequency": fault_frequency, "tried_measures": tried_measures, "running_condition": running_condition, "additional_info": additional_info} + return calculate_info_completeness(FAULT_INFO_WEIGHTS, values) + +async def classify_intent(state: FaultDiagnosisState) -> Dict[str, Any]: + """ + 意图分类节点 + """ + combined_query = state.get("combined_query", "") or "" + extracted_info = state.get("extracted_info", {}) or {} + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + fault = state.get("fault", "") or "" + + # 如果已经提取过维修信息(有设备名或故障现象),说明已在维修流程中,继续维修流程 + if extracted_info or device_name or fault or ship_number: + print("[classify_intent] 已在维修流程中,跳过意图分类") + return {"user_intent": "维修","current_node": "extract_info",} + + system_prompt = FAULT_DIAGNOSIS_PROMPTS["classify_intent_system"] + + prompt = COMMON_PROMPTS["classify_intent_user"].format(combined_query=combined_query) + + try: + response_text = await OpenaiAPI.open_api_chat_without_thinking(query=prompt,model=None,json_output=True,system_prompt=system_prompt,messages=[]) + + parsed = safe_json_extract(response_text) + intent = "维修" + if isinstance(parsed, dict): + intent = parsed.get("intent", "维修") + + print(f"[意图分类] 用户意图: {intent}") + + if intent == "维修": + return {"user_intent": intent,"scheme_generated": False,"current_node": "extract_info",} + else: + return {"user_intent": intent,"current_node": "follow_up_qa",} + + except Exception as e: + print(f"[意图分类失败: {str(e)},默认按维修处理") + return {"user_intent": "维修","scheme_generated": False,"current_node": "extract_info",} + +def _route_after_classify(state: FaultDiagnosisState) -> str: + """意图分类后的路由""" + intent = state.get("user_intent", "维修") + if intent == "百科": + return "follow_up_qa" + return "extract_info" + +async def extract_info(state: FaultDiagnosisState) -> Dict[str, Any]: + """ + 信息提取节点 + """ + if state.get("initialized", False): + print("[extract_info] 已初始化,跳过") + return {"current_node": "agent_think"} + + combined_query = state.get("combined_query", "") or "" + history_messages = state.get("history_messages", []) or [] + + history_str = build_history_str(history_messages, recent_count=4) + + existing_info = [] + existing_ship_number = state.get("ship_number", "") or "" + existing_device_name = state.get("device_name", "") or "" + existing_fault = state.get("fault", "") or "" + existing_fault_code = state.get("fault_code", "") or "" + existing_fault_time = state.get("fault_time", "") or "" + existing_fault_frequency = state.get("fault_frequency", "") or "" + existing_tried_measures = state.get("tried_measures", "") or "" + existing_running_condition = state.get("running_condition", "") or "" + existing_additional_info = state.get("additional_info", "") or "" + + if existing_ship_number: + existing_info.append(f"舷号: {existing_ship_number}") + if existing_device_name: + existing_info.append(f"设备名称: {existing_device_name}") + if existing_fault: + existing_info.append(f"故障现象: {existing_fault}") + if existing_fault_code: + existing_info.append(f"故障码: {existing_fault_code}") + if existing_fault_time: + existing_info.append(f"故障时间: {existing_fault_time}") + if existing_fault_frequency: + existing_info.append(f"故障频率: {existing_fault_frequency}") + if existing_tried_measures: + existing_info.append(f"已尝试措施: {existing_tried_measures}") + if existing_running_condition: + existing_info.append(f"运行工况: {existing_running_condition}") + if existing_additional_info: + existing_info.append(f"其他信息: {existing_additional_info}") + + existing_info_str = "\n".join(existing_info) if existing_info else "(无已提取信息)" + + system_prompt = FAULT_DIAGNOSIS_PROMPTS["extract_info_system"] + + prompt = COMMON_PROMPTS["extract_info_user"].format( + biz_label="维修", + existing_info_str=existing_info_str, + history_str=history_str or '(无历史对话)', + combined_query=combined_query + ) + + try: + response_text = await OpenaiAPI.open_api_chat_without_thinking(query=prompt,model=None,json_output=True,system_prompt=system_prompt,messages=[]) + + parsed = safe_json_extract(response_text) + if not isinstance(parsed, dict): + parsed = {} + + new_ship_number = parsed.get("ship_number", "") or existing_ship_number + new_device_name = parsed.get("device_name", "") or existing_device_name + new_fault = parsed.get("fault", "") or existing_fault + new_fault_code = parsed.get("fault_code", "") or existing_fault_code + new_fault_time = parsed.get("fault_time", "") or existing_fault_time + new_fault_frequency = parsed.get("fault_frequency", "") or existing_fault_frequency + new_tried_measures = parsed.get("tried_measures", "") or existing_tried_measures + new_running_condition = parsed.get("running_condition", "") or existing_running_condition + new_additional_info = parsed.get("additional_info", "") or existing_additional_info + + extracted_info = {"ship_number": new_ship_number,"device_name": new_device_name,"fault": new_fault,"fault_code": new_fault_code,"fault_time": new_fault_time,"fault_frequency": new_fault_frequency,"tried_measures": new_tried_measures,"running_condition": new_running_condition,"additional_info": new_additional_info,} + + print(f"[信息提取] 舷号={extracted_info['ship_number']}, 设备={extracted_info['device_name']}, 故障={extracted_info['fault']}") + + return {**extracted_info,"extracted_info": extracted_info,"initialized": True,"current_node": "agent_think",} + + except Exception as e: + print(f"信息提取失败: {str(e)}") + return {"initialized": True,"current_node": "agent_think",} + +async def agent_think(state: FaultDiagnosisState) -> Dict[str, Any]: + """ + Agent 思考节点:基于当前状态决定下一步动作 + """ + combined_query = state.get("combined_query", "") or "" + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + fault = state.get("fault", "") or "" + fault_code = state.get("fault_code", "") or "" + fault_time = state.get("fault_time", "") or "" + fault_frequency = state.get("fault_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + additional_info = state.get("additional_info", "") or "" + history_messages = state.get("history_messages", []) or [] + + scheme_generated = state.get("scheme_generated", False) + has_rag_result = state.get("has_rag_result", False) + has_graph_result = state.get("has_graph_result", False) + + rag_search_result = state.get("rag_search_result") + graph_search_result = state.get("graph_search_result") + iteration_count = state.get("iteration_count", 0) + + user_confirmed_info = state.get("user_confirmed_info", False) + waiting_feedback = state.get("waiting_feedback", False) + + has_ship = bool(ship_number and ship_number.strip()) + has_device = bool(device_name and device_name.strip()) + has_fault = bool(fault and fault.strip()) + has_rag = (rag_search_result is not None and len(rag_search_result) > 0) or has_rag_result + has_graph = (graph_search_result is not None and len(graph_search_result) > 100) or has_graph_result + + info_completeness = _calculate_info_completeness(ship_number=ship_number,device_name=device_name,fault=fault,fault_code=fault_code,fault_time=fault_time,fault_frequency=fault_frequency,tried_measures=tried_measures,running_condition=running_condition,additional_info=additional_info) + + MIN_COMPLETENESS_FOR_GENERATE = 0.25 + + if scheme_generated: + _re_diagnose_keywords = ["开始诊断", "信息已足够", "信息已足够,开始诊断", "开始", "诊断"] + _is_re_diagnose = any(kw in combined_query for kw in _re_diagnose_keywords) + if _is_re_diagnose: + print(f"[重新诊断] 检测到重新诊断意图: '{combined_query}',重置状态直接调用工具生成方案") + tools_to_call = ["rag_search"] + if device_name and fault: + tools_to_call.append("graph_rag_search") + return {"scheme_generated": False,"rag_search_result": None,"graph_search_result": None,"deep_rag_results": None,"has_rag_result": False,"has_graph_result": False,"is_regenerating": False,"user_feedback_for_regenerate": "","follow_up_intent": "","user_confirmed_info": True,"initialized": True,"iteration_count": 0,"ask_round": 0,"agent_decision": "call_tools","tools_to_call": tools_to_call,"current_node": "call_tools",} + + history_str = build_history_str(history_messages, recent_count=5, assistant_truncate=300) + + intent_system_prompt = FAULT_DIAGNOSIS_PROMPTS["agent_think_intent_system"] + + intent_prompt = FAULT_DIAGNOSIS_PROMPTS["agent_think_intent_user"].format( + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + fault=fault or '未提供', + history_str=history_str or '无', + combined_query=combined_query + ) + + try: + intent_response = await OpenaiAPI.open_api_chat_without_thinking( + query=intent_prompt, + model=None, + json_output=True, + system_prompt=intent_system_prompt, + messages=[] + ) + + parsed_intent = safe_json_extract(intent_response) + intent = "other" + if isinstance(parsed_intent, dict): + intent = parsed_intent.get("intent", "other") + + print(f"[Agent 意图分析] 用户意图: {intent}") + + # 根据大模型判断的意图来决定下一步 + if intent == "generate_report": + decision = "generate_report" + reasoning = "用户请求生成报告" + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + return {"agent_decision": decision,"agent_reasoning": reasoning,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + elif intent == "modify_info": + # 修改信息,跳转到 confirm_info,重置状态 + decision = "confirm_info" + reasoning = "用户需要修改或补充信息" + return {"agent_decision": decision,"agent_reasoning": reasoning,"rag_search_result": None,"graph_search_result": None,"deep_rag_results": None,"has_rag_result": False,"has_graph_result": False,"scheme_generated": False,"is_regenerating": False,"user_feedback_for_regenerate": "","follow_up_intent": "","user_confirmed_info": False,"initialized": False,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + elif intent == "has_error": + decision = "handle_feedback" + reasoning = "用户反馈方案有错误" + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + return {"agent_decision": decision,"agent_reasoning": reasoning,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + elif intent == "not_solved": + decision = "analyze_follow_up_intent" + reasoning = "已生成方案,分析用户后续意图" + # 设置 follow_up_intent,让 analyze_follow_up_intent 直接知道 + return {"agent_decision": decision,"agent_reasoning": reasoning,"follow_up_intent": "not_solved","info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + elif intent == "related_question": + decision = "analyze_follow_up_intent" + reasoning = "已生成方案,分析用户后续意图" + return {"agent_decision": decision,"agent_reasoning": reasoning,"follow_up_intent": "related_question","info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + else: + # other 情况,直接跳转到 follow_up_qa,不需要经过 analyze_follow_up_intent + decision = "follow_up_qa" + reasoning = "其他意图,直接调用 baike" + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + return {"agent_decision": decision,"agent_reasoning": reasoning,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + except Exception as e: + print(f"[Agent 意图分析失败: {str(e)}") + # 大模型判断失败,降级到原来的逻辑 + decision = "analyze_follow_up_intent" + reasoning = "已生成方案,分析用户后续意图" + print(f"[Agent 决策] decision={decision}, reasoning={reasoning} (降级)") + return {"agent_decision": decision,"agent_reasoning": reasoning,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + + if not has_device or not has_fault: + decision = "ask_user" + missing_info = [] + if not has_device: + missing_info.append("设备名称") + if not has_fault: + missing_info.append("故障现象") + reasoning = f"缺少基本信息: {', '.join(missing_info)}" + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + return {"agent_decision": decision,"agent_reasoning": reasoning,"missing_info": missing_info,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + + if info_completeness < MIN_COMPLETENESS_FOR_GENERATE: + decision = "ask_user" + reasoning = f"信息完整性评分 {info_completeness} 低于最低要求 {MIN_COMPLETENESS_FOR_GENERATE}" + missing_info = [] + if not ship_number: + missing_info.append("舷号(可选)") + if not fault_code: + missing_info.append("故障码") + if not fault_time: + missing_info.append("故障时间") + if not fault_frequency: + missing_info.append("故障频率") + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + return {"agent_decision": decision,"agent_reasoning": reasoning,"missing_info": missing_info or ["更多详细信息"],"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + + if has_rag or has_graph: + decision = "generate_response" + reasoning = "信息充足,已有检索结果,直接生成回复" + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + return {"agent_decision": decision,"agent_reasoning": reasoning,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + + if not user_confirmed_info: + decision = "confirm_info" + reasoning = "信息充足,需要用户确认后再检索知识库" + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + return {"agent_decision": decision,"agent_reasoning": reasoning,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + + decision = "call_tools" + reasoning = "信息充足,用户已确认,开始检索知识库" + tools_to_call = ["rag_search"] + if has_device and has_fault: + tools_to_call.append("graph_rag_search") + + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + print(f"[Agent 提取] 舷号={ship_number}, 设备={device_name}, 故障={fault}") + + emit_callback_event(["🤖 Agent 思考中", "🤖 智能分析", "🤖 决策判断"], f"决策: {decision} | 理由: {reasoning[:50]}...") + + return {"agent_decision": decision,"agent_reasoning": reasoning,"tools_to_call": tools_to_call,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + +async def ask_user(state: FaultDiagnosisState) -> Command: + """ + 询问用户节点:使用 interrupt 暂停等待用户输入 + """ + missing_info = state.get("missing_info", []) + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + fault = state.get("fault", "") or "" + fault_code = state.get("fault_code", "") or "" + fault_time = state.get("fault_time", "") or "" + fault_frequency = state.get("fault_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + additional_info = state.get("additional_info", "") or "" + combined_query = state.get("combined_query", "") or "" + info_completeness = state.get("info_completeness", 0) + ask_round = state.get("ask_round", 0) + + MAX_ASK_ROUNDS = 4 + if ask_round >= MAX_ASK_ROUNDS: + print(f"[ask_user] 已达到最大询问轮次 {MAX_ASK_ROUNDS},强制进入下一步") + tools_to_call = ["rag_search"] + if device_name and fault: + tools_to_call.append("graph_rag_search") + return Command( + update={"ask_round": ask_round + 1,"current_node": "call_tools","tools_to_call": tools_to_call,}, + goto="call_tools" + ) + + existing_parts = [] + if ship_number: + existing_parts.append(f"舷号: {ship_number}") + if device_name: + existing_parts.append(f"设备名称: {device_name}") + if fault: + existing_parts.append(f"故障现象: {fault}") + if fault_code: + existing_parts.append(f"故障码: {fault_code}") + if fault_time: + existing_parts.append(f"发生时间: {fault_time}") + if fault_frequency: + existing_parts.append(f"故障频率: {fault_frequency}") + if tried_measures: + existing_parts.append(f"已尝试措施: {tried_measures}") + if running_condition: + existing_parts.append(f"运行工况: {running_condition}") + if additional_info: + existing_parts.append(f"其他信息: {additional_info}") + + existing_str = ";".join(existing_parts) if existing_parts else "暂无" + missing_str = "、".join(missing_info) if missing_info else "相关信息" + + system_prompt = COMMON_PROMPTS["ask_user_system"].format(biz_label="故障诊断", biz_label_short="诊断") + + prompt = COMMON_PROMPTS["ask_user_prompt"].format( + info_completeness=info_completeness, + existing_str=existing_str, + missing_str=missing_str, + combined_query=combined_query + ) + + response = "" + try: + response = await OpenaiAPI.open_api_chat_without_thinking(query=prompt,model=None,system_prompt=system_prompt,messages=[]) + response = response.strip() if response else "" + except Exception as e: + print(f"[ask_user] API调用失败: {str(e)}") + response = "" + + if not response: + response = f"**【需要更多信息】**\n\n请补充:{missing_str}\n\n提供这些信息后,我可以更准确地为您诊断故障。" + + if existing_parts: + existing_info_section = f"\n**📋 已获取的信息:**\n\n" + "\n".join( + f"- {part}" for part in existing_parts) + "\n\n---\n" + response = existing_info_section + response + + suggested_replies = [] + if "舷号" in missing_info: + suggested_replies.append({"title": "补充舷号", "content": "舷号:"}) + if "设备名称" in missing_info: + suggested_replies.append({"title": "补充设备名称", "content": "设备名称:"}) + if "故障现象" in missing_info: + suggested_replies.append({"title": "补充故障现象", "content": "故障现象:"}) + + user_input = interrupt({"response": response,"suggestedReplies": suggested_replies,"waiting_for": "user_input","missing_info": missing_info,}) + + user_query = user_input.get("query", "") + + update_dict = {"combined_query": user_query,"current_node": "agent_think","ask_round": ask_round + 1,} + + if user_query: + extract_prompt = FAULT_DIAGNOSIS_PROMPTS["ask_user_extract"].format( + missing_str=missing_str, + user_query=user_query + ) + + try: + extract_response = await OpenaiAPI.open_api_chat_without_thinking(query=extract_prompt,model=None,json_output=True,system_prompt="你是信息提取专家。",messages=[]) + extracted = safe_json_extract(extract_response) + if isinstance(extracted, dict): + if extracted.get("ship_number"): + update_dict["ship_number"] = extracted["ship_number"] + print(f"[ask_user] 提取到舷号: {extracted['ship_number']}") + if extracted.get("device_name"): + update_dict["device_name"] = extracted["device_name"] + print(f"[ask_user] 提取到设备名称: {extracted['device_name']}") + if extracted.get("fault"): + update_dict["fault"] = extracted["fault"] + print(f"[ask_user] 提取到故障现象: {extracted['fault']}") + except Exception as e: + print(f"[ask_user] 信息提取失败: {str(e)}") + + return Command( + update=update_dict, + goto="agent_think" + ) + +async def confirm_info(state: FaultDiagnosisState) -> Command: + """ + 确认信息节点:重点询问用户是否需要补充信息 + """ + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + fault = state.get("fault", "") or "" + fault_code = state.get("fault_code", "") or "" + fault_time = state.get("fault_time", "") or "" + fault_frequency = state.get("fault_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + additional_info = state.get("additional_info", "") or "" + info_completeness = state.get("info_completeness", 0) + + existing_parts = [] + if ship_number: + existing_parts.append(f"舷号: {ship_number}") + if device_name: + existing_parts.append(f"设备名称: {device_name}") + if fault: + existing_parts.append(f"故障现象: {fault}") + if fault_code: + existing_parts.append(f"故障码: {fault_code}") + if fault_time: + existing_parts.append(f"发生时间: {fault_time}") + if fault_frequency: + existing_parts.append(f"故障频率: {fault_frequency}") + if tried_measures: + existing_parts.append(f"已尝试措施: {tried_measures}") + if running_condition: + existing_parts.append(f"运行工况: {running_condition}") + if additional_info: + existing_parts.append(f"其他信息: {additional_info}") + + existing_str = "\n".join(f"- {part}" for part in existing_parts) if existing_parts else "暂无" + + additional_prompt = "" + if not ship_number: + additional_prompt = "\n\n💡 **提示**:如果您知道舷号,请补充,这将有助于我们更准确地生成报告。" + + response = f"""**📋 已收集到的信息:** + +{existing_str} + +**信息完整性评分:** {info_completeness:.0%} + +--- + +**💡 请问是否需要补充其他信息?** +- 如故障码、故障时间、故障频率、已尝试措施、运行工况等 +- 如果信息已足够,请点击"开始诊断"{additional_prompt} +""" + + emit_callback_event(["✅ 信息确认", "📋 请确认信息", "🔍 准备检索"], f"等待用户确认或补充信息 (完整性: {info_completeness:.0%})") + + suggested_replies = [{"title": "开始诊断", "content": "信息已足够,开始诊断"},{"title": "补充信息", "content": "我需要补充:"}] + + user_input = interrupt({"response": response,"suggestedReplies": suggested_replies,"waiting_for": "user_confirm",}) + + user_query = user_input.get("query", "") + + if await is_confirmation_intent(user_query): + print(f"[confirm_info] 用户确认信息足够,准备调用工具") + tools_to_call = ["rag_search"] + if device_name and fault: + tools_to_call.append("graph_rag_search") + return Command( + update={"user_confirmed_info": True,"current_node": "call_tools","tools_to_call": tools_to_call,}, + goto="call_tools" + ) + else: + print(f"[confirm_info] 用户需要补充信息: {user_query}") + return Command( + update={"user_confirmed_info": False,"combined_query": user_query,"initialized": False,"current_node": "extract_info",}, + goto="extract_info" + ) + +async def call_tools(state: FaultDiagnosisState) -> Dict[str, Any]: + """调用检索工具""" + tools_to_call = state.get("tools_to_call", []) + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + fault = state.get("fault", "") or "" + additional_info = state.get("additional_info", "") or "" + + rag_results = state.get("rag_search_result") + graph_results = state.get("graph_search_result") + atlas_results = {} + + matched_kb_name = "" + matched_kb_id = "" + mapped_ship_number = "" + + if device_name: + normalized_result = await normalize_device_name_by_ship_graph(device_name) + if normalized_result.get("success"): + normalized_device_name = normalized_result.get("device_name") or device_name + if normalized_device_name != device_name: + print( + f"[设备标准化] '{device_name}' -> '{normalized_device_name}', " + f"score={normalized_result.get('score')}" + ) + device_name = normalized_device_name + else: + print(f"[设备标准化] 使用原始设备名称 '{device_name}': {normalized_result.get('error')}") + + async def _do_rag_search(): + from utils.ship_number_search import search_with_ship_number_strategy + + base_query = f"设备{device_name},故障{fault}的维修方案" + results, kb_name, kb_id, mapped_number = await search_with_ship_number_strategy( + base_query=base_query, + ship_number=ship_number, + top_k=5, + search_type="fault", + ) + if not kb_name: + kb_name = "通用知识库" + print(f"RAG 检索来源: {kb_name}") + return results, kb_name, kb_id, mapped_number + + async def _do_graph_search(): + if not (device_name and fault): + return "" + try: + results = await fault_graph_rag_search.ainvoke( + {"device_name": device_name, "fault_symptom": fault, "ship_number": ""} + ) + print(f"图谱检索完成,结果长度: {len(results) if results else 0}") + return results + except Exception as e: + print(f"图谱检索失败: {str(e)}") + return "" + + async def _do_atlas_search(): + if not device_name: + return {} + try: + print(f"[图册检索] 使用设备名称: {device_name}") + result = await atlas_retrieval(node_names=[device_name], top_k=10) + if result.get("success", False): + data = result.get("data", {}) + print(f"[图册检索] 图册检索结果: {list(data.keys())}") + return data + except Exception as e: + print(f"[图册检索] 图册检索失败: {str(e)}") + return {} + + tasks = [] + task_names = [] + if "rag_search" in tools_to_call and rag_results is None: + tasks.append(_do_rag_search()) + task_names.append("rag") + if "graph_rag_search" in tools_to_call and graph_results is None: + tasks.append(_do_graph_search()) + task_names.append("graph") + if device_name: + tasks.append(_do_atlas_search()) + task_names.append("atlas") + + if tasks: + task_results = await asyncio.gather(*tasks) + for task_name, task_result in zip(task_names, task_results): + if task_name == "rag": + rag_results, matched_kb_name, matched_kb_id, mapped_ship_number = task_result + elif task_name == "graph": + graph_results = task_result + elif task_name == "atlas": + atlas_results = task_result + + if mapped_ship_number and mapped_ship_number != ship_number: + print(f"[call_tools] 舷号映射: '{ship_number}' -> '{mapped_ship_number}',更新工作流状态") + ship_number = mapped_ship_number + + has_rag = bool(rag_results and len(rag_results) > 0) + has_graph = bool(graph_results and len(graph_results) > 100) + + if not has_rag and not has_graph: + print(f"[call_tools] 未找到任何相关资料,需要用户确认使用模型能力") + return {"rag_search_result": rag_results if isinstance(rag_results, list) else [],"graph_search_result": graph_results if isinstance(graph_results, str) else "","atlas_results": atlas_results,"matched_kb_id": matched_kb_id,"matched_kb_name": matched_kb_name,"ship_number": ship_number,"device_name": device_name,"has_rag_result": False,"has_graph_result": False,"need_model_confirm": True,"current_node": "model_confirm",} + + return {"rag_search_result": rag_results,"graph_search_result": graph_results,"atlas_results": atlas_results,"matched_kb_id": matched_kb_id,"matched_kb_name": matched_kb_name,"ship_number": ship_number,"device_name": device_name,"has_rag_result": has_rag,"has_graph_result": has_graph,"need_model_confirm": False,"current_node": "generate_response",} + +async def model_confirm(state: FaultDiagnosisState) -> Command: + """ + 模型能力确认节点:当没有找到任何资料时,让用户确认是否使用模型本身能力 + """ + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + fault = state.get("fault", "") or "" + + response = f"""**⚠️ 未找到相关资料** + +抱歉,在知识库中未找到与以下信息相关的资料: +- 舷号:{ship_number or '未提供'} +- 设备名称:{device_name or '未提供'} +- 故障现象:{fault or '未提供'} + +**💡 您可以选择:** +- 使用 AI 模型的知识库为您生成一般性建议 +- 提供更详细的信息重新检索 + +请问是否使用 AI 模型为您生成建议?""" + + emit_callback_event(["🤖 AI 模型确认", "⚠️ 资料未找到", "💡 使用模型能力"], "使用模型自身知识进行回复") + + suggested_replies = [{"title": "使用 AI 模型", "content": "确认,使用 AI 模型生成建议"},{"title": "补充信息", "content": "我需要补充更多信息:"},] + + user_input = interrupt({"response": response,"suggestedReplies": suggested_replies,"waiting_for": "model_confirm",}) + + user_query = user_input.get("query", "") + + if "确认" in user_query or "AI" in user_query or "模型" in user_query: + print(f"[model_confirm] 用户确认使用模型能力") + return Command( + update={"user_confirmed_model": True,"combined_query": user_query,"current_node": "generate_response",}, + goto="generate_response" + ) + else: + print(f"[model_confirm] 用户选择补充信息: {user_query}") + return Command( + update={"user_confirmed_model": False,"combined_query": user_query,"current_node": "agent_think",}, + goto="agent_think" + ) + +async def generate_response(state: FaultDiagnosisState) -> Dict[str, Any]: + """生成最终回复""" + print("========================生成最终回复========================") + combined_query = state.get("combined_query", "") or "" + history_messages = state.get("history_messages", []) or [] + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + fault = state.get("fault", "") or "" + additional_info = state.get("additional_info", "") or "" + fault_code = state.get("fault_code", "") or "" + fault_time = state.get("fault_time", "") or "" + fault_frequency = state.get("fault_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + rag_results = state.get("rag_search_result") or [] + graph_results = state.get("graph_search_result") or "" + scheme_generated = state.get("scheme_generated", False) + matched_kb_name = state.get("matched_kb_name", "") + + print("rag_results", rag_results) + history_str = build_history_str(history_messages, recent_count=5, assistant_truncate=500) + + has_rag = rag_results and len(rag_results) > 0 + has_graph = graph_results and len(graph_results) > 100 + + if not has_rag and not has_graph: + if not ship_number and not device_name and not fault: + system_prompt = COMMON_PROMPTS["generate_response_friendly_system"].format(biz_label="故障诊断") + prompt = COMMON_PROMPTS["generate_response_friendly_user"].format(combined_query=combined_query) + else: + system_prompt = COMMON_PROMPTS["generate_response_no_rag_system"].format(biz_label="故障诊断") + prompt = FAULT_DIAGNOSIS_PROMPTS["generate_response_no_rag_user"].format( + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + fault=fault or '未提供', + fault_code=fault_code or '未提供', + fault_time=fault_time or '未提供', + fault_frequency=fault_frequency or '未提供', + tried_measures=tried_measures or '未提供', + running_condition=running_condition or '未提供', + additional_info=additional_info or '无' + ) + + try: + response = await OpenaiAPI.open_api_chat_without_thinking(query=prompt,model=None,system_prompt=system_prompt,messages=[]) + return {"response": response.strip() if response else "抱歉,未能找到相关资料。","suggestedReplies": [{"title": "提供更详细信息", "content": "我来提供更详细的信息"},],"current_node": "end",} + except Exception as e: + return {"response": f"抱歉,处理过程中出现错误: {str(e)}","suggestedReplies": [],"current_node": "end",} + + rag_ctx_parts: List[str] = [] + if isinstance(rag_results, str) and rag_results.strip(): + rag_ctx_parts = [rag_results.strip()] + elif isinstance(rag_results, list): + for item in rag_results[:1]: + if isinstance(item, dict): + text = (item.get("text") or "").strip() + if text: + rag_ctx_parts.append(text) + elif isinstance(item, str) and item.strip(): + rag_ctx_parts.append(item.strip()) + + if len(graph_results) <= 50: + graph_results = "" + + all_source_text = graph_results + "\n" + "\n".join(rag_ctx_parts) if graph_results else "\n".join(rag_ctx_parts) + original_image_paths = extract_image_paths(all_source_text) + print("提取到的原始图片路径:", original_image_paths) + + rag_answer = "" + rag_type = "none" + + if graph_results and len(graph_results) > 50: + rag_answer = graph_results + rag_type = "graph" + elif rag_ctx_parts: + rag_answer = "\n\n".join(rag_ctx_parts[:1]) + rag_type = "rag" + + if not rag_answer or rag_answer == "NO_RELEVANT_RESULT": + system_prompt = COMMON_PROMPTS["generate_response_no_rag_simple_system"].format(biz_label="故障诊断") + prompt = FAULT_DIAGNOSIS_PROMPTS["generate_response_no_rag_simple_user"].format( + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + fault=fault or '未提供' + ) + + try: + response = await OpenaiAPI.open_api_chat_without_thinking(query=prompt,model=None,system_prompt=system_prompt,messages=[]) + return {"response": response.strip() if response else "抱歉,未能找到相关资料。","suggestedReplies": [],"current_node": "end",} + except Exception as e: + return {"response": f"抱歉,处理过程中出现错误: {str(e)}","suggestedReplies": [],"current_node": "end",} + + rag_type = "图谱" if rag_type == "graph" else "文本" + + emit_callback_event([f"✨当前使用的检索类型为{rag_type}", f"✨本次检索策略为{rag_type}"], f"内容为{rag_answer[:50]}...") + + detail_info = "" + if fault_code: + detail_info += f"- 故障码/报警代码:{fault_code}\n" + if fault_time: + detail_info += f"- 故障发生时间:{fault_time}\n" + if fault_frequency: + detail_info += f"- 故障频率/持续时间:{fault_frequency}\n" + if tried_measures: + detail_info += f"- 已尝试的维修措施:{tried_measures}\n" + if running_condition: + detail_info += f"- 设备运行工况:{running_condition}\n" + if additional_info: + detail_info += f"- 其他相关信息:{additional_info}\n" + + is_regenerating = state.get("is_regenerating", False) + user_feedback_for_regenerate = state.get("user_feedback_for_regenerate", "") + is_follow_up = scheme_generated and (has_rag or has_graph) and not is_regenerating + + image_guidance = get_image_prompt_guidance() + if is_regenerating and user_feedback_for_regenerate: + prompt = COMMON_PROMPTS["generate_response_regenerating"].format( + biz_label="维修", + item_label="故障现象", + item_name=fault or '未提供', + item_action="分析故障原因,给出维修方案", + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + fault=fault or '未提供', + detail_info=detail_info, + user_feedback_for_regenerate=user_feedback_for_regenerate, + rag_answer=rag_answer, + image_guidance=image_guidance + ) + elif is_follow_up: + prompt = COMMON_PROMPTS["generate_response_follow_up"].format( + biz_label="维修", + item_label="故障现象", + item_name=fault or '未提供', + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + fault=fault or '未提供', + detail_info=detail_info, + rag_answer=rag_answer, + combined_query=combined_query, + image_guidance=image_guidance + ) + else: + prompt = COMMON_PROMPTS["generate_response_normal"].format( + biz_label="维修", + item_label="故障现象", + item_name=fault or '未提供', + item_action="分析故障原因,给出维修方案", + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + fault=fault or '未提供', + detail_info=detail_info, + rag_answer=rag_answer + ) + + try: + # ========== 第一步:专注于生成高质量内容 ========== + content_system_prompt = COMMON_PROMPTS["generate_response_content_system"].format(biz_label="维修") + + raw_content = await OpenaiAPI.open_api_chat_without_thinking(model=None,system_prompt=content_system_prompt,messages=[{"role": "user", "content": prompt}],) + + raw_content = raw_content.strip() if raw_content else "抱歉,无法生成回答。" + + emit_callback_event(["🤖 诊断分析中", "🤖 故障预检中"], raw_content[:30] + "...") + + answer = await stream_format_and_postprocess(raw_content, original_image_paths, temperature=0.3) + + scheme_source = f"本方案依据{rag_type}知识库生成。" + answer = f"{scheme_source}\n\n{answer}" + + atlas_results = state.get("atlas_results", {}) + answer = append_atlas_section(answer, atlas_results) + + if not is_follow_up: + supplement_prompt = """ + +--- + +**💡 请您确认维修方案是否正确:** +- 如果方案有误或需要调整,请告诉我具体的错误之处 +- 我会根据您的反馈重新生成更准确的方案 + +请问方案是否有需要修改的地方?""" + answer += supplement_prompt + + suggested_replies = [{"title": "方案有误", "content": "方案有误,需要修改:"},] + + # 生成报告并添加下载按钮 + actions = _generate_report_from_scheme(scheme_content=answer,ship_number=ship_number,device_name=device_name,fault=fault) + + if not is_follow_up: + try: + await record_fault_from_state({"ship_number": ship_number,"device_name": device_name,"fault": fault,"last_generated_scheme": answer,}) + except Exception as rec_err: + print(f"[generate_response] 记录故障信息失败: {rec_err}") + + return {"response": answer,"actions": actions,"suggestedReplies": suggested_replies,"scheme_generated": True,"waiting_feedback": not is_follow_up,"last_generated_scheme": answer,"scheme_type": "normal","current_node": "end","last_combined_query": combined_query,} + + except Exception as e: + return {"response": f"分析生成失败: {str(e)}","actions": [],"suggestedReplies": [],"current_node": "end",} + +async def analyze_follow_up_intent(state: FaultDiagnosisState) -> Dict[str, Any]: + """ + 分析用户后续意图节点:直接使用 agent_think 已经判断好的意图 + agent_think 已经用大模型判断并设置了 follow_up_intent + """ + # 直接使用已经判断好的意图 + intent = state.get("follow_up_intent", "other") + + print(f"[意图分析] 使用已判断的意图: {intent}") + + return {"follow_up_intent": intent,} + +async def deep_rag_search(state: FaultDiagnosisState) -> Dict[str, Any]: + """ + 深层次RAG检索节点:分析之前方案并选择深度分析点进行检索 + """ + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + fault = state.get("fault", "") or "" + fault_code = state.get("fault_code", "") or "" + additional_info = state.get("additional_info", "") or "" + fault_time = state.get("fault_time", "") or "" + fault_frequency = state.get("fault_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + combined_query = state.get("combined_query", "") or "" + history_messages = state.get("history_messages", []) or [] + last_generated_scheme = state.get("last_generated_scheme", "") or "" + + deep_results = {} + deep_analysis_points = [] + + emit_callback_event(["🔍 深度检索中", "🔍 多维度检索", "🔍 精细分析"], "分析问题并选择深度分析点") + + # ========== 第一步:分析之前方案和用户问题,选择深度分析点 ========== + if last_generated_scheme or combined_query: + try: + history_str = build_history_str(history_messages, recent_count=5, assistant_truncate=300) + + detail_info = "" + if fault_code: + detail_info += f"- 故障码/报警代码:{fault_code}\n" + if fault_time: + detail_info += f"- 故障发生时间:{fault_time}\n" + if fault_frequency: + detail_info += f"- 故障频率/持续时间:{fault_frequency}\n" + if tried_measures: + detail_info += f"- 已尝试的维修措施:{tried_measures}\n" + if running_condition: + detail_info += f"- 设备运行工况:{running_condition}\n" + if additional_info: + detail_info += f"- 其他相关信息:{additional_info}\n" + + analysis_system_prompt = COMMON_PROMPTS["deep_rag_analysis_system"].format(biz_label="维修") + + analysis_prompt = COMMON_PROMPTS["deep_rag_analysis_user"].format( + item_label="故障现象", + item_name=fault or '未提供', + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + fault=fault or '未提供', + detail_info=detail_info, + history_str=history_str or '(无历史对话)', + last_generated_scheme=last_generated_scheme or '(无之前的方案)', + combined_query=combined_query + ) + + analysis_response = await OpenaiAPI.open_api_chat_without_thinking(query=analysis_prompt,model=None,json_output=True,system_prompt=analysis_system_prompt,messages=[]) + print("深度分析点", analysis_response) + parsed_analysis = safe_json_extract(analysis_response) + deep_analysis_points = parsed_analysis + print(f"[深度RAG] 选择的分析点: {deep_analysis_points}") + except Exception as e: + print(f"[深度RAG] 分析失败: {str(e)}") + + # ========== 第二步:对选中的分析点进行检索 ========== + for idx, point in enumerate(deep_analysis_points): + try: + print(point) + # 结合设备名和故障名进行检索,提高准确性 + search_query = point + if device_name and device_name not in point: + search_query = f"{device_name} {search_query}" + if fault and fault not in search_query: + search_query = f"{search_query} {fault}" + + rag_result = await rag_search(search_query, top_k=3) + if rag_result.get("success", False): + deep_results[f"analysis_point_{idx}"] = convert_rag_result(rag_result) + else: + deep_results[f"analysis_point_{idx}"] = [] + except Exception as e: + print(f"[深度RAG] 分析点 {point} 检索失败: {str(e)}") + deep_results[f"analysis_point_{idx}"] = [] + + # ========== 第三步:图册检索 ========== + atlas_results = {} + try: + # 从历史记录和当前信息中抽取实体名称 + entity_names = await extract_entity_names_from_history(history_messages=history_messages,device_name=device_name,fault=fault,last_generated_scheme=last_generated_scheme) + + if entity_names: + print(f"[深度RAG] 图册检索实体: {entity_names}") + atlas_result = await atlas_retrieval(node_names=entity_names, top_k=10) + if atlas_result.get("success", False): + atlas_results = atlas_result.get("data", {}) + print(f"[深度RAG] 图册检索结果: {list(atlas_results.keys())}") + except Exception as e: + print(f"[深度RAG] 图册检索失败: {str(e)}") + + return {"deep_rag_results": deep_results,"deep_analysis_points": deep_analysis_points,"atlas_results": atlas_results,"current_node": "generate_deep_response",} + +async def generate_deep_response(state: FaultDiagnosisState) -> Dict[str, Any]: + """ + 生成深度响应节点:基于深度RAG结果进行深度润色和推理 + """ + combined_query = state.get("combined_query", "") or "" + history_messages = state.get("history_messages", []) or [] + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + fault = state.get("fault", "") or "" + fault_code = state.get("fault_code", "") or "" + fault_time = state.get("fault_time", "") or "" + fault_frequency = state.get("fault_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + additional_info = state.get("additional_info", "") or "" + + deep_rag_results = state.get("deep_rag_results", {}) + deep_analysis_points = state.get("deep_analysis_points", []) + atlas_results = state.get("atlas_results", {}) + graph_results = state.get("graph_search_result", "") or "" + original_rag_results = state.get("rag_search_result", []) + + # 整理深度分析点资料 + analysis_points_text = "" + if deep_analysis_points: + for idx, point in enumerate(deep_analysis_points): + key = f"analysis_point_{idx}" + results = deep_rag_results.get(key, []) + if results: + analysis_points_text += f"\n【分析点:{point}】\n" + for i, item in enumerate(results[:2], 1): + if isinstance(item, dict): + text = item.get("text", "") + if text: + analysis_points_text += f"[{i}] {text}\n" + + # 整理原始RAG结果 + original_rag_text = "" + if original_rag_results: + original_rag_text = "\n【原始检索资料】\n" + for i, item in enumerate(original_rag_results[:3], 1): + if isinstance(item, dict): + text = item.get("text", "") + if text: + original_rag_text += f"[{i}] {text}\n" + + # 整理图册资料 + atlas_text = format_atlas_results(atlas_results) + + # 提取所有资料中的图片路径 + all_source_text = analysis_points_text + original_rag_text + atlas_text + graph_results + original_image_paths = extract_image_paths(all_source_text) + + detail_info = "" + if fault_code: + detail_info += f"- 故障码/报警代码:{fault_code}\n" + if fault_time: + detail_info += f"- 故障发生时间:{fault_time}\n" + if fault_frequency: + detail_info += f"- 故障频率/持续时间:{fault_frequency}\n" + if tried_measures: + detail_info += f"- 已尝试的维修措施:{tried_measures}\n" + if running_condition: + detail_info += f"- 设备运行工况:{running_condition}\n" + if additional_info: + detail_info += f"- 其他相关信息:{additional_info}\n" + + # 构建深度分析点的提示 + analysis_points_prompt = "" + if deep_analysis_points: + analysis_points_prompt = f"\n【本次深度分析重点】\n" + "\n".join([f"- {p}" for p in deep_analysis_points]) + "\n" + + prompt = COMMON_PROMPTS["generate_deep_response"].format( + biz_label="维修", + item_label="故障现象", + item_name=fault or '未提供', + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + fault=fault or '未提供', + detail_info=detail_info, + combined_query=combined_query, + analysis_points_prompt=analysis_points_prompt, + analysis_points_text=analysis_points_text, + original_rag_text=original_rag_text, + graph_results=graph_results if len(graph_results) > 50 else '(无)' + ) + + try: + # ========== 第一步:专注于生成高质量内容 ========== + content_system_prompt = COMMON_PROMPTS["generate_deep_response_content_system"].format(biz_label="维修") + + raw_content = await OpenaiAPI.open_api_chat_without_thinking(model=None,system_prompt=content_system_prompt,messages=[{"role": "user", "content": prompt}],) + + raw_content = raw_content.strip() if raw_content else "抱歉,无法生成深度分析。" + + emit_callback_event(["🤖 诊断分析中", "🤖 故障预检中"], raw_content[:30] + "...") + + answer = await stream_format_and_postprocess(raw_content, original_image_paths, temperature=0.1) + + answer = append_atlas_section(answer, atlas_results) + + suggested_replies = [{"title": "方案有误", "content": "方案还是有问题:"},] + + actions = _generate_report_from_scheme(scheme_content=answer,ship_number=ship_number,device_name=device_name,fault=fault) + + try: + await record_fault_from_state({"ship_number": ship_number,"device_name": device_name,"fault": fault,"last_generated_scheme": answer,}) + except Exception as rec_err: + print(f"[generate_deep_response] 记录故障信息失败: {rec_err}") + + return {"response": answer,"actions": actions,"suggestedReplies": suggested_replies,"scheme_generated": True,"last_generated_scheme": answer,"scheme_type": "deep","current_node": "end","last_combined_query": combined_query,} + + except Exception as e: + return {"response": f"深度分析生成失败: {str(e)}","actions": [],"suggestedReplies": [],"current_node": "end",} + +async def regenerate_scheme_from_feedback(state: FaultDiagnosisState) -> Dict[str, Any]: + """ + 基于用户反馈直接修改方案节点:使用之前保存的方案和用户反馈进行修改 + """ + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + fault = state.get("fault", "") or "" + additional_info = state.get("additional_info", "") or "" + fault_code = state.get("fault_code", "") or "" + fault_time = state.get("fault_time", "") or "" + fault_frequency = state.get("fault_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + + user_feedback = state.get("user_feedback_for_regenerate", "") or "" + last_scheme = state.get("last_generated_scheme", "") or "" + scheme_type = state.get("scheme_type", "normal") + + if not last_scheme: + # 如果没有保存的方案,回退到重新检索 + print("[regenerate_scheme_from_feedback] 没有保存的方案,回退到重新检索") + return {"is_regenerating": True,"rag_search_result": None,"graph_search_result": None,"has_rag_result": False,"has_graph_result": False,"scheme_generated": False,"current_node": "call_tools","tools_to_call": ["rag_search", "graph_rag_search"],} + + detail_info = "" + if fault_code: + detail_info += f"- 故障码/报警代码:{fault_code}\n" + if fault_time: + detail_info += f"- 故障发生时间:{fault_time}\n" + if fault_frequency: + detail_info += f"- 故障频率/持续时间:{fault_frequency}\n" + if tried_measures: + detail_info += f"- 已尝试的维修措施:{tried_measures}\n" + if running_condition: + detail_info += f"- 设备运行工况:{running_condition}\n" + if additional_info: + detail_info += f"- 其他相关信息:{additional_info}\n" + + emit_callback_event(["✏️ 修改方案中", "🔧 调整方案", "📝 优化方案"], f"根据反馈修改方案: {user_feedback[:30]}...") + + image_guidance = get_image_prompt_guidance() + + prompt = COMMON_PROMPTS["regenerate_scheme_from_feedback"].format( + biz_label="维修", + item_label="故障现象", + item_name=fault or '未提供', + item_action="分析故障原因,给出维修方案", + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + fault=fault or '未提供', + detail_info=detail_info, + last_scheme=last_scheme, + user_feedback=user_feedback, + image_guidance=image_guidance + ) + + try: + # ========== 第一步:专注于生成高质量内容 ========== + content_system_prompt = COMMON_PROMPTS["regenerate_scheme_from_feedback_system"].format(biz_label="维修") + + # 构建第一步的内容提示词(简化格式要求) + content_prompt = prompt.replace("【输出要求】", "【输出要求】\n- 请专注于内容的准确性、完整性和逻辑性,不要担心格式的美观性\n- 可以适当使用简单的段落分隔,但不要使用复杂的格式") + + raw_content = await OpenaiAPI.open_api_chat_without_thinking(model=None,system_prompt=content_system_prompt,messages=[{"role": "user", "content": content_prompt}],) + + raw_content = raw_content.strip() if raw_content else "抱歉,无法根据反馈修改方案。" + + original_image_paths = extract_image_paths(last_scheme) + answer = await stream_format_and_postprocess(raw_content, original_image_paths, temperature=0.3, content_label="维修方案") + + suggested_replies = [{"title": "方案有误", "content": "方案还是有问题:"},] + + actions = _generate_report_from_scheme(scheme_content=answer,ship_number=ship_number,device_name=device_name,fault=fault) + + try: + await record_fault_from_state({"ship_number": ship_number,"device_name": device_name,"fault": fault,"last_generated_scheme": answer,}) + except Exception as rec_err: + print(f"[regenerate_scheme_from_feedback] 记录故障信息失败: {rec_err}") + + return {"response": answer,"actions": actions,"suggestedReplies": suggested_replies,"scheme_generated": True,"last_generated_scheme": answer,"scheme_type": scheme_type,"current_node": "end","last_combined_query": combined_query,} + + except Exception as e: + print(f"[regenerate_scheme_from_feedback] 修改方案失败: {str(e)}") + # 如果修改失败,回退到重新检索 + return {"is_regenerating": True,"rag_search_result": None,"graph_search_result": None,"has_rag_result": False,"has_graph_result": False,"scheme_generated": False,"current_node": "call_tools","tools_to_call": ["rag_search", "graph_rag_search"],} + +async def follow_up_qa(state: FaultDiagnosisState) -> Dict[str, Any]: + """ + 后续问答节点:调用 baike 智能体处理用户的后续问题 + """ + combined_query = state.get("combined_query", "") or "" + history_messages = state.get("history_messages", []) or [] + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + fault = state.get("fault", "") or "" + + history_message_json = json.dumps(history_messages, ensure_ascii=False) if history_messages else "" + + context_prefix = "" + if ship_number or device_name or fault: + context_parts = [] + if ship_number: + context_parts.append(f"舷号: {ship_number}") + if device_name: + context_parts.append(f"设备名称: {device_name}") + if fault: + context_parts.append(f"故障现象: {fault}") + context_prefix = f"【当前故障诊断上下文】\n{'; '.join(context_parts)}\n\n" + + enhanced_query = f"{context_prefix}用户后续问题: {combined_query}" + + emit_callback_event(["💬 后续问答", "💬 执行指导", "💬 问题解答"], f"调用 百科 智能体: {combined_query[:30]}...") + + try: + baike_result = await run_baike_workflow(extracted_text=enhanced_query,combined_query=enhanced_query,history_message=history_message_json,route_flag="baike") + + response = baike_result.get("response", "") + suggested_replies = baike_result.get("suggestedReplies", []) + + return {"response": response,"suggestedReplies": suggested_replies,"actions": [],"scheme_generated": True,"current_node": "end",} + + except Exception as e: + return {"response": f"处理您的问题时出现错误: {str(e)}","suggestedReplies": [],"actions": [],"current_node": "end",} + +async def generate_report(state: FaultDiagnosisState) -> Dict[str, Any]: + """报告生成节点""" + from workflows.workflow_other_report import run_other_report_workflow + + combined_query = state.get("combined_query", "") or "" + history_message = state.get("history_messages", []) + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + fault = state.get("fault", "") or "" + + history_message_json = json.dumps(history_message, ensure_ascii=False) if history_message else "" + + try: + report_result = await run_other_report_workflow(extracted_text=combined_query,combined_query=combined_query,history_message=history_message_json,route_flag="report") + + return {"response": report_result.get("response", ""),"actions": report_result.get("actions", []),"suggestedReplies": report_result.get("suggestedReplies", []),"scheme_generated": True,"current_node": "end",} + except Exception as e: + return {"response": f"报告生成失败: {str(e)}","actions": [],"suggestedReplies": [],"current_node": "end",} + +async def handle_feedback(state: FaultDiagnosisState) -> Command: + """ + 处理用户反馈节点:接收用户对方案的反馈,提供上报和重新生成方案按钮 + """ + combined_query = state.get("combined_query", "") or "" + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + fault = state.get("fault", "") or "" + + response = f"""**📝 已收到您的反馈** + +感谢您对维修方案的建议!您的反馈已记录: + +{combined_query} + +--- + +**📋 反馈信息摘要:** +- 舷号:{ship_number or '未提供'} +- 设备名称:{device_name or '未提供'} +- 故障现象:{fault or '未提供'} +- 反馈内容:{combined_query[:100]}{'...' if len(combined_query) > 100 else ''} + +--- + +**💡 您可以选择:** +- 点击「重新生成方案」根据您的反馈重新生成方案 +- 点击「上报」将反馈提交给相关部门 +- 继续对话获取更多帮助""" + + actions = [ + {"type": "content","label": "重新生成方案","text": "重新生成方案"}, + {"type": "content","label": "上报","text": "上报"} + ] + + suggested_replies = [] + + emit_callback_event(["📝 反馈处理", "📋 方案反馈", "✅ 反馈确认"], f"处理用户反馈: {combined_query[:30]}...") + + user_input = interrupt({"response": response,"actions": actions,"suggestedReplies": suggested_replies,"waiting_for": "feedback_confirm",}) + + user_text = user_input.get("query", "") or "" + + if user_text == "重新生成方案": + print(f"[handle_feedback] 用户选择重新生成方案") + return Command( + update={"user_feedback_for_regenerate": combined_query,"current_node": "regenerate_scheme_from_feedback",}, + goto="regenerate_scheme_from_feedback" + ) + elif user_text == "上报": + print(f"[handle_feedback] 用户选择上报反馈") + return Command( + update={"user_feedback": combined_query,"waiting_report_confirm": True,"current_node": "confirm_report",}, + goto="confirm_report" + ) + else: + print(f"[handle_feedback] 用户继续对话: {user_text}") + return Command( + update={"user_feedback": combined_query,"combined_query": user_text,"current_node": "agent_think",}, + goto="agent_think" + ) + +async def confirm_report(state: FaultDiagnosisState) -> Dict[str, Any]: + """ + 上报确认节点:用户点击上报后确认完成 + """ + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + fault = state.get("fault", "") or "" + user_feedback = state.get("user_feedback", "") or "" + + response = f"""**✅ 上报成功** + +您的反馈已成功上报! + +--- + +**📋 上报信息:** +- 舷号:{ship_number or '未提供'} +- 设备名称:{device_name or '未提供'} +- 故障现象:{fault or '未提供'} +- 反馈内容:{user_feedback[:100]}{'...' if len(user_feedback) > 100 else ''} + +--- + +**💡 感谢您的反馈,相关部门将及时处理。**""" + + suggested_replies = [{"title": "生成维修报告", "content": "请帮我生成维修报告"},] + + emit_callback_event(["✅ 上报成功", "📋 反馈已提交", "🎉 完成"], "反馈上报成功") + + return {"response": response,"actions": [],"suggestedReplies": suggested_replies,"report_confirmed": True,"waiting_report_confirm": False,"current_node": "end",} + +def _route_after_think(state: FaultDiagnosisState) -> str: + """Agent 思考后的路由""" + decision = state.get("agent_decision", "ask_user") + + return decision + +def _route_after_intent(state: FaultDiagnosisState) -> str: + """意图分析后的路由""" + intent = state.get("follow_up_intent", "other") + + if intent == "not_solved": + return "deep_rag_search" + elif intent == "related_question": + return "follow_up_qa" + elif intent == "has_error": + return "handle_feedback" + elif intent == "modify_info": + # 修改信息,重置相关状态,重新开始信息提取 + return "extract_info" + else: + return "follow_up_qa" + +def _route_after_tools(state: FaultDiagnosisState) -> str: + """检索工具调用后的路由""" + need_model_confirm = state.get("need_model_confirm", False) + if need_model_confirm: + return "model_confirm" + return "generate_response" + +def create_fault_diagnosis_workflow(checkpointer=None): + """ + 创建故障诊断工作流(基于 Checkpointer 的状态持久化版本) + """ + workflow = StateGraph(FaultDiagnosisState) + + workflow.add_node("classify_intent", classify_intent) + workflow.add_node("extract_info", extract_info) + workflow.add_node("agent_think", agent_think) + workflow.add_node("ask_user", ask_user) + workflow.add_node("confirm_info", confirm_info) + workflow.add_node("call_tools", call_tools) + workflow.add_node("generate_response", generate_response) + workflow.add_node("follow_up_qa", follow_up_qa) + workflow.add_node("model_confirm", model_confirm) + workflow.add_node("generate_report", generate_report) + workflow.add_node("handle_feedback", handle_feedback) + workflow.add_node("confirm_report", confirm_report) + workflow.add_node("analyze_follow_up_intent", analyze_follow_up_intent) + workflow.add_node("deep_rag_search", deep_rag_search) + workflow.add_node("generate_deep_response", generate_deep_response) + workflow.add_node("regenerate_scheme_from_feedback", regenerate_scheme_from_feedback) + + workflow.add_edge(START, "classify_intent") + workflow.add_conditional_edges("classify_intent", _route_after_classify, + {"extract_info": "extract_info","follow_up_qa": "follow_up_qa",}) + workflow.add_edge("extract_info", "agent_think") + workflow.add_conditional_edges("agent_think", _route_after_think, + {"ask_user": "ask_user","confirm_info": "confirm_info","call_tools": "call_tools","generate_response": "generate_response","follow_up": "follow_up_qa","follow_up_qa": "follow_up_qa","generate_report": "generate_report","handle_feedback": "handle_feedback","extract_info": "extract_info","analyze_follow_up_intent": "analyze_follow_up_intent",}) + workflow.add_conditional_edges("call_tools", _route_after_tools, {"model_confirm": "model_confirm","generate_response": "generate_response",}) + workflow.add_conditional_edges("analyze_follow_up_intent", _route_after_intent, {"deep_rag_search": "deep_rag_search","follow_up_qa": "follow_up_qa","handle_feedback": "handle_feedback","extract_info": "extract_info",}) + workflow.add_edge("deep_rag_search", "generate_deep_response") + workflow.add_edge("generate_deep_response", END) + workflow.add_edge("generate_response", END) + workflow.add_edge("follow_up_qa", END) + workflow.add_edge("generate_report", END) + workflow.add_edge("regenerate_scheme_from_feedback", END) + workflow.add_edge("handle_feedback", END) + workflow.add_edge("confirm_report", END) + + return workflow.compile(checkpointer=checkpointer) + +async def run_fault_diagnosis_workflow(extracted_text: str,combined_query: str,history_message: str = "",route_flag: str = "",previous_state_json: str = "",chat_id: str = "",message_id: str = "",checkpointer=None,user_response: Dict[str, Any] = None) -> Dict[str, Any]: + """ + 执行故障诊断工作流 + + Args: + extracted_text: 文本描述 + combined_query: 组合查询 + history_message: 历史消息(JSON格式) + route_flag: 路由标识 + previous_state_json: 兼容参数(已废弃) + chat_id: 聊天会话 ID + message_id: 消息 ID + checkpointer: LangGraph checkpointer 实例 + user_response: 用户恢复执行的响应数据 + + Returns: + 工作流执行结果 + """ + thread_id = chat_id if chat_id else None + + config = {"configurable": {"thread_id": thread_id}} if thread_id else {} + + print(f"[DEBUG] run_fault_diagnosis_workflow: thread_id={thread_id}, checkpointer={checkpointer is not None}") + + if checkpointer is None: + from checkpointer_config import checkpointer_manager + checkpointer = await checkpointer_manager.get_async_checkpointer() + + app = create_fault_diagnosis_workflow(checkpointer=checkpointer) + + if thread_id: + try: + current_state = await app.aget_state(config) + print( + f"[DEBUG] current_state exists: {current_state is not None}, has values: {current_state.values is not None if current_state else False}, tasks: {len(current_state.tasks) if current_state else 0}") + + if current_state and current_state.values: + saved_state = current_state.values + print( + f"[Checkpointer] 发现已保存状态: ship_number={saved_state.get('ship_number')}, device_name={saved_state.get('device_name')}, fault={saved_state.get('fault')}") + + if current_state.tasks: + print(f"[Checkpointer] 恢复执行,注入用户响应") + resume_value = user_response if user_response else {"query": combined_query} + result = await app.ainvoke(Command(resume=resume_value), config) + else: + # 只使用当前的输入,不拼接之前的! + merged_query = combined_query + + # 如果当前输入是确认意图,且已有完整信息,直接视为用户已确认 + _is_confirm = await is_confirmation_intent(combined_query) + _has_full_info = (saved_state.get("ship_number") and saved_state.get("device_name") and saved_state.get("fault")) + _override_confirmed = _is_confirm and _has_full_info and not saved_state.get("user_confirmed_info", False) + + initial_state = {**saved_state,"combined_query": merged_query,"history_messages": parse_history(history_message),"initialized": False,"user_intent": "",} + if _override_confirmed: + print(f"[Checkpointer] 检测到确认意图,自动设置 user_confirmed_info=True") + initial_state["user_confirmed_info"] = True + result = await app.ainvoke(initial_state, config) + else: + initial_state = {"extracted_text": extracted_text,"combined_query": combined_query,"history_messages": parse_history(history_message),"ship_number": "","device_name": "","fault": "","additional_info": "", + "fault_code": "","fault_time": "","fault_frequency": "","tried_measures": "","running_condition": "","rag_search_result": None,"graph_search_result": None,"response": "", + "suggestedReplies": [],"actions": [],"error_message": "","agent_decision": "","agent_reasoning": "","tools_to_call": [],"missing_info": [],"info_completeness": 0.0, + "matched_kb_id": "","matched_kb_name": "","iteration_count": 0,"max_iterations": 5,"scheme_generated": False,"waiting_for_supplement": False,"extracted_info": {}, + "has_rag_result": False,"has_graph_result": False,"is_execution_feedback": False,"execution_detail": "","ask_round": 0,"user_confirmed_info": False,"need_model_confirm": False, + "user_confirmed_model": False,"waiting_feedback": False,"user_feedback": "","waiting_report_confirm": False,"report_confirmed": False,"initialized": False,"current_node": "","user_intent": "","last_combined_query": "",} + result = await app.ainvoke(initial_state, config) + except Exception as e: + print(f"[Checkpointer] 状态恢复失败: {e}") + import traceback + traceback.print_exc() + result = {} + else: + initial_state = {"extracted_text": extracted_text,"combined_query": combined_query,"history_messages": parse_history(history_message),"ship_number": "","device_name": "","fault": "","additional_info": "","fault_code": "","fault_time": "", + "fault_frequency": "","tried_measures": "","running_condition": "","rag_search_result": None,"graph_search_result": None,"response": "","suggestedReplies": [],"actions": [],"error_message": "","agent_decision": "","agent_reasoning": "","tools_to_call": [], + "missing_info": [],"info_completeness": 0.0,"matched_kb_id": "","matched_kb_name": "","iteration_count": 0,"max_iterations": 5,"scheme_generated": False,"waiting_for_supplement": False,"extracted_info": {},"has_rag_result": False,"has_graph_result": False, + "is_execution_feedback": False,"execution_detail": "","ask_round": 0,"user_confirmed_info": False,"need_model_confirm": False,"user_confirmed_model": False,"waiting_feedback": False,"user_feedback": "","waiting_report_confirm": False,"report_confirmed": False,"initialized": False,"current_node": "","user_intent": "","last_combined_query": "",} + + try: + result = await app.ainvoke(initial_state, config) + except Exception as e: + print(f"[工作流执行失败] {str(e)}") + import traceback + traceback.print_exc() + return {"response": f"故障诊断工作流执行失败: {str(e)}","actions": [],"result_tag": "fault_diagnosis","suggestedReplies": [],} + + if result is None: + result = {} + + interrupts = result.get("__interrupt__", []) + if interrupts: + interrupt_data = interrupts[0].value if interrupts else {} + print(f"[Interrupt] 工作流中断,等待用户输入: {interrupt_data}") + return {"response": interrupt_data.get("response", "请提供更多信息"),"actions": interrupt_data.get("actions", []),"result_tag": "fault_diagnosis","suggestedReplies": interrupt_data.get("suggestedReplies", []),"waiting_for": interrupt_data.get("waiting_for", ""),} + + if result.get("error_message"): + return {"response": f"故障诊断工作流执行失败: {result['error_message']}","actions": [],"result_tag": "fault_diagnosis","suggestedReplies": [],} + + route_flag = result.get("route_flag", "") + if not route_flag: + route_flag = "fault_diagnosis" + + return {"response": result.get("response", ""),"actions": result.get("actions", []),"result_tag": route_flag,"suggestedReplies": result.get("suggestedReplies", []),"route_flag": route_flag,} diff --git a/workflows/workflow_fault_diagnosis.py智能检索 b/workflows/workflow_fault_diagnosis.py智能检索 new file mode 100644 index 0000000..08cb2a0 --- /dev/null +++ b/workflows/workflow_fault_diagnosis.py智能检索 @@ -0,0 +1,1665 @@ +""" +故障诊断工作流 checkpointer 版本 +""" +from typing import TypedDict, Optional, Dict, Any, List +from langgraph.graph import StateGraph, START, END +from langgraph.types import interrupt, Command +from tools.function_tool import graph_rag_search +from tools.rag_tools import rag_search +from tools.graph_tools import atlas_retrieval, format_atlas_results, extract_entity_names_from_history +from modelsAPI.model_api import OpenaiAPI +from utils.function_tracker import get_all_callbacks +from utils.image_utils import extract_image_paths, normalize_markdown_images, get_image_prompt_guidance +from workflows.workflow_baike import run_baike_workflow +from workflows.workflow_utils import ( + safe_json_extract, parse_history, normalize_latex_spacing, + remove_duplicate_content, convert_rag_result, calculate_info_completeness, + emit_callback_event, build_history_str, build_detail_info, + append_atlas_section, stream_format_and_postprocess, +) +from utils.word_generator import generate_report_word +from utils.intent_matcher import is_confirmation_intent +from config import KB_TREE_CONFIG, SERVER_CONFIG +from prompts import FAULT_DIAGNOSIS_PROMPTS, COMMON_PROMPTS +from tools.fault_record_db import record_fault_from_state +import asyncio +import json +import re +import random +import os +import time + +class FaultDiagnosisState(TypedDict): + """故障诊断工作流状态""" + extracted_text: str + combined_query: str + history_messages: List[Dict[str, Any]] + + ship_number: str + device_name: str + fault: str + additional_info: str + fault_code: str + fault_time: str + fault_frequency: str + tried_measures: str + running_condition: str + + rag_search_result: Optional[List[Dict[str, Any]]] + graph_search_result: Optional[str] + deep_rag_results: Optional[Dict[str, Any]] + + response: str + suggestedReplies: List[Dict[str, Any]] + actions: List[Dict[str, Any]] + error_message: str + + agent_decision: str + agent_reasoning: str + tools_to_call: List[str] + missing_info: List[str] + info_completeness: float + + matched_kb_id: str + matched_kb_name: str + + iteration_count: int + max_iterations: int + scheme_generated: bool + waiting_for_supplement: bool + + extracted_info: Dict[str, Any] + has_rag_result: bool + has_graph_result: bool + ask_round: int + user_confirmed_info: bool + + need_model_confirm: bool + user_confirmed_model: bool + + user_feedback: str + waiting_report_confirm: bool + report_confirmed: bool + + initialized: bool + current_node: str + + # 意图分类相关字段 + user_intent: str # 用户初始意图: '维修', '百科' + + # 后续处理相关字段 + follow_up_intent: str # 用户后续意图: 'not_solved', 'related_question', 'has_error', 'modify_info', 'other' + user_feedback_for_regenerate: str # 用户用于重新生成方案的反馈 + is_regenerating: bool # 是否正在重新生成方案 + last_generated_scheme: str # 最后一次生成的方案(用于基于反馈修改) + scheme_type: str # 方案类型:'normal' 普通方案,'deep' 深度方案 + deep_analysis_points: Optional[List[str]] # 深度分析点(用于针对性检索) + atlas_results: Optional[Dict[str, Any]] # 图册检索结果 + last_combined_query: str # 上一轮生成方案时的用户输入 + +def _preprocess_scheme_for_word(content: str) -> str: + """ + 预处理方案内容:将相对路径图片URL转为绝对URL + 图片格式解析和公式渲染由 word_generator.py 直接处理 + """ + if not content: + return content + + base_url = SERVER_CONFIG.get("base_url", "").rstrip("/") + + kb_base_url = KB_TREE_CONFIG.get("url", "").rstrip("/") + kb_host = "" + if kb_base_url: + from urllib.parse import urlparse + kb_parsed = urlparse(kb_base_url) + kb_host = f"{kb_parsed.scheme}://{kb_parsed.netloc}" + + # 知识库图片由知识库服务提供 + if kb_host: + content = re.sub( + r'(/api/v1/knowledge/files/[^)\s]+)', + lambda m: f"{kb_host}{m.group(1)}" if not m.group(1).startswith("http") else m.group(1), + content + ) + elif base_url: + content = re.sub( + r'(/api/v1/knowledge/files/[^)\s]+)', + lambda m: f"{base_url}{m.group(1)}" if not m.group(1).startswith("http") else m.group(1), + content + ) + + # /picture/ 图片由本应用提供 + if base_url: + content = re.sub( + r'(/picture/[^)\s]+)', + lambda m: f"{base_url}{m.group(1)}" if not m.group(1).startswith("http") else m.group(1), + content + ) + + return content + + +def _generate_report_from_scheme(scheme_content: str, ship_number: str = "", device_name: str = "", fault: str = "") -> List[Dict[str, Any]]: + """ + 从方案内容直接生成报告并返回下载按钮 + + Args: + scheme_content: 方案内容(Markdown 格式) + ship_number: 舷号 + device_name: 设备名称 + fault: 故障现象 + + Returns: + actions 列表,包含下载按钮(如果生成成功) + """ + try: + # 构建报告标题 + report_title = "维修方案报告" + if ship_number: + report_title = f"{ship_number} - {report_title}" + if device_name: + report_title = f"{report_title} - {device_name}" + + # 预处理方案内容:解析图片URL和公式格式 + processed_content = _preprocess_scheme_for_word(scheme_content) + + # 准备报告内容 - 直接使用方案内容,添加标题信息 + 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") + + # 构建报告内容 + report_content = f"""## **{report_title}** + +报告编号:{report_number} +编制时间:{compile_time} + +--- + +{processed_content} +""" + + # 确保 created_word 目录存在 + current_dir = os.path.dirname(os.path.abspath(__file__)) + parent_dir = os.path.dirname(current_dir) + word_dir = os.path.join(parent_dir, "created_word") + os.makedirs(word_dir, exist_ok=True) + + # 生成 Word 文档 + timestamp = time.strftime("%Y%m%d_%H%M%S") + safe_title = re.sub(r'[\\/:*?"<>|]', '_', report_title) + file_name = f"{safe_title}_{timestamp}.docx" + output_path = os.path.join(word_dir, file_name) + + word_result = generate_report_word(markdown=report_content,title=report_title,output=output_path) + + # 构建下载按钮 + actions = [] + if word_result and os.path.exists(word_result): + base_url = SERVER_CONFIG.get("report", "") + 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_content}) + + print(f"[_generate_report_from_scheme] 报告生成成功: {output_path}") + + return actions + + except Exception as e: + print(f"[_generate_report_from_scheme] 生成报告失败: {e}") + import traceback + traceback.print_exc() + return [] + +FAULT_INFO_WEIGHTS = {"ship_number": 0.20, "device_name": 0.20, "fault": 0.25, "fault_code": 0.08, "fault_time": 0.06, "fault_frequency": 0.06, "tried_measures": 0.06, "running_condition": 0.05, "additional_info": 0.04} + +def _calculate_info_completeness(ship_number: str = "", device_name: str = "", fault: str = "", fault_code: str = "", fault_time: str = "", fault_frequency: str = "", tried_measures: str = "", running_condition: str = "", additional_info: str = "") -> float: + values = {"ship_number": ship_number, "device_name": device_name, "fault": fault, "fault_code": fault_code, "fault_time": fault_time, "fault_frequency": fault_frequency, "tried_measures": tried_measures, "running_condition": running_condition, "additional_info": additional_info} + return calculate_info_completeness(FAULT_INFO_WEIGHTS, values) + +async def classify_intent(state: FaultDiagnosisState) -> Dict[str, Any]: + """ + 意图分类节点 + """ + combined_query = state.get("combined_query", "") or "" + extracted_info = state.get("extracted_info", {}) or {} + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + fault = state.get("fault", "") or "" + + # 如果已经提取过维修信息(有设备名或故障现象),说明已在维修流程中,继续维修流程 + if extracted_info or device_name or fault or ship_number: + print("[classify_intent] 已在维修流程中,跳过意图分类") + return {"user_intent": "维修","current_node": "extract_info",} + + system_prompt = FAULT_DIAGNOSIS_PROMPTS["classify_intent_system"] + + prompt = COMMON_PROMPTS["classify_intent_user"].format(combined_query=combined_query) + + try: + response_text = await OpenaiAPI.open_api_chat_without_thinking(query=prompt,model=None,json_output=True,system_prompt=system_prompt,messages=[]) + + parsed = safe_json_extract(response_text) + intent = "维修" + if isinstance(parsed, dict): + intent = parsed.get("intent", "维修") + + print(f"[意图分类] 用户意图: {intent}") + + if intent == "维修": + return {"user_intent": intent,"scheme_generated": False,"current_node": "extract_info",} + else: + return {"user_intent": intent,"current_node": "follow_up_qa",} + + except Exception as e: + print(f"[意图分类失败: {str(e)},默认按维修处理") + return {"user_intent": "维修","scheme_generated": False,"current_node": "extract_info",} + +def _route_after_classify(state: FaultDiagnosisState) -> str: + """意图分类后的路由""" + intent = state.get("user_intent", "维修") + if intent == "百科": + return "follow_up_qa" + return "extract_info" + +async def extract_info(state: FaultDiagnosisState) -> Dict[str, Any]: + """ + 信息提取节点 + """ + if state.get("initialized", False): + print("[extract_info] 已初始化,跳过") + return {"current_node": "agent_think"} + + combined_query = state.get("combined_query", "") or "" + history_messages = state.get("history_messages", []) or [] + + history_str = build_history_str(history_messages, recent_count=4) + + existing_info = [] + existing_ship_number = state.get("ship_number", "") or "" + existing_device_name = state.get("device_name", "") or "" + existing_fault = state.get("fault", "") or "" + existing_fault_code = state.get("fault_code", "") or "" + existing_fault_time = state.get("fault_time", "") or "" + existing_fault_frequency = state.get("fault_frequency", "") or "" + existing_tried_measures = state.get("tried_measures", "") or "" + existing_running_condition = state.get("running_condition", "") or "" + existing_additional_info = state.get("additional_info", "") or "" + + if existing_ship_number: + existing_info.append(f"舷号: {existing_ship_number}") + if existing_device_name: + existing_info.append(f"设备名称: {existing_device_name}") + if existing_fault: + existing_info.append(f"故障现象: {existing_fault}") + if existing_fault_code: + existing_info.append(f"故障码: {existing_fault_code}") + if existing_fault_time: + existing_info.append(f"故障时间: {existing_fault_time}") + if existing_fault_frequency: + existing_info.append(f"故障频率: {existing_fault_frequency}") + if existing_tried_measures: + existing_info.append(f"已尝试措施: {existing_tried_measures}") + if existing_running_condition: + existing_info.append(f"运行工况: {existing_running_condition}") + if existing_additional_info: + existing_info.append(f"其他信息: {existing_additional_info}") + + existing_info_str = "\n".join(existing_info) if existing_info else "(无已提取信息)" + + system_prompt = FAULT_DIAGNOSIS_PROMPTS["extract_info_system"] + + prompt = COMMON_PROMPTS["extract_info_user"].format( + biz_label="维修", + existing_info_str=existing_info_str, + history_str=history_str or '(无历史对话)', + combined_query=combined_query + ) + + try: + response_text = await OpenaiAPI.open_api_chat_without_thinking(query=prompt,model=None,json_output=True,system_prompt=system_prompt,messages=[]) + + parsed = safe_json_extract(response_text) + if not isinstance(parsed, dict): + parsed = {} + + new_ship_number = parsed.get("ship_number", "") or existing_ship_number + new_device_name = parsed.get("device_name", "") or existing_device_name + new_fault = parsed.get("fault", "") or existing_fault + new_fault_code = parsed.get("fault_code", "") or existing_fault_code + new_fault_time = parsed.get("fault_time", "") or existing_fault_time + new_fault_frequency = parsed.get("fault_frequency", "") or existing_fault_frequency + new_tried_measures = parsed.get("tried_measures", "") or existing_tried_measures + new_running_condition = parsed.get("running_condition", "") or existing_running_condition + new_additional_info = parsed.get("additional_info", "") or existing_additional_info + + extracted_info = {"ship_number": new_ship_number,"device_name": new_device_name,"fault": new_fault,"fault_code": new_fault_code,"fault_time": new_fault_time,"fault_frequency": new_fault_frequency,"tried_measures": new_tried_measures,"running_condition": new_running_condition,"additional_info": new_additional_info,} + + print(f"[信息提取] 舷号={extracted_info['ship_number']}, 设备={extracted_info['device_name']}, 故障={extracted_info['fault']}") + + return {**extracted_info,"extracted_info": extracted_info,"initialized": True,"current_node": "agent_think",} + + except Exception as e: + print(f"信息提取失败: {str(e)}") + return {"initialized": True,"current_node": "agent_think",} + +async def agent_think(state: FaultDiagnosisState) -> Dict[str, Any]: + """ + Agent 思考节点:基于当前状态决定下一步动作 + """ + combined_query = state.get("combined_query", "") or "" + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + fault = state.get("fault", "") or "" + fault_code = state.get("fault_code", "") or "" + fault_time = state.get("fault_time", "") or "" + fault_frequency = state.get("fault_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + additional_info = state.get("additional_info", "") or "" + history_messages = state.get("history_messages", []) or [] + + scheme_generated = state.get("scheme_generated", False) + has_rag_result = state.get("has_rag_result", False) + has_graph_result = state.get("has_graph_result", False) + + rag_search_result = state.get("rag_search_result") + graph_search_result = state.get("graph_search_result") + iteration_count = state.get("iteration_count", 0) + + user_confirmed_info = state.get("user_confirmed_info", False) + waiting_feedback = state.get("waiting_feedback", False) + + has_ship = bool(ship_number and ship_number.strip()) + has_device = bool(device_name and device_name.strip()) + has_fault = bool(fault and fault.strip()) + has_rag = (rag_search_result is not None and len(rag_search_result) > 0) or has_rag_result + has_graph = (graph_search_result is not None and len(graph_search_result) > 100) or has_graph_result + + info_completeness = _calculate_info_completeness(ship_number=ship_number,device_name=device_name,fault=fault,fault_code=fault_code,fault_time=fault_time,fault_frequency=fault_frequency,tried_measures=tried_measures,running_condition=running_condition,additional_info=additional_info) + + MIN_COMPLETENESS_FOR_GENERATE = 0.25 + + if scheme_generated: + _re_diagnose_keywords = ["开始诊断", "信息已足够", "信息已足够,开始诊断", "开始", "诊断"] + _is_re_diagnose = any(kw in combined_query for kw in _re_diagnose_keywords) + if _is_re_diagnose: + print(f"[重新诊断] 检测到重新诊断意图: '{combined_query}',重置状态直接调用工具生成方案") + tools_to_call = ["rag_search"] + if device_name and fault: + tools_to_call.append("graph_rag_search") + return {"scheme_generated": False,"rag_search_result": None,"graph_search_result": None,"deep_rag_results": None,"has_rag_result": False,"has_graph_result": False,"is_regenerating": False,"user_feedback_for_regenerate": "","follow_up_intent": "","user_confirmed_info": True,"initialized": True,"iteration_count": 0,"ask_round": 0,"agent_decision": "call_tools","tools_to_call": tools_to_call,"current_node": "call_tools",} + + history_str = build_history_str(history_messages, recent_count=5, assistant_truncate=300) + + intent_system_prompt = FAULT_DIAGNOSIS_PROMPTS["agent_think_intent_system"] + + intent_prompt = FAULT_DIAGNOSIS_PROMPTS["agent_think_intent_user"].format( + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + fault=fault or '未提供', + history_str=history_str or '无', + combined_query=combined_query + ) + + try: + intent_response = await OpenaiAPI.open_api_chat_without_thinking( + query=intent_prompt, + model=None, + json_output=True, + system_prompt=intent_system_prompt, + messages=[] + ) + + parsed_intent = safe_json_extract(intent_response) + intent = "other" + if isinstance(parsed_intent, dict): + intent = parsed_intent.get("intent", "other") + + print(f"[Agent 意图分析] 用户意图: {intent}") + + # 根据大模型判断的意图来决定下一步 + if intent == "generate_report": + decision = "generate_report" + reasoning = "用户请求生成报告" + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + return {"agent_decision": decision,"agent_reasoning": reasoning,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + elif intent == "modify_info": + # 修改信息,跳转到 confirm_info,重置状态 + decision = "confirm_info" + reasoning = "用户需要修改或补充信息" + return {"agent_decision": decision,"agent_reasoning": reasoning,"rag_search_result": None,"graph_search_result": None,"deep_rag_results": None,"has_rag_result": False,"has_graph_result": False,"scheme_generated": False,"is_regenerating": False,"user_feedback_for_regenerate": "","follow_up_intent": "","user_confirmed_info": False,"initialized": False,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + elif intent == "has_error": + decision = "handle_feedback" + reasoning = "用户反馈方案有错误" + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + return {"agent_decision": decision,"agent_reasoning": reasoning,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + elif intent == "not_solved": + decision = "analyze_follow_up_intent" + reasoning = "已生成方案,分析用户后续意图" + # 设置 follow_up_intent,让 analyze_follow_up_intent 直接知道 + return {"agent_decision": decision,"agent_reasoning": reasoning,"follow_up_intent": "not_solved","info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + elif intent == "related_question": + decision = "analyze_follow_up_intent" + reasoning = "已生成方案,分析用户后续意图" + return {"agent_decision": decision,"agent_reasoning": reasoning,"follow_up_intent": "related_question","info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + else: + # other 情况,直接跳转到 follow_up_qa,不需要经过 analyze_follow_up_intent + decision = "follow_up_qa" + reasoning = "其他意图,直接调用 baike" + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + return {"agent_decision": decision,"agent_reasoning": reasoning,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + except Exception as e: + print(f"[Agent 意图分析失败: {str(e)}") + # 大模型判断失败,降级到原来的逻辑 + decision = "analyze_follow_up_intent" + reasoning = "已生成方案,分析用户后续意图" + print(f"[Agent 决策] decision={decision}, reasoning={reasoning} (降级)") + return {"agent_decision": decision,"agent_reasoning": reasoning,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + + if not has_device or not has_fault: + decision = "ask_user" + missing_info = [] + if not has_device: + missing_info.append("设备名称") + if not has_fault: + missing_info.append("故障现象") + reasoning = f"缺少基本信息: {', '.join(missing_info)}" + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + return {"agent_decision": decision,"agent_reasoning": reasoning,"missing_info": missing_info,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + + if info_completeness < MIN_COMPLETENESS_FOR_GENERATE: + decision = "ask_user" + reasoning = f"信息完整性评分 {info_completeness} 低于最低要求 {MIN_COMPLETENESS_FOR_GENERATE}" + missing_info = [] + if not ship_number: + missing_info.append("舷号(可选)") + if not fault_code: + missing_info.append("故障码") + if not fault_time: + missing_info.append("故障时间") + if not fault_frequency: + missing_info.append("故障频率") + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + return {"agent_decision": decision,"agent_reasoning": reasoning,"missing_info": missing_info or ["更多详细信息"],"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + + if has_rag or has_graph: + decision = "generate_response" + reasoning = "信息充足,已有检索结果,直接生成回复" + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + return {"agent_decision": decision,"agent_reasoning": reasoning,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + + if not user_confirmed_info: + decision = "confirm_info" + reasoning = "信息充足,需要用户确认后再检索知识库" + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + return {"agent_decision": decision,"agent_reasoning": reasoning,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + + decision = "call_tools" + reasoning = "信息充足,用户已确认,开始检索知识库" + tools_to_call = ["rag_search"] + if has_device and has_fault: + tools_to_call.append("graph_rag_search") + + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + print(f"[Agent 提取] 舷号={ship_number}, 设备={device_name}, 故障={fault}") + + emit_callback_event(["🤖 Agent 思考中", "🤖 智能分析", "🤖 决策判断"], f"决策: {decision} | 理由: {reasoning[:50]}...") + + return {"agent_decision": decision,"agent_reasoning": reasoning,"tools_to_call": tools_to_call,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + +async def ask_user(state: FaultDiagnosisState) -> Command: + """ + 询问用户节点:使用 interrupt 暂停等待用户输入 + """ + missing_info = state.get("missing_info", []) + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + fault = state.get("fault", "") or "" + fault_code = state.get("fault_code", "") or "" + fault_time = state.get("fault_time", "") or "" + fault_frequency = state.get("fault_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + additional_info = state.get("additional_info", "") or "" + combined_query = state.get("combined_query", "") or "" + info_completeness = state.get("info_completeness", 0) + ask_round = state.get("ask_round", 0) + + MAX_ASK_ROUNDS = 4 + if ask_round >= MAX_ASK_ROUNDS: + print(f"[ask_user] 已达到最大询问轮次 {MAX_ASK_ROUNDS},强制进入下一步") + tools_to_call = ["rag_search"] + if device_name and fault: + tools_to_call.append("graph_rag_search") + return Command( + update={"ask_round": ask_round + 1,"current_node": "call_tools","tools_to_call": tools_to_call,}, + goto="call_tools" + ) + + existing_parts = [] + if ship_number: + existing_parts.append(f"舷号: {ship_number}") + if device_name: + existing_parts.append(f"设备名称: {device_name}") + if fault: + existing_parts.append(f"故障现象: {fault}") + if fault_code: + existing_parts.append(f"故障码: {fault_code}") + if fault_time: + existing_parts.append(f"发生时间: {fault_time}") + if fault_frequency: + existing_parts.append(f"故障频率: {fault_frequency}") + if tried_measures: + existing_parts.append(f"已尝试措施: {tried_measures}") + if running_condition: + existing_parts.append(f"运行工况: {running_condition}") + if additional_info: + existing_parts.append(f"其他信息: {additional_info}") + + existing_str = ";".join(existing_parts) if existing_parts else "暂无" + missing_str = "、".join(missing_info) if missing_info else "相关信息" + + system_prompt = COMMON_PROMPTS["ask_user_system"].format(biz_label="故障诊断", biz_label_short="诊断") + + prompt = COMMON_PROMPTS["ask_user_prompt"].format( + info_completeness=info_completeness, + existing_str=existing_str, + missing_str=missing_str, + combined_query=combined_query + ) + + response = "" + try: + response = await OpenaiAPI.open_api_chat_without_thinking(query=prompt,model=None,system_prompt=system_prompt,messages=[]) + response = response.strip() if response else "" + except Exception as e: + print(f"[ask_user] API调用失败: {str(e)}") + response = "" + + if not response: + response = f"**【需要更多信息】**\n\n请补充:{missing_str}\n\n提供这些信息后,我可以更准确地为您诊断故障。" + + if existing_parts: + existing_info_section = f"\n**📋 已获取的信息:**\n\n" + "\n".join( + f"- {part}" for part in existing_parts) + "\n\n---\n" + response = existing_info_section + response + + suggested_replies = [] + if "舷号" in missing_info: + suggested_replies.append({"title": "补充舷号", "content": "舷号:"}) + if "设备名称" in missing_info: + suggested_replies.append({"title": "补充设备名称", "content": "设备名称:"}) + if "故障现象" in missing_info: + suggested_replies.append({"title": "补充故障现象", "content": "故障现象:"}) + + user_input = interrupt({"response": response,"suggestedReplies": suggested_replies,"waiting_for": "user_input","missing_info": missing_info,}) + + user_query = user_input.get("query", "") + + update_dict = {"combined_query": user_query,"current_node": "agent_think","ask_round": ask_round + 1,} + + if user_query: + extract_prompt = FAULT_DIAGNOSIS_PROMPTS["ask_user_extract"].format( + missing_str=missing_str, + user_query=user_query + ) + + try: + extract_response = await OpenaiAPI.open_api_chat_without_thinking(query=extract_prompt,model=None,json_output=True,system_prompt="你是信息提取专家。",messages=[]) + extracted = safe_json_extract(extract_response) + if isinstance(extracted, dict): + if extracted.get("ship_number"): + update_dict["ship_number"] = extracted["ship_number"] + print(f"[ask_user] 提取到舷号: {extracted['ship_number']}") + if extracted.get("device_name"): + update_dict["device_name"] = extracted["device_name"] + print(f"[ask_user] 提取到设备名称: {extracted['device_name']}") + if extracted.get("fault"): + update_dict["fault"] = extracted["fault"] + print(f"[ask_user] 提取到故障现象: {extracted['fault']}") + except Exception as e: + print(f"[ask_user] 信息提取失败: {str(e)}") + + return Command( + update=update_dict, + goto="agent_think" + ) + +async def confirm_info(state: FaultDiagnosisState) -> Command: + """ + 确认信息节点:重点询问用户是否需要补充信息 + """ + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + fault = state.get("fault", "") or "" + fault_code = state.get("fault_code", "") or "" + fault_time = state.get("fault_time", "") or "" + fault_frequency = state.get("fault_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + additional_info = state.get("additional_info", "") or "" + info_completeness = state.get("info_completeness", 0) + + existing_parts = [] + if ship_number: + existing_parts.append(f"舷号: {ship_number}") + if device_name: + existing_parts.append(f"设备名称: {device_name}") + if fault: + existing_parts.append(f"故障现象: {fault}") + if fault_code: + existing_parts.append(f"故障码: {fault_code}") + if fault_time: + existing_parts.append(f"发生时间: {fault_time}") + if fault_frequency: + existing_parts.append(f"故障频率: {fault_frequency}") + if tried_measures: + existing_parts.append(f"已尝试措施: {tried_measures}") + if running_condition: + existing_parts.append(f"运行工况: {running_condition}") + if additional_info: + existing_parts.append(f"其他信息: {additional_info}") + + existing_str = "\n".join(f"- {part}" for part in existing_parts) if existing_parts else "暂无" + + additional_prompt = "" + if not ship_number: + additional_prompt = "\n\n💡 **提示**:如果您知道舷号,请补充,这将有助于我们更准确地生成报告。" + + response = f"""**📋 已收集到的信息:** + +{existing_str} + +**信息完整性评分:** {info_completeness:.0%} + +--- + +**💡 请问是否需要补充其他信息?** +- 如故障码、故障时间、故障频率、已尝试措施、运行工况等 +- 如果信息已足够,请点击"开始诊断"{additional_prompt} +""" + + emit_callback_event(["✅ 信息确认", "📋 请确认信息", "🔍 准备检索"], f"等待用户确认或补充信息 (完整性: {info_completeness:.0%})") + + suggested_replies = [{"title": "开始诊断", "content": "信息已足够,开始诊断"},{"title": "补充信息", "content": "我需要补充:"}] + + user_input = interrupt({"response": response,"suggestedReplies": suggested_replies,"waiting_for": "user_confirm",}) + + user_query = user_input.get("query", "") + + if await is_confirmation_intent(user_query): + print(f"[confirm_info] 用户确认信息足够,准备调用工具") + tools_to_call = ["rag_search"] + if device_name and fault: + tools_to_call.append("graph_rag_search") + return Command( + update={"user_confirmed_info": True,"current_node": "call_tools","tools_to_call": tools_to_call,}, + goto="call_tools" + ) + else: + print(f"[confirm_info] 用户需要补充信息: {user_query}") + return Command( + update={"user_confirmed_info": False,"combined_query": user_query,"initialized": False,"current_node": "extract_info",}, + goto="extract_info" + ) + +async def call_tools(state: FaultDiagnosisState) -> Dict[str, Any]: + """调用检索工具""" + tools_to_call = state.get("tools_to_call", []) + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + fault = state.get("fault", "") or "" + additional_info = state.get("additional_info", "") or "" + + rag_results = state.get("rag_search_result") + graph_results = state.get("graph_search_result") + atlas_results = {} + + matched_kb_name = "" + matched_kb_id = "" + mapped_ship_number = "" + + async def _do_rag_search(): + from utils.ship_number_search import search_with_ship_number_strategy + base_query = f"{device_name}中{fault}该怎么修复" + results, kb_name, kb_id, mapped_num = await search_with_ship_number_strategy( + base_query=base_query, ship_number=ship_number, top_k=5, search_type="fault" + ) + if not kb_name: + kb_name = "通用知识库" + print(f"RAG 检索来源: {kb_name}") + return results, kb_name, kb_id, mapped_num + + async def _do_graph_search(): + if not (device_name and fault): + return "" + try: + graph_query = f"{device_name}中{fault}该怎么修复" + results = await graph_rag_search.ainvoke({"query": graph_query}) + if len(results) < 60: + graph_query = f"{fault}该怎么修复" + results = await graph_rag_search.ainvoke({"query": graph_query}) + print(f"图谱检索完成,结果长度: {len(results) if results else 0}") + return results + except Exception as e: + print(f"图谱检索失败: {str(e)}") + return "" + + need_rag = "rag_search" in tools_to_call and rag_results is None + need_graph = "graph_rag_search" in tools_to_call and graph_results is None + + if need_rag and need_graph: + (rag_results, matched_kb_name, matched_kb_id, mapped_ship_number), graph_results = \ + await asyncio.gather(_do_rag_search(), _do_graph_search()) + elif need_rag: + rag_results, matched_kb_name, matched_kb_id, mapped_ship_number = await _do_rag_search() + elif need_graph: + graph_results = await _do_graph_search() + + if mapped_ship_number and mapped_ship_number != ship_number: + print(f"[call_tools] 舷号映射: '{ship_number}' -> '{mapped_ship_number}',更新工作流状态") + ship_number = mapped_ship_number + + # 图册检索:只传入设备名称 + if device_name: + try: + print(f"[图册检索] 使用设备名称: {device_name}") + atlas_result = await atlas_retrieval(node_names=[device_name], top_k=10) + if atlas_result.get("success", False): + atlas_results = atlas_result.get("data", {}) + print(f"[图册检索] 图册检索结果: {list(atlas_results.keys())}") + except Exception as e: + print(f"[图册检索] 图册检索失败: {str(e)}") + + has_rag = bool(rag_results and len(rag_results) > 0) + has_graph = bool(graph_results and len(graph_results) > 100) + + if not has_rag and not has_graph: + print(f"[call_tools] 未找到任何相关资料,需要用户确认使用模型能力") + return {"rag_search_result": rag_results if isinstance(rag_results, list) else [],"graph_search_result": graph_results if isinstance(graph_results, str) else "","atlas_results": atlas_results,"matched_kb_id": matched_kb_id,"matched_kb_name": matched_kb_name,"ship_number": ship_number,"has_rag_result": False,"has_graph_result": False,"need_model_confirm": True,"current_node": "model_confirm",} + + return {"rag_search_result": rag_results,"graph_search_result": graph_results,"atlas_results": atlas_results,"matched_kb_id": matched_kb_id,"matched_kb_name": matched_kb_name,"ship_number": ship_number,"has_rag_result": has_rag,"has_graph_result": has_graph,"need_model_confirm": False,"current_node": "generate_response",} + +async def model_confirm(state: FaultDiagnosisState) -> Command: + """ + 模型能力确认节点:当没有找到任何资料时,让用户确认是否使用模型本身能力 + """ + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + fault = state.get("fault", "") or "" + + response = f"""**⚠️ 未找到相关资料** + +抱歉,在知识库中未找到与以下信息相关的资料: +- 舷号:{ship_number or '未提供'} +- 设备名称:{device_name or '未提供'} +- 故障现象:{fault or '未提供'} + +**💡 您可以选择:** +- 使用 AI 模型的知识库为您生成一般性建议 +- 提供更详细的信息重新检索 + +请问是否使用 AI 模型为您生成建议?""" + + emit_callback_event(["🤖 AI 模型确认", "⚠️ 资料未找到", "💡 使用模型能力"], "使用模型自身知识进行回复") + + suggested_replies = [{"title": "使用 AI 模型", "content": "确认,使用 AI 模型生成建议"},{"title": "补充信息", "content": "我需要补充更多信息:"},] + + user_input = interrupt({"response": response,"suggestedReplies": suggested_replies,"waiting_for": "model_confirm",}) + + user_query = user_input.get("query", "") + + if "确认" in user_query or "AI" in user_query or "模型" in user_query: + print(f"[model_confirm] 用户确认使用模型能力") + return Command( + update={"user_confirmed_model": True,"combined_query": user_query,"current_node": "generate_response",}, + goto="generate_response" + ) + else: + print(f"[model_confirm] 用户选择补充信息: {user_query}") + return Command( + update={"user_confirmed_model": False,"combined_query": user_query,"current_node": "agent_think",}, + goto="agent_think" + ) + +async def generate_response(state: FaultDiagnosisState) -> Dict[str, Any]: + """生成最终回复""" + print("========================生成最终回复========================") + combined_query = state.get("combined_query", "") or "" + history_messages = state.get("history_messages", []) or [] + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + fault = state.get("fault", "") or "" + additional_info = state.get("additional_info", "") or "" + fault_code = state.get("fault_code", "") or "" + fault_time = state.get("fault_time", "") or "" + fault_frequency = state.get("fault_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + rag_results = state.get("rag_search_result") or [] + graph_results = state.get("graph_search_result") or "" + scheme_generated = state.get("scheme_generated", False) + matched_kb_name = state.get("matched_kb_name", "") + + print("rag_results", rag_results) + history_str = build_history_str(history_messages, recent_count=5, assistant_truncate=500) + + has_rag = rag_results and len(rag_results) > 0 + has_graph = graph_results and len(graph_results) > 100 + + if not has_rag and not has_graph: + if not ship_number and not device_name and not fault: + system_prompt = COMMON_PROMPTS["generate_response_friendly_system"].format(biz_label="故障诊断") + prompt = COMMON_PROMPTS["generate_response_friendly_user"].format(combined_query=combined_query) + else: + system_prompt = COMMON_PROMPTS["generate_response_no_rag_system"].format(biz_label="故障诊断") + prompt = FAULT_DIAGNOSIS_PROMPTS["generate_response_no_rag_user"].format( + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + fault=fault or '未提供', + fault_code=fault_code or '未提供', + fault_time=fault_time or '未提供', + fault_frequency=fault_frequency or '未提供', + tried_measures=tried_measures or '未提供', + running_condition=running_condition or '未提供', + additional_info=additional_info or '无' + ) + + try: + response = await OpenaiAPI.open_api_chat_without_thinking(query=prompt,model=None,system_prompt=system_prompt,messages=[]) + return {"response": response.strip() if response else "抱歉,未能找到相关资料。","suggestedReplies": [{"title": "提供更详细信息", "content": "我来提供更详细的信息"},],"current_node": "end",} + except Exception as e: + return {"response": f"抱歉,处理过程中出现错误: {str(e)}","suggestedReplies": [],"current_node": "end",} + + rag_ctx_parts: List[str] = [] + if isinstance(rag_results, str) and rag_results.strip(): + rag_ctx_parts = [rag_results.strip()] + elif isinstance(rag_results, list): + for item in sorted(rag_results, key=lambda x: float(x.get("score") or 0) if isinstance(x, dict) else 0, reverse=True)[:2]: + # for item in rag_results[:1]: + if isinstance(item, dict): + text = (item.get("text") or "").strip() + if text: + rag_ctx_parts.append(text) + elif isinstance(item, str) and item.strip(): + rag_ctx_parts.append(item.strip()) + + if len(graph_results) <= 50: + graph_results = "" + + all_source_text = graph_results + "\n" + "\n".join(rag_ctx_parts) if graph_results else "\n".join(rag_ctx_parts) + original_image_paths = extract_image_paths(all_source_text) + print("提取到的原始图片路径:", original_image_paths) + + rag_answer = "" + rag_type = "none" + + if graph_results and len(graph_results) > 50: + rag_answer = graph_results + rag_type = "graph" + elif rag_ctx_parts: + rag_answer = "\n\n".join(rag_ctx_parts[:2]) + rag_type = "rag" + + if not rag_answer or rag_answer == "NO_RELEVANT_RESULT": + system_prompt = COMMON_PROMPTS["generate_response_no_rag_simple_system"].format(biz_label="故障诊断") + prompt = FAULT_DIAGNOSIS_PROMPTS["generate_response_no_rag_simple_user"].format( + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + fault=fault or '未提供' + ) + + try: + response = await OpenaiAPI.open_api_chat_without_thinking(query=prompt,model=None,system_prompt=system_prompt,messages=[]) + return {"response": response.strip() if response else "抱歉,未能找到相关资料。","suggestedReplies": [],"current_node": "end",} + except Exception as e: + return {"response": f"抱歉,处理过程中出现错误: {str(e)}","suggestedReplies": [],"current_node": "end",} + + rag_type = "图谱" if rag_type == "graph" else "文本" + + emit_callback_event([f"✨当前使用的检索类型为{rag_type}", f"✨本次检索策略为{rag_type}"], f"内容为{rag_answer[:50]}...") + + detail_info = "" + if fault_code: + detail_info += f"- 故障码/报警代码:{fault_code}\n" + if fault_time: + detail_info += f"- 故障发生时间:{fault_time}\n" + if fault_frequency: + detail_info += f"- 故障频率/持续时间:{fault_frequency}\n" + if tried_measures: + detail_info += f"- 已尝试的维修措施:{tried_measures}\n" + if running_condition: + detail_info += f"- 设备运行工况:{running_condition}\n" + if additional_info: + detail_info += f"- 其他相关信息:{additional_info}\n" + + is_regenerating = state.get("is_regenerating", False) + user_feedback_for_regenerate = state.get("user_feedback_for_regenerate", "") + is_follow_up = scheme_generated and (has_rag or has_graph) and not is_regenerating + + image_guidance = get_image_prompt_guidance() + if is_regenerating and user_feedback_for_regenerate: + prompt = COMMON_PROMPTS["generate_response_regenerating"].format( + biz_label="维修", + item_label="故障现象", + item_name=fault or '未提供', + item_action="分析故障原因,给出维修方案", + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + fault=fault or '未提供', + detail_info=detail_info, + user_feedback_for_regenerate=user_feedback_for_regenerate, + rag_answer=rag_answer, + image_guidance=image_guidance + ) + elif is_follow_up: + prompt = COMMON_PROMPTS["generate_response_follow_up"].format( + biz_label="维修", + item_label="故障现象", + item_name=fault or '未提供', + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + fault=fault or '未提供', + detail_info=detail_info, + rag_answer=rag_answer, + combined_query=combined_query, + image_guidance=image_guidance + ) + else: + prompt = COMMON_PROMPTS["generate_response_normal"].format( + biz_label="维修", + item_label="故障现象", + item_name=fault or '未提供', + item_action="分析故障原因,给出维修方案", + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + fault=fault or '未提供', + detail_info=detail_info, + rag_answer=rag_answer + ) + + try: + # ========== 第一步:专注于生成高质量内容 ========== + content_system_prompt = COMMON_PROMPTS["generate_response_content_system"].format(biz_label="维修") + + raw_content = await OpenaiAPI.open_api_chat_without_thinking(model=None,system_prompt=content_system_prompt,messages=[{"role": "user", "content": prompt}],) + + raw_content = raw_content.strip() if raw_content else "抱歉,无法生成回答。" + + emit_callback_event(["🤖 诊断分析中", "🤖 故障预检中"], raw_content[:30] + "...") + + answer = await stream_format_and_postprocess(raw_content, original_image_paths, temperature=0.3) + + scheme_source = f"本方案依据{rag_type}知识库生成。" + answer = f"{scheme_source}\n\n{answer}" + + atlas_results = state.get("atlas_results", {}) + answer = append_atlas_section(answer, atlas_results) + + if not is_follow_up: + supplement_prompt = """ + +--- + +**💡 请您确认维修方案是否正确:** +- 如果方案有误或需要调整,请告诉我具体的错误之处 +- 我会根据您的反馈重新生成更准确的方案 + +请问方案是否有需要修改的地方?""" + answer += supplement_prompt + + suggested_replies = [{"title": "方案有误", "content": "方案有误,需要修改:"},] + + # 生成报告并添加下载按钮 + actions = _generate_report_from_scheme(scheme_content=answer,ship_number=ship_number,device_name=device_name,fault=fault) + + if not is_follow_up: + try: + await record_fault_from_state({"ship_number": ship_number,"device_name": device_name,"fault": fault,"last_generated_scheme": answer,}) + except Exception as rec_err: + print(f"[generate_response] 记录故障信息失败: {rec_err}") + + return {"response": answer,"actions": actions,"suggestedReplies": suggested_replies,"scheme_generated": True,"waiting_feedback": not is_follow_up,"last_generated_scheme": answer,"scheme_type": "normal","current_node": "end","last_combined_query": combined_query,} + + except Exception as e: + return {"response": f"分析生成失败: {str(e)}","actions": [],"suggestedReplies": [],"current_node": "end",} + +async def analyze_follow_up_intent(state: FaultDiagnosisState) -> Dict[str, Any]: + """ + 分析用户后续意图节点:直接使用 agent_think 已经判断好的意图 + agent_think 已经用大模型判断并设置了 follow_up_intent + """ + # 直接使用已经判断好的意图 + intent = state.get("follow_up_intent", "other") + + print(f"[意图分析] 使用已判断的意图: {intent}") + + return {"follow_up_intent": intent,} + +async def deep_rag_search(state: FaultDiagnosisState) -> Dict[str, Any]: + """ + 深层次RAG检索节点:分析之前方案并选择深度分析点进行检索 + """ + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + fault = state.get("fault", "") or "" + fault_code = state.get("fault_code", "") or "" + additional_info = state.get("additional_info", "") or "" + fault_time = state.get("fault_time", "") or "" + fault_frequency = state.get("fault_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + combined_query = state.get("combined_query", "") or "" + history_messages = state.get("history_messages", []) or [] + last_generated_scheme = state.get("last_generated_scheme", "") or "" + + deep_results = {} + deep_analysis_points = [] + + emit_callback_event(["🔍 深度检索中", "🔍 多维度检索", "🔍 精细分析"], "分析问题并选择深度分析点") + + # ========== 第一步:分析之前方案和用户问题,选择深度分析点 ========== + if last_generated_scheme or combined_query: + try: + history_str = build_history_str(history_messages, recent_count=5, assistant_truncate=300) + + detail_info = "" + if fault_code: + detail_info += f"- 故障码/报警代码:{fault_code}\n" + if fault_time: + detail_info += f"- 故障发生时间:{fault_time}\n" + if fault_frequency: + detail_info += f"- 故障频率/持续时间:{fault_frequency}\n" + if tried_measures: + detail_info += f"- 已尝试的维修措施:{tried_measures}\n" + if running_condition: + detail_info += f"- 设备运行工况:{running_condition}\n" + if additional_info: + detail_info += f"- 其他相关信息:{additional_info}\n" + + analysis_system_prompt = COMMON_PROMPTS["deep_rag_analysis_system"].format(biz_label="维修") + + analysis_prompt = COMMON_PROMPTS["deep_rag_analysis_user"].format( + item_label="故障现象", + item_name=fault or '未提供', + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + fault=fault or '未提供', + detail_info=detail_info, + history_str=history_str or '(无历史对话)', + last_generated_scheme=last_generated_scheme or '(无之前的方案)', + combined_query=combined_query + ) + + analysis_response = await OpenaiAPI.open_api_chat_without_thinking(query=analysis_prompt,model=None,json_output=True,system_prompt=analysis_system_prompt,messages=[]) + print("深度分析点", analysis_response) + parsed_analysis = safe_json_extract(analysis_response) + deep_analysis_points = parsed_analysis + print(f"[深度RAG] 选择的分析点: {deep_analysis_points}") + except Exception as e: + print(f"[深度RAG] 分析失败: {str(e)}") + + # ========== 第二步:对选中的分析点进行检索 ========== + for idx, point in enumerate(deep_analysis_points): + try: + print(point) + # 结合设备名和故障名进行检索,提高准确性 + search_query = point + if device_name and device_name not in point: + search_query = f"{device_name} {search_query}" + if fault and fault not in search_query: + search_query = f"{search_query} {fault}" + + rag_result = await rag_search(search_query, top_k=3) + if rag_result.get("success", False): + deep_results[f"analysis_point_{idx}"] = convert_rag_result(rag_result) + else: + deep_results[f"analysis_point_{idx}"] = [] + except Exception as e: + print(f"[深度RAG] 分析点 {point} 检索失败: {str(e)}") + deep_results[f"analysis_point_{idx}"] = [] + + # ========== 第三步:图册检索 ========== + atlas_results = {} + try: + # 从历史记录和当前信息中抽取实体名称 + entity_names = await extract_entity_names_from_history(history_messages=history_messages,device_name=device_name,fault=fault,last_generated_scheme=last_generated_scheme) + + if entity_names: + print(f"[深度RAG] 图册检索实体: {entity_names}") + atlas_result = await atlas_retrieval(node_names=entity_names, top_k=10) + if atlas_result.get("success", False): + atlas_results = atlas_result.get("data", {}) + print(f"[深度RAG] 图册检索结果: {list(atlas_results.keys())}") + except Exception as e: + print(f"[深度RAG] 图册检索失败: {str(e)}") + + return {"deep_rag_results": deep_results,"deep_analysis_points": deep_analysis_points,"atlas_results": atlas_results,"current_node": "generate_deep_response",} + +async def generate_deep_response(state: FaultDiagnosisState) -> Dict[str, Any]: + """ + 生成深度响应节点:基于深度RAG结果进行深度润色和推理 + """ + combined_query = state.get("combined_query", "") or "" + history_messages = state.get("history_messages", []) or [] + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + fault = state.get("fault", "") or "" + fault_code = state.get("fault_code", "") or "" + fault_time = state.get("fault_time", "") or "" + fault_frequency = state.get("fault_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + additional_info = state.get("additional_info", "") or "" + + deep_rag_results = state.get("deep_rag_results", {}) + deep_analysis_points = state.get("deep_analysis_points", []) + atlas_results = state.get("atlas_results", {}) + graph_results = state.get("graph_search_result", "") or "" + original_rag_results = state.get("rag_search_result", []) + + # 整理深度分析点资料 + analysis_points_text = "" + if deep_analysis_points: + for idx, point in enumerate(deep_analysis_points): + key = f"analysis_point_{idx}" + results = deep_rag_results.get(key, []) + if results: + analysis_points_text += f"\n【分析点:{point}】\n" + for i, item in enumerate(results[:2], 1): + if isinstance(item, dict): + text = item.get("text", "") + if text: + analysis_points_text += f"[{i}] {text}\n" + + # 整理原始RAG结果 + original_rag_text = "" + if original_rag_results: + original_rag_text = "\n【原始检索资料】\n" + for i, item in enumerate(original_rag_results[:3], 1): + if isinstance(item, dict): + text = item.get("text", "") + if text: + original_rag_text += f"[{i}] {text}\n" + + # 整理图册资料 + atlas_text = format_atlas_results(atlas_results) + + # 提取所有资料中的图片路径 + all_source_text = analysis_points_text + original_rag_text + atlas_text + graph_results + original_image_paths = extract_image_paths(all_source_text) + + detail_info = "" + if fault_code: + detail_info += f"- 故障码/报警代码:{fault_code}\n" + if fault_time: + detail_info += f"- 故障发生时间:{fault_time}\n" + if fault_frequency: + detail_info += f"- 故障频率/持续时间:{fault_frequency}\n" + if tried_measures: + detail_info += f"- 已尝试的维修措施:{tried_measures}\n" + if running_condition: + detail_info += f"- 设备运行工况:{running_condition}\n" + if additional_info: + detail_info += f"- 其他相关信息:{additional_info}\n" + + # 构建深度分析点的提示 + analysis_points_prompt = "" + if deep_analysis_points: + analysis_points_prompt = f"\n【本次深度分析重点】\n" + "\n".join([f"- {p}" for p in deep_analysis_points]) + "\n" + + prompt = COMMON_PROMPTS["generate_deep_response"].format( + biz_label="维修", + item_label="故障现象", + item_name=fault or '未提供', + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + fault=fault or '未提供', + detail_info=detail_info, + combined_query=combined_query, + analysis_points_prompt=analysis_points_prompt, + analysis_points_text=analysis_points_text, + original_rag_text=original_rag_text, + graph_results=graph_results if len(graph_results) > 50 else '(无)' + ) + + try: + # ========== 第一步:专注于生成高质量内容 ========== + content_system_prompt = COMMON_PROMPTS["generate_deep_response_content_system"].format(biz_label="维修") + + raw_content = await OpenaiAPI.open_api_chat_without_thinking(model=None,system_prompt=content_system_prompt,messages=[{"role": "user", "content": prompt}],) + + raw_content = raw_content.strip() if raw_content else "抱歉,无法生成深度分析。" + + emit_callback_event(["🤖 诊断分析中", "🤖 故障预检中"], raw_content[:30] + "...") + + answer = await stream_format_and_postprocess(raw_content, original_image_paths, temperature=0.1) + + answer = append_atlas_section(answer, atlas_results) + + suggested_replies = [{"title": "方案有误", "content": "方案还是有问题:"},] + + actions = _generate_report_from_scheme(scheme_content=answer,ship_number=ship_number,device_name=device_name,fault=fault) + + try: + await record_fault_from_state({"ship_number": ship_number,"device_name": device_name,"fault": fault,"last_generated_scheme": answer,}) + except Exception as rec_err: + print(f"[generate_deep_response] 记录故障信息失败: {rec_err}") + + return {"response": answer,"actions": actions,"suggestedReplies": suggested_replies,"scheme_generated": True,"last_generated_scheme": answer,"scheme_type": "deep","current_node": "end","last_combined_query": combined_query,} + + except Exception as e: + return {"response": f"深度分析生成失败: {str(e)}","actions": [],"suggestedReplies": [],"current_node": "end",} + +async def regenerate_scheme_from_feedback(state: FaultDiagnosisState) -> Dict[str, Any]: + """ + 基于用户反馈直接修改方案节点:使用之前保存的方案和用户反馈进行修改 + """ + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + fault = state.get("fault", "") or "" + additional_info = state.get("additional_info", "") or "" + fault_code = state.get("fault_code", "") or "" + fault_time = state.get("fault_time", "") or "" + fault_frequency = state.get("fault_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + + user_feedback = state.get("user_feedback_for_regenerate", "") or "" + last_scheme = state.get("last_generated_scheme", "") or "" + scheme_type = state.get("scheme_type", "normal") + + if not last_scheme: + # 如果没有保存的方案,回退到重新检索 + print("[regenerate_scheme_from_feedback] 没有保存的方案,回退到重新检索") + return {"is_regenerating": True,"rag_search_result": None,"graph_search_result": None,"has_rag_result": False,"has_graph_result": False,"scheme_generated": False,"current_node": "call_tools","tools_to_call": ["rag_search", "graph_rag_search"],} + + detail_info = "" + if fault_code: + detail_info += f"- 故障码/报警代码:{fault_code}\n" + if fault_time: + detail_info += f"- 故障发生时间:{fault_time}\n" + if fault_frequency: + detail_info += f"- 故障频率/持续时间:{fault_frequency}\n" + if tried_measures: + detail_info += f"- 已尝试的维修措施:{tried_measures}\n" + if running_condition: + detail_info += f"- 设备运行工况:{running_condition}\n" + if additional_info: + detail_info += f"- 其他相关信息:{additional_info}\n" + + emit_callback_event(["✏️ 修改方案中", "🔧 调整方案", "📝 优化方案"], f"根据反馈修改方案: {user_feedback[:30]}...") + + image_guidance = get_image_prompt_guidance() + + prompt = COMMON_PROMPTS["regenerate_scheme_from_feedback"].format( + biz_label="维修", + item_label="故障现象", + item_name=fault or '未提供', + item_action="分析故障原因,给出维修方案", + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + fault=fault or '未提供', + detail_info=detail_info, + last_scheme=last_scheme, + user_feedback=user_feedback, + image_guidance=image_guidance + ) + + try: + # ========== 第一步:专注于生成高质量内容 ========== + content_system_prompt = COMMON_PROMPTS["regenerate_scheme_from_feedback_system"].format(biz_label="维修") + + # 构建第一步的内容提示词(简化格式要求) + content_prompt = prompt.replace("【输出要求】", "【输出要求】\n- 请专注于内容的准确性、完整性和逻辑性,不要担心格式的美观性\n- 可以适当使用简单的段落分隔,但不要使用复杂的格式") + + raw_content = await OpenaiAPI.open_api_chat_without_thinking(model=None,system_prompt=content_system_prompt,messages=[{"role": "user", "content": content_prompt}],) + + raw_content = raw_content.strip() if raw_content else "抱歉,无法根据反馈修改方案。" + + original_image_paths = extract_image_paths(last_scheme) + answer = await stream_format_and_postprocess(raw_content, original_image_paths, temperature=0.3, content_label="维修方案") + + suggested_replies = [{"title": "方案有误", "content": "方案还是有问题:"},] + + actions = _generate_report_from_scheme(scheme_content=answer,ship_number=ship_number,device_name=device_name,fault=fault) + + try: + await record_fault_from_state({"ship_number": ship_number,"device_name": device_name,"fault": fault,"last_generated_scheme": answer,}) + except Exception as rec_err: + print(f"[regenerate_scheme_from_feedback] 记录故障信息失败: {rec_err}") + + return {"response": answer,"actions": actions,"suggestedReplies": suggested_replies,"scheme_generated": True,"last_generated_scheme": answer,"scheme_type": scheme_type,"current_node": "end","last_combined_query": combined_query,} + + except Exception as e: + print(f"[regenerate_scheme_from_feedback] 修改方案失败: {str(e)}") + # 如果修改失败,回退到重新检索 + return {"is_regenerating": True,"rag_search_result": None,"graph_search_result": None,"has_rag_result": False,"has_graph_result": False,"scheme_generated": False,"current_node": "call_tools","tools_to_call": ["rag_search", "graph_rag_search"],} + +async def follow_up_qa(state: FaultDiagnosisState) -> Dict[str, Any]: + """ + 后续问答节点:调用 baike 智能体处理用户的后续问题 + """ + combined_query = state.get("combined_query", "") or "" + history_messages = state.get("history_messages", []) or [] + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + fault = state.get("fault", "") or "" + + history_message_json = json.dumps(history_messages, ensure_ascii=False) if history_messages else "" + + context_prefix = "" + if ship_number or device_name or fault: + context_parts = [] + if ship_number: + context_parts.append(f"舷号: {ship_number}") + if device_name: + context_parts.append(f"设备名称: {device_name}") + if fault: + context_parts.append(f"故障现象: {fault}") + context_prefix = f"【当前故障诊断上下文】\n{'; '.join(context_parts)}\n\n" + + enhanced_query = f"{context_prefix}用户后续问题: {combined_query}" + + emit_callback_event(["💬 后续问答", "💬 执行指导", "💬 问题解答"], f"调用 百科 智能体: {combined_query[:30]}...") + + try: + baike_result = await run_baike_workflow(extracted_text=enhanced_query,combined_query=enhanced_query,history_message=history_message_json,route_flag="baike") + + response = baike_result.get("response", "") + suggested_replies = baike_result.get("suggestedReplies", []) + + return {"response": response,"suggestedReplies": suggested_replies,"actions": [],"scheme_generated": True,"current_node": "end",} + + except Exception as e: + return {"response": f"处理您的问题时出现错误: {str(e)}","suggestedReplies": [],"actions": [],"current_node": "end",} + +async def generate_report(state: FaultDiagnosisState) -> Dict[str, Any]: + """报告生成节点""" + from workflows.workflow_other_report import run_other_report_workflow + + combined_query = state.get("combined_query", "") or "" + history_message = state.get("history_messages", []) + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + fault = state.get("fault", "") or "" + + history_message_json = json.dumps(history_message, ensure_ascii=False) if history_message else "" + + try: + report_result = await run_other_report_workflow(extracted_text=combined_query,combined_query=combined_query,history_message=history_message_json,route_flag="report") + + return {"response": report_result.get("response", ""),"actions": report_result.get("actions", []),"suggestedReplies": report_result.get("suggestedReplies", []),"scheme_generated": True,"current_node": "end",} + except Exception as e: + return {"response": f"报告生成失败: {str(e)}","actions": [],"suggestedReplies": [],"current_node": "end",} + +async def handle_feedback(state: FaultDiagnosisState) -> Command: + """ + 处理用户反馈节点:接收用户对方案的反馈,提供上报和重新生成方案按钮 + """ + combined_query = state.get("combined_query", "") or "" + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + fault = state.get("fault", "") or "" + + response = f"""**📝 已收到您的反馈** + +感谢您对维修方案的建议!您的反馈已记录: + +{combined_query} + +--- + +**📋 反馈信息摘要:** +- 舷号:{ship_number or '未提供'} +- 设备名称:{device_name or '未提供'} +- 故障现象:{fault or '未提供'} +- 反馈内容:{combined_query[:100]}{'...' if len(combined_query) > 100 else ''} + +--- + +**💡 您可以选择:** +- 点击「重新生成方案」根据您的反馈重新生成方案 +- 点击「上报」将反馈提交给相关部门 +- 继续对话获取更多帮助""" + + actions = [ + {"type": "content","label": "重新生成方案","text": "重新生成方案"}, + {"type": "content","label": "上报","text": "上报"} + ] + + suggested_replies = [] + + emit_callback_event(["📝 反馈处理", "📋 方案反馈", "✅ 反馈确认"], f"处理用户反馈: {combined_query[:30]}...") + + user_input = interrupt({"response": response,"actions": actions,"suggestedReplies": suggested_replies,"waiting_for": "feedback_confirm",}) + + user_text = user_input.get("query", "") or "" + + if user_text == "重新生成方案": + print(f"[handle_feedback] 用户选择重新生成方案") + return Command( + update={"user_feedback_for_regenerate": combined_query,"current_node": "regenerate_scheme_from_feedback",}, + goto="regenerate_scheme_from_feedback" + ) + elif user_text == "上报": + print(f"[handle_feedback] 用户选择上报反馈") + return Command( + update={"user_feedback": combined_query,"waiting_report_confirm": True,"current_node": "confirm_report",}, + goto="confirm_report" + ) + else: + print(f"[handle_feedback] 用户继续对话: {user_text}") + return Command( + update={"user_feedback": combined_query,"combined_query": user_text,"current_node": "agent_think",}, + goto="agent_think" + ) + +async def confirm_report(state: FaultDiagnosisState) -> Dict[str, Any]: + """ + 上报确认节点:用户点击上报后确认完成 + """ + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + fault = state.get("fault", "") or "" + user_feedback = state.get("user_feedback", "") or "" + + response = f"""**✅ 上报成功** + +您的反馈已成功上报! + +--- + +**📋 上报信息:** +- 舷号:{ship_number or '未提供'} +- 设备名称:{device_name or '未提供'} +- 故障现象:{fault or '未提供'} +- 反馈内容:{user_feedback[:100]}{'...' if len(user_feedback) > 100 else ''} + +--- + +**💡 感谢您的反馈,相关部门将及时处理。**""" + + suggested_replies = [{"title": "生成维修报告", "content": "请帮我生成维修报告"},] + + emit_callback_event(["✅ 上报成功", "📋 反馈已提交", "🎉 完成"], "反馈上报成功") + + return {"response": response,"actions": [],"suggestedReplies": suggested_replies,"report_confirmed": True,"waiting_report_confirm": False,"current_node": "end",} + +def _route_after_think(state: FaultDiagnosisState) -> str: + """Agent 思考后的路由""" + decision = state.get("agent_decision", "ask_user") + + return decision + +def _route_after_intent(state: FaultDiagnosisState) -> str: + """意图分析后的路由""" + intent = state.get("follow_up_intent", "other") + + if intent == "not_solved": + return "deep_rag_search" + elif intent == "related_question": + return "follow_up_qa" + elif intent == "has_error": + return "handle_feedback" + elif intent == "modify_info": + # 修改信息,重置相关状态,重新开始信息提取 + return "extract_info" + else: + return "follow_up_qa" + +def _route_after_tools(state: FaultDiagnosisState) -> str: + """检索工具调用后的路由""" + need_model_confirm = state.get("need_model_confirm", False) + if need_model_confirm: + return "model_confirm" + return "generate_response" + +def create_fault_diagnosis_workflow(checkpointer=None): + """ + 创建故障诊断工作流(基于 Checkpointer 的状态持久化版本) + """ + workflow = StateGraph(FaultDiagnosisState) + + workflow.add_node("classify_intent", classify_intent) + workflow.add_node("extract_info", extract_info) + workflow.add_node("agent_think", agent_think) + workflow.add_node("ask_user", ask_user) + workflow.add_node("confirm_info", confirm_info) + workflow.add_node("call_tools", call_tools) + workflow.add_node("generate_response", generate_response) + workflow.add_node("follow_up_qa", follow_up_qa) + workflow.add_node("model_confirm", model_confirm) + workflow.add_node("generate_report", generate_report) + workflow.add_node("handle_feedback", handle_feedback) + workflow.add_node("confirm_report", confirm_report) + workflow.add_node("analyze_follow_up_intent", analyze_follow_up_intent) + workflow.add_node("deep_rag_search", deep_rag_search) + workflow.add_node("generate_deep_response", generate_deep_response) + workflow.add_node("regenerate_scheme_from_feedback", regenerate_scheme_from_feedback) + + workflow.add_edge(START, "classify_intent") + workflow.add_conditional_edges("classify_intent", _route_after_classify, + {"extract_info": "extract_info","follow_up_qa": "follow_up_qa",}) + workflow.add_edge("extract_info", "agent_think") + workflow.add_conditional_edges("agent_think", _route_after_think, + {"ask_user": "ask_user","confirm_info": "confirm_info","call_tools": "call_tools","generate_response": "generate_response","follow_up": "follow_up_qa","follow_up_qa": "follow_up_qa","generate_report": "generate_report","handle_feedback": "handle_feedback","extract_info": "extract_info","analyze_follow_up_intent": "analyze_follow_up_intent",}) + workflow.add_conditional_edges("call_tools", _route_after_tools, {"model_confirm": "model_confirm","generate_response": "generate_response",}) + workflow.add_conditional_edges("analyze_follow_up_intent", _route_after_intent, {"deep_rag_search": "deep_rag_search","follow_up_qa": "follow_up_qa","handle_feedback": "handle_feedback","extract_info": "extract_info",}) + workflow.add_edge("deep_rag_search", "generate_deep_response") + workflow.add_edge("generate_deep_response", END) + workflow.add_edge("generate_response", END) + workflow.add_edge("follow_up_qa", END) + workflow.add_edge("generate_report", END) + workflow.add_edge("regenerate_scheme_from_feedback", END) + workflow.add_edge("handle_feedback", END) + workflow.add_edge("confirm_report", END) + + return workflow.compile(checkpointer=checkpointer) + +async def run_fault_diagnosis_workflow(extracted_text: str,combined_query: str,history_message: str = "",route_flag: str = "",previous_state_json: str = "",chat_id: str = "",message_id: str = "",checkpointer=None,user_response: Dict[str, Any] = None) -> Dict[str, Any]: + """ + 执行故障诊断工作流 + + Args: + extracted_text: 文本描述 + combined_query: 组合查询 + history_message: 历史消息(JSON格式) + route_flag: 路由标识 + previous_state_json: 兼容参数(已废弃) + chat_id: 聊天会话 ID + message_id: 消息 ID + checkpointer: LangGraph checkpointer 实例 + user_response: 用户恢复执行的响应数据 + + Returns: + 工作流执行结果 + """ + thread_id = chat_id if chat_id else None + + config = {"configurable": {"thread_id": thread_id}} if thread_id else {} + + print(f"[DEBUG] run_fault_diagnosis_workflow: thread_id={thread_id}, checkpointer={checkpointer is not None}") + + if checkpointer is None: + from checkpointer_config import checkpointer_manager + checkpointer = await checkpointer_manager.get_async_checkpointer() + + app = create_fault_diagnosis_workflow(checkpointer=checkpointer) + + if thread_id: + try: + current_state = await app.aget_state(config) + print( + f"[DEBUG] current_state exists: {current_state is not None}, has values: {current_state.values is not None if current_state else False}, tasks: {len(current_state.tasks) if current_state else 0}") + + if current_state and current_state.values: + saved_state = current_state.values + print( + f"[Checkpointer] 发现已保存状态: ship_number={saved_state.get('ship_number')}, device_name={saved_state.get('device_name')}, fault={saved_state.get('fault')}") + + if current_state.tasks: + print(f"[Checkpointer] 恢复执行,注入用户响应") + resume_value = user_response if user_response else {"query": combined_query} + result = await app.ainvoke(Command(resume=resume_value), config) + else: + # 只使用当前的输入,不拼接之前的! + merged_query = combined_query + + # 如果当前输入是确认意图,且已有完整信息,直接视为用户已确认 + _is_confirm = await is_confirmation_intent(combined_query) + _has_full_info = (saved_state.get("ship_number") and saved_state.get("device_name") and saved_state.get("fault")) + _override_confirmed = _is_confirm and _has_full_info and not saved_state.get("user_confirmed_info", False) + + initial_state = {**saved_state,"combined_query": merged_query,"history_messages": parse_history(history_message),"initialized": False,"user_intent": "",} + if _override_confirmed: + print(f"[Checkpointer] 检测到确认意图,自动设置 user_confirmed_info=True") + initial_state["user_confirmed_info"] = True + result = await app.ainvoke(initial_state, config) + else: + initial_state = {"extracted_text": extracted_text,"combined_query": combined_query,"history_messages": parse_history(history_message),"ship_number": "","device_name": "","fault": "","additional_info": "", + "fault_code": "","fault_time": "","fault_frequency": "","tried_measures": "","running_condition": "","rag_search_result": None,"graph_search_result": None,"response": "", + "suggestedReplies": [],"actions": [],"error_message": "","agent_decision": "","agent_reasoning": "","tools_to_call": [],"missing_info": [],"info_completeness": 0.0, + "matched_kb_id": "","matched_kb_name": "","iteration_count": 0,"max_iterations": 5,"scheme_generated": False,"waiting_for_supplement": False,"extracted_info": {}, + "has_rag_result": False,"has_graph_result": False,"is_execution_feedback": False,"execution_detail": "","ask_round": 0,"user_confirmed_info": False,"need_model_confirm": False, + "user_confirmed_model": False,"waiting_feedback": False,"user_feedback": "","waiting_report_confirm": False,"report_confirmed": False,"initialized": False,"current_node": "","user_intent": "","last_combined_query": "",} + result = await app.ainvoke(initial_state, config) + except Exception as e: + print(f"[Checkpointer] 状态恢复失败: {e}") + import traceback + traceback.print_exc() + result = {} + else: + initial_state = {"extracted_text": extracted_text,"combined_query": combined_query,"history_messages": parse_history(history_message),"ship_number": "","device_name": "","fault": "","additional_info": "","fault_code": "","fault_time": "", + "fault_frequency": "","tried_measures": "","running_condition": "","rag_search_result": None,"graph_search_result": None,"response": "","suggestedReplies": [],"actions": [],"error_message": "","agent_decision": "","agent_reasoning": "","tools_to_call": [], + "missing_info": [],"info_completeness": 0.0,"matched_kb_id": "","matched_kb_name": "","iteration_count": 0,"max_iterations": 5,"scheme_generated": False,"waiting_for_supplement": False,"extracted_info": {},"has_rag_result": False,"has_graph_result": False, + "is_execution_feedback": False,"execution_detail": "","ask_round": 0,"user_confirmed_info": False,"need_model_confirm": False,"user_confirmed_model": False,"waiting_feedback": False,"user_feedback": "","waiting_report_confirm": False,"report_confirmed": False,"initialized": False,"current_node": "","user_intent": "","last_combined_query": "",} + + try: + result = await app.ainvoke(initial_state, config) + except Exception as e: + print(f"[工作流执行失败] {str(e)}") + import traceback + traceback.print_exc() + return {"response": f"故障诊断工作流执行失败: {str(e)}","actions": [],"result_tag": "fault_diagnosis","suggestedReplies": [],} + + if result is None: + result = {} + + interrupts = result.get("__interrupt__", []) + if interrupts: + interrupt_data = interrupts[0].value if interrupts else {} + print(f"[Interrupt] 工作流中断,等待用户输入: {interrupt_data}") + return {"response": interrupt_data.get("response", "请提供更多信息"),"actions": interrupt_data.get("actions", []),"result_tag": "fault_diagnosis","suggestedReplies": interrupt_data.get("suggestedReplies", []),"waiting_for": interrupt_data.get("waiting_for", ""),} + + if result.get("error_message"): + return {"response": f"故障诊断工作流执行失败: {result['error_message']}","actions": [],"result_tag": "fault_diagnosis","suggestedReplies": [],} + + route_flag = result.get("route_flag", "") + if not route_flag: + route_flag = "fault_diagnosis" + + return {"response": result.get("response", ""),"actions": result.get("actions", []),"result_tag": route_flag,"suggestedReplies": result.get("suggestedReplies", []),"route_flag": route_flag,} + + diff --git a/workflows/workflow_operate.py b/workflows/workflow_operate.py new file mode 100644 index 0000000..5403a5f --- /dev/null +++ b/workflows/workflow_operate.py @@ -0,0 +1,1470 @@ +""" +操作指导工作流 checkpointer 版本 +""" +from typing import TypedDict, Optional, Dict, Any, List +from langgraph.graph import StateGraph, START, END +from langgraph.types import interrupt, Command +from tools.function_tool import graph_rag_search, operation_graph_rag_search +from tools.rag_tools import rag_search +from tools.graph_tools import atlas_retrieval, format_atlas_results, extract_entity_names_from_history +from modelsAPI.model_api import OpenaiAPI +from utils.function_tracker import get_all_callbacks +from utils.image_utils import extract_image_paths, get_image_prompt_guidance +from utils.intent_matcher import is_confirmation_intent +from workflows.workflow_baike import run_baike_workflow +from workflows.workflow_utils import ( + safe_json_extract, parse_history, normalize_latex_spacing, + remove_duplicate_content, convert_rag_result, calculate_info_completeness, + emit_callback_event, build_history_str, build_detail_info, + append_atlas_section, stream_format_and_postprocess, +) +from prompts import OPERATE_PROMPTS, COMMON_PROMPTS +import asyncio +import json +import re +import random + +class OperateGuideState(TypedDict): + """操作指导工作流状态""" + extracted_text: str + combined_query: str + history_messages: List[Dict[str, Any]] + + ship_number: str + device_name: str + operation_item: str + additional_info: str + operation_code: str + operation_time: str + operation_frequency: str + tried_measures: str + running_condition: str + + rag_search_result: Optional[List[Dict[str, Any]]] + graph_search_result: Optional[str] + deep_rag_results: Optional[Dict[str, Any]] + + response: str + suggestedReplies: List[Dict[str, Any]] + actions: List[Dict[str, Any]] + error_message: str + + agent_decision: str + agent_reasoning: str + tools_to_call: List[str] + missing_info: List[str] + info_completeness: float + + matched_kb_id: str + matched_kb_name: str + + iteration_count: int + max_iterations: int + scheme_generated: bool + waiting_for_supplement: bool + + extracted_info: Dict[str, Any] + has_rag_result: bool + has_graph_result: bool + ask_round: int + user_confirmed_info: bool + + need_model_confirm: bool + user_confirmed_model: bool + + user_feedback: str + waiting_report_confirm: bool + report_confirmed: bool + + initialized: bool + current_node: str + + # 意图分类相关字段 + user_intent: str # 用户初始意图: '操作', '百科' + + # 后续处理相关字段 + follow_up_intent: str # 用户后续意图: 'not_solved', 'related_question', 'has_error', 'modify_info', 'other' + user_feedback_for_regenerate: str # 用户用于重新生成方案的反馈 + is_regenerating: bool # 是否正在重新生成方案 + last_generated_scheme: str # 最后一次生成的方案(用于基于反馈修改) + scheme_type: str # 方案类型:'normal' 普通方案,'deep' 深度方案 + deep_analysis_points: Optional[List[str]] # 深度分析点(用于针对性检索) + atlas_results: Optional[Dict[str, Any]] # 图册检索结果 + last_combined_query: str # 上一轮生成方案时的用户输入 + +OPERATE_INFO_WEIGHTS = {"ship_number": 0.20, "device_name": 0.20, "operation_item": 0.25, "operation_code": 0.08, "operation_time": 0.06, "operation_frequency": 0.06, "tried_measures": 0.06, "running_condition": 0.05, "additional_info": 0.04} + +def _calculate_info_completeness(ship_number: str = "", device_name: str = "", operation_item: str = "", operation_code: str = "", operation_time: str = "", operation_frequency: str = "", tried_measures: str = "", running_condition: str = "", additional_info: str = "") -> float: + values = {"ship_number": ship_number, "device_name": device_name, "operation_item": operation_item, "operation_code": operation_code, "operation_time": operation_time, "operation_frequency": operation_frequency, "tried_measures": tried_measures, "running_condition": running_condition, "additional_info": additional_info} + return calculate_info_completeness(OPERATE_INFO_WEIGHTS, values) + +async def classify_intent(state: OperateGuideState) -> Dict[str, Any]: + """ + 意图分类节点 + """ + combined_query = state.get("combined_query", "") or "" + extracted_info = state.get("extracted_info", {}) or {} + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + + if extracted_info or device_name or operation_item or ship_number: + print("[classify_intent] 已在操作流程中,跳过意图分类") + return {"user_intent": "操作","current_node": "extract_info",} + + system_prompt = OPERATE_PROMPTS["classify_intent_system"] + + prompt = COMMON_PROMPTS["classify_intent_user"].format(combined_query=combined_query) + + try: + response_text = await OpenaiAPI.open_api_chat_without_thinking(query=prompt,model=None,json_output=True,system_prompt=system_prompt,messages=[]) + + parsed = safe_json_extract(response_text) + intent = "操作" + if isinstance(parsed, dict): + intent = parsed.get("intent", "操作") + + print(f"[意图分类] 用户意图: {intent}") + + if intent == "操作": + return {"user_intent": intent,"scheme_generated": False,"current_node": "extract_info",} + else: + return {"user_intent": intent,"current_node": "follow_up_qa",} + + except Exception as e: + print(f"[意图分类失败: {str(e)},默认按操作处理") + return {"user_intent": "操作","scheme_generated": False,"current_node": "extract_info",} + +def _route_after_classify(state: OperateGuideState) -> str: + """意图分类后的路由""" + intent = state.get("user_intent", "操作") + if intent == "百科": + return "follow_up_qa" + return "extract_info" + +async def extract_info(state: OperateGuideState) -> Dict[str, Any]: + """ + 信息提取节点 + """ + if state.get("initialized", False): + print("[extract_info] 已初始化,跳过") + return {"current_node": "agent_think"} + + combined_query = state.get("combined_query", "") or "" + history_messages = state.get("history_messages", []) or [] + + history_str = build_history_str(history_messages, recent_count=10) + + existing_info = [] + existing_ship_number = state.get("ship_number", "") or "" + existing_device_name = state.get("device_name", "") or "" + existing_operation_item = state.get("operation_item", "") or "" + existing_operation_code = state.get("operation_code", "") or "" + existing_operation_time = state.get("operation_time", "") or "" + existing_operation_frequency = state.get("operation_frequency", "") or "" + existing_tried_measures = state.get("tried_measures", "") or "" + existing_running_condition = state.get("running_condition", "") or "" + existing_additional_info = state.get("additional_info", "") or "" + + if existing_ship_number: + existing_info.append(f"舷号: {existing_ship_number}") + if existing_device_name: + existing_info.append(f"设备名称: {existing_device_name}") + if existing_operation_item: + existing_info.append(f"操作项目: {existing_operation_item}") + if existing_operation_code: + existing_info.append(f"操作代码: {existing_operation_code}") + if existing_operation_time: + existing_info.append(f"操作时间: {existing_operation_time}") + if existing_operation_frequency: + existing_info.append(f"操作频率: {existing_operation_frequency}") + if existing_tried_measures: + existing_info.append(f"已尝试措施: {existing_tried_measures}") + if existing_running_condition: + existing_info.append(f"运行工况: {existing_running_condition}") + if existing_additional_info: + existing_info.append(f"其他信息: {existing_additional_info}") + + existing_info_str = "\n".join(existing_info) if existing_info else "(无已提取信息)" + + system_prompt = OPERATE_PROMPTS["extract_info_system"] + + prompt = COMMON_PROMPTS["extract_info_user"].format( + biz_label="操作", + existing_info_str=existing_info_str, + history_str=history_str or '(无历史对话)', + combined_query=combined_query + ) + + try: + response_text = await OpenaiAPI.open_api_chat_without_thinking(query=prompt,model=None,json_output=True,system_prompt=system_prompt,messages=[]) + + parsed = safe_json_extract(response_text) + if not isinstance(parsed, dict): + parsed = {} + + new_ship_number = parsed.get("ship_number", "") or existing_ship_number + new_device_name = parsed.get("device_name", "") or existing_device_name + new_operation_item = parsed.get("operation_item", "") or existing_operation_item + new_operation_code = parsed.get("operation_code", "") or existing_operation_code + new_operation_time = parsed.get("operation_time", "") or existing_operation_time + new_operation_frequency = parsed.get("operation_frequency", "") or existing_operation_frequency + new_tried_measures = parsed.get("tried_measures", "") or existing_tried_measures + new_running_condition = parsed.get("running_condition", "") or existing_running_condition + new_additional_info = parsed.get("additional_info", "") or existing_additional_info + + extracted_info = {"ship_number": new_ship_number,"device_name": new_device_name,"operation_item": new_operation_item,"operation_code": new_operation_code,"operation_time": new_operation_time,"operation_frequency": new_operation_frequency,"tried_measures": new_tried_measures,"running_condition": new_running_condition,"additional_info": new_additional_info,} + + print(f"[信息提取] 舷号={extracted_info['ship_number']}, 设备={extracted_info['device_name']}, 操作={extracted_info['operation_item']}") + + return {**extracted_info,"extracted_info": extracted_info,"initialized": True,"current_node": "agent_think",} + + except Exception as e: + print(f"信息提取失败: {str(e)}") + return {"initialized": True,"current_node": "agent_think",} + +async def agent_think(state: OperateGuideState) -> Dict[str, Any]: + """ + Agent 思考节点:基于当前状态决定下一步动作 + """ + combined_query = state.get("combined_query", "") or "" + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + operation_code = state.get("operation_code", "") or "" + operation_time = state.get("operation_time", "") or "" + operation_frequency = state.get("operation_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + additional_info = state.get("additional_info", "") or "" + history_messages = state.get("history_messages", []) or [] + + scheme_generated = state.get("scheme_generated", False) + has_rag_result = state.get("has_rag_result", False) + has_graph_result = state.get("has_graph_result", False) + + rag_search_result = state.get("rag_search_result") + graph_search_result = state.get("graph_search_result") + iteration_count = state.get("iteration_count", 0) + + user_confirmed_info = state.get("user_confirmed_info", False) + waiting_feedback = state.get("waiting_feedback", False) + + has_ship = bool(ship_number and ship_number.strip()) + has_device = bool(device_name and device_name.strip()) + has_operation_item = bool(operation_item and operation_item.strip()) + has_rag = (rag_search_result is not None and len(rag_search_result) > 0) or has_rag_result + has_graph = (graph_search_result is not None and len(graph_search_result) > 100) or has_graph_result + + info_completeness = _calculate_info_completeness(ship_number=ship_number,device_name=device_name,operation_item=operation_item,operation_code=operation_code,operation_time=operation_time,operation_frequency=operation_frequency,tried_measures=tried_measures,running_condition=running_condition,additional_info=additional_info) + + MIN_COMPLETENESS_FOR_GENERATE = 0.25 + + if scheme_generated: + _re_diagnose_keywords = ["开始操作", "信息已足够", "信息已足够,开始操作", "开始", "操作"] + _is_re_diagnose = any(kw in combined_query for kw in _re_diagnose_keywords) + if _is_re_diagnose: + print(f"[重新操作] 检测到重新操作意图: '{combined_query}',重置状态直接调用工具生成方案") + tools_to_call = ["rag_search"] + if device_name and operation_item: + tools_to_call.append("graph_rag_search") + return {"scheme_generated": False,"rag_search_result": None,"graph_search_result": None,"deep_rag_results": None,"has_rag_result": False,"has_graph_result": False,"is_regenerating": False,"user_feedback_for_regenerate": "","follow_up_intent": "","user_confirmed_info": True,"initialized": True,"iteration_count": 0,"ask_round": 0,"agent_decision": "call_tools","tools_to_call": tools_to_call,"current_node": "call_tools",} + + history_str = build_history_str(history_messages, recent_count=8, assistant_truncate=300) + + intent_system_prompt = OPERATE_PROMPTS["agent_think_intent_system"] + + intent_prompt = OPERATE_PROMPTS["agent_think_intent_user"].format( + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + operation_item=operation_item or '未提供', + history_str=history_str or '无', + combined_query=combined_query + ) + + try: + intent_response = await OpenaiAPI.open_api_chat_without_thinking( + query=intent_prompt, + model=None, + json_output=True, + system_prompt=intent_system_prompt, + messages=[] + ) + + parsed_intent = safe_json_extract(intent_response) + intent = "other" + if isinstance(parsed_intent, dict): + intent = parsed_intent.get("intent", "other") + + print(f"[Agent 意图分析] 用户意图: {intent}") + + if intent == "modify_info": + decision = "confirm_info" + reasoning = "用户需要修改或补充信息" + return {"agent_decision": decision,"agent_reasoning": reasoning,"rag_search_result": None,"graph_search_result": None,"deep_rag_results": None,"has_rag_result": False,"has_graph_result": False,"scheme_generated": False,"is_regenerating": False,"user_feedback_for_regenerate": "","follow_up_intent": "","user_confirmed_info": False,"initialized": False,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + elif intent == "has_error": + decision = "handle_feedback" + reasoning = "用户反馈方案有错误" + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + return {"agent_decision": decision,"agent_reasoning": reasoning,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + elif intent == "not_solved": + decision = "analyze_follow_up_intent" + reasoning = "已生成方案,分析用户后续意图" + return {"agent_decision": decision,"agent_reasoning": reasoning,"follow_up_intent": "not_solved","info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + elif intent == "related_question": + decision = "analyze_follow_up_intent" + reasoning = "已生成方案,分析用户后续意图" + return {"agent_decision": decision,"agent_reasoning": reasoning,"follow_up_intent": "related_question","info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + else: + decision = "follow_up_qa" + reasoning = "其他意图,直接调用 baike" + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + return {"agent_decision": decision,"agent_reasoning": reasoning,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + except Exception as e: + print(f"[Agent 意图分析失败: {str(e)}") + decision = "analyze_follow_up_intent" + reasoning = "已生成方案,分析用户后续意图" + print(f"[Agent 决策] decision={decision}, reasoning={reasoning} (降级)") + return {"agent_decision": decision,"agent_reasoning": reasoning,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + + if not has_device or not has_operation_item: + decision = "ask_user" + missing_info = [] + if not has_device: + missing_info.append("设备名称") + if not has_operation_item: + missing_info.append("操作项目") + reasoning = f"缺少基本信息: {', '.join(missing_info)}" + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + return {"agent_decision": decision,"agent_reasoning": reasoning,"missing_info": missing_info,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + + if info_completeness < MIN_COMPLETENESS_FOR_GENERATE: + decision = "ask_user" + reasoning = f"信息完整性评分 {info_completeness} 低于最低要求 {MIN_COMPLETENESS_FOR_GENERATE}" + missing_info = [] + if not ship_number: + missing_info.append("舷号(可选)") + if not operation_code: + missing_info.append("操作代码") + if not operation_time: + missing_info.append("操作时间") + if not operation_frequency: + missing_info.append("操作频率") + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + return {"agent_decision": decision,"agent_reasoning": reasoning,"missing_info": missing_info or ["更多详细信息"],"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + + if has_rag or has_graph: + decision = "generate_response" + reasoning = "信息充足,已有检索结果,直接生成回复" + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + return {"agent_decision": decision,"agent_reasoning": reasoning,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + + if not user_confirmed_info: + decision = "confirm_info" + reasoning = "信息充足,需要用户确认后再检索知识库" + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + return {"agent_decision": decision,"agent_reasoning": reasoning,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + + decision = "call_tools" + reasoning = "信息充足,用户已确认,开始检索知识库" + tools_to_call = ["rag_search"] + if has_device and has_operation_item: + tools_to_call.append("graph_rag_search") + + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + print(f"[Agent 提取] 舷号={ship_number}, 设备={device_name}, 操作={operation_item}") + + emit_callback_event(["🤖 Agent 思考中", "🤖 智能分析", "🤖 决策判断"], f"决策: {decision} | 理由: {reasoning[:50]}...") + + return {"agent_decision": decision,"agent_reasoning": reasoning,"tools_to_call": tools_to_call,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + +async def ask_user(state: OperateGuideState) -> Command: + """ + 询问用户节点:使用 interrupt 暂停等待用户输入 + """ + missing_info = state.get("missing_info", []) + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + operation_code = state.get("operation_code", "") or "" + operation_time = state.get("operation_time", "") or "" + operation_frequency = state.get("operation_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + additional_info = state.get("additional_info", "") or "" + combined_query = state.get("combined_query", "") or "" + info_completeness = state.get("info_completeness", 0) + ask_round = state.get("ask_round", 0) + + MAX_ASK_ROUNDS = 4 + if ask_round >= MAX_ASK_ROUNDS: + print(f"[ask_user] 已达到最大询问轮次 {MAX_ASK_ROUNDS},强制进入下一步") + tools_to_call = ["rag_search"] + if device_name and operation_item: + tools_to_call.append("graph_rag_search") + return Command( + update={"ask_round": ask_round + 1,"current_node": "call_tools","tools_to_call": tools_to_call,}, + goto="call_tools" + ) + + existing_parts = [] + if ship_number: + existing_parts.append(f"舷号: {ship_number}") + if device_name: + existing_parts.append(f"设备名称: {device_name}") + if operation_item: + existing_parts.append(f"操作项目: {operation_item}") + if operation_code: + existing_parts.append(f"操作代码: {operation_code}") + if operation_time: + existing_parts.append(f"发生时间: {operation_time}") + if operation_frequency: + existing_parts.append(f"操作频率: {operation_frequency}") + if tried_measures: + existing_parts.append(f"已尝试措施: {tried_measures}") + if running_condition: + existing_parts.append(f"运行工况: {running_condition}") + if additional_info: + existing_parts.append(f"其他信息: {additional_info}") + + existing_str = ";".join(existing_parts) if existing_parts else "暂无" + missing_str = "、".join(missing_info) if missing_info else "相关信息" + + system_prompt = COMMON_PROMPTS["ask_user_system"].format(biz_label="操作指导", biz_label_short="指导") + + prompt = COMMON_PROMPTS["ask_user_prompt"].format( + info_completeness=info_completeness, + existing_str=existing_str, + missing_str=missing_str, + combined_query=combined_query + ) + + response = "" + try: + response = await OpenaiAPI.open_api_chat_without_thinking(query=prompt,model=None,system_prompt=system_prompt,messages=[]) + response = response.strip() if response else "" + except Exception as e: + print(f"[ask_user] API调用失败: {str(e)}") + response = "" + + if not response: + response = f"**【需要更多信息】**\n\n请补充:{missing_str}\n\n提供这些信息后,我可以更准确地为您指导操作。" + + if existing_parts: + existing_info_section = f"\n**📋 已获取的信息:**\n\n" + "\n".join( + f"- {part}" for part in existing_parts) + "\n\n---\n" + response = existing_info_section + response + + suggested_replies = [] + if "舷号" in missing_info: + suggested_replies.append({"title": "补充舷号", "content": "舷号:"}) + if "设备名称" in missing_info: + suggested_replies.append({"title": "补充设备名称", "content": "设备名称:"}) + if "操作项目" in missing_info: + suggested_replies.append({"title": "补充操作项目", "content": "操作项目:"}) + + user_input = interrupt({"response": response,"suggestedReplies": suggested_replies,"waiting_for": "user_input","missing_info": missing_info,}) + + user_query = user_input.get("query", "") + + update_dict = {"combined_query": user_query,"current_node": "agent_think","ask_round": ask_round + 1,} + + if user_query: + extract_prompt = OPERATE_PROMPTS["ask_user_extract"].format( + missing_str=missing_str, + user_query=user_query + ) + + try: + extract_response = await OpenaiAPI.open_api_chat_without_thinking(query=extract_prompt,model=None,json_output=True,system_prompt="你是信息提取专家。",messages=[]) + extracted = safe_json_extract(extract_response) + if isinstance(extracted, dict): + if extracted.get("ship_number"): + update_dict["ship_number"] = extracted["ship_number"] + print(f"[ask_user] 提取到舷号: {extracted['ship_number']}") + if extracted.get("device_name"): + update_dict["device_name"] = extracted["device_name"] + print(f"[ask_user] 提取到设备名称: {extracted['device_name']}") + if extracted.get("operation_item"): + update_dict["operation_item"] = extracted["operation_item"] + print(f"[ask_user] 提取到操作项目: {extracted['operation_item']}") + except Exception as e: + print(f"[ask_user] 信息提取失败: {str(e)}") + + return Command( + update=update_dict, + goto="agent_think" + ) + +async def confirm_info(state: OperateGuideState) -> Command: + """ + 确认信息节点:重点询问用户是否需要补充信息 + """ + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + operation_code = state.get("operation_code", "") or "" + operation_time = state.get("operation_time", "") or "" + operation_frequency = state.get("operation_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + additional_info = state.get("additional_info", "") or "" + info_completeness = state.get("info_completeness", 0) + + existing_parts = [] + if ship_number: + existing_parts.append(f"舷号: {ship_number}") + if device_name: + existing_parts.append(f"设备名称: {device_name}") + if operation_item: + existing_parts.append(f"操作项目: {operation_item}") + if operation_code: + existing_parts.append(f"操作代码: {operation_code}") + if operation_time: + existing_parts.append(f"发生时间: {operation_time}") + if operation_frequency: + existing_parts.append(f"操作频率: {operation_frequency}") + if tried_measures: + existing_parts.append(f"已尝试措施: {tried_measures}") + if running_condition: + existing_parts.append(f"运行工况: {running_condition}") + if additional_info: + existing_parts.append(f"其他信息: {additional_info}") + + existing_str = "\n".join(f"- {part}" for part in existing_parts) if existing_parts else "暂无" + + additional_prompt = "" + if not ship_number: + additional_prompt = "\n\n💡 **提示**:如果您知道舷号,请补充,这将有助于我们更准确地提供操作指导。" + + response = f"""**📋 已收集到的信息:** + +{existing_str} + +**信息完整性评分:** {info_completeness:.0%} + +--- + +**💡 请问是否需要补充其他信息?** +- 如操作代码、操作时间、操作频率、已尝试措施、运行工况等 +- 如果信息已足够,请点击"开始操作"{additional_prompt} +""" + + emit_callback_event(["✅ 信息确认", "📋 请确认信息", "🔍 准备检索"], f"等待用户确认或补充信息 (完整性: {info_completeness:.0%})") + + suggested_replies = [{"title": "开始操作", "content": "信息已足够,开始操作"},{"title": "补充信息", "content": "我需要补充:"}] + + user_input = interrupt({"response": response,"suggestedReplies": suggested_replies,"waiting_for": "user_confirm",}) + + user_query = user_input.get("query", "") + + if await is_confirmation_intent(user_query): + print(f"[confirm_info] 用户确认信息足够,准备调用工具") + tools_to_call = ["rag_search"] + if device_name and operation_item: + tools_to_call.append("graph_rag_search") + return Command( + update={"user_confirmed_info": True,"current_node": "call_tools","tools_to_call": tools_to_call,}, + goto="call_tools" + ) + else: + print(f"[confirm_info] 用户需要补充信息: {user_query}") + return Command( + update={"user_confirmed_info": False,"combined_query": user_query,"initialized": False,"current_node": "extract_info",}, + goto="extract_info" + ) + +async def call_tools(state: OperateGuideState) -> Dict[str, Any]: + """调用检索工具""" + tools_to_call = state.get("tools_to_call", []) + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + additional_info = state.get("additional_info", "") or "" + + rag_results = state.get("rag_search_result") + graph_results = state.get("graph_search_result") + atlas_results = {} + + matched_kb_name = "" + matched_kb_id = "" + + async def _do_rag_search(): + from utils.ship_number_search import search_with_ship_number_strategy + base_query = f"设备{device_name},操作{operation_item}的操作指导方案" + results, kb_name, kb_id, _ = await search_with_ship_number_strategy( + base_query=base_query, ship_number=ship_number, top_k=5, search_type="operate" + ) + if not kb_name: + kb_name = "通用知识库" + print(f"RAG 检索来源: {kb_name}") + return results, kb_name, kb_id + + async def _do_graph_search(): + if not (device_name and operation_item): + return "" + try: + graph_query = f"设备{device_name},操作{operation_item}的操作指导方案" + results = await operation_graph_rag_search.ainvoke({"ship_number": 163, "device_name": device_name, "operation_item": operation_item}) + + print(f"图谱检索完成,结果长度: {len(results) if results else 0}") + return results + except Exception as e: + print(f"图谱检索失败: {str(e)}") + return "" + + need_rag = "rag_search" in tools_to_call and rag_results is None + need_graph = "graph_rag_search" in tools_to_call and graph_results is None + + if need_rag and need_graph: + (rag_results, matched_kb_name, matched_kb_id), graph_results = await asyncio.gather( + _do_rag_search(), + _do_graph_search() + ) + elif need_rag: + rag_results, matched_kb_name, matched_kb_id = await _do_rag_search() + elif need_graph: + graph_results = await _do_graph_search() + + if device_name: + try: + print(f"[图册检索] 使用设备名称: {device_name}") + atlas_result = await atlas_retrieval(node_names=[device_name], top_k=10) + if atlas_result.get("success", False): + atlas_results = atlas_result.get("data", {}) + print(f"[图册检索] 图册检索结果: {list(atlas_results.keys())}") + except Exception as e: + print(f"[图册检索] 图册检索失败: {str(e)}") + + has_rag = bool(rag_results and len(rag_results) > 0) + has_graph = bool(graph_results and len(graph_results) > 100) + + if not has_rag and not has_graph: + print(f"[call_tools] 未找到任何相关资料,需要用户确认使用模型能力") + return {"rag_search_result": rag_results if isinstance(rag_results, list) else [],"graph_search_result": graph_results if isinstance(graph_results, str) else "","atlas_results": atlas_results,"matched_kb_id": matched_kb_id,"matched_kb_name": matched_kb_name,"has_rag_result": False,"has_graph_result": False,"need_model_confirm": True,"current_node": "model_confirm",} + + return {"rag_search_result": rag_results,"graph_search_result": graph_results,"atlas_results": atlas_results,"matched_kb_id": matched_kb_id,"matched_kb_name": matched_kb_name,"has_rag_result": has_rag,"has_graph_result": has_graph,"need_model_confirm": False,"current_node": "generate_response",} + +async def model_confirm(state: OperateGuideState) -> Command: + """ + 模型能力确认节点:当没有找到任何资料时,让用户确认是否使用模型本身能力 + """ + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + + response = f"""**⚠️ 未找到相关资料** + +抱歉,在知识库中未找到与以下信息相关的资料: +- 舷号:{ship_number or '未提供'} +- 设备名称:{device_name or '未提供'} +- 操作项目:{operation_item or '未提供'} + +**💡 您可以选择:** +- 使用 AI 模型的知识库为您生成一般性建议 +- 提供更详细的信息重新检索 + +请问是否使用 AI 模型为您生成建议?""" + + emit_callback_event(["🤖 AI 模型确认", "⚠️ 资料未找到", "💡 使用模型能力"], "使用模型自身知识进行回复") + + suggested_replies = [{"title": "使用 AI 模型", "content": "确认,使用 AI 模型生成建议"},{"title": "补充信息", "content": "我需要补充更多信息:"},] + + user_input = interrupt({"response": response,"suggestedReplies": suggested_replies,"waiting_for": "model_confirm",}) + + user_query = user_input.get("query", "") + + if "确认" in user_query or "AI" in user_query or "模型" in user_query: + print(f"[model_confirm] 用户确认使用模型能力") + return Command( + update={"user_confirmed_model": True,"combined_query": user_query,"current_node": "generate_response",}, + goto="generate_response" + ) + else: + print(f"[model_confirm] 用户选择补充信息: {user_query}") + return Command( + update={"user_confirmed_model": False,"combined_query": user_query,"current_node": "agent_think",}, + goto="agent_think" + ) + +async def generate_response(state: OperateGuideState) -> Dict[str, Any]: + """生成最终回复""" + print("========================生成最终回复========================") + combined_query = state.get("combined_query", "") or "" + history_messages = state.get("history_messages", []) or [] + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + additional_info = state.get("additional_info", "") or "" + operation_code = state.get("operation_code", "") or "" + operation_time = state.get("operation_time", "") or "" + operation_frequency = state.get("operation_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + rag_results = state.get("rag_search_result") or [] + graph_results = state.get("graph_search_result") or "" + atlas_results = state.get("atlas_results", {}) + scheme_generated = state.get("scheme_generated", False) + matched_kb_name = state.get("matched_kb_name", "") + + print("rag_results", rag_results) + history_str = build_history_str(history_messages, recent_count=12, assistant_truncate=500) + + has_rag = rag_results and len(rag_results) > 0 + has_graph = graph_results and len(graph_results) > 100 + + if not has_rag and not has_graph: + if not ship_number and not device_name and not operation_item: + system_prompt = COMMON_PROMPTS["generate_response_friendly_system"].format(biz_label="操作指导") + prompt = COMMON_PROMPTS["generate_response_friendly_user"].format(combined_query=combined_query) + else: + system_prompt = COMMON_PROMPTS["generate_response_no_rag_system"].format(biz_label="操作指导") + prompt = OPERATE_PROMPTS["generate_response_no_rag_user"].format( + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + operation_item=operation_item or '未提供', + operation_code=operation_code or '未提供', + operation_time=operation_time or '未提供', + operation_frequency=operation_frequency or '未提供', + tried_measures=tried_measures or '未提供', + running_condition=running_condition or '未提供', + additional_info=additional_info or '无' + ) + + try: + response = await OpenaiAPI.open_api_chat_without_thinking(query=prompt,model=None,system_prompt=system_prompt,messages=[]) + return {"response": response.strip() if response else "抱歉,未能找到相关资料。","suggestedReplies": [{"title": "提供更详细信息", "content": "我来提供更详细的信息"},],"current_node": "end",} + except Exception as e: + return {"response": f"抱歉,处理过程中出现错误: {str(e)}","suggestedReplies": [],"current_node": "end",} + + rag_ctx_parts: List[str] = [] + if isinstance(rag_results, str) and rag_results.strip(): + rag_ctx_parts = [rag_results.strip()] + elif isinstance(rag_results, list): + for item in sorted(rag_results, key=lambda x: float(x.get("score") or 0) if isinstance(x, dict) else 0, reverse=True)[:2]: + if isinstance(item, dict): + text = (item.get("text") or "").strip() + if text: + rag_ctx_parts.append(text) + elif isinstance(item, str) and item.strip(): + rag_ctx_parts.append(item.strip()) + + if len(graph_results) <= 50: + graph_results = "" + + all_source_text = graph_results + "\n" + "\n".join(rag_ctx_parts) if graph_results else "\n".join(rag_ctx_parts) + original_image_paths = extract_image_paths(all_source_text) + print("提取到的原始图片路径:", original_image_paths) + + rag_answer = "" + rag_type = "none" + + if graph_results and len(graph_results) > 50: + rag_answer = graph_results + rag_type = "graph" + elif rag_ctx_parts: + rag_answer = "\n\n".join(rag_ctx_parts[:2]) + rag_type = "rag" + + if not rag_answer or rag_answer == "NO_RELEVANT_RESULT": + system_prompt = COMMON_PROMPTS["generate_response_no_rag_simple_system"].format(biz_label="操作指导") + prompt = OPERATE_PROMPTS["generate_response_no_rag_simple_user"].format( + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + operation_item=operation_item or '未提供' + ) + + try: + response = await OpenaiAPI.open_api_chat_without_thinking(query=prompt,model=None,system_prompt=system_prompt,messages=[]) + return {"response": response.strip() if response else "抱歉,未能找到相关资料。","suggestedReplies": [],"current_node": "end",} + except Exception as e: + return {"response": f"抱歉,处理过程中出现错误: {str(e)}","suggestedReplies": [],"current_node": "end",} + + rag_type = "图谱" if rag_type == "graph" else "文本" + + emit_callback_event([f"✨当前使用的检索类型为{rag_type}", f"✨本次检索策略为{rag_type}"], f"内容为{rag_answer[:50]}...") + + detail_info = "" + if operation_code: + detail_info += f"- 操作代码/报警代码:{operation_code}\n" + if operation_time: + detail_info += f"- 操作发生时间:{operation_time}\n" + if operation_frequency: + detail_info += f"- 操作频率/持续时间:{operation_frequency}\n" + if tried_measures: + detail_info += f"- 已尝试的操作措施:{tried_measures}\n" + if running_condition: + detail_info += f"- 设备运行工况:{running_condition}\n" + if additional_info: + detail_info += f"- 其他相关信息:{additional_info}\n" + + is_regenerating = state.get("is_regenerating", False) + user_feedback_for_regenerate = state.get("user_feedback_for_regenerate", "") + is_follow_up = scheme_generated and (has_rag or has_graph) and not is_regenerating + + image_guidance = get_image_prompt_guidance() + if is_regenerating and user_feedback_for_regenerate: + prompt = COMMON_PROMPTS["generate_response_regenerating"].format( + biz_label="操作指导", + item_label="操作项目", + item_name=operation_item or '未提供', + item_action="分析操作要点,给出操作指导方案", + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + operation_item=operation_item or '未提供', + detail_info=detail_info, + user_feedback_for_regenerate=user_feedback_for_regenerate, + rag_answer=rag_answer, + image_guidance=image_guidance + ) + elif is_follow_up: + prompt = COMMON_PROMPTS["generate_response_follow_up"].format( + biz_label="操作指导", + item_label="操作项目", + item_name=operation_item or '未提供', + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + operation_item=operation_item or '未提供', + detail_info=detail_info, + rag_answer=rag_answer, + combined_query=combined_query, + image_guidance=image_guidance + ) + else: + prompt = COMMON_PROMPTS["generate_response_normal"].format( + biz_label="操作指导", + item_label="操作项目", + item_name=operation_item or '未提供', + item_action="分析操作要点,给出操作指导方案", + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + operation_item=operation_item or '未提供', + detail_info=detail_info, + rag_answer=rag_answer + ) + + try: + content_system_prompt = COMMON_PROMPTS["generate_response_content_system"].format(biz_label="操作指导") + + raw_content = await OpenaiAPI.open_api_chat_without_thinking(model=None,system_prompt=content_system_prompt,messages=[{"role": "user", "content": prompt}],) + + raw_content = raw_content.strip() if raw_content else "抱歉,无法生成回答。" + + emit_callback_event(["🤖 操作分析中", "🤖 方案生成中"], raw_content[:30] + "...") + + answer = await stream_format_and_postprocess(raw_content, original_image_paths, temperature=0.1) + + answer = append_atlas_section(answer, atlas_results) + + suggested_replies = [{"title": "方案有误", "content": "方案还是有问题:"},] + + return {"response": answer,"actions": [],"suggestedReplies": suggested_replies,"scheme_generated": True,"last_generated_scheme": answer,"scheme_type": "deep","current_node": "end","last_combined_query": combined_query,} + + except Exception as e: + return {"response": f"深度分析生成失败: {str(e)}","actions": [],"suggestedReplies": [],"current_node": "end",} + +async def regenerate_scheme_from_feedback(state: OperateGuideState) -> Dict[str, Any]: + """ + 基于用户反馈直接修改方案节点:使用之前保存的方案和用户反馈进行修改 + """ + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + additional_info = state.get("additional_info", "") or "" + operation_code = state.get("operation_code", "") or "" + operation_time = state.get("operation_time", "") or "" + operation_frequency = state.get("operation_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + combined_query = state.get("combined_query", "") or "" + + user_feedback = state.get("user_feedback_for_regenerate", "") or "" + last_scheme = state.get("last_generated_scheme", "") or "" + scheme_type = state.get("scheme_type", "normal") + + if not last_scheme: + print("[regenerate_scheme_from_feedback] 没有保存的方案,回退到重新检索") + return {"is_regenerating": True,"rag_search_result": None,"graph_search_result": None,"has_rag_result": False,"has_graph_result": False,"scheme_generated": False,"current_node": "call_tools","tools_to_call": ["rag_search", "graph_rag_search"],} + + detail_info = "" + if operation_code: + detail_info += f"- 操作代码/报警代码:{operation_code}\n" + if operation_time: + detail_info += f"- 操作发生时间:{operation_time}\n" + if operation_frequency: + detail_info += f"- 操作频率/持续时间:{operation_frequency}\n" + if tried_measures: + detail_info += f"- 已尝试的操作措施:{tried_measures}\n" + if running_condition: + detail_info += f"- 设备运行工况:{running_condition}\n" + if additional_info: + detail_info += f"- 其他相关信息:{additional_info}\n" + + emit_callback_event(["✏️ 修改方案中", "🔧 调整方案", "📝 优化方案"], f"根据反馈修改方案: {user_feedback[:30]}...") + + image_guidance = get_image_prompt_guidance() + + prompt = COMMON_PROMPTS["regenerate_scheme_from_feedback"].format( + biz_label="操作指导", + item_label="操作项目", + item_name=operation_item or '未提供', + item_action="分析操作要点,给出操作指导方案", + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + operation_item=operation_item or '未提供', + detail_info=detail_info, + last_scheme=last_scheme, + user_feedback=user_feedback, + image_guidance=image_guidance + ) + + try: + content_system_prompt = COMMON_PROMPTS["regenerate_scheme_from_feedback_system"].format(biz_label="操作指导") + + content_prompt = prompt.replace("【输出要求】", "【输出要求】\n- 请专注于内容的准确性、完整性和逻辑性,不要担心格式的美观性\n- 可以适当使用简单的段落分隔,但不要使用复杂的格式") + + raw_content = await OpenaiAPI.open_api_chat_without_thinking(model=None,system_prompt=content_system_prompt,messages=[{"role": "user", "content": content_prompt}],) + + raw_content = raw_content.strip() if raw_content else "抱歉,无法根据反馈修改方案。" + + original_image_paths = extract_image_paths(last_scheme) + answer = await stream_format_and_postprocess(raw_content, original_image_paths, temperature=0.3, content_label="操作指导方案") + + suggested_replies = [{"title": "方案有误", "content": "方案还是有问题:"},] + + return {"response": answer,"actions": [],"suggestedReplies": suggested_replies,"scheme_generated": True,"last_generated_scheme": answer,"scheme_type": scheme_type,"current_node": "end","last_combined_query": combined_query,} + + except Exception as e: + print(f"[regenerate_scheme_from_feedback] 修改方案失败: {str(e)}") + return {"is_regenerating": True,"rag_search_result": None,"graph_search_result": None,"has_rag_result": False,"has_graph_result": False,"scheme_generated": False,"current_node": "call_tools","tools_to_call": ["rag_search", "graph_rag_search"],} + +async def follow_up_qa(state: OperateGuideState) -> Dict[str, Any]: + """ + 后续问答节点:调用 baike 智能体处理用户的后续问题 + """ + combined_query = state.get("combined_query", "") or "" + history_messages = state.get("history_messages", []) or [] + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + + history_message_json = json.dumps(history_messages, ensure_ascii=False) if history_messages else "" + + context_prefix = "" + if ship_number or device_name or operation_item: + context_parts = [] + if ship_number: + context_parts.append(f"舷号: {ship_number}") + if device_name: + context_parts.append(f"设备名称: {device_name}") + if operation_item: + context_parts.append(f"操作项目: {operation_item}") + context_prefix = f"【当前操作指导上下文】\n{'; '.join(context_parts)}\n\n" + + enhanced_query = f"{context_prefix}用户后续问题: {combined_query}" + + emit_callback_event(["💬 后续问答", "💬 执行指导", "💬 问题解答"], f"调用 百科 智能体: {combined_query[:30]}...") + + try: + baike_result = await run_baike_workflow(extracted_text=enhanced_query,combined_query=enhanced_query,history_message=history_message_json,route_flag="baike") + + response = baike_result.get("response", "") + suggested_replies = baike_result.get("suggestedReplies", []) + + return {"response": response,"suggestedReplies": suggested_replies,"actions": [],"scheme_generated": True,"current_node": "end",} + + except Exception as e: + return {"response": f"处理您的问题时出现错误: {str(e)}","suggestedReplies": [],"actions": [],"current_node": "end",} + +async def handle_feedback(state: OperateGuideState) -> Command: + """ + 处理用户反馈节点:接收用户对方案的反馈,提供上报和重新生成方案按钮 + """ + combined_query = state.get("combined_query", "") or "" + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + + response = f"""**📝 已收到您的反馈** + +感谢您对操作指导方案的建议!您的反馈已记录: + +{combined_query} + +--- + +**📋 反馈信息摘要:** +- 舷号:{ship_number or '未提供'} +- 设备名称:{device_name or '未提供'} +- 操作项目:{operation_item or '未提供'} +- 反馈内容:{combined_query[:100]}{'...' if len(combined_query) > 100 else ''} + +--- + +**💡 您可以选择:** +- 点击「重新生成方案」根据您的反馈重新生成方案 +- 点击「上报」将反馈提交给相关部门 +- 继续对话获取更多帮助""" + + actions = [ + {"type": "content","label": "重新生成方案","text": "重新生成方案"}, + {"type": "content","label": "上报","text": "上报"} + ] + + suggested_replies = [] + + emit_callback_event(["📝 反馈处理", "📋 方案反馈", "✅ 反馈确认"], f"处理用户反馈: {combined_query[:30]}...") + + user_input = interrupt({"response": response,"actions": actions,"suggestedReplies": suggested_replies,"waiting_for": "feedback_confirm",}) + + user_text = user_input.get("query", "") or "" + + if user_text == "重新生成方案": + print(f"[handle_feedback] 用户选择重新生成方案") + return Command( + update={"user_feedback_for_regenerate": combined_query,"current_node": "regenerate_scheme_from_feedback",}, + goto="regenerate_scheme_from_feedback" + ) + elif user_text == "上报": + print(f"[handle_feedback] 用户选择上报反馈") + return Command( + update={"user_feedback": combined_query,"waiting_report_confirm": True,"current_node": "confirm_report",}, + goto="confirm_report" + ) + else: + print(f"[handle_feedback] 用户继续对话: {user_text}") + return Command( + update={"user_feedback": combined_query,"combined_query": user_text,"current_node": "agent_think",}, + goto="agent_think" + ) + +async def confirm_report(state: OperateGuideState) -> Dict[str, Any]: + """ + 上报确认节点:用户点击上报后确认完成 + """ + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + user_feedback = state.get("user_feedback", "") or "" + + response = f"""**✅ 上报成功** + +您的反馈已成功上报! + +--- + +**📋 上报信息:** +- 舷号:{ship_number or '未提供'} +- 设备名称:{device_name or '未提供'} +- 操作项目:{operation_item or '未提供'} +- 反馈内容:{user_feedback[:100]}{'...' if len(user_feedback) > 100 else ''} + +--- + +**💡 感谢您的反馈,相关部门将及时处理。**""" + + suggested_replies = [{"title": "继续对话", "content": "我还有其他问题"},] + + emit_callback_event(["✅ 上报成功", "📋 反馈已提交", "🎉 完成"], "反馈上报成功") + + return {"response": response,"actions": [],"suggestedReplies": suggested_replies,"report_confirmed": True,"waiting_report_confirm": False,"current_node": "end",} + +async def analyze_follow_up_intent(state: OperateGuideState) -> Dict[str, Any]: + """ + 分析用户后续意图节点:直接使用 agent_think 已经判断好的意图 + agent_think 已经用大模型判断并设置了 follow_up_intent + """ + intent = state.get("follow_up_intent", "other") + + print(f"[意图分析] 使用已判断的意图: {intent}") + + return {"follow_up_intent": intent,} + + +async def deep_rag_search(state: OperateGuideState) -> Dict[str, Any]: + """ + 深层次RAG检索节点:分析之前方案并选择深度分析点进行检索 + """ + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + operation_code = state.get("operation_code", "") or "" + additional_info = state.get("additional_info", "") or "" + operation_time = state.get("operation_time", "") or "" + operation_frequency = state.get("operation_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + combined_query = state.get("combined_query", "") or "" + history_messages = state.get("history_messages", []) or [] + last_generated_scheme = state.get("last_generated_scheme", "") or "" + + deep_results = {} + deep_analysis_points = [] + + emit_callback_event(["🔍 深度检索中", "🔍 多维度检索", "🔍 精细分析"], "分析问题并选择深度分析点") + + # ========== 第一步:分析之前方案和用户问题,选择深度分析点 ========== + if last_generated_scheme or combined_query: + try: + history_str = build_history_str(history_messages, recent_count=8, assistant_truncate=300) + + detail_info = "" + if operation_code: + detail_info += f"- 操作代码:{operation_code}\n" + if operation_time: + detail_info += f"- 操作时间:{operation_time}\n" + if operation_frequency: + detail_info += f"- 操作频率/持续时间:{operation_frequency}\n" + if tried_measures: + detail_info += f"- 已尝试的措施:{tried_measures}\n" + if running_condition: + detail_info += f"- 设备运行工况:{running_condition}\n" + if additional_info: + detail_info += f"- 其他相关信息:{additional_info}\n" + + analysis_system_prompt = COMMON_PROMPTS["deep_rag_analysis_system"].format(biz_label="操作指导") + + analysis_prompt = COMMON_PROMPTS["deep_rag_analysis_user"].format( + item_label="操作项目", + item_name=operation_item or '未提供', + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + operation_item=operation_item or '未提供', + detail_info=detail_info, + history_str=history_str or '(无历史对话)', + last_generated_scheme=last_generated_scheme or '(无之前的方案)', + combined_query=combined_query + ) + + analysis_response = await OpenaiAPI.open_api_chat_without_thinking(query=analysis_prompt,model=None,json_output=True,system_prompt=analysis_system_prompt,messages=[]) + print("深度分析点", analysis_response) + parsed_analysis = safe_json_extract(analysis_response) + deep_analysis_points = parsed_analysis + print(f"[深度RAG] 选择的分析点: {deep_analysis_points}") + except Exception as e: + print(f"[深度RAG] 分析失败: {str(e)}") + + # ========== 第二步:对选中的分析点进行检索 ========== + for idx, point in enumerate(deep_analysis_points): + try: + print(point) + search_query = point + if device_name and device_name not in point: + search_query = f"{device_name} {search_query}" + if operation_item and operation_item not in search_query: + search_query = f"{search_query} {operation_item}" + + rag_result = await rag_search(search_query, top_k=3) + if rag_result.get("success", False): + deep_results[f"analysis_point_{idx}"] = convert_rag_result(rag_result) + else: + deep_results[f"analysis_point_{idx}"] = [] + except Exception as e: + print(f"[深度RAG] 分析点 {point} 检索失败: {str(e)}") + deep_results[f"analysis_point_{idx}"] = [] + + # ========== 第三步:图谱检索 ========== + graph_deep_results = "" + try: + if device_name and operation_item: + graph_query = f"设备{device_name},操作{operation_item}的操作指导方案" + graph_deep_results = await graph_rag_search.ainvoke({"query": graph_query}) + if graph_deep_results: + print(f"[深度RAG] 图谱检索结果长度: {len(graph_deep_results)}") + except Exception as e: + print(f"[深度RAG] 图谱检索失败: {str(e)}") + + # ========== 第四步:图册检索 ========== + atlas_results = {} + try: + entity_names = await extract_entity_names_from_history(history_messages=history_messages,device_name=device_name,operation_item=operation_item,last_generated_scheme=last_generated_scheme) + + if entity_names: + print(f"[深度RAG] 图册检索实体: {entity_names}") + atlas_result = await atlas_retrieval(node_names=entity_names, top_k=10) + if atlas_result.get("success", False): + atlas_results = atlas_result.get("data", {}) + print(f"[深度RAG] 图册检索结果: {list(atlas_results.keys())}") + except Exception as e: + print(f"[深度RAG] 图册检索失败: {str(e)}") + + return {"deep_rag_results": deep_results,"deep_analysis_points": deep_analysis_points,"graph_search_result": graph_deep_results or (state.get("graph_search_result", "") or ""),"atlas_results": atlas_results,"current_node": "generate_deep_response",} + + +async def generate_deep_response(state: OperateGuideState) -> Dict[str, Any]: + """ + 生成深度响应节点:基于深度RAG结果进行深度润色和推理 + """ + combined_query = state.get("combined_query", "") or "" + history_messages = state.get("history_messages", []) or [] + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + operation_code = state.get("operation_code", "") or "" + operation_time = state.get("operation_time", "") or "" + operation_frequency = state.get("operation_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + additional_info = state.get("additional_info", "") or "" + + deep_rag_results = state.get("deep_rag_results", {}) + deep_analysis_points = state.get("deep_analysis_points", []) + atlas_results = state.get("atlas_results", {}) + graph_results = state.get("graph_search_result", "") or "" + original_rag_results = state.get("rag_search_result", []) + + # 整理深度分析点资料 + analysis_points_text = "" + if deep_analysis_points: + for idx, point in enumerate(deep_analysis_points): + key = f"analysis_point_{idx}" + results = deep_rag_results.get(key, []) + if results: + analysis_points_text += f"\n【分析点:{point}】\n" + for i, item in enumerate(sorted(results, key=lambda x: float(x.get("score") or 0) if isinstance(x, dict) else 0, reverse=True)[:2], 1): + if isinstance(item, dict): + text = item.get("text", "") + if text: + analysis_points_text += f"[{i}] {text}\n" + + # 整理原始RAG结果 + original_rag_text = "" + if original_rag_results: + original_rag_text = "\n【原始检索资料】\n" + for i, item in enumerate(sorted(original_rag_results, key=lambda x: float(x.get("score") or 0) if isinstance(x, dict) else 0, reverse=True)[:2], 1): + if isinstance(item, dict): + text = item.get("text", "") + if text: + original_rag_text += f"[{i}] {text}\n" + + # 整理图册资料 + atlas_text = format_atlas_results(atlas_results) + + # 提取所有资料中的图片路径 + all_source_text = analysis_points_text + original_rag_text + atlas_text + graph_results + original_image_paths = extract_image_paths(all_source_text) + + detail_info = "" + if operation_code: + detail_info += f"- 操作代码:{operation_code}\n" + if operation_time: + detail_info += f"- 操作时间:{operation_time}\n" + if operation_frequency: + detail_info += f"- 操作频率/持续时间:{operation_frequency}\n" + if tried_measures: + detail_info += f"- 已尝试的措施:{tried_measures}\n" + if running_condition: + detail_info += f"- 设备运行工况:{running_condition}\n" + if additional_info: + detail_info += f"- 其他相关信息:{additional_info}\n" + + # 构建深度分析点的提示 + analysis_points_prompt = "" + if deep_analysis_points: + analysis_points_prompt = f"\n【本次深度分析重点】\n" + "\n".join([f"- {p}" for p in deep_analysis_points]) + "\n" + + prompt = COMMON_PROMPTS["generate_deep_response"].format( + biz_label="操作指导", + item_label="操作项目", + item_name=operation_item or '未提供', + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + operation_item=operation_item or '未提供', + detail_info=detail_info, + combined_query=combined_query, + analysis_points_prompt=analysis_points_prompt, + analysis_points_text=analysis_points_text, + original_rag_text=original_rag_text, + graph_results=graph_results if len(graph_results) > 50 else '(无)' + ) + + try: + content_system_prompt = COMMON_PROMPTS["generate_deep_response_content_system"].format(biz_label="操作指导") + + raw_content = await OpenaiAPI.open_api_chat_without_thinking(model=None,system_prompt=content_system_prompt,messages=[{"role": "user", "content": prompt}],) + + raw_content = raw_content.strip() if raw_content else "抱歉,无法生成深度分析。" + + emit_callback_event(["🤖 分析中", "🤖 操作指导生成中"], raw_content[:30] + "...") + + answer = await stream_format_and_postprocess(raw_content, original_image_paths, temperature=0.1) + + answer = append_atlas_section(answer, atlas_results) + + suggested_replies = [{"title": "方案有误", "content": "方案还是有问题:"},] + + return {"response": answer,"actions": [],"suggestedReplies": suggested_replies,"scheme_generated": True,"last_generated_scheme": answer,"scheme_type": "deep","current_node": "end","last_combined_query": combined_query,} + + except Exception as e: + return {"response": f"深度分析生成失败: {str(e)}","actions": [],"suggestedReplies": [],"current_node": "end",} + + +def _route_after_think(state: OperateGuideState) -> str: + """Agent 思考后的路由""" + decision = state.get("agent_decision", "ask_user") + + return decision + +def _route_after_intent(state: OperateGuideState) -> str: + """意图分析后的路由""" + intent = state.get("follow_up_intent", "other") + + if intent == "not_solved": + return "deep_rag_search" + elif intent == "related_question": + return "follow_up_qa" + elif intent == "has_error": + return "handle_feedback" + elif intent == "modify_info": + return "extract_info" + else: + return "follow_up_qa" + +def _route_after_tools(state: OperateGuideState) -> str: + """检索工具调用后的路由""" + need_model_confirm = state.get("need_model_confirm", False) + if need_model_confirm: + return "model_confirm" + return "generate_response" + +def create_operate_workflow(checkpointer=None): + """ + 创建操作指导工作流(基于 Checkpointer 的状态持久化版本) + """ + workflow = StateGraph(OperateGuideState) + + workflow.add_node("classify_intent", classify_intent) + workflow.add_node("extract_info", extract_info) + workflow.add_node("agent_think", agent_think) + workflow.add_node("ask_user", ask_user) + workflow.add_node("confirm_info", confirm_info) + workflow.add_node("call_tools", call_tools) + workflow.add_node("generate_response", generate_response) + workflow.add_node("follow_up_qa", follow_up_qa) + workflow.add_node("model_confirm", model_confirm) + workflow.add_node("handle_feedback", handle_feedback) + workflow.add_node("confirm_report", confirm_report) + workflow.add_node("analyze_follow_up_intent", analyze_follow_up_intent) + workflow.add_node("deep_rag_search", deep_rag_search) + workflow.add_node("generate_deep_response", generate_deep_response) + workflow.add_node("regenerate_scheme_from_feedback", regenerate_scheme_from_feedback) + + workflow.add_edge(START, "classify_intent") + workflow.add_conditional_edges("classify_intent", _route_after_classify, + {"extract_info": "extract_info","follow_up_qa": "follow_up_qa",}) + workflow.add_edge("extract_info", "agent_think") + workflow.add_conditional_edges("agent_think", _route_after_think, + {"ask_user": "ask_user","confirm_info": "confirm_info","call_tools": "call_tools","generate_response": "generate_response","follow_up": "follow_up_qa","follow_up_qa": "follow_up_qa","handle_feedback": "handle_feedback","extract_info": "extract_info","analyze_follow_up_intent": "analyze_follow_up_intent",}) + workflow.add_conditional_edges("call_tools", _route_after_tools, {"model_confirm": "model_confirm","generate_response": "generate_response",}) + workflow.add_conditional_edges("analyze_follow_up_intent", _route_after_intent, {"deep_rag_search": "deep_rag_search","follow_up_qa": "follow_up_qa","handle_feedback": "handle_feedback","extract_info": "extract_info",}) + workflow.add_edge("deep_rag_search", "generate_deep_response") + workflow.add_edge("generate_deep_response", END) + workflow.add_edge("generate_response", END) + workflow.add_edge("follow_up_qa", END) + workflow.add_edge("regenerate_scheme_from_feedback", END) + workflow.add_edge("handle_feedback", END) + workflow.add_edge("confirm_report", END) + + return workflow.compile(checkpointer=checkpointer) + +async def run_operate_workflow(extracted_text: str,combined_query: str,history_message: str = "",route_flag: str = "",previous_state_json: str = "",chat_id: str = "",message_id: str = "",checkpointer=None,user_response: Dict[str, Any] = None) -> Dict[str, Any]: + """ + 执行操作指导工作流 + + Args: + extracted_text: 文本描述 + combined_query: 组合查询 + history_message: 历史消息(JSON格式) + route_flag: 路由标识 + previous_state_json: 兼容参数(已废弃) + chat_id: 聊天会话 ID + message_id: 消息 ID + checkpointer: LangGraph checkpointer 实例 + user_response: 用户恢复执行的响应数据 + + Returns: + 工作流执行结果 + """ + thread_id = chat_id if chat_id else None + + config = {"configurable": {"thread_id": thread_id}} if thread_id else {} + + print(f"[DEBUG] run_operate_workflow: thread_id={thread_id}, checkpointer={checkpointer is not None}") + + if checkpointer is None: + from checkpointer_config import checkpointer_manager + checkpointer = await checkpointer_manager.get_async_checkpointer() + + app = create_operate_workflow(checkpointer=checkpointer) + + if thread_id: + try: + current_state = await app.aget_state(config) + print( + f"[DEBUG] current_state exists: {current_state is not None}, has values: {current_state.values is not None if current_state else False}, tasks: {len(current_state.tasks) if current_state else 0}") + + if current_state and current_state.values: + saved_state = current_state.values + print( + f"[Checkpointer] 发现已保存状态: ship_number={saved_state.get('ship_number')}, device_name={saved_state.get('device_name')}, operation_item={saved_state.get('operation_item')}") + + if current_state.tasks: + print(f"[Checkpointer] 恢复执行,注入用户响应") + resume_value = user_response if user_response else {"query": combined_query} + result = await app.ainvoke(Command(resume=resume_value), config) + else: + merged_query = combined_query + + _is_confirm = await is_confirmation_intent(combined_query) + _has_full_info = (saved_state.get("ship_number") and saved_state.get("device_name") and saved_state.get("operation_item")) + _override_confirmed = _is_confirm and _has_full_info and not saved_state.get("user_confirmed_info", False) + + initial_state = {**saved_state,"combined_query": merged_query,"history_messages": parse_history(history_message),"initialized": False,"user_intent": "",} + if _override_confirmed: + print(f"[Checkpointer] 检测到确认意图,自动设置 user_confirmed_info=True") + initial_state["user_confirmed_info"] = True + result = await app.ainvoke(initial_state, config) + else: + initial_state = {"extracted_text": extracted_text,"combined_query": combined_query,"history_messages": parse_history(history_message),"ship_number": "","device_name": "","operation_item": "","additional_info": "", + "operation_code": "","operation_time": "","operation_frequency": "","tried_measures": "","running_condition": "","rag_search_result": None,"graph_search_result": None,"response": "", + "suggestedReplies": [],"actions": [],"error_message": "","agent_decision": "","agent_reasoning": "","tools_to_call": [],"missing_info": [],"info_completeness": 0.0, + "matched_kb_id": "","matched_kb_name": "","iteration_count": 0,"max_iterations": 5,"scheme_generated": False,"waiting_for_supplement": False,"extracted_info": {}, + "has_rag_result": False,"has_graph_result": False,"is_execution_feedback": False,"execution_detail": "","ask_round": 0,"user_confirmed_info": False,"need_model_confirm": False, + "user_confirmed_model": False,"waiting_feedback": False,"user_feedback": "","waiting_report_confirm": False,"report_confirmed": False,"initialized": False,"current_node": "","user_intent": "","last_combined_query": "",} + result = await app.ainvoke(initial_state, config) + except Exception as e: + print(f"[Checkpointer] 状态恢复失败: {e}") + import traceback + traceback.print_exc() + result = {} + else: + initial_state = {"extracted_text": extracted_text,"combined_query": combined_query,"history_messages": parse_history(history_message),"ship_number": "","device_name": "","operation_item": "","additional_info": "","operation_code": "","operation_time": "", + "operation_frequency": "","tried_measures": "","running_condition": "","rag_search_result": None,"graph_search_result": None,"response": "","suggestedReplies": [],"actions": [],"error_message": "","agent_decision": "","agent_reasoning": "","tools_to_call": [], + "missing_info": [],"info_completeness": 0.0,"matched_kb_id": "","matched_kb_name": "","iteration_count": 0,"max_iterations": 5,"scheme_generated": False,"waiting_for_supplement": False,"extracted_info": {},"has_rag_result": False,"has_graph_result": False, + "is_execution_feedback": False,"execution_detail": "","ask_round": 0,"user_confirmed_info": False,"need_model_confirm": False,"user_confirmed_model": False,"waiting_feedback": False,"user_feedback": "","waiting_report_confirm": False,"report_confirmed": False,"initialized": False,"current_node": "","user_intent": "","last_combined_query": "",} + + try: + result = await app.ainvoke(initial_state, config) + except Exception as e: + print(f"[工作流执行失败] {str(e)}") + import traceback + traceback.print_exc() + return {"response": f"操作工作流执行失败: {str(e)}","actions": [],"result_tag": "operate","suggestedReplies": [],} + + if result is None: + result = {} + + interrupts = result.get("__interrupt__", []) + if interrupts: + interrupt_data = interrupts[0].value if interrupts else {} + print(f"[Interrupt] 工作流中断,等待用户输入: {interrupt_data}") + return {"response": interrupt_data.get("response", "请提供更多信息"),"actions": interrupt_data.get("actions", []),"result_tag": "operate","suggestedReplies": interrupt_data.get("suggestedReplies", []),"waiting_for": interrupt_data.get("waiting_for", ""),} + + if result.get("error_message"): + return {"response": f"操作工作流执行失败: {result['error_message']}","actions": [],"result_tag": "operate","suggestedReplies": [],} + + route_flag = result.get("route_flag", "") + if not route_flag: + route_flag = "operate" + + return {"response": result.get("response", ""),"actions": result.get("actions", []),"result_tag": route_flag,"suggestedReplies": result.get("suggestedReplies", []),"route_flag": route_flag,} diff --git a/workflows/workflow_operate.py智能检索 b/workflows/workflow_operate.py智能检索 new file mode 100644 index 0000000..48db473 --- /dev/null +++ b/workflows/workflow_operate.py智能检索 @@ -0,0 +1,1470 @@ +""" +操作指导工作流 checkpointer 版本 +""" +from typing import TypedDict, Optional, Dict, Any, List +from langgraph.graph import StateGraph, START, END +from langgraph.types import interrupt, Command +from tools.function_tool import graph_rag_search +from tools.rag_tools import rag_search +from tools.graph_tools import atlas_retrieval, format_atlas_results, extract_entity_names_from_history +from modelsAPI.model_api import OpenaiAPI +from utils.function_tracker import get_all_callbacks +from utils.image_utils import extract_image_paths, get_image_prompt_guidance +from utils.intent_matcher import is_confirmation_intent +from workflows.workflow_baike import run_baike_workflow +from workflows.workflow_utils import ( + safe_json_extract, parse_history, normalize_latex_spacing, + remove_duplicate_content, convert_rag_result, calculate_info_completeness, + emit_callback_event, build_history_str, build_detail_info, + append_atlas_section, stream_format_and_postprocess, +) +from prompts import OPERATE_PROMPTS, COMMON_PROMPTS +import asyncio +import json +import re +import random + +class OperateGuideState(TypedDict): + """操作指导工作流状态""" + extracted_text: str + combined_query: str + history_messages: List[Dict[str, Any]] + + ship_number: str + device_name: str + operation_item: str + additional_info: str + operation_code: str + operation_time: str + operation_frequency: str + tried_measures: str + running_condition: str + + rag_search_result: Optional[List[Dict[str, Any]]] + graph_search_result: Optional[str] + deep_rag_results: Optional[Dict[str, Any]] + + response: str + suggestedReplies: List[Dict[str, Any]] + actions: List[Dict[str, Any]] + error_message: str + + agent_decision: str + agent_reasoning: str + tools_to_call: List[str] + missing_info: List[str] + info_completeness: float + + matched_kb_id: str + matched_kb_name: str + + iteration_count: int + max_iterations: int + scheme_generated: bool + waiting_for_supplement: bool + + extracted_info: Dict[str, Any] + has_rag_result: bool + has_graph_result: bool + ask_round: int + user_confirmed_info: bool + + need_model_confirm: bool + user_confirmed_model: bool + + user_feedback: str + waiting_report_confirm: bool + report_confirmed: bool + + initialized: bool + current_node: str + + # 意图分类相关字段 + user_intent: str # 用户初始意图: '操作', '百科' + + # 后续处理相关字段 + follow_up_intent: str # 用户后续意图: 'not_solved', 'related_question', 'has_error', 'modify_info', 'other' + user_feedback_for_regenerate: str # 用户用于重新生成方案的反馈 + is_regenerating: bool # 是否正在重新生成方案 + last_generated_scheme: str # 最后一次生成的方案(用于基于反馈修改) + scheme_type: str # 方案类型:'normal' 普通方案,'deep' 深度方案 + deep_analysis_points: Optional[List[str]] # 深度分析点(用于针对性检索) + atlas_results: Optional[Dict[str, Any]] # 图册检索结果 + last_combined_query: str # 上一轮生成方案时的用户输入 + +OPERATE_INFO_WEIGHTS = {"ship_number": 0.20, "device_name": 0.20, "operation_item": 0.25, "operation_code": 0.08, "operation_time": 0.06, "operation_frequency": 0.06, "tried_measures": 0.06, "running_condition": 0.05, "additional_info": 0.04} + +def _calculate_info_completeness(ship_number: str = "", device_name: str = "", operation_item: str = "", operation_code: str = "", operation_time: str = "", operation_frequency: str = "", tried_measures: str = "", running_condition: str = "", additional_info: str = "") -> float: + values = {"ship_number": ship_number, "device_name": device_name, "operation_item": operation_item, "operation_code": operation_code, "operation_time": operation_time, "operation_frequency": operation_frequency, "tried_measures": tried_measures, "running_condition": running_condition, "additional_info": additional_info} + return calculate_info_completeness(OPERATE_INFO_WEIGHTS, values) + +async def classify_intent(state: OperateGuideState) -> Dict[str, Any]: + """ + 意图分类节点 + """ + combined_query = state.get("combined_query", "") or "" + extracted_info = state.get("extracted_info", {}) or {} + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + + if extracted_info or device_name or operation_item or ship_number: + print("[classify_intent] 已在操作流程中,跳过意图分类") + return {"user_intent": "操作","current_node": "extract_info",} + + system_prompt = OPERATE_PROMPTS["classify_intent_system"] + + prompt = COMMON_PROMPTS["classify_intent_user"].format(combined_query=combined_query) + + try: + response_text = await OpenaiAPI.open_api_chat_without_thinking(query=prompt,model=None,json_output=True,system_prompt=system_prompt,messages=[]) + + parsed = safe_json_extract(response_text) + intent = "操作" + if isinstance(parsed, dict): + intent = parsed.get("intent", "操作") + + print(f"[意图分类] 用户意图: {intent}") + + if intent == "操作": + return {"user_intent": intent,"scheme_generated": False,"current_node": "extract_info",} + else: + return {"user_intent": intent,"current_node": "follow_up_qa",} + + except Exception as e: + print(f"[意图分类失败: {str(e)},默认按操作处理") + return {"user_intent": "操作","scheme_generated": False,"current_node": "extract_info",} + +def _route_after_classify(state: OperateGuideState) -> str: + """意图分类后的路由""" + intent = state.get("user_intent", "操作") + if intent == "百科": + return "follow_up_qa" + return "extract_info" + +async def extract_info(state: OperateGuideState) -> Dict[str, Any]: + """ + 信息提取节点 + """ + if state.get("initialized", False): + print("[extract_info] 已初始化,跳过") + return {"current_node": "agent_think"} + + combined_query = state.get("combined_query", "") or "" + history_messages = state.get("history_messages", []) or [] + + history_str = build_history_str(history_messages, recent_count=4) + + existing_info = [] + existing_ship_number = state.get("ship_number", "") or "" + existing_device_name = state.get("device_name", "") or "" + existing_operation_item = state.get("operation_item", "") or "" + existing_operation_code = state.get("operation_code", "") or "" + existing_operation_time = state.get("operation_time", "") or "" + existing_operation_frequency = state.get("operation_frequency", "") or "" + existing_tried_measures = state.get("tried_measures", "") or "" + existing_running_condition = state.get("running_condition", "") or "" + existing_additional_info = state.get("additional_info", "") or "" + + if existing_ship_number: + existing_info.append(f"舷号: {existing_ship_number}") + if existing_device_name: + existing_info.append(f"设备名称: {existing_device_name}") + if existing_operation_item: + existing_info.append(f"操作项目: {existing_operation_item}") + if existing_operation_code: + existing_info.append(f"操作代码: {existing_operation_code}") + if existing_operation_time: + existing_info.append(f"操作时间: {existing_operation_time}") + if existing_operation_frequency: + existing_info.append(f"操作频率: {existing_operation_frequency}") + if existing_tried_measures: + existing_info.append(f"已尝试措施: {existing_tried_measures}") + if existing_running_condition: + existing_info.append(f"运行工况: {existing_running_condition}") + if existing_additional_info: + existing_info.append(f"其他信息: {existing_additional_info}") + + existing_info_str = "\n".join(existing_info) if existing_info else "(无已提取信息)" + + system_prompt = OPERATE_PROMPTS["extract_info_system"] + + prompt = COMMON_PROMPTS["extract_info_user"].format( + biz_label="操作", + existing_info_str=existing_info_str, + history_str=history_str or '(无历史对话)', + combined_query=combined_query + ) + + try: + response_text = await OpenaiAPI.open_api_chat_without_thinking(query=prompt,model=None,json_output=True,system_prompt=system_prompt,messages=[]) + + parsed = safe_json_extract(response_text) + if not isinstance(parsed, dict): + parsed = {} + + new_ship_number = parsed.get("ship_number", "") or existing_ship_number + new_device_name = parsed.get("device_name", "") or existing_device_name + new_operation_item = parsed.get("operation_item", "") or existing_operation_item + new_operation_code = parsed.get("operation_code", "") or existing_operation_code + new_operation_time = parsed.get("operation_time", "") or existing_operation_time + new_operation_frequency = parsed.get("operation_frequency", "") or existing_operation_frequency + new_tried_measures = parsed.get("tried_measures", "") or existing_tried_measures + new_running_condition = parsed.get("running_condition", "") or existing_running_condition + new_additional_info = parsed.get("additional_info", "") or existing_additional_info + + extracted_info = {"ship_number": new_ship_number,"device_name": new_device_name,"operation_item": new_operation_item,"operation_code": new_operation_code,"operation_time": new_operation_time,"operation_frequency": new_operation_frequency,"tried_measures": new_tried_measures,"running_condition": new_running_condition,"additional_info": new_additional_info,} + + print(f"[信息提取] 舷号={extracted_info['ship_number']}, 设备={extracted_info['device_name']}, 操作={extracted_info['operation_item']}") + + return {**extracted_info,"extracted_info": extracted_info,"initialized": True,"current_node": "agent_think",} + + except Exception as e: + print(f"信息提取失败: {str(e)}") + return {"initialized": True,"current_node": "agent_think",} + +async def agent_think(state: OperateGuideState) -> Dict[str, Any]: + """ + Agent 思考节点:基于当前状态决定下一步动作 + """ + combined_query = state.get("combined_query", "") or "" + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + operation_code = state.get("operation_code", "") or "" + operation_time = state.get("operation_time", "") or "" + operation_frequency = state.get("operation_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + additional_info = state.get("additional_info", "") or "" + history_messages = state.get("history_messages", []) or [] + + scheme_generated = state.get("scheme_generated", False) + has_rag_result = state.get("has_rag_result", False) + has_graph_result = state.get("has_graph_result", False) + + rag_search_result = state.get("rag_search_result") + graph_search_result = state.get("graph_search_result") + iteration_count = state.get("iteration_count", 0) + + user_confirmed_info = state.get("user_confirmed_info", False) + waiting_feedback = state.get("waiting_feedback", False) + + has_ship = bool(ship_number and ship_number.strip()) + has_device = bool(device_name and device_name.strip()) + has_operation_item = bool(operation_item and operation_item.strip()) + has_rag = (rag_search_result is not None and len(rag_search_result) > 0) or has_rag_result + has_graph = (graph_search_result is not None and len(graph_search_result) > 100) or has_graph_result + + info_completeness = _calculate_info_completeness(ship_number=ship_number,device_name=device_name,operation_item=operation_item,operation_code=operation_code,operation_time=operation_time,operation_frequency=operation_frequency,tried_measures=tried_measures,running_condition=running_condition,additional_info=additional_info) + + MIN_COMPLETENESS_FOR_GENERATE = 0.25 + + if scheme_generated: + _re_diagnose_keywords = ["开始操作", "信息已足够", "信息已足够,开始操作", "开始", "操作"] + _is_re_diagnose = any(kw in combined_query for kw in _re_diagnose_keywords) + if _is_re_diagnose: + print(f"[重新操作] 检测到重新操作意图: '{combined_query}',重置状态直接调用工具生成方案") + tools_to_call = ["rag_search"] + if device_name and operation_item: + tools_to_call.append("graph_rag_search") + return {"scheme_generated": False,"rag_search_result": None,"graph_search_result": None,"deep_rag_results": None,"has_rag_result": False,"has_graph_result": False,"is_regenerating": False,"user_feedback_for_regenerate": "","follow_up_intent": "","user_confirmed_info": True,"initialized": True,"iteration_count": 0,"ask_round": 0,"agent_decision": "call_tools","tools_to_call": tools_to_call,"current_node": "call_tools",} + + history_str = build_history_str(history_messages, recent_count=5, assistant_truncate=300) + + intent_system_prompt = OPERATE_PROMPTS["agent_think_intent_system"] + + intent_prompt = OPERATE_PROMPTS["agent_think_intent_user"].format( + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + operation_item=operation_item or '未提供', + history_str=history_str or '无', + combined_query=combined_query + ) + + try: + intent_response = await OpenaiAPI.open_api_chat_without_thinking( + query=intent_prompt, + model=None, + json_output=True, + system_prompt=intent_system_prompt, + messages=[] + ) + + parsed_intent = safe_json_extract(intent_response) + intent = "other" + if isinstance(parsed_intent, dict): + intent = parsed_intent.get("intent", "other") + + print(f"[Agent 意图分析] 用户意图: {intent}") + + if intent == "modify_info": + decision = "confirm_info" + reasoning = "用户需要修改或补充信息" + return {"agent_decision": decision,"agent_reasoning": reasoning,"rag_search_result": None,"graph_search_result": None,"deep_rag_results": None,"has_rag_result": False,"has_graph_result": False,"scheme_generated": False,"is_regenerating": False,"user_feedback_for_regenerate": "","follow_up_intent": "","user_confirmed_info": False,"initialized": False,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + elif intent == "has_error": + decision = "handle_feedback" + reasoning = "用户反馈方案有错误" + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + return {"agent_decision": decision,"agent_reasoning": reasoning,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + elif intent == "not_solved": + decision = "analyze_follow_up_intent" + reasoning = "已生成方案,分析用户后续意图" + return {"agent_decision": decision,"agent_reasoning": reasoning,"follow_up_intent": "not_solved","info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + elif intent == "related_question": + decision = "analyze_follow_up_intent" + reasoning = "已生成方案,分析用户后续意图" + return {"agent_decision": decision,"agent_reasoning": reasoning,"follow_up_intent": "related_question","info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + else: + decision = "follow_up_qa" + reasoning = "其他意图,直接调用 baike" + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + return {"agent_decision": decision,"agent_reasoning": reasoning,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + except Exception as e: + print(f"[Agent 意图分析失败: {str(e)}") + decision = "analyze_follow_up_intent" + reasoning = "已生成方案,分析用户后续意图" + print(f"[Agent 决策] decision={decision}, reasoning={reasoning} (降级)") + return {"agent_decision": decision,"agent_reasoning": reasoning,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + + if not has_device or not has_operation_item: + decision = "ask_user" + missing_info = [] + if not has_device: + missing_info.append("设备名称") + if not has_operation_item: + missing_info.append("操作项目") + reasoning = f"缺少基本信息: {', '.join(missing_info)}" + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + return {"agent_decision": decision,"agent_reasoning": reasoning,"missing_info": missing_info,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + + if info_completeness < MIN_COMPLETENESS_FOR_GENERATE: + decision = "ask_user" + reasoning = f"信息完整性评分 {info_completeness} 低于最低要求 {MIN_COMPLETENESS_FOR_GENERATE}" + missing_info = [] + if not ship_number: + missing_info.append("舷号(可选)") + if not operation_code: + missing_info.append("操作代码") + if not operation_time: + missing_info.append("操作时间") + if not operation_frequency: + missing_info.append("操作频率") + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + return {"agent_decision": decision,"agent_reasoning": reasoning,"missing_info": missing_info or ["更多详细信息"],"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + + if has_rag or has_graph: + decision = "generate_response" + reasoning = "信息充足,已有检索结果,直接生成回复" + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + return {"agent_decision": decision,"agent_reasoning": reasoning,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + + if not user_confirmed_info: + decision = "confirm_info" + reasoning = "信息充足,需要用户确认后再检索知识库" + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + return {"agent_decision": decision,"agent_reasoning": reasoning,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + + decision = "call_tools" + reasoning = "信息充足,用户已确认,开始检索知识库" + tools_to_call = ["rag_search"] + if has_device and has_operation_item: + tools_to_call.append("graph_rag_search") + + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + print(f"[Agent 提取] 舷号={ship_number}, 设备={device_name}, 操作={operation_item}") + + emit_callback_event(["🤖 Agent 思考中", "🤖 智能分析", "🤖 决策判断"], f"决策: {decision} | 理由: {reasoning[:50]}...") + + return {"agent_decision": decision,"agent_reasoning": reasoning,"tools_to_call": tools_to_call,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + +async def ask_user(state: OperateGuideState) -> Command: + """ + 询问用户节点:使用 interrupt 暂停等待用户输入 + """ + missing_info = state.get("missing_info", []) + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + operation_code = state.get("operation_code", "") or "" + operation_time = state.get("operation_time", "") or "" + operation_frequency = state.get("operation_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + additional_info = state.get("additional_info", "") or "" + combined_query = state.get("combined_query", "") or "" + info_completeness = state.get("info_completeness", 0) + ask_round = state.get("ask_round", 0) + + MAX_ASK_ROUNDS = 4 + if ask_round >= MAX_ASK_ROUNDS: + print(f"[ask_user] 已达到最大询问轮次 {MAX_ASK_ROUNDS},强制进入下一步") + tools_to_call = ["rag_search"] + if device_name and operation_item: + tools_to_call.append("graph_rag_search") + return Command( + update={"ask_round": ask_round + 1,"current_node": "call_tools","tools_to_call": tools_to_call,}, + goto="call_tools" + ) + + existing_parts = [] + if ship_number: + existing_parts.append(f"舷号: {ship_number}") + if device_name: + existing_parts.append(f"设备名称: {device_name}") + if operation_item: + existing_parts.append(f"操作项目: {operation_item}") + if operation_code: + existing_parts.append(f"操作代码: {operation_code}") + if operation_time: + existing_parts.append(f"发生时间: {operation_time}") + if operation_frequency: + existing_parts.append(f"操作频率: {operation_frequency}") + if tried_measures: + existing_parts.append(f"已尝试措施: {tried_measures}") + if running_condition: + existing_parts.append(f"运行工况: {running_condition}") + if additional_info: + existing_parts.append(f"其他信息: {additional_info}") + + existing_str = ";".join(existing_parts) if existing_parts else "暂无" + missing_str = "、".join(missing_info) if missing_info else "相关信息" + + system_prompt = COMMON_PROMPTS["ask_user_system"].format(biz_label="操作指导", biz_label_short="指导") + + prompt = COMMON_PROMPTS["ask_user_prompt"].format( + info_completeness=info_completeness, + existing_str=existing_str, + missing_str=missing_str, + combined_query=combined_query + ) + + response = "" + try: + response = await OpenaiAPI.open_api_chat_without_thinking(query=prompt,model=None,system_prompt=system_prompt,messages=[]) + response = response.strip() if response else "" + except Exception as e: + print(f"[ask_user] API调用失败: {str(e)}") + response = "" + + if not response: + response = f"**【需要更多信息】**\n\n请补充:{missing_str}\n\n提供这些信息后,我可以更准确地为您指导操作。" + + if existing_parts: + existing_info_section = f"\n**📋 已获取的信息:**\n\n" + "\n".join( + f"- {part}" for part in existing_parts) + "\n\n---\n" + response = existing_info_section + response + + suggested_replies = [] + if "舷号" in missing_info: + suggested_replies.append({"title": "补充舷号", "content": "舷号:"}) + if "设备名称" in missing_info: + suggested_replies.append({"title": "补充设备名称", "content": "设备名称:"}) + if "操作项目" in missing_info: + suggested_replies.append({"title": "补充操作项目", "content": "操作项目:"}) + + user_input = interrupt({"response": response,"suggestedReplies": suggested_replies,"waiting_for": "user_input","missing_info": missing_info,}) + + user_query = user_input.get("query", "") + + update_dict = {"combined_query": user_query,"current_node": "agent_think","ask_round": ask_round + 1,} + + if user_query: + extract_prompt = OPERATE_PROMPTS["ask_user_extract"].format( + missing_str=missing_str, + user_query=user_query + ) + + try: + extract_response = await OpenaiAPI.open_api_chat_without_thinking(query=extract_prompt,model=None,json_output=True,system_prompt="你是信息提取专家。",messages=[]) + extracted = safe_json_extract(extract_response) + if isinstance(extracted, dict): + if extracted.get("ship_number"): + update_dict["ship_number"] = extracted["ship_number"] + print(f"[ask_user] 提取到舷号: {extracted['ship_number']}") + if extracted.get("device_name"): + update_dict["device_name"] = extracted["device_name"] + print(f"[ask_user] 提取到设备名称: {extracted['device_name']}") + if extracted.get("operation_item"): + update_dict["operation_item"] = extracted["operation_item"] + print(f"[ask_user] 提取到操作项目: {extracted['operation_item']}") + except Exception as e: + print(f"[ask_user] 信息提取失败: {str(e)}") + + return Command( + update=update_dict, + goto="agent_think" + ) + +async def confirm_info(state: OperateGuideState) -> Command: + """ + 确认信息节点:重点询问用户是否需要补充信息 + """ + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + operation_code = state.get("operation_code", "") or "" + operation_time = state.get("operation_time", "") or "" + operation_frequency = state.get("operation_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + additional_info = state.get("additional_info", "") or "" + info_completeness = state.get("info_completeness", 0) + + existing_parts = [] + if ship_number: + existing_parts.append(f"舷号: {ship_number}") + if device_name: + existing_parts.append(f"设备名称: {device_name}") + if operation_item: + existing_parts.append(f"操作项目: {operation_item}") + if operation_code: + existing_parts.append(f"操作代码: {operation_code}") + if operation_time: + existing_parts.append(f"发生时间: {operation_time}") + if operation_frequency: + existing_parts.append(f"操作频率: {operation_frequency}") + if tried_measures: + existing_parts.append(f"已尝试措施: {tried_measures}") + if running_condition: + existing_parts.append(f"运行工况: {running_condition}") + if additional_info: + existing_parts.append(f"其他信息: {additional_info}") + + existing_str = "\n".join(f"- {part}" for part in existing_parts) if existing_parts else "暂无" + + additional_prompt = "" + if not ship_number: + additional_prompt = "\n\n💡 **提示**:如果您知道舷号,请补充,这将有助于我们更准确地提供操作指导。" + + response = f"""**📋 已收集到的信息:** + +{existing_str} + +**信息完整性评分:** {info_completeness:.0%} + +--- + +**💡 请问是否需要补充其他信息?** +- 如操作代码、操作时间、操作频率、已尝试措施、运行工况等 +- 如果信息已足够,请点击"开始操作"{additional_prompt} +""" + + emit_callback_event(["✅ 信息确认", "📋 请确认信息", "🔍 准备检索"], f"等待用户确认或补充信息 (完整性: {info_completeness:.0%})") + + suggested_replies = [{"title": "开始操作", "content": "信息已足够,开始操作"},{"title": "补充信息", "content": "我需要补充:"}] + + user_input = interrupt({"response": response,"suggestedReplies": suggested_replies,"waiting_for": "user_confirm",}) + + user_query = user_input.get("query", "") + + if await is_confirmation_intent(user_query): + print(f"[confirm_info] 用户确认信息足够,准备调用工具") + tools_to_call = ["rag_search"] + if device_name and operation_item: + tools_to_call.append("graph_rag_search") + return Command( + update={"user_confirmed_info": True,"current_node": "call_tools","tools_to_call": tools_to_call,}, + goto="call_tools" + ) + else: + print(f"[confirm_info] 用户需要补充信息: {user_query}") + return Command( + update={"user_confirmed_info": False,"combined_query": user_query,"initialized": False,"current_node": "extract_info",}, + goto="extract_info" + ) + +async def call_tools(state: OperateGuideState) -> Dict[str, Any]: + """调用检索工具""" + tools_to_call = state.get("tools_to_call", []) + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + additional_info = state.get("additional_info", "") or "" + + rag_results = state.get("rag_search_result") + graph_results = state.get("graph_search_result") + atlas_results = {} + + matched_kb_name = "" + matched_kb_id = "" + + async def _do_rag_search(): + from utils.ship_number_search import search_with_ship_number_strategy + base_query = f"如何执行{device_name}的{operation_item}操作" + results, kb_name, kb_id, _ = await search_with_ship_number_strategy( + base_query=base_query, ship_number=ship_number, top_k=5, search_type="operate" + ) + if not kb_name: + kb_name = "通用知识库" + print(f"RAG 检索来源: {kb_name}") + return results, kb_name, kb_id + + async def _do_graph_search(): + if not (device_name and operation_item): + return "" + try: + graph_query = f"如何执行{device_name}的{operation_item}操作" + results = await graph_rag_search.ainvoke({"query": graph_query}) + print(f"图谱检索完成,结果长度: {len(results) if results else 0}") + return results + except Exception as e: + print(f"图谱检索失败: {str(e)}") + return "" + + need_rag = "rag_search" in tools_to_call and rag_results is None + need_graph = "graph_rag_search" in tools_to_call and graph_results is None + + if need_rag and need_graph: + (rag_results, matched_kb_name, matched_kb_id), graph_results = \ + await asyncio.gather(_do_rag_search(), _do_graph_search()) + elif need_rag: + rag_results, matched_kb_name, matched_kb_id = await _do_rag_search() + elif need_graph: + graph_results = await _do_graph_search() + + if device_name: + try: + print(f"[图册检索] 使用设备名称: {device_name}") + atlas_result = await atlas_retrieval(node_names=[device_name], top_k=10) + if atlas_result.get("success", False): + atlas_results = atlas_result.get("data", {}) + print(f"[图册检索] 图册检索结果: {list(atlas_results.keys())}") + except Exception as e: + print(f"[图册检索] 图册检索失败: {str(e)}") + + has_rag = bool(rag_results and len(rag_results) > 0) + has_graph = bool(graph_results and len(graph_results) > 100) + + if not has_rag and not has_graph: + print(f"[call_tools] 未找到任何相关资料,需要用户确认使用模型能力") + return {"rag_search_result": rag_results if isinstance(rag_results, list) else [],"graph_search_result": graph_results if isinstance(graph_results, str) else "","atlas_results": atlas_results,"matched_kb_id": matched_kb_id,"matched_kb_name": matched_kb_name,"has_rag_result": False,"has_graph_result": False,"need_model_confirm": True,"current_node": "model_confirm",} + + return {"rag_search_result": rag_results,"graph_search_result": graph_results,"atlas_results": atlas_results,"matched_kb_id": matched_kb_id,"matched_kb_name": matched_kb_name,"has_rag_result": has_rag,"has_graph_result": has_graph,"need_model_confirm": False,"current_node": "generate_response",} + +async def model_confirm(state: OperateGuideState) -> Command: + """ + 模型能力确认节点:当没有找到任何资料时,让用户确认是否使用模型本身能力 + """ + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + + response = f"""**⚠️ 未找到相关资料** + +抱歉,在知识库中未找到与以下信息相关的资料: +- 舷号:{ship_number or '未提供'} +- 设备名称:{device_name or '未提供'} +- 操作项目:{operation_item or '未提供'} + +**💡 您可以选择:** +- 使用 AI 模型的知识库为您生成一般性建议 +- 提供更详细的信息重新检索 + +请问是否使用 AI 模型为您生成建议?""" + + emit_callback_event(["🤖 AI 模型确认", "⚠️ 资料未找到", "💡 使用模型能力"], "使用模型自身知识进行回复") + + suggested_replies = [{"title": "使用 AI 模型", "content": "确认,使用 AI 模型生成建议"},{"title": "补充信息", "content": "我需要补充更多信息:"},] + + user_input = interrupt({"response": response,"suggestedReplies": suggested_replies,"waiting_for": "model_confirm",}) + + user_query = user_input.get("query", "") + + if "确认" in user_query or "AI" in user_query or "模型" in user_query: + print(f"[model_confirm] 用户确认使用模型能力") + return Command( + update={"user_confirmed_model": True,"combined_query": user_query,"current_node": "generate_response",}, + goto="generate_response" + ) + else: + print(f"[model_confirm] 用户选择补充信息: {user_query}") + return Command( + update={"user_confirmed_model": False,"combined_query": user_query,"current_node": "agent_think",}, + goto="agent_think" + ) + +async def generate_response(state: OperateGuideState) -> Dict[str, Any]: + """生成最终回复""" + print("========================生成最终回复========================") + combined_query = state.get("combined_query", "") or "" + history_messages = state.get("history_messages", []) or [] + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + additional_info = state.get("additional_info", "") or "" + operation_code = state.get("operation_code", "") or "" + operation_time = state.get("operation_time", "") or "" + operation_frequency = state.get("operation_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + rag_results = state.get("rag_search_result") or [] + graph_results = state.get("graph_search_result") or "" + atlas_results = state.get("atlas_results", {}) + scheme_generated = state.get("scheme_generated", False) + matched_kb_name = state.get("matched_kb_name", "") + + print("rag_results", rag_results) + history_str = build_history_str(history_messages, recent_count=5, assistant_truncate=500) + + has_rag = rag_results and len(rag_results) > 0 + has_graph = graph_results and len(graph_results) > 100 + + if not has_rag and not has_graph: + if not ship_number and not device_name and not operation_item: + system_prompt = COMMON_PROMPTS["generate_response_friendly_system"].format(biz_label="操作指导") + prompt = COMMON_PROMPTS["generate_response_friendly_user"].format(combined_query=combined_query) + else: + system_prompt = COMMON_PROMPTS["generate_response_no_rag_system"].format(biz_label="操作指导") + prompt = OPERATE_PROMPTS["generate_response_no_rag_user"].format( + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + operation_item=operation_item or '未提供', + operation_code=operation_code or '未提供', + operation_time=operation_time or '未提供', + operation_frequency=operation_frequency or '未提供', + tried_measures=tried_measures or '未提供', + running_condition=running_condition or '未提供', + additional_info=additional_info or '无' + ) + + try: + response = await OpenaiAPI.open_api_chat_without_thinking(query=prompt,model=None,system_prompt=system_prompt,messages=[]) + return {"response": response.strip() if response else "抱歉,未能找到相关资料。","suggestedReplies": [{"title": "提供更详细信息", "content": "我来提供更详细的信息"},],"current_node": "end",} + except Exception as e: + return {"response": f"抱歉,处理过程中出现错误: {str(e)}","suggestedReplies": [],"current_node": "end",} + + rag_ctx_parts: List[str] = [] + if isinstance(rag_results, str) and rag_results.strip(): + rag_ctx_parts = [rag_results.strip()] + elif isinstance(rag_results, list): + for item in sorted(rag_results, key=lambda x: float(x.get("score") or 0) if isinstance(x, dict) else 0, reverse=True)[:2]: +# for item in rag_results[:8]: + if isinstance(item, dict): + text = (item.get("text") or "").strip() + if text: + rag_ctx_parts.append(text) + elif isinstance(item, str) and item.strip(): + rag_ctx_parts.append(item.strip()) + + if len(graph_results) <= 50: + graph_results = "" + + all_source_text = graph_results + "\n" + "\n".join(rag_ctx_parts) if graph_results else "\n".join(rag_ctx_parts) + original_image_paths = extract_image_paths(all_source_text) + print("提取到的原始图片路径:", original_image_paths) + + rag_answer = "" + rag_type = "none" + + if graph_results and len(graph_results) > 50: + rag_answer = graph_results + rag_type = "graph" + elif rag_ctx_parts: + rag_answer = "\n\n".join(rag_ctx_parts[:2]) + rag_type = "rag" + + if not rag_answer or rag_answer == "NO_RELEVANT_RESULT": + system_prompt = COMMON_PROMPTS["generate_response_no_rag_simple_system"].format(biz_label="操作指导") + prompt = OPERATE_PROMPTS["generate_response_no_rag_simple_user"].format( + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + operation_item=operation_item or '未提供' + ) + + try: + response = await OpenaiAPI.open_api_chat_without_thinking(query=prompt,model=None,system_prompt=system_prompt,messages=[]) + return {"response": response.strip() if response else "抱歉,未能找到相关资料。","suggestedReplies": [],"current_node": "end",} + except Exception as e: + return {"response": f"抱歉,处理过程中出现错误: {str(e)}","suggestedReplies": [],"current_node": "end",} + + rag_type = "图谱" if rag_type == "graph" else "文本" + + emit_callback_event([f"✨当前使用的检索类型为{rag_type}", f"✨本次检索策略为{rag_type}"], f"内容为{rag_answer[:50]}...") + + detail_info = "" + if operation_code: + detail_info += f"- 操作代码/报警代码:{operation_code}\n" + if operation_time: + detail_info += f"- 操作发生时间:{operation_time}\n" + if operation_frequency: + detail_info += f"- 操作频率/持续时间:{operation_frequency}\n" + if tried_measures: + detail_info += f"- 已尝试的操作措施:{tried_measures}\n" + if running_condition: + detail_info += f"- 设备运行工况:{running_condition}\n" + if additional_info: + detail_info += f"- 其他相关信息:{additional_info}\n" + + is_regenerating = state.get("is_regenerating", False) + user_feedback_for_regenerate = state.get("user_feedback_for_regenerate", "") + is_follow_up = scheme_generated and (has_rag or has_graph) and not is_regenerating + + image_guidance = get_image_prompt_guidance() + if is_regenerating and user_feedback_for_regenerate: + prompt = COMMON_PROMPTS["generate_response_regenerating"].format( + biz_label="操作指导", + item_label="操作项目", + item_name=operation_item or '未提供', + item_action="分析操作要点,给出操作指导方案", + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + operation_item=operation_item or '未提供', + detail_info=detail_info, + user_feedback_for_regenerate=user_feedback_for_regenerate, + rag_answer=rag_answer, + image_guidance=image_guidance + ) + elif is_follow_up: + prompt = COMMON_PROMPTS["generate_response_follow_up"].format( + biz_label="操作指导", + item_label="操作项目", + item_name=operation_item or '未提供', + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + operation_item=operation_item or '未提供', + detail_info=detail_info, + rag_answer=rag_answer, + combined_query=combined_query, + image_guidance=image_guidance + ) + else: + prompt = COMMON_PROMPTS["generate_response_normal"].format( + biz_label="操作指导", + item_label="操作项目", + item_name=operation_item or '未提供', + item_action="分析操作要点,给出操作指导方案", + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + operation_item=operation_item or '未提供', + detail_info=detail_info, + rag_answer=rag_answer + ) + + try: + content_system_prompt = COMMON_PROMPTS["generate_response_content_system"].format(biz_label="操作指导") + + raw_content = await OpenaiAPI.open_api_chat_without_thinking(model=None,system_prompt=content_system_prompt,messages=[{"role": "user", "content": prompt}],) + + raw_content = raw_content.strip() if raw_content else "抱歉,无法生成回答。" + + emit_callback_event(["🤖 操作分析中", "🤖 方案生成中"], raw_content[:30] + "...") + + answer = await stream_format_and_postprocess(raw_content, original_image_paths, temperature=0.1) + + answer = append_atlas_section(answer, atlas_results) + + suggested_replies = [{"title": "方案有误", "content": "方案还是有问题:"},] + + return {"response": answer,"actions": [],"suggestedReplies": suggested_replies,"scheme_generated": True,"last_generated_scheme": answer,"scheme_type": "deep","current_node": "end","last_combined_query": combined_query,} + + except Exception as e: + return {"response": f"深度分析生成失败: {str(e)}","actions": [],"suggestedReplies": [],"current_node": "end",} + +async def regenerate_scheme_from_feedback(state: OperateGuideState) -> Dict[str, Any]: + """ + 基于用户反馈直接修改方案节点:使用之前保存的方案和用户反馈进行修改 + """ + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + additional_info = state.get("additional_info", "") or "" + operation_code = state.get("operation_code", "") or "" + operation_time = state.get("operation_time", "") or "" + operation_frequency = state.get("operation_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + combined_query = state.get("combined_query", "") or "" + + user_feedback = state.get("user_feedback_for_regenerate", "") or "" + last_scheme = state.get("last_generated_scheme", "") or "" + scheme_type = state.get("scheme_type", "normal") + + if not last_scheme: + print("[regenerate_scheme_from_feedback] 没有保存的方案,回退到重新检索") + return {"is_regenerating": True,"rag_search_result": None,"graph_search_result": None,"has_rag_result": False,"has_graph_result": False,"scheme_generated": False,"current_node": "call_tools","tools_to_call": ["rag_search", "graph_rag_search"],} + + detail_info = "" + if operation_code: + detail_info += f"- 操作代码/报警代码:{operation_code}\n" + if operation_time: + detail_info += f"- 操作发生时间:{operation_time}\n" + if operation_frequency: + detail_info += f"- 操作频率/持续时间:{operation_frequency}\n" + if tried_measures: + detail_info += f"- 已尝试的操作措施:{tried_measures}\n" + if running_condition: + detail_info += f"- 设备运行工况:{running_condition}\n" + if additional_info: + detail_info += f"- 其他相关信息:{additional_info}\n" + + emit_callback_event(["✏️ 修改方案中", "🔧 调整方案", "📝 优化方案"], f"根据反馈修改方案: {user_feedback[:30]}...") + + image_guidance = get_image_prompt_guidance() + + prompt = COMMON_PROMPTS["regenerate_scheme_from_feedback"].format( + biz_label="操作指导", + item_label="操作项目", + item_name=operation_item or '未提供', + item_action="分析操作要点,给出操作指导方案", + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + operation_item=operation_item or '未提供', + detail_info=detail_info, + last_scheme=last_scheme, + user_feedback=user_feedback, + image_guidance=image_guidance + ) + + try: + content_system_prompt = COMMON_PROMPTS["regenerate_scheme_from_feedback_system"].format(biz_label="操作指导") + + content_prompt = prompt.replace("【输出要求】", "【输出要求】\n- 请专注于内容的准确性、完整性和逻辑性,不要担心格式的美观性\n- 可以适当使用简单的段落分隔,但不要使用复杂的格式") + + raw_content = await OpenaiAPI.open_api_chat_without_thinking(model=None,system_prompt=content_system_prompt,messages=[{"role": "user", "content": content_prompt}],) + + raw_content = raw_content.strip() if raw_content else "抱歉,无法根据反馈修改方案。" + + original_image_paths = extract_image_paths(last_scheme) + answer = await stream_format_and_postprocess(raw_content, original_image_paths, temperature=0.3, content_label="操作指导方案") + + suggested_replies = [{"title": "方案有误", "content": "方案还是有问题:"},] + + return {"response": answer,"actions": [],"suggestedReplies": suggested_replies,"scheme_generated": True,"last_generated_scheme": answer,"scheme_type": scheme_type,"current_node": "end","last_combined_query": combined_query,} + + except Exception as e: + print(f"[regenerate_scheme_from_feedback] 修改方案失败: {str(e)}") + return {"is_regenerating": True,"rag_search_result": None,"graph_search_result": None,"has_rag_result": False,"has_graph_result": False,"scheme_generated": False,"current_node": "call_tools","tools_to_call": ["rag_search", "graph_rag_search"],} + +async def follow_up_qa(state: OperateGuideState) -> Dict[str, Any]: + """ + 后续问答节点:调用 baike 智能体处理用户的后续问题 + """ + combined_query = state.get("combined_query", "") or "" + history_messages = state.get("history_messages", []) or [] + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + + history_message_json = json.dumps(history_messages, ensure_ascii=False) if history_messages else "" + + context_prefix = "" + if ship_number or device_name or operation_item: + context_parts = [] + if ship_number: + context_parts.append(f"舷号: {ship_number}") + if device_name: + context_parts.append(f"设备名称: {device_name}") + if operation_item: + context_parts.append(f"操作项目: {operation_item}") + context_prefix = f"【当前操作指导上下文】\n{'; '.join(context_parts)}\n\n" + + enhanced_query = f"{context_prefix}用户后续问题: {combined_query}" + + emit_callback_event(["💬 后续问答", "💬 执行指导", "💬 问题解答"], f"调用 百科 智能体: {combined_query[:30]}...") + + try: + baike_result = await run_baike_workflow(extracted_text=enhanced_query,combined_query=enhanced_query,history_message=history_message_json,route_flag="baike") + + response = baike_result.get("response", "") + suggested_replies = baike_result.get("suggestedReplies", []) + + return {"response": response,"suggestedReplies": suggested_replies,"actions": [],"scheme_generated": True,"current_node": "end",} + + except Exception as e: + return {"response": f"处理您的问题时出现错误: {str(e)}","suggestedReplies": [],"actions": [],"current_node": "end",} + +async def handle_feedback(state: OperateGuideState) -> Command: + """ + 处理用户反馈节点:接收用户对方案的反馈,提供上报和重新生成方案按钮 + """ + combined_query = state.get("combined_query", "") or "" + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + + response = f"""**📝 已收到您的反馈** + +感谢您对操作指导方案的建议!您的反馈已记录: + +{combined_query} + +--- + +**📋 反馈信息摘要:** +- 舷号:{ship_number or '未提供'} +- 设备名称:{device_name or '未提供'} +- 操作项目:{operation_item or '未提供'} +- 反馈内容:{combined_query[:100]}{'...' if len(combined_query) > 100 else ''} + +--- + +**💡 您可以选择:** +- 点击「重新生成方案」根据您的反馈重新生成方案 +- 点击「上报」将反馈提交给相关部门 +- 继续对话获取更多帮助""" + + actions = [ + {"type": "content","label": "重新生成方案","text": "重新生成方案"}, + {"type": "content","label": "上报","text": "上报"} + ] + + suggested_replies = [] + + emit_callback_event(["📝 反馈处理", "📋 方案反馈", "✅ 反馈确认"], f"处理用户反馈: {combined_query[:30]}...") + + user_input = interrupt({"response": response,"actions": actions,"suggestedReplies": suggested_replies,"waiting_for": "feedback_confirm",}) + + user_text = user_input.get("query", "") or "" + + if user_text == "重新生成方案": + print(f"[handle_feedback] 用户选择重新生成方案") + return Command( + update={"user_feedback_for_regenerate": combined_query,"current_node": "regenerate_scheme_from_feedback",}, + goto="regenerate_scheme_from_feedback" + ) + elif user_text == "上报": + print(f"[handle_feedback] 用户选择上报反馈") + return Command( + update={"user_feedback": combined_query,"waiting_report_confirm": True,"current_node": "confirm_report",}, + goto="confirm_report" + ) + else: + print(f"[handle_feedback] 用户继续对话: {user_text}") + return Command( + update={"user_feedback": combined_query,"combined_query": user_text,"current_node": "agent_think",}, + goto="agent_think" + ) + +async def confirm_report(state: OperateGuideState) -> Dict[str, Any]: + """ + 上报确认节点:用户点击上报后确认完成 + """ + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + user_feedback = state.get("user_feedback", "") or "" + + response = f"""**✅ 上报成功** + +您的反馈已成功上报! + +--- + +**📋 上报信息:** +- 舷号:{ship_number or '未提供'} +- 设备名称:{device_name or '未提供'} +- 操作项目:{operation_item or '未提供'} +- 反馈内容:{user_feedback[:100]}{'...' if len(user_feedback) > 100 else ''} + +--- + +**💡 感谢您的反馈,相关部门将及时处理。**""" + + suggested_replies = [{"title": "继续对话", "content": "我还有其他问题"},] + + emit_callback_event(["✅ 上报成功", "📋 反馈已提交", "🎉 完成"], "反馈上报成功") + + return {"response": response,"actions": [],"suggestedReplies": suggested_replies,"report_confirmed": True,"waiting_report_confirm": False,"current_node": "end",} + +async def analyze_follow_up_intent(state: OperateGuideState) -> Dict[str, Any]: + """ + 分析用户后续意图节点:直接使用 agent_think 已经判断好的意图 + agent_think 已经用大模型判断并设置了 follow_up_intent + """ + intent = state.get("follow_up_intent", "other") + + print(f"[意图分析] 使用已判断的意图: {intent}") + + return {"follow_up_intent": intent,} + + +async def deep_rag_search(state: OperateGuideState) -> Dict[str, Any]: + """ + 深层次RAG检索节点:分析之前方案并选择深度分析点进行检索 + """ + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + operation_code = state.get("operation_code", "") or "" + additional_info = state.get("additional_info", "") or "" + operation_time = state.get("operation_time", "") or "" + operation_frequency = state.get("operation_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + combined_query = state.get("combined_query", "") or "" + history_messages = state.get("history_messages", []) or [] + last_generated_scheme = state.get("last_generated_scheme", "") or "" + + deep_results = {} + deep_analysis_points = [] + + emit_callback_event(["🔍 深度检索中", "🔍 多维度检索", "🔍 精细分析"], "分析问题并选择深度分析点") + + # ========== 第一步:分析之前方案和用户问题,选择深度分析点 ========== + if last_generated_scheme or combined_query: + try: + history_str = build_history_str(history_messages, recent_count=4, assistant_truncate=300) + + detail_info = "" + if operation_code: + detail_info += f"- 操作代码:{operation_code}\n" + if operation_time: + detail_info += f"- 操作时间:{operation_time}\n" + if operation_frequency: + detail_info += f"- 操作频率/持续时间:{operation_frequency}\n" + if tried_measures: + detail_info += f"- 已尝试的措施:{tried_measures}\n" + if running_condition: + detail_info += f"- 设备运行工况:{running_condition}\n" + if additional_info: + detail_info += f"- 其他相关信息:{additional_info}\n" + + analysis_system_prompt = COMMON_PROMPTS["deep_rag_analysis_system"].format(biz_label="操作指导") + + analysis_prompt = COMMON_PROMPTS["deep_rag_analysis_user"].format( + item_label="操作项目", + item_name=operation_item or '未提供', + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + operation_item=operation_item or '未提供', + detail_info=detail_info, + history_str=history_str or '(无历史对话)', + last_generated_scheme=last_generated_scheme or '(无之前的方案)', + combined_query=combined_query + ) + + analysis_response = await OpenaiAPI.open_api_chat_without_thinking(query=analysis_prompt,model=None,json_output=True,system_prompt=analysis_system_prompt,messages=[]) + print("深度分析点", analysis_response) + parsed_analysis = safe_json_extract(analysis_response) + deep_analysis_points = parsed_analysis + print(f"[深度RAG] 选择的分析点: {deep_analysis_points}") + except Exception as e: + print(f"[深度RAG] 分析失败: {str(e)}") + + # ========== 第二步:对选中的分析点进行检索 ========== + for idx, point in enumerate(deep_analysis_points): + try: + print(point) + search_query = point + if device_name and device_name not in point: + search_query = f"{device_name} {search_query}" + if operation_item and operation_item not in search_query: + search_query = f"{search_query} {operation_item}" + + rag_result = await rag_search(search_query, top_k=3) + if rag_result.get("success", False): + deep_results[f"analysis_point_{idx}"] = convert_rag_result(rag_result) + else: + deep_results[f"analysis_point_{idx}"] = [] + except Exception as e: + print(f"[深度RAG] 分析点 {point} 检索失败: {str(e)}") + deep_results[f"analysis_point_{idx}"] = [] + + # ========== 第三步:图谱检索 ========== + graph_deep_results = "" + try: + if device_name and operation_item: + graph_query = f"设备{device_name},操作{operation_item}的操作指导方案" + graph_deep_results = await graph_rag_search.ainvoke({"query": graph_query}) + if graph_deep_results: + print(f"[深度RAG] 图谱检索结果长度: {len(graph_deep_results)}") + except Exception as e: + print(f"[深度RAG] 图谱检索失败: {str(e)}") + + # ========== 第四步:图册检索 ========== + atlas_results = {} + try: + entity_names = await extract_entity_names_from_history(history_messages=history_messages,device_name=device_name,operation_item=operation_item,last_generated_scheme=last_generated_scheme) + + if entity_names: + print(f"[深度RAG] 图册检索实体: {entity_names}") + atlas_result = await atlas_retrieval(node_names=entity_names, top_k=10) + if atlas_result.get("success", False): + atlas_results = atlas_result.get("data", {}) + print(f"[深度RAG] 图册检索结果: {list(atlas_results.keys())}") + except Exception as e: + print(f"[深度RAG] 图册检索失败: {str(e)}") + + return {"deep_rag_results": deep_results,"deep_analysis_points": deep_analysis_points,"graph_search_result": graph_deep_results or (state.get("graph_search_result", "") or ""),"atlas_results": atlas_results,"current_node": "generate_deep_response",} + + +async def generate_deep_response(state: OperateGuideState) -> Dict[str, Any]: + """ + 生成深度响应节点:基于深度RAG结果进行深度润色和推理 + """ + combined_query = state.get("combined_query", "") or "" + history_messages = state.get("history_messages", []) or [] + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + operation_code = state.get("operation_code", "") or "" + operation_time = state.get("operation_time", "") or "" + operation_frequency = state.get("operation_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + additional_info = state.get("additional_info", "") or "" + + deep_rag_results = state.get("deep_rag_results", {}) + deep_analysis_points = state.get("deep_analysis_points", []) + atlas_results = state.get("atlas_results", {}) + graph_results = state.get("graph_search_result", "") or "" + original_rag_results = state.get("rag_search_result", []) + + # 整理深度分析点资料 + analysis_points_text = "" + if deep_analysis_points: + for idx, point in enumerate(deep_analysis_points): + key = f"analysis_point_{idx}" + results = deep_rag_results.get(key, []) + if results: + analysis_points_text += f"\n【分析点:{point}】\n" + for i, item in enumerate(results[:2], 1): + if isinstance(item, dict): + text = item.get("text", "") + if text: + analysis_points_text += f"[{i}] {text}\n" + + # 整理原始RAG结果 + original_rag_text = "" + if original_rag_results: + original_rag_text = "\n【原始检索资料】\n" + for i, item in enumerate(original_rag_results[:3], 1): + if isinstance(item, dict): + text = item.get("text", "") + if text: + original_rag_text += f"[{i}] {text}\n" + + # 整理图册资料 + atlas_text = format_atlas_results(atlas_results) + + # 提取所有资料中的图片路径 + all_source_text = analysis_points_text + original_rag_text + atlas_text + graph_results + original_image_paths = extract_image_paths(all_source_text) + + detail_info = "" + if operation_code: + detail_info += f"- 操作代码:{operation_code}\n" + if operation_time: + detail_info += f"- 操作时间:{operation_time}\n" + if operation_frequency: + detail_info += f"- 操作频率/持续时间:{operation_frequency}\n" + if tried_measures: + detail_info += f"- 已尝试的措施:{tried_measures}\n" + if running_condition: + detail_info += f"- 设备运行工况:{running_condition}\n" + if additional_info: + detail_info += f"- 其他相关信息:{additional_info}\n" + + # 构建深度分析点的提示 + analysis_points_prompt = "" + if deep_analysis_points: + analysis_points_prompt = f"\n【本次深度分析重点】\n" + "\n".join([f"- {p}" for p in deep_analysis_points]) + "\n" + + prompt = COMMON_PROMPTS["generate_deep_response"].format( + biz_label="操作指导", + item_label="操作项目", + item_name=operation_item or '未提供', + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + operation_item=operation_item or '未提供', + detail_info=detail_info, + combined_query=combined_query, + analysis_points_prompt=analysis_points_prompt, + analysis_points_text=analysis_points_text, + original_rag_text=original_rag_text, + graph_results=graph_results if len(graph_results) > 50 else '(无)' + ) + + try: + content_system_prompt = COMMON_PROMPTS["generate_deep_response_content_system"].format(biz_label="操作指导") + + raw_content = await OpenaiAPI.open_api_chat_without_thinking(model=None,system_prompt=content_system_prompt,messages=[{"role": "user", "content": prompt}],) + + raw_content = raw_content.strip() if raw_content else "抱歉,无法生成深度分析。" + + emit_callback_event(["🤖 分析中", "🤖 操作指导生成中"], raw_content[:30] + "...") + + answer = await stream_format_and_postprocess(raw_content, original_image_paths, temperature=0.1) + + answer = append_atlas_section(answer, atlas_results) + + suggested_replies = [{"title": "方案有误", "content": "方案还是有问题:"},] + + return {"response": answer,"actions": [],"suggestedReplies": suggested_replies,"scheme_generated": True,"last_generated_scheme": answer,"scheme_type": "deep","current_node": "end","last_combined_query": combined_query,} + + except Exception as e: + return {"response": f"深度分析生成失败: {str(e)}","actions": [],"suggestedReplies": [],"current_node": "end",} + + +def _route_after_think(state: OperateGuideState) -> str: + """Agent 思考后的路由""" + decision = state.get("agent_decision", "ask_user") + + return decision + +def _route_after_intent(state: OperateGuideState) -> str: + """意图分析后的路由""" + intent = state.get("follow_up_intent", "other") + + if intent == "not_solved": + return "deep_rag_search" + elif intent == "related_question": + return "follow_up_qa" + elif intent == "has_error": + return "handle_feedback" + elif intent == "modify_info": + return "extract_info" + else: + return "follow_up_qa" + +def _route_after_tools(state: OperateGuideState) -> str: + """检索工具调用后的路由""" + need_model_confirm = state.get("need_model_confirm", False) + if need_model_confirm: + return "model_confirm" + return "generate_response" + +def create_operate_workflow(checkpointer=None): + """ + 创建操作指导工作流(基于 Checkpointer 的状态持久化版本) + """ + workflow = StateGraph(OperateGuideState) + + workflow.add_node("classify_intent", classify_intent) + workflow.add_node("extract_info", extract_info) + workflow.add_node("agent_think", agent_think) + workflow.add_node("ask_user", ask_user) + workflow.add_node("confirm_info", confirm_info) + workflow.add_node("call_tools", call_tools) + workflow.add_node("generate_response", generate_response) + workflow.add_node("follow_up_qa", follow_up_qa) + workflow.add_node("model_confirm", model_confirm) + workflow.add_node("handle_feedback", handle_feedback) + workflow.add_node("confirm_report", confirm_report) + workflow.add_node("analyze_follow_up_intent", analyze_follow_up_intent) + workflow.add_node("deep_rag_search", deep_rag_search) + workflow.add_node("generate_deep_response", generate_deep_response) + workflow.add_node("regenerate_scheme_from_feedback", regenerate_scheme_from_feedback) + + workflow.add_edge(START, "classify_intent") + workflow.add_conditional_edges("classify_intent", _route_after_classify, + {"extract_info": "extract_info","follow_up_qa": "follow_up_qa",}) + workflow.add_edge("extract_info", "agent_think") + workflow.add_conditional_edges("agent_think", _route_after_think, + {"ask_user": "ask_user","confirm_info": "confirm_info","call_tools": "call_tools","generate_response": "generate_response","follow_up": "follow_up_qa","follow_up_qa": "follow_up_qa","handle_feedback": "handle_feedback","extract_info": "extract_info","analyze_follow_up_intent": "analyze_follow_up_intent",}) + workflow.add_conditional_edges("call_tools", _route_after_tools, {"model_confirm": "model_confirm","generate_response": "generate_response",}) + workflow.add_conditional_edges("analyze_follow_up_intent", _route_after_intent, {"deep_rag_search": "deep_rag_search","follow_up_qa": "follow_up_qa","handle_feedback": "handle_feedback","extract_info": "extract_info",}) + workflow.add_edge("deep_rag_search", "generate_deep_response") + workflow.add_edge("generate_deep_response", END) + workflow.add_edge("generate_response", END) + workflow.add_edge("follow_up_qa", END) + workflow.add_edge("regenerate_scheme_from_feedback", END) + workflow.add_edge("handle_feedback", END) + workflow.add_edge("confirm_report", END) + + return workflow.compile(checkpointer=checkpointer) + +async def run_operate_workflow(extracted_text: str,combined_query: str,history_message: str = "",route_flag: str = "",previous_state_json: str = "",chat_id: str = "",message_id: str = "",checkpointer=None,user_response: Dict[str, Any] = None) -> Dict[str, Any]: + """ + 执行操作指导工作流 + + Args: + extracted_text: 文本描述 + combined_query: 组合查询 + history_message: 历史消息(JSON格式) + route_flag: 路由标识 + previous_state_json: 兼容参数(已废弃) + chat_id: 聊天会话 ID + message_id: 消息 ID + checkpointer: LangGraph checkpointer 实例 + user_response: 用户恢复执行的响应数据 + + Returns: + 工作流执行结果 + """ + thread_id = chat_id if chat_id else None + + config = {"configurable": {"thread_id": thread_id}} if thread_id else {} + + print(f"[DEBUG] run_operate_workflow: thread_id={thread_id}, checkpointer={checkpointer is not None}") + + if checkpointer is None: + from checkpointer_config import checkpointer_manager + checkpointer = await checkpointer_manager.get_async_checkpointer() + + app = create_operate_workflow(checkpointer=checkpointer) + + if thread_id: + try: + current_state = await app.aget_state(config) + print( + f"[DEBUG] current_state exists: {current_state is not None}, has values: {current_state.values is not None if current_state else False}, tasks: {len(current_state.tasks) if current_state else 0}") + + if current_state and current_state.values: + saved_state = current_state.values + print( + f"[Checkpointer] 发现已保存状态: ship_number={saved_state.get('ship_number')}, device_name={saved_state.get('device_name')}, operation_item={saved_state.get('operation_item')}") + + if current_state.tasks: + print(f"[Checkpointer] 恢复执行,注入用户响应") + resume_value = user_response if user_response else {"query": combined_query} + result = await app.ainvoke(Command(resume=resume_value), config) + else: + merged_query = combined_query + + _is_confirm = await is_confirmation_intent(combined_query) + _has_full_info = (saved_state.get("ship_number") and saved_state.get("device_name") and saved_state.get("operation_item")) + _override_confirmed = _is_confirm and _has_full_info and not saved_state.get("user_confirmed_info", False) + + initial_state = {**saved_state,"combined_query": merged_query,"history_messages": parse_history(history_message),"initialized": False,"user_intent": "",} + if _override_confirmed: + print(f"[Checkpointer] 检测到确认意图,自动设置 user_confirmed_info=True") + initial_state["user_confirmed_info"] = True + result = await app.ainvoke(initial_state, config) + else: + initial_state = {"extracted_text": extracted_text,"combined_query": combined_query,"history_messages": parse_history(history_message),"ship_number": "","device_name": "","operation_item": "","additional_info": "", + "operation_code": "","operation_time": "","operation_frequency": "","tried_measures": "","running_condition": "","rag_search_result": None,"graph_search_result": None,"response": "", + "suggestedReplies": [],"actions": [],"error_message": "","agent_decision": "","agent_reasoning": "","tools_to_call": [],"missing_info": [],"info_completeness": 0.0, + "matched_kb_id": "","matched_kb_name": "","iteration_count": 0,"max_iterations": 5,"scheme_generated": False,"waiting_for_supplement": False,"extracted_info": {}, + "has_rag_result": False,"has_graph_result": False,"is_execution_feedback": False,"execution_detail": "","ask_round": 0,"user_confirmed_info": False,"need_model_confirm": False, + "user_confirmed_model": False,"waiting_feedback": False,"user_feedback": "","waiting_report_confirm": False,"report_confirmed": False,"initialized": False,"current_node": "","user_intent": "","last_combined_query": "",} + result = await app.ainvoke(initial_state, config) + except Exception as e: + print(f"[Checkpointer] 状态恢复失败: {e}") + import traceback + traceback.print_exc() + result = {} + else: + initial_state = {"extracted_text": extracted_text,"combined_query": combined_query,"history_messages": parse_history(history_message),"ship_number": "","device_name": "","operation_item": "","additional_info": "","operation_code": "","operation_time": "", + "operation_frequency": "","tried_measures": "","running_condition": "","rag_search_result": None,"graph_search_result": None,"response": "","suggestedReplies": [],"actions": [],"error_message": "","agent_decision": "","agent_reasoning": "","tools_to_call": [], + "missing_info": [],"info_completeness": 0.0,"matched_kb_id": "","matched_kb_name": "","iteration_count": 0,"max_iterations": 5,"scheme_generated": False,"waiting_for_supplement": False,"extracted_info": {},"has_rag_result": False,"has_graph_result": False, + "is_execution_feedback": False,"execution_detail": "","ask_round": 0,"user_confirmed_info": False,"need_model_confirm": False,"user_confirmed_model": False,"waiting_feedback": False,"user_feedback": "","waiting_report_confirm": False,"report_confirmed": False,"initialized": False,"current_node": "","user_intent": "","last_combined_query": "",} + + try: + result = await app.ainvoke(initial_state, config) + except Exception as e: + print(f"[工作流执行失败] {str(e)}") + import traceback + traceback.print_exc() + return {"response": f"操作工作流执行失败: {str(e)}","actions": [],"result_tag": "operate","suggestedReplies": [],} + + if result is None: + result = {} + + interrupts = result.get("__interrupt__", []) + if interrupts: + interrupt_data = interrupts[0].value if interrupts else {} + print(f"[Interrupt] 工作流中断,等待用户输入: {interrupt_data}") + return {"response": interrupt_data.get("response", "请提供更多信息"),"actions": interrupt_data.get("actions", []),"result_tag": "operate","suggestedReplies": interrupt_data.get("suggestedReplies", []),"waiting_for": interrupt_data.get("waiting_for", ""),} + + if result.get("error_message"): + return {"response": f"操作工作流执行失败: {result['error_message']}","actions": [],"result_tag": "operate","suggestedReplies": [],} + + route_flag = result.get("route_flag", "") + if not route_flag: + route_flag = "operate" + + return {"response": result.get("response", ""),"actions": result.get("actions", []),"result_tag": route_flag,"suggestedReplies": result.get("suggestedReplies", []),"route_flag": route_flag,} + + diff --git a/workflows/workflow_operate624.py b/workflows/workflow_operate624.py new file mode 100644 index 0000000..c471b90 --- /dev/null +++ b/workflows/workflow_operate624.py @@ -0,0 +1,1469 @@ +""" +操作指导工作流 checkpointer 版本 +""" +from typing import TypedDict, Optional, Dict, Any, List +from langgraph.graph import StateGraph, START, END +from langgraph.types import interrupt, Command +from tools.function_tool import graph_rag_search +from tools.rag_tools import rag_search +from tools.graph_tools import atlas_retrieval, format_atlas_results, extract_entity_names_from_history +from modelsAPI.model_api import OpenaiAPI +from utils.function_tracker import get_all_callbacks +from utils.image_utils import extract_image_paths, get_image_prompt_guidance +from utils.intent_matcher import is_confirmation_intent +from workflows.workflow_baike import run_baike_workflow +from workflows.workflow_utils import ( + safe_json_extract, parse_history, normalize_latex_spacing, + remove_duplicate_content, convert_rag_result, calculate_info_completeness, + emit_callback_event, build_history_str, build_detail_info, + append_atlas_section, stream_format_and_postprocess, +) +from prompts import OPERATE_PROMPTS, COMMON_PROMPTS +import asyncio +import json +import re +import random + +class OperateGuideState(TypedDict): + """操作指导工作流状态""" + extracted_text: str + combined_query: str + history_messages: List[Dict[str, Any]] + + ship_number: str + device_name: str + operation_item: str + additional_info: str + operation_code: str + operation_time: str + operation_frequency: str + tried_measures: str + running_condition: str + + rag_search_result: Optional[List[Dict[str, Any]]] + graph_search_result: Optional[str] + deep_rag_results: Optional[Dict[str, Any]] + + response: str + suggestedReplies: List[Dict[str, Any]] + actions: List[Dict[str, Any]] + error_message: str + + agent_decision: str + agent_reasoning: str + tools_to_call: List[str] + missing_info: List[str] + info_completeness: float + + matched_kb_id: str + matched_kb_name: str + + iteration_count: int + max_iterations: int + scheme_generated: bool + waiting_for_supplement: bool + + extracted_info: Dict[str, Any] + has_rag_result: bool + has_graph_result: bool + ask_round: int + user_confirmed_info: bool + + need_model_confirm: bool + user_confirmed_model: bool + + user_feedback: str + waiting_report_confirm: bool + report_confirmed: bool + + initialized: bool + current_node: str + + # 意图分类相关字段 + user_intent: str # 用户初始意图: '操作', '百科' + + # 后续处理相关字段 + follow_up_intent: str # 用户后续意图: 'not_solved', 'related_question', 'has_error', 'modify_info', 'other' + user_feedback_for_regenerate: str # 用户用于重新生成方案的反馈 + is_regenerating: bool # 是否正在重新生成方案 + last_generated_scheme: str # 最后一次生成的方案(用于基于反馈修改) + scheme_type: str # 方案类型:'normal' 普通方案,'deep' 深度方案 + deep_analysis_points: Optional[List[str]] # 深度分析点(用于针对性检索) + atlas_results: Optional[Dict[str, Any]] # 图册检索结果 + last_combined_query: str # 上一轮生成方案时的用户输入 + +OPERATE_INFO_WEIGHTS = {"ship_number": 0.20, "device_name": 0.20, "operation_item": 0.25, "operation_code": 0.08, "operation_time": 0.06, "operation_frequency": 0.06, "tried_measures": 0.06, "running_condition": 0.05, "additional_info": 0.04} + +def _calculate_info_completeness(ship_number: str = "", device_name: str = "", operation_item: str = "", operation_code: str = "", operation_time: str = "", operation_frequency: str = "", tried_measures: str = "", running_condition: str = "", additional_info: str = "") -> float: + values = {"ship_number": ship_number, "device_name": device_name, "operation_item": operation_item, "operation_code": operation_code, "operation_time": operation_time, "operation_frequency": operation_frequency, "tried_measures": tried_measures, "running_condition": running_condition, "additional_info": additional_info} + return calculate_info_completeness(OPERATE_INFO_WEIGHTS, values) + +async def classify_intent(state: OperateGuideState) -> Dict[str, Any]: + """ + 意图分类节点 + """ + combined_query = state.get("combined_query", "") or "" + extracted_info = state.get("extracted_info", {}) or {} + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + + if extracted_info or device_name or operation_item or ship_number: + print("[classify_intent] 已在操作流程中,跳过意图分类") + return {"user_intent": "操作","current_node": "extract_info",} + + system_prompt = OPERATE_PROMPTS["classify_intent_system"] + + prompt = COMMON_PROMPTS["classify_intent_user"].format(combined_query=combined_query) + + try: + response_text = await OpenaiAPI.open_api_chat_without_thinking(query=prompt,model=None,json_output=True,system_prompt=system_prompt,messages=[]) + + parsed = safe_json_extract(response_text) + intent = "操作" + if isinstance(parsed, dict): + intent = parsed.get("intent", "操作") + + print(f"[意图分类] 用户意图: {intent}") + + if intent == "操作": + return {"user_intent": intent,"scheme_generated": False,"current_node": "extract_info",} + else: + return {"user_intent": intent,"current_node": "follow_up_qa",} + + except Exception as e: + print(f"[意图分类失败: {str(e)},默认按操作处理") + return {"user_intent": "操作","scheme_generated": False,"current_node": "extract_info",} + +def _route_after_classify(state: OperateGuideState) -> str: + """意图分类后的路由""" + intent = state.get("user_intent", "操作") + if intent == "百科": + return "follow_up_qa" + return "extract_info" + +async def extract_info(state: OperateGuideState) -> Dict[str, Any]: + """ + 信息提取节点 + """ + if state.get("initialized", False): + print("[extract_info] 已初始化,跳过") + return {"current_node": "agent_think"} + + combined_query = state.get("combined_query", "") or "" + history_messages = state.get("history_messages", []) or [] + + history_str = build_history_str(history_messages, recent_count=4) + + existing_info = [] + existing_ship_number = state.get("ship_number", "") or "" + existing_device_name = state.get("device_name", "") or "" + existing_operation_item = state.get("operation_item", "") or "" + existing_operation_code = state.get("operation_code", "") or "" + existing_operation_time = state.get("operation_time", "") or "" + existing_operation_frequency = state.get("operation_frequency", "") or "" + existing_tried_measures = state.get("tried_measures", "") or "" + existing_running_condition = state.get("running_condition", "") or "" + existing_additional_info = state.get("additional_info", "") or "" + + if existing_ship_number: + existing_info.append(f"舷号: {existing_ship_number}") + if existing_device_name: + existing_info.append(f"设备名称: {existing_device_name}") + if existing_operation_item: + existing_info.append(f"操作项目: {existing_operation_item}") + if existing_operation_code: + existing_info.append(f"操作代码: {existing_operation_code}") + if existing_operation_time: + existing_info.append(f"操作时间: {existing_operation_time}") + if existing_operation_frequency: + existing_info.append(f"操作频率: {existing_operation_frequency}") + if existing_tried_measures: + existing_info.append(f"已尝试措施: {existing_tried_measures}") + if existing_running_condition: + existing_info.append(f"运行工况: {existing_running_condition}") + if existing_additional_info: + existing_info.append(f"其他信息: {existing_additional_info}") + + existing_info_str = "\n".join(existing_info) if existing_info else "(无已提取信息)" + + system_prompt = OPERATE_PROMPTS["extract_info_system"] + + prompt = COMMON_PROMPTS["extract_info_user"].format( + biz_label="操作", + existing_info_str=existing_info_str, + history_str=history_str or '(无历史对话)', + combined_query=combined_query + ) + + try: + response_text = await OpenaiAPI.open_api_chat_without_thinking(query=prompt,model=None,json_output=True,system_prompt=system_prompt,messages=[]) + + parsed = safe_json_extract(response_text) + if not isinstance(parsed, dict): + parsed = {} + + new_ship_number = parsed.get("ship_number", "") or existing_ship_number + new_device_name = parsed.get("device_name", "") or existing_device_name + new_operation_item = parsed.get("operation_item", "") or existing_operation_item + new_operation_code = parsed.get("operation_code", "") or existing_operation_code + new_operation_time = parsed.get("operation_time", "") or existing_operation_time + new_operation_frequency = parsed.get("operation_frequency", "") or existing_operation_frequency + new_tried_measures = parsed.get("tried_measures", "") or existing_tried_measures + new_running_condition = parsed.get("running_condition", "") or existing_running_condition + new_additional_info = parsed.get("additional_info", "") or existing_additional_info + + extracted_info = {"ship_number": new_ship_number,"device_name": new_device_name,"operation_item": new_operation_item,"operation_code": new_operation_code,"operation_time": new_operation_time,"operation_frequency": new_operation_frequency,"tried_measures": new_tried_measures,"running_condition": new_running_condition,"additional_info": new_additional_info,} + + print(f"[信息提取] 舷号={extracted_info['ship_number']}, 设备={extracted_info['device_name']}, 操作={extracted_info['operation_item']}") + + return {**extracted_info,"extracted_info": extracted_info,"initialized": True,"current_node": "agent_think",} + + except Exception as e: + print(f"信息提取失败: {str(e)}") + return {"initialized": True,"current_node": "agent_think",} + +async def agent_think(state: OperateGuideState) -> Dict[str, Any]: + """ + Agent 思考节点:基于当前状态决定下一步动作 + """ + combined_query = state.get("combined_query", "") or "" + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + operation_code = state.get("operation_code", "") or "" + operation_time = state.get("operation_time", "") or "" + operation_frequency = state.get("operation_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + additional_info = state.get("additional_info", "") or "" + history_messages = state.get("history_messages", []) or [] + + scheme_generated = state.get("scheme_generated", False) + has_rag_result = state.get("has_rag_result", False) + has_graph_result = state.get("has_graph_result", False) + + rag_search_result = state.get("rag_search_result") + graph_search_result = state.get("graph_search_result") + iteration_count = state.get("iteration_count", 0) + + user_confirmed_info = state.get("user_confirmed_info", False) + waiting_feedback = state.get("waiting_feedback", False) + + has_ship = bool(ship_number and ship_number.strip()) + has_device = bool(device_name and device_name.strip()) + has_operation_item = bool(operation_item and operation_item.strip()) + has_rag = (rag_search_result is not None and len(rag_search_result) > 0) or has_rag_result + has_graph = (graph_search_result is not None and len(graph_search_result) > 100) or has_graph_result + + info_completeness = _calculate_info_completeness(ship_number=ship_number,device_name=device_name,operation_item=operation_item,operation_code=operation_code,operation_time=operation_time,operation_frequency=operation_frequency,tried_measures=tried_measures,running_condition=running_condition,additional_info=additional_info) + + MIN_COMPLETENESS_FOR_GENERATE = 0.25 + + if scheme_generated: + _re_diagnose_keywords = ["开始操作", "信息已足够", "信息已足够,开始操作", "开始", "操作"] + _is_re_diagnose = any(kw in combined_query for kw in _re_diagnose_keywords) + if _is_re_diagnose: + print(f"[重新操作] 检测到重新操作意图: '{combined_query}',重置状态直接调用工具生成方案") + tools_to_call = ["rag_search"] + if device_name and operation_item: + tools_to_call.append("graph_rag_search") + return {"scheme_generated": False,"rag_search_result": None,"graph_search_result": None,"deep_rag_results": None,"has_rag_result": False,"has_graph_result": False,"is_regenerating": False,"user_feedback_for_regenerate": "","follow_up_intent": "","user_confirmed_info": True,"initialized": True,"iteration_count": 0,"ask_round": 0,"agent_decision": "call_tools","tools_to_call": tools_to_call,"current_node": "call_tools",} + + history_str = build_history_str(history_messages, recent_count=5, assistant_truncate=300) + + intent_system_prompt = OPERATE_PROMPTS["agent_think_intent_system"] + + intent_prompt = OPERATE_PROMPTS["agent_think_intent_user"].format( + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + operation_item=operation_item or '未提供', + history_str=history_str or '无', + combined_query=combined_query + ) + + try: + intent_response = await OpenaiAPI.open_api_chat_without_thinking( + query=intent_prompt, + model=None, + json_output=True, + system_prompt=intent_system_prompt, + messages=[] + ) + + parsed_intent = safe_json_extract(intent_response) + intent = "other" + if isinstance(parsed_intent, dict): + intent = parsed_intent.get("intent", "other") + + print(f"[Agent 意图分析] 用户意图: {intent}") + + if intent == "modify_info": + decision = "confirm_info" + reasoning = "用户需要修改或补充信息" + return {"agent_decision": decision,"agent_reasoning": reasoning,"rag_search_result": None,"graph_search_result": None,"deep_rag_results": None,"has_rag_result": False,"has_graph_result": False,"scheme_generated": False,"is_regenerating": False,"user_feedback_for_regenerate": "","follow_up_intent": "","user_confirmed_info": False,"initialized": False,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + elif intent == "has_error": + decision = "handle_feedback" + reasoning = "用户反馈方案有错误" + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + return {"agent_decision": decision,"agent_reasoning": reasoning,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + elif intent == "not_solved": + decision = "analyze_follow_up_intent" + reasoning = "已生成方案,分析用户后续意图" + return {"agent_decision": decision,"agent_reasoning": reasoning,"follow_up_intent": "not_solved","info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + elif intent == "related_question": + decision = "analyze_follow_up_intent" + reasoning = "已生成方案,分析用户后续意图" + return {"agent_decision": decision,"agent_reasoning": reasoning,"follow_up_intent": "related_question","info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + else: + decision = "follow_up_qa" + reasoning = "其他意图,直接调用 baike" + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + return {"agent_decision": decision,"agent_reasoning": reasoning,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + except Exception as e: + print(f"[Agent 意图分析失败: {str(e)}") + decision = "analyze_follow_up_intent" + reasoning = "已生成方案,分析用户后续意图" + print(f"[Agent 决策] decision={decision}, reasoning={reasoning} (降级)") + return {"agent_decision": decision,"agent_reasoning": reasoning,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + + if not has_device or not has_operation_item: + decision = "ask_user" + missing_info = [] + if not has_device: + missing_info.append("设备名称") + if not has_operation_item: + missing_info.append("操作项目") + reasoning = f"缺少基本信息: {', '.join(missing_info)}" + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + return {"agent_decision": decision,"agent_reasoning": reasoning,"missing_info": missing_info,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + + if info_completeness < MIN_COMPLETENESS_FOR_GENERATE: + decision = "ask_user" + reasoning = f"信息完整性评分 {info_completeness} 低于最低要求 {MIN_COMPLETENESS_FOR_GENERATE}" + missing_info = [] + if not ship_number: + missing_info.append("舷号(可选)") + if not operation_code: + missing_info.append("操作代码") + if not operation_time: + missing_info.append("操作时间") + if not operation_frequency: + missing_info.append("操作频率") + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + return {"agent_decision": decision,"agent_reasoning": reasoning,"missing_info": missing_info or ["更多详细信息"],"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + + if has_rag or has_graph: + decision = "generate_response" + reasoning = "信息充足,已有检索结果,直接生成回复" + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + return {"agent_decision": decision,"agent_reasoning": reasoning,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + + if not user_confirmed_info: + decision = "confirm_info" + reasoning = "信息充足,需要用户确认后再检索知识库" + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + return {"agent_decision": decision,"agent_reasoning": reasoning,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + + decision = "call_tools" + reasoning = "信息充足,用户已确认,开始检索知识库" + tools_to_call = ["rag_search"] + if has_device and has_operation_item: + tools_to_call.append("graph_rag_search") + + print(f"[Agent 决策] decision={decision}, reasoning={reasoning}") + print(f"[Agent 提取] 舷号={ship_number}, 设备={device_name}, 操作={operation_item}") + + emit_callback_event(["🤖 Agent 思考中", "🤖 智能分析", "🤖 决策判断"], f"决策: {decision} | 理由: {reasoning[:50]}...") + + return {"agent_decision": decision,"agent_reasoning": reasoning,"tools_to_call": tools_to_call,"info_completeness": info_completeness,"iteration_count": iteration_count + 1,"current_node": decision,} + +async def ask_user(state: OperateGuideState) -> Command: + """ + 询问用户节点:使用 interrupt 暂停等待用户输入 + """ + missing_info = state.get("missing_info", []) + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + operation_code = state.get("operation_code", "") or "" + operation_time = state.get("operation_time", "") or "" + operation_frequency = state.get("operation_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + additional_info = state.get("additional_info", "") or "" + combined_query = state.get("combined_query", "") or "" + info_completeness = state.get("info_completeness", 0) + ask_round = state.get("ask_round", 0) + + MAX_ASK_ROUNDS = 4 + if ask_round >= MAX_ASK_ROUNDS: + print(f"[ask_user] 已达到最大询问轮次 {MAX_ASK_ROUNDS},强制进入下一步") + tools_to_call = ["rag_search"] + if device_name and operation_item: + tools_to_call.append("graph_rag_search") + return Command( + update={"ask_round": ask_round + 1,"current_node": "call_tools","tools_to_call": tools_to_call,}, + goto="call_tools" + ) + + existing_parts = [] + if ship_number: + existing_parts.append(f"舷号: {ship_number}") + if device_name: + existing_parts.append(f"设备名称: {device_name}") + if operation_item: + existing_parts.append(f"操作项目: {operation_item}") + if operation_code: + existing_parts.append(f"操作代码: {operation_code}") + if operation_time: + existing_parts.append(f"发生时间: {operation_time}") + if operation_frequency: + existing_parts.append(f"操作频率: {operation_frequency}") + if tried_measures: + existing_parts.append(f"已尝试措施: {tried_measures}") + if running_condition: + existing_parts.append(f"运行工况: {running_condition}") + if additional_info: + existing_parts.append(f"其他信息: {additional_info}") + + existing_str = ";".join(existing_parts) if existing_parts else "暂无" + missing_str = "、".join(missing_info) if missing_info else "相关信息" + + system_prompt = COMMON_PROMPTS["ask_user_system"].format(biz_label="操作指导", biz_label_short="指导") + + prompt = COMMON_PROMPTS["ask_user_prompt"].format( + info_completeness=info_completeness, + existing_str=existing_str, + missing_str=missing_str, + combined_query=combined_query + ) + + response = "" + try: + response = await OpenaiAPI.open_api_chat_without_thinking(query=prompt,model=None,system_prompt=system_prompt,messages=[]) + response = response.strip() if response else "" + except Exception as e: + print(f"[ask_user] API调用失败: {str(e)}") + response = "" + + if not response: + response = f"**【需要更多信息】**\n\n请补充:{missing_str}\n\n提供这些信息后,我可以更准确地为您指导操作。" + + if existing_parts: + existing_info_section = f"\n**📋 已获取的信息:**\n\n" + "\n".join( + f"- {part}" for part in existing_parts) + "\n\n---\n" + response = existing_info_section + response + + suggested_replies = [] + if "舷号" in missing_info: + suggested_replies.append({"title": "补充舷号", "content": "舷号:"}) + if "设备名称" in missing_info: + suggested_replies.append({"title": "补充设备名称", "content": "设备名称:"}) + if "操作项目" in missing_info: + suggested_replies.append({"title": "补充操作项目", "content": "操作项目:"}) + + user_input = interrupt({"response": response,"suggestedReplies": suggested_replies,"waiting_for": "user_input","missing_info": missing_info,}) + + user_query = user_input.get("query", "") + + update_dict = {"combined_query": user_query,"current_node": "agent_think","ask_round": ask_round + 1,} + + if user_query: + extract_prompt = OPERATE_PROMPTS["ask_user_extract"].format( + missing_str=missing_str, + user_query=user_query + ) + + try: + extract_response = await OpenaiAPI.open_api_chat_without_thinking(query=extract_prompt,model=None,json_output=True,system_prompt="你是信息提取专家。",messages=[]) + extracted = safe_json_extract(extract_response) + if isinstance(extracted, dict): + if extracted.get("ship_number"): + update_dict["ship_number"] = extracted["ship_number"] + print(f"[ask_user] 提取到舷号: {extracted['ship_number']}") + if extracted.get("device_name"): + update_dict["device_name"] = extracted["device_name"] + print(f"[ask_user] 提取到设备名称: {extracted['device_name']}") + if extracted.get("operation_item"): + update_dict["operation_item"] = extracted["operation_item"] + print(f"[ask_user] 提取到操作项目: {extracted['operation_item']}") + except Exception as e: + print(f"[ask_user] 信息提取失败: {str(e)}") + + return Command( + update=update_dict, + goto="agent_think" + ) + +async def confirm_info(state: OperateGuideState) -> Command: + """ + 确认信息节点:重点询问用户是否需要补充信息 + """ + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + operation_code = state.get("operation_code", "") or "" + operation_time = state.get("operation_time", "") or "" + operation_frequency = state.get("operation_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + additional_info = state.get("additional_info", "") or "" + info_completeness = state.get("info_completeness", 0) + + existing_parts = [] + if ship_number: + existing_parts.append(f"舷号: {ship_number}") + if device_name: + existing_parts.append(f"设备名称: {device_name}") + if operation_item: + existing_parts.append(f"操作项目: {operation_item}") + if operation_code: + existing_parts.append(f"操作代码: {operation_code}") + if operation_time: + existing_parts.append(f"发生时间: {operation_time}") + if operation_frequency: + existing_parts.append(f"操作频率: {operation_frequency}") + if tried_measures: + existing_parts.append(f"已尝试措施: {tried_measures}") + if running_condition: + existing_parts.append(f"运行工况: {running_condition}") + if additional_info: + existing_parts.append(f"其他信息: {additional_info}") + + existing_str = "\n".join(f"- {part}" for part in existing_parts) if existing_parts else "暂无" + + additional_prompt = "" + if not ship_number: + additional_prompt = "\n\n💡 **提示**:如果您知道舷号,请补充,这将有助于我们更准确地提供操作指导。" + + response = f"""**📋 已收集到的信息:** + +{existing_str} + +**信息完整性评分:** {info_completeness:.0%} + +--- + +**💡 请问是否需要补充其他信息?** +- 如操作代码、操作时间、操作频率、已尝试措施、运行工况等 +- 如果信息已足够,请点击"开始操作"{additional_prompt} +""" + + emit_callback_event(["✅ 信息确认", "📋 请确认信息", "🔍 准备检索"], f"等待用户确认或补充信息 (完整性: {info_completeness:.0%})") + + suggested_replies = [{"title": "开始操作", "content": "信息已足够,开始操作"},{"title": "补充信息", "content": "我需要补充:"}] + + user_input = interrupt({"response": response,"suggestedReplies": suggested_replies,"waiting_for": "user_confirm",}) + + user_query = user_input.get("query", "") + + if await is_confirmation_intent(user_query): + print(f"[confirm_info] 用户确认信息足够,准备调用工具") + tools_to_call = ["rag_search"] + if device_name and operation_item: + tools_to_call.append("graph_rag_search") + return Command( + update={"user_confirmed_info": True,"current_node": "call_tools","tools_to_call": tools_to_call,}, + goto="call_tools" + ) + else: + print(f"[confirm_info] 用户需要补充信息: {user_query}") + return Command( + update={"user_confirmed_info": False,"combined_query": user_query,"initialized": False,"current_node": "extract_info",}, + goto="extract_info" + ) + +async def call_tools(state: OperateGuideState) -> Dict[str, Any]: + """调用检索工具""" + tools_to_call = state.get("tools_to_call", []) + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + additional_info = state.get("additional_info", "") or "" + + rag_results = state.get("rag_search_result") + graph_results = state.get("graph_search_result") + atlas_results = {} + + matched_kb_name = "" + matched_kb_id = "" + + async def _do_rag_search(): + from utils.ship_number_search import search_with_ship_number_strategy + base_query = f"设备{device_name},操作{operation_item}的操作指导方案" + results, kb_name, kb_id, _ = await search_with_ship_number_strategy( + base_query=base_query, ship_number=ship_number, top_k=5, search_type="operate" + ) + if not kb_name: + kb_name = "通用知识库" + print(f"RAG 检索来源: {kb_name}") + return results, kb_name, kb_id + + async def _do_graph_search(): + if not (device_name and operation_item): + return "" + try: + graph_query = f"设备{device_name},操作{operation_item}的操作指导方案" + results = await graph_rag_search.ainvoke({"query": graph_query}) + print(f"图谱检索完成,结果长度: {len(results) if results else 0}") + return results + except Exception as e: + print(f"图谱检索失败: {str(e)}") + return "" + + need_rag = "rag_search" in tools_to_call and rag_results is None + need_graph = "graph_rag_search" in tools_to_call and graph_results is None + + if need_rag and need_graph: + (rag_results, matched_kb_name, matched_kb_id), graph_results = \ + await asyncio.gather(_do_rag_search(), _do_graph_search()) + elif need_rag: + rag_results, matched_kb_name, matched_kb_id = await _do_rag_search() + elif need_graph: + graph_results = await _do_graph_search() + + if device_name: + try: + print(f"[图册检索] 使用设备名称: {device_name}") + atlas_result = await atlas_retrieval(node_names=[device_name], top_k=10) + if atlas_result.get("success", False): + atlas_results = atlas_result.get("data", {}) + print(f"[图册检索] 图册检索结果: {list(atlas_results.keys())}") + except Exception as e: + print(f"[图册检索] 图册检索失败: {str(e)}") + + has_rag = bool(rag_results and len(rag_results) > 0) + has_graph = bool(graph_results and len(graph_results) > 100) + + if not has_rag and not has_graph: + print(f"[call_tools] 未找到任何相关资料,需要用户确认使用模型能力") + return {"rag_search_result": rag_results if isinstance(rag_results, list) else [],"graph_search_result": graph_results if isinstance(graph_results, str) else "","atlas_results": atlas_results,"matched_kb_id": matched_kb_id,"matched_kb_name": matched_kb_name,"has_rag_result": False,"has_graph_result": False,"need_model_confirm": True,"current_node": "model_confirm",} + + return {"rag_search_result": rag_results,"graph_search_result": graph_results,"atlas_results": atlas_results,"matched_kb_id": matched_kb_id,"matched_kb_name": matched_kb_name,"has_rag_result": has_rag,"has_graph_result": has_graph,"need_model_confirm": False,"current_node": "generate_response",} + +async def model_confirm(state: OperateGuideState) -> Command: + """ + 模型能力确认节点:当没有找到任何资料时,让用户确认是否使用模型本身能力 + """ + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + + response = f"""**⚠️ 未找到相关资料** + +抱歉,在知识库中未找到与以下信息相关的资料: +- 舷号:{ship_number or '未提供'} +- 设备名称:{device_name or '未提供'} +- 操作项目:{operation_item or '未提供'} + +**💡 您可以选择:** +- 使用 AI 模型的知识库为您生成一般性建议 +- 提供更详细的信息重新检索 + +请问是否使用 AI 模型为您生成建议?""" + + emit_callback_event(["🤖 AI 模型确认", "⚠️ 资料未找到", "💡 使用模型能力"], "使用模型自身知识进行回复") + + suggested_replies = [{"title": "使用 AI 模型", "content": "确认,使用 AI 模型生成建议"},{"title": "补充信息", "content": "我需要补充更多信息:"},] + + user_input = interrupt({"response": response,"suggestedReplies": suggested_replies,"waiting_for": "model_confirm",}) + + user_query = user_input.get("query", "") + + if "确认" in user_query or "AI" in user_query or "模型" in user_query: + print(f"[model_confirm] 用户确认使用模型能力") + return Command( + update={"user_confirmed_model": True,"combined_query": user_query,"current_node": "generate_response",}, + goto="generate_response" + ) + else: + print(f"[model_confirm] 用户选择补充信息: {user_query}") + return Command( + update={"user_confirmed_model": False,"combined_query": user_query,"current_node": "agent_think",}, + goto="agent_think" + ) + +async def generate_response(state: OperateGuideState) -> Dict[str, Any]: + """生成最终回复""" + print("========================生成最终回复========================") + combined_query = state.get("combined_query", "") or "" + history_messages = state.get("history_messages", []) or [] + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + additional_info = state.get("additional_info", "") or "" + operation_code = state.get("operation_code", "") or "" + operation_time = state.get("operation_time", "") or "" + operation_frequency = state.get("operation_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + rag_results = state.get("rag_search_result") or [] + graph_results = state.get("graph_search_result") or "" + atlas_results = state.get("atlas_results", {}) + scheme_generated = state.get("scheme_generated", False) + matched_kb_name = state.get("matched_kb_name", "") + + print("rag_results", rag_results) + history_str = build_history_str(history_messages, recent_count=5, assistant_truncate=500) + + has_rag = rag_results and len(rag_results) > 0 + has_graph = graph_results and len(graph_results) > 100 + + if not has_rag and not has_graph: + if not ship_number and not device_name and not operation_item: + system_prompt = COMMON_PROMPTS["generate_response_friendly_system"].format(biz_label="操作指导") + prompt = COMMON_PROMPTS["generate_response_friendly_user"].format(combined_query=combined_query) + else: + system_prompt = COMMON_PROMPTS["generate_response_no_rag_system"].format(biz_label="操作指导") + prompt = OPERATE_PROMPTS["generate_response_no_rag_user"].format( + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + operation_item=operation_item or '未提供', + operation_code=operation_code or '未提供', + operation_time=operation_time or '未提供', + operation_frequency=operation_frequency or '未提供', + tried_measures=tried_measures or '未提供', + running_condition=running_condition or '未提供', + additional_info=additional_info or '无' + ) + + try: + response = await OpenaiAPI.open_api_chat_without_thinking(query=prompt,model=None,system_prompt=system_prompt,messages=[]) + return {"response": response.strip() if response else "抱歉,未能找到相关资料。","suggestedReplies": [{"title": "提供更详细信息", "content": "我来提供更详细的信息"},],"current_node": "end",} + except Exception as e: + return {"response": f"抱歉,处理过程中出现错误: {str(e)}","suggestedReplies": [],"current_node": "end",} + + rag_ctx_parts: List[str] = [] + if isinstance(rag_results, str) and rag_results.strip(): + rag_ctx_parts = [rag_results.strip()] + elif isinstance(rag_results, list): + for item in rag_results[:8]: + if isinstance(item, dict): + text = (item.get("text") or "").strip() + if text: + rag_ctx_parts.append(text) + elif isinstance(item, str) and item.strip(): + rag_ctx_parts.append(item.strip()) + + if len(graph_results) <= 50: + graph_results = "" + + all_source_text = graph_results + "\n" + "\n".join(rag_ctx_parts) if graph_results else "\n".join(rag_ctx_parts) + original_image_paths = extract_image_paths(all_source_text) + print("提取到的原始图片路径:", original_image_paths) + + rag_answer = "" + rag_type = "none" + + if graph_results and len(graph_results) > 50: + rag_answer = graph_results + rag_type = "graph" + elif rag_ctx_parts: + rag_answer = "\n\n".join(rag_ctx_parts[:3]) + rag_type = "rag" + + if not rag_answer or rag_answer == "NO_RELEVANT_RESULT": + system_prompt = COMMON_PROMPTS["generate_response_no_rag_simple_system"].format(biz_label="操作指导") + prompt = OPERATE_PROMPTS["generate_response_no_rag_simple_user"].format( + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + operation_item=operation_item or '未提供' + ) + + try: + response = await OpenaiAPI.open_api_chat_without_thinking(query=prompt,model=None,system_prompt=system_prompt,messages=[]) + return {"response": response.strip() if response else "抱歉,未能找到相关资料。","suggestedReplies": [],"current_node": "end",} + except Exception as e: + return {"response": f"抱歉,处理过程中出现错误: {str(e)}","suggestedReplies": [],"current_node": "end",} + + rag_type = "图谱" if rag_type == "graph" else "文本" + + emit_callback_event([f"✨当前使用的检索类型为{rag_type}", f"✨本次检索策略为{rag_type}"], f"内容为{rag_answer[:50]}...") + + detail_info = "" + if operation_code: + detail_info += f"- 操作代码/报警代码:{operation_code}\n" + if operation_time: + detail_info += f"- 操作发生时间:{operation_time}\n" + if operation_frequency: + detail_info += f"- 操作频率/持续时间:{operation_frequency}\n" + if tried_measures: + detail_info += f"- 已尝试的操作措施:{tried_measures}\n" + if running_condition: + detail_info += f"- 设备运行工况:{running_condition}\n" + if additional_info: + detail_info += f"- 其他相关信息:{additional_info}\n" + + is_regenerating = state.get("is_regenerating", False) + user_feedback_for_regenerate = state.get("user_feedback_for_regenerate", "") + is_follow_up = scheme_generated and (has_rag or has_graph) and not is_regenerating + + image_guidance = get_image_prompt_guidance() + if is_regenerating and user_feedback_for_regenerate: + prompt = COMMON_PROMPTS["generate_response_regenerating"].format( + biz_label="操作指导", + item_label="操作项目", + item_name=operation_item or '未提供', + item_action="分析操作要点,给出操作指导方案", + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + operation_item=operation_item or '未提供', + detail_info=detail_info, + user_feedback_for_regenerate=user_feedback_for_regenerate, + rag_answer=rag_answer, + image_guidance=image_guidance + ) + elif is_follow_up: + prompt = COMMON_PROMPTS["generate_response_follow_up"].format( + biz_label="操作指导", + item_label="操作项目", + item_name=operation_item or '未提供', + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + operation_item=operation_item or '未提供', + detail_info=detail_info, + rag_answer=rag_answer, + combined_query=combined_query, + image_guidance=image_guidance + ) + else: + prompt = COMMON_PROMPTS["generate_response_normal"].format( + biz_label="操作指导", + item_label="操作项目", + item_name=operation_item or '未提供', + item_action="分析操作要点,给出操作指导方案", + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + operation_item=operation_item or '未提供', + detail_info=detail_info, + rag_answer=rag_answer + ) + + try: + content_system_prompt = COMMON_PROMPTS["generate_response_content_system"].format(biz_label="操作指导") + + raw_content = await OpenaiAPI.open_api_chat_without_thinking(model=None,system_prompt=content_system_prompt,messages=[{"role": "user", "content": prompt}],) + + raw_content = raw_content.strip() if raw_content else "抱歉,无法生成回答。" + + emit_callback_event(["🤖 操作分析中", "🤖 方案生成中"], raw_content[:30] + "...") + + answer = await stream_format_and_postprocess(raw_content, original_image_paths, temperature=0.1) + + answer = append_atlas_section(answer, atlas_results) + + suggested_replies = [{"title": "方案有误", "content": "方案还是有问题:"},] + + return {"response": answer,"actions": [],"suggestedReplies": suggested_replies,"scheme_generated": True,"last_generated_scheme": answer,"scheme_type": "deep","current_node": "end","last_combined_query": combined_query,} + + except Exception as e: + return {"response": f"深度分析生成失败: {str(e)}","actions": [],"suggestedReplies": [],"current_node": "end",} + +async def regenerate_scheme_from_feedback(state: OperateGuideState) -> Dict[str, Any]: + """ + 基于用户反馈直接修改方案节点:使用之前保存的方案和用户反馈进行修改 + """ + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + additional_info = state.get("additional_info", "") or "" + operation_code = state.get("operation_code", "") or "" + operation_time = state.get("operation_time", "") or "" + operation_frequency = state.get("operation_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + combined_query = state.get("combined_query", "") or "" + + user_feedback = state.get("user_feedback_for_regenerate", "") or "" + last_scheme = state.get("last_generated_scheme", "") or "" + scheme_type = state.get("scheme_type", "normal") + + if not last_scheme: + print("[regenerate_scheme_from_feedback] 没有保存的方案,回退到重新检索") + return {"is_regenerating": True,"rag_search_result": None,"graph_search_result": None,"has_rag_result": False,"has_graph_result": False,"scheme_generated": False,"current_node": "call_tools","tools_to_call": ["rag_search", "graph_rag_search"],} + + detail_info = "" + if operation_code: + detail_info += f"- 操作代码/报警代码:{operation_code}\n" + if operation_time: + detail_info += f"- 操作发生时间:{operation_time}\n" + if operation_frequency: + detail_info += f"- 操作频率/持续时间:{operation_frequency}\n" + if tried_measures: + detail_info += f"- 已尝试的操作措施:{tried_measures}\n" + if running_condition: + detail_info += f"- 设备运行工况:{running_condition}\n" + if additional_info: + detail_info += f"- 其他相关信息:{additional_info}\n" + + emit_callback_event(["✏️ 修改方案中", "🔧 调整方案", "📝 优化方案"], f"根据反馈修改方案: {user_feedback[:30]}...") + + image_guidance = get_image_prompt_guidance() + + prompt = COMMON_PROMPTS["regenerate_scheme_from_feedback"].format( + biz_label="操作指导", + item_label="操作项目", + item_name=operation_item or '未提供', + item_action="分析操作要点,给出操作指导方案", + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + operation_item=operation_item or '未提供', + detail_info=detail_info, + last_scheme=last_scheme, + user_feedback=user_feedback, + image_guidance=image_guidance + ) + + try: + content_system_prompt = COMMON_PROMPTS["regenerate_scheme_from_feedback_system"].format(biz_label="操作指导") + + content_prompt = prompt.replace("【输出要求】", "【输出要求】\n- 请专注于内容的准确性、完整性和逻辑性,不要担心格式的美观性\n- 可以适当使用简单的段落分隔,但不要使用复杂的格式") + + raw_content = await OpenaiAPI.open_api_chat_without_thinking(model=None,system_prompt=content_system_prompt,messages=[{"role": "user", "content": content_prompt}],) + + raw_content = raw_content.strip() if raw_content else "抱歉,无法根据反馈修改方案。" + + original_image_paths = extract_image_paths(last_scheme) + answer = await stream_format_and_postprocess(raw_content, original_image_paths, temperature=0.3, content_label="操作指导方案") + + suggested_replies = [{"title": "方案有误", "content": "方案还是有问题:"},] + + return {"response": answer,"actions": [],"suggestedReplies": suggested_replies,"scheme_generated": True,"last_generated_scheme": answer,"scheme_type": scheme_type,"current_node": "end","last_combined_query": combined_query,} + + except Exception as e: + print(f"[regenerate_scheme_from_feedback] 修改方案失败: {str(e)}") + return {"is_regenerating": True,"rag_search_result": None,"graph_search_result": None,"has_rag_result": False,"has_graph_result": False,"scheme_generated": False,"current_node": "call_tools","tools_to_call": ["rag_search", "graph_rag_search"],} + +async def follow_up_qa(state: OperateGuideState) -> Dict[str, Any]: + """ + 后续问答节点:调用 baike 智能体处理用户的后续问题 + """ + combined_query = state.get("combined_query", "") or "" + history_messages = state.get("history_messages", []) or [] + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + + history_message_json = json.dumps(history_messages, ensure_ascii=False) if history_messages else "" + + context_prefix = "" + if ship_number or device_name or operation_item: + context_parts = [] + if ship_number: + context_parts.append(f"舷号: {ship_number}") + if device_name: + context_parts.append(f"设备名称: {device_name}") + if operation_item: + context_parts.append(f"操作项目: {operation_item}") + context_prefix = f"【当前操作指导上下文】\n{'; '.join(context_parts)}\n\n" + + enhanced_query = f"{context_prefix}用户后续问题: {combined_query}" + + emit_callback_event(["💬 后续问答", "💬 执行指导", "💬 问题解答"], f"调用 百科 智能体: {combined_query[:30]}...") + + try: + baike_result = await run_baike_workflow(extracted_text=enhanced_query,combined_query=enhanced_query,history_message=history_message_json,route_flag="baike") + + response = baike_result.get("response", "") + suggested_replies = baike_result.get("suggestedReplies", []) + + return {"response": response,"suggestedReplies": suggested_replies,"actions": [],"scheme_generated": True,"current_node": "end",} + + except Exception as e: + return {"response": f"处理您的问题时出现错误: {str(e)}","suggestedReplies": [],"actions": [],"current_node": "end",} + +async def handle_feedback(state: OperateGuideState) -> Command: + """ + 处理用户反馈节点:接收用户对方案的反馈,提供上报和重新生成方案按钮 + """ + combined_query = state.get("combined_query", "") or "" + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + + response = f"""**📝 已收到您的反馈** + +感谢您对操作指导方案的建议!您的反馈已记录: + +{combined_query} + +--- + +**📋 反馈信息摘要:** +- 舷号:{ship_number or '未提供'} +- 设备名称:{device_name or '未提供'} +- 操作项目:{operation_item or '未提供'} +- 反馈内容:{combined_query[:100]}{'...' if len(combined_query) > 100 else ''} + +--- + +**💡 您可以选择:** +- 点击「重新生成方案」根据您的反馈重新生成方案 +- 点击「上报」将反馈提交给相关部门 +- 继续对话获取更多帮助""" + + actions = [ + {"type": "content","label": "重新生成方案","text": "重新生成方案"}, + {"type": "content","label": "上报","text": "上报"} + ] + + suggested_replies = [] + + emit_callback_event(["📝 反馈处理", "📋 方案反馈", "✅ 反馈确认"], f"处理用户反馈: {combined_query[:30]}...") + + user_input = interrupt({"response": response,"actions": actions,"suggestedReplies": suggested_replies,"waiting_for": "feedback_confirm",}) + + user_text = user_input.get("query", "") or "" + + if user_text == "重新生成方案": + print(f"[handle_feedback] 用户选择重新生成方案") + return Command( + update={"user_feedback_for_regenerate": combined_query,"current_node": "regenerate_scheme_from_feedback",}, + goto="regenerate_scheme_from_feedback" + ) + elif user_text == "上报": + print(f"[handle_feedback] 用户选择上报反馈") + return Command( + update={"user_feedback": combined_query,"waiting_report_confirm": True,"current_node": "confirm_report",}, + goto="confirm_report" + ) + else: + print(f"[handle_feedback] 用户继续对话: {user_text}") + return Command( + update={"user_feedback": combined_query,"combined_query": user_text,"current_node": "agent_think",}, + goto="agent_think" + ) + +async def confirm_report(state: OperateGuideState) -> Dict[str, Any]: + """ + 上报确认节点:用户点击上报后确认完成 + """ + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + user_feedback = state.get("user_feedback", "") or "" + + response = f"""**✅ 上报成功** + +您的反馈已成功上报! + +--- + +**📋 上报信息:** +- 舷号:{ship_number or '未提供'} +- 设备名称:{device_name or '未提供'} +- 操作项目:{operation_item or '未提供'} +- 反馈内容:{user_feedback[:100]}{'...' if len(user_feedback) > 100 else ''} + +--- + +**💡 感谢您的反馈,相关部门将及时处理。**""" + + suggested_replies = [{"title": "继续对话", "content": "我还有其他问题"},] + + emit_callback_event(["✅ 上报成功", "📋 反馈已提交", "🎉 完成"], "反馈上报成功") + + return {"response": response,"actions": [],"suggestedReplies": suggested_replies,"report_confirmed": True,"waiting_report_confirm": False,"current_node": "end",} + +async def analyze_follow_up_intent(state: OperateGuideState) -> Dict[str, Any]: + """ + 分析用户后续意图节点:直接使用 agent_think 已经判断好的意图 + agent_think 已经用大模型判断并设置了 follow_up_intent + """ + intent = state.get("follow_up_intent", "other") + + print(f"[意图分析] 使用已判断的意图: {intent}") + + return {"follow_up_intent": intent,} + + +async def deep_rag_search(state: OperateGuideState) -> Dict[str, Any]: + """ + 深层次RAG检索节点:分析之前方案并选择深度分析点进行检索 + """ + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + operation_code = state.get("operation_code", "") or "" + additional_info = state.get("additional_info", "") or "" + operation_time = state.get("operation_time", "") or "" + operation_frequency = state.get("operation_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + combined_query = state.get("combined_query", "") or "" + history_messages = state.get("history_messages", []) or [] + last_generated_scheme = state.get("last_generated_scheme", "") or "" + + deep_results = {} + deep_analysis_points = [] + + emit_callback_event(["🔍 深度检索中", "🔍 多维度检索", "🔍 精细分析"], "分析问题并选择深度分析点") + + # ========== 第一步:分析之前方案和用户问题,选择深度分析点 ========== + if last_generated_scheme or combined_query: + try: + history_str = build_history_str(history_messages, recent_count=4, assistant_truncate=300) + + detail_info = "" + if operation_code: + detail_info += f"- 操作代码:{operation_code}\n" + if operation_time: + detail_info += f"- 操作时间:{operation_time}\n" + if operation_frequency: + detail_info += f"- 操作频率/持续时间:{operation_frequency}\n" + if tried_measures: + detail_info += f"- 已尝试的措施:{tried_measures}\n" + if running_condition: + detail_info += f"- 设备运行工况:{running_condition}\n" + if additional_info: + detail_info += f"- 其他相关信息:{additional_info}\n" + + analysis_system_prompt = COMMON_PROMPTS["deep_rag_analysis_system"].format(biz_label="操作指导") + + analysis_prompt = COMMON_PROMPTS["deep_rag_analysis_user"].format( + item_label="操作项目", + item_name=operation_item or '未提供', + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + operation_item=operation_item or '未提供', + detail_info=detail_info, + history_str=history_str or '(无历史对话)', + last_generated_scheme=last_generated_scheme or '(无之前的方案)', + combined_query=combined_query + ) + + analysis_response = await OpenaiAPI.open_api_chat_without_thinking(query=analysis_prompt,model=None,json_output=True,system_prompt=analysis_system_prompt,messages=[]) + print("深度分析点", analysis_response) + parsed_analysis = safe_json_extract(analysis_response) + deep_analysis_points = parsed_analysis + print(f"[深度RAG] 选择的分析点: {deep_analysis_points}") + except Exception as e: + print(f"[深度RAG] 分析失败: {str(e)}") + + # ========== 第二步:对选中的分析点进行检索 ========== + for idx, point in enumerate(deep_analysis_points): + try: + print(point) + search_query = point + if device_name and device_name not in point: + search_query = f"{device_name} {search_query}" + if operation_item and operation_item not in search_query: + search_query = f"{search_query} {operation_item}" + + rag_result = await rag_search(search_query, top_k=3) + if rag_result.get("success", False): + deep_results[f"analysis_point_{idx}"] = convert_rag_result(rag_result) + else: + deep_results[f"analysis_point_{idx}"] = [] + except Exception as e: + print(f"[深度RAG] 分析点 {point} 检索失败: {str(e)}") + deep_results[f"analysis_point_{idx}"] = [] + + # ========== 第三步:图谱检索 ========== + graph_deep_results = "" + try: + if device_name and operation_item: + graph_query = f"设备{device_name},操作{operation_item}的操作指导方案" + graph_deep_results = await graph_rag_search.ainvoke({"query": graph_query}) + if graph_deep_results: + print(f"[深度RAG] 图谱检索结果长度: {len(graph_deep_results)}") + except Exception as e: + print(f"[深度RAG] 图谱检索失败: {str(e)}") + + # ========== 第四步:图册检索 ========== + atlas_results = {} + try: + entity_names = await extract_entity_names_from_history(history_messages=history_messages,device_name=device_name,operation_item=operation_item,last_generated_scheme=last_generated_scheme) + + if entity_names: + print(f"[深度RAG] 图册检索实体: {entity_names}") + atlas_result = await atlas_retrieval(node_names=entity_names, top_k=10) + if atlas_result.get("success", False): + atlas_results = atlas_result.get("data", {}) + print(f"[深度RAG] 图册检索结果: {list(atlas_results.keys())}") + except Exception as e: + print(f"[深度RAG] 图册检索失败: {str(e)}") + + return {"deep_rag_results": deep_results,"deep_analysis_points": deep_analysis_points,"graph_search_result": graph_deep_results or (state.get("graph_search_result", "") or ""),"atlas_results": atlas_results,"current_node": "generate_deep_response",} + + +async def generate_deep_response(state: OperateGuideState) -> Dict[str, Any]: + """ + 生成深度响应节点:基于深度RAG结果进行深度润色和推理 + """ + combined_query = state.get("combined_query", "") or "" + history_messages = state.get("history_messages", []) or [] + ship_number = state.get("ship_number", "") or "" + device_name = state.get("device_name", "") or "" + operation_item = state.get("operation_item", "") or "" + operation_code = state.get("operation_code", "") or "" + operation_time = state.get("operation_time", "") or "" + operation_frequency = state.get("operation_frequency", "") or "" + tried_measures = state.get("tried_measures", "") or "" + running_condition = state.get("running_condition", "") or "" + additional_info = state.get("additional_info", "") or "" + + deep_rag_results = state.get("deep_rag_results", {}) + deep_analysis_points = state.get("deep_analysis_points", []) + atlas_results = state.get("atlas_results", {}) + graph_results = state.get("graph_search_result", "") or "" + original_rag_results = state.get("rag_search_result", []) + + # 整理深度分析点资料 + analysis_points_text = "" + if deep_analysis_points: + for idx, point in enumerate(deep_analysis_points): + key = f"analysis_point_{idx}" + results = deep_rag_results.get(key, []) + if results: + analysis_points_text += f"\n【分析点:{point}】\n" + for i, item in enumerate(results[:2], 1): + if isinstance(item, dict): + text = item.get("text", "") + if text: + analysis_points_text += f"[{i}] {text}\n" + + # 整理原始RAG结果 + original_rag_text = "" + if original_rag_results: + original_rag_text = "\n【原始检索资料】\n" + for i, item in enumerate(original_rag_results[:3], 1): + if isinstance(item, dict): + text = item.get("text", "") + if text: + original_rag_text += f"[{i}] {text}\n" + + # 整理图册资料 + atlas_text = format_atlas_results(atlas_results) + + # 提取所有资料中的图片路径 + all_source_text = analysis_points_text + original_rag_text + atlas_text + graph_results + original_image_paths = extract_image_paths(all_source_text) + + detail_info = "" + if operation_code: + detail_info += f"- 操作代码:{operation_code}\n" + if operation_time: + detail_info += f"- 操作时间:{operation_time}\n" + if operation_frequency: + detail_info += f"- 操作频率/持续时间:{operation_frequency}\n" + if tried_measures: + detail_info += f"- 已尝试的措施:{tried_measures}\n" + if running_condition: + detail_info += f"- 设备运行工况:{running_condition}\n" + if additional_info: + detail_info += f"- 其他相关信息:{additional_info}\n" + + # 构建深度分析点的提示 + analysis_points_prompt = "" + if deep_analysis_points: + analysis_points_prompt = f"\n【本次深度分析重点】\n" + "\n".join([f"- {p}" for p in deep_analysis_points]) + "\n" + + prompt = COMMON_PROMPTS["generate_deep_response"].format( + biz_label="操作指导", + item_label="操作项目", + item_name=operation_item or '未提供', + ship_number=ship_number or '未提供', + device_name=device_name or '未提供', + operation_item=operation_item or '未提供', + detail_info=detail_info, + combined_query=combined_query, + analysis_points_prompt=analysis_points_prompt, + analysis_points_text=analysis_points_text, + original_rag_text=original_rag_text, + graph_results=graph_results if len(graph_results) > 50 else '(无)' + ) + + try: + content_system_prompt = COMMON_PROMPTS["generate_deep_response_content_system"].format(biz_label="操作指导") + + raw_content = await OpenaiAPI.open_api_chat_without_thinking(model=None,system_prompt=content_system_prompt,messages=[{"role": "user", "content": prompt}],) + + raw_content = raw_content.strip() if raw_content else "抱歉,无法生成深度分析。" + + emit_callback_event(["🤖 分析中", "🤖 操作指导生成中"], raw_content[:30] + "...") + + answer = await stream_format_and_postprocess(raw_content, original_image_paths, temperature=0.1) + + answer = append_atlas_section(answer, atlas_results) + + suggested_replies = [{"title": "方案有误", "content": "方案还是有问题:"},] + + return {"response": answer,"actions": [],"suggestedReplies": suggested_replies,"scheme_generated": True,"last_generated_scheme": answer,"scheme_type": "deep","current_node": "end","last_combined_query": combined_query,} + + except Exception as e: + return {"response": f"深度分析生成失败: {str(e)}","actions": [],"suggestedReplies": [],"current_node": "end",} + + +def _route_after_think(state: OperateGuideState) -> str: + """Agent 思考后的路由""" + decision = state.get("agent_decision", "ask_user") + + return decision + +def _route_after_intent(state: OperateGuideState) -> str: + """意图分析后的路由""" + intent = state.get("follow_up_intent", "other") + + if intent == "not_solved": + return "deep_rag_search" + elif intent == "related_question": + return "follow_up_qa" + elif intent == "has_error": + return "handle_feedback" + elif intent == "modify_info": + return "extract_info" + else: + return "follow_up_qa" + +def _route_after_tools(state: OperateGuideState) -> str: + """检索工具调用后的路由""" + need_model_confirm = state.get("need_model_confirm", False) + if need_model_confirm: + return "model_confirm" + return "generate_response" + +def create_operate_workflow(checkpointer=None): + """ + 创建操作指导工作流(基于 Checkpointer 的状态持久化版本) + """ + workflow = StateGraph(OperateGuideState) + + workflow.add_node("classify_intent", classify_intent) + workflow.add_node("extract_info", extract_info) + workflow.add_node("agent_think", agent_think) + workflow.add_node("ask_user", ask_user) + workflow.add_node("confirm_info", confirm_info) + workflow.add_node("call_tools", call_tools) + workflow.add_node("generate_response", generate_response) + workflow.add_node("follow_up_qa", follow_up_qa) + workflow.add_node("model_confirm", model_confirm) + workflow.add_node("handle_feedback", handle_feedback) + workflow.add_node("confirm_report", confirm_report) + workflow.add_node("analyze_follow_up_intent", analyze_follow_up_intent) + workflow.add_node("deep_rag_search", deep_rag_search) + workflow.add_node("generate_deep_response", generate_deep_response) + workflow.add_node("regenerate_scheme_from_feedback", regenerate_scheme_from_feedback) + + workflow.add_edge(START, "classify_intent") + workflow.add_conditional_edges("classify_intent", _route_after_classify, + {"extract_info": "extract_info","follow_up_qa": "follow_up_qa",}) + workflow.add_edge("extract_info", "agent_think") + workflow.add_conditional_edges("agent_think", _route_after_think, + {"ask_user": "ask_user","confirm_info": "confirm_info","call_tools": "call_tools","generate_response": "generate_response","follow_up": "follow_up_qa","follow_up_qa": "follow_up_qa","handle_feedback": "handle_feedback","extract_info": "extract_info","analyze_follow_up_intent": "analyze_follow_up_intent",}) + workflow.add_conditional_edges("call_tools", _route_after_tools, {"model_confirm": "model_confirm","generate_response": "generate_response",}) + workflow.add_conditional_edges("analyze_follow_up_intent", _route_after_intent, {"deep_rag_search": "deep_rag_search","follow_up_qa": "follow_up_qa","handle_feedback": "handle_feedback","extract_info": "extract_info",}) + workflow.add_edge("deep_rag_search", "generate_deep_response") + workflow.add_edge("generate_deep_response", END) + workflow.add_edge("generate_response", END) + workflow.add_edge("follow_up_qa", END) + workflow.add_edge("regenerate_scheme_from_feedback", END) + workflow.add_edge("handle_feedback", END) + workflow.add_edge("confirm_report", END) + + return workflow.compile(checkpointer=checkpointer) + +async def run_operate_workflow(extracted_text: str,combined_query: str,history_message: str = "",route_flag: str = "",previous_state_json: str = "",chat_id: str = "",message_id: str = "",checkpointer=None,user_response: Dict[str, Any] = None) -> Dict[str, Any]: + """ + 执行操作指导工作流 + + Args: + extracted_text: 文本描述 + combined_query: 组合查询 + history_message: 历史消息(JSON格式) + route_flag: 路由标识 + previous_state_json: 兼容参数(已废弃) + chat_id: 聊天会话 ID + message_id: 消息 ID + checkpointer: LangGraph checkpointer 实例 + user_response: 用户恢复执行的响应数据 + + Returns: + 工作流执行结果 + """ + thread_id = chat_id if chat_id else None + + config = {"configurable": {"thread_id": thread_id}} if thread_id else {} + + print(f"[DEBUG] run_operate_workflow: thread_id={thread_id}, checkpointer={checkpointer is not None}") + + if checkpointer is None: + from checkpointer_config import checkpointer_manager + checkpointer = await checkpointer_manager.get_async_checkpointer() + + app = create_operate_workflow(checkpointer=checkpointer) + + if thread_id: + try: + current_state = await app.aget_state(config) + print( + f"[DEBUG] current_state exists: {current_state is not None}, has values: {current_state.values is not None if current_state else False}, tasks: {len(current_state.tasks) if current_state else 0}") + + if current_state and current_state.values: + saved_state = current_state.values + print( + f"[Checkpointer] 发现已保存状态: ship_number={saved_state.get('ship_number')}, device_name={saved_state.get('device_name')}, operation_item={saved_state.get('operation_item')}") + + if current_state.tasks: + print(f"[Checkpointer] 恢复执行,注入用户响应") + resume_value = user_response if user_response else {"query": combined_query} + result = await app.ainvoke(Command(resume=resume_value), config) + else: + merged_query = combined_query + + _is_confirm = await is_confirmation_intent(combined_query) + _has_full_info = (saved_state.get("ship_number") and saved_state.get("device_name") and saved_state.get("operation_item")) + _override_confirmed = _is_confirm and _has_full_info and not saved_state.get("user_confirmed_info", False) + + initial_state = {**saved_state,"combined_query": merged_query,"history_messages": parse_history(history_message),"initialized": False,"user_intent": "",} + if _override_confirmed: + print(f"[Checkpointer] 检测到确认意图,自动设置 user_confirmed_info=True") + initial_state["user_confirmed_info"] = True + result = await app.ainvoke(initial_state, config) + else: + initial_state = {"extracted_text": extracted_text,"combined_query": combined_query,"history_messages": parse_history(history_message),"ship_number": "","device_name": "","operation_item": "","additional_info": "", + "operation_code": "","operation_time": "","operation_frequency": "","tried_measures": "","running_condition": "","rag_search_result": None,"graph_search_result": None,"response": "", + "suggestedReplies": [],"actions": [],"error_message": "","agent_decision": "","agent_reasoning": "","tools_to_call": [],"missing_info": [],"info_completeness": 0.0, + "matched_kb_id": "","matched_kb_name": "","iteration_count": 0,"max_iterations": 5,"scheme_generated": False,"waiting_for_supplement": False,"extracted_info": {}, + "has_rag_result": False,"has_graph_result": False,"is_execution_feedback": False,"execution_detail": "","ask_round": 0,"user_confirmed_info": False,"need_model_confirm": False, + "user_confirmed_model": False,"waiting_feedback": False,"user_feedback": "","waiting_report_confirm": False,"report_confirmed": False,"initialized": False,"current_node": "","user_intent": "","last_combined_query": "",} + result = await app.ainvoke(initial_state, config) + except Exception as e: + print(f"[Checkpointer] 状态恢复失败: {e}") + import traceback + traceback.print_exc() + result = {} + else: + initial_state = {"extracted_text": extracted_text,"combined_query": combined_query,"history_messages": parse_history(history_message),"ship_number": "","device_name": "","operation_item": "","additional_info": "","operation_code": "","operation_time": "", + "operation_frequency": "","tried_measures": "","running_condition": "","rag_search_result": None,"graph_search_result": None,"response": "","suggestedReplies": [],"actions": [],"error_message": "","agent_decision": "","agent_reasoning": "","tools_to_call": [], + "missing_info": [],"info_completeness": 0.0,"matched_kb_id": "","matched_kb_name": "","iteration_count": 0,"max_iterations": 5,"scheme_generated": False,"waiting_for_supplement": False,"extracted_info": {},"has_rag_result": False,"has_graph_result": False, + "is_execution_feedback": False,"execution_detail": "","ask_round": 0,"user_confirmed_info": False,"need_model_confirm": False,"user_confirmed_model": False,"waiting_feedback": False,"user_feedback": "","waiting_report_confirm": False,"report_confirmed": False,"initialized": False,"current_node": "","user_intent": "","last_combined_query": "",} + + try: + result = await app.ainvoke(initial_state, config) + except Exception as e: + print(f"[工作流执行失败] {str(e)}") + import traceback + traceback.print_exc() + return {"response": f"操作工作流执行失败: {str(e)}","actions": [],"result_tag": "operate","suggestedReplies": [],} + + if result is None: + result = {} + + interrupts = result.get("__interrupt__", []) + if interrupts: + interrupt_data = interrupts[0].value if interrupts else {} + print(f"[Interrupt] 工作流中断,等待用户输入: {interrupt_data}") + return {"response": interrupt_data.get("response", "请提供更多信息"),"actions": interrupt_data.get("actions", []),"result_tag": "operate","suggestedReplies": interrupt_data.get("suggestedReplies", []),"waiting_for": interrupt_data.get("waiting_for", ""),} + + if result.get("error_message"): + return {"response": f"操作工作流执行失败: {result['error_message']}","actions": [],"result_tag": "operate","suggestedReplies": [],} + + route_flag = result.get("route_flag", "") + if not route_flag: + route_flag = "operate" + + return {"response": result.get("response", ""),"actions": result.get("actions", []),"result_tag": route_flag,"suggestedReplies": result.get("suggestedReplies", []),"route_flag": route_flag,} + + diff --git a/workflows/workflow_other.py b/workflows/workflow_other.py new file mode 100644 index 0000000..e4d39e0 --- /dev/null +++ b/workflows/workflow_other.py @@ -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 + ) + + # 过滤掉 标签(如果有) + import re + answer = re.sub(r'.*?', '', 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()) + diff --git a/workflows/workflow_other_report.py b/workflows/workflow_other_report.py new file mode 100644 index 0000000..87d5264 --- /dev/null +++ b/workflows/workflow_other_report.py @@ -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""" + +你是一名专业的报告编写专家。 +你的任务是根据用户需求和历史对话生成一份正式的报告。 + + + +{combined_query} + + + +{report_theme if report_theme else "根据内容自动确定"} + + + +{history_context if history_context else "(无历史对话上下文)"} + + +{f''' +{image_section} +''' if extracted_images else ''} + + + +必须严格遵守以下规则: + +1. 输出为 **正式报告文本** +2. **使用段落形式,如果需要表格,必须使用 Markdown 表格格式** +3. **输出内容必须基于历史对话信息,不得编造不实信息** +4. 不得输出解释说明 +5. 所有内容使用完整中文句子 +6. **【重要】每个段落首行必须缩进两个中文字符,使用两个全角空格"  "开头,例如:"  这是段落内容。"** +7. 根据报告主题自动选择合适的报告结构,可以是分析报告、总结报告、调研报告等 +8. **报告内容必须紧扣报告主题,确保内容相关性和连贯性** +{"9. **报告中必须包含用户上传的图片信息,在相关部分引用图片URL**" if extracted_images else ""} + + + + + +**标题格式规范(必须严格遵守):** + +1. **报告主标题**:使用二级标题格式,即 `## **标题内容**`,例如:`## **智能运维系统项目总结报告**` +2. **章节大标题**:使用三级标题格式,即 `### **标题内容**`,例如:`### **一、项目概述**` +3. **小节标题**:使用四级标题格式,即 `#### **标题内容**`,例如:`#### **1. 项目背景**` +4. 所有标题必须加粗,使用 `**标题内容**` 格式 +5. 标题前后各空一行 + + + + + +**段落格式规范(必须严格遵守):** + +1. 每个段落必须以两个全角空格"  "开头 +2. 正确示例: +  本项目旨在开发一套智能运维系统,用于提升企业IT运维效率。 +  系统采用微服务架构设计,支持高并发访问。 +3. 错误示例(缺少缩进): +本项目旨在开发一套智能运维系统。 +4. 段落之间空一行 + + + + + +## **{report_title}** + +报告编号:{report_number} +编制时间:{compile_time} + +### **一、概述** + +  介绍背景与目的。 + +### **二、主要内容** + +  阐述核心问题与关键分析过程。 + +### **三、备品备件** + +  列出本次维修或操作涉及的备品备件的名称(如有机油滤芯、密封圈、螺栓、传感器等),不要输出表格。如果没有涉及备件,请写"无"。 + +### **四、结论** + +  总结结果并提出结论性观点。 +{"- **附件图片:**" + chr(10) + chr(10).join([f"![图片{idx}]({img['url']})" for idx, img in enumerate(extracted_images, 1)]) if extracted_images else ""} + + + + +只输出完整报告正文,不要任何解释说明。每个段落首行必须使用"  "(两个全角空格)缩进。所有标题必须使用对应级别的Markdown格式并加粗。 + +""" + + 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()) + + + + + diff --git a/workflows/workflow_qa_report.py b/workflows/workflow_qa_report.py new file mode 100644 index 0000000..8d272d9 --- /dev/null +++ b/workflows/workflow_qa_report.py @@ -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""" + +你是一名设备运维与故障分析报告编写专家。 +你的任务是根据用户需求、历史对话和抽取信息生成一份正式的故障处理报告。 + + + +{combined_query} + + + +{extracted_items} + + + +{history_context if history_context else "(无历史对话上下文)"} + + +{f''' +{image_section} +''' if extracted_images else ''} + + + +必须严格遵守以下规则: + +1. 输出为 **正式技术报告文本** +2. **使用段落形式,如果有表格生成,生成表格必须arkdown表格,不要html形式** +3. **如果给定结果在历史对话信息中没有,可以进行删除** +4. **输出内容必须严格按照历史对话信息内容进行生成,不得编造不实信息和不相干信息** +5. 不得输出解释说明 +6. 不要使用列表符号 +7. 所有内容使用完整中文句子。 +8. **每个段落首行必须缩进,使用两个全角空格(  )实现首行缩进两个中文字符的效果** +{"9. **报告中必须包含用户上传的图片信息,在故障信息部分引用图片URL**" if extracted_images else ""} + + + + + +{report_title} + +报告编号: {report_number} +编制时间: {compile_time} +事件类型: 设备故障诊断与处置 + +一、事件概述 +  简要说明故障发生背景、报警情况、系统影响以及运维团队启动排查流程的情况。 + +二、故障信息 +  说明设备名称、型号、所属系统、报警时间、故障现象以及参与处置人员。 +{"  **附件图片:**" + chr(10) + chr(10).join([f"  ![图片{idx}]({img['url']})" for idx, img in enumerate(extracted_images, 1)]) if extracted_images else ""} + +三、故障排查过程 +  按照时间顺序描述排查步骤,包括:用户反馈 → 初步判断 → 数据分析 → 现场检查 → 故障确认。 + +四、维修措施 +  描述维修人员采取的具体维修步骤,包括设备停机、拆卸检查、更换部件、紧固连接、恢复运行和测试过程。 + +五、后续建议 +  提出预防类似故障的改进措施,例如:维护周期优化、知识库记录、巡检项目增加、技术培训等。 + +六、总结 +  总结本次故障原因、处理效果以及经验意义。 + + + + +只输出完整报告正文,不要任何解释说明。 + +""" + + try: + report_text = await OpenaiAPI.open_api_chat_without_thinking( + query=prompt, + model=None, + messages=history_messages + ) + if not report_text: + report_text = "抱歉,当前无法生成分析报告,请稍后重试。" + + word_dir = os.path.join(parent_dir, "created_word") + os.makedirs(word_dir, exist_ok=True) + + safe_topic = re.sub(r'[\\/:*?"<>|]', '_', report_title) + file_name = f"{safe_topic}.docx" + output_path = os.path.join(word_dir, file_name) + + word_result = generate_report_word( + markdown=report_text, + title=report_title, + output=output_path + ) + + actions: List[Dict[str, Any]] = [] + + if word_result and os.path.exists(word_result): + base_url = SERVER_CONFIG.get("report") + download_url = f"http://192.168.1.164:9088/api/download/{file_name}" + + actions.append({ + "type": "download", + "label": f"{report_title}.docx", + "url": download_url, + "fileName": file_name, + "content": report_text + }) + else: + error_msg = f"\n\n[注意] Word 文档生成失败" + report_text += error_msg + + # actions.append({ + # "type": "regenerate", + # "label": "重新生成", + # "icon": "report" + # }) + + suggested_replies = await generate_suggested_replies_qa_report(report_text) + + return { + "report_text": report_text, + "response": report_text, + "actions": actions, + "route_flag": "report", + "suggestedReplies": [], +# "suggestedReplies": suggested_replies, + "__details__": "分析报告生成完成" + } + except Exception as e: + # error_event = { + # "type": "function_execution", + # "title": "步骤二失败:生成分析报告", + # "details": f"错误: {str(e)}", + # "has_result": False + # } + # for callback in get_all_callbacks(): + # try: + # callback(error_event) + # except Exception: + # pass + + return { + "report_text": f"生成分析报告失败: {str(e)}", + "response": f"生成分析报告失败: {str(e)}", + "actions": [], + "route_flag": "report", + "suggestedReplies": [], + "error_message": str(e), + "__details__": f"生成分析报告失败: {str(e)}" + } + + +async def generate_suggested_replies_qa_report(report: str) -> List[Dict[str, Any]]: + """ + 生成针对历史问答分析报告的建议回复问题 + 使用大模型生成两个相关问题 + """ + prompt = f""" +你是一个专业的数据分析助手。根据以下历史问答分析报告,生成两个用户可能关心的后续问题。 + +【分析报告】 +{report[:500]} + +请生成两个简洁、实用的问题,这些问题应该: +1. 不得出现需要归档和下载等操作相关的问题 +2. 与分析报告相关,能够帮助用户更好地理解或使用分析结果 +3. 问题要具体、可操作 +4. 每个问题不超过20个字 + +请以JSON格式输出,格式如下: +[ + {{"title": "问题1的标题", "content": "问题1的完整内容"}}, + {{"title": "问题2的标题", "content": "问题2的完整内容"}} +] + +只输出JSON,不要有其他文字说明。 +""" + try: + answer = await OpenaiAPI.open_api_chat_without_thinking( + query=prompt, + model=None + ) + json_match = re.search(r'\[.*\]', answer, re.DOTALL) + if json_match: + json_str = json_match.group(0) + suggested_replies = json.loads(json_str) + if isinstance(suggested_replies, list) and len(suggested_replies) >= 2: + formatted_replies = [] + for reply in suggested_replies[:2]: + if isinstance(reply, dict) and "title" in reply and "content" in reply: + formatted_replies.append({ + "title": str(reply["title"]), + "content": str(reply["content"]) + }) + if len(formatted_replies) == 2: + return formatted_replies + + return [ + {"title": "如何优化问题分布?", "content": "如何优化问题分布?"}, + {"title": "有哪些改进建议?", "content": "有哪些改进建议?"} + ] + except Exception as e: + return [ + {"title": "如何优化问题分布?", "content": "如何优化问题分布?"}, + {"title": "有哪些改进建议?", "content": "有哪些改进建议?"} + ] + + +def create_history_qa_analysis_workflow(): + graph = StateGraph(HistoryQAAnalysisState) + + graph.add_node("extract_items", extract_analysis_items) + graph.add_node("generate_text", generate_analysis_report_text) + + graph.add_edge(START, "extract_items") + graph.add_edge("extract_items", "generate_text") + graph.add_edge("generate_text", END) + + return graph.compile() + + +# @track_function_calls +async def run_qa_report_agent( + extracted_text: str, + combined_query: str, + history_message: str = "", + route_flag: str = "", + extracted_images: List[Dict[str, str]] = None +) -> Dict[str, Any]: + app = create_history_qa_analysis_workflow() + + initial_state: HistoryQAAnalysisState = { + "extracted_text": extracted_text, + "combined_query": combined_query, + **init_history(history_message), + "extracted_items": [], + "extracted_images": extracted_images or [], + "report_title": "", + "report_text": "", + "response": "", + "actions": [], + "route_flag": "", + "suggestedReplies": [], + "error_message": "" + } + + return await app.ainvoke(initial_state) + + +if __name__ == "__main__": + import asyncio + + + async def test(): + history_message = json.dumps( + [ + {"role": "user", "content": "我需要分析最近的历史问答数据"}, + {"role": "assistant", "content": "好的,我可以帮您生成历史问答分析报告,请提供相关数据。"}, + {"role": "user", "content": "请重点关注设备故障和维护相关的内容"} + ], + ensure_ascii=False, + ) + + result = await run_qa_report_agent( + extracted_text="请重点关注设备故障和维护相关的内容", + combined_query="用户问题:请重点关注设备故障和维护相关的内容", + history_message=history_message + ) + + print("====== 历史问答分析报告文本 ======") + print(result["response"]) + print("\n====== 推荐问题 ======") + for reply in result.get("suggestedReplies", []): + print(f"- {reply['title']}: {reply['content']}") + print("\n====== 操作按钮 ======") + for action in result.get("actions", []): + print(f"- {action.get('label', 'Unknown')}: {action.get('type', 'Unknown')}") + + + asyncio.run(test()) + + + diff --git a/workflows/workflow_statistics.py b/workflows/workflow_statistics.py new file mode 100644 index 0000000..55b50c6 --- /dev/null +++ b/workflows/workflow_statistics.py @@ -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()) + diff --git a/workflows/workflow_utils.py b/workflows/workflow_utils.py new file mode 100644 index 0000000..f3b9382 --- /dev/null +++ b/workflows/workflow_utils.py @@ -0,0 +1,169 @@ +""" +工作流公共工具函数 +提取自 workflow_fault_diagnosis.py 和 workflow_operate.py 的共享代码 +""" + +from typing import TypedDict, Optional, Dict, Any, List +from prompts import COMMON_PROMPTS +from modelsAPI.model_api import OpenaiAPI +from utils.function_tracker import get_all_callbacks, get_current_stream_handler +from utils.image_utils import extract_image_paths, normalize_markdown_images +import json +import re +import random + + +def safe_json_extract(text: str) -> Optional[Any]: + if not text: + return None + cleaned = text.strip() + cleaned = re.sub(r"```json\s*", "", cleaned, flags=re.IGNORECASE) + cleaned = re.sub(r"```\s*", "", cleaned) + m = re.search(r"\[[\s\S]*\]", cleaned) + if m: + try: + return json.loads(m.group(0)) + except Exception: + pass + m = re.search(r"\{[\s\S]*\}", cleaned) + if m: + try: + return json.loads(m.group(0)) + except Exception: + pass + try: + return json.loads(cleaned) + except Exception: + return None + + +from workflows.history_manager import parse_history, build_history_str, filter_image_urls, init_history # noqa: F401 + + +def normalize_latex_spacing(text: str) -> str: + placeholder = "\x00DOUBLE_DOLLAR\x00" + text = text.replace("$$", placeholder) + text = re.sub(r'(? str: + if not text or len(text) < 10: + return text + + lines = text.split('\n') + result = [] + seen_segments = set() + + for line in lines: + line = line.rstrip() + if not line: + result.append(line) + continue + + normalized_line = line.strip().lower() + if normalized_line in seen_segments: + continue + + seen_segments.add(normalized_line) + result.append(line) + + cleaned_text = '\n'.join(result) + return cleaned_text + + +def convert_rag_result(rag_result): + if not rag_result or not rag_result.get("success", False): + return [] + source_citation = rag_result.get("sourceCitation", {}) + flat_results = [] + for resource, items in source_citation.items(): + for item in items: + flat_results.append(item) + return flat_results + + +def calculate_info_completeness(weights: Dict[str, float], values: Dict[str, str]) -> float: + score = 0.0 + for field, weight in weights.items(): + value = values.get(field, "") or "" + if value and str(value).strip(): + score += weight + return round(score, 2) + + +def emit_callback_event(title_options: List[str], details: str): + start_event = {"type": "function_execution", "title": random.choice(title_options), "details": details} + for callback in get_all_callbacks(): + try: + callback(start_event) + except Exception: + pass + + +def build_detail_info(fields: Dict[str, str]) -> str: + detail_info = "" + for label, value in fields.items(): + if value: + detail_info += f"- {label}:{value}\n" + return detail_info + + +def append_atlas_section(answer: str, atlas_results: Dict[str, Any]) -> str: + if not atlas_results: + return answer + atlas_section = "\n\n---\n\n**参考图册:**\n" + for node_name, node_data in atlas_results.items(): + if isinstance(node_data, dict): + for title, urls in node_data.items(): + if isinstance(urls, list) and urls: + for url in urls: + atlas_section += f"- {title}: [ `{title}` ]({url})\n" + return answer + atlas_section + + + + +async def stream_format_and_postprocess( + raw_content: str, + original_image_paths: List[str], + temperature: float = 0.3, + content_label: str = None, +) -> str: + if content_label: + format_user_content = COMMON_PROMPTS["format_user_simple"].format( + content_label=content_label, + raw_content=raw_content, + ) + else: + format_user_content = COMMON_PROMPTS["format_user_with_image_examples"].format(raw_content=raw_content) + + format_system_prompt = COMMON_PROMPTS["format_system"] + + stream_handler = get_current_stream_handler() + answer = "" + accumulated_content = "" + + async for chunk in OpenaiAPI.open_api_chat_stream( + model=None, + system_prompt=format_system_prompt, + messages=[{"role": "user", "content": format_user_content}], + temperature=temperature, + ): + answer += chunk + accumulated_content += chunk + if stream_handler: + stream_handler.send_stream_content(accumulated_content) + + answer = answer.strip() if answer else raw_content + answer = remove_duplicate_content(answer) + answer = normalize_markdown_images(answer, original_paths=original_image_paths) + answer = normalize_latex_spacing(answer) + + return answer + +