Files
2026-08-03 18:42:47 +08:00

527 lines
20 KiB
Python

"""Client adapter for the gene-family-mas-v2 QA and graph services.
The bbav5 frontend expects a compact RAG-like payload
(`answer`, `sources`, `knowledge_graph`). gene-family-mas-v2 exposes
separate QA and graph-QA routes, so this module centralizes the mapping.
"""
from __future__ import annotations
import json
import os
from typing import Any, Iterator
import requests
DUAL_KB_QA_BASE_URL = (
os.environ.get("GENE_FAMILY_MAS_API")
or os.environ.get("DUAL_KB_QA_BASE_URL")
or "http://127.0.0.1:7902"
).rstrip("/")
DUAL_KB_QA_ROUTE = os.environ.get("DUAL_KB_QA_ROUTE", "kb-qa").strip().strip("/") or "kb-qa"
DUAL_KB_GRAPH_QA_ROUTE = os.environ.get("DUAL_KB_GRAPH_QA_ROUTE", "v15-graph-qa").strip().strip("/") or "v15-graph-qa"
DUAL_KB_QA_VARIANT = os.environ.get("DUAL_KB_QA_VARIANT", "full").strip() or "full"
def _env_bool(name: str, default: bool) -> bool:
value = os.environ.get(name)
if value is None:
return default
return value.strip().lower() not in {"0", "false", "no", "off"}
def dual_kb_endpoint(path: str = "chat") -> str:
return f"{DUAL_KB_QA_BASE_URL}/api/v1/{DUAL_KB_QA_ROUTE}/{path.strip('/')}"
def graph_qa_endpoint(path: str = "chat") -> str:
return f"{DUAL_KB_QA_BASE_URL}/api/v1/{DUAL_KB_GRAPH_QA_ROUTE}/{path.strip('/')}"
def friendly_rag_progress_event(event: dict[str, Any]) -> dict[str, Any]:
"""Translate truthful QA milestones without inventing entities or counts."""
stage = str(event.get("stage") or "")
details = event.get("details") if isinstance(event.get("details"), dict) else {}
message = ""
if stage == "understanding_started":
message = "正在理解你的问题并识别检索目标……"
elif stage == "entities_identified":
entities = [str(value).strip() for value in details.get("entities") or [] if str(value).strip()]
message = f"我看到你关注的是:{'、'.join(entities)}。" if entities else "已识别检索目标,正在查找相关内容……"
elif stage == "related_search_started":
message = "正在知识库中查找相关内容……"
elif stage == "concepts_found":
concepts = [str(value).strip() for value in details.get("concepts") or [] if str(value).strip()]
concept_count = int(details.get("concept_count") or 0)
connection_count = int(details.get("connection_count") or 0)
prefix = f"找到了相关概念:{'、'.join(concepts)}。" if concepts else "找到了相关概念。"
if concept_count or connection_count:
message = f"{prefix}已找到{concept_count}个相关概念和{connection_count}条知识连接,正在扩展相关证据……"
else:
message = f"{prefix}正在扩展相关证据……"
elif stage == "subquestion_retrieved":
candidate_count = int(details.get("candidate_count") or 0)
message = (
f"已检索到{candidate_count}条候选内容,Agent正在检查其与问题的相关性……"
if candidate_count
else "已检索到候选内容,Agent正在检查其与问题的相关性……"
)
elif stage == "agent_review_started":
message = "Agent正在检查检索结果与问题是否匹配……"
elif stage == "subquestion_retry_started":
message = "部分内容证据不足,正在补充查找相关资料……"
elif stage in {"evidence_accepted", "evidence_review_completed"}:
accepted_count = int(details.get("accepted_count") or 0)
publication_count = int(details.get("publication_count") or 0)
if accepted_count or publication_count:
message = f"Agent已确认{accepted_count}条相关证据,其中包括{publication_count}篇相关文献,正在组织回答……"
else:
message = "Agent已确认相关证据,正在组织回答……"
elif stage == "answer_generation_started":
message = "正在根据已确认的证据组织回答……"
elif stage == "completed":
message = "回答完成。"
enriched = dict(event)
enriched["message"] = message
return enriched
def _sse_frame(event_name: str, payload: dict[str, Any]) -> str:
return f"event: {event_name}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
def dual_kb_info(timeout: float = 5.0) -> dict[str, Any]:
response = requests.get(dual_kb_endpoint("info"), timeout=timeout)
payload = response.json() if response.content else {}
if response.status_code >= 400:
detail = payload.get("detail") if isinstance(payload, dict) else response.text
raise RuntimeError(f"Dual-KB QA info failed: HTTP {response.status_code}: {detail}")
return payload if isinstance(payload, dict) else {}
def _normalize_source(item: Any) -> dict[str, Any]:
if not isinstance(item, dict):
return {"content": str(item)}
content = (
item.get("content")
or item.get("evidence")
or " -- ".join(str(item.get(key, "")) for key in ("source", "relation", "target") if item.get(key))
)
normalized = dict(item)
normalized.setdefault("content", content)
normalized.setdefault("doc_id", item.get("doc_id") or item.get("paper_id") or item.get("pubmed_id") or item.get("id", ""))
normalized.setdefault("score", item.get("score") or item.get("confidence") or 1.0)
return normalized
def _graph_edges_to_knowledge_graph(edges: Any) -> dict[str, Any] | None:
if not isinstance(edges, list) or not edges:
return None
nodes: dict[str, dict[str, Any]] = {}
kg_edges: list[dict[str, Any]] = []
for edge in edges:
if not isinstance(edge, dict):
continue
source = str(edge.get("source") or "").strip()
target = str(edge.get("target") or "").strip()
if not source or not target or source == target:
continue
nodes.setdefault(source, {"id": source, "type": edge.get("source_type") or "unknown"})
nodes.setdefault(target, {"id": target, "type": edge.get("target_type") or "unknown"})
kg_edges.append({
"source": source,
"target": target,
"relation": edge.get("relation") or edge.get("label") or "",
})
if not nodes:
return None
return {"nodes": list(nodes.values()), "edges": kg_edges}
def _native_chains_to_knowledge_graph(chains: Any) -> dict[str, Any] | None:
"""Convert QA V1.1.73 native path chains to the frontend graph payload."""
if isinstance(chains, dict):
chains = chains.get("chains") or []
if not isinstance(chains, list) or not chains:
return None
nodes: dict[str, dict[str, Any]] = {}
edges: list[dict[str, Any]] = []
seen_edges: set[tuple[str, str, str]] = set()
for chain in chains:
if not isinstance(chain, dict):
continue
chain_id = str(chain.get("chain_id") or "")
for hop_index, hop in enumerate(chain.get("hops") or [], 1):
if not isinstance(hop, dict):
continue
source = str(hop.get("source") or "").strip()
target = str(hop.get("target") or "").strip()
relation = str(hop.get("relation") or "").strip()
if not source or not target or source == target:
continue
edge_key = (source, relation, target)
if edge_key in seen_edges:
continue
seen_edges.add(edge_key)
nodes.setdefault(source, {
"id": source,
"label": source,
"type": hop.get("source_type") or "unknown",
})
nodes.setdefault(target, {
"id": target,
"label": target,
"type": hop.get("target_type") or "unknown",
})
edges.append({
"source": source,
"target": target,
"relation": relation,
"label": relation,
"chain_id": chain_id,
"hop": hop_index,
"paper_id": hop.get("paper_id") or "",
"pmid": hop.get("pmid") or "",
"pmcid": hop.get("pmcid") or "",
"doi": hop.get("doi") or "",
})
if not nodes:
return None
return {"nodes": list(nodes.values()), "edges": edges}
def _query_graph_qa(question: str, *, top_k: int, use_llm: bool, timeout: float) -> dict[str, Any]:
payload = {
"question": question,
"top_k": max(1, min(int(top_k or 10), 50)),
"use_llm": bool(use_llm),
}
response = requests.post(graph_qa_endpoint("chat"), json=payload, timeout=timeout)
data = response.json() if response.content else {}
if response.status_code >= 400:
detail = data.get("detail") if isinstance(data, dict) else response.text
raise RuntimeError(f"Graph QA chat failed: HTTP {response.status_code}: {detail}")
return data if isinstance(data, dict) else {}
def _build_dual_kb_payload(
question: str,
*,
top_k: int,
variant: str | None,
use_llm: bool | None,
graph_hops: int,
graph_limit_nodes: int,
graph_limit_edges: int,
) -> tuple[str, dict[str, Any]]:
route = DUAL_KB_QA_ROUTE.strip("/")
resolved_use_llm = _env_bool("DUAL_KB_QA_USE_LLM", True) if use_llm is None else bool(use_llm)
if route == "qa-v1-1-73":
payload: dict[str, Any] = {
"question": question,
"debug": False,
"use_llm": resolved_use_llm,
"chain_top_k": 3,
"chain_hops": max(1, min(int(graph_hops or 2), 2)),
"chain_max_edges": 12,
"chain_max_nodes": 10,
"native_chain_enabled": True,
"native_chain_max_hops": 3,
"native_chain_top_k": 5,
}
else:
payload = {
"question": question,
"top_k": max(1, min(int(top_k or 10), 40)),
"use_llm": resolved_use_llm,
}
if os.environ.get("DUAL_KB_QA_USE_PROVISIONAL_DRAFT") is not None:
payload["use_provisional_draft"] = _env_bool(
"DUAL_KB_QA_USE_PROVISIONAL_DRAFT",
False,
)
if route not in {"kb-qa", "v15-graph-qa", "v15-agentic-qa", "qa-v1-1-73"}:
payload.update({
"variant": variant or DUAL_KB_QA_VARIANT,
"graph_hops": max(1, min(int(graph_hops or 2), 2)),
"graph_limit_nodes": max(1, min(int(graph_limit_nodes or 60), 120)),
"graph_limit_edges": max(1, min(int(graph_limit_edges or 100), 180)),
})
return route, payload
def _normalize_dual_kb_response(
data: dict[str, Any],
*,
route: str,
graph_data: dict[str, Any] | None = None,
request_path: str = "chat",
) -> dict[str, Any]:
graph_data = graph_data or {}
answer = data.get("answer") or data.get("response") or data.get("message") or ""
raw_sources = data.get("sources") or data.get("citations") or data.get("graph_edges") or []
sources = [_normalize_source(item) for item in raw_sources]
raw_used_sources = data.get("used_sources") or data.get("references") or raw_sources
used_sources = [_normalize_source(item) for item in raw_used_sources]
graph_edges = data.get("graph_edges") or graph_data.get("graph_edges") or []
native_graph_chains = data.get("native_graph_chains") or []
graph_knowledge_chains = data.get("graph_knowledge_chains") or {}
knowledge_graph = (
data.get("knowledge_graph")
or _graph_edges_to_knowledge_graph(graph_edges)
or _native_chains_to_knowledge_graph(native_graph_chains or graph_knowledge_chains)
)
normalized = {
"success": bool(data.get("success", True)),
"answer": answer,
"response": answer,
"sources": sources,
"references": used_sources,
"knowledge_graph": knowledge_graph,
"kg_entities": data.get("kg_entities") or [],
"mode": data.get("mode") or f"{route}_chat",
"source_count": data.get("source_count", len(sources)),
"used_sources": used_sources,
"retrieval_plan": data.get("retrieval_plan") or data.get("agentic_plan") or {},
"retrieval_notes": data.get("retrieval_notes") or data.get("notes") or [],
"query_id": data.get("query_id") or "",
"version": data.get("version") or "",
"graph_knowledge_chains": graph_knowledge_chains,
"native_graph_chains": native_graph_chains,
"endpoint": dual_kb_endpoint(request_path),
"graph_endpoint": graph_qa_endpoint("chat"),
}
if graph_edges:
normalized["graph_edges"] = graph_edges
if graph_data.get("error"):
normalized.setdefault("retrieval_notes", []).append(f"Graph QA unavailable: {graph_data['error']}")
if data.get("timing") is not None:
normalized["timing"] = data.get("timing")
if data.get("timings") is not None:
normalized["timings"] = data.get("timings")
if data.get("citation_map") is not None:
normalized["citation_map"] = data.get("citation_map")
return normalized
def query_dual_kb_qa(
question: str,
*,
top_k: int = 10,
variant: str | None = None,
use_llm: bool | None = None,
graph_hops: int = 2,
graph_limit_nodes: int = 60,
graph_limit_edges: int = 100,
timeout: float = 240.0,
) -> dict[str, Any]:
question = str(question or "").strip()
if not question:
return {
"success": True,
"answer": "请输入要查询的问题。",
"sources": [],
"mode": "dual_kb_qa_empty_query",
}
route, payload = _build_dual_kb_payload(
question,
top_k=top_k,
variant=variant,
use_llm=use_llm,
graph_hops=graph_hops,
graph_limit_nodes=graph_limit_nodes,
graph_limit_edges=graph_limit_edges,
)
effective_use_llm = bool(payload.get("use_llm"))
request_path = "chat" if effective_use_llm else "search"
request_timeout = (
max(timeout, float(os.environ.get("QA_V1_1_73_TIMEOUT", "300")))
if route == "qa-v1-1-73"
else timeout
)
response = requests.post(
dual_kb_endpoint(request_path),
json=payload,
timeout=request_timeout,
)
data = response.json() if response.content else {}
initial_detail = data.get("detail") if isinstance(data, dict) else None
if (
effective_use_llm
and response.status_code >= 400
and isinstance(initial_detail, dict)
and initial_detail.get("error_code") == "invalid_llm_citation"
):
response = requests.post(
dual_kb_endpoint(request_path),
json=payload,
timeout=request_timeout,
)
data = response.json() if response.content else {}
if response.status_code >= 400:
detail = data.get("detail") if isinstance(data, dict) else response.text
raise RuntimeError(f"Dual-KB QA chat failed: HTTP {response.status_code}: {detail}")
if not isinstance(data, dict):
raise RuntimeError("Dual-KB QA chat returned a non-JSON-object payload")
graph_data = {}
if (
effective_use_llm
and not data.get("knowledge_graph")
and route not in {DUAL_KB_GRAPH_QA_ROUTE, "qa-v1-1-73"}
):
try:
graph_data = _query_graph_qa(
question,
top_k=top_k,
use_llm=False,
timeout=min(timeout, 60.0),
)
except Exception as exc:
graph_data = {"error": str(exc)}
return _normalize_dual_kb_response(
data,
route=route,
graph_data=graph_data,
request_path=request_path,
)
def stream_dual_kb_qa(
question: str,
*,
top_k: int = 10,
variant: str | None = None,
use_llm: bool | None = None,
graph_hops: int = 2,
graph_limit_nodes: int = 60,
graph_limit_edges: int = 100,
timeout: float = 300.0,
) -> Iterator[str]:
"""Proxy truthful QA milestones and normalize the final V1.1.73 payload."""
question = str(question or "").strip()
if not question:
yield _sse_frame("complete", {
"success": True,
"answer": "请输入要查询的问题。",
"response": "请输入要查询的问题。",
"sources": [],
"references": [],
"used_sources": [],
"mode": "dual_kb_qa_empty_query",
})
return
route, payload = _build_dual_kb_payload(
question,
top_k=top_k,
variant=variant,
use_llm=use_llm,
graph_hops=graph_hops,
graph_limit_nodes=graph_limit_nodes,
graph_limit_edges=graph_limit_edges,
)
request_timeout = (
max(timeout, float(os.environ.get("QA_V1_1_73_TIMEOUT", "300")))
if route == "qa-v1-1-73"
else timeout
)
try:
response = requests.post(
dual_kb_endpoint("chat/stream"),
json=payload,
stream=True,
timeout=(10.0, request_timeout),
)
except requests.Timeout as exc:
yield _sse_frame("error", {
"success": False,
"status": 504,
"error_code": "request_timeout",
"message": str(exc) or "QA stream request timed out",
})
return
except requests.ConnectionError as exc:
yield _sse_frame("error", {
"success": False,
"status": 503,
"error_code": "service_unavailable",
"message": str(exc) or "QA stream service unavailable",
})
return
try:
if response.status_code >= 400:
try:
body = response.json() if response.content else {}
except Exception:
body = {}
detail = body.get("detail") if isinstance(body, dict) else {}
detail = detail if isinstance(detail, dict) else {}
yield _sse_frame("error", {
"success": False,
"status": response.status_code,
"error_code": detail.get("error_code") or (
"stream_unavailable" if response.status_code in {404, 405} else "service_unavailable"
),
"message": detail.get("message") or response.text or "QA stream request failed",
})
return
event_name = "message"
data_lines: list[str] = []
for raw_line in response.iter_lines(decode_unicode=True):
line = raw_line.decode("utf-8") if isinstance(raw_line, bytes) else str(raw_line)
if line.startswith(":"):
yield ": keepalive\n\n"
continue
if line.startswith("event:"):
event_name = line.split(":", 1)[1].strip() or "message"
continue
if line.startswith("data:"):
data_lines.append(line.split(":", 1)[1].lstrip())
continue
if line != "":
continue
if not data_lines:
event_name = "message"
continue
try:
data = json.loads("\n".join(data_lines))
except json.JSONDecodeError:
data = {
"success": False,
"status": 502,
"message": "Invalid upstream stream event",
"error_code": "request_failed",
}
event_name = "error"
if event_name == "progress" and isinstance(data, dict):
data = friendly_rag_progress_event(data)
elif event_name == "complete" and isinstance(data, dict):
data = _normalize_dual_kb_response(data, route=route)
yield _sse_frame(event_name, data if isinstance(data, dict) else {"data": data})
event_name = "message"
data_lines = []
except requests.Timeout as exc:
yield _sse_frame("error", {
"success": False,
"status": 504,
"error_code": "request_timeout",
"message": str(exc) or "QA stream request timed out",
})
except requests.ConnectionError as exc:
yield _sse_frame("error", {
"success": False,
"status": 503,
"error_code": "service_unavailable",
"message": str(exc) or "QA stream service unavailable",
})
finally:
response.close()