diff --git a/__pycache__/app.cpython-310.pyc b/__pycache__/app.cpython-310.pyc new file mode 100644 index 0000000..02147f4 Binary files /dev/null and b/__pycache__/app.cpython-310.pyc differ diff --git a/__pycache__/checkpointer_config.cpython-310.pyc b/__pycache__/checkpointer_config.cpython-310.pyc new file mode 100644 index 0000000..086a03f Binary files /dev/null and b/__pycache__/checkpointer_config.cpython-310.pyc differ diff --git a/__pycache__/chunk_text.cpython-310.pyc b/__pycache__/chunk_text.cpython-310.pyc new file mode 100644 index 0000000..d2495be Binary files /dev/null and b/__pycache__/chunk_text.cpython-310.pyc differ diff --git a/__pycache__/config.cpython-310.pyc b/__pycache__/config.cpython-310.pyc new file mode 100644 index 0000000..7d81aa1 Binary files /dev/null and b/__pycache__/config.cpython-310.pyc differ diff --git a/__pycache__/doc2pdf.cpython-310.pyc b/__pycache__/doc2pdf.cpython-310.pyc new file mode 100644 index 0000000..ca376dc Binary files /dev/null and b/__pycache__/doc2pdf.cpython-310.pyc differ diff --git a/__pycache__/documents_prompt.cpython-310.pyc b/__pycache__/documents_prompt.cpython-310.pyc new file mode 100644 index 0000000..7edfc73 Binary files /dev/null and b/__pycache__/documents_prompt.cpython-310.pyc differ diff --git a/__pycache__/filename_proceess_and_kgquery.cpython-310.pyc b/__pycache__/filename_proceess_and_kgquery.cpython-310.pyc new file mode 100644 index 0000000..514a7ba Binary files /dev/null and b/__pycache__/filename_proceess_and_kgquery.cpython-310.pyc differ diff --git a/__pycache__/fileparse_util.cpython-310.pyc b/__pycache__/fileparse_util.cpython-310.pyc new file mode 100644 index 0000000..70c93d2 Binary files /dev/null and b/__pycache__/fileparse_util.cpython-310.pyc differ diff --git a/__pycache__/gw_write.cpython-310.pyc b/__pycache__/gw_write.cpython-310.pyc new file mode 100644 index 0000000..3cd0990 Binary files /dev/null and b/__pycache__/gw_write.cpython-310.pyc differ diff --git a/__pycache__/main_agent.cpython-310.pyc b/__pycache__/main_agent.cpython-310.pyc new file mode 100644 index 0000000..51f6d94 Binary files /dev/null and b/__pycache__/main_agent.cpython-310.pyc differ diff --git a/__pycache__/prompts.cpython-310.pyc b/__pycache__/prompts.cpython-310.pyc new file mode 100644 index 0000000..93ea0b3 Binary files /dev/null and b/__pycache__/prompts.cpython-310.pyc differ diff --git a/__pycache__/resetlevel.cpython-310.pyc b/__pycache__/resetlevel.cpython-310.pyc new file mode 100644 index 0000000..d24459b Binary files /dev/null and b/__pycache__/resetlevel.cpython-310.pyc differ diff --git a/__pycache__/workflow_registry.cpython-310.pyc b/__pycache__/workflow_registry.cpython-310.pyc new file mode 100644 index 0000000..4e13b0b Binary files /dev/null and b/__pycache__/workflow_registry.cpython-310.pyc differ diff --git a/app.py b/app.py index 666c85f..a2c4176 100644 --- a/app.py +++ b/app.py @@ -629,6 +629,76 @@ class FeedbackClassifyRequest(BaseModel): +def _citation_display_filename( + value: Any, + file_id: Any = "", +) -> Any: + """仅转换文本溯源展示名,不修改 file_id 或实际文件路径。""" + if not isinstance(value, str): + return value + if not re.search(r"(?i)\.(?:pptx|ppt)$", value.strip()): + return value + normalized_file_id = str(file_id or "").strip() + if not normalized_file_id: + return value + return f"{normalized_file_id}.pdf" + + +def normalize_source_citation_filenames( + source_citation: Dict[str, Any], +) -> Dict[str, Any]: + """将最终文本溯源中的 PPT/PPTX 展示后缀统一转换为 PDF。""" + if not isinstance(source_citation, dict): + return source_citation + + normalized: Dict[str, Any] = {} + for resource_name, items in source_citation.items(): + group_file_id = "" + if isinstance(items, list): + group_file_id = next( + ( + item.get("file_id") + for item in items + if isinstance(item, dict) and item.get("file_id") + ), + "", + ) + display_resource = _citation_display_filename( + resource_name, + group_file_id, + ) + + if isinstance(items, list): + normalized_items = [] + for item in items: + if not isinstance(item, dict): + normalized_items.append(item) + continue + normalized_item = dict(item) + item_file_id = normalized_item.get("file_id") or group_file_id + for field in ("filename", "resource", "title"): + if field in normalized_item: + normalized_item[field] = _citation_display_filename( + normalized_item[field], + item_file_id, + ) + normalized_items.append(normalized_item) + else: + normalized_items = items + + # 极少数同名 .ppt/.pptx 会映射到同一 .pdf,保留全部切片。 + if ( + display_resource in normalized + and isinstance(normalized[display_resource], list) + and isinstance(normalized_items, list) + ): + normalized[display_resource].extend(normalized_items) + else: + normalized[display_resource] = normalized_items + + return normalized + + def format_stream_event(event_type: str, data: Dict[str, Any], chat_id: str = "", @@ -690,7 +760,9 @@ def format_stream_event(event_type: str, if has_rag or has_graph: source_citation = {} if has_rag: - source_citation.update(rag_result) + source_citation.update( + normalize_source_citation_filenames(rag_result) + ) if has_graph: source_citation["xxxx.graph"] = graph_result @@ -936,6 +1008,7 @@ async def stream_main_agent_execution( agent_task = asyncio.create_task(run_agent()) execution_complete_received = False + has_streamed_content = False rag_result = [] graph_result = [] while True: @@ -953,6 +1026,11 @@ async def stream_main_agent_execution( if event_type == "function_execution": logger.info(f"function_execution event - title: {title}, has_result: {result is not None}, result_type: {type(result)}") + # 装饰器产生的无标题事件属于内部入参/返回值,不应在前端展示。 + # 前端会把它们自动编号成“步骤 2”“步骤 5”,从而泄露检索原文 + # 和未整理的回答草稿。 + if not title.strip(): + continue if title == "知识库搜索工具" and result and isinstance(result, dict): print("===============================", result) rag_result_raw = result.get("sourceCitation", {}) @@ -985,9 +1063,11 @@ async def stream_main_agent_execution( 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) + if content: + has_streamed_content = True + yield format_stream_event("stream_content", { + "content": content + }, chat_id=chat_id, message_id=message_id) except asyncio.TimeoutError: @@ -1005,8 +1085,13 @@ async def stream_main_agent_execution( }, chat_id=chat_id, message_id=message_id) else: title = await extract_conversation_title(final_result.get("content", "") if final_result else "") + frontend_final_result = final_result + if has_streamed_content and final_result: + # 正文已按增量流式发送,完成事件只补充 actions 等元数据。 + # 再携带完整 content 会被前端追加为第二份重复正文。 + frontend_final_result = {**final_result, "content": ""} yield format_stream_event("execution_complete", { - "final_result": final_result + "final_result": frontend_final_result }, chat_id=chat_id, message_id=message_id) yield format_stream_event("execution_complete", { diff --git a/config.py b/config.py index a838438..e8122b9 100644 --- a/config.py +++ b/config.py @@ -37,7 +37,7 @@ _request_user_config: ContextVar[Optional[Dict[str, str]]] = ContextVar('request # RAG配置的默认值 _RAG_CONFIG_DEFAULT = { - "search_endpoint": "http://192.168.0.46:11000/api/v1/knowledge/search/", + "search_endpoint": "http://192.168.0.46:11100/api/v1/knowledge/search/", "x-user-id": "1", "x-user-name": "testuser", "x-role": "admin", @@ -180,7 +180,7 @@ NEO4J_CONFIG = { # ==================== 知识库匹配配置 ==================== KB_TREE_CONFIG = { - "url": "http://192.168.0.46:11000/api/v1/knowledge/knowledge_base/tree" + "url": "http://192.168.0.46:11100/api/v1/knowledge/knowledge_base/tree" } # ==================== 维修反馈统计配置 ==================== @@ -214,6 +214,17 @@ MAP_INS={ "舷号" : "ship_number", } +# ==================== Rerank 服务配置 ==================== +RERANK_CONFIG = { + "enabled": "true".lower() == "true", + "endpoint": "http://192.168.0.46:59600/v1/rerank", + "model": "bge-rerank", + "timeout": 30.0, + "max_candidates": 24, + "top_k": 10, +} + + # ==================== neo4j配置 ==================== NEO4J_CONFIG = { "uri": "neo4j://192.168.0.46:57687", @@ -242,4 +253,4 @@ SHIP_MODEL_NAME = "/app/ship_xinghao.json" TREE_JSON_PATH = "/app/tree_data.json" # ==================== mineru和kgrag目录映射地址 ==================== SEARCH_DIR ="/app/mineru_output" -IMAGE_DIR = "/app/mineru_output/images" \ No newline at end of file +IMAGE_DIR = "/app/mineru_output/images" diff --git a/main_agent.py.bak b/main_agent.py.bak new file mode 100644 index 0000000..3ba9199 --- /dev/null +++ b/main_agent.py.bak @@ -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/main_agent.py~ b/main_agent.py~ new file mode 100644 index 0000000..e69de29 diff --git a/modelsAPI/__pycache__/__init__.cpython-310.pyc b/modelsAPI/__pycache__/__init__.cpython-310.pyc index 7ce8c18..b1007b9 100644 Binary files a/modelsAPI/__pycache__/__init__.cpython-310.pyc and b/modelsAPI/__pycache__/__init__.cpython-310.pyc differ diff --git a/modelsAPI/__pycache__/model_api.cpython-310.pyc b/modelsAPI/__pycache__/model_api.cpython-310.pyc index f433fd5..b5604eb 100644 Binary files a/modelsAPI/__pycache__/model_api.cpython-310.pyc and b/modelsAPI/__pycache__/model_api.cpython-310.pyc differ diff --git a/modelsAPI/model_api.py b/modelsAPI/model_api.py index f6bfdf3..2cade31 100644 --- a/modelsAPI/model_api.py +++ b/modelsAPI/model_api.py @@ -4,7 +4,7 @@ from typing import List, Union import os import numpy as np from openai import OpenAI -from config import LLM_CONFIG, EMBEDDING_CONFIG +from config import LLM_CONFIG, EMBEDDING_CONFIG, RERANK_CONFIG from openai import AsyncOpenAI @@ -216,6 +216,66 @@ class OpenaiAPI: print(error_msg) return f"Error: {str(e)}" + @staticmethod + async def rerank( + query: str, + documents: List[str], + top_k: int = None, + model: str = None, + ) -> List[dict]: + """ + 调用 59600 rerank 服务。 + + 返回格式: + [ + {"index": 0, "relevance_score": 0.98}, + ... + ] + """ + if not RERANK_CONFIG.get("enabled", True) or not query or not documents: + return [] + + max_candidates = int(RERANK_CONFIG.get("max_candidates") or 24) + selected_documents = [str(x or "")[:4000] for x in documents[:max_candidates]] + + try: + async with httpx.AsyncClient( + timeout=float(RERANK_CONFIG.get("timeout") or 30), + verify=False, + trust_env=False, + ) as client: + response = await client.post( + str(RERANK_CONFIG["endpoint"]), + json={ + "model": model or RERANK_CONFIG.get("model") or "bge-rerank", + "query": query, + "documents": selected_documents, + }, + ) + response.raise_for_status() + payload = response.json() + except Exception as exc: + print(f"[rerank] rerank service failed: {exc}") + return [] + + rows: List[dict] = [] + for row in payload.get("results", []): + try: + idx = int(row.get("index")) + except Exception: + continue + if idx < 0 or idx >= len(selected_documents): + continue + rows.append({ + "index": idx, + "relevance_score": float(row.get("relevance_score") or 0.0), + }) + + rows.sort(key=lambda x: float(x.get("relevance_score") or 0), reverse=True) + if top_k is not None: + rows = rows[:top_k] + return rows + @staticmethod def get_embeddings(embedding_text: str): embedding_base_url = EMBEDDING_CONFIG["base_url"] diff --git a/modelsAPI/model_api.py.bak b/modelsAPI/model_api.py.bak new file mode 100644 index 0000000..4473b6f --- /dev/null +++ b/modelsAPI/model_api.py.bak @@ -0,0 +1,368 @@ +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 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 + + + + +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/prompt.pt_0721 b/prompt.pt_0721 new file mode 100644 index 0000000..0311e91 --- /dev/null +++ b/prompt.pt_0721 @@ -0,0 +1,1232 @@ +""" +提示词集中管理模块 +将各工作流中的提示词统一提取到此处,方便调整和维护。 +每个提示词都附有详细注释说明其用途和所属工作流节点。 + +模块结构: +- 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": ( + "You are an intent classifier for a knowledge-base QA assistant. Decide whether the user question needs retrieval.\n" + "\n" + "Return true when the question asks about documents, files, knowledge-base content, equipment, systems, faults, operations, standards, procedures, meeting notes, database-related materials, or any factual/domain-specific information.\n" + "Return false only for pure greetings, thanks, cancellation/confirmation with no substantive question, or a follow-up that can be fully answered from the conversation history alone.\n" + "Default rule: when unsure, return true.\n" + "\n" + "Conversation history:\n" + "{history_messages}\n" + "\n" + "User question:\n" + "{original_query}\n" + "\n" + "Output only true or 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/prompts.py b/prompts.py index e056a7b..962f533 100644 --- a/prompts.py +++ b/prompts.py @@ -470,7 +470,7 @@ BAIKE_PROMPTS = { # 输入变量: question, answer # ------------------------------------------------------------------ "generate_suggested_replies": ( - "你是一个专业的公文问答助手。根据以下问答内容,生成两个用户可能关心的后续问题。\n" + "你是一个专业的工业设备问答助手。根据以下问答内容,生成两个用户可能关心的后续问题。\n" "\n" "【用户问题】\n" "{question}\n" @@ -498,7 +498,13 @@ BAIKE_PROMPTS = { # 输入变量: domain_desc # ------------------------------------------------------------------ "generate_answer_with_retrieval_system": ( - "我是一个专业的{domain_desc}知识问答助手,根据用户的问题,综合所有检索结果,提供准确、专业、完整的回答内容。" + "You are a professional knowledge-base assistant for {domain_desc}.\n" + "Answer ONLY from the retrieved evidence provided by the user message. Do not use prior knowledge as factual evidence.\n" + "If the evidence is insufficient, say clearly what is missing and what can be confirmed from the evidence.\n" + "Prefer concrete facts, names, parameters, steps, conclusions, and source relationships from the evidence.\n" + "When sources conflict, explain the conflict instead of inventing a merged fact.\n" + "Use images only when their URLs appear in the retrieved evidence; never fabricate or repair image URLs.\n" + "Always respond in Chinese. Do not expose internal tool names or implementation details." ), # ------------------------------------------------------------------ @@ -506,18 +512,20 @@ BAIKE_PROMPTS = { # 输入变量: extracted_text, rag_count, all_source_text # ------------------------------------------------------------------ "generate_answer_with_retrieval_user": ( + "用户问题:\n" "{extracted_text}\n" "\n" - "# 检索到的相关信息(共 {rag_count} 条):\n" + "# Retrieved evidence ({rag_count} items)\n" "\n" "{all_source_text}\n" "\n" "【输出要求】\n" - "1. **重要**:如果参考资料中包含图片链接(格式为 `![图片](URL)` 或 `images/xxx.jpg`),请务必在相关位置自然展示使用,如果参考资料中没有图片,**不要输出任何关于图片的说明**\n" - "2. 如果参考资料包含 LaTeX 公式、连接等,请在对应位置正常使用,不得修改或删除\n" - "3. 请专注于内容的准确性、完整性和逻辑性,不要担心格式的美观性\n" - "4. 可以适当使用简单的段落分隔,但不要使用复杂的Markdown格式\n" - "5. 如果参考资料包含表格,请不要输出表格,而要直接描述表格的内容\n" + "1. 先直接回答用户问题,再补充必要依据。\n" + "2. 每个关键事实都必须能在 Retrieved evidence 中找到依据;不要补充资料外结论。\n" + "3. 如果资料只能说明部分内容,请明确说“资料中只能确认...”和“资料中未提供...”。\n" + "4. LaTeX 格式必须严格区分:变量或很短的表达式使用 $...$;包含等号、求和、积分、分式或较长的完整方程必须使用独立段落,且 $$ 必须各自单独占一行。两条独立公式必须分别使用两个公式块,两个公式块之间保留一个空行,不能写进同一个公式块或紧挨在一起。不要把完整方程嵌在列表正文中。\n" + "5. 参考资料中真实存在的图片可在对应段落附近展示;URL 必须逐字来自资料。\n" + "6. 表格内容请转成自然语言描述,不要输出复杂表格。\n" "\n" "请给出回复:" ), @@ -541,6 +549,7 @@ BAIKE_PROMPTS = { "1. 请根据您的专业知识提供准确、完整的回答\n" "2. 可以适当使用简单的段落分隔,但不要使用复杂的Markdown格式\n" "3. 请专注于内容的准确性和专业性\n" + "4. LaTeX 格式必须严格区分:变量或很短的表达式使用 $...$;包含等号、求和、积分、分式或较长的完整方程必须使用独立段落,且 $$ 必须各自单独占一行。两条独立公式必须分别使用两个公式块,两个公式块之间保留一个空行,不能写进同一个公式块或紧挨在一起。不要把完整方程嵌在列表正文中。\n" "\n" "请给出回复:" ), @@ -1228,4 +1237,3 @@ OPERATE_PROMPTS = { "请给出一般性的操作指导建议:" ), } - diff --git a/tools/__pycache__/__init__.cpython-310.pyc b/tools/__pycache__/__init__.cpython-310.pyc index 73c380d..9ba6da5 100644 Binary files a/tools/__pycache__/__init__.cpython-310.pyc and b/tools/__pycache__/__init__.cpython-310.pyc differ diff --git a/tools/__pycache__/agent_usage_statistics.cpython-310.pyc b/tools/__pycache__/agent_usage_statistics.cpython-310.pyc new file mode 100644 index 0000000..5bdf858 Binary files /dev/null and b/tools/__pycache__/agent_usage_statistics.cpython-310.pyc differ diff --git a/tools/__pycache__/fault_record_db.cpython-310.pyc b/tools/__pycache__/fault_record_db.cpython-310.pyc new file mode 100644 index 0000000..cf23432 Binary files /dev/null and b/tools/__pycache__/fault_record_db.cpython-310.pyc differ diff --git a/tools/__pycache__/fault_statistics.cpython-310.pyc b/tools/__pycache__/fault_statistics.cpython-310.pyc new file mode 100644 index 0000000..917fd28 Binary files /dev/null and b/tools/__pycache__/fault_statistics.cpython-310.pyc differ diff --git a/tools/__pycache__/function_tool.cpython-310.pyc b/tools/__pycache__/function_tool.cpython-310.pyc index 1206824..d4fd75b 100644 Binary files a/tools/__pycache__/function_tool.cpython-310.pyc and b/tools/__pycache__/function_tool.cpython-310.pyc differ diff --git a/tools/__pycache__/graph_tools.cpython-310.pyc b/tools/__pycache__/graph_tools.cpython-310.pyc new file mode 100644 index 0000000..85a0062 Binary files /dev/null and b/tools/__pycache__/graph_tools.cpython-310.pyc differ diff --git a/tools/__pycache__/rag_tools.cpython-310.pyc b/tools/__pycache__/rag_tools.cpython-310.pyc new file mode 100644 index 0000000..6c854b8 Binary files /dev/null and b/tools/__pycache__/rag_tools.cpython-310.pyc differ diff --git a/tools/__pycache__/ship_model_db.cpython-310.pyc b/tools/__pycache__/ship_model_db.cpython-310.pyc new file mode 100644 index 0000000..074ca5c Binary files /dev/null and b/tools/__pycache__/ship_model_db.cpython-310.pyc differ diff --git a/tools/__pycache__/vlm_tools.cpython-310.pyc b/tools/__pycache__/vlm_tools.cpython-310.pyc new file mode 100644 index 0000000..7e25b97 Binary files /dev/null and b/tools/__pycache__/vlm_tools.cpython-310.pyc differ diff --git a/utils/__pycache__/__init__.cpython-310.pyc b/utils/__pycache__/__init__.cpython-310.pyc index b4cb04d..8cf1caa 100644 Binary files a/utils/__pycache__/__init__.cpython-310.pyc and b/utils/__pycache__/__init__.cpython-310.pyc differ diff --git a/utils/__pycache__/function_tracker.cpython-310.pyc b/utils/__pycache__/function_tracker.cpython-310.pyc index 9846d10..fa01e63 100644 Binary files a/utils/__pycache__/function_tracker.cpython-310.pyc and b/utils/__pycache__/function_tracker.cpython-310.pyc differ diff --git a/utils/__pycache__/image_utils.cpython-310.pyc b/utils/__pycache__/image_utils.cpython-310.pyc new file mode 100644 index 0000000..47958fd Binary files /dev/null and b/utils/__pycache__/image_utils.cpython-310.pyc differ diff --git a/utils/__pycache__/intent_matcher.cpython-310.pyc b/utils/__pycache__/intent_matcher.cpython-310.pyc new file mode 100644 index 0000000..addad3c Binary files /dev/null and b/utils/__pycache__/intent_matcher.cpython-310.pyc differ diff --git a/utils/__pycache__/word_generator.cpython-310.pyc b/utils/__pycache__/word_generator.cpython-310.pyc index 157de49..5e4a4d2 100644 Binary files a/utils/__pycache__/word_generator.cpython-310.pyc and b/utils/__pycache__/word_generator.cpython-310.pyc differ diff --git a/workflows/.workflow_baike.py.swp b/workflows/.workflow_baike.py.swp new file mode 100644 index 0000000..e3896da Binary files /dev/null and b/workflows/.workflow_baike.py.swp differ diff --git a/workflows/__init__.py b/workflows/__init__.py index 12a2821..08d3aab 100644 --- a/workflows/__init__.py +++ b/workflows/__init__.py @@ -2,7 +2,7 @@ 工作流模块 包含所有工作流Agent """ -from .workflow_fault_diagnosis import run_fault_diagnosis_workflow +from .workflow_baike import run_baike_workflow # 其他工作流暂未实现,待后续添加 # from .workflow_repair import run_repair_workflow @@ -11,10 +11,11 @@ from .workflow_fault_diagnosis import run_fault_diagnosis_workflow # from .workflow_report import run_report_workflow __all__ = [ - "run_fault_diagnosis_workflow", + "run_baike_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 index 7975dd0..8e55d2e 100644 Binary files a/workflows/__pycache__/__init__.cpython-310.pyc and b/workflows/__pycache__/__init__.cpython-310.pyc differ diff --git a/workflows/__pycache__/history_manager.cpython-310.pyc b/workflows/__pycache__/history_manager.cpython-310.pyc new file mode 100644 index 0000000..1fa781b Binary files /dev/null and b/workflows/__pycache__/history_manager.cpython-310.pyc differ diff --git a/workflows/__pycache__/workflow_baike.cpython-310.pyc b/workflows/__pycache__/workflow_baike.cpython-310.pyc index 9f5cb82..0e285ef 100644 Binary files a/workflows/__pycache__/workflow_baike.cpython-310.pyc and b/workflows/__pycache__/workflow_baike.cpython-310.pyc differ diff --git a/workflows/__pycache__/workflow_fault_diagnosis.cpython-310.pyc b/workflows/__pycache__/workflow_fault_diagnosis.cpython-310.pyc index b07e218..6d44da0 100644 Binary files a/workflows/__pycache__/workflow_fault_diagnosis.cpython-310.pyc and b/workflows/__pycache__/workflow_fault_diagnosis.cpython-310.pyc differ diff --git a/workflows/__pycache__/workflow_utils.cpython-310.pyc b/workflows/__pycache__/workflow_utils.cpython-310.pyc new file mode 100644 index 0000000..412c7f2 Binary files /dev/null and b/workflows/__pycache__/workflow_utils.cpython-310.pyc differ diff --git a/workflows/history_manager.py b/workflows/history_manager.py index 88333f6..0654dfb 100644 --- a/workflows/history_manager.py +++ b/workflows/history_manager.py @@ -7,14 +7,17 @@ import json from typing import Any, Dict, List, Optional +DEFAULT_HISTORY_MAX_MESSAGES = 12 +DEFAULT_ASSISTANT_TRUNCATE = 1000 + + class HistoryManager: """统一的历史对话管理器,提供解析、截断、格式化、过滤等功能""" - ALLOWED_HISTORY_ROLES = {"user", "assistant"} def __init__( self, - max_messages: int = 10, - max_assistant_chars: int = 300, + max_messages: int = DEFAULT_HISTORY_MAX_MESSAGES, + max_assistant_chars: int = DEFAULT_ASSISTANT_TRUNCATE, ): """ Args: @@ -35,18 +38,7 @@ class HistoryManager: return [] try: data = json.loads(history_message) if isinstance(history_message, str) else history_message - if not isinstance(data, list): - return [] - - messages = [] - for message in data: - if not isinstance(message, dict): - continue - role = message.get("role") - content = message.get("content") - if role in self.ALLOWED_HISTORY_ROLES and content: - messages.append({"role": role, "content": content}) - return messages[-self.max_messages:] + return data[-self.max_messages:] if isinstance(data, list) else [] except Exception: return [] @@ -96,11 +88,7 @@ class HistoryManager: return [] result = [] for m in messages: - if ( - isinstance(m, dict) - and m.get("role") in self.ALLOWED_HISTORY_ROLES - and m.get("content") - ): + if isinstance(m, dict) and m.get("role") and m.get("content"): result.append({"role": m["role"], "content": m["content"]}) return result @@ -208,8 +196,8 @@ def parse_history(history_message: Any) -> List[Dict[str, Any]]: def build_history_str( history_messages: List[Dict[str, Any]], - recent_count: int = 10, - assistant_truncate: int = 300, + recent_count: int = DEFAULT_HISTORY_MAX_MESSAGES, + assistant_truncate: int = DEFAULT_ASSISTANT_TRUNCATE, ) -> str: """兼容旧调用的模块级函数""" mgr = HistoryManager(max_messages=recent_count, max_assistant_chars=assistant_truncate) diff --git a/workflows/history_manager.py.bak b/workflows/history_manager.py.bak new file mode 100644 index 0000000..523de8c --- /dev/null +++ b/workflows/history_manager.py.bak @@ -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/history_manager.py_0723 b/workflows/history_manager.py_0723 new file mode 100644 index 0000000..88333f6 --- /dev/null +++ b/workflows/history_manager.py_0723 @@ -0,0 +1,233 @@ +""" +统一历史对话管理器 +整合所有分散的 parse_history / _parse_history / build_history_str / filter_image_urls 逻辑 +""" + +import json +from typing import Any, Dict, List, Optional + + +class HistoryManager: + """统一的历史对话管理器,提供解析、截断、格式化、过滤等功能""" + ALLOWED_HISTORY_ROLES = {"user", "assistant"} + + 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 + if not isinstance(data, list): + return [] + + messages = [] + for message in data: + if not isinstance(message, dict): + continue + role = message.get("role") + content = message.get("content") + if role in self.ALLOWED_HISTORY_ROLES and content: + messages.append({"role": role, "content": content}) + return messages[-self.max_messages:] + 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") in self.ALLOWED_HISTORY_ROLES + 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 index 891318f..2f5e598 100644 --- a/workflows/workflow_baike.py +++ b/workflows/workflow_baike.py @@ -1,594 +1,560 @@ """ -工作流:普通问答Agent(使用LangGraph) -输入:文本(转化后和原文本query)、图片描述、语音文本 -步骤: -1. 使用RAG和GraphRAG检索相关信息 -2. 生成回答 -使用 OpenaiAPI 方法调用大模型,尽可能使用异步 +百科问答工作流。 + +核心链路借鉴 WeKnora: +1. 意图判断默认偏向检索 +2. 基于历史重写检索 query,并生成多个检索变体 +3. RAG / Graph 并行召回 +4. 多源 evidence 去重、融合排序、rerank +5. 只基于 evidence 生成可追溯回答 """ -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 os import random +import re +import sys +from typing import Any, Dict, List, Optional, TypedDict + +from langgraph.graph import END, START, StateGraph + +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from modelsAPI.model_api import OpenaiAPI +from prompts import BAIKE_PROMPTS +from tools.function_tool import graph_rag_search +from tools.rag_tools import rag_search +from utils.function_tracker import get_all_callbacks, track_function_calls +from utils.image_utils import extract_image_paths +from workflows.history_manager import DEFAULT_HISTORY_MAX_MESSAGES, init_history +from workflows.workflow_utils import dedupe_rag_results, stream_generate_and_postprocess + + +BAIKE_RAG_TOP_K = 8 +BAIKE_GRAPH_TOP_K = 240 +BAIKE_RESULT_LIMIT = 8 +BAIKE_EVIDENCE_LIMIT = 10 -# =============== 定义状态 =============== class QAState(TypedDict): - """普通问答工作流状态""" - extracted_text: str # 已提取的文本(统一接口,只接收文本) + 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] # 是否需要检索 + retrieval_query: str + retrieval_queries: List[str] + route_flag: str + history_message: str + history_messages: List[Dict[str, Any]] + need_retrieval: Optional[bool] + rag_search_result: Optional[List[Dict[str, Any]]] + graph_search_result: Optional[str] + evidence_items: Optional[List[Dict[str, Any]]] + response: str + actions: List[Dict[str, Any]] + suggestedReplies: List[Dict[str, Any]] + error_message: str - # 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) + return init_history(state.get("history_message", "") or "") 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 + history_messages=history_messages if history_messages else "无", + original_query=original_query, ) - - need_retrieval = True + + substantive_query = _looks_like_substantive_query(original_query) + need_retrieval = substantive_query 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 - - + if not substantive_query and result and result.strip().lower() == "false": + need_retrieval = False + except Exception as exc: + print(f"判断是否需要检索失败: {exc}") print(f"是否需要检索: {need_retrieval}") return {"need_retrieval": need_retrieval} +def _looks_like_substantive_query(query: str) -> bool: + text = re.sub(r"\s+", "", (query or "").strip()) + if not text: + return False + if re.fullmatch(r"(你好|您好|hi|hello|在吗|谢谢|多谢|好的|好|嗯|哦|确认|取消|不用了)[。!!?\s]*", text, re.I): + return False + if len(text) >= 4: + return True + return bool(re.search(r"[??]|[A-Za-z0-9]{2,}", text)) + + 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(如果有) + trimmed_history = history_messages[-DEFAULT_HISTORY_MAX_MESSAGES:] if history_messages else [] + history_str = _json_dumps(trimmed_history) 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 + for msg in reversed(trimmed_history): + if msg.get("role") == "user": + last_user_query = str(msg.get("content") or "").strip() + break + + if not original_query: + return {"retrieval_query": "", "retrieval_queries": []} + prompt = BAIKE_PROMPTS["rewrite_query_with_history"].format( - last_user_query=last_user_query or '(无)', + last_user_query=last_user_query or "无", history_str=history_str, - original_query=original_query + original_query=original_query, ) rewritten_query = original_query + query_variants: List[str] = [] 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 + raw = await OpenaiAPI.open_api_chat_without_thinking(prompt, model=None, json_output=True) + data = _parse_json_object(raw) + if data: + candidate = str(data.get("rewrite_query") or "").strip() + if candidate: + rewritten_query = candidate[:180] + for q in data.get("search_queries") or []: + if isinstance(q, str) and q.strip(): + query_variants.append(q.strip()[:180]) + except Exception as exc: + print(f"query 重写失败: {exc}") + query_variants = _build_search_queries(rewritten_query, original_query, query_variants) + _emit_event("✨正在分析中,请稍等...", "正在生成检索策略...") print("原始检索query:", original_query) - print("重写后检索query:", last_user_query+rewritten_query) - - return {"retrieval_query":last_user_query+rewritten_query} + print("重写后检索query:", rewritten_query) + print("检索query变体:", query_variants) + return {"retrieval_query": rewritten_query, "retrieval_queries": query_variants} @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] - + kb_id = "3" if state.get("route_flag") == "rules" else None + query = (state.get("retrieval_query") or state.get("combined_query") or state.get("extracted_text") or "").strip() + extracted_text = (state.get("extracted_text") or "").strip() + query_variants = _build_search_queries(query, extracted_text, state.get("retrieval_queries") or []) 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_task = _search_rag_variants(query_variants[:3], kb_id=kb_id) + graph_task = graph_rag_search.ainvoke({"query": query, "top_k": BAIKE_GRAPH_TOP_K}) + rag_results, graph_results = await asyncio.gather(rag_task, graph_task) + evidence_items = _build_evidence_items( + rag_results=rag_results, + graph_results=graph_results if isinstance(graph_results, str) else "", + query=query, ) - - # 处理 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] + evidence_items = await _rerank_evidence(query, evidence_items) return { - "rag_search_result": rag_search_result, - "graph_search_result": graph_results if isinstance(graph_results, str) else "" + "rag_search_result": rag_results, + "graph_search_result": graph_results if isinstance(graph_results, str) else "", + "evidence_items": evidence_items, } - except Exception as e: + except Exception as exc: return { "rag_search_result": [], "graph_search_result": "", - "error_message": f"检索失败: {str(e)}" + "evidence_items": [], + "error_message": f"检索失败: {exc}", } - 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] - ) + 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) + json_match = re.search(r"\[.*\]", result or "", 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": "还有哪些相关的知识点?"} - ] + replies = json.loads(json_match.group(0)) + formatted = [ + {"title": str(x["title"]), "content": str(x["content"])} + for x in replies[:2] + if isinstance(x, dict) and x.get("title") and x.get("content") + ] + if len(formatted) == 2: + return formatted + except Exception: + pass + 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 [] + question = state.get("combined_query") or state.get("extracted_text") or "" + evidence_items = state.get("evidence_items") or [] need_retrieval = state.get("need_retrieval", True) - print("history_message(qa)", history_messages) - print("是否使用检索结果:", need_retrieval) + history_messages = state.get("history_messages", []) or [] 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 + source_text = "" + original_image_paths: List[str] = [] + if need_retrieval and evidence_items: + source_text = _format_evidence_context(evidence_items) + original_image_paths = extract_image_paths(source_text) + source_counts = _count_sources(evidence_items) + _emit_event( + f"✨已融合 {len(evidence_items)} 条证据", + f"RAG {source_counts.get('rag', 0)} 条", ) - 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 + domain_desc = ( + "舰船维修相关的法规、规范、行业标准及技术文件" + if state.get("route_flag") == "rules" + else "工业设备的结构原理、维护规程、故障诊断与维修操作" ) - # 过滤掉异常标签(如果有) - raw_content = re.sub(r'ynchroneg>.*?ost switching>', '', raw_content, flags=re.DOTALL) - raw_content = raw_content.strip() if raw_content else "抱歉,无法生成回答。" + if need_retrieval and source_text.strip(): + system_prompt = BAIKE_PROMPTS["generate_answer_with_retrieval_system"].format(domain_desc=domain_desc) + user_prompt = BAIKE_PROMPTS["generate_answer_with_retrieval_user"].format( + extracted_text=question, + rag_count=len(evidence_items), + all_source_text=source_text, + ) + else: + system_prompt = BAIKE_PROMPTS["generate_answer_without_retrieval_system"].format(domain_desc=domain_desc) + user_prompt = BAIKE_PROMPTS["generate_answer_without_retrieval_user"].format(extracted_text=question) - title_options = ["🤖 诊断分析中", "🤖 故障预检中"] - start_event = { - "type": "function_execution", - "title": random.choice(title_options), - "details": raw_content[:30] + "..." - } - for callback in get_all_callbacks(): + messages = [] + if history_messages: + messages.extend(history_messages) + messages.append({"role": "user", "content": user_prompt}) + + _emit_event(random.choice(["🔍 证据整理中", "🧠 正在生成回答"]), "正在基于检索资料生成回答...") + answer = await stream_generate_and_postprocess( + system_prompt=system_prompt, + messages=messages, + original_image_paths=original_image_paths, + temperature=0.2, + fallback="抱歉,无法生成回答。", + ) + suggested_replies = await generate_suggested_replies_baike(answer, question) + return {"response": answer, "actions": [], "result_tag": "qa", "suggestedReplies": suggested_replies} + except Exception as exc: + return {"response": f"生成回答失败:{exc}", "actions": [], "suggestedReplies": []} + + +async def _search_rag_variants(queries: List[str], kb_id: Optional[str] = None) -> List[Dict[str, Any]]: + tasks = [rag_search(query=q, kb_id=kb_id, top_k=BAIKE_RAG_TOP_K) for q in queries if q.strip()] + results = await asyncio.gather(*tasks, return_exceptions=True) if tasks else [] + items: List[Dict[str, Any]] = [] + for result in results: + if isinstance(result, Exception) or not result.get("success"): + continue + for resource, rows in (result.get("sourceCitation") or {}).items(): + for row in rows: + item = dict(row) + item.setdefault("resource", resource) + items.append(item) + items = sorted(items, key=lambda x: float(x.get("score") or 0), reverse=True) + return dedupe_rag_results(items, limit=BAIKE_RESULT_LIMIT) + + +def _build_evidence_items( + rag_results: List[Dict[str, Any]], + graph_results: str, + query: str, +) -> List[Dict[str, Any]]: + candidates: List[Dict[str, Any]] = [] + for item in rag_results or []: + candidates.append({ + "source_type": "rag", + "title": item.get("filename") or item.get("resource") or "RAG 检索结果", + "filename": item.get("filename") or item.get("resource") or "", + "text": item.get("text") or "", + "score": float(item.get("score") or 0), + "file_id": str(item.get("file_id") or ""), + "slice_id": str(item.get("id") or item.get("slice_id") or ""), + "metadata": item, + }) + + graph_text = str(graph_results or "").strip() + if graph_text and "图谱检索完成,但未返回有效数据。" not in graph_text and graph_text != "查询无结果": + candidates.append({ + "source_type": "graph", + "title": "图谱检索结果", + "filename": "", + "text": graph_text, + "score": 0.45, + "file_id": "", + "slice_id": "", + "metadata": {}, + }) + + deduped: Dict[str, Dict[str, Any]] = {} + for item in candidates: + text = _clean_text(item.get("text") or "") + if not text: + continue + item["text"] = text[:5000] + key = item.get("slice_id") or _content_key(text) + local_score = _local_evidence_score(query, item) + item["local_score"] = local_score + existing = deduped.get(key) + if not existing or local_score > float(existing.get("local_score") or 0): + deduped[key] = item + return sorted(deduped.values(), key=lambda x: float(x.get("local_score") or 0), reverse=True) + + +async def _rerank_evidence(query: str, evidence_items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + if not query.strip() or not evidence_items: + return evidence_items[:BAIKE_EVIDENCE_LIMIT] + selected = evidence_items[:24] + documents = [ + "\n".join([ + str(item.get("title") or ""), + str(item.get("filename") or ""), + str(item.get("text") or ""), + ])[:4000] + for item in selected + ] + rows = await OpenaiAPI.rerank(query=query, documents=documents, top_k=BAIKE_EVIDENCE_LIMIT) + if not rows: + return evidence_items[:BAIKE_EVIDENCE_LIMIT] + + ranked: List[Dict[str, Any]] = [] + seen = set() + for row in rows: + idx = int(row.get("index") or 0) + if idx < 0 or idx >= len(selected) or idx in seen: + continue + seen.add(idx) + item = dict(selected[idx]) + item["rerank_score"] = float(row.get("relevance_score") or 0.0) + ranked.append(item) + for idx, item in enumerate(selected): + if idx not in seen and len(ranked) < BAIKE_EVIDENCE_LIMIT: + ranked.append(item) + return ranked[:BAIKE_EVIDENCE_LIMIT] + + +def _format_evidence_context(items: List[Dict[str, Any]]) -> str: + parts = [] + label_map = {"rag": "RAG", "graph": "Graph"} + for idx, item in enumerate(items[:BAIKE_EVIDENCE_LIMIT], start=1): + source = label_map.get(item.get("source_type"), item.get("source_type") or "unknown") + score = item.get("rerank_score", item.get("local_score", item.get("score", ""))) + meta_lines = [ + f"[Evidence {idx}] source={source} score={score}", + f"Title: {item.get('title') or ''}", + ] + if item.get("filename"): + meta_lines.append(f"File: {item.get('filename')}") + if item.get("file_id"): + meta_lines.append(f"File ID: {item.get('file_id')}") + if item.get("slice_id"): + meta_lines.append(f"Slice ID: {item.get('slice_id')}") + meta_lines.append("Content:") + meta_lines.append(str(item.get("text") or "")) + parts.append("\n".join(meta_lines)) + return "\n\n---\n\n".join(parts) + + +def _local_evidence_score(query: str, item: Dict[str, Any]) -> float: + base = {"rag": 1.0, "graph": 0.65}.get(item.get("source_type"), 0.8) + raw = float(item.get("score") or 0) + score = base + min(max(raw, 0.0), 1.0) + text = " ".join([ + str(item.get("title") or ""), + str(item.get("filename") or ""), + str(item.get("text") or "")[:1500], + ]).lower() + title = str(item.get("title") or "").lower() + for term in _extract_search_terms(query): + t = term.lower() + if not t: + continue + if t in title: + score += 0.7 + elif t in text: + score += 0.25 + return score + + +def _build_search_queries(main_query: str, original_query: str = "", extra_queries: Optional[List[str]] = None) -> List[str]: + # search_queries 是模型生成的最终检索列表,优先保持其顺序。 + candidates: List[str] = list(extra_queries or []) + main_query = _strip_query_noise(main_query or original_query) + if main_query: + main_key = _query_dedupe_key(main_query) + if not any(_query_dedupe_key(text) == main_key for text in candidates): + # 最多保留两个前置问题,最后一条固定为原始综合问题。 + candidates = candidates[:2] + [main_query] + + result: List[str] = [] + seen = set() + for text in candidates: + text = _strip_query_noise(text) + if not text: + continue + key = _query_dedupe_key(text) + if not key or key in seen: + continue + seen.add(key) + result.append(text) + return result[:3] + + +def _query_dedupe_key(text: str) -> str: + """忽略空白和句末标点,对相同检索问题去重。""" + text = re.sub(r"\s+", "", str(text or "")).lower() + return re.sub(r"[??。!!;;,,、]+$", "", text) + + +def _extract_search_terms(query: str) -> List[str]: + stop = {"什么", "如何", "怎么", "哪些", "主要", "内容", "相关", "信息", "一下", "这个", "那个", "是否", "有没有"} + terms = [] + for token in re.findall(r"[\u4e00-\u9fff]{2,12}|[A-Za-z0-9][A-Za-z0-9_\-]{1,30}", query or ""): + token = token.strip("::,,。.;;、()()[]【】") + if len(token) < 2 or token in stop or re.fullmatch(r"\d+", token): + continue + terms.append(token) + return _dedupe_strings(terms) + + +def _strip_query_noise(text: str) -> str: + text = str(text or "").strip().strip('"“”') + if "用户问题:" in text: + text = text.split("用户问题:", 1)[1].strip() + return re.sub(r"\s+", " ", text) + + +def _parse_json_object(raw: str) -> Dict[str, Any]: + if not raw: + return {} + try: + return json.loads(raw) + except Exception: + match = re.search(r"\{.*\}", raw, re.DOTALL) + if match: try: - callback(start_event) + return json.loads(match.group(0)) except Exception: - pass + return {} + return {} - # ========== 第二步:专注于规范格式 ========== - # 构建第二步的提示词:只关注格式规范,不修改内容 - format_system_prompt = COMMON_PROMPTS["format_system"] - - format_user_content = COMMON_PROMPTS["format_user_with_image_examples"].format(raw_content=raw_content) +def _json_dumps(value: Any) -> str: + try: + return json.dumps(value, ensure_ascii=False) + except Exception: + return str(value) - # 第二步:规范格式(流式输出) - 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 +def _content_key(text: str) -> str: + return re.sub(r"\s+", "", text or "")[:220] - # LaTeX 公式空格规范化 - def normalize_latex_spacing(text: str) -> str: - placeholder = "\x00DOUBLE_DOLLAR\x00" - text = text.replace("$$", placeholder) - text = re.sub(r'(? str: + return re.sub(r"\n{3,}", "\n\n", str(text or "")).strip() - # 使用增强的图片路径匹配与规范化(仅在有检索结果时) - if need_retrieval and original_image_paths: - answer = normalize_markdown_images(answer, original_paths=original_image_paths) - print("llm output", answer) - # 生成建议回复问题 - suggested_replies = await generate_suggested_replies_baike(answer, extracted_text) +def _dedupe_strings(values: List[str]) -> List[str]: + out = [] + seen = set() + for value in values: + value = str(value or "").strip() + if not value or value in seen: + continue + seen.add(value) + out.append(value) + return out + + +def _count_sources(items: List[Dict[str, Any]]) -> Dict[str, int]: + counts: Dict[str, int] = {} + for item in items: + key = str(item.get("source_type") or "unknown") + counts[key] = counts.get(key, 0) + 1 + return counts + + +def _emit_event(title: str, details: str) -> None: + event = {"type": "function_execution", "title": title, "details": details} + for callback in get_all_callbacks(): + try: + callback(event) + except Exception: + pass + - return { - "response": answer, - "actions": [], - "result_tag": "qa", - "suggestedReplies": suggested_replies - } - except Exception as e: - return { - "response": f"生成回答失败:{str(e)}", - "actions": [], - "suggestedReplies": [] - } def route_after_judge(state: QAState) -> str: - """ - 判断后的路由:根据need_retrieval决定下一步 - """ - need_retrieval = state.get("need_retrieval", True) - if need_retrieval: - return "rewrite_query_with_history" - else: - return "generate_answer" + return "rewrite_query_with_history" if state.get("need_retrieval", True) else "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" - } + {"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 = "" + 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": "", + "retrieval_queries": [], "route_flag": route_flag, "history_message": history_message, "history_messages": [], "need_retrieval": None, "rag_search_result": None, "graph_search_result": None, + "evidence_items": None, "response": "", "actions": [], "suggestedReplies": [], - "error_message": "" + "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": [] + "suggestedReplies": [], } - - # 统一输出格式:完全透传 generate_answer 的结果 return { "response": final_state.get("response", ""), "actions": final_state.get("actions", []), "result_tag": "qa", - "suggestedReplies": final_state.get("suggestedReplies", []) + "suggestedReplies": final_state.get("suggestedReplies", []), } - - except Exception as e: + except Exception as exc: return { - "response": f"普通问答工作流执行失败: {str(e)}", + "response": f"普通问答工作流执行失败: {exc}", "actions": [], "result_tag": "qa", - "suggestedReplies": [] + "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_baike.py_0721 b/workflows/workflow_baike.py_0721 new file mode 100644 index 0000000..07a8f08 --- /dev/null +++ b/workflows/workflow_baike.py_0721 @@ -0,0 +1,597 @@ +""" +工作流:普通问答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 deleted file mode 100644 index b9c3725..0000000 --- a/workflows/workflow_fault_diagnosis.py +++ /dev/null @@ -1,1651 +0,0 @@ -""" -故障诊断工作流 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 deleted file mode 100644 index 27dcb90..0000000 --- a/workflows/workflow_operate.py +++ /dev/null @@ -1,1457 +0,0 @@ -""" -操作指导工作流 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_utils.py b/workflows/workflow_utils.py index f3b9382..39e7f28 100644 --- a/workflows/workflow_utils.py +++ b/workflows/workflow_utils.py @@ -4,13 +4,21 @@ """ from typing import TypedDict, Optional, Dict, Any, List -from prompts import COMMON_PROMPTS +from difflib import SequenceMatcher from modelsAPI.model_api import OpenaiAPI from utils.function_tracker import get_all_callbacks, get_current_stream_handler from utils.image_utils import extract_image_paths, normalize_markdown_images +import asyncio import json import re import random +import inspect +import unicodedata + + +RAG_DEDUP_SIMILARITY_THRESHOLD = 0.86 +RAG_DEDUP_CONTAINMENT_THRESHOLD = 0.92 +RAG_DEDUP_MIN_TEXT_LENGTH = 20 def safe_json_extract(text: str) -> Optional[Any]: @@ -39,41 +47,1203 @@ def safe_json_extract(text: str) -> Optional[Any]: from workflows.history_manager import parse_history, build_history_str, filter_image_urls, init_history # noqa: F401 +import re + def normalize_latex_spacing(text: str) -> str: - placeholder = "\x00DOUBLE_DOLLAR\x00" - text = text.replace("$$", placeholder) - text = re.sub(r'(? str: + token = ( + f"\uE000LATEX_PROTECTED_" + f"{len(protected_parts)}" + f"\uE001" + ) + protected_parts.append(content) + return token + + def restore(content: str) -> str: + # 保护内容可能再次包含更早生成的占位符,必须按后进先出恢复。 + for index in reversed(range(len(protected_parts))): + original = protected_parts[index] + token = f"\uE000LATEX_PROTECTED_{index}\uE001" + content = content.replace(token, original) + + return content + + def is_escaped(content: str, position: int) -> bool: + """ + 判断 content[position] 是否被奇数个反斜杠转义。 + """ + slash_count = 0 + position -= 1 + + while position >= 0 and content[position] == "\\": + slash_count += 1 + position -= 1 + + return slash_count % 2 == 1 + + # ================================================================ + # 1. 保护 Markdown 围栏代码块 + # ================================================================ + + lines = text.splitlines(keepends=True) + protected_lines: list[str] = [] + line_index = 0 + + while line_index < len(lines): + line = lines[line_index] + + opening_match = re.match( + r"^[ \t]{0,3}(`{3,}|~{3,})", + line, + ) + + if not opening_match: + protected_lines.append(line) + line_index += 1 + continue + + opening_fence = opening_match.group(1) + fence_character = opening_fence[0] + fence_length = len(opening_fence) + + closing_pattern = re.compile( + rf"^[ \t]{{0,3}}" + rf"{re.escape(fence_character)}" + rf"{{{fence_length},}}" + rf"[ \t]*(?:\n|$)" + ) + + block_lines = [line] + line_index += 1 + + while line_index < len(lines): + current_line = lines[line_index] + block_lines.append(current_line) + line_index += 1 + + if closing_pattern.match(current_line): + break + + protected_lines.append( + protect("".join(block_lines)) + ) + + text = "".join(protected_lines) + + # ================================================================ + # 2. 保护 HTML 代码区域和注释 + # ================================================================ + + text = re.sub( + r"(?is)" + r"<(?Ppre|code|script|style)\b[^>]*>" + r".*?" + r"", + lambda match: protect(match.group(0)), + text, + ) + + text = re.sub( + r"(?s)", + lambda match: protect(match.group(0)), + text, + ) + + # ================================================================ + # 3. 保护 Markdown 链接地址 + # ================================================================ + + text = re.sub( + r"(!?\[[^\]\n]*\]\()([^)\n]*)(\))", + lambda match: ( + match.group(1) + + protect(match.group(2)) + + match.group(3) + ), + text, + ) + + text = re.sub( + r"(?im)" + r"^([ \t]{0,3}\[[^\]\n]+\]:[ \t]*)" + r"(\S+(?:[ \t]+.*)?)$", + lambda match: ( + match.group(1) + + protect(match.group(2)) + ), + text, + ) + + # ================================================================ + # 4. 保护 Markdown 行内代码 + # ================================================================ + + inline_code_result: list[str] = [] + position = 0 + + while position < len(text): + if text[position] != "`": + inline_code_result.append(text[position]) + position += 1 + continue + + delimiter_end = position + + while ( + delimiter_end < len(text) + and text[delimiter_end] == "`" + ): + delimiter_end += 1 + + delimiter = text[position:delimiter_end] + search_position = delimiter_end + closing_position = -1 + + while search_position < len(text): + candidate = text.find( + delimiter, + search_position, + ) + + if candidate < 0: + break + + previous_character = ( + text[candidate - 1] + if candidate > 0 + else "" + ) + + candidate_end = candidate + len(delimiter) + + next_character = ( + text[candidate_end] + if candidate_end < len(text) + else "" + ) + + if ( + previous_character != "`" + and next_character != "`" + ): + closing_position = candidate + break + + search_position = candidate + 1 + + if closing_position < 0: + inline_code_result.append(delimiter) + position = delimiter_end + continue + + end_position = closing_position + len(delimiter) + + inline_code_result.append( + protect(text[position:end_position]) + ) + + position = end_position + + text = "".join(inline_code_result) + + # ================================================================ + # 5. 转换其他 LaTeX 公式定界符 + # ================================================================ + + # \( x + y \) -> $x + y$ + text = re.sub( + r"(? $$x + y$$ + text = re.sub( + r"(?s)(? bool: + compact_body = re.sub(r"\s+", "", formula_body) + if len(compact_body) >= 46: + return True + + has_relation = bool( + re.search( + r"(?]" + r"|\\(?:leq?|geq?|approx|sim|equiv|propto|rightarrow|mapsto)\b", + formula_body, + ) + ) + has_large_operator = bool( + re.search( + r"\\(?:sum|int|iint|iiint|prod|lim|begin|cases|matrix)\b", + formula_body, + ) + ) + has_structured_expression = bool( + re.search( + r"\\(?:d?frac|sqrt|left|right|overbrace|underbrace)\b", + formula_body, + ) + ) + + return ( + has_large_operator + or ( + has_relation + and has_structured_expression + and len(compact_body) >= 24 + ) + ) + + promoted_result: list[str] = [] + position = 0 + + while position < len(text): + is_inline_opening = ( + text[position] == "$" + and not is_escaped(text, position) + and not text.startswith("$$", position) + and not ( + position > 0 + and text[position - 1] == "$" + ) + ) + + if not is_inline_opening: + promoted_result.append(text[position]) + position += 1 + continue + + opening_position = position + closing_position = -1 + search_position = position + 1 + + while search_position < len(text): + candidate = text.find("$", search_position) + if candidate < 0: + break + if "\n" in text[opening_position + 1:candidate]: + break + if is_escaped(text, candidate): + search_position = candidate + 1 + continue + if text.startswith("$$", candidate): + search_position = candidate + 2 + continue + closing_position = candidate + break + + if closing_position < 0: + promoted_result.append("$") + position += 1 + continue + + formula_body = text[ + opening_position + 1:closing_position + ].strip() + line_start = text.rfind("\n", 0, opening_position) + 1 + line_end = text.find("\n", closing_position + 1) + if line_end < 0: + line_end = len(text) + stripped_line = text[line_start:line_end].strip() + is_markdown_table_row = ( + stripped_line.startswith("|") + and stripped_line.endswith("|") + ) + + if ( + not formula_body + or is_markdown_table_row + or not should_promote_inline_formula(formula_body) + ): + promoted_result.append( + text[opening_position:closing_position + 1] + ) + position = closing_position + 1 + continue + + before_text = "".join(promoted_result) + if before_text and not before_text.endswith("\n\n"): + before_text = before_text.rstrip(" \t\n") + "\n\n" + promoted_result = [before_text] if before_text else [] + promoted_result.append( + "$$\n" + formula_body + "\n$$" + ) + + position = closing_position + 1 + while position < len(text) and text[position] in " \t": + position += 1 + + # 独立公式后的句号不再单独占一段;逗号和冒号仍保留语义。 + if position < len(text) and text[position] in "。.": + position += 1 + + if position < len(text): + while position < len(text) and text[position] == "\n": + position += 1 + promoted_result.append("\n\n") + + text = "".join(promoted_result) + + # ================================================================ + # 7. 完整提取并保护 $$...$$ 段落公式 + # ================================================================ + + display_result: list[str] = [] + position = 0 + + while position < len(text): + if not ( + text.startswith("$$", position) + and not is_escaped(text, position) + ): + display_result.append(text[position]) + position += 1 + continue + + opening_position = position + search_position = position + 2 + closing_position = -1 + + while search_position < len(text): + candidate = text.find( + "$$", + search_position, + ) + + if candidate < 0: + break + + if not is_escaped(text, candidate): + closing_position = candidate + break + + search_position = candidate + 2 + + # 未找到闭合 $$,剩余内容原样保留。 + if closing_position < 0: + display_result.append( + text[opening_position:] + ) + position = len(text) + break + + complete_formula = text[ + opening_position:closing_position + 2 + ] + + formula_body = text[ + opening_position + 2:closing_position + ] + + # ------------------------------------------------------------ + # 多行段落公式也统一重建,并确保公式块前后各有一个空行。 + # 相邻公式若只有一个换行,部分 Markdown/KaTeX 解析器会把它们 + # 合并到同一个显示块中,导致两条公式横向排在同一行。 + # ------------------------------------------------------------ + if "\n" in complete_formula: + formula_lines = [ + line.strip() + for line in formula_body.splitlines() + if line.strip() + ] + contains_multiline_environment = bool( + re.search( + r"\\(?:begin|end|substack)\b" + r"|\\\\", + formula_body, + ) + ) + lines_are_independent_equations = ( + len(formula_lines) > 1 + and not contains_multiline_environment + and all( + re.search( + r"(?]" + r"|\\(?:leq?|geq?|approx|sim|equiv|propto)\b", + line, + ) + for line in formula_lines + ) + ) + + if lines_are_independent_equations: + normalized_formula = "\n\n".join( + "$$\n" + line + "\n$$" + for line in formula_lines + ) + else: + normalized_formula = ( + "$$\n" + + formula_body.strip() + + "\n$$" + ) + before_text = "".join(display_result) + if before_text and not before_text.endswith("\n\n"): + before_text = before_text.rstrip(" \t\n") + "\n\n" + display_result = [before_text] if before_text else [] + display_result.append( + protect(normalized_formula) + ) + position = closing_position + 2 + + while ( + position < len(text) + and text[position] in " \t" + ): + position += 1 + + if position < len(text): + while ( + position < len(text) + and text[position] == "\n" + ): + position += 1 + display_result.append("\n\n") + continue + + # ------------------------------------------------------------ + # 判断公式是否位于 Markdown 表格内。 + # 表格中的公式不能强制拆成多行。 + # ------------------------------------------------------------ + + line_start = text.rfind( + "\n", + 0, + opening_position, + ) + 1 + + line_end = text.find( + "\n", + closing_position + 2, + ) + + if line_end < 0: + line_end = len(text) + + current_line = text[line_start:line_end] + clean_body = formula_body.strip() + + stripped_line = current_line.strip() + is_markdown_table_row = ( + stripped_line.startswith("|") + and stripped_line.endswith("|") + ) + + if is_markdown_table_row: + display_result.append( + protect( + "$$" + clean_body + "$$" + ) + ) + + position = closing_position + 2 + continue + + # ------------------------------------------------------------ + # 单行段落公式转换成标准多行格式。 + # + # $$R = K$$ + # + # 转换为: + # + # $$ + # R = K + # $$ + # ------------------------------------------------------------ + + normalized_formula = ( + "$$\n" + + clean_body + + "\n$$" + ) + + before_text = "".join(display_result) + + # 公式前存在普通正文时,确保前面有空行。 + if before_text and not before_text.endswith("\n\n"): + before_text = before_text.rstrip(" \t\n") + "\n\n" + + display_result = [before_text] if before_text else [] + + display_result.append( + protect(normalized_formula) + ) + + position = closing_position + 2 + + # 清理公式后已有的普通空格。 + while ( + position < len(text) + and text[position] in " \t" + ): + position += 1 + + # 后面还有内容时,确保公式后有空行。 + if position < len(text): + if text[position] == "\n": + while ( + position < len(text) + and text[position] == "\n" + ): + position += 1 + + display_result.append("\n\n") + + text = "".join(display_result) + + # ================================================================ + # 8. 规范 $...$ 行内公式 + # ================================================================ + + inline_result: list[str] = [] + position = 0 + + while position < len(text): + is_inline_opening = ( + text[position] == "$" + and not is_escaped(text, position) + and not text.startswith("$$", position) + and not ( + position > 0 + and text[position - 1] == "$" + ) + ) + + if not is_inline_opening: + inline_result.append(text[position]) + position += 1 + continue + + opening_position = position + + # 避免把 $100 识别成行内公式开始。 + next_opening_character = ( + text[position + 1] + if position + 1 < len(text) + else "" + ) + + if next_opening_character.isdigit(): + inline_result.append("$") + position += 1 + continue + + search_position = position + 1 + closing_position = -1 + + while search_position < len(text): + candidate = text.find( + "$", + search_position, + ) + + if candidate < 0: + break + + # 行内公式不能跨行。 + if "\n" in text[ + opening_position + 1:candidate + ]: + break + + if is_escaped(text, candidate): + search_position = candidate + 1 + continue + + # 跳过属于 $$ 的美元符号。 + if text.startswith("$$", candidate): + search_position = candidate + 2 + continue + + closing_position = candidate + break + + # 未找到闭合符时原样保留。 + if closing_position < 0: + inline_result.append("$") + position += 1 + continue + + formula_body = text[ + opening_position + 1:closing_position + ].strip() + + # 空公式不处理。 + if not formula_body: + inline_result.append( + text[ + opening_position:closing_position + 1 + ] + ) + + position = closing_position + 1 + continue + + # 折叠公式内部普通空格,不修改 LaTeX 命令。 + formula_body = re.sub( + r"[ \t]+", + " ", + formula_body, + ) + + previous_character = "" + + for part in reversed(inline_result): + if part: + previous_character = part[-1] + break + + next_character = ( + text[closing_position + 1] + if closing_position + 1 < len(text) + else "" + ) + + # 公式前只要存在非空白字符,就补空格。 + # + # 参数$T$ -> 参数 $T$ + # ($T$) -> ( $T$ + # ($T$) -> ( $T$ + if ( + previous_character + and not previous_character.isspace() + ): + inline_result.append(" ") + + inline_result.append( + "$" + formula_body + "$" + ) + + # 公式后只要存在非空白字符,就补空格。 + # + # $T$参数 -> $T$ 参数 + # ($T$) -> $T$ ) + # ($T$) -> $T$ ) + if ( + next_character + and not next_character.isspace() + ): + inline_result.append(" ") + + position = closing_position + 1 + + return restore("".join(inline_result)) + + +def convert_display_math_for_frontend(text: str) -> str: + """将段落公式改为 16000 前端能正确识别的块级定界符。""" + return re.sub( + r"(?ms)^[ \t]*\$\$[ \t]*\n" + r"(.*?)" + r"\n[ \t]*\$\$[ \t]*$", + lambda match: ( + "\\[\n" + + match.group(1).strip() + + "\n\\]" + ), + text, + ) def remove_duplicate_content(text: str) -> str: + """ + 删除普通 Markdown 正文中的完全重复行。 + + 不对以下内容进行去重: + - $$ ... $$ 段落公式 + - \\[ ... \\] 段落公式 + - equation、align、gather 等 LaTeX 环境 + - Markdown 围栏代码块 + - Markdown 缩进代码块 + - Markdown 标题、列表、引用、表格等结构行 + + 这样不会删除段落公式末尾的第二个 $$。 + """ if not text or len(text) < 10: return text - lines = text.split('\n') - result = [] - seen_segments = set() + if not isinstance(text, str): + raise TypeError( + f"text 必须是 str,实际类型为 {type(text).__name__}" + ) + + text = text.replace("\r\n", "\n").replace("\r", "\n") + lines = text.split("\n") + + result: list[str] = [] + seen_segments: set[str] = set() + + in_code_block = False + code_fence_character = "" + code_fence_length = 0 + + in_dollar_math = False + in_bracket_math = False + + latex_environment_stack: list[str] = [] + + display_environments = { + "equation", + "equation*", + "align", + "align*", + "alignat", + "alignat*", + "gather", + "gather*", + "multline", + "multline*", + "flalign", + "flalign*", + "eqnarray", + "eqnarray*", + "displaymath", + "math", + } + + def is_escaped(value: str, position: int) -> bool: + slash_count = 0 + position -= 1 + + while position >= 0 and value[position] == "\\": + slash_count += 1 + position -= 1 + + return slash_count % 2 == 1 + + def count_unescaped_double_dollars(value: str) -> int: + """ + 统计一行中未转义的 $$ 数量。 + """ + count = 0 + position = 0 + + while position < len(value) - 1: + if ( + value[position:position + 2] == "$$" + and not is_escaped(value, position) + ): + count += 1 + position += 2 + else: + position += 1 + + return count + + def is_markdown_structure_line(value: str) -> bool: + """ + Markdown 结构行不参与去重,避免破坏语义。 + """ + stripped = value.strip() + + if not stripped: + return True + + patterns = ( + # 标题 + r"^#{1,6}\s+", + + # 无序列表 + r"^[-+*]\s+", + + # 有序列表 + r"^\d+[.)]\s+", + + # 引用 + r"^>\s*", + + # 任务列表 + r"^[-+*]\s+\[[ xX]\]\s+", + + # 水平分隔线 + r"^(?:-{3,}|\*{3,}|_{3,})$", + + # 表格分隔行 + r"^\|?\s*:?-{3,}:?" + r"(?:\s*\|\s*:?-{3,}:?)+\s*\|?$", + + # 普通表格行 + r"^\|.*\|$", + + # HTML 标签 + r"^]*>$", + + # LaTeX 环境边界 + r"^\\(?:begin|end)\{[^}]+\}", + + # 公式定界符 + r"^(?:\$\$|\\\[|\\\])$", + + # Markdown 链接引用定义 + r"^\[[^\]]+\]:\s*\S+", + ) + + return any( + re.match(pattern, stripped) + for pattern in patterns + ) for line in lines: line = line.rstrip() - if not line: + stripped = line.strip() + + # ============================================================ + # 1. Markdown 围栏代码块 + # ============================================================ + + fence_match = re.match( + r"^[ \t]{0,3}(`{3,}|~{3,})", + line, + ) + + if not in_code_block and fence_match: + fence = fence_match.group(1) + + in_code_block = True + code_fence_character = fence[0] + code_fence_length = len(fence) + result.append(line) continue - normalized_line = line.strip().lower() + if in_code_block: + result.append(line) + + closing_pattern = re.compile( + rf"^[ \t]{{0,3}}" + rf"{re.escape(code_fence_character)}" + rf"{{{code_fence_length},}}" + rf"[ \t]*$" + ) + + if closing_pattern.match(line): + in_code_block = False + code_fence_character = "" + code_fence_length = 0 + + continue + + # Markdown 缩进代码块不参与去重。 + if re.match(r"^(?:\t| {4})", line): + result.append(line) + continue + + # ============================================================ + # 2. \[ ... \] 段落公式 + # ============================================================ + + if in_bracket_math: + result.append(line) + + if re.search(r"(? bracket_close_count: + in_bracket_math = True + + continue + + # ============================================================ + # 3. LaTeX display 环境 + # ============================================================ + + begin_matches = re.findall( + r"\\begin\{([^}]+)\}", + line, + ) + + end_matches = re.findall( + r"\\end\{([^}]+)\}", + line, + ) + + relevant_begins = [ + environment + for environment in begin_matches + if environment in display_environments + ] + + relevant_ends = [ + environment + for environment in end_matches + if environment in display_environments + ] + + if latex_environment_stack: + result.append(line) + + for environment in relevant_begins: + latex_environment_stack.append(environment) + + for environment in relevant_ends: + if ( + latex_environment_stack + and latex_environment_stack[-1] + == environment + ): + latex_environment_stack.pop() + elif environment in latex_environment_stack: + latex_environment_stack.remove(environment) + + continue + + if relevant_begins: + result.append(line) + + for environment in relevant_begins: + latex_environment_stack.append(environment) + + for environment in relevant_ends: + if ( + latex_environment_stack + and latex_environment_stack[-1] + == environment + ): + latex_environment_stack.pop() + elif environment in latex_environment_stack: + latex_environment_stack.remove(environment) + + continue + + # ============================================================ + # 4. $$ ... $$ 段落公式 + # ============================================================ + + double_dollar_count = ( + count_unescaped_double_dollars(line) + ) + + if in_dollar_math: + # 公式块中的内容全部保留,包括最后一行的 $$。 + result.append(line) + + if double_dollar_count % 2 == 1: + in_dollar_math = False + + continue + + if double_dollar_count > 0: + # 含有 $$ 的行永远不参与去重。 + result.append(line) + + # 奇数个 $$ 代表开启了跨行段落公式。 + if double_dollar_count % 2 == 1: + in_dollar_math = True + + continue + + # ============================================================ + # 5. 空行和 Markdown 结构行 + # ============================================================ + + if not stripped: + result.append(line) + continue + + if is_markdown_structure_line(line): + result.append(line) + continue + + # ============================================================ + # 6. 仅对普通正文行去重 + # ============================================================ + + normalized_line = re.sub( + r"\s+", + " ", + stripped.lower(), + ) + if normalized_line in seen_segments: continue seen_segments.add(normalized_line) result.append(line) - cleaned_text = '\n'.join(result) - return cleaned_text + return "\n".join(result) + + + + +# 推荐调用顺序: +# +# text = remove_duplicate_content(text) +# text = normalize_latex_spacing(text) + + + +def _extract_rag_text(item: Any) -> str: + if isinstance(item, dict): + return str(item.get("text") or item.get("content") or "") + if isinstance(item, str): + return item + return "" + + +def _extract_rag_score(item: Any) -> float: + if not isinstance(item, dict): + return 0.0 + try: + return float(item.get("score") or item.get("socre") or 0.0) + except (TypeError, ValueError): + return 0.0 + + +def _normalize_rag_text_for_dedupe(text: Any) -> str: + value = unicodedata.normalize("NFKC", str(text or "")) + value = re.sub(r"!\[[^\]]*\]\([^)]+\)", "", value) + value = re.sub(r"\[([^\]]*)\]\([^)]+\)", r"\1", value) + value = re.sub(r"https?://\S+", "", value, flags=re.IGNORECASE) + value = re.sub(r"[/\\]?picture[/\\]\S+", "", value, flags=re.IGNORECASE) + value = re.sub(r"\s+", "", value).lower() + return "".join(ch for ch in value if ch.isalnum()) + + +def _char_ngrams(text: str, n: int) -> set: + if len(text) <= n: + return {text} if text else set() + return {text[i:i + n] for i in range(len(text) - n + 1)} + + +def _rag_texts_are_similar(text_a: str, text_b: str, threshold: float) -> bool: + if not text_a or not text_b: + return False + if text_a == text_b: + return True + + min_len = min(len(text_a), len(text_b)) + max_len = max(len(text_a), len(text_b)) + if min_len < 8: + return False + + if min_len >= RAG_DEDUP_MIN_TEXT_LENGTH and (text_a in text_b or text_b in text_a): + return (min_len / max_len) >= RAG_DEDUP_CONTAINMENT_THRESHOLD + + if min_len < RAG_DEDUP_MIN_TEXT_LENGTH: + return SequenceMatcher(None, text_a, text_b).ratio() >= 0.95 + + ngram_size = 3 if min_len >= 30 else 2 + grams_a = _char_ngrams(text_a, ngram_size) + grams_b = _char_ngrams(text_b, ngram_size) + if not grams_a or not grams_b: + return False + + jaccard = len(grams_a & grams_b) / len(grams_a | grams_b) + if jaccard >= threshold: + return True + + return SequenceMatcher(None, text_a, text_b).ratio() >= threshold + + +def dedupe_rag_results( + items: Any, + limit: Optional[int] = None, + similarity_threshold: float = RAG_DEDUP_SIMILARITY_THRESHOLD, +) -> Any: + """ + Deduplicate RAG result chunks while preserving the original item shape. + + Higher-score items are kept first. Graph results are intentionally not handled + here; callers should pass only text RAG chunks. + """ + if not isinstance(items, list): + return items + if len(items) < 2: + return items[:limit] if limit else items + + prepared = [] + for idx, item in enumerate(items): + prepared.append({ + "item": item, + "index": idx, + "score": _extract_rag_score(item), + "normalized_text": _normalize_rag_text_for_dedupe(_extract_rag_text(item)), + }) + + prepared.sort(key=lambda entry: (-entry["score"], entry["index"])) + + kept = [] + for entry in prepared: + current_text = entry["normalized_text"] + is_duplicate = False + if current_text: + for kept_entry in kept: + if _rag_texts_are_similar( + current_text, + kept_entry["normalized_text"], + similarity_threshold, + ): + is_duplicate = True + break + if not is_duplicate: + kept.append(entry) + + deduped = [entry["item"] for entry in kept] + return deduped[:limit] if limit else deduped def convert_rag_result(rag_result): @@ -105,6 +1275,204 @@ def emit_callback_event(title_options: List[str], details: str): pass +async def resolve_ship_number_for_workflow( + ship_number: str, + context: str = "workflow", +) -> tuple[str, Dict[str, Any]]: + """ + 工作流内舷号/舰名标准化。 + + 能映射到具体舷号时返回映射后的舷号;失败或未匹配时返回原值。 + """ + original_ship_number = str(ship_number or "").strip() + if not original_ship_number: + return original_ship_number, {} + + try: + from utils.ship_number_search import smart_ship_number_mapping + + matched_ship_number, matched_model, all_numbers, match_type = await smart_ship_number_mapping( + original_ship_number + ) + result = { + "matched_ship_number": matched_ship_number, + "matched_model": matched_model, + "all_numbers": all_numbers, + "match_type": match_type, + } + if match_type in ["exact_number", "exact_name", "embedding"] and matched_ship_number: + mapped_ship_number = str(matched_ship_number).strip() + if mapped_ship_number and mapped_ship_number != original_ship_number: + print( + f"[{context}] 舷号映射: '{original_ship_number}' -> " + f"'{mapped_ship_number}',匹配类型={match_type}" + ) + return mapped_ship_number or original_ship_number, result + + print(f"[{context}] 舷号未映射,继续使用原值: {original_ship_number}") + return original_ship_number, result + except Exception as e: + print(f"[{context}] 舷号映射异常,继续使用原值: {original_ship_number}; 错误: {str(e)}") + return original_ship_number, {} + + +async def resolve_ship_scope_for_workflow( + ship_number: str, + context: str = "workflow", +) -> tuple[str, List[str], Dict[str, Any]]: + """ + Resolve user ship input into a display hull number and a hull-number scope. + + Hull number and ship-name matches collapse to one hull. Model matches keep the + user's original value for display but return every hull number under that model + so shared device normalization and system lookup can search the whole scope. + """ + original_ship_number = str(ship_number or "").strip() + if not original_ship_number: + return original_ship_number, [], {} + + try: + from utils.ship_number_search import smart_ship_number_mapping + + matched_ship_number, matched_model, all_numbers, match_type = await smart_ship_number_mapping( + original_ship_number + ) + scope_numbers = [str(num).strip() for num in (all_numbers or []) if str(num or "").strip()] + result = { + "matched_ship_number": matched_ship_number, + "matched_model": matched_model, + "all_numbers": scope_numbers, + "match_type": match_type, + } + + if match_type in ["exact_number", "exact_name", "embedding"] and matched_ship_number: + mapped_ship_number = str(matched_ship_number).strip() + return mapped_ship_number or original_ship_number, [mapped_ship_number], result + + if match_type == "exact_model" and scope_numbers: + print( + f"[{context}] ship model scope resolved: '{original_ship_number}' -> " + f"{matched_model}, hull_numbers={scope_numbers}" + ) + return original_ship_number, scope_numbers, result + + print(f"[{context}] ship scope not resolved, continuing with original value: {original_ship_number}") + return original_ship_number, [], result + except Exception as e: + print(f"[{context}] ship scope resolve failed, continuing with original value: {original_ship_number}; error: {str(e)}") + return original_ship_number, [], {} + + +async def normalize_device_name_for_workflow( + device_name: str, + ship_number: str = "", + ship_numbers: Optional[List[str]] = None, + context: str = "workflow", +) -> tuple[str, Dict[str, Any]]: + """ + 工作流内设备名称标准化。 + + 标准化失败时返回原设备名,避免检索流程被 Neo4j 或 embedding 服务异常阻断。 + """ + original_device_name = str(device_name or "").strip() + if not original_device_name: + return original_device_name, {} + + try: + from tools.graph_tools import normalize_device_name_by_ship_graph + + print( + f"[{context}] 开始设备名称标准化: device='{original_device_name}', " + f"ship_number='{ship_number or '未提供'}'" + ) + call_kwargs = {} + if "ship_number" in inspect.signature(normalize_device_name_by_ship_graph).parameters: + call_kwargs["ship_number"] = str(ship_number or "").strip() or None + if "ship_numbers" in inspect.signature(normalize_device_name_by_ship_graph).parameters: + scoped_numbers = [str(num).strip() for num in (ship_numbers or []) if str(num or "").strip()] + call_kwargs["ship_numbers"] = scoped_numbers or None + elif ship_number: + print(f"[{context}] 当前进程加载的设备标准化函数不支持 ship_number,请重启服务加载最新 graph_tools.py") + + result = await normalize_device_name_by_ship_graph(original_device_name, **call_kwargs) + if result.get("success", False): + normalized_device_name = str(result.get("device_name") or original_device_name).strip() + print( + f"[{context}] 设备名称标准化完成: '{original_device_name}' -> " + f"'{normalized_device_name or original_device_name}',舷号={ship_number or '未提供'}" + ) + emit_callback_event( + ["🔎 设备名称标准化", "🔧 设备匹配"], + f"{original_device_name} -> {normalized_device_name or original_device_name}" + ) + return normalized_device_name or original_device_name, result + + print( + f"[{context}] 设备名称标准化失败,继续使用原设备名: {original_device_name}; " + f"原因: {result.get('error', '未知错误')}" + ) + return original_device_name, result + except Exception as e: + print(f"[{context}] 设备名称标准化异常,继续使用原设备名: {original_device_name}; 错误: {str(e)}") + return original_device_name, {} + + +async def resolve_device_system_for_workflow( + device_name: str, + ship_number: str = "", + ship_numbers: Optional[List[str]] = None, + context: str = "workflow", +) -> tuple[str, Dict[str, Any]]: + """ + 工作流内根据设备名称反推所属系统。 + + 失败时返回空字符串,调用方可继续执行主流程。 + """ + device_name = str(device_name or "").strip() + if not device_name: + return "", {} + + try: + print( + f"[{context}] 开始设备反推系统: device='{device_name}', " + f"ship_number='{ship_number or '未提供'}'" + ) + from tools.graph_tools import find_system_by_device + + call_kwargs = {"device_name": device_name} + if "ship_number" in inspect.signature(find_system_by_device).parameters: + call_kwargs["ship_number"] = str(ship_number or "").strip() or None + if "ship_numbers" in inspect.signature(find_system_by_device).parameters: + scoped_numbers = [str(num).strip() for num in (ship_numbers or []) if str(num or "").strip()] + call_kwargs["ship_numbers"] = scoped_numbers or None + elif ship_number: + print(f"[{context}] 当前进程加载的设备反推系统函数不支持 ship_number,请重启服务加载最新 graph_tools.py") + + result = await find_system_by_device(**call_kwargs) + if not result.get("success", False): + print(f"[{context}] 设备反推系统失败: {result.get('error', '未知错误')}") + return "", result + + systems = result.get("systems") or [] + first_system = systems[0] if systems else {} + system_name = "" + if isinstance(first_system, dict): + system_name = str(first_system.get("name") or first_system.get("系统名称") or first_system.get("名称") or "").strip() + + if system_name: + print(f"[{context}] 设备反推系统完成: {device_name} -> {system_name}") + emit_callback_event( + ["🔎 设备所属系统识别", "🧭 系统反查"], + f"{device_name} -> {system_name}" + ) + else: + print(f"[{context}] 设备反推系统无结果: {device_name}") + return system_name, result + except Exception as e: + print(f"[{context}] 设备反推系统异常: {str(e)}") + return "", {} + + def build_detail_info(fields: Dict[str, str]) -> str: detail_info = "" for label, value in fields.items(): @@ -128,42 +1496,145 @@ def append_atlas_section(answer: str, atlas_results: Dict[str, Any]) -> str: -async def stream_format_and_postprocess( - raw_content: str, +async def stream_generate_and_postprocess( + system_prompt: str, + messages: List[Dict[str, Any]], original_image_paths: List[str], temperature: float = 0.3, - content_label: str = None, + fallback: str = "抱歉,无法生成回答。", ) -> 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( + raw_answer = await OpenaiAPI.open_api_chat_without_thinking( + query=None, model=None, - system_prompt=format_system_prompt, - messages=[{"role": "user", "content": format_user_content}], + system_prompt=system_prompt, + messages=messages, + enable_thinking=False, 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 = (raw_answer or fallback).strip() + answer = re.sub( + r"ynchroneg>.*?ost switching>", + "", + answer, + flags=re.DOTALL, + ) + # 修复模型把下一个无序列表项接在上一句末尾的情况。 + answer = re.sub( + r"(?<=[。!?;:])\s+\*\s+(?=\S)", + "\n* ", + answer, + ) answer = remove_duplicate_content(answer) - answer = normalize_markdown_images(answer, original_paths=original_image_paths) - answer = normalize_latex_spacing(answer) + answer = normalize_markdown_images( + answer, + original_paths=original_image_paths, + ) + answer = normalize_latex_spacing(answer).strip() or fallback + # 16000 前端同时注册了两套 KaTeX 插件,其中旧插件会抢先把标准 + # $$...$$ 识别为 displayMode=False。改用同为标准 LaTeX 的 \[...\] + # 可命中新插件的块级规则,生成真正居中且独占一行的 katex-display。 + answer = convert_display_math_for_frontend(answer) + + def split_prose_line(line: str) -> List[str]: + """在闭合的行内公式/代码之外按中文语义停顿切分。""" + if len(line) <= 52: + return [line] + + pieces: List[str] = [] + start = 0 + for index, character in enumerate(line): + length = index + 1 - start + is_sentence_end = character in "。!?;" + is_long_pause = character in ",," and length >= 92 + if length < 36 or not (is_sentence_end or is_long_pause): + continue + + candidate = line[start:index + 1] + if candidate.count("$") % 2 or candidate.count("`") % 2: + continue + pieces.append(candidate) + start = index + 1 + + if start < len(line): + pieces.append(line[start:]) + return pieces or [line] + + def build_fake_stream_chunks(content: str) -> List[str]: + """构造不会截断特殊 Markdown 单元、且可原样拼回正文的片段。""" + lines = content.splitlines(keepends=True) + chunks: List[str] = [] + index = 0 + + while index < len(lines): + line = lines[index] + stripped = line.strip() + + if stripped in ("$$", "\\["): + closing_delimiter = ( + "$$" if stripped == "$$" else "\\]" + ) + block = line + index += 1 + while index < len(lines): + block += lines[index] + closing = ( + lines[index].strip() + == closing_delimiter + ) + index += 1 + if closing: + break + chunks.append(block) + continue + + if stripped.startswith("```"): + block = line + index += 1 + while index < len(lines): + block += lines[index] + closing = lines[index].strip().startswith("```") + index += 1 + if closing: + break + chunks.append(block) + continue + + if stripped.startswith("|"): + block = line + index += 1 + while index < len(lines) and lines[index].strip().startswith("|"): + block += lines[index] + index += 1 + chunks.append(block) + continue + + if "![" in line: + chunks.append(line) + else: + chunks.extend(split_prose_line(line)) + index += 1 + + return [chunk for chunk in chunks if chunk] + + chunks = build_fake_stream_chunks(answer) + for index, chunk in enumerate(chunks): + if stream_handler: + stream_handler.send_stream_content(chunk) + + if index < len(chunks) - 1: + visible_length = len(re.sub(r"\s+", "", chunk)) + # 总体约 120 字/秒,并保留 160~600ms 的自然停顿。 + delay = max(0.16, min(0.60, visible_length / 120)) + if chunk.lstrip().startswith(("$$", "\\[", "```", "|", "![")): + delay = max(delay, 0.22) + await asyncio.sleep(delay) return answer - - diff --git a/workflows/workflow_utils.py_0723 b/workflows/workflow_utils.py_0723 new file mode 100644 index 0000000..881d319 --- /dev/null +++ b/workflows/workflow_utils.py_0723 @@ -0,0 +1,1332 @@ +""" +工作流公共工具函数 +提取自 workflow_fault_diagnosis.py 和 workflow_operate.py 的共享代码 +""" + +from typing import TypedDict, Optional, Dict, Any, List +from difflib import SequenceMatcher +from modelsAPI.model_api import OpenaiAPI +from utils.function_tracker import get_all_callbacks, get_current_stream_handler +from utils.image_utils import extract_image_paths, normalize_markdown_images +import json +import re +import random +import inspect +import unicodedata + + +RAG_DEDUP_SIMILARITY_THRESHOLD = 0.86 +RAG_DEDUP_CONTAINMENT_THRESHOLD = 0.92 +RAG_DEDUP_MIN_TEXT_LENGTH = 20 + + +def safe_json_extract(text: str) -> Optional[Any]: + if not text: + return None + cleaned = text.strip() + cleaned = re.sub(r"```json\s*", "", cleaned, flags=re.IGNORECASE) + cleaned = re.sub(r"```\s*", "", cleaned) + m = re.search(r"\[[\s\S]*\]", cleaned) + if m: + try: + return json.loads(m.group(0)) + except Exception: + pass + m = re.search(r"\{[\s\S]*\}", cleaned) + if m: + try: + return json.loads(m.group(0)) + except Exception: + pass + try: + return json.loads(cleaned) + except Exception: + return None + + +from workflows.history_manager import parse_history, build_history_str, filter_image_urls, init_history # noqa: F401 + +import re + + +def normalize_latex_spacing(text: str) -> str: + """ + 规范 Markdown 中的 LaTeX 公式。 + + 规则: + 1. 行内公式 $...$ + - 清除公式定界符内部首尾空格 + - 公式前后只要存在非空白字符,就补一个空格 + - ($T$) 会转换为 ( $T$ ) + + 2. 段落公式 $$...$$ + - 多行公式完整保留,不会删除结尾的 $$ + - 单行 $$公式$$ 转换为标准多行段落公式 + - Markdown 表格中的 $$公式$$ 不拆行 + + 3. 转换: + - \\(...\\) 转换为 $...$ + - \\[...\\] 转换为 $$...$$ + + 4. 不处理: + - Markdown 围栏代码块 + - Markdown 行内代码 + - HTML code、pre、script、style + - HTML 注释 + - Markdown 链接地址 + - 转义美元符号 \\$ + """ + if not isinstance(text, str): + raise TypeError( + f"text 必须是 str,实际类型为 {type(text).__name__}" + ) + + if not text: + return text + + text = text.replace("\r\n", "\n").replace("\r", "\n") + + protected_parts: list[str] = [] + + def protect(content: str) -> str: + token = ( + f"\uE000LATEX_PROTECTED_" + f"{len(protected_parts)}" + f"\uE001" + ) + protected_parts.append(content) + return token + + def restore(content: str) -> str: + for index, original in enumerate(protected_parts): + token = f"\uE000LATEX_PROTECTED_{index}\uE001" + content = content.replace(token, original) + + return content + + def is_escaped(content: str, position: int) -> bool: + """ + 判断 content[position] 是否被奇数个反斜杠转义。 + """ + slash_count = 0 + position -= 1 + + while position >= 0 and content[position] == "\\": + slash_count += 1 + position -= 1 + + return slash_count % 2 == 1 + + # ================================================================ + # 1. 保护 Markdown 围栏代码块 + # ================================================================ + + lines = text.splitlines(keepends=True) + protected_lines: list[str] = [] + line_index = 0 + + while line_index < len(lines): + line = lines[line_index] + + opening_match = re.match( + r"^[ \t]{0,3}(`{3,}|~{3,})", + line, + ) + + if not opening_match: + protected_lines.append(line) + line_index += 1 + continue + + opening_fence = opening_match.group(1) + fence_character = opening_fence[0] + fence_length = len(opening_fence) + + closing_pattern = re.compile( + rf"^[ \t]{{0,3}}" + rf"{re.escape(fence_character)}" + rf"{{{fence_length},}}" + rf"[ \t]*(?:\n|$)" + ) + + block_lines = [line] + line_index += 1 + + while line_index < len(lines): + current_line = lines[line_index] + block_lines.append(current_line) + line_index += 1 + + if closing_pattern.match(current_line): + break + + protected_lines.append( + protect("".join(block_lines)) + ) + + text = "".join(protected_lines) + + # ================================================================ + # 2. 保护 HTML 代码区域和注释 + # ================================================================ + + text = re.sub( + r"(?is)" + r"<(?Ppre|code|script|style)\b[^>]*>" + r".*?" + r"", + lambda match: protect(match.group(0)), + text, + ) + + text = re.sub( + r"(?s)", + lambda match: protect(match.group(0)), + text, + ) + + # ================================================================ + # 3. 保护 Markdown 链接地址 + # ================================================================ + + text = re.sub( + r"(!?\[[^\]\n]*\]\()([^)\n]*)(\))", + lambda match: ( + match.group(1) + + protect(match.group(2)) + + match.group(3) + ), + text, + ) + + text = re.sub( + r"(?im)" + r"^([ \t]{0,3}\[[^\]\n]+\]:[ \t]*)" + r"(\S+(?:[ \t]+.*)?)$", + lambda match: ( + match.group(1) + + protect(match.group(2)) + ), + text, + ) + + # ================================================================ + # 4. 保护 Markdown 行内代码 + # ================================================================ + + inline_code_result: list[str] = [] + position = 0 + + while position < len(text): + if text[position] != "`": + inline_code_result.append(text[position]) + position += 1 + continue + + delimiter_end = position + + while ( + delimiter_end < len(text) + and text[delimiter_end] == "`" + ): + delimiter_end += 1 + + delimiter = text[position:delimiter_end] + search_position = delimiter_end + closing_position = -1 + + while search_position < len(text): + candidate = text.find( + delimiter, + search_position, + ) + + if candidate < 0: + break + + previous_character = ( + text[candidate - 1] + if candidate > 0 + else "" + ) + + candidate_end = candidate + len(delimiter) + + next_character = ( + text[candidate_end] + if candidate_end < len(text) + else "" + ) + + if ( + previous_character != "`" + and next_character != "`" + ): + closing_position = candidate + break + + search_position = candidate + 1 + + if closing_position < 0: + inline_code_result.append(delimiter) + position = delimiter_end + continue + + end_position = closing_position + len(delimiter) + + inline_code_result.append( + protect(text[position:end_position]) + ) + + position = end_position + + text = "".join(inline_code_result) + + # ================================================================ + # 5. 转换其他 LaTeX 公式定界符 + # ================================================================ + + # \( x + y \) -> $x + y$ + text = re.sub( + r"(? $$x + y$$ + text = re.sub( + r"(?s)(? 0 + and text[position - 1] == "$" + ) + ) + + if not is_inline_opening: + inline_result.append(text[position]) + position += 1 + continue + + opening_position = position + + # 避免把 $100 识别成行内公式开始。 + next_opening_character = ( + text[position + 1] + if position + 1 < len(text) + else "" + ) + + if next_opening_character.isdigit(): + inline_result.append("$") + position += 1 + continue + + search_position = position + 1 + closing_position = -1 + + while search_position < len(text): + candidate = text.find( + "$", + search_position, + ) + + if candidate < 0: + break + + # 行内公式不能跨行。 + if "\n" in text[ + opening_position + 1:candidate + ]: + break + + if is_escaped(text, candidate): + search_position = candidate + 1 + continue + + # 跳过属于 $$ 的美元符号。 + if text.startswith("$$", candidate): + search_position = candidate + 2 + continue + + closing_position = candidate + break + + # 未找到闭合符时原样保留。 + if closing_position < 0: + inline_result.append("$") + position += 1 + continue + + formula_body = text[ + opening_position + 1:closing_position + ].strip() + + # 空公式不处理。 + if not formula_body: + inline_result.append( + text[ + opening_position:closing_position + 1 + ] + ) + + position = closing_position + 1 + continue + + # 折叠公式内部普通空格,不修改 LaTeX 命令。 + formula_body = re.sub( + r"[ \t]+", + " ", + formula_body, + ) + + previous_character = "" + + for part in reversed(inline_result): + if part: + previous_character = part[-1] + break + + next_character = ( + text[closing_position + 1] + if closing_position + 1 < len(text) + else "" + ) + + # 公式前只要存在非空白字符,就补空格。 + # + # 参数$T$ -> 参数 $T$ + # ($T$) -> ( $T$ + # ($T$) -> ( $T$ + if ( + previous_character + and not previous_character.isspace() + ): + inline_result.append(" ") + + inline_result.append( + "$" + formula_body + "$" + ) + + # 公式后只要存在非空白字符,就补空格。 + # + # $T$参数 -> $T$ 参数 + # ($T$) -> $T$ ) + # ($T$) -> $T$ ) + if ( + next_character + and not next_character.isspace() + ): + inline_result.append(" ") + + position = closing_position + 1 + + return restore("".join(inline_result)) + + +def remove_duplicate_content(text: str) -> str: + """ + 删除普通 Markdown 正文中的完全重复行。 + + 不对以下内容进行去重: + - $$ ... $$ 段落公式 + - \\[ ... \\] 段落公式 + - equation、align、gather 等 LaTeX 环境 + - Markdown 围栏代码块 + - Markdown 缩进代码块 + - Markdown 标题、列表、引用、表格等结构行 + + 这样不会删除段落公式末尾的第二个 $$。 + """ + if not text or len(text) < 10: + return text + + if not isinstance(text, str): + raise TypeError( + f"text 必须是 str,实际类型为 {type(text).__name__}" + ) + + text = text.replace("\r\n", "\n").replace("\r", "\n") + lines = text.split("\n") + + result: list[str] = [] + seen_segments: set[str] = set() + + in_code_block = False + code_fence_character = "" + code_fence_length = 0 + + in_dollar_math = False + in_bracket_math = False + + latex_environment_stack: list[str] = [] + + display_environments = { + "equation", + "equation*", + "align", + "align*", + "alignat", + "alignat*", + "gather", + "gather*", + "multline", + "multline*", + "flalign", + "flalign*", + "eqnarray", + "eqnarray*", + "displaymath", + "math", + } + + def is_escaped(value: str, position: int) -> bool: + slash_count = 0 + position -= 1 + + while position >= 0 and value[position] == "\\": + slash_count += 1 + position -= 1 + + return slash_count % 2 == 1 + + def count_unescaped_double_dollars(value: str) -> int: + """ + 统计一行中未转义的 $$ 数量。 + """ + count = 0 + position = 0 + + while position < len(value) - 1: + if ( + value[position:position + 2] == "$$" + and not is_escaped(value, position) + ): + count += 1 + position += 2 + else: + position += 1 + + return count + + def is_markdown_structure_line(value: str) -> bool: + """ + Markdown 结构行不参与去重,避免破坏语义。 + """ + stripped = value.strip() + + if not stripped: + return True + + patterns = ( + # 标题 + r"^#{1,6}\s+", + + # 无序列表 + r"^[-+*]\s+", + + # 有序列表 + r"^\d+[.)]\s+", + + # 引用 + r"^>\s*", + + # 任务列表 + r"^[-+*]\s+\[[ xX]\]\s+", + + # 水平分隔线 + r"^(?:-{3,}|\*{3,}|_{3,})$", + + # 表格分隔行 + r"^\|?\s*:?-{3,}:?" + r"(?:\s*\|\s*:?-{3,}:?)+\s*\|?$", + + # 普通表格行 + r"^\|.*\|$", + + # HTML 标签 + r"^]*>$", + + # LaTeX 环境边界 + r"^\\(?:begin|end)\{[^}]+\}", + + # 公式定界符 + r"^(?:\$\$|\\\[|\\\])$", + + # Markdown 链接引用定义 + r"^\[[^\]]+\]:\s*\S+", + ) + + return any( + re.match(pattern, stripped) + for pattern in patterns + ) + + for line in lines: + line = line.rstrip() + stripped = line.strip() + + # ============================================================ + # 1. Markdown 围栏代码块 + # ============================================================ + + fence_match = re.match( + r"^[ \t]{0,3}(`{3,}|~{3,})", + line, + ) + + if not in_code_block and fence_match: + fence = fence_match.group(1) + + in_code_block = True + code_fence_character = fence[0] + code_fence_length = len(fence) + + result.append(line) + continue + + if in_code_block: + result.append(line) + + closing_pattern = re.compile( + rf"^[ \t]{{0,3}}" + rf"{re.escape(code_fence_character)}" + rf"{{{code_fence_length},}}" + rf"[ \t]*$" + ) + + if closing_pattern.match(line): + in_code_block = False + code_fence_character = "" + code_fence_length = 0 + + continue + + # Markdown 缩进代码块不参与去重。 + if re.match(r"^(?:\t| {4})", line): + result.append(line) + continue + + # ============================================================ + # 2. \[ ... \] 段落公式 + # ============================================================ + + if in_bracket_math: + result.append(line) + + if re.search(r"(? bracket_close_count: + in_bracket_math = True + + continue + + # ============================================================ + # 3. LaTeX display 环境 + # ============================================================ + + begin_matches = re.findall( + r"\\begin\{([^}]+)\}", + line, + ) + + end_matches = re.findall( + r"\\end\{([^}]+)\}", + line, + ) + + relevant_begins = [ + environment + for environment in begin_matches + if environment in display_environments + ] + + relevant_ends = [ + environment + for environment in end_matches + if environment in display_environments + ] + + if latex_environment_stack: + result.append(line) + + for environment in relevant_begins: + latex_environment_stack.append(environment) + + for environment in relevant_ends: + if ( + latex_environment_stack + and latex_environment_stack[-1] + == environment + ): + latex_environment_stack.pop() + elif environment in latex_environment_stack: + latex_environment_stack.remove(environment) + + continue + + if relevant_begins: + result.append(line) + + for environment in relevant_begins: + latex_environment_stack.append(environment) + + for environment in relevant_ends: + if ( + latex_environment_stack + and latex_environment_stack[-1] + == environment + ): + latex_environment_stack.pop() + elif environment in latex_environment_stack: + latex_environment_stack.remove(environment) + + continue + + # ============================================================ + # 4. $$ ... $$ 段落公式 + # ============================================================ + + double_dollar_count = ( + count_unescaped_double_dollars(line) + ) + + if in_dollar_math: + # 公式块中的内容全部保留,包括最后一行的 $$。 + result.append(line) + + if double_dollar_count % 2 == 1: + in_dollar_math = False + + continue + + if double_dollar_count > 0: + # 含有 $$ 的行永远不参与去重。 + result.append(line) + + # 奇数个 $$ 代表开启了跨行段落公式。 + if double_dollar_count % 2 == 1: + in_dollar_math = True + + continue + + # ============================================================ + # 5. 空行和 Markdown 结构行 + # ============================================================ + + if not stripped: + result.append(line) + continue + + if is_markdown_structure_line(line): + result.append(line) + continue + + # ============================================================ + # 6. 仅对普通正文行去重 + # ============================================================ + + normalized_line = re.sub( + r"\s+", + " ", + stripped.lower(), + ) + + if normalized_line in seen_segments: + continue + + seen_segments.add(normalized_line) + result.append(line) + + return "\n".join(result) + + + + +# 推荐调用顺序: +# +# text = remove_duplicate_content(text) +# text = normalize_latex_spacing(text) + + + +def _extract_rag_text(item: Any) -> str: + if isinstance(item, dict): + return str(item.get("text") or item.get("content") or "") + if isinstance(item, str): + return item + return "" + + +def _extract_rag_score(item: Any) -> float: + if not isinstance(item, dict): + return 0.0 + try: + return float(item.get("score") or item.get("socre") or 0.0) + except (TypeError, ValueError): + return 0.0 + + +def _normalize_rag_text_for_dedupe(text: Any) -> str: + value = unicodedata.normalize("NFKC", str(text or "")) + value = re.sub(r"!\[[^\]]*\]\([^)]+\)", "", value) + value = re.sub(r"\[([^\]]*)\]\([^)]+\)", r"\1", value) + value = re.sub(r"https?://\S+", "", value, flags=re.IGNORECASE) + value = re.sub(r"[/\\]?picture[/\\]\S+", "", value, flags=re.IGNORECASE) + value = re.sub(r"\s+", "", value).lower() + return "".join(ch for ch in value if ch.isalnum()) + + +def _char_ngrams(text: str, n: int) -> set: + if len(text) <= n: + return {text} if text else set() + return {text[i:i + n] for i in range(len(text) - n + 1)} + + +def _rag_texts_are_similar(text_a: str, text_b: str, threshold: float) -> bool: + if not text_a or not text_b: + return False + if text_a == text_b: + return True + + min_len = min(len(text_a), len(text_b)) + max_len = max(len(text_a), len(text_b)) + if min_len < 8: + return False + + if min_len >= RAG_DEDUP_MIN_TEXT_LENGTH and (text_a in text_b or text_b in text_a): + return (min_len / max_len) >= RAG_DEDUP_CONTAINMENT_THRESHOLD + + if min_len < RAG_DEDUP_MIN_TEXT_LENGTH: + return SequenceMatcher(None, text_a, text_b).ratio() >= 0.95 + + ngram_size = 3 if min_len >= 30 else 2 + grams_a = _char_ngrams(text_a, ngram_size) + grams_b = _char_ngrams(text_b, ngram_size) + if not grams_a or not grams_b: + return False + + jaccard = len(grams_a & grams_b) / len(grams_a | grams_b) + if jaccard >= threshold: + return True + + return SequenceMatcher(None, text_a, text_b).ratio() >= threshold + + +def dedupe_rag_results( + items: Any, + limit: Optional[int] = None, + similarity_threshold: float = RAG_DEDUP_SIMILARITY_THRESHOLD, +) -> Any: + """ + Deduplicate RAG result chunks while preserving the original item shape. + + Higher-score items are kept first. Graph results are intentionally not handled + here; callers should pass only text RAG chunks. + """ + if not isinstance(items, list): + return items + if len(items) < 2: + return items[:limit] if limit else items + + prepared = [] + for idx, item in enumerate(items): + prepared.append({ + "item": item, + "index": idx, + "score": _extract_rag_score(item), + "normalized_text": _normalize_rag_text_for_dedupe(_extract_rag_text(item)), + }) + + prepared.sort(key=lambda entry: (-entry["score"], entry["index"])) + + kept = [] + for entry in prepared: + current_text = entry["normalized_text"] + is_duplicate = False + if current_text: + for kept_entry in kept: + if _rag_texts_are_similar( + current_text, + kept_entry["normalized_text"], + similarity_threshold, + ): + is_duplicate = True + break + if not is_duplicate: + kept.append(entry) + + deduped = [entry["item"] for entry in kept] + return deduped[:limit] if limit else deduped + + +def convert_rag_result(rag_result): + if not rag_result or not rag_result.get("success", False): + return [] + source_citation = rag_result.get("sourceCitation", {}) + flat_results = [] + for resource, items in source_citation.items(): + for item in items: + flat_results.append(item) + return flat_results + + +def calculate_info_completeness(weights: Dict[str, float], values: Dict[str, str]) -> float: + score = 0.0 + for field, weight in weights.items(): + value = values.get(field, "") or "" + if value and str(value).strip(): + score += weight + return round(score, 2) + + +def emit_callback_event(title_options: List[str], details: str): + start_event = {"type": "function_execution", "title": random.choice(title_options), "details": details} + for callback in get_all_callbacks(): + try: + callback(start_event) + except Exception: + pass + + +async def resolve_ship_number_for_workflow( + ship_number: str, + context: str = "workflow", +) -> tuple[str, Dict[str, Any]]: + """ + 工作流内舷号/舰名标准化。 + + 能映射到具体舷号时返回映射后的舷号;失败或未匹配时返回原值。 + """ + original_ship_number = str(ship_number or "").strip() + if not original_ship_number: + return original_ship_number, {} + + try: + from utils.ship_number_search import smart_ship_number_mapping + + matched_ship_number, matched_model, all_numbers, match_type = await smart_ship_number_mapping( + original_ship_number + ) + result = { + "matched_ship_number": matched_ship_number, + "matched_model": matched_model, + "all_numbers": all_numbers, + "match_type": match_type, + } + if match_type in ["exact_number", "exact_name", "embedding"] and matched_ship_number: + mapped_ship_number = str(matched_ship_number).strip() + if mapped_ship_number and mapped_ship_number != original_ship_number: + print( + f"[{context}] 舷号映射: '{original_ship_number}' -> " + f"'{mapped_ship_number}',匹配类型={match_type}" + ) + return mapped_ship_number or original_ship_number, result + + print(f"[{context}] 舷号未映射,继续使用原值: {original_ship_number}") + return original_ship_number, result + except Exception as e: + print(f"[{context}] 舷号映射异常,继续使用原值: {original_ship_number}; 错误: {str(e)}") + return original_ship_number, {} + + +async def resolve_ship_scope_for_workflow( + ship_number: str, + context: str = "workflow", +) -> tuple[str, List[str], Dict[str, Any]]: + """ + Resolve user ship input into a display hull number and a hull-number scope. + + Hull number and ship-name matches collapse to one hull. Model matches keep the + user's original value for display but return every hull number under that model + so shared device normalization and system lookup can search the whole scope. + """ + original_ship_number = str(ship_number or "").strip() + if not original_ship_number: + return original_ship_number, [], {} + + try: + from utils.ship_number_search import smart_ship_number_mapping + + matched_ship_number, matched_model, all_numbers, match_type = await smart_ship_number_mapping( + original_ship_number + ) + scope_numbers = [str(num).strip() for num in (all_numbers or []) if str(num or "").strip()] + result = { + "matched_ship_number": matched_ship_number, + "matched_model": matched_model, + "all_numbers": scope_numbers, + "match_type": match_type, + } + + if match_type in ["exact_number", "exact_name", "embedding"] and matched_ship_number: + mapped_ship_number = str(matched_ship_number).strip() + return mapped_ship_number or original_ship_number, [mapped_ship_number], result + + if match_type == "exact_model" and scope_numbers: + print( + f"[{context}] ship model scope resolved: '{original_ship_number}' -> " + f"{matched_model}, hull_numbers={scope_numbers}" + ) + return original_ship_number, scope_numbers, result + + print(f"[{context}] ship scope not resolved, continuing with original value: {original_ship_number}") + return original_ship_number, [], result + except Exception as e: + print(f"[{context}] ship scope resolve failed, continuing with original value: {original_ship_number}; error: {str(e)}") + return original_ship_number, [], {} + + +async def normalize_device_name_for_workflow( + device_name: str, + ship_number: str = "", + ship_numbers: Optional[List[str]] = None, + context: str = "workflow", +) -> tuple[str, Dict[str, Any]]: + """ + 工作流内设备名称标准化。 + + 标准化失败时返回原设备名,避免检索流程被 Neo4j 或 embedding 服务异常阻断。 + """ + original_device_name = str(device_name or "").strip() + if not original_device_name: + return original_device_name, {} + + try: + from tools.graph_tools import normalize_device_name_by_ship_graph + + print( + f"[{context}] 开始设备名称标准化: device='{original_device_name}', " + f"ship_number='{ship_number or '未提供'}'" + ) + call_kwargs = {} + if "ship_number" in inspect.signature(normalize_device_name_by_ship_graph).parameters: + call_kwargs["ship_number"] = str(ship_number or "").strip() or None + if "ship_numbers" in inspect.signature(normalize_device_name_by_ship_graph).parameters: + scoped_numbers = [str(num).strip() for num in (ship_numbers or []) if str(num or "").strip()] + call_kwargs["ship_numbers"] = scoped_numbers or None + elif ship_number: + print(f"[{context}] 当前进程加载的设备标准化函数不支持 ship_number,请重启服务加载最新 graph_tools.py") + + result = await normalize_device_name_by_ship_graph(original_device_name, **call_kwargs) + if result.get("success", False): + normalized_device_name = str(result.get("device_name") or original_device_name).strip() + print( + f"[{context}] 设备名称标准化完成: '{original_device_name}' -> " + f"'{normalized_device_name or original_device_name}',舷号={ship_number or '未提供'}" + ) + emit_callback_event( + ["🔎 设备名称标准化", "🔧 设备匹配"], + f"{original_device_name} -> {normalized_device_name or original_device_name}" + ) + return normalized_device_name or original_device_name, result + + print( + f"[{context}] 设备名称标准化失败,继续使用原设备名: {original_device_name}; " + f"原因: {result.get('error', '未知错误')}" + ) + return original_device_name, result + except Exception as e: + print(f"[{context}] 设备名称标准化异常,继续使用原设备名: {original_device_name}; 错误: {str(e)}") + return original_device_name, {} + + +async def resolve_device_system_for_workflow( + device_name: str, + ship_number: str = "", + ship_numbers: Optional[List[str]] = None, + context: str = "workflow", +) -> tuple[str, Dict[str, Any]]: + """ + 工作流内根据设备名称反推所属系统。 + + 失败时返回空字符串,调用方可继续执行主流程。 + """ + device_name = str(device_name or "").strip() + if not device_name: + return "", {} + + try: + print( + f"[{context}] 开始设备反推系统: device='{device_name}', " + f"ship_number='{ship_number or '未提供'}'" + ) + from tools.graph_tools import find_system_by_device + + call_kwargs = {"device_name": device_name} + if "ship_number" in inspect.signature(find_system_by_device).parameters: + call_kwargs["ship_number"] = str(ship_number or "").strip() or None + if "ship_numbers" in inspect.signature(find_system_by_device).parameters: + scoped_numbers = [str(num).strip() for num in (ship_numbers or []) if str(num or "").strip()] + call_kwargs["ship_numbers"] = scoped_numbers or None + elif ship_number: + print(f"[{context}] 当前进程加载的设备反推系统函数不支持 ship_number,请重启服务加载最新 graph_tools.py") + + result = await find_system_by_device(**call_kwargs) + if not result.get("success", False): + print(f"[{context}] 设备反推系统失败: {result.get('error', '未知错误')}") + return "", result + + systems = result.get("systems") or [] + first_system = systems[0] if systems else {} + system_name = "" + if isinstance(first_system, dict): + system_name = str(first_system.get("name") or first_system.get("系统名称") or first_system.get("名称") or "").strip() + + if system_name: + print(f"[{context}] 设备反推系统完成: {device_name} -> {system_name}") + emit_callback_event( + ["🔎 设备所属系统识别", "🧭 系统反查"], + f"{device_name} -> {system_name}" + ) + else: + print(f"[{context}] 设备反推系统无结果: {device_name}") + return system_name, result + except Exception as e: + print(f"[{context}] 设备反推系统异常: {str(e)}") + return "", {} + + +def build_detail_info(fields: Dict[str, str]) -> str: + detail_info = "" + for label, value in fields.items(): + if value: + detail_info += f"- {label}:{value}\n" + return detail_info + + +def append_atlas_section(answer: str, atlas_results: Dict[str, Any]) -> str: + if not atlas_results: + return answer + atlas_section = "\n\n---\n\n**参考图册:**\n" + for node_name, node_data in atlas_results.items(): + if isinstance(node_data, dict): + for title, urls in node_data.items(): + if isinstance(urls, list) and urls: + for url in urls: + atlas_section += f"- {title}: [ `{title}` ]({url})\n" + return answer + atlas_section + + + + +async def stream_generate_and_postprocess( + system_prompt: str, + messages: List[Dict[str, Any]], + original_image_paths: List[str], + temperature: float = 0.3, + fallback: str = "抱歉,无法生成回答。", +) -> str: + """单次流式生成最终回复,并在本地做稳定的格式与图片校验。""" + stream_handler = get_current_stream_handler() + answer = "" + accumulated_content = "" + + async for chunk in OpenaiAPI.open_api_chat_stream( + model=None, + system_prompt=system_prompt, + messages=messages, + temperature=temperature, + ): + answer += chunk + accumulated_content += chunk + if stream_handler: + stream_handler.send_stream_content(accumulated_content) + + answer = answer.strip() if answer else fallback + print("111-",answer) + answer = re.sub(r'ynchroneg>.*?ost switching>', '', answer, flags=re.DOTALL) + print("222-",answer) + answer = remove_duplicate_content(answer) + print("333-",answer) + answer = normalize_markdown_images(answer, original_paths=original_image_paths) + print("444-",answer) + answer = normalize_latex_spacing(answer) + + return answer.strip() if answer else fallback +