Merge remote-tracking branch 'origin/master' into fix/ppi-kaks-enable
# Conflicts: # tests/unit/test_router_analysis_handoff_boundary.py
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Small real-data journey for the gene-family report module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from apps.cli.run_gene_family_report import main as report_cli_main
|
||||
from gfa_core.mas.utils.intent_parser import parse_intent_from_query
|
||||
|
||||
|
||||
PREFIX = "Real.SPL"
|
||||
SCOPE_ID = "scope-real-spl"
|
||||
ANALYSIS_ROUTE = "post_subfamily_confirm"
|
||||
MEMBER_SET_ID = "members-real-spl"
|
||||
|
||||
|
||||
def _sequence_count(path: Path) -> int:
|
||||
count = sum(line.startswith(">") for line in path.read_text(encoding="utf-8").splitlines())
|
||||
if not count:
|
||||
raise AssertionError("representative FASTA is empty")
|
||||
return count
|
||||
|
||||
|
||||
def _write_identify_manifest(workspace: Path, fasta: Path, count: int) -> None:
|
||||
final_dir = workspace / "final"
|
||||
final_dir.mkdir(parents=True, exist_ok=True)
|
||||
manifest = {
|
||||
"manifest_version": "1.0",
|
||||
"stage": "identify",
|
||||
"status": "success",
|
||||
"prefix": PREFIX,
|
||||
"scope_id": SCOPE_ID,
|
||||
"analysis_route": ANALYSIS_ROUTE,
|
||||
"member_set_id": MEMBER_SET_ID,
|
||||
"parameters": {
|
||||
"scope_id": SCOPE_ID,
|
||||
"analysis_route": ANALYSIS_ROUTE,
|
||||
"member_set_id": MEMBER_SET_ID,
|
||||
},
|
||||
"metrics": {"member_count": count, "final_members": count, "total_proteins": count},
|
||||
"artifacts": {
|
||||
"final_members": {"path": str(fasta), "full_path": str(fasta), "type": "protein_fasta"}
|
||||
},
|
||||
}
|
||||
(final_dir / f"{PREFIX}.identify.manifest.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _run(workspace: Path, action: str):
|
||||
return report_cli_main(
|
||||
[
|
||||
"--workspace", str(workspace),
|
||||
"--prefix", PREFIX,
|
||||
"--job-id", "representative-gene-family-report",
|
||||
"--expected-stages", "identify",
|
||||
"--stage-statuses-json", '{"identify":"success"}',
|
||||
"--scope-id", SCOPE_ID,
|
||||
"--analysis-route", ANALYSIS_ROUTE,
|
||||
"--member-set-id", MEMBER_SET_ID,
|
||||
"--action", action,
|
||||
"--no-model",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _stage_section(report: str, stage: str) -> str:
|
||||
heading = re.search(rf"(?m)^## (?!#).*?\(`{re.escape(stage)}`\)", report)
|
||||
if heading is None:
|
||||
raise AssertionError(f"missing report chapter for {stage}")
|
||||
next_heading = re.search(r"(?m)^## (?!#)", report[heading.end():])
|
||||
end = heading.end() + next_heading.start() if next_heading else len(report)
|
||||
return report[heading.start():end]
|
||||
|
||||
|
||||
def _subsection(report: str, number: str) -> str:
|
||||
heading = re.search(rf"(?m)^### {re.escape(number)}(?:\s|$)", report)
|
||||
if heading is None:
|
||||
raise AssertionError(f"missing report subsection {number}")
|
||||
next_heading = re.search(r"(?m)^#{2,3} ", report[heading.end():])
|
||||
end = heading.end() + next_heading.start() if next_heading else len(report)
|
||||
return report[heading.start():end]
|
||||
|
||||
|
||||
def _verify_real_job(workspace: Path, prefix: str) -> None:
|
||||
final_dir = workspace / "final"
|
||||
report_path = final_dir / f"{prefix}.gene_family_report.md"
|
||||
manifest_path = final_dir / f"{prefix}.gene_family_report.manifest.json"
|
||||
report = report_path.read_text(encoding="utf-8")
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
expected_stages = [
|
||||
"identify",
|
||||
"phylogeny",
|
||||
"reference_classification",
|
||||
"motif",
|
||||
"domain",
|
||||
"structure",
|
||||
"combo",
|
||||
"promoter",
|
||||
"tfbs",
|
||||
"synteny",
|
||||
"karyotype",
|
||||
"interspecies_synteny",
|
||||
"pep",
|
||||
]
|
||||
|
||||
assert report.startswith(f"# {prefix} 基因家族分析结果解读报告\n")
|
||||
for placeholder in ("【填写】", "未解析", "N/A", "None"):
|
||||
assert placeholder not in report
|
||||
assert "/home/xzq/" not in report
|
||||
assert not re.search(r"(?m)^- `[a-zA-Z0-9_.-]+`:\s*", report)
|
||||
assert manifest["parameters"]["expected_stages"] == expected_stages
|
||||
assert manifest["metrics"]["included_stages"] == expected_stages
|
||||
assert manifest["metrics"]["generation_mode"] == "fallback_script"
|
||||
assert sorted(path.name for path in final_dir.glob(f"{prefix}.gene_family_report*")) == [
|
||||
f"{prefix}.gene_family_report.manifest.json",
|
||||
f"{prefix}.gene_family_report.md",
|
||||
]
|
||||
for stage in expected_stages:
|
||||
section = _stage_section(report, stage)
|
||||
for chunk in re.split(r"(?m)(?=^### )", section)[1:]:
|
||||
assert "\n".join(chunk.splitlines()[1:]).strip(), chunk.splitlines()[0]
|
||||
assert "分析对象:" in section
|
||||
assert "限制:" in section
|
||||
assert report.count("本节解释仅限上述可追溯结果,不据此断言真实功能或因果关系。") <= 1
|
||||
summary = _subsection(report, "1.3")
|
||||
assert 6 <= summary.count("。") <= 10
|
||||
assert re.search(r"目标物种.*24.*家族成员", summary)
|
||||
assert re.search(r"形成\s*24\s*个\s*SPL家族", _subsection(report, "4.3"))
|
||||
assert re.search(
|
||||
r"使用\s*40\s*条序列.*比对长度为\s*1449", _stage_section(report, "phylogeny")
|
||||
)
|
||||
motif = _stage_section(report, "motif")
|
||||
assert re.search(r"识别\s*10\s*个\s*motif", motif)
|
||||
assert re.search(r"总位点数为\s*105", motif)
|
||||
assert re.search(r"宽度范围为\s*27–138", motif)
|
||||
assert re.search(r"将\s*24\s*个家族成员定位到\s*8\s*条染色体", _stage_section(report, "karyotype"))
|
||||
assert re.search(
|
||||
r"全基因组\s*block\s*521\s*个.*目标-参考家族.*?2\s*对",
|
||||
_stage_section(report, "interspecies_synteny"),
|
||||
)
|
||||
assert re.search(r"覆盖\s*40\s*个蛋白,子任务成功数为\s*3/3", _stage_section(report, "pep"))
|
||||
assert re.search(r"24\s*个目标成员.*8\s*个小组.*高信心\s*14.*中信心\s*7.*低信心\s*3", _subsection(report, "6.2"))
|
||||
assert re.search(r"40/40.*SBP.*40.*Ig_SPL7.*12", _subsection(report, "8.1"))
|
||||
assert re.search(r"24/40.*FtPinG0003446200\.01\.T01.*12454", _subsection(report, "9.1"))
|
||||
assert "Motif、Domain、Structure" in _subsection(report, "10.1")
|
||||
promoter = _subsection(report, "11.1")
|
||||
assert re.search(r"23\s*个目标基因.*3699.*TATA-box.*1460", promoter)
|
||||
assert "CAAT-box—MYB" in promoter and "FtPinG0006337300.01.T01" in promoter
|
||||
tfbs = _subsection(report, "12.1")
|
||||
assert re.search(r"24\s*条目标启动子.*3734.*AHYPO_015621.*153", tfbs)
|
||||
assert re.search(r"背景启动子为\s*0.*不能.*富集", tfbs)
|
||||
synteny = _subsection(report, "13.1")
|
||||
assert re.search(r"全基因组.*316.*3267.*8\s*条染色体", synteny)
|
||||
assert "源结果未提供家族专属复制基因对" in synteny
|
||||
assert re.search(r"Nucleus\s*37.*Cytoplasm\s*2.*Chloroplast\s*1", _subsection(report, "16.4"))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--mode", choices=("data", "e2e"))
|
||||
parser.add_argument("--workspace", type=Path)
|
||||
parser.add_argument("--prefix")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.workspace is not None:
|
||||
if not args.prefix:
|
||||
parser.error("--prefix is required with --workspace")
|
||||
_verify_real_job(args.workspace, args.prefix)
|
||||
print(json.dumps({"status": "passed", "workspace": str(args.workspace)}, ensure_ascii=False))
|
||||
return 0
|
||||
if args.mode is None:
|
||||
parser.error("--mode is required when --workspace is not provided")
|
||||
|
||||
output_dir = PROJECT_ROOT / "test-results/gene-family-report" / args.mode
|
||||
if output_dir.exists():
|
||||
shutil.rmtree(output_dir)
|
||||
workspace = output_dir / "job"
|
||||
fasta = PROJECT_ROOT / "tests/ft_data/SPL_Ath.fasta"
|
||||
count = _sequence_count(fasta)
|
||||
_write_identify_manifest(workspace, fasta, count)
|
||||
|
||||
generated = _run(workspace, "generate")
|
||||
report = generated.report_path.read_text(encoding="utf-8")
|
||||
manifest = json.loads(generated.manifest_path.read_text(encoding="utf-8"))
|
||||
assert generated.report_path.name == f"{PREFIX}.gene_family_report.md"
|
||||
assert generated.manifest_path.name == f"{PREFIX}.gene_family_report.manifest.json"
|
||||
assert "生成模式:候补脚本" in report
|
||||
assert f"最终成员数:{count}" in report
|
||||
assert "## 基因家族鉴定 (`identify`)" in report
|
||||
assert "(`motif`)" not in report
|
||||
assert manifest["scope_id"] == SCOPE_ID
|
||||
assert manifest["analysis_route"] == ANALYSIS_ROUTE
|
||||
assert manifest["member_set_id"] == MEMBER_SET_ID
|
||||
assert manifest["metrics"]["included_stages"] == ["identify"]
|
||||
assert set(manifest["artifacts"]) == {"gene_family_report", "gene_family_report_manifest"}
|
||||
|
||||
reopened = _run(workspace, "open")
|
||||
assert reopened.reused is True
|
||||
intent = parse_intent_from_query("打开基因家族结果报告")
|
||||
assert intent["stages"] == ["gene_family_report"]
|
||||
assert intent["params"]["reportAction"] == "open"
|
||||
|
||||
print(json.dumps({
|
||||
"status": "passed",
|
||||
"mode": args.mode,
|
||||
"sequence_count": count,
|
||||
"report": str(generated.report_path),
|
||||
}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -92,6 +92,7 @@ async def test_agent_chat_maps_router_actions_and_passes_context(
|
||||
"history": history,
|
||||
"session_id": "session-7",
|
||||
"job_id": "job-7",
|
||||
"language": "zh",
|
||||
}
|
||||
]
|
||||
assert result["success"] is True
|
||||
@@ -108,6 +109,40 @@ async def test_agent_chat_maps_router_actions_and_passes_context(
|
||||
assert result["intent"]["stages"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_chat_keeps_english_language_separate_from_business_query(
|
||||
monkeypatch,
|
||||
web_main,
|
||||
):
|
||||
captured = []
|
||||
_install_router(
|
||||
monkeypatch,
|
||||
{
|
||||
"status": "ok",
|
||||
"action": "reply",
|
||||
"message": "I can help with that.",
|
||||
"intent": None,
|
||||
"sources": [],
|
||||
"tool_trace": [],
|
||||
},
|
||||
captured,
|
||||
)
|
||||
request = web_main.AgentChatRequest(
|
||||
message="执行接下来的分析",
|
||||
language="en",
|
||||
session_id="english-business-query",
|
||||
job_id="job-english-business-query",
|
||||
history=[],
|
||||
)
|
||||
|
||||
result = await web_main.agent_chat(request)
|
||||
|
||||
assert result["intent"]["message"] == "I can help with that."
|
||||
assert request.message == "执行接下来的分析"
|
||||
assert captured[0]["query"] == "执行接下来的分析"
|
||||
assert captured[0]["language"] == "en"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_chat_persists_router_proposal_without_analysis_intent(
|
||||
monkeypatch,
|
||||
@@ -158,6 +193,68 @@ async def test_agent_chat_persists_router_proposal_without_analysis_intent(
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_chat_exposes_and_persists_current_recommendation(
|
||||
monkeypatch,
|
||||
web_main,
|
||||
tmp_path,
|
||||
):
|
||||
repository = SQLiteAgentStateRepository(tmp_path / "agent-state.sqlite3")
|
||||
repository.initialize()
|
||||
store = PersistentConversationStore(repository)
|
||||
monkeypatch.setattr(web_main, "agent_conversation_store", store)
|
||||
recommendation = {
|
||||
"recommendation_id": "rec-build-subfamily-evidence",
|
||||
"job_id": "job-recommendation",
|
||||
"workflow_id": "gene_family",
|
||||
"transition_id": "build_subfamily_evidence",
|
||||
"from_state_id": "family_identified",
|
||||
"to_state_id": "subfamily_evidence_ready",
|
||||
"target_stage": "combo",
|
||||
"authorized_stages": [
|
||||
"domain",
|
||||
"motif",
|
||||
"phylogeny",
|
||||
"structure",
|
||||
"combo",
|
||||
],
|
||||
"evidence_digest": "a" * 64,
|
||||
"family": "SPL",
|
||||
"scope_id": "SPL.target-Ft.target_only",
|
||||
}
|
||||
_install_router(
|
||||
monkeypatch,
|
||||
{
|
||||
"status": "ok",
|
||||
"action": "show_result",
|
||||
"message": "下一步是构建亚家族证据。",
|
||||
"intent": None,
|
||||
"recommendation": recommendation,
|
||||
"sources": [],
|
||||
"tool_trace": [],
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
result = await web_main.agent_chat(
|
||||
web_main.AgentChatRequest(
|
||||
message="接下来的分析有哪些?",
|
||||
session_id="recommendation-session",
|
||||
job_id="job-recommendation",
|
||||
history=[],
|
||||
)
|
||||
)
|
||||
|
||||
assert result["action"] == "show_result"
|
||||
assert result["recommendation"] == recommendation
|
||||
ledger = repository.get_goal_ledger(result["session_id"])
|
||||
assert ledger is not None
|
||||
assert (
|
||||
ledger.model_dump(mode="json")["user_terms"]["current_recommendation"]
|
||||
== recommendation
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_chat_exposes_confirmed_request_for_original_planner(
|
||||
monkeypatch,
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import asyncio
|
||||
|
||||
from apps.web.vue3 import main as api
|
||||
|
||||
|
||||
def test_build_subfamily_evidence_plan_keeps_confirmed_stage_boundary():
|
||||
intent = {
|
||||
"workflow_action_id": "build_subfamily_evidence",
|
||||
"stages": ["domain", "motif", "phylogeny", "structure", "combo"],
|
||||
"params": {
|
||||
"workflowActionId": "build_subfamily_evidence",
|
||||
"authorizedStages": [
|
||||
"domain",
|
||||
"motif",
|
||||
"phylogeny",
|
||||
"structure",
|
||||
"combo",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
stages = api._authorized_workflow_action_stages(intent)
|
||||
|
||||
assert stages == ["phylogeny", "motif", "domain", "structure", "combo"]
|
||||
assert "identify" not in stages
|
||||
assert "reference_classification" not in stages
|
||||
|
||||
|
||||
def test_unbound_plan_does_not_invent_an_authorized_stage_boundary():
|
||||
assert api._authorized_workflow_action_stages(
|
||||
{"stages": ["identify", "phylogeny", "reference_classification"]}
|
||||
) == []
|
||||
|
||||
|
||||
def test_agent_plan_honors_authorized_workflow_action_without_keyword_reclassification(
|
||||
monkeypatch,
|
||||
):
|
||||
authorized = ["domain", "motif", "phylogeny", "structure", "combo"]
|
||||
intent = {
|
||||
"gene_family": "SPL",
|
||||
"target_species": ["Ft"],
|
||||
"reference_species": ["At"],
|
||||
"stages": authorized,
|
||||
"primary_stage": "combo",
|
||||
"workflow_action_id": "build_subfamily_evidence",
|
||||
"params": {
|
||||
"workflowActionId": "build_subfamily_evidence",
|
||||
"authorizedStages": authorized,
|
||||
"targetSpecies": ["Ft"],
|
||||
"referenceSpecies": ["At"],
|
||||
},
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
api,
|
||||
"_reconcile_confirmed_gene_family_species",
|
||||
lambda *args, **kwargs: {"applicable": False, "conflicts": []},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
api, "_resolve_authoritative_gene_family", lambda *args, **kwargs: "SPL"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
api,
|
||||
"_resolve_subfamily_request_species_roles",
|
||||
lambda *args, **kwargs: {
|
||||
"classification_species": ["Ft"],
|
||||
"reference_species": ["At"],
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
api,
|
||||
"_latest_gene_family_scope_defaults",
|
||||
lambda *args, **kwargs: {
|
||||
"target_species": ["Ft"],
|
||||
"reference_species": ["At"],
|
||||
"comparative_species": [],
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
api,
|
||||
"_load_identify_species_selection",
|
||||
lambda *args, **kwargs: {
|
||||
"groups": {
|
||||
"target": [{"species": "Ft"}],
|
||||
"reference": [{"species": "At"}],
|
||||
"comparative": [],
|
||||
}
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
api,
|
||||
"_subfamily_state_for_targets",
|
||||
lambda *args, **kwargs: {"overall": "not_classified", "by_species": {}},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
api, "_identity_completed_stages_for_plan", lambda *args, **kwargs: []
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
api,
|
||||
"_resolve_contextual_gene_family_request",
|
||||
lambda *args, **kwargs: {
|
||||
"family": "SPL",
|
||||
"prefix": "Ft.SPL",
|
||||
"stages": authorized,
|
||||
"targetSpecies": ["Ft"],
|
||||
"referenceSpecies": ["At"],
|
||||
"comparativeSpecies": [],
|
||||
"scopeMode": "target_plus_reference",
|
||||
"contextGate": {"valid": False, "issues": []},
|
||||
"context_route": "gene_family_default_scope",
|
||||
"requiresConfirmedSubfamily": False,
|
||||
"skipSubfamilyLabels": False,
|
||||
"subfamilyState": {
|
||||
"overall": "not_classified",
|
||||
"by_species": {"Ft": {"status": "not_classified"}},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
response = asyncio.run(
|
||||
api.agent_plan(
|
||||
api.AgentPlanRequest(
|
||||
message="执行接下来的步骤",
|
||||
session_id="authorized-action-test",
|
||||
job_id="job-test",
|
||||
prefix="Ft.SPL",
|
||||
intent=intent,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
expected = ["phylogeny", "motif", "domain", "structure", "combo"]
|
||||
assert response["plan"]["all_stages"] == expected
|
||||
assert response["plan"]["stages"] == expected
|
||||
assert response["intent"]["workflow_action_id"] == "build_subfamily_evidence"
|
||||
assert response["intent"]["params"]["authorizedStages"] == authorized
|
||||
assert "reference_classification" not in response["plan"]["all_stages"]
|
||||
assert "subfamily_confirm" not in response["plan"]["all_stages"]
|
||||
@@ -0,0 +1,450 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from gfa_core.mas.agent_state_repository import SQLiteAgentStateRepository
|
||||
from gfa_core.mas.agents.conversation_tools import ConversationToolRegistry
|
||||
from gfa_core.mas.agents.router_runtime import RouterRuntime
|
||||
from gfa_core.mas.llm import LLMResponse, LLMToolCall
|
||||
from gfa_core.mas.persistent_conversation import PersistentConversationStore
|
||||
from gfa_core.mas.workflow_models import CurrentRecommendation, GoalLedger
|
||||
|
||||
|
||||
class _NoArgs(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class _IntentArgs(BaseModel):
|
||||
query: str
|
||||
|
||||
|
||||
class _SequenceLLM:
|
||||
def __init__(self, responses):
|
||||
self.responses = list(responses)
|
||||
|
||||
async def acall(self, *_args, **_kwargs):
|
||||
return self.responses.pop(0)
|
||||
|
||||
|
||||
def _response(content="", *, tool_calls=None):
|
||||
return LLMResponse(
|
||||
content=content,
|
||||
model="test-model",
|
||||
provider="test-provider",
|
||||
usage={},
|
||||
tool_calls=list(tool_calls or []),
|
||||
)
|
||||
|
||||
|
||||
def _recommendation(job_id="job-current-recommendation"):
|
||||
return CurrentRecommendation(
|
||||
recommendation_id="rec-build-subfamily-evidence",
|
||||
job_id=job_id,
|
||||
workflow_id="gene_family",
|
||||
transition_id="build_subfamily_evidence",
|
||||
from_state_id="family_identified",
|
||||
to_state_id="subfamily_evidence_ready",
|
||||
target_stage="combo",
|
||||
authorized_stages=("domain", "motif", "phylogeny", "structure", "combo"),
|
||||
evidence_digest="a" * 64,
|
||||
family="SPL",
|
||||
scope_id="SPL.target-Ft.target_only",
|
||||
)
|
||||
|
||||
|
||||
def test_identified_project_context_projects_one_typed_recommendation():
|
||||
recommendation = RouterRuntime._current_workflow_recommendation(
|
||||
"gene_family",
|
||||
"job-project-recommendation",
|
||||
{
|
||||
"family": "SPL",
|
||||
"scopes": [{"scope_id": "SPL.target-Ft.target_only"}],
|
||||
"manifests": [{"stage": "identify", "status": "success"}],
|
||||
"stage_status": {
|
||||
"identify": {"stage": "identify", "status": "success"}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert isinstance(recommendation, CurrentRecommendation)
|
||||
assert recommendation.transition_id == "build_subfamily_evidence"
|
||||
assert recommendation.target_stage == "combo"
|
||||
assert recommendation.authorized_stages == (
|
||||
"domain",
|
||||
"motif",
|
||||
"phylogeny",
|
||||
"structure",
|
||||
"combo",
|
||||
)
|
||||
|
||||
|
||||
def test_next_analysis_question_stays_read_only_and_returns_typed_recommendation():
|
||||
job_id = "job-next-analysis-question"
|
||||
context = {
|
||||
"job_id": job_id,
|
||||
"family": "SPL",
|
||||
"scopes": [{"scope_id": "SPL.target-Ft.target_only"}],
|
||||
"manifests": [{"stage": "identify", "status": "success"}],
|
||||
"stage_status": {
|
||||
"identify": {"stage": "identify", "status": "success"}
|
||||
},
|
||||
}
|
||||
registry = ConversationToolRegistry()
|
||||
registry.register(
|
||||
name="inspect_project_context",
|
||||
description="Inspect current project context.",
|
||||
args_model=_NoArgs,
|
||||
handler=lambda _args: context,
|
||||
permission="read_only",
|
||||
)
|
||||
llm = _SequenceLLM(
|
||||
[
|
||||
_response(
|
||||
'{"goal":"project_status","needs":["project_context"],'
|
||||
'"subject":"当前工作流的下一业务动作",'
|
||||
'"referent":null,"clarification_question":null,'
|
||||
'"interaction_kind":"status","workflow_id":"gene_family",'
|
||||
'"analysis_request_kind":null,'
|
||||
'"uses_current_recommendation":false,'
|
||||
'"confirms_pending_proposal":false}'
|
||||
),
|
||||
_response(
|
||||
tool_calls=[
|
||||
LLMToolCall(
|
||||
id="inspect-next-analysis-1",
|
||||
name="inspect_project_context",
|
||||
arguments="{}",
|
||||
)
|
||||
]
|
||||
),
|
||||
_response(
|
||||
'{"status":"ok","action":"show_result",'
|
||||
'"message":"下一步是构建亚家族证据。"}'
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
outcome = asyncio.run(
|
||||
RouterRuntime(llm, registry, max_iterations=5).run(
|
||||
query="接下来的分析有哪些?",
|
||||
history=[],
|
||||
session_id="session-next-analysis-question",
|
||||
job_id=job_id,
|
||||
goal_ledger=GoalLedger(
|
||||
conversation_id="conversation-next-analysis-question",
|
||||
revision=0,
|
||||
user_terms={"active_job_id": job_id, "gene_family": "SPL"},
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
assert outcome.action == "show_result"
|
||||
assert outcome.proposal is None
|
||||
assert outcome.recommendation is not None
|
||||
assert outcome.recommendation.transition_id == "build_subfamily_evidence"
|
||||
|
||||
|
||||
def test_show_result_persists_and_cancel_clears_current_recommendation(tmp_path):
|
||||
repository = SQLiteAgentStateRepository(tmp_path / "agent-state.sqlite3")
|
||||
repository.initialize()
|
||||
store = PersistentConversationStore(repository)
|
||||
turn = store.begin_turn(
|
||||
session_id="session-current-recommendation",
|
||||
job_id="job-current-recommendation",
|
||||
query="接下来的分析有哪些?",
|
||||
client_history=[],
|
||||
)
|
||||
recommendation = _recommendation().model_dump(mode="json")
|
||||
|
||||
ledger = store.finish_turn(
|
||||
turn,
|
||||
assistant_message="下一步是构建亚家族证据。",
|
||||
interaction_kind="status",
|
||||
action="show_result",
|
||||
recommendation=recommendation,
|
||||
)
|
||||
assert (
|
||||
ledger.model_dump(mode="json")["user_terms"]["current_recommendation"]
|
||||
== recommendation
|
||||
)
|
||||
|
||||
cancel_turn = store.begin_turn(
|
||||
session_id="session-current-recommendation",
|
||||
job_id="job-current-recommendation",
|
||||
query="取消",
|
||||
client_history=[],
|
||||
)
|
||||
ledger = store.finish_turn(
|
||||
cancel_turn,
|
||||
assistant_message="已取消。",
|
||||
interaction_kind="cancel",
|
||||
action="reply",
|
||||
)
|
||||
assert "current_recommendation" not in ledger.user_terms
|
||||
|
||||
|
||||
def test_pending_proposal_preserves_complete_workflow_action_binding(tmp_path):
|
||||
repository = SQLiteAgentStateRepository(tmp_path / "agent-state.sqlite3")
|
||||
repository.initialize()
|
||||
store = PersistentConversationStore(repository)
|
||||
turn = store.begin_turn(
|
||||
session_id="session-action-binding",
|
||||
job_id="job-action-binding",
|
||||
query="执行当前推荐",
|
||||
client_history=[],
|
||||
)
|
||||
proposal = {
|
||||
"proposal_id": "proposal-action-binding",
|
||||
"request": "执行 build_subfamily_evidence",
|
||||
"subject": "执行当前推荐",
|
||||
"workflow_id": "gene_family",
|
||||
"workflow_action_id": "build_subfamily_evidence",
|
||||
"authorized_stages": "domain,motif,phylogeny,structure,combo",
|
||||
"target_stage": "combo",
|
||||
}
|
||||
|
||||
ledger = store.finish_turn(
|
||||
turn,
|
||||
assistant_message="请确认。",
|
||||
interaction_kind="supplement",
|
||||
action="clarify",
|
||||
proposal=proposal,
|
||||
)
|
||||
|
||||
assert ledger.user_terms["pending_proposal"] == {
|
||||
**proposal,
|
||||
"job_id": "job-action-binding",
|
||||
}
|
||||
|
||||
|
||||
def test_execute_current_recommendation_revalidates_and_limits_intent():
|
||||
job_id = "job-execute-current-recommendation"
|
||||
context = {
|
||||
"job_id": job_id,
|
||||
"family": "SPL",
|
||||
"scopes": [{"scope_id": "SPL.target-Ft.target_only"}],
|
||||
"manifests": [{"stage": "identify", "status": "success"}],
|
||||
"stage_status": {
|
||||
"identify": {"stage": "identify", "status": "success"}
|
||||
},
|
||||
}
|
||||
recommendation = RouterRuntime._current_workflow_recommendation(
|
||||
"gene_family", job_id, context
|
||||
)
|
||||
registry = ConversationToolRegistry()
|
||||
registry.register(
|
||||
name="inspect_project_context",
|
||||
description="Inspect current project context.",
|
||||
args_model=_NoArgs,
|
||||
handler=lambda _args: context,
|
||||
permission="read_only",
|
||||
)
|
||||
registry.register(
|
||||
name="recognize_analysis_intent",
|
||||
description="Recognize an analysis intent.",
|
||||
args_model=_IntentArgs,
|
||||
handler=lambda _args: {
|
||||
"gene_family": "SPL",
|
||||
"stages": ["identify"],
|
||||
"primary_stage": "identify",
|
||||
"params": {"targetSpecies": ["Ft"], "referenceSpecies": ["At"]},
|
||||
},
|
||||
permission="pure",
|
||||
)
|
||||
ledger = GoalLedger(
|
||||
conversation_id="conversation-execute-current-recommendation",
|
||||
revision=0,
|
||||
user_terms={
|
||||
"active_job_id": job_id,
|
||||
"current_recommendation": recommendation.model_dump(mode="json"),
|
||||
},
|
||||
)
|
||||
llm = _SequenceLLM(
|
||||
[
|
||||
_response(
|
||||
'{"goal":"new_analysis","needs":["analysis_intent"],'
|
||||
'"subject":"执行当前推荐动作","referent":"current_recommendation",'
|
||||
'"clarification_question":null,"interaction_kind":"supplement",'
|
||||
'"analysis_request_kind":"execution",'
|
||||
'"uses_current_recommendation":true,'
|
||||
'"confirms_pending_proposal":false}'
|
||||
),
|
||||
_response(
|
||||
tool_calls=[
|
||||
LLMToolCall(
|
||||
id="recognize-current-recommendation-1",
|
||||
name="recognize_analysis_intent",
|
||||
arguments=json.dumps(
|
||||
{"query": "执行接下来的分析"}, ensure_ascii=False
|
||||
),
|
||||
)
|
||||
]
|
||||
),
|
||||
_response(
|
||||
'{"status":"ok","action":"propose_analysis",'
|
||||
'"message":"已交给现有分析系统。"}'
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
outcome = asyncio.run(
|
||||
RouterRuntime(llm, registry, max_iterations=5).run(
|
||||
query="执行接下来的分析",
|
||||
history=[],
|
||||
session_id="session-execute-current-recommendation",
|
||||
job_id=job_id,
|
||||
goal_ledger=ledger,
|
||||
)
|
||||
)
|
||||
|
||||
assert outcome.action == "propose_analysis"
|
||||
assert outcome.intent["stages"] == [
|
||||
"domain",
|
||||
"motif",
|
||||
"phylogeny",
|
||||
"structure",
|
||||
"combo",
|
||||
]
|
||||
assert outcome.intent["primary_stage"] == "combo"
|
||||
assert outcome.intent["workflow_action_id"] == "build_subfamily_evidence"
|
||||
|
||||
|
||||
def test_semantic_business_referent_hands_off_when_workflow_is_terminal():
|
||||
captured_queries = []
|
||||
registry = ConversationToolRegistry()
|
||||
registry.register(
|
||||
name="recognize_analysis_intent",
|
||||
description="Recognize an analysis intent.",
|
||||
args_model=_IntentArgs,
|
||||
handler=lambda args: captured_queries.append(args.query)
|
||||
or {
|
||||
"gene_family": "SPL",
|
||||
"stages": ["promoter", "synteny", "ppi"],
|
||||
"primary_stage": "promoter",
|
||||
},
|
||||
permission="pure",
|
||||
)
|
||||
llm = _SequenceLLM(
|
||||
[
|
||||
_response(
|
||||
'{"goal":"new_analysis","needs":["analysis_intent"],'
|
||||
'"subject":"执行上一轮说明的后续业务",'
|
||||
'"referent":"常规基因家族分析",'
|
||||
'"clarification_question":null,"interaction_kind":"supplement",'
|
||||
'"workflow_id":"gene_family","analysis_request_kind":"execution",'
|
||||
'"uses_current_recommendation":true,'
|
||||
'"confirms_pending_proposal":false}'
|
||||
),
|
||||
_response(
|
||||
tool_calls=[
|
||||
LLMToolCall(
|
||||
id="recognize-semantic-business-referent-1",
|
||||
name="recognize_analysis_intent",
|
||||
arguments=json.dumps(
|
||||
{"query": "执行常规基因家族分析"}, ensure_ascii=False
|
||||
),
|
||||
)
|
||||
]
|
||||
),
|
||||
_response(
|
||||
'{"status":"ok","action":"propose_analysis",'
|
||||
'"message":"分析请求已交给现有意图系统。"}'
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
outcome = asyncio.run(
|
||||
RouterRuntime(llm, registry, max_iterations=5).run(
|
||||
query="执行接下来的分析",
|
||||
history=[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": (
|
||||
"亚家族分类与确认已经完成,"
|
||||
"接下来进入常规基因家族分析。"
|
||||
),
|
||||
}
|
||||
],
|
||||
session_id="session-semantic-business-referent",
|
||||
job_id="job-confirmed-family",
|
||||
goal_ledger=GoalLedger(
|
||||
conversation_id="conversation-semantic-business-referent",
|
||||
revision=0,
|
||||
user_terms={"active_job_id": "job-confirmed-family"},
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
assert outcome.action == "propose_analysis"
|
||||
assert captured_queries == ["执行常规基因家族分析"]
|
||||
assert outcome.intent["stages"] == ["promoter", "synteny", "ppi"]
|
||||
|
||||
|
||||
def test_unresolved_current_recommendation_without_binding_still_clarifies():
|
||||
llm = _SequenceLLM(
|
||||
[
|
||||
_response(
|
||||
'{"goal":"new_analysis","needs":["analysis_intent"],'
|
||||
'"subject":"执行当前推荐动作",'
|
||||
'"referent":"current_recommendation",'
|
||||
'"clarification_question":null,"interaction_kind":"supplement",'
|
||||
'"workflow_id":"gene_family","analysis_request_kind":"execution",'
|
||||
'"uses_current_recommendation":true,'
|
||||
'"confirms_pending_proposal":false}'
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
outcome = asyncio.run(
|
||||
RouterRuntime(llm, ConversationToolRegistry(), max_iterations=5).run(
|
||||
query="执行接下来的分析",
|
||||
history=[],
|
||||
session_id="session-unresolved-recommendation",
|
||||
job_id="job-unresolved-recommendation",
|
||||
goal_ledger=GoalLedger(
|
||||
conversation_id="conversation-unresolved-recommendation",
|
||||
revision=0,
|
||||
user_terms={"active_job_id": "job-unresolved-recommendation"},
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
assert outcome.action == "clarify"
|
||||
assert "明确" in outcome.message
|
||||
|
||||
|
||||
def test_execute_current_recommendation_rejects_cross_job_binding():
|
||||
recommendation = _recommendation(job_id="job-original")
|
||||
ledger = GoalLedger(
|
||||
conversation_id="conversation-cross-job-recommendation",
|
||||
revision=0,
|
||||
user_terms={
|
||||
"current_recommendation": recommendation.model_dump(mode="json")
|
||||
},
|
||||
)
|
||||
llm = _SequenceLLM(
|
||||
[
|
||||
_response(
|
||||
'{"goal":"new_analysis","needs":["analysis_intent"],'
|
||||
'"subject":"执行当前推荐动作","referent":"current_recommendation",'
|
||||
'"clarification_question":null,"interaction_kind":"supplement",'
|
||||
'"analysis_request_kind":"execution",'
|
||||
'"uses_current_recommendation":true,'
|
||||
'"confirms_pending_proposal":false}'
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
outcome = asyncio.run(
|
||||
RouterRuntime(llm, ConversationToolRegistry(), max_iterations=5).run(
|
||||
query="执行接下来的分析",
|
||||
history=[],
|
||||
session_id="session-cross-job-recommendation",
|
||||
job_id="job-other",
|
||||
goal_ledger=ledger,
|
||||
)
|
||||
)
|
||||
|
||||
assert outcome.action == "clarify"
|
||||
assert "当前 job" in outcome.message
|
||||
@@ -0,0 +1,88 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
from gfa_core.mas.agents.conversation_tools import ConversationToolRegistry
|
||||
from gfa_core.mas.agents.router_runtime import RouterRuntime
|
||||
from gfa_core.mas.llm import LLMMessage, LLMResponse
|
||||
from gfa_core.mas.llm.providers.deepseek import DeepSeekProvider
|
||||
|
||||
|
||||
def test_deepseek_provider_forwards_requested_json_mode():
|
||||
captured = {}
|
||||
|
||||
class _Completions:
|
||||
def create(self, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
message=SimpleNamespace(content='{"ok":true}', tool_calls=[]),
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
usage=SimpleNamespace(
|
||||
prompt_tokens=1,
|
||||
completion_tokens=1,
|
||||
total_tokens=2,
|
||||
),
|
||||
)
|
||||
|
||||
provider = DeepSeekProvider(api_key="test-key")
|
||||
provider.client = SimpleNamespace(
|
||||
chat=SimpleNamespace(completions=_Completions())
|
||||
)
|
||||
|
||||
provider.call(
|
||||
[LLMMessage(role="user", content="Return JSON")],
|
||||
response_format={"type": "json_object"},
|
||||
)
|
||||
|
||||
assert captured["response_format"] == {"type": "json_object"}
|
||||
|
||||
|
||||
def test_router_route_but_not_freeform_reply_requests_native_json_mode():
|
||||
class _RecordingLLM:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.responses = [
|
||||
LLMResponse(
|
||||
content=(
|
||||
'{"goal":"general","needs":[],"subject":"编写脚本",'
|
||||
'"referent":null,"clarification_question":null,'
|
||||
'"interaction_kind":"supplement"}'
|
||||
),
|
||||
model="test-model",
|
||||
provider="test-provider",
|
||||
usage={},
|
||||
),
|
||||
LLMResponse(
|
||||
content=(
|
||||
'{"status":"ok","action":"reply",'
|
||||
'"message":"已生成脚本。"}'
|
||||
),
|
||||
model="test-model",
|
||||
provider="test-provider",
|
||||
usage={},
|
||||
),
|
||||
]
|
||||
|
||||
async def acall(self, _messages, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
return self.responses.pop(0)
|
||||
|
||||
llm = _RecordingLLM()
|
||||
outcome = asyncio.run(
|
||||
RouterRuntime(llm, ConversationToolRegistry(), max_iterations=5).run(
|
||||
query="帮我写一个 FASTA 脚本",
|
||||
history=[],
|
||||
session_id="deepseek-json-protocol",
|
||||
job_id="",
|
||||
goal_ledger=None,
|
||||
)
|
||||
)
|
||||
|
||||
assert outcome.action == "reply"
|
||||
assert [call.get("response_format") for call in llm.calls] == [
|
||||
{"type": "json_object"},
|
||||
None,
|
||||
]
|
||||
@@ -0,0 +1,844 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
import gfa_core.gene_family_report as report_module
|
||||
from gfa_core.gene_family_report import (
|
||||
NoSuccessfulGeneFamilyEvidence,
|
||||
generate_gene_family_report,
|
||||
)
|
||||
|
||||
|
||||
def _write_manifest(
|
||||
workspace: Path,
|
||||
*,
|
||||
prefix: str,
|
||||
stage: str,
|
||||
status: str = "success",
|
||||
scope_id: str = "scope-a",
|
||||
analysis_route: str = "post_subfamily_confirm",
|
||||
member_set_id: str = "members-a",
|
||||
metric_value: int = 1,
|
||||
) -> Path:
|
||||
final_dir = workspace / "final"
|
||||
final_dir.mkdir(parents=True, exist_ok=True)
|
||||
token = f"{scope_id}.{analysis_route}.{member_set_id}"
|
||||
table_path = final_dir / f"{prefix}.{token}.{stage}.tsv"
|
||||
table_path.write_text(f"gene\tvalue\n{scope_id}_gene\t{metric_value}\n", encoding="utf-8")
|
||||
manifest_path = final_dir / f"{prefix}.{token}.{stage}.manifest.json"
|
||||
manifest = {
|
||||
"manifest_version": "1.0",
|
||||
"stage": stage,
|
||||
"status": status,
|
||||
"prefix": prefix,
|
||||
"scope_id": scope_id,
|
||||
"analysis_route": analysis_route,
|
||||
"member_set_id": member_set_id,
|
||||
"parameters": {
|
||||
"scope_id": scope_id,
|
||||
"analysis_route": analysis_route,
|
||||
"member_set_id": member_set_id,
|
||||
},
|
||||
"metrics": {"member_count": metric_value},
|
||||
"artifacts": {
|
||||
"result_table": {
|
||||
"path": f"final/{table_path.name}",
|
||||
"full_path": str(table_path),
|
||||
"type": "table",
|
||||
}
|
||||
},
|
||||
}
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
|
||||
return manifest_path
|
||||
|
||||
|
||||
def _generate(
|
||||
workspace: Path,
|
||||
*,
|
||||
expected_stages: list[str],
|
||||
stage_statuses: dict[str, str],
|
||||
use_model: bool = False,
|
||||
action: str = "generate",
|
||||
):
|
||||
return generate_gene_family_report(
|
||||
workspace,
|
||||
"Ft.SPL",
|
||||
expected_stages=expected_stages,
|
||||
stage_statuses=stage_statuses,
|
||||
scope_id="scope-a",
|
||||
analysis_route="post_subfamily_confirm",
|
||||
member_set_id="members-a",
|
||||
use_model=use_model,
|
||||
action=action,
|
||||
job_id="job-report",
|
||||
)
|
||||
|
||||
|
||||
def _stage_section(markdown: str, stage: str) -> str:
|
||||
heading = re.search(rf"(?m)^## (?!#).*?\(`{re.escape(stage)}`\)", markdown)
|
||||
if heading is None:
|
||||
raise AssertionError(f"missing report chapter for {stage}")
|
||||
next_heading = re.search(r"(?m)^## (?!#)", markdown[heading.end():])
|
||||
end = heading.end() + next_heading.start() if next_heading else len(markdown)
|
||||
return markdown[heading.start():end]
|
||||
|
||||
|
||||
def _subsection(markdown: str, number: str) -> str:
|
||||
heading = re.search(rf"(?m)^### {re.escape(number)}(?:\s|$)", markdown)
|
||||
if heading is None:
|
||||
raise AssertionError(f"missing report subsection {number}")
|
||||
next_heading = re.search(r"(?m)^#{2,3} ", markdown[heading.end():])
|
||||
end = heading.end() + next_heading.start() if next_heading else len(markdown)
|
||||
return markdown[heading.start():end]
|
||||
|
||||
|
||||
def _valid_api_arrangement(payload: dict, *, reverse: bool = False) -> dict:
|
||||
sections = []
|
||||
for section in payload["sections"]:
|
||||
sentence_ids = [sentence["sentence_id"] for sentence in section["sentences"]]
|
||||
if reverse:
|
||||
sentence_ids.reverse()
|
||||
sections.append(
|
||||
{
|
||||
"section_id": section["section_id"],
|
||||
"paragraphs": [
|
||||
{
|
||||
"sentence_ids": sentence_ids,
|
||||
"connector_ids": ["also"] * max(0, len(sentence_ids) - 1),
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
return {"sections": sections}
|
||||
|
||||
|
||||
def test_report_uses_only_planned_stages_and_current_identity(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "job"
|
||||
_write_manifest(workspace, prefix="Ft.SPL", stage="identify", metric_value=3)
|
||||
other = _write_manifest(
|
||||
workspace,
|
||||
prefix="Ft.SPL",
|
||||
stage="identify",
|
||||
scope_id="scope-b",
|
||||
analysis_route="target_only",
|
||||
member_set_id="members-b",
|
||||
metric_value=99,
|
||||
)
|
||||
|
||||
result = _generate(
|
||||
workspace,
|
||||
expected_stages=["identify"],
|
||||
stage_statuses={"identify": "success"},
|
||||
)
|
||||
|
||||
report_text = result.report_path.read_text(encoding="utf-8")
|
||||
assert "最终成员数:3" in report_text
|
||||
assert other.name not in report_text
|
||||
generated = {path.name for path in (workspace / "final").glob("Ft.SPL.gene_family_report.*")}
|
||||
assert generated == {
|
||||
"Ft.SPL.gene_family_report.md",
|
||||
"Ft.SPL.gene_family_report.manifest.json",
|
||||
}
|
||||
|
||||
|
||||
def test_report_renders_template_subsections_for_only_planned_stages(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "job"
|
||||
for stage in ("identify", "motif", "pep"):
|
||||
_write_manifest(workspace, prefix="Ft.SPL", stage=stage)
|
||||
|
||||
result = _generate(
|
||||
workspace,
|
||||
expected_stages=["identify", "motif", "pep"],
|
||||
stage_statuses={"identify": "success", "motif": "success", "pep": "success"},
|
||||
)
|
||||
|
||||
markdown = result.report_path.read_text(encoding="utf-8")
|
||||
expected_subsections = {
|
||||
"identify": "### 4.3 最终成员数量与命名",
|
||||
"motif": "### 7.2 Motif 数量与出现频率",
|
||||
"pep": "### 16.1 蛋白理化性质分布图",
|
||||
}
|
||||
for stage, subsection in expected_subsections.items():
|
||||
assert subsection in _stage_section(markdown, stage)
|
||||
assert "(`synteny`)" not in markdown
|
||||
assert "【填写】" not in markdown
|
||||
|
||||
|
||||
def test_report_renders_nested_motif_metrics_without_placeholder_values(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "job"
|
||||
manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="motif")
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
manifest["metrics"] = {
|
||||
"motif_count": 10,
|
||||
"total_sites": 105,
|
||||
"motifs": [
|
||||
{"width": width}
|
||||
for width in (78, 76, 80, 113, 57, 29, 52, 138, 27, 29)
|
||||
],
|
||||
}
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
result = _generate(
|
||||
workspace,
|
||||
expected_stages=["motif"],
|
||||
stage_statuses={"motif": "success"},
|
||||
)
|
||||
|
||||
motif_section = _stage_section(result.report_path.read_text(encoding="utf-8"), "motif")
|
||||
assert re.search(r"识别\s*10\s*个\s*motif", motif_section)
|
||||
assert re.search(r"总位点数为\s*105", motif_section)
|
||||
assert re.search(r"宽度范围为\s*27–138", motif_section)
|
||||
assert "未解析" not in motif_section
|
||||
assert "N/A" not in motif_section
|
||||
assert "None" not in motif_section
|
||||
|
||||
|
||||
def test_identify_uses_real_manifest_member_count_aliases(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "job"
|
||||
manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="identify")
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
manifest["metrics"] = {"identified_members_count": 24, "final_members": 24}
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
result = _generate(
|
||||
workspace,
|
||||
expected_stages=["identify"],
|
||||
stage_statuses={"identify": "success"},
|
||||
)
|
||||
|
||||
section = _stage_section(result.report_path.read_text(encoding="utf-8"), "identify")
|
||||
assert "最终成员数:24" in section
|
||||
assert re.search(r"形成\s*24\s*个\s*SPL家族", section)
|
||||
assert "源结果未提供此指标" not in section
|
||||
|
||||
|
||||
def test_phylogeny_renders_real_manifest_schema_with_explicit_labels(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "job"
|
||||
manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="phylogeny")
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
manifest["metrics"] = {"sequences_count": 40, "msa_length": 1449}
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
result = _generate(
|
||||
workspace,
|
||||
expected_stages=["phylogeny"],
|
||||
stage_statuses={"phylogeny": "success"},
|
||||
)
|
||||
|
||||
section = _stage_section(result.report_path.read_text(encoding="utf-8"), "phylogeny")
|
||||
assert "序列数:40" in section
|
||||
assert "多序列比对长度:1449" in section
|
||||
assert re.search(r"使用\s*40\s*条序列.*比对长度为\s*1449", section)
|
||||
|
||||
|
||||
def test_interspecies_synteny_reads_only_named_real_schema_artifacts(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "job"
|
||||
manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="interspecies_synteny")
|
||||
final_dir = workspace / "final"
|
||||
gene_to_species = final_dir / "Ft.SPL.gene_to_species.tsv"
|
||||
gene_to_species.write_text(
|
||||
"gene_id\tspecies\tgroup\n"
|
||||
"FtGene1.01.T01\tFt\ttarget\n"
|
||||
"FtGene2.01.T01\tFt\ttarget\n"
|
||||
"AT1G00010\tAT\tref\n"
|
||||
"AT1G00020\tAT\tref\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
collinearity = final_dir / "Ft.At.collinearity"
|
||||
lines = []
|
||||
for index in range(521):
|
||||
lines.append(f"## Alignment {index}: score=1 e_value=1e-10 N=1 Ft&At plus")
|
||||
if index == 0:
|
||||
lines.append(" 0- 0:\tAT1G00010.1\tFtGene1.01.T01\t1e-20")
|
||||
elif index == 1:
|
||||
lines.append(" 1- 0:\tAT1G00020.1\tFtGene2.01.T01\t1e-20")
|
||||
collinearity.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
manifest["metrics"] = {"pair_success_count": 1, "pair_total_count": 1}
|
||||
manifest["artifacts"] = {
|
||||
"gene_to_species": {"path": f"final/{gene_to_species.name}", "type": "tsv"},
|
||||
"Ft.At_collinearity": {"path": f"final/{collinearity.name}", "type": "data"},
|
||||
}
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
result = _generate(
|
||||
workspace,
|
||||
expected_stages=["interspecies_synteny"],
|
||||
stage_statuses={"interspecies_synteny": "success"},
|
||||
)
|
||||
|
||||
section = _stage_section(result.report_path.read_text(encoding="utf-8"), "interspecies_synteny")
|
||||
assert "共线性区块数:521" in section
|
||||
assert "家族成员目标—参考配对数:2" in section
|
||||
assert re.search(r"全基因组\s*block\s*521\s*个.*目标-参考家族.*?2\s*对", section)
|
||||
|
||||
|
||||
def test_pep_renders_real_manifest_task_summary_with_explicit_labels(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "job"
|
||||
manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="pep")
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
manifest["metrics"] = {"protein_count": 40, "tasks_success": 3, "tasks_total": 3}
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
result = _generate(
|
||||
workspace,
|
||||
expected_stages=["pep"],
|
||||
stage_statuses={"pep": "success"},
|
||||
)
|
||||
|
||||
section = _stage_section(result.report_path.read_text(encoding="utf-8"), "pep")
|
||||
assert "蛋白数:40" in section
|
||||
assert "子任务成功数:3/3" in section
|
||||
assert re.search(r"覆盖\s*40\s*个蛋白,子任务成功数为\s*3/3", section)
|
||||
|
||||
|
||||
def test_report_has_no_empty_third_level_subsections_or_repeated_generic_interpretation(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
workspace = tmp_path / "job"
|
||||
for stage in ("identify", "phylogeny", "motif"):
|
||||
manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage=stage)
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
if stage == "phylogeny":
|
||||
manifest["metrics"] = {"sequences_count": 40, "msa_length": 1449}
|
||||
elif stage == "motif":
|
||||
manifest["metrics"] = {
|
||||
"sequence_count": 40,
|
||||
"motif_count": 10,
|
||||
"total_sites": 105,
|
||||
"motifs": [{"width": 27}, {"width": 138}],
|
||||
}
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
result = _generate(
|
||||
workspace,
|
||||
expected_stages=["identify", "phylogeny", "motif"],
|
||||
stage_statuses={stage: "success" for stage in ("identify", "phylogeny", "motif")},
|
||||
)
|
||||
|
||||
markdown = result.report_path.read_text(encoding="utf-8")
|
||||
for stage in ("identify", "phylogeny", "motif"):
|
||||
section = _stage_section(markdown, stage)
|
||||
chunks = re.split(r"(?m)(?=^### )", section)[1:]
|
||||
assert chunks
|
||||
for chunk in chunks:
|
||||
body = "\n".join(chunk.splitlines()[1:]).strip()
|
||||
assert body, f"empty subsection: {chunk.splitlines()[0]}"
|
||||
assert markdown.count("本节解释仅限上述可追溯结果,不据此断言真实功能或因果关系。") <= 1
|
||||
assert markdown.count("结果范围受当前输入、参数和源文件完整性限制。") <= 1
|
||||
|
||||
|
||||
def test_front_summary_contains_project_numbers_in_six_to_ten_sentences(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "job"
|
||||
identify_path = _write_manifest(workspace, prefix="Ft.SPL", stage="identify")
|
||||
identify = json.loads(identify_path.read_text(encoding="utf-8"))
|
||||
identify["metrics"] = {"identified_members_count": 24, "final_members": 24}
|
||||
identify_path.write_text(json.dumps(identify, ensure_ascii=False), encoding="utf-8")
|
||||
phylogeny_path = _write_manifest(workspace, prefix="Ft.SPL", stage="phylogeny")
|
||||
phylogeny = json.loads(phylogeny_path.read_text(encoding="utf-8"))
|
||||
phylogeny["metrics"] = {"sequences_count": 40, "msa_length": 1449}
|
||||
phylogeny_path.write_text(json.dumps(phylogeny, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
result = _generate(
|
||||
workspace,
|
||||
expected_stages=["identify", "phylogeny"],
|
||||
stage_statuses={"identify": "success", "phylogeny": "success"},
|
||||
)
|
||||
|
||||
summary = _subsection(result.report_path.read_text(encoding="utf-8"), "1.3")
|
||||
assert re.search(r"目标物种.*24.*家族成员", summary)
|
||||
assert re.search(r"目标.*参考.*40.*1449", summary)
|
||||
assert 6 <= summary.count("。") <= 10
|
||||
|
||||
|
||||
def test_stage_specific_key_facts_land_in_their_semantic_subsections(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "job"
|
||||
final_dir = workspace / "final"
|
||||
stages = [
|
||||
"reference_classification", "domain", "structure", "combo",
|
||||
"promoter", "tfbs", "synteny",
|
||||
]
|
||||
manifests = {stage: _write_manifest(workspace, prefix="Ft.SPL", stage=stage) for stage in stages}
|
||||
|
||||
assignment = final_dir / "Ft.SPL.reference_classification.assignment.tsv"
|
||||
assignment.write_text(
|
||||
"target_gene\tassigned_subfamily\tconfidence\n"
|
||||
+ "\n".join(
|
||||
f"FtGene{i}\t{'I' if i < 2 else 'II'}\t{confidence}"
|
||||
for i, confidence in enumerate(("high", "high", "medium", "low"), start=1)
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
reference = json.loads(manifests["reference_classification"].read_text(encoding="utf-8"))
|
||||
reference["metrics"] = {"target_gene_count": 4, "subfamily_rows": 6}
|
||||
reference["artifacts"] = {"assignment_table": {"path": f"final/{assignment.name}", "type": "tsv"}}
|
||||
manifests["reference_classification"].write_text(json.dumps(reference), encoding="utf-8")
|
||||
|
||||
domain = json.loads(manifests["domain"].read_text(encoding="utf-8"))
|
||||
domain["metrics"] = {
|
||||
"proteins_total": 40, "proteins_with_domains": 40, "domain_coverage_rate": 1.0,
|
||||
"top_domains": [["SBP", 40], ["Ig_SPL7", 12]],
|
||||
}
|
||||
manifests["domain"].write_text(json.dumps(domain), encoding="utf-8")
|
||||
|
||||
structure = json.loads(manifests["structure"].read_text(encoding="utf-8"))
|
||||
structure["metrics"] = {
|
||||
"genes_total": 40, "genes_with_structure": 24, "structure_coverage_rate": 0.6,
|
||||
"long_gene_outlier_count": 1,
|
||||
"long_gene_outliers": [{"gene_id": "FtLong1", "length_bp": 12454, "cds_count": 10}],
|
||||
}
|
||||
manifests["structure"].write_text(json.dumps(structure), encoding="utf-8")
|
||||
|
||||
combo = json.loads(manifests["combo"].read_text(encoding="utf-8"))
|
||||
combo["metrics"] = None
|
||||
combo["parameters"] = {"panels": ["motif", "domain", "structure"]}
|
||||
combo["artifacts"] = {
|
||||
"combo_plot_png": {"path": "final/Ft.SPL.combo.png", "type": "image"},
|
||||
"combo_plot_pdf": {"path": "final/Ft.SPL.combo.pdf", "type": "image"},
|
||||
}
|
||||
manifests["combo"].write_text(json.dumps(combo), encoding="utf-8")
|
||||
|
||||
element_counts = final_dir / "Ft.SPL.element_counts.txt"
|
||||
element_counts.write_text("1460\tTATA-box\n852\tCAAT-box\n72\tMYB\n", encoding="utf-8")
|
||||
category_summary = final_dir / "Ft.SPL.promoter_category_summary.tsv"
|
||||
category_summary.write_text("big7\telement_instances\nCore promoter\t2600\nLight response\t188\n", encoding="utf-8")
|
||||
enrichment = final_dir / "Ft.SPL.enrichment_analysis.txt"
|
||||
enrichment.write_text(
|
||||
"Subfamily\tElement\tCount\tSubfamilyTotal\tGlobalCount\tEnrichmentRatio\nI\tAACA_motif\t1\t222\t1\t16.66\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
cooccurrence = final_dir / "Ft.SPL.cooccurrence_analysis.txt"
|
||||
cooccurrence.write_text(
|
||||
"Element1\tElement2\tCo-occurrenceCount\tGenes\nCAAT-box\tMYB\t23\tFtGene1,FtGene2\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
complexity = final_dir / "Ft.SPL.complexity_analysis.txt"
|
||||
complexity.write_text(
|
||||
"Gene\tTotalElements\tUniqueElements\tComplexityScore\tComplexityClass\nFtComplex1\t278\t33\t361.44\tVery High\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
promoter = json.loads(manifests["promoter"].read_text(encoding="utf-8"))
|
||||
promoter["metrics"] = {"total_elements": 3699, "unique_elements": 87, "genes_with_elements": 23}
|
||||
promoter["artifacts"] = {
|
||||
"element_counts": {"path": f"final/{element_counts.name}"},
|
||||
"viz_promoter_position_distribution_summary": {"path": f"final/{category_summary.name}"},
|
||||
"enrichment_analysis": {"path": f"final/{enrichment.name}"},
|
||||
"cooccurrence_analysis": {"path": f"final/{cooccurrence.name}"},
|
||||
"complexity_analysis": {"path": f"final/{complexity.name}"},
|
||||
}
|
||||
manifests["promoter"].write_text(json.dumps(promoter), encoding="utf-8")
|
||||
|
||||
motif_summary = final_dir / "Ft.SPL.tfbs.motif_summary.tsv"
|
||||
motif_summary.write_text(
|
||||
"motif_id\tmotif_alt_id\tfamily_sites\tfamily_genes_with_site\tbackground_sites\tbackground_genes_with_site\n"
|
||||
"MOTIF_A\t\t153\t18\t0\t0\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
tfbs = json.loads(manifests["tfbs"].read_text(encoding="utf-8"))
|
||||
tfbs["metrics"] = {
|
||||
"family_promoter_count": 24, "background_promoter_count": 0,
|
||||
"family_site_count": 3734, "family_motif_count": 230, "motif_database": "PlantTFDB",
|
||||
}
|
||||
tfbs["artifacts"] = {"motif_summary": {"path": f"final/{motif_summary.name}"}}
|
||||
manifests["tfbs"].write_text(json.dumps(tfbs), encoding="utf-8")
|
||||
|
||||
synteny = json.loads(manifests["synteny"].read_text(encoding="utf-8"))
|
||||
synteny["metrics"] = {"gene_count": 31679, "synteny_blocks": 316, "gene_pairs": 3267, "circos_chromosomes_plotted": 8}
|
||||
manifests["synteny"].write_text(json.dumps(synteny), encoding="utf-8")
|
||||
|
||||
result = _generate(
|
||||
workspace,
|
||||
expected_stages=stages,
|
||||
stage_statuses={stage: "success" for stage in stages},
|
||||
)
|
||||
markdown = result.report_path.read_text(encoding="utf-8")
|
||||
assert re.search(r"4\s*个目标成员.*2\s*个小组.*高信心\s*2", _subsection(markdown, "6.2"))
|
||||
assert re.search(r"40/40.*SBP.*40.*Ig_SPL7.*12", _subsection(markdown, "8.1"))
|
||||
assert re.search(r"24/40.*FtLong1.*12454", _subsection(markdown, "9.1"))
|
||||
assert "Motif、Domain、Structure" in _subsection(markdown, "10.1")
|
||||
promoter_text = _subsection(markdown, "11.1")
|
||||
assert re.search(r"23\s*个目标基因.*3699.*TATA-box.*1460", promoter_text)
|
||||
assert "FtComplex1" in promoter_text and "CAAT-box—MYB" in promoter_text
|
||||
assert re.search(r"24\s*条目标启动子.*3734.*MOTIF_A.*153", _subsection(markdown, "12.1"))
|
||||
assert re.search(r"背景启动子为\s*0.*不能.*富集", _subsection(markdown, "12.1"))
|
||||
assert re.search(r"全基因组.*316.*3267.*8\s*条染色体", _subsection(markdown, "13.1"))
|
||||
assert "源结果未提供家族专属复制基因对" in _subsection(markdown, "13.1")
|
||||
|
||||
|
||||
def test_all_planned_success_is_final(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "job"
|
||||
_write_manifest(workspace, prefix="Ft.SPL", stage="identify")
|
||||
_write_manifest(workspace, prefix="Ft.SPL", stage="motif")
|
||||
|
||||
result = _generate(
|
||||
workspace,
|
||||
expected_stages=["identify", "motif"],
|
||||
stage_statuses={"identify": "success", "motif": "success"},
|
||||
)
|
||||
|
||||
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||
assert result.report_status == "final"
|
||||
assert manifest["metrics"]["report_status"] == "final"
|
||||
assert manifest["metrics"]["included_stages"] == ["identify", "motif"]
|
||||
|
||||
|
||||
def test_unsupported_successful_stage_is_visible_but_not_included_or_final(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "job"
|
||||
_write_manifest(workspace, prefix="Ft.SPL", stage="identify")
|
||||
_write_manifest(workspace, prefix="Ft.SPL", stage="ppi")
|
||||
|
||||
result = _generate(
|
||||
workspace,
|
||||
expected_stages=["identify", "ppi"],
|
||||
stage_statuses={"identify": "success", "ppi": "success"},
|
||||
)
|
||||
|
||||
markdown = result.report_path.read_text(encoding="utf-8")
|
||||
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||
assert result.report_status == "partial"
|
||||
assert result.included_stages == ("identify",)
|
||||
assert "ppi" in markdown
|
||||
assert "不支持" in markdown
|
||||
assert "(`ppi`)" not in markdown
|
||||
assert manifest["metrics"]["included_stages"] == ["identify"]
|
||||
assert manifest["metrics"]["unsupported_stages"] == ["ppi"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("expected_stages", [None, []])
|
||||
def test_generate_rejects_missing_plan_instead_of_inferring_historical_manifests(
|
||||
tmp_path: Path, expected_stages: list[str] | None
|
||||
) -> None:
|
||||
workspace = tmp_path / "job"
|
||||
_write_manifest(workspace, prefix="Ft.SPL", stage="identify")
|
||||
|
||||
with pytest.raises(ValueError, match="expected_stages"):
|
||||
generate_gene_family_report(
|
||||
workspace,
|
||||
"Ft.SPL",
|
||||
expected_stages=expected_stages,
|
||||
stage_statuses={"identify": "success"},
|
||||
scope_id="scope-a",
|
||||
analysis_route="post_subfamily_confirm",
|
||||
member_set_id="members-a",
|
||||
use_model=False,
|
||||
action="generate",
|
||||
)
|
||||
|
||||
assert not list((workspace / "final").glob("*.gene_family_report.*"))
|
||||
|
||||
|
||||
def test_report_selects_key_facts_without_dumping_manifest_or_stale_paths(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "job"
|
||||
manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="identify", metric_value=3)
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
manifest["metrics"].update({"total_proteins": 31679, "blast_enabled": True})
|
||||
manifest["artifacts"]["result_table"]["path"] = "/home/xzq/old/job/final/Ft.SPL.identify.tsv"
|
||||
manifest["artifacts"]["result_table"]["full_path"] = "/home/xzq/old/job/final/Ft.SPL.identify.tsv"
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
result = _generate(
|
||||
workspace,
|
||||
expected_stages=["identify"],
|
||||
stage_statuses={"identify": "success"},
|
||||
)
|
||||
|
||||
markdown = result.report_path.read_text(encoding="utf-8")
|
||||
assert "最终成员数:3" in _subsection(markdown, "4.3")
|
||||
assert not re.search(r"(?m)^- `(?:member_count|total_proteins|blast_enabled)\s*`:", markdown)
|
||||
assert "/home/xzq/" not in markdown
|
||||
|
||||
|
||||
def test_fallback_uses_nested_karyotype_metrics(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "job"
|
||||
manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="karyotype")
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
manifest["metrics"] = {
|
||||
"species": {
|
||||
"Ft": {
|
||||
"gene_count": 24,
|
||||
"chromosome_count": 8,
|
||||
"chromosomes": [f"Ft{i}" for i in range(1, 9)],
|
||||
"tandem_count": 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
result = _generate(
|
||||
workspace,
|
||||
expected_stages=["karyotype"],
|
||||
stage_statuses={"karyotype": "success"},
|
||||
)
|
||||
|
||||
markdown = result.report_path.read_text(encoding="utf-8")
|
||||
assert "24个家族成员定位到8条染色体" in markdown
|
||||
assert "核型分析将N/A" not in markdown
|
||||
|
||||
|
||||
def test_artifact_path_remaps_remote_job_to_current_mount(tmp_path: Path) -> None:
|
||||
from gfa_core.mas.agents.interpretation_evidence import _artifact_path
|
||||
|
||||
final_dir = tmp_path / "job" / "final"
|
||||
final_dir.mkdir(parents=True)
|
||||
manifest_path = final_dir / "Ft.SPL.stage.manifest.json"
|
||||
local_artifact = final_dir / "Ft.SPL.result.tsv"
|
||||
local_artifact.write_text("gene\tvalue\nFtSPL1\t1\n", encoding="utf-8")
|
||||
manifest = {
|
||||
"_manifest_path": str(manifest_path),
|
||||
"artifacts": {
|
||||
"result": {
|
||||
"full_path": "/home/xzq/old/PlantAI-dev/workspace/job-old/final/Ft.SPL.result.tsv",
|
||||
"path": "/home/xzq/old/PlantAI-dev/workspace/job-old/final/Ft.SPL.result.tsv",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
assert _artifact_path(manifest, "result") == local_artifact
|
||||
|
||||
|
||||
def test_failed_or_missing_planned_stage_is_partial(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "job"
|
||||
_write_manifest(workspace, prefix="Ft.SPL", stage="identify")
|
||||
_write_manifest(workspace, prefix="Ft.SPL", stage="motif", status="failed")
|
||||
|
||||
result = _generate(
|
||||
workspace,
|
||||
expected_stages=["identify", "motif"],
|
||||
stage_statuses={"identify": "success", "motif": "failed"},
|
||||
)
|
||||
|
||||
markdown = result.report_path.read_text(encoding="utf-8")
|
||||
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||
assert result.report_status == "partial"
|
||||
assert "motif" in markdown.lower()
|
||||
assert "failed" in markdown.lower()
|
||||
assert manifest["metrics"]["failed_or_missing_stages"] == ["motif"]
|
||||
|
||||
|
||||
def test_model_failure_creates_labeled_fallback_report(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
workspace = tmp_path / "job"
|
||||
_write_manifest(workspace, prefix="Ft.SPL", stage="identify")
|
||||
|
||||
def fail_model(_evidence: dict, _stages: list[str]):
|
||||
raise TimeoutError("provider timeout")
|
||||
|
||||
monkeypatch.setattr(report_module, "_generate_api_narrative", fail_model)
|
||||
result = _generate(
|
||||
workspace,
|
||||
expected_stages=["identify"],
|
||||
stage_statuses={"identify": "success"},
|
||||
use_model=True,
|
||||
)
|
||||
|
||||
markdown = result.report_path.read_text(encoding="utf-8")
|
||||
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||
assert result.generation_mode == "fallback_script"
|
||||
assert "生成模式:候补脚本" in markdown
|
||||
assert manifest["metrics"]["generation_mode"] == "fallback_script"
|
||||
assert manifest["model"]["reason"] == "api_unavailable_or_boundary_violation"
|
||||
assert "{'action':" not in markdown
|
||||
|
||||
|
||||
def test_valid_model_sections_create_api_report(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
workspace = tmp_path / "job"
|
||||
_write_manifest(workspace, prefix="Ft.SPL", stage="identify")
|
||||
|
||||
seen_payload: dict = {}
|
||||
|
||||
def valid_model(payload: dict, _stages: list[str]):
|
||||
seen_payload.update(payload)
|
||||
return (
|
||||
_valid_api_arrangement(payload, reverse=True),
|
||||
{"status": "generated", "provider": "test", "model": "test-model"},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(report_module, "_generate_api_narrative", valid_model)
|
||||
result = _generate(
|
||||
workspace,
|
||||
expected_stages=["identify"],
|
||||
stage_statuses={"identify": "success"},
|
||||
use_model=True,
|
||||
)
|
||||
|
||||
markdown = result.report_path.read_text(encoding="utf-8")
|
||||
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||
assert result.generation_mode == "api"
|
||||
assert set(seen_payload) == {"sections", "allowed_connectors"}
|
||||
assert all(set(section) == {"section_id", "sentences"} for section in seen_payload["sections"])
|
||||
assert all(
|
||||
set(sentence) == {"sentence_id", "text"}
|
||||
for section in seen_payload["sections"]
|
||||
for sentence in section["sentences"]
|
||||
)
|
||||
assert "最终成员数:1" in markdown
|
||||
assert manifest["metrics"]["generation_mode"] == "api"
|
||||
assert "【填写】" not in markdown
|
||||
|
||||
|
||||
def test_unknown_model_sentence_id_falls_back_for_the_whole_report(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
workspace = tmp_path / "job"
|
||||
_write_manifest(workspace, prefix="Ft.SPL", stage="identify", metric_value=3)
|
||||
|
||||
def invalid_model(payload: dict, _stages: list[str]):
|
||||
response = _valid_api_arrangement(payload)
|
||||
response["sections"][0]["paragraphs"][0]["sentence_ids"][0] = "unknown.sentence"
|
||||
return response, {"status": "generated"}
|
||||
|
||||
monkeypatch.setattr(report_module, "_generate_api_narrative", invalid_model)
|
||||
result = _generate(
|
||||
workspace,
|
||||
expected_stages=["identify"],
|
||||
stage_statuses={"identify": "success"},
|
||||
use_model=True,
|
||||
)
|
||||
|
||||
markdown = result.report_path.read_text(encoding="utf-8")
|
||||
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||
assert result.generation_mode == "fallback_script"
|
||||
assert "最终成员数:3" in markdown
|
||||
assert manifest["model"]["reason"] == "api_unavailable_or_boundary_violation"
|
||||
|
||||
|
||||
def test_missing_and_duplicate_model_sentence_ids_fall_back_for_the_whole_report(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
workspace = tmp_path / "job"
|
||||
_write_manifest(workspace, prefix="Ft.SPL", stage="identify", metric_value=3)
|
||||
|
||||
def invalid_model(payload: dict, _stages: list[str]):
|
||||
response = _valid_api_arrangement(payload)
|
||||
paragraph = next(
|
||||
item["paragraphs"][0]
|
||||
for item in response["sections"]
|
||||
if len(item["paragraphs"][0]["sentence_ids"]) >= 2
|
||||
)
|
||||
paragraph["sentence_ids"][-1] = paragraph["sentence_ids"][0]
|
||||
return response, {"status": "generated"}
|
||||
|
||||
monkeypatch.setattr(report_module, "_generate_api_narrative", invalid_model)
|
||||
result = _generate(
|
||||
workspace,
|
||||
expected_stages=["identify"],
|
||||
stage_statuses={"identify": "success"},
|
||||
use_model=True,
|
||||
)
|
||||
|
||||
markdown = result.report_path.read_text(encoding="utf-8")
|
||||
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||
assert result.generation_mode == "fallback_script"
|
||||
assert "最终成员数:3" in markdown
|
||||
assert manifest["model"]["reason"] == "api_unavailable_or_boundary_violation"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"violation",
|
||||
["unknown_connector", "extra_field", "free_text_field", "changed_section"],
|
||||
)
|
||||
def test_model_schema_or_connector_boundary_violation_falls_back_for_the_whole_report(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, violation: str
|
||||
) -> None:
|
||||
workspace = tmp_path / "job"
|
||||
_write_manifest(workspace, prefix="Ft.SPL", stage="identify", metric_value=3)
|
||||
|
||||
def invalid_model(payload: dict, _stages: list[str]):
|
||||
response = _valid_api_arrangement(payload)
|
||||
if violation == "unknown_connector":
|
||||
paragraph = next(
|
||||
item["paragraphs"][0]
|
||||
for item in response["sections"]
|
||||
if item["paragraphs"][0]["connector_ids"]
|
||||
)
|
||||
paragraph["connector_ids"][0] = "invented"
|
||||
elif violation == "extra_field":
|
||||
response["unexpected"] = True
|
||||
elif violation == "free_text_field":
|
||||
response["sections"][0]["paragraphs"][0]["text"] = "API 自由文本"
|
||||
else:
|
||||
response["sections"][0]["section_id"] = "changed.section"
|
||||
return response, {"status": "generated"}
|
||||
|
||||
monkeypatch.setattr(report_module, "_generate_api_narrative", invalid_model)
|
||||
result = _generate(
|
||||
workspace,
|
||||
expected_stages=["identify"],
|
||||
stage_statuses={"identify": "success"},
|
||||
use_model=True,
|
||||
)
|
||||
|
||||
markdown = result.report_path.read_text(encoding="utf-8")
|
||||
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||
assert result.generation_mode == "fallback_script"
|
||||
assert "最终成员数:3" in markdown
|
||||
assert "API 自由文本" not in markdown
|
||||
assert manifest["model"]["reason"] == "api_unavailable_or_boundary_violation"
|
||||
|
||||
|
||||
def test_open_reuses_existing_report(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
workspace = tmp_path / "job"
|
||||
manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="identify")
|
||||
calls = {"count": 0}
|
||||
|
||||
def valid_model(payload: dict, _stages: list[str]):
|
||||
calls["count"] += 1
|
||||
return (
|
||||
_valid_api_arrangement(payload),
|
||||
{"status": "generated", "provider": "test", "model": "test-model"},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(report_module, "_generate_api_narrative", valid_model)
|
||||
first = _generate(
|
||||
workspace,
|
||||
expected_stages=["identify"],
|
||||
stage_statuses={"identify": "success"},
|
||||
use_model=True,
|
||||
)
|
||||
reopened = _generate(
|
||||
workspace,
|
||||
expected_stages=["identify"],
|
||||
stage_statuses={"identify": "success"},
|
||||
use_model=True,
|
||||
action="open",
|
||||
)
|
||||
assert first.reused is False
|
||||
assert reopened.reused is True
|
||||
assert calls["count"] == 1
|
||||
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
manifest["metrics"]["member_count"] = 2
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
|
||||
reopened_after_source_change = _generate(
|
||||
workspace,
|
||||
expected_stages=["identify"],
|
||||
stage_statuses={"identify": "success"},
|
||||
use_model=True,
|
||||
action="open",
|
||||
)
|
||||
assert reopened_after_source_change.reused is True
|
||||
assert calls["count"] == 1
|
||||
|
||||
|
||||
def test_no_successful_evidence_creates_no_empty_report(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "job"
|
||||
_write_manifest(workspace, prefix="Ft.SPL", stage="identify", status="failed")
|
||||
|
||||
with pytest.raises(NoSuccessfulGeneFamilyEvidence, match="暂无可生成"):
|
||||
_generate(
|
||||
workspace,
|
||||
expected_stages=["identify"],
|
||||
stage_statuses={"identify": "failed"},
|
||||
)
|
||||
|
||||
assert not list((workspace / "final").glob("*.gene_family_report.*"))
|
||||
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from gfa_core.mas.core.fast_intent import fast_intent_recognition
|
||||
from gfa_core.mas.utils.intent_parser import parse_intent_from_query
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("query", "action"),
|
||||
[
|
||||
("生成结果报告", "generate"),
|
||||
("生成基因家族结果报告", "generate"),
|
||||
("打开基因家族结果报告", "open"),
|
||||
],
|
||||
)
|
||||
def test_explicit_gene_family_report_intent_is_provider_independent(query: str, action: str) -> None:
|
||||
for parsed in (parse_intent_from_query(query), fast_intent_recognition(query)):
|
||||
assert parsed is not None
|
||||
assert parsed["stages"] == ["gene_family_report"]
|
||||
assert parsed["params"]["geneFamilyReportAction"] == action
|
||||
assert parsed["params"]["reportAction"] == action
|
||||
assert parsed["method"] == "gene_family_report_deterministic"
|
||||
|
||||
|
||||
def test_rnaseq_report_intent_is_not_stolen() -> None:
|
||||
for parsed in (
|
||||
parse_intent_from_query("打开 RNA-seq 结果报告"),
|
||||
fast_intent_recognition("打开 RNA-seq 结果报告"),
|
||||
):
|
||||
assert not parsed or parsed.get("stages") != ["gene_family_report"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("query", "action"),
|
||||
[
|
||||
("生成结果报告", "generate"),
|
||||
("生成基因家族结果报告", "generate"),
|
||||
("打开基因家族结果报告", "open"),
|
||||
],
|
||||
)
|
||||
def test_gene_family_report_chat_bypasses_router(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
query: str,
|
||||
action: str,
|
||||
) -> None:
|
||||
from apps.web.vue3 import main as web_main
|
||||
from gfa_core.mas.agents import router as router_module
|
||||
|
||||
final_dir = tmp_path / "workspace" / "job-report" / "final"
|
||||
final_dir.mkdir(parents=True)
|
||||
(final_dir / "Ft.SPL.gene_family_report.manifest.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"stage": "gene_family_report",
|
||||
"status": "success",
|
||||
"prefix": "Ft.SPL",
|
||||
"parameters": {
|
||||
"expected_stages": ["identify", "motif"],
|
||||
"stage_statuses": {"identify": "success", "motif": "success"},
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
async def forbidden_router(*_args, **_kwargs):
|
||||
raise AssertionError("report intent must return before Router/LLM")
|
||||
|
||||
class ForbiddenOrchestrator:
|
||||
async def plan(self, *_args, **_kwargs):
|
||||
raise AssertionError("report plan must not call the LLM orchestrator")
|
||||
|
||||
monkeypatch.setattr(web_main, "PROJECT_ROOT", tmp_path)
|
||||
monkeypatch.setattr(web_main, "WORKSPACE_ROOT", tmp_path / "workspace")
|
||||
monkeypatch.setattr(web_main, "agent_conversation_store", None, raising=False)
|
||||
monkeypatch.setattr(web_main, "llm_manager", object())
|
||||
monkeypatch.setattr(web_main, "orchestrator", ForbiddenOrchestrator())
|
||||
monkeypatch.setattr(web_main, "MAS_AVAILABLE", True)
|
||||
monkeypatch.setattr(router_module.RouterAgent, "execute", forbidden_router)
|
||||
|
||||
result = asyncio.run(
|
||||
web_main._run_agent_chat(
|
||||
web_main.AgentChatRequest(message=query, job_id="job-report")
|
||||
)
|
||||
)
|
||||
|
||||
assert result["action"] == "propose_analysis"
|
||||
assert result["model_used"] == "rule"
|
||||
assert result["intent"]["stages"] == ["gene_family_report"]
|
||||
assert result["intent"]["prefix"] == "Ft.SPL"
|
||||
assert result["intent"]["params"]["reportAction"] == action
|
||||
assert result["intent"]["params"]["expectedStages"] == ["identify", "motif"]
|
||||
|
||||
plan = asyncio.run(
|
||||
web_main.agent_plan(
|
||||
web_main.AgentPlanRequest(
|
||||
message=query,
|
||||
job_id="job-report",
|
||||
prefix="Ft.SPL",
|
||||
intent=result["intent"],
|
||||
)
|
||||
)
|
||||
)
|
||||
assert plan["success"] is True
|
||||
assert plan["mode"] == "gene_family_report_deterministic"
|
||||
assert plan["plan"]["stages"] == ["gene_family_report"]
|
||||
assert plan["plan"]["params"]["expectedStages"] == ["identify", "motif"]
|
||||
@@ -0,0 +1,181 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _request_and_stages():
|
||||
from apps.web.vue3.main import ConfirmedPlanExecutionRequest, ConfirmedPlanStage
|
||||
|
||||
stages = [
|
||||
ConfirmedPlanStage(
|
||||
id="identify",
|
||||
params={
|
||||
"scopeId": "scope-a",
|
||||
"analysisRoute": "post_subfamily_confirm",
|
||||
"memberSetId": "members-a",
|
||||
},
|
||||
),
|
||||
ConfirmedPlanStage(id="motif", dependencies=["identify"]),
|
||||
ConfirmedPlanStage(id="pep", dependencies=["motif"]),
|
||||
]
|
||||
request = ConfirmedPlanExecutionRequest(
|
||||
session_id="session-a",
|
||||
job_id="job-a",
|
||||
prefix="Ft.SPL",
|
||||
plan_id="plan-a",
|
||||
stages=stages,
|
||||
)
|
||||
return request, stages
|
||||
|
||||
|
||||
def test_confirmed_plan_terminal_success_requests_final_report() -> None:
|
||||
from apps.web.vue3.main import _confirmed_plan_report_request
|
||||
|
||||
request, stages = _request_and_stages()
|
||||
report = _confirmed_plan_report_request(
|
||||
request,
|
||||
stages,
|
||||
{"status": "success", "completed_stages": ["identify", "motif", "pep"]},
|
||||
)
|
||||
|
||||
assert report is not None
|
||||
assert report.stage == "gene_family_report"
|
||||
assert report.expectedStages == ["identify", "motif", "pep"]
|
||||
assert report.stageStatuses == {"identify": "success", "motif": "success", "pep": "success"}
|
||||
assert report.reportAction == "generate"
|
||||
|
||||
|
||||
def test_confirmed_plan_failure_requests_partial_report() -> None:
|
||||
from apps.web.vue3.main import _confirmed_plan_report_request
|
||||
|
||||
request, stages = _request_and_stages()
|
||||
report = _confirmed_plan_report_request(
|
||||
request,
|
||||
stages,
|
||||
{
|
||||
"status": "failed",
|
||||
"completed_stages": ["identify"],
|
||||
"failure": {"stage": "motif"},
|
||||
},
|
||||
)
|
||||
|
||||
assert report is not None
|
||||
assert report.stageStatuses == {"identify": "success", "motif": "failed", "pep": "not_run"}
|
||||
assert report.scopeId == "scope-a"
|
||||
assert report.analysisRoute == "post_subfamily_confirm"
|
||||
assert report.memberSetId == "members-a"
|
||||
|
||||
|
||||
def test_paused_plan_does_not_auto_finalize_report() -> None:
|
||||
from apps.web.vue3.main import _confirmed_plan_report_request
|
||||
|
||||
request, stages = _request_and_stages()
|
||||
report = _confirmed_plan_report_request(
|
||||
request,
|
||||
stages,
|
||||
{"status": "paused", "completed_stages": ["identify"]},
|
||||
)
|
||||
assert report is None
|
||||
|
||||
|
||||
def test_frontend_batch_terminal_path_calls_report_stage_with_planned_statuses() -> None:
|
||||
source = (Path(__file__).resolve().parents[2] / "apps/web/vue3/index.html").read_text(encoding="utf-8")
|
||||
|
||||
assert "submitGeneFamilyReportForBatch" in source
|
||||
assert "executeSingleStageWithParams('gene_family_report'" in source
|
||||
assert "expectedStages: plannedIds" in source
|
||||
assert "stageStatuses" in source
|
||||
assert "if (pausedCount > 0) return" in source
|
||||
assert "continuationOpened" in source
|
||||
assert "name: 'gene_family_report'" in source
|
||||
assert "openGeneratedGeneFamilyReport" in source
|
||||
|
||||
|
||||
def test_report_context_prefers_terminal_confirmed_plan(monkeypatch, tmp_path: Path) -> None:
|
||||
from apps.web.vue3 import main as web_main
|
||||
|
||||
final_dir = tmp_path / "job-a" / "final"
|
||||
confirmed_dir = final_dir / ".confirmed_plans"
|
||||
confirmed_dir.mkdir(parents=True)
|
||||
(final_dir / "Ft.SPL.gene_family_report.manifest.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"stage": "gene_family_report",
|
||||
"status": "success",
|
||||
"prefix": "Ft.SPL",
|
||||
"parameters": {
|
||||
"expected_stages": ["identify"],
|
||||
"stage_statuses": {"identify": "success"},
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(confirmed_dir / "0123456789abcdefabcd.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "failed",
|
||||
"job_id": "job-a",
|
||||
"prefix": "Ft.SPL",
|
||||
"stages": ["identify", "motif", "pep", "gene_family_report"],
|
||||
"completed_stages": ["identify", "motif"],
|
||||
"failure": {"stage": "pep"},
|
||||
"scopeId": "scope-a",
|
||||
"memberSetId": "members-a",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(web_main, "WORKSPACE_ROOT", tmp_path)
|
||||
|
||||
context = web_main._gene_family_report_plan_context("job-a")
|
||||
|
||||
assert context == {
|
||||
"prefix": "Ft.SPL",
|
||||
"expectedStages": ["identify", "motif", "pep"],
|
||||
"stageStatuses": {"identify": "success", "motif": "success", "pep": "failed"},
|
||||
"scopeId": "scope-a",
|
||||
"analysisRoute": "",
|
||||
"memberSetId": "members-a",
|
||||
"rnaseqScopeId": "",
|
||||
}
|
||||
|
||||
|
||||
def test_report_context_reuses_formal_report_plan_only(monkeypatch, tmp_path: Path) -> None:
|
||||
from apps.web.vue3 import main as web_main
|
||||
|
||||
final_dir = tmp_path / "job-a" / "final"
|
||||
final_dir.mkdir(parents=True)
|
||||
(final_dir / "Ft.SPL.motif.manifest.json").write_text(
|
||||
json.dumps({"stage": "motif", "status": "success", "prefix": "Ft.SPL"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(web_main, "WORKSPACE_ROOT", tmp_path)
|
||||
assert web_main._gene_family_report_plan_context("job-a") is None
|
||||
|
||||
(final_dir / "Ft.SPL.gene_family_report.manifest.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"stage": "gene_family_report",
|
||||
"status": "success",
|
||||
"prefix": "Ft.SPL",
|
||||
"scope_id": "scope-old",
|
||||
"parameters": {
|
||||
"expected_stages": ["identify", "motif"],
|
||||
"stage_statuses": {"identify": "success", "motif": "success"},
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
context = web_main._gene_family_report_plan_context("job-a", "Ft.SPL")
|
||||
|
||||
assert context is not None
|
||||
assert context["expectedStages"] == ["identify", "motif"]
|
||||
assert context["stageStatuses"] == {"identify": "success", "motif": "success"}
|
||||
assert context["scopeId"] == ""
|
||||
assert context["analysisRoute"] == ""
|
||||
assert context["memberSetId"] == ""
|
||||
assert context["rnaseqScopeId"] == ""
|
||||
@@ -0,0 +1,347 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def _write_identify_manifest(workspace: Path, prefix: str = "Ft.SPL") -> Path:
|
||||
final_dir = workspace / "final"
|
||||
final_dir.mkdir(parents=True, exist_ok=True)
|
||||
table = final_dir / f"{prefix}.identify.tsv"
|
||||
table.write_text("gene\tvalue\nFtSPL1\t1\n", encoding="utf-8")
|
||||
manifest = final_dir / f"{prefix}.identify.manifest.json"
|
||||
manifest.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"manifest_version": "1.0",
|
||||
"stage": "identify",
|
||||
"status": "success",
|
||||
"prefix": prefix,
|
||||
"metrics": {"member_count": 1},
|
||||
"artifacts": {
|
||||
"members": {
|
||||
"path": f"final/{table.name}",
|
||||
"full_path": str(table),
|
||||
"type": "table",
|
||||
}
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return manifest
|
||||
|
||||
|
||||
def _flag_value(command: list[str], flag: str) -> str:
|
||||
return command[command.index(flag) + 1]
|
||||
|
||||
|
||||
def test_report_stage_is_registered_without_analysis_dependencies() -> None:
|
||||
from gfa_core.mas.stage_capabilities import CAPABILITIES
|
||||
from gfa_core.mas.stage_catalog import STAGE_ORDER, is_stage_executable
|
||||
from gfa_core.mas.tools.stage_tools import create_stage_registry
|
||||
|
||||
capability = CAPABILITIES["gene_family_report"]
|
||||
stage_tool = create_stage_registry().get("gene_family_report")
|
||||
|
||||
assert "gene_family_report" in STAGE_ORDER
|
||||
assert is_stage_executable("gene_family_report")
|
||||
assert capability.consumes == []
|
||||
assert stage_tool is not None
|
||||
assert stage_tool.required_inputs == []
|
||||
assert stage_tool.script == "run_gene_family_report.py"
|
||||
|
||||
|
||||
def test_report_cli_can_start_as_a_script() -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "apps/cli/run_gene_family_report.py", "--help"],
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
|
||||
|
||||
def test_report_cli_writes_report_and_manifest(tmp_path: Path) -> None:
|
||||
from apps.cli.run_gene_family_report import main
|
||||
|
||||
workspace = tmp_path / "job"
|
||||
_write_identify_manifest(workspace)
|
||||
result = main(
|
||||
[
|
||||
"--workspace",
|
||||
str(workspace),
|
||||
"--prefix",
|
||||
"Ft.SPL",
|
||||
"--job-id",
|
||||
"job-wiring",
|
||||
"--expected-stages",
|
||||
"identify",
|
||||
"--stage-statuses-json",
|
||||
'{"identify":"success"}',
|
||||
"--no-model",
|
||||
]
|
||||
)
|
||||
|
||||
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||
assert result.report_path.is_file()
|
||||
assert result.manifest_path.is_file()
|
||||
assert "gene_family_report" in manifest["artifacts"]
|
||||
|
||||
|
||||
def test_pipeline_backend_passes_plan_status_and_identity_to_report_cli(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
from apps.web import pipeline_backend
|
||||
|
||||
workspace = tmp_path / "job"
|
||||
input_dir = workspace / "input"
|
||||
logs_dir = workspace / "logs"
|
||||
input_dir.mkdir(parents=True)
|
||||
logs_dir.mkdir(parents=True)
|
||||
_write_identify_manifest(workspace)
|
||||
captured: dict[str, list[str]] = {}
|
||||
|
||||
def capture(command, _log_file, cwd=None):
|
||||
captured["command"] = [str(value) for value in command]
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(pipeline_backend, "can_start_new_task", lambda: (True, ""))
|
||||
monkeypatch.setattr(pipeline_backend, "run_cmd_logged", capture)
|
||||
job = pipeline_backend.JobInfo(
|
||||
job_id="job-backend",
|
||||
input_dir=str(input_dir),
|
||||
workspace_dir=str(workspace),
|
||||
logs_dir=str(logs_dir),
|
||||
created_at="2026-08-08 00:00:00",
|
||||
)
|
||||
|
||||
pipeline_backend.run_stage(
|
||||
"gene_family_report",
|
||||
job,
|
||||
"Ft.SPL",
|
||||
expected_stages=["identify", "motif"],
|
||||
stage_statuses={"identify": "success", "motif": "failed"},
|
||||
report_action="open",
|
||||
report_use_model=False,
|
||||
scope_id="scope-a",
|
||||
analysis_route="post_subfamily_confirm",
|
||||
member_set_id="members-a",
|
||||
rnaseq_scope_id="rna-a",
|
||||
)
|
||||
|
||||
command = captured["command"]
|
||||
assert command[1:3] == ["apps/cli/run_gene_family_report.py", "--workspace"]
|
||||
assert _flag_value(command, "--expected-stages") == "identify,motif"
|
||||
assert json.loads(_flag_value(command, "--stage-statuses-json")) == {
|
||||
"identify": "success",
|
||||
"motif": "failed",
|
||||
}
|
||||
assert _flag_value(command, "--action") == "open"
|
||||
assert _flag_value(command, "--job-id") == "job-backend"
|
||||
assert _flag_value(command, "--scope-id") == "scope-a"
|
||||
assert _flag_value(command, "--analysis-route") == "post_subfamily_confirm"
|
||||
assert _flag_value(command, "--member-set-id") == "members-a"
|
||||
assert _flag_value(command, "--rnaseq-scope-id") == "rna-a"
|
||||
assert "--no-model" in command
|
||||
assert not any("run_identify.py" in value or "run_motif.py" in value for value in command)
|
||||
|
||||
|
||||
def test_pipeline_backend_rejects_empty_plan_before_starting_report_cli(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
from apps.web import pipeline_backend
|
||||
|
||||
workspace = tmp_path / "job"
|
||||
input_dir = workspace / "input"
|
||||
logs_dir = workspace / "logs"
|
||||
input_dir.mkdir(parents=True)
|
||||
logs_dir.mkdir(parents=True)
|
||||
invoked = False
|
||||
|
||||
def capture(*_args, **_kwargs):
|
||||
nonlocal invoked
|
||||
invoked = True
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(pipeline_backend, "can_start_new_task", lambda: (True, ""))
|
||||
monkeypatch.setattr(pipeline_backend, "run_cmd_logged", capture)
|
||||
job = pipeline_backend.JobInfo(
|
||||
job_id="job-empty-plan",
|
||||
input_dir=str(input_dir),
|
||||
workspace_dir=str(workspace),
|
||||
logs_dir=str(logs_dir),
|
||||
created_at="2026-08-08 00:00:00",
|
||||
)
|
||||
|
||||
message, _manifest = pipeline_backend.run_stage(
|
||||
"gene_family_report",
|
||||
job,
|
||||
"Ft.SPL",
|
||||
expected_stages=[],
|
||||
report_action="generate",
|
||||
report_use_model=False,
|
||||
)
|
||||
|
||||
assert "expected_stages" in message
|
||||
assert invoked is False
|
||||
|
||||
|
||||
def test_stage_tool_executor_forwards_gene_family_report_context(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
import asyncio
|
||||
import types
|
||||
from dataclasses import dataclass
|
||||
|
||||
from gfa_core.mas.tools.stage_tools import StageTool, StageToolExecutor
|
||||
|
||||
captured = {}
|
||||
|
||||
@dataclass
|
||||
class FakeJobInfo:
|
||||
job_id: str
|
||||
input_dir: str
|
||||
workspace_dir: str
|
||||
logs_dir: str
|
||||
created_at: str
|
||||
|
||||
def fake_run_stage(**kwargs):
|
||||
captured.update(kwargs)
|
||||
manifest = Path(kwargs["job"].workspace_dir) / "final" / "Ft.SPL.gene_family_report.manifest.json"
|
||||
manifest.parent.mkdir(parents=True, exist_ok=True)
|
||||
manifest.write_text(
|
||||
json.dumps({"stage": "gene_family_report", "status": "success"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return "报告已生成", manifest
|
||||
|
||||
fake_backend = types.ModuleType("apps.web.pipeline_backend")
|
||||
fake_backend.JobInfo = FakeJobInfo
|
||||
fake_backend.run_stage = fake_run_stage
|
||||
monkeypatch.setitem(sys.modules, "apps.web.pipeline_backend", fake_backend)
|
||||
|
||||
result = asyncio.run(
|
||||
StageToolExecutor(project_root=tmp_path).execute(
|
||||
StageTool(
|
||||
"gene_family_report",
|
||||
"Gene Family Report",
|
||||
"run_gene_family_report.py",
|
||||
"report",
|
||||
"test",
|
||||
timeout=5,
|
||||
),
|
||||
"job-report",
|
||||
"Ft.SPL",
|
||||
{
|
||||
"expectedStages": ["identify", "motif"],
|
||||
"stageStatuses": {"identify": "success", "motif": "failed"},
|
||||
"reportAction": "open",
|
||||
"reportUseModel": False,
|
||||
"scopeId": "scope-a",
|
||||
"rnaseqScopeId": "rna-a",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert captured["expected_stages"] == ["identify", "motif"]
|
||||
assert captured["stage_statuses"] == {"identify": "success", "motif": "failed"}
|
||||
assert captured["report_action"] == "open"
|
||||
assert captured["report_use_model"] is False
|
||||
assert captured["scope_id"] == "scope-a"
|
||||
assert captured["rnaseq_scope_id"] == "rna-a"
|
||||
|
||||
|
||||
def test_execute_report_does_not_invent_scope_identity(monkeypatch) -> None:
|
||||
import asyncio
|
||||
from unittest.mock import Mock
|
||||
|
||||
from apps.web.vue3 import main as web_main
|
||||
from gfa_core.mas import resource_governor
|
||||
from tasks import execute_single_stage_task
|
||||
|
||||
submitted = Mock()
|
||||
submitted.return_value.id = "report-task"
|
||||
monkeypatch.setattr(execute_single_stage_task, "apply_async", submitted)
|
||||
monkeypatch.setattr(
|
||||
web_main,
|
||||
"_ensure_celery_queue_health",
|
||||
lambda *_args, **_kwargs: ({"available": True}, None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
web_main,
|
||||
"_ensure_gene_family_scope_file",
|
||||
lambda **_kwargs: ("", ""),
|
||||
)
|
||||
monkeypatch.setattr(resource_governor, "record_task", lambda **_kwargs: {})
|
||||
|
||||
request = web_main.ExecuteRequest(
|
||||
stage="gene_family_report",
|
||||
job_id="job-report-no-invented-identity",
|
||||
prefix="Ft.SPL",
|
||||
expectedStages=["identify", "motif"],
|
||||
stageStatuses={"identify": "success", "motif": "success"},
|
||||
)
|
||||
result = asyncio.run(web_main.execute_stage(request))
|
||||
|
||||
assert result["success"] is True
|
||||
params = submitted.call_args.kwargs["kwargs"]["params"]
|
||||
assert params["scopeId"] == ""
|
||||
assert params["analysisRoute"] == ""
|
||||
assert params["memberSetId"] == ""
|
||||
assert params["rnaseqScopeId"] == ""
|
||||
|
||||
|
||||
def test_report_artifact_validation_does_not_require_invented_identity(monkeypatch) -> None:
|
||||
from types import SimpleNamespace
|
||||
|
||||
import tasks
|
||||
from gfa_core.mas.tools import stage_identity
|
||||
|
||||
captured = {}
|
||||
identity = SimpleNamespace(
|
||||
scope_id="",
|
||||
analysis_route="custom",
|
||||
member_set_id="Ft.SPL:custom:all_members",
|
||||
wgs_sample_set_id="",
|
||||
wgs_reference_id="",
|
||||
wgs_analysis_id="",
|
||||
genotype_scope_id="",
|
||||
phenotype_scope_id="",
|
||||
covariate_scope_id="",
|
||||
model_scope_id="",
|
||||
source_model_scope_id="",
|
||||
analysis_scope_id="",
|
||||
)
|
||||
monkeypatch.setattr(stage_identity, "resolve_stage_identity", lambda **_kwargs: identity)
|
||||
|
||||
def capture_status(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return SimpleNamespace(completed=True)
|
||||
|
||||
monkeypatch.setattr(stage_identity, "get_stage_status_for_identity", capture_status)
|
||||
|
||||
failure = tasks.validate_single_stage_artifacts(
|
||||
"job-report",
|
||||
"Ft.SPL",
|
||||
"gene_family_report",
|
||||
{
|
||||
"manifest": {
|
||||
"stage": "gene_family_report",
|
||||
"status": "success",
|
||||
"prefix": "Ft.SPL",
|
||||
"analysis_route": "",
|
||||
"member_set_id": "",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert failure is None
|
||||
assert captured["strict_identity"] is False
|
||||
@@ -0,0 +1,70 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
from gfa_core.mas.agents.base import AgentMessage, AgentState
|
||||
from gfa_core.mas.agents.intent_v2 import IntentAgentV2
|
||||
|
||||
|
||||
def test_fallback_does_not_turn_unrecognized_continuation_into_identify():
|
||||
intent = IntentAgentV2(None)._fallback_recognition("执行接下来的分析")
|
||||
|
||||
assert intent["stages"] == []
|
||||
assert intent["needs_clarification"] is True
|
||||
assert intent["clarification_type"] == "analysis_stage"
|
||||
|
||||
|
||||
def test_validation_does_not_turn_empty_model_stages_into_identify():
|
||||
intent = IntentAgentV2(None)._validate_intent(
|
||||
{
|
||||
"gene_family": "SPL",
|
||||
"species": "Ft",
|
||||
"stages": [],
|
||||
"params": {},
|
||||
},
|
||||
"执行接下来的步骤",
|
||||
)
|
||||
|
||||
assert intent["stages"] == []
|
||||
assert intent["needs_clarification"] is True
|
||||
assert intent["clarification_type"] == "analysis_stage"
|
||||
|
||||
|
||||
def test_fallback_still_recognizes_explicit_identify():
|
||||
intent = IntentAgentV2(None)._fallback_recognition("执行 identify 分析")
|
||||
|
||||
assert intent["stages"] == ["identify"]
|
||||
assert intent.get("needs_clarification") is not True
|
||||
|
||||
|
||||
def test_empty_model_response_cannot_authorize_identify_and_keeps_goal_context():
|
||||
class _EmptyLLM:
|
||||
async def acall(self, *_args, **_kwargs):
|
||||
return SimpleNamespace(content="")
|
||||
|
||||
state = AgentState(session_id="empty-intent", job_id="job-empty-intent")
|
||||
message = AgentMessage(
|
||||
sender="router",
|
||||
receiver="intent_v2",
|
||||
message_type="recognize_intent",
|
||||
payload={
|
||||
"query": "执行接下来的分析",
|
||||
"job_id": "job-empty-intent",
|
||||
"workspace": "/tmp/job-empty-intent",
|
||||
"goal_context": {
|
||||
"user_terms": {
|
||||
"gene_family": "SPL",
|
||||
"target_species": ["Ft"],
|
||||
"reference_species": ["At"],
|
||||
"comparative_species": [],
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
returned = asyncio.run(IntentAgentV2(_EmptyLLM()).execute(state, message))
|
||||
intent = returned.results["results"]["intent"]
|
||||
|
||||
assert intent["stages"] == []
|
||||
assert intent["needs_clarification"] is True
|
||||
assert intent["target_species"] == ["Ft"]
|
||||
assert intent["reference_species"] == ["At"]
|
||||
@@ -0,0 +1,85 @@
|
||||
import json
|
||||
|
||||
from gfa_core.mas.agents.conversation_tools import build_plantai_conversation_tools
|
||||
from gfa_core.mas.llm import LLMManager
|
||||
from gfa_core.mas.workflow_models import GoalLedger
|
||||
|
||||
|
||||
def _registry(tmp_path, *, job_id="job-data-preparation-handoff"):
|
||||
ledger = GoalLedger(
|
||||
conversation_id="conversation-data-preparation-handoff",
|
||||
revision=0,
|
||||
user_terms={
|
||||
"active_job_id": job_id,
|
||||
"gene_family": "SPL",
|
||||
"target_species": ["Ft"],
|
||||
"reference_species": ["At"],
|
||||
"config_confirmed": True,
|
||||
},
|
||||
)
|
||||
return build_plantai_conversation_tools(
|
||||
llm_manager=LLMManager({}),
|
||||
project_root=tmp_path,
|
||||
bound_job_id=job_id,
|
||||
goal_ledger=ledger,
|
||||
)
|
||||
|
||||
|
||||
def test_analysis_handoff_is_not_rewritten_to_genome_prepare(tmp_path):
|
||||
resolved = _registry(tmp_path).resolve_analysis_handoff_request(
|
||||
"执行亚家族分类",
|
||||
workflow_id="gene_family",
|
||||
guidance=True,
|
||||
)
|
||||
|
||||
assert resolved.startswith("执行亚家族分类\n")
|
||||
assert "执行数据准备(genome_prepare)" not in resolved
|
||||
assert "目标物种 Ft" in resolved
|
||||
assert "参考物种 At" in resolved
|
||||
assert "正式 config.xlsx 已确认" in resolved
|
||||
|
||||
|
||||
def test_genome_prepare_manifest_does_not_change_analysis_handoff(tmp_path):
|
||||
job_id = "job-manifest-independent-handoff"
|
||||
registry = _registry(tmp_path, job_id=job_id)
|
||||
before = registry.resolve_analysis_handoff_request(
|
||||
"执行亚家族分类",
|
||||
workflow_id="gene_family",
|
||||
guidance=True,
|
||||
)
|
||||
final_dir = tmp_path / "workspace" / job_id / "final"
|
||||
final_dir.mkdir(parents=True)
|
||||
(final_dir / "genome_prepare.genome_prepare.manifest.json").write_text(
|
||||
json.dumps({"stage": "genome_prepare", "status": "success"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
after = registry.resolve_analysis_handoff_request(
|
||||
"执行亚家族分类",
|
||||
workflow_id="gene_family",
|
||||
guidance=True,
|
||||
)
|
||||
|
||||
assert after == before
|
||||
|
||||
|
||||
def test_explicit_data_preparation_request_is_preserved(tmp_path):
|
||||
resolved = _registry(tmp_path).resolve_analysis_handoff_request(
|
||||
"执行数据准备",
|
||||
workflow_id="data_preparation",
|
||||
guidance=False,
|
||||
)
|
||||
|
||||
assert resolved.startswith("执行数据准备\n")
|
||||
assert "当前业务动作已由数据准备契约唯一确定" not in resolved
|
||||
|
||||
|
||||
def test_next_step_question_is_not_rewritten_to_genome_prepare(tmp_path):
|
||||
resolved = _registry(tmp_path).resolve_analysis_handoff_request(
|
||||
"接下来需要做什么?",
|
||||
workflow_id="gene_family",
|
||||
guidance=True,
|
||||
)
|
||||
|
||||
assert resolved.startswith("接下来需要做什么?\n")
|
||||
assert "执行数据准备(genome_prepare)" not in resolved
|
||||
@@ -0,0 +1,204 @@
|
||||
import asyncio
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from gfa_core.mas.agents.conversation_tools import ConversationToolRegistry
|
||||
from gfa_core.mas.agents.router_runtime import RouterRuntime
|
||||
from gfa_core.mas.llm import LLMResponse, LLMToolCall
|
||||
|
||||
|
||||
_HISTORY = [
|
||||
{"role": "user", "content": "如果我想写个脚本来修订我的蛋白id"},
|
||||
{"role": "assistant", "content": "请提供输入格式、修订规则和输出要求。"},
|
||||
{"role": "user", "content": ">id.02020202 | name:123 | xxx"},
|
||||
{"role": "assistant", "content": "请确认修订规则。"},
|
||||
{"role": "user", "content": "仅保留第一个字段(id.02020202)"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "请确认输入文件格式,以及覆盖原文件还是输出到新文件?",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _response(content: str) -> LLMResponse:
|
||||
return LLMResponse(
|
||||
content=content,
|
||||
model="test-model",
|
||||
provider="test-provider",
|
||||
usage={},
|
||||
)
|
||||
|
||||
|
||||
class _SequenceLLM:
|
||||
def __init__(self, responses):
|
||||
self.responses = list(responses)
|
||||
self.calls = 0
|
||||
|
||||
async def acall(self, _messages, **_kwargs):
|
||||
self.calls += 1
|
||||
return self.responses.pop(0)
|
||||
|
||||
|
||||
class _QueryArgs(BaseModel):
|
||||
query: str
|
||||
|
||||
|
||||
def test_drafting_profile_preserves_short_specification_follow_ups():
|
||||
messages = RouterRuntime._route_messages(
|
||||
query="FASTA格式,输出到新文件",
|
||||
history=_HISTORY,
|
||||
session_id="drafting-continuation",
|
||||
job_id="job-bound-to-gene-family",
|
||||
goal_ledger=None,
|
||||
)
|
||||
prompt = " ".join(messages[0].content.split())
|
||||
|
||||
assert "Multi-turn drafting continuity" in prompt
|
||||
assert "a supplement to the same general goal" in prompt
|
||||
assert "must not be routed to utility" in prompt
|
||||
assert "actually transform a current or supplied file" in prompt
|
||||
|
||||
|
||||
def test_explicit_script_request_cannot_be_promoted_to_utility_by_router():
|
||||
llm = _SequenceLLM(
|
||||
[
|
||||
_response(
|
||||
'{"goal":"utility","needs":["utility_intent"],'
|
||||
'"subject":"简化FASTA ID","referent":"test.fa",'
|
||||
'"clarification_question":null,"interaction_kind":"independent"}'
|
||||
),
|
||||
_response(
|
||||
'{"status":"ok","action":"reply",'
|
||||
'"message":"下面给出一个只生成、不执行的 Python 脚本。"}'
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
outcome = asyncio.run(
|
||||
RouterRuntime(llm, ConversationToolRegistry(), max_iterations=5).run(
|
||||
query="帮我写脚本简化 test.fa 的id",
|
||||
history=[],
|
||||
session_id="explicit-script-request",
|
||||
job_id="",
|
||||
goal_ledger=None,
|
||||
)
|
||||
)
|
||||
|
||||
assert outcome.action == "reply"
|
||||
assert outcome.tool_trace == []
|
||||
assert llm.calls == 2
|
||||
|
||||
|
||||
def test_drafting_follow_up_uses_responder_instead_of_router_clarification():
|
||||
llm = _SequenceLLM(
|
||||
[
|
||||
_response(
|
||||
'{"goal":"general","needs":[],"subject":"补充FASTA脚本规则",'
|
||||
'"referent":"蛋白ID脚本",'
|
||||
'"clarification_question":"请重新提供全部需求。",'
|
||||
'"interaction_kind":"supplement"}'
|
||||
),
|
||||
_response(
|
||||
'{"status":"ok","action":"reply",'
|
||||
'"message":"已记录保留第一个字段;还需要确认输入格式和输出方式。"}'
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
outcome = asyncio.run(
|
||||
RouterRuntime(llm, ConversationToolRegistry(), max_iterations=5).run(
|
||||
query="仅保留第一个字段(id.02020202)",
|
||||
history=_HISTORY[:4],
|
||||
session_id="drafting-follow-up",
|
||||
job_id="",
|
||||
goal_ledger=None,
|
||||
)
|
||||
)
|
||||
|
||||
assert outcome.action == "reply"
|
||||
assert outcome.message.startswith("已记录保留第一个字段")
|
||||
assert llm.calls == 2
|
||||
|
||||
|
||||
def test_locked_general_reply_accepts_bare_drafting_text_without_retry():
|
||||
llm = _SequenceLLM(
|
||||
[
|
||||
_response(
|
||||
'{"goal":"general","needs":[],"subject":"生成FASTA脚本",'
|
||||
'"referent":null,"clarification_question":null,'
|
||||
'"interaction_kind":"supplement"}'
|
||||
),
|
||||
_response("```python\nprint('FASTA')\n```"),
|
||||
_response(
|
||||
'{"status":"ok","action":"reply",'
|
||||
'"message":"recovered after retry"}'
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
outcome = asyncio.run(
|
||||
RouterRuntime(llm, ConversationToolRegistry(), max_iterations=5).run(
|
||||
query="FASTA格式,输出到新文件",
|
||||
history=_HISTORY,
|
||||
session_id="bare-drafting-reply",
|
||||
job_id="",
|
||||
goal_ledger=None,
|
||||
)
|
||||
)
|
||||
|
||||
assert outcome.action == "reply"
|
||||
assert outcome.message == "```python\nprint('FASTA')\n```"
|
||||
assert outcome.tool_trace == []
|
||||
assert llm.calls == 2
|
||||
|
||||
|
||||
def test_unrecognized_file_utility_returns_actionable_chinese_clarification():
|
||||
registry = ConversationToolRegistry()
|
||||
registry.register(
|
||||
name="recognize_utility_intent",
|
||||
description="Recognize a supported utility request.",
|
||||
args_model=_QueryArgs,
|
||||
handler=lambda _args: {
|
||||
"success": False,
|
||||
"error_code": "utility_intent_not_found",
|
||||
"message": "No supported utility request was recognized.",
|
||||
},
|
||||
permission="pure",
|
||||
)
|
||||
llm = _SequenceLLM(
|
||||
[
|
||||
_response(
|
||||
'{"goal":"utility","needs":["utility_intent"],'
|
||||
'"subject":"处理文件","referent":null,'
|
||||
'"clarification_question":null,"interaction_kind":"independent"}'
|
||||
),
|
||||
LLMResponse(
|
||||
content="",
|
||||
model="test-model",
|
||||
provider="test-provider",
|
||||
usage={},
|
||||
tool_calls=[
|
||||
LLMToolCall(
|
||||
id="utility-not-found-1",
|
||||
name="recognize_utility_intent",
|
||||
arguments='{"query":"帮我处理一下文件"}',
|
||||
)
|
||||
],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
outcome = asyncio.run(
|
||||
RouterRuntime(llm, registry, max_iterations=5).run(
|
||||
query="帮我处理一下文件",
|
||||
history=[],
|
||||
session_id="unrecognized-utility",
|
||||
job_id="job-utility",
|
||||
goal_ledger=None,
|
||||
)
|
||||
)
|
||||
|
||||
assert outcome.action == "clarify"
|
||||
assert "实际文件路径" in outcome.message
|
||||
assert "处理规则" in outcome.message
|
||||
assert "I couldn't identify" not in outcome.message
|
||||
@@ -0,0 +1,113 @@
|
||||
from gfa_core.mas.agents.router_runtime import RouteDecision, RouterRuntime
|
||||
|
||||
|
||||
def _general_prompt(query: str, history=None) -> str:
|
||||
return RouterRuntime._initial_messages(
|
||||
query=query,
|
||||
history=list(history or []),
|
||||
session_id="product-business-session",
|
||||
job_id="job-bound-to-other-workflow",
|
||||
decision=RouteDecision(
|
||||
goal="general",
|
||||
needs=[],
|
||||
subject="解释产品业务",
|
||||
interaction_kind="independent",
|
||||
),
|
||||
goal_ledger=None,
|
||||
)[0].content
|
||||
|
||||
|
||||
def _route_prompt(query: str) -> str:
|
||||
return RouterRuntime._route_messages(
|
||||
query=query,
|
||||
history=[],
|
||||
session_id="product-business-route",
|
||||
job_id="job-bound-to-other-workflow",
|
||||
goal_ledger=None,
|
||||
)[0].content
|
||||
|
||||
|
||||
def test_gene_family_product_help_defines_two_level_business_view():
|
||||
prompt = _general_prompt("基因家族有哪些分析步骤?")
|
||||
compact = " ".join(prompt.split())
|
||||
|
||||
assert "亚家族分类与确认(前置)" in prompt
|
||||
assert "常规基因家族分析(主体)" in prompt
|
||||
assert "First answer with these two parts only" in prompt
|
||||
assert "Do not enumerate any individual stage in that first answer" in compact
|
||||
assert "must not use RAG or project tools" in compact
|
||||
assert (
|
||||
"phylogeny,motif,domain,structure,combo,promoter,tfbs,synteny,"
|
||||
"interspecies_synteny,karyotype,pep,ortholog,kaks,ppi"
|
||||
) in prompt
|
||||
|
||||
|
||||
def test_rnaseq_product_help_defines_modes_and_authoritative_inputs():
|
||||
prompt = _general_prompt("系统中 RNA-seq 分析有哪些步骤?")
|
||||
compact = " ".join(prompt.split())
|
||||
|
||||
assert "全基因组 RNA-seq 分析(主体)" in prompt
|
||||
assert "已确认基因家族成员的 RNA 结果投影" in prompt
|
||||
assert "First answer with these two modes only" in compact
|
||||
assert "config.xlsx and samples.yaml are equally authoritative inputs" in compact
|
||||
assert (
|
||||
"rna_fact_projection,rnaseq_qc,rnaseq_align,rnaseq_quant,"
|
||||
"rnaseq_sample_qc,rnaseq_de"
|
||||
) in prompt
|
||||
assert "must not rerun a separate family-mode DE" in compact
|
||||
|
||||
|
||||
def test_rnaseq_product_question_and_execution_have_distinct_route_examples():
|
||||
prompt = _route_prompt("全基因组 RNA-seq 分析的正式输入和具体步骤是什么?")
|
||||
|
||||
assert (
|
||||
'"全基因组 RNA-seq 分析的正式输入和具体步骤是什么?" -> '
|
||||
"general + needs=[]."
|
||||
) in prompt
|
||||
assert (
|
||||
'"执行全基因组 RNA-seq 分析" -> new_analysis + analysis_intent.'
|
||||
) in prompt
|
||||
|
||||
|
||||
def test_product_follow_up_keeps_all_prior_business_context():
|
||||
history = [
|
||||
{"role": "user", "content": "系统中转录组分析有哪些步骤?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "分为全基因组 RNA-seq 和已确认家族成员的 RNA 结果投影。",
|
||||
},
|
||||
{"role": "user", "content": "先说全基因组这一部分。"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "需要先确认 config.xlsx 和 samples.yaml。",
|
||||
},
|
||||
]
|
||||
messages = RouterRuntime._initial_messages(
|
||||
query="那家族成员这一部分呢?",
|
||||
history=history,
|
||||
session_id="product-follow-up-session",
|
||||
job_id="job-product-follow-up",
|
||||
decision=RouteDecision(
|
||||
goal="general",
|
||||
needs=[],
|
||||
subject="继续解释产品业务",
|
||||
interaction_kind="supplement",
|
||||
),
|
||||
goal_ledger=None,
|
||||
)
|
||||
|
||||
assert [message.content for message in messages[1:-1]] == [
|
||||
item["content"] for item in history
|
||||
]
|
||||
|
||||
|
||||
def test_completed_subfamily_or_stage_list_is_not_full_analysis_completion():
|
||||
assert not RouterRuntime._claims_gene_family_complete_at_readiness_boundary(
|
||||
"亚家族分类与确认已经完成,接下来进入常规基因家族分析,包括 ppi。"
|
||||
)
|
||||
assert not RouterRuntime._claims_gene_family_complete_at_readiness_boundary(
|
||||
"已完成的阶段包括 identify;后续还有常规基因家族分析。"
|
||||
)
|
||||
assert RouterRuntime._claims_gene_family_complete_at_readiness_boundary(
|
||||
"亚家族确认完成,因此完整基因家族分析已经全部完成。"
|
||||
)
|
||||
@@ -0,0 +1,100 @@
|
||||
from gfa_core.mas.agent_state_repository import SQLiteAgentStateRepository
|
||||
from gfa_core.mas.agents.router_runtime import RouteDecision, RouterRuntime
|
||||
from gfa_core.mas.persistent_conversation import PersistentConversationStore
|
||||
|
||||
|
||||
def test_begin_turn_imports_and_returns_complete_valid_history(tmp_path):
|
||||
repository = SQLiteAgentStateRepository(tmp_path / "agent-state.sqlite3")
|
||||
repository.initialize()
|
||||
store = PersistentConversationStore(repository)
|
||||
client_history = [
|
||||
{
|
||||
"role": "user" if index % 2 == 0 else "assistant",
|
||||
"content": f"历史消息-{index:02d}",
|
||||
}
|
||||
for index in range(14)
|
||||
]
|
||||
|
||||
turn = store.begin_turn(
|
||||
session_id="full-history-session",
|
||||
job_id="job-full-history",
|
||||
query="当前问题",
|
||||
client_history=client_history,
|
||||
)
|
||||
|
||||
assert list(turn.history) == client_history
|
||||
stored = repository.list_messages(turn.conversation_id)
|
||||
assert [item.content for item in stored] == [
|
||||
*(item["content"] for item in client_history),
|
||||
"当前问题",
|
||||
]
|
||||
|
||||
|
||||
def test_begin_turn_returns_all_persisted_messages_not_only_latest_ten(tmp_path):
|
||||
repository = SQLiteAgentStateRepository(tmp_path / "agent-state.sqlite3")
|
||||
repository.initialize()
|
||||
store = PersistentConversationStore(repository)
|
||||
first_turn = store.begin_turn(
|
||||
session_id="persisted-full-history-session",
|
||||
job_id="job-full-history",
|
||||
query="第一个问题",
|
||||
client_history=[],
|
||||
)
|
||||
store.finish_turn(
|
||||
first_turn,
|
||||
assistant_message="第一个回答",
|
||||
interaction_kind="independent",
|
||||
action="reply",
|
||||
)
|
||||
for index in range(1, 7):
|
||||
turn = store.begin_turn(
|
||||
session_id="persisted-full-history-session",
|
||||
job_id="job-full-history",
|
||||
query=f"问题-{index}",
|
||||
client_history=[],
|
||||
)
|
||||
store.finish_turn(
|
||||
turn,
|
||||
assistant_message=f"回答-{index}",
|
||||
interaction_kind="supplement",
|
||||
action="reply",
|
||||
)
|
||||
|
||||
final_turn = store.begin_turn(
|
||||
session_id="persisted-full-history-session",
|
||||
job_id="job-full-history",
|
||||
query="最终问题",
|
||||
client_history=[],
|
||||
)
|
||||
|
||||
assert len(final_turn.history) == 14
|
||||
assert final_turn.history[0]["content"] == "第一个问题"
|
||||
assert final_turn.history[-1]["content"] == "回答-6"
|
||||
|
||||
|
||||
def test_main_agent_prompt_receives_the_complete_history():
|
||||
history = [
|
||||
{
|
||||
"role": "user" if index % 2 == 0 else "assistant",
|
||||
"content": f"消息-{index}",
|
||||
}
|
||||
for index in range(12)
|
||||
]
|
||||
|
||||
messages = RouterRuntime._initial_messages(
|
||||
query="当前问题",
|
||||
history=history,
|
||||
session_id="full-history-session",
|
||||
job_id="job-full-history",
|
||||
decision=RouteDecision(
|
||||
goal="general",
|
||||
needs=[],
|
||||
subject="继续普通问答",
|
||||
interaction_kind="supplement",
|
||||
),
|
||||
goal_ledger=None,
|
||||
)
|
||||
|
||||
assert [message.content for message in messages[1:-1]] == [
|
||||
f"消息-{index}" for index in range(12)
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@ class _PassThroughTools:
|
||||
*,
|
||||
workflow_id: str | None = None,
|
||||
guidance: bool = False,
|
||||
language: str = "zh",
|
||||
) -> str:
|
||||
return query
|
||||
|
||||
@@ -30,6 +31,7 @@ class _ContextEnrichingTools:
|
||||
*,
|
||||
workflow_id: str | None = None,
|
||||
guidance: bool = False,
|
||||
language: str = "zh",
|
||||
) -> str:
|
||||
return (
|
||||
f"{query}\n已确认上下文:当前 job 的 subfamily_confirm 已成功完成,"
|
||||
@@ -150,3 +152,23 @@ def test_new_analysis_decision_discards_main_agent_workflow_and_clarification()
|
||||
assert normalized.workflow_id is None
|
||||
assert normalized.clarification_question is None
|
||||
assert normalized.analysis_request_kind == "execution"
|
||||
|
||||
|
||||
def test_router_response_language_does_not_rewrite_business_query() -> None:
|
||||
query = "执行接下来的分析"
|
||||
messages = RouterRuntime._initial_messages(
|
||||
query=query,
|
||||
history=[],
|
||||
session_id="english-router-session",
|
||||
job_id="job-english-router",
|
||||
decision=RouteDecision(
|
||||
goal="general",
|
||||
needs=[],
|
||||
subject=query,
|
||||
),
|
||||
goal_ledger=None,
|
||||
language="en",
|
||||
)
|
||||
|
||||
assert messages[-1].content == query
|
||||
assert "Respond to the user in English." in messages[0].content
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from gfa_core.mas import context_index, stage_requirements
|
||||
from gfa_core.mas.context_index import ProjectContextIndex
|
||||
from gfa_core.mas.stage_requirements import (
|
||||
resolve_many_stage_requirements,
|
||||
resolve_stage_requirements,
|
||||
)
|
||||
|
||||
|
||||
def test_stage_requirements_does_not_load_unconsumed_global_stage_status(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
calls = []
|
||||
|
||||
def fake_get_stage_status(**kwargs):
|
||||
calls.append(kwargs["stage"])
|
||||
return SimpleNamespace(status="missing", manifest_path="", to_dict=lambda: {})
|
||||
|
||||
monkeypatch.setattr(context_index, "get_stage_status", fake_get_stage_status)
|
||||
|
||||
report = resolve_stage_requirements(
|
||||
stage="identify",
|
||||
prefix="Ft.SPL",
|
||||
job_id="job-test",
|
||||
project_root=tmp_path,
|
||||
)
|
||||
|
||||
assert report.stage == "identify"
|
||||
assert calls == []
|
||||
|
||||
|
||||
def _empty_context(tmp_path, job_id):
|
||||
workspace = tmp_path / "workspace" / job_id
|
||||
return ProjectContextIndex(
|
||||
job_id=job_id,
|
||||
workspace=str(workspace),
|
||||
input_dir=str(tmp_path / "input" / job_id),
|
||||
final_dir=str(workspace / "final"),
|
||||
logs_dir=str(workspace / "logs"),
|
||||
)
|
||||
|
||||
|
||||
def test_many_stage_requirements_reuses_context_without_mutating_shared_identity(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
calls = []
|
||||
base_context = _empty_context(tmp_path, "job-test")
|
||||
|
||||
def fake_build_project_context(**kwargs):
|
||||
calls.append(kwargs["prefix"])
|
||||
return base_context
|
||||
|
||||
monkeypatch.setattr(
|
||||
stage_requirements,
|
||||
"build_project_context",
|
||||
fake_build_project_context,
|
||||
)
|
||||
|
||||
reports = resolve_many_stage_requirements(
|
||||
stages=["identify", "phylogeny"],
|
||||
prefix="Ft.SPL",
|
||||
job_id="job-test",
|
||||
params_by_stage={
|
||||
"identify": {"targetSpecies": ["Ft"]},
|
||||
"phylogeny": {"targetSpecies": ["Os"]},
|
||||
},
|
||||
project_root=tmp_path,
|
||||
)
|
||||
|
||||
assert calls == ["Ft.SPL"]
|
||||
assert base_context.target_species == []
|
||||
assert reports["identify"]["identity"]["target_species"] == ["Ft"]
|
||||
assert reports["phylogeny"]["identity"]["target_species"] == ["Os"]
|
||||
|
||||
|
||||
def test_many_stage_requirements_caches_context_per_effective_prefix(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
calls = []
|
||||
|
||||
def fake_build_project_context(**kwargs):
|
||||
calls.append(kwargs["prefix"])
|
||||
return _empty_context(tmp_path, kwargs["job_id"])
|
||||
|
||||
monkeypatch.setattr(
|
||||
stage_requirements,
|
||||
"build_project_context",
|
||||
fake_build_project_context,
|
||||
)
|
||||
|
||||
resolve_many_stage_requirements(
|
||||
stages=["identify", "phylogeny", "motif"],
|
||||
prefix="Ft.SPL",
|
||||
job_id="job-test",
|
||||
params_by_stage={
|
||||
"identify": {"prefix": "Ft.SPL"},
|
||||
"phylogeny": {"prefix": "Ft.SPL"},
|
||||
"motif": {"prefix": "At.SPL"},
|
||||
},
|
||||
project_root=tmp_path,
|
||||
)
|
||||
|
||||
assert calls == ["Ft.SPL", "At.SPL"]
|
||||
Reference in New Issue
Block a user