Files
PlantAI-rag-new/bioflow_service/tests/test_planner.py
T

131 lines
5.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""tests/test_planner.py —— 路径规划(LLM 候选 + catalog 硬校验 + 降级 + 复用)。"""
from __future__ import annotations
import asyncio
import pytest
from conftest import FakeFlowStore, FakeLLM
from app.services.bioflow.planner import (
PlanResult,
Planner,
PlannerError,
)
pytestmark = pytest.mark.bioflow_unit
def _run(coro):
return asyncio.new_event_loop().run_until_complete(coro)
def make_planner(catalog, monkeypatch, llm=None, flow_store=None, archive=None):
planner = Planner(catalog, flow_store or FakeFlowStore([]),
llm_plan_timeout=5.0, plan_candidates=3, plan_max_rounds=2,
archive_finder=None)
import app.services.bioflow.planner as planner_mod
monkeypatch.setattr(planner_mod, "complete_json", llm or FakeLLM())
async def find(name):
return archive
planner.archive_finder = find
return planner
def test_llm_candidates_validated(catalog, monkeypatch):
"""FR-BIO-002:LLM 候选经 catalog 硬校验。"""
planner = make_planner(catalog, monkeypatch)
result = _run(planner.plan("帮我跑水稻 SPL 家族鉴定"))
assert isinstance(result, PlanResult)
assert len(result.candidates) >= 1
for c in result.candidates:
assert c.stages
assert c.purpose
assert catalog.validate_chain(c.stages) == []
assert result.source == "llm_only"
def test_unknown_stage_rejected(catalog, monkeypatch):
"""FR-BIO-003:伪造阶段被拒(TC-BIO-API-003 服务层对应)。"""
llm = FakeLLM(candidates=[
{"purpose": "坏候选", "stages": ["fake_stage_xyz"]},
{"purpose": "好候选", "stages": ["identify"]},
])
planner = make_planner(catalog, monkeypatch, llm=llm)
result = _run(planner.plan("帮我跑水稻 SPL 家族鉴定"))
assert all(c.stages != ["fake_stage_xyz"] for c in result.candidates)
assert result.candidates[0].stages == ["identify"]
def test_prerequisites_autofilled(catalog, monkeypatch):
"""FR-BIO-003:缺失前置自动补全。"""
llm = FakeLLM(candidates=[{"purpose": "p", "stages": ["phylogeny"]}])
planner = make_planner(catalog, monkeypatch, llm=llm)
result = _run(planner.plan("帮我跑水稻 SPL 家族鉴定"))
stages = result.candidates[0].stages
assert "identify" in stages
assert stages.index("identify") < stages.index("phylogeny")
def test_llm_timeout_fallback(catalog, monkeypatch):
"""TC-BIO-API-009:LLM 超时降级(PLAN_LLM_TIMEOUT)。"""
planner = make_planner(catalog, monkeypatch, llm=FakeLLM(error="timeout"))
with pytest.raises(PlannerError) as ei:
_run(planner.plan("帮我跑水稻 SPL 家族鉴定"))
assert ei.value.code == "PLAN_LLM_TIMEOUT"
def test_llm_unavailable_fallback(catalog, monkeypatch):
"""TC-BIO-API-020:LLM 完全不可用(PLAN_UNAVAILABLE)。"""
planner = make_planner(catalog, monkeypatch, llm=FakeLLM(error="unavailable"))
with pytest.raises(PlannerError) as ei:
_run(planner.plan("帮我跑水稻 SPL 家族鉴定"))
assert ei.value.code == "PLAN_UNAVAILABLE"
def test_vstore_unavailable_degrades(catalog, monkeypatch):
"""TC-BIO-API-010:vstore 不可用降级 llm_only。"""
class BrokenFlowStore:
def search(self, query, top_k=6, **kw):
raise RuntimeError("vstore down")
planner = make_planner(catalog, monkeypatch, flow_store=BrokenFlowStore())
result = _run(planner.plan("帮我跑水稻 SPL 家族鉴定"))
assert result.source == "llm_only"
def test_vstore_kb_hits(catalog, monkeypatch):
store = FakeFlowStore([
{"chunk_key": "c1", "id": "c1", "source": "stage_contract",
"stage_name": "identify", "title_path": "Identify | ## 1",
"title": "Identify", "path": "x.md", "cite": {"anchor": "## 1"}},
])
planner = make_planner(catalog, monkeypatch, flow_store=store)
result = _run(planner.plan("帮我跑水稻 SPL 家族鉴定"))
assert result.source == "llm_rag"
def test_archive_reuse(catalog, monkeypatch):
"""FR-BIO-023:复用句式直接装载存档链(不调 LLM)。"""
archive = {"id": "a1", "name": "XXX", "stage_chain": ["identify", "motif"],
"params": {"identify": {"cpu": "8"}}}
planner = make_planner(catalog, monkeypatch, llm=FakeLLM(error="unavailable"), archive=archive)
result = _run(planner.plan("用 XXX 存档任务的方法帮我跑水稻 SPL 家族鉴定"))
assert result.source == "archive"
assert result.reuse_archive_id == "a1"
assert result.reuse_name == "XXX"
assert result.candidates[0].stages == ["identify", "motif"]
def test_all_candidates_invalid_fallback(catalog, monkeypatch):
"""LLM 全部候选非法 → 内置兜底(identify 链)。"""
llm = FakeLLM(candidates=[
{"purpose": "x", "stages": ["nope1"]},
{"purpose": "y", "stages": []},
])
planner = make_planner(catalog, monkeypatch, llm=llm)
result = _run(planner.plan("帮我跑水稻 SPL 家族鉴定"))
assert result.candidates
assert all(catalog.validate_chain(c.stages) == [] for c in result.candidates)