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:
potaser
2026-08-10 23:24:47 +08:00
68 changed files with 41004 additions and 668 deletions
@@ -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())
+97
View File
@@ -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
+88
View File
@@ -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,
]
+844
View File
@@ -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"]