- 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/
362 lines
15 KiB
Python
362 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Tuple
|
|
|
|
from neo4j import GraphDatabase
|
|
|
|
from src.rag.dual_kb_taxonomy_rag.entity_alias import EntityAliasNormalizer
|
|
|
|
from .schemas import EvidenceItem
|
|
|
|
|
|
def _text(value: Any) -> str:
|
|
return str(value or "").strip()
|
|
|
|
|
|
def _norm(value: Any) -> str:
|
|
return " ".join(_text(value).lower().replace("_", " ").split())
|
|
|
|
|
|
def _type(value: Any) -> str:
|
|
return _norm(value).replace(" ", "_")
|
|
|
|
|
|
def _pmcid(value: Any) -> str:
|
|
match = re.search(r"\bPMC\d+", _text(value), re.IGNORECASE)
|
|
return match.group(0).upper() if match else ""
|
|
|
|
|
|
class AgenticGraphRetriever:
|
|
def __init__(
|
|
self,
|
|
driver: Any | None = None,
|
|
*,
|
|
graph_schema: str | None = None,
|
|
alias_path: str | Path | None = None,
|
|
):
|
|
self._owns_driver = driver is None
|
|
self.graph_schema = (
|
|
graph_schema or os.environ.get("QA_V1_GRAPH_SCHEMA", "canonical")
|
|
).strip().lower()
|
|
resolved_alias_path = alias_path or (
|
|
Path(__file__).resolve().parents[3] / "configs/rag/entity_alias.yaml"
|
|
)
|
|
self.aliaser = EntityAliasNormalizer(resolved_alias_path)
|
|
self.driver = driver or GraphDatabase.driver(
|
|
os.environ.get("NEO4J_URI", "bolt://127.0.0.1:18788"),
|
|
auth=(
|
|
os.environ.get("NEO4J_USER", "neo4j"),
|
|
os.environ.get("NEO4J_PASSWORD", ""),
|
|
),
|
|
)
|
|
|
|
def close(self) -> None:
|
|
if self._owns_driver and self.driver is not None:
|
|
self.driver.close()
|
|
|
|
def retrieve(
|
|
self, plan: Dict[str, Any]
|
|
) -> Tuple[List[EvidenceItem], List[Dict[str, Any]], Dict[str, Any]]:
|
|
anchors = [item for item in plan.get("anchors") or [] if isinstance(item, dict)]
|
|
anchor_ids = [
|
|
_text(item.get("canonical_id"))
|
|
for item in anchors
|
|
if _text(item.get("canonical_id")).startswith("cent_")
|
|
]
|
|
anchor_names = []
|
|
for item in anchors:
|
|
for value in (item.get("canonical_name"), item.get("mention")):
|
|
normalized = _norm(value)
|
|
if normalized and normalized not in anchor_names:
|
|
anchor_names.append(normalized)
|
|
canonical_name = _text(item.get("canonical_name"))
|
|
alias_entry = self.aliaser.entries.get(canonical_name) or {}
|
|
for value in alias_entry.get("aliases") or []:
|
|
normalized = _norm(value)
|
|
if normalized and normalized not in anchor_names:
|
|
anchor_names.append(normalized)
|
|
|
|
relation_plan = plan.get("relation_plan") if isinstance(plan.get("relation_plan"), dict) else {}
|
|
graph_plan = plan.get("graph_plan") if isinstance(plan.get("graph_plan"), dict) else {}
|
|
allow_incoming = bool(relation_plan.get("allow_incoming", True))
|
|
allow_outgoing = bool(relation_plan.get("allow_outgoing", True))
|
|
terminal_types = {
|
|
_type(value)
|
|
for value in (
|
|
graph_plan.get("required_terminal_types")
|
|
or [
|
|
entity_type
|
|
for target in plan.get("answer_targets") or []
|
|
for entity_type in target.get("allowed_entity_types") or []
|
|
]
|
|
)
|
|
if _type(value)
|
|
}
|
|
terminal_type_wildcard = "unknown" in terminal_types
|
|
# Planner uses "unknown" to mean that no terminal type is constrained.
|
|
terminal_types.discard("unknown")
|
|
relation_categories = {
|
|
_norm(value)
|
|
for value in relation_plan.get("allowed_relation_categories") or []
|
|
if _norm(value)
|
|
}
|
|
audit: Dict[str, Any] = {
|
|
"enabled": bool(graph_plan.get("enabled", True)),
|
|
"anchor_ids": anchor_ids,
|
|
"anchor_names": anchor_names,
|
|
"allow_incoming": allow_incoming,
|
|
"allow_outgoing": allow_outgoing,
|
|
"required_terminal_types": sorted(terminal_types),
|
|
"terminal_type_wildcard": terminal_type_wildcard,
|
|
"max_hops": max(1, min(int(graph_plan.get("max_hops", 1)), 3)),
|
|
"query_mode": (
|
|
"legacy_anchor_neighborhood"
|
|
if self.graph_schema == "legacy"
|
|
else "canonical_anchor_neighborhood"
|
|
),
|
|
"preserve_assertion_direction": True,
|
|
"filtered_reasons": {},
|
|
}
|
|
if not audit["enabled"]:
|
|
audit["status"] = "disabled_by_plan"
|
|
return [], [], audit
|
|
if not anchor_ids and not anchor_names:
|
|
audit["status"] = "no_verified_anchor"
|
|
return [], [], audit
|
|
if not allow_incoming and not allow_outgoing:
|
|
audit["status"] = "no_direction_enabled"
|
|
return [], [], audit
|
|
|
|
rows = self.driver.execute_query(
|
|
self._statement(),
|
|
parameters_={
|
|
"anchor_ids": anchor_ids,
|
|
"anchor_names": anchor_names,
|
|
"limit": 100 if self.graph_schema == "legacy" else 300,
|
|
"layers": [
|
|
"main_kg",
|
|
"background_relations",
|
|
"method_relations",
|
|
"methodology_relations",
|
|
"dataset_metadata",
|
|
],
|
|
},
|
|
result_transformer_=lambda result: [dict(record) for record in result],
|
|
)
|
|
rows = list(rows or [])
|
|
evidence: List[EvidenceItem] = []
|
|
chains: List[Dict[str, Any]] = []
|
|
seen_assertions = set()
|
|
|
|
def filtered(reason: str) -> None:
|
|
counts = audit["filtered_reasons"]
|
|
counts[reason] = int(counts.get(reason) or 0) + 1
|
|
|
|
for row in rows:
|
|
assertion_id = _text(row.get("assertion_id"))
|
|
if not assertion_id:
|
|
filtered("missing_assertion_id")
|
|
continue
|
|
if assertion_id in seen_assertions:
|
|
filtered("duplicate_assertion")
|
|
continue
|
|
direction = _norm(row.get("direction"))
|
|
if (direction == "incoming" and not allow_incoming) or (
|
|
direction == "outgoing" and not allow_outgoing
|
|
):
|
|
filtered("direction_disabled")
|
|
continue
|
|
if direction not in {"incoming", "outgoing"}:
|
|
filtered("unknown_direction")
|
|
continue
|
|
|
|
answer_entity = row.get("source") if direction == "incoming" else row.get("target")
|
|
answer_type = row.get("source_type") if direction == "incoming" else row.get("target_type")
|
|
if terminal_types and _type(answer_type) not in terminal_types:
|
|
filtered("terminal_type_mismatch")
|
|
continue
|
|
category = _norm(row.get("relation_category"))
|
|
if relation_categories and category not in relation_categories:
|
|
filtered("relation_category_mismatch")
|
|
continue
|
|
|
|
seen_assertions.add(assertion_id)
|
|
anchor_name = _text(row.get("anchor_name"))
|
|
paper_id = _text(row.get("paper_id"))
|
|
pmcid = _text(row.get("pmcid")) or _pmcid(paper_id)
|
|
raw = {
|
|
"assertion_id": assertion_id,
|
|
"source_type": _text(row.get("source_type")),
|
|
"target_type": _text(row.get("target_type")),
|
|
"source_canonical_id": _text(row.get("source_canonical_id")),
|
|
"target_canonical_id": _text(row.get("target_canonical_id")),
|
|
"relation_category": _text(row.get("relation_category")),
|
|
"navigation_direction": direction,
|
|
"anchor_id": _text(row.get("anchor_id")),
|
|
"anchor_name": anchor_name,
|
|
"answer_entity": _text(answer_entity),
|
|
"answer_entity_type": _type(answer_type),
|
|
"preserved_assertion_direction": True,
|
|
}
|
|
item = EvidenceItem(
|
|
evidence_id="",
|
|
kb="V16/V15",
|
|
source=_text(row.get("source")),
|
|
relation=_text(row.get("relation")),
|
|
target=_text(row.get("target")),
|
|
evidence=_text(row.get("evidence")),
|
|
paper_id=paper_id,
|
|
title=_text(row.get("title")),
|
|
pmid=_text(row.get("pmid")),
|
|
pmcid=pmcid,
|
|
doi=_text(row.get("doi")),
|
|
species=_text(row.get("species")),
|
|
output_layer="canonical_graph",
|
|
relation_id=assertion_id,
|
|
retrieval_methods=["agentic_anchor_graph", direction],
|
|
final_score=float(row.get("best_score") or 0.0),
|
|
raw=raw,
|
|
)
|
|
evidence.append(item)
|
|
hop = {
|
|
"hop_index": 1,
|
|
"from_id": _text(row.get("source_canonical_id")),
|
|
"from_name": _text(row.get("source")),
|
|
"to_id": _text(row.get("target_canonical_id")),
|
|
"to_name": _text(row.get("target")),
|
|
"source": _text(row.get("source")),
|
|
"source_type": _text(row.get("source_type")),
|
|
"source_canonical_id": _text(row.get("source_canonical_id")),
|
|
"relation": _text(row.get("relation")),
|
|
"relation_category": _text(row.get("relation_category")),
|
|
"target": _text(row.get("target")),
|
|
"target_type": _text(row.get("target_type")),
|
|
"target_canonical_id": _text(row.get("target_canonical_id")),
|
|
"assertion_id": assertion_id,
|
|
"paper_id": item.paper_id,
|
|
"title": item.title,
|
|
"pmid": item.pmid,
|
|
"pmcid": item.pmcid,
|
|
"doi": item.doi,
|
|
"species": item.species,
|
|
"evidence": item.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),
|
|
"navigation_direction": direction,
|
|
"preserved_assertion_direction": True,
|
|
}
|
|
chains.append(
|
|
{
|
|
"chain_id": f"AG{len(chains) + 1}",
|
|
"chain_source": "agentic_anchor_graph",
|
|
"hop_count": 1,
|
|
"path_score": float(row.get("best_score") or 0.0),
|
|
"start": anchor_name,
|
|
"end": _text(answer_entity),
|
|
"anchor_id": _text(row.get("anchor_id")),
|
|
"anchor_name": anchor_name,
|
|
"answer_entity": _text(answer_entity),
|
|
"answer_entity_type": _type(answer_type),
|
|
"navigation_direction": direction,
|
|
"preserve_assertion_direction": True,
|
|
"paper_id": item.paper_id,
|
|
"pmid": item.pmid,
|
|
"pmcid": item.pmcid,
|
|
"doi": item.doi,
|
|
"hops": [hop],
|
|
"score_breakdown": {
|
|
"anchor_entity_match": 1.0,
|
|
"answer_type_match": 1.0,
|
|
"direction_match": 1.0,
|
|
"final_score": float(row.get("best_score") or 0.0),
|
|
},
|
|
}
|
|
)
|
|
|
|
audit["raw_assertion_count"] = len(rows)
|
|
audit["returned_assertion_count"] = len(evidence)
|
|
audit["returned_chain_count"] = len(chains)
|
|
audit["status"] = "ok" if evidence else "no_matching_assertions"
|
|
return evidence, chains, audit
|
|
|
|
def _statement(self) -> str:
|
|
if self.graph_schema == "legacy":
|
|
return """
|
|
MATCH (anchor:Entity)
|
|
WHERE toLower(anchor.name) IN $anchor_names
|
|
OR toLower(coalesce(anchor.name_norm, anchor.name)) IN $anchor_names
|
|
WITH anchor LIMIT 24
|
|
MATCH (anchor)-[r:RELATION]-(neighbor:Entity)
|
|
WHERE r.output_layer IN $layers
|
|
WITH anchor, r, startNode(r) AS source_node, endNode(r) AS target_node,
|
|
CASE
|
|
WHEN endNode(r) = anchor THEN 'incoming'
|
|
ELSE 'outgoing'
|
|
END AS direction
|
|
RETURN coalesce(anchor.entity_id, anchor.id, '') AS anchor_id,
|
|
anchor.name AS anchor_name,
|
|
direction,
|
|
r.relation_id AS assertion_id,
|
|
source_node.name AS source,
|
|
source_node.entity_type AS source_type,
|
|
coalesce(source_node.entity_id, source_node.id, '') AS source_canonical_id,
|
|
coalesce(r.relation_norm, r.relation) AS relation,
|
|
'' AS relation_category,
|
|
target_node.name AS target,
|
|
target_node.entity_type AS target_type,
|
|
coalesce(target_node.entity_id, target_node.id, '') AS target_canonical_id,
|
|
r.evidence AS evidence,
|
|
coalesce(r.canonical_paper_id, r.paper_id, '') AS paper_id,
|
|
'' AS title,
|
|
coalesce(r.pubmed_id, '') AS pmid,
|
|
'' AS pmcid,
|
|
'' AS doi,
|
|
coalesce(r.species, '') AS species,
|
|
1 AS support_count,
|
|
1 AS paper_count,
|
|
coalesce(r.confidence, 0.0) AS best_score
|
|
ORDER BY CASE WHEN coalesce(r.evidence, '') = '' THEN 1 ELSE 0 END,
|
|
coalesce(r.confidence, 0.0) DESC
|
|
LIMIT $limit
|
|
"""
|
|
return """
|
|
MATCH (a:SRT2CanonicalAssertion)
|
|
WHERE a.source_canonical_id IN $anchor_ids
|
|
OR a.target_canonical_id IN $anchor_ids
|
|
OR toLower(a.source) IN $anchor_names
|
|
OR toLower(a.target) IN $anchor_names
|
|
WITH a,
|
|
CASE
|
|
WHEN a.target_canonical_id IN $anchor_ids OR toLower(a.target) IN $anchor_names
|
|
THEN 'incoming'
|
|
ELSE 'outgoing'
|
|
END AS direction,
|
|
CASE
|
|
WHEN a.target_canonical_id IN $anchor_ids OR toLower(a.target) IN $anchor_names
|
|
THEN a.target_canonical_id
|
|
ELSE a.source_canonical_id
|
|
END AS anchor_id,
|
|
CASE
|
|
WHEN a.target_canonical_id IN $anchor_ids OR toLower(a.target) IN $anchor_names
|
|
THEN a.target
|
|
ELSE a.source
|
|
END AS anchor_name
|
|
RETURN anchor_id, anchor_name, direction,
|
|
a.assertion_id AS assertion_id,
|
|
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, a.support_count AS support_count,
|
|
a.paper_count AS paper_count, a.best_score AS best_score
|
|
ORDER BY a.paper_count DESC, a.support_count DESC, a.best_score DESC
|
|
LIMIT $limit
|
|
"""
|