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_*.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
|
||||
debug_*.py
|
||||
fix_*.py
|
||||
@@ -146,6 +148,7 @@ scripts/*
|
||||
!scripts/judge_direct_qa100_unified.py
|
||||
!scripts/merge_ablation_100q_raw.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_v16_1_2_one_paper.py
|
||||
!scripts/run_v16_batch10_20260625.py
|
||||
@@ -178,6 +181,10 @@ tests/*
|
||||
|
||||
# ----- top-level test_*.py files (not under tests/) -----
|
||||
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
|
||||
|
||||
# ----- 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."""
|
||||
|
||||
__version__ = "1.1.73"
|
||||
|
||||
from .pipeline import QAV1Pipeline
|
||||
|
||||
__all__ = ["QAV1Pipeline", "__version__"]
|
||||
"""Production QA V1.1.73 assertion-anchored graph expansion."""
|
||||
|
||||
__version__ = "1.1.73-assertion-anchored"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -95,6 +95,9 @@ class AgenticGraphRetriever:
|
||||
)
|
||||
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 = {
|
||||
_norm(value)
|
||||
for value in relation_plan.get("allowed_relation_categories") or []
|
||||
@@ -107,6 +110,7 @@ class AgenticGraphRetriever:
|
||||
"allow_incoming": allow_incoming,
|
||||
"allow_outgoing": allow_outgoing,
|
||||
"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)),
|
||||
"query_mode": (
|
||||
"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_beam_width: int = Field(default=60, ge=1, le=300)
|
||||
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)
|
||||
@@ -54,7 +62,7 @@ def health() -> Dict[str, Any]:
|
||||
def info() -> Dict[str, Any]:
|
||||
config = QAConfig.from_env()
|
||||
return {
|
||||
"name": "QA V1.1.73 Relation-Guided Native Neo4j Path Chains",
|
||||
"name": "QA V1.1.73 Assertion-Anchored Full Graph",
|
||||
"version": __version__,
|
||||
"pipeline": [
|
||||
"question",
|
||||
@@ -67,6 +75,8 @@ def info() -> Dict[str, Any]:
|
||||
"BGE rerank",
|
||||
"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 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",
|
||||
],
|
||||
"safety_boundary": (
|
||||
@@ -94,6 +104,9 @@ def info() -> Dict[str, Any]:
|
||||
"native_chain_max_hops": 3,
|
||||
"native_chain_top_k": 5,
|
||||
"native_chain_min_hops": 1,
|
||||
"endpoint_path_enabled": False,
|
||||
"endpoint_path_max_hops": 3,
|
||||
"endpoint_path_top_k": 5,
|
||||
},
|
||||
"request_ablation_supported": [
|
||||
"use_provisional_draft",
|
||||
@@ -101,13 +114,12 @@ def info() -> Dict[str, Any]:
|
||||
"ablation.retrieval.use_qdrant",
|
||||
"ablation.retrieval.use_neo4j",
|
||||
"ablation.retrieval.use_graph_expansion",
|
||||
"ablation.retrieval.use_sqlite",
|
||||
"ablation.retrieval.use_pc",
|
||||
"ablation.retrieval.use_v15",
|
||||
"ablation.use_bge_rerank",
|
||||
"ablation.native_seed_policy",
|
||||
"ablation.native_seed_top_k",
|
||||
"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_beam_width=request.native_chain_beam_width,
|
||||
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:
|
||||
raise HTTPException(
|
||||
@@ -203,6 +223,14 @@ async def chat_stream(request: QARequest) -> StreamingResponse:
|
||||
native_chain_min_hops=request.native_chain_min_hops,
|
||||
native_chain_beam_width=request.native_chain_beam_width,
|
||||
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,
|
||||
)
|
||||
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_beam_width=request.native_chain_beam_width,
|
||||
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,
|
||||
)
|
||||
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*([^\]]+)\]",
|
||||
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)
|
||||
_FULL_WIDTH_PUBLIC_CITATION = re.compile(
|
||||
r"【(?:PMID|PMCID|DOI)\b[^】\n]*】",
|
||||
@@ -68,6 +80,49 @@ def visible_public_citations(answer: str) -> list[str]:
|
||||
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]:
|
||||
text = str(answer or "")
|
||||
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()
|
||||
tail = match.group(2).strip()
|
||||
closing = match.group(3)
|
||||
if closing != "]":
|
||||
malformed.add(match.group(0))
|
||||
continue
|
||||
value = re.sub(r"^[::]\s*", "", tail)
|
||||
if kind == "PMID":
|
||||
identifier_shaped = bool(re.fullmatch(r"\d{5,10}", value))
|
||||
@@ -91,7 +149,7 @@ def malformed_citation_labels(answer: str) -> list[str]:
|
||||
else:
|
||||
identifier_shaped = bool(re.fullmatch(r"10\.\d{4,9}/\S+", value, re.I))
|
||||
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))
|
||||
return sorted(malformed)
|
||||
|
||||
|
||||
@@ -32,6 +32,13 @@ class QAConfig:
|
||||
@classmethod
|
||||
def from_env(cls) -> "QAConfig":
|
||||
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(
|
||||
project_root=root,
|
||||
gfanalysis_root=Path(
|
||||
@@ -74,7 +81,7 @@ class QAConfig:
|
||||
== "1",
|
||||
enable_bge_rerank=os.environ.get("QA_V1_ENABLE_BGE_RERANK", "1")
|
||||
== "1",
|
||||
graph_schema=os.environ.get("QA_V1_GRAPH_SCHEMA", "legacy").strip().lower(),
|
||||
graph_schema=graph_schema,
|
||||
enable_agentic_controller=os.environ.get(
|
||||
"QA_V1_ENABLE_AGENTIC_CONTROLLER", "1"
|
||||
)
|
||||
|
||||
@@ -12,6 +12,11 @@ from qdrant_client import QdrantClient
|
||||
|
||||
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:
|
||||
return " ".join(str(value or "").split())
|
||||
@@ -57,11 +62,19 @@ class DirectFlowRetriever:
|
||||
vector_query_workers: int = 2,
|
||||
) -> None:
|
||||
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()
|
||||
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.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")
|
||||
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_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")
|
||||
@@ -72,12 +85,10 @@ class DirectFlowRetriever:
|
||||
load_paper_metadata_registry(Path(registry_path)) if registry_path else {}
|
||||
)
|
||||
self.qdrant = QdrantClient(url=self.qdrant_url, timeout=60)
|
||||
self.driver = None
|
||||
if self.graph_schema == "canonical":
|
||||
self.driver = GraphDatabase.driver(
|
||||
self.neo4j_uri,
|
||||
auth=(self.neo4j_user, self.neo4j_password),
|
||||
)
|
||||
self.driver = GraphDatabase.driver(
|
||||
self.neo4j_uri,
|
||||
auth=(self.neo4j_user, self.neo4j_password),
|
||||
)
|
||||
|
||||
def close(self) -> 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
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Dict, List
|
||||
|
||||
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:
|
||||
def __init__(self) -> None:
|
||||
self.api_key = (
|
||||
@@ -27,6 +41,19 @@ class LLMClient:
|
||||
or "deepseek-v4-flash"
|
||||
)
|
||||
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
|
||||
def available(self) -> bool:
|
||||
@@ -39,31 +66,131 @@ class LLMClient:
|
||||
*,
|
||||
temperature: float = 0.1,
|
||||
max_tokens: int = 2000,
|
||||
json_mode: bool = False,
|
||||
request_attempts: int | None = None,
|
||||
) -> str:
|
||||
if not self.api_key:
|
||||
raise RuntimeError("QA V1 LLM API key is not configured")
|
||||
response = requests.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"stream": False,
|
||||
},
|
||||
timeout=self.timeout,
|
||||
request_body = {
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"stream": False,
|
||||
}
|
||||
if json_mode:
|
||||
request_body["response_format"] = {"type": "json_object"}
|
||||
attempt_limit = self.max_attempts
|
||||
if request_attempts is not None:
|
||||
attempt_limit = max(1, min(int(request_attempts), self.max_attempts))
|
||||
last_error = "unknown upstream failure"
|
||||
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(
|
||||
self,
|
||||
@@ -71,21 +198,68 @@ class LLMClient:
|
||||
user: str,
|
||||
*,
|
||||
max_tokens: int = 2500,
|
||||
request_attempts: int | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
text = self.complete(
|
||||
system + "\nReturn valid JSON only. Do not use Markdown fences.",
|
||||
user,
|
||||
temperature=0.0,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
parsed = self._parse_json_object(text)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
text = ""
|
||||
for json_attempt in range(1, self.json_attempts + 1):
|
||||
repair_note = ""
|
||||
if json_attempt > 1:
|
||||
repair_note = (
|
||||
"\nA previous response was not a complete JSON object. Return a "
|
||||
"single compact JSON object with every object and array closed."
|
||||
)
|
||||
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 {
|
||||
"_json_parse_error": "LLM returned JSON that could not be parsed",
|
||||
"_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
|
||||
def _parse_json_object(text: str) -> Dict[str, Any] | None:
|
||||
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 typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
from .agentic_controller import AgenticOutputError
|
||||
|
||||
|
||||
PROMPT_VERSION = "question_decomposition_v1"
|
||||
MAX_SUBQUESTIONS = 6
|
||||
@@ -68,6 +70,13 @@ Decomposition rules
|
||||
caller-supplied verified entities/relation intents. For a simple "how does X
|
||||
help under Y" question, prefer Q0 plus one broad mechanism slot; retrieval,
|
||||
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)
|
||||
- retrieval.vector_top_k: 30
|
||||
@@ -206,17 +215,58 @@ class QuestionDecompositionPlanner:
|
||||
raw = self.llm.complete_json(
|
||||
QUESTION_DECOMPOSITION_PROMPT,
|
||||
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"):
|
||||
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["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
|
||||
except Exception as exc:
|
||||
fallback["plan_source"] = "deterministic_fallback"
|
||||
fallback["fallback_reason"] = f"{type(exc).__name__}: {exc}"[:500]
|
||||
return fallback
|
||||
recovered = deepcopy(fallback)
|
||||
recovered["plan_source"] = (
|
||||
"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(
|
||||
self,
|
||||
@@ -281,17 +331,6 @@ class QuestionDecompositionPlanner:
|
||||
break
|
||||
if not items:
|
||||
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.update(
|
||||
|
||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from .pc_retriever import PCIndependentRetriever
|
||||
from .schemas import EvidenceItem
|
||||
from .v16_retriever import V16IndependentRetriever
|
||||
|
||||
@@ -13,8 +12,8 @@ def _text(value: Any) -> str:
|
||||
|
||||
DEFAULT_RETRIEVAL_OPTIONS: Dict[str, bool] = {
|
||||
"use_v15": True,
|
||||
"use_pc": True,
|
||||
"use_sqlite": True,
|
||||
"use_pc": False,
|
||||
"use_sqlite": False,
|
||||
"use_qdrant": True,
|
||||
"use_neo4j": 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]:
|
||||
normalized = dict(DEFAULT_RETRIEVAL_OPTIONS)
|
||||
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):
|
||||
if key in options:
|
||||
normalized[key] = bool(options[key])
|
||||
@@ -38,7 +41,6 @@ class DualKBRetriever:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.v15 = V16IndependentRetriever()
|
||||
self.pc = PCIndependentRetriever()
|
||||
|
||||
def retrieve_queries(
|
||||
self,
|
||||
@@ -66,10 +68,6 @@ class DualKBRetriever:
|
||||
options,
|
||||
)
|
||||
)
|
||||
if options["use_pc"]:
|
||||
output.extend(
|
||||
self._retrieve_pc(query, lane, top_k_per_query, graph_hops, options)
|
||||
)
|
||||
audit.append(
|
||||
{
|
||||
"lane": lane,
|
||||
@@ -96,7 +94,7 @@ class DualKBRetriever:
|
||||
top_k=top_k,
|
||||
max_hops=graph_hops,
|
||||
include_candidate=include_candidate,
|
||||
use_sqlite=options["use_sqlite"],
|
||||
use_sqlite=False,
|
||||
use_qdrant=options["use_qdrant"],
|
||||
use_neo4j=options["use_neo4j"],
|
||||
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))
|
||||
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
|
||||
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 {}
|
||||
|
||||
@@ -4,6 +4,8 @@ from concurrent.futures import ThreadPoolExecutor
|
||||
from copy import deepcopy
|
||||
from typing import Any, Callable, Dict, Iterable, List
|
||||
|
||||
from .agentic_controller import AgenticOutputError
|
||||
|
||||
|
||||
_STATUSES = {"SATISFIED", "PARTIAL", "EMPTY"}
|
||||
_REFINEMENT_KEYS = {
|
||||
@@ -80,7 +82,7 @@ class SubquestionRetrievalOrchestrator:
|
||||
|
||||
first_payload = [self._review_payload(states[task["subquestion_id"]]) for task in tasks]
|
||||
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))
|
||||
self._apply_review(states, first_review)
|
||||
|
||||
@@ -111,7 +113,7 @@ class SubquestionRetrievalOrchestrator:
|
||||
)
|
||||
second_payload = [self._review_payload(states[value]) for value in retry_ids]
|
||||
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))
|
||||
self._apply_review(states, second_review)
|
||||
for subquestion_id in retry_ids:
|
||||
@@ -373,31 +375,19 @@ class SubquestionRetrievalOrchestrator:
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _safe_review(
|
||||
def _required_review(
|
||||
review: Callable[..., Dict[str, Any]],
|
||||
question: str,
|
||||
groups: List[Dict[str, Any]],
|
||||
attempt: int,
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
result = review(question, groups, attempt=attempt)
|
||||
if isinstance(result, dict):
|
||||
return result
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"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",
|
||||
}
|
||||
result = review(question, groups, attempt=attempt)
|
||||
if not isinstance(result, dict):
|
||||
raise AgenticOutputError(
|
||||
"evidence_review",
|
||||
"subquestion review must return a JSON object",
|
||||
)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
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