diff --git a/apps/web/vue3/main.py b/apps/web/vue3/main.py
index 11cd8410e..77e5596a2 100644
--- a/apps/web/vue3/main.py
+++ b/apps/web/vue3/main.py
@@ -15354,6 +15354,10 @@ async def get_results(
if group_stage not in visible_stage_names:
result_groups.pop(group_name, None)
+ gene_family_report_group = result_groups.get("gene_family_report")
+ if isinstance(gene_family_report_group, dict):
+ stages["gene_family_report"] = gene_family_report_group
+
stages["_result_groups"] = result_groups
stages["_scope"] = {
"rnaseq_scope_id": active_rnaseq_scope_id,
diff --git a/gfa_core/gene_family_report.py b/gfa_core/gene_family_report.py
index 5fd145909..becc12d30 100644
--- a/gfa_core/gene_family_report.py
+++ b/gfa_core/gene_family_report.py
@@ -14,6 +14,12 @@ from gfa_core.gene_family_report_data import (
ReportSection,
build_report_data,
)
+from gfa_core.gene_family_report_html import (
+ REPORT_RENDERER_VERSION,
+ collect_stage_figures,
+ markdown_to_html,
+ render_stage_figures,
+)
from gfa_core.mas.agents.interpretation_evidence import load_stage_manifests
@@ -40,6 +46,7 @@ class GeneFamilyReportResult:
generation_mode: str
included_stages: tuple[str, ...]
reused: bool = False
+ html_path: Path | None = None
def _normalized_stages(stages: Iterable[str] | None) -> list[str]:
@@ -185,12 +192,32 @@ def _arranged_content(section: ReportSection, arranged: Mapping[str, Any]) -> st
return "\n\n".join(paragraphs)
+def _render_completion_table(report_data: ReportData) -> str:
+ supported_stages = {section.stage for section in report_data.sections if section.stage}
+ included = set(report_data.included)
+ status_labels = {"success": "成功", "partial_success": "部分完成", "failed": "失败", "missing": "未完成"}
+ lines = [
+ f"本次计划 {len(report_data.planned)} 个阶段,纳入解读 {len(report_data.included)} 个,未纳入 {len(report_data.planned) - len(report_data.included)} 个。",
+ "",
+ "| 分析阶段 | 运行状态 | 模板支持 | 纳入报告 |",
+ "|---|---|---|---|",
+ ]
+ for stage in report_data.planned:
+ status = str(report_data.statuses.get(stage, "missing"))
+ lines.append(
+ f"| `{stage}` | {status_labels.get(status, status)} | "
+ f"{'是' if stage in supported_stages else '否'} | {'是' if stage in included else '否'} |"
+ )
+ return "\n".join(lines)
+
+
def _render_markdown(
report_data: ReportData,
*,
job_id: str,
generation_mode: str,
arrangement: Mapping[str, Any],
+ figures_by_stage: Mapping[str, list[dict[str, str]]],
) -> str:
section_data = {section.section_id: section for section in report_data.sections}
arranged = {section["section_id"]: section for section in arrangement["sections"]}
@@ -198,7 +225,15 @@ def _render_markdown(
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])))
+ content = (
+ _render_completion_table(report_data)
+ if section_id == "common.completeness"
+ else _arranged_content(section_data[section_id], arranged[section_id])
+ )
+ stage = section_id.removeprefix("stage.") if section_id.startswith("stage.") else ""
+ if stage and figures_by_stage.get(stage):
+ content += render_stage_figures(stage, figures_by_stage[stage])
+ rendered.append(block.replace("{{SECTION_CONTENT}}", content))
markdown = "\n\n".join(rendered)
fallback_notice = (
"> 生成模式:候补脚本。模型 API 不可用或输出越过语言编排边界;报告事实仍来自确定性结果数据。"
@@ -237,9 +272,14 @@ def generate_gene_family_report(
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"
+ html_path = final_dir / f"{prefix}.gene_family_report.html"
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"))
+ if not html_path.is_file():
+ html_path.write_text(markdown_to_html(report_path.read_text(encoding="utf-8"), report_path, f"{prefix} 基因家族分析结果解读报告"), encoding="utf-8")
+ manifest.setdefault("artifacts", {})["gene_family_report_html"] = {"path": f"final/{html_path.name}", "full_path": str(html_path), "type": "html"}
+ manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
metrics = manifest.get("metrics") or {}
return GeneFamilyReportResult(
report_path,
@@ -248,6 +288,7 @@ def generate_gene_family_report(
str(metrics.get("generation_mode") or "fallback_script"),
tuple(metrics.get("included_stages") or []),
reused=True,
+ html_path=html_path,
)
planned = _normalized_stages(expected_stages)
@@ -283,14 +324,17 @@ def generate_gene_family_report(
"reason": "api_unavailable_or_boundary_violation",
}
+ figures_by_stage = collect_stage_figures(final_dir, report_data.included, manifests)
markdown = _render_markdown(
report_data,
job_id=job_id,
generation_mode=generation_mode,
arrangement=arrangement,
+ figures_by_stage=figures_by_stage,
)
final_dir.mkdir(parents=True, exist_ok=True)
report_path.write_text(markdown, encoding="utf-8")
+ html_path.write_text(markdown_to_html(markdown, report_path, f"{prefix} 基因家族分析结果解读报告"), encoding="utf-8")
manifest = {
"manifest_version": "1.0",
"stage": REPORT_STAGE,
@@ -308,6 +352,8 @@ def generate_gene_family_report(
"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],
+ "report_renderer_version": REPORT_RENDERER_VERSION,
+ "embedded_figure_count": sum(len(items) for items in figures_by_stage.values()),
},
"model": model_metadata,
"artifacts": {
@@ -316,6 +362,9 @@ def generate_gene_family_report(
"full_path": str(report_path),
"type": "markdown",
},
+ "gene_family_report_html": {
+ "path": f"final/{html_path.name}", "full_path": str(html_path), "type": "html",
+ },
"gene_family_report_manifest": {
"path": f"final/{manifest_path.name}",
"full_path": str(manifest_path),
@@ -330,4 +379,5 @@ def generate_gene_family_report(
report_data.report_status,
generation_mode,
report_data.included,
+ html_path=html_path,
)
diff --git a/gfa_core/gene_family_report_data.py b/gfa_core/gene_family_report_data.py
index 968765ac8..108a94ae9 100644
--- a/gfa_core/gene_family_report_data.py
+++ b/gfa_core/gene_family_report_data.py
@@ -719,13 +719,7 @@ def build_report_data(
)
for index, sentence in enumerate(integrated_observations, start=1)
),
- interpretations=(
- _sentence(
- "common.integrated.boundary",
- "综合结论只并列上述已提取事实;当前源结果不足以无偏地指定候选基因,也不据此建立功能因果。",
- "interpretation",
- ),
- ),
+ interpretations=(),
),
ReportSection(
"common.limitations",
diff --git a/gfa_core/gene_family_report_html.py b/gfa_core/gene_family_report_html.py
new file mode 100644
index 000000000..e4cfc712b
--- /dev/null
+++ b/gfa_core/gene_family_report_html.py
@@ -0,0 +1,122 @@
+# HTML rendering and representative-figure selection for gene-family reports.
+from __future__ import annotations
+import base64
+import html
+import mimetypes
+from pathlib import Path
+import re
+from typing import Any, Iterable, Mapping
+
+REPORT_RENDERER_VERSION = "gene_family_report_html_v4"
+IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".svg"}
+STAGE_FIGURE_TEXT = {
+ "phylogeny": ("系统发育图展示目标成员与参考成员之间的聚类关系。", "结合分支拓扑、节点支持率和成员标签阅读,不把邻近关系直接等同于功能一致。", "目标与参考序列的聚类关系见图,具体分支以图中节点和标签为准。"),
+ "reference_classification": ("参考分类图展示目标成员与已知参考亚家族的相对位置。", "结合参考成员标签、目标成员所在分支及支持率判断分类线索。", "目标成员的参考辅助分类位置见图,分类结论以本节确定性结果为准。"),
+ "motif": ("Motif图展示各成员短保守片段的组成、顺序和相对位置。", "按成员逐行比较Motif颜色、编号、数量和排列,不把Motif相似直接写成功能相同。", "本次成员的Motif组成与排列见图,数量统计以本节结果数据为准。"),
+ "domain": ("Domain图展示蛋白主要功能区域的类型和位置。", "结合结构域名称、覆盖区间和成员间差异阅读。", "各成员的结构域组成见图,覆盖数量和比例以本节结果数据为准。"),
+ "structure": ("基因结构图展示外显子、内含子和基因长度的相对关系。", "结合图例比较外显子数量、结构排列和异常长基因。", "目标成员的基因结构差异见图,具体统计以本节结果数据为准。"),
+ "combo": ("综合组合图并列展示系统发育、Motif、Domain和基因结构信息。", "沿同一成员横向比较多个证据面板,关注一致模式与明显差异。", "多类结构证据的对应关系见图,不据此单独确认功能改变。"),
+ "promoter": ("启动子图展示顺式作用元件的类型、数量或位置分布。", "结合元件图例、位置坐标和成员标签比较,不把预测位点写成已验证调控。", "目标成员启动子元件的分布见图,数量和类别统计以本节结果数据为准。"),
+ "synteny": ("共线性图展示染色体区段和基因对之间的连线关系。", "结合染色体标签、连线和家族成员标记阅读,区分全基因组背景与家族证据。", "本次共线性背景见图,家族专属关系以本节可追溯结果为准。"),
+ "karyotype": ("染色体分布图展示家族成员在染色体上的物理位置。", "结合染色体编号、位置刻度和基因标签查看分布范围。", "目标家族成员的染色体位置见图,成员数和染色体数以本节结果数据为准。"),
+ "interspecies_synteny": ("种间共线性图展示目标物种与参考物种之间的保守区块和基因连线。", "结合物种、染色体、标记连线和成员标签阅读,不直接移植参考物种功能。", "跨物种共线性关系见图,目标与参考家族配对数以本节结果数据为准。"),
+ "pep": ("蛋白特征图展示理化性质、跨膜区域或亚细胞定位预测结果。", "根据具体图例比较成员分布,区分不同预测子任务。", "本次蛋白预测结果见图,具体数量与分类以本节结果数据为准。"),
+ "tfbs": ("Motif统计图展示不同基因的位点计数、全部Motif计数和高频Motif。", "结合基因标签、颜色图例、计数刻度和Motif名称比较分布。", "本次gene、all和top motif统计结果见图,具体计数以图例和源结果为准。"),
+}
+
+def _artifact_image(final_dir: Path, artifact: Any) -> Path | None:
+ if not isinstance(artifact, Mapping): return None
+ raw = str(artifact.get("full_path") or artifact.get("path") or "").strip()
+ target = final_dir / Path(raw).name if raw else None
+ return target if target and target.is_file() and target.suffix.lower() in IMAGE_SUFFIXES else None
+
+def _kind(role: str, filename: str) -> str:
+ value = f"{role} {filename}".lower()
+ groups = (("gene_motif_count", ("gene_motif_heatmap",)), ("all_motif_count", ("all_motif_count_heatmap",)), ("top_motif", ("top_motifs_barplot",)), ("tree", ("tree",)), ("msa", ("msa_plot", "conserved_region")), ("synteny", ("synteny", "circos")), ("motif", ("motif",)), ("domain", ("domain",)), ("structure", ("structure",)), ("combo", ("combo",)), ("promoter_combined", ("promoter_v6_combined",)), ("promoter_position", ("promoter_position_distribution",)), ("promoter", ("promoter",)), ("karyotype", ("karyotype",)), ("tmhmm", ("tmhmm",)), ("subcellular", ("subcellular",)), ("properties", ("properties",)))
+ return next((kind for kind, tokens in groups if any(token in value for token in tokens)), role.lower())
+
+def _rank(role: str, path: Path) -> tuple[int, int, str]:
+ value = f"{role} {path.name}".lower()
+ role_lower = role.lower()
+ preferred = -5 if role_lower == "synteny_marked_png" else -4 if "circular" in value else -2 if "marked" in value else -1 if "summary" in value else 0
+ return (preferred, 0, path.name)
+
+def collect_stage_figures(final_dir: Path, stages: Iterable[str], manifests: Mapping[str, Mapping[str, Any]]) -> dict[str, list[dict[str, str]]]:
+ result = {}
+ for stage in stages:
+ selected = {}; seen = set()
+ for role, artifact in ((manifests.get(stage) or {}).get("artifacts") or {}).items():
+ target = _artifact_image(final_dir, artifact)
+ if target is None or target.name in seen: continue
+ seen.add(target.name); kind = _kind(str(role), target.name)
+ candidate = (_rank(str(role), target), {"role": str(role), "filename": target.name})
+ if kind not in selected or candidate[0] < selected[kind][0]: selected[kind] = candidate
+ if stage == "tfbs":
+ for role, pattern in (
+ ("gene_motif_heatmap", "*gene_motif_heatmap.png"),
+ ("all_motif_count_heatmap", "*all_motif_count_heatmap.png"),
+ ("top_motifs_barplot", "*top_motifs_barplot.png"),
+ ):
+ matches = sorted(final_dir.glob(pattern))
+ if not matches:
+ continue
+ target = matches[0]
+ kind = _kind(role, target.name)
+ selected[kind] = (_rank(role, target), {"role": role, "filename": target.name})
+ figures = [entry[1] for _, entry in sorted(selected.items(), key=lambda item: (item[1][0], item[0]))][:4]
+ if figures: result[stage] = figures
+ return result
+
+def render_stage_figures(stage: str, figures: list[dict[str, str]]) -> str:
+ meaning, reading, result = STAGE_FIGURE_TEXT.get(stage, ("结果图展示该阶段的主要计算产物。", "结合图题、坐标轴、图例和成员标签阅读。", "本阶段代表结果见图,具体事实以本节确定性数据为准。"))
+ lines = ["", "### 结果图"]
+ for index, figure in enumerate(figures, 1):
+ title = figure["role"]
+ lines.extend(["", f"#### 图 {index}. {title}", "", f"", "", f"图形含义:{meaning}", "", f"阅读方法:{reading}", "", f"本次结果:{result}"])
+ return "\n".join(lines)
+
+def _inline(value: str, report_path: Path) -> str:
+ tokens = []
+ def stash(content):
+ token = f"@@GFAHTML{len(tokens)}@@"; tokens.append(content); return token
+ def image(match):
+ alt = match.group(1).strip() or "基因家族结果图"; target = report_path.parent / Path(match.group(2).strip().replace("\\", "/")).name
+ source = target.name; mime = mimetypes.guess_type(target.name)[0] or ""
+ if target.is_file() and mime.startswith("image/"): source = f"data:{mime};base64,{base64.b64encode(target.read_bytes()).decode('ascii')}"
+ return stash(f'
| {_inline(c, report_path)} | " for c in rows[0]) + '
|---|
| {_inline(c, report_path)} | " for c in row) + '
{_inline(' | '.join(row), report_path)}
" for row in rows) + for raw in markdown.splitlines(): + line = raw.strip() + if line.startswith("|") and line.endswith("|"): close_list(); table_rows.append([c.strip() for c in line.strip("|").split("|")]); continue + flush_table() + if not line: close_list(); continue + heading = re.match(r"^(#{1,6})\s+(.+)$", line) + if heading: close_list(); level=len(heading.group(1)); output.append(f"{_inline(line[2:], report_path)}"); continue + item = re.match(r"^([-*]|\d+\.)\s+(.+)$", line) + if item: + tag = "ol" if item.group(1)[0].isdigit() else "ul" + if list_tag != tag: close_list(); list_tag=tag; output.append(f"<{tag}>") + output.append(f"
{rendered}
") + close_list(); flush_table(); body="\n".join(output); safe_title=html.escape(title) + return f'''