同步46服务器代码并修复RAG溯源合并

This commit is contained in:
ChenRui 2026-07-22 17:17:32 +08:00
parent e242d9caa8
commit e84cd1f2aa
14 changed files with 1497 additions and 1338 deletions

16
app.py
View File

@ -31,6 +31,7 @@ from workflow_registry import WORKFLOW_CONFIG, VALID_ROUTE_FLAGS
from main_agent import extract_conversation_title
from workflows.history_manager import filter_image_urls, preprocess_from_request
from config import set_request_user_config
from utils.source_citations import merge_rag_source_citations
from checkpointer_config import (
CheckpointerManager,
checkpointer_manager
@ -462,7 +463,7 @@ async def stream_main_agent_execution(
agent_task = asyncio.create_task(run_agent())
execution_complete_received = False
rag_result = []
rag_result = {}
graph_result = []
while True:
try:
@ -482,16 +483,7 @@ async def stream_main_agent_execution(
if title == "知识库搜索工具" and result and isinstance(result, dict):
print("===============================", result)
rag_result_raw = result.get("sourceCitation", {})
rag_result = {}
for doc_name, chunks in rag_result_raw.items():
if isinstance(chunks, list):
rag_result[doc_name] = [
{k: v for k, v in item.items() if k != "text"}
if isinstance(item, dict) else item
for item in chunks
]
else:
rag_result[doc_name] = chunks
merge_rag_source_citations(rag_result, rag_result_raw)
elif title == "图谱检索工具" and result and isinstance(result, dict):
print("===============================", result)
graph_result = result.get("xxxx.graph", [])
@ -1017,5 +1009,3 @@ if __name__ == "__main__":

View File

@ -220,3 +220,13 @@ NEO4J_CONFIG = {
"user": "neo4j",
"password": "zdht123@"
}
# ==================== 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,
}

View File

@ -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 neo4j_graphrag.embeddings.base import Embedder
from openai import AsyncOpenAI
@ -24,6 +24,25 @@ def _get_async_client() -> AsyncOpenAI:
return _async_client
def _log_model_messages(label: str, model: str, message_list: list) -> None:
if os.getenv("LLM_LOG_FULL_PROMPTS", "false").lower() == "true":
print(label, message_list)
return
if os.getenv("LLM_LOG_CALLS", "true").lower() != "true":
return
summary = []
total_chars = 0
for message in message_list or []:
content = message.get("content") if isinstance(message, dict) else ""
if isinstance(content, str):
chars = len(content)
else:
chars = len(str(content))
total_chars += chars
summary.append({"role": message.get("role", ""), "chars": chars})
print(label, {"model": model, "messages": summary, "total_chars": total_chars})
class OpenaiAPI:
@staticmethod
async def open_api_chat_without_thinking(
@ -64,7 +83,7 @@ class OpenaiAPI:
if json_output:
kwargs["response_format"] = {"type": "json_object"}
print("model API. history message", message_list)
_log_model_messages("model API call", model, message_list)
response = await client.chat.completions.create(**kwargs)
return response.choices[0].message.content
@ -112,7 +131,7 @@ class OpenaiAPI:
if json_output:
kwargs["response_format"] = {"type": "json_object"}
print("model API stream. history message", message_list)
_log_model_messages("model API stream call", model, message_list)
stream = await client.chat.completions.create(**kwargs)
async for chunk in stream:
@ -224,6 +243,66 @@ class OpenaiAPI:
embedding = response.json()["data"][0]["embedding"]
return embedding
@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
async def get_embeddings_batch_async(texts: List[str]) -> List[List[float]]:
embedding_base_url = EMBEDDING_CONFIG["base_url"]
@ -384,3 +463,4 @@ if __name__ == "__main__":

View File

@ -427,29 +427,19 @@ BAIKE_PROMPTS = {
# 输入变量: history_messages, original_query
# ------------------------------------------------------------------
"judge_need_retrieval": (
"你是一个专业的问题分析助手,请判断用户的问题是否需要检索知识库才能回答。\n"
"You are an intent classifier for a knowledge-base QA assistant. Decide whether the user question needs retrieval.\n"
"\n"
"【判断标准】\n"
"需要检索的情况:\n"
"- 询问特定设备、技术、规程、规范的具体内容\n"
"- 需要专业知识或特定数据支持的问题\n"
"- 询问故障诊断、维修方法等专业领域问题\n"
"- 询问历史事件、法规条款、技术标准等需要查证的内容\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"
"不需要检索的情况:\n"
"- 简单的问候、寒暄\n"
"- 常识性问题(如常识性知识、简单计算等)\n"
"- 明确不需要专业知识的问题\n"
"- 对之前对话的简单追问,上下文已足够回答\n"
"\n"
"【对话历史】\n"
"Conversation history:\n"
"{history_messages}\n"
"\n"
"【当前用户问题】\n"
"User question:\n"
"{original_query}\n"
"\n"
"请仅输出 \"true\"\"false\"不带引号true 表示需要检索false 表示不需要检索。"
"Output only true or false."
),
# ------------------------------------------------------------------
@ -457,31 +447,35 @@ BAIKE_PROMPTS = {
# 输入变量: last_user_query, history_str, original_query
# ------------------------------------------------------------------
"rewrite_query_with_history": (
"你是一个专业的检索query重写助手负责根据对话历史和当前用户问题生成**适合向量检索/RAG 的单条查询语句**。\n"
"You are a query understanding assistant for a Chinese knowledge-base retrieval system.\n"
"\n"
"请严格遵守以下要求:\n"
"1. 只融合与当前问题语义强相关的**最近几轮对话信息**,不要机械拼接全部历史。\n"
"2. 消除指代和省略(例如:这个、刚才那个问题、上面说的故障等),补全成自洽、完整的描述。\n"
"3. 不要加入与用户问题无关的背景信息,不要过度扩展检索范围。\n"
"4. 输出必须是一句自然的中文问句或陈述句,便于检索;不要包含条目符号、编号或解释性文字。\n"
"5. 只输出改写后的query本身\n"
" - 不要输出说明文字\n"
" - 不要包含「改写结果」「检索query」等前缀\n"
" - 不要添加引号\n"
"Tasks:\n"
"1. Rewrite the current user question into a self-contained Chinese retrieval query.\n"
"2. Generate 2-5 short search queries for hybrid retrieval.\n"
"\n"
"【特别提示】\n"
"- 请优先参考**上一轮用户的问题**来重写本轮query。\n"
"- 上一轮用户的问题是:{last_user_query}\n"
"Rules:\n"
"- Resolve references and ellipsis using only relevant recent history.\n"
"- Preserve concrete entities, file names, database/table terms, equipment names, fault names, standards, procedure names, and exact keywords.\n"
"- Do not output meta instructions such as “在知识库中查找”. Output the actual search content.\n"
"- Include compact keyword queries that help exact matching, for example document titles, equipment names, or core nouns.\n"
"- The rewritten query and search queries must be in Chinese unless the original keyword is English.\n"
"\n"
"【对话历史JSON 格式,已按时间排序)】\n"
"Last user question:\n"
"{last_user_query}\n"
"\n"
"Conversation history JSON:\n"
"{history_str}\n"
"\n"
"【当前用户问题】\n"
"Current user question:\n"
"{original_query}\n"
"\n"
"请给出最终用于检索的单条中文query。"
"Output ONLY valid JSON:\n"
"{{\"rewrite_query\":\"string\",\"search_queries\":[\"string\"]}}"
),
# ------------------------------------------------------------------
# generate_suggested_replies_baike 函数:生成百科问答的建议回复问题
# 输入变量: question, answer
@ -514,8 +508,15 @@ BAIKE_PROMPTS = {
# generate_answer 节点 - 有检索结果时的系统提示词
# 输入变量: 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."
),
# ------------------------------------------------------------------
@ -523,24 +524,24 @@ 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. 先直接回答用户问题,再按需要补充结构、原理、参数、应用场景、注意事项或资料依据\n"
"2. 参考资料中真实存在的图片可在当前问题、设备结构、部件位置、流程步骤或现象说明对应段落附近自然展示;图片规则不要写入正文\n"
"3. 图片只作为资料引用输出为单个 Markdown 图片标签URL 和文件名必须逐字来自参考资料,不要改写、拆分、补全、连续重复或自行构造;同一图片资源重复出现时只引用一次\n"
"4. 如果参考资料包含 LaTeX 公式、链接等,请在对应位置正常使用,不得修改或删除\n"
"5. 请专注于内容的准确性、完整性和逻辑性,不要担心格式的美观性\n"
"6. 可以适当使用简单的段落分隔但不要使用复杂的Markdown格式\n"
"7. 如果参考资料包含表格,请不要输出表格,而要直接描述表格的内容\n"
"1. 先直接回答用户问题,再补充必要依据。\n"
"2. 每个关键事实都必须能在 Retrieved evidence 中找到依据;不要补充资料外结论。\n"
"3. 参考资料中真实存在的图片可在对应段落附近展示URL 必须逐字来自资料。\n"
"4. 表格内容请转成自然语言描述,不要输出复杂表格。\n"
"\n"
"请给出回复:"
),
# ------------------------------------------------------------------
# generate_answer 节点 - 无检索结果时的系统提示词
# 输入变量: domain_desc

View File

@ -1,41 +1,41 @@
"""
工具模块
包含RAGGraphRAGVLMASRTTS等工具
"""
from .function_tool import (
detect_input_type,
graph_rag_search,
fault_graph_rag_search,
operation_graph_rag_search,
rag_search_tool,
vlm_image_to_text,
asr_audio_to_text,
extract_device_and_fault,
image_rag_describe,
file_to_text_tool,
fault_type_statistics_tool,
fault_frequency_statistics_tool,
spare_parts_statistics_tool,
ALL_TOOLS,
TOOL_CATEGORIES
)
__all__ = [
"detect_input_type",
"graph_rag_search",
"fault_graph_rag_search",
"operation_graph_rag_search",
"rag_search_tool",
"vlm_image_to_text",
"asr_audio_to_text",
"extract_device_and_fault",
"image_rag_describe",
"file_to_text_tool",
"fault_type_statistics_tool",
"fault_frequency_statistics_tool",
"spare_parts_statistics_tool",
"ALL_TOOLS",
"TOOL_CATEGORIES"
]
"""
工具模块
包含RAGGraphRAGVLMASRTTS等工具
"""
from .function_tool import (
detect_input_type,
graph_rag_search,
fault_graph_rag_search,
operation_graph_rag_search,
rag_search_tool,
vlm_image_to_text,
asr_audio_to_text,
extract_device_and_fault,
image_rag_describe,
file_to_text_tool,
fault_type_statistics_tool,
fault_frequency_statistics_tool,
spare_parts_statistics_tool,
ALL_TOOLS,
TOOL_CATEGORIES
)
__all__ = [
"detect_input_type",
"graph_rag_search",
"fault_graph_rag_search",
"operation_graph_rag_search",
"rag_search_tool",
"vlm_image_to_text",
"asr_audio_to_text",
"extract_device_and_fault",
"image_rag_describe",
"file_to_text_tool",
"fault_type_statistics_tool",
"fault_frequency_statistics_tool",
"spare_parts_statistics_tool",
"ALL_TOOLS",
"TOOL_CATEGORIES"
]

View File

@ -536,6 +536,7 @@ def _node_record_to_dict(record: Any) -> Dict[str, Any]:
return result
@track_function_calls
async def normalize_device_name_by_ship_graph(
device_name: str,
fulltext_index: str = GLOBAL_FULLTEXT_INDEX,
@ -902,6 +903,7 @@ def _build_fault_graph_results_text(
return "\n".join(parts)
@track_function_calls
async def find_system_by_device(
device_name: str,
min_hops: int = 2,

View File

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

View File

@ -1,360 +1,360 @@
"""
使用装饰器实现全栈函数调用追踪
解决LangGraph无法捕获子函数内部状态的问题
"""
import functools
import inspect
import asyncio
import contextvars
from typing import Callable, Any, Dict, Optional, List
from datetime import datetime
from collections import deque
# 使用 contextvars 实现请求级别的隔离,避免多个并发请求互相干扰
_event_callbacks: contextvars.ContextVar[List[Callable]] = contextvars.ContextVar('event_callbacks', default=None)
_current_stream_handler: contextvars.ContextVar[Optional[Any]] = contextvars.ContextVar('current_stream_handler',
default=None)
def register_event_callback(callback: Callable[[Dict[str, Any]], None]):
"""
注册事件回调函数使用 contextvars 实现请求隔离
"""
callbacks = _event_callbacks.get()
if callbacks is None:
callbacks = []
_event_callbacks.set(callbacks)
callbacks.append(callback)
def unregister_event_callback(callback: Callable[[Dict[str, Any]], None]):
"""
移除事件回调函数使用 contextvars 实现请求隔离
"""
callbacks = _event_callbacks.get()
if callbacks and callback in callbacks:
callbacks.remove(callback)
def clear_event_callbacks():
"""
清空所有事件回调使用 contextvars 实现请求隔离
"""
callbacks = _event_callbacks.get()
if callbacks:
callbacks.clear()
def get_all_callbacks():
"""
获取所有注册的回调函数使用 contextvars 实现请求隔离
"""
callbacks = _event_callbacks.get()
return callbacks.copy() if callbacks else []
def extract_function_description(func: Callable) -> str:
"""
提取函数的描述docstring的第一行或前100个字符
"""
if func.__doc__:
doc = func.__doc__.strip()
# 取第一行或前100个字符
first_line = doc.split('\n')[0].strip()
return first_line[:100] if len(first_line) > 100 else first_line
return ""
def _extract_details_from_result(result: Any) -> str:
"""
从函数返回值中提取 details顺序固定为 details 再中文
1) 先看返回的 dict 里是否有details 字段__details___detailsdetails 或键名含 "details"
且其值为非空则用该值
2) 若没有或为空再走原本的中文兜底从返回结构中找一段包含中文的内容作为 details
"""
default = "完成"
if not result:
return default
# 第一步:优先用 details 类字段(仅当有值且非空时采用)
if isinstance(result, dict):
for key in ("__details__", "_details", "details"):
if key in result and result[key] is not None:
s = str(result[key]).strip()
if s:
return str(result[key])
for k, v in result.items():
if "details" in k.lower() and v is not None:
s = str(v).strip()
if s:
return str(v)
# 第二步:保留原本的中文兜底
def _contains_chinese(text: str) -> bool:
if not text:
return False
return any("\u4e00" <= c <= "\u9fff" for c in str(text))
def _find_chinese(obj: Any, max_length: int = 200) -> Optional[str]:
if isinstance(obj, str):
return (obj[:max_length] + "...") if _contains_chinese(obj) and len(obj) > max_length else (
obj if _contains_chinese(obj) else None)
if isinstance(obj, dict):
for key, value in obj.items():
if "details" in key.lower():
continue
found = _find_chinese(value, max_length)
if found:
return found
if isinstance(obj, list):
for item in obj:
found = _find_chinese(item, max_length)
if found:
return found
return None
chinese = _find_chinese(result)
return chinese if chinese else default
def track_function_calls(func: Callable) -> Callable:
"""
装饰器追踪函数调用过程支持同步和异步函数
Args:
func: 被装饰的函数
"""
@functools.wraps(func)
def sync_wrapper(*args, **kwargs):
# 获取函数签名信息
sig = inspect.signature(func)
bound_args = sig.bind(*args, **kwargs)
bound_args.apply_defaults()
func_name = func.__name__
module_name = func.__module__
# 尝试从函数参数中获取 state
state = None
if args and isinstance(args[0], dict):
state = args[0]
elif 'state' in bound_args.arguments:
state = bound_args.arguments.get('state')
func_description = extract_function_description(func)
# 准备函数参数信息
params = {k: v for k, v in bound_args.arguments.items()}
try:
# 执行原函数
result = func(*args, **kwargs)
# 构建 details优先用返回值中的 details 类字段__details__、_details、details 或键名含 details
details = _extract_details_from_result(result)
# 合并函数执行事件(只在执行完成后发送一次)
execution_event = {
"type": "function_execution",
"title": func_description,
"details": details,
"result": result, # 添加函数返回结果
"timestamp": datetime.now().isoformat()
}
# 调用所有注册的回调(从上下文变量获取)
callbacks = _event_callbacks.get() or []
for callback in callbacks:
try:
callback(execution_event)
except Exception as e:
print(f"回调执行出错: {e}")
return result
except Exception as e:
# 发送函数执行失败事件
error_event = {
"type": "function_error",
"title": func_description,
"details": f"错误: {str(e)}",
"timestamp": datetime.now().isoformat()
}
callbacks = _event_callbacks.get() or []
for callback in callbacks:
try:
callback(error_event)
except Exception as ex:
print(f"回调执行出错: {ex}")
raise
@functools.wraps(func)
async def async_wrapper(*args, **kwargs):
# 获取函数签名信息
sig = inspect.signature(func)
bound_args = sig.bind(*args, **kwargs)
bound_args.apply_defaults()
func_name = func.__name__
module_name = func.__module__
# 尝试从函数参数中获取 state
state = None
if args and isinstance(args[0], dict):
state = args[0]
elif 'state' in bound_args.arguments:
state = bound_args.arguments.get('state')
func_description = extract_function_description(func)
# 准备函数参数信息
params = {k: v for k, v in bound_args.arguments.items()}
try:
# 执行原函数
result = await func(*args, **kwargs)
# 构建 details优先用返回值中的 details 类字段__details__、_details、details 或键名含 details
details = _extract_details_from_result(result)
# 合并函数执行事件(只在执行完成后发送一次)
execution_event = {
"type": "function_execution",
"title": func_description,
"details": details,
"result": result, # 添加函数返回结果
"timestamp": datetime.now().isoformat()
}
# 调用所有注册的回调(从上下文变量获取)
callbacks = _event_callbacks.get() or []
for callback in callbacks:
try:
callback(execution_event)
except Exception as e:
print(f"回调执行出错: {e}")
return result
except Exception as e:
# 发送函数执行失败事件
error_event = {
"type": "function_error",
"title": func_description,
"details": f"错误: {str(e)}",
"timestamp": datetime.now().isoformat()
}
callbacks = _event_callbacks.get() or []
for callback in callbacks:
try:
callback(error_event)
except Exception as ex:
print(f"回调执行出错: {ex}")
raise
# 根据函数是否为协程函数返回对应的装饰器
if asyncio.iscoroutinefunction(func):
return async_wrapper
else:
return sync_wrapper
# 事件处理器
def console_event_handler(event: Dict[str, Any]):
"""
控制台事件处理器
"""
event_type = event["type"]
title = event.get("title", "")
details = event.get("details", "")
if event_type == "function_execution":
print(f"✅ 函数执行: {title}")
if details:
print(f" 详情: {details}")
elif event_type == "function_error":
print(f"❌ 函数错误: {title}")
if details:
print(f" 详情: {details}")
class StreamEventHandler:
"""
流式事件处理器将装饰器事件放入队列供流式输出使用
"""
def __init__(self, queue: asyncio.Queue):
self.queue = queue
def __call__(self, event: Dict[str, Any]):
"""
处理事件将事件放入队列
注意这是同步方法但队列是异步的需要特殊处理
"""
try:
# 使用线程安全的方式将事件放入队列
# 如果队列已满,使用 put_nowait 并忽略错误
try:
self.queue.put_nowait(event)
except asyncio.QueueFull:
# 队列已满,跳过此事件(不应该发生,因为队列大小足够)
pass
except Exception as e:
# 忽略错误,避免影响主流程
pass
def send_stream_content(self, content: str, title: str = ""):
"""
发送流式内容事件
Args:
content: 流式内容片段
title: 函数描述/标题可选
"""
try:
event = {
"type": "stream_content",
"content": content,
"title": title,
"timestamp": datetime.now().isoformat()
}
self.queue.put_nowait(event)
except Exception as e:
# 忽略错误,避免影响主流程
pass
def create_stream_event_handler() -> tuple[StreamEventHandler, asyncio.Queue]:
"""
创建流式事件处理器和对应的队列使用 contextvars 实现请求隔离
Returns:
(handler, queue): 事件处理器和事件队列
"""
queue = asyncio.Queue(maxsize=1000) # 设置足够大的队列大小
handler = StreamEventHandler(queue)
_current_stream_handler.set(handler) # 保存到上下文变量
return handler, queue
def get_current_stream_handler() -> Optional[StreamEventHandler]:
"""
获取当前活动的流式事件处理器使用 contextvars 实现请求隔离
Returns:
当前流式事件处理器如果没有则返回 None
"""
return _current_stream_handler.get()
def clear_current_stream_handler():
"""
清除当前活动的流式事件处理器使用 contextvars 实现请求隔离
"""
_current_stream_handler.set(None)
"""
使用装饰器实现全栈函数调用追踪
解决LangGraph无法捕获子函数内部状态的问题
"""
import functools
import inspect
import asyncio
import contextvars
from typing import Callable, Any, Dict, Optional, List
from datetime import datetime
from collections import deque
# 使用 contextvars 实现请求级别的隔离,避免多个并发请求互相干扰
_event_callbacks: contextvars.ContextVar[List[Callable]] = contextvars.ContextVar('event_callbacks', default=None)
_current_stream_handler: contextvars.ContextVar[Optional[Any]] = contextvars.ContextVar('current_stream_handler',
default=None)
def register_event_callback(callback: Callable[[Dict[str, Any]], None]):
"""
注册事件回调函数使用 contextvars 实现请求隔离
"""
callbacks = _event_callbacks.get()
if callbacks is None:
callbacks = []
_event_callbacks.set(callbacks)
callbacks.append(callback)
def unregister_event_callback(callback: Callable[[Dict[str, Any]], None]):
"""
移除事件回调函数使用 contextvars 实现请求隔离
"""
callbacks = _event_callbacks.get()
if callbacks and callback in callbacks:
callbacks.remove(callback)
def clear_event_callbacks():
"""
清空所有事件回调使用 contextvars 实现请求隔离
"""
callbacks = _event_callbacks.get()
if callbacks:
callbacks.clear()
def get_all_callbacks():
"""
获取所有注册的回调函数使用 contextvars 实现请求隔离
"""
callbacks = _event_callbacks.get()
return callbacks.copy() if callbacks else []
def extract_function_description(func: Callable) -> str:
"""
提取函数的描述docstring的第一行或前100个字符
"""
if func.__doc__:
doc = func.__doc__.strip()
# 取第一行或前100个字符
first_line = doc.split('\n')[0].strip()
return first_line[:100] if len(first_line) > 100 else first_line
return ""
def _extract_details_from_result(result: Any) -> str:
"""
从函数返回值中提取 details顺序固定为 details 再中文
1) 先看返回的 dict 里是否有details 字段__details___detailsdetails 或键名含 "details"
且其值为非空则用该值
2) 若没有或为空再走原本的中文兜底从返回结构中找一段包含中文的内容作为 details
"""
default = "完成"
if not result:
return default
# 第一步:优先用 details 类字段(仅当有值且非空时采用)
if isinstance(result, dict):
for key in ("__details__", "_details", "details"):
if key in result and result[key] is not None:
s = str(result[key]).strip()
if s:
return str(result[key])
for k, v in result.items():
if "details" in k.lower() and v is not None:
s = str(v).strip()
if s:
return str(v)
# 第二步:保留原本的中文兜底
def _contains_chinese(text: str) -> bool:
if not text:
return False
return any("\u4e00" <= c <= "\u9fff" for c in str(text))
def _find_chinese(obj: Any, max_length: int = 200) -> Optional[str]:
if isinstance(obj, str):
return (obj[:max_length] + "...") if _contains_chinese(obj) and len(obj) > max_length else (
obj if _contains_chinese(obj) else None)
if isinstance(obj, dict):
for key, value in obj.items():
if "details" in key.lower():
continue
found = _find_chinese(value, max_length)
if found:
return found
if isinstance(obj, list):
for item in obj:
found = _find_chinese(item, max_length)
if found:
return found
return None
chinese = _find_chinese(result)
return chinese if chinese else default
def track_function_calls(func: Callable) -> Callable:
"""
装饰器追踪函数调用过程支持同步和异步函数
Args:
func: 被装饰的函数
"""
@functools.wraps(func)
def sync_wrapper(*args, **kwargs):
# 获取函数签名信息
sig = inspect.signature(func)
bound_args = sig.bind(*args, **kwargs)
bound_args.apply_defaults()
func_name = func.__name__
module_name = func.__module__
# 尝试从函数参数中获取 state
state = None
if args and isinstance(args[0], dict):
state = args[0]
elif 'state' in bound_args.arguments:
state = bound_args.arguments.get('state')
func_description = extract_function_description(func)
# 准备函数参数信息
params = {k: v for k, v in bound_args.arguments.items()}
try:
# 执行原函数
result = func(*args, **kwargs)
# 构建 details优先用返回值中的 details 类字段__details__、_details、details 或键名含 details
details = _extract_details_from_result(result)
# 合并函数执行事件(只在执行完成后发送一次)
execution_event = {
"type": "function_execution",
"title": func_description,
"details": details,
"result": result, # 添加函数返回结果
"timestamp": datetime.now().isoformat()
}
# 调用所有注册的回调(从上下文变量获取)
callbacks = _event_callbacks.get() or []
for callback in callbacks:
try:
callback(execution_event)
except Exception as e:
print(f"回调执行出错: {e}")
return result
except Exception as e:
# 发送函数执行失败事件
error_event = {
"type": "function_error",
"title": func_description,
"details": f"错误: {str(e)}",
"timestamp": datetime.now().isoformat()
}
callbacks = _event_callbacks.get() or []
for callback in callbacks:
try:
callback(error_event)
except Exception as ex:
print(f"回调执行出错: {ex}")
raise
@functools.wraps(func)
async def async_wrapper(*args, **kwargs):
# 获取函数签名信息
sig = inspect.signature(func)
bound_args = sig.bind(*args, **kwargs)
bound_args.apply_defaults()
func_name = func.__name__
module_name = func.__module__
# 尝试从函数参数中获取 state
state = None
if args and isinstance(args[0], dict):
state = args[0]
elif 'state' in bound_args.arguments:
state = bound_args.arguments.get('state')
func_description = extract_function_description(func)
# 准备函数参数信息
params = {k: v for k, v in bound_args.arguments.items()}
try:
# 执行原函数
result = await func(*args, **kwargs)
# 构建 details优先用返回值中的 details 类字段__details__、_details、details 或键名含 details
details = _extract_details_from_result(result)
# 合并函数执行事件(只在执行完成后发送一次)
execution_event = {
"type": "function_execution",
"title": func_description,
"details": details,
"result": result, # 添加函数返回结果
"timestamp": datetime.now().isoformat()
}
# 调用所有注册的回调(从上下文变量获取)
callbacks = _event_callbacks.get() or []
for callback in callbacks:
try:
callback(execution_event)
except Exception as e:
print(f"回调执行出错: {e}")
return result
except Exception as e:
# 发送函数执行失败事件
error_event = {
"type": "function_error",
"title": func_description,
"details": f"错误: {str(e)}",
"timestamp": datetime.now().isoformat()
}
callbacks = _event_callbacks.get() or []
for callback in callbacks:
try:
callback(error_event)
except Exception as ex:
print(f"回调执行出错: {ex}")
raise
# 根据函数是否为协程函数返回对应的装饰器
if asyncio.iscoroutinefunction(func):
return async_wrapper
else:
return sync_wrapper
# 事件处理器
def console_event_handler(event: Dict[str, Any]):
"""
控制台事件处理器
"""
event_type = event["type"]
title = event.get("title", "")
details = event.get("details", "")
if event_type == "function_execution":
print(f"✅ 函数执行: {title}")
if details:
print(f" 详情: {details}")
elif event_type == "function_error":
print(f"❌ 函数错误: {title}")
if details:
print(f" 详情: {details}")
class StreamEventHandler:
"""
流式事件处理器将装饰器事件放入队列供流式输出使用
"""
def __init__(self, queue: asyncio.Queue):
self.queue = queue
def __call__(self, event: Dict[str, Any]):
"""
处理事件将事件放入队列
注意这是同步方法但队列是异步的需要特殊处理
"""
try:
# 使用线程安全的方式将事件放入队列
# 如果队列已满,使用 put_nowait 并忽略错误
try:
self.queue.put_nowait(event)
except asyncio.QueueFull:
# 队列已满,跳过此事件(不应该发生,因为队列大小足够)
pass
except Exception as e:
# 忽略错误,避免影响主流程
pass
def send_stream_content(self, content: str, title: str = ""):
"""
发送流式内容事件
Args:
content: 流式内容片段
title: 函数描述/标题可选
"""
try:
event = {
"type": "stream_content",
"content": content,
"title": title,
"timestamp": datetime.now().isoformat()
}
self.queue.put_nowait(event)
except Exception as e:
# 忽略错误,避免影响主流程
pass
def create_stream_event_handler() -> tuple[StreamEventHandler, asyncio.Queue]:
"""
创建流式事件处理器和对应的队列使用 contextvars 实现请求隔离
Returns:
(handler, queue): 事件处理器和事件队列
"""
queue = asyncio.Queue(maxsize=1000) # 设置足够大的队列大小
handler = StreamEventHandler(queue)
_current_stream_handler.set(handler) # 保存到上下文变量
return handler, queue
def get_current_stream_handler() -> Optional[StreamEventHandler]:
"""
获取当前活动的流式事件处理器使用 contextvars 实现请求隔离
Returns:
当前流式事件处理器如果没有则返回 None
"""
return _current_stream_handler.get()
def clear_current_stream_handler():
"""
清除当前活动的流式事件处理器使用 contextvars 实现请求隔离
"""
_current_stream_handler.set(None)

71
utils/source_citations.py Normal file
View File

@ -0,0 +1,71 @@
"""Utilities for accumulating RAG source citations across multiple searches."""
import json
from typing import Any, Dict
def _citation_item_key(item: Any) -> str:
"""Build a stable identity for one RAG citation chunk."""
if not isinstance(item, dict):
return json.dumps(item, ensure_ascii=False, sort_keys=True, default=str)
chunk_id = item.get("id")
if chunk_id not in (None, ""):
return f"id:{chunk_id}"
location = {
"file_id": item.get("file_id"),
"page_idx": item.get("page_idx"),
"positions": item.get("positions"),
}
if any(value not in (None, "", []) for value in location.values()):
return "location:" + json.dumps(
location, ensure_ascii=False, sort_keys=True, default=str
)
# index/score may differ between rewritten queries and do not identify a source.
fallback = {
key: value
for key, value in item.items()
if key not in {"index", "score", "text"}
}
return "fallback:" + json.dumps(
fallback, ensure_ascii=False, sort_keys=True, default=str
)
def merge_rag_source_citations(
accumulated: Dict[str, Any], incoming: Any
) -> Dict[str, Any]:
"""
Append a RAG call's citations without losing earlier calls or duplicating chunks.
Citation text is intentionally removed here because the stream response has never
exposed it; the full text remains available inside the workflow for answer generation.
"""
if not isinstance(incoming, dict) or not incoming:
return accumulated
for doc_name, chunks in incoming.items():
if isinstance(chunks, list):
target = accumulated.setdefault(doc_name, [])
if not isinstance(target, list):
# Be defensive about malformed/legacy data while preserving both values.
target = [target]
accumulated[doc_name] = target
seen = {_citation_item_key(item) for item in target}
for item in chunks:
sanitized_item = (
{key: value for key, value in item.items() if key != "text"}
if isinstance(item, dict)
else item
)
item_key = _citation_item_key(sanitized_item)
if item_key not in seen:
target.append(sanitized_item)
seen.add(item_key)
elif doc_name not in accumulated:
accumulated[doc_name] = chunks
return accumulated

View File

@ -1,158 +1,158 @@
"""
工作流注册表 - 主代理与 app 共用
主代理阶段四根据此配置路由到所有注册的子 agent
app 据此执行对应工作流
添加新工作流时在此配置 WORKFLOW_CONFIG ROUTE_DESCRIPTIONS
"""
# ============================================================================
# =============== 工作流配置区域 - 添加新工作流时只需修改这里 ===============
# ============================================================================
# 格式:
# "route_flag": {
# "module": "workflows.workflow_xxx",
# "function": "run_xxx_workflow",
# "is_async": True/False,
# }
# ============================================================================
WORKFLOW_CONFIG = {
"fault_diagnosis": {
"module": "workflows.workflow_fault_diagnosis",
"function": "run_fault_diagnosis_workflow",
"is_async": True,
},
"qa": {
"module": "workflows.workflow_qa",
"function": "run_qa_workflow",
"is_async": True,
},
"问题排查": {
"module": "workflows.workflow_fault_diagnosis",
"function": "run_fault_diagnosis_workflow",
"is_async": True,
},
"spare_parts_statistics": {
"module": "workflows.workflow_spare_parts_statistics",
"function": "run_spare_parts_statistics_workflow",
"is_async": True,
},
"rules": {
"module": "workflows.workflow_qa",
"function": "run_qa_workflow",
"is_async": True,
},
"other": {
"module": "workflows.workflow_fault_diagnosis",
"function": "run_fault_diagnosis_workflow",
"is_async": True,
#
# "module": "workflows.workflow_other",
# "function": "run_other_workflow",
# "is_async": True,
},
"report":{
# "module": "workflows.workflow_qa_report",
# "function": "run_qa_report_agent",
# "is_async": True,
"module": "workflows.workflow_fault_diagnosis",
"function": "run_fault_diagnosis_workflow",
"is_async": True,
},
"百科问答":{
"module": "workflows.workflow_baike",
"function": "run_baike_workflow",
"is_async": True,
},
"操作使用":{
"module": "workflows.workflow_operate",
"function": "run_operate_workflow",
"is_async": True,
},
"统计":{
"module": "workflows.workflow_statistics",
"function": "run_statistics_workflow",
"is_async": True,
}
}
# 路由标志 -> 任务分类描述(供主代理阶段四 LLM 使用)
ROUTE_DESCRIPTIONS = {
"fault_diagnosis": """
- 特征用户描述**当前或近期发生的设备异常报警性能下降等具体故障现象**需要分析原因或定位问题
- 关键词报警异常异响冒烟过热无法启动突然停机振动大压力低温度高故障代码不工作
- 示例
主机滑油压力突然降到0.2MPa并报警
柴油机启动后冒黑烟且转速不稳
舵机发出异常噪音疑似齿轮损坏
- 注意若历史记录中包含请补充需要更多信息通常属于此类因已聚焦具体故障场景
当用户问题中只有舷号设备或故障现象时属于此类舷号一般为连续的阿拉伯数字如101,"111","122","163""16","17","554","602","545","985","31","3261","799","905","781",或者数字后加舰比如101","199","18"等。
""",
"qa": """
- 特征询问定义原理结构功能分类标准配置常见类型安全规范等**一般性知识**也包括对专业术语现象部件作用的解释
**即使问题表述模糊不完整缺少上下文只要涉及船舶机械电气动力等技术内容均优先归为此类**
- 关键词什么是如何工作为什么请问介绍解释有哪些常见典型一般作用组成是不是叫什么
- 示例
船舶主发动机有哪些常见故障类型
涡轮增压器的工作原理是什么
主机由哪些主要部件组成
什么是MAN B&W柴油机
- 注意
**本类作为技术相关问题的默认兜底类别**凡涉及设备系统部件工况参数等技术语境的问题若无法明确归入 fault_diagnosis / repair / spare_parts_statistics / rules则优先视为 qa
""",
"repair": """
- 特征用户明确请求**维修方法操作步骤检修流程更换方案或现场处理建议**通常在已知故障或需预防性维护时提出
- 关键词怎么修如何维修维修步骤检修方法更换拆卸安装处理修复维护保养调整
- 示例
主机喷油器漏油怎么更换
给出主机缸套磨损的维修方案
如何拆卸增压器进行检修
""",
"spare_parts_statistics": """
- 特征用户需要对**备品备件的数量使用记录库存消耗趋势预测需求或成本分析**进行查询或统计
- 关键词备件备品库存数量统计预测分析消耗需求清单台账寿命更换周期用了多少还剩多少
- 示例
统计过去一年主机喷油嘴的使用数量
预测下季度所需空冷器备件数量
主机活塞环的平均更换周期是多少
- 注意若仅问某个部件是不是备件这个备件叫什么名字属于知识问答qa而非本类
""",
"rules": """
- 特征用户询问与船舶设备维修检验安全环保保密等相关的**国家法规军用标准如GJB行业规范船级社要求或管理制度**
- 关键词法规标准规范规定合规条例依据要求准则制度认证资质船级社CCSIMOSOLASMARPOL资格
- 示例
船舶主机修理需遵循哪些GJB标准
IMO对主机排放有哪些强制要求
舰船维修单位需要哪些资质
保密资格申请流程是什么
- 注意若问题提到标准里有没有但未指明具体法规/标准名称且无法规标准等关键词可先归为 qa一旦明确提及法规标准规范SOLAS则归为 rules
""",
"other": """
- 特征**完全不涉及新的技术问题故障诊断维修请求备件统计法规查询或报告生成**而是针对**已有对话内容方案步骤或文本的反馈修正补充顺序调整或格式优化**典型场景包括
指出之前提供的操作步骤有误异物清理步骤有误
要求调整操作顺序这一步应当先怎样然后再怎么操作最后
对生成内容提出修改意见步骤有误/有补充请重写第三点
请求优化语言格式或结构请用更简洁的方式表达
闲聊非技术性确认或与当前技术任务无关的交互
- 注意
**只要用户意图是修正补充或调整已有内容而非提出新问题即使涉及技术术语也应归为 other**
不要将此类反馈误判为 repair qa它们不是在问该怎么修原理是什么而是在说你刚才说的不对/不全
此类通常出现在多轮对话中是对前文输出的回应
""",
"report": """
- 特征用户明确要求**生成整理导出或汇总**某种形式的文档报表清单或总结材料通常涉及将分散的故障记录维修历史备件数据或问答内容整合成结构化文本如日报周报故障分析报告维修总结备件消耗表等
- 关键词生成报告写总结导出表格整理清单汇总数据形成文档日报周报月报分析报告维修记录单台账报表
- 示例
请根据本周的故障记录生成一份主机系统运行分析报告
总结这次对话
导出过去一年的备件消耗统计报告
帮我写一份关于此次主机停机事故的详细处理总结
"""
}
VALID_ROUTE_FLAGS = tuple(WORKFLOW_CONFIG.keys())
"""
工作流注册表 - 主代理与 app 共用
主代理阶段四根据此配置路由到所有注册的子 agent
app 据此执行对应工作流
添加新工作流时在此配置 WORKFLOW_CONFIG ROUTE_DESCRIPTIONS
"""
# ============================================================================
# =============== 工作流配置区域 - 添加新工作流时只需修改这里 ===============
# ============================================================================
# 格式:
# "route_flag": {
# "module": "workflows.workflow_xxx",
# "function": "run_xxx_workflow",
# "is_async": True/False,
# }
# ============================================================================
WORKFLOW_CONFIG = {
"fault_diagnosis": {
"module": "workflows.workflow_fault_diagnosis",
"function": "run_fault_diagnosis_workflow",
"is_async": True,
},
"qa": {
"module": "workflows.workflow_qa",
"function": "run_qa_workflow",
"is_async": True,
},
"问题排查": {
"module": "workflows.workflow_fault_diagnosis",
"function": "run_fault_diagnosis_workflow",
"is_async": True,
},
"spare_parts_statistics": {
"module": "workflows.workflow_spare_parts_statistics",
"function": "run_spare_parts_statistics_workflow",
"is_async": True,
},
"rules": {
"module": "workflows.workflow_qa",
"function": "run_qa_workflow",
"is_async": True,
},
"other": {
"module": "workflows.workflow_fault_diagnosis",
"function": "run_fault_diagnosis_workflow",
"is_async": True,
#
# "module": "workflows.workflow_other",
# "function": "run_other_workflow",
# "is_async": True,
},
"report":{
# "module": "workflows.workflow_qa_report",
# "function": "run_qa_report_agent",
# "is_async": True,
"module": "workflows.workflow_fault_diagnosis",
"function": "run_fault_diagnosis_workflow",
"is_async": True,
},
"百科问答":{
"module": "workflows.workflow_baike",
"function": "run_baike_workflow",
"is_async": True,
},
"操作使用":{
"module": "workflows.workflow_operate",
"function": "run_operate_workflow",
"is_async": True,
},
"统计":{
"module": "workflows.workflow_statistics",
"function": "run_statistics_workflow",
"is_async": True,
}
}
# 路由标志 -> 任务分类描述(供主代理阶段四 LLM 使用)
ROUTE_DESCRIPTIONS = {
"fault_diagnosis": """
- 特征用户描述**当前或近期发生的设备异常报警性能下降等具体故障现象**需要分析原因或定位问题
- 关键词报警异常异响冒烟过热无法启动突然停机振动大压力低温度高故障代码不工作
- 示例
主机滑油压力突然降到0.2MPa并报警
柴油机启动后冒黑烟且转速不稳
舵机发出异常噪音疑似齿轮损坏
- 注意若历史记录中包含请补充需要更多信息通常属于此类因已聚焦具体故障场景
当用户问题中只有舷号设备或故障现象时属于此类舷号一般为连续的阿拉伯数字如101,"111","122","163""16","17","554","602","545","985","31","3261","799","905","781",或者数字后加舰比如101","199","18"等。
""",
"qa": """
- 特征询问定义原理结构功能分类标准配置常见类型安全规范等**一般性知识**也包括对专业术语现象部件作用的解释
**即使问题表述模糊不完整缺少上下文只要涉及船舶机械电气动力等技术内容均优先归为此类**
- 关键词什么是如何工作为什么请问介绍解释有哪些常见典型一般作用组成是不是叫什么
- 示例
船舶主发动机有哪些常见故障类型
涡轮增压器的工作原理是什么
主机由哪些主要部件组成
什么是MAN B&W柴油机
- 注意
**本类作为技术相关问题的默认兜底类别**凡涉及设备系统部件工况参数等技术语境的问题若无法明确归入 fault_diagnosis / repair / spare_parts_statistics / rules则优先视为 qa
""",
"repair": """
- 特征用户明确请求**维修方法操作步骤检修流程更换方案或现场处理建议**通常在已知故障或需预防性维护时提出
- 关键词怎么修如何维修维修步骤检修方法更换拆卸安装处理修复维护保养调整
- 示例
主机喷油器漏油怎么更换
给出主机缸套磨损的维修方案
如何拆卸增压器进行检修
""",
"spare_parts_statistics": """
- 特征用户需要对**备品备件的数量使用记录库存消耗趋势预测需求或成本分析**进行查询或统计
- 关键词备件备品库存数量统计预测分析消耗需求清单台账寿命更换周期用了多少还剩多少
- 示例
统计过去一年主机喷油嘴的使用数量
预测下季度所需空冷器备件数量
主机活塞环的平均更换周期是多少
- 注意若仅问某个部件是不是备件这个备件叫什么名字属于知识问答qa而非本类
""",
"rules": """
- 特征用户询问与船舶设备维修检验安全环保保密等相关的**国家法规军用标准如GJB行业规范船级社要求或管理制度**
- 关键词法规标准规范规定合规条例依据要求准则制度认证资质船级社CCSIMOSOLASMARPOL资格
- 示例
船舶主机修理需遵循哪些GJB标准
IMO对主机排放有哪些强制要求
舰船维修单位需要哪些资质
保密资格申请流程是什么
- 注意若问题提到标准里有没有但未指明具体法规/标准名称且无法规标准等关键词可先归为 qa一旦明确提及法规标准规范SOLAS则归为 rules
""",
"other": """
- 特征**完全不涉及新的技术问题故障诊断维修请求备件统计法规查询或报告生成**而是针对**已有对话内容方案步骤或文本的反馈修正补充顺序调整或格式优化**典型场景包括
指出之前提供的操作步骤有误异物清理步骤有误
要求调整操作顺序这一步应当先怎样然后再怎么操作最后
对生成内容提出修改意见步骤有误/有补充请重写第三点
请求优化语言格式或结构请用更简洁的方式表达
闲聊非技术性确认或与当前技术任务无关的交互
- 注意
**只要用户意图是修正补充或调整已有内容而非提出新问题即使涉及技术术语也应归为 other**
不要将此类反馈误判为 repair qa它们不是在问该怎么修原理是什么而是在说你刚才说的不对/不全
此类通常出现在多轮对话中是对前文输出的回应
""",
"report": """
- 特征用户明确要求**生成整理导出或汇总**某种形式的文档报表清单或总结材料通常涉及将分散的故障记录维修历史备件数据或问答内容整合成结构化文本如日报周报故障分析报告维修总结备件消耗表等
- 关键词生成报告写总结导出表格整理清单汇总数据形成文档日报周报月报分析报告维修记录单台账报表
- 示例
请根据本周的故障记录生成一份主机系统运行分析报告
总结这次对话
导出过去一年的备件消耗统计报告
帮我写一份关于此次主机停机事故的详细处理总结
"""
}
VALID_ROUTE_FLAGS = tuple(WORKFLOW_CONFIG.keys())

View File

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

View File

@ -1,546 +1,544 @@
"""
工作流普通问答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, DEFAULT_HISTORY_MAX_MESSAGES
# from workflows.workflow_utils import stream_generate_and_postprocess
from workflows.workflow_utils import dedupe_rag_results, stream_generate_and_postprocess
from tools.function_tool import (
graph_rag_search
)
from tools.rag_tools import rag_search
from modelsAPI.model_api import OpenaiAPI
from utils.function_tracker import track_function_calls, get_all_callbacks
from utils.image_utils import extract_image_paths
from prompts import BAIKE_PROMPTS
import asyncio
import json
import re
import 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[-DEFAULT_HISTORY_MAX_MESSAGES:] if history_messages else []
try:
history_str = json.dumps(trimmed_history, ensure_ascii=False)
except Exception:
history_str = str(trimmed_history)
# 如果没有历史或当前问题过短,就直接跳过重写
if not original_query:
return {"retrieval_query": original_query}
# 在构造 prompt 前,提取上一轮用户 query如果有
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=BAIKE_RAG_TOP_K
)
else:
return await rag_search(
query=extracted_text,
top_k=BAIKE_RAG_TOP_K
)
async def _do_graph_search():
return await graph_rag_search.ainvoke({
"query": query,
"top_k": BAIKE_GRAPH_TOP_K
})
rag_result, graph_results = await asyncio.gather(
_do_rag_search(),
_do_graph_search()
rag_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)
})
rag_search_result = sorted(rag_search_result,key=lambda x:float(x.get("score") or 0), reverse=True)[:BAIKE_RESULT_LIMIT]
rag_search_result = dedupe_rag_results(rag_search_result, limit=BAIKE_RESULT_LIMIT)
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
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)} 条,图谱 {source_counts.get('graph', 0)}",
)
query_desc = extracted_text if extracted_text else "当前未明确描述问题"
domain_desc = (
"舰船维修相关的法规、规范、行业标准及技术文件"
if state.get("route_flag") == "rules"
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
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:
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)
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)
content_messages = []
messages = []
if history_messages:
content_messages.extend(history_messages)
content_messages.append({"role": "user", "content": content_user_content})
title_options = ["🤖 诊断分析中", "🤖 故障预检中"]
start_event = {
"type": "function_execution",
"title": random.choice(title_options),
"details": "正在生成回答..."
}
for callback in get_all_callbacks():
try:
callback(start_event)
except Exception:
pass
messages.extend(history_messages)
messages.append({"role": "user", "content": user_prompt})
_emit_event(random.choice(["🔍 证据整理中", "🧠 正在生成回答"]), "正在基于检索资料生成回答...")
answer = await stream_generate_and_postprocess(
system_prompt=content_system_prompt,
messages=content_messages,
system_prompt=system_prompt,
messages=messages,
original_image_paths=original_image_paths,
temperature=0.5,
fallback="抱歉,无法生成回答。"
temperature=0.2,
fallback="抱歉,无法生成回答。",
)
print("llm output", answer)
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]:
candidates: List[str] = []
for text in [main_query, original_query, *(extra_queries or [])]:
text = _strip_query_noise(text)
if not text:
continue
candidates.append(text)
regex_terms = _extract_search_terms(text)
if regex_terms:
candidates.append(" ".join(regex_terms[:4]))
candidates.extend(regex_terms[:4])
return _dedupe_strings(candidates)[:10]
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:
return json.loads(match.group(0))
except Exception:
return {}
return {}
def _json_dumps(value: Any) -> str:
try:
return json.dumps(value, ensure_ascii=False)
except Exception:
return str(value)
def _content_key(text: str) -> str:
return re.sub(r"\s+", "", text or "")[:220]
def _clean_text(text: str) -> str:
return re.sub(r"\n{3,}", "\n\n", str(text or "")).strip()
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
# 生成建议回复问题
suggested_replies = await generate_suggested_replies_baike(answer, extracted_text)
return {
"response": answer,
"actions": [],
"result_tag": "qa",
"suggestedReplies": suggested_replies
}
except Exception as e:
return {
"response": f"生成回答失败:{str(e)}",
"actions": [],
"suggestedReplies": []
}
def route_after_judge(state: QAState) -> str:
"""
判断后的路由根据need_retrieval决定下一步
"""
need_retrieval = state.get("need_retrieval", True)
if need_retrieval:
return "rewrite_query_with_history"
else:
return "generate_answer"
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())

View File

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

View File

@ -926,12 +926,15 @@ def remove_duplicate_content(text: str) -> str:
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 "")
@ -1316,9 +1319,13 @@ async def stream_generate_and_postprocess(
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