测试:轻量拓展分析 NL 触发两轮测试交付(单测 79 + 集成 23 + 前端契约 20)

This commit is contained in:
potaser
2026-08-27 00:34:19 +08:00
parent afdb1614a2
commit 214eb11490
5 changed files with 1707 additions and 0 deletions
@@ -248,3 +248,55 @@ assert.strictEqual(parameterRows.find((row) => row.item === '可改参数').valu
intent = source[source.index("const runExtensionStageFromIntent"):source.index("const extensionOutputIcon")]
assert "if (isLightweightExtensionId(extensionId))" in intent
assert intent.index("openLightweightRuntimeCard(extensionId)") < intent.index("executeExtensionStageRun(")
# ---------- 自然语言触发(轻量拓展分析)追加断言 ----------
def test_lightweight_nl_run_wizard_and_state_exist_in_source() -> None:
source = _source()
assert "lightweightNlRun" in source
assert "const lightweightNlRun = reactive({" in source
assert "let lightweightNlRunResolve = null;" in source
assert "openLightweightNlRunDialog" in source
assert "executeLightweightNlRun" in source
assert "handleNlRunFileDrop" in source
assert "importNlRunServerPaths" in source
assert "cancelLightweightNlRun" in source
assert "confirmLightweightNlRun" in source
assert "nextLightweightNlRunStep" in source
def test_lightweight_nl_run_uploads_with_progress_and_imports_server_paths() -> None:
source = _source()
assert "xhr.upload.onprogress" in source
assert "el-progress" in source
assert "/api/nl-import-paths" in source
assert "input/" in source
def test_lightweight_nl_run_prefills_from_llm_slots() -> None:
source = _source()
assert "llm_slots" in source
assert "input_overrides" in source
assert "parameter_overrides" in source
assert "explicit_prefix" in source
assert "parse_reason" in source
def test_lightweight_nl_run_intent_context_and_conversation_replies() -> None:
source = _source()
assert "intent_context" in source
assert "fromIntent" in source
assert "addAgentMessage" in source
assert "taskType === 'extension_stage'" in source
def test_lightweight_nl_run_prefill_flag_wired() -> None:
source = _source()
assert "prefill" in source
assert "已识别为轻量拓展分析" not in source # 消息文案由后端生成,前端按 taskType 路由
@@ -0,0 +1,562 @@
"""轻量拓展分析自然语言触发的后端集成测试(main.py 侧)。
monkeypatch BBAV5_LIGHTWEIGHT_EXTENSION_STORE 指向 tmp store,
monkeypatch nl_intent.resolve_lightweight_extension_intent 返回固定 resolution。
风格对齐 tests/unit/test_agent_chat_actions.py。
"""
from __future__ import annotations
import json
import shutil
import tempfile
from pathlib import Path
import pytest
from apps.web.vue3 import main as WEB_MAIN
from gfa_core.extension_stage_lightweight import nl_intent
STORE_ENV = "BBAV5_LIGHTWEIGHT_EXTENSION_STORE"
# ---------- fixtures / helpers ----------
def _write(root: Path, relative: str, content: str) -> Path:
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return path
def _record(extension_id: str) -> dict:
return {
"extension_id": extension_id,
"status": "published",
"semantic_candidate": {"function_summary": "This tool searches sequences with hmmsearch."},
"runtime_binding_contract": {
"inputs": [
{"path": "family.hmm", "role": "HMM profile input", "description": "profile", "replaceable": True},
{"path": "01.col.primary.pep.fa", "role": "Protein sequence input", "description": "seqs", "replaceable": True},
],
"parameters": [
{"fact_id": "step_002:parameter:003", "flag": "-E", "display_name": "E-value threshold", "description": "evalue"},
],
},
}
def _write_published_record(store_root: Path, extension_id: str) -> None:
_write(store_root, f"extensions/{extension_id}/extension.json", json.dumps(_record(extension_id)))
def _resolution(extension_id: str = "ext_hmm") -> nl_intent.LightweightIntentResolution:
return nl_intent.LightweightIntentResolution(
extension_id=extension_id,
extension_name="This tool searches sequences with hmmsearch.",
input_overrides={"family.hmm": "input/job-nl-1/_nl_imports/family.hmm"},
parameter_overrides={"step_002:parameter:003": "1e-5"},
explicit_prefix="outputs/nl",
confidence=0.9,
prefill=True,
reason="ok",
candidates_considered=[extension_id],
)
def _patch_resolver(monkeypatch, resolution):
monkeypatch.setattr(
nl_intent,
"resolve_lightweight_extension_intent",
lambda message, records, environ=None, client_factory=None, job_file_hints=None: resolution,
)
@pytest.fixture
def lightweight_store(monkeypatch, tmp_path: Path) -> Path:
store_root = tmp_path / "lw-store"
monkeypatch.setenv(STORE_ENV, str(store_root))
return store_root
# ---------- 1. _match_lightweight_extension_intent ----------
def test_match_lightweight_intent_returns_full_contract(
monkeypatch, lightweight_store: Path
) -> None:
_write_published_record(lightweight_store, "ext_hmm")
_patch_resolver(monkeypatch, _resolution())
intent = WEB_MAIN._match_lightweight_extension_intent("使用 HMM 拓展分析", "job-nl-1")
assert intent.get("lightweight") is True
assert intent["stage"] == "extension_stage"
params = intent["params"]
assert params["extension_id"] == "ext_hmm"
assert params["extensionName"] == "This tool searches sequences with hmmsearch."
assert params["extension_store"] == "lightweight"
assert params["llm_slots"] == {
"input_overrides": {"family.hmm": "input/job-nl-1/_nl_imports/family.hmm"},
"parameter_overrides": {"step_002:parameter:003": "1e-5"},
"explicit_prefix": "outputs/nl",
}
assert params["prefill"] is True
assert params["confidence"] == 0.9
assert params["parse_reason"] == "ok"
assert intent["confidence"] == 0.9
# message 中文模板:extension_id 为主、summary 首句截 60 字括号补充
assert "已识别为轻量拓展分析" in intent["message"]
assert "ext_hmm" in intent["message"]
assert "This tool searches sequences with hmmsearch" in intent["message"]
assert "请按引导上传或选择输入文件并核对参数后执行" in intent["message"]
def test_match_lightweight_intent_empty_store_returns_empty_dict(
monkeypatch, lightweight_store: Path
) -> None:
# store 无任何 published record
intent = WEB_MAIN._match_lightweight_extension_intent("使用拓展分析", "job-x")
assert intent == {}
def test_match_lightweight_intent_none_resolution_returns_empty_dict(
monkeypatch, lightweight_store: Path
) -> None:
_write_published_record(lightweight_store, "ext_hmm")
_patch_resolver(monkeypatch, None) # resolution None(模拟 LLM 降级)
intent = WEB_MAIN._match_lightweight_extension_intent("使用拓展分析", "job-x")
assert intent == {}
def test_match_lightweight_intent_none_extension_id_returns_empty_dict(
monkeypatch, lightweight_store: Path
) -> None:
_write_published_record(lightweight_store, "ext_hmm")
resolution = _resolution()
resolution = nl_intent.LightweightIntentResolution(
extension_id=None,
extension_name=None,
input_overrides={},
parameter_overrides={},
explicit_prefix=None,
confidence=0.0,
prefill=False,
reason="no_trigger",
candidates_considered=[],
)
_patch_resolver(monkeypatch, resolution)
intent = WEB_MAIN._match_lightweight_extension_intent("使用拓展分析", "job-x")
assert intent == {}
# ---------- 2. _match_extension_stage_intent 回退 ----------
def _phase_a_ext(extension_id: str = "legacy_ext", status: str = "active", nl_triggers=None) -> dict:
return {
"extension_id": extension_id,
"name": extension_id,
"display_name": f"{extension_id} display",
"status": status,
"nl_triggers": nl_triggers or ["hmmsearch"],
"requirements": {"system": ["hmmsearch"]},
"analysis_type": "hmmer_search",
"parameters": {},
}
def test_extension_stage_intent_keeps_phase_a_when_published(
monkeypatch, lightweight_store: Path
) -> None:
"""Phase A 命中(构造 nl_triggers 命中)时不回退轻量匹配。"""
monkeypatch.setattr(
WEB_MAIN.extension_stage_agent,
"list_extensions",
lambda include_drafts=False: [_phase_a_ext()],
)
_write_published_record(lightweight_store, "ext_hmm")
_patch_resolver(monkeypatch, _resolution())
intent = WEB_MAIN._match_extension_stage_intent("使用 hmmsearch 做结构域分析", "job-x")
assert intent.get("lightweight") is None
assert intent.get("params", {}).get("extension_id") == "legacy_ext"
def test_extension_stage_intent_falls_back_to_lightweight_when_phase_a_empty(
monkeypatch, lightweight_store: Path
) -> None:
monkeypatch.setattr(
WEB_MAIN.extension_stage_agent,
"list_extensions",
lambda include_drafts=False: [],
)
_write_published_record(lightweight_store, "ext_hmm")
_patch_resolver(monkeypatch, _resolution())
intent = WEB_MAIN._match_extension_stage_intent("使用 HMM 拓展分析我的文件", "job-x")
assert intent.get("lightweight") is True
assert intent["params"]["extension_id"] == "ext_hmm"
# ---------- 3. _lightweight_job_file_hints ----------
def test_job_file_hints_collects_input_and_workspace_relative_paths(
monkeypatch, tmp_path: Path
) -> None:
input_root = tmp_path / "input"
workspace_root = tmp_path / "workspace"
_write(input_root, "job-h1/data/seq.fa", ">a\nAAAA\n")
_write(input_root, "job-h1/notes.txt", "notes")
_write(workspace_root, "job-h1/out/table.tsv", "a\tb\n")
_write(workspace_root, "job-h1/stray.tsv", "x")
_write(input_root, "job-other/other.fa", ">o\n") # 其他 job 不应出现
monkeypatch.setattr(WEB_MAIN, "INPUT_ROOT", input_root)
monkeypatch.setattr(WEB_MAIN, "WORKSPACE_ROOT", workspace_root)
hints = WEB_MAIN._lightweight_job_file_hints("job-h1")
assert "input/job-h1/data/seq.fa" in hints
assert "input/job-h1/notes.txt" in hints
assert "workspace/job-h1/out/table.tsv" in hints
assert "workspace/job-h1/stray.tsv" in hints
assert "input/job-other/other.fa" not in hints
assert len(hints) == 4
def test_job_file_hints_empty_job_id_returns_empty(monkeypatch) -> None:
assert WEB_MAIN._lightweight_job_file_hints("") == []
def test_job_file_hints_capped_at_max(monkeypatch, tmp_path: Path) -> None:
input_root = tmp_path / "input"
for i in range(nl_intent.MAX_JOB_FILE_HINTS + 20):
_write(input_root, f"job-cap/f{i:03d}.txt", "x")
monkeypatch.setattr(WEB_MAIN, "INPUT_ROOT", input_root)
monkeypatch.setattr(WEB_MAIN, "WORKSPACE_ROOT", tmp_path / "workspace")
hints = WEB_MAIN._lightweight_job_file_hints("job-cap")
assert len(hints) == nl_intent.MAX_JOB_FILE_HINTS
# ---------- 4. import-paths 端点 ----------
def _call_nl_import_paths(job_id: str, paths: list[str]):
request = WEB_MAIN.NlImportPathsRequest(job_id=job_id, paths=paths)
import asyncio
return asyncio.run(WEB_MAIN.nl_import_paths(request))
@pytest.fixture
def allowed_source(tmp_path: Path) -> Path:
"""在端点允许前缀 /home/xzq/ 下创建临时 source 文件。"""
workdir = Path(tempfile.mkdtemp(dir="/home/xzq", prefix="nl-import-test-"))
yield workdir
shutil.rmtree(workdir, ignore_errors=True)
def test_nl_import_paths_copies_file_into_nl_imports(monkeypatch, tmp_path: Path, allowed_source: Path) -> None:
source = _write(allowed_source, "family.hmm", "HMM profile content")
input_root = tmp_path / "input"
monkeypatch.setattr(WEB_MAIN, "INPUT_ROOT", input_root)
result = _call_nl_import_paths("job-imp-1", [str(source)])
assert result["success"] is True
assert result["imported"] == {str(source): "input/job-imp-1/_nl_imports/family.hmm"}
copied = input_root / "job-imp-1" / "_nl_imports" / "family.hmm"
assert copied.is_file()
assert copied.read_text(encoding="utf-8") == "HMM profile content"
def test_nl_import_paths_name_conflict_gets_suffix(monkeypatch, tmp_path: Path, allowed_source: Path) -> None:
source = _write(allowed_source, "family.hmm", "new content")
input_root = tmp_path / "input"
existing = _write(input_root, "job-imp-2/_nl_imports/family.hmm", "old content")
monkeypatch.setattr(WEB_MAIN, "INPUT_ROOT", input_root)
result = _call_nl_import_paths("job-imp-2", [str(source)])
assert result["imported"] == {str(source): "input/job-imp-2/_nl_imports/family_1.hmm"}
assert existing.read_text(encoding="utf-8") == "old content"
copied = input_root / "job-imp-2" / "_nl_imports" / "family_1.hmm"
assert copied.is_file()
assert copied.read_text(encoding="utf-8") == "new content"
def test_nl_import_paths_rejects_path_outside_allowed_prefixes(
monkeypatch, tmp_path: Path
) -> None:
monkeypatch.setattr(WEB_MAIN, "INPUT_ROOT", tmp_path / "input")
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc:
_call_nl_import_paths("job-imp-3", ["/etc/passwd"])
assert exc.value.status_code == 400
assert "路径必须位于" in exc.value.detail
def test_nl_import_paths_rejects_missing_path(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setattr(WEB_MAIN, "INPUT_ROOT", tmp_path / "input")
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc:
_call_nl_import_paths("job-imp-4", ["/home/xzq/definitely-not-exists-xyz.tsv"])
assert exc.value.status_code == 400
assert "路径不存在" in exc.value.detail
def test_nl_import_paths_rejects_empty_job_id(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setattr(WEB_MAIN, "INPUT_ROOT", tmp_path / "input")
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc:
_call_nl_import_paths("", [])
assert exc.value.status_code == 400
assert "job_id 不能为空" in exc.value.detail
# ---------- 5. _run_agent_chat 预闸短路 ----------
def _call_run_agent_chat(request):
import asyncio
return asyncio.run(WEB_MAIN._run_agent_chat(request))
def test_run_agent_chat_short_circuits_on_lightweight_intent(
monkeypatch, lightweight_store: Path, tmp_path: Path
) -> None:
"""预闸命中 + 轻量 intent 时 _run_agent_chat 短路返回 propose_extension_stage,
且绝不触碰 LLM manager / RouterAgent。"""
_write_published_record(lightweight_store, "ext_hmm")
monkeypatch.setattr(
nl_intent,
"resolve_lightweight_extension_intent",
lambda message, records, environ=None, client_factory=None, job_file_hints=None: _resolution(),
)
monkeypatch.setattr(WEB_MAIN, "llm_manager", None, raising=False)
from gfa_core.mas.config import config as mas_config
monkeypatch.setattr(
mas_config,
"load_llm_config",
lambda: (_ for _ in ()).throw(RuntimeError("LLM manager must not be touched")),
)
from gfa_core.mas.agents.router import RouterAgent
def _boom(_self, _llm_manager, **kwargs):
raise RuntimeError("RouterAgent must not be constructed")
monkeypatch.setattr(RouterAgent, "__init__", _boom)
result = _call_run_agent_chat(
WEB_MAIN.AgentChatRequest(message="使用 HMM 拓展分析我的文件", job_id="job-nl-1")
)
assert result["success"] is True
assert result["status"] == "ok"
assert result["action"] == "propose_extension_stage"
assert result["intent"].get("lightweight") is True
assert result["intent"]["params"]["extension_id"] == "ext_hmm"
assert result["model_used"] == "rule"
def test_run_agent_chat_does_not_short_circuit_without_lightweight_match(
monkeypatch, lightweight_store: Path
) -> None:
"""预闸不命中(消息无触发词)时不查轻量 store、正常走 MainAgent 流程。"""
_write_published_record(lightweight_store, "ext_hmm")
monkeypatch.setattr(
nl_intent,
"resolve_lightweight_extension_intent",
lambda message, records, environ=None, client_factory=None, job_file_hints=None: nl_intent.LightweightIntentResolution(
None, None, {}, {}, None, 0.0, False, "no_trigger", []
),
)
monkeypatch.setattr(WEB_MAIN, "llm_manager", None, raising=False)
from gfa_core.mas.config import config as mas_config
monkeypatch.setattr(
mas_config,
"load_llm_config",
lambda: (_ for _ in ()).throw(RuntimeError("LLM manager unavailable (expected path)")),
)
result = _call_run_agent_chat(
WEB_MAIN.AgentChatRequest(message="帮我看看这株水稻的基因家族", job_id="job-nl-2")
)
assert result["status"] == "agent_unavailable" # 说明走完了预闸后的 MainAgent 流程
# ---------- 6. /api/agent/plan 守卫 ----------
def _call_agent_plan(request):
import asyncio
return asyncio.run(WEB_MAIN.agent_plan(request))
def test_agent_plan_guard_skips_extension_response_for_lightweight(
monkeypatch, lightweight_store: Path
) -> None:
"""plan 里 extension_intent 带 lightweight=True 时不走 _extension_stage_plan_response。"""
_write_published_record(lightweight_store, "ext_hmm")
_patch_resolver(monkeypatch, _resolution())
captured = {}
def _fake_extension_stage_plan_response(extension_intent, session_id, job_id=""):
captured["called"] = True
return {"_extension_plan_response": True}
monkeypatch.setattr(WEB_MAIN, "_extension_stage_plan_response", _fake_extension_stage_plan_response)
result = _call_agent_plan(
WEB_MAIN.AgentPlanRequest(
message="使用 HMM 拓展分析我的文件",
job_id="job-guard",
intent={},
)
)
assert "called" not in captured # 守卫拦截,未进入扩展规划响应
assert result.get("_extension_plan_response") is not True
def test_agent_plan_guard_allows_phase_a_extension_response(
monkeypatch, lightweight_store: Path
) -> None:
"""Phase A 命中(非轻量)时 plan 正常进入扩展规划响应。"""
monkeypatch.setattr(
WEB_MAIN.extension_stage_agent,
"list_extensions",
lambda include_drafts=False: [_phase_a_ext()],
)
_write_published_record(lightweight_store, "ext_hmm")
captured = {}
def _fake_extension_stage_plan_response(extension_intent, session_id, job_id=""):
captured["called"] = True
return {"_extension_plan_response": True}
monkeypatch.setattr(WEB_MAIN, "_extension_stage_plan_response", _fake_extension_stage_plan_response)
result = _call_agent_plan(
WEB_MAIN.AgentPlanRequest(
message="使用 hmmsearch 做结构域分析",
job_id="job-guard-phase-a",
intent={},
)
)
assert captured.get("called") is True
assert result.get("_extension_plan_response") is True
# ---------- 8. import-paths 第二轮修复 ----------
def test_nl_import_paths_directory_name_conflict_gets_suffix(
monkeypatch, tmp_path: Path, allowed_source: Path
) -> None:
source_dir = allowed_source / "mydata"
source_dir.mkdir(parents=True)
_write(source_dir, "a.txt", "content-a")
input_root = tmp_path / "input"
existing_dir = input_root / "job-imp-dir" / "_nl_imports" / "mydata"
existing_dir.mkdir(parents=True)
_write(existing_dir, "old.txt", "old")
monkeypatch.setattr(WEB_MAIN, "INPUT_ROOT", input_root)
result = _call_nl_import_paths("job-imp-dir", [str(source_dir)])
assert result["success"] is True
assert result["imported"] == {str(source_dir): "input/job-imp-dir/_nl_imports/mydata_1"}
copied = input_root / "job-imp-dir" / "_nl_imports" / "mydata_1"
assert copied.is_dir()
assert (copied / "a.txt").read_text(encoding="utf-8") == "content-a"
assert (existing_dir / "old.txt").read_text(encoding="utf-8") == "old" # 原目录未被覆盖
def test_nl_import_paths_validates_all_before_copying(
monkeypatch, tmp_path: Path, allowed_source: Path
) -> None:
"""多路径先整体校验后复制:任一非法直接 400,前面的合法路径不被复制。"""
good = _write(allowed_source, "good.fa", ">g\nAAAA\n")
input_root = tmp_path / "input"
monkeypatch.setattr(WEB_MAIN, "INPUT_ROOT", input_root)
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc:
_call_nl_import_paths("job-imp-valid", [str(good), "/etc/passwd"])
assert exc.value.status_code == 400
assert "路径必须位于" in exc.value.detail
# 整体校验失败时 good 未被复制
assert not (input_root / "job-imp-valid" / "_nl_imports" / "good.fa").exists()
def test_nl_import_paths_missing_path_validated_before_copying(
monkeypatch, tmp_path: Path, allowed_source: Path
) -> None:
good = _write(allowed_source, "good2.fa", ">g\nAAAA\n")
input_root = tmp_path / "input"
monkeypatch.setattr(WEB_MAIN, "INPUT_ROOT", input_root)
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc:
_call_nl_import_paths("job-imp-valid2", [str(good), "/home/xzq/definitely-not-exists-xyz.tsv"])
assert exc.value.status_code == 400
assert "路径不存在" in exc.value.detail
assert not (input_root / "job-imp-valid2" / "_nl_imports" / "good2.fa").exists()
def test_nl_import_paths_multiple_valid_paths_all_copied(
monkeypatch, tmp_path: Path, allowed_source: Path
) -> None:
good1 = _write(allowed_source, "one.fa", ">1\n")
good2 = _write(allowed_source, "two.fa", ">2\n")
input_root = tmp_path / "input"
monkeypatch.setattr(WEB_MAIN, "INPUT_ROOT", input_root)
result = _call_nl_import_paths("job-imp-multi", [str(good1), str(good2)])
assert result["success"] is True
assert set(result["imported"].values()) == {
"input/job-imp-multi/_nl_imports/one.fa",
"input/job-imp-multi/_nl_imports/two.fa",
}
def test_match_lightweight_intent_message_without_summary_hint(
monkeypatch, lightweight_store: Path
) -> None:
"""record 无 summary 时 message 不带括号补充。"""
_write_published_record(lightweight_store, "ext_nosum")
resolution = nl_intent.LightweightIntentResolution(
extension_id="ext_nosum",
extension_name="ext_nosum",
input_overrides={},
parameter_overrides={},
explicit_prefix=None,
confidence=0.5,
prefill=False,
reason="ok",
candidates_considered=["ext_nosum"],
)
# 用无 semantic_candidate 的 record 覆盖 fixture
record_path = lightweight_store / "extensions" / "ext_nosum" / "extension.json"
record = json.loads(record_path.read_text(encoding="utf-8"))
record["semantic_candidate"] = {}
record_path.write_text(json.dumps(record), encoding="utf-8")
_patch_resolver(monkeypatch, resolution)
intent = WEB_MAIN._match_lightweight_extension_intent("使用拓展分析", "job-x")
assert intent.get("lightweight") is True
assert "已识别为轻量拓展分析:ext_nosum。" in intent["message"]
assert "(" not in intent["message"]
@@ -0,0 +1,908 @@
"""轻量版拓展分析自然语言触发 nl_intent 模块单元测试。
mock 风格对齐 tests/unit/test_extension_stage_lightweight_deepseek_semantics.py:
client_factory 注入 + SimpleNamespace 伪造 choices[0].message.content。
"""
from __future__ import annotations
import json
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import pytest
from gfa_core.extension_stage_lightweight import nl_intent
def _module():
return nl_intent
def _write(root: Path, relative: str, content: str) -> Path:
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return path
# ---------- 契约构造 ----------
def _contract(*, inputs: list[dict] | None = None, parameters: list[dict] | None = None) -> dict:
inputs = inputs if inputs is not None else [
{
"path": "family.hmm",
"role": "HMM profile input",
"description": "HMM profile file used to search the protein sequences.",
"replaceable": True,
"locked": False,
},
{
"path": "01.col.primary.pep.fa",
"role": "Protein sequence input",
"description": "Protein FASTA file containing the sequences to search against the HMM profile.",
"replaceable": True,
"locked": False,
},
{
"path": "02_hmm/fixed.tsv",
"role": "Locked intermediate",
"description": "Intermediate produced inside the run, must not be overridable.",
"replaceable": False,
"locked": True,
},
]
parameters = parameters if parameters is not None else [
{
"fact_id": "step_001:parameter:001",
"flag": "-p",
"display_name": "Output directory",
"description": "Directory path to create for HMM search outputs.",
"value": "outputs/02_hmm",
},
{
"fact_id": "step_002:parameter:003",
"flag": "-E",
"display_name": "E-value threshold",
"description": "E-value threshold for full sequence hits in hmmsearch.",
"value": "1e-3",
},
]
return {"inputs": inputs, "parameters": parameters}
def _record(
extension_id: str,
*,
status: str = "published",
summary: str = "This tool searches a protein FASTA file against an HMM profile using hmmsearch.",
contract: dict | None = None,
) -> dict:
return {
"extension_id": extension_id,
"status": status,
"semantic_candidate": {
"function_summary": summary,
},
"runtime_binding_contract": contract if contract is not None else _contract(),
}
def _good_raw() -> dict:
return {
"extension_id": "ext_hmm",
"input_overrides": [
{"path": "family.hmm", "value": "input/job-1/_nl_imports/family.hmm"},
{"path": "01.col.primary.pep.fa", "value": "input/job-1/01.col.primary.pep.fa"},
],
"parameter_overrides": [
{"fact_id": "step_002:parameter:003", "value": "1e-5"},
],
"explicit_prefix": "outputs/nl_run",
"confidence": 0.85,
}
def _catalog() -> dict:
return _module()._build_catalog([_record("ext_hmm")])
def _response(content: str):
return SimpleNamespace(
id="request-123",
model="deepseek-test",
choices=[SimpleNamespace(message=SimpleNamespace(content=content), finish_reason="stop")],
usage=SimpleNamespace(prompt_tokens=120, completion_tokens=80, total_tokens=200),
)
class _FakeCompletions:
def __init__(self, response=None, error: Exception | None = None) -> None:
self.response = response
self.error = error
self.requests: list[dict] = []
def create(self, **kwargs):
self.requests.append(kwargs)
if self.error is not None:
raise self.error
return self.response
class _FakeClient:
def __init__(self, response=None, error: Exception | None = None) -> None:
self.completions = _FakeCompletions(response=response, error=error)
self.chat = SimpleNamespace(completions=self.completions)
class _CountingFactory:
def __init__(self, client) -> None:
self.client = client
self.calls = 0
self.configs: list[Any] = []
def __call__(self, config):
self.calls += 1
self.configs.append(config)
return self.client
# ---------- 1. 粗筛不中不调 LLM ----------
@pytest.mark.parametrize(
"message",
[
"帮我看看这株水稻的基因家族有哪些",
"请绘制进化树并做共线性分析",
"这个蛋白序列应该怎么注释",
],
)
def test_no_trigger_message_resolves_none_and_never_calls_client(message: str) -> None:
records = [_record("ext_hmm")]
fake = _FakeClient(response=_response(json.dumps(_good_raw())))
factory = _CountingFactory(fake)
resolution = _module().resolve_lightweight_extension_intent(
message, records, environ={}, client_factory=factory
)
assert resolution.extension_id is None
assert resolution.reason == "no_trigger"
assert factory.calls == 0
assert fake.completions.requests == []
# ---------- 2. 触发词命中但无 records ----------
def test_trigger_without_records_never_calls_llm() -> None:
fake = _FakeClient(response=_response(json.dumps(_good_raw())))
factory = _CountingFactory(fake)
resolution = _module().resolve_lightweight_extension_intent(
"使用拓展分析处理我的文件", [], environ={}, client_factory=factory
)
assert resolution.extension_id is None
assert resolution.reason == "no_published_records"
assert factory.calls == 0
# ---------- 3. _build_catalog ----------
def test_build_catalog_only_replaceable_inputs_without_locked_and_parameter_values() -> None:
catalog = _module()._build_catalog([_record("ext_hmm")])
assert catalog["truncated"] is False
entry = catalog["extensions"][0]
assert entry["extension_id"] == "ext_hmm"
assert entry["function_summary"].startswith("This tool searches a protein FASTA")
input_keys = {tuple(sorted(item.keys())) for item in entry["inputs"]}
assert input_keys == {("description", "path", "role")}
paths = {item["path"] for item in entry["inputs"]}
assert "family.hmm" in paths
assert "01.col.primary.pep.fa" in paths
assert "02_hmm/fixed.tsv" not in paths # locked 输入被剔除
parameter_keys = {tuple(sorted(item.keys())) for item in entry["parameters"]}
assert parameter_keys == {("description", "display_name", "fact_id", "flag")}
assert all("value" not in item for item in entry["parameters"])
flags = {item["flag"] for item in entry["parameters"]}
assert flags == {"-p", "-E"}
def test_build_catalog_truncates_when_over_max_chars() -> None:
module = _module()
long_summary = "x" * 500
record = _record("ext_long", summary=long_summary)
single_size = len(json.dumps(module._build_catalog([record])["extensions"][0], ensure_ascii=False))
assert single_size < module.MAX_CATALOG_CHARS # 单条不足以触发截断
records = [_record(f"ext_{i:02d}", summary=long_summary) for i in range(60)]
catalog = module._build_catalog(records)
assert catalog["truncated"] is True
included = len(catalog["extensions"])
assert included < len(records)
assert included >= 1
total = sum(len(json.dumps(item, ensure_ascii=False)) for item in catalog["extensions"])
assert total <= module.MAX_CATALOG_CHARS
# 截断点之后的首条记录(若追加)必然超限
first_excluded = records[included]["extension_id"]
extra = len(json.dumps(module._build_catalog([records[included]])["extensions"][0], ensure_ascii=False))
assert total + extra > module.MAX_CATALOG_CHARS
assert first_excluded not in [item["extension_id"] for item in catalog["extensions"]]
# ---------- 4. _sanitize_resolution ----------
@pytest.mark.parametrize(
("mutate", "fragment"),
[
(lambda raw: raw.update({"input_overrides": [{"path": "invented.fa", "value": "input/job-1/invented.fa"}]}),
"input_overrides 引用了不存在的 path"),
(lambda raw: raw.update({"parameter_overrides": [{"fact_id": "step_099:parameter:099", "value": "x"}]}),
"parameter_overrides 引用了不存在的 fact_id"),
(lambda raw: raw.update({"extension_id": "ext_invented"}), "extension_id 不在可选目录中"),
(lambda raw: raw.update({"parameter_overrides": [{"fact_id": "step_002:parameter:003", "value": 7}]}),
"的 value 必须是非空字符串"),
(lambda raw: raw.update({"confidence": "high"}), "confidence 不是数字"),
(lambda raw: raw.update({"input_overrides": "family.hmm"}), "input_overrides 必须是数组"),
(lambda raw: raw.update({"input_overrides": [{"path": "family.hmm", "value": ""}]}),
"的 value 必须是非空字符串"),
],
)
def test_sanitize_rejects_invalid_resolution(mutate, fragment: str) -> None:
raw = _good_raw()
mutate(raw)
sanitized, reason = _module()._sanitize_resolution(raw, _catalog())
assert sanitized is None
assert reason is not None
assert fragment in reason
@pytest.mark.parametrize(
"raw",
[
"not a json object",
["extension_id"],
None,
42,
],
)
def test_sanitize_rejects_non_object_payloads(raw) -> None:
sanitized, reason = _module()._sanitize_resolution(raw, _catalog())
assert sanitized is None
assert reason is not None
def test_sanitize_accepts_valid_resolution_and_maps_fields() -> None:
sanitized, reason = _module()._sanitize_resolution(_good_raw(), _catalog())
assert reason is None
assert sanitized is not None
assert sanitized["extension_id"] == "ext_hmm"
assert sanitized["input_overrides"] == {
"family.hmm": "input/job-1/_nl_imports/family.hmm",
"01.col.primary.pep.fa": "input/job-1/01.col.primary.pep.fa",
}
assert sanitized["parameter_overrides"] == {"step_002:parameter:003": "1e-5"}
assert sanitized["explicit_prefix"] == "outputs/nl_run"
assert sanitized["confidence"] == 0.85
def test_sanitize_accepts_parameter_override_even_when_user_message_omits_it() -> None:
"""sanitize 只做结构校验;'参数值必须来自用户消息' 的约束在 prompt 层,不做程序级校验。"""
raw = _good_raw() # 参数 1e-5 未在用户消息中出现(消息由调用方构造)
sanitized, reason = _module()._sanitize_resolution(raw, _catalog())
assert reason is None
assert sanitized is not None
assert sanitized["parameter_overrides"] == {"step_002:parameter:003": "1e-5"}
def test_sanitize_allows_missing_optional_fields() -> None:
raw = {"extension_id": "ext_hmm", "confidence": None}
sanitized, reason = _module()._sanitize_resolution(raw, _catalog())
assert reason is None
assert sanitized is not None
assert sanitized["input_overrides"] == {}
assert sanitized["parameter_overrides"] == {}
assert sanitized["explicit_prefix"] is None
assert sanitized["confidence"] == 0.0
# ---------- 5. LLM 异常 / 坏 JSON / 缺 choices ----------
@pytest.mark.parametrize(
("content", "error"),
[
("this is not json", None), # 坏 JSON
("{\"hello\": \"world\"}", None), # 合法 JSON 但字段缺失
(None, RuntimeError("connection reset")), # LLM 抛异常
],
)
def test_llm_failures_degrade_to_none_resolution(content, error) -> None:
fake = _FakeClient(response=_response(content) if content is not None else None, error=error)
factory = _CountingFactory(fake)
records = [_record("ext_hmm")]
resolution = _module().resolve_lightweight_extension_intent(
"使用 HMM 拓展分析对刚上传的文件进行分析",
records,
environ={},
client_factory=factory,
)
assert resolution.extension_id is None
assert resolution.reason == "llm_unavailable_or_invalid" or resolution.reason.startswith("invalid_resolution:")
assert factory.calls == 1
class _NoChoicesClient:
def __init__(self) -> None:
self.chat = SimpleNamespace(
completions=SimpleNamespace(
create=lambda **kwargs: SimpleNamespace(choices=[]),
)
)
def test_missing_choices_degrades_to_none_resolution() -> None:
factory = _CountingFactory(_NoChoicesClient())
resolution = _module().resolve_lightweight_extension_intent(
"使用 HMM 拓展分析对刚上传的文件进行分析",
[_record("ext_hmm")],
environ={},
client_factory=factory,
)
assert resolution.extension_id is None
assert resolution.reason == "llm_unavailable_or_invalid"
# ---------- 6. happy path ----------
def test_happy_path_resolves_all_fields_and_prefills() -> None:
fake = _FakeClient(response=_response(json.dumps(_good_raw())))
factory = _CountingFactory(fake)
records = [_record("ext_hmm")]
message = "使用 HMM 拓展分析对刚上传的文件进行分析,参数 e-value=1e-5"
resolution = _module().resolve_lightweight_extension_intent(
message, records, environ={}, client_factory=factory
)
assert resolution.extension_id == "ext_hmm"
assert resolution.extension_name == "ext_hmm"
assert resolution.input_overrides == {
"family.hmm": "input/job-1/_nl_imports/family.hmm",
"01.col.primary.pep.fa": "input/job-1/01.col.primary.pep.fa",
}
assert resolution.parameter_overrides == {"step_002:parameter:003": "1e-5"}
assert resolution.explicit_prefix == "outputs/nl_run"
assert resolution.confidence == 0.85
assert resolution.prefill is True
assert resolution.reason == "ok"
assert resolution.candidates_considered == ["ext_hmm"]
assert factory.calls == 1
def test_high_confidence_without_overrides_does_not_prefill() -> None:
raw = _good_raw()
raw["input_overrides"] = []
raw["parameter_overrides"] = []
fake = _FakeClient(response=_response(json.dumps(raw)))
resolution = _module().resolve_lightweight_extension_intent(
"使用 HMM 拓展分析", [_record("ext_hmm")], environ={}, client_factory=lambda config: fake
)
assert resolution.extension_id == "ext_hmm"
assert resolution.confidence >= _module().PREFILL_THRESHOLD
assert resolution.prefill is False
def test_low_confidence_does_not_prefill() -> None:
raw = _good_raw()
raw["confidence"] = 0.3
fake = _FakeClient(response=_response(json.dumps(raw)))
resolution = _module().resolve_lightweight_extension_intent(
"使用 HMM 拓展分析", [_record("ext_hmm")], environ={}, client_factory=lambda config: fake
)
assert resolution.extension_id == "ext_hmm"
assert resolution.prefill is False
# ---------- 7. load_published_lightweight_records ----------
def test_load_published_records_only_returns_published(monkeypatch, tmp_path: Path) -> None:
_write(tmp_path, "extensions/ext_pub/extension.json", json.dumps(_record("ext_pub", status="published")))
_write(tmp_path, "extensions/ext_pending/extension.json", json.dumps(_record("ext_pending", status="confirmation_pending")))
_write(tmp_path, "extensions/ext_failed/extension.json", json.dumps(_record("ext_failed", status="baseline_failed")))
_write(tmp_path, "extensions/ext_bad/extension.json", "{bad json")
_write(tmp_path, "extensions/ext_notdict/extension.json", "[1, 2, 3]")
_write(tmp_path, "extensions/ext_other/list.txt", "{}")
records = _module().load_published_lightweight_records(tmp_path)
ids = [str(r["extension_id"]) for r in records]
assert ids == ["ext_pub"]
# ---------- 8. job_file_hints 进入 LLM user 消息 ----------
def test_job_file_hints_reach_llm_user_message() -> None:
fake = _FakeClient(response=_response(json.dumps(_good_raw())))
factory = _CountingFactory(fake)
_module().resolve_lightweight_extension_intent(
"使用 HMM 拓展分析对刚上传的文件进行分析",
[_record("ext_hmm")],
environ={},
client_factory=factory,
job_file_hints=["input/job-1/01.col.primary.pep.fa", "workspace/job-1/out/table.tsv"],
)
request = fake.completions.requests[0]
user_message = next(item["content"] for item in request["messages"] if item["role"] == "user")
payload = json.loads(user_message)
assert payload["user_message"] == "使用 HMM 拓展分析对刚上传的文件进行分析"
assert payload["job_files"] == ["input/job-1/01.col.primary.pep.fa", "workspace/job-1/out/table.tsv"]
assert payload["extensions"][0]["extension_id"] == "ext_hmm"
def test_job_file_hints_are_capped() -> None:
fake = _FakeClient(response=_response(json.dumps(_good_raw())))
_module().resolve_lightweight_extension_intent(
"使用 HMM 拓展分析",
[_record("ext_hmm")],
environ={},
client_factory=lambda config: fake,
job_file_hints=[f"input/job-1/file_{i}.txt" for i in range(500)],
)
request = fake.completions.requests[0]
user_message = next(item["content"] for item in request["messages"] if item["role"] == "user")
payload = json.loads(user_message)
assert len(payload["job_files"]) == _module().MAX_JOB_FILE_HINTS
# ---------- 9. 粗筛行为 ----------
def test_rough_candidates_without_records_is_empty() -> None:
assert _module()._rough_candidates("拓展分析我的文件", []) == []
def test_rough_trigger_without_records_is_false() -> None:
assert _module()._rough_trigger("拓展分析我的文件", []) is False
@pytest.mark.parametrize(
("message", "expected_ids"),
[
# 强触发词且无关键词命中:全量兜底
("帮我做拓展分析", ["ext_hmm", "ext_dia"]),
("帮我做扩展分析", ["ext_hmm", "ext_dia"]),
("do an extension analysis", ["ext_hmm", "ext_dia"]),
# 工具词命中子集(不再全量)
("用 hmmsearch 搜索结构域", ["ext_hmm"]),
# 工具词无关键词命中:空(不再全量);用不含 blast/hmmer 相关词的记录验证
("run blast against the library", []),
("run hmmer on my file", []),
# 英文关键词命中单个
("diamond align my sequences", ["ext_dia"]),
("evalue cutoff please", ["ext_dia"]),
# 无关消息
("帮我看看这株水稻的基因家族", []),
],
)
def test_rough_candidates(message: str, expected_ids: list[str]) -> None:
if message.startswith("run "):
# 工具词无关键词命中:用不含相关词的记录,确保结果为 []
records = [
_record("x9_zz", summary="Sorts entries in parallel for a table."),
_record("x9_yy", summary="Counts rows in a matrix."),
]
assert _module()._rough_candidates(message, records) == expected_ids
return
records = [
_record("ext_hmm", summary="This tool searches a protein FASTA file against an HMM profile using hmmsearch."),
_record("ext_dia", summary="This tool aligns sequences with diamond using an e-value cutoff."),
]
assert _module()._rough_candidates(message, records) == expected_ids
def test_rough_candidates_reject_summary_stopwords_only() -> None:
"""summary 只含停用词时,无关消息不应命中该记录。"""
records = [_record("ext_generic", summary="This tool analyzes sequence files using a data tool.")]
assert _module()._rough_candidates("帮我看看这株水稻的基因家族", records) == []
def test_rough_candidates_chinese_summary_substrings() -> None:
record = _record("ext_zh", summary="使用样本表校验工具对测序数据进行质控。")
assert "ext_zh" in _module()._rough_candidates("帮我做样本表校验", [record])
# ---------- 10. resolve 对空 catalog 的容错 ----------
def test_resolve_degrades_when_catalog_empty_after_rough_match() -> None:
record = _record("ext_broken")
record["runtime_binding_contract"] = None # 粗筛命中(有 summary 关键词)但目录构造为空
fake = _FakeClient(response=_response(json.dumps(_good_raw())))
factory = _CountingFactory(fake)
resolution = _module().resolve_lightweight_extension_intent(
"hmmsearch tool analysis", [record], environ={}, client_factory=factory
)
assert resolution.extension_id is None
assert factory.calls == 0
def test_resolution_uses_compact_extension_name() -> None:
raw = _good_raw()
raw["extension_id"] = "03___hmm_1787130993548"
fake = _FakeClient(response=_response(json.dumps(raw)))
summary = "Searches profiles. It then parses the domain table and reports hits."
record = _record("03___hmm_1787130993548", summary=summary)
resolution = _module().resolve_lightweight_extension_intent(
"使用拓展分析", [record], environ={}, client_factory=lambda config: fake
)
assert resolution.extension_name == "03___hmm"
# ---------- 11. 真实 store 冒烟 ----------
def test_real_store_catalog_truncation_smoke() -> None:
"""用远端真实 store 验证目录截断现象(数据源随仓库存在)。"""
store_root = Path(__file__).resolve().parents[2] / ".runtime/lightweight-extension/store"
records = _module().load_published_lightweight_records(store_root)
if not records:
pytest.skip("真实 store 不可用(无 published 记录)")
catalog = _module()._build_catalog(records)
all_ids = sorted(str(r.get("extension_id")) for r in records)
included_ids = [item["extension_id"] for item in catalog["extensions"]]
assert catalog["truncated"] is True
assert set(included_ids) < set(all_ids)
def test_rough_trigger_accepts_real_published_records() -> None:
store_root = Path(__file__).resolve().parents[2] / ".runtime/lightweight-extension/store"
records = _module().load_published_lightweight_records(store_root)
if not records:
pytest.skip("真实 store 不可用(无 published 记录)")
assert _module()._rough_trigger("帮我做拓展分析", records) is True
# ---------- 12. 分支补测(覆盖率补充) ----------
def test_load_published_records_bad_store_root_returns_empty() -> None:
# Path 构造带 NUL 字节会抛 ValueError,走 except 分支
assert _module().load_published_lightweight_records(5) == []
def test_function_summary_text_missing_semantic_candidate_returns_empty() -> None:
record = _record("ext_none")
record["semantic_candidate"] = None
assert _module()._function_summary_text(record) == ""
def test_build_catalog_skips_record_without_extension_id() -> None:
record = _record("")
catalog = _module()._build_catalog([record])
assert catalog["extensions"] == []
assert catalog["truncated"] is False
def test_sanitize_rejects_non_mapping_input_override_element() -> None:
raw = _good_raw()
raw["input_overrides"] = [42]
sanitized, reason = _module()._sanitize_resolution(raw, _catalog())
assert sanitized is None
assert reason == "input_overrides 元素必须是对象"
def test_sanitize_rejects_parameter_overrides_not_list() -> None:
raw = _good_raw()
raw["parameter_overrides"] = {"step_002:parameter:003": "1e-5"}
sanitized, reason = _module()._sanitize_resolution(raw, _catalog())
assert sanitized is None
assert reason == "parameter_overrides 必须是数组"
def test_sanitize_rejects_non_mapping_parameter_element() -> None:
raw = _good_raw()
raw["parameter_overrides"] = ["1e-5"]
sanitized, reason = _module()._sanitize_resolution(raw, _catalog())
assert sanitized is None
assert reason == "parameter_overrides 元素必须是对象"
def test_sanitize_rejects_non_string_explicit_prefix() -> None:
raw = _good_raw()
raw["explicit_prefix"] = 42
sanitized, reason = _module()._sanitize_resolution(raw, _catalog())
assert sanitized is None
assert reason == "explicit_prefix 必须是字符串或 null"
def test_sanitize_rejects_confidence_out_of_range() -> None:
raw = _good_raw()
raw["confidence"] = 1.5
sanitized, reason = _module()._sanitize_resolution(raw, _catalog())
assert sanitized is None
assert reason == "confidence 超出 [0,1]"
def test_default_client_path_uses_configured_env_file(monkeypatch, tmp_path: Path) -> None:
"""environ 提供有效 env 文件且未注入 client_factory 时走 _default_client 分支。"""
from gfa_core.extension_stage_lightweight import deepseek_semantics
env_file = _write(
tmp_path,
".env",
"NEW_API_BASE_URL=https://api.example.test/v1\nNEW_API_MODEL=deepseek-test\nNEW_API_KEY=secret-test-key\n",
)
fake = _FakeClient(response=_response(json.dumps(_good_raw())))
monkeypatch.setattr(deepseek_semantics, "_default_client", lambda config: fake)
resolution = _module().resolve_lightweight_extension_intent(
"使用 HMM 拓展分析",
[_record("ext_hmm")],
environ={"BBAV5_PROJECT_ENV_FILE": str(env_file)},
client_factory=None,
)
assert resolution.extension_id == "ext_hmm"
assert fake.completions.requests
def test_default_client_without_config_degrades(monkeypatch) -> None:
"""environ 无有效配置且未注入 client_factory 时,默认客户端分支因 config=None 降级。"""
from gfa_core.extension_stage_lightweight import deepseek_semantics
monkeypatch.setattr(deepseek_semantics, "load_deepseek_config", lambda environ: None)
resolution = _module().resolve_lightweight_extension_intent(
"使用 HMM 拓展分析",
[_record("ext_hmm")],
environ={},
client_factory=None,
)
assert resolution.extension_id is None
assert resolution.reason == "llm_unavailable_or_invalid"
def test_real_store_pure_chinese_function_words_miss_rough_candidates() -> None:
"""R2 实测固化:function_summary 全英文,纯中文功能词(无强触发词)不命中粗筛、不调 LLM。"""
store_root = Path(__file__).resolve().parents[2] / ".runtime/lightweight-extension/store"
records = _module().load_published_lightweight_records(store_root)
if not records:
pytest.skip("真实 store 不可用(无 published 记录)")
assert _module()._rough_candidates("帮我做样本表校验", records) == []
assert _module()._rough_trigger("帮我做样本表校验", records) is False
# 强触发词下全部记录进候选(61 个),目录截断导致 LLM 看不到后段记录
assert len(_module()._rough_candidates("用拓展分析做分析", records)) == len(records)
# ---------- 13. 第二轮:union 粗筛 / compact 名称 / catalog 24000 ----------
@pytest.mark.parametrize(
("extension_id", "expected"),
[
("03___hmm_1787130993548", "03___hmm"),
("10_pangene__pangene_1786213304494", "10_pangene__pangene"),
("ext_no_timestamp", "ext_no_timestamp"),
("", ""),
("x" * 40, "x" * 30),
("12_1787211341321", "12"),
],
)
def test_compact_extension_name(extension_id: str, expected: str) -> None:
assert _module().compact_extension_name(extension_id) == expected
def test_extension_id_tokens() -> None:
record = _record("03___hmm_1787130993548")
tokens = _module()._extension_id_tokens(record)
assert "hmm" in tokens
assert "03" not in tokens # len<3 剔除
def test_parameter_flag_tokens() -> None:
record = _record("ext_hmm")
record["runtime_binding_contract"]["parameters"] = [
{"flag": "--samples", "fact_id": "p1"},
{"flag": "-e", "fact_id": "p2"}, # len<3 剔除
{"flag": "--E", "fact_id": "p3"}, # 单字母剔除
{"flag": "output", "fact_id": "p4"}, # 英文停用词剔除
]
tokens = _module()._parameter_flag_tokens(record)
assert "samples" in tokens
assert "e" not in tokens
assert "output" not in tokens
def test_parameter_flag_tokens_no_contract() -> None:
record = _record("ext_hmm")
record["runtime_binding_contract"] = None
assert _module()._parameter_flag_tokens(record) == []
def test_record_keyword_sources_merges_all_sources() -> None:
record = _record("03___hmm_1787130993548", summary="Aligns with diamond using e-value.")
record["runtime_binding_contract"]["parameters"] = [{"flag": "--samples"}]
sources = _module()._record_keyword_sources(record)
assert "hmm" in sources # extension_id token
assert "diamond" in sources # summary token
assert "samples" in sources # parameter flag
def test_rough_candidates_union_strategy_with_strong_trigger_and_keyword() -> None:
"""带工具词 + 强触发词 → 关键词子集(不因强触发词全量)。"""
records = [
_record("ext_hmm", summary="This tool searches a protein FASTA file against an HMM profile using hmmsearch."),
_record("ext_dia", summary="This tool aligns sequences with diamond using an e-value cutoff."),
]
cands = _module()._rough_candidates("用 hmmsearch 拓展分析我的文件", records)
assert cands == ["ext_hmm"]
def test_rough_candidates_pure_strong_trigger_falls_back_to_all() -> None:
records = [
_record("ext_hmm", summary="This tool searches a protein FASTA file against an HMM profile using hmmsearch."),
_record("ext_dia", summary="This tool aligns sequences with diamond using an e-value cutoff."),
]
cands = _module()._rough_candidates("帮我做拓展分析", records)
assert sorted(cands) == ["ext_dia", "ext_hmm"]
def test_rough_candidates_no_match_returns_empty() -> None:
records = [_record("ext_hmm", summary="This tool searches a protein FASTA file against an HMM profile using hmmsearch.")]
assert _module()._rough_candidates("帮我看看这株水稻的基因家族", records) == []
def test_rough_candidates_skips_missing_extension_id() -> None:
"""缺失 extension_id 的记录被过滤,不产出 None 候选。"""
no_id = _record("")
no_id["extension_id"] = None
records = [
no_id,
_record("ext_hmm", summary="This tool searches a protein FASTA file against an HMM profile using hmmsearch."),
]
cands = _module()._rough_candidates("hmmsearch 结构域", records)
assert cands == ["ext_hmm"]
assert "None" not in cands
assert None not in cands
def test_rough_candidates_sorted_by_hits_desc() -> None:
"""命中关键词数降序(同分按 id 排序)。"""
records = [
_record("ext_a", summary="diamond e-value cutoff search"),
_record("ext_b", summary="diamond align"),
]
cands = _module()._rough_candidates("diamond e-value", records)
assert cands == ["ext_a", "ext_b"]
def test_catalog_uses_max_24000() -> None:
module = _module()
assert module.MAX_CATALOG_CHARS == 24000
long_summary = "x" * 500
records = [_record(f"ext_{i:02d}", summary=long_summary) for i in range(60)]
catalog = module._build_catalog(records)
assert catalog["truncated"] is True
included = len(catalog["extensions"])
assert included < len(records)
total = sum(len(json.dumps(item, ensure_ascii=False)) for item in catalog["extensions"])
assert total <= module.MAX_CATALOG_CHARS
first_excluded = records[included]["extension_id"]
extra = len(json.dumps(module._build_catalog([records[included]])["extensions"][0], ensure_ascii=False))
assert total + extra > module.MAX_CATALOG_CHARS
assert first_excluded not in [item["extension_id"] for item in catalog["extensions"]]
def test_catalog_dedupes_duplicate_ids() -> None:
module = _module()
records = [_record("ext_dup"), _record("ext_dup")]
catalog = module._build_catalog(records)
assert len(catalog["extensions"]) == 1
def test_truncated_catalog_sets_reason_hint() -> None:
"""目录截断时 resolution.reason 注明请补充工具名。"""
module = _module()
records = [_record(f"ext_{i:02d}", summary="x" * 500) for i in range(60)]
raw = _good_raw()
raw["extension_id"] = "ext_00" # catalog 首个 id(命中数降序后)
# 纯强触发词全量兜底 → catalog 截断 → reason 带提示
resolution = module.resolve_lightweight_extension_intent(
"帮我做拓展分析",
records,
environ={},
client_factory=lambda config: _FakeClient(response=_response(json.dumps(raw))),
)
assert resolution.reason.startswith("ok; 候选过多已截断")
def test_resolve_orders_records_by_candidate_score() -> None:
"""catalog 顺序跟随 _rough_candidates 的命中数降序。"""
module = _module()
records = [
_record("ext_a", summary="diamond e-value cutoff search"),
_record("ext_b", summary="diamond align"),
]
raw = _good_raw()
raw["extension_id"] = "ext_a"
fake = _FakeClient(response=_response(json.dumps(raw)))
resolution = module.resolve_lightweight_extension_intent(
"diamond e-value", records, environ={}, client_factory=lambda config: fake
)
assert resolution.extension_id == "ext_a"
request = fake.completions.requests[0]
user_message = next(item["content"] for item in request["messages"] if item["role"] == "user")
payload = json.loads(user_message)
ids = [e["extension_id"] for e in payload["extensions"]]
assert ids[0] == "ext_a"
def test_parameter_flag_tokens_skips_non_mapping_items() -> None:
"""parameters 列表含非 dict 元素时跳过。"""
record = _record("ext_hmm")
record["runtime_binding_contract"]["parameters"] = [
{"flag": "--samples", "fact_id": "p1"},
"not-a-dict",
42,
None,
]
tokens = _module()._parameter_flag_tokens(record)
assert tokens == ["samples"]
def test_real_store_pm_alignment_counts() -> None:
"""PM 实测对齐固化:HMM→9、DIAMOND→8、OrthoFinder→3、KaKs→2、ManifestQC→5、纯拓展→61。"""
store_root = Path(__file__).resolve().parents[2] / ".runtime/lightweight-extension/store"
records = _module().load_published_lightweight_records(store_root)
if len(records) != 61:
pytest.skip(f"真实 store 记录数变化(当前 {len(records)},基线 61),跳过 PM 对齐断言")
assert len(_module()._rough_candidates("帮我用 HMM 拓展分析我的文件", records)) == 9
assert len(_module()._rough_candidates("帮我用 DIAMOND 拓展分析", records)) == 8
assert len(_module()._rough_candidates("用 OrthoFinder 拓展分析", records)) == 3
assert len(_module()._rough_candidates("用 KaKs 拓展分析", records)) == 2
assert len(_module()._rough_candidates("用 ManifestQC 拓展分析", records)) == 5
assert len(_module()._rough_candidates("帮我做拓展分析", records)) == 61
# HMM 子集全为 03_ 系列(hmm 关键词),且截断在子集内不发生
hmm = _module()._rough_candidates("帮我用 HMM 拓展分析我的文件", records)
assert all(c.startswith("03___hmm") for c in hmm[:4])
catalog = _module()._build_catalog(
[r for r in records if str(r.get("extension_id")) in hmm]
)
assert catalog["truncated"] is False