commit ce6dd1af15e823bf2b5faf569d3c730e1b05465c Author: Defeng Date: Mon Jul 13 16:01:00 2026 +0800 Initial commit: 初始化项目代码 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/__pycache__/app.cpython-312.pyc b/__pycache__/app.cpython-312.pyc new file mode 100644 index 0000000..1cb5ae1 Binary files /dev/null and b/__pycache__/app.cpython-312.pyc differ diff --git a/__pycache__/checkpointer_config.cpython-312.pyc b/__pycache__/checkpointer_config.cpython-312.pyc new file mode 100644 index 0000000..f3aebef Binary files /dev/null and b/__pycache__/checkpointer_config.cpython-312.pyc differ diff --git a/__pycache__/chunk_text.cpython-312.pyc b/__pycache__/chunk_text.cpython-312.pyc new file mode 100644 index 0000000..cc86682 Binary files /dev/null and b/__pycache__/chunk_text.cpython-312.pyc differ diff --git a/__pycache__/config.cpython-312.pyc b/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000..b612c7a Binary files /dev/null and b/__pycache__/config.cpython-312.pyc differ diff --git a/__pycache__/doc2pdf.cpython-312.pyc b/__pycache__/doc2pdf.cpython-312.pyc new file mode 100644 index 0000000..22333ba Binary files /dev/null and b/__pycache__/doc2pdf.cpython-312.pyc differ diff --git a/__pycache__/documents_prompt.cpython-312.pyc b/__pycache__/documents_prompt.cpython-312.pyc new file mode 100644 index 0000000..5be96eb Binary files /dev/null and b/__pycache__/documents_prompt.cpython-312.pyc differ diff --git a/__pycache__/documents_prompt.cpython-313.pyc b/__pycache__/documents_prompt.cpython-313.pyc new file mode 100644 index 0000000..866aea5 Binary files /dev/null and b/__pycache__/documents_prompt.cpython-313.pyc differ diff --git a/__pycache__/fileparse_util.cpython-312.pyc b/__pycache__/fileparse_util.cpython-312.pyc new file mode 100644 index 0000000..54878be Binary files /dev/null and b/__pycache__/fileparse_util.cpython-312.pyc differ diff --git a/__pycache__/main_agent.cpython-312.pyc b/__pycache__/main_agent.cpython-312.pyc new file mode 100644 index 0000000..92e82cf Binary files /dev/null and b/__pycache__/main_agent.cpython-312.pyc differ diff --git a/__pycache__/prompts.cpython-312.pyc b/__pycache__/prompts.cpython-312.pyc new file mode 100644 index 0000000..484628f Binary files /dev/null and b/__pycache__/prompts.cpython-312.pyc differ diff --git a/__pycache__/workflow_registry.cpython-312.pyc b/__pycache__/workflow_registry.cpython-312.pyc new file mode 100644 index 0000000..3ca32f4 Binary files /dev/null and b/__pycache__/workflow_registry.cpython-312.pyc differ 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 copy.py b/app copy.py new file mode 100644 index 0000000..087516c --- /dev/null +++ b/app copy.py @@ -0,0 +1,1605 @@ +import asyncio +import re +import sys +from typing import Optional, List, Set,Iterable + +if sys.platform.startswith("win") and hasattr(asyncio, "WindowsSelectorEventLoopPolicy"): + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + +import ahocorasick +from pydantic import BaseModel, Field, model_validator +from fastapi import FastAPI, File, Path, UploadFile, HTTPException, Form, Request, Header, Body, BackgroundTasks +from fastapi.responses import StreamingResponse +try: + from langchain_core.prompts import FewShotPromptTemplate, PromptTemplate +except ImportError: + from langchain.prompts import FewShotPromptTemplate, PromptTemplate +from openai.types.chat import ChatCompletionSystemMessageParam, ChatCompletionUserMessageParam, ChatCompletionMessageParam +from chunk_text import get_chunk_bbox, split_text_preserve_sentences, data_replace, chunk_check,merge_short_slices,find_and_read_content_list,process_pdf_file,process_other_file +from fastapi.responses import JSONResponse, StreamingResponse +import requests +from pathlib import PurePath, Path as PathLib +import tempfile + +from modelsAPI import model_api +from documents_prompt import ( + BEAUTIFY_TYPE_DICT, + TEMPLATE_DICT, + GENERATE_NORMAL_OUTLINE_PROMPT, + GENERATE_MARKDOWN_LIST_OUTLINE_PROMPT, + GENERATE_METHOD_OUTLINE_PROMPT, +) +"""app.py +FastAPI 主 Agent 接口 - 修复重复执行问题 +提供主脑 Agent 的 HTTP API 服务,支持流式输出 +集成 LangGraph Checkpointer 实现状态持久化 +""" +import logging +from time import sleep +import aiofiles + +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 doc2pdf import Doc2PDF + +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 +) +app = FastAPI(max_request_size=1024 * 1024 * 10) + +converter = Doc2PDF() +DATA_DIR = "/app/files" +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 RewriteRequest(BaseModel): + rewrite_type: Optional[str] = None + content: Optional[str] = None + require: Optional[str] = None + sentence: Optional[str] = None + + +REWRITE_TYPE_MAP = { + "quotePolish": "金句润色", + "formalStyle": "书面化", + "speechStyle": "讲稿化", + "formatting": "规整", + "expand": "扩写", + "continueWriting": "续写", + "report": "汇报", + "quote": "金句", + "summarize": "精简", + "condense": "总结", +} + + +def build_rewrite_prompt(request: RewriteRequest) -> str: + beautify_type = REWRITE_TYPE_MAP.get(request.rewrite_type or "") + if not beautify_type: + raise HTTPException(status_code=400, detail=f"不支持的 rewrite_type: {request.rewrite_type}") + + prompt_template = BEAUTIFY_TYPE_DICT.get(beautify_type) + if not prompt_template: + raise HTTPException(status_code=400, detail=f"未找到提示词模板: {beautify_type}") + + return prompt_template.format( + require=request.require or "", + content=request.content or "", + sentence=request.sentence or request.content or "", + ) + + +async def stream_llm_content(prompt: str): + async for chunk in model_api.OpenaiAPI.open_api_chat_stream( + query=prompt, + model=None, + system_prompt="你是专业文稿改写助手,根据要求进行文本改写。", + messages=[], + ): + if chunk: + yield chunk + + +@app.post("/Rewrite") +async def rewrite(request: RewriteRequest): + prompt = build_rewrite_prompt(request) + + return StreamingResponse( + stream_llm_content(prompt), + media_type="text/plain; charset=utf-8", + ) + + +class WriteRequest(BaseModel): + role: Optional[str] = None + template: Optional[str] = None + title: Optional[str] = None + length: Optional[str] = None + requirement: Optional[str] = None + references: Optional[str] = None + outline: Optional[str] = None + konwlege: Optional[str] = None + prompt: Optional[str] = None + + +class OutlineRequest(BaseModel): + role: Optional[str] = None + template: Optional[str] = None + title: Optional[str] = None + length: Optional[str] = None + requirement: Optional[str] = None + references: Optional[str] = None + konwlege: Optional[str] = None + prompt: Optional[str] = None + + +def length_convert(length: Optional[str]) -> str: + if length == "短": + return "1000字" + if length == "中": + return "1000-2000字" + if length == "长": + return "2000-5000字" + return length or "" + + +def build_write_prompt(request: WriteRequest) -> str: + if request.prompt and request.prompt.strip(): + return request.prompt.strip() + + template = request.template or "" + prompt_template = TEMPLATE_DICT.get(template) + if not prompt_template: + raise HTTPException(status_code=400, detail=f"不支持的写作模板: {template}") + + title = request.title or "" + if not title.strip(): + raise HTTPException(status_code=400, detail="title不能为空") + + return prompt_template.format( + role=request.role or "", + title=title, + length=length_convert(request.length), + requirement=request.requirement or "", + references=request.references or "", + outline=request.outline or "", + ) + + +async def stream_write_content(prompt: str): + async for chunk in model_api.OpenaiAPI.open_api_chat_stream( + query=prompt, + model=None, + system_prompt="你是专业公文写作助手,请直接输出正文内容。", + messages=[], + ): + if chunk: + yield chunk + + +@app.post("/Write") +async def write(request: WriteRequest): + prompt = build_write_prompt(request) + + return StreamingResponse( + stream_write_content(prompt), + media_type="text/plain; charset=utf-8", + ) + + +def build_outline_prompt(request: OutlineRequest) -> str: + if request.prompt and request.prompt.strip(): + return request.prompt.strip() + + template = request.template or "" + title = request.title or "" + if not title.strip(): + raise HTTPException(status_code=400, detail="title不能为空") + + markdown_list_template = {"报告", "函", "文书", "工作总结"} + prompt_template = GENERATE_NORMAL_OUTLINE_PROMPT + + if template == "方案": + prompt_template = GENERATE_METHOD_OUTLINE_PROMPT + elif template in markdown_list_template: + prompt_template = GENERATE_MARKDOWN_LIST_OUTLINE_PROMPT + + return prompt_template.format( + role=request.role or "", + template=template, + title=title, + length=length_convert(request.length), + requirement=request.requirement or "", + references=request.references or "", + ) + + +async def stream_outline_content(prompt: str): + async for chunk in model_api.OpenaiAPI.open_api_chat_stream( + query=prompt, + model=None, + system_prompt="你是专业公文提纲生成助手,请直接输出提纲内容。", + messages=[], + ): + if chunk: + yield chunk + + +@app.post("/Outline") +async def outline(request: OutlineRequest): + prompt = build_outline_prompt(request) + + return StreamingResponse( + stream_outline_content(prompt), + media_type="text/plain; charset=utf-8", + ) + + +class ReviewRequest(BaseModel): + require: Optional[str] = Field(None, description="额外校对要求") + content: str = Field(..., description="待校对文本") + types: List[str] = Field(..., description="检查类型") + sensitive_words: List[str] = Field(default_factory=list, description="敏感词汇") + negative_words: List[str] = Field(default_factory=list, description="错误词汇") + positive_words: List[str] = Field(default_factory=list, description="正词词汇") + + @model_validator(mode="after") + def check_content(self): + if not self.content or not self.content.strip(): + raise ValueError("content不能为空") + return self + + +class ReviewItem(BaseModel): + original: str + error: str + suggestion: str + + +class ReviewResponse(BaseModel): + data: List[ReviewItem] + + +def normalize_words(words: List[str]) -> Set[str]: + return {word.strip() for word in words if word and word.strip()} + + +def find_keywords(text: str, keyword_set: Set[str]) -> List[str]: + if not text or not keyword_set: + return [] + + automaton = ahocorasick.Automaton() + + for idx, keyword in enumerate(keyword_set): + automaton.add_word(keyword, (idx, keyword)) + + automaton.make_automaton() + + found = set() + for _, (_, keyword) in automaton.iter(text): + found.add(keyword) + + return list(found) + + +def append_dictionary_results( + items: List[dict], + content: str, + sensitive_words: Set[str], + negative_words: Set[str], +) -> List[dict]: + for word in find_keywords(content, sensitive_words): + items.append({ + "original": word, + "error": "敏感词汇", + "suggestion": "", + }) + + for word in find_keywords(content, negative_words): + items.append({ + "original": word, + "error": "错误词汇", + "suggestion": "", + }) + + return items + + +def filter_positive_word_false_positives( + items: List[dict], + positive_words: Set[str], +) -> List[dict]: + if not positive_words: + return items + + result = [] + + for item in items: + original = str(item.get("original", "")) + error = str(item.get("error", "")) + + has_positive_word = any(word in original for word in positive_words) + is_typo_error = ( + "错别字" in error + or "错别词" in error + or "错别词语" in error + ) + + if has_positive_word and is_typo_error: + continue + + result.append(item) + + return result + + +def dedupe_and_filter(items: List[dict], source_text: str) -> List[dict]: + seen = set() + result = [] + + for item in items: + original = str(item.get("original", "")).strip() + error = str(item.get("error", "")).strip() + suggestion = str(item.get("suggestion", "")).strip() + + if not original: + continue + + if original == suggestion: + continue + + if original not in source_text: + continue + + key = (original, error, suggestion) + if key in seen: + continue + + seen.add(key) + result.append({ + "original": original, + "error": error, + "suggestion": suggestion, + }) + + return result + +def cotprompt(query): + conten1 = """ + 人工智能正在深刻改变现,代教育的方式。通过智能教学系统【】AI可以根据学生的学习习惯和掌握情况,提供个性化的学习内容和节奏学习内容和节奏,提高学习效率。同时,AI还能辅助教师进行作业批改、学习分析,甚至参与课堂互动,减轻教师的重复工作压力。 + 虚拟助教、智能语音识别、自动文达系统等技术,也让远程教育更加高效和便捷。更加高效和便捷。随着AI技术的发展,教育不再局限于固定的教材和课堂,而是变得更加开放、灵活和智能化------让每个雪生都能获得更适合自己的学习体验。人工智能不仅是工具,更是推动教育公平和质量提升的重要力量。 + """ + reasoning1 = """ + "深刻改变现,代教育中"多了",",修改为“深刻改变现代教育中”;“智能教学系统【】”中存在错误符号“【】”,修改为“智能教学系统”;“提供个性化的学习内容和节奏学习内容和节奏”存在重复内容,修改为"提供个性化的学习内容和节奏";"自动文达系统"存在错误词语"文达",修改为”自动问答系统“;”而是变得更加开放、灵活和智能化------“ + 中存在错误符号“------”,修改为“而是变得更加开放、灵活和智能化,”;“每个雪生”中存在错别字“雪生”,修改为“每个学生”;检查完上述错误之后,正确的文本内容应该是:人工智能正在深刻改变现代教育的方式。通过智能教学系统,AI可以根据学生的学习习惯和掌握情况,提供个性化的学习内容和节奏,提高学习效率。同时,AI还能辅助教师进行作业批改、学习分析,甚至参与课堂互动,减轻教师的重复工作压力。虚拟助教、智能语音识别、自动问答系统等技术,也让远程教育更加高效和便捷。 + 随着AI技术的发展,教育不再局限于固定的教材和课堂,而是变得更加开放、灵活和智能化,让每个学生都能获得更适合自己的学习体验。人工智能不仅是工具,更是推动教育公平和质量提升的重要力量。 + """ + ques = f""" + 文本内容为{conten1},对给出的文本内容进行逻辑校对、基础校对和合规性检查,逻辑校对检测文本逻辑连贯性,基础校对检查文本中错别字、错误标点符号和重复内容。 + 校对文本之后,如果你发现没有任何错误,不需要返回其他内容,只需要返回: + ###\n### + 发现错误时返回: + ###\n错误:错别字、标点不规范\n原文:星光店电;万里乌云\n建议:星光点点,万里无云 + \n错误:错别词语、标点不规范\n原文:请注丰收与团圆。\n建议:庆祝丰收与团圆。 + \n错误:标点不规范\n原文:你好;明天\n建议:你好,明天 + \n错误:重复内容\n原文:我听说你心情很不错不错。你心情很不错不错。\n建议:我听说你心情很不错。### + """ + + conten2 = """ + 关于近期工作安排的通知 + 各位同事, + 京公司研九决定,现将近期工作安排通知通知如下: + 一、项目推进方面。各部门需加快项目进度,确保按时交付。如有问题,及时与相关领导沟通解决。项目负责人要切实履行职责,加强团队协作,提高工作效率。 + 二、培训学习方面:公司将组织系列培训课程,提升员工专业技能。具体时间、地点另行通知。员工需按时参加,不得无辜缺席。自即日起,严禁在工作时间从事与工作无关的活动。 + 二、纪律要求。近期发现部分员工上班时间做与工作无关的事情,如炒股、聊天等,严重影响工作效率和公司形象。自即日起,严禁在工作时间从事与工作无关的活动。违反规定者,将按照公司规章制度严肃处理。 + 特此通知: + [公司名称] + [日期] + """ + reasoning2 = """ + "京公司研九决定"中存在错别字和词语“京”、“研九”,修改为“经公司研究决定”;“工作安排通知通知”存在重复内容,修改为“工作安排通知”;“一、项目推进方面。”存在错误标点符号,参与后续内容中“二、培训学习方面:”,因此“一、项目推进方面”之后应当使用“:”,修改为“一、项目推进方面:”;“无辜缺席。”中存在错别字“辜”,修改为“无故缺席”; + “二、纪律要求。”存在错别字、标点不规范,纪律要求应该是第三点,应该使用”:“,修改为“三、纪律要求:”;”特此通知:“中存在标点不规范,应该修改为”特此通知。“检查完上述错误之后,正确的文本内容应该是:关于近期工作安排的通知 + 各位同事, + 经公司研究决定,现将近期工作安排通知如下: + 一、项目推进方面:各部门需加快项目进度,确保按时交付。如有问题,及时与相关领导沟通解决。项目负责人要切实履行职责,加强团队协作,提高工作效率。 + 二、培训学习方面:公司将组织系列培训课程,提升员工专业技能。具体时间、地点另行通知。员工需按时参加,不得无故缺席。自即日起,严禁在工作时间从事与工作无关的活动。 + 三、纪律要求:近期发现部分员工上班时间做与工作无关的事情,如炒股、聊天等,严重影响工作效率和公司形象。自即日起,严禁在工作时间从事与工作无关的活动。违反规定者,将按照公司规章制度严肃处理。 + 特此通知。 + [公司名称] + [日期] + """ + + ques2 = f""" + 文本内容为{conten2},对给出的文本内容进行逻辑校对、基础校对和合规性检查,逻辑校对检测文本逻辑连贯性,基础校对检查文本中错别字、错误标点符号和重复内容。 + 校对文本之后,如果你发现没有任何错误,不需要返回其他内容,只需要返回: + ###\n### + 发现错误时返回: + ###\n错误:错别字、标点不规范\n原文:星光店电;万里乌云\n建议:星光点点,万里无云 + \n错误:错别词语、标点不规范\n原文:请注丰收与团圆。\n建议:庆祝丰收与团圆。 + \n错误:标点不规范\n原文:你好;明天\n建议:你好,明天 + \n错误:重复内容\n原文:我听说你心情很不错不错。你心情很不错不错。\n建议:我听说你心情很不错。### + """ + + conten3 = """ + 《水声目标识别》课程教学计划 + + 一、课程名称 + 水声目标识别,该课程式用于海洋科学、水声工程、电子信息等专业本科生asfqfqfqwfwqf、研究生,以及军事院校学员、相关领域科研人员和企业技术人员技术人员。 + + 三、计划学时 + 2026学年,总学时:48学时(理论授课32学时,实作授课16学时) + + 三、授课时间 + 每周4学时,共12周完成 + 四、教学目标 + 1. 知识目标 + 使学生掌握水声目标识别的几本原理】方法和技术,包括声纳信号的特性、目标特征提取、分类与识别算法等。 + 了解水声目标识别在军事(如潜艇作战、反潜作战等)和民用(如海洋资源勘探、水下考古等)领域的应用。 + 熟悉水声目标识别系统的基本组成和工作原理, + 2. 能力目标 + 培养学生运用所学知识对水声信号进行分析处理、特征提取和目标识别的能力。 + 提高学生运用专业软件(如MATLAB等)进行水声水声信号处理和目标识别仿真的能力。 + 增强学生解决实际水声目标识别问题的实践能力和创新思维。 + 3. 素质目标 + 培养学生严谨的科学态度和扎实的专业素养,使其剧备良好的团队协作精神和沟通能力。 + 激发学生对水声技术领域的学习兴趣和探索精神,培养其自主学习和终身学习的意识。 + """ + + ques3 = f""" + 文本内容为{conten3},对给出的文本内容进行逻辑校对、基础校对和合规性检查,逻辑校对检测文本逻辑连贯性,基础校对检查文本中错别字、错误标点符号和重复内容。 + 校对文本之后,如果你发现没有任何错误,不需要返回其他内容,只需要返回: + ###\n### + 发现错误时返回: + ###\n错误:错别字、标点不规范\n原文:星光店电;万里乌云\n建议:星光点点,万里无云 + \n错误:错别词语、标点不规范\n原文:请注丰收与团圆。\n建议:庆祝丰收与团圆。 + \n错误:标点不规范\n原文:你好;明天\n建议:你好,明天 + \n错误:重复内容\n原文:我听说你心情很不错不错。你心情很不错不错。\n建议:我听说你心情很不错。### + """ + reasoning3 = """ + "式用"中存在错别字“式”,修改为:“适用”;“本科生asfqfqfqwfwqf、”中存在错别字和符号“asfqfqfqwfwqf”,修改为:“本科生、”;“三、计划学时”存在错别字使用不当,修改为:“二、计划学时”; + “几本原理】”存在错别字、标点不规范,应该修改为:“基本原理”;“工作原理,”存在标点不规范,应该修改为:“工作原理。”;“水声水声信号”中存在重复内容“水声”,应该修改为“水声信号”; + “剧备”存在错别字“剧”,应该修改为:”具备“。检查完上述错误之后,正确的文本内容应该是: + 《水声目标识别》课程教学计划 + + 一、课程名称 + 水声目标识别,该课程式用于海洋科学、水声工程、电子信息等专业本科生、研究生,以及军事院校学员、相关领域科研人员和企业技术人员。 + + 三、计划学时 + 2026学年,总学时:48学时(理论授课32学时,实作授课16学时) + + 三、授课时间 + 每周4学时,共12周完成 + 四、教学目标 + 1. 知识目标 + 使学生掌握水声目标识别的基本原理、方法和技术,包括声纳信号的特性、目标特征提取、分类与识别算法等。 + 了解水声目标识别在军事(如潜艇作战、反潜作战等)和民用(如海洋资源勘探、水下考古等)领域的应用。 + 熟悉水声目标识别系统的基本组成和工作原理。 + 2. 能力目标 + 培养学生运用所学知识对水声信号进行分析处理、特征提取和目标识别的能力。 + 提高学生运用专业软件(如MATLAB等)进行水声信号处理和目标识别仿真的能力。 + 增强学生解决实际水声目标识别问题的实践能力和创新思维。 + 3. 素质目标 + 培养学生严谨的科学态度和扎实的专业素养,使其具备良好的团队协作精神和沟通能力。 + 激发学生对水声技术领域的学习兴趣和探索精神,培养其自主学习和终身学习的意识。 + """ + + # 定义 Few-Shot CoT 示例 + examples = [ + { + "question": ques, + "reasoning": reasoning1, + "answer": """ + \n错误:标点不规范\n原文:现,代教育\n建议:现代教育 + \n错误:标点不规范\n原文:智能教学系统【】\n建议:智能教学系统 + \n错误:重复内容\n原文:提供个性化的学习内容和节奏学习内容和节奏\n建议:提供个性化的学习内容和节奏 + \n错误:错别字、错别词语\n原文:自动文达系统\n建议:自动问答系统 + \n错误:标点不规范\n原文:而是变得更加开放、灵活和智能化------\n建议:而是变得更加开放、灵活和智能化, + \n错误:错别字、错别词语\n原文:每个雪生\n建议:每个学生 + """ + }, + { + "question": ques2, + "reasoning": reasoning2, + "answer": """ + \n错误:错别字、错别词语\n原文:京公司研九决定\n建议:经公司研究决定 + \n错误:重复内容\n原文:工作安排通知通知\n建议:工作安排通知 + \n错误:错别字、错别词语\n原文:无辜缺席。\n建议:无故缺席 + \n错误:错别字、标点不规范\n原文:二、纪律要求。\n建议:三、纪律要求: + \n错误:标点不规范\n原文:特此通知:\n建议:特此通知。 + """ + }, + { + "question": ques3, + "reasoning": reasoning3, + "answer": """ + \n错误:错别字、错别词语\n原文:式用\n建议:适用 + \n错误:错别字、错别字符\n原文:本科生asfqfqfqwfwqf、\n建议:本科生、 + \n错误:错别字、逻辑不顺\n原文:三、计划学时\n建议:二、计划学时 + \n错误:错别字、标点不规范\n原文:几本原理】\n建议:基本原理 + \n错误:标点不规范\n原文:工作原理,\n建议:工作原理。 + \n错误:重复内容\n原文:水声水声信号\n建议:水声信号 + \n错误:错别字、错别词语\n原文:剧备\n建议:具备 + """ + } + ] + + # 定义单个示例的模板 + example_template = """ + 问题: {question} + 推理过程: {reasoning} + 答案: {answer} + """ + + # 创建 PromptTemplate 对象 + example_prompt = PromptTemplate( + input_variables=["question", "reasoning", "answer"], + template=example_template + ) + + # 创建 FewShotPromptTemplate 对象 + few_shot_prompt = FewShotPromptTemplate( + examples=examples, + example_prompt=example_prompt, + prefix="你是一个文本内容校对高手。能够根据要要求进行错误检查。请根据以下示例,解决用户提出的问题。", + suffix="问题: {question}\n推理过程:", + input_variables=["question"], + example_separator="\n\n" + ) + prompt = few_shot_prompt.format(question=query) + return prompt + + +def build_review_prompt(types: List[str], content: str, require: Optional[str]) -> Iterable[ChatCompletionMessageParam]: + if require is None: + require = "" + review_t = "现在需要你帮我完成以下文本校对任务:" + for i , t in enumerate(types): + if t =="逻辑校对": + review_t += f"{i} 对这段文本进行逻辑校对\n" + if t =="基础校对": + review_t += f"{i}.1 进行错别字校对,错别字错误主要以文本用字不当为主,例如:星光店电,正确的应该为:星光点点。\n" + review_t += f"{i}.2 进行标点校对,主要以标点符号使用不当为主,例如:星光点点;万里无云,正确的应该为:星光点点,万里无云\n" + review_t += f"{i}.3 进行格式规范校对\n" + review_t += f"{i}.4 进行重复内容校对,主要以原文中出现重复词语、句子或文本为主,例如:“今天今天心情相当不错,我很开心。我很开心。“,正确的内容应该为:今天心情相当不错,我很开心。\n" + if t=="合规性检查": + review_t += f"{i} 对这段文本进行合规性检查\n" + query = f""" + 我会给你对应的文本,进行相应的检查,{review_t} + """ + query1 = cotprompt(content) + return [ + ChatCompletionUserMessageParam(role="user", content="请不要进行思考,直接输出内容"), + ChatCompletionUserMessageParam(role="user", content=query), + ChatCompletionUserMessageParam(role="user", content=query1), + + ] + + + + +_REVIEW_BLOCK_RE = re.compile( + r"错误[::]\s*(?P.*?)\s*[\r\n]+" + r"原文[::]\s*(?P.*?)\s*[\r\n]+" + r"建议[::]\s*(?P.*?)(?=\n\s*\n|$)", + re.S, +) + + +def parse_review_output(text: str) -> List[dict]: + text = text.strip() + + if text.startswith("###"): + text = text[3:].strip() + + if text.endswith("###"): + text = text[:-3].strip() + + result = [] + + for match in _REVIEW_BLOCK_RE.finditer(text): + result.append({ + "error": match.group("error").strip(), + "original": match.group("original").strip(), + "suggestion": match.group("suggestion").strip(), + }) + + return result + + +async def call_review_model(prompt: Iterable[ChatCompletionMessageParam]) -> str: + system_prompt = ( + "你是一个专业的文本校对专家,能够根据要求进行内容的纠正和改错" + "请严格按照用户要求的格式输出。" + "不要输出推理过程、解释性文字、前缀或总结。" + ) + + full_text = "" + + async for chunk in model_api.OpenaiAPI.open_api_chat_stream( + model=None, + json_output=False, + system_prompt=system_prompt, + messages=prompt, + enable_thinking=False, + temperature=0.1, + ): + if chunk: + full_text += chunk + + return full_text + + +async def review_chunk( + content: str, + types: List[str], + require: Optional[str], +) -> List[dict]: + prompt = build_review_prompt( + content=content, + types=types, + require=require, + ) + + model_output = await call_review_model(prompt) + return parse_review_output(model_output) + + +async def review_text( + content: str, + types: List[str], + require: Optional[str], + sensitive_words: List[str], + negative_words: List[str], + positive_words: List[str], +) -> List[dict]: + sensitive_word_set = normalize_words(sensitive_words) + negative_word_set = normalize_words(negative_words) + positive_word_set = normalize_words(positive_words) + + chunk_size = 1000 + chunks = [ + content[i:i + chunk_size] + for i in range(0, len(content), chunk_size) + ] + + all_items = [] + + for chunk in chunks: + llm_items = await review_chunk( + content=chunk, + types=types, + require=require, + ) + + llm_items = filter_positive_word_false_positives( + llm_items, + positive_word_set, + ) + + llm_items = append_dictionary_results( + llm_items, + content=chunk, + sensitive_words=sensitive_word_set, + negative_words=negative_word_set, + ) + + all_items.extend(llm_items) + + return dedupe_and_filter(all_items, content) + + +@app.post("/Review", response_model=ReviewResponse) +async def review(request: ReviewRequest): + result = await review_text( + content=request.content, + types=request.types, + require=request.require, + sensitive_words=request.sensitive_words, + negative_words=request.negative_words, + positive_words=request.positive_words, + ) + + return {"data": result} + + + +# =============== 请求模型 =============== + + +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.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 + +class PdfContentItem(BaseModel): + page_idx: int + bbox: Optional[str] = None + text: Optional[str] = None + text_level: Optional[int] = None + img_path: Optional[str] = None + table_body: Optional[str] = None + + +class RequestWrapper(BaseModel): + pdf_contents: List[PdfContentItem] + + + + +@app.post("/split_content_list") +async def split_content_list(request_data: RequestWrapper): + pdf_contents = request_data.pdf_contents + processed_list = [] + + for item in pdf_contents: + # 将 Pydantic 模型转换为字典,方便修改 + item_dict = item.model_dump() + + # --- 核心逻辑:判断并添加 type 字段 --- + item_type = None + + # 1. 如果 text 非空 -> type: "text" + if item.text: + item_type = "text" + + # 2. 如果 table_body 非空 -> type: "table" + # 注意:如果 text 和 table_body 都有,根据代码顺序,table 会覆盖 text。 + # 如果你的业务逻辑是互斥的或需要优先级,请调整此处顺序。 + elif item.table_body: + item_type = "table" + + # 3. 如果 img_path 非空 且 table_body 为空 -> type: "image" + # 注意:上面用了 elif 判断 table_body,所以到这里 table_body 肯定为空, + # 但为了逻辑清晰,显式写出条件。 + elif item.img_path and not item.table_body: + item_type = "image" + + # 如果匹配到了类型,写入字典 + if item_type: + item_dict["type"] = item_type + + processed_list.append(item_dict) + slices = get_chunk_bbox(processed_list) + slices_check = chunk_check(slices, 8000) + + return {"code": 200, "message": "ok", "data": {"slices": slices_check}} + + +@app.post("/split_result") +async def split_result( + file: UploadFile = File(...), + image_prefix: str = Form("/api/v1/knowledge/files/images/"), +): + if not file.filename: + raise HTTPException(status_code=400, detail="File name is missing") + + chunk_size = 1024 + chunk_overlap = 100 + + filename = file.filename + suffix = PathLib(filename).suffix.lower() # 获取文件后缀 + temp_input_path: Optional[PathLib] = None + converted_pdf_path: Optional[PathLib] = None + logger.info("调用知识切分接口") + + try: + # ? 创建临时文件时指定后缀名 + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp_in: + content = await file.read() + logger.info(f"文件大小:{len(content)}") + logger.info(f"前10字节:{content[:10]}") + # if not content.startwith(b"%PDF"): + # logger.error("不是合法PDF") + # raise HTTPException(status_code=400,detail="Uploaded file is not a valid PDF") + tmp_in.write(content) + temp_input_path = PathLib(tmp_in.name) + # 2. 根据文件类型处理 + if suffix == ".pdf": + logger.info("调用pdf切分方式") + + result_data = await process_pdf_file(temp_input_path, image_prefix,filename) + if result_data is None: + raise HTTPException(status_code=502, detail="PDF processing failed") + elif suffix == ".docx": + logger.info("调用docx切分方式") + logger.info(f"转换 DOCX -> PDF: {filename}") + + # 执行转换 + converter.convert(str(temp_input_path), output_dir=str(DATA_DIR)) + + # ? 使用临时文件的基础名查找 PDF + temp_base_name = temp_input_path.stem # 如 tmp2bb0l4cn + converted_pdf_path = PathLib(DATA_DIR) / f"{temp_base_name}.pdf" + + if not converted_pdf_path.exists(): + raise HTTPException(status_code=500, detail=f"PDF 转换失败: {converted_pdf_path}") + + result_data = await process_pdf_file(converted_pdf_path, image_prefix) + if result_data is None: + raise HTTPException(status_code=502, detail="DOCX to PDF processing failed") + + elif suffix in (".txt", ".md"): + logger.info("调用txt切分方式") + + # 直接读取文本内容 + async with aiofiles.open(temp_input_path, "r", encoding="utf-8", errors="ignore") as f: + text_content = await f.read() + contents = split_text_preserve_sentences(text_content) + # 构造统一结构(无 images) + slices = [] + for ins in contents: + slices.append({"content": ins, "positions": []}) + result_data = {"slices": slices, "images": {}} + elif suffix in [".xlsx"]: + # converter.convert(str(temp_input_path), output_dir=str(DATA_DIR)) + # temp_base_name = temp_input_path.stem # 如 tmp2bb0l4cn + # converted_pdf_path = PathLib(DATA_DIR) / f"{temp_base_name}.pdf" + # if not converted_pdf_path.exists(): + # raise HTTPException(status_code=500, detail=f"PDF 转换失败: {converted_pdf_path}") + logger.info("调用xlsx切分方式") + + result_data = await process_other_file(temp_input_path, image_prefix,filename) + if result_data is None: + raise HTTPException(status_code=502, detail="DOCX parse failed") + elif suffix in [".ppt", ".pptx"]: + logger.info("调用 PPT/PPTX 切分方式") + + # 1. 判断是否为旧版 .ppt 格式,如果是,则先进行转换 + if suffix == ".ppt": + logger.info(f"检测到旧版 PPT 文件,正在转换为 PPTX: {temp_input_path}") + try: + # 定义 LibreOffice 转换命令 + command = [ + 'libreoffice', + '--headless', # 无头模式,不弹出图形界面 + '--convert-to', 'pptx', + '--outdir', os.path.dirname(temp_input_path), + temp_input_path + ] + + # 2. 使用 run_in_executor 将阻塞的同步转换操作放入线程池,避免阻塞异步事件循环 + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + None, + lambda: subprocess.run(command, capture_output=True, text=True) + ) + + # 3. 检查转换是否成功 + if result.returncode != 0: + logger.error(f"PPT 转换失败: {result.stderr}") + raise HTTPException(status_code=500, detail="PPT to PPTX conversion failed") + + # 4. 转换成功后,更新输入路径为新生成的 .pptx 文件路径 + temp_input_path = os.path.splitext(temp_input_path)[0] + ".pptx" + logger.info(f"PPT 转换成功,新文件路径: {temp_input_path}") + + except Exception as e: + logger.exception(f"PPT 转换过程发生异常: {e}") + raise HTTPException(status_code=500, detail=f"PPT conversion error: {str(e)}") + + # 5. 无论是原本就是 .pptx,还是刚刚转换完成的 .pptx,都统一走后续处理逻辑 + result_data = await process_other_file(temp_input_path, image_prefix, filename) + if result_data is None: + raise HTTPException(status_code=502, detail="PPTX parse failed") + else: + raise HTTPException( + status_code=400, detail="Unsupported file type. Only PDF, DOCX, TXT, and MD are supported." + ) + + return JSONResponse({"code": 200, "message": "ok", "data": result_data}) + + except requests.RequestException as e: + logger.error(f"Split service request failed: {e}") + raise HTTPException(status_code=502, detail=f"Split service error: {str(e)}") + except Exception as e: + logger.error(f"Internal error: {e}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + finally: + # 清理临时文件 + for path in [temp_input_path, converted_pdf_path]: + if path and path.exists(): + try: + path.unlink() + except Exception as e: + logger.warning(f"Failed to delete temp file {path}: {e}") + + +@app.get("/") +async def root(): + """根路径""" + return { + "message": "Agent API 服务", + "docs": "/docs" + } + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=9090) diff --git a/app.py b/app.py new file mode 100644 index 0000000..5318af9 --- /dev/null +++ b/app.py @@ -0,0 +1,1677 @@ +import asyncio +import re +import sys +from typing import Optional, List, Set,Iterable + +if sys.platform.startswith("win") and hasattr(asyncio, "WindowsSelectorEventLoopPolicy"): + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + +import ahocorasick +from pydantic import BaseModel, Field, model_validator +from fastapi import FastAPI, File, Path, UploadFile, HTTPException, Form, Request, Header, Body, BackgroundTasks +from fastapi.responses import StreamingResponse +try: + from langchain_core.prompts import FewShotPromptTemplate, PromptTemplate +except ImportError: + from langchain.prompts import FewShotPromptTemplate, PromptTemplate +from openai.types.chat import ChatCompletionSystemMessageParam, ChatCompletionUserMessageParam, ChatCompletionMessageParam +from chunk_text import get_chunk_bbox, split_text_preserve_sentences, data_replace, chunk_check,merge_short_slices,find_and_read_content_list,process_pdf_file,process_other_file +from fastapi.responses import JSONResponse, StreamingResponse +import requests +from pathlib import PurePath, Path as PathLib +import tempfile + +from modelsAPI import model_api +from documents_prompt import ( + BEAUTIFY_TYPE_DICT, + TEMPLATE_DICT, + GENERATE_NORMAL_OUTLINE_PROMPT, + GENERATE_MARKDOWN_LIST_OUTLINE_PROMPT, + GENERATE_METHOD_OUTLINE_PROMPT, +) +"""app.py +FastAPI 主 Agent 接口 - 修复重复执行问题 +提供主脑 Agent 的 HTTP API 服务,支持流式输出 +集成 LangGraph Checkpointer 实现状态持久化 +""" +import logging +from time import sleep +import aiofiles + +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 doc2pdf import Doc2PDF + +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 +) +app = FastAPI(max_request_size=1024 * 1024 * 10) + +converter = Doc2PDF() +DATA_DIR = "/app/files" +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") + +STREAM_RESPONSE_HEADERS = { + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + "X-Content-Type-Options": "nosniff", +} + + +def llm_text_stream_response(content) -> StreamingResponse: + return StreamingResponse( + content, + media_type="text/plain; charset=utf-8", + headers=STREAM_RESPONSE_HEADERS, + ) + + +@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 RewriteRequest(BaseModel): + rewrite_type: Optional[str] = None + content: Optional[str] = None + require: Optional[str] = None + sentence: Optional[str] = None + + +REWRITE_TYPE_MAP = { + "quotePolish": "金句润色", + "formalStyle": "书面化", + "speechStyle": "讲稿化", + "formatting": "规整", + "expand": "扩写", + "continueWriting": "续写", + "report": "汇报", + "quote": "金句", + "summarize": "精简", + "condense": "总结", +} + + +def build_rewrite_prompt(request: RewriteRequest) -> str: + beautify_type = REWRITE_TYPE_MAP.get(request.rewrite_type or "") + if not beautify_type: + raise HTTPException(status_code=400, detail=f"不支持的 rewrite_type: {request.rewrite_type}") + + prompt_template = BEAUTIFY_TYPE_DICT.get(beautify_type) + if not prompt_template: + raise HTTPException(status_code=400, detail=f"未找到提示词模板: {beautify_type}") + + return prompt_template.format( + require=request.require or "", + content=request.content or "", + sentence=request.sentence or request.content or "", + ) + + +async def stream_llm_content(prompt: str): + async for chunk in model_api.OpenaiAPI.open_api_chat_stream( + query=prompt, + model=None, + system_prompt="你是专业文稿改写助手,根据要求进行文本改写。", + messages=[], + ): + if chunk: + yield chunk + + +@app.post("/Rewrite") +async def rewrite(request: RewriteRequest): + prompt = build_rewrite_prompt(request) + + return llm_text_stream_response(stream_llm_content(prompt)) + + +class WriteRequest(BaseModel): + role: Optional[str] = None + template: Optional[str] = None + title: Optional[str] = None + length: Optional[str] = None + requirement: Optional[str] = None + references: Optional[str] = None + outline: Optional[str] = None + konwlege: Optional[str] = None + prompt: Optional[str] = None + +class OutlineRequest(BaseModel): + role: Optional[str] = None + template: Optional[str] = None + title: Optional[str] = None + length: Optional[str] = None + requirement: Optional[str] = None + references: Optional[str] = None + konwlege: Optional[str] = None + prompt: Optional[str] = None + + +def length_convert(length: Optional[str]) -> str: + if length == "短": + return "1000字" + if length == "中": + return "1000-2000字" + if length == "长": + return "2000-5000字" + return length or "" + + +def build_write_prompt(request: WriteRequest) -> str: + prompt = request.prompt or "" + length = length_convert(request.length) + template = request.template or "" + content = f"请生成一篇字数在{length}的{template},字数严格遵守该规则,并且不能超过给定字数范围。{prompt}" + prompt_template = TEMPLATE_DICT.get(template) + if not prompt_template: + raise HTTPException(status_code=400, detail=f"不支持的写作模板: {template}") + + title = request.title or "" + if not title.strip(): + raise HTTPException(status_code=400, detail="title不能为空") + + return content+prompt_template.format( + role=request.role or "", + title=title, + length=length_convert(request.length), + requirement=request.requirement or "", + references=request.references or "", + outline=request.outline or "", + ) + + +async def stream_write_content(prompt: str): + async for chunk in model_api.OpenaiAPI.open_api_chat_stream( + query=prompt, + model=None, + system_prompt="你是专业公文写作助手,请直接输出正文内容。", + messages=[], + ): + if chunk: + yield chunk + + +@app.post("/Write") +async def write(request: WriteRequest): + prompt = build_write_prompt(request) + + return llm_text_stream_response(stream_write_content(prompt)) + + +def build_outline_prompt(request: OutlineRequest) -> str: + if request.prompt and request.prompt.strip(): + return request.prompt.strip() + + template = request.template or "" + title = request.title or "" + if not title.strip(): + raise HTTPException(status_code=400, detail="title不能为空") + + markdown_list_template = {"报告", "函", "文书", "工作总结"} + prompt_template = GENERATE_NORMAL_OUTLINE_PROMPT + + if template == "方案": + prompt_template = GENERATE_METHOD_OUTLINE_PROMPT + elif template in markdown_list_template: + prompt_template = GENERATE_MARKDOWN_LIST_OUTLINE_PROMPT + + return prompt_template.format( + role=request.role or "", + template=template, + title=title, + length=length_convert(request.length), + requirement=request.requirement or "", + references=request.references or "", + ) + + +async def stream_outline_content(prompt: str): + async for chunk in model_api.OpenaiAPI.open_api_chat_stream( + query=prompt, + model=None, + system_prompt="你是专业公文提纲生成助手,请直接输出提纲内容。", + messages=[], + ): + if chunk: + yield chunk + + +@app.post("/Outline") +async def outline(request: OutlineRequest): + prompt = build_outline_prompt(request) + + return llm_text_stream_response(stream_outline_content(prompt)) + + +class ReviewRequest(BaseModel): + require: Optional[str] = Field(None, description="额外校对要求") + content: str = Field(..., description="待校对文本") + types: List[str] = Field(..., description="检查类型") + sensitive_words: List[str] = Field(default_factory=list, description="敏感词汇") + negative_words: List[str] = Field(default_factory=list, description="错误词汇") + positive_words: List[str] = Field(default_factory=list, description="正词词汇") + + @model_validator(mode="after") + def check_content(self): + if not self.content or not self.content.strip(): + raise ValueError("content不能为空") + return self + + +class ReviewItem(BaseModel): + original: str + error: str + suggestion: str + + +class ReviewResponse(BaseModel): + data: List[ReviewItem] + + +def normalize_words(words: List[str]) -> Set[str]: + return {word.strip() for word in words if word and word.strip()} + + +def find_keywords(text: str, keyword_set: Set[str]) -> List[str]: + if not text or not keyword_set: + return [] + + automaton = ahocorasick.Automaton() + + for idx, keyword in enumerate(keyword_set): + automaton.add_word(keyword, (idx, keyword)) + + automaton.make_automaton() + + found = set() + for _, (_, keyword) in automaton.iter(text): + found.add(keyword) + + return list(found) + + +def append_dictionary_results( + items: List[dict], + content: str, + sensitive_words: Set[str], + negative_words: Set[str], +) -> List[dict]: + for word in find_keywords(content, sensitive_words): + items.append({ + "original": word, + "error": "敏感词汇", + "suggestion": "", + }) + + for word in find_keywords(content, negative_words): + items.append({ + "original": word, + "error": "错误词汇", + "suggestion": "", + }) + + return items + + +def filter_positive_word_false_positives( + items: List[dict], + positive_words: Set[str], +) -> List[dict]: + if not positive_words: + return items + + result = [] + + for item in items: + original = str(item.get("original", "")) + error = str(item.get("error", "")) + + has_positive_word = any(word in original for word in positive_words) + is_typo_error = ( + "错别字" in error + or "错别词" in error + or "错别词语" in error + ) + + if has_positive_word and is_typo_error: + continue + + result.append(item) + + return result + + +def dedupe_and_filter(items: List[dict], source_text: str) -> List[dict]: + seen = set() + result = [] + + for item in items: + original = str(item.get("original", "")).strip() + error = str(item.get("error", "")).strip() + suggestion = str(item.get("suggestion", "")).strip() + + if not original: + continue + + if original == suggestion: + continue + + if original not in source_text: + continue + + key = (original, error, suggestion) + if key in seen: + continue + + seen.add(key) + result.append({ + "original": original, + "error": error, + "suggestion": suggestion, + }) + + return result + +def cotprompt(query): + conten1 = """ + 人工智能正在深刻改变现,代教育的方式。通过智能教学系统【】AI可以根据学生的学习习惯和掌握情况,提供个性化的学习内容和节奏学习内容和节奏,提高学习效率。同时,AI还能辅助教师进行作业批改、学习分析,甚至参与课堂互动,减轻教师的重复工作压力。 + 虚拟助教、智能语音识别、自动文达系统等技术,也让远程教育更加高效和便捷。更加高效和便捷。随着AI技术的发展,教育不再局限于固定的教材和课堂,而是变得更加开放、灵活和智能化------让每个雪生都能获得更适合自己的学习体验。人工智能不仅是工具,更是推动教育公平和质量提升的重要力量。 + """ + reasoning1 = """ + "深刻改变现,代教育中"多了",",修改为“深刻改变现代教育中”;“智能教学系统【】”中存在错误符号“【】”,修改为“智能教学系统”;“提供个性化的学习内容和节奏学习内容和节奏”存在重复内容,修改为"提供个性化的学习内容和节奏";"自动文达系统"存在错误词语"文达",修改为”自动问答系统“;”而是变得更加开放、灵活和智能化------“ + 中存在错误符号“------”,修改为“而是变得更加开放、灵活和智能化,”;“每个雪生”中存在错别字“雪生”,修改为“每个学生”;检查完上述错误之后,正确的文本内容应该是:人工智能正在深刻改变现代教育的方式。通过智能教学系统,AI可以根据学生的学习习惯和掌握情况,提供个性化的学习内容和节奏,提高学习效率。同时,AI还能辅助教师进行作业批改、学习分析,甚至参与课堂互动,减轻教师的重复工作压力。虚拟助教、智能语音识别、自动问答系统等技术,也让远程教育更加高效和便捷。 + 随着AI技术的发展,教育不再局限于固定的教材和课堂,而是变得更加开放、灵活和智能化,让每个学生都能获得更适合自己的学习体验。人工智能不仅是工具,更是推动教育公平和质量提升的重要力量。 + """ + ques = f""" + 文本内容为{conten1},对给出的文本内容进行逻辑校对、基础校对和合规性检查,逻辑校对检测文本逻辑连贯性,基础校对检查文本中错别字、错误标点符号和重复内容。 + 校对文本之后,如果你发现没有任何错误,不需要返回其他内容,只需要返回: + ###\n### + 发现错误时返回: + ###\n错误:错别字、标点不规范\n原文:星光店电;万里乌云\n建议:星光点点,万里无云 + \n错误:错别词语、标点不规范\n原文:请注丰收与团圆。\n建议:庆祝丰收与团圆。 + \n错误:标点不规范\n原文:你好;明天\n建议:你好,明天 + \n错误:重复内容\n原文:我听说你心情很不错不错。你心情很不错不错。\n建议:我听说你心情很不错。### + """ + + conten2 = """ + 关于近期工作安排的通知 + 各位同事, + 京公司研九决定,现将近期工作安排通知通知如下: + 一、项目推进方面。各部门需加快项目进度,确保按时交付。如有问题,及时与相关领导沟通解决。项目负责人要切实履行职责,加强团队协作,提高工作效率。 + 二、培训学习方面:公司将组织系列培训课程,提升员工专业技能。具体时间、地点另行通知。员工需按时参加,不得无辜缺席。自即日起,严禁在工作时间从事与工作无关的活动。 + 二、纪律要求。近期发现部分员工上班时间做与工作无关的事情,如炒股、聊天等,严重影响工作效率和公司形象。自即日起,严禁在工作时间从事与工作无关的活动。违反规定者,将按照公司规章制度严肃处理。 + 特此通知: + [公司名称] + [日期] + """ + reasoning2 = """ + "京公司研九决定"中存在错别字和词语“京”、“研九”,修改为“经公司研究决定”;“工作安排通知通知”存在重复内容,修改为“工作安排通知”;“一、项目推进方面。”存在错误标点符号,参与后续内容中“二、培训学习方面:”,因此“一、项目推进方面”之后应当使用“:”,修改为“一、项目推进方面:”;“无辜缺席。”中存在错别字“辜”,修改为“无故缺席”; + “二、纪律要求。”存在错别字、标点不规范,纪律要求应该是第三点,应该使用”:“,修改为“三、纪律要求:”;”特此通知:“中存在标点不规范,应该修改为”特此通知。“检查完上述错误之后,正确的文本内容应该是:关于近期工作安排的通知 + 各位同事, + 经公司研究决定,现将近期工作安排通知如下: + 一、项目推进方面:各部门需加快项目进度,确保按时交付。如有问题,及时与相关领导沟通解决。项目负责人要切实履行职责,加强团队协作,提高工作效率。 + 二、培训学习方面:公司将组织系列培训课程,提升员工专业技能。具体时间、地点另行通知。员工需按时参加,不得无故缺席。自即日起,严禁在工作时间从事与工作无关的活动。 + 三、纪律要求:近期发现部分员工上班时间做与工作无关的事情,如炒股、聊天等,严重影响工作效率和公司形象。自即日起,严禁在工作时间从事与工作无关的活动。违反规定者,将按照公司规章制度严肃处理。 + 特此通知。 + [公司名称] + [日期] + """ + + ques2 = f""" + 文本内容为{conten2},对给出的文本内容进行逻辑校对、基础校对和合规性检查,逻辑校对检测文本逻辑连贯性,基础校对检查文本中错别字、错误标点符号和重复内容。 + 校对文本之后,如果你发现没有任何错误,不需要返回其他内容,只需要返回: + ###\n### + 发现错误时返回: + ###\n错误:错别字、标点不规范\n原文:星光店电;万里乌云\n建议:星光点点,万里无云 + \n错误:错别词语、标点不规范\n原文:请注丰收与团圆。\n建议:庆祝丰收与团圆。 + \n错误:标点不规范\n原文:你好;明天\n建议:你好,明天 + \n错误:重复内容\n原文:我听说你心情很不错不错。你心情很不错不错。\n建议:我听说你心情很不错。### + """ + + conten3 = """ + 《水声目标识别》课程教学计划 + + 一、课程名称 + 水声目标识别,该课程式用于海洋科学、水声工程、电子信息等专业本科生asfqfqfqwfwqf、研究生,以及军事院校学员、相关领域科研人员和企业技术人员技术人员。 + + 三、计划学时 + 2026学年,总学时:48学时(理论授课32学时,实作授课16学时) + + 三、授课时间 + 每周4学时,共12周完成 + 四、教学目标 + 1. 知识目标 + 使学生掌握水声目标识别的几本原理】方法和技术,包括声纳信号的特性、目标特征提取、分类与识别算法等。 + 了解水声目标识别在军事(如潜艇作战、反潜作战等)和民用(如海洋资源勘探、水下考古等)领域的应用。 + 熟悉水声目标识别系统的基本组成和工作原理, + 2. 能力目标 + 培养学生运用所学知识对水声信号进行分析处理、特征提取和目标识别的能力。 + 提高学生运用专业软件(如MATLAB等)进行水声水声信号处理和目标识别仿真的能力。 + 增强学生解决实际水声目标识别问题的实践能力和创新思维。 + 3. 素质目标 + 培养学生严谨的科学态度和扎实的专业素养,使其剧备良好的团队协作精神和沟通能力。 + 激发学生对水声技术领域的学习兴趣和探索精神,培养其自主学习和终身学习的意识。 + """ + + ques3 = f""" + 文本内容为{conten3},对给出的文本内容进行逻辑校对、基础校对和合规性检查,逻辑校对检测文本逻辑连贯性,基础校对检查文本中错别字、错误标点符号和重复内容。 + 校对文本之后,如果你发现没有任何错误,不需要返回其他内容,只需要返回: + ###\n### + 发现错误时返回: + ###\n错误:错别字、标点不规范\n原文:星光店电;万里乌云\n建议:星光点点,万里无云 + \n错误:错别词语、标点不规范\n原文:请注丰收与团圆。\n建议:庆祝丰收与团圆。 + \n错误:标点不规范\n原文:你好;明天\n建议:你好,明天 + \n错误:重复内容\n原文:我听说你心情很不错不错。你心情很不错不错。\n建议:我听说你心情很不错。### + """ + reasoning3 = """ + "式用"中存在错别字“式”,修改为:“适用”;“本科生asfqfqfqwfwqf、”中存在错别字和符号“asfqfqfqwfwqf”,修改为:“本科生、”;“三、计划学时”存在错别字使用不当,修改为:“二、计划学时”; + “几本原理】”存在错别字、标点不规范,应该修改为:“基本原理”;“工作原理,”存在标点不规范,应该修改为:“工作原理。”;“水声水声信号”中存在重复内容“水声”,应该修改为“水声信号”; + “剧备”存在错别字“剧”,应该修改为:”具备“。检查完上述错误之后,正确的文本内容应该是: + 《水声目标识别》课程教学计划 + + 一、课程名称 + 水声目标识别,该课程式用于海洋科学、水声工程、电子信息等专业本科生、研究生,以及军事院校学员、相关领域科研人员和企业技术人员。 + + 三、计划学时 + 2026学年,总学时:48学时(理论授课32学时,实作授课16学时) + + 三、授课时间 + 每周4学时,共12周完成 + 四、教学目标 + 1. 知识目标 + 使学生掌握水声目标识别的基本原理、方法和技术,包括声纳信号的特性、目标特征提取、分类与识别算法等。 + 了解水声目标识别在军事(如潜艇作战、反潜作战等)和民用(如海洋资源勘探、水下考古等)领域的应用。 + 熟悉水声目标识别系统的基本组成和工作原理。 + 2. 能力目标 + 培养学生运用所学知识对水声信号进行分析处理、特征提取和目标识别的能力。 + 提高学生运用专业软件(如MATLAB等)进行水声信号处理和目标识别仿真的能力。 + 增强学生解决实际水声目标识别问题的实践能力和创新思维。 + 3. 素质目标 + 培养学生严谨的科学态度和扎实的专业素养,使其具备良好的团队协作精神和沟通能力。 + 激发学生对水声技术领域的学习兴趣和探索精神,培养其自主学习和终身学习的意识。 + """ + + # 定义 Few-Shot CoT 示例 + examples = [ + { + "question": ques, + "reasoning": reasoning1, + "answer": """ + \n错误:标点不规范\n原文:现,代教育\n建议:现代教育 + \n错误:标点不规范\n原文:智能教学系统【】\n建议:智能教学系统 + \n错误:重复内容\n原文:提供个性化的学习内容和节奏学习内容和节奏\n建议:提供个性化的学习内容和节奏 + \n错误:错别字、错别词语\n原文:自动文达系统\n建议:自动问答系统 + \n错误:标点不规范\n原文:而是变得更加开放、灵活和智能化------\n建议:而是变得更加开放、灵活和智能化, + \n错误:错别字、错别词语\n原文:每个雪生\n建议:每个学生 + """ + }, + { + "question": ques2, + "reasoning": reasoning2, + "answer": """ + \n错误:错别字、错别词语\n原文:京公司研九决定\n建议:经公司研究决定 + \n错误:重复内容\n原文:工作安排通知通知\n建议:工作安排通知 + \n错误:错别字、错别词语\n原文:无辜缺席。\n建议:无故缺席 + \n错误:错别字、标点不规范\n原文:二、纪律要求。\n建议:三、纪律要求: + \n错误:标点不规范\n原文:特此通知:\n建议:特此通知。 + """ + }, + { + "question": ques3, + "reasoning": reasoning3, + "answer": """ + \n错误:错别字、错别词语\n原文:式用\n建议:适用 + \n错误:错别字、错别字符\n原文:本科生asfqfqfqwfwqf、\n建议:本科生、 + \n错误:错别字、逻辑不顺\n原文:三、计划学时\n建议:二、计划学时 + \n错误:错别字、标点不规范\n原文:几本原理】\n建议:基本原理 + \n错误:标点不规范\n原文:工作原理,\n建议:工作原理。 + \n错误:重复内容\n原文:水声水声信号\n建议:水声信号 + \n错误:错别字、错别词语\n原文:剧备\n建议:具备 + """ + } + ] + + # 定义单个示例的模板 + example_template = """ + 问题: {question} + 推理过程: {reasoning} + 答案: {answer} + """ + + # 创建 PromptTemplate 对象 + example_prompt = PromptTemplate( + input_variables=["question", "reasoning", "answer"], + template=example_template + ) + + # 创建 FewShotPromptTemplate 对象 + few_shot_prompt = FewShotPromptTemplate( + examples=examples, + example_prompt=example_prompt, + prefix="你是一个文本内容校对高手。能够根据要要求进行错误检查。请根据以下示例,解决用户提出的问题。", + suffix="问题: {question}\n推理过程:", + input_variables=["question"], + example_separator="\n\n" + ) + prompt = few_shot_prompt.format(question=query) + return prompt + + +def build_review_prompt(types: List[str], content: str, require: Optional[str]) -> Iterable[ChatCompletionMessageParam]: + if require is None: + require = "" + require = require.strip() + review_t = "现在需要你帮我完成以下文本校对任务:" + for i , t in enumerate(types): + if t =="逻辑校对": + review_t += f"{i} 对这段文本进行逻辑校对\n" + if t =="基础校对": + review_t += f"{i}.1 进行错别字校对,错别字错误主要以文本用字不当为主,例如:星光店电,正确的应该为:星光点点。\n" + review_t += f"{i}.2 进行标点校对,主要以标点符号使用不当为主,例如:星光点点;万里无云,正确的应该为:星光点点,万里无云\n" + review_t += f"{i}.3 进行格式规范校对\n" + review_t += f"{i}.4 进行重复内容校对,主要以原文中出现重复词语、句子或文本为主,例如:“今天今天心情相当不错,我很开心。我很开心。“,正确的内容应该为:今天心情相当不错,我很开心。\n" + if t=="合规性检查": + review_t += f"{i} 对这段文本进行合规性检查\n" + require_text = f"\n额外要求:{require}\n" if require else "" + query = f""" + 我会给你对应的文本,进行相应的检查,{review_t} + {require_text} + """ + query1 = cotprompt(content) + return [ + ChatCompletionUserMessageParam(role="user", content="请不要进行思考,直接输出内容"), + ChatCompletionUserMessageParam(role="user", content=query), + ChatCompletionUserMessageParam(role="user", content=query1), + + ] + + + + +_REVIEW_BLOCK_RE = re.compile( + r"错误[::]\s*(?P.*?)\s*[\r\n]+" + r"原文[::]\s*(?P.*?)\s*[\r\n]+" + r"建议[::]\s*(?P.*?)(?=\n\s*\n|$)", + re.S, +) + + +def parse_review_output(text: str) -> List[dict]: + text = text.strip() + + if text.startswith("###"): + text = text[3:].strip() + + if text.endswith("###"): + text = text[:-3].strip() + + result = [] + + for match in _REVIEW_BLOCK_RE.finditer(text): + result.append({ + "error": match.group("error").strip(), + "original": match.group("original").strip(), + "suggestion": match.group("suggestion").strip(), + }) + + return result + + +REVIEW_SYSTEM_PROMPT = ( + "你是一个专业的文本校对专家,能够根据要求进行内容的纠正和改错" + "请严格按照用户要求的格式输出。" + "不要输出推理过程、解释性文字、前缀或总结。" +) + + +async def stream_review_model(prompt: Iterable[ChatCompletionMessageParam]): + async for chunk in model_api.OpenaiAPI.open_api_chat_stream( + model=None, + json_output=False, + system_prompt=REVIEW_SYSTEM_PROMPT, + messages=prompt, + enable_thinking=False, + temperature=0.1, + ): + if chunk: + yield chunk + + +async def call_review_model(prompt: Iterable[ChatCompletionMessageParam]) -> str: + full_text = "" + + async for chunk in stream_review_model(prompt): + full_text += chunk + + return full_text + + +def build_review_stream_require( + require: Optional[str], + sensitive_words: List[str], + negative_words: List[str], + positive_words: List[str], +) -> Optional[str]: + requirements = [] + + if require and require.strip(): + requirements.append(require.strip()) + + sensitive_text = "、".join(dict.fromkeys(word.strip() for word in sensitive_words if word and word.strip())) + if sensitive_text: + requirements.append(f"请重点检查以下敏感词:{sensitive_text}。如果原文出现这些词,错误类型写“敏感词汇”。") + + negative_text = "、".join(dict.fromkeys(word.strip() for word in negative_words if word and word.strip())) + if negative_text: + requirements.append(f"请重点检查以下错误词:{negative_text}。如果原文出现这些词,错误类型写“错误词汇”。") + + positive_text = "、".join(dict.fromkeys(word.strip() for word in positive_words if word and word.strip())) + if positive_text: + requirements.append(f"以下词为正确词,涉及错别字判断时不要误判:{positive_text}。") + + if not requirements: + return None + + return "\n".join(requirements) + + +async def stream_review_content( + content: str, + types: List[str], + require: Optional[str], + sensitive_words: List[str], + negative_words: List[str], + positive_words: List[str], +): + review_require = build_review_stream_require( + require=require, + sensitive_words=sensitive_words, + negative_words=negative_words, + positive_words=positive_words, + ) + + chunk_size = 1000 + chunks = [ + content[i:i + chunk_size] + for i in range(0, len(content), chunk_size) + ] + + for chunk in chunks: + prompt = build_review_prompt( + content=chunk, + types=types, + require=review_require, + ) + + async for token in stream_review_model(prompt): + yield token + + +async def review_chunk( + content: str, + types: List[str], + require: Optional[str], +) -> List[dict]: + prompt = build_review_prompt( + content=content, + types=types, + require=require, + ) + + model_output = await call_review_model(prompt) + return parse_review_output(model_output) + + +async def review_text( + content: str, + types: List[str], + require: Optional[str], + sensitive_words: List[str], + negative_words: List[str], + positive_words: List[str], +) -> List[dict]: + sensitive_word_set = normalize_words(sensitive_words) + negative_word_set = normalize_words(negative_words) + positive_word_set = normalize_words(positive_words) + + chunk_size = 1000 + chunks = [ + content[i:i + chunk_size] + for i in range(0, len(content), chunk_size) + ] + + all_items = [] + + for chunk in chunks: + llm_items = await review_chunk( + content=chunk, + types=types, + require=require, + ) + + llm_items = filter_positive_word_false_positives( + llm_items, + positive_word_set, + ) + + llm_items = append_dictionary_results( + llm_items, + content=chunk, + sensitive_words=sensitive_word_set, + negative_words=negative_word_set, + ) + + all_items.extend(llm_items) + + return dedupe_and_filter(all_items, content) + + +@app.post("/Review") +async def review(request: ReviewRequest): + return llm_text_stream_response(stream_review_content( + content=request.content, + types=request.types, + require=request.require, + sensitive_words=request.sensitive_words, + negative_words=request.negative_words, + positive_words=request.positive_words, + )) + + + +# =============== 请求模型 =============== + + +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.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 + +class PdfContentItem(BaseModel): + page_idx: int + bbox: Optional[str] = None + text: Optional[str] = None + text_level: Optional[int] = None + img_path: Optional[str] = None + table_body: Optional[str] = None + + +class RequestWrapper(BaseModel): + pdf_contents: List[PdfContentItem] + + + + +@app.post("/split_content_list") +async def split_content_list(request_data: RequestWrapper): + pdf_contents = request_data.pdf_contents + processed_list = [] + + for item in pdf_contents: + # 将 Pydantic 模型转换为字典,方便修改 + item_dict = item.model_dump() + + # --- 核心逻辑:判断并添加 type 字段 --- + item_type = None + + # 1. 如果 text 非空 -> type: "text" + if item.text: + item_type = "text" + + # 2. 如果 table_body 非空 -> type: "table" + # 注意:如果 text 和 table_body 都有,根据代码顺序,table 会覆盖 text。 + # 如果你的业务逻辑是互斥的或需要优先级,请调整此处顺序。 + elif item.table_body: + item_type = "table" + + # 3. 如果 img_path 非空 且 table_body 为空 -> type: "image" + # 注意:上面用了 elif 判断 table_body,所以到这里 table_body 肯定为空, + # 但为了逻辑清晰,显式写出条件。 + elif item.img_path and not item.table_body: + item_type = "image" + + # 如果匹配到了类型,写入字典 + if item_type: + item_dict["type"] = item_type + + processed_list.append(item_dict) + slices = get_chunk_bbox(processed_list) + slices_check = chunk_check(slices, 8000) + + return {"code": 200, "message": "ok", "data": {"slices": slices_check}} + + +@app.post("/split_result") +async def split_result( + file: UploadFile = File(...), + image_prefix: str = Form("/api/v1/knowledge/files/images/"), +): + if not file.filename: + raise HTTPException(status_code=400, detail="File name is missing") + + chunk_size = 1024 + chunk_overlap = 100 + + filename = file.filename + suffix = PathLib(filename).suffix.lower() # 获取文件后缀 + temp_input_path: Optional[PathLib] = None + converted_pdf_path: Optional[PathLib] = None + logger.info("调用知识切分接口") + + try: + # ? 创建临时文件时指定后缀名 + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp_in: + content = await file.read() + logger.info(f"文件大小:{len(content)}") + logger.info(f"前10字节:{content[:10]}") + # if not content.startwith(b"%PDF"): + # logger.error("不是合法PDF") + # raise HTTPException(status_code=400,detail="Uploaded file is not a valid PDF") + tmp_in.write(content) + temp_input_path = PathLib(tmp_in.name) + # 2. 根据文件类型处理 + if suffix == ".pdf": + logger.info("调用pdf切分方式") + + result_data = await process_pdf_file(temp_input_path, image_prefix,filename) + if result_data is None: + raise HTTPException(status_code=502, detail="PDF processing failed") + elif suffix == ".docx": + logger.info("调用docx切分方式") + logger.info(f"转换 DOCX -> PDF: {filename}") + + # 执行转换 + converter.convert(str(temp_input_path), output_dir=str(DATA_DIR)) + + # ? 使用临时文件的基础名查找 PDF + temp_base_name = temp_input_path.stem # 如 tmp2bb0l4cn + converted_pdf_path = PathLib(DATA_DIR) / f"{temp_base_name}.pdf" + + if not converted_pdf_path.exists(): + raise HTTPException(status_code=500, detail=f"PDF 转换失败: {converted_pdf_path}") + + result_data = await process_pdf_file(converted_pdf_path, image_prefix) + if result_data is None: + raise HTTPException(status_code=502, detail="DOCX to PDF processing failed") + + elif suffix in (".txt", ".md"): + logger.info("调用txt切分方式") + + # 直接读取文本内容 + async with aiofiles.open(temp_input_path, "r", encoding="utf-8", errors="ignore") as f: + text_content = await f.read() + contents = split_text_preserve_sentences(text_content) + # 构造统一结构(无 images) + slices = [] + for ins in contents: + slices.append({"content": ins, "positions": []}) + result_data = {"slices": slices, "images": {}} + elif suffix in [".xlsx"]: + # converter.convert(str(temp_input_path), output_dir=str(DATA_DIR)) + # temp_base_name = temp_input_path.stem # 如 tmp2bb0l4cn + # converted_pdf_path = PathLib(DATA_DIR) / f"{temp_base_name}.pdf" + # if not converted_pdf_path.exists(): + # raise HTTPException(status_code=500, detail=f"PDF 转换失败: {converted_pdf_path}") + logger.info("调用xlsx切分方式") + + result_data = await process_other_file(temp_input_path, image_prefix,filename) + if result_data is None: + raise HTTPException(status_code=502, detail="DOCX parse failed") + elif suffix in [".ppt", ".pptx"]: + logger.info("调用 PPT/PPTX 切分方式") + + # 1. 判断是否为旧版 .ppt 格式,如果是,则先进行转换 + if suffix == ".ppt": + logger.info(f"检测到旧版 PPT 文件,正在转换为 PPTX: {temp_input_path}") + try: + # 定义 LibreOffice 转换命令 + command = [ + 'libreoffice', + '--headless', # 无头模式,不弹出图形界面 + '--convert-to', 'pptx', + '--outdir', os.path.dirname(temp_input_path), + temp_input_path + ] + + # 2. 使用 run_in_executor 将阻塞的同步转换操作放入线程池,避免阻塞异步事件循环 + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + None, + lambda: subprocess.run(command, capture_output=True, text=True) + ) + + # 3. 检查转换是否成功 + if result.returncode != 0: + logger.error(f"PPT 转换失败: {result.stderr}") + raise HTTPException(status_code=500, detail="PPT to PPTX conversion failed") + + # 4. 转换成功后,更新输入路径为新生成的 .pptx 文件路径 + temp_input_path = os.path.splitext(temp_input_path)[0] + ".pptx" + logger.info(f"PPT 转换成功,新文件路径: {temp_input_path}") + + except Exception as e: + logger.exception(f"PPT 转换过程发生异常: {e}") + raise HTTPException(status_code=500, detail=f"PPT conversion error: {str(e)}") + + # 5. 无论是原本就是 .pptx,还是刚刚转换完成的 .pptx,都统一走后续处理逻辑 + result_data = await process_other_file(temp_input_path, image_prefix, filename) + if result_data is None: + raise HTTPException(status_code=502, detail="PPTX parse failed") + else: + raise HTTPException( + status_code=400, detail="Unsupported file type. Only PDF, DOCX, TXT, and MD are supported." + ) + + return JSONResponse({"code": 200, "message": "ok", "data": result_data}) + + except requests.RequestException as e: + logger.error(f"Split service request failed: {e}") + raise HTTPException(status_code=502, detail=f"Split service error: {str(e)}") + except Exception as e: + logger.error(f"Internal error: {e}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + finally: + # 清理临时文件 + for path in [temp_input_path, converted_pdf_path]: + if path and path.exists(): + try: + path.unlink() + except Exception as e: + logger.warning(f"Failed to delete temp file {path}: {e}") + + +@app.get("/") +async def root(): + """根路径""" + return { + "message": "Agent API 服务", + "docs": "/docs" + } + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=9090) 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/chunk_text.py b/chunk_text.py new file mode 100644 index 0000000..1b9a366 --- /dev/null +++ b/chunk_text.py @@ -0,0 +1,1071 @@ +import json +import fitz # PyMuPDF +import os +from collections import Counter +import uuid +from openai import OpenAI +import re +from typing import List, Dict, Optional, Union, Any, Tuple +# 指定 JSON 文件路径 +# file_path = '/storage01/home/hdf/project/wxkgrag/kgextract/output/122-06A0014-B01001_发动机-维修手册_content_list.json' +from pathlib import PurePath, Path as PathLib +from fastapi import FastAPI, File, Path, UploadFile, HTTPException, Form, Request, Header, Body +from fileparse_util import process_document +import threading +import aiofiles +import httpx +import logging +from config import API_URLS +logger = logging.getLogger(__name__) + + +_api_url_inflight = [0] * len(API_URLS) +_api_url_lock = threading.Lock() + +def format_entity_text(data): + # 1. 定义实体类型与属性的映射关系 + # 格式: '实体类型': [ ('前缀1', '属性1'), ('前缀2', '属性2') ] + type_mapping = { + '舰艇': [('舰艇为', '名称'), (',舷号', '舷号')], + '系统': [(',系统:', '名称')], + '子系统': [(',子系统:', '名称')], + '设备': [(',设备:', '名称')] + } + + text_parts = [] + + # 2. 遍历 entities 列表 + for entity in data.get('entities', []): + entity_type = entity.get('type') + + properties = entity.get('properties', {}) + + # 3. 如果当前实体类型在映射表中,则进行拼接 + if entity_type in type_mapping: + entity_str = "" + # 遍历该实体需要提取的所有属性 + for prefix, attr_key in type_mapping[entity_type]: + value = str(properties.get(attr_key, '')) + entity_str += f"{prefix}{value}" + text_parts.append(entity_str) + + # 4. 将所有部分拼接成一段完整的文本 + # 注意:因为前缀中已经包含了逗号,这里直接拼接即可 + return ''.join(text_parts) + +def get_api_url(task_id: str = "") -> tuple: + """返回 (analyze_url, contentlist_url),选当前 inflight 最少的实例。""" + with _api_url_lock: + idx = _api_url_inflight.index(min(_api_url_inflight)) + _api_url_inflight[idx] += 1 + base = API_URLS[idx] + return base + + +def release_api_url(url: str): + with _api_url_lock: + if url in API_URLS: + idx = API_URLS.index(url) + _api_url_inflight[idx] = max(0, _api_url_inflight[idx] - 1) +def extract_text_from_first_tr(html: str) -> str: + """提取 HTML 表格中第一个 内的文本(去除标签)""" + if not isinstance(html, str): + return "" + match = re.search(r']*>(.*?)', html, re.IGNORECASE | re.DOTALL) + if not match: + return "" + first_tr_content = match.group(1) + # 去除所有 HTML 标签 + text = re.sub(r'<[^>]+>', '', first_tr_content) + return text.strip() + + +def matches_any_pattern(text: str, patterns: List[List[str]]) -> bool: + """检查文本是否包含任意一组关键词(全部命中)""" + if not text: + return False + text_lower = text.lower() + for pattern in patterns: + if all(keyword in text_lower for keyword in pattern): + return True + return False + + +def is_maintenance_table(table_body: str, patterns: List[List[str]]) -> bool: + """判断是否为维修项目表格:仅检查第一个 是否匹配任一模式""" + first_tr_text = extract_text_from_first_tr(table_body) + return matches_any_pattern(first_tr_text, patterns) + + +# ============================== +# 维修项目表格的关键词模式(可按需扩展) +# ============================== + +MAINTENANCE_TABLE_PATTERNS: List[List[str]] = [ + ["维修项目编号", "组成编码", "名称", "维修项目", "维修间隔期"], + ["维修项目编号", "维修级别", "名称"], + ["项目编号", "编码", "维修内容", "周期"], + ["序号", "维修项", "标准", "频次"], # 示例:可继续添加业务变体 +] + + +# ============================== +# 主函数:提取维修项目表格及其后续内容组 +# ============================== + +def extract_maintenance_groups_and_remaining( + records: List[Dict[str, Any]], + patterns: List[List[str]] = MAINTENANCE_TABLE_PATTERNS, + max_following_for_last: int | None = None +) -> Tuple[List[List[Dict]], List[Dict]]: + groups = [] + current_group = None + used_ids = set() + + # 标记是否已经遇到第一个维修表格(用于跳过) + skipped_first = False + + for record in records: + is_start = ( + record.get("type") == "table" and + is_maintenance_table(record.get("table_body", ""), patterns) + ) + + if is_start: + # 如果是第一个维修表,跳过(不加入 groups,也不标记 used_ids) + if not skipped_first: + skipped_first = True + # 注意:这里不清空 current_group,因为前面不可能有 group(第一个 start) + current_group = None # 确保不会把之前的非 start 内容误加 + continue # 跳过这个 record,不加入任何 group + + # 从第二个维修表开始,才正常分组 + if current_group is not None: + groups.append(current_group) + used_ids.update(r["id"] for r in current_group) + current_group = [record] + else: + if current_group is not None: + current_group.append(record) + + # 处理最后一个 group(仅当有有效 group 时) + if current_group is not None: + groups.append(current_group) + used_ids.update(r["id"] for r in current_group) + + # 限制最后一个 group 长度 + if groups and max_following_for_last is not None: + last_group = groups[-1] + max_len = max_following_for_last + 1 + if len(last_group) > max_len: + truncated = last_group[:max_len] + removed_ids = {r["id"] for r in last_group[max_len:]} + used_ids -= removed_ids + groups[-1] = truncated + + # remaining_records 包含所有未被 used_ids 标记的记录 + # 由于第一个 group 没有加入 used_ids,所以会保留在 remaining_records 中 + remaining_records = [r for r in records if r["id"] not in used_ids] + return groups, remaining_records + + +def is_operation_table_by_first_tr(table_body: str) -> bool: + """ + 判断 table_body 是否为操作项目表,依据是第一行 中是否包含 '项目编号' 和 '操作项目' + """ + if not isinstance(table_body, str): + return False + + # 非贪婪匹配第一个 ... + match = re.search(r']*>(.*?)', table_body, re.IGNORECASE | re.DOTALL) + if not match: + return False + + first_tr_html = match.group(1) + + # 去除所有 HTML 标签,提取纯文本 + text_in_first_tr = re.sub(r'<[^>]+>', '', first_tr_html) + + # 转为小写(中文无所谓大小写,但统一处理更安全) + text_clean = text_in_first_tr.strip() + + # 检查是否同时包含两个关键词 + has_code = "项目编号" in text_clean + has_item = "操作项目" in text_clean + return has_code and has_item + +def extract_operation_groups_and_remaining(records, max_following_for_last=23): + """ + 通过语义判断表格是否为操作项目表(基于第一行内容),并分组。 + 注意:第一个识别到的操作项目表及其后续记录组成的 group 会被跳过(不加入 groups), + 但这些记录仍保留在 remaining_records 中。 + + 普通起始标记:组 = [start, ..., next_start - 1] + 最后一个起始标记:组 = [start, ..., start + max_following_for_last] (若指定) + """ + groups = [] + current_group = None + used_ids = set() + first_group_skipped = False # 标记是否已跳过第一个 group + + for record in records: + is_start = ( + record.get("type") == "table" + and is_operation_table_by_first_tr(record.get("table_body", "")) + ) + + if is_start: + if current_group is not None: + # 决定是否保留当前积累的 group + if not first_group_skipped: + # 跳过第一个 group:不清空 current_group,也不加入 groups 或 used_ids + first_group_skipped = True + # 注意:这里不把 current_group 加入 groups,也不更新 used_ids + else: + groups.append(current_group) + used_ids.update(r["id"] for r in current_group) + # 开启新 group + current_group = [record] + else: + # 第一个起始点 + current_group = [record] + else: + if current_group is not None: + current_group.append(record) + + # 处理最后一个 group(如果存在) + if current_group is not None: + if not first_group_skipped: + # 整个数据中只有一个 group,且是第一个 → 跳过它 + pass # 不加入 groups,也不加 id 到 used_ids + else: + groups.append(current_group) + used_ids.update(r["id"] for r in current_group) + + # —————— 后处理:截断最后一个 group(如果需要) —————— + if groups and max_following_for_last is not None: + last_group = groups[-1] + if len(last_group) > max_following_for_last + 1: # +1 是起始记录本身 + truncated = last_group[:max_following_for_last + 1] + removed_ids = {r["id"] for r in last_group[max_following_for_last + 1:]} + used_ids -= removed_ids + groups[-1] = truncated + + remaining_records = [r for r in records if r["id"] not in used_ids] + return groups, remaining_records + + +def extract_wxanli(records): + keywords = [ + "一、故障现象", + "单位:", + "故障名称:", + "一、基本情况", + "故障现象及原因:", + "故障名称:", + "实例故障" + ] + + # 存储切片 + slices = [] + # 标记已处理的记录索引 + processed_indices = set() + + # 查找所有包含关键词的记录位置 + keyword_positions = [] + for i, record in enumerate(records): + if record.get("type") == "text": + text = record.get("text", "") + # 检查是否包含任一关键词 + if any(keyword in text for keyword in keywords): + keyword_positions.append(i) + + # 为每个关键词位置创建切片 + for idx, start_pos in enumerate(keyword_positions): + # 确定切片结束位置 + if idx + 1 < len(keyword_positions): + end_pos = keyword_positions[idx + 1] + else: + end_pos = len(records) + + # 收集当前切片 + slice_records = [] + + # 添加前2个记录(如果存在且未超出边界) + for offset in [2, 1]: + prev_idx = start_pos - offset + if prev_idx >= 0: + slice_records.append(records[prev_idx]) + processed_indices.add(prev_idx) + + # 添加从关键词位置到下一个关键词位置的所有记录 + for i in range(start_pos, end_pos): + slice_records.append(records[i]) + processed_indices.add(i) + + slices.append(slice_records) + + # 收集未处理的剩余记录 + remaining_records = [ + records[i] for i in range(len(records)) + if i not in processed_indices + ] + + return slices, remaining_records + +def split_content_bbox(data, max_length=8000): + contents = [] + highlight_lists = [] + + current_content = "" + current_highlights = [] + + for ins in data: + if "type" not in ins: + continue + if not ins.get("bbox"): + continue + # 提取通用字段 + bbox = ins.get("bbox") + page_idx = ins.get("page_idx", -1) + + new_text = "" + should_record_highlight = False # 标记是否需要记录 highlight(即使 new_text 为空) + + if ins["type"] == "text": + text = ins.get("text", "") + level = ins.get("text_level") + if level is not None and isinstance(level, int) and level > 0: + text = "#" * min(level, 6) + " " + text # 安全限制标题级别 + new_text = text + "\n" + should_record_highlight = True + + elif ins["type"] == "table": + table_caption = ins.get("table_caption") + table_body = ins.get("table_body", "") + + # 清理冗余属性 + for pattern in [' colspan="1"', ' rowspan="1"', " colspan='1'", " rowspan='1'", " colspan=1", " rowspan=1"]: + table_body = table_body.replace(pattern, "") + + caption_str = "" + if table_caption: + if isinstance(table_caption, list): + caption_str = "\n".join(str(c) for c in table_caption) + else: + caption_str = str(table_caption) + + # 判断是否有实质内容 + has_caption = bool(caption_str.strip()) + has_body = bool(table_body.strip()) + + if has_caption or has_body: + new_text = caption_str + ("\n" if has_caption else "") + table_body + "\n" + else: + new_text = "" # 空内容,不拼接 + + # 即使内容为空,只要是 table 类型,就记录 highlight(满足你的需求) + should_record_highlight = True + + elif ins["type"] == "image": + image_caption = ins.get("image_caption", "") + img_path = ins.get("img_path", "") + + if isinstance(image_caption, list): + image_caption = "\n".join(str(item) for item in image_caption) + elif not isinstance(image_caption, str): + image_caption = str(image_caption) + + if isinstance(img_path, list): + img_path = "\n".join(str(p) for p in img_path) + elif not isinstance(img_path, str): + img_path = str(img_path) + + if image_caption.strip() or img_path.strip(): + new_text = image_caption + "\n" + img_path + "\n" + else: + new_text = "" + + should_record_highlight = True + + elif ins["type"] == "discarded": + text = ins.get("text", "") + if text.strip(): + new_text = text + "\n" + else: + new_text = "" + should_record_highlight = True + + else: + continue # 不支持的 type,跳过且不记录 highlight + + # --- 关键:即使 new_text 为空,只要 should_record_highlight 为 True,就要处理 chunk 切分和 highlight 记录 --- + + # 构造 highlight 项(即使 new_text 为空) + highlight_item = {"page_idx": page_idx, "bbox": bbox} + + if not new_text.strip() and not should_record_highlight: + continue # 完全跳过 + + # 检查是否需要切分(仅当 new_text 非空时才影响长度) + would_exceed = len(current_content) + len(new_text) > max_length + + # 如果 new_text 非空 且 会导致超长,则先保存当前 chunk + if new_text.strip() and would_exceed: + if current_content or current_highlights: + contents.append(current_content.rstrip('\n')) + highlight_lists.append(current_highlights) + current_content = new_text + current_highlights = [highlight_item] + else: + # 追加内容(如果 new_text 非空) + if new_text.strip(): + current_content += new_text + # 无论如何,只要 should_record_highlight,就加 highlight + if should_record_highlight: + current_highlights.append(highlight_item) + + # 保存最后一段 + if current_content or current_highlights: + contents.append(current_content.rstrip('\n')) + highlight_lists.append(current_highlights) + + return contents, highlight_lists + + + +def group_records(records): + result = [] + current_group = [] + + for i, ins in enumerate(records): + if 'text_level' in ins and ins['text_level']: + # 当遇到新的text_level时,如果current_group非空,则先将其添加到结果中 + if current_group: + result.append(current_group) + current_group = [] # 开始新一组 + current_group.append(ins) + else: + # 如果当前记录没有text_level或其值为空,则继续添加到当前组 + current_group.append(ins) + + # 别忘了将最后一个组添加到结果中 + if current_group: + result.append(current_group) + + return result + +def get_chunk_bbox_otherfile(data, max_length=8000): + """ + 处理没有 bbox 信息的文件数据(主要面向纯表格类内容)。 + 每个 table 作为一个独立切片,不与其他记录拼接。 + + - 单个 table 内容超过 max_length 时,调用 chunk_reset 在 处安全切分, + 切分后的多个 chunk 共享同一组 positions + - 清理冗余 colspan="1" / rowspan="1" 属性 + - bbox 缺失时填充空列表 [] + + 返回结构与 get_chunk_bbox 一致:[{"content": str, "positions": [...]}] + """ + slices = [] + + for ins in data: + if ins.get("type") != "table": + continue + + page_idx = ins.get("page_idx", -1) + bbox = ins.get("bbox", []) + + table_caption = ins.get("table_caption") + table_body = ins.get("table_body", "") or "" + + # 清理冗余属性 + for pattern in [ + ' colspan="1"', ' rowspan="1"', + " colspan='1'", " rowspan='1'", + " colspan=1", " rowspan=1", + ]: + table_body = table_body.replace(pattern, "") + + # 拼接 caption + caption_str = "" + if table_caption: + if isinstance(table_caption, list): + caption_str = "\n".join(str(c) for c in table_caption) + else: + caption_str = str(table_caption) + + has_caption = bool(caption_str.strip()) + has_body = bool(table_body.strip()) + + if not (has_caption or has_body): + continue # 空表格跳过 + + content = caption_str + ("\n" if has_caption else "") + table_body + positions = [{"page_idx": page_idx, "bbox": bbox}] + + # 单表超长 → 在 处切分,多个 chunk 共享 positions + if len(content) > max_length: + sub_chunks = chunk_reset(content, max_length) + for sub in sub_chunks: + slices.append({ + "content": sub.rstrip('\n'), + "positions": positions + }) + else: + slices.append({ + "content": content.rstrip('\n'), + "positions": positions + }) + + # 按 page_idx 排序,与 get_chunk_bbox 风格一致 + valid_slices = [ + item for item in slices + if item.get("positions") + and isinstance(item["positions"], list) + and len(item["positions"]) > 0 + and "page_idx" in item["positions"][0] + ] + valid_slices.sort(key=lambda item: item["positions"][-1]["page_idx"]) + + return valid_slices + +def get_chunk_bbox(data): + for idx, item in enumerate(data, start=1): + item["id"] = idx + repair_data, clean_repair_records = extract_maintenance_groups_and_remaining(data,max_following_for_last=23) + operation_data, clean_operation_records = extract_operation_groups_and_remaining(data,max_following_for_last=23) + wxanli_data, clean_wxanli_data = extract_wxanli(data) + slices = [] + if len(repair_data) > 0 : + for ins in repair_data: + content, highlight_list = split_content_bbox(ins) + for i, (content, h_list) in enumerate(zip(content, highlight_list)): + slices.append({ + "content" : content, + "positions":h_list + }) + groups = group_records(clean_repair_records) + for ins in groups: + content, highlight_list = split_content_bbox(ins) + for i, (content, h_list) in enumerate(zip(content, highlight_list)): + slices.append({ + "content" : content, + "positions":h_list + }) + + valid_slices = [ + item for item in slices + if item.get('positions') + and isinstance(item['positions'], list) + and len(item['positions']) > 0 + and 'page_idx' in item['positions'][0] + ] + slices = sorted(valid_slices, key=lambda item: item['positions'][-1]['page_idx']) + elif len(operation_data) > 0: + for ins in operation_data: + content, highlight_list = split_content_bbox(ins) + for i, (content, h_list) in enumerate(zip(content, highlight_list)): + slices.append({ + "content" : content, + "positions":h_list + }) + groups = group_records(clean_operation_records) + for ins in groups: + content, highlight_list = split_content_bbox(ins) + for i, (content, h_list) in enumerate(zip(content, highlight_list)): + slices.append({ + "content" : content, + "positions":h_list + }) + valid_slices = [ + item for item in slices + if item.get('positions') + and isinstance(item['positions'], list) + and len(item['positions']) > 0 + and 'page_idx' in item['positions'][0] + ] + slices = sorted(valid_slices, key=lambda item: item['positions'][-1]['page_idx']) + + elif len(wxanli_data) > 0: + for ins in wxanli_data: + content, highlight_list = split_content_bbox(ins) + for i, (content, h_list) in enumerate(zip(content, highlight_list)): + slices.append({ + "content" : content, + "positions":h_list + }) + for ins in clean_wxanli_data: + if not ins.get("bbox"): + continue + page_idx = ins.get("page_idx", 0) + bbox = ins["bbox"] + positions = [{"page_idx": page_idx, "bbox": bbox}] + + if ins["type"] == "text": + if ins.get("text_level"): + text = ins.get("text", "") + level = ins.get("text_level") + content = "#" * level + text + else: + content = ins.get("text", "") + elif ins["type"] == "table": + content = (ins.get("table_caption") or "") + "\n" + (ins.get("table_body") or "") + elif ins["type"] == "image": + image_caption = ins.get("image_caption", []) + img_path = ins.get("img_path", []) + + # 如果 image_caption 是列表,则将其转换为字符串 + if isinstance(image_caption, list): + image_caption = "\n".join(str(item) for item in image_caption) + elif not isinstance(image_caption, str): + image_caption = str(image_caption) + + # 如果 img_path 是列表,则将其转换为字符串 + if isinstance(img_path, list): + img_path = "\n".join(str(p) for p in img_path) + elif not isinstance(img_path, str): + img_path = str(img_path) + + if image_caption.strip() or img_path.strip(): + content = (image_caption or "") + "\n" + (img_path or "") + else: + continue # 跳过空图像项 + elif ins["type"] == "discarded": + content = ins.get("text", "") + else: + continue + + slices.append({"content": content, "positions": positions}) + + + valid_slices = [ + item for item in slices + if item.get('positions') + and isinstance(item['positions'], list) + and len(item['positions']) > 0 + and 'page_idx' in item['positions'][0] + ] + slices = sorted(valid_slices, key=lambda item: item['positions'][-1]['page_idx']) + else: + groups = group_records(data) + for ins in groups: + content, highlight_list = split_content_bbox(ins) + for i, (content, h_list) in enumerate(zip(content, highlight_list)): + slices.append({ + "content" : content, + "positions":h_list + }) + valid_slices = [ + item for item in slices + if item.get('positions') + and isinstance(item['positions'], list) + and len(item['positions']) > 0 + and 'page_idx' in item['positions'][0] + ] + valid_slices = [ + item for item in slices + if item.get('positions') + and isinstance(item['positions'], list) + and len(item['positions']) > 0 + and 'page_idx' in item['positions'][0] + ] + slices = sorted(valid_slices, key=lambda item: item['positions'][-1]['page_idx']) + return slices + +def split_text_preserve_sentences(text, max_length=7500): + if len(text) <= max_length: + return [text] + + # 按句子分割,保留标点 + parts = re.split(r'([。!?;!?])', text) + sentences = [] + for i in range(0, len(parts), 2): + s = parts[i] + (parts[i+1] if i+1 < len(parts) else '') + sentences.append(s) + + chunks = [] + current = "" + + for sent in sentences: + if len(current) + len(sent) <= max_length: + current += sent + else: + if current: + chunks.append(current) + else: + # 单句超长,硬切(极端情况) + chunks.append(sent[:max_length]) + sent = sent[max_length:] + current = sent + + if current: + chunks.append(current) + + return chunks + +def data_replace(data, prefix): + """ + 遍历 data 列表,若元素包含 'img_path' 字段,则移除前缀 'images/' 并拼接新前缀。 + 使用 pathlib 安全处理路径。 + """ + from pathlib import PurePosixPath + for ins in data: + if isinstance(ins, dict) and "img_path" in ins: + img_path = ins["img_path"] + p = PurePosixPath(img_path) + # 如果以 images/ 开头,去掉第一级目录 + if p.parts and p.parts[0] == "images": + relative_path = str(PurePosixPath(*p.parts[1:])) + ins["img_path"] = f"![图片]({prefix}{relative_path})" + else: + # 否则保留原路径(或按需处理) + ins["img_path"] = f"![图片]({prefix}{img_path})" + return data + +def filter_content_bbox(data, max_length=3000): + """ + 将 data 中的 text/table/image 内容按字符长度分块(每块不超过 max_length), + 返回 [{"content": str, "highlight_list": list}, ...] + """ + chunks = [] + current_content = "" + current_highlight_list = [] + + def _flush_chunk(): + """内部函数:将当前内容打包进 chunks,并重置""" + if current_content or current_highlight_list: + chunks.append({ + "content": current_content, + "highlight_list": current_highlight_list.copy() + }) + + for ins in data: + if "type" not in ins: + continue + + added_content = "" + bbox = ins.get("bbox", []) + page_idx = ins.get("page_idx", -1) + + if ins["type"] == "text": + text_content = ins.get("text", "") + added_content = text_content + "\n" + + elif ins["type"] == "table": + table_caption = ins.get("table_caption") + table_body = ins.get("table_body") + + caption_str = "" + if table_caption: + if isinstance(table_caption, list): + caption_str = "\n".join(str(c) for c in table_caption) + else: + caption_str = str(table_caption) + + added_content = caption_str + "\n" + (table_body or "") + "\n" + + elif ins["type"] == "image": + image_caption = ins.get("image_caption", "") + img_path = ins.get("img_path", "") + + if isinstance(image_caption, list): + image_caption = "\n".join(str(item) for item in image_caption) + elif not isinstance(image_caption, str): + image_caption = str(image_caption) + + if isinstance(img_path, list): + img_path = "\n".join(str(p) for p in img_path) + elif not isinstance(img_path, str): + img_path = str(img_path) + + if image_caption or img_path: + added_content = image_caption + "\n" + img_path + "\n" + else: + continue # 跳过空图像项 + else: + continue + + # 检查加上 added_content 后是否超限 + if len(current_content) + len(added_content) > max_length: + # 如果当前块非空,先 flush + if current_content: + _flush_chunk() + current_content = "" + current_highlight_list = [] + + # 如果单个 added_content 超过 max_length,仍需保留(避免丢弃) + if len(added_content) > max_length: + # 强制截断或直接放入(这里选择直接放入,避免信息丢失) + current_content = added_content[:max_length] + current_highlight_list.append({"page_idx": page_idx, "bbox": bbox}) + _flush_chunk() + # 剩余部分?此处简化处理:忽略超长部分(或可递归拆分,但复杂) + continue + else: + current_content = added_content + current_highlight_list.append({"page_idx": page_idx, "bbox": bbox}) + else: + current_content += added_content + current_highlight_list.append({"page_idx": page_idx, "bbox": bbox}) + + # 处理最后一块 + if current_content or current_highlight_list: + chunks.append({ + "content": current_content, + "highlight_list": current_highlight_list + }) + + return chunks + +def chunk_reset(content,MAX_length=8000): + chunks = [] + start = 0 + while start < len(content): + end = start + MAX_length + if end >= len(content): + chunks.append(content[start:]) + break + # 尽量在 后面切分 + snippet = content[start:end] + last_tr_end = snippet.rfind("") + if last_tr_end != -1: + end = start + last_tr_end + len("") + else: + # 如果找不到 ,退回到最近的换行或空白 + last_newline = snippet.rfind("\n") + if last_newline != -1: + end = start + last_newline + else: + # 实在不行就硬切 + pass + chunks.append(content[start:end]) + start = end + return chunks +def chunk_check(data,MAX_length=8000): + slice_reset = [] + for ins in data: + content = ins["content"] + positions = ins["positions"] + if len(content) <= 8000: + slice_reset.append(ins) + else: + # 使用你的 chunk_reset 函数切分 content + chunks = chunk_reset(content, MAX_length) + for chunk in chunks: + slice_reset.append({ + "content": chunk, + "positions": positions # 共享原始位置信息 + }) + return slice_reset + +def find_ship_info_by_hull(json_file_path, data): + """ + 从NER结果中提取舷号,再从JSON文件中查找对应的舰船信息 + :param json_file_path: JSON文件路径 + :param data: get_entity() 返回的NER结果字典 + :return: 包含 model_name 和 ship_name 的字典,未找到返回 None + """ + # 1. 提取舷号 + hulls = [ + entity['properties']['舷号'] + for entity in data.get('entities', []) + if 'properties' in entity and '舷号' in entity['properties'] + ] + if not hulls: + return None + + target_hull = hulls[0] + + # 2. 根据舷号查找舰船信息 + try: + with open(json_file_path, 'r', encoding='utf-8') as f: + ship_data = json.load(f) + + for item in ship_data: + if str(item.get('hull_number')) == str(target_hull): + return { + "model_name": item.get('model_name'), + "ship_name": item.get('ship_name') + } + + return None + + except FileNotFoundError: + print(f"错误:找不到文件 {json_file_path}") + return None + except json.JSONDecodeError: + print("错误:JSON 文件格式不正确") + return None +def merge_short_slices(slices, min_length=30,filename="122-06A0014-B01003_雷达-使用说明书.pdf"): + """ + 合并过短的切片: + - 如果某切片 content 长度 <= min_length,则将其合并到下一个切片的开头 + - 若处于末尾无下一个切片,则反向合并到上一个切片末尾 + - content 用换行拼接,positions 顺序拼接 + + Args: + slices: [{"content": str, "positions": [...]}] + min_length: 短切片的字符长度阈值(含) + filename: 用于实体提取的文件名 + + Returns: + 合并后的 slices 列表 + """ + if not slices: + return slices + + result = [] + pending_contents = [] # 缓存等待合并到"下一个"的短切片 content + pending_positions = [] # 缓存对应的 positions + + for ins in slices: + content = ins.get("content", "") or "" + positions = ins.get("positions", []) or [] + + if len(content) <= min_length: + # 暂存,等到下一个正常长度的切片再合并 + pending_contents.append(content) + pending_positions.extend(positions) + else: + # 正常切片:把暂存的短切片合并到它的开头 + if pending_contents: + merged_prefix = "\n".join(pending_contents) + content = merged_prefix + ("\n" if merged_prefix else "") + content + positions = pending_positions + positions + pending_contents = [] + pending_positions = [] + + result.append({ + "content": content, + "positions": positions + }) + + # 收尾:如果末尾还有未合并的短切片(后面没有正常切片可合并) + # 则反向合并到上一个切片末尾 + if pending_contents: + merged_suffix = "\n".join(pending_contents) + if result: + last = result[-1] + last["content"] = (last["content"] or "") + ("\n" if last["content"] else "") + merged_suffix + last["positions"] = (last["positions"] or []) + pending_positions + else: + # 极端情况:所有切片都很短,整体作为一个切片返回 + result.append({ + "content": merged_suffix, + "positions": pending_positions + }) + + return result +def find_and_read_content_list(directory, original_filename, encoding='utf-8'): + """ + 根据原始文件名,在指定目录及其子目录下查找对应的 _content_list.json 文件并返回内容。 + """ + # 1. 去除原始文件的后缀,并拼接新的后缀 + # 加上 只获取不带后缀的文件名部分 + base_name = os.path.splitext(original_filename) + file_name = base_name[0] + target_filename = f"{file_name}_content_list.json" + + print(f"正在查找文件: {target_filename}") + + # 2. 使用 os.walk 递归遍历目录 + for root, dirs, files in os.walk(directory): + if target_filename in files: + file_path = os.path.join(root, target_filename) + + try: + with open(file_path, 'r', encoding=encoding) as f: + json_data = json.load(f) + return json_data + except json.JSONDecodeError: + print(f"文件 {file_path} 不是有效的 JSON 格式。") + return None + except Exception as e: + print(f"读取文件 {file_path} 时发生错误: {e}") + return None + + return None + +async def process_pdf_file(pdf_path: Path, image_prefix: str,filename:str) -> Optional[Dict[str, Any]]: + """异步调用 PDF 分析服务并处理响应""" + doc_result = await process_document(filename) + if doc_result is not None: + content_list, images = doc_result + if content_list and images: + replaced_content_list = data_replace(content_list, image_prefix) + slices = get_chunk_bbox(replaced_content_list) + slices_check = chunk_check(slices, 8000) + slices_check = merge_short_slices(slices_check, min_length=30,filename=filename) + return {"slices": slices_check, "images": images} + else: + return {"slices": [], "images": {}} + api_url = get_api_url() + try: + async with aiofiles.open(pdf_path, "rb") as f: + file_content = await f.read() + files = {"file": (filename, file_content, "application/pdf")} + + async with httpx.AsyncClient(timeout=60 * 20) as client: + response = await client.post(api_url, files=files) + + if response.status_code != 200: + logger.error(f"❌ API 请求失败,状态码: {response.status_code}, 响应: {response.text}") + return None + + result = response.json() + data = result.get("data", {}) + content_list = data.get("content_list", []) + images = data.get("images", {}) + replaced_content_list = data_replace(content_list, image_prefix) + slices = get_chunk_bbox(replaced_content_list) + slices_check = chunk_check(slices, 8000) + slices_check = merge_short_slices(slices_check, min_length=30,filename=filename) + + return {"slices": [], "images": {}} + + except json.JSONDecodeError: + logger.error("❌ 响应不是有效的 JSON 格式") + logger.debug(response.text) + return {"slices": [], "images": {}} + except Exception as e: + logger.error(f"处理 PDF 文件时出错: {e}", exc_info=True) + return None + finally: + release_api_url(api_url) + +async def process_other_file(pdf_path: Path, image_prefix: str, filename: str) -> Optional[Dict[str, Any]]: + """异步调用 Office 文件分析服务并处理响应""" + # 优先尝试走本地/缓存处理(保持与你原逻辑一致) + doc_result = await process_document(filename) + if doc_result is not None: + content_list, images = doc_result + if content_list and images: + replaced_content_list = data_replace(content_list, image_prefix) + slices = get_chunk_bbox(replaced_content_list) + slices_check = chunk_check(slices, 8000) + slices_check = merge_short_slices(slices_check, min_length=30,filename=filename) + return {"slices": slices_check, "images": images} + else: + return {"slices": [], "images": {}} +if __name__ == "__main__": + + filepath = r"E:\ZKYNLP\Hjunproject\project0506\kgrag\122-06A0014-B01001_发动机-维修手册_content_list.json" + try: + with open(filepath, 'r', encoding='utf-8') as file: + data = json.load(file) + except Exception as e: + print(f"读取 JSON 文件时出错:{e}") + data = [] + # print(len(data)) + start_chars = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "第", "十", + "一", "二", "三", "四", "五", "六", "七", "八", "九"] + + data = data_replace(data=data,prefix="/api/v1/knowledge/files/images/") + slices = get_chunk_bbox(data=data) + slices_1 =chunk_check(slices, 8000) + slices_2 = merge_short_slices(slices_1, min_length=30) # ← 新增这一行 + + + for ins in slices_2: + print(ins) diff --git a/config.py b/config.py new file mode 100644 index 0000000..17bb047 --- /dev/null +++ b/config.py @@ -0,0 +1,230 @@ +""" +项目配置文件 +从.env文件读取配置 +""" +import os +from dotenv import load_dotenv +from contextvars import ContextVar +from typing import Optional, Dict, Any + +# 加载.env文件 +load_dotenv() + +# ==================== 大模型配置 ==================== +LLM_CONFIG = { +# "model": "Qwen3.5-35B-A3B", + "model": "46-qwen3.5-35B", +# "model": "Qwen3-14B", + "base_url": "http://192.168.0.46:59800/v1", + "api_key": "gpustack_dee9ca823290886c_5edfc86aeeeceb1e9ee5162941cb2cb5", + "temperature": float("0.1"), + "max_tokens": int("8192"), + "timeout": int("30") +} + +# ==================== 嵌入模型配置 ==================== +EMBEDDING_CONFIG = { + "model": "bge-m3", + "base_url": "http://192.168.0.46:59700/v1/embeddings", + "api_key": "gpustack_dee9ca823290886c_5edfc86aeeeceb1e9ee5162941cb2cb5", + "base_url_v2" : "http://192.168.0.46:59700/v1", +} + + +# ==================== 线程安全的请求上下文配置 ==================== +# 使用 contextvars 存储每个请求的用户配置,确保多用户并发时不会相互干扰 +_request_user_config: ContextVar[Optional[Dict[str, str]]] = ContextVar('request_user_config', default=None) + +# RAG配置的默认值 +_RAG_CONFIG_DEFAULT = { + "search_endpoint": "http://192.168.0.46:11000/api/v1/knowledge/search/", + "x-user-id": "1", + "x-user-name": "testuser", + "x-role": "admin", + "kb-id": None, + "file-id": None +} + +class DynamicRAGConfig(dict): + """ + 动态RAG配置类,支持线程安全的请求级配置 + 当访问用户相关配置时,自动从当前请求上下文读取 + 其他配置项直接返回默认值 + 兼容字典的所有操作方式(如 get(), [], in 等) + """ + def __getitem__(self, key: str): + # 如果是用户相关配置或知识库参数,从当前请求上下文读取 + if key in ["x-user-id", "x-user-name", "x-role", "kb-id", "file-id"]: + request_config = _request_user_config.get() + if request_config and key in request_config: + return request_config[key] + # 如果没有设置,返回默认值 + return _RAG_CONFIG_DEFAULT[key] + # 其他配置项直接返回默认值 + return _RAG_CONFIG_DEFAULT.get(key) + + def get(self, key: str, default=None): + """支持 get() 方法""" + try: + return self[key] + except KeyError: + return default + + def __contains__(self, key: str): + """支持 in 操作符""" + return key in _RAG_CONFIG_DEFAULT + + def keys(self): + """返回所有键""" + return _RAG_CONFIG_DEFAULT.keys() + + def values(self): + """返回所有值(动态读取用户配置)""" + return [self[key] for key in _RAG_CONFIG_DEFAULT.keys()] + + def items(self): + """返回所有键值对(动态读取用户配置)""" + return [(key, self[key]) for key in _RAG_CONFIG_DEFAULT.keys()] + +# ==================== RAG服务配置 ==================== +# 使用动态配置类,支持多用户并发时自动读取各自的配置 +RAG_CONFIG = DynamicRAGConfig() + +def set_request_user_config(x_user_id: Optional[str] = None, + x_user_name: Optional[str] = None, + x_role: Optional[str] = None, + text_kb_id: Optional[str] = None, + text_file_id: Optional[str] = None): + """ + 设置当前请求的用户配置和知识库参数(线程安全) + + Args: + x_user_id: 用户ID + x_user_name: 用户名称 + x_role: 角色 + text_kb_id: 文本检索知识库ID + text_file_id: 文本检索文件ID + """ + config = {} + if x_user_id is not None: + config["x-user-id"] = x_user_id + if x_user_name is not None: + config["x-user-name"] = x_user_name + if x_role is not None: + config["x-role"] = x_role + # 仅当非空字符串时才设置,空字符串时不传入 RAG 接口 + if text_kb_id is not None and str(text_kb_id).strip(): + config["kb-id"] = str(text_kb_id).strip() + if text_file_id is not None and str(text_file_id).strip(): + config["file-id"] = str(text_file_id).strip() + _request_user_config.set(config if config else None) + +# ==================== 图谱检索服务配置 ==================== +GRAPH_SEARCH_CONFIG = { + "search_endpoint": "http://192.168.0.46:59085/search_graph", + "graph_timeout": float("30.0"), + "default_top_k": int("10"), + "operation_search_endpoint": "http://192.168.0.46:59085/operation_graph_search" +} + +# ==================== 设备到系统检索服务配置 ==================== +DEVICE_SYSTEM_CONFIG = { + "endpoint": "http://192.168.0.46:59085/device_to_system", + "timeout": float("30.0"), + "default_min_hops": int("2"), + "default_max_hops": int("8"), + "default_top_k": int("10") +} + +# ==================== 图册检索服务配置 ==================== +ATLAS_CONFIG = { + "endpoint": "http://192.168.0.46:59085/atlas_retrieval", + "timeout": float("30.0"), + "default_top_k": int("10") +} + +# ==================== 工作流配置 ==================== +WORKFLOW_CONFIG = { + "max_retry_count": int("2"), # 最大重试次数 + "image_clarity_threshold": float("0.7"), # 图片清晰度阈值 + "default_timeout": int("30") # 默认超时时间(秒) +} + +# ==================== 服务器配置 ==================== +SERVER_CONFIG = { + "host": "192.168.0.46", + "port": "59088", + "base_url": "http://192.168.0.46:59088" +} + +# ==================== ASR服务配置 ==================== +ASR_CONFIG = { + "api_base": "http://192.168.0.46:59900/v1", + "model": "base", + "language": "zh", + "timeout": int("30") +} + +# ==================== VLM服务配置 ==================== +VLM_CONFIG = { + "api_base" : "http://192.168.0.46:59801/v1", + "model" : "Qwen3-VL-8B" +} + +# ==================== Neo4J配置 ==================== +NEO4J_CONFIG = { + "uri": "neo4j://192.168.0.46:57687", + "user": "neo4j", + "password": "zdht123@" +} + +# ==================== 知识库匹配配置 ==================== +KB_TREE_CONFIG = { + "url": "http://192.168.0.46:11000/api/v1/knowledge/knowledge_base/tree" +} + +# ==================== 维修反馈统计配置 ==================== +REPAIR_FEEDBACK_CONFIG = { + "url": "http://192.168.0.46:22000", + "email": "15088888888@163.com", + "password": "11223344" +} + +# ==================== 数据库配置 ==================== +POSTGRES_CONNECTION_STRING = "postgresql://postgres:password@192.168.0.46:5432/an_webui" +#POSTGRES_CONNECTION_STRING = "postgresql+psycopg://postgres:password@192.168.0.46:5432/an_webui" +POSTGRES_SQLALCHEMY_URL = "postgresql+psycopg://postgres:password@192.168.0.46:5432/an_webui" + + +# ==================== 索引配置 ==================== +INDEX_CONFIG = { + "NAME_PROPERTY" : "名称", + "FULLTEXT_PROPERTY" :"fulltext", + "EMBEDDING_PROPERTY" : "embedding", + "SEARCH_LABEL" : "Searchable", + "VECTOR_INDEX_NAME" : "global_searchable_embedding", + "FULLTEXT_INDEX_NAME" : "global_searchable_content_search" +} + +# ==================== sql表格字段与neo4j节点映射 ==================== +MAP_INS={ + "故障模式" : "fault", + "系统" : "system_name", + "设备" : "device_name", + "舷号" : "ship_number", +} + +# ==================== neo4j配置 ==================== +NEO4J_CONFIG = { + "uri": "neo4j://192.168.0.46:57687", + "user": "neo4j", + "password": "zdht123@" +} +API_URLS = [ + "http://192.168.0.46:59988/analyze-pdf", + # "http://192.168.0.111:9977/analyze-pdf", + # "http://192.168.0.111:9978/analyze-pdf", + #"http://192.168.0.111:9979/analyze-pdf", + #"http://192.168.0.111:9980/analyze-pdf", + # "http://192.168.0.111:9975/analyze-pdf", +] diff --git a/doc2pdf.py b/doc2pdf.py new file mode 100644 index 0000000..011c25a --- /dev/null +++ b/doc2pdf.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +""" +文档转 PDF 工具 +使用 LibreOffice 将各种文档格式转换为 PDF + +支持格式: +- Word: .doc, .docx, .rtf +- Excel: .xls, .xlsx +- PowerPoint: .ppt, .pptx +- OpenDocument: .odt, .ods, .odp +- 文本: .txt, .csv +- 图片: .jpg, .png (会嵌入PDF) +""" + +import subprocess +import os +import sys +from pathlib import Path +from typing import Optional, List +import concurrent.futures + + +class Doc2PDF: + """文档转 PDF 转换器""" + + SUPPORTED_FORMATS = { + # 文档 + '.doc', '.docx', '.rtf', '.odt', '.txt', + # 表格 + '.xls', '.xlsx', '.ods', '.csv', + # 演示文稿 + '.ppt', '.pptx', '.odp', + # 图片 + '.jpg', '.jpeg', '.png', '.bmp', '.gif', + # 其他 + '.html', '.htm', '.xml' + } + + def __init__(self, libreoffice_path: str = 'libreoffice'): + self.libreoffice_path = libreoffice_path + self._verify_libreoffice() + + def _verify_libreoffice(self): + """验证 LibreOffice 是否可用""" + try: + result = subprocess.run( + [self.libreoffice_path, '--version'], + capture_output=True, text=True + ) + if result.returncode != 0: + raise RuntimeError("LibreOffice 不可用") + print(f"✓ 检测到 {result.stdout.strip().split(chr(10))[0]}") + except FileNotFoundError: + raise RuntimeError("未找到 LibreOffice,请先安装") + + def convert( + self, + input_file: str, + output_dir: Optional[str] = None, + timeout: int = 120 + ) -> str: + """ + 转换单个文件为 PDF + + Args: + input_file: 输入文件路径 + output_dir: 输出目录(默认为输入文件所在目录) + timeout: 超时时间(秒) + + Returns: + 生成的 PDF 文件路径 + """ + input_path = Path(input_file).resolve() + + if not input_path.exists(): + raise FileNotFoundError(f"文件不存在: {input_file}") + + suffix = input_path.suffix.lower() + if suffix not in self.SUPPORTED_FORMATS: + raise ValueError(f"不支持的格式: {suffix}") + + if output_dir is None: + output_dir = str(input_path.parent) + else: + output_dir = str(Path(output_dir).resolve()) + os.makedirs(output_dir, exist_ok=True) + + cmd = [ + self.libreoffice_path, + '--headless', + '--invisible', + '--nologo', + '--nofirststartwizard', + '--convert-to', 'pdf', + '--outdir', output_dir, + str(input_path) + ] + + print(f" 转换中: {input_path.name} ...", end=' ', flush=True) + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout + ) + + if result.returncode != 0: + print("✗") + raise RuntimeError(f"转换失败: {result.stderr}") + + output_file = os.path.join(output_dir, f"{input_path.stem}.pdf") + + if os.path.exists(output_file): + print("✓") + return output_file + else: + print("✗") + raise RuntimeError("转换完成但未找到输出文件") + + except subprocess.TimeoutExpired: + print("✗ (超时)") + raise RuntimeError(f"转换超时 ({timeout}秒)") + + def convert_batch( + self, + input_files: List[str], + output_dir: Optional[str] = None, + max_workers: int = 1 # LibreOffice 并发可能有问题,建议设为1 + ) -> List[dict]: + """ + 批量转换文件 + + Args: + input_files: 输入文件列表 + output_dir: 输出目录 + max_workers: 并发数 + + Returns: + 转换结果列表 + """ + results = [] + + print(f"\n开始批量转换 {len(input_files)} 个文件...\n") + + for i, input_file in enumerate(input_files, 1): + print(f"[{i}/{len(input_files)}]", end='') + try: + output_file = self.convert(input_file, output_dir) + results.append({ + 'input': input_file, + 'output': output_file, + 'success': True, + 'error': None + }) + except Exception as e: + results.append({ + 'input': input_file, + 'output': None, + 'success': False, + 'error': str(e) + }) + + # 打印统计 + success_count = sum(1 for r in results if r['success']) + print(f"\n转换完成: {success_count}/{len(results)} 成功") + + return results + + def convert_directory( + self, + input_dir: str, + output_dir: Optional[str] = None, + recursive: bool = False + ) -> List[dict]: + """ + 转换目录中的所有支持文件 + + Args: + input_dir: 输入目录 + output_dir: 输出目录 + recursive: 是否递归处理子目录 + """ + input_path = Path(input_dir) + + if not input_path.is_dir(): + raise NotADirectoryError(f"不是目录: {input_dir}") + + # 收集所有支持的文件 + files = [] + pattern = '**/*' if recursive else '*' + + for file_path in input_path.glob(pattern): + if file_path.is_file() and file_path.suffix.lower() in self.SUPPORTED_FORMATS: + files.append(str(file_path)) + + if not files: + print("未找到支持的文件") + return [] + + return self.convert_batch(files, output_dir) + + +def main(): + """命令行入口""" + import argparse + + parser = argparse.ArgumentParser( + description='使用 LibreOffice 将文档转换为 PDF', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=''' +示例: + python doc2pdf.py document.docx + python doc2pdf.py document.xlsx -o ./output + python doc2pdf.py ./documents/ -o ./pdfs -r + ''' + ) + + parser.add_argument('input', help='输入文件或目录') + parser.add_argument('-o', '--output', help='输出目录') + parser.add_argument('-r', '--recursive', action='store_true', + help='递归处理子目录') + parser.add_argument('-t', '--timeout', type=int, default=120, + help='单个文件转换超时时间(秒)') + + args = parser.parse_args() + + converter = Doc2PDF() + + input_path = Path(args.input) + + if input_path.is_file(): + # 单文件转换 + try: + output = converter.convert(args.input, args.output, args.timeout) + print(f"\n输出文件: {output}") + except Exception as e: + print(f"\n错误: {e}") + sys.exit(1) + + elif input_path.is_dir(): + # 目录转换 + results = converter.convert_directory( + args.input, + args.output, + args.recursive + ) + + # 显示失败的文件 + failures = [r for r in results if not r['success']] + if failures: + print("\n失败的文件:") + for f in failures: + print(f" - {f['input']}: {f['error']}") + + else: + print(f"错误: 路径不存在 - {args.input}") + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/documents_prompt.py b/documents_prompt.py new file mode 100644 index 0000000..fb3deec --- /dev/null +++ b/documents_prompt.py @@ -0,0 +1,1683 @@ +BEAUTIFY_TYPES = { + "润色": ["金句润色", "书面化", "讲稿化", "规整"], + "扩写": ["扩写", "续写", "汇报"], + "推荐": ["金句"], + "缩写": ["精简", "总结"] +} + +BEAUTIFY_TYPE_DICT = { + "金句润色": +"""你是一位专业文案大师,擅长用精炼的语言创作令人印象深刻的金句。请根据用户提供的文本,在保持原意的基础上进行金句润色,要求满足以下条件: +1. 首先仔细阅读给出的文本内容,分析并仔细理解文本内容含义。 +2. 对选择的金句进行润色,保证句子结构不变的情况下,将润色后句子进行金句替换。 +3. 禁止使用Markdown,不添加无关内容,保持原有用词风格。 +4. 最后,通读润色后的文本,确保没有语法错误,表意清晰,金句融入自然。 +5. 整体润色后的文本既精炼又富有文采,符合用户的所有要求。 +6. 如果有其他要求,则继续考虑到该要求进行金句润色,如果没有,则保持上述要求进行金句润色。 +7. 请将根据上述要求改写后的内容直接输出,不要输出额外的信息描述。 +8. 只需要输出润色后的金句,请不要输出文本内容,严格遵守该指令。 + 其他要求如下:{require} + 文本内容如下:{content} + 需要润色的金句如下:{sentence}""", + + "书面化": +"""你是一位专业文案大师,擅长文本改写成书面文字的形式。请根据用户提供的文本,在保持原意的基础上进行书面化改写,要求满足以下条件: +1. 首先仔细阅读给出的文本内容,分析并仔细理解文本内容含义。 +2. 仔细分析原文的结构和用词,对选择的文本内容进行书面化改写和润色。 +3. 禁止使用Markdown,不添加无关内容,保持纯文本风格,改写后的文本必须严格对应原文,不能添加或删减信息。 +4. 考虑公文的要求:书面化、严谨、规范这些要求,避免重复内容出现。 +5. 最后,通读书面化改写后的文本,确保没有语法错误,确保整体段落结构清晰,逻辑连贯,符合公文的庄重风格 +6. 如果有其他要求,则继续考虑到该要求进行书面化改写,如果没有,则保持上述要求进行书面化改写。 +7. 请将根据上述要求改写后的内容直接输出,不要输出额外的信息描述。 +8. 只需要输出书面化改写的内容,请不要输出文本内容,严格遵守该指令。 + 其他要求如下:{require} + 文本内容如下:{content} + 需要书面化改写的内容如下:{sentence}""", + + "讲稿化": +"""你是一位专业文案大师,擅长文本改写成讲稿化的形式,即文本转换为正式场合发言的口语化版本。请根据用户提供的文本,在保持原意的基础上进行讲稿化改写,要求满足以下条件: +1. 首先仔细阅读给出的文本内容,分析并仔细理解文本内容含义。 +2. 仔细分析原文的结构和用词,对选择的文本内容进行讲稿化改写,并转换为正式场合发言的口语化版本。 +3. 将文本中的比喻和描绘转化为更平实的表达,同时保留原有的意境和情感。 +4. 禁止使用Markdown,不添加无关内容,保持纯文本风格,改写后的文本必须严格对应原文,不能添加或删减信息。 +5. 避免有过于复杂的词汇或结构,原文中的破折号和星号标记需要去除,改用连贯的句子连接。 +6. 最后,通读讲稿化改写后的文本,确保没有语法错误,确保整体段落结构清晰,逻辑连贯,确保整个发言稿的结构合理,有开头、中间和结尾,符合正式发言的流程。 +7. 如果有其他要求,则继续考虑到该要求进行讲稿化改写,如果没有,则保持上述要求进行讲稿化改写。 +8,请将根据上述要求改写后的内容直接输出,不要输出额外的信息描述。 +9. 只需要输出讲稿化改写的内容,请不要输出文本内容,严格遵守该指令。 + 其他要求如下:{require} + 文本内容如下:{content} + 需要讲稿化改写的内容如下:{sentence}""", + + "规整": +"""你是一位专业文案大师,擅长对文本进行规整,即对文本进行格式规范、内容严谨和语言得体的调整。请根据用户提供的文本,在保持原意的基础上进行规整改写,要求满足以下条件: +1. 首先仔细阅读给出的文本内容,分析并仔细理解文本内容含义。 +2. 仔细分析原文的结构和用词,对选择的文本内容进行规整改写。 +3. 忽略原文的文学风格,将其转换为符合公文要求的正式、简洁、准确的语言。 +4. 禁止使用Markdown,不添加无关内容,保持纯文本风格,改写后的文本必须严格对应原文,不能添加或删减信息。 +5. 检查格式规范,避免抒情和比喻语句,将需要改写的内容转换为客观、准确的描述 +6. 需要确认描述的内容是否符合实际情况,是否有数据或政策支持。 +7. 语言方面,文本若使用大量文学修辞,需要调整为庄重、简洁的公文语言。 +6. 最后,通读规整改写后的文本,确保没有语法错误,确保整体段落结构清晰,逻辑连贯,确保不遗漏任何关键要素,比如时间、地点、具体设施等,并删除所有口语化或情感化的表达,保持客观中立。 +7. 如果有其他要求,则继续考虑到该要求进行规整,如果没有,则保持上述要求进行规整。 +8,请将根据上述要求改写后的内容直接输出,不要输出额外的信息描述。 +9. 只需要输出规整改写的内容,请不要输出文本内容,严格遵守该指令。 + 其他要求如下:{require} + 文本内容如下:{content} + 需要规整改写的内容如下:{sentence}""", + + "扩写": +"""你是一位专业文案大师,擅长对文本进行扩写,即对选中的文本内容进行扩充。请根据用户提供的文本,在保持原意的基础上进行扩写,要求满足以下条件: +1. 首先仔细阅读给出的文本内容,分析并仔细理解文本内容含义。 +2. 仔细分析原文的结构和用词,对选择的文本内容进行扩写。 +3. 扩写的内容不能超过200字,保持在100字到200字这个范围内。 +4. 禁止使用Markdown,不添加无关内容,保持纯文本风格,在扩写过程中,扩写后的内容都紧扣原文,不能脱离中心思想。 +6. 扩写的内容为公文风格,严格遵守格式和内容规范,确保内容符合公文口吻。 +6. 最后,通读扩写后的文本,确保没有语法错误,确保整体段落结构清晰,逻辑连贯。 +7. 如果有其他要求,则继续考虑到该要求进行扩写,如果没有,则保持上述要求进行扩写。 +8,请将根据上述要求改写后的内容直接输出,不要输出额外的信息描述。 +9. 只需要输出扩写的内容,请不要输出文本内容,严格遵守该指令。 + 其他要求如下:{require} + 文本内容如下:{content} + 需要扩写的内容如下:{sentence}""", + + "续写": +"""你是一位专业文案大师,擅长对文本进行续写,即对选中的文本内容进行续写。请根据用户提供的文本,在保持原意的基础上进行续写,要求满足以下条件: +1. 首先仔细阅读给出的文本内容,分析并仔细理解文本内容含义。 +2. 仔细分析原文的结构和用词,对选择的文本内容进行续写。 +3. 续写的内容不能超过200字,保持在100字到200字这个范围内。 +4. 禁止使用Markdown,不添加无关内容,保持纯文本风格,在续写过程中,续写后的内容都紧扣原文,同时要无缝衔接,不能脱离中心思想。 +5. 最后,通读续写后的文本,确保没有语法错误,确保整体段落结构清晰,逻辑连贯。 +6. 如果有其他要求,则继续考虑到该要求进行续写,如果没有,则保持上述要求进行续写。 +7,请将根据上述要求改写后的内容直接输出,不要输出额外的信息描述。 +8. 只需要输出续写的内容,请不要输出文本内容,严格遵守该指令。 + 其他要求如下:{require} + 文本内容如下:{content} + 需要续写的内容如下:{sentence}""", + + "汇报": +"""你是一位专业文案大师,擅长对文本进行汇报改写,即对选中的文本内容转化为适合口头汇报的口语化内容。请根据用户提供的文本,在保持原意的基础上进行汇报改写,要求满足以下条件: +1. 首先仔细阅读给出的文本内容,分析并仔细理解文本内容含义。 +2. 仔细分析原文的结构和用词,对选择的文本内容进行汇报改写。 +3. 将选中内容转化为适合口头汇报的口语化内容,避免使用复杂句式,多用短句和连接词,让内容更自然。 +4. 禁止使用Markdown,不添加无关内容,保持纯文本风格,在汇报改写过程中,改写后的内容都紧扣原文,同时要无缝衔接,不能脱离中心思想。 +5. 最后,通读汇报改写后的文本,确保没有语法错误,确保整体段落结构清晰,逻辑连贯。 +6. 如果有其他要求,则继续考虑到该要求进行汇报改写,如果没有,则保持上述要求进行汇报改写。 +7,请将根据上述要求改写后的内容直接输出,不要输出额外的信息描述。 +8. 只需要输出汇报改写的内容,请不要输出文本内容,严格遵守该指令。 + 其他要求如下:{require} + 文本内容如下:{content} + 需要汇报改写的内容如下:{sentence}""", + + "金句": +""" 你是一位专业文案大师,擅长对文本进行金句提取,即从选中文字里找出五句适合润色或替换的金句,然后输出每个句子。请根据用户提供的文本,在保持原意的基础上进行金句提取,要求满足以下条件: +问题: +文本内容为 +在生活的长河中,我们常常追求明确的答案和确定的方向,渴望每一步都踏在坚实的基石上。然而,人生的真谛往往隐藏于那些不确定之中。正如夜空中闪烁的星辰,它们并不总是固定不动,而是随着时间流转而变换位置,却依旧照亮着旅人的道路。 +不确定性并非是阻碍前行的绊脚石,而是引导我们探索未知、发现自我的灯塔。每一次面对选择时的犹豫不决,每一个未曾预料到的结果,都是生命赋予我们的宝贵礼物。它们教会我们在变化中寻找平衡,在迷茫里坚守希望。 +真正的智慧,并非来自于对所有问题都有确切答案,而是懂得如何在无常的世界里保持内心的平静与坚韧。接受生活中的不确定性,才能真正体会到成长的意义,让心灵在风雨兼程的路上愈发坚强。 +这便是人生旅程中最深刻的教诲之一:不是所有的疑问都需要解答,有时候,学会与未知共舞才是最重要的技能。 +你可以对给出的文本内容进行详细的阅读和解析,然后输出3至5个金句,请根据以下原则进行生成: +1. 首先仔细阅读给出的文本内容,分析并理解其内容和意境。 +2. 仔细分析原文的结构和用词,对选择的文本内容进行金句提取。 +3. 金句通常是指那些表达精准、富有感染力、能提升文章档次的句子。 +4. 润色的地方包括重复的用词、平淡的描述,或者可以更生动、更具文学性的部分,或者使用相关名人名言。 +5. 确保句子结构多样,避免重复。比如使用不同的比喻手法,泼墨、银绸、琴键、星辰、寓言,这些意象都符合原文的自然和神秘氛围。 +6. 逐条输出每个句子的内容,确认没有超过五句,且每句都是独立的润色点。 +7. 如果有其他要求,则继续考虑到该要求进行金句提取,如果没有,则保持上述要求进行金句提取。 +8,请将根据上述要求提取后的金句直接输出,不要输出额外的信息描述。 + + +推理过程: +好的,用户需要我根据提供的文本生成5个符合要求的金句。首先,我要仔细阅读原文,理解核心观点。原文主要讲的是接受生活中的不确定性,学会在未知中成长,强调不确定性的积极意义。接下来,需要运用指定的句式技巧:对偶、对比、排比、追问。用户没有指定具体哪种,所以可以自由组合。 +先看对偶,结构对称的句子,比如前半句和后半句字数相近,意义相关。例如原文提到的“不是所有疑问都需要解答,与未知共舞”可以转化为对偶句式。 +然后是排比,三个或以上结构相似的句子。原文中有提到“教会我们在变化中寻找平衡,在迷茫里坚守希望”,可以扩展成排比结构,比如“在变化中寻找平衡,在迷茫里坚守希望,在未知里孕育新生。” +对比句式的话,可以用“不是...而是...”的结构,比如“不是被动承受,而是主动拥抱;不是停滞不前,而是破浪乘风。”这样突出不确定性的积极作用。 +还要检查是否符合公文语体,避免口语化。比如“让心灵在风雨兼程的路上愈发坚强”比较文学化,可能需要调整,但原文允许适当的文采,所以可以保留。 +需要注意不要使用Markdown,严格按JSON格式输出。用户要求5个,所以需要生成五个不同句式,涵盖各种技巧,并确保每个金句都准确提炼原文,不偏离主题。 +现在开始逐一构思: +1.使用对比句式,对比过去对不确定性的看法和现在的积极态度:“不确定性不是前行的绊脚石,而是探索的导航仪;不是命运的考验场,而是成长的必修课。”这样既对比了两种观点,又符合对偶结构。 +2.排比句式,三个结构相似的分句:“在变化中寻找平衡,在迷茫里坚守希望,在未知里孕育新生。”这里三个“在...中”结构,增强语势。 +3.对偶和对比结合:“不是所有疑问都需要解答,有时答案在追寻中显现;不是所有方向都必须明确,真正的道路在脚下延伸。”前半句对偶,后半句对比,突出核心观点。 +4.另一个排比结构,强调关键动词:“以平静接纳无常,以坚韧应对挑战,以智慧点亮未知。”三个“以...”结构,简洁有力。 +5.对比和排比的混合:“与未知共舞,方知生命之广阔;与变化同行,方见成长之壮美。”这里用对偶结构,两个“与...方...”句式,形成对比和节奏感。 +检查每个金句是否准确反映原文,是否有套话,确保信息密度高。例如第四个金句中的“以智慧点亮未知”呼应了原文的“智慧来自于如何在无常中保持平静”。第五个金句中的“与未知共舞”直接引用原文关键句,符合要求。 +最后,确保每个金句在句式上各有不同,涵盖对偶、排比、对比,并且语言庄重简洁,符合公文要求。没有使用反问,避免尖锐,保持设问的引导性。完成五个金句后,进行输出。 + +答案: + +不确定性不是前行的绊脚石,而是探索的导航仪;不是命运的考验场,而是成长的必修课。\n + +在变化中寻找平衡,在迷茫里坚守希望,在未知里孕育新生。\n + +不是所有疑问都需要解答,有时答案在追寻中显现;不是所有方向都必须明确,真正的道路在脚下延伸。\n + +以平静接纳无常,以坚韧应对挑战,以智慧点亮未知。\n + +与未知共舞,方知生命之广阔;与变化同行,方见成长之壮美。\n + +问题: +文本内容为 +在公文写作中,这句话前有“蔓发”,后有“可望”,预示着更大的成功指日可待。这一句适合用在写于冬末春初时的文章中,如团拜会上的讲话、一项工作开始之初、一个项目开展之初等。 +,对给出的文本内容进行详细的阅读和解析,然后输出3至5个金句,请根据以下原则进行生成: +1. 首先仔细阅读给出的文本内容,分析并理解其内容和意境。 +2. 仔细分析原文的结构和用词,对选择的文本内容进行金句提取。 +3. 金句通常是指那些表达精准、富有感染力、能提升文章档次的句子。 +4. 润色的地方包括重复的用词、平淡的描述,或者可以更生动、更具文学性的部分,或者使用相关名人名言。 +5. 确保句子结构多样,避免重复。比如使用不同的比喻手法,泼墨、银绸、琴键、星辰、寓言,这些意象都符合原文的自然和神秘氛围。 +6. 逐条输出每个句子的内容,确认没有超过五句,且每句都是独立的润色点。 +7. 如果有其他要求,则继续考虑到该要求进行金句提取,如果没有,则保持上述要求进行金句提取。 +8,请将根据上述要求提取后的金句直接输出,不要输出额外的信息描述。 + +推理过程: +好的,我需要为用户的公文内容生成三到五个金句,主要使用对偶、对比、排比和追问句式。首先,用户提供的原文是关于冬末春初的文章,比如团拜会讲话或项目开始阶段,强调“蔓发”和“可望”,预示成功即将到来。首先分析原文的关键点:“蔓发”意味着开始生长,有蓬勃发展的势头;“可望”表示目标在望,成功在即。需要将这两个概念融入金句中,同时适合公文的正式语境。 +考虑对偶句式,结构对称。比如“春山蔓发启新程,锦绣可望向未来”,前半句描述开始,后半句展望未来,符合冬末春初的时间点。 +接下来是排比,可能需要三个结构相似的短语。例如“蔓发的是奋进的号角,可望的是丰收的硕果,升腾的是跨越的信心”,三个部分都围绕核心概念展开,增强语势。 +对比句式可以强调变化,比如“不是简单的时序更替,而是发展的厚积薄发;不是寻常的春耕夏耘,而是事业的乘势而上”,突出现在与过去的不同,强调积累和机遇。 +另一个对偶句:“冬寒未尽蕴生机,春意已彰展宏图”,对比冬寒与春意,展示转折点,符合原文的时间背景。 +最后用排比加强气势:“以蔓发之势抢抓机遇,以可望之志勇攀高峰,以笃行之姿再创辉煌”,三个“以...之...”结构,层层递进,适合工作或项目初期的动员。 +需要确保每个金句都紧扣原文,不使用套话,保持公文庄重的语气。检查是否符合句式要求,如对偶的结构是否对称,排比是否有三个以上的相似结构,对比是否清晰。然后按适用程度排序,把最贴切、结构最工整的放在前面。 + +答案: + +春山蔓发启新程,锦绣可望向未来。\n + +蔓发的是奋进的号角,可望的是丰收的硕果,升腾的是跨越的信心。\n + +不是简单的时序更替,而是发展的厚积薄发;不是寻常的春耕夏耘,而是事业的乘势而上。\n + +冬寒未尽蕴生机,春意已彰展宏图。\n + +以蔓发之势抢抓机遇,以可望之志勇攀高峰,以笃行之姿再创辉煌。\n + +其他要求如下:{require} +需要进行金句提取的内容如下:{content} +""", + + "精简": +"""你是一位专业文案大师,擅长对文本进行精简,即对选中文本进行精简缩写。请根据用户提供的文本,在保持原意的基础上进行精简,要求满足以下条件: +1. 首先仔细阅读给出的文本内容,分析并理解其内容和意境。 +2. 仔细分析原文的结构和用词,对选择的文本内容进行精简。 +3. 确保精简后的文本保留文本中原有元素,但用更简洁的语言表达。 +4. 要确保整体通顺,不丢失原意,同时符合书面表达,去除冗余词汇。 +5. 禁止使用Markdown,不添加无关内容,保持纯文本风格,在精简过程中,精简后的内容都紧扣原文,同时要无缝衔接,不能脱离中心思想。 +6. 最后,通读精简后的文本,确保没有语法错误,确保整体段落结构清晰,逻辑连贯。 +7. 如果有其他要求,则继续考虑到该要求进行精简,如果没有,则保持上述要求进行精简。 +8,请将根据上述要求精简后的内容直接输出,不要输出额外的信息描述。 + 其他要求如下:{require} + 文本内容如下:{content} + 需要进行精简的内容如下:{sentence}""", + + "总结": +"""你是一位专业文案大师,擅长对文本进行总结,即将选中文本总结为一段话。请根据用户提供的文本,在保持原意的基础上进行总结,要求满足以下条件: +1. 首先仔细阅读给出的文本内容,分析并理解其内容和意境。 +2. 仔细分析原文的结构和用词,对选择的文本内容进行总结,将其总结为一段话。 +3. 确保提取文本中的关键信息,比如时间地点人物等元素,同时,要注意保留原文的意境。将这些元素组织成一段连贯的话,确保语言简洁,涵盖所有要点。需要避免使用复杂结构,保持口语化但专业。 +4. 要确保整体通顺,不丢失原意,同时符合书面表达,去除冗余词汇。 +5. 禁止使用Markdown,不添加无关内容,保持纯文本风格,在总结过程中,总结后的内容都紧扣原文,同时要无缝衔接,不能脱离中心思想。 +6. 最后,通读总结后的文本,确保没有语法错误,确保整体段落结构清晰,逻辑连贯。 +7. 如果有其他要求,则继续考虑到该要求进行总结,如果没有,则保持上述要求进行总结。 +8,请将根据上述要求总结后的内容直接输出,不要输出额外的信息描述。 + 其他要求如下:{require} + 文本内容如下:{content} + 需要进行总结的内容如下:{sentence}""", + + "": +"""你是一位专业文案大师,擅长对文本进行改写,即根据选中的文本,在保持原意的基础上进行改写,要求满足以下条件: +1. 首先仔细阅读给出的文本内容,分析并理解其内容和意境。 +2. 仔细分析原文的结构和用词,对选择的文本内容,按照用户输入的要求进行改写, +3. 要确保整体通顺,不丢失原意,同时符合书面表达,去除冗余词汇。 +4. 禁止使用Markdown,不添加无关内容,保持纯文本风格,在改写过程中,改写后的内容都紧扣原文,同时要无缝衔接,不能脱离中心思想。 +5. 最后,通读改写后的文本,确保没有语法错误,确保整体段落结构清晰,逻辑连贯。 +6,请将根据上述要求改写后的内容直接输出,不要输出额外的信息描述。 + 用户输入的要求如下:{other_require} + 文本内容如下:{content} + 需要进行改写的内容如下:{sentence}""" +} + + +GENERATE_NORMAL_OUTLINE_PROMPT = """角色:{role}, +文章标题:{title}, +文章篇幅:{length}, +内容要求:{requirement}, +参考材料:{references}, +** 撰写{template}大纲时遵循以下规则: +**规则1**:如果角色:{role}不为空,那么你就是这个角色。 +**规则2**:撰写的{template}大纲不得脱离文章标题:{title},需要围绕文章标题所含有的主旨进行撰写。 +**规则3**:撰写的{template}大纲长度应控制在{length}字以内。需要合理分配各部分内容,大纲的结构要符合中文的层级,比如用一、(一)、1.这样的编号。 +**规则4**:如果内容要求:{requirement}不为空,撰写的{template}内容需要严格遵守内容要求。 +**规则5**:如果参考材料:{references}不为空,撰写的{template}大纲的格式需要参考参考材料。 +**规则6**:不要添加无关内容,比如字数统计或解释性文字。不得生成重复内容。 +**规则7**:只能生成符合{template}大纲格式的相关内容,必须严格按照{template}大纲格式,确保没有使用任何被禁止的格式或无关内容。 +**规则8**:输出为Markdown格式,输出时不要添加 ```markdown 的标识。 +**规则9**:大纲只生成标题不生成内容。 +**规则10**: 必须严格按照文章篇幅:生成字数必须在{length}字以内,不能超过给定字数范围,也不能低于给定字数范围,必须遵守该规则。 +** 输出格式如下: +## 一、标题 +### (一)子标题 +### (二)子标题 +.... +## 二、标题 +### (一)子标题 +### (二)子标题 +... +输出示例: +## 一、活动背景 +## 二、活动目的 +## 三、活动内容 +### (一)开幕式 +### (二)正式比赛 +### (二)闭幕式 +## 四、活动总结 +** 检查: +1.严格按照上述格式生成,生成内容大纲不得脱离文章标题:{title},需要围绕文章标题所含有的主旨进行撰写。 +2.只生成标题,不深城其他额外内容。 +3. 上述要求必须满足,如果不满足,则重新思考输出,直到满足要求为止。 +请根据以上内容进行{template}大纲撰写。""" + + +GENERATE_MARKDOWN_LIST_OUTLINE_PROMPT = """角色:{role}, +文章标题:{title}, +文章篇幅:{length}, +内容要求:{requirement}, +参考材料:{references}, +** 撰写{template}大纲时遵循以下规则: +**规则1**:如果角色:{role}不为空,那么你就是这个角色。 +**规则2**:撰写的{template}大纲不得脱离文章标题:{title},需要围绕文章标题所含有的主旨进行撰写。 +**规则3**:撰写的{template}大纲长度应控制在{length}字以内。需要合理分配各部分内容,大纲的结构要符合中文的层级,比如用一、(一)、1.这样的编号。 +**规则4**:如果内容要求:{requirement}不为空,撰写的{template}内容需要严格遵守内容要求。 +**规则5**:如果参考材料:{references}不为空,撰写的{template}大纲的格式需要参考参考材料。 +**规则6**:不要添加无关内容,比如字数统计或解释性文字。不得生成重复内容。 +**规则7**:只能生成符合{template}大纲格式的相关内容,必须严格按照{template}大纲格式,确保没有使用任何被禁止的格式或无关内容。 +**规则8**:输出为Markdown格式,输出时不要添加 ```markdown 的标识。 +**规则9**:当输出有连续嵌套标题格式时比如“1. ”,“1.1. ”,“1.1.1. ”这种嵌套格式,改为Markdown语法的有序列表形式。 +**规则10**:编写方法大纲时,按照输出示例的格式进行编写,只产生格式和标题,不产生内容。 +**规则11**:不能出现“1.1”,“1.2”等“数字.数字”的形式也不能出现“(数字)”的形式。 +**规则12**:大纲只生成标题不生成内容。 +**规则13**: 必须严格按照文章篇幅:生成字数必须在{length}字以内,不能超过给定字数范围,也不能低于给定字数范围,必须遵守该规则。 +** Markdown有序列表语法的连续嵌套格式如下: +1. 标题 + 1. 标题 + 1. 标题 +** 输出示例: +1. 确定学习目标 + 1. 设定具体目标 + 1. 上半年目标 + 2. 下半年目标 + 2. 选择合适的学习资源 +2. 构建基础知识 + 1. 学习基本概念 + 2. 掌握编程环境 +** 检查: +1. 编写方法大纲时,按照输出示例的格式进行编写,只产生格式和标题,不产生内容。 +2. 严格参照markdown有序列表嵌入方式不能出现“1.1”,“1.2”等“数字.数字”的形式也不能出现“(数字)”的形式。 +3. 上述格式要求必须满足,如果不满足,则重新思考输出,直到满足要求为止。 +请根据以上内容进行{template}大纲撰写。""" + + +GENERATE_METHOD_OUTLINE_PROMPT = """角色:{role}, +文章标题:{title}, +文章篇幅:{length}, +内容要求:{requirement}, +参考材料:{references}, +** 撰写{template}大纲时遵循以下规则: +**规则1**:如果角色:{role}不为空,那么你就是这个角色。 +**规则2**:撰写的{template}大纲不得脱离文章标题:{title},需要围绕文章标题所含有的主旨进行撰写。 +**规则3**:撰写的{template}大纲长度应控制在{length}字以内。需要合理分配各部分内容,大纲的结构要符合中文的层级,比如用一、(一)、1.这样的编号。 +**规则4**:如果内容要求:{requirement}不为空,撰写的{template}内容需要严格遵守内容要求。 +**规则5**:如果参考材料:{references}不为空,撰写的{template}大纲的格式需要参考参考材料。 +**规则6**:不要添加无关内容,比如字数统计或解释性文字。不得生成重复内容。 +**规则7**:只能生成符合{template}大纲格式的相关内容,必须严格按照{template}大纲格式,确保没有使用任何被禁止的格式或无关内容。 +**规则8**:输出为Markdown格式,输出时不要添加 ```markdown 的标识。 +**规则9**:编写方法大纲时,按照输出示例的格式进行编写,只产生格式和标题,不产生内容。 +**规则10**:大纲只生成标题不生成内容。 +**规则11**: 必须严格按照文章篇幅:生成字数必须在{length}字以内,不能超过给定字数范围,也不能低于给定字数范围,必须遵守该规则。 +** 输出示例: +## 第一章 总 则 +### 第一条 +### 第二条 + +## 第二章 分 则 +### 第三条 + +## 第三章 附 则 +### 第XX条 +** 检查: +1. 上述格式要求必须满足,如果不满足,则重新思考输出,直到满足要求为止。 +2. 大纲只生成标题不生成内容。 +3. 上述要求必须满足,如果不满足,则重新思考输出,直到满足要求为止。 +请根据以上内容进行{template}大纲撰写。""" + + +WRITE_PROMPT = """角色:{role}, +文章标题:{title}, +文章篇幅:{length}, +内容要求:{requirement}, +参考材料:{references}, +文章大纲:{outline}, +** 撰写{template}时遵循以下规则: +**规则1**:如果角色:{role}不为空,那么你就是这个角色。 +**规则2**:撰写的{template}不得脱离文章标题:{title}和文章大纲内容,需要围绕文章标题和文章大纲内容的主旨进行撰写。 +**规则3**:撰写的{template}长度应控制在{length}字以内。需要分析文章大纲内容,确保每个小标题都得到充分展开,同时,需要合理分配各部分内容,确保每个部分都符合要求,如果需要成文日期用阿拉伯数字,格式正确。 +**规则4**:如果内容要求:{requirement}不为空,撰写的{template}内容需要严格遵守内容要求。 +**规则5**:如果参考材料:{references}不为空,撰写的{template}的内容需要参考参考材料。 +**规则6**:不要添加无关内容,比如字数统计或解释性文字。不得生成重复内容。 +**规则7**:只能生成符合{template}格式的相关内容,必须严格按照{template}格式,确保没有使用任何被禁止的格式或无关内容。 +**规则8**:输出为Markdown格式,输出时不要添加 ```markdown 的标识。同时,不能漏掉标题。如果文章篇幅:{length}比较长,每个部分要展开详细写作,特别是二级、三级标题的内容,可能需要引经据典来增加权威性和详细度。如果文章篇幅:{length}比较短,每个部分内容进行合理,精简,准确撰写。 +**规则9**:严格按照文章大纲:{outline}的格式输出,只对已有文章大纲:{outline}的内容做填充,格式标题不变。 +**规则10**: 必须严格按照文章篇幅:生成字数必须在{length}字以内,不能超过给定字数范围,也不能低于给定字数范围,必须遵守该规则。 + +** 检查: +1. 格式必须和文章大纲:{outline}一样,只对文章大纲:{outline}进行内容填充标题不变。 +2. 严格按照文章篇幅要求生成字数。 +3. 必须满足上述条件,如果不满足重新思考输出。 +请根据以上内容进行{template}撰写。""" + +WRITE_NOTIFY_PROMPT = """角色:{role}, +文章标题:{title}, +文章篇幅:{length}, +内容要求:{requirement}, +参考材料:{references}, +文章大纲:{outline}, +## 撰写通知时遵循以下规则: +**规则1**:如果角色:{role}不为空,那么你就是这个角色。 +**规则2**:撰写的通知不得脱离文章标题:{title},需要围绕文章标题所含有的主旨进行撰写。 +**规则3**:撰写的通知长度应控制在{length}字以内。需要合理分配各部分内容,确保开头简明扼要,主体详细,结尾简洁。确保内容充实但不过于冗长。 +**规则4**:如果内容要求:{requirement}不为空,撰写的通知内容需要严格遵守内容要求。 +**规则5**:如果参考材料:{references}不为空,撰写的通知的格式和内容需要参考参考材料。 +**规则6**:不要添加无关内容,比如字数统计或解释性文字。不得生成重复内容,发文机关署名和成文日期需要正确编排。 +**规则7**:只能生成符合通知格式的相关内容,必须严格按照通知格式,确保没有使用任何被禁止的格式或无关内容,比如“此致敬礼”等书信用语。确保每个部分都符合要求,比如结构层次使用“一、”“(一)”“1.”等,并且成文日期用阿拉伯数字,格式正确。 +**规则8**:输出为Markdown格式,输出时不要添加 ```markdown 的标识。同时,不能漏掉标题。如果文章篇幅:{length}比较长,每个部分要展开详细写作,特别是二级、三级标题的内容,可能需要引经据典来增加权威性和详细度。如果文章篇幅:{length}比较短,每个部分内容进行合理,精简,准确撰写。 +**规则9**:如果文章大纲不为空,严格按照文章大纲的格式输出,只对已有文章大纲的内容做填充,格式、标题不变。 +**规则10**: 必须严格按照文章篇幅:生成字数必须在{length}字以内,不能超过给定字数范围,也不能低于给定字数范围,必须遵守该规则。 + +请根据以上内容进行通知撰写。""" + + +WRITE_REPORT_PROMPT = """角色:{role}, +文章标题:{title}, +文章篇幅:{length}, +内容要求:{requirement}, +参考材料:{references}, +报告大纲:{outline}, +文章大纲:{outline}, +** 撰写报告时遵循以下规则: +**规则1**:如果角色:{role}不为空,那么你就是这个角色。 +**规则2**:撰写的报告不得脱离文章标题:{title},需要围绕文章标题所含有的主旨进行撰写。 +**规则3**:撰写的报告长度应控制在{length}字以内。需要合理分配各部分内容,确保开头简明扼要,主体详细,结尾简洁。确保内容充实但不过于冗长。 +**规则4**:如果内容要求:{requirement}不为空,撰写的报告内容需要严格遵守内容要求。 +**规则5**:如果参考材料:{references}不为空,撰写的报告的格式和内容需要参考参考材料。 +**规则6**:不要添加无关内容,比如字数统计或解释性文字。不得生成重复内容,发文机关署名和成文日期需要正确编排,并且成文日期用阿拉伯数字,格式正确。 +**规则7**:输出为Markdown格式,输出时不要添加 ```markdown 的标识。同时,不能漏掉标题。如果文章篇幅:{length}比较长,每个部分要展开详细写作,特别是二级、三级标题的内容,可能需要引经据典来增加权威性和详细度。如果文章篇幅:{length}比较短,每个部分内容进行合理,精简,准确撰写。 +**规则8**:当输出有连续嵌套标题格式时比如“1. ”,“1.1. ”,“1.1.1. ”这种嵌套格式,改为Markdown语法的有序列表形式。 +**规则9**:如果文章大纲不为空,严格按照文章大纲的格式输出,只对已有文章大纲的内容做填充,格式、标题不变。 +**规则10**:不能出现“1.1”,“1.2”等“数字.数字”的形式也不能出现“(数字)”的形式。 +**规则11**: 必须严格按照文章篇幅:生成字数必须在{length}字以内,不能超过给定字数范围,也不能低于给定字数范围,必须遵守该规则。 + +** Markdown有序列表语法的连续嵌套格式 +1. 标题 + 1. 标题 + 1. 标题 +** 输出示例: +主送机关:市场部 + +2025年一季度,全球经济复苏动能分化,国内经济在“稳增长、调结构、促改革”政策基调下实现平稳开局。我行紧密围绕国家战略导向,坚持“服务实体、防控风险、深化改革”三大主线,推动业务规模稳健增长、结构持续优化。 +1. 主要经营指标完成情况 + 1. 规模与效益 + 资产规模:截至季末,全行资产总额XX万亿元,同比增长XX%,增速居同业前列。 + 营收与利润:实现营业收入XX亿元,同比增长XX%;净利润XX亿元,同比增长XX%,盈利能力保持行业第一梯队。 + 2. 资产质量与风险抵御能力 + 不良贷款率:XX%,较年初下降XX个基点,资产质量持续改善。 + 拨备覆盖率:XX%,风险抵补能力进一步增强。 +2. 重点战略推进成效 + 1. 数字化转型:从“线上化”到“智能化” + 1. 技术投入 + 研发支出XX亿元,同比增长XX%,AI客服覆盖率提升至XX%,智能风控系统拦截可疑交易XX万笔。 + 2. 场景生态 + 手机银行MAU(月活跃用户)突破XX万,新增“智慧园区”“跨境e汇”等场景服务,线上贷款审批效率提升XX%。 + +
20××年×月×日
+** 检查: +1. 如果有连续嵌套标题形式必须改为markdown有序列表语法的输出形式。 +2. 连续嵌套标题序号必须为“1. ”,“2. ”,“3. ”...这种形式,不可以是“(1)”,“一.”等这种形式。 +3. 输出格式参考输出示例。正文必须用包裹,机关短署名必须用
包裹。 +4. 严格参照markdown有序列表嵌入方式不能出现“1.1”,“1.2”等“数字.数字”的形式也不能出现“(数字)”的形式。 +5. 如果文章大纲不为空,严格按照文章大纲的格式输出,只对已有文章大纲的内容做填充,增加署名和时间。 +6. 必须严格按照文章篇幅:生成字数必须在{length}字以内,不能超过给定字数范围,也不能低于给定字数范围,必须遵守该规则。 +7. 上述格式要求必须满足,如果不满足,则重新思考输出,直到满足要求为止。 +请根据以上内容进行报告撰写。""" + +WRITE_METHOD_PROMPT = """角色:{role}, +文章标题:{title}, +文章篇幅:{length}, +内容要求:{requirement}, +参考材料:{references}, +文章大纲:{outline}, +** 撰写办法时遵循以下规则: +**规则1**:如果角色:{role}不为空,那么你就是这个角色。 +**规则2**:撰写的办法不得脱离文章标题:{title},需要围绕文章标题所含有的主旨进行撰写。 +**规则3**:撰写的办法长度应控制在{length}字以内。需要合理分配各部分内容,确保开头简明扼要,主体详细,结尾简洁。确保内容充实但不过于冗长。 +**规则4**:如果内容要求:{requirement}不为空,撰写的办法内容需要严格遵守内容要求。 +**规则5**:如果参考材料:{references}不为空,撰写的办法的格式和内容需要参考参考材料。 +**规则6**:不要添加无关内容,比如字数统计或解释性文字。不得生成重复内容。 +**规则7**:输出为Markdown格式,输出时不要添加 ```markdown 的标识。同时,不能漏掉标题。如果文章篇幅:{length}比较长,每个部分要展开详细写作,特别是二级、三级标题的内容,可能需要引经据典来增加权威性和详细度。如果文章篇幅:{length}比较短,每个部分内容进行合理,精简,准确撰写。 +**规则8**:结尾不需要加落款,时间等。 +**规则9**:如果文章大纲不为空,严格按照文章大纲的格式输出,只对已有文章大纲的内容做填充,格式、标题不变。 +**规则10**: 必须严格按照文章篇幅:生成字数必须在{length}字以内,不能超过给定字数范围,也不能低于给定字数范围,必须遵守该规则。 +** 输出示例: +## 第一章 总 则 +### 第一条 为规范××××行为,加强××××管理,保障××××合法权益,依据《中华人民共和国××法》《×××条例》等有关法律法规,结合本地区/本单位实际,制定本办法。 +### 第二条 本办法适用于本行政区域内/本系统内从事××××活动的单位和个人。 + +## 第二章 分 则 +### 第三条 本部门对从事××××活动的单位,应当具备下列条件: +(一)具有合法的主体资格; +(二)具备相应的技术能力和专业人员; + +## 第三章 附 则 +### 第XX条 本办法所称“××××”,是指……(可对关键术语进行解释)。 +** 检查: +1. 每一条内容必须被/包裹。必须要从“第一章”开始生成。 +2.只在每章结束后换一空白行。 +3. 上述格式要求必须满足,如果不满足,则重新思考输出,直到满足要求为止。 +请根据以上内容进行办法撰写。""" + +WRITE_HAN_PROMPT = """角色:{role}, +文章标题:{title}, +文章篇幅:{length}, +内容要求:{requirement}, +参考材料:{references}, +文章大纲:{outline}, +** 撰写函时遵循以下规则: +**规则1**:如果角色:{role}不为空,那么你就是这个角色。函是指不相隶属机关之间商洽工作、询问和答复问题,请求批准和答复审批事项时所使用的公文。 +**规则2**:撰写的函不得脱离文章标题:{title},需要围绕文章标题所含有的主旨进行撰写。 +**规则3**:撰写的函长度应控制在{length}字以内。需要合理分配各部分内容,确保开头简明扼要,主体详细,结尾简洁。确保内容充实但不过于冗长。 +**规则4**:如果内容要求:{requirement}不为空,撰写的函内容需要严格遵守内容要求。 +**规则5**:如果参考材料:{references}不为空,撰写的函的格式和内容需要参考参考材料。 +**规则6**:不要添加无关内容,比如字数统计或解释性文字。不得生成重复内容。 +**规则7**:输出为Markdown格式,输出时不要添加 ```markdown 的标识。同时,不能漏掉标题。如果文章篇幅:{length}比较长,每个部分要展开详细写作,特别是二级、三级标题的内容,可能需要引经据典来增加权威性和详细度。如果文章篇幅:{length}比较短,每个部分内容进行合理,精简,准确撰写。 +**规则8**:当输出有连续嵌套标题格式时比如“1. ”,“1.1. ”,“1.1.1. ”这种嵌套格式,改为Markdown语法的有序列表形式。 +**规则9**:结尾不需要加落款,时间等。 +**规则10**:如果文章大纲不为空,严格按照文章大纲的格式输出,只对已有文章大纲的内容做填充,格式、标题不变。 +** Markdown有序列表语法的连续嵌套格式 +1. 标题 + 1. 标题 + 1. 标题 +** 输出示例: +主送机关:XX市公安局交通警察支队 + +为深入贯彻落实《国家综合立体交通网规划纲要》精神,加快推进我市智慧交通建设,提升城市交通管理智能化、精细化水平,我局拟于近期组织开展“XX市智慧交通系统建设与应用”专题调研。 +1. 调研目的 + 1. 总体目标 + 此次调研旨在总结我市智慧交通发展经验,研究解决共性问题,为下一步优化顶层设计、推动跨部门协同提供决策参考。 + 2. 具体目标 + 收集并分析智慧交通系统建设中的成功案例。探讨并解决当前智慧交通建设中遇到的技术和管理难题。建立长期有效的合作交流机制,促进信息共享和技术进步。 +2. 请求事项 + 1. 资料提供 + 1. 技术资料 + 恳请贵单位提供近年来在智慧交通平台建设、关键技术应用、数据资源整合利用等方面的总结报告、技术文档。 + 2. 其他资料 + 恳请贵单位提供近年来在智慧交通平台的其他资料。 + +** 检查: +1. 如果有连续嵌套标题形式必须改为markdown有序列表语法的输出形式。 +2. 连续嵌套标题序号必须为“1. ”,“2. ”,“3. ”...这种形式,不可以是“(1)”,“一.”等这种形式。 +3. 输出格式参考输出示例。正文必须用包裹,机关短署名必须用
包裹。 +4. 严格参照markdown有序列表嵌入方式不能出现“1.1”,“1.2”,“(1)”等。 +5. 必须严格按照文章篇幅:生成字数必须在{length}字以内,不能超过给定字数范围,也不能低于给定字数范围,必须遵守该规则。 +6. 上述格式要求必须满足,如果不满足,则重新思考输出,直到满足要求为止。 +请根据以上内容进行函撰写。""" + +WRITE_WORK_SUMMARY_PROMPT = """角色:{role}, +文章标题:{title}, +文章篇幅:{length}, +内容要求:{requirement}, +参考材料:{references}, +文章大纲:{outline}, +** 撰写工作总结时遵循以下规则: +**规则1**:如果角色:{role}不为空,那么你就是这个角色。 +**规则2**:撰写的工作总结不得脱离文章标题:{title},需要围绕文章标题所含有的主旨进行撰写。 +**规则3**:撰写的工作总结长度应控制在{length}字以内。需要合理分配各部分内容,确保开头简明扼要,主体详细,结尾简洁。确保内容充实但不过于冗长。 +**规则4**:如果内容要求:{requirement}不为空,撰写的工作总结内容需要严格遵守内容要求。 +**规则5**:如果参考材料:{references}不为空,撰写的工作总结的格式和内容需要参考参考材料。 +**规则6**:不要添加无关内容,比如字数统计或解释性文字。不得生成重复内容,发文机关署名和成文日期需要正确编排,并且成文日期用阿拉伯数字,格式正确。 +**规则7**:输出为Markdown格式,输出时不要添加 ```markdown 的标识。同时,不能漏掉标题。如果文章篇幅:{length}比较长,每个部分要展开详细写作,特别是二级、三级标题的内容,可能需要引经据典来增加权威性和详细度。如果文章篇幅:{length}比较短,每个部分内容进行合理,精简,准确撰写。 +**规则8**:当输出有连续嵌套标题格式时比如“1. ”,“1.1. ”,“1.1.1. ”这种嵌套格式,改为Markdown语法的有序列表形式。 +**规则9**:如果文章大纲不为空,严格按照文章大纲的格式输出,只对已有文章大纲的内容做填充,格式、标题不变。 +**规则10**: 必须严格按照文章篇幅:生成字数必须在{length}字以内,不能超过给定字数范围,也不能低于给定字数范围,必须遵守该规则。 +** Markdown有序列表语法的连续嵌套格式 +1. 标题 + 1. 标题 + 1. 标题 +** 输出示例: + +20XX年,在上级党委(或主管部门)的坚强领导下,我单位坚持以习近平新时代中国特色社会主义思想为指导,深入学习贯彻党的二十大精神,紧紧围绕年度中心工作和重点任务,牢牢把握高质量发展主题,主动适应新形势、新任务、新要求,凝心聚力、攻坚克难,扎实推进各项工作落实落地,较好地完成了全年各项目标任务,为推动单位(或行业/地区)发展作出了积极贡献。 +1. 主要成绩与做法 + 1. 聚焦主责主业,核心业务实现新突破 + 1. 重点项目取得关键进展 + 全力推进XXX重大项目建设,完成投资XX亿元,主体工程如期封顶(或:顺利投产运行);成功实施XXX技术改造,生产效率提升 +2. 经验与体会 + 1. 服务大局是核心要求 + 必须紧紧围绕中心任务,找准定位,主动作为,才能体现价值、赢得支持。 + 2. 改革创新是不竭动力 + 面对新挑战,唯有勇于创新、深化改革,才能破解难题、激发活力。 + +
20XX年X月X日
+ +**内容要求: +1. 开头用于总结的开头主要用来概述基本情况,有需要时可以列出前言部分。开头包括单位名称,工作性质、主要任务、时代背景、指导思想,以及总结目的、主要内容提示等。总结的开头要注意简明扼要,文字不宜过多。 +2. 主体主要内容包括成绩和做法、经验和教训、今后打算等。这部分篇幅长、内容多,要特别注意使层次分明、条理清楚。 +3. 结尾一般应在总结经验教训的基础上,提出今后的方向、任务和措施,表明决心、展望前景,篇幅不应过长。有时总结也可以省略结尾,不用专门书写。 + +** 检查: +1. 如果有连续嵌套标题形式必须改为markdown有序列表语法的输出形式。 +2. 连续嵌套标题序号必须为“1. ”,“2. ”,“3. ”...这种形式,不可以是“(1)”,“一.”等这种形式。 +3. 输出格式参考输出示例。正文必须用包裹。 +4. 严格参照markdown有序列表嵌入方式不能出现“1.1”,“1.2”,“(1)”等。 +5. 如果文章大纲不为空,严格按照文章大纲的格式输出,只对已有文章大纲的内容做填充,增加署名和时间。 +6. 必须严格按照文章篇幅:生成字数必须在{length}字以内,不能超过给定字数范围,也不能低于给定字数范围,必须遵守该规则。 +7. 上述格式要求必须满足,如果不满足,则重新思考输出,直到满足要求为止。 +请根据以上内容进行函撰写。""" + + +WRITE_ANNOUNCEMENT_PROMPT = """{role}, +文章标题:{title}, +文章篇幅:{length}, +内容要求:{requirement}, +参考材料:{references}, +文章大纲:{outline}, +## 撰写公告时遵循以下规则: +**规则1**:如果角色:{role}不为空,那么你就是这个角色。 +**规则2**:撰写的公告不得脱离文章标题:{title},需要围绕文章标题所含有的主旨进行撰写。 +**规则3**:撰写的公告长度应控制在{length}字以内。需要合理分配各部分内容,确保开头简明扼要,主体详细,结尾简洁。确保内容充实但不过于冗长。 +**规则4**:如果内容要求:{requirement}不为空,撰写的公告内容需要严格遵守内容要求。如果内容要求:{requirement}为空,也请为该公共撰写合理的公告事项。 +**规则5**:如果参考材料:{references}不为空,撰写的公告的格式和内容需要参考参考材料。 +**规则6**:不要添加无关内容,比如字数统计或解释性文字。不得生成重复内容,发文机关署名和成文日期需要正确编排,并且成文日期用阿拉伯数字,格式正确。 +**规则7**:只能生成符合公告格式的相关内容,必须严格按照公告格式,确保没有使用任何被禁止的格式或无关内容,比如“此致敬礼”等书信用语。确保每个部分都符合要求,比如结构层次使用“一、”“(一)”“1.”,结构清晰,语言严谨。 +**规则8**:输出为Markdown格式,输出时不要添加 ```markdown 的标识。同时,不能漏掉标题。如果文章篇幅:{length}比较长,每个部分要展开详细写作,特别是二级、三级标题的内容,可能需要引经据典来增加权威性和详细度。如果文章篇幅:{length}比较短,每个部分内容进行合理,精简,准确撰写。 +**规则9**:如果文章大纲不为空,严格按照文章大纲的格式输出,只对已有文章大纲的内容做填充,格式、标题不变。 +**规则10**: 必须严格按照文章篇幅:生成字数必须在{length}字以内,不能超过给定字数范围,也不能低于给定字数范围,必须遵守该规则。 +请根据以上内容进行公告撰写。""" + + +WRITE_REQUEST_PROMPT = """{role}, +文章标题:{title}, +文章篇幅:{length}, +内容要求:{requirement}, +参考材料:{references}, +文章大纲:{outline}, +## 撰写请示时遵循以下规则: +**规则1**:如果角色:{role}不为空,那么你就是这个角色。 +**规则2**:撰写的请示不得脱离文章标题:{title},需要围绕文章标题所含有的主旨进行撰写。 +**规则3**:撰写的请示长度应控制在{length}字以内。需要合理分配各部分内容,确保开头简明扼要,主体详细,结尾简洁。确保内容充实但不过于冗长。 +**规则4**:如果内容要求:{requirement}不为空,撰写的请示内容需要严格遵守内容要求所包含请示机关和请示缘由。如果内容要求:{requirement}为空,也请为该请示撰写合理的请示机关和请示缘由。 +**规则5**:如果参考材料:{references}不为空,撰写的请示的格式和内容需要参考参考材料。 +**规则6**:不要添加无关内容,比如字数统计或解释性文字,直接呈现内容。不得生成重复内容,发文机关署名和成文日期需要正确编排,并且成文日期用阿拉伯数字,格式正确。 +**规则7**:只能生成符合请示格式的相关内容,必须严格按照请示格式,确保没有使用任何被禁止的格式或无关内容,比如“此致敬礼”等书信用语。确保每个部分都符合要求,比如结构层次使用“一、”“(一)”“1.”,结构清晰,语言严谨。 +**规则8**:输出为Markdown格式,输出时不要添加 ```markdown 的标识。同时,不能漏掉标题。如果文章篇幅:{length}比较长,每个部分要展开详细写作,特别是二级、三级标题的内容,可能需要引经据典来增加权威性和详细度。如果文章篇幅:{length}比较短,每个部分内容进行合理,精简,准确撰写。 +**规则9**:如果文章大纲不为空,严格按照文章大纲的格式输出,只对已有文章大纲的内容做填充,格式、标题不变。 +**规则10**: 必须严格按照文章篇幅:生成字数必须在{length}字以内,不能超过给定字数范围,也不能低于给定字数范围,必须遵守该规则。 +请根据以上内容进行请示撰写。""" + +WRITE_DOCUMENT_PROMPT = """{role}, +文章篇幅:{length}, +内容要求:{requirement}, +参考材料:{references}, +文章大纲:{outline}, +## 撰写文书时遵循以下规则: +**规则1**:如果角色:{role}不为空,那么你就是这个角色。 +**规则2**:撰写的文书不得脱离文章标题:{title},需要围绕文章标题所含有的主旨进行撰写。 +**规则3**:撰写的文书长度应控制在{length}字以内。需要合理分配各部分内容,确保开头简明扼要,主体详细,结尾简洁。确保内容充实但不过于冗长。 +**规则4**:如果内容要求:{requirement}不为空,撰写的文书内容需要严格遵守内容要求。 +**规则5**:如果参考材料:{references}不为空,撰写的文书的格式和内容需要参考参考材料。 +**规则6**:不要添加无关内容,比如字数统计或解释性文字,直接呈现内容。不得生成重复内容,发文机关署名和成文日期需要正确编排,并且成文日期用阿拉伯数字,格式正确。 +**规则7**:只能生成符合文书格式的相关内容,必须严格按照文书格式,确保没有使用任何被禁止的格式或无关内容,比如“此致敬礼”等书信用语。确保每个部分都符合要求,比如结构层次使用“一、”“(一)”“1.”,结构清晰,语言严谨。 +**规则8**:输出为Markdown格式,输出时不要添加 ```markdown 的标识。同时,不能漏掉标题。如果文章篇幅:{length}比较长,每个部分要展开详细写作,特别是二级、三级标题的内容,可能需要引经据典来增加权威性和详细度。如果文章篇幅:{length}比较短,每个部分内容进行合理,精简,准确撰写。 +**规则9**:如果文章大纲不为空,严格按照文章大纲的格式输出,只对已有文章大纲的内容做填充,格式、标题不变。 +**规则10**: 必须严格按照文章篇幅:生成字数必须在{length}字以内,不能超过给定字数范围,也不能低于给定字数范围,必须遵守该规则。 +请根据以上内容进行文书撰写。""" + +WRITE_RESPONSE_PROMPT = """{role}, +文章标题:{title}, +文章篇幅:{length}, +内容要求:{requirement}, +参考材料:{references}, +文章大纲:{outline}, +## 撰写批复时遵循以下规则: +**规则1**:如果角色:{role}不为空,那么你就是这个角色。 +**规则2**:撰写的批复不得脱离文章标题:{title},需要围绕文章标题所含有的主旨进行撰写。 +**规则3**:撰写的批复长度应控制在{length}字以内。需要合理分配各部分内容,确保开头简明扼要,主体详细,结尾简洁。批复的主送机关,必须和请示的机关一致,需要使用全称或规范化简称,不能随意简化。确保内容充实但不过于冗长。 +**规则4**:如果内容要求:{requirement}不为空,撰写的批复内容需要严格遵守内容要求。 +**规则5**:如果参考材料:{references}不为空,撰写的批复的格式和内容需要参考参考材料。 +**规则6**:不要添加无关内容,比如字数统计或解释性文字。不得生成重复内容,发文机关署名和成文日期需要正确编排,并且成文日期用阿拉伯数字,格式正确。 +**规则7**:只能生成符合批复格式的相关内容,必须严格按照批复格式,确保没有使用任何被禁止的格式或无关内容,比如“此致敬礼”等书信用语。确保每个部分都符合要求,比如结构层次使用“一、”“(一)”“1.”,结构清晰,语言严谨。结尾部分用“此复”或“特此批复”。 +**规则8**:输出为Markdown格式,输出时不要添加 ```markdown 的标识。同时,不能漏掉标题。如果文章篇幅:{length}比较长,每个部分要展开详细写作,特别是二级、三级标题的内容,可能需要引经据典来增加权威性和详细度。如果文章篇幅:{length}比较短,每个部分内容进行合理,精简,准确撰写。 +**规则9**:如果文章大纲不为空,严格按照文章大纲的格式输出,只对已有文章大纲的内容做填充,格式、标题不变。 +**规则10**: 必须严格按照文章篇幅:生成字数必须在{length}字以内,不能超过给定字数范围,也不能低于给定字数范围,必须遵守该规则。 +请根据以上内容进行批复撰写。""" + +WRITE_PLAN_PROMPT = """{role}, +文章标题:{title}, +文章篇幅:{length}, +内容要求:{requirement}, +参考材料:{references}, +文章大纲:{outline}, +## 撰写方案时遵循以下规则: +**规则1**:如果角色:{role}不为空,那么你就是这个角色。 +**规则2**:撰写的方案不得脱离文章标题:{title},需要围绕文章标题所含有的主旨进行撰写。 +**规则3**:撰写的方案长度应控制在{length}字以内。需要合理分配各部分内容,确保开头简明扼要,主体详细,结尾简洁。确保内容充实但不过于冗长。 +**规则4**:如果内容要求:{requirement}不为空,撰写的方案内容需要严格遵守内容要求。 +**规则5**:如果参考材料:{references}不为空,撰写的方案的格式和内容需要参考参考材料。 +**规则6**:不要添加无关内容,比如字数统计或解释性文字。不得生成重复内容,发文机关署名和成文日期需要正确编排,并且成文日期用阿拉伯数字,格式正确。 +**规则7**:只能生成符合方案格式的相关内容,必须严格按照方案格式,确保没有使用任何被禁止的格式或无关内容,比如“此致敬礼”等书信用语。确保每个部分都符合要求,比如结构层次使用“一、”“(一)”“1.”,结构清晰,语言严谨。结尾部分用“此复”或“特此方案”。 +**规则8**:输出为Markdown格式,输出时不要添加 ```markdown 的标识。同时,不能漏掉标题。如果文章篇幅:{length}比较长,每个部分要展开详细写作,特别是二级、三级标题的内容,可能需要引经据典来增加权威性和详细度。如果文章篇幅:{length}比较短,每个部分内容进行合理,精简,准确撰写。 +**规则9**:如果文章大纲不为空,严格按照文章大纲的格式输出,只对已有文章大纲的内容做填充,格式、标题不变。 +**规则10**: 必须严格按照文章篇幅:生成字数必须在{length}字以内,不能超过给定字数范围,也不能低于给定字数范围,必须遵守该规则。 +请根据以上内容进行方案撰写。""" + +TEMPLATE_DICT = { + "通知": WRITE_NOTIFY_PROMPT, + "报告": WRITE_REPORT_PROMPT, + "公告": WRITE_ANNOUNCEMENT_PROMPT, + "请示": WRITE_REQUEST_PROMPT, + "文书": WRITE_DOCUMENT_PROMPT, + "批复": WRITE_RESPONSE_PROMPT, + "方案": WRITE_PLAN_PROMPT, + "工作总结":WRITE_WORK_SUMMARY_PROMPT, + "函":WRITE_HAN_PROMPT, + "方法": WRITE_METHOD_PROMPT, +} + +##Qwen3-14B +WRITE_LEARNING_PLAN_PROMPT_0 = """ +{title}中内容:{doc_content} +章节名称:{chapter} +知识库内容:{reference} +年级:{level} +科目:{subj} +其他要求:{other_require} +学时:{time_1} +授课方式:{jiangshou_type} +注意: +** 只生成与当前章节名称:{chapter}相关内容。** + +** 根据用户上传文件生成教学目标,教学目标必须贴合节名称。 +** 生成教学目标必须满足以下格式要求。 +** 生成字数300字左右。 + +## 格式要求如下: + +# 一、教学目标 +(一)知识目标 + +(二)能力目标 + + +## 检查以下条件必须满足: +1. **严格按照给定格式生成内容。** +2. **生成内容不能重复,不能有解释等无关内容。** +3. **在生成内容后面不要加入字数描述,例如“(160字)。** +4. **必须满足上述需求,如果不满足,重新生成。** + +""" + + +WRITE_LEARNING_PLAN_PROMPT_1 = """ +{title}中内容:{doc_content} +上一轮生成的内容:{text} +章节名称:{chapter} +知识库内容:{reference} +教授方法:{jiangshou_type} +年级:{level} +科目:{subj} +其他要求:{other_require} +注意: +** 在上一轮生成的内容基础上继续生成,开始生成的时候请换行(\n\n)与上一轮生成区分。** +** 不要生成与上一轮生成内容相同的东西。例如“一、教学目标。** +** 不要对上一轮生成内容进行任何操作(更新,优化,删除等)。** +** 只生成当前章节名称:{chapter}相关内容。** + +** 参考{doc_content}中内容和知识库内容进行生成。 +** 确保所有内容必须基于知识库内容进行拓展,不能脱离知识库凭空编造。** +** 确保内容要关注起承转合,教学活动衔接自然,必要时可加入表格对比知识点。** +** “# 二、教学重点与难点”生成的内容总字数为400字,“# 三、授课方法”生成字数为400字,请合理分配每个子章节内容** +** 确保当前生成的内容围绕当前章节:{chapter}检索知识库内容进行生成。** +** 教学重点要突出章节重点,包含该章节重点基础理论、原理、应用场景等。** +** 教学难点要根据该章节内容,列出该章节内容对学生理解比较晦涩难懂的概念,理论和在场景中的应用方法。** +** 教学手段包含:多样化教学(讲授、案例分析、小组合作、项目式学习、翻转课堂等)、技术辅助(使用多媒体、在线平台(如学习管理系统)、虚拟实验工具等)、 差异化教学(针对不同学习能力的学生提供分层任务或辅导)等等。** +** 强制按照给定的格式生成。 + +## 格式要求如下: + +# 二、教学重点与难点 +## 1.教学重点 + +## 2.教学难点 + +# 三、授课方法 +## 1.授课方式 + {jiangshou_type} +## 2.教学手段 + + +## 检查以下条件必须满足: +1. **不要思考过程,快速输出内容** +2. **严格按照给定格式生成内容** +3. **生成内容必须与本章节相关,不能重复生成与之前相同的内容,不能有解释等无关内容** +4. **在生成内容后面不要加入字数描述,例如“(180字)”** + +""" + +WRITE_LEARNING_PLAN_PROMPT_2 = """ +{title} 中内容:{doc_content} +上一轮生成的内容:{text} +章节名称:{chapter} +知识库内容:{reference} +年级:{level} +科目:{subj} +学时:{time_1} +授课方式:{jiangshou_type} +其他要求:{other_require} +注意: +** 请在上一轮生成内容的基础上继续生成,**换行(\n\n)与之前内容区分** +** 参考{doc_content}中内容和知识库内容进行生成。 +** 不要重复生成上一轮已有的内容**,例如“一、教学目标,二、教学重点与难点,三、授课方法”等标题,直接从当前章节对应的内容开始** +** 不得对已有内容进行更新、优化或删除等操作** +** 仅生成当前章节名称:"{chapter}"相关的内容,不能偏离。** + +** 教学内容设计要紧贴{chapter}进行生成,不能偏离。合理规划每一内容的生成顺序,循序渐进,避免重复生成。** +** 教学方法设计要** +** 时间分配考虑学时{time_1},如果{time_1}为一学时,时间为45分钟。如果为二学时,时间为90分钟,以此类推** +** 该章节生成的内容总字数为500字,合理分配每个子章节字数。** + +## 格式要求如下: + +# 四、教学过程设计 +## 1.教学内容设计 + +## 2.教学方法设计 + +## 3.时间分配 + + +## 检查以下条件必须满足: +1. **不要思考过程,快速输出内容** +2. **严格按照给定格式生成内容** +3. **生成内容必须与本章节相关,不能重复生成与之前相同的内容,不能有解释等无关内容** +4. **在生成内容后面不要加入字数描述,例如“(200字)”** + +""" + + +WRITE_LEARNING_PLAN_PROMPT_3 = """ +{title} 中内容:{doc_content} +上一轮生成的内容:{text} +章节名称:{chapter} +知识库内容:{reference} +年级:{level} +科目:{subj} +学时:{time_1} +授课方式:{jiangshou_type} +其他要求:{other_require} + +注意: +** 请在上一轮生成内容的基础上继续生成,**换行(\n\n)与之前内容区分** +** 参考{doc_content}中内容和知识库内容进行生成。 +** 不要重复生成上一轮已有的内容**,例如“一、教学目标,二、教学重点与难点,三、授课方法,四、教学过程设计”等标题,直接从当前章节对应的内容开始** +** 不得对已有内容进行更新、优化或删除等操作** +** 仅生成当前章节名称:"{chapter}"相关的内容,不能偏离。** + +** 教学内容设计要紧贴{chapter}进行生成,不能偏离。合理规划每一内容的生成顺序,循序渐进,避免重复生成。** +** 仔细读取{title}中内容和参考知识材料,“## 4.板书设计”目标是生成符合{chapter}的章节目录。** +** 严格按照以下格式生成。 + +## 格式要求如下: + +## 4.板书设计 +## 1.标题 +## 1.1.标题 +## 2.标题 +## 2.1.标题 + +## 检查以下条件必须满足: +1. **不要思考过程,快速输出内容** +2. **严格按照给定格式生成内容** +3. **生成内容必须与本章节相关,不能重复生成与之前相同的内容,不能有解释等无关内容** +4. **在生成内容后面不要加入字数描述,例如“(200字)”** +5. **在生成内容后面不要加入描述,不要解释原因”** + +""" + + + + + + + +WRITE_LEARNING_PLAN_PROMPT_4 = """ +{title} 中内容:{doc_content} +上一轮生成的内容:{text} +章节名称:{chapter} +知识库内容:{reference} +年级:{level} +科目:{subj} +学时:{time_1} +授课方式:{jiangshou_type} +其他要求:{other_require} +注意: +** 请在上一轮生成内容的基础上继续生成,**换行(\n\n)与之前内容区分** +** 参考{doc_content}中内容和知识库内容进行生成。 +** 不要重复生成上一轮已有的内容**,例如“一、教学目标,二、教学重点与难点,三、授课方法,四、教学过程设计”等标题,直接从当前章节对应的内容开始** +** 不得对已有内容进行更新、优化或删除等操作** +** 仅生成当前章节名称:"{chapter}"相关的内容,不能偏离。** + +** 教学内容设计要紧贴{chapter}进行生成,不能偏离。合理规划每一内容的生成顺序,循序渐进,避免重复生成。** +** 仔细读取{title}中内容和参考知识材料,“# 五、小节与作业”目标是达成教学目标、巩固学习效果需求,从而布置作业题、思考题、阅读参考资料、复习和预习等课前或课后学习任务,输出内容要紧紧贴近“章节名称”这个主题,力求清晰、明确,该章节生成的内容总字数为500字,每个子章节生成100字。** +** 严格按照以下格式生成。 + +## 格式要求如下: + +# 五、小节与作业 +## 1.作业题 + +## 2.思考题 + +## 3.阅读参考资料 + +## 4.复习 + +## 5.预习 + + +## 检查以下条件必须满足: +1. **不要思考过程,快速输出内容** +2. **严格按照给定格式生成内容** +3. **生成内容必须与本章节相关,不能重复生成与之前相同的内容,不能有解释等无关内容** +4. **在生成内容后面不要加入字数描述,例如“(200字)”** + +""" + +WRITE_LEARNING_PLAN_PROMPT_5 = """ +{title} 中内容:{doc_content} +上一轮生成的内容:{text} +章节名称:{chapter} +知识库内容:{reference} +年级:{level} +科目:{subj} +学时:{time_1} +其他要求:{other_require} +注意: +** 请在上一轮生成内容的基础上继续生成,**换行(\n\n)与之前内容区分** +** 参考{doc_content}中内容和知识库内容进行生成。 +** 不要重复生成上一轮已有的内容**,例如“一、教学目标,二、教学重点与难点,三、授课方法,四、教学过程设计,五、小节与作业”等标题,直接从当前章节对应的内容开始** +** 不得对已有内容进行更新、优化或删除等操作** +** 仅生成当前章节名称:"{chapter}"相关的内容,不能偏离。** + +** 教学内容设计要紧贴{chapter}进行生成,不能偏离。合理规划每一内容的生成顺序,循序渐进,避免重复生成。** +** “## 导入新课”内容生成字数为800字。导入新课要求将教学内容自然过渡到当前章节{chapter},为接下来的教学做准备。** +** 严格按照以下格式生成。 + +## 格式要求如下: + +# 六、教学内容 +## 1.导入新课 + + +## 检查以下条件必须满足: +1. **不要思考过程,快速输出内容** +2. **严格按照给定格式生成内容** +3. **生成内容必须与本章节相关,不能重复生成与之前相同的内容,不能有解释等无关内容** +4. **在生成内容后面不要加入字数描述,例如“(200字)”** + +""" + + + + + +## 1.{step}. {chapter} +#教案生成 +WRITE_LEARNING_PLAN_PROMPT_6 = """ +{title}中内容:{doc_content} +案例:{all_case} +上一轮生成的内容:{text} +章节名称:{chapter} +序号:{step} +数据库内容:{reference_sql} +知识库内容:{reference} +年级:{level} +科目:{subj} +学时:{time_1} +年级:{level} +其他要求:{other_require} + +注意: +** 在上一轮内容基础上继续生成,请以 `\n\n` 换行区分。** +** 不要重复生成已有内容,如“教学目标”、“教学重点”等标题。** +** 不得修改或删除已有内容。** +** 只生成章节名称:{chapter}”相关内容。** + + +** 所有内容必须基于知识库拓展,不得凭空编造。** +** 生成每个内容不能低于1000字**; +** 生成“标题”,“内容”不能重复。** +** 围绕章节名称:{chapter},结合知识库内容和数据库内容生成。** +** 如果案例不为空生成内容必须包含对应案例,使用案例不能重复** +** 必须严格按照下面格式生成:** + + + +### 2.{step}.1.标题 + +### 2.{step}.2.标题 + + + +## 检查以下条件必须满足: +1. **不要思考过程,快速输出内容** +2. **“2.{step}.1.标题”,“2.{step}.2.标题”必须有,生成标题与{chapter}相关。** +3. **严格按照给定格式生成内容** +4. **生成内容必须与本章节相关,不能重复生成与之前相同的内容,不能有解释等无关内容** +5. **在生成内容后面不要加入字数描述,例如“(1000字)”** +6. **生成完2.{step}.2.内容后结束生成,禁止生成2.{step}.3.标题和内容** +7. **必须生成“2.{step}.2.标题”的内容,不能只生成到“2.{step}.1.标题”内容就终止生成。** +8. **格式中所有“标题”字样必须替换为和{chapter}内容相关的具体标题,“内容”必须是模型参考知识库和数据库生成和{chapter}相关的内容。** +9. **如果有对应案例,生成内容必须包含对应案例,案例不能使用之前使用过的案例** +10. **强制生成内容不能重复** + +""" +# ** 必需满足生成按照上述格式要求生成,字数是否符合要求,内容与之前生成不重复这几个基本要求。 + + + +WRITE_LEARNING_PLAN_PROMPT_12 = """ +{title}中内容:{doc_content} +上一轮生成的内容:{text} +章节名称:{chapter_1} +子章节一名称:{title_1_real} +子章节一相关内容:{content_1} +序号:{step} +一序号:{step_1} +子章节一知识库内容:{reference_1} +年级:{level} +科目:{subj} +学时:{time_1} +年级:{level} +其他要求:{other_require} +注意: +** 在上一轮内容基础上继续生成,请以 `\n\n` 换行区分。** +** 不要重复生成已有内容,如“教学目标”、“教学重点”等标题。** +** 不得修改或删除已有内容。** + + +** 所有内容必须基于知识库拓展,不得凭空编造。** +** 生成每个内容不能低于1000字**; +** 生成“标题”,“内容”不能重复。** +** 2.{step}.{step_1}.{title_1_real}生成的内容参考子章节一相关内容和子章节一知识库内容进行生成** +** 如果案例不为空生成内容必须包含对应案例,使用案例不能与之前重复** +** 必须严格按照下面格式生成:** + + + +### 2.{step}.{step_1}.{title_1_real} + + + +## 检查以下条件必须满足: +1. **不要思考过程,快速输出内容** +2. **严格按照给定格式生成内容** +3. **如果有对应案例,生成内容必须包含对应案例,案例不能使用之前使用过的案例** +4. **生成内容必须与“2.{step}.{step_1}.{title_1_real}”相关,不能重复生成与之前相同的内容,不能有解释等无关内容** +5. **在生成内容后面不要加入字数描述,例如“(1000字)”** +6. **生成完“2.{step}.{step_1}.{title_1_real}”内容后结束生成,禁止生成2.{step}.2.标题和内容** +7.**强制生成内容不能重复。** + +""" + + +WRITE_LEARNING_PLAN_PROMPT_13 = """ +{title}中内容:{doc_content} +案例:{case_2} +上一轮生成的内容:{text} +章节名称:{chapter_1} +子章节二名称:{title_2_real} +子章节二相关内容:{content_2} +序号:{step} +二序号:{step_2} +子章节二知识库内容:{reference_2} +知识库内容:{reference} +年级:{level} +科目:{subj} +学时:{time_1} +年级:{level} +其他要求:{other_require} +注意: +** 在上一轮内容基础上继续生成,请以 `\n\n` 换行区分。** +** 不要重复生成已有内容,如“教学目标”、“教学重点”等标题。** +** 不得修改或删除已有内容。** +** 如果子章节二名称:{title_2_real}为空,则生成子章节二相关名称替换。** + + +** 所有内容必须基于知识库拓展,不得凭空编造。** +** 生成每个内容不能低于1000字**; +** 生成“标题”,“内容”不能重复。** +** 如果案例不为空生成内容必须包含对应案例,使用案例不能与之前重复** +** 如果{title_2_real}不为空, 2.{step}.{step_2}.{title_2_real}生成的内容参考子章节二相关内容和子章节二知识库内容进行生成** +** 如果{title_2_real}为空, 生成{title_2_real}与{chapter_1}相关,“2.{step}.{step_2}.{title_2_real}"生成的内容参考知识库内容进行生成** +** 必须严格按照下面格式生成:** + + + +### 2.{step}.{step_2}.{title_2_real} + + + +## 检查以下条件必须满足: +1. **不要思考过程,快速输出内容** +2. **严格按照给定格式生成内容** +3. **如果有对应案例,生成内容必须包含对应案例,案例不能使用之前使用过的案例** +4. **生成内容必须与“2.{step}.{step_2}.{title_2_real}”相关,不能重复生成与之前相同的内容,不能有解释等无关内容** +5. **在生成内容后面不要加入字数描述,例如“(1000字)”** +6. **生成完“2.{step}.{step_2}.{title_2_real}”内容后结束生成,禁止生成2.{step}.3.标题和内容** +7. **必须生成“2.{step}.{step_2}.{title_2_real}”的内容** +8.**如果{title_2_real}为空,必须为其生成具体标题内容。** +9.**强制生成内容不能重复。** + +""" + + + +#教案生成 +WRITE_LEARNING_PLAN_PROMPT_7 =""" +{title}中内容:{doc_content} +案例:{all_case} +上一轮生成的内容:{text} +章节名称:{chapter} +知识库内容:{reference} +年级:{level} +科目:{subj} +年级:{level} +其他要求:{other_require} + +注意: +** 在上一轮内容基础上继续生成,请以 `\n\n` 换行区分。** +** 不要重复生成已有内容,如“教学目标”、“教学重点”等标题。** +** 不得修改或删除已有内容。** +** 只生成章节名称:{chapter}”相关内容。** + + +** 所有内容必须基于知识库拓展,不得凭空编造。** +** 参考{doc_content}中内容和知识库内容进行生成。 +** 如果案例不为空生成内容必须包含对应案例,使用案例不能与之前重复** +** 生成每个内容字数范围在1000字到4000字之间**; +** 必须保证生成的内容不能重复。** +** 生成“标题”,“内容”不能重复。** +** 围绕章节名称:{chapter},结合知识库内容和数据库内容生成。** +** 必须严格按照下面格式生成:** + + + +## 2.1. 标题 +### 2.1.1.标题 + +## 2.1.2.标题 + +## 2.2.标题 +### 2.2.1.标题 + +### 2.2.2.标题 + + + +## 检查以下条件必须满足: +1.** 不要生成“第几章”,“第几节”这种字样。例如“第一章”,“第一节”这种形式的内容。** +2.** 必需满足生成按照上述格式要求生成,字数是否符合要求,内容与之前生成不重复这几个基本要求。** +3.** 禁止延展生成“2.1.1.1.”,“2.1.2.1.”,“2.2.1.1.”,“2.2.2.1.”,“2.2.3.”,“2.3.”,等标题内容,必须按照上述固定格式内容输出。** +4.** 不要生成额外的解释。** +5.** 必须要从“2.1.标题”开始生成到“2.2.2.”相关内容结束。** +6.** “2.1.标题”和“2.1.1.标题”之间不能有内容。** +7.** “2.2.标题”和“2.2.1.标题”之间不能有内容。** +8.** 生成每个内容字数不能低于1000字** +9.** 格式中所有“标题”字样必须替换为和{chapter}内容相关的具体标题,“内容”必须是模型生成和{chapter}相关的内容。** +10.** 保证标题间的所属关系。** +11.** 强制生成内容不能重复** +12.**禁止生成2.2.3.标题*和2.3.标题** +13.**如果有对应案例,生成内容必须包含对应案例,案例不能使用之前使用过的案例** +14.**生成内容不能重复,如果有重复内容重新输出** +15.**不要思考过程,快速输出内容** +""" + +WRITE_LEARNING_PLAN_PROMPT_8 =""" +{title}中内容:{doc_content} +案例:{all_case} +上一轮生成的内容:{text} +章节名称:{chapter} +知识库内容:{reference} +年级:{level} +科目:{subj} +年级:{level} +其他要求:{other_require} + +注意: +** 在上一轮内容基础上继续生成,请以 `\n\n` 换行区分。** +** 参考{doc_content}中内容和知识库内容进行生成。 +** 不要重复生成已有内容,如“教学目标”、“教学重点”等标题。** +** 不得修改或删除已有内容。** +** 只生成章节名称:{chapter}”相关内容。** + + +** 所有内容必须基于知识库拓展,不得凭空编造。** +** 生成每个内容字数范围在1000字到4000字之间** +** 如果案例不为空生成内容必须包含对应案例,使用案例不能与之前重复** +** 生成“标题”,“内容”不能重复。** +** 必须保证生成的内容不能重复。** +** 围绕章节名称:{chapter},结合知识库内容和数据库内容生成。** +** 必须严格按照下面格式生成:** + + + +## 2.3. 标题 +### 2.3.1.标题 + +### 2.3.2.标题 + +## 2.4.标题 +### 2.4.1.标题 + +### 2.4.2.标题 + + + +## 检查以下条件必须满足: +1.** 不要生成“第几章”,“第几节”这种字样。例如“第一章”,“第一节”这种形式的内容。** +2.** 必需满足生成按照上述格式要求生成,字数是否符合要求,内容与之前生成不重复这几个基本要求。** +3.** 禁止延展生成“2.3.1.1.”,“2.3.2.1.”,“2.4.1.1.”,“2.4.2.1.”,“2.4.3.”,“2.5.”等标题内容,必须按照上述固定格式内容输出。** +4.** 不要生成额外的解释。** +5.** 生成每个内容字数不能低于1000字** +6.** 必须要从“2.3.标题”开始生成到“2.4.2.”相关内容结束。** +7.** “2.3.标题”和“2.3.1.标题”之间不能有内容,“2.4.标题”和“2.4.1.标题”之间不能有内容。** +8.** 格式中所有“标题”字样必须替换为和{chapter}内容相关的具体标题,“内容”必须是模型生成和{chapter}相关的内容。** +9.** 保证标题间的所属关系。** +10. **强制生成内容不能重复** +11. **禁止生成2.4.3.标题*和2.5.标题** +12. **如果有对应案例,生成内容必须包含对应案例,案例不能使用之前使用过的案例** +14.**生成内容不能重复,如果有重复内容重新输出** +15. **不要思考过程,快速输出内容** +""" + + + + +# 备份 +# WRITE_LEARNING_PLAN_PROMPT_12 = """ +# {title}中内容:{doc_content} +# 上一轮生成的内容:{text} +# 章节名称:{chapter_1} +# 子章节一名称:{title_1_real} +# 子章节二名称:{title_2_real} +# 子章节一相关内容:{content_1} +# 子章节二相关内容:{content_2} +# 序号:{step} +# 一序号:{step_1} +# 二序号:{step_2} +# 子章节一知识库内容:{reference_1} +# 子章节二知识库内容:{reference_2} +# 年级:{level} +# 科目:{subj} +# 学时:{time_1} +# 年级:{level} +# 其他要求:{other_require} +# 注意: +# ** 在上一轮内容基础上继续生成,请以 `\n\n` 换行区分。** +# ** 不要重复生成已有内容,如“教学目标”、“教学重点”等标题。** +# ** 不得修改或删除已有内容。** +# ** 如果子章节二名称:{title_2_real}为空,则生成子章节二相关名称替换。** + +# +# ** 所有内容必须基于知识库拓展,不得凭空编造。** +# ** 生成每个内容不能低于1000字**; +# ** 禁止出现公式、图表编号等非文本内容。** +# ** 生成“标题”,“内容”不能重复。** +# ** 2.{step}.{step_1}.{title_1_real}生成的内容参考子章节一相关内容和子章节一知识库内容进行生成** +# ** 如果{title_2_real}不为空, 2.{step}.{step_1}.{title_2_real}生成的内容参考子章节二相关内容和子章节二知识库内容进行生成** +# ** 如果{title_2_real}为空, 生成{title_2_real}与{title_1_real}相关,“2.{step}.{step_2}.{title_2_real}"生成的内容参考子章节一相关内容和子章节一知识库内容进行生成** +# ** 必须严格按照下面格式生成:** +# + +# +# ### 2.{step}.{step_1}.{title_1_real} + +# ### 2.{step}.{step_2}.{title_2_real} + +# + +# ## 检查以下条件必须满足: +# 1. **不要思考过程,快速输出内容** +# 2. **严格按照给定格式生成内容** +# 3. **生成内容必须与“2.{step}.{step_1}.{title_1_real}”和“2.{step}.{step_1}.{title_2_real}”相关,不能重复生成与之前相同的内容,不能有解释等无关内容** +# 4. **在生成内容后面不要加入字数描述,例如“(1000字)”** +# 5. **生成完“2.{step}.{step_2}.{title_2_real}”内容后结束生成,禁止生成2.{step}.3.标题和内容** +# 6. **必须生成“2.{step}.{step_2}.{title_2_real}”的内容,不能只生成到“2.{step}.{step_1}.{title_1_real}”内容就终止生成。** +# 7.**如果{title_2_real}为空,必须为其生成具体标题内容。** +# 8.**强制生成内容不能重复。** +# +# """ + + + +# WRITE_LEARNING_PLAN_PROMPT_8 = """ +# {title}中内容:{doc_content} +# 上一轮生成的内容:{content} +# 章节名称:{chapter} +# 知识库内容:{reference} +# 年级:{level} +# 科目:{subj} +# 其他要求:{other_require} + +# 根据上一轮生成的内容继续生成参考知识库内容生成只与当前章节相关内容。字数不少于1000字,不要在后面写“(1000字)” +# ** 注意: +# 1. **严格按照上一轮生成的格式继续生成。不能对格式有任何修改。 +# 2. **只在三级标题生成内容,其他标题只生成标题。 +# 3. **生成的三级标题必须要有两个。不能多也不能少。 +# 4. **强制内容生成从“## 2. 一级标题”开始生成,到“## 2.1.2. 三级标题 三级标题相关内容”结束。不能生成其他额外内容。 +# 5. **最终检查是否符合标题层级、内容长度、格式结构等要求**; +# 6. **确保不添加额外无关内容,如“六、教学内容(续)”等**; +# 7. **确认三级标题与相关内容之间存在换行,整体输出严格遵循给定格式**; +# 8. **强制内容生成从“## 1.3. 二级标题”开始生成,到“## 1.3.2.. 三级标题 三级标题相关内容”结束。不能生成其他额外内容。 +# 9. **生成每个三级标题相关内容不能低于1000字**; +# 10. **生成内容不能与之前内容重复**; +# 11. **不能生成“## 1.4 二级标题”,“## 1.3.3. 三级标题”等,生成到“## 1.3.2. 三级标题 三级标题相关内容”就结束**; +# 12. **生成内容一定要根据章节名称:{chapter}到知识库中检索强相关内容。 +# +# ## 1.3. 二级标题 +# ## 1.3.1.三级标题 +# 三级标题相关内容 +# ## 1.3.2. 三级标题 +# 三级标题相关内容 +# +# *** 检查如下条件必须满足: +# ** 开始生成时“## 1.3. 二级标题”二级标题要紧接上一轮生成的内容,继续生成。 +# ** 生成内容一定要与章节名称{chapter}所检索到到的知识库内容相关。 +# ** 不能直接生成“1.3. 二级标题”,“1.3.1 三级标题”,“三级标题相关内容”等字样,要把“二级标题”,“三级标题”替换为真正与当前章节内容相关的标题。“三级标题相关内容”为真正与本章节相关的内容。 +# ** 必需满足生成按照上述格式要求生成,字数是否符合要求,内容与之前生成不重复这几个基本要求。 +# ** 不能生成“## 1.4. 标题”,“## 1.3.3. 标题”,“1.3.1.1.标题 相关内容,1.3.1.2.标题 相关内容”等**; +# ** 一定要从生从“## 1.3. 二级标题”开始生成到“## 1.3.2. 三级标题 三级标题相关内容”就结束 +# ** 不要生成额外的解释。 +# ** 一定不要生成和之前相同的内容。 +# +# """ + + +WRITE_LEARNING_PLAN_PROMPT_9 = """ +{title}中内容:{doc_content} +上一轮生成的内容:{text} +章节名称:{chapter} +知识库内容:{reference} +年级:{level} +科目:{subj} +其他要求:{other_require} + +注意: +** 在上一轮内容基础上继续生成,请以 `\n\n` 换行区分。** +** 参考{doc_content}中内容和知识库内容进行生成。 +** 不要重复生成已有内容,如“教学目标”、“教学重点”等标题。** +** 不得修改或删除已有内容。** +** 只生成当前章节:“{chapter}”相关内容。** + +** 所有内容必须基于知识库拓展,不得凭空编造。** +** 生成每个内容不能低于500字**; +** 围绕当前章节:“{chapter}”,结合知识库生成内容。** +** 输出必须从以下格式开始并完整结束:** + + +## 3.课堂小结 +## 3.1.当前章节回顾 +### 3.1.1.知识点总结 + +### 3.1.2. 重点难点梳理 + +## 4.课后作业 +## 4.1. 当前章节作业安排 +### 4.1.1. 巩固练习 + +### 4.1.2. 拓展任务 + + + +## 检查以下条件必须满足: +1. **不要思考过程,快速输出内容** +2. **严格按照给定格式生成内容** +3. **生成内容必须与本章节相关,不能重复生成与之前相同的内容,不能有解释等无关内容** +4. **在生成内容后面不要加入字数描述,例如“(200字)”** + +""" + + + + + +WRITE_LEARNING_PLAN_PROMPT_10 = """ +{title}中内容:{doc_content} +上一轮生成的内容:{content} +章节名称:{chapter} +知识库内容:{reference} +年级:{level} +科目:{subj} +教学形式:{jiangshou_type} +其他要求:{other_require} +注意: +** 在上一轮生成的内容基础上继续生成,开始生成的时候请换行(\n\n)与上一轮生成区分。 +** 参考{doc_content}中内容和知识库内容进行生成。 +** 不要生成与上一轮生成内容相同的东西。例如“一、教学目标,二、教学重点与难点,三、授课方式与手段,四、教学过程设计,五、小节与作业”直接继续生成第六章内容。 +** 不要对上一轮生成内容进行任何操作(更新,优化,删除等)。 +** 只生成当前章节名称:{chapter}相关内容。 + +** 确保所有内容必须基于知识库内容进行拓展,不能脱离知识库凭空编造。 +** 确保内容要关注起承转合,教学活动衔接自然,必要时可加入表格对比知识点。 +** 确保当前生成的内容围绕当前章节:{chapter}检索知识库内容进行生成。 +**不要思考过程,快速输出内容** + + +如果授课方式为实作授课,生成的内容总字数不得少于4000个汉字,每一部分参考知识库内容生成尽量详细,。在生成内容后面不要加入字数描述,例如“(300字),输出形式为: + +# 六、教学内容 +## 场景二:{jiangshou_type} +### 1.教学准备 +### 2.下达任务 +### 3.讲解示范 +### 4.组织训练 +### 5.总结点评 +### 6.小节与作业 +### 7.教学后记 + + +""" +WRITE_LEARNING_PLAN_PROMPT_11 = """ +{title}中内容:{doc_content} +上一轮生成的内容:{text} +章节名称:{chapter} +知识库内容:{reference} +年级:{level} +科目:{subj} +其他要求:{other_require} +学时:{time_1} +注意: +** 在上一轮生成的内容基础上继续生成,开始生成的时候请换行(\n\n)与上一轮生成区分。 +** 参考{doc_content}中内容和知识库内容进行生成。 +** 不要重复生成上一轮已有的内容**,例如“一、教学目标,二、教学重点与难点,三、授课方法,四、教学过程设计,五、小节与作业,六、教学内容”等标题,直接从当前章节对应的内容开始** +** 不要对上一轮生成内容进行任何操作(更新,优化,删除等)。 +** 只生成当前章节名称:{chapter}相关内容。 +** 必须要包含下面所有格式的内容。 + +** 强制确保标题和内容之间有换行“\n\n”。** +** 填写主要教学环节、教学设计意图、以及教学时间分配相关内容** +** 填写重难知识点讲授策略、解析方法等相关内容** +** 填写板书具体设计与书写时机相关内容** +** 填写思政具体设计与融入方法相关内容** +** 填写注意事项。如:起承转合时机、教学活动关注要点、注意调整授课节奏等相关内容。** +** 教学时间分配分配考虑学时{time_1},如果{time_1}为一学时,时间为45分钟。如果为二学时,时间为90分钟,以此类推。** +** “七、备注”章节生成的内容总字数为700字,每个子章节生成100字左右。** + + +# 七、备注 +## 1、教学环节 + +## 2、教学设计意图 + +## 3、教学时间分配 + +## 4、重难知识点讲授策略及解析方法 + +## 5、板书具体设计与书写时机 + +## 6、思政具体设计与融入方法 + +## 7、注意事项 + + + +** 课堂表现评估内容为总结学员在课堂上的参与度,包括是否积极回答问题、参与小组讨论等。对学员的发言进行评价,看其对知识的理解程度和分析问题的能力。** +** 作业评估内容根据学生提交的课后作业,对论文的质量、资料简报的内容完整性和准确性等方面进行评分。通过作业评估,了解学员对本节课知识的掌握情况和自主学习能力,发现教学中存在的不足之处,以便在今后的教学中进行改进。** +** “八、教学后记”章节生成字数为300字,每个子章节150字左右。** + + +# 八、教学后记 +## 1、课堂表现评估 + +## 2、作业评估 + + +## 检查以下条件必须满足: +1. **不要思考过程,快速输出内容** +2. **严格按照给定格式生成内容** +3. **生成内容必须与本章节相关,不能重复生成与之前相同的内容,不能有解释等无关内容** +4. **在生成内容后面不要加入字数描述,例如“(200字)”** + +""" + + +REFERENCE_CASE_FIND="""你是一案例查找专家,擅长根据给定的文本查找与标题相关的案例。现在有文本内容{case_content}和标题{case_title}如下: +文本内容:{case_content} +标题:{case_title} +请根据文本内容和标题按照以下要求查找案例: +** 1. 找到的案例必须与标题相关且包含具体内容,案例内容越丰富越好。 +** 2. 案例包含一些经典应用和基于教材参数的典型应用。 +** 3. 案例包含一些场景,系统配置,影响和分析等。 +** 4. 不能编造案例。 +** 5. 不需要输出解释等无关内容。 +** 6. 如果没有案例输出“没有相关案例”。 +** 7. 找到相关案例输出格式如下: +**输出格式: +案例1:"......" +案例2:"......" +...... +""" + + + + + + + + + + + +#教案生成 +# WRITE_LEARNING_PLAN_PROMPT_8 = """ +# {title}中内容:{doc_content} +# 上一轮生成的内容:{text} +# 章节名称:{chapter} +# 知识库内容:{reference} +# 年级:{level} +# 科目:{subj} +# 其他要求:{other_require} + +# 注意: +# ** 在上一轮内容基础上继续生成,请以 `\n\n` 换行区分。** +# ** 不要重复生成已有内容,如“教学目标”、“教学重点”等标题。** +# ** 不得修改或删除已有内容。** +# ** 只生成当前章节:“{chapter}”相关内容。** +# +# ** 所有内容必须基于知识库拓展,不得凭空编造。** +# ** 生成每个内容不能低于500字**; +# ** 禁止出现公式、图表编号等非文本内容。** +# ** 围绕当前章节:“{chapter}”,结合知识库生成内容。** +# ** 输出必须从以下格式开始并完整结束:** +# +# +# ## 课堂小结 +# ## 1. 课堂小结 +# ## 1.1. 当前章节回顾 +# ## 1.1.1. 知识点总结 +# 内容 +# ## 1.1.2. 重点难点梳理 +# 内容 +# ## 课后作业 +# ## 1. 课后作业 +# ## 1.1. 当前章节作业安排 +# ## 1.1.1. 巩固练习 +# 内容 +# ## 1.1.2. 拓展任务 +# 内容 +# + +# ## 检查以下条件必须满足: +# 1. **不要思考过程,快速输出内容** +# 2. **严格按照给定格式生成内容** +# 3. **生成内容必须与本章节相关,不能重复生成与之前相同的内容,不能有解释等无关内容** +# 4. **在生成内容后面不要加入字数描述,例如“(200字)”** +# +# """ + + + + + +# WRITE_LEARNING_PLAN_PROMPT_6 = """ +# {title}中内容:{doc_content} +# 上一轮生成的内容:{text} +# 章节名称:{chapter} +# 知识库内容:{reference} +# 年级:{level} +# 科目:{subj} +# 其他要求:{other_require} +# 学时:{time} +# 注意: +# ** 在上一轮生成的内容基础上继续生成,开始生成的时候请换行(\n\n)与上一轮生成区分。 +# ** 不要重复生成上一轮已有的内容**,例如“一、教学目标,二、教学重点与难点,三、授课方法,四、教学过程设计,五、小节与作业,六、教学内容”等标题,直接从当前章节对应的内容开始** +# ** 不要对上一轮生成内容进行任何操作(更新,优化,删除等)。 +# ** 只生成当前章节名称:{chapter}相关内容。 +# ** 必须要包含下面所有格式的内容。 +# +# ** 强制确保标题和内容之间有换行“\n\n”。** +# ** 填写主要教学环节、教学设计意图、以及教学时间分配相关内容** +# ** 填写重难知识点讲授策略、解析方法等相关内容** +# ** 填写板书具体设计与书写时机相关内容** +# ** 填写思政具体设计与融入方法相关内容** +# ** 填写注意事项。如:起承转合时机、教学活动关注要点、注意调整授课节奏等相关内容。** +# ** 教学时间分配分配考虑学时{time},如果{time}为一学时,时间为45分钟。如果为二学时,时间为90分钟,以此类推。** +# ** “七、备注”章节生成的内容总字数为700字,每个子章节生成100字左右。** +# +# +# # 七、备注 +# ## 1、教学环节 + +# ## 2、教学设计意图 + +# ## 3、教学时间分配 + +# ## 4、重难知识点讲授策略及解析方法 + +# ## 5、板书具体设计与书写时机 + +# ## 6、思政具体设计与融入方法 + +# ## 7、注意事项 + +# +# +# ** 课堂表现评估内容为总结学员在课堂上的参与度,包括是否积极回答问题、参与小组讨论等。对学员的发言进行评价,看其对知识的理解程度和分析问题的能力。** +# ** 作业评估内容根据学生提交的课后作业,对论文的质量、资料简报的内容完整性和准确性等方面进行评分。通过作业评估,了解学员对本节课知识的掌握情况和自主学习能力,发现教学中存在的不足之处,以便在今后的教学中进行改进。** +# ** “八、教学后记”章节生成字数为300字,每个子章节150字左右。** +# +# +# # 八、教学后记 +# ## 1、课堂表现评估 + +# ## 2、作业评估 + +# +# ## 检查以下条件必须满足: +# 1. **不要思考过程,快速输出内容** +# 2. **严格按照给定格式生成内容** +# 3. **生成内容必须与本章节相关,不能重复生成与之前相同的内容,不能有解释等无关内容** +# 4. **在生成内容后面不要加入字数描述,例如“(200字)”** +# +# """ + + + + + +WRITE_PROJECT_PROPOSAL_PROMPT = """你现在是一个开题报告编写专家,请帮我生成一个主题为{title}的开题报告,开题报告的输出形式参考我给你的开题报告模版,生成的内容要准确、清晰无误,同时满足以下要求: + 1.研究目的要阐述清楚,为什么要开展{title}的研究,其最终目的是什么。 + 2.研究意义中,需要介绍在怎样的背景下,研究对象是什么,为什么要研究这个选题,然后阐明选题着重解决哪些问题(讨论范围)。先写宏观背景,然后介绍企业背景,然后引入到本文的研究。从理论意义和实践意义两个角度开展叙述。 + 3.研究现状要从国内和国外现状开展阐述,结合当下最新的研究,进行详细的分析并生成国内外研究现状。 + 4.章节研究目标、内容(论文提纲)及拟解决关键问题,从研究目标、研究内容、拟解决问题和论文提纲开展阐述,其中研究目标要清晰明确,研究内容需要说清楚研究哪些内容,论文提纲可以使用多个多级标题。 + 5.研究方法及技术路线,从研究方法和具体实现的技术路线开展研究,要准确,贴近主题,根据给出的大纲内容进行生成,同时可以适当加入一些流程图和表格。 + 6.主要参考文献及出处,请给出与{title}相关的参考文献,同时给出对应的论文引用方式 + 7.研究进度时间安排表,请输出一个表格,记录整个论文的时间和工作安排 + 8.整个开题报告的字数为{length}左右,同时会给你一些参考资料,请根据参考资料的内容,进行上述{title}的开题报告编写。请尽量多生成一些内容,保证字数。 + 9.其中,在满足上述要求的同时,如果还存在其他要求,也要一并兼顾考虑;如果没有,则根据上述要求编写开题报告即可。 + 10.生成内容时,进行换行时,模版中每一行生成后,用两个换行“\n\n”进行换行,不要使用一个“\n”。 + 11.其中“姓名:”、“学号:”和“论文题目:”与“生成内容”中间不要进行添加“\n”,作为同一行输出,请严格遵循该要求。 + 12. 如果完成教案内容的编写,在编写的内容之后加入"end";否则,内容不能一次性完全输出的情况下,不要加入“end" + 其中,开题报告模版如下: + +姓名:生成内容 + +学号:生成内容 + +论文题目:生成内容 + +# 一、选题的背景和意义(含国内外研究现状综述) +## (一)研究目的 +## (二)研究意义 +### 1.理论意义 +### 2.实践意义 +## (三)研究现状 +### 1.国内研究现状 +### 2.国外研究现状 +# 二、研究目标、内容(论文提纲)及拟解决关键问题 +## (一)研究目标 +## (二)研究内容 +## (三)拟解决问题 +## (四)论文提纲 + - 1. 一级标题 + - 1.1. 二级标题 + - 1.1.1. 三级标题 + - 1.1.2. 三级标题 + - 1.2. 二级标题 + - 1.3. 二级标题 + - 2. 一级标题 + ..... +# 三、研究方法及技术路线 +## (一)研究方法 +## (二)技术路线 +# 四、主要参考文献及出处 + + +(1)Amesim 机电一体化仿真教程[M]. 梁全,谢基晨,聂利卫.机械工业出版社.2021. + + +(2)信息与文献参考文献著录规则[M]. 梁全,谢基晨,聂利卫.机械工业出版社.2021. + + + ..... + + +# 五、研究进度时间安排表 + + + 其他要求如下:{other_require} + 参考资料如下:{reference} +""" + + +TEXT_CUT= """你是一位专业文案大师,擅长对文本进行精简,即对选中文本进行精简缩写。请根据用户提供的文本,在保持原意的基础上进行精简,要求满足以下条件: +1. 首先仔细阅读给出的文本内容,分析并理解其内容和意境。 +2. 仔细分析原文的结构和用词,对选择的文本内容进行精简。 +3. 确保精简后的文本保留文本中原有元素,但用更简洁的语言表达。 +4. 要确保整体通顺,不丢失原意,同时符合书面表达,去除冗余词汇。 +5. 禁止使用Markdown,不添加无关内容,保持纯文本风格,在精简过程中,精简后的内容都紧扣原文,同时要无缝衔接,不能脱离中心思想。 +6. 通读精简后的文本,确保没有语法错误,确保整体段落结构清晰,逻辑连贯。 +7. 如果有其他要求,则继续考虑到该要求进行精简,如果没有,则保持上述要求进行精简。 +8. 请将根据上述要求精简后的内容直接输出,不要输出额外的信息描述。 +9. 精简后字数控制在100字左右。 +10.不能生成其他额外的内容。只将精简后的内容进行输出。 +文本内容如下:{content} +""" + diff --git a/fileparse_util.py b/fileparse_util.py new file mode 100644 index 0000000..723d7cb --- /dev/null +++ b/fileparse_util.py @@ -0,0 +1,168 @@ +import os +import json +import base64 +import asyncio +import logging +from typing import Optional, Tuple, Dict +import aiofiles # pip install aiofiles + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def find_and_read_content_list( + directory: str, + original_filename: str, + encoding: str = 'utf-8' +) -> Tuple[Optional[list], Optional[str]]: + """根据原始文件名查找并读取对应的 _content_list.json 文件。""" + file_name, _ = os.path.splitext(original_filename) + target_filename = f"{file_name}_content_list.json" + logger.info(f"正在查找文件: {target_filename}") + + # os.walk 没有原生 async 版本,放到线程里执行避免阻塞事件循环 + def _walk_for_file(): + for root, _, files in os.walk(directory): + if target_filename in files: + return os.path.join(root, target_filename) + return None + + file_path = await asyncio.to_thread(_walk_for_file) + if file_path is None: + return None, None + + try: + async with aiofiles.open(file_path, 'r', encoding=encoding) as f: + text = await f.read() + # JSON 解析是 CPU 操作,大文件可考虑放线程 + return json.loads(text), file_path + except json.JSONDecodeError: + logger.error(f"文件 {file_path} 不是有效的 JSON 格式。") + return None, None + except Exception as e: + logger.error(f"读取文件 {file_path} 时发生错误: {e}") + return None, None + + +def extract_all_jpg_filenames(doc_data: list) -> set: + """从文档数据中提取所有 .jpg 图片的纯文件名。纯 CPU 操作,不需要 async。""" + result_set = set() + for item in doc_data: + if 'img_path' in item and item['img_path']: + img_path = item['img_path'] + if img_path.endswith('.jpg'): + result_set.add(os.path.basename(img_path)) + return result_set + + +async def _encode_one_image( + img_file: str, + local_image_dir: str, + mime_map: dict, +) -> Tuple[str, Optional[str]]: + """编码单张图片,返回 (文件名, data_uri 或 None)。""" + img_path = os.path.join(local_image_dir, img_file) + if not os.path.isfile(img_path): + logger.warning(f"图片不存在: {img_path}") + return img_file, None + + try: + async with aiofiles.open(img_path, "rb") as f: + img_bytes = await f.read() + # base64 编码是 CPU 操作,小文件直接做即可;大文件可放线程 + img_base64 = base64.b64encode(img_bytes).decode('utf-8') + ext = img_file.lower().rsplit('.', 1)[-1] + mime_type = f"image/{mime_map.get(ext, 'png')}" + return img_file, f"data:{mime_type};base64,{img_base64}" + except Exception as e: + logger.error(f"编码图片失败 {img_file}: {str(e)}") + return img_file, None + + +async def encode_images_to_base64( + local_image_dir: str, + referenced_images: set, + concurrency: int = 32, +) -> Dict[str, str]: + """并发将引用的图片编码为 base64。""" + if not os.path.exists(local_image_dir) or not referenced_images: + return {} + + mime_map = {'jpg': 'jpeg', 'jpeg': 'jpeg', 'png': 'png', 'gif': 'gif', 'webp': 'webp'} + + # 用 Semaphore 限制并发数,避免一次性打开几百个文件句柄 + sem = asyncio.Semaphore(concurrency) + + async def _bounded(img_file: str): + async with sem: + return await _encode_one_image(img_file, local_image_dir, mime_map) + + results = await asyncio.gather(*(_bounded(f) for f in referenced_images)) + return {name: data for name, data in results if data is not None} + + +async def process_document( + input_file_name: str, + search_dir: Optional[str] = "/app/mineru_output", + local_image_dir: Optional[str] = None, + concurrency: int = 32, +) -> Optional[Tuple[list, Dict[str, str]]]: + """ + 异步处理文档:查找 content_list.json,提取图片引用,并发编码为 base64。 + + Args: + input_file_name: 原始文件名(如 "xxx.pdf") + search_dir: 搜索 _content_list.json 的根目录 + local_image_dir: 图片所在目录,None 则自动推断 + concurrency: 图片编码的并发数上限,默认 32 + + Returns: + 成功: (content_list, images_dict) + 失败: None + """ + content, content_path = await find_and_read_content_list(search_dir, input_file_name) + if content is None: + logger.error("❌ 未找到指定的 _content_list.json 文件。") + return None + + logger.info(f"✅ 找到文件: {content_path}") + logger.info(f"内容类型: {type(content).__name__}, 长度: {len(str(content))}") + + if local_image_dir is None: + local_image_dir = os.path.join(os.path.dirname(content_path), "images") + logger.info(f"图片目录: {local_image_dir}") + + referenced_images = extract_all_jpg_filenames(content) + logger.info(f"引用图片数量: {len(referenced_images)}") + + images_dict = await encode_images_to_base64( + local_image_dir, referenced_images, concurrency=concurrency + ) + logger.info(f"成功编码 {len(images_dict)} 张图片") + + return content, images_dict + + +async def main(): + result = await process_document( + input_file_name="163-06A0014-B01001_发动机-维修手册.pdf", + search_dir="/app/mineru_output", + concurrency=32, + ) + + if result is None: + return 1 + + content, images_dict = result + print(f"\n=== 处理完成 ===") + print(f"文档段落数: {len(content)}") + print(f"图片数量: {len(images_dict)}") + if images_dict: + first_key = next(iter(images_dict)) + print(f"示例图片 [{first_key}]: {images_dict[first_key][:80]}...") + return 0 + + +if __name__ == "__main__": + exit_code = asyncio.run(main()) + exit(exit_code) diff --git a/main_agent.py b/main_agent.py new file mode 100644 index 0000000..3ba9199 --- /dev/null +++ b/main_agent.py @@ -0,0 +1,249 @@ +""" +主脑Agent - 简化版(无意图分类) + +职责: +1. 输入处理:图片、音频、文本提取 +2. 直接路由:根据外部传入的 route_flag 决定调用哪个工作流 + +工作流列表(来自 workflow_registry): +- 故障排查与修理 +- 舰船百科 +- 操作使用 +""" + +from typing import TypedDict, Literal, Optional, Dict, Any, List +from langgraph.graph import StateGraph, START, END +from tools.function_tool import ( + vlm_image_to_text, + asr_audio_to_text, + detect_input_type +) +from modelsAPI.model_api import OpenaiAPI +from utils.function_tracker import track_function_calls +from workflow_registry import VALID_ROUTE_FLAGS +import json +import re + + +class MainAgentState(TypedDict): + """主脑Agent状态""" + raw_input: Dict[str, Any] + has_image: bool + has_audio: bool + has_query: bool + has_file_text: bool + input_type: str + + history_message: str + extracted_text: str + image_description: str + asr_text: str + file_text: str + combined_query: str + + workflow_result: Optional[Dict[str, Any]] + final_response: str + error_message: str + + route_flag: str + route_params: Dict[str, Any] + + +def detect_input_type_node(state: MainAgentState) -> Dict[str, Any]: + """检测输入类型""" + raw = state["raw_input"] + print(raw) + print(22222222222222222222222222) + result = detect_input_type.invoke({"raw_input": raw}) + + has_image = result.get("has_image", False) + has_audio = result.get("has_audio", False) + has_query = result.get("has_query", False) + + has_file_text = False + file_text_v = raw.get("file_text") + if isinstance(file_text_v, str) and file_text_v.strip(): + has_file_text = True + + input_type = "mixed" + count = sum([has_image, has_audio, has_query, has_file_text]) + if count == 1: + if has_image: + input_type = "image" + elif has_audio: + input_type = "audio" + elif has_query: + input_type = "text" + elif has_file_text: + input_type = "file" + + return { + "has_image": has_image, + "has_audio": has_audio, + "has_query": has_query, + "has_file_text": has_file_text, + "input_type": input_type, + } + + +async def process_multimodal_input(state: MainAgentState) -> Dict[str, Any]: + """处理多模态输入""" + has_image = state.get("has_image", False) + has_audio = state.get("has_audio", False) + has_query = state.get("has_query", False) + has_file_text = state.get("has_file_text", False) + raw = state["raw_input"] + + image_description = "" + asr_text = "" + extracted_text = "" + + try: + if has_image: + image_path = raw.get("image", "") + if image_path: + try: + image_description = await vlm_image_to_text.ainvoke({ + "image_path": image_path + }) + if not image_description or image_description.startswith("VLM 调用失败"): + image_description = "" + except Exception: + image_description = "" + + if has_audio: + audio_path = raw.get("audio", "") + if audio_path: + try: + asr_text = await asr_audio_to_text.ainvoke({ + "audio_path": audio_path + }) + if not asr_text or asr_text.startswith("ASR 调用失败"): + asr_text = "" + except Exception: + asr_text = "" + + text_parts = [] + if raw.get("query"): + text_parts.append(raw["query"]) + if image_description: + text_parts.append(f"[图片描述] {image_description}") + if asr_text: + text_parts.append(f"[语音转文字] {asr_text}") + if raw.get("file_text"): + text_parts.append(f"[文件内容] {raw['file_text']}") + + extracted_text = "\n".join(text_parts) if text_parts else "" + combined_query = raw.get("query", "") or extracted_text + + return { + "extracted_text": extracted_text, + "image_description": image_description, + "asr_text": asr_text, + "combined_query": combined_query, + "file_text": raw.get("file_text", ""), + } + + except Exception as e: + return { + "error_message": f"输入处理失败: {str(e)}", + "extracted_text": "", + } + +# @track_function_calls +def route_to_workflow(state: MainAgentState) -> Dict[str, Any]: + """ + 🤖分析意图,调用相关智能体 + """ + route_flag = state.get("route_flag", "问题排查") + + if route_flag not in VALID_ROUTE_FLAGS: + route_flag = "问题排查" + + query_text = state.get("extracted_text", "") + combined_query = state.get("combined_query", "") or query_text + + raw_input = state.get("raw_input", {}) + history_message = raw_input.get("history_message", "") + + route_params = { + "extracted_text": query_text, + "combined_query": combined_query, + "history_message": history_message, + } + + if raw_input.get("file_text"): + route_params["file_text"] = raw_input["file_text"] + + print(f"[主脑Agent] 路由到: {route_flag}") + print(f"[主脑Agent] 参数: combined_query={combined_query[:100]}...") + + if route_flag == "问题排查": + detail = "正在调用问题排查智能体" + elif route_flag == "百科问答": + detail = "正在调用百科问答智能体" + elif route_flag == "操作使用": + detail = "正在调用操作使用智能体" + elif route_flag == "统计": + detail = "正在调用统计智能体" + else: + detail = "正在调用通用智能体" + + return { + "route_flag": route_flag, + "route_params": route_params, + "__detail__": detail, + } + + +def create_main_agent(): + """创建主脑Agent""" + workflow = StateGraph(MainAgentState) + + workflow.add_node("detect_input_type", detect_input_type_node) + workflow.add_node("process_input", process_multimodal_input) + workflow.add_node("route", route_to_workflow) + + workflow.add_edge(START, "detect_input_type") + workflow.add_edge("detect_input_type", "process_input") + workflow.add_edge("process_input", "route") + workflow.add_edge("route", END) + + return workflow.compile() + + +from workflows.history_manager import filter_image_urls + + +async def extract_conversation_title(content: str) -> str: + """提取对话标题""" + try: + prompt = f"请从以下对话内容中提取一个简短的标题(不超过20字):\n\n{content[:500]}" + response = await OpenaiAPI.open_api_chat_without_thinking( + query=prompt, + model=None, + system_prompt="你是一个标题提取助手,请从对话内容中提取一个简短的标题。", + messages=[] + ) + title = response.strip() if response else "新对话" + if len(title) > 30: + title = title[:30] + "..." + return title + except Exception: + return "新对话" + + +if __name__ == "__main__": + import asyncio + + async def test(): + print("测试主脑Agent") + agent = create_main_agent() + result = await agent.ainvoke({ + "raw_input": {"query": "101舰主机发生异响"}, + "route_flag": "故障排查与修理" + }) + print(f"结果: {result}") + + asyncio.run(test()) + diff --git a/modelsAPI/__init__.py b/modelsAPI/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modelsAPI/__pycache__/__init__.cpython-310.pyc b/modelsAPI/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..7ce8c18 Binary files /dev/null and b/modelsAPI/__pycache__/__init__.cpython-310.pyc differ diff --git a/modelsAPI/__pycache__/__init__.cpython-312.pyc b/modelsAPI/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..4335e95 Binary files /dev/null and b/modelsAPI/__pycache__/__init__.cpython-312.pyc differ diff --git a/modelsAPI/__pycache__/model_api.cpython-310.pyc b/modelsAPI/__pycache__/model_api.cpython-310.pyc new file mode 100644 index 0000000..f433fd5 Binary files /dev/null and b/modelsAPI/__pycache__/model_api.cpython-310.pyc differ diff --git a/modelsAPI/__pycache__/model_api.cpython-312.pyc b/modelsAPI/__pycache__/model_api.cpython-312.pyc new file mode 100644 index 0000000..32ffdc6 Binary files /dev/null and b/modelsAPI/__pycache__/model_api.cpython-312.pyc differ 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/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/prompts.py b/prompts.py new file mode 100644 index 0000000..feb7426 --- /dev/null +++ b/prompts.py @@ -0,0 +1,1240 @@ +""" +提示词集中管理模块 +将各工作流中的提示词统一提取到此处,方便调整和维护。 +每个提示词都附有详细注释说明其用途和所属工作流节点。 + +模块结构: +- 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" + "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" + "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" + "{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/test_ship_mapping.py b/test_ship_mapping.py new file mode 100644 index 0000000..fa3604a --- /dev/null +++ b/test_ship_mapping.py @@ -0,0 +1,124 @@ +""" +智能舷号映射 + RAG检索 完整测试脚本 +直接调用 search_with_ship_number_strategy,展示真实检索结果 +""" +import asyncio +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from utils.ship_number_search import search_with_ship_number_strategy, smart_ship_number_mapping + + +async def interactive_test(): + """交互式完整测试""" + print("=" * 60) + print("智能舷号映射 + RAG检索 完整测试") + print("=" * 60) + print("输入 'exit' 或 'quit' 退出") + print("输入 'help' 查看说明") + print("=" * 60) + + while True: + print("\n" + "-" * 60) + ship_input = input("请输入舷号/型号/舰名: ").strip() + + if ship_input.lower() in ['exit', 'quit', 'q']: + print("退出测试") + break + + if ship_input.lower() == 'help': + print("\n使用说明:") + print(" 先输入舷号/型号/舰名(如 163、南昌舰、055驱逐舰)") + print(" 再输入查询内容(如 主机排烟温度高)") + print(" 系统会执行完整的智能映射 + RAG三步检索") + continue + + if not ship_input: + print("请输入有效内容") + continue + + query_input = input("请输入查询内容: ").strip() + if not query_input: + query_input = "主机排烟温度高的维修方案" + print(f"使用默认查询: {query_input}") + + search_type = input("搜索类型 (fault/operate,回车默认fault): ").strip() + if search_type not in ["fault", "operate"]: + search_type = "fault" + + print("\n" + "=" * 60) + print("第一步:智能映射") + print("=" * 60) + + try: + matched_ship_number, matched_model, all_numbers, match_type = await smart_ship_number_mapping(ship_input) + print(f"输入: '{ship_input}'") + print(f"匹配类型: {match_type}") + print(f"匹配舷号: {matched_ship_number}") + print(f"匹配型号: {matched_model}") + print(f"该型号所有舷号: {all_numbers}") + + if match_type == "none": + print("未匹配到,将使用原始输入进行检索") + except Exception as e: + print(f"映射失败: {str(e)}") + matched_ship_number = None + matched_model = None + all_numbers = None + match_type = "none" + + print("\n" + "=" * 60) + print("第二步:RAG检索(三步策略)") + print("=" * 60) + + try: + rag_results, matched_kb_name, matched_kb_id = await search_with_ship_number_strategy( + base_query=query_input, + ship_number=ship_input, + top_k=5, + search_type=search_type + ) + + print(f"\n检索完成!") + print(f"知识库: {matched_kb_name or '未匹配'}") + print(f"知识库ID: {matched_kb_id or '未匹配'}") + print(f"结果数量: {len(rag_results)}") + + if rag_results: + print(f"\n{'=' * 60}") + print("第三步:检索结果详情") + print("=" * 60) + for i, item in enumerate(rag_results, 1): + if isinstance(item, dict): + text = item.get("text", "") + kb_name = item.get("kb_name", "") + score = item.get("score", "") + source = item.get("source", "") + + print(f"\n--- 结果 {i} ---") + if kb_name: + print(f"知识库: {kb_name}") + if score: + print(f"相似度: {score}") + if source: + print(f"来源: {source}") + if text: + preview = text[:300] + "..." if len(text) > 300 else text + print(f"内容预览:\n{preview}") + elif isinstance(item, str): + preview = item[:300] + "..." if len(item) > 300 else item + print(f"\n--- 结果 {i} ---") + print(f"内容预览:\n{preview}") + else: + print("\n未检索到任何结果") + + except Exception as e: + print(f"\n检索失败: {str(e)}") + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + asyncio.run(interactive_test()) 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/__pycache__/__init__.cpython-310.pyc b/tools/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..73c380d Binary files /dev/null and b/tools/__pycache__/__init__.cpython-310.pyc differ diff --git a/tools/__pycache__/__init__.cpython-312.pyc b/tools/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..b2ca32c Binary files /dev/null and b/tools/__pycache__/__init__.cpython-312.pyc differ diff --git a/tools/__pycache__/agent_usage_statistics.cpython-312.pyc b/tools/__pycache__/agent_usage_statistics.cpython-312.pyc new file mode 100644 index 0000000..5ed96fa Binary files /dev/null and b/tools/__pycache__/agent_usage_statistics.cpython-312.pyc differ diff --git a/tools/__pycache__/fault_record_db.cpython-312.pyc b/tools/__pycache__/fault_record_db.cpython-312.pyc new file mode 100644 index 0000000..5ed14e7 Binary files /dev/null and b/tools/__pycache__/fault_record_db.cpython-312.pyc differ diff --git a/tools/__pycache__/fault_statistics.cpython-312.pyc b/tools/__pycache__/fault_statistics.cpython-312.pyc new file mode 100644 index 0000000..8625e1b Binary files /dev/null and b/tools/__pycache__/fault_statistics.cpython-312.pyc differ diff --git a/tools/__pycache__/function_tool.cpython-310.pyc b/tools/__pycache__/function_tool.cpython-310.pyc new file mode 100644 index 0000000..1206824 Binary files /dev/null and b/tools/__pycache__/function_tool.cpython-310.pyc differ diff --git a/tools/__pycache__/function_tool.cpython-312.pyc b/tools/__pycache__/function_tool.cpython-312.pyc new file mode 100644 index 0000000..ddb8e0b Binary files /dev/null and b/tools/__pycache__/function_tool.cpython-312.pyc differ diff --git a/tools/__pycache__/graph_tools.cpython-312.pyc b/tools/__pycache__/graph_tools.cpython-312.pyc new file mode 100644 index 0000000..dbec28f Binary files /dev/null and b/tools/__pycache__/graph_tools.cpython-312.pyc differ diff --git a/tools/__pycache__/rag_tools.cpython-312.pyc b/tools/__pycache__/rag_tools.cpython-312.pyc new file mode 100644 index 0000000..01a8e05 Binary files /dev/null and b/tools/__pycache__/rag_tools.cpython-312.pyc differ diff --git a/tools/__pycache__/ship_model_db.cpython-312.pyc b/tools/__pycache__/ship_model_db.cpython-312.pyc new file mode 100644 index 0000000..d5a5303 Binary files /dev/null and b/tools/__pycache__/ship_model_db.cpython-312.pyc differ diff --git a/tools/__pycache__/text2sql_tool.cpython-312.pyc b/tools/__pycache__/text2sql_tool.cpython-312.pyc new file mode 100644 index 0000000..cc2780e Binary files /dev/null and b/tools/__pycache__/text2sql_tool.cpython-312.pyc differ diff --git a/tools/__pycache__/texttosql_utils.cpython-312.pyc b/tools/__pycache__/texttosql_utils.cpython-312.pyc new file mode 100644 index 0000000..d891d60 Binary files /dev/null and b/tools/__pycache__/texttosql_utils.cpython-312.pyc differ diff --git a/tools/__pycache__/vlm_tools.cpython-312.pyc b/tools/__pycache__/vlm_tools.cpython-312.pyc new file mode 100644 index 0000000..d03fb1e Binary files /dev/null and b/tools/__pycache__/vlm_tools.cpython-312.pyc differ diff --git a/tools/agent_usage_statistics.py b/tools/agent_usage_statistics.py new file mode 100644 index 0000000..6af0d84 --- /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..01bbac6 --- /dev/null +++ b/tools/graph_tools.py @@ -0,0 +1,1146 @@ +""" +图谱检索工具模块 +包含图谱搜索、故障图谱检索、操作图谱检索等功能 +""" +import re +import json +import asyncio +import httpx +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] = 40, + 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)} + + +def _string_similarity(a: str, b: str) -> float: + """ + 简单字符串相似度:综合长度差、包含关系和公共字符比例。 + 0~1 之间,越大越相似。 + """ + 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 + + +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 + + +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_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 DISTINCT + id(n) AS id, + labels(n) AS labels, + properties(n) AS props, + nm AS display_name, + n.embedding AS embedding + LIMIT 100 + """ + dev_records = await session.run( + device_query, ship_id=ship_id + ) + else: + # 无舷号:全局搜索设备节点 + device_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 200 + """ + dev_records = await session.run(device_query) + + device_candidates = [_node_record_to_dict(r) async for r in dev_records] + if not device_candidates: + return {"success": False, "error": "未找到匹配的设备节点"} + + device_node, device_score = await OpenaiAPI.hybrid_match_with_embeddings( + query_text=device_key, + candidates=device_candidates, + name_key="display_name", + embedding_key="embedding", + text_weight=0.3, + semantic_weight=0.7 + ) + if not device_node: + return {"success": False, "error": f"未找到匹配的设备节点(最高得分: {device_score:.3f})"} + device_id = device_node["id"] + + fault_mode_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_query, device_id=device_id) + fault_modes = [_node_record_to_dict(r) async for r in fm_records] + if not fault_modes: + return {"success": False, "error": "在该设备节点下未找到故障模式节点"} + + fault_mode_node, fault_score = await OpenaiAPI.hybrid_match_with_embeddings( + query_text=fault_symptom, + candidates=fault_modes, + name_key="display_name", + embedding_key="embedding", + text_weight=0.3, + 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 ship_number or not str(ship_number).strip(): + return {"success": False, "error": "舷号不能为空"} + 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 = 40) -> 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/text2sql_tool.py b/tools/text2sql_tool.py new file mode 100644 index 0000000..d1f5cf1 --- /dev/null +++ b/tools/text2sql_tool.py @@ -0,0 +1,777 @@ +""" +受控 ReAct Text2SQL 工具 +核心思想:Agent负责推理,Tool负责SQL,Reviewer负责纠错 + +内部流程: +1. Schema Injection - 注入数据库表结构 +2. SQL Generation - LLM生成SQL(使用 SQLAlchemy 连接池 + extra_info 辅助) +3. SQL Review - LLM审查SQL正确性 +4. SQL Guard - 代码级安全校验(硬限制) +5. SQL Execute - 执行SQL并返回结果(使用 SQLAlchemy 连接池单例) +6. Merge - 相似结果合并(Embedding) +""" +import re +import json +import asyncio +from datetime import datetime +from typing import Dict, Any, Optional, List, Tuple + +from sqlalchemy import text +from langchain_community.utilities import SQLDatabase + +from config import POSTGRES_CONNECTION_STRING,POSTGRES_SQLALCHEMY_URL +from modelsAPI.model_api import OpenaiAPI +from prompts import TEXT2SQL_PROMPTS +from tools.texttosql_utils import get_extra_info + +# ============================================================ +# 数据库连接池单例(避免重复初始化) +# ============================================================ + +_db_instance: Optional[SQLDatabase] = None +_db_lock = asyncio.Lock() + + +async def _get_db() -> SQLDatabase: + """懒加载并缓存数据库连接,避免重复初始化""" + global _db_instance + if _db_instance is None: + async with _db_lock: + if _db_instance is None: + _db_instance = await asyncio.to_thread( + SQLDatabase.from_uri, POSTGRES_SQLALCHEMY_URL + ) + return _db_instance + + +# ============================================================ +# Step 1: Schema Injection +# ============================================================ + +FAULT_RECORDS_SCHEMA = { + "table_name": "fault_records", + "description": "故障维修记录表,存储所有故障诊断和维修的记录", + "columns": { + "id": { + "type": "SERIAL", + "description": "自增主键", + "usable_in_query": False + }, + "ship_number": { + "type": "TEXT", + "description": "舰船舷号,如'101'、'163'、'554'等,2-4位数字", + "usable_in_query": True, + "examples": ["101", "163", "554", "602"] + }, + "device_name": { + "type": "TEXT", + "description": "设备名称,如'主机'、'发电机'、'分油机'、'空压机'等", + "usable_in_query": True, + "examples": ["主机", "发电机", "分油机", "空压机", "舵机", "锅炉"] + }, + "fault": { + "type": "TEXT", + "description": "故障现象描述,如'滑油压力低'、'异常振动'、'启动失败'等", + "usable_in_query": True, + "examples": ["滑油压力低", "异常振动", "启动失败", "排烟温度高"] + }, + "spare_parts": { + "type": "JSONB", + "description": "消耗的备件列表,JSON数组格式,如'[\"密封圈\", \"滤芯\"]'", + "usable_in_query": True, + "json_query_hint": "使用 jsonb_array_elements_text(spare_parts) 展开备件进行统计" + }, + "system_name": { + "type": "TEXT", + "description": "所属系统名称,如'动力系统'、'电气系统'、'导航系统'等", + "usable_in_query": True, + "examples": ["动力系统", "电气系统", "导航系统", "通信系统", "其他"] + }, + "created_at": { + "type": "TIMESTAMP", + "description": "记录创建时间,默认为当前时间", + "usable_in_query": True, + "examples": ["2025-01-15 10:30:00"] + } + } +} + +# 字段白名单(SQL Guard 使用) +ALLOWED_COLUMNS = set(FAULT_RECORDS_SCHEMA["columns"].keys()) + + +def get_schema_prompt_section() -> str: + """生成 Schema 注入的提示词部分""" + lines = [f"数据库表:{FAULT_RECORDS_SCHEMA['table_name']}"] + lines.append(f"说明:{FAULT_RECORDS_SCHEMA['description']}") + lines.append("") + lines.append("字段列表:") + + for col_name, col_info in FAULT_RECORDS_SCHEMA["columns"].items(): + if not col_info.get("usable_in_query", True): + continue + line = f" - {col_name} ({col_info['type']}): {col_info['description']}" + if "examples" in col_info: + line += f"(示例:{', '.join(col_info['examples'][:3])})" + if "json_query_hint" in col_info: + line += f"\n 查询提示:{col_info['json_query_hint']}" + lines.append(line) + + return "\n".join(lines) + + +# ============================================================ +# Step 2: SQL Generation +# ============================================================ + +async def generate_sql(question: str, current_date: str) -> Dict[str, Any]: + """ + 使用 LLM 根据用户问题生成 SQL。 + 并发预热数据库连接与获取 extra_info,提升整体响应速度。 + + Returns: + {"sql": "SELECT ...", "success": True/False, "error": "..."} + """ + + + schema_section = get_schema_prompt_section() + + # 并发:获取辅助信息 + 预热DB连接 + extra_info, _ = await asyncio.gather( + get_extra_info(question), + _get_db(), + ) + + prompt = TEXT2SQL_PROMPTS["sql_generation"].format( + schema=schema_section, + question=question, + current_date=current_date, + extra_info=extra_info or "" + ) + print("输出最终sql的提示词内容") + print(prompt) + try: + result = await OpenaiAPI.open_api_chat_without_thinking( + prompt, + model=None, + json_output=True + ) + + # 提取 JSON + json_match = re.search(r'\{.*\}', result, re.DOTALL) + if json_match: + parsed = json.loads(json_match.group(0)) + sql = parsed.get("sql", "").strip() + if sql: + return {"sql": sql, "success": True, "error": ""} + + # 兜底:直接提取 SELECT 语句 + sql_match = re.search(r'(SELECT\s+.+?;?)$', result.strip(), re.IGNORECASE | re.DOTALL) + if sql_match: + sql = sql_match.group(1).strip().rstrip(';') + return {"sql": sql, "success": True, "error": ""} + + return {"sql": "", "success": False, "error": "LLM未返回有效SQL"} + + except Exception as e: + return {"sql": "", "success": False, "error": f"SQL生成失败: {str(e)}"} + + +# ============================================================ +# Step 3: SQL Review (LLM 审查) +# ============================================================ + +async def review_sql(question: str, sql: str) -> Dict[str, Any]: + """ + 使用 LLM 审查 SQL 是否真正回答了用户问题 + + Returns: + { + "approved": True/False, + "issues": ["问题1", "问题2"], + "suggested_sql": "修正后的SQL(如果有问题)", + "review_comment": "审查意见" + } + """ + from modelsAPI.model_api import OpenaiAPI + from prompts import TEXT2SQL_PROMPTS + + schema_section = get_schema_prompt_section() + prompt = TEXT2SQL_PROMPTS["sql_review"].format( + schema=schema_section, + question=question, + sql=sql + ) + + try: + result = await OpenaiAPI.open_api_chat_without_thinking( + prompt, + model=None, + json_output=True + ) + + json_match = re.search(r'\{.*\}', result, re.DOTALL) + if json_match: + parsed = json.loads(json_match.group(0)) + approved = parsed.get("approved", False) + issues = parsed.get("issues", []) + suggested_sql = parsed.get("suggested_sql", "").strip() + review_comment = parsed.get("review_comment", "") + + if suggested_sql: + suggested_sql = suggested_sql.rstrip(';') + + return { + "approved": approved, + "issues": issues, + "suggested_sql": suggested_sql, + "review_comment": review_comment + } + + return { + "approved": True, + "issues": [], + "suggested_sql": "", + "review_comment": "审查结果解析失败,默认通过" + } + + except Exception as e: + return { + "approved": True, + "issues": [], + "suggested_sql": "", + "review_comment": f"审查过程异常,默认通过: {str(e)}" + } + + +# ============================================================ +# Step 4: SQL Guard (代码级安全校验) +# ============================================================ + +class SQLGuardError(Exception): + """SQL Guard 校验失败异常""" + pass + + +def guard_sql(sql: str) -> Tuple[bool, str, str]: + """ + SQL 安全守卫 - 代码硬限制 + + Returns: + (passed, cleaned_sql, error_message) + """ + if not sql or not sql.strip(): + return False, "", "SQL为空" + + sql_upper = sql.strip().upper() + + # 1. 只允许 SELECT + if not sql_upper.startswith("SELECT"): + return False, "", "只允许SELECT查询,禁止INSERT/UPDATE/DELETE/CREATE等操作" + + # 2. 禁止 UNION + if "UNION" in sql_upper: + return False, "", "禁止使用UNION操作" + + # 3. 禁止子查询嵌套过深(最多2层) + subquery_count = sql_upper.count("SELECT") - 1 + if subquery_count > 2: + return False, "", f"子查询嵌套过深({subquery_count}层),最多允许2层" + + # 4. 禁止危险函数 + dangerous_functions = ["PG_SLEEP", "SLEEP", "BENCHMARK", "WAITFOR", "DELAY"] + for func in dangerous_functions: + if func in sql_upper: + return False, "", f"禁止使用危险函数: {func}" + + # 5. 禁止系统表访问 + system_tables = ["PG_", "INFORMATION_SCHEMA", "PG_CATALOG", "PG_CLASS"] + for sys_table in system_tables: + if sys_table in sql_upper: + return False, "", f"禁止访问系统表: {sys_table}" + + # 6. 禁止注释注入 + if "--" in sql or "/*" in sql or "*/" in sql: + return False, "", "SQL中不允许包含注释" + + # 7. 禁止分号(防止多语句注入) + if ";" in sql.rstrip(';'): + return False, "", "禁止多语句执行" + + # 8. 检查字段白名单 + used_columns = set() + dot_matches = re.findall(r'fault_records\.(\w+)', sql, re.IGNORECASE) + used_columns.update(m.lower() for m in dot_matches) + + select_match = re.search(r'SELECT\s+(.*?)\s+FROM', sql, re.IGNORECASE | re.DOTALL) + if select_match: + select_part = select_match.group(1) + if '*' not in select_part: + for part in select_part.split(','): + part = part.strip() + col_match = re.search(r'(\w+)\s*(?:AS|as)?\s*$', part) + if col_match: + col_name = col_match.group(1).lower() + if col_name not in ('count', 'sum', 'avg', 'max', 'min', 'as', + 'desc', 'asc', 'null', 'distinct', 'all', + 'jsonb_array_elements_text', 'jsonb_array_elements', + 'coalesce', 'cast', 'date', 'timestamp', 'text'): + used_columns.add(col_name) + + for col in used_columns: + if col not in ALLOWED_COLUMNS and col != 'id': + return False, "", f"使用了不允许的字段: {col}" + + # 9. 自动添加 LIMIT(如果没有) + cleaned_sql = sql.strip().rstrip(';') + if "LIMIT" not in sql_upper: + cleaned_sql = f"{cleaned_sql} LIMIT 100" + + # 10. 强制只查 fault_records 表 + from_match = re.search(r'FROM\s+(\w+)', sql, re.IGNORECASE) + if from_match: + table_name = from_match.group(1).lower() + if table_name != "fault_records": + return False, "", f"只允许查询 fault_records 表,不允许查询: {table_name}" + + return True, cleaned_sql, "" + + +# ============================================================ +# Step 5: SQL Execute +# ============================================================ + +async def execute_sql(sql: str) -> Dict[str, Any]: + """ + 执行 SQL 并返回结果。 + 使用模块级 SQLAlchemy 连接池单例,避免每次重复建立连接。 + + Returns: + {"rows": [...], "columns": [...], "row_count": int, "success": True/False, "error": ""} + """ + try: + db = await _get_db() + + def _run() -> Dict[str, Any]: + try: + with db._engine.connect() as conn: + result = conn.execute(text(sql)) + columns = list(result.keys()) + result_rows = [] + for row in result.fetchall(): + row_dict = {} + for i, col in enumerate(columns): + val = row[i] + if isinstance(val, datetime): + val = val.isoformat() + elif isinstance(val, (list, dict)): + val = json.dumps(val, ensure_ascii=False) + elif not isinstance(val, (str, int, float, bool, type(None))): + val = str(val) + row_dict[col] = val + result_rows.append(row_dict) + conn.commit() + return { + "rows": result_rows, + "columns": columns, + "row_count": len(result_rows), + "success": True, + "error": "" + } + except Exception as e: + return { + "rows": [], + "columns": [], + "row_count": 0, + "success": False, + "error": f"SQL执行失败: {str(e)}" + } + + return await asyncio.to_thread(_run) + + except Exception as e: + return { + "rows": [], + "columns": [], + "row_count": 0, + "success": False, + "error": f"获取数据库连接失败: {str(e)}" + } + + +# ============================================================ +# Text2SQL 主入口 - 内部闭环 +# ============================================================ + +MAX_RETRY = 2 # 最大重试次数(生成→审查→修正) + + +async def text2sql_tool(question: str, current_date: Optional[str] = None) -> Dict[str, Any]: + """ + 受控 Text2SQL 工具 - 内部形成闭环 + + 流程:question → SQL生成 → SQL审查 → SQL Guard → SQL执行 → 返回结果 + + Args: + question: 用户问题 + current_date: 当前日期(格式:YYYY-MM-DD),默认为今天 + + Returns: + { + "success": True/False, + "sql": "最终执行的SQL", + "rows": [...], + "columns": [...], + "row_count": int, + "error": "", + "review_log": [...] # 审查日志 + } + """ + if not current_date: + current_date = datetime.now().strftime("%Y-%m-%d") + + review_log = [] + current_sql = "" + + for attempt in range(MAX_RETRY + 1): + # Step 2: SQL Generation + if attempt == 0: + gen_result = await generate_sql(question, current_date) + else: + gen_result = await generate_sql_with_feedback( + question, current_date, current_sql, review_log + ) + + if not gen_result["success"]: + return { + "success": False, + "sql": "", + "rows": [], + "columns": [], + "row_count": 0, + "error": gen_result["error"], + "review_log": review_log + } + + current_sql = gen_result["sql"] + + # Step 3: SQL Review + review_result = await review_sql(question, current_sql) + review_log.append({ + "attempt": attempt + 1, + "sql": current_sql, + "approved": review_result["approved"], + "issues": review_result["issues"], + "comment": review_result["review_comment"] + }) + + if not review_result["approved"]: + if review_result["suggested_sql"]: + current_sql = review_result["suggested_sql"] + review_log.append({ + "attempt": attempt + 1, + "sql": current_sql, + "note": "使用审查建议的修正SQL" + }) + elif attempt < MAX_RETRY: + continue + else: + return { + "success": False, + "sql": current_sql, + "rows": [], + "columns": [], + "row_count": 0, + "error": f"SQL审查不通过: {'; '.join(review_result['issues'])}", + "review_log": review_log + } + + # Step 4: SQL Guard + passed, cleaned_sql, guard_error = guard_sql(current_sql) + if not passed: + review_log.append({ + "attempt": attempt + 1, + "sql": current_sql, + "guard_passed": False, + "guard_error": guard_error + }) + if attempt < MAX_RETRY: + review_log.append({"note": f"Guard拒绝: {guard_error},将重新生成"}) + continue + else: + return { + "success": False, + "sql": current_sql, + "rows": [], + "columns": [], + "row_count": 0, + "error": f"SQL安全校验失败: {guard_error}", + "review_log": review_log + } + + current_sql = cleaned_sql + review_log.append({ + "attempt": attempt + 1, + "sql": current_sql, + "guard_passed": True, + "note": "Guard通过" + }) + + # Step 5: SQL Execute + exec_result = await execute_sql(current_sql) + + if not exec_result["success"]: + if attempt < MAX_RETRY: + review_log.append({ + "attempt": attempt + 1, + "sql": current_sql, + "exec_error": exec_result["error"], + "note": "执行失败,将重新生成" + }) + continue + else: + return { + "success": False, + "sql": current_sql, + "rows": [], + "columns": [], + "row_count": 0, + "error": exec_result["error"], + "review_log": review_log + } + + # Step 6: 相似结果合并 + merged_rows = await merge_similar_rows( + exec_result["rows"], exec_result["columns"], question + ) + + return { + "success": True, + "sql": current_sql, + "rows": merged_rows, + "columns": exec_result["columns"], + "row_count": len(merged_rows), + "raw_row_count": exec_result["row_count"], + "error": "", + "review_log": review_log + } + + return { + "success": False, + "sql": current_sql, + "rows": [], + "columns": [], + "row_count": 0, + "error": "超过最大重试次数", + "review_log": review_log + } + + +async def generate_sql_with_feedback( + question: str, + current_date: str, + previous_sql: str, + review_log: List[Dict[str, Any]] +) -> Dict[str, Any]: + """ + 基于审查反馈重新生成 SQL + """ + from modelsAPI.model_api import OpenaiAPI + from prompts import TEXT2SQL_PROMPTS + + schema_section = get_schema_prompt_section() + + feedback_parts = [] + for log_entry in review_log: + if "issues" in log_entry and log_entry["issues"]: + feedback_parts.append(f"问题: {'; '.join(log_entry['issues'])}") + if "guard_error" in log_entry: + feedback_parts.append(f"安全校验失败: {log_entry['guard_error']}") + if "exec_error" in log_entry: + feedback_parts.append(f"执行失败: {log_entry['exec_error']}") + + feedback = "\n".join(feedback_parts) if feedback_parts else "无具体反馈" + + prompt = TEXT2SQL_PROMPTS["sql_regeneration"].format( + schema=schema_section, + question=question, + current_date=current_date, + previous_sql=previous_sql, + feedback=feedback + ) + + try: + result = await OpenaiAPI.open_api_chat_without_thinking( + prompt, + model=None, + json_output=True + ) + + json_match = re.search(r'\{.*\}', result, re.DOTALL) + if json_match: + parsed = json.loads(json_match.group(0)) + sql = parsed.get("sql", "").strip() + if sql: + return {"sql": sql, "success": True, "error": ""} + + sql_match = re.search(r'(SELECT\s+.+?;?)$', result.strip(), re.IGNORECASE | re.DOTALL) + if sql_match: + sql = sql_match.group(1).strip().rstrip(';') + return {"sql": sql, "success": True, "error": ""} + + return {"sql": "", "success": False, "error": "重新生成SQL失败"} + + except Exception as e: + return {"sql": "", "success": False, "error": f"重新生成SQL异常: {str(e)}"} + + +# ============================================================ +# Step 6: 相似结果合并(Embedding) +# ============================================================ + +MERGE_KEYWORDS = ["故障", "频次", "排名", "高频", "最多", "top", "发生次数"] + + +def _should_merge(columns: List[str], question: str) -> bool: + """判断是否需要合并相似行""" + question_lower = question.lower() + if any(kw in question_lower for kw in MERGE_KEYWORDS): + col_names = [c.lower() for c in columns] + has_name = any(n in col_names for n in ["name", "故障", "fault", "device_name"]) + has_count = any(n in col_names for n in ["count", "次数", "频次"]) + return has_name and has_count + return False + + +def _find_name_column(columns: List[str]) -> Optional[str]: + """找到名称列""" + priority = ["name", "故障名称", "故障", "fault", "device_name", "备件", "spare"] + col_lower_map = {c.lower(): c for c in columns} + for p in priority: + if p in col_lower_map: + return col_lower_map[p] + for c in columns: + if c.lower() not in ("count", "次数", "频次", "rank", "排名", "system_name", "系统"): + return c + return None + + +def _find_count_column(columns: List[str]) -> Optional[str]: + """找到计数列""" + priority = ["count", "次数", "频次", "cnt"] + col_lower_map = {c.lower(): c for c in columns} + for p in priority: + if p in col_lower_map: + return col_lower_map[p] + return None + + +def _find_system_column(columns: List[str]) -> Optional[str]: + """找到系统分类列""" + priority = ["system_name", "系统", "系统分类", "system"] + col_lower_map = {c.lower(): c for c in columns} + for p in priority: + if p in col_lower_map: + return col_lower_map[p] + return None + + +async def merge_similar_rows( + rows: List[Dict[str, Any]], + columns: List[str], + question: str, + similarity_threshold: float = 0.85 +) -> List[Dict[str, Any]]: + """ + 使用 embedding 合并相似的故障/备件行 + + 例如: + - "连杆大端轴承发生损坏异常" (7次, 其他) + "连杆大端轴承发生损坏异常" (6次, 汽车配件) + → "连杆大端轴承发生损坏异常" (13次, 其他) + - "发动机中空冷器管束芯体结垢或堵塞..." + "空冷器管束芯体结垢或堵塞..." + → 合并为一条 + """ + if not rows or len(rows) <= 1: + return rows + + if not _should_merge(columns, question): + return rows + + name_col = _find_name_column(columns) + count_col = _find_count_column(columns) + system_col = _find_system_column(columns) + + if not name_col or not count_col: + return rows + + try: + from modelsAPI.model_api import OpenaiAPI + + names = [row[name_col] for row in rows if row.get(name_col)] + if len(names) <= 1: + return rows + + embeddings = await OpenaiAPI.get_embeddings_batch_async(names) + name_emb_map = {name: emb for name, emb in zip(names, embeddings)} + + sorted_rows = sorted(rows, key=lambda r: int(r.get(count_col, 0)), reverse=True) + + merged_rows = [] + merged_indices = set() + + for i, row in enumerate(sorted_rows): + if i in merged_indices: + continue + + name_i = row.get(name_col, "") + emb_i = name_emb_map.get(name_i) + + if not emb_i: + merged_rows.append(row) + continue + + merged_row = dict(row) + total_count = int(row.get(count_col, 0)) + systems = set() + if system_col and row.get(system_col): + systems.add(row[system_col]) + + for j in range(i + 1, len(sorted_rows)): + if j in merged_indices: + continue + + name_j = sorted_rows[j].get(name_col, "") + emb_j = name_emb_map.get(name_j) + + if not emb_j: + continue + + similarity = OpenaiAPI.cosine_similarity(emb_i, emb_j) + + if similarity >= similarity_threshold: + total_count += int(sorted_rows[j].get(count_col, 0)) + if system_col and sorted_rows[j].get(system_col): + systems.add(sorted_rows[j][system_col]) + merged_indices.add(j) + + merged_row[count_col] = total_count + if system_col and systems: + merged_row[system_col] = "、".join(sorted(systems)) + + merged_rows.append(merged_row) + merged_indices.add(i) + + merged_rows.sort(key=lambda r: int(r.get(count_col, 0)), reverse=True) + + if "rank" in columns or "排名" in columns: + rank_col = "rank" if "rank" in columns else "排名" + for idx, row in enumerate(merged_rows): + row[rank_col] = idx + 1 + + return merged_rows + + except Exception as e: + print(f"Embedding合并失败,使用原始数据: {str(e)}") + return rows + + diff --git a/tools/texttosql_utils.py b/tools/texttosql_utils.py new file mode 100644 index 0000000..bb9da46 --- /dev/null +++ b/tools/texttosql_utils.py @@ -0,0 +1,184 @@ +import os +import ast +import re +import asyncio +import httpx +from typing import List, Dict + +from neo4j import GraphDatabase +from neo4j_graphrag.retrievers import HybridCypherRetriever +from neo4j_graphrag.embeddings.base import Embedder +from modelsAPI.model_api import LocalBgeM3Embeddings, OpenaiAPI +from config import MAP_INS, NEO4J_CONFIG, INDEX_CONFIG, EMBEDDING_CONFIG + +# ================== 配置 ================== +URI = NEO4J_CONFIG["uri"] +AUTH = (NEO4J_CONFIG["user"], NEO4J_CONFIG["password"]) +NAME_PROPERTY = INDEX_CONFIG["NAME_PROPERTY"] +FULLTEXT_PROPERTY = INDEX_CONFIG["FULLTEXT_PROPERTY"] +EMBEDDING_PROPERTY = INDEX_CONFIG["EMBEDDING_PROPERTY"] +SEARCH_LABEL = INDEX_CONFIG["SEARCH_LABEL"] +VECTOR_INDEX_NAME = INDEX_CONFIG["VECTOR_INDEX_NAME"] +FULLTEXT_INDEX_NAME = INDEX_CONFIG["FULLTEXT_INDEX_NAME"] +map_ins = MAP_INS + +# ================== 全局复用:driver / embedder 只初始化一次 ================== +_driver = None +_embedder = None + +def get_driver(): + global _driver + if _driver is None: + _driver = GraphDatabase.driver(URI, auth=AUTH) + return _driver + +def get_embedder(): + global _embedder + if _embedder is None: + _embedder = LocalBgeM3Embeddings( + base_url=EMBEDDING_CONFIG["base_url"], + api_key=EMBEDDING_CONFIG["api_key"], + ) + return _embedder + + +# ================== 辅助:解析 Record 字符串 ================== +def parse_record_string(s: str): + name_match = re.search(r"name='([^']*)'", s) + labels_match = re.search(r"labels=(\[[^\]]*\])", s) + score_match = re.search(r"score=([\d.]+)", s) + + name = name_match.group(1) if name_match else "未知名称" + labels = ["未知标签"] + if labels_match: + try: + labels = ast.literal_eval(labels_match.group(1)) + except (ValueError, SyntaxError): + pass + score = float(score_match.group(1)) if score_match else 0.0 + return name, labels, score + + +# ================== 构建 Retriever ================== +def build_retriever(sync_driver, embedder: Embedder) -> HybridCypherRetriever: + return HybridCypherRetriever( + driver=sync_driver, + vector_index_name=VECTOR_INDEX_NAME, + fulltext_index_name=FULLTEXT_INDEX_NAME, + embedder=embedder, + retrieval_query=f""" + RETURN + node.`{NAME_PROPERTY}` AS name, + [lbl IN labels(node) WHERE lbl <> '{SEARCH_LABEL}'] AS labels, + score + """, + ) + + +# ================== 同步检索(在线程池中执行,不阻塞事件循环)================== +def _hybrid_search_sync(sentence: str, top_k: int = 10) -> List[Dict]: + retriever = build_retriever(get_driver(), get_embedder()) + results_raw = retriever.search(query_text=sentence, top_k=top_k) + + hybrid_results: List[Dict] = [] + for item in results_raw.items: + content = item.content + name = "未知名称" + labels = ["未知标签"] + score = 0.0 + + if hasattr(content, "keys"): + name = content.get("name", name) + labels = content.get("labels", labels) + score = content.get("score", score) + elif isinstance(content, str): + if content.startswith(" str: + """ + 参数: + user_question: 用户问题 + top_k: 检索返回条数 + 返回: + LLM 筛选结果字符串 + """ + # 同步检索放入线程池,不阻塞事件循环 + results = await asyncio.to_thread(_hybrid_search_sync, user_question, top_k) + + # 构建 prompt 并异步调用 LLM + res = await OpenaiAPI.open_api_chat_without_thinking( + system_prompt=SYSTEM_PROMPT, + query=PROMPT_TEMPLATE.format(results=results, map_ins=map_ins,user_question=user_question), + ) + + return res.strip() + + +# ================== 入口 ================== +if __name__ == "__main__": + answer = asyncio.run(get_extra_info("发动机这个月发生了多少次故障")) + print(111111111111111111) + print(answer) + 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/__pycache__/__init__.cpython-310.pyc b/utils/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..b4cb04d Binary files /dev/null and b/utils/__pycache__/__init__.cpython-310.pyc differ diff --git a/utils/__pycache__/__init__.cpython-312.pyc b/utils/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..3e79885 Binary files /dev/null and b/utils/__pycache__/__init__.cpython-312.pyc differ diff --git a/utils/__pycache__/function_tracker.cpython-310.pyc b/utils/__pycache__/function_tracker.cpython-310.pyc new file mode 100644 index 0000000..9846d10 Binary files /dev/null and b/utils/__pycache__/function_tracker.cpython-310.pyc differ diff --git a/utils/__pycache__/function_tracker.cpython-312.pyc b/utils/__pycache__/function_tracker.cpython-312.pyc new file mode 100644 index 0000000..6ca0710 Binary files /dev/null and b/utils/__pycache__/function_tracker.cpython-312.pyc differ diff --git a/utils/__pycache__/image_utils.cpython-312.pyc b/utils/__pycache__/image_utils.cpython-312.pyc new file mode 100644 index 0000000..dcb8bd8 Binary files /dev/null and b/utils/__pycache__/image_utils.cpython-312.pyc differ diff --git a/utils/__pycache__/intent_matcher.cpython-312.pyc b/utils/__pycache__/intent_matcher.cpython-312.pyc new file mode 100644 index 0000000..0502498 Binary files /dev/null and b/utils/__pycache__/intent_matcher.cpython-312.pyc differ diff --git a/utils/__pycache__/pdf_generator.cpython-310.pyc b/utils/__pycache__/pdf_generator.cpython-310.pyc new file mode 100644 index 0000000..d18bbb3 Binary files /dev/null and b/utils/__pycache__/pdf_generator.cpython-310.pyc differ diff --git a/utils/__pycache__/ship_number_search.cpython-312.pyc b/utils/__pycache__/ship_number_search.cpython-312.pyc new file mode 100644 index 0000000..cbffd18 Binary files /dev/null and b/utils/__pycache__/ship_number_search.cpython-312.pyc differ diff --git a/utils/__pycache__/word_generator.cpython-310.pyc b/utils/__pycache__/word_generator.cpython-310.pyc new file mode 100644 index 0000000..157de49 Binary files /dev/null and b/utils/__pycache__/word_generator.cpython-310.pyc differ diff --git a/utils/__pycache__/word_generator.cpython-312.pyc b/utils/__pycache__/word_generator.cpython-312.pyc new file mode 100644 index 0000000..5839684 Binary files /dev/null and b/utils/__pycache__/word_generator.cpython-312.pyc differ 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..b1468a0 --- /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/=0.2.0 b/workflows/=0.2.0 new file mode 100644 index 0000000..e69de29 diff --git a/workflows/=2.0.0 b/workflows/=2.0.0 new file mode 100644 index 0000000..9b13221 --- /dev/null +++ b/workflows/=2.0.0 @@ -0,0 +1,55 @@ +Requirement already satisfied: langgraph in /usr/local/lib/python3.10/dist-packages (1.0.3) +Collecting langgraph + Downloading langgraph-1.1.3-py3-none-any.whl (168 kB) + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 168.1/168.1 KB 685.8 kB/s eta 0:00:00 +Requirement already satisfied: langgraph-checkpoint-postgres in /usr/local/lib/python3.10/dist-packages (3.0.5) +Requirement already satisfied: langchain-core>=0.1 in /usr/local/lib/python3.10/dist-packages (from langgraph) (1.1.0) +Collecting langgraph-sdk<0.4.0,>=0.3.0 + Downloading langgraph_sdk-0.3.12-py3-none-any.whl (95 kB) + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 95.8/95.8 KB 3.9 MB/s eta 0:00:00 +Requirement already satisfied: xxhash>=3.5.0 in /usr/local/lib/python3.10/dist-packages (from langgraph) (3.6.0) +Requirement already satisfied: pydantic>=2.7.4 in /usr/local/lib/python3.10/dist-packages (from langgraph) (2.12.4) +Requirement already satisfied: langgraph-checkpoint<5.0.0,>=2.1.0 in /usr/local/lib/python3.10/dist-packages (from langgraph) (3.0.1) +Collecting langgraph-prebuilt<1.1.0,>=1.0.8 + Downloading langgraph_prebuilt-1.0.8-py3-none-any.whl (35 kB) +Requirement already satisfied: psycopg-pool>=3.2.0 in /usr/local/lib/python3.10/dist-packages (from langgraph-checkpoint-postgres) (3.3.0) +Requirement already satisfied: orjson>=3.11.5 in /usr/local/lib/python3.10/dist-packages (from langgraph-checkpoint-postgres) (3.11.7) +Requirement already satisfied: psycopg>=3.2.0 in /usr/local/lib/python3.10/dist-packages (from langgraph-checkpoint-postgres) (3.3.3) +Requirement already satisfied: jsonpatch<2.0.0,>=1.33.0 in /usr/local/lib/python3.10/dist-packages (from langchain-core>=0.1->langgraph) (1.33) +Requirement already satisfied: langsmith<1.0.0,>=0.3.45 in /usr/local/lib/python3.10/dist-packages (from langchain-core>=0.1->langgraph) (0.4.46) +Requirement already satisfied: packaging<26.0.0,>=23.2.0 in /usr/local/lib/python3.10/dist-packages (from langchain-core>=0.1->langgraph) (25.0) +Requirement already satisfied: pyyaml<7.0.0,>=5.3.0 in /usr/local/lib/python3.10/dist-packages (from langchain-core>=0.1->langgraph) (6.0.3) +Requirement already satisfied: tenacity!=8.4.0,<10.0.0,>=8.1.0 in /usr/local/lib/python3.10/dist-packages (from langchain-core>=0.1->langgraph) (9.1.2) +Requirement already satisfied: typing-extensions<5.0.0,>=4.7.0 in /usr/local/lib/python3.10/dist-packages (from langchain-core>=0.1->langgraph) (4.15.0) +Requirement already satisfied: ormsgpack>=1.12.0 in /usr/local/lib/python3.10/dist-packages (from langgraph-checkpoint<5.0.0,>=2.1.0->langgraph) (1.12.0) +Requirement already satisfied: httpx>=0.25.2 in /usr/local/lib/python3.10/dist-packages (from langgraph-sdk<0.4.0,>=0.3.0->langgraph) (0.28.1) +Requirement already satisfied: typing-inspection>=0.4.2 in /usr/local/lib/python3.10/dist-packages (from pydantic>=2.7.4->langgraph) (0.4.2) +Requirement already satisfied: annotated-types>=0.6.0 in /usr/local/lib/python3.10/dist-packages (from pydantic>=2.7.4->langgraph) (0.7.0) +Requirement already satisfied: pydantic-core==2.41.5 in /usr/local/lib/python3.10/dist-packages (from pydantic>=2.7.4->langgraph) (2.41.5) +Requirement already satisfied: httpcore==1.* in /usr/local/lib/python3.10/dist-packages (from httpx>=0.25.2->langgraph-sdk<0.4.0,>=0.3.0->langgraph) (1.0.9) +Requirement already satisfied: anyio in /usr/local/lib/python3.10/dist-packages (from httpx>=0.25.2->langgraph-sdk<0.4.0,>=0.3.0->langgraph) (4.11.0) +Requirement already satisfied: certifi in /usr/local/lib/python3.10/dist-packages (from httpx>=0.25.2->langgraph-sdk<0.4.0,>=0.3.0->langgraph) (2025.11.12) +Requirement already satisfied: idna in /usr/local/lib/python3.10/dist-packages (from httpx>=0.25.2->langgraph-sdk<0.4.0,>=0.3.0->langgraph) (3.11) +Requirement already satisfied: h11>=0.16 in /usr/local/lib/python3.10/dist-packages (from httpcore==1.*->httpx>=0.25.2->langgraph-sdk<0.4.0,>=0.3.0->langgraph) (0.16.0) +Requirement already satisfied: jsonpointer>=1.9 in /usr/local/lib/python3.10/dist-packages (from jsonpatch<2.0.0,>=1.33.0->langchain-core>=0.1->langgraph) (3.0.0) +Requirement already satisfied: requests>=2.0.0 in /usr/local/lib/python3.10/dist-packages (from langsmith<1.0.0,>=0.3.45->langchain-core>=0.1->langgraph) (2.32.5) +Requirement already satisfied: zstandard>=0.23.0 in /usr/local/lib/python3.10/dist-packages (from langsmith<1.0.0,>=0.3.45->langchain-core>=0.1->langgraph) (0.25.0) +Requirement already satisfied: requests-toolbelt>=1.0.0 in /usr/local/lib/python3.10/dist-packages (from langsmith<1.0.0,>=0.3.45->langchain-core>=0.1->langgraph) (1.0.0) +Requirement already satisfied: urllib3<3,>=1.21.1 in /usr/local/lib/python3.10/dist-packages (from requests>=2.0.0->langsmith<1.0.0,>=0.3.45->langchain-core>=0.1->langgraph) (2.5.0) +Requirement already satisfied: charset_normalizer<4,>=2 in /usr/local/lib/python3.10/dist-packages (from requests>=2.0.0->langsmith<1.0.0,>=0.3.45->langchain-core>=0.1->langgraph) (3.4.4) +Requirement already satisfied: exceptiongroup>=1.0.2 in /usr/local/lib/python3.10/dist-packages (from anyio->httpx>=0.25.2->langgraph-sdk<0.4.0,>=0.3.0->langgraph) (1.3.1) +Requirement already satisfied: sniffio>=1.1 in /usr/local/lib/python3.10/dist-packages (from anyio->httpx>=0.25.2->langgraph-sdk<0.4.0,>=0.3.0->langgraph) (1.3.1) +Installing collected packages: langgraph-sdk, langgraph-prebuilt, langgraph + Attempting uninstall: langgraph-sdk + Found existing installation: langgraph-sdk 0.2.9 + Uninstalling langgraph-sdk-0.2.9: + Successfully uninstalled langgraph-sdk-0.2.9 + Attempting uninstall: langgraph-prebuilt + Found existing installation: langgraph-prebuilt 1.0.5 + Uninstalling langgraph-prebuilt-1.0.5: + Successfully uninstalled langgraph-prebuilt-1.0.5 + Attempting uninstall: langgraph + Found existing installation: langgraph 1.0.3 + Uninstalling langgraph-1.0.3: + Successfully uninstalled langgraph-1.0.3 +Successfully installed langgraph-1.1.3 langgraph-prebuilt-1.0.8 langgraph-sdk-0.3.12 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/__pycache__/__init__.cpython-310.pyc b/workflows/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..7975dd0 Binary files /dev/null and b/workflows/__pycache__/__init__.cpython-310.pyc differ diff --git a/workflows/__pycache__/__init__.cpython-312.pyc b/workflows/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..129c78f Binary files /dev/null and b/workflows/__pycache__/__init__.cpython-312.pyc differ diff --git a/workflows/__pycache__/history_manager.cpython-312.pyc b/workflows/__pycache__/history_manager.cpython-312.pyc new file mode 100644 index 0000000..cec3ff7 Binary files /dev/null and b/workflows/__pycache__/history_manager.cpython-312.pyc differ diff --git a/workflows/__pycache__/workflow_baike.cpython-310.pyc b/workflows/__pycache__/workflow_baike.cpython-310.pyc new file mode 100644 index 0000000..9f5cb82 Binary files /dev/null and b/workflows/__pycache__/workflow_baike.cpython-310.pyc differ diff --git a/workflows/__pycache__/workflow_baike.cpython-312.pyc b/workflows/__pycache__/workflow_baike.cpython-312.pyc new file mode 100644 index 0000000..8350862 Binary files /dev/null and b/workflows/__pycache__/workflow_baike.cpython-312.pyc differ diff --git a/workflows/__pycache__/workflow_fault_common.cpython-310.pyc b/workflows/__pycache__/workflow_fault_common.cpython-310.pyc new file mode 100644 index 0000000..7525c60 Binary files /dev/null and b/workflows/__pycache__/workflow_fault_common.cpython-310.pyc differ diff --git a/workflows/__pycache__/workflow_fault_define.cpython-310.pyc b/workflows/__pycache__/workflow_fault_define.cpython-310.pyc new file mode 100644 index 0000000..b46333d Binary files /dev/null and b/workflows/__pycache__/workflow_fault_define.cpython-310.pyc differ diff --git a/workflows/__pycache__/workflow_fault_diagnosis.cpython-310.pyc b/workflows/__pycache__/workflow_fault_diagnosis.cpython-310.pyc new file mode 100644 index 0000000..b07e218 Binary files /dev/null and b/workflows/__pycache__/workflow_fault_diagnosis.cpython-310.pyc differ diff --git a/workflows/__pycache__/workflow_fault_diagnosis.cpython-312.pyc b/workflows/__pycache__/workflow_fault_diagnosis.cpython-312.pyc new file mode 100644 index 0000000..3edfcce Binary files /dev/null and b/workflows/__pycache__/workflow_fault_diagnosis.cpython-312.pyc differ diff --git a/workflows/__pycache__/workflow_operate.cpython-310.pyc b/workflows/__pycache__/workflow_operate.cpython-310.pyc new file mode 100644 index 0000000..f93d3fc Binary files /dev/null and b/workflows/__pycache__/workflow_operate.cpython-310.pyc differ diff --git a/workflows/__pycache__/workflow_operate.cpython-312.pyc b/workflows/__pycache__/workflow_operate.cpython-312.pyc new file mode 100644 index 0000000..9aee3fe Binary files /dev/null and b/workflows/__pycache__/workflow_operate.cpython-312.pyc differ diff --git a/workflows/__pycache__/workflow_other.cpython-310.pyc b/workflows/__pycache__/workflow_other.cpython-310.pyc new file mode 100644 index 0000000..44655aa Binary files /dev/null and b/workflows/__pycache__/workflow_other.cpython-310.pyc differ diff --git a/workflows/__pycache__/workflow_other_report.cpython-310.pyc b/workflows/__pycache__/workflow_other_report.cpython-310.pyc new file mode 100644 index 0000000..8a333cb Binary files /dev/null and b/workflows/__pycache__/workflow_other_report.cpython-310.pyc differ diff --git a/workflows/__pycache__/workflow_other_report.cpython-312.pyc b/workflows/__pycache__/workflow_other_report.cpython-312.pyc new file mode 100644 index 0000000..5ee5045 Binary files /dev/null and b/workflows/__pycache__/workflow_other_report.cpython-312.pyc differ diff --git a/workflows/__pycache__/workflow_qa.cpython-310.pyc b/workflows/__pycache__/workflow_qa.cpython-310.pyc new file mode 100644 index 0000000..5d92432 Binary files /dev/null and b/workflows/__pycache__/workflow_qa.cpython-310.pyc differ diff --git a/workflows/__pycache__/workflow_qa.cpython-312.pyc b/workflows/__pycache__/workflow_qa.cpython-312.pyc new file mode 100644 index 0000000..0689dc2 Binary files /dev/null and b/workflows/__pycache__/workflow_qa.cpython-312.pyc differ diff --git a/workflows/__pycache__/workflow_qa_report.cpython-310.pyc b/workflows/__pycache__/workflow_qa_report.cpython-310.pyc new file mode 100644 index 0000000..5c5579c Binary files /dev/null and b/workflows/__pycache__/workflow_qa_report.cpython-310.pyc differ diff --git a/workflows/__pycache__/workflow_rag_define.cpython-310.pyc b/workflows/__pycache__/workflow_rag_define.cpython-310.pyc new file mode 100644 index 0000000..84d301f Binary files /dev/null and b/workflows/__pycache__/workflow_rag_define.cpython-310.pyc differ diff --git a/workflows/__pycache__/workflow_repair.cpython-310.pyc b/workflows/__pycache__/workflow_repair.cpython-310.pyc new file mode 100644 index 0000000..849ac0e Binary files /dev/null and b/workflows/__pycache__/workflow_repair.cpython-310.pyc differ diff --git a/workflows/__pycache__/workflow_repair.cpython-312.pyc b/workflows/__pycache__/workflow_repair.cpython-312.pyc new file mode 100644 index 0000000..4871def Binary files /dev/null and b/workflows/__pycache__/workflow_repair.cpython-312.pyc differ diff --git a/workflows/__pycache__/workflow_repair_report.cpython-310.pyc b/workflows/__pycache__/workflow_repair_report.cpython-310.pyc new file mode 100644 index 0000000..3dac53a Binary files /dev/null and b/workflows/__pycache__/workflow_repair_report.cpython-310.pyc differ diff --git a/workflows/__pycache__/workflow_repair_report.cpython-312.pyc b/workflows/__pycache__/workflow_repair_report.cpython-312.pyc new file mode 100644 index 0000000..0efa82a Binary files /dev/null and b/workflows/__pycache__/workflow_repair_report.cpython-312.pyc differ diff --git a/workflows/__pycache__/workflow_report.cpython-312.pyc b/workflows/__pycache__/workflow_report.cpython-312.pyc new file mode 100644 index 0000000..9225d03 Binary files /dev/null and b/workflows/__pycache__/workflow_report.cpython-312.pyc differ diff --git a/workflows/__pycache__/workflow_spare_parts_statistics.cpython-310.pyc b/workflows/__pycache__/workflow_spare_parts_statistics.cpython-310.pyc new file mode 100644 index 0000000..a7e1c3a Binary files /dev/null and b/workflows/__pycache__/workflow_spare_parts_statistics.cpython-310.pyc differ diff --git a/workflows/__pycache__/workflow_statistics.cpython-312.pyc b/workflows/__pycache__/workflow_statistics.cpython-312.pyc new file mode 100644 index 0000000..37bd83b Binary files /dev/null and b/workflows/__pycache__/workflow_statistics.cpython-312.pyc differ diff --git a/workflows/__pycache__/workflow_utils.cpython-312.pyc b/workflows/__pycache__/workflow_utils.cpython-312.pyc new file mode 100644 index 0000000..8b90341 Binary files /dev/null and b/workflows/__pycache__/workflow_utils.cpython-312.pyc differ diff --git a/workflows/history_manager.py b/workflows/history_manager.py new file mode 100644 index 0000000..523de8c --- /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 = 10, + 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 = 10, + 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..891318f --- /dev/null +++ b/workflows/workflow_baike.py @@ -0,0 +1,594 @@ +""" +工作流:普通问答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)[:4] + + return { + "rag_search_result": rag_search_result, + "graph_search_result": graph_results if isinstance(graph_results, str) else "" + } + except Exception as e: + return { + "rag_search_result": [], + "graph_search_result": "", + "error_message": f"检索失败: {str(e)}" + } + + + +async def generate_suggested_replies_baike(answer: str, question: str) -> List[Dict[str, Any]]: + """ + 生成针对问答结果的建议回复问题 + 使用大模型生成两个相关问题 + """ + prompt = BAIKE_PROMPTS["generate_suggested_replies"].format( + question=question, + answer=answer[:500] + ) + try: + result = await OpenaiAPI.open_api_chat_without_thinking(prompt, model=None, json_output=True) + # 尝试解析JSON + # 提取JSON部分 + json_match = re.search(r'\[.*\]', result, re.DOTALL) + if json_match: + json_str = json_match.group(0) + suggested_replies = json.loads(json_str) + # 验证格式 + if isinstance(suggested_replies, list) and len(suggested_replies) >= 2: + # 确保每个元素都有title和content + formatted_replies = [] + for reply in suggested_replies[:2]: + if isinstance(reply, dict) and "title" in reply and "content" in reply: + formatted_replies.append({ + "title": str(reply["title"]), + "content": str(reply["content"]) + }) + if len(formatted_replies) == 2: + return formatted_replies + + # 如果解析失败,返回默认问题 + return [ + {"title": "这个设备的具体工作原理是什么?", "content": "这个设备的具体工作原理是什么?"}, + {"title": "还有哪些相关的知识点?", "content": "还有哪些相关的知识点?"} + ] + except Exception as e: + # 如果生成失败,返回默认问题 + return [ + {"title": "这个设备的具体工作原理是什么?", "content": "这个设备的具体工作原理是什么?"}, + {"title": "还有哪些相关的知识点?", "content": "还有哪些相关的知识点?"} + ] + + +@track_function_calls +async def generate_answer(state: QAState) -> Dict[str, Any]: + """ + 正在生成问答结果 + """ + extracted_text = state.get("combined_query", "") + rag_results = state.get("rag_search_result", []) + graph_results = state.get("graph_search_result", "") + history_messages = state.get("history_messages", []) or [] + need_retrieval = state.get("need_retrieval", True) + print("history_message(qa)", history_messages) + print("是否使用检索结果:", need_retrieval) + + try: + # 1. 处理检索结果(如果有) + rag_ctx_parts: List[str] = [] + file_name_list = [] + all_source_text = "" + original_image_paths = [] + + if need_retrieval: + # 收集所有 RAG 结果文本(只取前 4 条) + for item in sorted(rag_results or [], key=lambda x: float(x.get("score") or 0) if isinstance(x,dict) else 0, reverse=True)[:4]: +# for item in (rag_results or [])[:1]: + print(item) + print(111111111111111111111111111) + if isinstance(item, dict): + text = (item.get("text") or "").strip() + file_name = (item.get("filename") or "").strip() + if text: + rag_ctx_parts.append(text) + if file_name: + file_name_list.append(file_name) + + # 处理图谱结果 + print("123456", graph_results) + if graph_results and "图谱检索完成,但未返回有效数据。" in graph_results: + graph_results = "" + print("654321", graph_results) + + # 从所有结果中提取图片路径 + if graph_results: + all_source_text = graph_results + "\n" + "\n".join(rag_ctx_parts) + else: + all_source_text = "\n".join(rag_ctx_parts) + original_image_paths = extract_image_paths(all_source_text) + print("提取到的原始图片路径:", original_image_paths) + + # 发送事件通知(可选) + title_options = [f"✨已综合 {len(rag_ctx_parts)} 条检索结果", f"✨本次检索策略为综合多源信息"] + start_event = { + "type": "function_execution", + "title": random.choice(title_options), + "details": f"综合了 {len(rag_ctx_parts)} 条检索结果" + } + for callback in get_all_callbacks(): + try: + callback(start_event) + except Exception: + pass + + query_desc = extracted_text if extracted_text else "当前未明确描述问题" + + # 构建系统提示词 + if state.get("route_flag") == "rules": + domain_desc = "舰船修理相关的法规、规范、行业标准及技术文件" + else: + domain_desc = "工业设备的结构原理、维护规程、故障诊断与维修操作" + + # ========== 第一步:专注于生成高质量内容 ========== + # 根据是否有检索结果构建不同的提示词 + if need_retrieval and all_source_text.strip(): + content_system_prompt = BAIKE_PROMPTS["generate_answer_with_retrieval_system"].format(domain_desc=domain_desc) + content_user_content = BAIKE_PROMPTS["generate_answer_with_retrieval_user"].format( + extracted_text=extracted_text, + rag_count=len(rag_ctx_parts), + all_source_text=all_source_text + ) + else: + content_system_prompt = BAIKE_PROMPTS["generate_answer_without_retrieval_system"].format(domain_desc=domain_desc) + content_user_content = BAIKE_PROMPTS["generate_answer_without_retrieval_user"].format(extracted_text=extracted_text) + + content_messages = [] + if history_messages: + content_messages.extend(history_messages) + content_messages.append({"role": "user", "content": content_user_content}) + + # 第一步:生成内容 + 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..b9c3725 --- /dev/null +++ b/workflows/workflow_fault_diagnosis.py @@ -0,0 +1,1651 @@ +""" +故障诊断工作流 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 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("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_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=10) + + 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=8, 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 "rag_search" in tools_to_call and rag_results is None: + from utils.ship_number_search import search_with_ship_number_strategy + + base_query = f"{device_name}发生{fault}该如何解决" + + rag_results, matched_kb_name, matched_kb_id, mapped_ship_number = await search_with_ship_number_strategy(base_query=base_query,ship_number=ship_number,top_k=5,search_type="fault") + + if not matched_kb_name: + matched_kb_name = "通用知识库" + + print(f"RAG 检索来源: {matched_kb_name}") + + 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 "graph_rag_search" in tools_to_call and graph_results is None: + if device_name and fault: + try: + graph_query = f"{device_name}发生{fault}该如何解决" + graph_results = await graph_rag_search.ainvoke({"query": graph_query}) + print(f"图谱检索完成,结果长度: {len(graph_results) if graph_results else 0}") + except Exception as e: + print(f"图谱检索失败: {str(e)}") + graph_results = "" + + # 图册检索:只传入设备名称 + 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=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 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 or [], key=lambda x: float(x.get("score") or 0) if isinstance(x,dict) else 0, reverse=True)[:4]: +# 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) + 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}" + answer = answer +# 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=8, 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..27dcb90 --- /dev/null +++ b/workflows/workflow_operate.py @@ -0,0 +1,1457 @@ +""" +操作指导工作流 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 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 = "" + + if "rag_search" in tools_to_call and rag_results is None: + from utils.ship_number_search import search_with_ship_number_strategy + + base_query = f"{device_name}进行{operation_item}的操作方案" + + rag_results, matched_kb_name, matched_kb_id, _mapped_ship_number = await search_with_ship_number_strategy(base_query=base_query,ship_number=ship_number,top_k=5,search_type="operate") + + if not matched_kb_name: + matched_kb_name = "通用知识库" + + print(f"RAG 检索来源: {matched_kb_name}") + + if "graph_rag_search" in tools_to_call and graph_results is None: + if device_name and operation_item: + try: + graph_query = f"{device_name}进行{operation_item}的操作方案" + graph_results = await graph_rag_search.ainvoke({"query": graph_query}) + print(f"图谱检索完成,结果长度: {len(graph_results) if graph_results else 0}") + except Exception as e: + print(f"图谱检索失败: {str(e)}") + graph_results = "" + + 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 or [], key=lambda x: float(x.get("score") or 0) if isinstance(x,dict) else 0, reverse=True)[:4]: +# 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) + 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 = "" + 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(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..4a92ba8 --- /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("base_url") + download_url = f"{base_url}/api/download/{file_name}" + + actions.append({ + "type": "download", + "label": f"{report_title}.docx", + "url": download_url, + "fileName": file_name, + "content": report_text + }) + else: + error_msg = f"\n\n[注意] Word 文档生成失败" + report_text += error_msg + + # actions.append({ + # "type": "regenerate", + # "label": "重新生成", + # "icon": "report" + # }) + + suggested_replies = await generate_suggested_replies_qa_report(report_text) + + return { + "report_text": report_text, + "response": report_text, + "actions": actions, + "route_flag": "report", + "suggestedReplies": [], +# "suggestedReplies": suggested_replies, + "__details__": "分析报告生成完成" + } + except Exception as e: + # error_event = { + # "type": "function_execution", + # "title": "步骤二失败:生成分析报告", + # "details": f"错误: {str(e)}", + # "has_result": False + # } + # for callback in get_all_callbacks(): + # try: + # callback(error_event) + # except Exception: + # pass + + return { + "report_text": f"生成分析报告失败: {str(e)}", + "response": f"生成分析报告失败: {str(e)}", + "actions": [], + "route_flag": "report", + "suggestedReplies": [], + "error_message": str(e), + "__details__": f"生成分析报告失败: {str(e)}" + } + + +async def generate_suggested_replies_qa_report(report: str) -> List[Dict[str, Any]]: + """ + 生成针对历史问答分析报告的建议回复问题 + 使用大模型生成两个相关问题 + """ + prompt = f""" +你是一个专业的数据分析助手。根据以下历史问答分析报告,生成两个用户可能关心的后续问题。 + +【分析报告】 +{report[:500]} + +请生成两个简洁、实用的问题,这些问题应该: +1. 不得出现需要归档和下载等操作相关的问题 +2. 与分析报告相关,能够帮助用户更好地理解或使用分析结果 +3. 问题要具体、可操作 +4. 每个问题不超过20个字 + +请以JSON格式输出,格式如下: +[ + {{"title": "问题1的标题", "content": "问题1的完整内容"}}, + {{"title": "问题2的标题", "content": "问题2的完整内容"}} +] + +只输出JSON,不要有其他文字说明。 +""" + try: + answer = await OpenaiAPI.open_api_chat_without_thinking( + query=prompt, + model=None + ) + json_match = re.search(r'\[.*\]', answer, re.DOTALL) + if json_match: + json_str = json_match.group(0) + suggested_replies = json.loads(json_str) + if isinstance(suggested_replies, list) and len(suggested_replies) >= 2: + formatted_replies = [] + for reply in suggested_replies[:2]: + if isinstance(reply, dict) and "title" in reply and "content" in reply: + formatted_replies.append({ + "title": str(reply["title"]), + "content": str(reply["content"]) + }) + if len(formatted_replies) == 2: + return formatted_replies + + return [ + {"title": "如何优化问题分布?", "content": "如何优化问题分布?"}, + {"title": "有哪些改进建议?", "content": "有哪些改进建议?"} + ] + except Exception as e: + return [ + {"title": "如何优化问题分布?", "content": "如何优化问题分布?"}, + {"title": "有哪些改进建议?", "content": "有哪些改进建议?"} + ] + + +def create_history_qa_analysis_workflow(): + graph = StateGraph(HistoryQAAnalysisState) + + graph.add_node("extract_items", extract_analysis_items) + graph.add_node("generate_text", generate_analysis_report_text) + + graph.add_edge(START, "extract_items") + graph.add_edge("extract_items", "generate_text") + graph.add_edge("generate_text", END) + + return graph.compile() + + +# @track_function_calls +async def run_qa_report_agent( + extracted_text: str, + combined_query: str, + history_message: str = "", + route_flag: str = "", + extracted_images: List[Dict[str, str]] = None +) -> Dict[str, Any]: + app = create_history_qa_analysis_workflow() + + initial_state: HistoryQAAnalysisState = { + "extracted_text": extracted_text, + "combined_query": combined_query, + **init_history(history_message), + "extracted_items": [], + "extracted_images": extracted_images or [], + "report_title": "", + "report_text": "", + "response": "", + "actions": [], + "route_flag": "", + "suggestedReplies": [], + "error_message": "" + } + + return await app.ainvoke(initial_state) + + +if __name__ == "__main__": + import asyncio + + + async def test(): + history_message = json.dumps( + [ + {"role": "user", "content": "我需要分析最近的历史问答数据"}, + {"role": "assistant", "content": "好的,我可以帮您生成历史问答分析报告,请提供相关数据。"}, + {"role": "user", "content": "请重点关注设备故障和维护相关的内容"} + ], + ensure_ascii=False, + ) + + result = await run_qa_report_agent( + extracted_text="请重点关注设备故障和维护相关的内容", + combined_query="用户问题:请重点关注设备故障和维护相关的内容", + history_message=history_message + ) + + print("====== 历史问答分析报告文本 ======") + print(result["response"]) + print("\n====== 推荐问题 ======") + for reply in result.get("suggestedReplies", []): + print(f"- {reply['title']}: {reply['content']}") + print("\n====== 操作按钮 ======") + for action in result.get("actions", []): + print(f"- {action.get('label', 'Unknown')}: {action.get('type', 'Unknown')}") + + + asyncio.run(test()) + + + 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 + +