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"![{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'
{html.escape(alt, quote=True)}
{html.escape(alt)}
') + def link(match): + label = html.escape(match.group(1).strip()); target = Path(match.group(2).strip().replace("\\", "/")).name + return stash(f'{label}') + value = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", image, value); value = re.sub(r"(?{html.escape(m.group(1))}"), value); value = html.escape(value); value = re.sub(r"\*\*([^*]+)\*\*", r"\1", 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 = "" + 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('
' + ''.join(f"" for c in rows[0]) + ''); output.extend('' + ''.join(f"" for c in row) + '' for row in rows[2:]); output.append('
{_inline(c, report_path)}
{_inline(c, report_path)}
') + else: output.extend(f"

{_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(heading.group(2), report_path)}"); continue + if line.startswith("> "): close_list(); 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"
  • {_inline(item.group(2), report_path)}
  • "); continue + close_list(); rendered=_inline(line, report_path); output.append(rendered if line.startswith("![") else f"

    {rendered}

    ") + close_list(); flush_table(); body="\n".join(output); safe_title=html.escape(title) + return f'''{safe_title}
    {body}
    ''' diff --git a/gfa_core/templates/gene_family_report_v1.md b/gfa_core/templates/gene_family_report_v1.md index effa63973..5a673c580 100644 --- a/gfa_core/templates/gene_family_report_v1.md +++ b/gfa_core/templates/gene_family_report_v1.md @@ -3,10 +3,8 @@ - 任务:{{JOB_ID}} - 分析前缀:{{PREFIX}} -- 生成模式:{{GENERATION_MODE}} - 生成时间:{{GENERATED_AT}} -{{FALLBACK_NOTICE}} ## 一、报告首页 @@ -51,91 +49,91 @@ 报告按计划逐阶段选择成功 Manifest;当各阶段使用的成员集合不同,正文明确区分目标成员、目标加参考成员和全基因组背景。 -## 四、基因家族鉴定(Identify) (`identify`) +## 四、基因家族鉴定(Identify) ### 4.3 最终成员数量与命名 {{SECTION_CONTENT}} -## 五、亲缘关系分析(Phylogeny) (`phylogeny`) +## 五、亲缘关系分析(Phylogeny) ### 5.5 亲缘关系阶段小结 {{SECTION_CONTENT}} -## 六、参考物种辅助的小组分类(Reference Classification) (`reference_classification`) +## 六、参考物种辅助的小组分类(Reference Classification) ### 6.2 分类可信度表述 {{SECTION_CONTENT}} -## 七、短保守片段分析(Motif) (`motif`) +## 七、短保守片段分析(Motif) ### 7.2 Motif 数量与出现频率 {{SECTION_CONTENT}} -## 八、主要功能区域分析(Domain) (`domain`) +## 八、主要功能区域分析(Domain) ### 8.1 Domain 整体统计 {{SECTION_CONTENT}} -## 九、基因内部结构分析(Structure) (`structure`) +## 九、基因内部结构分析(Structure) ### 9.1 结构覆盖与异常结构 {{SECTION_CONTENT}} -## 十、综合组合图(Combo) (`combo`) +## 十、综合组合图(Combo) ### 10.1 树 + Motif + Domain + Structure 组合图 {{SECTION_CONTENT}} -## 十一、启动子元件分析(Promoter) (`promoter`) +## 十一、启动子元件分析(Promoter) ### 11.1 元件总量、类别与高级统计 {{SECTION_CONTENT}} -## 十二、转录因子结合位点预测(TFBS) (`tfbs`) +## 十二、转录因子结合位点预测(TFBS) ### 12.1 高频结合片段与统计边界 {{SECTION_CONTENT}} -## 十三、种内共线性分析(Synteny) (`synteny`) +## 十三、种内共线性分析(Synteny) ### 13.1 全基因组共线性背景与家族证据缺口 {{SECTION_CONTENT}} -## 十四、染色体分布(Karyotype) (`karyotype`) +## 十四、染色体分布(Karyotype) ### 14.1 染色体位置概述 {{SECTION_CONTENT}} -## 十五、种间共线性与跨物种关系(Interspecies Synteny) (`interspecies_synteny`) +## 十五、种间共线性与跨物种关系(Interspecies Synteny) ### 15.6 Interspecies Synteny 阶段小结 {{SECTION_CONTENT}} -## 十六、蛋白基本特征分析(PEP) (`pep`) +## 十六、蛋白基本特征分析(PEP) ### 16.1 蛋白理化性质分布图 @@ -151,20 +149,3 @@ ### 17.5 综合结论 {{SECTION_CONTENT}} - - -## 十八、主要限制 - -{{SECTION_CONTENT}} - - -## 十九、后续验证建议 - -{{SECTION_CONTENT}} - - -## 二十、报告交付前检查清单 - -### 20.1 可追溯性与语言检查 - -{{SECTION_CONTENT}}