feat: add gene family result reports

This commit is contained in:
wtx
2026-08-09 04:32:28 +08:00
parent 65f79207f0
commit cb122fd088
20 changed files with 3676 additions and 20 deletions
+333
View File
@@ -0,0 +1,333 @@
"""Generate the formal report for one gene-family analysis plan."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
import json
from pathlib import Path
import re
from typing import Any, Iterable, Mapping
from gfa_core.gene_family_report_data import (
ReportData,
ReportSection,
build_report_data,
)
from gfa_core.mas.agents.interpretation_evidence import load_stage_manifests
REPORT_STAGE = "gene_family_report"
REPORT_TEMPLATE_VERSION = "gene_family_report_template_v1"
EXCLUDED_PLAN_STAGES = {"summary", REPORT_STAGE, "subfamily_confirm"}
TEMPLATE_PATH = Path(__file__).with_name("templates") / "gene_family_report_v1.md"
CONNECTORS = {
"also": "此外,",
"however": "不过,",
"therefore": "因此,",
}
class NoSuccessfulGeneFamilyEvidence(RuntimeError):
"""Raised when the requested plan has no successful result to report."""
@dataclass(frozen=True)
class GeneFamilyReportResult:
report_path: Path
manifest_path: Path
report_status: str
generation_mode: str
included_stages: tuple[str, ...]
reused: bool = False
def _normalized_stages(stages: Iterable[str] | None) -> list[str]:
result: list[str] = []
for raw in stages or ():
stage = str(raw).strip()
if stage and stage not in EXCLUDED_PLAN_STAGES and stage not in result:
result.append(stage)
return result
def _parse_json_object(text: str) -> dict[str, Any]:
value = text.strip()
if value.startswith("```"):
value = re.sub(r"^```(?:json)?\s*", "", value, flags=re.IGNORECASE)
value = re.sub(r"\s*```$", "", value)
parsed = json.loads(value)
if not isinstance(parsed, dict):
raise ValueError("model output must be a JSON object")
return parsed
def _api_payload(report_data: ReportData) -> dict[str, Any]:
return {
"sections": [
{
"section_id": section.section_id,
"sentences": [
{"sentence_id": sentence.id, "text": sentence.text}
for sentence in section.sentences
],
}
for section in report_data.sections
],
"allowed_connectors": list(CONNECTORS),
}
def _generate_api_narrative(payload: dict, stages: list[str]) -> tuple[dict[str, Any], dict[str, Any]]:
from gfa_core.mas.config import config as mas_config
from gfa_core.mas.llm.base import LLMMessage
from gfa_core.mas.llm.manager import LLMManager
prompt = (
"仅编排以下不可改写句子。输出 JSON 字段只能是 sections;每节只能含 section_id、paragraphs,"
"每段只能含 sentence_ids、connector_ids。必须保留所有章节和句子且各出现一次,"
"句子不得跨章节;connector_ids 只能来自 allowed_connectors。计划阶段为:"
f"{','.join(stages)}。\n"
+ json.dumps(payload, ensure_ascii=False)
)
response = LLMManager(config=mas_config.load_llm_config()).call(
[
LLMMessage(role="system", content="你是证据约束的生物信息学报告编辑器,只返回 JSON。"),
LLMMessage(role="user", content=prompt),
],
track_agent_name="GeneFamilyReport",
)
return _parse_json_object(response.content), {
"status": "generated",
"provider": response.provider,
"model": response.model,
}
def _default_arrangement(report_data: ReportData) -> dict[str, Any]:
return {
"sections": [
{
"section_id": section.section_id,
"paragraphs": [
{"sentence_ids": [sentence.id], "connector_ids": []}
for sentence in section.sentences
],
}
for section in report_data.sections
]
}
def _validate_arrangement(arrangement: Any, report_data: ReportData) -> None:
if not isinstance(arrangement, dict) or set(arrangement) != {"sections"}:
raise ValueError("API response has unknown or missing root fields")
sections = arrangement["sections"]
if not isinstance(sections, list):
raise ValueError("API sections must be a list")
expected = {section.section_id: {sentence.id for sentence in section.sentences} for section in report_data.sections}
seen_sections: set[str] = set()
for section in sections:
if not isinstance(section, dict) or set(section) != {"section_id", "paragraphs"}:
raise ValueError("API section has unknown or missing fields")
section_id = section["section_id"]
if not isinstance(section_id, str) or section_id not in expected or section_id in seen_sections:
raise ValueError("API changed or repeated a report section")
seen_sections.add(section_id)
paragraphs = section["paragraphs"]
if not isinstance(paragraphs, list):
raise ValueError("API paragraphs must be a list")
seen_sentences: list[str] = []
for paragraph in paragraphs:
if not isinstance(paragraph, dict) or set(paragraph) != {"sentence_ids", "connector_ids"}:
raise ValueError("API paragraph has unknown or missing fields")
sentence_ids = paragraph["sentence_ids"]
connector_ids = paragraph["connector_ids"]
if not isinstance(sentence_ids, list) or not sentence_ids or any(not isinstance(value, str) for value in sentence_ids):
raise ValueError("API sentence_ids must be a non-empty string list")
if not isinstance(connector_ids, list) or len(connector_ids) != len(sentence_ids) - 1:
raise ValueError("API connector count does not match its paragraph")
if any(not isinstance(value, str) or value not in CONNECTORS for value in connector_ids):
raise ValueError("API used an unknown connector")
if any(value not in expected[section_id] for value in sentence_ids):
raise ValueError("API used an unknown or cross-section sentence")
seen_sentences.extend(sentence_ids)
if len(seen_sentences) != len(set(seen_sentences)) or set(seen_sentences) != expected[section_id]:
raise ValueError("API omitted or repeated a required sentence")
if seen_sections != set(expected):
raise ValueError("API omitted a report section")
def _template_blocks() -> list[tuple[str, str]]:
template = TEMPLATE_PATH.read_text(encoding="utf-8")
marker = re.compile(r"(?m)^<!-- GFA_SECTION ([a-z0-9_.]+) -->\s*$")
matches = list(marker.finditer(template))
if not matches:
raise ValueError(f"报告模板没有章节边界:{TEMPLATE_PATH}")
return [
(
match.group(1),
template[match.end(): matches[index + 1].start() if index + 1 < len(matches) else len(template)].strip(),
)
for index, match in enumerate(matches)
]
def _arranged_content(section: ReportSection, arranged: Mapping[str, Any]) -> str:
sentences = {sentence.id: sentence.text for sentence in section.sentences}
paragraphs: list[str] = []
for paragraph in arranged["paragraphs"]:
sentence_ids = paragraph["sentence_ids"]
text = sentences[sentence_ids[0]]
for connector_id, sentence_id in zip(paragraph["connector_ids"], sentence_ids[1:]):
text += f" {CONNECTORS[connector_id]}{sentences[sentence_id]}"
paragraphs.append(text)
return "\n\n".join(paragraphs)
def _render_markdown(
report_data: ReportData,
*,
job_id: str,
generation_mode: str,
arrangement: Mapping[str, Any],
) -> str:
section_data = {section.section_id: section for section in report_data.sections}
arranged = {section["section_id"]: section for section in arrangement["sections"]}
rendered: list[str] = []
for section_id, block in _template_blocks():
if section_id not in section_data:
continue
rendered.append(block.replace("{{SECTION_CONTENT}}", _arranged_content(section_data[section_id], arranged[section_id])))
markdown = "\n\n".join(rendered)
fallback_notice = (
"> 生成模式:候补脚本。模型 API 不可用或输出越过语言编排边界;报告事实仍来自确定性结果数据。"
if generation_mode == "fallback_script"
else ""
)
replacements = {
"{{PREFIX}}": report_data.prefix,
"{{JOB_ID}}": job_id or "未指定",
"{{GENERATION_MODE}}": "API 编排" if generation_mode == "api" else "候补脚本",
"{{GENERATED_AT}}": datetime.now().isoformat(timespec="seconds"),
"{{FALLBACK_NOTICE}}": fallback_notice,
}
for placeholder, value in replacements.items():
markdown = markdown.replace(placeholder, value)
if "{{" in markdown or "【填写】" in markdown:
raise ValueError("报告模板仍含未替换占位符")
return markdown.rstrip() + "\n"
def generate_gene_family_report(
workspace: Path | str,
prefix: str,
*,
expected_stages: Iterable[str] | None = None,
stage_statuses: Mapping[str, str] | None = None,
scope_id: str = "",
analysis_route: str = "",
member_set_id: str = "",
rnaseq_scope_id: str = "",
use_model: bool = True,
action: str = "generate",
job_id: str = "",
) -> GeneFamilyReportResult:
if action not in {"generate", "open"}:
raise ValueError("action must be 'generate' or 'open'")
final_dir = Path(workspace).expanduser().resolve() / "final"
report_path = final_dir / f"{prefix}.gene_family_report.md"
manifest_path = final_dir / f"{prefix}.gene_family_report.manifest.json"
if action == "open" and report_path.is_file() and manifest_path.is_file():
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
metrics = manifest.get("metrics") or {}
return GeneFamilyReportResult(
report_path,
manifest_path,
str(metrics.get("report_status") or "partial"),
str(metrics.get("generation_mode") or "fallback_script"),
tuple(metrics.get("included_stages") or []),
reused=True,
)
planned = _normalized_stages(expected_stages)
if not planned:
raise ValueError("expected_stages is required when generating a gene-family report")
manifests = load_stage_manifests(
final_dir,
prefix,
scope_id=scope_id,
analysis_route=analysis_route,
member_set_id=member_set_id,
rnaseq_scope_id=rnaseq_scope_id,
)
statuses = {str(key): str(value) for key, value in (stage_statuses or {}).items()}
for stage in planned:
statuses.setdefault(stage, str((manifests.get(stage) or {}).get("status") or "missing"))
report_data = build_report_data(final_dir, prefix, planned, statuses, manifests)
if not report_data.included:
raise NoSuccessfulGeneFamilyEvidence("暂无可生成的基因家族分析结果")
arrangement = _default_arrangement(report_data)
generation_mode = "fallback_script"
model_metadata: dict[str, Any] = {"status": "disabled"}
if use_model:
try:
candidate, model_metadata = _generate_api_narrative(_api_payload(report_data), list(report_data.included))
_validate_arrangement(candidate, report_data)
arrangement = candidate
generation_mode = "api"
except Exception:
model_metadata = {
"status": "fallback",
"reason": "api_unavailable_or_boundary_violation",
}
markdown = _render_markdown(
report_data,
job_id=job_id,
generation_mode=generation_mode,
arrangement=arrangement,
)
final_dir.mkdir(parents=True, exist_ok=True)
report_path.write_text(markdown, encoding="utf-8")
manifest = {
"manifest_version": "1.0",
"stage": REPORT_STAGE,
"status": "success",
"prefix": prefix,
"job_id": job_id,
"scope_id": scope_id,
"analysis_route": analysis_route,
"member_set_id": member_set_id,
"rnaseq_scope_id": rnaseq_scope_id,
"parameters": {"expected_stages": planned, "stage_statuses": {stage: statuses[stage] for stage in planned}},
"metrics": {
"report_status": report_data.report_status,
"generation_mode": generation_mode,
"included_stages": list(report_data.included),
"unsupported_stages": list(report_data.unsupported_stages),
"failed_or_missing_stages": [stage for stage in planned if stage not in report_data.included],
},
"model": model_metadata,
"artifacts": {
"gene_family_report": {
"path": f"final/{report_path.name}",
"full_path": str(report_path),
"type": "markdown",
},
"gene_family_report_manifest": {
"path": f"final/{manifest_path.name}",
"full_path": str(manifest_path),
"type": "manifest",
},
},
}
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return GeneFamilyReportResult(
report_path,
manifest_path,
report_data.report_status,
generation_mode,
report_data.included,
)
+773
View File
@@ -0,0 +1,773 @@
"""Deterministic data model for the formal gene-family report."""
from __future__ import annotations
from dataclasses import dataclass
import csv
import json
from pathlib import Path
import re
from typing import Any, Mapping, Sequence
@dataclass(frozen=True)
class MissingMetric:
"""A metric that is genuinely absent from every declared source."""
label: str
checked_sources: tuple[str, ...]
def render(self) -> str:
checked = "、".join(self.checked_sources)
return f"{self.label}:源结果未提供此指标(已检查:{checked})。"
@dataclass(frozen=True)
class Sentence:
"""One immutable report sentence decided by deterministic code."""
id: str
text: str
kind: str
evidence_ids: tuple[str, ...] = ()
@dataclass(frozen=True)
class ReportSection:
section_id: str
stage: str | None
facts: tuple[Sentence, ...] = ()
observations: tuple[Sentence, ...] = ()
interpretations: tuple[Sentence, ...] = ()
limitations: tuple[Sentence, ...] = ()
recommendations: tuple[Sentence, ...] = ()
@property
def sentences(self) -> tuple[Sentence, ...]:
return (
self.facts
+ self.observations
+ self.interpretations
+ self.limitations
+ self.recommendations
)
@dataclass(frozen=True)
class ReportData:
prefix: str
planned: tuple[str, ...]
statuses: Mapping[str, str]
included: tuple[str, ...]
unsupported_stages: tuple[str, ...]
report_status: str
sections: tuple[ReportSection, ...]
STAGE_SECTION_IDS = {
"identify": "stage.identify",
"phylogeny": "stage.phylogeny",
"reference_classification": "stage.reference_classification",
"motif": "stage.motif",
"domain": "stage.domain",
"structure": "stage.structure",
"combo": "stage.combo",
"promoter": "stage.promoter",
"tfbs": "stage.tfbs",
"synteny": "stage.synteny",
"karyotype": "stage.karyotype",
"interspecies_synteny": "stage.interspecies_synteny",
"pep": "stage.pep",
}
_REQUIRED_METRICS = {
"identify": (("member_count", "最终成员数"),),
"phylogeny": (
("sequences_count", "序列数"),
("msa_length", "多序列比对长度"),
),
"motif": (
("motif_count", "Motif 类别数"),
("total_sites", "Motif 总位点数"),
("motif_width_range", "Motif 宽度范围"),
),
"pep": (
("protein_count", "蛋白数"),
("tasks_success", "成功子任务数"),
("tasks_total", "子任务总数"),
),
}
_ARTIFACT_METRIC_ROLES = {
("identify", "member_count"): ("identified_members", "identified_members_id", "member_ids"),
("pep", "protein_count"): ("pep_properties", "properties_table"),
}
def _manifest_source(manifest: Mapping[str, Any], field: str) -> str:
path = str(manifest.get("_manifest_path") or "stage manifest")
return f"{Path(path).name}#metrics.{field}"
def _artifact_path(final_dir: Path, artifact: Any) -> Path | None:
if not isinstance(artifact, Mapping):
return None
raw = artifact.get("full_path") or artifact.get("path")
if not raw:
return None
path = Path(str(raw))
if path.is_file():
return path
candidates = [final_dir / path.name]
if path.is_absolute():
job_index = next((index for index, part in enumerate(path.parts) if part.startswith("job_")), None)
if job_index is not None:
candidates.append(final_dir.parent.joinpath(*path.parts[job_index + 1 :]))
else:
candidates.append(final_dir.parent / path)
return next((candidate for candidate in candidates if candidate.is_file()), None)
def _artifact_by_suffix(final_dir: Path, manifest: Mapping[str, Any], suffix: str) -> Path | None:
artifacts = manifest.get("artifacts") if isinstance(manifest.get("artifacts"), Mapping) else {}
for role in sorted(artifacts):
if str(role).endswith(suffix):
path = _artifact_path(final_dir, artifacts[role])
if path is not None:
return path
return None
def _count_named_records(path: Path) -> int:
suffix = path.suffix.lower()
try:
if suffix in {".fa", ".faa", ".fasta", ".pep"}:
return sum(1 for line in path.read_text(encoding="utf-8").splitlines() if line.startswith(">"))
if suffix in {".tsv", ".csv"}:
delimiter = "\t" if suffix == ".tsv" else ","
with path.open("r", encoding="utf-8", newline="") as handle:
return sum(1 for _ in csv.DictReader(handle, delimiter=delimiter))
if suffix == ".json":
value = json.loads(path.read_text(encoding="utf-8"))
if isinstance(value, list):
return len(value)
if isinstance(value, Mapping):
for key in ("members", "proteins", "records"):
if isinstance(value.get(key), list):
return len(value[key])
raise ValueError("JSON does not contain a declared record list")
return sum(1 for line in path.read_text(encoding="utf-8").splitlines() if line.strip())
except (OSError, UnicodeError, csv.Error, json.JSONDecodeError, ValueError) as exc:
raise ValueError(f"无法解析声明的报告数据源 {path}: {exc}") from exc
def _metric(
final_dir: Path,
stage: str,
manifest: Mapping[str, Any],
field: str,
label: str,
) -> tuple[Any | MissingMetric, tuple[str, ...]]:
metrics = manifest.get("metrics") if isinstance(manifest.get("metrics"), Mapping) else {}
manifest_source = _manifest_source(manifest, field)
if stage == "identify" and field == "member_count":
for alias in ("member_count", "identified_members_count", "final_members"):
if metrics.get(alias) is not None:
source = _manifest_source(manifest, alias)
return metrics[alias], (source,)
elif field == "motif_width_range":
motifs = metrics.get("motifs")
if isinstance(motifs, Sequence) and not isinstance(motifs, (str, bytes)):
widths = [item.get("width") for item in motifs if isinstance(item, Mapping) and item.get("width") is not None]
if widths:
try:
numeric = [int(value) for value in widths]
except (TypeError, ValueError) as exc:
raise ValueError(f"无法解析 {manifest_source.replace(field, 'motifs[].width')}") from exc
source = _manifest_source(manifest, "motifs[].width")
return (min(numeric), max(numeric)), (source,)
elif field in metrics and metrics[field] is not None:
return metrics[field], (manifest_source,)
checked = [manifest_source]
artifacts = manifest.get("artifacts") if isinstance(manifest.get("artifacts"), Mapping) else {}
for role in _ARTIFACT_METRIC_ROLES.get((stage, field), ()):
checked.append(f"artifact:{role}")
path = _artifact_path(final_dir, artifacts.get(role))
if path is not None:
return _count_named_records(path), (str(path),)
return MissingMetric(label, tuple(checked)), tuple(checked)
def _gene_aliases(gene_id: str) -> tuple[str, ...]:
value = str(gene_id).strip()
aliases = {value, re.sub(r"\.T\d+$", "", value), re.sub(r"\.\d+$", "", value)}
return tuple(alias for alias in aliases if alias)
def _parse_interspecies_artifacts(
final_dir: Path,
manifest: Mapping[str, Any],
) -> tuple[int, int, tuple[str, ...]] | None:
artifacts = manifest.get("artifacts") if isinstance(manifest.get("artifacts"), Mapping) else {}
mapping_path = _artifact_path(final_dir, artifacts.get("gene_to_species"))
collinearity_path = _artifact_by_suffix(final_dir, manifest, "_collinearity")
if mapping_path is None or collinearity_path is None:
return None
groups: dict[str, str] = {}
try:
with mapping_path.open("r", encoding="utf-8", newline="") as handle:
for row in csv.DictReader(handle, delimiter="\t"):
gene_id = str(row.get("gene_id") or "").strip()
group = str(row.get("group") or "").strip().lower()
if gene_id and group in {"target", "ref"}:
for alias in _gene_aliases(gene_id):
groups[alias] = group
alignment_blocks = 0
target_ref_pairs = 0
with collinearity_path.open("r", encoding="utf-8") as handle:
for line in handle:
if line.startswith("## Alignment"):
alignment_blocks += 1
continue
match = re.search(r":\s*(\S+)\s+(\S+)", line) if line.startswith(" ") else None
if not match:
continue
pair_groups = {
groups[alias]
for gene_id in match.groups()
for alias in _gene_aliases(gene_id)
if alias in groups
}
if pair_groups == {"target", "ref"}:
target_ref_pairs += 1
except (OSError, UnicodeError, csv.Error) as exc:
raise ValueError(f"无法解析种间共线性命名产物:{mapping_path};{collinearity_path}: {exc}") from exc
return alignment_blocks, target_ref_pairs, (str(collinearity_path), str(mapping_path))
def _sentence(sentence_id: str, text: str, kind: str, *evidence_ids: str) -> Sentence:
return Sentence(sentence_id, text, kind, tuple(evidence_ids))
def _metrics(manifest: Mapping[str, Any]) -> Mapping[str, Any]:
value = manifest.get("metrics")
return value if isinstance(value, Mapping) else {}
def _artifacts(manifest: Mapping[str, Any]) -> Mapping[str, Any]:
value = manifest.get("artifacts")
return value if isinstance(value, Mapping) else {}
def _artifact_for_roles(
final_dir: Path,
manifest: Mapping[str, Any],
*roles: str,
) -> tuple[Path | None, str | None]:
artifacts = _artifacts(manifest)
for role in roles:
path = _artifact_path(final_dir, artifacts.get(role))
if path is not None:
return path, role
for role, artifact in artifacts.items():
if any(token in str(role) for token in roles):
path = _artifact_path(final_dir, artifact)
if path is not None:
return path, str(role)
return None, None
def _relative_artifact(final_dir: Path, manifest: Mapping[str, Any], *roles: str) -> tuple[str, str] | None:
artifacts = _artifacts(manifest)
for role in roles:
artifact = artifacts.get(role)
path = _artifact_path(final_dir, artifact)
if path is not None:
try:
relative = path.relative_to(final_dir.parent)
except ValueError:
relative = Path("final") / path.name
return relative.as_posix(), f"artifact:{role}"
if isinstance(artifact, Mapping):
raw = artifact.get("path")
if raw and not Path(str(raw)).is_absolute() and ".." not in Path(str(raw)).parts:
return Path(str(raw)).as_posix(), f"artifact:{role}"
return None
def _tabular_rows(path: Path) -> list[dict[str, str]]:
try:
lines = [
line
for line in path.read_text(encoding="utf-8").splitlines()
if line.strip() and not line.lstrip().startswith("#")
]
if not lines:
return []
return [dict(row) for row in csv.DictReader(lines, delimiter="\t")]
except (OSError, UnicodeError, csv.Error) as exc:
raise ValueError(f"无法解析命名表格 {path}: {exc}") from exc
def _element_counts(path: Path) -> list[tuple[str, int]]:
result: list[tuple[str, int]] = []
try:
for line in path.read_text(encoding="utf-8").splitlines():
parts = line.split("\t", 1)
if len(parts) != 2:
continue
try:
count = int(parts[0])
except ValueError:
continue
result.append((parts[1].strip(), count))
except (OSError, UnicodeError) as exc:
raise ValueError(f"无法解析启动子元件计数 {path}: {exc}") from exc
return sorted(result, key=lambda item: (-item[1], item[0]))
def _key_artifact_sentence(
final_dir: Path,
manifest: Mapping[str, Any],
section_id: str,
label: str,
*roles: str,
) -> Sentence | None:
artifact = _relative_artifact(final_dir, manifest, *roles)
if artifact is None:
return None
path, evidence = artifact
return _sentence(f"{section_id}.artifact.{roles[0]}", f"关键产物:{label} `{path}`。", "fact", evidence)
def _stage_section(
final_dir: Path,
prefix: str,
stage: str,
status: str,
manifest: Mapping[str, Any],
) -> ReportSection:
section_id = STAGE_SECTION_IDS[stage]
facts: list[Sentence] = []
observations: list[Sentence] = []
interpretations: list[Sentence] = []
limitations: list[Sentence] = []
recommendations: list[Sentence] = []
if status != "success" or str(manifest.get("status") or "") != "success":
facts.append(_sentence(f"{section_id}.status", f"阶段状态:`{status}`。", "fact", _manifest_source(manifest, "status")))
limitations.append(
_sentence(
f"{section_id}.not_completed",
"该阶段未成功完成,因此本报告不生成结果推断。",
"limitation",
)
)
return ReportSection(section_id, stage, tuple(facts), limitations=tuple(limitations))
metrics = _metrics(manifest)
if stage == "identify":
member_count, sources = _metric(final_dir, stage, manifest, "member_count", "最终成员数")
family = prefix.split(".", 1)[1] if "." in prefix else prefix
facts.append(_sentence(f"{section_id}.object", "分析对象:目标物种完整蛋白集中的候选成员。", "fact"))
if isinstance(member_count, MissingMetric):
facts.append(_sentence(f"{section_id}.missing.member_count", member_count.render(), "fact", *sources))
else:
hmm = metrics.get("hmm_members")
blast = metrics.get("blast_members")
search = f";HMM 与 BLAST 各支持 {hmm} 和 {blast} 个候选" if hmm is not None and blast is not None else ""
observations.append(_sentence(
f"{section_id}.observation.members",
f"目标物种最终成员数:{member_count}。本阶段形成 {member_count} 个 {family}家族成员{search}。",
"observation", *sources,
))
interpretations.append(_sentence(f"{section_id}.interpretation", "解释:最终名单是后续阶段的目标物种成员集合;搜索方法的一致数量支持集合稳定,但不等同于实验功能确认。", "interpretation"))
limitations.append(_sentence(f"{section_id}.limitation", "限制:成员边界仍受蛋白注释、搜索阈值和转录本选择规则影响。", "limitation"))
artifact = _key_artifact_sentence(final_dir, manifest, section_id, "目标成员 FASTA", "identified_members", "target_Ft_identified_members_pep")
elif stage == "phylogeny":
sequences = metrics.get("sequences_count")
msa_length = metrics.get("msa_length")
species = metrics.get("scope_species_counts") if isinstance(metrics.get("scope_species_counts"), Mapping) else {}
object_text = (
f"分析对象:目标物种 {species.get('Ft')} 条与参考物种 {species.get('At')} 条蛋白合并的 {sequences} 条序列。"
if species
else f"分析对象:目标与参考成员合并集合中的 {sequences} 条蛋白序列。"
)
facts.append(_sentence(f"{section_id}.object", object_text, "fact", _manifest_source(manifest, "scope_species_counts")))
observations.append(_sentence(f"{section_id}.observation", f"序列数:{sequences};多序列比对长度:{msa_length}。目标与参考成员共 {sequences} 条序列进入比对,本阶段使用 {sequences} 条序列,比对长度为 {msa_length}。", "observation", _manifest_source(manifest, "sequences_count"), _manifest_source(manifest, "msa_length")))
interpretations.append(_sentence(f"{section_id}.interpretation", "解释:该树用于比较序列关系并辅助分组;它分析的是目标加参考集合,不应与 24 个目标成员的鉴定集合混称。", "interpretation"))
limitations.append(_sentence(f"{section_id}.limitation", "限制:源 Manifest 未汇总各主要分支的支持值,不能据此报告小组稳定性等级。", "limitation"))
artifact = _key_artifact_sentence(final_dir, manifest, section_id, "关系树", "tree")
elif stage == "reference_classification":
target_count = int(metrics.get("target_gene_count") or 0)
all_rows = int(metrics.get("subfamily_rows") or 0)
assignment_path, assignment_role = _artifact_for_roles(final_dir, manifest, "assignment_table")
rows = _tabular_rows(assignment_path) if assignment_path else []
counts: dict[str, int] = {}
subfamilies: set[str] = set()
for row in rows:
confidence = str(row.get("confidence") or "").lower()
if confidence:
counts[confidence] = counts.get(confidence, 0) + 1
label = str(row.get("assigned_subfamily") or "").strip()
if label:
subfamilies.add(label)
facts.append(_sentence(f"{section_id}.object", f"分析对象:{target_count} 个目标成员与 {max(0, all_rows - target_count)} 个参考成员的联合分类。", "fact"))
if rows:
observations.append(_sentence(f"{section_id}.observation", f"分类结果:{len(rows)} 个目标成员分入 {len(subfamilies)} 个小组;高信心 {counts.get('high', 0)} 个、中信心 {counts.get('medium', 0)} 个、低信心 {counts.get('low', 0)} 个。", "observation", f"artifact:{assignment_role}"))
else:
missing = MissingMetric("分类信心分布", ("artifact:assignment_table",))
facts.append(_sentence(f"{section_id}.missing", missing.render(), "fact", *missing.checked_sources))
interpretations.append(_sentence(f"{section_id}.interpretation", "解释:参考成员为目标成员提供小组标签,其中低信心标签只能作为后续分组线索。", "interpretation"))
limitations.append(_sentence(f"{section_id}.limitation", "限制:分类依赖参考成员覆盖和树拓扑;低信心成员不宜用于强分组结论。", "limitation"))
artifact = _key_artifact_sentence(final_dir, manifest, section_id, "分类明细", "assignment_table")
elif stage == "motif":
motifs = metrics.get("motifs") if isinstance(metrics.get("motifs"), Sequence) else []
widths = [int(item["width"]) for item in motifs if isinstance(item, Mapping) and item.get("width") is not None]
count = metrics.get("motif_count")
total_sites = metrics.get("total_sites")
sequence_count = metrics.get("sequence_count") or metrics.get("protein_record_count")
if sequence_count is None:
facts.append(_sentence(f"{section_id}.object", "分析对象:当前 Motif Manifest 声明的蛋白集合。", "fact"))
missing = MissingMetric("Motif 输入序列数", (_manifest_source(manifest, "sequence_count"),))
facts.append(_sentence(f"{section_id}.missing.sequence_count", missing.render(), "fact", *missing.checked_sources))
else:
facts.append(_sentence(f"{section_id}.object", f"分析对象:目标与参考成员合并集合中的 {sequence_count} 条蛋白序列。", "fact"))
if widths:
observations.append(_sentence(f"{section_id}.observation", f"共识别 {count} 个 motif,总位点数为 {total_sites},宽度范围为 {min(widths)}–{max(widths)}。", "observation", _manifest_source(manifest, "motifs")))
else:
missing = MissingMetric("Motif 宽度范围", (_manifest_source(manifest, "motifs[].width"),))
facts.append(_sentence(f"{section_id}.missing", missing.render(), "fact", *missing.checked_sources))
interpretations.append(_sentence(f"{section_id}.interpretation", "解释:这些统计描述短片段的类别、总命中与长度范围;总位点数不能替代逐成员覆盖率。", "interpretation"))
limitations.append(_sentence(f"{section_id}.limitation", "限制:源结果未汇总同组排列一致性或异常成员,不能从总数推断特定小组功能分化。", "limitation"))
artifact = _key_artifact_sentence(final_dir, manifest, section_id, "Motif 排列图", "motif_plot_png")
elif stage == "domain":
total = metrics.get("proteins_total")
hit = metrics.get("proteins_with_domains")
top_domains = metrics.get("top_domains") if isinstance(metrics.get("top_domains"), Sequence) else []
top_text = "、".join(f"{item[0]} {item[1]} 次" for item in top_domains if isinstance(item, Sequence) and len(item) >= 2)
facts.append(_sentence(f"{section_id}.object", f"分析对象:目标与参考成员合并集合中的 {total} 条蛋白。", "fact"))
observations.append(_sentence(f"{section_id}.observation", f"Domain 覆盖为 {hit}/{total} 条蛋白;主要命中为 {top_text}。", "observation", _manifest_source(manifest, "proteins_with_domains"), _manifest_source(manifest, "top_domains")))
interpretations.append(_sentence(f"{section_id}.interpretation", "解释:全部蛋白均有 Domain 命中,SBP 的普遍命中支持该合并集合保留家族典型区域;Ig_SPL7 只出现在部分命中记录中。", "interpretation"))
limitations.append(_sentence(f"{section_id}.limitation", "限制:命中次数不等于独立蛋白数,Ig_SPL7 的 12 次命中不能直接解释为 12 个功能不同成员。", "limitation"))
artifact = _key_artifact_sentence(final_dir, manifest, section_id, "Domain 图", "domain_plot_png")
elif stage == "structure":
total = metrics.get("genes_total")
covered = metrics.get("genes_with_structure")
outliers = metrics.get("long_gene_outliers") if isinstance(metrics.get("long_gene_outliers"), Sequence) else []
outlier = next((item for item in outliers if isinstance(item, Mapping)), None)
facts.append(_sentence(f"{section_id}.object", f"分析对象:目标与参考合并集合的 {total} 个成员;结构注释实际匹配 {covered} 个目标成员。", "fact"))
detail = f";长基因异常 1 个,为 {outlier.get('gene_id')}({outlier.get('length_bp')} bp,{outlier.get('cds_count')} 个 CDS)" if outlier else ""
observations.append(_sentence(f"{section_id}.observation", f"结构覆盖为 {covered}/{total}{detail}。", "observation", _manifest_source(manifest, "genes_with_structure"), _manifest_source(manifest, "long_gene_outliers")))
interpretations.append(_sentence(f"{section_id}.interpretation", "解释:24/40 的覆盖差异主要反映参考成员未纳入当前目标物种 GFF3;超长基因需要回到原始注释核对。", "interpretation"))
limitations.append(_sentence(f"{section_id}.limitation", "限制:未匹配的参考成员不能作为目标物种结构缺失;单个长基因也不能单独证明功能变化。", "limitation"))
artifact = _key_artifact_sentence(final_dir, manifest, section_id, "结构明细", "structure_tsv")
elif stage == "combo":
parameters = manifest.get("parameters") if isinstance(manifest.get("parameters"), Mapping) else {}
panels = [str(value).capitalize() for value in parameters.get("panels", [])]
panel_text = "、".join(panels) if panels else "Motif、Domain、Structure"
facts.append(_sentence(f"{section_id}.object", "分析对象:目标与参考成员合并集合的关系树及其多类结构证据。", "fact"))
observations.append(_sentence(f"{section_id}.observation", f"组合图已将关系树与 {panel_text} 三类面板汇总到同一图中。", "observation", _manifest_source(manifest, "parameters.panels")))
facts.append(_sentence(f"{section_id}.missing", "组合证据一致性数值:源结果未提供此指标(已检查:Combo Manifest、combo_plot_png、combo_plot_pdf)。", "fact"))
interpretations.append(_sentence(f"{section_id}.interpretation", "解释:该图适合逐行核对多类证据,但当前命名源没有可机器追溯的一致性评分。", "interpretation"))
limitations.append(_sentence(f"{section_id}.limitation", "限制:没有逐成员一致性表时,不指定稳定小组、异常成员或候选基因。", "limitation"))
artifact = _key_artifact_sentence(final_dir, manifest, section_id, "组合图", "combo_plot_png")
elif stage == "promoter":
genes = metrics.get("genes_with_elements")
total = metrics.get("total_elements")
unique = metrics.get("unique_elements")
counts_path, counts_role = _artifact_for_roles(final_dir, manifest, "element_counts")
counts = _element_counts(counts_path) if counts_path else []
category_path, category_role = _artifact_for_roles(final_dir, manifest, "position_distribution_summary", "promoter_category_summary")
categories = _tabular_rows(category_path) if category_path else []
enrichment_path, enrichment_role = _artifact_for_roles(final_dir, manifest, "enrichment_analysis")
enrichment_rows = _tabular_rows(enrichment_path) if enrichment_path else []
co_path, co_role = _artifact_for_roles(final_dir, manifest, "cooccurrence_analysis")
co_rows = _tabular_rows(co_path) if co_path else []
complexity_path, complexity_role = _artifact_for_roles(final_dir, manifest, "complexity_analysis")
complexity_rows = _tabular_rows(complexity_path) if complexity_path else []
facts.append(_sentence(f"{section_id}.object", f"分析对象:检测到顺式元件的 {genes} 个目标基因启动子。", "fact"))
top_elements = "、".join(f"{name} {count} 个" for name, count in counts[:3]) or "源结果未提供主要元件排行"
top_category = categories[0] if categories else {}
category_name = top_category.get("big7") or top_category.get("Category")
category_count = top_category.get("element_instances") or top_category.get("Count")
category_text = ""
if category_name and category_count and total:
category_text = f";主要类别为 {category_name} {category_count} 个({int(category_count) / int(total) * 100:.1f}%)"
enrichment_top = enrichment_rows[0] if enrichment_rows else {}
enrichment_text = ""
if enrichment_top:
enrichment_text = f";最高列示富集比为小组 {enrichment_top.get('Subfamily')} 的 {enrichment_top.get('Element')}({enrichment_top.get('EnrichmentRatio')})"
co_top = co_rows[0] if co_rows else {}
co_text = ""
if co_top:
co_text = f";最常见共现为 {co_top.get('Element1')}—{co_top.get('Element2')}({co_top.get('Co-occurrenceCount')} 个基因)"
complex_top = complexity_rows[0] if complexity_rows else {}
complex_text = ""
if complex_top:
complex_text = f";复杂度最高的候选为 {complex_top.get('Gene')}(得分 {complex_top.get('ComplexityScore')})"
observations.append(_sentence(f"{section_id}.observation", f"{genes} 个目标基因共记录 {total} 个元件、{unique} 种类型;主要元件为 {top_elements}{category_text}{enrichment_text}{co_text}{complex_text}。", "observation", *(f"artifact:{role}" for role in (counts_role, category_role, enrichment_role, co_role, complexity_role) if role)))
interpretations.append(_sentence(f"{section_id}.interpretation", "解释:类别比例、共现和复杂度描述的是上游序列预测模式;富集比可用于定位值得复核的小组—元件组合。", "interpretation"))
limitations.append(_sentence(f"{section_id}.limitation", "限制:仅 23 个目标基因有元件记录,且序列片段出现、共现或高复杂度都不能证明真实调控。", "limitation"))
artifact = _key_artifact_sentence(final_dir, manifest, section_id, "启动子汇总图", "viz_Ft.SPL_promoter_v6_combined", "promoter_v6_combined")
elif stage == "tfbs":
promoters = metrics.get("family_promoter_count")
sites = metrics.get("family_site_count")
motif_count = metrics.get("family_motif_count")
background = metrics.get("background_promoter_count")
summary_path, summary_role = _artifact_for_roles(final_dir, manifest, "motif_summary")
rows = _tabular_rows(summary_path) if summary_path else []
rows.sort(key=lambda row: int(float(row.get("family_sites") or 0)), reverse=True)
top = rows[0] if rows else {}
facts.append(_sentence(f"{section_id}.object", f"分析对象:{promoters} 条目标成员启动子;Motif 模型来自 {metrics.get('motif_database', '声明数据库')}。", "fact"))
top_text = f";最高频 Motif {top.get('motif_id')} 有 {top.get('family_sites')} 个位点,覆盖 {top.get('family_genes_with_site')} 个基因" if top else ""
observations.append(_sentence(f"{section_id}.observation", f"{promoters} 条目标启动子共得到 {sites} 个预测位点、{motif_count} 个有命中的 Motif{top_text}。", "observation", _manifest_source(manifest, "family_site_count"), *(tuple([f"artifact:{summary_role}"]) if summary_role else ())))
if int(background or 0) == 0:
facts.append(_sentence(f"{section_id}.no_background", "背景启动子为 0,不能据此进行家族相对背景的富集判断。", "fact", _manifest_source(manifest, "background_promoter_count")))
interpretations.append(_sentence(f"{section_id}.interpretation", "解释:高频位点表示序列扫描中出现较多,只能作为潜在结合线索,不能写成转录因子已调控目标基因。", "interpretation"))
limitations.append(_sentence(f"{section_id}.limitation", "限制:Motif 使用最近分类物种投射且缺少背景集合,统计富集和物种特异调控均无法确认。", "limitation"))
artifact = _key_artifact_sentence(final_dir, manifest, section_id, "Motif 汇总表", "motif_summary")
elif stage == "synteny":
facts.append(_sentence(f"{section_id}.object", f"分析对象:目标物种全基因组 {metrics.get('gene_count')} 个基因,而非仅家族成员。", "fact"))
observations.append(_sentence(f"{section_id}.observation", f"全基因组结果记录 {metrics.get('synteny_blocks')} 个共线性区块、{metrics.get('gene_pairs')} 个基因对,并绘制 {metrics.get('circos_chromosomes_plotted')} 条染色体。", "observation", _manifest_source(manifest, "synteny_blocks"), _manifest_source(manifest, "gene_pairs")))
facts.append(_sentence(f"{section_id}.missing", "源结果未提供家族专属复制基因对(已检查:Synteny Manifest、collinearity artifact、dup_class_log artifact)。", "fact"))
interpretations.append(_sentence(f"{section_id}.interpretation", "解释:这些数字刻画全基因组共线性背景,不能直接归因于 SPL 家族扩张。", "interpretation"))
limitations.append(_sentence(f"{section_id}.limitation", "限制:缺少家族成员专属 links 或复制类型表,本节不判断大片段复制或串联复制孰为家族主要来源。", "limitation"))
artifact = _key_artifact_sentence(final_dir, manifest, section_id, "Circos 图", "circos_png")
elif stage == "karyotype":
species = metrics.get("species") if isinstance(metrics.get("species"), Mapping) else {}
item = next(iter(species.values()), {}) if species else metrics
genes = item.get("gene_count") if isinstance(item, Mapping) else None
chromosomes = item.get("chromosome_count") if isinstance(item, Mapping) else None
facts.append(_sentence(f"{section_id}.object", f"分析对象:目标物种的 {genes} 个家族成员。", "fact"))
observations.append(_sentence(f"{section_id}.observation", f"核型分析将{genes}个家族成员定位到{chromosomes}条染色体。", "observation", _manifest_source(manifest, "species.*.gene_count"), _manifest_source(manifest, "species.*.chromosome_count")))
interpretations.append(_sentence(f"{section_id}.interpretation", "解释:染色体位置图确认了目标成员的基因组分布范围,可与家族专属复制证据联合阅读。", "interpretation"))
limitations.append(_sentence(f"{section_id}.limitation", "限制:Manifest 未汇总每条染色体的成员数,不能仅凭 24/8 判断分布均匀性或成簇关系。", "limitation"))
artifact = _key_artifact_sentence(final_dir, manifest, section_id, "核型图", "Ft_karyotype_png")
elif stage == "interspecies_synteny":
parsed = _parse_interspecies_artifacts(final_dir, manifest)
target = metrics.get("target_family_count")
reference = metrics.get("ref_family_count")
facts.append(_sentence(f"{section_id}.object", f"分析对象:目标物种 {target} 个与参考物种 {reference} 个家族成员,并结合两物种全基因组共线性。", "fact"))
if parsed is None:
missing = MissingMetric(
"种间共线性区块与家族成员配对",
("artifact:gene_to_species", "artifact:*_collinearity"),
)
facts.append(
_sentence(
f"{section_id}.missing.collinearity_summary",
missing.render(),
"fact",
*missing.checked_sources,
)
)
else:
alignment_blocks, target_ref_pairs, sources = parsed
observations.append(
_sentence(
f"{section_id}.observation.summary",
f"共线性区块数:{alignment_blocks}(全基因组 block {alignment_blocks} 个);"
f"家族成员目标—参考配对数:{target_ref_pairs}(目标-参考家族 {target_ref_pairs} 对)。",
"observation",
*sources,
)
)
interpretations.append(_sentence(f"{section_id}.interpretation", "解释:全基因组区块提供跨物种背景,2 个目标—参考家族配对是当前命名产物中可直接追溯的家族关系。", "interpretation"))
limitations.append(_sentence(f"{section_id}.limitation", "限制:共线性配对只能支持保留关系线索,不能直接移植参考物种功能或断言一一对应。", "limitation"))
artifact = _key_artifact_sentence(final_dir, manifest, section_id, "标记版种间共线性图", "synteny_marked_png")
elif stage == "pep":
protein_count = metrics.get("protein_count")
success = metrics.get("tasks_success")
total_tasks = metrics.get("tasks_total")
counts = metrics.get("subcellular_counts") if isinstance(metrics.get("subcellular_counts"), Mapping) else {}
facts.append(_sentence(f"{section_id}.object", f"分析对象:目标与参考成员合并集合中的 {protein_count} 条蛋白。", "fact"))
localization = ""
if counts:
localization = f";亚细胞定位预测为 Nucleus {counts.get('Nucleus', 0)}、Cytoplasm {counts.get('Cytoplasm', 0)}、Chloroplast {counts.get('Chloroplast', 0)}"
else:
missing = MissingMetric("亚细胞定位分类计数", (_manifest_source(manifest, "subcellular_counts"),))
facts.append(_sentence(f"{section_id}.missing.localization", missing.render(), "fact", *missing.checked_sources))
observations.append(_sentence(f"{section_id}.observation", f"蛋白数:{protein_count};子任务成功数:{success}/{total_tasks}。本阶段覆盖 {protein_count} 个蛋白,子任务成功数为 {success}/{total_tasks}{localization}。", "observation", _manifest_source(manifest, "protein_count"), _manifest_source(manifest, "subcellular_counts")))
interpretations.append(_sentence(f"{section_id}.interpretation", "解释:核定位预测占多数,与转录因子研究方向相容,但这里只能作为定位线索。", "interpretation"))
limitations.append(_sentence(f"{section_id}.limitation", "限制:理化性质、跨膜与亚细胞定位均为计算预测;当前汇总不能替代逐蛋白实验验证。", "limitation"))
artifact = _key_artifact_sentence(final_dir, manifest, section_id, "亚细胞定位表", "subcellular_data")
else:
artifact = None
if artifact is not None:
facts.append(artifact)
return ReportSection(
section_id,
stage,
tuple(facts),
tuple(observations),
tuple(interpretations),
tuple(limitations),
tuple(recommendations),
)
def build_report_data(
final_dir: Path | str,
prefix: str,
planned: Sequence[str],
statuses: Mapping[str, str],
manifests: Mapping[str, Mapping[str, Any]],
) -> ReportData:
"""Build the only deterministic data source used by both render modes."""
final_path = Path(final_dir)
unsupported_stages = tuple(stage for stage in planned if stage not in STAGE_SECTION_IDS)
included = tuple(
stage
for stage in planned
if stage in STAGE_SECTION_IDS
and statuses.get(stage) == "success"
and str((manifests.get(stage) or {}).get("status") or "") == "success"
)
report_status = "partial" if len(included) != len(planned) else "final"
success_count = len(included)
failed_count = len(planned) - success_count
stage_sections = [
_stage_section(final_path, prefix, stage, str(statuses.get(stage) or "missing"), manifests.get(stage) or {})
for stage in planned
if stage in STAGE_SECTION_IDS
]
stage_sections_by_name = {section.stage: section for section in stage_sections}
summary_texts = [
f"本次按用户计划检查 {len(planned)} 个阶段,其中 {success_count} 个成功结果纳入正文。",
f"报告状态为{'最终报告' if report_status == 'final' else '部分报告'},未纳入阶段数为 {failed_count}。",
]
for stage in (
"identify", "phylogeny", "reference_classification", "domain", "structure",
"promoter", "karyotype", "pep",
):
section = stage_sections_by_name.get(stage)
if section and section.observations and len(summary_texts) < 9:
summary_texts.append(section.observations[0].text.replace("。", ";").rstrip(";") + "。")
for stage in ("identify", "phylogeny"):
section = stage_sections_by_name.get(stage)
if section:
for sentence in section.facts:
if sentence.text.startswith("分析对象:") and len(summary_texts) < 5:
summary_texts.append(sentence.text)
while len(summary_texts) < 5:
summary_texts.append("正文只纳入计划内成功阶段,并在各节分别声明实际分析对象。")
summary_texts.append("本摘要只组合下文可追溯的阶段事实,不据此指定候选基因或推断功能因果。")
summary_facts = tuple(
_sentence(f"common.front.summary.{index}", text, "fact")
for index, text in enumerate(summary_texts[:10], start=1)
)
completion_facts = [
_sentence(
"common.completeness.counts",
f"本次计划 {len(planned)} 个阶段,纳入解读 {success_count} 个,未纳入 {failed_count} 个。",
"fact",
)
]
for stage in planned:
supported = stage in STAGE_SECTION_IDS
completion_facts.append(
_sentence(
f"common.completeness.stage.{stage}",
f"计划阶段 `{stage}`:源状态 `{statuses.get(stage, 'missing')}`;"
f"模板支持:{'是' if supported else '不支持'};"
f"纳入解读:{'是' if stage in included else '否'}。",
"fact",
)
)
sections: list[ReportSection] = [
ReportSection(
"common.front",
None,
facts=summary_facts,
),
ReportSection(
"common.route",
None,
facts=(
_sentence("common.route.stages", f"本报告按计划阶段顺序解读:{'、'.join(planned)}。", "fact"),
),
),
ReportSection(
"common.completeness",
None,
facts=tuple(completion_facts),
),
]
sections.extend(stage_sections)
integrated_observations = [
section.observations[0]
for section in stage_sections
if section.observations
][:3]
sections.extend(
[
ReportSection(
"common.integrated",
None,
facts=tuple(
_sentence(
f"common.integrated.fact.{index}",
sentence.text,
"fact",
*sentence.evidence_ids,
)
for index, sentence in enumerate(integrated_observations, start=1)
),
interpretations=(
_sentence(
"common.integrated.boundary",
"综合结论只并列上述已提取事实;当前源结果不足以无偏地指定候选基因,也不据此建立功能因果。",
"interpretation",
),
),
),
ReportSection(
"common.limitations",
None,
limitations=(
_sentence(
"common.limitations.general",
"基因组与注释质量、分析阈值和计算预测边界都会影响结果,缺少实验信息时不能确定具体生物学功能。",
"limitation",
),
),
),
ReportSection(
"common.recommendations",
None,
recommendations=(
_sentence(
"common.recommendations.general",
"优先核对异常成员的序列与注释,并用表达、定位或功能实验验证由至少两类独立证据支持的候选。",
"recommendation",
),
),
),
ReportSection(
"common.checklist",
None,
recommendations=(
_sentence(
"common.checklist.traceability",
"交付前应确认每项结论均能追溯到当前身份的 Manifest 或命名结果文件。",
"recommendation",
),
),
),
]
)
return ReportData(
prefix,
tuple(planned),
dict(statuses),
included,
unsupported_stages,
report_status,
tuple(sections),
)
+17 -7
View File
@@ -193,14 +193,24 @@ def _read_json(path: Path) -> Dict[str, Any]:
def _artifact_path(manifest: Dict[str, Any], artifact_key: str) -> Optional[Path]:
artifact = (manifest.get("artifacts") or {}).get(artifact_key) or {}
path_text = artifact.get("full_path") or artifact.get("path")
if path_text:
manifest_path = Path(str(manifest.get("_manifest_path") or ""))
job_dir = manifest_path.parent.parent if manifest_path else None
for path_text in (artifact.get("full_path"), artifact.get("path")):
if not path_text:
continue
path = Path(str(path_text))
if not path.is_absolute() and manifest.get("_manifest_path"):
job_dir = Path(str(manifest["_manifest_path"])).parent.parent
path = job_dir / path
if path.exists():
return path
candidates = [path]
if job_dir:
if not path.is_absolute():
candidates.append(job_dir / path)
else:
job_index = next((index for index, part in enumerate(path.parts) if part.startswith("job_")), None)
if job_index is not None:
candidates.append(job_dir.joinpath(*path.parts[job_index + 1 :]))
candidates.append(manifest_path.parent / path.name)
for candidate in candidates:
if candidate.exists():
return candidate
return None
+7 -1
View File
@@ -4,7 +4,7 @@
import re
from typing import Optional, Dict, Any, List
from gfa_core.mas.stage_catalog import complete_dependencies as catalog_complete_dependencies
from gfa_core.mas.utils.intent_parser import is_gwas_fmt_intent, parse_gene_family_scope_selection, resolve_gwas_intent, resolve_population_intent
from gfa_core.mas.utils.intent_parser import is_gwas_fmt_intent, parse_gene_family_scope_selection, resolve_gene_family_report_intent, resolve_gwas_intent, resolve_population_intent
# 分析类型关键词映射(注意:key必须与orchestrator.py中的analysis_type匹配)
ANALYSIS_KEYWORDS = {
@@ -96,6 +96,12 @@ def fast_intent_recognition(query: str) -> Optional[Dict[str, Any]]:
None: 无法识别
"""
query_lower = query.lower()
report_intent = resolve_gene_family_report_intent(query)
if report_intent:
return {
"stage": "gene_family_report",
**report_intent,
}
population = resolve_population_intent(query)
if population:
return {
+17
View File
@@ -812,6 +812,23 @@ CAPABILITIES: Dict[str, StageCapability] = {
repair_candidates=["manifest_recovery", "artifact_reresolve"],
rerun_policy="auto_after_manifest_recovery",
),
"gene_family_report": StageCapability(
stage="gene_family_report",
cli="apps/cli/run_gene_family_report.py",
env="test4py311",
category="report",
consumes=[],
provides=[
ArtifactProduct("gene_family_report", "markdown", "{prefix}.gene_family_report.md"),
ArtifactProduct("gene_family_report_evidence", "json", "{prefix}.gene_family_report.evidence.json"),
ArtifactProduct("gene_family_report_model", "json", "{prefix}.gene_family_report.model.json"),
ArtifactProduct("gene_family_report_manifest", "manifest", "{prefix}.gene_family_report.manifest.json"),
],
scope_modes=["target_only", "target_plus_reference", "all_selected"],
preflight_checks=["at_least_one_successful_planned_manifest"],
postflight_checks=["formal_report_four_files_exist"],
rerun_policy="open_reuses_fresh_generate_rebuilds",
),
}
+4
View File
@@ -78,6 +78,7 @@ PIPELINE_STAGES = {
"ppi",
"kaks",
"combo",
"gene_family_report",
"rnaseq_align",
"rnaseq_quant",
"rnaseq_sample_qc",
@@ -391,6 +392,7 @@ STAGE_ORDER = [
"rnaseq_fgsea",
"rnaseq_candidate_family",
"rnaseq_enrich",
"gene_family_report",
]
STAGE_INFO = {
@@ -441,6 +443,7 @@ STAGE_INFO = {
"msa": {"name": "多序列比对", "estimated_time": 10, "cpu": 4},
"enrichment": {"name": "富集分析", "estimated_time": 15, "cpu": 4},
"combo": {"name": "组合图", "estimated_time": 10, "cpu": 4},
"gene_family_report": {"name": "基因家族结果报告", "estimated_time": 2, "cpu": 1},
"genome_prepare": {"name": "基因组准备", "estimated_time": 10, "cpu": 4},
"ncbi_fetch_sequences": {"name": "NCBI序列下载", "estimated_time": 5, "cpu": 4},
"sequence_extract_by_id": {"name": "按ID提取序列", "estimated_time": 5, "cpu": 4},
@@ -529,6 +532,7 @@ EXECUTABLE_STAGES = {
"ppi",
"kaks",
"combo",
"gene_family_report",
"rnaseq_align",
"rnaseq_quant",
"rnaseq_sample_qc",
+22
View File
@@ -558,9 +558,15 @@ class StageToolExecutor:
tfbs_top_motifs=(extra_params or {}).get("tfbsTopMotifs", 30),
tfbs_scan_both_strands=bool((extra_params or {}).get("tfbsScanBothStrands", True)),
scope_file=(extra_params or {}).get("scopeFile", "") or (extra_params or {}).get("scope_file", ""),
scope_id=param_value("scopeId", "scope_id", default=""),
rnaseq_scope_id=param_value("rnaseqScopeId", "rnaseq_scope_id", default=""),
analysis_route=(extra_params or {}).get("analysisRoute", "") or (extra_params or {}).get("analysis_route", ""),
member_set_id=(extra_params or {}).get("memberSetId", "") or (extra_params or {}).get("member_set_id", ""),
analysis_mode=(extra_params or {}).get("analysisMode", "") or (extra_params or {}).get("analysis_mode", ""),
expected_stages=param_value("expectedStages", "expected_stages", default=[]),
stage_statuses=param_value("stageStatuses", "stage_statuses", default={}),
report_action=param_value("reportAction", "report_action", default="generate"),
report_use_model=bool_param("reportUseModel", "report_use_model", default=True),
species_queue=(extra_params or {}).get("speciesQueue", []) or (extra_params or {}).get("species_queue", []),
target_species=target_species,
interspecies_ref_species=interspecies_ref_species,
@@ -1047,6 +1053,22 @@ class StageToolRegistry:
"parameters": {"scope_aware": True},
"timeout": 1800
},
{
"id": "gene_family_report",
"name": "Gene Family Result Report",
"script": "run_gene_family_report.py",
"category": "report",
"description": "Generate or open the formal report for exactly the confirmed gene-family stages",
"required_inputs": [],
"outputs": [
"gene_family_report",
"gene_family_report_evidence",
"gene_family_report_model",
"gene_family_report_manifest",
],
"parameters": {"action": "generate", "useModel": True},
"timeout": 1800
},
# 15. Genome Prepare
{
"id": "genome_prepare",
+33
View File
@@ -260,6 +260,35 @@ RNASEQ_STAGES = {
"rnaseq_report",
}
def resolve_gene_family_report_intent(query: str) -> Optional[Dict]:
"""Resolve only explicit gene-family report generate/open requests."""
text = str(query or "").lower()
compact = re.sub(r"\s+", "", text)
if any(marker.replace(" ", "") in compact for marker in RNASEQ_MARKERS + ["转录组"]):
return None
action = ""
if any(phrase in compact for phrase in ("打开基因家族结果报告", "打开基因家族报告", "openresultreport")):
action = "open"
elif any(phrase in compact for phrase in ("生成基因家族结果报告", "生成基因家族报告", "生成结果报告", "generateresultreport")):
action = "generate"
if not action:
return None
return {
"gene_family": "",
"species": "",
"stages": ["gene_family_report"],
"primary_stage": "gene_family_report",
"task_type": "report",
"task_kind": "report",
"params": {"geneFamilyReportAction": action, "reportAction": action},
"confidence": 1.0,
"need_clarify": False,
"clarify_type": None,
"message": None,
"method": "gene_family_report_deterministic",
}
# 物种关键词映射
SPECIES_KEYWORDS = {
"Ft": ["ft", "tartary", "苦荞", "荞麦"],
@@ -648,6 +677,10 @@ def parse_intent_from_query(query: str) -> Dict:
query_lower = query.lower()
query_without_space = re.sub(r"\s+", "", query_lower)
report_intent = resolve_gene_family_report_intent(query)
if report_intent:
return report_intent
if any(token.replace(" ", "") in query_without_space for token in (
"wgs全流程", "vcf全流程", "wgs full pipeline", "vcf full pipeline"
)):
@@ -0,0 +1,82 @@
# Gene Family Report Stage Contract v1.0
## 阶段信息
- **阶段名称**: `gene_family_report`
- **脚本**: `apps/cli/run_gene_family_report.py`
- **职责**: 按用户确认的计划阶段,生成或打开可持久化的基因家族分析结果报告。
## 输入契约
| 字段 | 类型 | 必需 | 描述 |
|---|---|---|---|
| workspace | directory | 是 | 当前 job 工作目录 |
| prefix | string | 是 | 结果前缀 |
| expected_stages | non-empty stage list | 生成时是 | 用户确认的计划阶段,保持原顺序;不得从目录中已有 Manifest 反推计划 |
| stage_statuses | JSON object | 是 | 每个计划阶段的终态或当前状态 |
| job_id | string | 否 | job 身份 |
| scope_id / analysis_route / member_set_id | string | 否 | 严格证据身份 |
| rnaseq_scope_id | string | 否 | 可选 RNA-seq 证据身份 |
| action | enum | 否 | `generate` 生成;`open` 有现成报告时复用,否则生成 |
阶段不启动任何上游生物分析。至少一个同身份、同计划范围的成功 Manifest 才能生成报告。
`action=generate`,以及 `action=open` 但不存在可复用报告时,`expected_stages` 必须为非空;否则明确拒绝生成。`action=open` 且 Markdown 与报告 Manifest 已存在时可以直接复用,不要求再次提供计划。
## 输出契约
| artifact key | 类型 | 文件 |
|---|---|---|
| gene_family_report | markdown | `*.gene_family_report.md` |
| gene_family_report_manifest | manifest | `*.gene_family_report.manifest.json` |
Manifest 记录 `final|partial` 报告状态、`api|fallback_script` 生成模式和当前分析身份。`included_stages` 只包含模板支持且成功的计划阶段;成功但不在模板 13 个阶段中的计划项写入 `unsupported_stages`,在公共完成表标记“不支持”,不生成推断章节,并使报告状态保持 `partial`。API 不可用或编排越界时,`model.reason` 固定为 `api_unavailable_or_boundary_violation`。
## 确定性数据接口
`build_report_data(final_dir, prefix, planned, statuses, manifests) -> ReportData` 是模板渲染和 API 编排共用的唯一结果模型。每节把脚本确定的内容分为 `facts`、`observations`、`interpretations`、`limitations`、`recommendations`,并汇总为带稳定 `sentence_id`、原文、类型和证据编号的不可变句子。
指标优先读取同身份 Manifest 的嵌套 `metrics`。仅当模板所需指标缺失时,集中提取器才读取该指标声明的命名 artifact;不得从文件名、其他阶段或相似字段猜值。真缺失使用 `MissingMetric(label, checked_sources)`,渲染为“源结果未提供此指标(已检查:…)”。声明的数据源存在但解析失败时必须报错,不得静默写成“未解析”、`N/A` 或 `None`。
当前真实 schema 映射包括:Identify 的 `member_count` / `identified_members_count` / `final_members`(按此前顺序取首个已提供值),Phylogeny 的 `sequences_count` 与 `msa_length`,PEP 的 `protein_count`、`tasks_success` 与 `tasks_total`。Interspecies Synteny 在没有直接语义指标时只读取 Manifest 声明的 `gene_to_species` 与 `*_collinearity` 命名 artifact:以 `## Alignment` 计共线性区块,并按 TSV 的 `group=target|ref` 名单统计目标—参考家族直接配对。
## API 语言编排接口
API 输入字段仅限:
```json
{
"sections": [
{
"section_id": "stage.identify",
"sentences": [{"sentence_id": "stage.identify.status", "text": "不可改写原句"}]
}
],
"allowed_connectors": ["also", "however", "therefore"]
}
```
API 输出字段仅限:
```json
{
"sections": [
{
"section_id": "stage.identify",
"paragraphs": [
{"sentence_ids": ["stage.identify.status"], "connector_ids": []}
]
}
]
}
```
输出必须保持完整章节集合;每个不可变句子必须在原章节中恰好出现一次;连接语必须来自白名单,数量必须与段内句间位置一致。未知、遗漏或重复 `sentence_id`,未知连接语,额外字段,自由文本字段,章节遗漏、重复或改变,均使整份 API 编排作废并回退 `fallback_script`,不能局部采用。
## 报告生成原则
1. Manifest、结果表格和结果文件是报告事实的唯一来源。成员数、统计值、比较结果、证据等级、限制、可下结论范围和后续建议均由确定性脚本提取或生成。
2. 模型 API 默认启用,但只负责排列脚本已生成的 `sentence_id`、组织段落并选择受控 `connector_id`;API 不直接返回报告文本。不得补充、推断或修改任何数值、结果判断、生物学解释、因果关系、候选基因、证据等级、限制或建议。
3. API 输出必须通过上述严格结构与集合校验;任一边界错误都使整份 API 编排作废,并自动使用同一 `ReportData` 的默认顺序。
4. API 不可用、超时或校验失败时,不影响报告事实和章节完整性;报告明确标注 `fallback_script`,但仍使用同一模板和同一份确定性结果数据。
5. 只纳入用户计划中的阶段。源结果确实没有提供某项指标时,明确写“源结果未提供此指标”并指出所检查的来源;不得用“未解析”、`N/A` 或模型猜测代替可提取的数据。
+170
View File
@@ -0,0 +1,170 @@
<!-- GFA_SECTION common.front -->
# {{PREFIX}} 基因家族分析结果解读报告
- 任务:{{JOB_ID}}
- 分析前缀:{{PREFIX}}
- 生成模式:{{GENERATION_MODE}}
- 生成时间:{{GENERATED_AT}}
{{FALLBACK_NOTICE}}
## 一、报告首页
### 1.1 项目信息
本报告只覆盖用户计划中的成功阶段;不同阶段的目标集合在各节单独标明。
### 1.2 研究目的
本报告用于确认家族成员,比较目标成员及必要参考成员的关系、结构、调控序列与蛋白预测特征,并界定结果能够支持的结论范围。
### 1.3 一页式结论摘要
{{SECTION_CONTENT}}
### 1.4 结论可信度说明
成员数和命名表中的统计可直接复核;树、启动子、TFBS、共线性和蛋白定位只提供计算线索,不等同于功能或因果证据。
<!-- GFA_SECTION common.route -->
## 二、分析路线与阅读顺序
{{SECTION_CONTENT}}
### 2.1 统一解读顺序
各阶段依次写明分析对象、源结果中的直接观察、谨慎解释、限制,并仅在有助复核时列出少量相对路径。
### 2.2 语言边界
预测关系统一表述为线索;没有逐成员表或独立证据时,不指定候选基因,不声称功能改变或调控关系已经证实。
<!-- GFA_SECTION common.completeness -->
## 三、结果总览与完整性检查
### 3.1 阶段完成情况
{{SECTION_CONTENT}}
### 3.2 结果完整性概述
报告按计划逐阶段选择成功 Manifest;当各阶段使用的成员集合不同,正文明确区分目标成员、目标加参考成员和全基因组背景。
<!-- GFA_SECTION stage.identify -->
## 四、基因家族鉴定(Identify) (`identify`)
### 4.3 最终成员数量与命名
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.phylogeny -->
## 五、亲缘关系分析(Phylogeny) (`phylogeny`)
### 5.5 亲缘关系阶段小结
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.reference_classification -->
## 六、参考物种辅助的小组分类(Reference Classification) (`reference_classification`)
### 6.2 分类可信度表述
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.motif -->
## 七、短保守片段分析(Motif) (`motif`)
### 7.2 Motif 数量与出现频率
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.domain -->
## 八、主要功能区域分析(Domain) (`domain`)
### 8.1 Domain 整体统计
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.structure -->
## 九、基因内部结构分析(Structure) (`structure`)
### 9.1 结构覆盖与异常结构
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.combo -->
## 十、综合组合图(Combo) (`combo`)
### 10.1 树 + Motif + Domain + Structure 组合图
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.promoter -->
## 十一、启动子元件分析(Promoter) (`promoter`)
### 11.1 元件总量、类别与高级统计
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.tfbs -->
## 十二、转录因子结合位点预测(TFBS) (`tfbs`)
### 12.1 高频结合片段与统计边界
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.synteny -->
## 十三、种内共线性分析(Synteny) (`synteny`)
### 13.1 全基因组共线性背景与家族证据缺口
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.karyotype -->
## 十四、染色体分布(Karyotype) (`karyotype`)
### 14.1 染色体位置概述
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.interspecies_synteny -->
## 十五、种间共线性与跨物种关系(Interspecies Synteny) (`interspecies_synteny`)
### 15.6 Interspecies Synteny 阶段小结
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.pep -->
## 十六、蛋白基本特征分析(PEP) (`pep`)
### 16.1 蛋白理化性质分布图
逐蛋白理化性质保留在命名结果表中;源 Manifest 没有汇总长度、分子量或等电点范围时,正文不从图片反推数值。
### 16.4 亚细胞定位与阶段小结
{{SECTION_CONTENT}}
<!-- GFA_SECTION common.integrated -->
## 十七、多证据综合分析
### 17.5 综合结论
{{SECTION_CONTENT}}
<!-- GFA_SECTION common.limitations -->
## 十八、主要限制
{{SECTION_CONTENT}}
<!-- GFA_SECTION common.recommendations -->
## 十九、后续验证建议
{{SECTION_CONTENT}}
<!-- GFA_SECTION common.checklist -->
## 二十、报告交付前检查清单
### 20.1 可追溯性与语言检查
{{SECTION_CONTENT}}