131 lines
5.1 KiB
Python
131 lines
5.1 KiB
Python
"""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)
|