1343 lines
41 KiB
Python
1343 lines
41 KiB
Python
"""
|
||
工作流:普通问答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 # 错误信息
|
||
|
||
# =============== 辅助函数 ===============
|
||
import re
|
||
|
||
|
||
def normalize_latex_spacing(text: str) -> str:
|
||
"""
|
||
规范 Markdown 中的 LaTeX 公式。
|
||
|
||
处理:
|
||
- $...$:行内公式,清理内侧空格并补充正文间空格
|
||
- $$...$$:段落公式,完整保留开始和结束定界符
|
||
- \\(...\\):转换为 $...$
|
||
- \\[...\\]:转换为 $$...$$
|
||
|
||
不修改:
|
||
- Markdown 围栏代码块
|
||
- Markdown 行内代码
|
||
- HTML code/pre/script/style
|
||
- HTML 注释
|
||
- 转义美元符号 \\$
|
||
"""
|
||
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:
|
||
slash_count = 0
|
||
position -= 1
|
||
|
||
while position >= 0 and content[position] == "\\":
|
||
slash_count += 1
|
||
position -= 1
|
||
|
||
return slash_count % 2 == 1
|
||
|
||
# ================================================================
|
||
# 保护 Markdown 围栏代码块
|
||
# ================================================================
|
||
|
||
lines = text.splitlines(keepends=True)
|
||
output_lines: list[str] = []
|
||
line_index = 0
|
||
|
||
while line_index < len(lines):
|
||
line = lines[line_index]
|
||
|
||
fence_match = re.match(
|
||
r"^[ \t]{0,3}(`{3,}|~{3,})",
|
||
line,
|
||
)
|
||
|
||
if not fence_match:
|
||
output_lines.append(line)
|
||
line_index += 1
|
||
continue
|
||
|
||
opening_fence = fence_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
|
||
|
||
output_lines.append(
|
||
protect("".join(block_lines))
|
||
)
|
||
|
||
text = "".join(output_lines)
|
||
|
||
# ================================================================
|
||
# 保护 HTML 代码区域和注释
|
||
# ================================================================
|
||
|
||
text = re.sub(
|
||
r"(?is)"
|
||
r"<(?P<tag>pre|code|script|style)\b[^>]*>"
|
||
r".*?"
|
||
r"</(?P=tag)\s*>",
|
||
lambda match: protect(match.group(0)),
|
||
text,
|
||
)
|
||
|
||
text = re.sub(
|
||
r"(?s)<!--.*?-->",
|
||
lambda match: protect(match.group(0)),
|
||
text,
|
||
)
|
||
|
||
# ================================================================
|
||
# 保护 Markdown 链接地址
|
||
# ================================================================
|
||
|
||
text = re.sub(
|
||
r"(!?\[[^\]\n]*\]\()([^)\n]*)(\))",
|
||
lambda match: (
|
||
match.group(1)
|
||
+ protect(match.group(2))
|
||
+ match.group(3)
|
||
),
|
||
text,
|
||
)
|
||
|
||
# ================================================================
|
||
# 保护 Markdown 行内代码
|
||
# ================================================================
|
||
|
||
inline_code_output: list[str] = []
|
||
position = 0
|
||
|
||
while position < len(text):
|
||
if text[position] != "`":
|
||
inline_code_output.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 ""
|
||
)
|
||
|
||
next_position = candidate + len(delimiter)
|
||
|
||
next_character = (
|
||
text[next_position]
|
||
if next_position < len(text)
|
||
else ""
|
||
)
|
||
|
||
if (
|
||
previous_character != "`"
|
||
and next_character != "`"
|
||
):
|
||
closing_position = candidate
|
||
break
|
||
|
||
search_position = candidate + 1
|
||
|
||
if closing_position < 0:
|
||
inline_code_output.append(delimiter)
|
||
position = delimiter_end
|
||
continue
|
||
|
||
end_position = closing_position + len(delimiter)
|
||
|
||
inline_code_output.append(
|
||
protect(text[position:end_position])
|
||
)
|
||
|
||
position = end_position
|
||
|
||
text = "".join(inline_code_output)
|
||
|
||
# ================================================================
|
||
# 转换其他 LaTeX 定界符
|
||
# ================================================================
|
||
|
||
# \( x + y \) -> $x + y$
|
||
text = re.sub(
|
||
r"(?<!\\)\\\((.*?)(?<!\\)\\\)",
|
||
lambda match: (
|
||
"$" + match.group(1).strip() + "$"
|
||
),
|
||
text,
|
||
)
|
||
|
||
# \[ x + y \] -> $$x + y$$
|
||
text = re.sub(
|
||
r"(?s)(?<!\\)\\\[(.*?)(?<!\\)\\\]",
|
||
lambda match: (
|
||
"$$" + match.group(1).strip() + "$$"
|
||
),
|
||
text,
|
||
)
|
||
|
||
# ================================================================
|
||
# 扫描公式
|
||
# ================================================================
|
||
|
||
opening_punctuation = set(
|
||
"([{(【《〈“‘"
|
||
)
|
||
|
||
closing_punctuation = set(
|
||
")]})】》〉”’、,。;:!?,.!?;:"
|
||
)
|
||
|
||
result: list[str] = []
|
||
position = 0
|
||
|
||
while position < len(text):
|
||
# ------------------------------------------------------------
|
||
# 段落公式 $$...$$
|
||
# ------------------------------------------------------------
|
||
|
||
if (
|
||
text.startswith("$$", position)
|
||
and not is_escaped(text, 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:
|
||
result.append(text[position:])
|
||
break
|
||
|
||
complete_formula = text[
|
||
position:closing_position + 2
|
||
]
|
||
|
||
formula_body = text[
|
||
position + 2:closing_position
|
||
]
|
||
|
||
# 多行段落公式完整原样保留。
|
||
if "\n" in complete_formula:
|
||
result.append(complete_formula)
|
||
else:
|
||
# 单行段落公式只清理内侧空格,
|
||
# 不删除或重建结束的 $$。
|
||
result.append(
|
||
"$$" + formula_body.strip() + "$$"
|
||
)
|
||
|
||
position = closing_position + 2
|
||
continue
|
||
|
||
# ------------------------------------------------------------
|
||
# 行内公式 $...$
|
||
# ------------------------------------------------------------
|
||
|
||
if (
|
||
text[position] == "$"
|
||
and not is_escaped(text, position)
|
||
and not text.startswith("$$", position)
|
||
and not (
|
||
position > 0
|
||
and text[position - 1] == "$"
|
||
)
|
||
):
|
||
opening_position = position
|
||
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
|
||
|
||
next_character = (
|
||
text[candidate + 1]
|
||
if candidate + 1 < len(text)
|
||
else ""
|
||
)
|
||
|
||
# 避免把 $100 和 $200 配对成公式。
|
||
if next_character.isdigit():
|
||
search_position = candidate + 1
|
||
continue
|
||
|
||
closing_position = candidate
|
||
break
|
||
|
||
if closing_position < 0:
|
||
result.append("$")
|
||
position += 1
|
||
continue
|
||
|
||
formula_body = text[
|
||
opening_position + 1:closing_position
|
||
].strip()
|
||
|
||
if not formula_body:
|
||
result.append(
|
||
text[
|
||
opening_position:
|
||
closing_position + 1
|
||
]
|
||
)
|
||
position = closing_position + 1
|
||
continue
|
||
|
||
formula_body = re.sub(
|
||
r"[ \t]+",
|
||
" ",
|
||
formula_body,
|
||
)
|
||
|
||
previous_character = ""
|
||
|
||
if result and result[-1]:
|
||
previous_character = result[-1][-1]
|
||
|
||
next_character = (
|
||
text[closing_position + 1]
|
||
if closing_position + 1 < len(text)
|
||
else ""
|
||
)
|
||
|
||
if (
|
||
previous_character
|
||
and not previous_character.isspace()
|
||
and previous_character
|
||
not in opening_punctuation
|
||
):
|
||
result.append(" ")
|
||
|
||
result.append(
|
||
"$" + formula_body + "$"
|
||
)
|
||
|
||
if (
|
||
next_character
|
||
and not next_character.isspace()
|
||
and next_character
|
||
not in closing_punctuation
|
||
):
|
||
result.append(" ")
|
||
|
||
position = closing_position + 1
|
||
continue
|
||
|
||
result.append(text[position])
|
||
position += 1
|
||
|
||
return restore("".join(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
|
||
|
||
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:
|
||
stripped = value.strip()
|
||
|
||
if not stripped:
|
||
return True
|
||
|
||
patterns = (
|
||
# Markdown 标题
|
||
r"^#{1,6}\s+",
|
||
|
||
# 无序列表
|
||
r"^[-+*]\s+",
|
||
|
||
# 有序列表
|
||
r"^\d+[.)]\s+",
|
||
|
||
# 引用
|
||
r"^>\s*",
|
||
|
||
# 任务列表
|
||
r"^[-+*]\s+\[[ xX]\]\s+",
|
||
|
||
# 水平分隔线
|
||
r"^(?:-{3,}|\*{3,}|_{3,})$",
|
||
|
||
# Markdown 表格分隔行
|
||
r"^\|?\s*:?-{3,}:?"
|
||
r"(?:\s*\|\s*:?-{3,}:?)+\s*\|?$",
|
||
|
||
# 普通表格行
|
||
r"^\|.*\|$",
|
||
|
||
# HTML 标签
|
||
r"^</?[A-Za-z][^>]*>$",
|
||
|
||
# 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()
|
||
|
||
# ============================================================
|
||
# 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
|
||
|
||
# ============================================================
|
||
# \[ ... \] 段落公式
|
||
# ============================================================
|
||
|
||
if in_bracket_math:
|
||
result.append(line)
|
||
|
||
if re.search(r"(?<!\\)\\\]", line):
|
||
in_bracket_math = False
|
||
|
||
continue
|
||
|
||
bracket_open_count = len(
|
||
re.findall(r"(?<!\\)\\\[", line)
|
||
)
|
||
|
||
bracket_close_count = len(
|
||
re.findall(r"(?<!\\)\\\]", line)
|
||
)
|
||
|
||
if bracket_open_count:
|
||
result.append(line)
|
||
|
||
if bracket_open_count > bracket_close_count:
|
||
in_bracket_math = True
|
||
|
||
continue
|
||
|
||
# ============================================================
|
||
# 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
|
||
|
||
# ============================================================
|
||
# $$ ... $$ 段落公式
|
||
# ============================================================
|
||
|
||
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
|
||
|
||
# ============================================================
|
||
# 空行和 Markdown 结构行
|
||
# ============================================================
|
||
|
||
if not stripped:
|
||
result.append(line)
|
||
continue
|
||
|
||
if is_markdown_structure_line(line):
|
||
result.append(line)
|
||
continue
|
||
|
||
# ============================================================
|
||
# 仅对普通正文行去重
|
||
# ============================================================
|
||
|
||
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 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'(?<!\s)\$', r' $', text)
|
||
# text = re.sub(r'\$(?!\s)', r'$ ', text)
|
||
# text = text.replace(placeholder, "$$")
|
||
# text = re.sub(r'(?<!\s)\$\$', r' $$', text)
|
||
# text = re.sub(r'\$\$(?!\s)', r'$$ ', text)
|
||
# return text
|
||
answer = remove_duplicate_content(answer)
|
||
answer = normalize_latex_spacing(answer)
|
||
|
||
# answer = normalize_latex_spacing(answer)
|
||
|
||
# 使用增强的图片路径匹配与规范化(仅在有检索结果时)
|
||
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)
|
||
|
||
return {
|
||
"response": answer,
|
||
"actions": [],
|
||
"result_tag": "qa",
|
||
"suggestedReplies": suggested_replies
|
||
}
|
||
except Exception as e:
|
||
return {
|
||
"response": f"生成回答失败:{str(e)}",
|
||
"actions": [],
|
||
"suggestedReplies": []
|
||
}
|
||
def route_after_judge(state: QAState) -> str:
|
||
"""
|
||
判断后的路由:根据need_retrieval决定下一步
|
||
"""
|
||
need_retrieval = state.get("need_retrieval", True)
|
||
if need_retrieval:
|
||
return "rewrite_query_with_history"
|
||
else:
|
||
return "generate_answer"
|
||
|
||
|
||
# =============== 构建工作流 ===============
|
||
def create_baike_workflow():
|
||
"""创建普通问答工作流"""
|
||
workflow = StateGraph(QAState)
|
||
|
||
# 添加节点
|
||
workflow.add_node("get_baike_history_context", get_baike_history_context)
|
||
workflow.add_node("judge_need_retrieval", judge_need_retrieval)
|
||
workflow.add_node("rewrite_query_with_history", rewrite_query_with_history)
|
||
workflow.add_node("search_rag_and_graph", search_rag_and_graph)
|
||
workflow.add_node("generate_answer", generate_answer)
|
||
|
||
# 设置边
|
||
workflow.add_edge(START, "get_baike_history_context")
|
||
workflow.add_edge("get_baike_history_context", "judge_need_retrieval")
|
||
workflow.add_conditional_edges(
|
||
"judge_need_retrieval",
|
||
route_after_judge,
|
||
{
|
||
"rewrite_query_with_history": "rewrite_query_with_history",
|
||
"generate_answer": "generate_answer"
|
||
}
|
||
)
|
||
workflow.add_edge("rewrite_query_with_history", "search_rag_and_graph")
|
||
workflow.add_edge("search_rag_and_graph", "generate_answer")
|
||
workflow.add_edge("generate_answer", END)
|
||
|
||
# 编译工作流
|
||
return workflow.compile()
|
||
|
||
|
||
# =============== 工作流执行函数 ===============
|
||
async def run_baike_workflow(
|
||
extracted_text: str,
|
||
combined_query: str,
|
||
history_message: str = "",
|
||
route_flag: str = ""
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
执行普通问答工作流(统一接口)
|
||
|
||
Args:
|
||
extracted_text: 文本描述(统一的输入参数)
|
||
history_message: 历史消息(目前只接收,不做处理)
|
||
|
||
Returns:
|
||
工作流执行结果
|
||
"""
|
||
# 创建并编译工作流
|
||
app = create_baike_workflow()
|
||
|
||
# 初始化状态(统一接口,只使用extracted_text和history_message)
|
||
initial_state = {
|
||
"extracted_text": extracted_text,
|
||
"combined_query": combined_query,
|
||
"retrieval_query": "",
|
||
"route_flag": route_flag,
|
||
"history_message": history_message,
|
||
"history_messages": [],
|
||
"need_retrieval": None,
|
||
"rag_search_result": None,
|
||
"graph_search_result": None,
|
||
"response": "",
|
||
"actions": [],
|
||
"suggestedReplies": [],
|
||
"error_message": ""
|
||
}
|
||
|
||
try:
|
||
# 执行工作流(使用异步方式)
|
||
final_state = await app.ainvoke(initial_state)
|
||
|
||
# 检查是否有错误
|
||
if final_state.get("error_message"):
|
||
return {
|
||
"response": f"普通问答工作流执行失败: {final_state['error_message']}",
|
||
"actions": [],
|
||
"result_tag": "qa",
|
||
"suggestedReplies": []
|
||
}
|
||
|
||
# 统一输出格式:完全透传 generate_answer 的结果
|
||
return {
|
||
"response": final_state.get("response", ""),
|
||
"actions": final_state.get("actions", []),
|
||
"result_tag": "qa",
|
||
"suggestedReplies": final_state.get("suggestedReplies", [])
|
||
}
|
||
|
||
except Exception as e:
|
||
return {
|
||
"response": f"普通问答工作流执行失败: {str(e)}",
|
||
"actions": [],
|
||
"result_tag": "qa",
|
||
"suggestedReplies": []
|
||
}
|
||
|
||
|
||
|
||
# =============== 测试 ===============
|
||
if __name__ == "__main__":
|
||
import asyncio
|
||
|
||
# 测试用例
|
||
test_cases = [
|
||
"船舰主发动机的安全警告是什么",
|
||
]
|
||
|
||
async def test():
|
||
for i, test_text in enumerate(test_cases, 1):
|
||
print(f"\n{'='*60}")
|
||
print(f"测试用例 {i}")
|
||
print(f"{'='*60}")
|
||
|
||
# ✅ 修复:传入 combined_query 参数
|
||
result = await run_baike_workflow(
|
||
extracted_text=test_text,
|
||
combined_query=test_text
|
||
)
|
||
|
||
print(f"\n执行结果:")
|
||
print(f"成功:{result.get('success', False)}")
|
||
print(f"\n响应内容:")
|
||
print(result.get("response", "无响应"))
|
||
|
||
asyncio.run(test())
|
||
|
||
|
||
|
||
|
||
|