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:
potaser
2026-08-29 12:39:25 +08:00
parent fdf24c8ab0
commit 4fef3a65ac
24 changed files with 6145 additions and 510 deletions
+7
View File
@@ -118,6 +118,8 @@ lib/
run_*.py run_*.py
run_*.sh run_*.sh
start_*.sh start_*.sh
!docs/legacy_startup_scripts/start_qa_v1_1_73_5090.sh
!docs/legacy_startup_scripts/start_qa_v1_1_73_7924.sh
batch*.py batch*.py
debug_*.py debug_*.py
fix_*.py fix_*.py
@@ -146,6 +148,7 @@ scripts/*
!scripts/judge_direct_qa100_unified.py !scripts/judge_direct_qa100_unified.py
!scripts/merge_ablation_100q_raw.py !scripts/merge_ablation_100q_raw.py
!scripts/qa_v1_0_0_server.py !scripts/qa_v1_0_0_server.py
!scripts/qa_v1_1_73_server.py
!scripts/run_qa_v1_0_0_ablation.py !scripts/run_qa_v1_0_0_ablation.py
!scripts/run_v16_1_2_one_paper.py !scripts/run_v16_1_2_one_paper.py
!scripts/run_v16_batch10_20260625.py !scripts/run_v16_batch10_20260625.py
@@ -178,6 +181,10 @@ tests/*
# ----- top-level test_*.py files (not under tests/) ----- # ----- top-level test_*.py files (not under tests/) -----
test_*.py test_*.py
!tests/test_qa_v1_1_73_llm_fail_fast.py
!tests/test_qa_v1_1_73_q0_soft_fallback.py
!tests/test_qa_v1_1_73_soft_gate_policy.py
!tests/test_qa_v1_1_73_soft_type_gate.py
!scripts/test_qa_v1_0_0.py !scripts/test_qa_v1_0_0.py
# ----- top-level config ignores ----- # ----- top-level config ignores -----
+46
View File
@@ -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),
}
+3 -7
View File
@@ -1,7 +1,3 @@
"""QA V1.1.73: relation-guided native Neo4j path chains.""" """Production QA V1.1.73 assertion-anchored graph expansion."""
__version__ = "1.1.73" __version__ = "1.1.73-assertion-anchored"
from .pipeline import QAV1Pipeline
__all__ = ["QAV1Pipeline", "__version__"]
File diff suppressed because it is too large Load Diff
@@ -95,6 +95,9 @@ class AgenticGraphRetriever:
) )
if _type(value) if _type(value)
} }
terminal_type_wildcard = "unknown" in terminal_types
# Planner uses "unknown" to mean that no terminal type is constrained.
terminal_types.discard("unknown")
relation_categories = { relation_categories = {
_norm(value) _norm(value)
for value in relation_plan.get("allowed_relation_categories") or [] for value in relation_plan.get("allowed_relation_categories") or []
@@ -107,6 +110,7 @@ class AgenticGraphRetriever:
"allow_incoming": allow_incoming, "allow_incoming": allow_incoming,
"allow_outgoing": allow_outgoing, "allow_outgoing": allow_outgoing,
"required_terminal_types": sorted(terminal_types), "required_terminal_types": sorted(terminal_types),
"terminal_type_wildcard": terminal_type_wildcard,
"max_hops": max(1, min(int(graph_plan.get("max_hops", 1)), 3)), "max_hops": max(1, min(int(graph_plan.get("max_hops", 1)), 3)),
"query_mode": ( "query_mode": (
"legacy_anchor_neighborhood" "legacy_anchor_neighborhood"
+251
View File
@@ -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
+39 -3
View File
@@ -35,6 +35,14 @@ class QARequest(BaseModel):
native_chain_min_hops: int = Field(default=1, ge=1, le=3) native_chain_min_hops: int = Field(default=1, ge=1, le=3)
native_chain_beam_width: int = Field(default=60, ge=1, le=300) native_chain_beam_width: int = Field(default=60, ge=1, le=300)
native_chain_per_node_expand: int = Field(default=12, ge=1, le=50) native_chain_per_node_expand: int = Field(default=12, ge=1, le=50)
endpoint_path_enabled: bool = False
endpoint_path_max_hops: int = Field(default=3, ge=1, le=3)
endpoint_path_top_k: int = Field(default=5, ge=0, le=10)
endpoint_path_max_endpoint_candidates: int = Field(default=3, ge=1, le=5)
endpoint_path_per_node_limit: int = Field(default=16, ge=2, le=50)
endpoint_path_max_expansions: int = Field(default=24, ge=10, le=1000)
endpoint_path_max_frontier_states: int = Field(default=120, ge=20, le=2000)
endpoint_path_time_budget_seconds: float = Field(default=5.0, ge=1.0, le=30.0)
@lru_cache(maxsize=1) @lru_cache(maxsize=1)
@@ -54,7 +62,7 @@ def health() -> Dict[str, Any]:
def info() -> Dict[str, Any]: def info() -> Dict[str, Any]:
config = QAConfig.from_env() config = QAConfig.from_env()
return { return {
"name": "QA V1.1.73 Relation-Guided Native Neo4j Path Chains", "name": "QA V1.1.73 Assertion-Anchored Full Graph",
"version": __version__, "version": __version__,
"pipeline": [ "pipeline": [
"question", "question",
@@ -67,6 +75,8 @@ def info() -> Dict[str, Any]:
"BGE rerank", "BGE rerank",
"graph-balanced top30 evidence chain, with at least 15 graph-derived items when available", "graph-balanced top30 evidence chain, with at least 15 graph-derived items when available",
"BGE top10 Qdrant source seed selection for cascaded native Neo4j two-hop chain retrieval", "BGE top10 Qdrant source seed selection for cascaded native Neo4j two-hop chain retrieval",
"BGE top10 complete assertions locked as hop 1 and expanded from each target in Neo4j",
"merge assertion-anchored chains with the unchanged formal native graph lane",
"compose a validated final LLM answer for chat; retrieval-only search returns evidence without an answer", "compose a validated final LLM answer for chat; retrieval-only search returns evidence without an answer",
], ],
"safety_boundary": ( "safety_boundary": (
@@ -94,6 +104,9 @@ def info() -> Dict[str, Any]:
"native_chain_max_hops": 3, "native_chain_max_hops": 3,
"native_chain_top_k": 5, "native_chain_top_k": 5,
"native_chain_min_hops": 1, "native_chain_min_hops": 1,
"endpoint_path_enabled": False,
"endpoint_path_max_hops": 3,
"endpoint_path_top_k": 5,
}, },
"request_ablation_supported": [ "request_ablation_supported": [
"use_provisional_draft", "use_provisional_draft",
@@ -101,13 +114,12 @@ def info() -> Dict[str, Any]:
"ablation.retrieval.use_qdrant", "ablation.retrieval.use_qdrant",
"ablation.retrieval.use_neo4j", "ablation.retrieval.use_neo4j",
"ablation.retrieval.use_graph_expansion", "ablation.retrieval.use_graph_expansion",
"ablation.retrieval.use_sqlite",
"ablation.retrieval.use_pc",
"ablation.retrieval.use_v15", "ablation.retrieval.use_v15",
"ablation.use_bge_rerank", "ablation.use_bge_rerank",
"ablation.native_seed_policy", "ablation.native_seed_policy",
"ablation.native_seed_top_k", "ablation.native_seed_top_k",
"ablation.native_target_seed_policy", "ablation.native_target_seed_policy",
"endpoint_path_enabled",
], ],
}, },
} }
@@ -148,6 +160,14 @@ async def chat(request: QARequest) -> Dict[str, Any]:
native_chain_min_hops=request.native_chain_min_hops, native_chain_min_hops=request.native_chain_min_hops,
native_chain_beam_width=request.native_chain_beam_width, native_chain_beam_width=request.native_chain_beam_width,
native_chain_per_node_expand=request.native_chain_per_node_expand, native_chain_per_node_expand=request.native_chain_per_node_expand,
endpoint_path_enabled=request.endpoint_path_enabled,
endpoint_path_max_hops=request.endpoint_path_max_hops,
endpoint_path_top_k=request.endpoint_path_top_k,
endpoint_path_max_endpoint_candidates=request.endpoint_path_max_endpoint_candidates,
endpoint_path_per_node_limit=request.endpoint_path_per_node_limit,
endpoint_path_max_expansions=request.endpoint_path_max_expansions,
endpoint_path_max_frontier_states=request.endpoint_path_max_frontier_states,
endpoint_path_time_budget_seconds=request.endpoint_path_time_budget_seconds,
) )
except QAAnswerError as exc: except QAAnswerError as exc:
raise HTTPException( raise HTTPException(
@@ -203,6 +223,14 @@ async def chat_stream(request: QARequest) -> StreamingResponse:
native_chain_min_hops=request.native_chain_min_hops, native_chain_min_hops=request.native_chain_min_hops,
native_chain_beam_width=request.native_chain_beam_width, native_chain_beam_width=request.native_chain_beam_width,
native_chain_per_node_expand=request.native_chain_per_node_expand, native_chain_per_node_expand=request.native_chain_per_node_expand,
endpoint_path_enabled=request.endpoint_path_enabled,
endpoint_path_max_hops=request.endpoint_path_max_hops,
endpoint_path_top_k=request.endpoint_path_top_k,
endpoint_path_max_endpoint_candidates=request.endpoint_path_max_endpoint_candidates,
endpoint_path_per_node_limit=request.endpoint_path_per_node_limit,
endpoint_path_max_expansions=request.endpoint_path_max_expansions,
endpoint_path_max_frontier_states=request.endpoint_path_max_frontier_states,
endpoint_path_time_budget_seconds=request.endpoint_path_time_budget_seconds,
progress_callback=emit, progress_callback=emit,
) )
events.put(("complete", result)) events.put(("complete", result))
@@ -270,6 +298,14 @@ async def search(request: QARequest) -> Dict[str, Any]:
native_chain_min_hops=request.native_chain_min_hops, native_chain_min_hops=request.native_chain_min_hops,
native_chain_beam_width=request.native_chain_beam_width, native_chain_beam_width=request.native_chain_beam_width,
native_chain_per_node_expand=request.native_chain_per_node_expand, native_chain_per_node_expand=request.native_chain_per_node_expand,
endpoint_path_enabled=request.endpoint_path_enabled,
endpoint_path_max_hops=request.endpoint_path_max_hops,
endpoint_path_top_k=request.endpoint_path_top_k,
endpoint_path_max_endpoint_candidates=request.endpoint_path_max_endpoint_candidates,
endpoint_path_per_node_limit=request.endpoint_path_per_node_limit,
endpoint_path_max_expansions=request.endpoint_path_max_expansions,
endpoint_path_max_frontier_states=request.endpoint_path_max_frontier_states,
endpoint_path_time_budget_seconds=request.endpoint_path_time_budget_seconds,
retrieval_only=True, retrieval_only=True,
) )
if isinstance(response, dict): if isinstance(response, dict):
@@ -0,0 +1,544 @@
from __future__ import annotations
import time
from typing import Any, Dict, Iterable, List, Tuple
from neo4j import Query
from .bounded_canonical_retriever import BoundedCanonicalGraphPathRetriever
from .graph_path_chain import BLOCKED_CATEGORIES, GraphHop, GraphPath, _norm, _text
from .schemas import RetrievalHypothesis
UNKNOWN_ANSWER_TYPES = {
"",
"any",
"none",
"not specified",
"unspecified",
"unknown",
}
class AssertionAnchoredGraphRetriever(BoundedCanonicalGraphPathRetriever):
"""Lock reranked vector assertions as hop 1, then expand their targets.
Qdrant and Neo4j were built from the same canonical assertion corpus. The
vector assertion ID is therefore a reliable cross-store join key. The
public query entrance remains natural-language/vector retrieval; canonical
entity IDs are used only after Neo4j has verified the selected assertion.
"""
CHAIN_SOURCE = "neo4j_vector_assertion_anchored_path"
def retrieve_from_assertions(
self,
question: str,
hypothesis: RetrievalHypothesis,
assertion_records: Iterable[Dict[str, Any]],
*,
relation_categories: Iterable[str] | None = None,
answer_type_groups: Iterable[str] | None = None,
max_hops: int = 3,
per_node_expand: int = 12,
beam_width: int = 80,
top_k_chains: int = 10,
hop_gate: Any | None = None,
hop_gate_context: Dict[str, Any] | None = None,
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
started = time.perf_counter()
max_hops = max(1, min(int(max_hops or 3), 3))
per_node_expand = max(1, min(int(per_node_expand or 12), 30))
beam_width = max(10, min(int(beam_width or 80), 200))
top_k_chains = max(0, min(int(top_k_chains or 10), 20))
records = self._clean_assertion_records(assertion_records)[:10]
answer_types = [
_norm(value)
for value in (answer_type_groups or [])
if _norm(value) not in UNKNOWN_ANSWER_TYPES
]
audit: Dict[str, Any] = {
"enabled": True,
"chain_source": self.CHAIN_SOURCE,
"policy": "qdrant_bge_top10_assertion_locked_as_hop1_then_target_expansion",
"requested_assertion_count": len(records),
"requested_assertion_ids": [row["assertion_id"] for row in records],
"max_hops": max_hops,
"per_node_expand": per_node_expand,
"beam_width": beam_width,
"top_k_chains": top_k_chains,
"answer_type_groups": answer_types,
"hop_stats": [],
"agentic_hop_gate": {
"enabled": hop_gate is not None,
"decisions": [],
},
"filtered_reasons": {},
"fallback_required": False,
}
if top_k_chains <= 0:
audit["status"] = "disabled_by_top_k"
return [], audit
if not records:
audit.update(
{
"status": "no_vector_assertions",
"fallback_required": True,
"elapsed_seconds": time.perf_counter() - started,
}
)
return [], audit
self._bounded_reset_metrics()
self._bounded_prepare_question(question)
first_rows = self._load_verified_first_hops(records)
audit["verified_first_hop_count"] = len(first_rows)
audit["verified_first_hop_assertion_ids"] = [
_text(row.get("assertion_id")) for row in first_rows
]
if not first_rows:
audit.update(
{
"status": "no_assertion_mapped_to_neo4j",
"fallback_required": True,
"bounded_graph_backend": dict(self._bounded_metrics),
"elapsed_seconds": time.perf_counter() - started,
}
)
return [], audit
question_norm = _norm(question)
allowed = self._allowed_categories(hypothesis, relation_categories)
allowed.update(self._categories_from_question(question_norm))
relation_clues = self._relation_clues(question)
priority_relation_clues = self._priority_relation_clues(question_norm)
seed_norms = {
value
for row in first_rows
for value in (_norm(row.get("source")), _norm(row.get("target")))
if value
}
entity_clues = self._visible_entity_clues(question_norm, seed_norms)
first_paths: List[GraphPath] = []
for row in first_rows:
edge_score, edge_breakdown = self._score_edge(
row, question_norm, seed_norms, allowed
)
vector_score = float(row.get("seed_rerank_score") or 0.0)
edge_score = min(1.0, 0.55 * edge_score + 0.45 * vector_score)
edge_breakdown.update(
{
"vector_assertion_first_hop_verified": 1.0,
"vector_rerank_score": vector_score,
}
)
hop = self._row_to_hop(row, hop_index=1, edge_score=edge_score, edge_breakdown=edge_breakdown)
path = GraphPath(
hops=[hop],
node_ids=[hop.source_canonical_id, hop.target_canonical_id],
)
path.path_score, path.score_breakdown = self._score_anchored_path(
path,
question_norm,
seed_norms,
allowed,
relation_clues,
answer_types,
entity_clues,
vector_score,
)
first_paths.append(path)
all_paths: List[GraphPath] = list(first_paths)
beam = self._select_beam_paths(
list(first_paths), beam_width, entity_clues=entity_clues
)
filtered: Dict[str, int] = {}
if hop_gate is not None and beam:
decision = hop_gate.decide(
question,
hypothesis,
beam,
current_hop=1,
safety_max_hops=max_hops,
task_context=hop_gate_context or {},
)
audit["agentic_hop_gate"]["decisions"].append(
self._public_hop_gate_decision(decision)
)
if decision["action"] == "continue":
beam = self._paths_at_indexes(
beam, decision.get("expand_path_indexes") or []
)
else:
beam = []
for hop_index in range(2, max_hops + 1):
frontier_ids = self._dedupe([path.end_id for path in beam if path.end_id])
if not frontier_ids:
break
rows = self._fetch_neighbors(
frontier_ids,
allowed,
relation_clues=relation_clues,
priority_relation_clues=priority_relation_clues,
entity_clues=entity_clues,
answer_type_groups=answer_types,
per_node_expand=per_node_expand,
)
paths_by_end: Dict[str, List[GraphPath]] = {}
for path in beam:
paths_by_end.setdefault(path.end_id, []).append(path)
next_paths: List[GraphPath] = []
for row in rows:
from_id = _text(row.get("from_id"))
to_id = _text(row.get("to_id"))
if not from_id or not to_id:
self._inc(filtered, "missing_node")
continue
if _text(row.get("relation_category")) in BLOCKED_CATEGORIES:
self._inc(filtered, "blocked_relation_category")
continue
if (
_text(row.get("source_canonical_id")) != from_id
or _text(row.get("target_canonical_id")) != to_id
):
self._inc(filtered, "non_directed_srt_continuity")
continue
for base in paths_by_end.get(from_id, []):
assertion_id = _text(row.get("assertion_id"))
if assertion_id and assertion_id in base.assertion_ids():
self._inc(filtered, "repeat_assertion")
continue
if to_id in base.node_ids:
self._inc(filtered, "repeat_node")
continue
edge_score, edge_breakdown = self._score_edge(
row, question_norm, seed_norms, allowed
)
relation_tokens = {
token
for token in _norm(row.get("relation")).split()
if len(token) >= 4
}
question_tokens = set(question_norm.split())
relation_question_overlap = (
len(relation_tokens & question_tokens)
/ max(1, len(relation_tokens))
)
edge_score = min(
1.0, edge_score + 0.18 * relation_question_overlap
)
edge_breakdown["relation_question_token_overlap"] = (
relation_question_overlap
)
if edge_score < 0.12:
self._inc(filtered, "low_edge_score")
continue
hop = self._row_to_hop(
row,
hop_index=hop_index,
edge_score=edge_score,
edge_breakdown=edge_breakdown,
)
path = GraphPath(
hops=base.hops + [hop],
node_ids=base.node_ids + [to_id],
)
vector_score = float(
base.score_breakdown.get("vector_first_hop_score") or 0.0
)
path.path_score, path.score_breakdown = self._score_anchored_path(
path,
question_norm,
seed_norms,
allowed,
relation_clues,
answer_types,
entity_clues,
vector_score,
)
next_paths.append(path)
next_paths = self._dedupe_paths(next_paths)
next_paths.sort(key=lambda item: item.path_score, reverse=True)
all_paths.extend(next_paths)
candidate_beam = self._select_beam_paths(
next_paths,
beam_width,
entity_clues=entity_clues,
)
if hop_gate is not None and candidate_beam:
decision = hop_gate.decide(
question,
hypothesis,
candidate_beam,
current_hop=hop_index,
safety_max_hops=max_hops,
task_context=hop_gate_context or {},
)
audit["agentic_hop_gate"]["decisions"].append(
self._public_hop_gate_decision(decision)
)
if decision["action"] == "continue":
beam = self._paths_at_indexes(
candidate_beam,
decision.get("expand_path_indexes") or [],
)
else:
beam = []
else:
beam = candidate_beam
audit["hop_stats"].append(
{
"hop": hop_index,
"frontier_count": len(frontier_ids),
"raw_edge_count": len(rows),
"candidate_path_count": len(next_paths),
"kept_beam_count": len(beam),
}
)
if not beam:
break
all_paths = self._dedupe_paths(all_paths)
selected = self._select_diverse_paths(all_paths, top_k_chains)
chains = []
for index, path in enumerate(selected, start=1):
chain = path.to_chain_dict(index)
chain["chain_source"] = self.CHAIN_SOURCE
chain["first_hop_assertion_id"] = path.hops[0].assertion_id if path.hops else ""
chain["first_hop_locked"] = True
chains.append(chain)
audit.update(
{
"status": "ok" if chains else "no_expanded_chain",
"fallback_required": not bool(chains),
"candidate_path_count": len(all_paths),
"returned_chain_count": len(chains),
"selected_hop_counts": {
str(hop): sum(1 for path in selected if len(path.hops) == hop)
for hop in range(1, max_hops + 1)
},
"filtered_reasons": filtered,
"bounded_graph_backend": dict(self._bounded_metrics),
"elapsed_seconds": time.perf_counter() - started,
}
)
return chains, audit
@staticmethod
def _paths_at_indexes(paths: List[GraphPath], indexes: Iterable[Any]) -> List[GraphPath]:
selected: List[GraphPath] = []
seen: set[int] = set()
for value in indexes:
try:
index = int(value)
except (TypeError, ValueError):
continue
if index in seen or index < 0 or index >= len(paths):
continue
seen.add(index)
selected.append(paths[index])
return selected
@staticmethod
def _public_hop_gate_decision(decision: Dict[str, Any]) -> Dict[str, Any]:
return {
key: value
for key, value in decision.items()
if key != "expand_path_indexes"
}
@staticmethod
def _clean_assertion_records(
assertion_records: Iterable[Dict[str, Any]],
) -> List[Dict[str, Any]]:
output: List[Dict[str, Any]] = []
seen: set[str] = set()
for record in assertion_records or []:
if not isinstance(record, dict):
continue
assertion_id = _text(record.get("assertion_id"))
if not assertion_id.startswith("cass_") or assertion_id in seen:
continue
seen.add(assertion_id)
output.append(dict(record))
return output
def _load_verified_first_hops(
self, records: List[Dict[str, Any]]
) -> List[Dict[str, Any]]:
statement = Query(
"""
UNWIND $records AS seed
MATCH (a:SRT2CanonicalAssertion {assertion_id: seed.assertion_id})
MATCH (s:SRT2CanonicalEntity {canonical_id: a.source_canonical_id})
MATCH (t:SRT2CanonicalEntity {canonical_id: a.target_canonical_id})
MATCH (s)-[r:SRT2_CANONICAL_ASSERTS]-(t)
WHERE r.assertion_id = a.assertion_id
RETURN seed.rank_after_bge AS seed_rank,
seed.final_score AS seed_final_score,
seed.rerank_score AS seed_rerank_score,
seed.vector_score AS seed_vector_score,
a.assertion_id AS assertion_id,
s.canonical_id AS from_id,
s.name AS from_name,
t.canonical_id AS to_id,
t.name AS to_name,
a.source AS source,
a.source_type AS source_type,
a.source_canonical_id AS source_canonical_id,
a.relation_norm AS relation,
a.relation_category AS relation_category,
a.target AS target,
a.target_type AS target_type,
a.target_canonical_id AS target_canonical_id,
a.best_evidence AS evidence,
a.best_paper_id AS paper_id,
a.title AS title,
a.pmid AS pmid,
a.pmcid AS pmcid,
a.doi AS doi,
'' AS species,
coalesce(a.support_count, 0) AS support_count,
coalesce(a.paper_count, 0) AS paper_count,
coalesce(a.best_score, 0.0) AS best_score,
1 AS category_match,
0 AS relation_clue_score
ORDER BY seed.rank_after_bge ASC
""",
timeout=self._bounded_neo4j_timeout,
)
with self.driver.session() as session:
return [dict(record) for record in session.run(statement, records=records)]
def _score_anchored_path(
self,
path: GraphPath,
question_norm: str,
seed_norms: set[str],
allowed: set[str],
relation_clues: List[str],
answer_types: List[str],
entity_clues: List[str],
vector_score: float,
) -> Tuple[float, Dict[str, Any]]:
base_score, breakdown = self._score_path(
path,
question_norm,
seed_norms,
allowed,
relation_clues,
answer_types,
entity_clues,
)
vector_score = max(0.0, min(float(vector_score or 0.0), 1.0))
multihop_bonus = 0.035 if len(path.hops) >= 2 else 0.0
terminal_relation_tokens = {
token
for token in _norm(path.hops[-1].relation if path.hops else "").split()
if len(token) >= 4
}
terminal_relation_overlap = (
len(terminal_relation_tokens & set(question_norm.split()))
/ max(1, len(terminal_relation_tokens))
)
final_score = min(
1.0,
0.68 * base_score
+ 0.32 * vector_score
+ multihop_bonus
+ 0.18 * terminal_relation_overlap,
)
breakdown.update(
{
"base_native_path_score": base_score,
"vector_first_hop_score": vector_score,
"vector_first_hop_weight": 0.32,
"first_hop_locked": True,
"multihop_bonus": multihop_bonus,
"terminal_relation_question_overlap": terminal_relation_overlap,
"final_score": final_score,
}
)
return final_score, breakdown
@staticmethod
def _row_to_hop(
row: Dict[str, Any],
*,
hop_index: int,
edge_score: float,
edge_breakdown: Dict[str, Any],
) -> GraphHop:
return GraphHop(
hop_index=hop_index,
from_id=_text(row.get("from_id")),
from_name=_text(row.get("from_name")),
to_id=_text(row.get("to_id")),
to_name=_text(row.get("to_name")),
source=_text(row.get("source")),
relation=_text(row.get("relation")),
target=_text(row.get("target")),
source_canonical_id=_text(row.get("source_canonical_id")),
target_canonical_id=_text(row.get("target_canonical_id")),
source_type=_text(row.get("source_type")),
target_type=_text(row.get("target_type")),
relation_category=_text(row.get("relation_category")),
assertion_id=_text(row.get("assertion_id")),
paper_id=_text(row.get("paper_id")),
title=_text(row.get("title")),
pmid=_text(row.get("pmid")),
pmcid=_text(row.get("pmcid")),
doi=_text(row.get("doi")),
species=_text(row.get("species")),
evidence=_text(row.get("evidence")),
support_count=int(row.get("support_count") or 0),
paper_count=int(row.get("paper_count") or 0),
best_score=float(row.get("best_score") or 0.0),
edge_score=edge_score,
score_breakdown=edge_breakdown,
passive_relation=False,
structural_direction=(
f"{_text(row.get('source'))} -> {_text(row.get('target'))}"
),
semantic_direction=(
f"{_text(row.get('source'))} -> {_text(row.get('target'))}"
),
)
@staticmethod
def _select_diverse_paths(
paths: List[GraphPath], top_k: int
) -> List[GraphPath]:
ranked = sorted(
paths,
key=lambda path: (len(path.hops) >= 2, path.path_score),
reverse=True,
)
selected: List[GraphPath] = []
selected_keys: set[Tuple[str, ...]] = set()
first_hop_counts: Dict[str, int] = {}
for path in ranked:
key = tuple(path.assertion_ids())
if not key or key in selected_keys:
continue
first = key[0]
if first_hop_counts.get(first, 0) >= 2:
continue
selected.append(path)
selected_keys.add(key)
first_hop_counts[first] = first_hop_counts.get(first, 0) + 1
if len(selected) >= top_k:
return selected
for path in ranked:
key = tuple(path.assertion_ids())
if not key or key in selected_keys:
continue
selected.append(path)
selected_keys.add(key)
if len(selected) >= top_k:
break
return selected
+206
View File
@@ -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
+59 -1
View File
@@ -14,6 +14,18 @@ _VISIBLE_PUBLIC = re.compile(
r"\[(PMID|PMCID|DOI)\s*[::]\s*([^\]]+)\]", r"\[(PMID|PMCID|DOI)\s*[::]\s*([^\]]+)\]",
re.I, re.I,
) )
_GROUPED_PUBLIC = re.compile(
r"\[((?:PMID|PMCID|DOI)\s*[::]\s*[^\]\n]+)\]",
re.I,
)
_GROUPED_PUBLIC_SEPARATOR = re.compile(
r"\s*[,;]\s*(?=(?:PMID|PMCID|DOI)\s*[::])",
re.I,
)
_GROUPED_PUBLIC_PART = re.compile(
r"^\s*(PMID|PMCID|DOI)\s*[::]\s*(.+?)\s*$",
re.I,
)
_CITATION_LIKE = re.compile(r"\[(PMID|PMCID|DOI)([^\]\n]*)(\]|$)", re.I) _CITATION_LIKE = re.compile(r"\[(PMID|PMCID|DOI)([^\]\n]*)(\]|$)", re.I)
_FULL_WIDTH_PUBLIC_CITATION = re.compile( _FULL_WIDTH_PUBLIC_CITATION = re.compile(
r"【(?:PMID|PMCID|DOI)\b[^】\n]*】", r"【(?:PMID|PMCID|DOI)\b[^】\n]*】",
@@ -68,6 +80,49 @@ def visible_public_citations(answer: str) -> list[str]:
return citations return citations
def normalize_grouped_public_citations(
answer: str,
allowed_citations: set[str],
) -> tuple[str, list[dict[str, object]]]:
"""Split a citation group only when every identifier is already allowlisted."""
allowed = set(allowed_citations)
audit: list[dict[str, object]] = []
def replace(match: re.Match[str]) -> str:
original = match.group(0)
parts = _GROUPED_PUBLIC_SEPARATOR.split(match.group(1))
if len(parts) < 2:
return original
normalized: list[str] = []
for part in parts:
parsed = _GROUPED_PUBLIC_PART.fullmatch(part)
if not parsed:
return original
kind, value = parsed.groups()
citations = visible_public_citations(
f"[{kind.upper()}: {str(value or '').strip()}]"
)
if len(citations) != 1 or citations[0] not in allowed:
return original
if citations[0] not in normalized:
normalized.append(citations[0])
replacement = " ".join(normalized)
audit.append(
{
"original": original,
"replacement": replacement,
"citation_count": len(normalized),
}
)
return replacement
normalized_answer = _GROUPED_PUBLIC.sub(replace, str(answer or ""))
return normalized_answer, audit
def internal_answer_labels(answer: str) -> list[str]: def internal_answer_labels(answer: str) -> list[str]:
text = str(answer or "") text = str(answer or "")
return sorted({match.group(0) for pattern in _INTERNAL_LABELS for match in pattern.finditer(text)}) return sorted({match.group(0) for pattern in _INTERNAL_LABELS for match in pattern.finditer(text)})
@@ -83,6 +138,9 @@ def malformed_citation_labels(answer: str) -> list[str]:
kind = match.group(1).upper() kind = match.group(1).upper()
tail = match.group(2).strip() tail = match.group(2).strip()
closing = match.group(3) closing = match.group(3)
if closing != "]":
malformed.add(match.group(0))
continue
value = re.sub(r"^[::]\s*", "", tail) value = re.sub(r"^[::]\s*", "", tail)
if kind == "PMID": if kind == "PMID":
identifier_shaped = bool(re.fullmatch(r"\d{5,10}", value)) identifier_shaped = bool(re.fullmatch(r"\d{5,10}", value))
@@ -91,7 +149,7 @@ def malformed_citation_labels(answer: str) -> list[str]:
else: else:
identifier_shaped = bool(re.fullmatch(r"10\.\d{4,9}/\S+", value, re.I)) identifier_shaped = bool(re.fullmatch(r"10\.\d{4,9}/\S+", value, re.I))
has_colon = bool(re.match(r"^[::]", tail)) has_colon = bool(re.match(r"^[::]", tail))
if identifier_shaped and (closing != "]" or not has_colon): if not identifier_shaped or not has_colon:
malformed.add(match.group(0)) malformed.add(match.group(0))
return sorted(malformed) return sorted(malformed)
+8 -1
View File
@@ -32,6 +32,13 @@ class QAConfig:
@classmethod @classmethod
def from_env(cls) -> "QAConfig": def from_env(cls) -> "QAConfig":
root = Path(os.environ.get("GENE_FAMILY_ROOT", "/data/gene-family-mas-v2")) root = Path(os.environ.get("GENE_FAMILY_ROOT", "/data/gene-family-mas-v2"))
graph_schema = os.environ.get(
"QA_V1_GRAPH_SCHEMA", "canonical"
).strip().lower()
if graph_schema != "canonical":
raise RuntimeError(
"QA V1.1.73 production only permits the canonical graph schema"
)
return cls( return cls(
project_root=root, project_root=root,
gfanalysis_root=Path( gfanalysis_root=Path(
@@ -74,7 +81,7 @@ class QAConfig:
== "1", == "1",
enable_bge_rerank=os.environ.get("QA_V1_ENABLE_BGE_RERANK", "1") enable_bge_rerank=os.environ.get("QA_V1_ENABLE_BGE_RERANK", "1")
== "1", == "1",
graph_schema=os.environ.get("QA_V1_GRAPH_SCHEMA", "legacy").strip().lower(), graph_schema=graph_schema,
enable_agentic_controller=os.environ.get( enable_agentic_controller=os.environ.get(
"QA_V1_ENABLE_AGENTIC_CONTROLLER", "1" "QA_V1_ENABLE_AGENTIC_CONTROLLER", "1"
) )
+18 -7
View File
@@ -12,6 +12,11 @@ from qdrant_client import QdrantClient
from .schemas import EvidenceItem, RetrievalHypothesis from .schemas import EvidenceItem, RetrievalHypothesis
ALLOWED_QDRANT_COLLECTIONS = {
"plant_srt_direct_v1_bge_m3_20260702",
"plant_srt_direct_v1_bge_m3_citations_20260717",
}
def _text(value: Any) -> str: def _text(value: Any) -> str:
return " ".join(str(value or "").split()) return " ".join(str(value or "").split())
@@ -57,11 +62,19 @@ class DirectFlowRetriever:
vector_query_workers: int = 2, vector_query_workers: int = 2,
) -> None: ) -> None:
self.graph_schema = ( self.graph_schema = (
graph_schema or os.environ.get("QA_V1_GRAPH_SCHEMA", "legacy") graph_schema or os.environ.get("QA_V1_GRAPH_SCHEMA", "canonical")
).strip().lower() ).strip().lower()
if self.graph_schema != "canonical":
raise RuntimeError(
"Legacy Entity/RELATION and PCEntity/PC_REL graphs are disabled"
)
self.vector_query_workers = max(1, min(int(vector_query_workers), 2)) self.vector_query_workers = max(1, min(int(vector_query_workers), 2))
self.qdrant_url = os.environ.get("V15_QDRANT_URL", os.environ.get("QDRANT_URL", "http://127.0.0.1:6333")) self.qdrant_url = os.environ.get("V15_QDRANT_URL", os.environ.get("QDRANT_URL", "http://127.0.0.1:6333"))
self.collection = os.environ.get("V15_QDRANT_COLLECTION", "plant_srt_direct_v1_bge_m3_20260702") self.collection = os.environ.get("V15_QDRANT_COLLECTION", "plant_srt_direct_v1_bge_m3_20260702")
if self.collection not in ALLOWED_QDRANT_COLLECTIONS:
raise RuntimeError(
f"Qdrant collection {self.collection!r} is not allowed in production"
)
self.embed_url = os.environ.get("QA_V112_EMBED_URL", os.environ.get("QA_V1_EMBED_URL", "http://127.0.0.1:11434/api/embed")) self.embed_url = os.environ.get("QA_V112_EMBED_URL", os.environ.get("QA_V1_EMBED_URL", "http://127.0.0.1:11434/api/embed"))
self.embed_model = os.environ.get("QA_V112_EMBED_MODEL", os.environ.get("QA_V1_EMBED_MODEL", "bge-m3:567m")) self.embed_model = os.environ.get("QA_V112_EMBED_MODEL", os.environ.get("QA_V1_EMBED_MODEL", "bge-m3:567m"))
self.neo4j_uri = os.environ.get("NEO4J_URI", "bolt://127.0.0.1:18788") self.neo4j_uri = os.environ.get("NEO4J_URI", "bolt://127.0.0.1:18788")
@@ -72,12 +85,10 @@ class DirectFlowRetriever:
load_paper_metadata_registry(Path(registry_path)) if registry_path else {} load_paper_metadata_registry(Path(registry_path)) if registry_path else {}
) )
self.qdrant = QdrantClient(url=self.qdrant_url, timeout=60) self.qdrant = QdrantClient(url=self.qdrant_url, timeout=60)
self.driver = None self.driver = GraphDatabase.driver(
if self.graph_schema == "canonical": self.neo4j_uri,
self.driver = GraphDatabase.driver( auth=(self.neo4j_user, self.neo4j_password),
self.neo4j_uri, )
auth=(self.neo4j_user, self.neo4j_password),
)
def close(self) -> None: def close(self) -> None:
if self.driver is not None: if self.driver is not None:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,337 @@
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
import os
from typing import Any, Dict, List, Tuple
from neo4j import GraphDatabase
from .direct_flow_retriever import DirectFlowRetriever, _text
from .schemas import EvidenceItem, RetrievalHypothesis
class FlatTop10SourceRetriever(DirectFlowRetriever):
"""Planner plus Qdrant-source seeds, then exact lookup in the flat graph.
Seed policy is intentionally explicit:
- retain graph-seed entities identified by the structured planner;
- globally sort Qdrant hits by vector score;
- retain the first 10 distinct ``source`` strings;
- merge and deduplicate those two seed sets;
- match only ``(:Entity {name: seed})``;
- never seed from vector targets, canonical IDs, or fuzzy names.
"""
def __init__(
self,
*,
graph_schema: str | None = None,
vector_query_workers: int = 2,
) -> None:
super().__init__(
graph_schema="legacy",
vector_query_workers=vector_query_workers,
)
if self.driver is not None:
self.driver.close()
self.graph_schema = "flat_top10_source"
self.driver = GraphDatabase.driver(
self.neo4j_uri,
auth=(self.neo4j_user, self.neo4j_password),
)
self.source_seed_top_k = max(
1, min(int(os.environ.get("QA_FLAT_SOURCE_SEED_TOP_K", "10")), 10)
)
self.graph_hard_limit = max(
100,
min(int(os.environ.get("QA_FLAT_GRAPH_HARD_LIMIT", "2000")), 5000),
)
def retrieve(
self,
question: str,
queries: Dict[str, List[str]],
hypothesis: RetrievalHypothesis,
*,
qdrant_top_k: int = 120,
graph_top_k: int = 80,
use_qdrant: bool = True,
use_neo4j: bool = True,
use_graph_expansion: bool = True,
) -> Tuple[List[EvidenceItem], Dict[str, Any]]:
audit: Dict[str, Any] = {
"policy": (
"planner_entities_plus_qdrant_vector_top10_distinct_source_"
"to_flat_neo4j_exact_name"
),
"qdrant": [],
"seed_extraction": {},
"neo4j": [],
"dedup": {},
"options": {
"qdrant_top_k": qdrant_top_k,
"requested_graph_top_k": graph_top_k,
"source_seed_top_k": self.source_seed_top_k,
"graph_hard_limit": self.graph_hard_limit,
"use_qdrant": bool(use_qdrant),
"use_neo4j": bool(use_neo4j),
"use_graph_expansion": bool(use_graph_expansion),
"canonical_ids_used": False,
"target_seeds_used": False,
"planner_seeds_used": True,
"fuzzy_name_matching_used": False,
},
"neo4j_status": "enabled_flat_exact_source" if use_neo4j else "disabled_by_request",
}
all_queries = self._flatten_queries(question, queries)
qdrant_items: List[EvidenceItem] = []
if use_qdrant:
per_query = max(10, min(qdrant_top_k // max(1, len(all_queries)), 50))
if self.vector_query_workers == 1 or len(all_queries) == 1:
query_rows = [
(query, self.qdrant_search(query, per_query))
for query in all_queries
]
else:
with ThreadPoolExecutor(
max_workers=min(self.vector_query_workers, len(all_queries)),
thread_name_prefix="qa-flat-vector",
) as executor:
futures = [
executor.submit(self.qdrant_search, query, per_query)
for query in all_queries
]
query_rows = [
(query, future.result())
for query, future in zip(all_queries, futures)
]
for query, rows in query_rows:
audit["qdrant"].append({"query": query, "rows": len(rows)})
qdrant_items.extend(rows)
vector_sources = self._top_vector_sources(qdrant_items)
planner_sources = self._planner_sources(hypothesis, vector_sources)
sources = self._merge_graph_sources(planner_sources, vector_sources)
audit["seed_extraction"] = {
"seed_basis": (
"structured_planner_entities_union_"
"raw_qdrant_vector_top10_distinct_source"
),
"planner_seed_count": len(planner_sources),
"planner_seeds": planner_sources,
"vector_top10_source_seed_count": len(vector_sources),
"vector_top10_source_seeds": vector_sources,
"final_graph_seed_count": len(sources),
"final_graph_seeds": sources,
}
graph_items: List[EvidenceItem] = []
graph_audit: Dict[str, Any] = {
"requested_source_count": len(sources),
"matched_source_count": 0,
"unmatched_sources": [item["source"] for item in sources],
"rows": 0,
}
if use_neo4j and sources:
graph_items, graph_audit = self.flat_source_search(sources)
audit["neo4j"].append(graph_audit)
merged = self.dedup(qdrant_items + graph_items)
audit["dedup"] = {
"qdrant_items": len(qdrant_items),
"graph_items": len(graph_items),
"merged_items": len(merged),
}
return merged, audit
@staticmethod
def _planner_sources(
hypothesis: RetrievalHypothesis,
vector_sources: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
output: List[Dict[str, Any]] = []
seen: set[str] = set()
vector_ceiling = max(
(float(item.get("vector_score") or 0.0) for item in vector_sources),
default=0.7,
)
entities = list(hypothesis.direct_question_entities or [])
entities.extend(
entity
for entity in (hypothesis.hypothesized_entities or [])
if bool(entity.use_as_graph_seed)
)
for entity in entities:
matched = _text(entity.matched_kb_entity)
if matched.startswith(("cent_", "cass_", "asrt_", "ent_")):
matched = ""
seed = matched or _text(entity.canonical_candidate) or _text(entity.mention)
if not seed or seed in seen:
continue
seen.add(seed)
output.append(
{
"rank": len(output) + 1,
"source": seed,
"source_type": _text(entity.entity_type),
"vector_score": vector_ceiling,
"seed_origin": "planner",
"planner_mention": _text(entity.mention),
"planner_canonical_candidate": _text(entity.canonical_candidate),
"planner_confidence": float(entity.confidence or 0.0),
}
)
return output
@staticmethod
def _merge_graph_sources(
planner_sources: List[Dict[str, Any]],
vector_sources: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
output: List[Dict[str, Any]] = []
by_source: Dict[str, Dict[str, Any]] = {}
for item in planner_sources + vector_sources:
source = _text(item.get("source"))
if not source:
continue
if source in by_source:
origins = by_source[source].setdefault("seed_origins", [])
origin = _text(item.get("seed_origin") or "vector_top10_source")
if origin not in origins:
origins.append(origin)
continue
record = dict(item)
origin = _text(record.get("seed_origin") or "vector_top10_source")
record["seed_origin"] = origin
record["seed_origins"] = [origin]
record["final_rank"] = len(output) + 1
by_source[source] = record
output.append(record)
return output
def _top_vector_sources(
self, items: List[EvidenceItem]
) -> List[Dict[str, Any]]:
output: List[Dict[str, Any]] = []
seen: set[str] = set()
for item in sorted(
items,
key=lambda value: float(value.vector_score or 0.0),
reverse=True,
):
source = _text(item.source)
if not source or source in seen:
continue
seen.add(source)
output.append(
{
"rank": len(output) + 1,
"source": source,
"source_type": _text((item.raw or {}).get("source_type")),
"vector_score": float(item.vector_score or 0.0),
"assertion_id": _text(item.relation_id),
"relation": _text(item.relation),
"target": _text(item.target),
"seed_origin": "vector_top10_source",
}
)
if len(output) >= self.source_seed_top_k:
break
return output
def flat_source_search(
self, sources: List[Dict[str, Any]]
) -> Tuple[List[EvidenceItem], Dict[str, Any]]:
statement = """
UNWIND $seeds AS seed
MATCH (s:Entity {name: seed.source})-[r:RELATION]->(t:Entity)
RETURN seed.rank AS seed_rank,
seed.source AS requested_source,
seed.vector_score AS seed_vector_score,
seed.seed_origin AS seed_origin,
s.entity_id AS source_entity_id,
s.name AS source,
s.entity_type AS source_type,
r.relation_id AS relation_id,
coalesce(r.relation_norm, r.relation) AS relation,
t.entity_id AS target_entity_id,
t.name AS target,
t.entity_type AS target_type,
r.evidence AS evidence,
r.paper_id AS paper_id,
r.pubmed_id AS pmid,
r.section AS section,
r.output_layer AS output_layer,
r.claim_scope AS claim_scope,
r.candidate_status AS candidate_status,
r.triple_quality AS triple_quality,
r.confidence AS confidence,
r.source_label AS source_label
LIMIT $hard_limit
"""
with self.driver.session() as session:
rows = [
dict(record)
for record in session.run(
statement,
seeds=sources,
hard_limit=self.graph_hard_limit,
)
]
matched = {
_text(row.get("requested_source"))
for row in rows
if _text(row.get("requested_source"))
}
output: List[EvidenceItem] = []
for row in rows:
payload = {
"relation_id": row.get("relation_id"),
"assertion_id": row.get("relation_id"),
"source": row.get("source"),
"source_type": row.get("source_type"),
"source_entity_id": row.get("source_entity_id"),
"relation": row.get("relation"),
"relation_norm": row.get("relation"),
"target": row.get("target"),
"target_type": row.get("target_type"),
"target_entity_id": row.get("target_entity_id"),
"evidence": row.get("evidence"),
"paper_id": row.get("paper_id"),
"pmid": row.get("pmid"),
"section": row.get("section"),
"output_layer": row.get("output_layer"),
"claim_scope": row.get("claim_scope"),
"candidate_status": row.get("candidate_status"),
"triple_quality": row.get("triple_quality"),
"confidence": row.get("confidence"),
"source_label": row.get("source_label"),
}
item = self.item_from_payload(
payload,
"neo4j_flat_exact_source",
_text(row.get("requested_source")),
)
item.raw["graph_seed_origin"] = _text(row.get("seed_origin"))
seed_score = float(row.get("seed_vector_score") or 0.0)
item.vector_score = seed_score
item.rrf_score = max(item.rrf_score, seed_score * 0.98)
output.append(item)
requested = [_text(item.get("source")) for item in sources]
return output, {
"method": "neo4j_flat_exact_source",
"query_shape": (
"UNWIND planner + vector top10 source seeds -> "
"Entity{name} index seek -> outgoing RELATION"
),
"requested_source_count": len(requested),
"matched_source_count": len(matched),
"matched_sources": [name for name in requested if name in matched],
"unmatched_sources": [name for name in requested if name not in matched],
"rows": len(rows),
"hard_limit": self.graph_hard_limit,
"truncated": len(rows) >= self.graph_hard_limit,
}
+204 -30
View File
@@ -1,13 +1,27 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging
import os import os
import random
import re import re
import time
from typing import Any, Dict, List from typing import Any, Dict, List
import requests import requests
LOGGER = logging.getLogger(__name__)
class LLMResponseError(RuntimeError):
"""Raised only after all bounded upstream recovery attempts are exhausted."""
class LLMNonRetryableError(LLMResponseError):
"""Raised immediately for permanent upstream HTTP failures."""
class LLMClient: class LLMClient:
def __init__(self) -> None: def __init__(self) -> None:
self.api_key = ( self.api_key = (
@@ -27,6 +41,19 @@ class LLMClient:
or "deepseek-v4-flash" or "deepseek-v4-flash"
) )
self.timeout = float(os.environ.get("QA_V1_LLM_TIMEOUT", "120")) self.timeout = float(os.environ.get("QA_V1_LLM_TIMEOUT", "120"))
self.max_attempts = max(
1, min(int(os.environ.get("QA_V1_LLM_MAX_ATTEMPTS", "4")), 8)
)
self.retry_base_seconds = max(
0.0, float(os.environ.get("QA_V1_LLM_RETRY_BASE_SECONDS", "1.5"))
)
self.retry_max_seconds = max(
self.retry_base_seconds,
float(os.environ.get("QA_V1_LLM_RETRY_MAX_SECONDS", "12")),
)
self.json_attempts = max(
1, min(int(os.environ.get("QA_V1_LLM_JSON_ATTEMPTS", "2")), 4)
)
@property @property
def available(self) -> bool: def available(self) -> bool:
@@ -39,31 +66,131 @@ class LLMClient:
*, *,
temperature: float = 0.1, temperature: float = 0.1,
max_tokens: int = 2000, max_tokens: int = 2000,
json_mode: bool = False,
request_attempts: int | None = None,
) -> str: ) -> str:
if not self.api_key: if not self.api_key:
raise RuntimeError("QA V1 LLM API key is not configured") raise RuntimeError("QA V1 LLM API key is not configured")
response = requests.post( request_body = {
f"{self.base_url}/chat/completions", "model": self.model,
headers={ "messages": [
"Authorization": f"Bearer {self.api_key}", {"role": "system", "content": system},
"Content-Type": "application/json", {"role": "user", "content": user},
}, ],
json={ "temperature": temperature,
"model": self.model, "max_tokens": max_tokens,
"messages": [ "stream": False,
{"role": "system", "content": system}, }
{"role": "user", "content": user}, if json_mode:
], request_body["response_format"] = {"type": "json_object"}
"temperature": temperature, attempt_limit = self.max_attempts
"max_tokens": max_tokens, if request_attempts is not None:
"stream": False, attempt_limit = max(1, min(int(request_attempts), self.max_attempts))
}, last_error = "unknown upstream failure"
timeout=self.timeout, for attempt in range(1, attempt_limit + 1):
started = time.perf_counter()
try:
response = requests.post(
f"{self.base_url}/chat/completions",
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
json=request_body,
timeout=self.timeout,
)
try:
payload = response.json()
except Exception as exc:
error_type = (
LLMNonRetryableError
if response.status_code >= 400
and response.status_code not in {
408,
409,
425,
429,
500,
502,
503,
504,
}
else LLMResponseError
)
raise error_type(
f"LLM HTTP {response.status_code} returned non-JSON content"
) from exc
if response.status_code >= 400:
last_error = f"LLM HTTP {response.status_code}: {payload}"
if response.status_code not in {
408,
409,
425,
429,
500,
502,
503,
504,
}:
raise LLMNonRetryableError(last_error)
raise requests.HTTPError(last_error, response=response)
choices = payload.get("choices") if isinstance(payload, dict) else None
if not isinstance(choices, list) or not choices:
last_error = "LLM response contains no choices"
raise LLMResponseError(last_error)
choice = choices[0] if isinstance(choices[0], dict) else {}
message = choice.get("message") if isinstance(choice, dict) else {}
content = self._message_text(message)
finish_reason = str(choice.get("finish_reason") or "")
if finish_reason.strip().lower() in {"length", "max_tokens"}:
last_error = (
"LLM output was truncated by the token limit; "
f"content_chars={len(content)}"
)
raise LLMResponseError(last_error)
if content:
if attempt > 1:
LOGGER.warning(
"LLM call recovered on attempt %s/%s after %.2fs; "
"finish_reason=%s content_chars=%s",
attempt,
attempt_limit,
time.perf_counter() - started,
finish_reason,
len(content),
)
return content
last_error = (
"LLM returned empty message.content; "
f"finish_reason={finish_reason or '<missing>'}; "
f"has_reasoning_content={bool((message or {}).get('reasoning_content'))}"
)
raise LLMResponseError(last_error)
except LLMNonRetryableError as exc:
last_error = f"{type(exc).__name__}: {exc}"
LOGGER.warning(
"LLM call failed without retry after %.2fs: %s",
time.perf_counter() - started,
last_error[:800],
)
raise
except Exception as exc:
last_error = f"{type(exc).__name__}: {exc}"
LOGGER.warning(
"LLM call attempt %s/%s failed after %.2fs: %s",
attempt,
attempt_limit,
time.perf_counter() - started,
last_error[:800],
)
if attempt >= attempt_limit:
break
self._sleep_before_retry(attempt)
raise LLMResponseError(
f"LLM failed after {attempt_limit} attempts: {last_error}"
) )
payload = response.json()
if response.status_code >= 400:
raise RuntimeError(f"LLM HTTP {response.status_code}: {payload}")
return str(payload["choices"][0]["message"]["content"]).strip()
def complete_json( def complete_json(
self, self,
@@ -71,21 +198,68 @@ class LLMClient:
user: str, user: str,
*, *,
max_tokens: int = 2500, max_tokens: int = 2500,
request_attempts: int | None = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
text = self.complete( text = ""
system + "\nReturn valid JSON only. Do not use Markdown fences.", for json_attempt in range(1, self.json_attempts + 1):
user, repair_note = ""
temperature=0.0, if json_attempt > 1:
max_tokens=max_tokens, repair_note = (
) "\nA previous response was not a complete JSON object. Return a "
parsed = self._parse_json_object(text) "single compact JSON object with every object and array closed."
if isinstance(parsed, dict): )
return parsed text = self.complete(
system
+ "\nReturn valid JSON only. Do not use Markdown fences."
+ repair_note,
user,
temperature=0.0,
max_tokens=max_tokens,
json_mode=True,
request_attempts=request_attempts,
)
parsed = self._parse_json_object(text)
if isinstance(parsed, dict):
return parsed
LOGGER.warning(
"LLM JSON parse attempt %s/%s failed; raw_chars=%s",
json_attempt,
self.json_attempts,
len(text),
)
return { return {
"_json_parse_error": "LLM returned JSON that could not be parsed", "_json_parse_error": "LLM returned JSON that could not be parsed",
"_raw_text_head": text[:1200], "_raw_text_head": text[:1200],
} }
@staticmethod
def _message_text(message: Any) -> str:
if not isinstance(message, dict):
return ""
content = message.get("content")
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
parts: List[str] = []
for block in content:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict):
value = block.get("text") or block.get("content")
if isinstance(value, str):
parts.append(value)
return "\n".join(part.strip() for part in parts if part.strip()).strip()
return ""
def _sleep_before_retry(self, attempt: int) -> None:
delay = min(
self.retry_max_seconds,
self.retry_base_seconds * (2 ** max(0, attempt - 1)),
)
if delay <= 0:
return
time.sleep(delay + random.uniform(0.0, min(0.5, delay * 0.2)))
@staticmethod @staticmethod
def _parse_json_object(text: str) -> Dict[str, Any] | None: def _parse_json_object(text: str) -> Dict[str, Any] | None:
candidates: List[str] = [] candidates: List[str] = []
File diff suppressed because it is too large Load Diff
+56 -17
View File
@@ -5,6 +5,8 @@ import re
from copy import deepcopy from copy import deepcopy
from typing import Any, Dict, Iterable, List, Optional from typing import Any, Dict, Iterable, List, Optional
from .agentic_controller import AgenticOutputError
PROMPT_VERSION = "question_decomposition_v1" PROMPT_VERSION = "question_decomposition_v1"
MAX_SUBQUESTIONS = 6 MAX_SUBQUESTIONS = 6
@@ -68,6 +70,13 @@ Decomposition rules
caller-supplied verified entities/relation intents. For a simple "how does X caller-supplied verified entities/relation intents. For a simple "how does X
help under Y" question, prefer Q0 plus one broad mechanism slot; retrieval, help under Y" question, prefer Q0 plus one broad mechanism slot; retrieval,
not decomposition, must discover the individual mechanisms. not decomposition, must discover the individual mechanisms.
12. An endpoint-constrained connection question such as "what connects X with
Y, including intermediate entities" asks for a graph path, not necessarily a
molecular mechanism. Classify it as other unless the question explicitly
requests a molecular or physiological mechanism. Prefer Q0 alone because it
already contains both endpoints and the requested path; add a supporting
subquestion only when the original question contains a genuinely distinct
answer slot.
Fixed execution contract (the runtime will enforce these values) Fixed execution contract (the runtime will enforce these values)
- retrieval.vector_top_k: 30 - retrieval.vector_top_k: 30
@@ -206,17 +215,58 @@ class QuestionDecompositionPlanner:
raw = self.llm.complete_json( raw = self.llm.complete_json(
QUESTION_DECOMPOSITION_PROMPT, QUESTION_DECOMPOSITION_PROMPT,
json.dumps(payload, ensure_ascii=False), json.dumps(payload, ensure_ascii=False),
max_tokens=5000, max_tokens=16000,
) )
json_repair_used = False
if not isinstance(raw, dict) or raw.get("_json_parse_error"): if not isinstance(raw, dict) or raw.get("_json_parse_error"):
raise ValueError("question decomposition is not valid JSON") json_repair_used = True
raw_head = (
_text(raw.get("_raw_text_head"))
if isinstance(raw, dict)
else ""
)
repair_payload = {
**payload,
"format_repair": (
"The previous DECOMPOSE response was empty, incomplete, or "
"malformed. Return one complete compact JSON object matching "
"the required decomposition schema."
),
}
raw = self.llm.complete_json(
QUESTION_DECOMPOSITION_PROMPT
+ "\nThis is a single format-repair retry. Close every object and "
"array. Return the compact DECOMPOSE JSON object only.",
json.dumps(repair_payload, ensure_ascii=False),
max_tokens=16000,
)
if not isinstance(raw, dict) or raw.get("_json_parse_error"):
raw_head = (
_text(raw.get("_raw_text_head"))
if isinstance(raw, dict)
else raw_head
)
raise ValueError(
"question decomposition is not valid JSON"
+ (f"; raw_head={raw_head[:300]!r}" if raw_head else "; raw_head=<empty>")
)
plan = self._sanitize(raw, fallback, question, entities, relations) plan = self._sanitize(raw, fallback, question, entities, relations)
plan["plan_source"] = "llm_agent" plan["plan_source"] = (
"llm_agent_json_repair" if json_repair_used else "llm_agent"
)
plan["plan_json_attempts"] = 2 if json_repair_used else 1
return plan return plan
except Exception as exc: except Exception as exc:
fallback["plan_source"] = "deterministic_fallback" recovered = deepcopy(fallback)
fallback["fallback_reason"] = f"{type(exc).__name__}: {exc}"[:500] recovered["plan_source"] = (
return fallback "deterministic_recovery_after_llm_decomposition_failure"
)
recovered["plan_json_attempts"] = 0
recovered["recovery_error"] = (
f"{type(exc).__name__}: {exc}"
)[:1500]
recovered["recovery_degraded"] = True
return recovered
def _sanitize( def _sanitize(
self, self,
@@ -281,17 +331,6 @@ class QuestionDecompositionPlanner:
break break
if not items: if not items:
raise ValueError("decomposition contains no usable subquestions") raise ValueError("decomposition contains no usable subquestions")
if question_type == "mechanism" and len(items) == 1:
safe_support = self._safe_mechanism_support(
verified_entities, allowed_relation_intents
)
item = self._sanitize_subquestion(
safe_support, 1, safe_support["question"]
)
item["anchors"] = self._implicit_verified_anchors(
item["question"], verified_entities
)
items.append(item)
plan = deepcopy(fallback) plan = deepcopy(fallback)
plan.update( plan.update(
+7 -30
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
from typing import Any, Dict, List, Tuple from typing import Any, Dict, List, Tuple
from .pc_retriever import PCIndependentRetriever
from .schemas import EvidenceItem from .schemas import EvidenceItem
from .v16_retriever import V16IndependentRetriever from .v16_retriever import V16IndependentRetriever
@@ -13,8 +12,8 @@ def _text(value: Any) -> str:
DEFAULT_RETRIEVAL_OPTIONS: Dict[str, bool] = { DEFAULT_RETRIEVAL_OPTIONS: Dict[str, bool] = {
"use_v15": True, "use_v15": True,
"use_pc": True, "use_pc": False,
"use_sqlite": True, "use_sqlite": False,
"use_qdrant": True, "use_qdrant": True,
"use_neo4j": True, "use_neo4j": True,
"use_graph_expansion": True, "use_graph_expansion": True,
@@ -24,6 +23,10 @@ DEFAULT_RETRIEVAL_OPTIONS: Dict[str, bool] = {
def normalize_retrieval_options(options: Dict[str, Any] | None) -> Dict[str, bool]: def normalize_retrieval_options(options: Dict[str, Any] | None) -> Dict[str, bool]:
normalized = dict(DEFAULT_RETRIEVAL_OPTIONS) normalized = dict(DEFAULT_RETRIEVAL_OPTIONS)
if isinstance(options, dict): if isinstance(options, dict):
if options.get("use_pc") or options.get("use_sqlite"):
raise ValueError(
"PC and SQLite retrieval backends are disabled; use Neo4j and Qdrant"
)
for key in list(normalized): for key in list(normalized):
if key in options: if key in options:
normalized[key] = bool(options[key]) normalized[key] = bool(options[key])
@@ -38,7 +41,6 @@ class DualKBRetriever:
def __init__(self) -> None: def __init__(self) -> None:
self.v15 = V16IndependentRetriever() self.v15 = V16IndependentRetriever()
self.pc = PCIndependentRetriever()
def retrieve_queries( def retrieve_queries(
self, self,
@@ -66,10 +68,6 @@ class DualKBRetriever:
options, options,
) )
) )
if options["use_pc"]:
output.extend(
self._retrieve_pc(query, lane, top_k_per_query, graph_hops, options)
)
audit.append( audit.append(
{ {
"lane": lane, "lane": lane,
@@ -96,7 +94,7 @@ class DualKBRetriever:
top_k=top_k, top_k=top_k,
max_hops=graph_hops, max_hops=graph_hops,
include_candidate=include_candidate, include_candidate=include_candidate,
use_sqlite=options["use_sqlite"], use_sqlite=False,
use_qdrant=options["use_qdrant"], use_qdrant=options["use_qdrant"],
use_neo4j=options["use_neo4j"], use_neo4j=options["use_neo4j"],
use_graph_expansion=options["use_graph_expansion"], use_graph_expansion=options["use_graph_expansion"],
@@ -108,27 +106,6 @@ class DualKBRetriever:
items.append(self._from_row(row, "V16/V15", f"{lane}:{method}", query)) items.append(self._from_row(row, "V16/V15", f"{lane}:{method}", query))
return items return items
def _retrieve_pc(
self,
query: str,
lane: str,
top_k: int,
graph_hops: int,
options: Dict[str, bool],
) -> List[EvidenceItem]:
try:
rows = self.pc.search(
query,
top_k=top_k,
max_hops=graph_hops,
use_qdrant=options["use_qdrant"],
use_neo4j=options["use_neo4j"],
use_graph_expansion=options["use_graph_expansion"],
)
except Exception:
return []
return [self._from_row(row, "PC", f"{lane}:pc_hybrid", query) for row in rows]
@staticmethod @staticmethod
def _from_row(row: Dict[str, Any], kb: str, method: str, query: str) -> EvidenceItem: def _from_row(row: Dict[str, Any], kb: str, method: str, query: str) -> EvidenceItem:
metadata = row.get("metadata") if isinstance(row.get("metadata"), dict) else {} metadata = row.get("metadata") if isinstance(row.get("metadata"), dict) else {}
+12 -22
View File
@@ -4,6 +4,8 @@ from concurrent.futures import ThreadPoolExecutor
from copy import deepcopy from copy import deepcopy
from typing import Any, Callable, Dict, Iterable, List from typing import Any, Callable, Dict, Iterable, List
from .agentic_controller import AgenticOutputError
_STATUSES = {"SATISFIED", "PARTIAL", "EMPTY"} _STATUSES = {"SATISFIED", "PARTIAL", "EMPTY"}
_REFINEMENT_KEYS = { _REFINEMENT_KEYS = {
@@ -80,7 +82,7 @@ class SubquestionRetrievalOrchestrator:
first_payload = [self._review_payload(states[task["subquestion_id"]]) for task in tasks] first_payload = [self._review_payload(states[task["subquestion_id"]]) for task in tasks]
self._emit(progress_callback, "agent_review_started", {"attempt": 1}) self._emit(progress_callback, "agent_review_started", {"attempt": 1})
first_review = self._safe_review(review, question, first_payload, 1) first_review = self._required_review(review, question, first_payload, 1)
review_rounds.append(deepcopy(first_review)) review_rounds.append(deepcopy(first_review))
self._apply_review(states, first_review) self._apply_review(states, first_review)
@@ -111,7 +113,7 @@ class SubquestionRetrievalOrchestrator:
) )
second_payload = [self._review_payload(states[value]) for value in retry_ids] second_payload = [self._review_payload(states[value]) for value in retry_ids]
self._emit(progress_callback, "agent_review_started", {"attempt": 2}) self._emit(progress_callback, "agent_review_started", {"attempt": 2})
second_review = self._safe_review(review, question, second_payload, 2) second_review = self._required_review(review, question, second_payload, 2)
review_rounds.append(deepcopy(second_review)) review_rounds.append(deepcopy(second_review))
self._apply_review(states, second_review) self._apply_review(states, second_review)
for subquestion_id in retry_ids: for subquestion_id in retry_ids:
@@ -373,31 +375,19 @@ class SubquestionRetrievalOrchestrator:
} }
@staticmethod @staticmethod
def _safe_review( def _required_review(
review: Callable[..., Dict[str, Any]], review: Callable[..., Dict[str, Any]],
question: str, question: str,
groups: List[Dict[str, Any]], groups: List[Dict[str, Any]],
attempt: int, attempt: int,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
try: result = review(question, groups, attempt=attempt)
result = review(question, groups, attempt=attempt) if not isinstance(result, dict):
if isinstance(result, dict): raise AgenticOutputError(
return result "evidence_review",
except Exception: "subquestion review must return a JSON object",
pass )
return { return result
"groups": [
{
"subquestion_id": group["subquestion_id"],
"status": "PARTIAL" if group["evidence"] or group["chains"] else "EMPTY",
"accepted_evidence_ids": [],
"accepted_chain_ids": [],
"refinement": {},
}
for group in groups
],
"review_source": "deterministic_safe_fallback",
}
@staticmethod @staticmethod
def _apply_review(states: Dict[str, Dict[str, Any]], review: Dict[str, Any]) -> None: def _apply_review(states: Dict[str, Dict[str, Any]], review: Dict[str, Any]) -> None:
+52
View File
@@ -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
+205
View File
@@ -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,
]