feat(qa): soft gate 兼容策略(fail-closed to legacy)
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Iterable, Mapping
|
||||
from typing import Any
|
||||
|
||||
|
||||
LEGACY = "legacy"
|
||||
SOFT_COMPAT_V1 = "soft_compat_v1"
|
||||
_POLICIES = {LEGACY, SOFT_COMPAT_V1}
|
||||
|
||||
_ANSWER_SLOT_TYPES = {
|
||||
"biochemical_activity",
|
||||
"chromatin_modification",
|
||||
"enzyme_activity",
|
||||
"expression_level",
|
||||
"expression_pattern",
|
||||
"expression_relationship",
|
||||
"function",
|
||||
"gene_expression",
|
||||
"regulation",
|
||||
"regulatory_relationship",
|
||||
"transporter_activity",
|
||||
}
|
||||
|
||||
_SYMMETRIC_COMPATIBILITY = (
|
||||
{"pathogen", "organism_group"},
|
||||
{"compartment", "organelle"},
|
||||
{"compartment", "cellular_component"},
|
||||
{"gene_group", "biosynthetic_gene_set"},
|
||||
{"enzyme", "enzyme_activity"},
|
||||
)
|
||||
|
||||
_DOI_INTERNAL_RE = re.compile(
|
||||
r"^(10\.\d{4,9})_([A-Za-z0-9][A-Za-z0-9.;()/:+\-]*)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_OXFORD_DOI_INTERNAL_RE = re.compile(
|
||||
r"^(10\.1093)_([A-Za-z0-9][A-Za-z0-9.;()+\-]*)_([A-Za-z0-9][A-Za-z0-9.;()+\-]*)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DOI_RE = re.compile(
|
||||
r"^10\.\d{4,9}/[A-Za-z0-9][A-Za-z0-9._;()/:+\-]*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _type_token(value: Any) -> str:
|
||||
text = re.sub(r"[^a-z0-9]+", "_", str(value or "").strip().lower())
|
||||
return text.strip("_")
|
||||
|
||||
|
||||
def gate_policy(env: Mapping[str, str] | None = None) -> str:
|
||||
values = os.environ if env is None else env
|
||||
value = str(values.get("QA_AGENTIC_GATE_POLICY", LEGACY) or LEGACY).strip().lower()
|
||||
return value if value in _POLICIES else LEGACY
|
||||
|
||||
|
||||
def type_compatibility(
|
||||
actual_type: Any,
|
||||
requested_types: Iterable[Any],
|
||||
*,
|
||||
relationship_context: bool = False,
|
||||
) -> tuple[bool, str]:
|
||||
actual = _type_token(actual_type)
|
||||
requested = {_type_token(value) for value in requested_types}
|
||||
requested.discard("")
|
||||
requested.difference_update({"any", "unknown", "unspecified"})
|
||||
|
||||
if not requested:
|
||||
return True, "no_terminal_type_constraint"
|
||||
if actual in requested:
|
||||
return True, "exact_type_match"
|
||||
if requested.intersection(_ANSWER_SLOT_TYPES):
|
||||
return True, "answer_slot_not_endpoint_type"
|
||||
for family in _SYMMETRIC_COMPATIBILITY:
|
||||
if actual in family and family.intersection(requested):
|
||||
return True, "controlled_type_family"
|
||||
if actual == "transcription_factor" and requested.intersection({"gene", "protein"}):
|
||||
return True, "transcription_factor_endpoint"
|
||||
if (
|
||||
relationship_context
|
||||
and actual == "genetic_variant"
|
||||
and requested.intersection({"gene", "condition"})
|
||||
):
|
||||
return True, "genetic_variant_relationship_endpoint"
|
||||
return False, "terminal_type_mismatch"
|
||||
|
||||
|
||||
def doi_from_paper_id(paper_id: Any) -> str:
|
||||
value = str(paper_id or "").strip()
|
||||
oxford_match = _OXFORD_DOI_INTERNAL_RE.fullmatch(value)
|
||||
if oxford_match:
|
||||
normalized = (
|
||||
f"{oxford_match.group(1)}/{oxford_match.group(2)}/{oxford_match.group(3)}"
|
||||
)
|
||||
return normalized if _DOI_RE.fullmatch(normalized) else ""
|
||||
match = _DOI_INTERNAL_RE.fullmatch(value)
|
||||
if not match:
|
||||
return ""
|
||||
normalized = f"{match.group(1)}/{match.group(2)}"
|
||||
return normalized if _DOI_RE.fullmatch(normalized) else ""
|
||||
|
||||
|
||||
def pmcid_from_paper_id(paper_id: Any) -> str:
|
||||
value = str(paper_id or "").strip()
|
||||
match = re.fullmatch(r"(PMC\d+)(?:_[A-Za-z0-9][A-Za-z0-9.\-]*)?", value, re.IGNORECASE)
|
||||
return match.group(1).upper() if match else ""
|
||||
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from src.rag.qa_v1_1_73 import soft_gate_policy as module
|
||||
|
||||
|
||||
def test_gate_policy_defaults_and_fails_closed_to_legacy():
|
||||
assert module.gate_policy({}) == "legacy"
|
||||
assert (
|
||||
module.gate_policy({"QA_AGENTIC_GATE_POLICY": "soft_compat_v1"})
|
||||
== "soft_compat_v1"
|
||||
)
|
||||
assert module.gate_policy({"QA_AGENTIC_GATE_POLICY": "unexpected"}) == "legacy"
|
||||
|
||||
|
||||
def test_closed_type_compatibility_map_and_answer_slots():
|
||||
compatible = [
|
||||
("pathogen", ["organism_group"], False),
|
||||
("organism_group", ["pathogen"], False),
|
||||
("compartment", ["organelle"], False),
|
||||
("compartment", ["cellular_component"], False),
|
||||
("organelle", ["compartment"], False),
|
||||
("transcription_factor", ["gene"], False),
|
||||
("transcription_factor", ["protein"], False),
|
||||
("gene_group", ["biosynthetic_gene_set"], False),
|
||||
("biosynthetic_gene_set", ["gene_group"], False),
|
||||
("enzyme", ["enzyme_activity"], False),
|
||||
("enzyme_activity", ["enzyme"], False),
|
||||
("genetic_variant", ["gene", "condition"], True),
|
||||
("gene", ["expression_pattern"], False),
|
||||
("tissue", ["regulatory_relationship"], False),
|
||||
]
|
||||
|
||||
for actual, requested, relationship_context in compatible:
|
||||
accepted, reason = module.type_compatibility(
|
||||
actual,
|
||||
requested,
|
||||
relationship_context=relationship_context,
|
||||
)
|
||||
assert accepted, (actual, requested, reason)
|
||||
|
||||
assert not module.type_compatibility("pathogen", ["phenotype"])[0]
|
||||
assert not module.type_compatibility("genetic_variant", ["gene"])[0]
|
||||
assert not module.type_compatibility("unknown_new_type", ["gene"])[0]
|
||||
|
||||
|
||||
def test_doi_from_paper_id_accepts_only_unambiguous_internal_form():
|
||||
|
||||
assert (
|
||||
module.doi_from_paper_id("10.3389_fpls.2016.00954")
|
||||
== "10.3389/fpls.2016.00954"
|
||||
)
|
||||
assert (
|
||||
module.doi_from_paper_id("10.1016_j.cj.2021.04.004")
|
||||
== "10.1016/j.cj.2021.04.004"
|
||||
)
|
||||
assert module.doi_from_paper_id("10.1093_jxb_ery350") == "10.1093/jxb/ery350"
|
||||
assert (
|
||||
module.doi_from_paper_id("10.1093_plphys_kiac335")
|
||||
== "10.1093/plphys/kiac335"
|
||||
)
|
||||
assert module.doi_from_paper_id("PMC123") == ""
|
||||
assert module.doi_from_paper_id("12345678") == ""
|
||||
assert module.doi_from_paper_id("10.3389_fpls_2016_00954") == ""
|
||||
assert module.doi_from_paper_id("10.3389/") == ""
|
||||
assert module.doi_from_paper_id("not_a_doi") == ""
|
||||
|
||||
|
||||
def test_pmcid_from_paper_id_accepts_only_leading_pmc_accession():
|
||||
|
||||
assert module.pmcid_from_paper_id("PMC10299364_ijms-24-10165") == "PMC10299364"
|
||||
assert module.pmcid_from_paper_id("pmc8508941") == "PMC8508941"
|
||||
assert module.pmcid_from_paper_id("prefix_PMC10299364") == ""
|
||||
assert module.pmcid_from_paper_id("PMC_10299364") == ""
|
||||
@@ -0,0 +1,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.rag.qa_v1_1_73.agentic_controller import AgenticRetrievalController
|
||||
from src.rag.qa_v1_1_73.schemas import EvidenceItem
|
||||
|
||||
|
||||
def _controller():
|
||||
return AgenticRetrievalController.__new__(AgenticRetrievalController)
|
||||
|
||||
|
||||
def _item(
|
||||
actual_type: str,
|
||||
*,
|
||||
source: str = "ANCHOR",
|
||||
relation: str = "regulates",
|
||||
target: str = "ANSWER",
|
||||
evidence: str = "ANCHOR regulates ANSWER under the reported condition.",
|
||||
paper_id: str = "paper-1",
|
||||
) -> EvidenceItem:
|
||||
return EvidenceItem(
|
||||
evidence_id="Q0.A1.E1",
|
||||
kb="V16/V15",
|
||||
source=source,
|
||||
relation=relation,
|
||||
target=target,
|
||||
evidence=evidence,
|
||||
paper_id=paper_id,
|
||||
raw={
|
||||
"answer_entity_type": actual_type,
|
||||
"source_type": "gene",
|
||||
"target_type": actual_type,
|
||||
"anchor_name": "ANCHOR",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _group(requested_types: list[str], *, question: str = "How does ANCHOR regulate ANSWER?"):
|
||||
return {
|
||||
"subquestion_id": "Q0",
|
||||
"question": question,
|
||||
"role": "required",
|
||||
"anchors": [{"mention": "ANCHOR", "canonical_name": "ANCHOR"}],
|
||||
"target_entity_types": requested_types,
|
||||
"acceptance_requirements": {
|
||||
"requires_structured_assertion": True,
|
||||
"requires_paper_provenance": True,
|
||||
"requires_anchor_connection": True,
|
||||
"requires_terminal_type_match": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("actual_type", "requested_types"),
|
||||
[
|
||||
("pathogen", ["organism_group"]),
|
||||
("compartment", ["organelle", "cellular_component"]),
|
||||
("transcription_factor", ["gene", "regulatory_relationship"]),
|
||||
("gene_group", ["biosynthetic_gene_set"]),
|
||||
("enzyme", ["enzyme_activity"]),
|
||||
("genetic_variant", ["gene", "condition", "phenotype"]),
|
||||
("tissue", ["gene", "expression_pattern"]),
|
||||
("gene", ["process", "pathway", "function"]),
|
||||
("genomic_region", ["gene", "chromatin_modification"]),
|
||||
],
|
||||
)
|
||||
def test_group_type_gate_is_legacy_exact_and_soft_compatible(
|
||||
monkeypatch, actual_type, requested_types
|
||||
):
|
||||
controller = _controller()
|
||||
item = _item(actual_type)
|
||||
group = _group(requested_types)
|
||||
|
||||
monkeypatch.setenv("QA_AGENTIC_GATE_POLICY", "legacy")
|
||||
assert not controller._group_evidence_satisfies(group, item)
|
||||
|
||||
monkeypatch.setenv("QA_AGENTIC_GATE_POLICY", "soft_compat_v1")
|
||||
assert controller._group_evidence_satisfies(group, item)
|
||||
|
||||
|
||||
def test_plan_evidence_and_chain_type_gates_use_same_policy(monkeypatch):
|
||||
controller = _controller()
|
||||
item = _item("transcription_factor")
|
||||
plan = {
|
||||
"anchors": [{"mention": "ANCHOR"}],
|
||||
"graph_plan": {"required_terminal_types": ["gene"]},
|
||||
"acceptance_gate": {},
|
||||
}
|
||||
chain = {
|
||||
"chain_id": "Q0.A1.G1",
|
||||
"start": "ANCHOR",
|
||||
"end": "ANSWER",
|
||||
"anchor_name": "ANCHOR",
|
||||
"answer_entity_type": "transcription_factor",
|
||||
"hops": [
|
||||
{
|
||||
"source": "ANCHOR",
|
||||
"relation": "regulates",
|
||||
"target": "ANSWER",
|
||||
"paper_id": "paper-1",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
monkeypatch.setenv("QA_AGENTIC_GATE_POLICY", "legacy")
|
||||
assert not controller._evidence_satisfies(plan, item)
|
||||
assert not controller._chain_satisfies(plan, chain)
|
||||
|
||||
monkeypatch.setenv("QA_AGENTIC_GATE_POLICY", "soft_compat_v1")
|
||||
assert controller._evidence_satisfies(plan, item)
|
||||
assert controller._chain_satisfies(plan, chain)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("group", "item"),
|
||||
[
|
||||
(_group(["gene"]), _item("gene", paper_id="")),
|
||||
(
|
||||
_group(["gene"], question="Which genes are associated with ANCHOR?"),
|
||||
_item(
|
||||
"gene",
|
||||
source="OTHER",
|
||||
evidence="OTHER regulates ANSWER under the reported condition.",
|
||||
),
|
||||
),
|
||||
(
|
||||
_group(["gene"], question="How does ANCHOR respond to heat stress?"),
|
||||
_item(
|
||||
"gene",
|
||||
evidence="ANCHOR responds only to cold stress and freezing.",
|
||||
),
|
||||
),
|
||||
(
|
||||
_group(["gene"], question="Which genes does ANCHOR regulate?"),
|
||||
_item("gene", relation="compared_to"),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_non_type_safety_gates_remain_active_in_both_policies(monkeypatch, group, item):
|
||||
controller = _controller()
|
||||
for policy in ("legacy", "soft_compat_v1"):
|
||||
monkeypatch.setenv("QA_AGENTIC_GATE_POLICY", policy)
|
||||
assert not controller._group_evidence_satisfies(group, item)
|
||||
Reference in New Issue
Block a user