gwdoc/workflows/workflow_baike.py

561 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
百科问答工作流。
核心链路借鉴 WeKnora
1. 意图判断默认偏向检索
2. 基于历史重写检索 query并生成多个检索变体
3. RAG / Graph 并行召回
4. 多源 evidence 去重、融合排序、rerank
5. 只基于 evidence 生成可追溯回答
"""
import asyncio
import json
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
combined_query: str
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
def get_baike_history_context(state: QAState) -> Dict[str, Any]:
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,
)
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 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]:
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 []
history_str = _json_dumps(trimmed_history)
last_user_query = ""
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 "",
history_str=history_str,
original_query=original_query,
)
rewritten_query = original_query
query_variants: List[str] = []
try:
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:", 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 = "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_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,
)
evidence_items = await _rerank_evidence(query, evidence_items)
return {
"rag_search_result": rag_results,
"graph_search_result": graph_results if isinstance(graph_results, str) else "",
"evidence_items": evidence_items,
}
except Exception as exc:
return {
"rag_search_result": [],
"graph_search_result": "",
"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])
try:
result = await OpenaiAPI.open_api_chat_without_thinking(prompt, model=None, json_output=True)
json_match = re.search(r"\[.*\]", result or "", re.DOTALL)
if json_match:
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]:
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)
history_messages = state.get("history_messages", []) or []
try:
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)}",
)
domain_desc = (
"舰船维修相关的法规、规范、行业标准及技术文件"
if state.get("route_flag") == "rules"
else "工业设备的结构原理、维护规程、故障诊断与维修操作"
)
if need_retrieval and source_text.strip():
system_prompt = BAIKE_PROMPTS["generate_answer_with_retrieval_system"].format(domain_desc=domain_desc)
user_prompt = BAIKE_PROMPTS["generate_answer_with_retrieval_user"].format(
extracted_text=question,
rag_count=len(evidence_items),
all_source_text=source_text,
)
else:
system_prompt = BAIKE_PROMPTS["generate_answer_without_retrieval_system"].format(domain_desc=domain_desc)
user_prompt = BAIKE_PROMPTS["generate_answer_without_retrieval_user"].format(extracted_text=question)
messages = []
if history_messages:
messages.extend(history_messages)
messages.append({"role": "user", "content": user_prompt})
_emit_event(random.choice(["🔍 证据整理中", "🧠 正在生成回答"]), "正在基于检索资料生成回答...")
answer = await stream_generate_and_postprocess(
system_prompt=system_prompt,
messages=messages,
original_image_paths=original_image_paths,
temperature=0.2,
fallback="抱歉,无法生成回答。",
)
suggested_replies = await generate_suggested_replies_baike(answer, question)
return {"response": answer, "actions": [], "result_tag": "qa", "suggestedReplies": suggested_replies}
except Exception as exc:
return {"response": f"生成回答失败:{exc}", "actions": [], "suggestedReplies": []}
async def _search_rag_variants(queries: List[str], kb_id: Optional[str] = None) -> List[Dict[str, Any]]:
tasks = [rag_search(query=q, kb_id=kb_id, top_k=BAIKE_RAG_TOP_K) for q in queries if q.strip()]
results = await asyncio.gather(*tasks, return_exceptions=True) if tasks else []
items: List[Dict[str, Any]] = []
for result in results:
if isinstance(result, Exception) or not result.get("success"):
continue
for resource, rows in (result.get("sourceCitation") or {}).items():
for row in rows:
item = dict(row)
item.setdefault("resource", resource)
items.append(item)
items = sorted(items, key=lambda x: float(x.get("score") or 0), reverse=True)
return dedupe_rag_results(items, limit=BAIKE_RESULT_LIMIT)
def _build_evidence_items(
rag_results: List[Dict[str, Any]],
graph_results: str,
query: str,
) -> List[Dict[str, Any]]:
candidates: List[Dict[str, Any]] = []
for item in rag_results or []:
candidates.append({
"source_type": "rag",
"title": item.get("filename") or item.get("resource") or "RAG 检索结果",
"filename": item.get("filename") or item.get("resource") or "",
"text": item.get("text") or "",
"score": float(item.get("score") or 0),
"file_id": str(item.get("file_id") or ""),
"slice_id": str(item.get("id") or item.get("slice_id") or ""),
"metadata": item,
})
graph_text = str(graph_results or "").strip()
if graph_text and "图谱检索完成,但未返回有效数据。" not in graph_text and graph_text != "查询无结果":
candidates.append({
"source_type": "graph",
"title": "图谱检索结果",
"filename": "",
"text": graph_text,
"score": 0.45,
"file_id": "",
"slice_id": "",
"metadata": {},
})
deduped: Dict[str, Dict[str, Any]] = {}
for item in candidates:
text = _clean_text(item.get("text") or "")
if not text:
continue
item["text"] = text[:5000]
key = item.get("slice_id") or _content_key(text)
local_score = _local_evidence_score(query, item)
item["local_score"] = local_score
existing = deduped.get(key)
if not existing or local_score > float(existing.get("local_score") or 0):
deduped[key] = item
return sorted(deduped.values(), key=lambda x: float(x.get("local_score") or 0), reverse=True)
async def _rerank_evidence(query: str, evidence_items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
if not query.strip() or not evidence_items:
return evidence_items[:BAIKE_EVIDENCE_LIMIT]
selected = evidence_items[:24]
documents = [
"\n".join([
str(item.get("title") or ""),
str(item.get("filename") or ""),
str(item.get("text") or ""),
])[:4000]
for item in selected
]
rows = await OpenaiAPI.rerank(query=query, documents=documents, top_k=BAIKE_EVIDENCE_LIMIT)
if not rows:
return evidence_items[:BAIKE_EVIDENCE_LIMIT]
ranked: List[Dict[str, Any]] = []
seen = set()
for row in rows:
idx = int(row.get("index") or 0)
if idx < 0 or idx >= len(selected) or idx in seen:
continue
seen.add(idx)
item = dict(selected[idx])
item["rerank_score"] = float(row.get("relevance_score") or 0.0)
ranked.append(item)
for idx, item in enumerate(selected):
if idx not in seen and len(ranked) < BAIKE_EVIDENCE_LIMIT:
ranked.append(item)
return ranked[:BAIKE_EVIDENCE_LIMIT]
def _format_evidence_context(items: List[Dict[str, Any]]) -> str:
parts = []
label_map = {"rag": "RAG", "graph": "Graph"}
for idx, item in enumerate(items[:BAIKE_EVIDENCE_LIMIT], start=1):
source = label_map.get(item.get("source_type"), item.get("source_type") or "unknown")
score = item.get("rerank_score", item.get("local_score", item.get("score", "")))
meta_lines = [
f"[Evidence {idx}] source={source} score={score}",
f"Title: {item.get('title') or ''}",
]
if item.get("filename"):
meta_lines.append(f"File: {item.get('filename')}")
if item.get("file_id"):
meta_lines.append(f"File ID: {item.get('file_id')}")
if item.get("slice_id"):
meta_lines.append(f"Slice ID: {item.get('slice_id')}")
meta_lines.append("Content:")
meta_lines.append(str(item.get("text") or ""))
parts.append("\n".join(meta_lines))
return "\n\n---\n\n".join(parts)
def _local_evidence_score(query: str, item: Dict[str, Any]) -> float:
base = {"rag": 1.0, "graph": 0.65}.get(item.get("source_type"), 0.8)
raw = float(item.get("score") or 0)
score = base + min(max(raw, 0.0), 1.0)
text = " ".join([
str(item.get("title") or ""),
str(item.get("filename") or ""),
str(item.get("text") or "")[:1500],
]).lower()
title = str(item.get("title") or "").lower()
for term in _extract_search_terms(query):
t = term.lower()
if not t:
continue
if t in title:
score += 0.7
elif t in text:
score += 0.25
return score
def _build_search_queries(main_query: str, original_query: str = "", extra_queries: Optional[List[str]] = None) -> List[str]:
# search_queries 是模型生成的最终检索列表,优先保持其顺序。
candidates: List[str] = list(extra_queries or [])
main_query = _strip_query_noise(main_query or original_query)
if main_query:
main_key = _query_dedupe_key(main_query)
if not any(_query_dedupe_key(text) == main_key for text in candidates):
# 最多保留两个前置问题,最后一条固定为原始综合问题。
candidates = candidates[:2] + [main_query]
result: List[str] = []
seen = set()
for text in candidates:
text = _strip_query_noise(text)
if not text:
continue
key = _query_dedupe_key(text)
if not key or key in seen:
continue
seen.add(key)
result.append(text)
return result[:3]
def _query_dedupe_key(text: str) -> str:
"""忽略空白和句末标点,对相同检索问题去重。"""
text = re.sub(r"\s+", "", str(text or "")).lower()
return re.sub(r"[?。!!;,、]+$", "", text)
def _extract_search_terms(query: str) -> List[str]:
stop = {"什么", "如何", "怎么", "哪些", "主要", "内容", "相关", "信息", "一下", "这个", "那个", "是否", "有没有"}
terms = []
for token in re.findall(r"[\u4e00-\u9fff]{2,12}|[A-Za-z0-9][A-Za-z0-9_\-]{1,30}", query or ""):
token = token.strip(":,。.;;、()()[]【】")
if len(token) < 2 or token in stop or re.fullmatch(r"\d+", token):
continue
terms.append(token)
return _dedupe_strings(terms)
def _strip_query_noise(text: str) -> str:
text = str(text or "").strip().strip('"“”')
if "用户问题:" in text:
text = text.split("用户问题:", 1)[1].strip()
return re.sub(r"\s+", " ", text)
def _parse_json_object(raw: str) -> Dict[str, Any]:
if not raw:
return {}
try:
return json.loads(raw)
except Exception:
match = re.search(r"\{.*\}", raw, re.DOTALL)
if match:
try:
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
def route_after_judge(state: QAState) -> str:
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"},
)
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]:
app = create_baike_workflow()
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": "",
}
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": [],
}
return {
"response": final_state.get("response", ""),
"actions": final_state.get("actions", []),
"result_tag": "qa",
"suggestedReplies": final_state.get("suggestedReplies", []),
}
except Exception as exc:
return {
"response": f"普通问答工作流执行失败: {exc}",
"actions": [],
"result_tag": "qa",
"suggestedReplies": [],
}