feat(qa): 断言锚定检索 + robustness 加固
- assertion_anchored_graph_retriever 断言锚定候选与 top30 合并 - pipeline/agentic_controller/llm_client 重试与 Q0 软回退 - question_decomposition JSON repair、citation_contract grouped 归一化 - api endpoint_path 全参数、retrieval 禁用 PC/SQLite、canonical-only 校验 - bounded_canonical/direct_flow 白名单、subquestion_orchestrator required_review - scripts/qa_v1_1_73_server.py 新服务入口(backend_guard + gate_policy) - 旧 start_qa_v1_1_73_*.sh 移入 docs/legacy_startup_scripts/
This commit is contained in:
@@ -118,6 +118,8 @@ lib/
|
|||||||
run_*.py
|
run_*.py
|
||||||
run_*.sh
|
run_*.sh
|
||||||
start_*.sh
|
start_*.sh
|
||||||
|
!docs/legacy_startup_scripts/start_qa_v1_1_73_5090.sh
|
||||||
|
!docs/legacy_startup_scripts/start_qa_v1_1_73_7924.sh
|
||||||
batch*.py
|
batch*.py
|
||||||
debug_*.py
|
debug_*.py
|
||||||
fix_*.py
|
fix_*.py
|
||||||
@@ -146,6 +148,7 @@ scripts/*
|
|||||||
!scripts/judge_direct_qa100_unified.py
|
!scripts/judge_direct_qa100_unified.py
|
||||||
!scripts/merge_ablation_100q_raw.py
|
!scripts/merge_ablation_100q_raw.py
|
||||||
!scripts/qa_v1_0_0_server.py
|
!scripts/qa_v1_0_0_server.py
|
||||||
|
!scripts/qa_v1_1_73_server.py
|
||||||
!scripts/run_qa_v1_0_0_ablation.py
|
!scripts/run_qa_v1_0_0_ablation.py
|
||||||
!scripts/run_v16_1_2_one_paper.py
|
!scripts/run_v16_1_2_one_paper.py
|
||||||
!scripts/run_v16_batch10_20260625.py
|
!scripts/run_v16_batch10_20260625.py
|
||||||
@@ -178,6 +181,10 @@ tests/*
|
|||||||
|
|
||||||
# ----- top-level test_*.py files (not under tests/) -----
|
# ----- top-level test_*.py files (not under tests/) -----
|
||||||
test_*.py
|
test_*.py
|
||||||
|
!tests/test_qa_v1_1_73_llm_fail_fast.py
|
||||||
|
!tests/test_qa_v1_1_73_q0_soft_fallback.py
|
||||||
|
!tests/test_qa_v1_1_73_soft_gate_policy.py
|
||||||
|
!tests/test_qa_v1_1_73_soft_type_gate.py
|
||||||
!scripts/test_qa_v1_0_0.py
|
!scripts/test_qa_v1_0_0.py
|
||||||
|
|
||||||
# ----- top-level config ignores -----
|
# ----- top-level config ignores -----
|
||||||
|
|||||||
Executable
+46
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(os.environ.get("GENE_FAMILY_ROOT", "/data/gene-family-mas-v2"))
|
||||||
|
GFANALYSIS_ROOT = Path(os.environ.get("GFANALYSIS_ROOT", "/data/wutx/00test/gfAnalysis"))
|
||||||
|
for root in (PROJECT_ROOT, GFANALYSIS_ROOT):
|
||||||
|
value = str(root)
|
||||||
|
if value not in sys.path:
|
||||||
|
sys.path.insert(0, value)
|
||||||
|
|
||||||
|
from src.rag.qa_v1_1_73.api import router
|
||||||
|
from src.rag.qa_v1_1_73.backend_guard import validate_backends
|
||||||
|
from src.rag.qa_v1_1_73.soft_gate_policy import gate_policy
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Plant Biology QA V1.1.73",
|
||||||
|
version="1.1.73",
|
||||||
|
description=(
|
||||||
|
"QA V1.1.73: graph-balanced top30 plus cascaded two-hop native Neo4j path chains. The top30 context keeps "
|
||||||
|
"at least 15 graph-derived evidence items when available, and sends "
|
||||||
|
"native graph chains as a separate output. Chat composes a validated final LLM answer; "
|
||||||
|
"retrieval-only search returns evidence without an answer."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
app.include_router(router)
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def validate_production_backends() -> None:
|
||||||
|
app.state.kb_backend_identity = validate_backends()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"service": "qa_v1_1_73",
|
||||||
|
"version": "1.1.73-assertion-anchored",
|
||||||
|
"gate_policy": gate_policy(),
|
||||||
|
"backend": getattr(app.state, "kb_backend_identity", None),
|
||||||
|
}
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
"""QA V1.1.73: relation-guided native Neo4j path chains."""
|
"""Production QA V1.1.73 assertion-anchored graph expansion."""
|
||||||
|
|
||||||
__version__ = "1.1.73"
|
__version__ = "1.1.73-assertion-anchored"
|
||||||
|
|
||||||
from .pipeline import QAV1Pipeline
|
|
||||||
|
|
||||||
__all__ = ["QAV1Pipeline", "__version__"]
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -95,6 +95,9 @@ class AgenticGraphRetriever:
|
|||||||
)
|
)
|
||||||
if _type(value)
|
if _type(value)
|
||||||
}
|
}
|
||||||
|
terminal_type_wildcard = "unknown" in terminal_types
|
||||||
|
# Planner uses "unknown" to mean that no terminal type is constrained.
|
||||||
|
terminal_types.discard("unknown")
|
||||||
relation_categories = {
|
relation_categories = {
|
||||||
_norm(value)
|
_norm(value)
|
||||||
for value in relation_plan.get("allowed_relation_categories") or []
|
for value in relation_plan.get("allowed_relation_categories") or []
|
||||||
@@ -107,6 +110,7 @@ class AgenticGraphRetriever:
|
|||||||
"allow_incoming": allow_incoming,
|
"allow_incoming": allow_incoming,
|
||||||
"allow_outgoing": allow_outgoing,
|
"allow_outgoing": allow_outgoing,
|
||||||
"required_terminal_types": sorted(terminal_types),
|
"required_terminal_types": sorted(terminal_types),
|
||||||
|
"terminal_type_wildcard": terminal_type_wildcard,
|
||||||
"max_hops": max(1, min(int(graph_plan.get("max_hops", 1)), 3)),
|
"max_hops": max(1, min(int(graph_plan.get("max_hops", 1)), 3)),
|
||||||
"query_mode": (
|
"query_mode": (
|
||||||
"legacy_anchor_neighborhood"
|
"legacy_anchor_neighborhood"
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, Iterable, List, Sequence
|
||||||
|
|
||||||
|
from .llm_client import LLMClient
|
||||||
|
from .schemas import RetrievalHypothesis
|
||||||
|
|
||||||
|
|
||||||
|
class AgenticHopGateError(RuntimeError):
|
||||||
|
"""Raised when the Agent cannot produce a valid hop decision."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgenticHopGate:
|
||||||
|
"""Let an Agent decide whether verified graph paths need another hop.
|
||||||
|
|
||||||
|
The gate never queries Neo4j and never invents a path. It only reviews a
|
||||||
|
compact representation of paths already verified by the graph retriever.
|
||||||
|
Python retains operational safeguards such as the maximum hop count and
|
||||||
|
cycle prevention, but does not infer the semantic hop count.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SYSTEM = """You are the hop-control Agent for a scientific GraphRAG system.
|
||||||
|
|
||||||
|
Decide whether the CURRENT VERIFIED GRAPH PATHS already support a complete
|
||||||
|
answer to the ORIGINAL QUESTION. Do not predict a hop count before retrieval.
|
||||||
|
Do not answer the question. Do not use outside biological knowledge.
|
||||||
|
|
||||||
|
Choose exactly one action:
|
||||||
|
- stop_answerable: the current paths cover every requested answer slot.
|
||||||
|
- continue: the current paths are useful but at least one answer slot remains
|
||||||
|
unresolved; select only the path IDs whose terminal entities should be
|
||||||
|
expanded by one additional graph hop.
|
||||||
|
- stop_insufficient: the current paths do not support a complete answer and
|
||||||
|
none of their terminal entities provides a defensible next frontier.
|
||||||
|
|
||||||
|
Treat entity spelling or harmless surface-form differences as aliases when the
|
||||||
|
provided path itself makes the identity clear. Preserve relation direction,
|
||||||
|
species, treatment, genotype, tissue, time, negation, and evidence scope.
|
||||||
|
Similarity by itself is not proof. Return JSON only."""
|
||||||
|
|
||||||
|
def __init__(self, llm: LLMClient, *, max_paths: int = 12) -> None:
|
||||||
|
self.llm = llm
|
||||||
|
self.max_paths = max(4, min(int(max_paths or 18), 24))
|
||||||
|
|
||||||
|
def decide(
|
||||||
|
self,
|
||||||
|
question: str,
|
||||||
|
hypothesis: RetrievalHypothesis,
|
||||||
|
paths: Sequence[Any],
|
||||||
|
*,
|
||||||
|
current_hop: int,
|
||||||
|
safety_max_hops: int,
|
||||||
|
task_context: Dict[str, Any] | None = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
started = time.perf_counter()
|
||||||
|
compact_paths = self._compact_paths(paths)
|
||||||
|
if not compact_paths:
|
||||||
|
raise AgenticHopGateError("hop gate received no verified paths")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"original_question": str(question or "").strip(),
|
||||||
|
"question_type": str(
|
||||||
|
(task_context or {}).get("question_type")
|
||||||
|
or hypothesis.question_type
|
||||||
|
or ""
|
||||||
|
),
|
||||||
|
"requested_answer_slots": list(hypothesis.answer_slots or []),
|
||||||
|
"task_answer_slot": str((task_context or {}).get("answer_slot") or ""),
|
||||||
|
"task_constraints": [
|
||||||
|
str(value)
|
||||||
|
for value in ((task_context or {}).get("constraints") or [])
|
||||||
|
if str(value).strip()
|
||||||
|
][:12],
|
||||||
|
"current_hop": int(current_hop),
|
||||||
|
"safety_max_hops": int(safety_max_hops),
|
||||||
|
"can_expand_further": int(current_hop) < int(safety_max_hops),
|
||||||
|
"verified_paths": compact_paths,
|
||||||
|
"required_output_schema": {
|
||||||
|
"action": "stop_answerable|continue|stop_insufficient",
|
||||||
|
"answer_sufficient": False,
|
||||||
|
"covered_answer_slots": ["slot already supported"],
|
||||||
|
"unresolved_answer_slots": ["slot still missing"],
|
||||||
|
"expand_path_ids": ["H1P01"],
|
||||||
|
"next_relation_intents": ["relation concept to seek"],
|
||||||
|
"reason": "brief evidence-grounded decision",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts: List[Dict[str, Any]] = []
|
||||||
|
value: Dict[str, Any] | None = None
|
||||||
|
error = ""
|
||||||
|
for attempt in range(2):
|
||||||
|
request_payload = dict(payload)
|
||||||
|
if attempt:
|
||||||
|
request_payload["format_repair"] = (
|
||||||
|
"The previous response was malformed or internally inconsistent. "
|
||||||
|
"Return one complete JSON object matching required_output_schema."
|
||||||
|
)
|
||||||
|
request_payload["previous_error"] = error
|
||||||
|
raw = self.llm.complete_json(
|
||||||
|
self.SYSTEM,
|
||||||
|
json.dumps(request_payload, ensure_ascii=False),
|
||||||
|
max_tokens=5000,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
value = self._validate(raw, compact_paths, current_hop, safety_max_hops)
|
||||||
|
attempts.append({"attempt": attempt + 1, "valid": True})
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
error = f"{type(exc).__name__}: {exc}"
|
||||||
|
attempts.append(
|
||||||
|
{
|
||||||
|
"attempt": attempt + 1,
|
||||||
|
"valid": False,
|
||||||
|
"error": error[:500],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
value = None
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
raise AgenticHopGateError(
|
||||||
|
f"Agent hop decision invalid after {len(attempts)} attempts: {error}"
|
||||||
|
)
|
||||||
|
|
||||||
|
id_to_index = {
|
||||||
|
str(item["path_id"]): int(item["path_index"])
|
||||||
|
for item in compact_paths
|
||||||
|
}
|
||||||
|
value["expand_path_indexes"] = [
|
||||||
|
id_to_index[path_id] for path_id in value["expand_path_ids"]
|
||||||
|
]
|
||||||
|
value["current_hop"] = int(current_hop)
|
||||||
|
value["safety_max_hops"] = int(safety_max_hops)
|
||||||
|
value["candidate_path_count"] = len(compact_paths)
|
||||||
|
value["json_attempts"] = attempts
|
||||||
|
value["elapsed_seconds"] = time.perf_counter() - started
|
||||||
|
value["decision_source"] = "llm_agent_after_verified_hop"
|
||||||
|
return value
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate(
|
||||||
|
value: Any,
|
||||||
|
compact_paths: Sequence[Dict[str, Any]],
|
||||||
|
current_hop: int,
|
||||||
|
safety_max_hops: int,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
if not isinstance(value, dict) or value.get("_json_parse_error"):
|
||||||
|
raw_head = (
|
||||||
|
str(value.get("_raw_text_head") or "")[:300]
|
||||||
|
if isinstance(value, dict)
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
raise ValueError(
|
||||||
|
"response is not a JSON object"
|
||||||
|
+ (f"; raw_head={raw_head!r}" if raw_head else "; raw_head is empty")
|
||||||
|
)
|
||||||
|
action = str(value.get("action") or "").strip().lower()
|
||||||
|
if action not in {"stop_answerable", "continue", "stop_insufficient"}:
|
||||||
|
raise ValueError("action is missing or unsupported")
|
||||||
|
sufficient = value.get("answer_sufficient")
|
||||||
|
if not isinstance(sufficient, bool):
|
||||||
|
raise ValueError("answer_sufficient must be a JSON boolean")
|
||||||
|
if action == "stop_answerable" and not sufficient:
|
||||||
|
raise ValueError("stop_answerable requires answer_sufficient=true")
|
||||||
|
if action in {"continue", "stop_insufficient"} and sufficient:
|
||||||
|
raise ValueError(f"{action} requires answer_sufficient=false")
|
||||||
|
|
||||||
|
valid_ids = {str(item.get("path_id") or "") for item in compact_paths}
|
||||||
|
expand_ids = AgenticHopGate._dedupe_strings(value.get("expand_path_ids") or [])
|
||||||
|
unknown_ids = [path_id for path_id in expand_ids if path_id not in valid_ids]
|
||||||
|
if unknown_ids:
|
||||||
|
raise ValueError(f"expand_path_ids contains unknown IDs: {unknown_ids}")
|
||||||
|
if action == "continue":
|
||||||
|
if int(current_hop) >= int(safety_max_hops):
|
||||||
|
raise ValueError("continue is forbidden at the safety hop limit")
|
||||||
|
if not expand_ids:
|
||||||
|
raise ValueError("continue requires at least one verified expand_path_id")
|
||||||
|
elif expand_ids:
|
||||||
|
raise ValueError(f"{action} must not include expand_path_ids")
|
||||||
|
|
||||||
|
reason = str(value.get("reason") or "").strip()
|
||||||
|
if not reason:
|
||||||
|
raise ValueError("reason is required")
|
||||||
|
return {
|
||||||
|
"action": action,
|
||||||
|
"answer_sufficient": sufficient,
|
||||||
|
"covered_answer_slots": AgenticHopGate._dedupe_strings(
|
||||||
|
value.get("covered_answer_slots") or []
|
||||||
|
)[:12],
|
||||||
|
"unresolved_answer_slots": AgenticHopGate._dedupe_strings(
|
||||||
|
value.get("unresolved_answer_slots") or []
|
||||||
|
)[:12],
|
||||||
|
"expand_path_ids": expand_ids,
|
||||||
|
"next_relation_intents": AgenticHopGate._dedupe_strings(
|
||||||
|
value.get("next_relation_intents") or []
|
||||||
|
)[:12],
|
||||||
|
"reason": reason[:1000],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _compact_paths(self, paths: Sequence[Any]) -> List[Dict[str, Any]]:
|
||||||
|
ranked = sorted(
|
||||||
|
enumerate(paths),
|
||||||
|
key=lambda pair: float(getattr(pair[1], "path_score", 0.0) or 0.0),
|
||||||
|
reverse=True,
|
||||||
|
)[: self.max_paths]
|
||||||
|
output: List[Dict[str, Any]] = []
|
||||||
|
for display_index, (path_index, path) in enumerate(ranked, start=1):
|
||||||
|
hops = []
|
||||||
|
for hop in list(getattr(path, "hops", []) or []):
|
||||||
|
evidence = " ".join(str(getattr(hop, "evidence", "") or "").split())
|
||||||
|
hops.append(
|
||||||
|
{
|
||||||
|
"source": str(getattr(hop, "source", "") or ""),
|
||||||
|
"relation": str(getattr(hop, "relation", "") or ""),
|
||||||
|
"target": str(getattr(hop, "target", "") or ""),
|
||||||
|
"source_type": str(getattr(hop, "source_type", "") or ""),
|
||||||
|
"target_type": str(getattr(hop, "target_type", "") or ""),
|
||||||
|
"paper_id": str(getattr(hop, "paper_id", "") or ""),
|
||||||
|
"evidence": evidence[:260],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
output.append(
|
||||||
|
{
|
||||||
|
"path_id": f"H{len(hops)}P{display_index:02d}",
|
||||||
|
"path_index": path_index,
|
||||||
|
"path_score": round(
|
||||||
|
float(getattr(path, "path_score", 0.0) or 0.0), 6
|
||||||
|
),
|
||||||
|
"terminal_entity": (
|
||||||
|
str(getattr(path.hops[-1], "target", "") or "")
|
||||||
|
if getattr(path, "hops", None)
|
||||||
|
else ""
|
||||||
|
),
|
||||||
|
"hops": hops,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return output
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _dedupe_strings(values: Iterable[Any]) -> List[str]:
|
||||||
|
output: List[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for value in values:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
key = text.casefold()
|
||||||
|
if text and key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
output.append(text)
|
||||||
|
return output
|
||||||
@@ -35,6 +35,14 @@ class QARequest(BaseModel):
|
|||||||
native_chain_min_hops: int = Field(default=1, ge=1, le=3)
|
native_chain_min_hops: int = Field(default=1, ge=1, le=3)
|
||||||
native_chain_beam_width: int = Field(default=60, ge=1, le=300)
|
native_chain_beam_width: int = Field(default=60, ge=1, le=300)
|
||||||
native_chain_per_node_expand: int = Field(default=12, ge=1, le=50)
|
native_chain_per_node_expand: int = Field(default=12, ge=1, le=50)
|
||||||
|
endpoint_path_enabled: bool = False
|
||||||
|
endpoint_path_max_hops: int = Field(default=3, ge=1, le=3)
|
||||||
|
endpoint_path_top_k: int = Field(default=5, ge=0, le=10)
|
||||||
|
endpoint_path_max_endpoint_candidates: int = Field(default=3, ge=1, le=5)
|
||||||
|
endpoint_path_per_node_limit: int = Field(default=16, ge=2, le=50)
|
||||||
|
endpoint_path_max_expansions: int = Field(default=24, ge=10, le=1000)
|
||||||
|
endpoint_path_max_frontier_states: int = Field(default=120, ge=20, le=2000)
|
||||||
|
endpoint_path_time_budget_seconds: float = Field(default=5.0, ge=1.0, le=30.0)
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
@@ -54,7 +62,7 @@ def health() -> Dict[str, Any]:
|
|||||||
def info() -> Dict[str, Any]:
|
def info() -> Dict[str, Any]:
|
||||||
config = QAConfig.from_env()
|
config = QAConfig.from_env()
|
||||||
return {
|
return {
|
||||||
"name": "QA V1.1.73 Relation-Guided Native Neo4j Path Chains",
|
"name": "QA V1.1.73 Assertion-Anchored Full Graph",
|
||||||
"version": __version__,
|
"version": __version__,
|
||||||
"pipeline": [
|
"pipeline": [
|
||||||
"question",
|
"question",
|
||||||
@@ -67,6 +75,8 @@ def info() -> Dict[str, Any]:
|
|||||||
"BGE rerank",
|
"BGE rerank",
|
||||||
"graph-balanced top30 evidence chain, with at least 15 graph-derived items when available",
|
"graph-balanced top30 evidence chain, with at least 15 graph-derived items when available",
|
||||||
"BGE top10 Qdrant source seed selection for cascaded native Neo4j two-hop chain retrieval",
|
"BGE top10 Qdrant source seed selection for cascaded native Neo4j two-hop chain retrieval",
|
||||||
|
"BGE top10 complete assertions locked as hop 1 and expanded from each target in Neo4j",
|
||||||
|
"merge assertion-anchored chains with the unchanged formal native graph lane",
|
||||||
"compose a validated final LLM answer for chat; retrieval-only search returns evidence without an answer",
|
"compose a validated final LLM answer for chat; retrieval-only search returns evidence without an answer",
|
||||||
],
|
],
|
||||||
"safety_boundary": (
|
"safety_boundary": (
|
||||||
@@ -94,6 +104,9 @@ def info() -> Dict[str, Any]:
|
|||||||
"native_chain_max_hops": 3,
|
"native_chain_max_hops": 3,
|
||||||
"native_chain_top_k": 5,
|
"native_chain_top_k": 5,
|
||||||
"native_chain_min_hops": 1,
|
"native_chain_min_hops": 1,
|
||||||
|
"endpoint_path_enabled": False,
|
||||||
|
"endpoint_path_max_hops": 3,
|
||||||
|
"endpoint_path_top_k": 5,
|
||||||
},
|
},
|
||||||
"request_ablation_supported": [
|
"request_ablation_supported": [
|
||||||
"use_provisional_draft",
|
"use_provisional_draft",
|
||||||
@@ -101,13 +114,12 @@ def info() -> Dict[str, Any]:
|
|||||||
"ablation.retrieval.use_qdrant",
|
"ablation.retrieval.use_qdrant",
|
||||||
"ablation.retrieval.use_neo4j",
|
"ablation.retrieval.use_neo4j",
|
||||||
"ablation.retrieval.use_graph_expansion",
|
"ablation.retrieval.use_graph_expansion",
|
||||||
"ablation.retrieval.use_sqlite",
|
|
||||||
"ablation.retrieval.use_pc",
|
|
||||||
"ablation.retrieval.use_v15",
|
"ablation.retrieval.use_v15",
|
||||||
"ablation.use_bge_rerank",
|
"ablation.use_bge_rerank",
|
||||||
"ablation.native_seed_policy",
|
"ablation.native_seed_policy",
|
||||||
"ablation.native_seed_top_k",
|
"ablation.native_seed_top_k",
|
||||||
"ablation.native_target_seed_policy",
|
"ablation.native_target_seed_policy",
|
||||||
|
"endpoint_path_enabled",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -148,6 +160,14 @@ async def chat(request: QARequest) -> Dict[str, Any]:
|
|||||||
native_chain_min_hops=request.native_chain_min_hops,
|
native_chain_min_hops=request.native_chain_min_hops,
|
||||||
native_chain_beam_width=request.native_chain_beam_width,
|
native_chain_beam_width=request.native_chain_beam_width,
|
||||||
native_chain_per_node_expand=request.native_chain_per_node_expand,
|
native_chain_per_node_expand=request.native_chain_per_node_expand,
|
||||||
|
endpoint_path_enabled=request.endpoint_path_enabled,
|
||||||
|
endpoint_path_max_hops=request.endpoint_path_max_hops,
|
||||||
|
endpoint_path_top_k=request.endpoint_path_top_k,
|
||||||
|
endpoint_path_max_endpoint_candidates=request.endpoint_path_max_endpoint_candidates,
|
||||||
|
endpoint_path_per_node_limit=request.endpoint_path_per_node_limit,
|
||||||
|
endpoint_path_max_expansions=request.endpoint_path_max_expansions,
|
||||||
|
endpoint_path_max_frontier_states=request.endpoint_path_max_frontier_states,
|
||||||
|
endpoint_path_time_budget_seconds=request.endpoint_path_time_budget_seconds,
|
||||||
)
|
)
|
||||||
except QAAnswerError as exc:
|
except QAAnswerError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -203,6 +223,14 @@ async def chat_stream(request: QARequest) -> StreamingResponse:
|
|||||||
native_chain_min_hops=request.native_chain_min_hops,
|
native_chain_min_hops=request.native_chain_min_hops,
|
||||||
native_chain_beam_width=request.native_chain_beam_width,
|
native_chain_beam_width=request.native_chain_beam_width,
|
||||||
native_chain_per_node_expand=request.native_chain_per_node_expand,
|
native_chain_per_node_expand=request.native_chain_per_node_expand,
|
||||||
|
endpoint_path_enabled=request.endpoint_path_enabled,
|
||||||
|
endpoint_path_max_hops=request.endpoint_path_max_hops,
|
||||||
|
endpoint_path_top_k=request.endpoint_path_top_k,
|
||||||
|
endpoint_path_max_endpoint_candidates=request.endpoint_path_max_endpoint_candidates,
|
||||||
|
endpoint_path_per_node_limit=request.endpoint_path_per_node_limit,
|
||||||
|
endpoint_path_max_expansions=request.endpoint_path_max_expansions,
|
||||||
|
endpoint_path_max_frontier_states=request.endpoint_path_max_frontier_states,
|
||||||
|
endpoint_path_time_budget_seconds=request.endpoint_path_time_budget_seconds,
|
||||||
progress_callback=emit,
|
progress_callback=emit,
|
||||||
)
|
)
|
||||||
events.put(("complete", result))
|
events.put(("complete", result))
|
||||||
@@ -270,6 +298,14 @@ async def search(request: QARequest) -> Dict[str, Any]:
|
|||||||
native_chain_min_hops=request.native_chain_min_hops,
|
native_chain_min_hops=request.native_chain_min_hops,
|
||||||
native_chain_beam_width=request.native_chain_beam_width,
|
native_chain_beam_width=request.native_chain_beam_width,
|
||||||
native_chain_per_node_expand=request.native_chain_per_node_expand,
|
native_chain_per_node_expand=request.native_chain_per_node_expand,
|
||||||
|
endpoint_path_enabled=request.endpoint_path_enabled,
|
||||||
|
endpoint_path_max_hops=request.endpoint_path_max_hops,
|
||||||
|
endpoint_path_top_k=request.endpoint_path_top_k,
|
||||||
|
endpoint_path_max_endpoint_candidates=request.endpoint_path_max_endpoint_candidates,
|
||||||
|
endpoint_path_per_node_limit=request.endpoint_path_per_node_limit,
|
||||||
|
endpoint_path_max_expansions=request.endpoint_path_max_expansions,
|
||||||
|
endpoint_path_max_frontier_states=request.endpoint_path_max_frontier_states,
|
||||||
|
endpoint_path_time_budget_seconds=request.endpoint_path_time_budget_seconds,
|
||||||
retrieval_only=True,
|
retrieval_only=True,
|
||||||
)
|
)
|
||||||
if isinstance(response, dict):
|
if isinstance(response, dict):
|
||||||
|
|||||||
@@ -0,0 +1,544 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, Iterable, List, Tuple
|
||||||
|
|
||||||
|
from neo4j import Query
|
||||||
|
|
||||||
|
from .bounded_canonical_retriever import BoundedCanonicalGraphPathRetriever
|
||||||
|
from .graph_path_chain import BLOCKED_CATEGORIES, GraphHop, GraphPath, _norm, _text
|
||||||
|
from .schemas import RetrievalHypothesis
|
||||||
|
|
||||||
|
|
||||||
|
UNKNOWN_ANSWER_TYPES = {
|
||||||
|
"",
|
||||||
|
"any",
|
||||||
|
"none",
|
||||||
|
"not specified",
|
||||||
|
"unspecified",
|
||||||
|
"unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AssertionAnchoredGraphRetriever(BoundedCanonicalGraphPathRetriever):
|
||||||
|
"""Lock reranked vector assertions as hop 1, then expand their targets.
|
||||||
|
|
||||||
|
Qdrant and Neo4j were built from the same canonical assertion corpus. The
|
||||||
|
vector assertion ID is therefore a reliable cross-store join key. The
|
||||||
|
public query entrance remains natural-language/vector retrieval; canonical
|
||||||
|
entity IDs are used only after Neo4j has verified the selected assertion.
|
||||||
|
"""
|
||||||
|
|
||||||
|
CHAIN_SOURCE = "neo4j_vector_assertion_anchored_path"
|
||||||
|
|
||||||
|
def retrieve_from_assertions(
|
||||||
|
self,
|
||||||
|
question: str,
|
||||||
|
hypothesis: RetrievalHypothesis,
|
||||||
|
assertion_records: Iterable[Dict[str, Any]],
|
||||||
|
*,
|
||||||
|
relation_categories: Iterable[str] | None = None,
|
||||||
|
answer_type_groups: Iterable[str] | None = None,
|
||||||
|
max_hops: int = 3,
|
||||||
|
per_node_expand: int = 12,
|
||||||
|
beam_width: int = 80,
|
||||||
|
top_k_chains: int = 10,
|
||||||
|
hop_gate: Any | None = None,
|
||||||
|
hop_gate_context: Dict[str, Any] | None = None,
|
||||||
|
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
|
||||||
|
started = time.perf_counter()
|
||||||
|
max_hops = max(1, min(int(max_hops or 3), 3))
|
||||||
|
per_node_expand = max(1, min(int(per_node_expand or 12), 30))
|
||||||
|
beam_width = max(10, min(int(beam_width or 80), 200))
|
||||||
|
top_k_chains = max(0, min(int(top_k_chains or 10), 20))
|
||||||
|
records = self._clean_assertion_records(assertion_records)[:10]
|
||||||
|
answer_types = [
|
||||||
|
_norm(value)
|
||||||
|
for value in (answer_type_groups or [])
|
||||||
|
if _norm(value) not in UNKNOWN_ANSWER_TYPES
|
||||||
|
]
|
||||||
|
audit: Dict[str, Any] = {
|
||||||
|
"enabled": True,
|
||||||
|
"chain_source": self.CHAIN_SOURCE,
|
||||||
|
"policy": "qdrant_bge_top10_assertion_locked_as_hop1_then_target_expansion",
|
||||||
|
"requested_assertion_count": len(records),
|
||||||
|
"requested_assertion_ids": [row["assertion_id"] for row in records],
|
||||||
|
"max_hops": max_hops,
|
||||||
|
"per_node_expand": per_node_expand,
|
||||||
|
"beam_width": beam_width,
|
||||||
|
"top_k_chains": top_k_chains,
|
||||||
|
"answer_type_groups": answer_types,
|
||||||
|
"hop_stats": [],
|
||||||
|
"agentic_hop_gate": {
|
||||||
|
"enabled": hop_gate is not None,
|
||||||
|
"decisions": [],
|
||||||
|
},
|
||||||
|
"filtered_reasons": {},
|
||||||
|
"fallback_required": False,
|
||||||
|
}
|
||||||
|
if top_k_chains <= 0:
|
||||||
|
audit["status"] = "disabled_by_top_k"
|
||||||
|
return [], audit
|
||||||
|
if not records:
|
||||||
|
audit.update(
|
||||||
|
{
|
||||||
|
"status": "no_vector_assertions",
|
||||||
|
"fallback_required": True,
|
||||||
|
"elapsed_seconds": time.perf_counter() - started,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return [], audit
|
||||||
|
|
||||||
|
self._bounded_reset_metrics()
|
||||||
|
self._bounded_prepare_question(question)
|
||||||
|
first_rows = self._load_verified_first_hops(records)
|
||||||
|
audit["verified_first_hop_count"] = len(first_rows)
|
||||||
|
audit["verified_first_hop_assertion_ids"] = [
|
||||||
|
_text(row.get("assertion_id")) for row in first_rows
|
||||||
|
]
|
||||||
|
if not first_rows:
|
||||||
|
audit.update(
|
||||||
|
{
|
||||||
|
"status": "no_assertion_mapped_to_neo4j",
|
||||||
|
"fallback_required": True,
|
||||||
|
"bounded_graph_backend": dict(self._bounded_metrics),
|
||||||
|
"elapsed_seconds": time.perf_counter() - started,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return [], audit
|
||||||
|
|
||||||
|
question_norm = _norm(question)
|
||||||
|
allowed = self._allowed_categories(hypothesis, relation_categories)
|
||||||
|
allowed.update(self._categories_from_question(question_norm))
|
||||||
|
relation_clues = self._relation_clues(question)
|
||||||
|
priority_relation_clues = self._priority_relation_clues(question_norm)
|
||||||
|
seed_norms = {
|
||||||
|
value
|
||||||
|
for row in first_rows
|
||||||
|
for value in (_norm(row.get("source")), _norm(row.get("target")))
|
||||||
|
if value
|
||||||
|
}
|
||||||
|
entity_clues = self._visible_entity_clues(question_norm, seed_norms)
|
||||||
|
|
||||||
|
first_paths: List[GraphPath] = []
|
||||||
|
for row in first_rows:
|
||||||
|
edge_score, edge_breakdown = self._score_edge(
|
||||||
|
row, question_norm, seed_norms, allowed
|
||||||
|
)
|
||||||
|
vector_score = float(row.get("seed_rerank_score") or 0.0)
|
||||||
|
edge_score = min(1.0, 0.55 * edge_score + 0.45 * vector_score)
|
||||||
|
edge_breakdown.update(
|
||||||
|
{
|
||||||
|
"vector_assertion_first_hop_verified": 1.0,
|
||||||
|
"vector_rerank_score": vector_score,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
hop = self._row_to_hop(row, hop_index=1, edge_score=edge_score, edge_breakdown=edge_breakdown)
|
||||||
|
path = GraphPath(
|
||||||
|
hops=[hop],
|
||||||
|
node_ids=[hop.source_canonical_id, hop.target_canonical_id],
|
||||||
|
)
|
||||||
|
path.path_score, path.score_breakdown = self._score_anchored_path(
|
||||||
|
path,
|
||||||
|
question_norm,
|
||||||
|
seed_norms,
|
||||||
|
allowed,
|
||||||
|
relation_clues,
|
||||||
|
answer_types,
|
||||||
|
entity_clues,
|
||||||
|
vector_score,
|
||||||
|
)
|
||||||
|
first_paths.append(path)
|
||||||
|
|
||||||
|
all_paths: List[GraphPath] = list(first_paths)
|
||||||
|
beam = self._select_beam_paths(
|
||||||
|
list(first_paths), beam_width, entity_clues=entity_clues
|
||||||
|
)
|
||||||
|
filtered: Dict[str, int] = {}
|
||||||
|
if hop_gate is not None and beam:
|
||||||
|
decision = hop_gate.decide(
|
||||||
|
question,
|
||||||
|
hypothesis,
|
||||||
|
beam,
|
||||||
|
current_hop=1,
|
||||||
|
safety_max_hops=max_hops,
|
||||||
|
task_context=hop_gate_context or {},
|
||||||
|
)
|
||||||
|
audit["agentic_hop_gate"]["decisions"].append(
|
||||||
|
self._public_hop_gate_decision(decision)
|
||||||
|
)
|
||||||
|
if decision["action"] == "continue":
|
||||||
|
beam = self._paths_at_indexes(
|
||||||
|
beam, decision.get("expand_path_indexes") or []
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
beam = []
|
||||||
|
|
||||||
|
for hop_index in range(2, max_hops + 1):
|
||||||
|
frontier_ids = self._dedupe([path.end_id for path in beam if path.end_id])
|
||||||
|
if not frontier_ids:
|
||||||
|
break
|
||||||
|
rows = self._fetch_neighbors(
|
||||||
|
frontier_ids,
|
||||||
|
allowed,
|
||||||
|
relation_clues=relation_clues,
|
||||||
|
priority_relation_clues=priority_relation_clues,
|
||||||
|
entity_clues=entity_clues,
|
||||||
|
answer_type_groups=answer_types,
|
||||||
|
per_node_expand=per_node_expand,
|
||||||
|
)
|
||||||
|
paths_by_end: Dict[str, List[GraphPath]] = {}
|
||||||
|
for path in beam:
|
||||||
|
paths_by_end.setdefault(path.end_id, []).append(path)
|
||||||
|
next_paths: List[GraphPath] = []
|
||||||
|
for row in rows:
|
||||||
|
from_id = _text(row.get("from_id"))
|
||||||
|
to_id = _text(row.get("to_id"))
|
||||||
|
if not from_id or not to_id:
|
||||||
|
self._inc(filtered, "missing_node")
|
||||||
|
continue
|
||||||
|
if _text(row.get("relation_category")) in BLOCKED_CATEGORIES:
|
||||||
|
self._inc(filtered, "blocked_relation_category")
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
_text(row.get("source_canonical_id")) != from_id
|
||||||
|
or _text(row.get("target_canonical_id")) != to_id
|
||||||
|
):
|
||||||
|
self._inc(filtered, "non_directed_srt_continuity")
|
||||||
|
continue
|
||||||
|
for base in paths_by_end.get(from_id, []):
|
||||||
|
assertion_id = _text(row.get("assertion_id"))
|
||||||
|
if assertion_id and assertion_id in base.assertion_ids():
|
||||||
|
self._inc(filtered, "repeat_assertion")
|
||||||
|
continue
|
||||||
|
if to_id in base.node_ids:
|
||||||
|
self._inc(filtered, "repeat_node")
|
||||||
|
continue
|
||||||
|
edge_score, edge_breakdown = self._score_edge(
|
||||||
|
row, question_norm, seed_norms, allowed
|
||||||
|
)
|
||||||
|
relation_tokens = {
|
||||||
|
token
|
||||||
|
for token in _norm(row.get("relation")).split()
|
||||||
|
if len(token) >= 4
|
||||||
|
}
|
||||||
|
question_tokens = set(question_norm.split())
|
||||||
|
relation_question_overlap = (
|
||||||
|
len(relation_tokens & question_tokens)
|
||||||
|
/ max(1, len(relation_tokens))
|
||||||
|
)
|
||||||
|
edge_score = min(
|
||||||
|
1.0, edge_score + 0.18 * relation_question_overlap
|
||||||
|
)
|
||||||
|
edge_breakdown["relation_question_token_overlap"] = (
|
||||||
|
relation_question_overlap
|
||||||
|
)
|
||||||
|
if edge_score < 0.12:
|
||||||
|
self._inc(filtered, "low_edge_score")
|
||||||
|
continue
|
||||||
|
hop = self._row_to_hop(
|
||||||
|
row,
|
||||||
|
hop_index=hop_index,
|
||||||
|
edge_score=edge_score,
|
||||||
|
edge_breakdown=edge_breakdown,
|
||||||
|
)
|
||||||
|
path = GraphPath(
|
||||||
|
hops=base.hops + [hop],
|
||||||
|
node_ids=base.node_ids + [to_id],
|
||||||
|
)
|
||||||
|
vector_score = float(
|
||||||
|
base.score_breakdown.get("vector_first_hop_score") or 0.0
|
||||||
|
)
|
||||||
|
path.path_score, path.score_breakdown = self._score_anchored_path(
|
||||||
|
path,
|
||||||
|
question_norm,
|
||||||
|
seed_norms,
|
||||||
|
allowed,
|
||||||
|
relation_clues,
|
||||||
|
answer_types,
|
||||||
|
entity_clues,
|
||||||
|
vector_score,
|
||||||
|
)
|
||||||
|
next_paths.append(path)
|
||||||
|
next_paths = self._dedupe_paths(next_paths)
|
||||||
|
next_paths.sort(key=lambda item: item.path_score, reverse=True)
|
||||||
|
all_paths.extend(next_paths)
|
||||||
|
candidate_beam = self._select_beam_paths(
|
||||||
|
next_paths,
|
||||||
|
beam_width,
|
||||||
|
entity_clues=entity_clues,
|
||||||
|
)
|
||||||
|
if hop_gate is not None and candidate_beam:
|
||||||
|
decision = hop_gate.decide(
|
||||||
|
question,
|
||||||
|
hypothesis,
|
||||||
|
candidate_beam,
|
||||||
|
current_hop=hop_index,
|
||||||
|
safety_max_hops=max_hops,
|
||||||
|
task_context=hop_gate_context or {},
|
||||||
|
)
|
||||||
|
audit["agentic_hop_gate"]["decisions"].append(
|
||||||
|
self._public_hop_gate_decision(decision)
|
||||||
|
)
|
||||||
|
if decision["action"] == "continue":
|
||||||
|
beam = self._paths_at_indexes(
|
||||||
|
candidate_beam,
|
||||||
|
decision.get("expand_path_indexes") or [],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
beam = []
|
||||||
|
else:
|
||||||
|
beam = candidate_beam
|
||||||
|
audit["hop_stats"].append(
|
||||||
|
{
|
||||||
|
"hop": hop_index,
|
||||||
|
"frontier_count": len(frontier_ids),
|
||||||
|
"raw_edge_count": len(rows),
|
||||||
|
"candidate_path_count": len(next_paths),
|
||||||
|
"kept_beam_count": len(beam),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if not beam:
|
||||||
|
break
|
||||||
|
|
||||||
|
all_paths = self._dedupe_paths(all_paths)
|
||||||
|
selected = self._select_diverse_paths(all_paths, top_k_chains)
|
||||||
|
chains = []
|
||||||
|
for index, path in enumerate(selected, start=1):
|
||||||
|
chain = path.to_chain_dict(index)
|
||||||
|
chain["chain_source"] = self.CHAIN_SOURCE
|
||||||
|
chain["first_hop_assertion_id"] = path.hops[0].assertion_id if path.hops else ""
|
||||||
|
chain["first_hop_locked"] = True
|
||||||
|
chains.append(chain)
|
||||||
|
|
||||||
|
audit.update(
|
||||||
|
{
|
||||||
|
"status": "ok" if chains else "no_expanded_chain",
|
||||||
|
"fallback_required": not bool(chains),
|
||||||
|
"candidate_path_count": len(all_paths),
|
||||||
|
"returned_chain_count": len(chains),
|
||||||
|
"selected_hop_counts": {
|
||||||
|
str(hop): sum(1 for path in selected if len(path.hops) == hop)
|
||||||
|
for hop in range(1, max_hops + 1)
|
||||||
|
},
|
||||||
|
"filtered_reasons": filtered,
|
||||||
|
"bounded_graph_backend": dict(self._bounded_metrics),
|
||||||
|
"elapsed_seconds": time.perf_counter() - started,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return chains, audit
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _paths_at_indexes(paths: List[GraphPath], indexes: Iterable[Any]) -> List[GraphPath]:
|
||||||
|
selected: List[GraphPath] = []
|
||||||
|
seen: set[int] = set()
|
||||||
|
for value in indexes:
|
||||||
|
try:
|
||||||
|
index = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if index in seen or index < 0 or index >= len(paths):
|
||||||
|
continue
|
||||||
|
seen.add(index)
|
||||||
|
selected.append(paths[index])
|
||||||
|
return selected
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _public_hop_gate_decision(decision: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
key: value
|
||||||
|
for key, value in decision.items()
|
||||||
|
if key != "expand_path_indexes"
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _clean_assertion_records(
|
||||||
|
assertion_records: Iterable[Dict[str, Any]],
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
output: List[Dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for record in assertion_records or []:
|
||||||
|
if not isinstance(record, dict):
|
||||||
|
continue
|
||||||
|
assertion_id = _text(record.get("assertion_id"))
|
||||||
|
if not assertion_id.startswith("cass_") or assertion_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(assertion_id)
|
||||||
|
output.append(dict(record))
|
||||||
|
return output
|
||||||
|
|
||||||
|
def _load_verified_first_hops(
|
||||||
|
self, records: List[Dict[str, Any]]
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
statement = Query(
|
||||||
|
"""
|
||||||
|
UNWIND $records AS seed
|
||||||
|
MATCH (a:SRT2CanonicalAssertion {assertion_id: seed.assertion_id})
|
||||||
|
MATCH (s:SRT2CanonicalEntity {canonical_id: a.source_canonical_id})
|
||||||
|
MATCH (t:SRT2CanonicalEntity {canonical_id: a.target_canonical_id})
|
||||||
|
MATCH (s)-[r:SRT2_CANONICAL_ASSERTS]-(t)
|
||||||
|
WHERE r.assertion_id = a.assertion_id
|
||||||
|
RETURN seed.rank_after_bge AS seed_rank,
|
||||||
|
seed.final_score AS seed_final_score,
|
||||||
|
seed.rerank_score AS seed_rerank_score,
|
||||||
|
seed.vector_score AS seed_vector_score,
|
||||||
|
a.assertion_id AS assertion_id,
|
||||||
|
s.canonical_id AS from_id,
|
||||||
|
s.name AS from_name,
|
||||||
|
t.canonical_id AS to_id,
|
||||||
|
t.name AS to_name,
|
||||||
|
a.source AS source,
|
||||||
|
a.source_type AS source_type,
|
||||||
|
a.source_canonical_id AS source_canonical_id,
|
||||||
|
a.relation_norm AS relation,
|
||||||
|
a.relation_category AS relation_category,
|
||||||
|
a.target AS target,
|
||||||
|
a.target_type AS target_type,
|
||||||
|
a.target_canonical_id AS target_canonical_id,
|
||||||
|
a.best_evidence AS evidence,
|
||||||
|
a.best_paper_id AS paper_id,
|
||||||
|
a.title AS title,
|
||||||
|
a.pmid AS pmid,
|
||||||
|
a.pmcid AS pmcid,
|
||||||
|
a.doi AS doi,
|
||||||
|
'' AS species,
|
||||||
|
coalesce(a.support_count, 0) AS support_count,
|
||||||
|
coalesce(a.paper_count, 0) AS paper_count,
|
||||||
|
coalesce(a.best_score, 0.0) AS best_score,
|
||||||
|
1 AS category_match,
|
||||||
|
0 AS relation_clue_score
|
||||||
|
ORDER BY seed.rank_after_bge ASC
|
||||||
|
""",
|
||||||
|
timeout=self._bounded_neo4j_timeout,
|
||||||
|
)
|
||||||
|
with self.driver.session() as session:
|
||||||
|
return [dict(record) for record in session.run(statement, records=records)]
|
||||||
|
|
||||||
|
def _score_anchored_path(
|
||||||
|
self,
|
||||||
|
path: GraphPath,
|
||||||
|
question_norm: str,
|
||||||
|
seed_norms: set[str],
|
||||||
|
allowed: set[str],
|
||||||
|
relation_clues: List[str],
|
||||||
|
answer_types: List[str],
|
||||||
|
entity_clues: List[str],
|
||||||
|
vector_score: float,
|
||||||
|
) -> Tuple[float, Dict[str, Any]]:
|
||||||
|
base_score, breakdown = self._score_path(
|
||||||
|
path,
|
||||||
|
question_norm,
|
||||||
|
seed_norms,
|
||||||
|
allowed,
|
||||||
|
relation_clues,
|
||||||
|
answer_types,
|
||||||
|
entity_clues,
|
||||||
|
)
|
||||||
|
vector_score = max(0.0, min(float(vector_score or 0.0), 1.0))
|
||||||
|
multihop_bonus = 0.035 if len(path.hops) >= 2 else 0.0
|
||||||
|
terminal_relation_tokens = {
|
||||||
|
token
|
||||||
|
for token in _norm(path.hops[-1].relation if path.hops else "").split()
|
||||||
|
if len(token) >= 4
|
||||||
|
}
|
||||||
|
terminal_relation_overlap = (
|
||||||
|
len(terminal_relation_tokens & set(question_norm.split()))
|
||||||
|
/ max(1, len(terminal_relation_tokens))
|
||||||
|
)
|
||||||
|
final_score = min(
|
||||||
|
1.0,
|
||||||
|
0.68 * base_score
|
||||||
|
+ 0.32 * vector_score
|
||||||
|
+ multihop_bonus
|
||||||
|
+ 0.18 * terminal_relation_overlap,
|
||||||
|
)
|
||||||
|
breakdown.update(
|
||||||
|
{
|
||||||
|
"base_native_path_score": base_score,
|
||||||
|
"vector_first_hop_score": vector_score,
|
||||||
|
"vector_first_hop_weight": 0.32,
|
||||||
|
"first_hop_locked": True,
|
||||||
|
"multihop_bonus": multihop_bonus,
|
||||||
|
"terminal_relation_question_overlap": terminal_relation_overlap,
|
||||||
|
"final_score": final_score,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return final_score, breakdown
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _row_to_hop(
|
||||||
|
row: Dict[str, Any],
|
||||||
|
*,
|
||||||
|
hop_index: int,
|
||||||
|
edge_score: float,
|
||||||
|
edge_breakdown: Dict[str, Any],
|
||||||
|
) -> GraphHop:
|
||||||
|
return GraphHop(
|
||||||
|
hop_index=hop_index,
|
||||||
|
from_id=_text(row.get("from_id")),
|
||||||
|
from_name=_text(row.get("from_name")),
|
||||||
|
to_id=_text(row.get("to_id")),
|
||||||
|
to_name=_text(row.get("to_name")),
|
||||||
|
source=_text(row.get("source")),
|
||||||
|
relation=_text(row.get("relation")),
|
||||||
|
target=_text(row.get("target")),
|
||||||
|
source_canonical_id=_text(row.get("source_canonical_id")),
|
||||||
|
target_canonical_id=_text(row.get("target_canonical_id")),
|
||||||
|
source_type=_text(row.get("source_type")),
|
||||||
|
target_type=_text(row.get("target_type")),
|
||||||
|
relation_category=_text(row.get("relation_category")),
|
||||||
|
assertion_id=_text(row.get("assertion_id")),
|
||||||
|
paper_id=_text(row.get("paper_id")),
|
||||||
|
title=_text(row.get("title")),
|
||||||
|
pmid=_text(row.get("pmid")),
|
||||||
|
pmcid=_text(row.get("pmcid")),
|
||||||
|
doi=_text(row.get("doi")),
|
||||||
|
species=_text(row.get("species")),
|
||||||
|
evidence=_text(row.get("evidence")),
|
||||||
|
support_count=int(row.get("support_count") or 0),
|
||||||
|
paper_count=int(row.get("paper_count") or 0),
|
||||||
|
best_score=float(row.get("best_score") or 0.0),
|
||||||
|
edge_score=edge_score,
|
||||||
|
score_breakdown=edge_breakdown,
|
||||||
|
passive_relation=False,
|
||||||
|
structural_direction=(
|
||||||
|
f"{_text(row.get('source'))} -> {_text(row.get('target'))}"
|
||||||
|
),
|
||||||
|
semantic_direction=(
|
||||||
|
f"{_text(row.get('source'))} -> {_text(row.get('target'))}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _select_diverse_paths(
|
||||||
|
paths: List[GraphPath], top_k: int
|
||||||
|
) -> List[GraphPath]:
|
||||||
|
ranked = sorted(
|
||||||
|
paths,
|
||||||
|
key=lambda path: (len(path.hops) >= 2, path.path_score),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
selected: List[GraphPath] = []
|
||||||
|
selected_keys: set[Tuple[str, ...]] = set()
|
||||||
|
first_hop_counts: Dict[str, int] = {}
|
||||||
|
for path in ranked:
|
||||||
|
key = tuple(path.assertion_ids())
|
||||||
|
if not key or key in selected_keys:
|
||||||
|
continue
|
||||||
|
first = key[0]
|
||||||
|
if first_hop_counts.get(first, 0) >= 2:
|
||||||
|
continue
|
||||||
|
selected.append(path)
|
||||||
|
selected_keys.add(key)
|
||||||
|
first_hop_counts[first] = first_hop_counts.get(first, 0) + 1
|
||||||
|
if len(selected) >= top_k:
|
||||||
|
return selected
|
||||||
|
for path in ranked:
|
||||||
|
key = tuple(path.assertion_ids())
|
||||||
|
if not key or key in selected_keys:
|
||||||
|
continue
|
||||||
|
selected.append(path)
|
||||||
|
selected_keys.add(key)
|
||||||
|
if len(selected) >= top_k:
|
||||||
|
break
|
||||||
|
return selected
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Any, Dict
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
from neo4j import GraphDatabase
|
||||||
|
|
||||||
|
|
||||||
|
ALLOWED_QDRANT_COLLECTIONS = {
|
||||||
|
"plant_srt_direct_v1_bge_m3_20260702",
|
||||||
|
"plant_srt_direct_v1_bge_m3_citations_20260717",
|
||||||
|
}
|
||||||
|
REQUIRED_NODE_LABELS = {
|
||||||
|
"SRT2Paper",
|
||||||
|
"SRT2CanonicalEntity",
|
||||||
|
"SRT2CanonicalAssertion",
|
||||||
|
}
|
||||||
|
REQUIRED_RELATIONSHIP_TYPES = {
|
||||||
|
"SRT2_CANONICAL_ASSERTS",
|
||||||
|
"SRT2_SUPPORTED_BY",
|
||||||
|
}
|
||||||
|
FORBIDDEN_NODE_LABELS = {
|
||||||
|
"Entity",
|
||||||
|
"PCEntity",
|
||||||
|
"PCRelationEvidence",
|
||||||
|
"PCPaper",
|
||||||
|
"SRT2Entity",
|
||||||
|
"SRT2Assertion",
|
||||||
|
}
|
||||||
|
FORBIDDEN_RELATIONSHIP_TYPES = {
|
||||||
|
"RELATION",
|
||||||
|
"PC_REL",
|
||||||
|
"HAS_SOURCE",
|
||||||
|
"HAS_TARGET",
|
||||||
|
"FROM_PAPER",
|
||||||
|
"SRT2_ASSERTS",
|
||||||
|
"SRT2_ASSERTION_SOURCE",
|
||||||
|
"SRT2_ASSERTION_TARGET",
|
||||||
|
}
|
||||||
|
EXPECTED_NEO4J_PAPERS = 31_207
|
||||||
|
EXPECTED_NEO4J_CANONICAL_ENTITIES = 3_820_996
|
||||||
|
EXPECTED_NEO4J_CANONICAL_ASSERTIONS = 8_246_875
|
||||||
|
EXPECTED_NEO4J_CANONICAL_ASSERTS = 8_246_875
|
||||||
|
EXPECTED_NEO4J_SUPPORTED_BY = 8_202_284
|
||||||
|
|
||||||
|
|
||||||
|
class BackendIdentityError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _qdrant_json(url: str) -> Dict[str, Any]:
|
||||||
|
with urlopen(url, timeout=30) as response:
|
||||||
|
return json.load(response)
|
||||||
|
|
||||||
|
|
||||||
|
def _qdrant_sample(url: str, collection: str) -> Dict[str, Any]:
|
||||||
|
body = json.dumps(
|
||||||
|
{"limit": 1, "with_payload": True, "with_vector": False}
|
||||||
|
).encode("utf-8")
|
||||||
|
request = Request(
|
||||||
|
f"{url.rstrip('/')}/collections/{collection}/points/scroll",
|
||||||
|
data=body,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
with urlopen(request, timeout=30) as response:
|
||||||
|
data = json.load(response)
|
||||||
|
points = data.get("result", {}).get("points") or []
|
||||||
|
return dict(points[0].get("payload") or {}) if points else {}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_backends() -> Dict[str, Any]:
|
||||||
|
graph_schema = os.environ.get("QA_V1_GRAPH_SCHEMA", "canonical").strip().lower()
|
||||||
|
if graph_schema != "canonical":
|
||||||
|
raise BackendIdentityError(
|
||||||
|
f"QA_V1_GRAPH_SCHEMA must be canonical, got {graph_schema!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
collection = os.environ.get(
|
||||||
|
"V15_QDRANT_COLLECTION", "plant_srt_direct_v1_bge_m3_20260702"
|
||||||
|
).strip()
|
||||||
|
if collection not in ALLOWED_QDRANT_COLLECTIONS:
|
||||||
|
raise BackendIdentityError(
|
||||||
|
f"Qdrant collection {collection!r} is not in the production allowlist"
|
||||||
|
)
|
||||||
|
|
||||||
|
qdrant_url = os.environ.get(
|
||||||
|
"V15_QDRANT_URL", os.environ.get("QDRANT_URL", "http://127.0.0.1:6333")
|
||||||
|
).rstrip("/")
|
||||||
|
qdrant_info = _qdrant_json(f"{qdrant_url}/collections/{collection}").get(
|
||||||
|
"result", {}
|
||||||
|
)
|
||||||
|
qdrant_points = int(qdrant_info.get("points_count") or 0)
|
||||||
|
if qdrant_points != 8_246_777:
|
||||||
|
raise BackendIdentityError(
|
||||||
|
f"Qdrant identity mismatch: expected 8246777 points, got {qdrant_points}"
|
||||||
|
)
|
||||||
|
sample_payload = _qdrant_sample(qdrant_url, collection)
|
||||||
|
if sample_payload.get("source_system") != "V15_V16_canonical":
|
||||||
|
raise BackendIdentityError(
|
||||||
|
"Qdrant payload is not the V15/V16 canonical assertion collection"
|
||||||
|
)
|
||||||
|
|
||||||
|
neo4j_uri = os.environ.get("NEO4J_URI", "bolt://127.0.0.1:18788")
|
||||||
|
neo4j_user = os.environ.get("NEO4J_USER", "neo4j")
|
||||||
|
neo4j_password = os.environ.get("NEO4J_PASSWORD", "")
|
||||||
|
driver = GraphDatabase.driver(
|
||||||
|
neo4j_uri,
|
||||||
|
auth=(neo4j_user, neo4j_password),
|
||||||
|
connection_timeout=15,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with driver.session() as session:
|
||||||
|
labels = set(
|
||||||
|
session.run(
|
||||||
|
"CALL db.labels() YIELD label RETURN label"
|
||||||
|
).value("label")
|
||||||
|
)
|
||||||
|
relationship_types = set(
|
||||||
|
session.run(
|
||||||
|
"CALL db.relationshipTypes() YIELD relationshipType "
|
||||||
|
"RETURN relationshipType"
|
||||||
|
).value("relationshipType")
|
||||||
|
)
|
||||||
|
counts = session.run(
|
||||||
|
"""
|
||||||
|
MATCH (p:SRT2Paper)
|
||||||
|
WITH count(p) AS papers
|
||||||
|
MATCH (c:SRT2CanonicalAssertion)
|
||||||
|
WITH papers, count(c) AS canonical_assertions
|
||||||
|
MATCH (e:SRT2CanonicalEntity)
|
||||||
|
RETURN papers, canonical_assertions,
|
||||||
|
count(e) AS canonical_entities
|
||||||
|
"""
|
||||||
|
).single()
|
||||||
|
relationship_counts = {
|
||||||
|
row["relationship_type"]: int(row["count"])
|
||||||
|
for row in session.run(
|
||||||
|
"""
|
||||||
|
CALL db.relationshipTypes() YIELD relationshipType
|
||||||
|
CALL {
|
||||||
|
WITH relationshipType
|
||||||
|
MATCH ()-[r]->()
|
||||||
|
WHERE type(r) = relationshipType
|
||||||
|
RETURN count(r) AS relation_count
|
||||||
|
}
|
||||||
|
RETURN relationshipType AS relationship_type,
|
||||||
|
relation_count AS count
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
driver.close()
|
||||||
|
|
||||||
|
missing_labels = REQUIRED_NODE_LABELS - labels
|
||||||
|
forbidden_labels = FORBIDDEN_NODE_LABELS & labels
|
||||||
|
missing_relationships = REQUIRED_RELATIONSHIP_TYPES - relationship_types
|
||||||
|
forbidden_relationships = FORBIDDEN_RELATIONSHIP_TYPES & relationship_types
|
||||||
|
if missing_labels or forbidden_labels or missing_relationships or forbidden_relationships:
|
||||||
|
raise BackendIdentityError(
|
||||||
|
"Neo4j schema identity mismatch: "
|
||||||
|
f"missing_labels={sorted(missing_labels)}, "
|
||||||
|
f"forbidden_labels={sorted(forbidden_labels)}, "
|
||||||
|
f"missing_relationships={sorted(missing_relationships)}, "
|
||||||
|
f"forbidden_relationships={sorted(forbidden_relationships)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
papers = int(counts["papers"])
|
||||||
|
canonical_entities = int(counts["canonical_entities"])
|
||||||
|
canonical_assertions = int(counts["canonical_assertions"])
|
||||||
|
canonical_asserts = relationship_counts.get("SRT2_CANONICAL_ASSERTS", 0)
|
||||||
|
supported_by = relationship_counts.get("SRT2_SUPPORTED_BY", 0)
|
||||||
|
expected_identity = (
|
||||||
|
papers == EXPECTED_NEO4J_PAPERS
|
||||||
|
and canonical_entities == EXPECTED_NEO4J_CANONICAL_ENTITIES
|
||||||
|
and canonical_assertions == EXPECTED_NEO4J_CANONICAL_ASSERTIONS
|
||||||
|
and canonical_asserts == EXPECTED_NEO4J_CANONICAL_ASSERTS
|
||||||
|
and supported_by == EXPECTED_NEO4J_SUPPORTED_BY
|
||||||
|
)
|
||||||
|
if not expected_identity:
|
||||||
|
raise BackendIdentityError(
|
||||||
|
"Neo4j store does not match the July 2 direct canonical identity: "
|
||||||
|
f"papers={papers}, canonical_entities={canonical_entities}, "
|
||||||
|
f"canonical_assertions={canonical_assertions}, "
|
||||||
|
f"canonical_asserts={canonical_asserts}, supported_by={supported_by}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"backend_policy": "neo4j_qdrant_only",
|
||||||
|
"graph_schema": graph_schema,
|
||||||
|
"neo4j_uri": neo4j_uri,
|
||||||
|
"neo4j_store": "neo4j_plant_srt_direct_v1_20260702",
|
||||||
|
"neo4j_papers": papers,
|
||||||
|
"neo4j_canonical_entities": canonical_entities,
|
||||||
|
"neo4j_canonical_assertions": canonical_assertions,
|
||||||
|
"neo4j_canonical_asserts": canonical_asserts,
|
||||||
|
"neo4j_supported_by": supported_by,
|
||||||
|
"qdrant_url": qdrant_url,
|
||||||
|
"qdrant_collection": collection,
|
||||||
|
"qdrant_points": qdrant_points,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(json.dumps(validate_backends(), ensure_ascii=False, indent=2))
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, Iterable, List, Tuple
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from neo4j import Query
|
||||||
|
from qdrant_client import QdrantClient
|
||||||
|
from qdrant_client.http import models
|
||||||
|
|
||||||
|
from .direct_flow_retriever import DirectFlowRetriever, _norm, _text
|
||||||
|
from .graph_path_chain import NativeGraphPathRetriever
|
||||||
|
from .schemas import EvidenceItem, RetrievalHypothesis
|
||||||
|
|
||||||
|
|
||||||
|
class _BoundedCanonicalMixin:
|
||||||
|
"""Generate bounded graph candidates from indexed Qdrant payload fields.
|
||||||
|
|
||||||
|
Neo4j is used only to validate exact cass_* assertion IDs. This avoids
|
||||||
|
enumerating and sorting every relationship attached to a high-degree node.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _bounded_init(self) -> None:
|
||||||
|
self._bounded_qdrant = QdrantClient(
|
||||||
|
url=os.environ.get(
|
||||||
|
"V15_QDRANT_URL",
|
||||||
|
os.environ.get("QDRANT_URL", "http://127.0.0.1:6333"),
|
||||||
|
),
|
||||||
|
timeout=float(os.environ.get("QA_V1_BOUNDED_QDRANT_TIMEOUT", "20")),
|
||||||
|
)
|
||||||
|
self._bounded_collection = os.environ.get(
|
||||||
|
"V15_QDRANT_COLLECTION",
|
||||||
|
"plant_srt_direct_v1_bge_m3_20260702",
|
||||||
|
)
|
||||||
|
self._bounded_embed_url = os.environ.get(
|
||||||
|
"QA_V112_EMBED_URL",
|
||||||
|
os.environ.get("QA_V1_EMBED_URL", "http://127.0.0.1:11434/api/embed"),
|
||||||
|
)
|
||||||
|
self._bounded_embed_model = os.environ.get(
|
||||||
|
"QA_V112_EMBED_MODEL",
|
||||||
|
os.environ.get("QA_V1_EMBED_MODEL", "bge-m3:567m"),
|
||||||
|
)
|
||||||
|
self._bounded_max_frontier = max(
|
||||||
|
1, min(int(os.environ.get("QA_V1_BOUNDED_MAX_FRONTIER", "24")), 48)
|
||||||
|
)
|
||||||
|
self._bounded_per_node_cap = max(
|
||||||
|
1, min(int(os.environ.get("QA_V1_BOUNDED_PER_NODE", "8")), 16)
|
||||||
|
)
|
||||||
|
self._bounded_max_hops = max(
|
||||||
|
1, min(int(os.environ.get("QA_V1_BOUNDED_MAX_HOPS", "2")), 3)
|
||||||
|
)
|
||||||
|
self._bounded_neo4j_timeout = max(
|
||||||
|
1.0,
|
||||||
|
min(
|
||||||
|
float(os.environ.get("QA_V1_BOUNDED_NEO4J_TIMEOUT", "5")),
|
||||||
|
20.0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._bounded_question = ""
|
||||||
|
self._bounded_vector: List[float] = []
|
||||||
|
self._bounded_metrics: Dict[str, Any] = {}
|
||||||
|
self._bounded_reset_metrics()
|
||||||
|
|
||||||
|
def _bounded_reset_metrics(self) -> None:
|
||||||
|
self._bounded_metrics = {
|
||||||
|
"backend": "qdrant_payload_index_candidates_plus_neo4j_exact_assertion_validation",
|
||||||
|
"collection": getattr(self, "_bounded_collection", ""),
|
||||||
|
"max_frontier": getattr(self, "_bounded_max_frontier", 0),
|
||||||
|
"per_node_cap": getattr(self, "_bounded_per_node_cap", 0),
|
||||||
|
"max_hops": getattr(self, "_bounded_max_hops", 0),
|
||||||
|
"qdrant_batch_calls": 0,
|
||||||
|
"qdrant_requests": 0,
|
||||||
|
"qdrant_candidates": 0,
|
||||||
|
"neo4j_validation_calls": 0,
|
||||||
|
"neo4j_validated": 0,
|
||||||
|
"qdrant_seconds": 0.0,
|
||||||
|
"neo4j_seconds": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _bounded_prepare_question(self, question: str) -> None:
|
||||||
|
self._bounded_question = _text(question)
|
||||||
|
response = requests.post(
|
||||||
|
self._bounded_embed_url,
|
||||||
|
json={
|
||||||
|
"model": self._bounded_embed_model,
|
||||||
|
"input": self._bounded_question[:2400],
|
||||||
|
},
|
||||||
|
timeout=float(os.environ.get("QA_V1_BOUNDED_EMBED_TIMEOUT", "30")),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
vectors = payload.get("embeddings") or []
|
||||||
|
if vectors:
|
||||||
|
self._bounded_vector = list(vectors[0])
|
||||||
|
elif payload.get("embedding"):
|
||||||
|
self._bounded_vector = list(payload["embedding"])
|
||||||
|
else:
|
||||||
|
raise RuntimeError("bounded graph embedding endpoint returned no vector")
|
||||||
|
|
||||||
|
def _bounded_candidates(
|
||||||
|
self,
|
||||||
|
frontier_ids: Iterable[str],
|
||||||
|
per_node_expand: int,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
ids = []
|
||||||
|
seen = set()
|
||||||
|
for value in frontier_ids:
|
||||||
|
canonical_id = _text(value)
|
||||||
|
if (
|
||||||
|
canonical_id.startswith("cent_")
|
||||||
|
and canonical_id not in seen
|
||||||
|
and len(ids) < self._bounded_max_frontier
|
||||||
|
):
|
||||||
|
ids.append(canonical_id)
|
||||||
|
seen.add(canonical_id)
|
||||||
|
if not ids or not self._bounded_vector:
|
||||||
|
return []
|
||||||
|
|
||||||
|
per_node = max(
|
||||||
|
1,
|
||||||
|
min(
|
||||||
|
int(per_node_expand or self._bounded_per_node_cap),
|
||||||
|
self._bounded_per_node_cap,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
requests_batch = [
|
||||||
|
models.QueryRequest(
|
||||||
|
query=self._bounded_vector,
|
||||||
|
filter=models.Filter(
|
||||||
|
must=[
|
||||||
|
models.FieldCondition(
|
||||||
|
key="source_canonical_id",
|
||||||
|
match=models.MatchValue(value=canonical_id),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
limit=per_node,
|
||||||
|
with_payload=True,
|
||||||
|
with_vector=False,
|
||||||
|
)
|
||||||
|
for canonical_id in ids
|
||||||
|
]
|
||||||
|
|
||||||
|
started = time.perf_counter()
|
||||||
|
responses = self._bounded_qdrant.query_batch_points(
|
||||||
|
collection_name=self._bounded_collection,
|
||||||
|
requests=requests_batch,
|
||||||
|
timeout=int(
|
||||||
|
float(os.environ.get("QA_V1_BOUNDED_QDRANT_TIMEOUT", "20"))
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._bounded_metrics["qdrant_seconds"] += time.perf_counter() - started
|
||||||
|
self._bounded_metrics["qdrant_batch_calls"] += 1
|
||||||
|
self._bounded_metrics["qdrant_requests"] += len(requests_batch)
|
||||||
|
|
||||||
|
candidates: List[Dict[str, Any]] = []
|
||||||
|
for canonical_id, response in zip(ids, responses):
|
||||||
|
for point in response.points:
|
||||||
|
payload = dict(point.payload or {})
|
||||||
|
if _text(payload.get("source_canonical_id")) != canonical_id:
|
||||||
|
continue
|
||||||
|
payload["_bounded_vector_score"] = float(point.score or 0.0)
|
||||||
|
candidates.append(payload)
|
||||||
|
self._bounded_metrics["qdrant_candidates"] += len(candidates)
|
||||||
|
return self._bounded_validate_assertions(candidates)
|
||||||
|
|
||||||
|
def _bounded_validate_assertions(
|
||||||
|
self, candidates: List[Dict[str, Any]]
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
by_id: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for row in candidates:
|
||||||
|
assertion_id = _text(row.get("assertion_id") or row.get("relation_id"))
|
||||||
|
if assertion_id.startswith("cass_") and assertion_id not in by_id:
|
||||||
|
by_id[assertion_id] = row
|
||||||
|
if not by_id:
|
||||||
|
return []
|
||||||
|
|
||||||
|
statement = Query(
|
||||||
|
"""
|
||||||
|
UNWIND $assertion_ids AS assertion_id
|
||||||
|
MATCH (a:SRT2CanonicalAssertion {assertion_id: assertion_id})
|
||||||
|
RETURN a.assertion_id AS assertion_id
|
||||||
|
""",
|
||||||
|
timeout=self._bounded_neo4j_timeout,
|
||||||
|
)
|
||||||
|
started = time.perf_counter()
|
||||||
|
with self.driver.session() as session:
|
||||||
|
validated = {
|
||||||
|
_text(record["assertion_id"])
|
||||||
|
for record in session.run(
|
||||||
|
statement, assertion_ids=list(by_id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
self._bounded_metrics["neo4j_seconds"] += time.perf_counter() - started
|
||||||
|
self._bounded_metrics["neo4j_validation_calls"] += 1
|
||||||
|
self._bounded_metrics["neo4j_validated"] += len(validated)
|
||||||
|
return [by_id[assertion_id] for assertion_id in by_id if assertion_id in validated]
|
||||||
|
|
||||||
|
|
||||||
|
class BoundedCanonicalGraphPathRetriever(
|
||||||
|
_BoundedCanonicalMixin, NativeGraphPathRetriever
|
||||||
|
):
|
||||||
|
"""Native path retriever with bounded, indexed adjacency candidate lookup."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._bounded_init()
|
||||||
|
|
||||||
|
def retrieve_chains(
|
||||||
|
self,
|
||||||
|
question: str,
|
||||||
|
hypothesis: RetrievalHypothesis,
|
||||||
|
seed_ids: Iterable[str],
|
||||||
|
seed_names: Iterable[str],
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
|
||||||
|
self._bounded_reset_metrics()
|
||||||
|
self._bounded_prepare_question(question)
|
||||||
|
kwargs["max_hops"] = min(
|
||||||
|
int(kwargs.get("max_hops") or self._bounded_max_hops),
|
||||||
|
self._bounded_max_hops,
|
||||||
|
)
|
||||||
|
kwargs["beam_width"] = min(
|
||||||
|
int(kwargs.get("beam_width") or self._bounded_max_frontier),
|
||||||
|
self._bounded_max_frontier,
|
||||||
|
)
|
||||||
|
kwargs["per_node_expand"] = min(
|
||||||
|
int(kwargs.get("per_node_expand") or self._bounded_per_node_cap),
|
||||||
|
self._bounded_per_node_cap,
|
||||||
|
)
|
||||||
|
chains, audit = super().retrieve_chains(
|
||||||
|
question,
|
||||||
|
hypothesis,
|
||||||
|
seed_ids,
|
||||||
|
seed_names,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
audit["bounded_graph_backend"] = dict(self._bounded_metrics)
|
||||||
|
return chains, audit
|
||||||
|
|
||||||
|
def _fetch_neighbors(
|
||||||
|
self,
|
||||||
|
frontier_ids: List[str],
|
||||||
|
allowed: set[str],
|
||||||
|
*,
|
||||||
|
relation_clues: List[str],
|
||||||
|
priority_relation_clues: List[str],
|
||||||
|
entity_clues: List[str],
|
||||||
|
answer_type_groups: List[str],
|
||||||
|
per_node_expand: int,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
candidates = self._bounded_candidates(frontier_ids, per_node_expand)
|
||||||
|
rows: List[Dict[str, Any]] = []
|
||||||
|
allowed_norm = {_text(value) for value in allowed}
|
||||||
|
relation_terms = [
|
||||||
|
_norm(value) for value in relation_clues if _norm(value)
|
||||||
|
]
|
||||||
|
priority_terms = [
|
||||||
|
_norm(value) for value in priority_relation_clues if _norm(value)
|
||||||
|
]
|
||||||
|
entity_terms = {_norm(value) for value in entity_clues if _norm(value)}
|
||||||
|
answer_terms = [
|
||||||
|
_norm(value) for value in answer_type_groups if _norm(value)
|
||||||
|
]
|
||||||
|
for payload in candidates:
|
||||||
|
compact = _norm(
|
||||||
|
" ".join(
|
||||||
|
[
|
||||||
|
_text(payload.get("source")),
|
||||||
|
_text(payload.get("relation_norm") or payload.get("relation")),
|
||||||
|
_text(payload.get("target")),
|
||||||
|
_text(payload.get("relation_category")),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
evidence = _norm(
|
||||||
|
payload.get("best_evidence") or payload.get("evidence")
|
||||||
|
)
|
||||||
|
target_norm = _norm(payload.get("target"))
|
||||||
|
relation_score = 0
|
||||||
|
if any(term in compact for term in priority_terms):
|
||||||
|
relation_score = 4
|
||||||
|
elif any(term in compact for term in relation_terms):
|
||||||
|
relation_score = 2
|
||||||
|
elif any(term in evidence for term in relation_terms):
|
||||||
|
relation_score = 1
|
||||||
|
target_type = _norm(payload.get("target_type"))
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"from_id": payload.get("source_canonical_id"),
|
||||||
|
"from_name": payload.get("source"),
|
||||||
|
"to_id": payload.get("target_canonical_id"),
|
||||||
|
"to_name": payload.get("target"),
|
||||||
|
"to_entity_type": payload.get("target_type"),
|
||||||
|
"assertion_id": payload.get("assertion_id"),
|
||||||
|
"relation_category": payload.get("relation_category") or "",
|
||||||
|
"source": payload.get("source"),
|
||||||
|
"source_type": payload.get("source_type"),
|
||||||
|
"source_canonical_id": payload.get("source_canonical_id"),
|
||||||
|
"relation": payload.get("relation_norm")
|
||||||
|
or payload.get("relation"),
|
||||||
|
"target": payload.get("target"),
|
||||||
|
"target_type": payload.get("target_type"),
|
||||||
|
"target_canonical_id": payload.get("target_canonical_id"),
|
||||||
|
"evidence": payload.get("best_evidence")
|
||||||
|
or payload.get("evidence"),
|
||||||
|
"paper_id": payload.get("best_paper_id")
|
||||||
|
or payload.get("paper_id"),
|
||||||
|
"title": payload.get("title") or "",
|
||||||
|
"pmid": payload.get("pmid") or "",
|
||||||
|
"pmcid": payload.get("pmcid") or "",
|
||||||
|
"doi": payload.get("doi") or "",
|
||||||
|
"species": payload.get("species") or "",
|
||||||
|
"support_count": payload.get("support_count") or 0,
|
||||||
|
"paper_count": payload.get("paper_count") or 0,
|
||||||
|
"best_score": payload.get("best_score")
|
||||||
|
or payload.get("quality_score")
|
||||||
|
or 0.0,
|
||||||
|
"entity_clue_score": int(target_norm in entity_terms),
|
||||||
|
"answer_type_score": int(
|
||||||
|
any(term in target_type for term in answer_terms)
|
||||||
|
),
|
||||||
|
"relation_clue_score": relation_score,
|
||||||
|
"category_match": int(
|
||||||
|
not allowed_norm
|
||||||
|
or _text(payload.get("relation_category"))
|
||||||
|
in allowed_norm
|
||||||
|
),
|
||||||
|
"bounded_vector_score": payload.get(
|
||||||
|
"_bounded_vector_score", 0.0
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
class BoundedCanonicalDirectFlowRetriever(
|
||||||
|
_BoundedCanonicalMixin, DirectFlowRetriever
|
||||||
|
):
|
||||||
|
"""Direct retriever whose graph lane cannot scan complete node adjacency."""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs: Any) -> None:
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self._bounded_init()
|
||||||
|
self._bounded_seen_assertions: set[str] = set()
|
||||||
|
|
||||||
|
def retrieve(
|
||||||
|
self,
|
||||||
|
question: str,
|
||||||
|
queries: Dict[str, List[str]],
|
||||||
|
hypothesis: RetrievalHypothesis,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> Tuple[List[EvidenceItem], Dict[str, Any]]:
|
||||||
|
self._bounded_reset_metrics()
|
||||||
|
self._bounded_seen_assertions = set()
|
||||||
|
self._bounded_prepare_question(question)
|
||||||
|
items, audit = super().retrieve(
|
||||||
|
question,
|
||||||
|
queries,
|
||||||
|
hypothesis,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
audit["bounded_graph_backend"] = dict(self._bounded_metrics)
|
||||||
|
return items, audit
|
||||||
|
|
||||||
|
def neo4j_seed_search(
|
||||||
|
self,
|
||||||
|
canonical_ids: List[str],
|
||||||
|
names: List[str],
|
||||||
|
limit: int,
|
||||||
|
*,
|
||||||
|
method: str,
|
||||||
|
) -> List[EvidenceItem]:
|
||||||
|
ids = [
|
||||||
|
value
|
||||||
|
for value in canonical_ids
|
||||||
|
if _text(value).startswith("cent_")
|
||||||
|
][: self._bounded_max_frontier]
|
||||||
|
if not ids and names:
|
||||||
|
statement = Query(
|
||||||
|
"""
|
||||||
|
UNWIND $names AS name
|
||||||
|
MATCH (e:SRT2CanonicalEntity {name_norm: name})
|
||||||
|
RETURN e.canonical_id AS canonical_id
|
||||||
|
LIMIT $limit
|
||||||
|
""",
|
||||||
|
timeout=self._bounded_neo4j_timeout,
|
||||||
|
)
|
||||||
|
with self.driver.session() as session:
|
||||||
|
ids = [
|
||||||
|
_text(record["canonical_id"])
|
||||||
|
for record in session.run(
|
||||||
|
statement,
|
||||||
|
names=[_norm(name) for name in names[:20]],
|
||||||
|
limit=self._bounded_max_frontier,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
candidates = self._bounded_candidates(
|
||||||
|
ids,
|
||||||
|
min(
|
||||||
|
self._bounded_per_node_cap,
|
||||||
|
max(1, int(limit or 1) // max(1, len(ids))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
output: List[EvidenceItem] = []
|
||||||
|
for payload in candidates:
|
||||||
|
assertion_id = _text(
|
||||||
|
payload.get("assertion_id") or payload.get("relation_id")
|
||||||
|
)
|
||||||
|
if assertion_id in self._bounded_seen_assertions:
|
||||||
|
continue
|
||||||
|
self._bounded_seen_assertions.add(assertion_id)
|
||||||
|
item = self.item_from_payload(payload, method, "bounded_canonical")
|
||||||
|
item.vector_score = float(payload.get("_bounded_vector_score") or 0.0)
|
||||||
|
item.rrf_score = max(item.rrf_score, item.vector_score)
|
||||||
|
output.append(item)
|
||||||
|
if len(output) >= min(int(limit or 1), 160):
|
||||||
|
break
|
||||||
|
return output
|
||||||
@@ -14,6 +14,18 @@ _VISIBLE_PUBLIC = re.compile(
|
|||||||
r"\[(PMID|PMCID|DOI)\s*[::]\s*([^\]]+)\]",
|
r"\[(PMID|PMCID|DOI)\s*[::]\s*([^\]]+)\]",
|
||||||
re.I,
|
re.I,
|
||||||
)
|
)
|
||||||
|
_GROUPED_PUBLIC = re.compile(
|
||||||
|
r"\[((?:PMID|PMCID|DOI)\s*[::]\s*[^\]\n]+)\]",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
_GROUPED_PUBLIC_SEPARATOR = re.compile(
|
||||||
|
r"\s*[,;]\s*(?=(?:PMID|PMCID|DOI)\s*[::])",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
_GROUPED_PUBLIC_PART = re.compile(
|
||||||
|
r"^\s*(PMID|PMCID|DOI)\s*[::]\s*(.+?)\s*$",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
_CITATION_LIKE = re.compile(r"\[(PMID|PMCID|DOI)([^\]\n]*)(\]|$)", re.I)
|
_CITATION_LIKE = re.compile(r"\[(PMID|PMCID|DOI)([^\]\n]*)(\]|$)", re.I)
|
||||||
_FULL_WIDTH_PUBLIC_CITATION = re.compile(
|
_FULL_WIDTH_PUBLIC_CITATION = re.compile(
|
||||||
r"【(?:PMID|PMCID|DOI)\b[^】\n]*】",
|
r"【(?:PMID|PMCID|DOI)\b[^】\n]*】",
|
||||||
@@ -68,6 +80,49 @@ def visible_public_citations(answer: str) -> list[str]:
|
|||||||
return citations
|
return citations
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_grouped_public_citations(
|
||||||
|
answer: str,
|
||||||
|
allowed_citations: set[str],
|
||||||
|
) -> tuple[str, list[dict[str, object]]]:
|
||||||
|
"""Split a citation group only when every identifier is already allowlisted."""
|
||||||
|
|
||||||
|
allowed = set(allowed_citations)
|
||||||
|
audit: list[dict[str, object]] = []
|
||||||
|
|
||||||
|
def replace(match: re.Match[str]) -> str:
|
||||||
|
original = match.group(0)
|
||||||
|
parts = _GROUPED_PUBLIC_SEPARATOR.split(match.group(1))
|
||||||
|
if len(parts) < 2:
|
||||||
|
return original
|
||||||
|
|
||||||
|
normalized: list[str] = []
|
||||||
|
for part in parts:
|
||||||
|
parsed = _GROUPED_PUBLIC_PART.fullmatch(part)
|
||||||
|
if not parsed:
|
||||||
|
return original
|
||||||
|
kind, value = parsed.groups()
|
||||||
|
citations = visible_public_citations(
|
||||||
|
f"[{kind.upper()}: {str(value or '').strip()}]"
|
||||||
|
)
|
||||||
|
if len(citations) != 1 or citations[0] not in allowed:
|
||||||
|
return original
|
||||||
|
if citations[0] not in normalized:
|
||||||
|
normalized.append(citations[0])
|
||||||
|
|
||||||
|
replacement = " ".join(normalized)
|
||||||
|
audit.append(
|
||||||
|
{
|
||||||
|
"original": original,
|
||||||
|
"replacement": replacement,
|
||||||
|
"citation_count": len(normalized),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return replacement
|
||||||
|
|
||||||
|
normalized_answer = _GROUPED_PUBLIC.sub(replace, str(answer or ""))
|
||||||
|
return normalized_answer, audit
|
||||||
|
|
||||||
|
|
||||||
def internal_answer_labels(answer: str) -> list[str]:
|
def internal_answer_labels(answer: str) -> list[str]:
|
||||||
text = str(answer or "")
|
text = str(answer or "")
|
||||||
return sorted({match.group(0) for pattern in _INTERNAL_LABELS for match in pattern.finditer(text)})
|
return sorted({match.group(0) for pattern in _INTERNAL_LABELS for match in pattern.finditer(text)})
|
||||||
@@ -83,6 +138,9 @@ def malformed_citation_labels(answer: str) -> list[str]:
|
|||||||
kind = match.group(1).upper()
|
kind = match.group(1).upper()
|
||||||
tail = match.group(2).strip()
|
tail = match.group(2).strip()
|
||||||
closing = match.group(3)
|
closing = match.group(3)
|
||||||
|
if closing != "]":
|
||||||
|
malformed.add(match.group(0))
|
||||||
|
continue
|
||||||
value = re.sub(r"^[::]\s*", "", tail)
|
value = re.sub(r"^[::]\s*", "", tail)
|
||||||
if kind == "PMID":
|
if kind == "PMID":
|
||||||
identifier_shaped = bool(re.fullmatch(r"\d{5,10}", value))
|
identifier_shaped = bool(re.fullmatch(r"\d{5,10}", value))
|
||||||
@@ -91,7 +149,7 @@ def malformed_citation_labels(answer: str) -> list[str]:
|
|||||||
else:
|
else:
|
||||||
identifier_shaped = bool(re.fullmatch(r"10\.\d{4,9}/\S+", value, re.I))
|
identifier_shaped = bool(re.fullmatch(r"10\.\d{4,9}/\S+", value, re.I))
|
||||||
has_colon = bool(re.match(r"^[::]", tail))
|
has_colon = bool(re.match(r"^[::]", tail))
|
||||||
if identifier_shaped and (closing != "]" or not has_colon):
|
if not identifier_shaped or not has_colon:
|
||||||
malformed.add(match.group(0))
|
malformed.add(match.group(0))
|
||||||
return sorted(malformed)
|
return sorted(malformed)
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ class QAConfig:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_env(cls) -> "QAConfig":
|
def from_env(cls) -> "QAConfig":
|
||||||
root = Path(os.environ.get("GENE_FAMILY_ROOT", "/data/gene-family-mas-v2"))
|
root = Path(os.environ.get("GENE_FAMILY_ROOT", "/data/gene-family-mas-v2"))
|
||||||
|
graph_schema = os.environ.get(
|
||||||
|
"QA_V1_GRAPH_SCHEMA", "canonical"
|
||||||
|
).strip().lower()
|
||||||
|
if graph_schema != "canonical":
|
||||||
|
raise RuntimeError(
|
||||||
|
"QA V1.1.73 production only permits the canonical graph schema"
|
||||||
|
)
|
||||||
return cls(
|
return cls(
|
||||||
project_root=root,
|
project_root=root,
|
||||||
gfanalysis_root=Path(
|
gfanalysis_root=Path(
|
||||||
@@ -74,7 +81,7 @@ class QAConfig:
|
|||||||
== "1",
|
== "1",
|
||||||
enable_bge_rerank=os.environ.get("QA_V1_ENABLE_BGE_RERANK", "1")
|
enable_bge_rerank=os.environ.get("QA_V1_ENABLE_BGE_RERANK", "1")
|
||||||
== "1",
|
== "1",
|
||||||
graph_schema=os.environ.get("QA_V1_GRAPH_SCHEMA", "legacy").strip().lower(),
|
graph_schema=graph_schema,
|
||||||
enable_agentic_controller=os.environ.get(
|
enable_agentic_controller=os.environ.get(
|
||||||
"QA_V1_ENABLE_AGENTIC_CONTROLLER", "1"
|
"QA_V1_ENABLE_AGENTIC_CONTROLLER", "1"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ from qdrant_client import QdrantClient
|
|||||||
|
|
||||||
from .schemas import EvidenceItem, RetrievalHypothesis
|
from .schemas import EvidenceItem, RetrievalHypothesis
|
||||||
|
|
||||||
|
ALLOWED_QDRANT_COLLECTIONS = {
|
||||||
|
"plant_srt_direct_v1_bge_m3_20260702",
|
||||||
|
"plant_srt_direct_v1_bge_m3_citations_20260717",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _text(value: Any) -> str:
|
def _text(value: Any) -> str:
|
||||||
return " ".join(str(value or "").split())
|
return " ".join(str(value or "").split())
|
||||||
@@ -57,11 +62,19 @@ class DirectFlowRetriever:
|
|||||||
vector_query_workers: int = 2,
|
vector_query_workers: int = 2,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.graph_schema = (
|
self.graph_schema = (
|
||||||
graph_schema or os.environ.get("QA_V1_GRAPH_SCHEMA", "legacy")
|
graph_schema or os.environ.get("QA_V1_GRAPH_SCHEMA", "canonical")
|
||||||
).strip().lower()
|
).strip().lower()
|
||||||
|
if self.graph_schema != "canonical":
|
||||||
|
raise RuntimeError(
|
||||||
|
"Legacy Entity/RELATION and PCEntity/PC_REL graphs are disabled"
|
||||||
|
)
|
||||||
self.vector_query_workers = max(1, min(int(vector_query_workers), 2))
|
self.vector_query_workers = max(1, min(int(vector_query_workers), 2))
|
||||||
self.qdrant_url = os.environ.get("V15_QDRANT_URL", os.environ.get("QDRANT_URL", "http://127.0.0.1:6333"))
|
self.qdrant_url = os.environ.get("V15_QDRANT_URL", os.environ.get("QDRANT_URL", "http://127.0.0.1:6333"))
|
||||||
self.collection = os.environ.get("V15_QDRANT_COLLECTION", "plant_srt_direct_v1_bge_m3_20260702")
|
self.collection = os.environ.get("V15_QDRANT_COLLECTION", "plant_srt_direct_v1_bge_m3_20260702")
|
||||||
|
if self.collection not in ALLOWED_QDRANT_COLLECTIONS:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Qdrant collection {self.collection!r} is not allowed in production"
|
||||||
|
)
|
||||||
self.embed_url = os.environ.get("QA_V112_EMBED_URL", os.environ.get("QA_V1_EMBED_URL", "http://127.0.0.1:11434/api/embed"))
|
self.embed_url = os.environ.get("QA_V112_EMBED_URL", os.environ.get("QA_V1_EMBED_URL", "http://127.0.0.1:11434/api/embed"))
|
||||||
self.embed_model = os.environ.get("QA_V112_EMBED_MODEL", os.environ.get("QA_V1_EMBED_MODEL", "bge-m3:567m"))
|
self.embed_model = os.environ.get("QA_V112_EMBED_MODEL", os.environ.get("QA_V1_EMBED_MODEL", "bge-m3:567m"))
|
||||||
self.neo4j_uri = os.environ.get("NEO4J_URI", "bolt://127.0.0.1:18788")
|
self.neo4j_uri = os.environ.get("NEO4J_URI", "bolt://127.0.0.1:18788")
|
||||||
@@ -72,12 +85,10 @@ class DirectFlowRetriever:
|
|||||||
load_paper_metadata_registry(Path(registry_path)) if registry_path else {}
|
load_paper_metadata_registry(Path(registry_path)) if registry_path else {}
|
||||||
)
|
)
|
||||||
self.qdrant = QdrantClient(url=self.qdrant_url, timeout=60)
|
self.qdrant = QdrantClient(url=self.qdrant_url, timeout=60)
|
||||||
self.driver = None
|
self.driver = GraphDatabase.driver(
|
||||||
if self.graph_schema == "canonical":
|
self.neo4j_uri,
|
||||||
self.driver = GraphDatabase.driver(
|
auth=(self.neo4j_user, self.neo4j_password),
|
||||||
self.neo4j_uri,
|
)
|
||||||
auth=(self.neo4j_user, self.neo4j_password),
|
|
||||||
)
|
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
if self.driver is not None:
|
if self.driver is not None:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,337 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
import os
|
||||||
|
from typing import Any, Dict, List, Tuple
|
||||||
|
|
||||||
|
from neo4j import GraphDatabase
|
||||||
|
|
||||||
|
from .direct_flow_retriever import DirectFlowRetriever, _text
|
||||||
|
from .schemas import EvidenceItem, RetrievalHypothesis
|
||||||
|
|
||||||
|
|
||||||
|
class FlatTop10SourceRetriever(DirectFlowRetriever):
|
||||||
|
"""Planner plus Qdrant-source seeds, then exact lookup in the flat graph.
|
||||||
|
|
||||||
|
Seed policy is intentionally explicit:
|
||||||
|
- retain graph-seed entities identified by the structured planner;
|
||||||
|
- globally sort Qdrant hits by vector score;
|
||||||
|
- retain the first 10 distinct ``source`` strings;
|
||||||
|
- merge and deduplicate those two seed sets;
|
||||||
|
- match only ``(:Entity {name: seed})``;
|
||||||
|
- never seed from vector targets, canonical IDs, or fuzzy names.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
graph_schema: str | None = None,
|
||||||
|
vector_query_workers: int = 2,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(
|
||||||
|
graph_schema="legacy",
|
||||||
|
vector_query_workers=vector_query_workers,
|
||||||
|
)
|
||||||
|
if self.driver is not None:
|
||||||
|
self.driver.close()
|
||||||
|
self.graph_schema = "flat_top10_source"
|
||||||
|
self.driver = GraphDatabase.driver(
|
||||||
|
self.neo4j_uri,
|
||||||
|
auth=(self.neo4j_user, self.neo4j_password),
|
||||||
|
)
|
||||||
|
self.source_seed_top_k = max(
|
||||||
|
1, min(int(os.environ.get("QA_FLAT_SOURCE_SEED_TOP_K", "10")), 10)
|
||||||
|
)
|
||||||
|
self.graph_hard_limit = max(
|
||||||
|
100,
|
||||||
|
min(int(os.environ.get("QA_FLAT_GRAPH_HARD_LIMIT", "2000")), 5000),
|
||||||
|
)
|
||||||
|
|
||||||
|
def retrieve(
|
||||||
|
self,
|
||||||
|
question: str,
|
||||||
|
queries: Dict[str, List[str]],
|
||||||
|
hypothesis: RetrievalHypothesis,
|
||||||
|
*,
|
||||||
|
qdrant_top_k: int = 120,
|
||||||
|
graph_top_k: int = 80,
|
||||||
|
use_qdrant: bool = True,
|
||||||
|
use_neo4j: bool = True,
|
||||||
|
use_graph_expansion: bool = True,
|
||||||
|
) -> Tuple[List[EvidenceItem], Dict[str, Any]]:
|
||||||
|
audit: Dict[str, Any] = {
|
||||||
|
"policy": (
|
||||||
|
"planner_entities_plus_qdrant_vector_top10_distinct_source_"
|
||||||
|
"to_flat_neo4j_exact_name"
|
||||||
|
),
|
||||||
|
"qdrant": [],
|
||||||
|
"seed_extraction": {},
|
||||||
|
"neo4j": [],
|
||||||
|
"dedup": {},
|
||||||
|
"options": {
|
||||||
|
"qdrant_top_k": qdrant_top_k,
|
||||||
|
"requested_graph_top_k": graph_top_k,
|
||||||
|
"source_seed_top_k": self.source_seed_top_k,
|
||||||
|
"graph_hard_limit": self.graph_hard_limit,
|
||||||
|
"use_qdrant": bool(use_qdrant),
|
||||||
|
"use_neo4j": bool(use_neo4j),
|
||||||
|
"use_graph_expansion": bool(use_graph_expansion),
|
||||||
|
"canonical_ids_used": False,
|
||||||
|
"target_seeds_used": False,
|
||||||
|
"planner_seeds_used": True,
|
||||||
|
"fuzzy_name_matching_used": False,
|
||||||
|
},
|
||||||
|
"neo4j_status": "enabled_flat_exact_source" if use_neo4j else "disabled_by_request",
|
||||||
|
}
|
||||||
|
|
||||||
|
all_queries = self._flatten_queries(question, queries)
|
||||||
|
qdrant_items: List[EvidenceItem] = []
|
||||||
|
if use_qdrant:
|
||||||
|
per_query = max(10, min(qdrant_top_k // max(1, len(all_queries)), 50))
|
||||||
|
if self.vector_query_workers == 1 or len(all_queries) == 1:
|
||||||
|
query_rows = [
|
||||||
|
(query, self.qdrant_search(query, per_query))
|
||||||
|
for query in all_queries
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
with ThreadPoolExecutor(
|
||||||
|
max_workers=min(self.vector_query_workers, len(all_queries)),
|
||||||
|
thread_name_prefix="qa-flat-vector",
|
||||||
|
) as executor:
|
||||||
|
futures = [
|
||||||
|
executor.submit(self.qdrant_search, query, per_query)
|
||||||
|
for query in all_queries
|
||||||
|
]
|
||||||
|
query_rows = [
|
||||||
|
(query, future.result())
|
||||||
|
for query, future in zip(all_queries, futures)
|
||||||
|
]
|
||||||
|
for query, rows in query_rows:
|
||||||
|
audit["qdrant"].append({"query": query, "rows": len(rows)})
|
||||||
|
qdrant_items.extend(rows)
|
||||||
|
|
||||||
|
vector_sources = self._top_vector_sources(qdrant_items)
|
||||||
|
planner_sources = self._planner_sources(hypothesis, vector_sources)
|
||||||
|
sources = self._merge_graph_sources(planner_sources, vector_sources)
|
||||||
|
audit["seed_extraction"] = {
|
||||||
|
"seed_basis": (
|
||||||
|
"structured_planner_entities_union_"
|
||||||
|
"raw_qdrant_vector_top10_distinct_source"
|
||||||
|
),
|
||||||
|
"planner_seed_count": len(planner_sources),
|
||||||
|
"planner_seeds": planner_sources,
|
||||||
|
"vector_top10_source_seed_count": len(vector_sources),
|
||||||
|
"vector_top10_source_seeds": vector_sources,
|
||||||
|
"final_graph_seed_count": len(sources),
|
||||||
|
"final_graph_seeds": sources,
|
||||||
|
}
|
||||||
|
|
||||||
|
graph_items: List[EvidenceItem] = []
|
||||||
|
graph_audit: Dict[str, Any] = {
|
||||||
|
"requested_source_count": len(sources),
|
||||||
|
"matched_source_count": 0,
|
||||||
|
"unmatched_sources": [item["source"] for item in sources],
|
||||||
|
"rows": 0,
|
||||||
|
}
|
||||||
|
if use_neo4j and sources:
|
||||||
|
graph_items, graph_audit = self.flat_source_search(sources)
|
||||||
|
audit["neo4j"].append(graph_audit)
|
||||||
|
|
||||||
|
merged = self.dedup(qdrant_items + graph_items)
|
||||||
|
audit["dedup"] = {
|
||||||
|
"qdrant_items": len(qdrant_items),
|
||||||
|
"graph_items": len(graph_items),
|
||||||
|
"merged_items": len(merged),
|
||||||
|
}
|
||||||
|
return merged, audit
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _planner_sources(
|
||||||
|
hypothesis: RetrievalHypothesis,
|
||||||
|
vector_sources: List[Dict[str, Any]],
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
output: List[Dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
vector_ceiling = max(
|
||||||
|
(float(item.get("vector_score") or 0.0) for item in vector_sources),
|
||||||
|
default=0.7,
|
||||||
|
)
|
||||||
|
entities = list(hypothesis.direct_question_entities or [])
|
||||||
|
entities.extend(
|
||||||
|
entity
|
||||||
|
for entity in (hypothesis.hypothesized_entities or [])
|
||||||
|
if bool(entity.use_as_graph_seed)
|
||||||
|
)
|
||||||
|
for entity in entities:
|
||||||
|
matched = _text(entity.matched_kb_entity)
|
||||||
|
if matched.startswith(("cent_", "cass_", "asrt_", "ent_")):
|
||||||
|
matched = ""
|
||||||
|
seed = matched or _text(entity.canonical_candidate) or _text(entity.mention)
|
||||||
|
if not seed or seed in seen:
|
||||||
|
continue
|
||||||
|
seen.add(seed)
|
||||||
|
output.append(
|
||||||
|
{
|
||||||
|
"rank": len(output) + 1,
|
||||||
|
"source": seed,
|
||||||
|
"source_type": _text(entity.entity_type),
|
||||||
|
"vector_score": vector_ceiling,
|
||||||
|
"seed_origin": "planner",
|
||||||
|
"planner_mention": _text(entity.mention),
|
||||||
|
"planner_canonical_candidate": _text(entity.canonical_candidate),
|
||||||
|
"planner_confidence": float(entity.confidence or 0.0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return output
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _merge_graph_sources(
|
||||||
|
planner_sources: List[Dict[str, Any]],
|
||||||
|
vector_sources: List[Dict[str, Any]],
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
output: List[Dict[str, Any]] = []
|
||||||
|
by_source: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for item in planner_sources + vector_sources:
|
||||||
|
source = _text(item.get("source"))
|
||||||
|
if not source:
|
||||||
|
continue
|
||||||
|
if source in by_source:
|
||||||
|
origins = by_source[source].setdefault("seed_origins", [])
|
||||||
|
origin = _text(item.get("seed_origin") or "vector_top10_source")
|
||||||
|
if origin not in origins:
|
||||||
|
origins.append(origin)
|
||||||
|
continue
|
||||||
|
record = dict(item)
|
||||||
|
origin = _text(record.get("seed_origin") or "vector_top10_source")
|
||||||
|
record["seed_origin"] = origin
|
||||||
|
record["seed_origins"] = [origin]
|
||||||
|
record["final_rank"] = len(output) + 1
|
||||||
|
by_source[source] = record
|
||||||
|
output.append(record)
|
||||||
|
return output
|
||||||
|
|
||||||
|
def _top_vector_sources(
|
||||||
|
self, items: List[EvidenceItem]
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
output: List[Dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for item in sorted(
|
||||||
|
items,
|
||||||
|
key=lambda value: float(value.vector_score or 0.0),
|
||||||
|
reverse=True,
|
||||||
|
):
|
||||||
|
source = _text(item.source)
|
||||||
|
if not source or source in seen:
|
||||||
|
continue
|
||||||
|
seen.add(source)
|
||||||
|
output.append(
|
||||||
|
{
|
||||||
|
"rank": len(output) + 1,
|
||||||
|
"source": source,
|
||||||
|
"source_type": _text((item.raw or {}).get("source_type")),
|
||||||
|
"vector_score": float(item.vector_score or 0.0),
|
||||||
|
"assertion_id": _text(item.relation_id),
|
||||||
|
"relation": _text(item.relation),
|
||||||
|
"target": _text(item.target),
|
||||||
|
"seed_origin": "vector_top10_source",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if len(output) >= self.source_seed_top_k:
|
||||||
|
break
|
||||||
|
return output
|
||||||
|
|
||||||
|
def flat_source_search(
|
||||||
|
self, sources: List[Dict[str, Any]]
|
||||||
|
) -> Tuple[List[EvidenceItem], Dict[str, Any]]:
|
||||||
|
statement = """
|
||||||
|
UNWIND $seeds AS seed
|
||||||
|
MATCH (s:Entity {name: seed.source})-[r:RELATION]->(t:Entity)
|
||||||
|
RETURN seed.rank AS seed_rank,
|
||||||
|
seed.source AS requested_source,
|
||||||
|
seed.vector_score AS seed_vector_score,
|
||||||
|
seed.seed_origin AS seed_origin,
|
||||||
|
s.entity_id AS source_entity_id,
|
||||||
|
s.name AS source,
|
||||||
|
s.entity_type AS source_type,
|
||||||
|
r.relation_id AS relation_id,
|
||||||
|
coalesce(r.relation_norm, r.relation) AS relation,
|
||||||
|
t.entity_id AS target_entity_id,
|
||||||
|
t.name AS target,
|
||||||
|
t.entity_type AS target_type,
|
||||||
|
r.evidence AS evidence,
|
||||||
|
r.paper_id AS paper_id,
|
||||||
|
r.pubmed_id AS pmid,
|
||||||
|
r.section AS section,
|
||||||
|
r.output_layer AS output_layer,
|
||||||
|
r.claim_scope AS claim_scope,
|
||||||
|
r.candidate_status AS candidate_status,
|
||||||
|
r.triple_quality AS triple_quality,
|
||||||
|
r.confidence AS confidence,
|
||||||
|
r.source_label AS source_label
|
||||||
|
LIMIT $hard_limit
|
||||||
|
"""
|
||||||
|
with self.driver.session() as session:
|
||||||
|
rows = [
|
||||||
|
dict(record)
|
||||||
|
for record in session.run(
|
||||||
|
statement,
|
||||||
|
seeds=sources,
|
||||||
|
hard_limit=self.graph_hard_limit,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
matched = {
|
||||||
|
_text(row.get("requested_source"))
|
||||||
|
for row in rows
|
||||||
|
if _text(row.get("requested_source"))
|
||||||
|
}
|
||||||
|
output: List[EvidenceItem] = []
|
||||||
|
for row in rows:
|
||||||
|
payload = {
|
||||||
|
"relation_id": row.get("relation_id"),
|
||||||
|
"assertion_id": row.get("relation_id"),
|
||||||
|
"source": row.get("source"),
|
||||||
|
"source_type": row.get("source_type"),
|
||||||
|
"source_entity_id": row.get("source_entity_id"),
|
||||||
|
"relation": row.get("relation"),
|
||||||
|
"relation_norm": row.get("relation"),
|
||||||
|
"target": row.get("target"),
|
||||||
|
"target_type": row.get("target_type"),
|
||||||
|
"target_entity_id": row.get("target_entity_id"),
|
||||||
|
"evidence": row.get("evidence"),
|
||||||
|
"paper_id": row.get("paper_id"),
|
||||||
|
"pmid": row.get("pmid"),
|
||||||
|
"section": row.get("section"),
|
||||||
|
"output_layer": row.get("output_layer"),
|
||||||
|
"claim_scope": row.get("claim_scope"),
|
||||||
|
"candidate_status": row.get("candidate_status"),
|
||||||
|
"triple_quality": row.get("triple_quality"),
|
||||||
|
"confidence": row.get("confidence"),
|
||||||
|
"source_label": row.get("source_label"),
|
||||||
|
}
|
||||||
|
item = self.item_from_payload(
|
||||||
|
payload,
|
||||||
|
"neo4j_flat_exact_source",
|
||||||
|
_text(row.get("requested_source")),
|
||||||
|
)
|
||||||
|
item.raw["graph_seed_origin"] = _text(row.get("seed_origin"))
|
||||||
|
seed_score = float(row.get("seed_vector_score") or 0.0)
|
||||||
|
item.vector_score = seed_score
|
||||||
|
item.rrf_score = max(item.rrf_score, seed_score * 0.98)
|
||||||
|
output.append(item)
|
||||||
|
|
||||||
|
requested = [_text(item.get("source")) for item in sources]
|
||||||
|
return output, {
|
||||||
|
"method": "neo4j_flat_exact_source",
|
||||||
|
"query_shape": (
|
||||||
|
"UNWIND planner + vector top10 source seeds -> "
|
||||||
|
"Entity{name} index seek -> outgoing RELATION"
|
||||||
|
),
|
||||||
|
"requested_source_count": len(requested),
|
||||||
|
"matched_source_count": len(matched),
|
||||||
|
"matched_sources": [name for name in requested if name in matched],
|
||||||
|
"unmatched_sources": [name for name in requested if name not in matched],
|
||||||
|
"rows": len(rows),
|
||||||
|
"hard_limit": self.graph_hard_limit,
|
||||||
|
"truncated": len(rows) >= self.graph_hard_limit,
|
||||||
|
}
|
||||||
@@ -1,13 +1,27 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LLMResponseError(RuntimeError):
|
||||||
|
"""Raised only after all bounded upstream recovery attempts are exhausted."""
|
||||||
|
|
||||||
|
|
||||||
|
class LLMNonRetryableError(LLMResponseError):
|
||||||
|
"""Raised immediately for permanent upstream HTTP failures."""
|
||||||
|
|
||||||
|
|
||||||
class LLMClient:
|
class LLMClient:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.api_key = (
|
self.api_key = (
|
||||||
@@ -27,6 +41,19 @@ class LLMClient:
|
|||||||
or "deepseek-v4-flash"
|
or "deepseek-v4-flash"
|
||||||
)
|
)
|
||||||
self.timeout = float(os.environ.get("QA_V1_LLM_TIMEOUT", "120"))
|
self.timeout = float(os.environ.get("QA_V1_LLM_TIMEOUT", "120"))
|
||||||
|
self.max_attempts = max(
|
||||||
|
1, min(int(os.environ.get("QA_V1_LLM_MAX_ATTEMPTS", "4")), 8)
|
||||||
|
)
|
||||||
|
self.retry_base_seconds = max(
|
||||||
|
0.0, float(os.environ.get("QA_V1_LLM_RETRY_BASE_SECONDS", "1.5"))
|
||||||
|
)
|
||||||
|
self.retry_max_seconds = max(
|
||||||
|
self.retry_base_seconds,
|
||||||
|
float(os.environ.get("QA_V1_LLM_RETRY_MAX_SECONDS", "12")),
|
||||||
|
)
|
||||||
|
self.json_attempts = max(
|
||||||
|
1, min(int(os.environ.get("QA_V1_LLM_JSON_ATTEMPTS", "2")), 4)
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
@@ -39,31 +66,131 @@ class LLMClient:
|
|||||||
*,
|
*,
|
||||||
temperature: float = 0.1,
|
temperature: float = 0.1,
|
||||||
max_tokens: int = 2000,
|
max_tokens: int = 2000,
|
||||||
|
json_mode: bool = False,
|
||||||
|
request_attempts: int | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
if not self.api_key:
|
if not self.api_key:
|
||||||
raise RuntimeError("QA V1 LLM API key is not configured")
|
raise RuntimeError("QA V1 LLM API key is not configured")
|
||||||
response = requests.post(
|
request_body = {
|
||||||
f"{self.base_url}/chat/completions",
|
"model": self.model,
|
||||||
headers={
|
"messages": [
|
||||||
"Authorization": f"Bearer {self.api_key}",
|
{"role": "system", "content": system},
|
||||||
"Content-Type": "application/json",
|
{"role": "user", "content": user},
|
||||||
},
|
],
|
||||||
json={
|
"temperature": temperature,
|
||||||
"model": self.model,
|
"max_tokens": max_tokens,
|
||||||
"messages": [
|
"stream": False,
|
||||||
{"role": "system", "content": system},
|
}
|
||||||
{"role": "user", "content": user},
|
if json_mode:
|
||||||
],
|
request_body["response_format"] = {"type": "json_object"}
|
||||||
"temperature": temperature,
|
attempt_limit = self.max_attempts
|
||||||
"max_tokens": max_tokens,
|
if request_attempts is not None:
|
||||||
"stream": False,
|
attempt_limit = max(1, min(int(request_attempts), self.max_attempts))
|
||||||
},
|
last_error = "unknown upstream failure"
|
||||||
timeout=self.timeout,
|
for attempt in range(1, attempt_limit + 1):
|
||||||
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{self.base_url}/chat/completions",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json=request_body,
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except Exception as exc:
|
||||||
|
error_type = (
|
||||||
|
LLMNonRetryableError
|
||||||
|
if response.status_code >= 400
|
||||||
|
and response.status_code not in {
|
||||||
|
408,
|
||||||
|
409,
|
||||||
|
425,
|
||||||
|
429,
|
||||||
|
500,
|
||||||
|
502,
|
||||||
|
503,
|
||||||
|
504,
|
||||||
|
}
|
||||||
|
else LLMResponseError
|
||||||
|
)
|
||||||
|
raise error_type(
|
||||||
|
f"LLM HTTP {response.status_code} returned non-JSON content"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
last_error = f"LLM HTTP {response.status_code}: {payload}"
|
||||||
|
if response.status_code not in {
|
||||||
|
408,
|
||||||
|
409,
|
||||||
|
425,
|
||||||
|
429,
|
||||||
|
500,
|
||||||
|
502,
|
||||||
|
503,
|
||||||
|
504,
|
||||||
|
}:
|
||||||
|
raise LLMNonRetryableError(last_error)
|
||||||
|
raise requests.HTTPError(last_error, response=response)
|
||||||
|
|
||||||
|
choices = payload.get("choices") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(choices, list) or not choices:
|
||||||
|
last_error = "LLM response contains no choices"
|
||||||
|
raise LLMResponseError(last_error)
|
||||||
|
choice = choices[0] if isinstance(choices[0], dict) else {}
|
||||||
|
message = choice.get("message") if isinstance(choice, dict) else {}
|
||||||
|
content = self._message_text(message)
|
||||||
|
finish_reason = str(choice.get("finish_reason") or "")
|
||||||
|
if finish_reason.strip().lower() in {"length", "max_tokens"}:
|
||||||
|
last_error = (
|
||||||
|
"LLM output was truncated by the token limit; "
|
||||||
|
f"content_chars={len(content)}"
|
||||||
|
)
|
||||||
|
raise LLMResponseError(last_error)
|
||||||
|
if content:
|
||||||
|
if attempt > 1:
|
||||||
|
LOGGER.warning(
|
||||||
|
"LLM call recovered on attempt %s/%s after %.2fs; "
|
||||||
|
"finish_reason=%s content_chars=%s",
|
||||||
|
attempt,
|
||||||
|
attempt_limit,
|
||||||
|
time.perf_counter() - started,
|
||||||
|
finish_reason,
|
||||||
|
len(content),
|
||||||
|
)
|
||||||
|
return content
|
||||||
|
last_error = (
|
||||||
|
"LLM returned empty message.content; "
|
||||||
|
f"finish_reason={finish_reason or '<missing>'}; "
|
||||||
|
f"has_reasoning_content={bool((message or {}).get('reasoning_content'))}"
|
||||||
|
)
|
||||||
|
raise LLMResponseError(last_error)
|
||||||
|
except LLMNonRetryableError as exc:
|
||||||
|
last_error = f"{type(exc).__name__}: {exc}"
|
||||||
|
LOGGER.warning(
|
||||||
|
"LLM call failed without retry after %.2fs: %s",
|
||||||
|
time.perf_counter() - started,
|
||||||
|
last_error[:800],
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
last_error = f"{type(exc).__name__}: {exc}"
|
||||||
|
LOGGER.warning(
|
||||||
|
"LLM call attempt %s/%s failed after %.2fs: %s",
|
||||||
|
attempt,
|
||||||
|
attempt_limit,
|
||||||
|
time.perf_counter() - started,
|
||||||
|
last_error[:800],
|
||||||
|
)
|
||||||
|
if attempt >= attempt_limit:
|
||||||
|
break
|
||||||
|
self._sleep_before_retry(attempt)
|
||||||
|
raise LLMResponseError(
|
||||||
|
f"LLM failed after {attempt_limit} attempts: {last_error}"
|
||||||
)
|
)
|
||||||
payload = response.json()
|
|
||||||
if response.status_code >= 400:
|
|
||||||
raise RuntimeError(f"LLM HTTP {response.status_code}: {payload}")
|
|
||||||
return str(payload["choices"][0]["message"]["content"]).strip()
|
|
||||||
|
|
||||||
def complete_json(
|
def complete_json(
|
||||||
self,
|
self,
|
||||||
@@ -71,21 +198,68 @@ class LLMClient:
|
|||||||
user: str,
|
user: str,
|
||||||
*,
|
*,
|
||||||
max_tokens: int = 2500,
|
max_tokens: int = 2500,
|
||||||
|
request_attempts: int | None = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
text = self.complete(
|
text = ""
|
||||||
system + "\nReturn valid JSON only. Do not use Markdown fences.",
|
for json_attempt in range(1, self.json_attempts + 1):
|
||||||
user,
|
repair_note = ""
|
||||||
temperature=0.0,
|
if json_attempt > 1:
|
||||||
max_tokens=max_tokens,
|
repair_note = (
|
||||||
)
|
"\nA previous response was not a complete JSON object. Return a "
|
||||||
parsed = self._parse_json_object(text)
|
"single compact JSON object with every object and array closed."
|
||||||
if isinstance(parsed, dict):
|
)
|
||||||
return parsed
|
text = self.complete(
|
||||||
|
system
|
||||||
|
+ "\nReturn valid JSON only. Do not use Markdown fences."
|
||||||
|
+ repair_note,
|
||||||
|
user,
|
||||||
|
temperature=0.0,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
json_mode=True,
|
||||||
|
request_attempts=request_attempts,
|
||||||
|
)
|
||||||
|
parsed = self._parse_json_object(text)
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
return parsed
|
||||||
|
LOGGER.warning(
|
||||||
|
"LLM JSON parse attempt %s/%s failed; raw_chars=%s",
|
||||||
|
json_attempt,
|
||||||
|
self.json_attempts,
|
||||||
|
len(text),
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"_json_parse_error": "LLM returned JSON that could not be parsed",
|
"_json_parse_error": "LLM returned JSON that could not be parsed",
|
||||||
"_raw_text_head": text[:1200],
|
"_raw_text_head": text[:1200],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _message_text(message: Any) -> str:
|
||||||
|
if not isinstance(message, dict):
|
||||||
|
return ""
|
||||||
|
content = message.get("content")
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content.strip()
|
||||||
|
if isinstance(content, list):
|
||||||
|
parts: List[str] = []
|
||||||
|
for block in content:
|
||||||
|
if isinstance(block, str):
|
||||||
|
parts.append(block)
|
||||||
|
elif isinstance(block, dict):
|
||||||
|
value = block.get("text") or block.get("content")
|
||||||
|
if isinstance(value, str):
|
||||||
|
parts.append(value)
|
||||||
|
return "\n".join(part.strip() for part in parts if part.strip()).strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _sleep_before_retry(self, attempt: int) -> None:
|
||||||
|
delay = min(
|
||||||
|
self.retry_max_seconds,
|
||||||
|
self.retry_base_seconds * (2 ** max(0, attempt - 1)),
|
||||||
|
)
|
||||||
|
if delay <= 0:
|
||||||
|
return
|
||||||
|
time.sleep(delay + random.uniform(0.0, min(0.5, delay * 0.2)))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_json_object(text: str) -> Dict[str, Any] | None:
|
def _parse_json_object(text: str) -> Dict[str, Any] | None:
|
||||||
candidates: List[str] = []
|
candidates: List[str] = []
|
||||||
|
|||||||
+1337
-216
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,8 @@ import re
|
|||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from typing import Any, Dict, Iterable, List, Optional
|
from typing import Any, Dict, Iterable, List, Optional
|
||||||
|
|
||||||
|
from .agentic_controller import AgenticOutputError
|
||||||
|
|
||||||
|
|
||||||
PROMPT_VERSION = "question_decomposition_v1"
|
PROMPT_VERSION = "question_decomposition_v1"
|
||||||
MAX_SUBQUESTIONS = 6
|
MAX_SUBQUESTIONS = 6
|
||||||
@@ -68,6 +70,13 @@ Decomposition rules
|
|||||||
caller-supplied verified entities/relation intents. For a simple "how does X
|
caller-supplied verified entities/relation intents. For a simple "how does X
|
||||||
help under Y" question, prefer Q0 plus one broad mechanism slot; retrieval,
|
help under Y" question, prefer Q0 plus one broad mechanism slot; retrieval,
|
||||||
not decomposition, must discover the individual mechanisms.
|
not decomposition, must discover the individual mechanisms.
|
||||||
|
12. An endpoint-constrained connection question such as "what connects X with
|
||||||
|
Y, including intermediate entities" asks for a graph path, not necessarily a
|
||||||
|
molecular mechanism. Classify it as other unless the question explicitly
|
||||||
|
requests a molecular or physiological mechanism. Prefer Q0 alone because it
|
||||||
|
already contains both endpoints and the requested path; add a supporting
|
||||||
|
subquestion only when the original question contains a genuinely distinct
|
||||||
|
answer slot.
|
||||||
|
|
||||||
Fixed execution contract (the runtime will enforce these values)
|
Fixed execution contract (the runtime will enforce these values)
|
||||||
- retrieval.vector_top_k: 30
|
- retrieval.vector_top_k: 30
|
||||||
@@ -206,17 +215,58 @@ class QuestionDecompositionPlanner:
|
|||||||
raw = self.llm.complete_json(
|
raw = self.llm.complete_json(
|
||||||
QUESTION_DECOMPOSITION_PROMPT,
|
QUESTION_DECOMPOSITION_PROMPT,
|
||||||
json.dumps(payload, ensure_ascii=False),
|
json.dumps(payload, ensure_ascii=False),
|
||||||
max_tokens=5000,
|
max_tokens=16000,
|
||||||
)
|
)
|
||||||
|
json_repair_used = False
|
||||||
if not isinstance(raw, dict) or raw.get("_json_parse_error"):
|
if not isinstance(raw, dict) or raw.get("_json_parse_error"):
|
||||||
raise ValueError("question decomposition is not valid JSON")
|
json_repair_used = True
|
||||||
|
raw_head = (
|
||||||
|
_text(raw.get("_raw_text_head"))
|
||||||
|
if isinstance(raw, dict)
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
repair_payload = {
|
||||||
|
**payload,
|
||||||
|
"format_repair": (
|
||||||
|
"The previous DECOMPOSE response was empty, incomplete, or "
|
||||||
|
"malformed. Return one complete compact JSON object matching "
|
||||||
|
"the required decomposition schema."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
raw = self.llm.complete_json(
|
||||||
|
QUESTION_DECOMPOSITION_PROMPT
|
||||||
|
+ "\nThis is a single format-repair retry. Close every object and "
|
||||||
|
"array. Return the compact DECOMPOSE JSON object only.",
|
||||||
|
json.dumps(repair_payload, ensure_ascii=False),
|
||||||
|
max_tokens=16000,
|
||||||
|
)
|
||||||
|
if not isinstance(raw, dict) or raw.get("_json_parse_error"):
|
||||||
|
raw_head = (
|
||||||
|
_text(raw.get("_raw_text_head"))
|
||||||
|
if isinstance(raw, dict)
|
||||||
|
else raw_head
|
||||||
|
)
|
||||||
|
raise ValueError(
|
||||||
|
"question decomposition is not valid JSON"
|
||||||
|
+ (f"; raw_head={raw_head[:300]!r}" if raw_head else "; raw_head=<empty>")
|
||||||
|
)
|
||||||
plan = self._sanitize(raw, fallback, question, entities, relations)
|
plan = self._sanitize(raw, fallback, question, entities, relations)
|
||||||
plan["plan_source"] = "llm_agent"
|
plan["plan_source"] = (
|
||||||
|
"llm_agent_json_repair" if json_repair_used else "llm_agent"
|
||||||
|
)
|
||||||
|
plan["plan_json_attempts"] = 2 if json_repair_used else 1
|
||||||
return plan
|
return plan
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
fallback["plan_source"] = "deterministic_fallback"
|
recovered = deepcopy(fallback)
|
||||||
fallback["fallback_reason"] = f"{type(exc).__name__}: {exc}"[:500]
|
recovered["plan_source"] = (
|
||||||
return fallback
|
"deterministic_recovery_after_llm_decomposition_failure"
|
||||||
|
)
|
||||||
|
recovered["plan_json_attempts"] = 0
|
||||||
|
recovered["recovery_error"] = (
|
||||||
|
f"{type(exc).__name__}: {exc}"
|
||||||
|
)[:1500]
|
||||||
|
recovered["recovery_degraded"] = True
|
||||||
|
return recovered
|
||||||
|
|
||||||
def _sanitize(
|
def _sanitize(
|
||||||
self,
|
self,
|
||||||
@@ -281,17 +331,6 @@ class QuestionDecompositionPlanner:
|
|||||||
break
|
break
|
||||||
if not items:
|
if not items:
|
||||||
raise ValueError("decomposition contains no usable subquestions")
|
raise ValueError("decomposition contains no usable subquestions")
|
||||||
if question_type == "mechanism" and len(items) == 1:
|
|
||||||
safe_support = self._safe_mechanism_support(
|
|
||||||
verified_entities, allowed_relation_intents
|
|
||||||
)
|
|
||||||
item = self._sanitize_subquestion(
|
|
||||||
safe_support, 1, safe_support["question"]
|
|
||||||
)
|
|
||||||
item["anchors"] = self._implicit_verified_anchors(
|
|
||||||
item["question"], verified_entities
|
|
||||||
)
|
|
||||||
items.append(item)
|
|
||||||
|
|
||||||
plan = deepcopy(fallback)
|
plan = deepcopy(fallback)
|
||||||
plan.update(
|
plan.update(
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any, Dict, List, Tuple
|
from typing import Any, Dict, List, Tuple
|
||||||
|
|
||||||
from .pc_retriever import PCIndependentRetriever
|
|
||||||
from .schemas import EvidenceItem
|
from .schemas import EvidenceItem
|
||||||
from .v16_retriever import V16IndependentRetriever
|
from .v16_retriever import V16IndependentRetriever
|
||||||
|
|
||||||
@@ -13,8 +12,8 @@ def _text(value: Any) -> str:
|
|||||||
|
|
||||||
DEFAULT_RETRIEVAL_OPTIONS: Dict[str, bool] = {
|
DEFAULT_RETRIEVAL_OPTIONS: Dict[str, bool] = {
|
||||||
"use_v15": True,
|
"use_v15": True,
|
||||||
"use_pc": True,
|
"use_pc": False,
|
||||||
"use_sqlite": True,
|
"use_sqlite": False,
|
||||||
"use_qdrant": True,
|
"use_qdrant": True,
|
||||||
"use_neo4j": True,
|
"use_neo4j": True,
|
||||||
"use_graph_expansion": True,
|
"use_graph_expansion": True,
|
||||||
@@ -24,6 +23,10 @@ DEFAULT_RETRIEVAL_OPTIONS: Dict[str, bool] = {
|
|||||||
def normalize_retrieval_options(options: Dict[str, Any] | None) -> Dict[str, bool]:
|
def normalize_retrieval_options(options: Dict[str, Any] | None) -> Dict[str, bool]:
|
||||||
normalized = dict(DEFAULT_RETRIEVAL_OPTIONS)
|
normalized = dict(DEFAULT_RETRIEVAL_OPTIONS)
|
||||||
if isinstance(options, dict):
|
if isinstance(options, dict):
|
||||||
|
if options.get("use_pc") or options.get("use_sqlite"):
|
||||||
|
raise ValueError(
|
||||||
|
"PC and SQLite retrieval backends are disabled; use Neo4j and Qdrant"
|
||||||
|
)
|
||||||
for key in list(normalized):
|
for key in list(normalized):
|
||||||
if key in options:
|
if key in options:
|
||||||
normalized[key] = bool(options[key])
|
normalized[key] = bool(options[key])
|
||||||
@@ -38,7 +41,6 @@ class DualKBRetriever:
|
|||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.v15 = V16IndependentRetriever()
|
self.v15 = V16IndependentRetriever()
|
||||||
self.pc = PCIndependentRetriever()
|
|
||||||
|
|
||||||
def retrieve_queries(
|
def retrieve_queries(
|
||||||
self,
|
self,
|
||||||
@@ -66,10 +68,6 @@ class DualKBRetriever:
|
|||||||
options,
|
options,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if options["use_pc"]:
|
|
||||||
output.extend(
|
|
||||||
self._retrieve_pc(query, lane, top_k_per_query, graph_hops, options)
|
|
||||||
)
|
|
||||||
audit.append(
|
audit.append(
|
||||||
{
|
{
|
||||||
"lane": lane,
|
"lane": lane,
|
||||||
@@ -96,7 +94,7 @@ class DualKBRetriever:
|
|||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
max_hops=graph_hops,
|
max_hops=graph_hops,
|
||||||
include_candidate=include_candidate,
|
include_candidate=include_candidate,
|
||||||
use_sqlite=options["use_sqlite"],
|
use_sqlite=False,
|
||||||
use_qdrant=options["use_qdrant"],
|
use_qdrant=options["use_qdrant"],
|
||||||
use_neo4j=options["use_neo4j"],
|
use_neo4j=options["use_neo4j"],
|
||||||
use_graph_expansion=options["use_graph_expansion"],
|
use_graph_expansion=options["use_graph_expansion"],
|
||||||
@@ -108,27 +106,6 @@ class DualKBRetriever:
|
|||||||
items.append(self._from_row(row, "V16/V15", f"{lane}:{method}", query))
|
items.append(self._from_row(row, "V16/V15", f"{lane}:{method}", query))
|
||||||
return items
|
return items
|
||||||
|
|
||||||
def _retrieve_pc(
|
|
||||||
self,
|
|
||||||
query: str,
|
|
||||||
lane: str,
|
|
||||||
top_k: int,
|
|
||||||
graph_hops: int,
|
|
||||||
options: Dict[str, bool],
|
|
||||||
) -> List[EvidenceItem]:
|
|
||||||
try:
|
|
||||||
rows = self.pc.search(
|
|
||||||
query,
|
|
||||||
top_k=top_k,
|
|
||||||
max_hops=graph_hops,
|
|
||||||
use_qdrant=options["use_qdrant"],
|
|
||||||
use_neo4j=options["use_neo4j"],
|
|
||||||
use_graph_expansion=options["use_graph_expansion"],
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
return [self._from_row(row, "PC", f"{lane}:pc_hybrid", query) for row in rows]
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _from_row(row: Dict[str, Any], kb: str, method: str, query: str) -> EvidenceItem:
|
def _from_row(row: Dict[str, Any], kb: str, method: str, query: str) -> EvidenceItem:
|
||||||
metadata = row.get("metadata") if isinstance(row.get("metadata"), dict) else {}
|
metadata = row.get("metadata") if isinstance(row.get("metadata"), dict) else {}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ from concurrent.futures import ThreadPoolExecutor
|
|||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from typing import Any, Callable, Dict, Iterable, List
|
from typing import Any, Callable, Dict, Iterable, List
|
||||||
|
|
||||||
|
from .agentic_controller import AgenticOutputError
|
||||||
|
|
||||||
|
|
||||||
_STATUSES = {"SATISFIED", "PARTIAL", "EMPTY"}
|
_STATUSES = {"SATISFIED", "PARTIAL", "EMPTY"}
|
||||||
_REFINEMENT_KEYS = {
|
_REFINEMENT_KEYS = {
|
||||||
@@ -80,7 +82,7 @@ class SubquestionRetrievalOrchestrator:
|
|||||||
|
|
||||||
first_payload = [self._review_payload(states[task["subquestion_id"]]) for task in tasks]
|
first_payload = [self._review_payload(states[task["subquestion_id"]]) for task in tasks]
|
||||||
self._emit(progress_callback, "agent_review_started", {"attempt": 1})
|
self._emit(progress_callback, "agent_review_started", {"attempt": 1})
|
||||||
first_review = self._safe_review(review, question, first_payload, 1)
|
first_review = self._required_review(review, question, first_payload, 1)
|
||||||
review_rounds.append(deepcopy(first_review))
|
review_rounds.append(deepcopy(first_review))
|
||||||
self._apply_review(states, first_review)
|
self._apply_review(states, first_review)
|
||||||
|
|
||||||
@@ -111,7 +113,7 @@ class SubquestionRetrievalOrchestrator:
|
|||||||
)
|
)
|
||||||
second_payload = [self._review_payload(states[value]) for value in retry_ids]
|
second_payload = [self._review_payload(states[value]) for value in retry_ids]
|
||||||
self._emit(progress_callback, "agent_review_started", {"attempt": 2})
|
self._emit(progress_callback, "agent_review_started", {"attempt": 2})
|
||||||
second_review = self._safe_review(review, question, second_payload, 2)
|
second_review = self._required_review(review, question, second_payload, 2)
|
||||||
review_rounds.append(deepcopy(second_review))
|
review_rounds.append(deepcopy(second_review))
|
||||||
self._apply_review(states, second_review)
|
self._apply_review(states, second_review)
|
||||||
for subquestion_id in retry_ids:
|
for subquestion_id in retry_ids:
|
||||||
@@ -373,31 +375,19 @@ class SubquestionRetrievalOrchestrator:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _safe_review(
|
def _required_review(
|
||||||
review: Callable[..., Dict[str, Any]],
|
review: Callable[..., Dict[str, Any]],
|
||||||
question: str,
|
question: str,
|
||||||
groups: List[Dict[str, Any]],
|
groups: List[Dict[str, Any]],
|
||||||
attempt: int,
|
attempt: int,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
try:
|
result = review(question, groups, attempt=attempt)
|
||||||
result = review(question, groups, attempt=attempt)
|
if not isinstance(result, dict):
|
||||||
if isinstance(result, dict):
|
raise AgenticOutputError(
|
||||||
return result
|
"evidence_review",
|
||||||
except Exception:
|
"subquestion review must return a JSON object",
|
||||||
pass
|
)
|
||||||
return {
|
return result
|
||||||
"groups": [
|
|
||||||
{
|
|
||||||
"subquestion_id": group["subquestion_id"],
|
|
||||||
"status": "PARTIAL" if group["evidence"] or group["chains"] else "EMPTY",
|
|
||||||
"accepted_evidence_ids": [],
|
|
||||||
"accepted_chain_ids": [],
|
|
||||||
"refinement": {},
|
|
||||||
}
|
|
||||||
for group in groups
|
|
||||||
],
|
|
||||||
"review_source": "deterministic_safe_fallback",
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _apply_review(states: Dict[str, Dict[str, Any]], review: Dict[str, Any]) -> None:
|
def _apply_review(states: Dict[str, Dict[str, Any]], review: Dict[str, Any]) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.rag.qa_v1_1_73 import llm_client as module
|
||||||
|
|
||||||
|
|
||||||
|
class _Response:
|
||||||
|
def __init__(self, status_code: int) -> None:
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return {"error": {"message": "upstream failure"}}
|
||||||
|
|
||||||
|
|
||||||
|
def _client(monkeypatch) -> module.LLMClient:
|
||||||
|
monkeypatch.setenv("QA_V1_LLM_API_KEY", "test-only")
|
||||||
|
monkeypatch.setenv("QA_V1_LLM_MAX_ATTEMPTS", "4")
|
||||||
|
monkeypatch.setenv("QA_V1_LLM_RETRY_BASE_SECONDS", "0")
|
||||||
|
return module.LLMClient()
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_402_fails_without_retry(monkeypatch):
|
||||||
|
client = _client(monkeypatch)
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def post(*args, **kwargs):
|
||||||
|
calls.append((args, kwargs))
|
||||||
|
return _Response(402)
|
||||||
|
|
||||||
|
monkeypatch.setattr(module.requests, "post", post)
|
||||||
|
|
||||||
|
with pytest.raises(module.LLMNonRetryableError, match="HTTP 402"):
|
||||||
|
client.complete("system", "user")
|
||||||
|
|
||||||
|
assert len(calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_503_keeps_bounded_retry(monkeypatch):
|
||||||
|
client = _client(monkeypatch)
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def post(*args, **kwargs):
|
||||||
|
calls.append((args, kwargs))
|
||||||
|
return _Response(503)
|
||||||
|
|
||||||
|
monkeypatch.setattr(module.requests, "post", post)
|
||||||
|
|
||||||
|
with pytest.raises(module.LLMResponseError, match="after 4 attempts"):
|
||||||
|
client.complete("system", "user")
|
||||||
|
|
||||||
|
assert len(calls) == 4
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.rag.qa_v1_1_73.agentic_controller import AgenticRetrievalController
|
||||||
|
from src.rag.qa_v1_1_73.pipeline import QAV1Pipeline
|
||||||
|
from src.rag.qa_v1_1_73.schemas import EvidenceItem
|
||||||
|
|
||||||
|
|
||||||
|
def _controller():
|
||||||
|
return AgenticRetrievalController.__new__(AgenticRetrievalController)
|
||||||
|
|
||||||
|
|
||||||
|
def _item(
|
||||||
|
index: int,
|
||||||
|
*,
|
||||||
|
source: str = "ANCHOR",
|
||||||
|
relation: str = "regulates",
|
||||||
|
target: str | None = None,
|
||||||
|
evidence: str | None = None,
|
||||||
|
paper_id: str = "paper-1",
|
||||||
|
answer_type: str = "unmapped_type",
|
||||||
|
) -> EvidenceItem:
|
||||||
|
resolved_target = target or f"ANSWER{index}"
|
||||||
|
return EvidenceItem(
|
||||||
|
evidence_id=f"Q0.A1.E{index}",
|
||||||
|
kb="V16/V15",
|
||||||
|
source=source,
|
||||||
|
relation=relation,
|
||||||
|
target=resolved_target,
|
||||||
|
evidence=evidence or f"ANCHOR {relation} {resolved_target}.",
|
||||||
|
paper_id=paper_id,
|
||||||
|
relation_id=f"assertion-{index}",
|
||||||
|
raw={
|
||||||
|
"assertion_id": f"assertion-{index}",
|
||||||
|
"answer_entity_type": answer_type,
|
||||||
|
"source_type": "gene",
|
||||||
|
"target_type": answer_type,
|
||||||
|
"anchor_name": "ANCHOR",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _decomposition():
|
||||||
|
return {
|
||||||
|
"subquestions": [
|
||||||
|
{
|
||||||
|
"subquestion_id": "Q0",
|
||||||
|
"question": "Which entities are associated with ANCHOR?",
|
||||||
|
"role": "required",
|
||||||
|
"answer_slot": "entities",
|
||||||
|
"anchors": [{"mention": "ANCHOR", "canonical_name": "ANCHOR"}],
|
||||||
|
"target_entity_types": ["gene"],
|
||||||
|
"acceptance_requirements": {
|
||||||
|
"requires_structured_assertion": True,
|
||||||
|
"requires_paper_provenance": True,
|
||||||
|
"requires_anchor_connection": True,
|
||||||
|
"requires_terminal_type_match": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"subquestion_id": "Q1",
|
||||||
|
"question": "What background is known?",
|
||||||
|
"role": "supporting",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _groups(q0_evidence=None, q0_chains=None, supporting=None):
|
||||||
|
q0_evidence = list(q0_evidence or [])
|
||||||
|
q0_chains = list(q0_chains or [])
|
||||||
|
supporting = list(supporting or [])
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"subquestion_id": "Q0",
|
||||||
|
"question": "Which entities are associated with ANCHOR?",
|
||||||
|
"role": "required",
|
||||||
|
"status": "COMPLETE" if q0_evidence or q0_chains else "EMPTY",
|
||||||
|
"accepted_evidence_ids": [item.evidence_id for item in q0_evidence],
|
||||||
|
"accepted_chain_ids": [chain["chain_id"] for chain in q0_chains],
|
||||||
|
"evidence": q0_evidence,
|
||||||
|
"chains": q0_chains,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"subquestion_id": "Q1",
|
||||||
|
"question": "What background is known?",
|
||||||
|
"role": "supporting",
|
||||||
|
"status": "COMPLETE" if supporting else "EMPTY",
|
||||||
|
"accepted_evidence_ids": [item.evidence_id for item in supporting],
|
||||||
|
"accepted_chain_ids": [],
|
||||||
|
"evidence": supporting,
|
||||||
|
"chains": [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_soft_fallback_keeps_agent_items_first_and_copies_safe_q0(monkeypatch):
|
||||||
|
monkeypatch.setenv("QA_AGENTIC_GATE_POLICY", "soft_compat_v1")
|
||||||
|
accepted = _item(1, answer_type="gene")
|
||||||
|
fallback = _item(2, answer_type="not_in_planner_vocabulary")
|
||||||
|
groups = _groups(q0_evidence=[accepted])
|
||||||
|
|
||||||
|
result = QAV1Pipeline._apply_q0_soft_fallback(
|
||||||
|
_decomposition(), groups, [accepted, fallback], _controller(), limit=30
|
||||||
|
)
|
||||||
|
q0 = result["groups"][0]
|
||||||
|
|
||||||
|
assert [item.evidence_id for item in q0["evidence"]] == [
|
||||||
|
accepted.evidence_id,
|
||||||
|
fallback.evidence_id,
|
||||||
|
]
|
||||||
|
assert q0["evidence"][1] is not fallback
|
||||||
|
assert fallback.raw.get("handoff_origin") is None
|
||||||
|
assert q0["evidence"][1].raw["handoff_origin"] == "q0_soft_fallback"
|
||||||
|
assert result["strict_review_accepted"] is True
|
||||||
|
assert result["required_q0_complete"] is True
|
||||||
|
assert result["fallback_count"] == 1
|
||||||
|
assert result["fallback_original_ranks"] == [2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_soft_fallback_preserves_non_type_safety_checks(monkeypatch):
|
||||||
|
monkeypatch.setenv("QA_AGENTIC_GATE_POLICY", "soft_compat_v1")
|
||||||
|
no_provenance = _item(2, paper_id="")
|
||||||
|
wrong_anchor = _item(
|
||||||
|
3,
|
||||||
|
source="OTHER",
|
||||||
|
evidence="OTHER regulates ANSWER3.",
|
||||||
|
)
|
||||||
|
cold_for_heat = _item(
|
||||||
|
4,
|
||||||
|
evidence="ANCHOR responds only to cold stress and freezing.",
|
||||||
|
)
|
||||||
|
decomposition = _decomposition()
|
||||||
|
decomposition["subquestions"][0]["question"] = (
|
||||||
|
"Which entities are associated with ANCHOR under heat stress?"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = QAV1Pipeline._apply_q0_soft_fallback(
|
||||||
|
decomposition,
|
||||||
|
_groups(),
|
||||||
|
[no_provenance, wrong_anchor, cold_for_heat],
|
||||||
|
_controller(),
|
||||||
|
limit=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["groups"][0]["evidence"] == []
|
||||||
|
assert result["strict_review_accepted"] is False
|
||||||
|
assert result["required_q0_complete"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_supporting_only_never_counts_as_required_q0_complete(monkeypatch):
|
||||||
|
monkeypatch.setenv("QA_AGENTIC_GATE_POLICY", "soft_compat_v1")
|
||||||
|
supporting = _item(9, source="BACKGROUND", answer_type="gene")
|
||||||
|
|
||||||
|
result = QAV1Pipeline._apply_q0_soft_fallback(
|
||||||
|
_decomposition(), _groups(supporting=[supporting]), [], _controller(), limit=30
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["groups"][1]["evidence"] == [supporting]
|
||||||
|
assert result["strict_review_accepted"] is False
|
||||||
|
assert result["required_q0_complete"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_q0_fallback_deduplicates_and_caps_at_thirty(monkeypatch):
|
||||||
|
monkeypatch.setenv("QA_AGENTIC_GATE_POLICY", "soft_compat_v1")
|
||||||
|
accepted = [_item(1, answer_type="gene"), _item(2, answer_type="gene")]
|
||||||
|
candidates = accepted + [_item(index) for index in range(2, 40)]
|
||||||
|
|
||||||
|
result = QAV1Pipeline._apply_q0_soft_fallback(
|
||||||
|
_decomposition(), _groups(q0_evidence=accepted), candidates, _controller(), limit=30
|
||||||
|
)
|
||||||
|
q0 = result["groups"][0]
|
||||||
|
|
||||||
|
assert len(q0["evidence"]) == 30
|
||||||
|
assert len({item.relation_id for item in q0["evidence"]}) == 30
|
||||||
|
assert q0["evidence"][:2] == accepted
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_policy_is_a_noop(monkeypatch):
|
||||||
|
monkeypatch.setenv("QA_AGENTIC_GATE_POLICY", "legacy")
|
||||||
|
fallback = _item(2)
|
||||||
|
groups = _groups()
|
||||||
|
|
||||||
|
result = QAV1Pipeline._apply_q0_soft_fallback(
|
||||||
|
_decomposition(), groups, [fallback], _controller(), limit=30
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["groups"] == groups
|
||||||
|
assert result["fallback_count"] == 0
|
||||||
|
assert result["strict_review_accepted"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_global_handoff_orders_all_agent_items_before_q0_fallback():
|
||||||
|
q0_agent = _item(1, answer_type="gene")
|
||||||
|
q0_fallback = _item(2)
|
||||||
|
q0_fallback.raw["handoff_origin"] = "q0_soft_fallback"
|
||||||
|
supporting_agent = _item(9, source="BACKGROUND", answer_type="gene")
|
||||||
|
groups = _groups(q0_evidence=[q0_agent, q0_fallback], supporting=[supporting_agent])
|
||||||
|
|
||||||
|
selected = QAV1Pipeline._ordered_agentic_handoff_evidence(groups, limit=3)
|
||||||
|
|
||||||
|
assert [item.evidence_id for item in selected] == [
|
||||||
|
q0_agent.evidence_id,
|
||||||
|
supporting_agent.evidence_id,
|
||||||
|
q0_fallback.evidence_id,
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user