Files
2026-07-10 17:59:59 +08:00

936 lines
35 KiB
Python
Executable File
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.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Hybrid (Qdrant + Neo4j) QA routes for the V15 SRT knowledge base.
This route uses V15HybridRetriever for vector (Qdrant) and graph (Neo4j)
retrieval. The old SQLite-only path is preserved in the .bak file.
"""
from __future__ import annotations
import json
import os
import re
import asyncio
import urllib.request
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from src.rag.v15_hybrid_retriever import V15HybridRetriever
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_KB_DIR = PROJECT_ROOT / "data" / "knowledge_base" / "v15_srt_kb_20260525"
router = APIRouter(prefix="/kb-qa", tags=["V15 SRT KB QA"])
_llm_manager = None
_result_chunk_cache: Dict[str, List[Dict[str, str]]] = {}
STOPWORDS = {
"what",
"which",
"where",
"when",
"who",
"how",
"does",
"did",
"are",
"was",
"were",
"the",
"and",
"for",
"with",
"from",
"into",
"about",
"that",
"this",
"these",
"those",
"gene",
"genes",
"protein",
"proteins",
"family",
"tell",
"me",
"is",
"of",
"in",
"to",
"a",
"an",
}
LAYER_FILTERS = {
"current_findings": (
"r.output_layer = 'main_kg' "
"AND COALESCE(r.claim_scope, '') NOT IN ('background_context', 'cited_prior_work') "
"AND COALESCE(r.candidate_status, '') != 'candidate'"
),
"main_kg": (
"r.output_layer = 'main_kg' "
"AND COALESCE(r.claim_scope, '') NOT IN ('background_context', 'cited_prior_work') "
"AND COALESCE(r.candidate_status, '') != 'candidate'"
),
"methods": "r.output_layer = 'method_relations' AND COALESCE(r.candidate_status, '') != 'candidate'",
"method_relations": "r.output_layer = 'method_relations' AND COALESCE(r.candidate_status, '') != 'candidate'",
"datasets": "r.output_layer = 'dataset_metadata' AND COALESCE(r.candidate_status, '') != 'candidate'",
"dataset_metadata": "r.output_layer = 'dataset_metadata' AND COALESCE(r.candidate_status, '') != 'candidate'",
"prior_work": "r.output_layer = 'background_relations'",
"background_relations": "r.output_layer = 'background_relations'",
"candidate": "(r.output_layer = 'candidate_review' OR r.candidate_status = 'candidate')",
"candidate_review": "(r.output_layer = 'candidate_review' OR r.candidate_status = 'candidate')",
"all": "1 = 1",
}
RELATION_FAMILY_ALIASES = {
"baseline_expression": [
"expressed_in",
"highly_expressed_during",
"only_expressed_during",
"differentially_expressed_under",
"upregulated_in",
"downregulated_in",
"downregulated_during",
"peak_at",
"not_induced_under",
],
"up_expression": ["upregulated_in", "induced_in", "increased_in"],
"down_expression": ["downregulated_in", "downregulated_during", "repressed_in", "decreased_in"],
"differential_expression": ["differentially_expressed_under"],
"expression_peak": ["peak_at", "reached_maximum_at"],
"not_induced": ["not_induced_under"],
"identification": ["identified_in", "identified_from"],
"member_count": ["has_member_count", "contains_gene_family_members"],
"classification": ["member_of", "classified_into", "divided_into", "separated_into", "categorized_into"],
"domain": ["contains_domain", "has_domain"],
"cis_element": ["contains_cis_element", "has_cis_element", "has_cis_elements"],
"motif": ["contains_motif"],
"localization": ["localizes_to"],
"interaction": ["interacts_with"],
"binding": ["binds_to"],
"targeting": ["targets"],
"homology": ["clustered_with", "homologous_to"],
"synteny": ["syntenic_with", "has_collinear_pairs_with", "collinear_with"],
"regulation": ["regulates", "positively_regulates", "negatively_regulates"],
"effect": ["promotes", "enhances", "overexpression_enhances", "overexpression_increases", "overexpression_improves"],
"function": ["plays_role_in", "play_roles_in", "involved_in", "participates_in"],
"identity": ["is_a"],
}
class KBQARequest(BaseModel):
question: str = Field(..., description="Question to answer from the V15 SRT KB")
top_k: int = Field(8, ge=1, le=50)
layer: str = Field(
"auto",
description=(
"auto/current_findings/methods/datasets/prior_work/"
"candidate/all"
),
)
include_evidence: bool = True
include_candidate_fallback: bool = False
use_llm: bool = True
llm_provider: Optional[str] = None
llm_model: Optional[str] = None
anchor_entity: Optional[str] = Field(
None,
description="Optional structured retrieval hint, e.g. GmP450 genes",
)
relation_family: Optional[str] = Field(
None,
description="Optional question/relation family hint, e.g. baseline_expression",
)
preferred_relations: List[str] = Field(
default_factory=list,
description="Optional exact relation hints for structured retrieval",
)
preferred_papers: List[str] = Field(
default_factory=list,
description="Optional paper_id hints for benchmark/debug QA; used as boost, not as a hard filter",
)
class KBQASource(BaseModel):
relation_id: str
paper_id: str = ""
pubmed_id: str = ""
p_source: str = ""
source_result_path: str = ""
chunk_id: str = ""
chunk_hint: str = ""
evidence_key: str = ""
source: str
source_type: str = ""
relation: str
target: str
target_type: str = ""
evidence: str = ""
section: str = ""
source_label: str = ""
claim_scope: str = ""
output_layer: str = ""
candidate_status: str = ""
confidence: str = ""
score: float = 0.0
class KBQAResponse(BaseModel):
question: str
answer: str
selected_layer: str
layer_reason: str
source_count: int
paper_source_count: int = 0
sources: List[KBQASource]
kb_path: str
answer_mode: str = "extractive"
llm_used: bool = False
notes: List[str] = []
def _kb_dir() -> Path:
configured = os.environ.get("V15_SRT_KB_DIR")
return Path(configured).expanduser() if configured else DEFAULT_KB_DIR
def get_llm_manager():
"""Lazy-load the existing project LLM manager for answer synthesis."""
global _llm_manager
if _llm_manager is not None:
return _llm_manager
try:
import yaml
from src.llm.manager import LLMManager
config_path = PROJECT_ROOT / "config" / "llm.yaml"
if config_path.exists():
config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
else:
config = {"providers": {}, "default": {}}
# KB-QA must be able to use DeepSeek even when the shared llm.yaml was
# created before the DeepSeek provider was added to the project.
config.setdefault("providers", {}).setdefault(
"deepseek",
{
"name": "DeepSeek",
"api_base": os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
"models": [
{"name": "deepseek-v4-flash", "context_length": 128000},
{"name": "deepseek-v4-pro", "context_length": 128000},
{"name": "deepseek-chat", "context_length": 64000},
],
"env_key": "DEEPSEEK_API_KEY",
},
)
provider = os.environ.get("V15_KB_QA_LLM_PROVIDER")
model = os.environ.get("V15_KB_QA_LLM_MODEL")
if provider:
config.setdefault("default", {})["provider"] = provider
if model:
config.setdefault("default", {})["model"] = model
if os.environ.get("V15_KB_QA_LLM_MAX_TOKENS"):
config.setdefault("default", {})["max_tokens"] = int(os.environ["V15_KB_QA_LLM_MAX_TOKENS"])
if os.environ.get("V15_KB_QA_LLM_TEMPERATURE"):
config.setdefault("default", {})["temperature"] = float(os.environ["V15_KB_QA_LLM_TEMPERATURE"])
_llm_manager = LLMManager(config)
return _llm_manager
except Exception as exc:
print(f"[KB-QA] LLM manager init failed: {exc}")
return None
def _classify_layer(question: str, requested_layer: str) -> Tuple[str, str]:
requested = (requested_layer or "auto").strip().lower()
if requested != "auto":
if requested not in LAYER_FILTERS:
raise HTTPException(
status_code=400,
detail=f"Unsupported layer '{requested_layer}'. "
f"Use one of: auto, {', '.join(sorted(LAYER_FILTERS))}",
)
return requested, "user_selected"
q = question.lower()
if re.search(r"\b(tool|software|method|pipeline|database|using|used|analy[sz]ed by)\b", q) or any(
word in question for word in ["工具", "软件", "方法", "流程", "用了什么", "使用了什么"]
):
return "methods", "auto_detected_method_question"
if re.search(r"\b(accession|geo|sra|prjna|srp|gse|rna-seq|rnaseq|doi)\b", q) or any(
word in question for word in ["编号", "登录号", "测序数据", "转录组数据"]
):
return "datasets", "auto_detected_dataset_question"
if re.search(r"\b(prior|previous|background|reported|known|literature|cited)\b", q) or any(
word in question for word in ["前人", "背景", "已有研究", "文献提到", "报道过"]
):
return "prior_work", "auto_detected_prior_work_question"
if any(word in question for word in ["候选", "不确定", "可能相关"]):
return "candidate", "auto_detected_candidate_question"
return "current_findings", "default_current_findings"
def _query_tokens(question: str) -> List[str]:
raw_tokens = re.findall(r"[A-Za-z][A-Za-z0-9_.:/+-]*|\d+[A-Za-z0-9_.:/+-]*", question)
tokens: List[str] = []
for token in raw_tokens:
cleaned = re.sub(r"[^A-Za-z0-9_]+", " ", token).strip()
for part in cleaned.split():
lower = part.lower()
if len(part) < 2 or lower in STOPWORDS:
continue
tokens.append(part)
# Preserve order while removing duplicates.
seen = set()
unique = []
for token in tokens:
key = token.lower()
if key not in seen:
seen.add(key)
unique.append(token)
return unique[:12]
def _focus_entity(question: str) -> str:
"""Extract a focus entity from short definition-style questions."""
text = question.strip()
patterns = [
r"^\s*what\s+(?:is|are)\s+(.+?)[??。.!]*\s*$",
r"^\s*define\s+(.+?)[??。.!]*\s*$",
r"^\s*(.+?)\s*(?:是什么|是啥|指什么)[??。.!]*\s*$",
]
for pattern in patterns:
match = re.search(pattern, text, flags=re.IGNORECASE)
if not match:
continue
focus = match.group(1).strip()
focus = re.sub(r"^(the|a|an)\s+", "", focus, flags=re.IGNORECASE)
focus = focus.strip(" \t\r\n\"'`.,;:()[]{}")
if 2 <= len(focus) <= 80:
return focus
return ""
def _clean_hint_text(value: str) -> str:
value = re.sub(r"\s+", " ", value or "").strip()
value = value.strip(" \t\r\n\"'`.,;:()[]{}??。")
return value[:120]
def _infer_anchor_entity(question: str) -> str:
"""Infer a likely SRT source entity from a natural-language question."""
patterns = [
r"问题[::]\s*(.+?)\s*(?:的|之)\s*表达",
r"问题[::]\s*(.+?)\s*(?:的|之)\s*鉴定",
r"问题[::]\s*(.+?)\s*(?:报道了多少|有多少)",
r"expression of\s+(.+?)\s+(?:was|were|reported|under|in|\\?)",
r"identifying\s+(.+?)(?:\\?|$)",
r"identification of\s+(.+?)(?:\\?|$)",
r"reported for\s+(.+?)(?:\\?|$)",
r"reported in\s+(.+?)(?:\\?|$)",
r"reported to interact with\s+(.+?)(?:\\?|$)",
r"was\s+(.+?)\s+upregulated",
r"was\s+(.+?)\s+downregulated",
]
for pattern in patterns:
match = re.search(pattern, question, flags=re.IGNORECASE)
if not match:
continue
candidate = _clean_hint_text(match.group(1))
candidate = re.sub(r"^(the|a|an)\s+", "", candidate, flags=re.IGNORECASE)
candidate = re.split(r"\n|English question:|回答要求[::]", candidate)[0].strip()
if 2 <= len(candidate) <= 100 and not re.search(r"\b(where|which|what|how|under)\b", candidate, re.I):
return candidate
focus = _focus_entity(question)
return focus
def _entity_variants(entity: str) -> List[str]:
entity = _clean_hint_text(entity)
if not entity:
return []
variants = {entity}
base = re.sub(
r"\s+(gene|genes|protein|proteins|transcripts?|family|gene family|protein family)$",
"",
entity,
flags=re.IGNORECASE,
).strip()
if base and len(base) >= 2:
variants.add(base)
if re.search(r"[A-Za-z0-9]$", base):
variants.add(base + "s")
variants.add(base + " genes")
variants.add(base + " proteins")
if entity.endswith("s") and len(entity) > 3:
variants.add(entity[:-1])
return [v for v in variants if len(v) >= 2][:8]
def _infer_relation_family(question: str) -> str:
q = question.lower()
checks = [
("baseline_expression", [r"表达", r"expression", r"expressed", r"tissue", r"condition"]),
("up_expression", [r"上调", r"upregulated", r"induced"]),
("down_expression", [r"下调", r"downregulated", r"repressed"]),
("not_induced", [r"未被诱导", r"not induced"]),
("identification", [r"鉴定", r"identification", r"identifying", r"identified"]),
("member_count", [r"多少个成员", r"how many members", r"member count"]),
("cis_element", [r"顺式", r"cis[- ]?acting", r"promoter", r"启动子"]),
("domain", [r"结构域", r"domain"]),
("motif", [r"motif"]),
("localization", [r"定位", r"localiz"]),
("interaction", [r"互作", r"interact"]),
("binding", [r"结合", r"bind"]),
("synteny", [r"共线", r"synten", r"collinear"]),
("regulation", [r"调控", r"regulat"]),
("function", [r"功能", r"function", r"process", r"involved", r"role"]),
("classification", [r"亚家族", r"类别", r"class", r"group", r"subfamily", r"belong"]),
]
for family, patterns in checks:
if any(re.search(pattern, q) for pattern in patterns):
return family
return ""
def _resolve_preferred_relations(relation_family: str, preferred_relations: List[str]) -> List[str]:
resolved: List[str] = []
for rel in preferred_relations or []:
rel = _clean_hint_text(rel)
if rel and re.fullmatch(r"[A-Za-z0-9_]+", rel):
resolved.append(rel)
family_key = (relation_family or "").strip()
resolved.extend(RELATION_FAMILY_ALIASES.get(family_key, []))
seen = set()
unique = []
for rel in resolved:
key = rel.lower()
if key not in seen:
seen.add(key)
unique.append(rel)
return unique[:20]
def _build_query_plan(
question: str,
anchor_entity: Optional[str] = None,
relation_family: Optional[str] = None,
preferred_relations: Optional[List[str]] = None,
preferred_papers: Optional[List[str]] = None,
) -> Dict[str, Any]:
anchor = _clean_hint_text(anchor_entity or "") or _infer_anchor_entity(question)
family = _clean_hint_text(relation_family or "") or _infer_relation_family(question)
relations = _resolve_preferred_relations(family, preferred_relations or [])
papers = [_clean_hint_text(paper) for paper in (preferred_papers or []) if _clean_hint_text(paper)]
return {
"anchor_entity": anchor,
"entity_variants": _entity_variants(anchor),
"relation_family": family,
"preferred_relations": relations,
"preferred_papers": papers[:20],
}
def _norm_space(text: str) -> str:
return re.sub(r"\s+", " ", text or "").strip()
def _evidence_key(text: str) -> str:
text = _norm_space(text).lower()
text = re.sub(r"[^a-z0-9\u4e00-\u9fff]+", " ", text)
return _norm_space(text)
def _paper_group_key(row: Dict[str, Any]) -> str:
return (
row.get("pubmed_id")
or row.get("paper_id")
or row.get("source_result_path")
or row.get("relation_id")
or ""
)
def _resolve_result_path(source_result_path: str) -> Optional[Path]:
if not source_result_path:
return None
path = Path(source_result_path)
if not path.is_absolute():
path = PROJECT_ROOT / path
if path.is_dir():
path = path / "result.json"
if path.name != "result.json" and path.suffix != ".json":
candidate = path / "result.json"
if candidate.exists():
path = candidate
return path if path.exists() else None
def _load_result_chunks(source_result_path: str) -> List[Dict[str, str]]:
path = _resolve_result_path(source_result_path)
if path is None:
return []
key = str(path)
if key in _result_chunk_cache:
return _result_chunk_cache[key]
chunks: List[Dict[str, str]] = []
try:
data = json.loads(path.read_text(encoding="utf-8"))
for idx, chunk in enumerate(data.get("chunks") or []):
text = _norm_space(str(chunk.get("text") or chunk.get("content") or ""))
if not text:
continue
chunk_id = str(chunk.get("chunk_id") or chunk.get("id") or f"chunk_{idx:04d}")
chunks.append(
{
"chunk_id": chunk_id,
"section": str(chunk.get("section") or chunk.get("section_title") or ""),
"text": text,
"norm_text": _evidence_key(text),
}
)
except Exception:
chunks = []
_result_chunk_cache[key] = chunks
return chunks
def _infer_chunk_id(row: Dict[str, Any]) -> str:
evidence_norm = _evidence_key(row.get("evidence", ""))
if not evidence_norm:
return ""
chunks = _load_result_chunks(row.get("source_result_path", ""))
if not chunks:
return ""
anchors = [evidence_norm]
if len(evidence_norm) > 260:
anchors.extend([evidence_norm[:260], evidence_norm[:160], evidence_norm[-200:]])
for anchor in anchors:
if len(anchor) < 40:
continue
for chunk in chunks:
if anchor in chunk.get("norm_text", ""):
return chunk.get("chunk_id", "")
return ""
def _fallback_chunk_hint(row: Dict[str, Any]) -> str:
parts = []
if row.get("section"):
parts.append(f"section={row.get('section')}")
if row.get("p_source"):
parts.append(f"p_source={row.get('p_source')}")
if row.get("source_label"):
parts.append(f"source_label={row.get('source_label')}")
if not parts and row.get("relation_id"):
parts.append(f"relation={row.get('relation_id')}")
return " | ".join(parts)
def _decorate_source_rows(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
for row in rows:
row["evidence_key"] = _evidence_key(row.get("evidence", ""))
chunk_id = _infer_chunk_id(row)
row["chunk_id"] = chunk_id
row["chunk_hint"] = chunk_id or _fallback_chunk_hint(row)
return rows
def _shorten(text: str, limit: int = 420) -> str:
text = re.sub(r"\s+", " ", text or "").strip()
if len(text) <= limit:
return text
return text[: limit - 3].rstrip() + "..."
def _format_answer(question: str, layer: str, rows: List[Dict[str, Any]], include_evidence: bool) -> str:
if not rows:
return (
"未在当前知识库层中检索到可用证据。可以尝试切换 layer=all、"
"layer=prior_work、layer=methods 或 layer=datasets。"
)
layer_label = {
"current_findings": "本文主要发现(main_kg)",
"main_kg": "本文主要发现(main_kg)",
"methods": "方法/工具(method_relations)",
"method_relations": "方法/工具(method_relations)",
"datasets": "数据集(dataset_metadata)",
"dataset_metadata": "数据集(dataset_metadata)",
"prior_work": "前人研究/背景(background_relations)",
"background_relations": "前人研究/背景(background_relations)",
"candidate": "候选关系(candidate_review)",
"candidate_review": "候选关系(candidate_review)",
"all": "全部非拒绝关系",
}.get(layer, layer)
paper_count = len({_paper_group_key(row) for row in rows})
lines = [
f"基于 {layer_label} 检索到 {paper_count} 篇文献中的 {len(rows)} 条去重证据:"
]
for idx, row in enumerate(rows[:8], start=1):
pmid = row.get("pubmed_id") or "NA"
chunk = row.get("chunk_id") or row.get("chunk_hint") or row.get("section") or "NA"
lines.append(
f"{idx}. PMID: {pmid} | paper: {row.get('paper_id', '') or 'NA'} | chunk: {chunk}"
)
if include_evidence and row.get("evidence"):
lines.append(f" 证据:{_shorten(row['evidence'])}")
lines.append("回答只基于当前 V15 SRT 知识库;需要原文级核查时请查看返回的 sources。")
return "\n".join(lines)
def _build_llm_context(rows: List[Dict[str, Any]], max_rows: Optional[int] = None) -> str:
if max_rows is None:
max_rows = max(int(os.environ.get("V15_KB_QA_LLM_CONTEXT_EVIDENCE_ROWS", "18")), 1)
parts = []
for idx, row in enumerate(rows[:max_rows], start=1):
evidence = re.sub(r"\s+", " ", row.get("evidence", "") or "").strip()
if len(evidence) > 900:
evidence = evidence[:897].rstrip() + "..."
parts.append(
"\n".join(
[
f"[S{idx}]",
f"PMID: {row.get('pubmed_id', '')}",
f"paper_id: {row.get('paper_id', '')}",
f"chunk: {row.get('chunk_id') or row.get('chunk_hint') or row.get('section', '')}",
f"layer: {row.get('output_layer', '')}",
f"claim_scope: {row.get('claim_scope', '')}",
f"SRT: {row.get('source', '')} --{row.get('relation', '')}--> {row.get('target', '')}",
f"source_type: {row.get('source_type', '')}; target_type: {row.get('target_type', '')}",
f"evidence: {evidence}",
]
)
)
return "\n\n".join(parts)
def _build_answer_prompt(question: str, layer: str, rows: List[Dict[str, Any]]) -> str:
layer_instruction = {
"current_findings": "These are current-paper biological findings.",
"main_kg": "These are current-paper biological findings.",
"methods": "These are method/tool/software relations. Answer method questions only.",
"method_relations": "These are method/tool/software relations. Answer method questions only.",
"datasets": "These are dataset/accession/provenance relations.",
"dataset_metadata": "These are dataset/accession/provenance relations.",
"prior_work": "These are prior-work or background relations; explicitly say they are prior/background if used.",
"background_relations": "These are prior-work or background relations; explicitly say they are prior/background if used.",
"candidate": "These are candidate/uncertain relations; explicitly warn that they are candidates.",
"candidate_review": "These are candidate/uncertain relations; explicitly warn that they are candidates.",
"all": "These relations may come from different layers. Do not mix layer meanings.",
}.get(layer, "Use the layer metadata to qualify the answer.")
return f"""You are answering from an audited plant gene-family SRT knowledge base.
Question:
{question}
Selected layer:
{layer}
Layer instruction:
{layer_instruction}
Retrieved evidence records:
{_build_llm_context(rows)}
Rules:
1. Answer only from the retrieved SRT evidence above. Do not add outside knowledge.
2. If the retrieved SRTs do not answer the question, say so clearly.
3. Cite supporting rows with [S1], [S2], etc.
4. Keep current findings, prior/background work, method/tool facts, datasets, and candidate relations conceptually separate.
5. If evidence is limited or conflicting, say "the KB evidence is limited" and explain the limitation.
6. Answer in the same language as the user.s question. If the question contains non-English characters (e.g. Chinese), respond in that language primarily. If the question is purely English, respond in English.
7. Be concise but conversational; do not just repeat a raw SRT table.
Answer:
"""
async def _generate_llm_answer(
question: str,
layer: str,
rows: List[Dict[str, Any]],
provider: Optional[str] = None,
model: Optional[str] = None,
) -> str:
if not rows:
return ""
llm_mgr = get_llm_manager()
if llm_mgr is None:
raise RuntimeError("LLM manager is not available")
prompt = _build_answer_prompt(question, layer, rows)
from src.llm.base import LLMMessage
provider = provider or os.environ.get("V15_KB_QA_LLM_PROVIDER")
model = model or os.environ.get("V15_KB_QA_LLM_MODEL")
max_tokens = int(os.environ.get("V15_KB_QA_LLM_MAX_TOKENS", "1200"))
temperature = float(os.environ.get("V15_KB_QA_LLM_TEMPERATURE", "0.2"))
response = await llm_mgr.acall(
[LLMMessage(role="user", content=prompt)],
provider=provider,
model=model,
max_tokens=max_tokens,
temperature=temperature,
)
return response.content if hasattr(response, "content") else str(response)
def _strip_think_tags(text: str) -> str:
return re.sub(r"<think>.*?</think>", "", text or "", flags=re.DOTALL).strip()
def _ollama_chat_sync(prompt: str, model: str) -> str:
raise RuntimeError("Local Ollama is disabled for KB-QA answer generation")
host = os.environ.get("OLLAMA_HOST", "http://localhost:11434").rstrip("/")
payload = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"stream": False,
"temperature": float(os.environ.get("V15_KB_QA_LLM_TEMPERATURE", "0.2")),
"options": {"num_predict": int(os.environ.get("V15_KB_QA_LLM_MAX_TOKENS", "1200"))},
}
req = urllib.request.Request(
f"{host}/api/chat",
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=180) as resp:
data = json.loads(resp.read().decode("utf-8"))
return _strip_think_tags(data.get("message", {}).get("content", ""))
async def _generate_llm_answer_with_fallback(
question: str,
layer: str,
rows: List[Dict[str, Any]],
provider: Optional[str] = None,
model: Optional[str] = None,
) -> Tuple[str, List[str], str]:
"""Generate an answer with the configured remote LLM only.
Local Ollama fallback is intentionally disabled for QA evaluation. If the
configured remote/API model fails, the caller will return an extractive
answer instead of retrying with a local small model.
"""
notes: List[str] = []
provider = provider or os.environ.get("V15_KB_QA_LLM_PROVIDER")
model = model or os.environ.get("V15_KB_QA_LLM_MODEL")
if (provider or "").lower() == "ollama":
raise RuntimeError("Local Ollama provider is disabled for KB-QA answer generation")
try:
answer = await _generate_llm_answer(question, layer, rows, provider=provider, model=model)
return answer, notes, f"{provider or 'default'}:{model or 'default'}"
except Exception as exc:
notes.append(f"Primary LLM failed: {exc}")
raise
# ---------------------------------------------------------------------------
# Hybrid → KBQASource adapter
# ---------------------------------------------------------------------------
def _hybrid_row_to_source(row: dict) -> KBQASource:
"""Map a V15HybridRetriever result row to KBQASource, filling defaults."""
return KBQASource(
relation_id=row.get("relation_id", "") or "graph_{}_{}_{}".format(
row.get("source", ""), row.get("relation", ""), row.get("target", "")
),
paper_id=row.get("paper_id", ""),
pubmed_id=str(row.get("pubmed_id", "") or ""),
p_source=row.get("p_source", ""),
source_result_path=row.get("source_result_path", ""),
chunk_id=row.get("chunk_id", ""),
chunk_hint=row.get("chunk_hint", ""),
evidence_key=row.get("evidence_key", ""),
source=row.get("source", ""),
source_type=row.get("source_type", ""),
relation=row.get("relation", ""),
target=row.get("target", ""),
target_type=row.get("target_type", ""),
evidence=row.get("evidence", ""),
section=row.get("section", ""),
source_label=row.get("source_label", ""),
claim_scope=row.get("claim_scope", ""),
output_layer=row.get("output_layer", ""),
candidate_status=row.get("candidate_status", ""),
confidence=str(row.get("confidence", "")) if row.get("confidence") is not None else "",
score=float(row.get("_vector_score", 0.0) or 0.0),
)
def _source_model(row: Dict[str, Any]) -> KBQASource:
return KBQASource(
relation_id=row.get("relation_id", ""),
paper_id=row.get("paper_id", ""),
pubmed_id=row.get("pubmed_id", ""),
p_source=row.get("p_source", ""),
source_result_path=row.get("source_result_path", ""),
chunk_id=row.get("chunk_id", ""),
chunk_hint=row.get("chunk_hint", ""),
evidence_key=row.get("evidence_key", ""),
source=row.get("source", ""),
source_type=row.get("source_type", ""),
relation=row.get("relation", ""),
target=row.get("target", ""),
target_type=row.get("target_type", ""),
evidence=row.get("evidence", ""),
section=row.get("section", ""),
source_label=row.get("source_label", ""),
claim_scope=row.get("claim_scope", ""),
output_layer=row.get("output_layer", ""),
candidate_status=row.get("candidate_status", ""),
confidence=row.get("confidence", ""),
score=float(row.get("score", 0.0) or 0.0),
)
@router.get("/info")
async def kb_info() -> Dict[str, Any]:
"""Return hybrid (Qdrant + Neo4j) KB status."""
return {
"backend": "hybrid",
"qdrant_collection": os.environ.get("V15_QDRANT_COLLECTION", "v15_srt_bge_20260527"),
"neo4j_uri": os.environ.get("NEO4J_URI", "bolt://localhost:7687"),
"recommended_default": "Use /api/v1/kb-qa/chat with layer=auto for QA.",
"note": "SQLite-only path is preserved in kb_qa_routes.py.bak.20260528",
}
@router.post("/chat", response_model=KBQAResponse)
async def kb_qa_chat(request: KBQARequest) -> KBQAResponse:
"""Retrieve SRT evidence via hybrid (Qdrant + Neo4j) retrieval and format an auditable answer."""
question = request.question.strip()
if not question:
raise HTTPException(status_code=400, detail="question is required")
selected_layer, reason = _classify_layer(question, request.layer)
answer_layer = selected_layer
query_plan = _build_query_plan(
question,
anchor_entity=request.anchor_entity,
relation_family=request.relation_family,
preferred_relations=request.preferred_relations,
preferred_papers=request.preferred_papers,
)
# Hybrid retrieval via Qdrant (vector) + Neo4j (graph), no SQLite
hybrid = V15HybridRetriever()
anchor = query_plan.get("anchor_entity", "")
try:
result = hybrid.retrieve(
question,
top_k=request.top_k,
layer=selected_layer,
use_sqlite=False,
use_vector=True,
use_graph=True,
include_candidate=request.include_candidate_fallback,
anchor_entity=anchor or None,
)
raw_rows = result["retrieved_evidence"]
notes = list(result["notes"])
graph_paths = result.get("graph_paths", [])
if not raw_rows and request.layer == "auto" and selected_layer != "all":
fallback = hybrid.retrieve(
question,
top_k=request.top_k,
layer="all",
use_sqlite=False,
use_vector=True,
use_graph=True,
include_candidate=False,
anchor_entity=anchor or None,
)
if fallback["retrieved_evidence"]:
raw_rows = fallback["retrieved_evidence"]
notes.extend(fallback["notes"])
answer_layer = "all"
notes.append("Auto layer returned no result; answered from all layers.")
graph_paths = fallback.get("graph_paths", [])
finally:
hybrid.close()
# Decorate rows with evidence_key/chunk_hint (using existing helpers)
rows = _decorate_source_rows(raw_rows)
answer_mode = "extractive"
llm_used = False
answer = ""
if request.use_llm and rows:
try:
answer, llm_notes, llm_backend = await _generate_llm_answer_with_fallback(
question,
answer_layer,
rows,
provider=request.llm_provider,
model=request.llm_model,
)
notes.extend(llm_notes)
if answer.strip():
answer_mode = f"llm_synthesis:{llm_backend}"
llm_used = True
except Exception as exc:
notes.append(f"LLM answer synthesis failed; returned extractive answer. Error: {exc}")
print(f"[KB-QA] LLM answer synthesis failed: {exc}")
if not answer.strip():
answer = _format_answer(question, answer_layer, rows, request.include_evidence)
return KBQAResponse(
question=question,
answer=answer,
selected_layer=answer_layer,
layer_reason=reason,
source_count=len(rows),
paper_source_count=len({_paper_group_key(row) for row in rows}),
sources=[_hybrid_row_to_source(row) for row in rows],
kb_path="hybrid:qdrant+neo4j",
answer_mode=answer_mode,
llm_used=llm_used,
notes=notes,
)
@router.get("/entity/{entity_name}/relations")
async def entity_relations(
entity_name: str,
layer: str = Query("all"),
limit: int = Query(30, ge=1, le=200),
) -> Dict[str, Any]:
"""Deprecated: SQLite endpoint removed. Use /chat for hybrid retrieval."""
return {
"entity": entity_name,
"deprecated": True,
"message": "This endpoint was backed by SQLite and is no longer available. Use /api/v1/kb-qa/chat for hybrid (Qdrant + Neo4j) retrieval.",
"count": 0,
"relations": [],
}
@router.get("/relation/{relation_id}")
async def relation_detail(relation_id: str) -> Dict[str, Any]:
"""Deprecated: SQLite endpoint removed."""
return {
"deprecated": True,
"message": "This endpoint was backed by SQLite and is no longer available. Use /api/v1/kb-qa/chat for hybrid (Qdrant + Neo4j) retrieval.",
}