feat: refine gene family result report

This commit is contained in:
wtx
2026-08-09 14:28:41 +08:00
parent dc9d44f95b
commit 0e5cece447
5 changed files with 191 additions and 40 deletions
+4
View File
@@ -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,
+51 -1
View File
@@ -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,
)
+1 -7
View File
@@ -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",
+122
View File
@@ -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"![{title}]({figure['filename']})", "", 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'<figure><img src="{html.escape(source, quote=True)}" alt="{html.escape(alt, quote=True)}" loading="lazy"><figcaption>{html.escape(alt)}</figcaption></figure>')
def link(match):
label = html.escape(match.group(1).strip()); target = Path(match.group(2).strip().replace("\\", "/")).name
return stash(f'<a href="{html.escape(target, quote=True)}" target="_blank" rel="noopener">{label}</a>')
value = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", image, value); value = re.sub(r"(?<!!)\[([^\]]+)\]\(([^)]+)\)", link, value)
value = re.sub(r"`([^`]+)`", lambda m: stash(f"<code>{html.escape(m.group(1))}</code>"), value); value = html.escape(value); value = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", value)
for index, content in enumerate(tokens): value = value.replace(f"@@GFAHTML{index}@@", content)
return value
def markdown_to_html(markdown: str, report_path: Path, title: str) -> str:
output = []; list_tag = ""; table_rows = []
def close_list():
nonlocal list_tag
if list_tag: output.append(f"</{list_tag}>"); list_tag = ""
def flush_table():
nonlocal table_rows
if not table_rows: return
rows, table_rows = table_rows, []
if len(rows) >= 2 and all(re.fullmatch(r":?-{3,}:?", cell.replace(" ", "")) for cell in rows[1]):
output.append('<div class="table-wrap"><table><thead><tr>' + ''.join(f"<th>{_inline(c, report_path)}</th>" for c in rows[0]) + '</tr></thead><tbody>'); output.extend('<tr>' + ''.join(f"<td>{_inline(c, report_path)}</td>" for c in row) + '</tr>' for row in rows[2:]); output.append('</tbody></table></div>')
else: output.extend(f"<p>{_inline(' | '.join(row), report_path)}</p>" 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"<h{level}>{_inline(heading.group(2), report_path)}</h{level}>"); continue
if line.startswith("> "): close_list(); output.append(f"<blockquote>{_inline(line[2:], report_path)}</blockquote>"); 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"<li>{_inline(item.group(2), report_path)}</li>"); continue
close_list(); rendered=_inline(line, report_path); output.append(rendered if line.startswith("![") else f"<p>{rendered}</p>")
close_list(); flush_table(); body="\n".join(output); safe_title=html.escape(title)
return f'''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{safe_title}</title><style>:root{{--ink:#17352c;--paper:#fffdf7;--line:#dce5dc;--accent:#157a57;--soft:#edf6ef;--warm:#f2e5c7}}*{{box-sizing:border-box}}html{{background:#e9efe9}}body{{margin:0;color:var(--ink);font-family:"Noto Serif SC","Source Han Serif SC","Microsoft YaHei",serif;line-height:1.85;background:radial-gradient(circle at top left,#f8edcf 0,transparent 28rem),linear-gradient(150deg,#edf4ec,#f6f2e8)}}main{{width:min(1120px,calc(100% - 32px));margin:32px auto 64px;padding:clamp(28px,5vw,72px);background:var(--paper);border:1px solid rgba(23,53,44,.12);border-radius:22px;box-shadow:0 24px 70px rgba(35,62,48,.14);overflow:hidden}}h1{{font-size:clamp(2rem,5vw,3.5rem);line-height:1.2;color:#0d4f39}}h2{{margin:3.2rem 0 1.1rem;padding-top:.7rem;border-top:3px solid var(--warm);font-size:clamp(1.45rem,3vw,2rem)}}h3{{color:var(--accent)}}p,h1,h2,h3,h4,a,figcaption,code{{overflow-wrap:anywhere;word-break:break-word}}blockquote{{margin:1.4rem 0;padding:1rem 1.25rem;border-left:5px solid var(--accent);background:var(--soft)}}code{{padding:.14em .4em;border-radius:5px;background:#edf0eb}}figure{{margin:1.6rem 0 2.2rem;padding:clamp(8px,2vw,18px);border:1px solid var(--line);border-radius:16px;background:#fff;box-shadow:0 10px 30px rgba(28,62,48,.09)}}img{{display:block;max-width:100%;height:auto;margin:auto;border-radius:10px}}figcaption{{margin-top:.7rem;text-align:center;color:#60726b}}a{{color:#087553}}.table-wrap{{overflow-x:auto;margin:1.4rem 0;border:1px solid var(--line);border-radius:12px}}table{{width:100%;border-collapse:collapse;background:#fff}}th,td{{padding:.72rem .85rem;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}}th{{background:var(--soft)}}@media(max-width:640px){{main{{width:100%;margin:0;padding:24px 18px 48px;border:0;border-radius:0}}}}@media print{{html,body{{background:#fff}}main{{width:100%;margin:0;padding:0;border:0;box-shadow:none}}figure{{break-inside:avoid}}}}</style></head><body><main>{body}</main></body></html>'''
+13 -32
View File
@@ -3,10 +3,8 @@
- 任务:{{JOB_ID}}
- 分析前缀:{{PREFIX}}
- 生成模式:{{GENERATION_MODE}}
- 生成时间:{{GENERATED_AT}}
{{FALLBACK_NOTICE}}
## 一、报告首页
@@ -51,91 +49,91 @@
报告按计划逐阶段选择成功 Manifest;当各阶段使用的成员集合不同,正文明确区分目标成员、目标加参考成员和全基因组背景。
<!-- GFA_SECTION stage.identify -->
## 四、基因家族鉴定(Identify) (`identify`)
## 四、基因家族鉴定(Identify)
### 4.3 最终成员数量与命名
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.phylogeny -->
## 五、亲缘关系分析(Phylogeny) (`phylogeny`)
## 五、亲缘关系分析(Phylogeny)
### 5.5 亲缘关系阶段小结
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.reference_classification -->
## 六、参考物种辅助的小组分类(Reference Classification) (`reference_classification`)
## 六、参考物种辅助的小组分类(Reference Classification)
### 6.2 分类可信度表述
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.motif -->
## 七、短保守片段分析(Motif) (`motif`)
## 七、短保守片段分析(Motif)
### 7.2 Motif 数量与出现频率
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.domain -->
## 八、主要功能区域分析(Domain) (`domain`)
## 八、主要功能区域分析(Domain)
### 8.1 Domain 整体统计
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.structure -->
## 九、基因内部结构分析(Structure) (`structure`)
## 九、基因内部结构分析(Structure)
### 9.1 结构覆盖与异常结构
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.combo -->
## 十、综合组合图(Combo) (`combo`)
## 十、综合组合图(Combo)
### 10.1 树 + Motif + Domain + Structure 组合图
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.promoter -->
## 十一、启动子元件分析(Promoter) (`promoter`)
## 十一、启动子元件分析(Promoter)
### 11.1 元件总量、类别与高级统计
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.tfbs -->
## 十二、转录因子结合位点预测(TFBS) (`tfbs`)
## 十二、转录因子结合位点预测(TFBS)
### 12.1 高频结合片段与统计边界
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.synteny -->
## 十三、种内共线性分析(Synteny) (`synteny`)
## 十三、种内共线性分析(Synteny)
### 13.1 全基因组共线性背景与家族证据缺口
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.karyotype -->
## 十四、染色体分布(Karyotype) (`karyotype`)
## 十四、染色体分布(Karyotype)
### 14.1 染色体位置概述
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.interspecies_synteny -->
## 十五、种间共线性与跨物种关系(Interspecies Synteny) (`interspecies_synteny`)
## 十五、种间共线性与跨物种关系(Interspecies Synteny)
### 15.6 Interspecies Synteny 阶段小结
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.pep -->
## 十六、蛋白基本特征分析(PEP) (`pep`)
## 十六、蛋白基本特征分析(PEP)
### 16.1 蛋白理化性质分布图
@@ -151,20 +149,3 @@
### 17.5 综合结论
{{SECTION_CONTENT}}
<!-- GFA_SECTION common.limitations -->
## 十八、主要限制
{{SECTION_CONTENT}}
<!-- GFA_SECTION common.recommendations -->
## 十九、后续验证建议
{{SECTION_CONTENT}}
<!-- GFA_SECTION common.checklist -->
## 二十、报告交付前检查清单
### 20.1 可追溯性与语言检查
{{SECTION_CONTENT}}