diff --git a/.gitignore b/.gitignore index d5e2ea7f0..eedcfcbf5 100755 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,7 @@ outputs/ input/ workspace/ paper/ -memory/ +#memory/ memory_storage/ backup/ backups/ @@ -29,3 +29,4 @@ nohup.out Rplots.pdf *.pyc *.log +.gitignore diff --git a/apps/cli/run_gene_family_report.py b/apps/cli/run_gene_family_report.py new file mode 100644 index 000000000..a2edf8ab2 --- /dev/null +++ b/apps/cli/run_gene_family_report.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""CLI for the plan-scoped formal gene-family result report.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import sys +from typing import Sequence + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from gfa_core.gene_family_report import GeneFamilyReportResult, generate_gene_family_report + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Generate or open a gene-family result report") + parser.add_argument("--workspace", type=Path, required=True) + parser.add_argument("--prefix", required=True) + parser.add_argument("--job-id", default="") + parser.add_argument("--expected-stages", default="") + parser.add_argument("--stage-statuses-json", default="{}") + parser.add_argument("--scope-id", default="") + parser.add_argument("--analysis-route", default="") + parser.add_argument("--member-set-id", default="") + parser.add_argument("--rnaseq-scope-id", default="") + parser.add_argument("--action", choices=("generate", "open"), default="generate") + parser.add_argument("--no-model", action="store_true") + return parser + + +def main(argv: Sequence[str] | None = None) -> GeneFamilyReportResult: + args = _parser().parse_args(argv) + statuses = json.loads(args.stage_statuses_json) + if not isinstance(statuses, dict): + raise ValueError("--stage-statuses-json must contain a JSON object") + result = generate_gene_family_report( + args.workspace, + args.prefix, + expected_stages=[value.strip() for value in args.expected_stages.split(",") if value.strip()], + stage_statuses={str(key): str(value) for key, value in statuses.items()}, + scope_id=args.scope_id, + analysis_route=args.analysis_route, + member_set_id=args.member_set_id, + rnaseq_scope_id=args.rnaseq_scope_id, + use_model=not args.no_model, + action=args.action, + job_id=args.job_id, + ) + print( + json.dumps( + { + "status": "success", + "report_path": str(result.report_path), + "manifest_path": str(result.manifest_path), + "report_status": result.report_status, + "generation_mode": result.generation_mode, + "reused": result.reused, + }, + ensure_ascii=False, + ) + ) + return result + + +if __name__ == "__main__": + main() diff --git a/apps/web/pipeline_backend.py b/apps/web/pipeline_backend.py index 448953c59..38a17a464 100644 --- a/apps/web/pipeline_backend.py +++ b/apps/web/pipeline_backend.py @@ -6112,8 +6112,9 @@ def run_stage( target_species = kwargs.get("target_species") interspecies_ref_species = kwargs.get("interspecies_ref_species") - scope_file = str(kwargs.get("scope_file") or kwargs.get("scopeFile") or "").strip() - analysis_route = str(kwargs.get("analysis_route") or kwargs.get("analysisRoute") or "").strip() + scope_file = str(kwargs.get("scope_file") or kwargs.get("scopeFile") or "").strip() + scope_id = str(kwargs.get("scope_id") or kwargs.get("scopeId") or "").strip() + analysis_route = str(kwargs.get("analysis_route") or kwargs.get("analysisRoute") or "").strip() analysis_mode = str(kwargs.get("analysis_mode") or kwargs.get("analysisMode") or "").strip() member_set_id = str(kwargs.get("member_set_id") or kwargs.get("memberSetId") or "").strip() combo_output_label = str(combo_output_label or kwargs.get("comboOutputLabel") or kwargs.get("combo_output_label") or "").strip() @@ -8432,7 +8433,62 @@ def run_stage( else: return f"❌ PEP failed (exit code: {code}). Log: {log_file}", manifest - if stage == "summary": + if stage == "gene_family_report": + expected_stages = kwargs.get("expected_stages") or kwargs.get("expectedStages") or [] + if isinstance(expected_stages, str): + expected_stages = [value.strip() for value in expected_stages.split(",") if value.strip()] + else: + expected_stages = [str(value).strip() for value in expected_stages if str(value).strip()] + report_action = str(kwargs.get("report_action") or kwargs.get("reportAction") or "generate").strip() + if report_action == "generate" and not expected_stages: + return "❌ 基因家族结果报告生成需要非空 expected_stages", manifest + stage_statuses = kwargs.get("stage_statuses") or kwargs.get("stageStatuses") or {} + if isinstance(stage_statuses, str): + try: + stage_statuses = json.loads(stage_statuses) + except json.JSONDecodeError: + return "❌ 基因家族结果报告的 stage_statuses 不是合法 JSON", manifest + if not isinstance(stage_statuses, dict): + return "❌ 基因家族结果报告的 stage_statuses 必须是对象", manifest + report_use_model_raw = kwargs.get("report_use_model", kwargs.get("reportUseModel", True)) + rnaseq_scope_id = str(kwargs.get("rnaseq_scope_id") or kwargs.get("rnaseqScopeId") or "").strip() + cmd = [ + sys.executable, + "apps/cli/run_gene_family_report.py", + "--workspace", + str(ws), + "--prefix", + prefix, + "--job-id", + job.job_id, + "--expected-stages", + ",".join(expected_stages), + "--stage-statuses-json", + json.dumps(stage_statuses, ensure_ascii=False, separators=(",", ":")), + "--action", + report_action, + ] + for flag, value in ( + ("--scope-id", scope_id), + ("--analysis-route", analysis_route), + ("--member-set-id", member_set_id), + ("--rnaseq-scope-id", rnaseq_scope_id), + ): + if value: + cmd += [flag, value] + if not _truthy(report_use_model_raw): + cmd.append("--no-model") + code = run_cmd_logged(cmd, log_file, cwd=Path(".")) + report_manifests = sorted( + final_dir.glob(f"{prefix}*.gene_family_report.manifest.json"), + key=lambda path: path.stat().st_mtime, + ) + report_manifest = report_manifests[-1] if report_manifests else manifest + if code != 0: + return f"❌ 基因家族结果报告生成失败,日志:{log_file}", report_manifest + return f"✅ 基因家族结果报告已生成。日志:{log_file}", report_manifest + + if stage == "summary": summary_manifest_seed = ( ws / "final" / f"{prefix}.identify.manifest.json" if (ws / "final" / f"{prefix}.identify.manifest.json").exists() diff --git a/apps/web/vue3/agent_status_component_ml.html b/apps/web/vue3/agent_status_component_ml.html new file mode 100755 index 000000000..dead607b1 --- /dev/null +++ b/apps/web/vue3/agent_status_component_ml.html @@ -0,0 +1,416 @@ + + + + +// Agent状态组件 +const AgentStatusComponent = { + template: '#agent-status-component', + + data() { + return { + messages: [], + userInput: '', + isProcessing: false, + currentMode: '', + sessionId: '', + tasks: [], + currentTask: null, + pollingInterval: null + }; + }, + + mounted() { + this.sessionId = 'session_' + Date.now(); + this.currentMode = t('standby'); + this.loadTasks(); + this.startPolling(); + }, + + beforeUnmount() { + this.stopPolling(); + }, + + methods: { + async sendMessage() { + if (!this.userInput.trim() || this.isProcessing) return; + + const message = this.userInput.trim(); + this.userInput = ''; + this.isProcessing = true; + + // 添加用户消息 + this.addMessage('user', message); + + try { + // 调用Agent执行API + const response = await fetch('/api/agent/execute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message: message, + session_id: this.sessionId, + model: 'kimi' + }) + }); + + const data = await response.json(); + + if (data.success) { + this.currentTask = data.task_id; + this.addMessage('system', t('msg-task-submitted') + data.task_id); + this.loadTasks(); + } else { + this.addMessage('error', data.message || t('msg-submit-failed')); + } + } catch (error) { + this.addMessage('error', t('msg-request-failed') + error.message); + } + + this.isProcessing = false; + }, + + async loadTasks() { + try { + const response = await fetch('/api/agent/tasks'); + const data = await response.json(); + + if (data.success) { + this.tasks = data.tasks; + } + } catch (error) { + console.error(t('msg-load-task-failed'), error); + } + }, + + async checkTaskStatus(taskId) { + try { + const response = await fetch(`/api/agent/status/${taskId}`); + const data = await response.json(); + + if (data.success && data.task) { + const task = data.task; + + // 更新任务状态 + const taskIndex = this.tasks.findIndex(t => t.task_id === taskId); + if (taskIndex >= 0) { + this.tasks[taskIndex] = { ...this.tasks[taskIndex], ...task }; + } + + // 如果任务完成,显示结果 + if (task.status === 'completed' && task.result) { + this.addMessage('assistant', task.result.response || t('msg-task-completed')); + + // 显示执行结果摘要 + if (task.result.execution_result) { + const execResult = task.result.execution_result; + if (execResult.status === 'success') { + const manifest = execResult.manifest || {}; + const metrics = manifest.metrics || {}; + this.addMessage('system', + t('msg-analysis-complete') + (metrics.final_members || 0) + t('msg-gene-members')); + } + } + } else if (task.status === 'failed') { + this.addMessage('error', task.result?.response || t('msg-task-failed')); + } + } + } catch (error) { + console.error(t('msg-check-task-status-failed'), error); + } + }, + + selectTask(task) { + this.currentTask = task.task_id; + this.checkTaskStatus(task.task_id); + }, + + startPolling() { + this.pollingInterval = setInterval(() => { + if (this.currentTask) { + this.checkTaskStatus(this.currentTask); + } + this.loadTasks(); + }, 3000); // 每3秒轮询一次 + }, + + stopPolling() { + if (this.pollingInterval) { + clearInterval(this.pollingInterval); + } + }, + + addMessage(role, content) { + this.messages.push({ + role: role, + content: content, + timestamp: new Date().toISOString() + }); + + // 滚动到底部 + setTimeout(() => { + const container = this.$refs.messageContainer; + if (container) { + container.scrollTop = container.scrollHeight; + } + }, 100); + }, + + getRoleLabel(role) { + const keys = { + 'user': 'user-role', + 'assistant': 'agent-role', + 'system': 'system-role', + 'error': 'error-role', + 'execution': 'execution-role', + 'planning': 'planning-role', + 'diagnosis': 'diagnosis-role' + }; + return keys[role] ? t(keys[role]) : role; + }, + + getStatusIcon(status) { + const icons = { + 'pending': '⏳', + 'running': '🔄', + 'completed': '✅', + 'failed': '❌', + 'cancelled': '🚫' + }; + return icons[status] || '❓'; + }, + + formatMessage(content) { + // 简单的Markdown格式化 + return content + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\n/g, '
'); + }, + + formatTime(timestamp) { + if (!timestamp) return ''; + const date = new Date(timestamp); + return date.toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + }, + + t(key) { + return window.t ? window.t(key) : key; + } + } +}; + + + \ No newline at end of file diff --git a/apps/web/vue3/index-with-sidebar_ml.html b/apps/web/vue3/index-with-sidebar_ml.html new file mode 100755 index 000000000..05f6b5d3b --- /dev/null +++ b/apps/web/vue3/index-with-sidebar_ml.html @@ -0,0 +1,391 @@ + + + + + + + BotanicalBaseAgent + + + + + + + + +
+ +
+

{{ t('gf-analysis-system') }}

+
+ +
+ +
+ +
+ + + + +
+
+
{{ t('conv-list') }}
+
+
+ +
+
+
{{ conv.title }}
+
+ {{ conv.gene_family }} + {{ conv.message_count }}{{ t('msg-count') }} +
+
+
+
+
+
+ + +
+
{{ t('analysis-stage') }}
+ + + + + + + + + {{ t('execute-analysis') }} + +
+ + +
+
{{ t('param-settings') }}
+ + + + + + + + +
+
+ + +
+
+
+
+ {{ msg.role === 'user' ? t('user') : t('ai-assistant') }} · {{ msg.time }} +
+
{{ msg.content }}
+
+
+ +

{{ t('start-new-chat') }}

+

{{ t('or-continue') }}

+
+
+ +
+ + + + {{ t('send') }} + +
+
+ + +
+
📊 Analysis Results
+ +
+
+ {{ stage.label }} + {{ stage.statusText }} +
+ +
+ + + +
+ +

{{ t('results-here') }}

+
+
+
+
+ + + + diff --git a/apps/web/vue3/index.html b/apps/web/vue3/index.html index ec8499c5f..56376ac03 100644 --- a/apps/web/vue3/index.html +++ b/apps/web/vue3/index.html @@ -1019,7 +1019,7 @@ } .execution-log-item.assistant .log-content.structured-content { - /* 结构化内容使用rag样式 */ + font-size: 13px; } .execution-log-item.assistant .log-content.short-content { @@ -1387,7 +1387,8 @@ -
+

拓展分析阶段

@@ -1413,10 +1414,13 @@ 名称 原始命令 环境 - - - - + + + 原始代码 @@ -1801,6 +1805,20 @@ 📂 Select
+ + +
@@ -7955,6 +7973,7 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓 working_dir: '', output_dir: '' }); + const lightweightExtensionTestStatus = ref('拖入固定结构文件夹,系统将自动创建 Job、解析环境并开始 upload。'); const activeRnaseqScopeId = ref(''); const activeGenotypeScopeId = ref(''); @@ -8131,6 +8150,7 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓 pop_snp_density: 'SNP密度分析', rnaseq_de: 'RNA-seq DE', combo: '组合图', + gene_family_report: '基因家族结果报告', reference_classification: '参考亚家族分类', subfamily_confirm: '亚家族标签确认', ppi_string_reference_confirmation: 'Confirm the STRING reference species for PPI', @@ -9310,6 +9330,7 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓 statusDialogVisible: false, statusEvents: [] }); + const extensionEnvOptions = ref(['test4py311', 'rnaseq', 'rnaR']); const lightweightExtensions = ref([]); const lightweightExtensionConversation = reactive({ cards: [], @@ -10300,6 +10321,7 @@ compare: [[flesh, peel]]`; { name: 'pep', label: '1️⃣2️⃣ PEP', status: 'pending', images: [], files: [], elapsed_time: 0 }, { name: 'expression', label: '1️⃣3️⃣ Expression (FPKM)', status: 'pending', images: [], files: [], elapsed_time: 0 }, { name: 'combo', label: '6️⃣ Combo', status: 'pending', images: [], files: [], elapsed_time: 0 }, + { name: 'gene_family_report', label: '📄 基因家族结果报告', status: 'pending', images: [], files: [], elapsed_time: 0 }, // RNA-seq stages { name: 'rnaseq_qc', label: '1️⃣5️⃣ RNA-seq QC', status: 'pending', images: [], files: [], elapsed_time: 0 }, { name: 'rnaseq_align', label: '1️⃣6️⃣ RNA-seq Align', status: 'pending', images: [], files: [], elapsed_time: 0 }, @@ -15499,6 +15521,54 @@ compare: [[flesh, peel]]`; return `⏸️ 批量执行已暂停:成功 ${completedCount}/${totalCount},暂停 ${pausedCount}/${totalCount}。真实原因:${reasonText}。`; }; + const openGeneratedGeneFamilyReport = async () => { + await refreshResults(jobInfo.job_id, 'gene_family_report'); + const reportStage = stages.find(stage => stage.name === 'gene_family_report'); + const reportFile = (reportStage?.files || []).find(file => + file.role === 'gene_family_report' || String(file.name || '').endsWith('.gene_family_report.md') + ); + if (!reportFile) return false; + rightPanelTab.value = 'results'; + selectFile('gene_family_report', reportFile); + return true; + }; + + const submitGeneFamilyReportForBatch = async (stages, pausedCount) => { + if (pausedCount > 0) return false; + const plannedIds = (stages || []).map(stage => getStageId(stage)).filter( + stageId => stageId && !['summary', 'gene_family_report', 'subfamily_confirm'].includes(stageId) + ); + const geneFamilyStages = new Set([ + 'identify', 'phylogeny', 'motif', 'domain', 'structure', 'promoter', + 'synteny', 'interspecies_synteny', 'karyotype', 'combo', 'ppi', 'expression', 'pep' + ]); + if (!plannedIds.some(stageId => geneFamilyStages.has(stageId))) return false; + const stageStatuses = Object.fromEntries(plannedIds.map(stageId => { + const status = stageExecutionStatus.value[stageId]; + return [stageId, status === 'completed' ? 'success' : status === 'failed' ? 'failed' : 'missing']; + })); + const batchParams = batchExecuteParams.value || {}; + const planParams = batchParams.plan?.params || {}; + const stageParams = (stages || []).find(stage => stage && typeof stage === 'object' && stage.params)?.params || {}; + try { + await executeSingleStageWithParams('gene_family_report', { + expectedStages: plannedIds, + stageStatuses, + reportAction: 'generate', + reportUseModel: true, + scopeId: batchParams.scopeId || planParams.scopeId || stageParams.scopeId || '', + analysisRoute: batchParams.analysisRoute || planParams.analysisRoute || stageParams.analysisRoute || '', + memberSetId: batchParams.memberSetId || planParams.memberSetId || stageParams.memberSetId || '', + rnaseqScopeId: batchParams.rnaseqScopeId || planParams.rnaseqScopeId || stageParams.rnaseqScopeId || '' + }); + addAgentMessage('assistant', '📄 基因家族结果报告已生成。'); + return true; + } catch (err) { + addAgentMessage('assistant', `⚠️ 基因家族结果报告生成失败:${err.message || err}`); + return false; + } + }; + const confirmAndStartBatchExecute = async (event) => { if (!event?.isTrusted) return; batchExecutionUserConfirmed.value = true; @@ -15616,6 +15686,7 @@ compare: [[flesh, peel]]`; }).length; const totalCount = stages.length; + let continuationOpened = false; if (failedCount > 0) { addAgentMessage('assistant', `⚠️ 批量执行完成:成功 ${completedCount}/${totalCount},失败 ${failedCount}/${totalCount}`); } else if (pausedCount > 0) { @@ -15623,13 +15694,16 @@ compare: [[flesh, peel]]`; } else { addAgentMessage('assistant', `🎉 批量执行完成!成功: ${completedCount}/${totalCount}`); if (isMethodologyDataPreparationContinuationCandidate(stages, completedCount, totalCount, failedCount, pausedCount)) { - await openMethodologyDataPreparationContinuation(); + continuationOpened = await openMethodologyDataPreparationContinuation(); } else if (isMethodologyPostConfirmContinuationCandidate(stages, completedCount, totalCount, failedCount, pausedCount)) { - await openMethodologyPostConfirmContinuation(); + continuationOpened = await openMethodologyPostConfirmContinuation(); } else if (isContextualPostConfirmContinuationCandidate(stages, completedCount, totalCount, failedCount, pausedCount)) { - await openContextualPostConfirmContinuation(); + continuationOpened = await openContextualPostConfirmContinuation(); } } + if (pausedCount === 0 && !continuationOpened) { + await submitGeneFamilyReportForBatch(stages, pausedCount); + } } }; @@ -18023,6 +18097,16 @@ compare: [[flesh, peel]]`; requestBody.geoId = confirmParams.geoId || ''; requestBody.idTransform = confirmParams.idTransform || 'remove_T01'; } + if (stageName === 'gene_family_report') { + requestBody.expectedStages = activeStageParams.expectedStages || []; + requestBody.stageStatuses = activeStageParams.stageStatuses || {}; + requestBody.reportAction = activeStageParams.reportAction || 'generate'; + requestBody.reportUseModel = activeStageParams.reportUseModel !== false; + requestBody.scopeId = activeStageParams.scopeId || ''; + requestBody.analysisRoute = activeStageParams.analysisRoute || ''; + requestBody.memberSetId = activeStageParams.memberSetId || ''; + requestBody.rnaseqScopeId = activeStageParams.rnaseqScopeId || ''; + } if (stageName === 'promoter_plot_revise') { const elements = Array.isArray(confirmParams.promoterElements) ? confirmParams.promoterElements : []; const categories = Array.isArray(confirmParams.promoterCategories) ? confirmParams.promoterCategories : []; @@ -18113,6 +18197,9 @@ compare: [[flesh, peel]]`; } else if (data.async === false) { // 同步执行完成,直接返回结果 console.log(`[DEBUG-EXEC] ${stageName}: sync completed`); + if (stageName === 'gene_family_report') { + await openGeneratedGeneFamilyReport(); + } return data; } else { throw createStructuredExecuteError(data); @@ -19381,6 +19468,9 @@ compare: [[flesh, peel]]`; result: data }); await handleStageCompleted(stageName, data, {}); + if (stageName === 'gene_family_report') { + await openGeneratedGeneFamilyReport(); + } // 更新阶段结果 - 如果有分stage的文件,则更新所有stage的文件 if (stageIdx >= 0 && data.result) { @@ -19949,6 +20039,10 @@ compare: [[flesh, peel]]`; }; const extensionFileKey = (file) => `${file?.name || ''}::${file?.size || 0}::${file?.lastModified || 0}`; + const extensionUploadPath = (file) => { + const relative = String(file?.lightweightRelativePath || '').trim().replace(/^\/+/, ''); + return relative || String(file?.name || '').trim(); + }; const appendExtensionFiles = (target, files) => { const current = Array.isArray(target) ? target : []; const merged = [...current]; @@ -19974,6 +20068,53 @@ compare: [[flesh, peel]]`; if (selected.length) ElMessage.success(`已选择 ${selected.length} 个测试数据文件`); event.target.value = ''; }; + const handleLightweightExtensionFolderDrop = (event) => { + const droppedFiles = Array.from(event?.dataTransfer?.files || []); + if (!droppedFiles.length) { + ElMessage.warning('未检测到可用文件'); + return; + } + + const codeExtPattern = /\.(py|r|sh|bash|pl|js|ts|java|c|cc|cpp|go|rs|jl|rb|m|smk|nf|ipynb)$/i; + const dataExtPattern = /\.(fastq|fq|gz|fa|fasta|fna|faa|pep|cds|gff|gff3|gtf|csv|tsv|txt|xlsx|xls|vcf|sam|bam)$/i; + + const normalized = droppedFiles.map((file) => { + const relativePath = file.webkitRelativePath || file.name; + if (!file.lightweightRelativePath) { + Object.defineProperty(file, 'lightweightRelativePath', { + value: relativePath, + configurable: true + }); + } + return file; + }); + + const codeFiles = []; + const dataFiles = []; + normalized.forEach((file) => { + const filePath = extensionUploadPath(file).toLowerCase(); + if (codeExtPattern.test(filePath)) { + codeFiles.push(file); + } else if (dataExtPattern.test(filePath)) { + dataFiles.push(file); + } else { + codeFiles.push(file); + } + }); + + extensionBuild.codeFiles = appendExtensionFiles(extensionBuild.codeFiles, codeFiles); + extensionBuild.dataFiles = appendExtensionFiles(extensionBuild.dataFiles, dataFiles); + + if (!extensionBuild.displayName) { + const firstPath = normalized[0]?.webkitRelativePath || normalized[0]?.name || ''; + const folder = firstPath.includes('/') ? firstPath.split('/')[0] : ''; + if (folder) { + extensionBuild.displayName = folder.replace(/[^a-zA-Z0-9_-]+/g, '_'); + } + } + + ElMessage.success(`已拖入 ${normalized.length} 个文件,已自动填充拓展包`); + }; const removeExtensionFile = (kind, file) => { const key = extensionFileKey(file); if (kind === 'code') { @@ -20006,9 +20147,10 @@ compare: [[flesh, peel]]`; }; const extensionApiUrl = (path) => { const value = String(path || ''); - if (!value.startsWith('/api/extension-stages') && !value.startsWith('/api/extension-stage-runs')) return value; + if (!value.startsWith('/api/extension-stages') && !value.startsWith('/api/extension-stage-runs') && !value.startsWith('/api/lightweight-extension-stages')) return value; const host = window.location.hostname || '127.0.0.1'; - return `${window.location.protocol}//${host}:7880${value}`; + const port = window.location.port; + return port ? `${window.location.protocol}//${host}:${port}${value}` : `${window.location.protocol}//${host}${value}`; }; const extensionApiFetch = async (path, options = {}) => fetch(extensionApiUrl(path), options); const pushExtensionStatus = (message, type = 'info', showDialog = false) => { @@ -20369,9 +20511,10 @@ compare: [[flesh, peel]]`; form.append('extension_id', extensionId); if (hasCommand) form.append('original_command', extensionBuild.command); form.append('operation_token', token); - extensionBuild.codeFiles.forEach(file => form.append('raw_code_files', file)); - extensionBuild.dataFiles.forEach(file => form.append('raw_data_files', file)); + extensionBuild.codeFiles.forEach(file => form.append('raw_code_files', file, extensionUploadPath(file))); + extensionBuild.dataFiles.forEach(file => form.append('raw_data_files', file, extensionUploadPath(file))); if (hasDataPaths) form.append('raw_data_paths', extensionBuild.dataPaths); + form.append('runtime_env', extensionBuild.runtimeEnv); const response = await extensionApiFetch('/api/lightweight-extension-stages/upload', { method: 'POST', body: form }); if (!response.ok) throw new Error(await extensionApiError(response, '原始包可复现检查提交失败')); ElMessage.success('文件上传成功,已开始生成并验收'); @@ -20616,6 +20759,18 @@ compare: [[flesh, peel]]`; console.warn('[lightweight extension] list failed', err); } }; + const loadExtensionEnvOptions = async () => { + try { + const response = await extensionApiFetch('/api/extension-stages/envs'); + if (!response.ok) return; + const data = await response.json(); + if (data.success && Array.isArray(data.envs) && data.envs.length) { + extensionEnvOptions.value = data.envs; + } + } catch (err) { + console.warn('[extension env options] load failed', err); + } + }; const answerLightweightExtensionHelp = async (message) => { const query = String(message || ''); const matched = (lightweightExtensions.value || []).find(item => query.includes(item.extension_id)); @@ -23857,6 +24012,7 @@ compare: [[flesh, peel]]`; startRuntimeMonitor(); loadExtensionStages(); loadLightweightExtensionStages(); + loadExtensionEnvOptions(); resumeLightweightOperation(); loadExtensionRuns(); loadMethodologyJobs(); @@ -24189,6 +24345,7 @@ compare: [[flesh, peel]]`; intentHistory, uploadedFiles, extensionBuild, + extensionEnvOptions, extensionStages, extensionCompletedStages, lightweightExtensionConversation, @@ -24207,6 +24364,7 @@ compare: [[flesh, peel]]`; extensionFileKey, onExtensionCodeFilesChange, onExtensionDataFilesChange, + handleLightweightExtensionFolderDrop, removeExtensionFile, buildExtensionStage, confirmLightweightCandidate, @@ -24226,6 +24384,7 @@ compare: [[flesh, peel]]`; validateExtensionStage, publishExtensionStage, loadExtensionStages, + loadExtensionEnvOptions, loadExtensionRuns, runExtensionStage, runExtensionStageFromIntent, @@ -24345,6 +24504,24 @@ compare: [[flesh, peel]]`; openPreview, redrawImage, createNewJob, + lightweightExtensionTestStatus, + handleLightweightExtensionFolderDrop: (event) => window.LightWeightExtensionTest && window.LightWeightExtensionTest.handleDrop({ + jobInfo, + extensionBuild, + createNewJob, + appendExtensionFiles, + buildLightweightExtensionStage, + lightweightExtensionTestStatus, + }, event), + handleLightweightExtensionFolderPick: (event) => window.LightWeightExtensionTest && window.LightWeightExtensionTest.handlePick({ + jobInfo, + extensionBuild, + createNewJob, + appendExtensionFiles, + buildLightweightExtensionStage, + lightweightExtensionTestStatus, + }, event), + openLightweightExtensionFolderPicker: () => window.LightWeightExtensionTest && window.LightWeightExtensionTest.openPicker(), // Working DirectorySelect existingJobs, jobSelectorVisible, @@ -24461,6 +24638,9 @@ compare: [[flesh, peel]]`; document.getElementById('app').style.display = 'block'; console.log('App mounted successfully'); + + + + + + +
+ Loading… +
+ + + + + + + + + + + + + + diff --git a/apps/web/vue3/kg_ml.html b/apps/web/vue3/kg_ml.html new file mode 100755 index 000000000..be6f9fc76 --- /dev/null +++ b/apps/web/vue3/kg_ml.html @@ -0,0 +1,712 @@ + + + + + + Knowledge Graph + + + + + + + + + +
+
+
+

{{ t('kg-visualization') }}

+
{{ t('kg-subtitle') }}
+
+ + {{ t('back-to-main') }} + +
+ +
+ +
+
+

{{ t('kg-stats') }}

+
+ {{ t('node-count') }} + {{ stats.nodes }} +
+
+ {{ t('edge-count') }} + {{ stats.edges }} +
+
+ {{ t('node-types') }} + {{ stats.nodeTypes }} +
+
+ {{ t('edge-types') }} + {{ stats.edgeTypes }} +
+
+ +
+

{{ t('node-search') }}

+ + + +
+ +
+

{{ t('relation-filter-pc') }}

+
+
+
+ {{ category === 'activation' ? t('activation-induction') : + category === 'repression' ? t('repression-inhibition') : + category === 'regulation' ? t('regulation-control') : + category === 'expression' ? t('expression-detection') : + category === 'association' ? t('association-interaction') : + category === 'localization' ? t('localization') : + category === 'containment' ? t('containment-composition') : + category === 'participation' ? t('participation-function') : t('other-rel') }} +
+ + {{ type }} ({{ relationCounts[type] }}) + +
+
+
+ +
+

{{ t('node-legend-pc') }}

+
Gene/Protein
+
+
+
+ {{ nodeTypeLabels[type] }} +
+
+
Phenotype
+
+
+
+ {{ nodeTypeLabels[type] }} +
+
+
Cell/Organ/Organism
+
+
+
+ {{ nodeTypeLabels[type] }} +
+
+
Biological Process
+
+
+
+ {{ nodeTypeLabels[type] }} +
+
+
Genomic
+
+
+
+ {{ nodeTypeLabels[type] }} +
+
+

🔗 {{ t('edge-legend-pc') }}

+
+
+
+ {{ cat === 'activation' ? 'Activation/Induction' : + cat === 'repression' ? 'Repression/Inhibition' : + cat === 'regulation' ? 'Regulation/Control' : + cat === 'expression' ? 'Expression/Detection' : + cat === 'association' ? 'Association/Interaction' : + cat === 'localization' ? 'Localization' : + cat === 'containment' ? 'Containment/Composition' : + cat === 'participation' ? 'Participation/Function' : 'Other' }} +
+
+
+
+ + +
+
+ + + {{ t('fit-view') }} + + + {{ t('reset-filters') }} + + + + + {{ t('click-drag-hint') }} + +
+
+
+ + +
+

{{ t('node-detail') }}

+
+
+
{{ t('node-name') }}
+
{{ selectedNode.name }}
+
+
+
{{ t('node-type-pc') }}
+
+ + {{ nodeTypeLabels[selectedNode.category] || selectedNode.category }} + +
+
+
+
{{ t('connection-count') }}
+
{{ selectedNode.degree }}{{ t('num-connections') }}
+
+
+
+ {{ t('click-node-detail') }} +
+ + +
+
+
+ + + + diff --git a/apps/web/vue3/lightWeightExtensionTest.js b/apps/web/vue3/lightWeightExtensionTest.js new file mode 100644 index 000000000..f93af52ca --- /dev/null +++ b/apps/web/vue3/lightWeightExtensionTest.js @@ -0,0 +1,304 @@ +(function () { + const PY_EXTENSIONS = new Set(['.py']); + const R_EXTENSIONS = new Set(['.r']); + + function normalizedRelativePath(file) { + const relative = String(file.webkitRelativePath || file.relativePath || file.name || '').replace(/^\/+/, ''); + return relative.split('\\').join('/'); + } + + function parsePythonImports(content) { + const imports = new Set(); + const lines = String(content || '').split(/\r?\n/); + for (const line of lines) { + let match = line.match(/^\s*import\s+([A-Za-z0-9_.,\s]+)/); + if (match) { + match[1].split(',').forEach((item) => { + const token = item.trim().split(/\s+as\s+/i)[0].split('.')[0].trim(); + if (token) imports.add(token); + }); + } + match = line.match(/^\s*from\s+([A-Za-z0-9_.]+)\s+import\s+/); + if (match) { + const token = match[1].split('.')[0].trim(); + if (token) imports.add(token); + } + } + return Array.from(imports); + } + + function parseRPackages(content) { + const packages = new Set(); + const pattern = /(?:library|require)\(\s*["']?([A-Za-z0-9_.-]+)["']?\s*\)/g; + let match; + const text = String(content || ''); + while ((match = pattern.exec(text)) !== null) { + if (match[1]) packages.add(match[1]); + } + return Array.from(packages); + } + + function commonPathPrefix(files) { + const paths = files.map((f) => normalizedRelativePath(f).split('/')).filter((parts) => parts.length > 0); + if (!paths.length) return ''; + const first = paths[0]; + let prefixLen = 0; + for (let i = 0; i < first.length; i++) { + if (!paths.every((parts) => parts[i] === first[i])) break; + prefixLen = i + 1; + } + return first.slice(0, prefixLen).join('/'); + } + + function sanitizeDisplayName(raw) { + return String(raw || '').replace(/[^A-Za-z0-9_-]+/g, '_').replace(/^_+|_+$/g, '') || 'lightweight_extension'; + } + + function extractRootName(files) { + const prefix = commonPathPrefix(files); + const base = (prefix && prefix !== 'raw_code' && prefix !== 'raw_test_data') + ? sanitizeDisplayName(prefix.split('/')[0]) + : 'lightweight_extension_drop'; + const ts = Date.now(); + return base + '_' + ts; + } + + function splitFixedStructure(files) { + const codeFiles = []; + const dataFiles = []; + let hasRunScript = false; + let prefix = commonPathPrefix(files); + if (prefix && !prefix.endsWith('/')) prefix += '/'; + + const sampled = []; + for (const file of files) { + let relativePath = normalizedRelativePath(file); + if (prefix && relativePath.startsWith(prefix)) { + relativePath = relativePath.slice(prefix.length); + } + if (sampled.length < 10) sampled.push(relativePath); + if (relativePath.startsWith('raw_code/')) { + file.lightweightRelativePath = relativePath.slice('raw_code/'.length) || file.name; + codeFiles.push(file); + if (relativePath === 'raw_code/run.sh') { + hasRunScript = true; + } + } else if (relativePath.startsWith('raw_test_data/')) { + file.lightweightRelativePath = relativePath.slice('raw_test_data/'.length) || file.name; + dataFiles.push(file); + } + } + if (!codeFiles.length) { + const preview = sampled.length ? sampled.slice(0, 10).join(', ') : '(无)'; + throw new Error('缺少 raw_code/ 目录(检测到的路径样例: ' + preview + ')'); + } + if (!dataFiles.length) { + throw new Error('缺少 raw_test_data/ 目录'); + } + if (!hasRunScript) { + throw new Error('缺少 raw_code/run.sh'); + } + return { codeFiles, dataFiles }; + } + + async function extractDependencies(files) { + const pythonImports = new Set(); + const rPackages = new Set(); + for (const file of files) { + const path = normalizedRelativePath(file); + const lower = path.toLowerCase(); + const extension = lower.includes('.') ? lower.slice(lower.lastIndexOf('.')) : ''; + if (!PY_EXTENSIONS.has(extension) && !R_EXTENSIONS.has(extension)) { + continue; + } + const content = await file.text(); + if (PY_EXTENSIONS.has(extension)) { + parsePythonImports(content).forEach((name) => pythonImports.add(name)); + } else { + parseRPackages(content).forEach((name) => rPackages.add(name)); + } + } + return { + python_imports: Array.from(pythonImports), + r_packages: Array.from(rPackages), + }; + } + + async function encodeBase64(file) { + const buffer = await file.arrayBuffer(); + const bytes = new Uint8Array(buffer); + let binary = ''; + const chunkSize = 0x8000; + for (let index = 0; index < bytes.length; index += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize)); + } + return btoa(binary); + } + + async function importFolderToJob(hooks, rootName, files) { + const payload = { + job_id: String(hooks.jobInfo.job_id || '').trim(), + root_name: rootName, + files: [], + relative_paths: [], + }; + for (const file of files) { + payload.files.push(await encodeBase64(file)); + payload.relative_paths.push(normalizedRelativePath(file)); + } + const response = await fetch('/api/job/import-folder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await response.json(); + if (!response.ok || !data.success) { + throw new Error(data.detail || data.error || data.message || 'Job 导入失败'); + } + return data; + } + + async function resolveRuntimeEnv(files) { + const dependencies = await extractDependencies(files); + console.log('[lightWeightExtensionTest] 提取的依赖:', JSON.stringify(dependencies), '| 扫描文件数:', files.length); + try { + const response = await fetch('/api/extension-stages/envs/resolve', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(dependencies), + }); + const data = await response.json(); + console.log('[lightWeightExtensionTest] 后端 env/resolve 原始响应:', response.status, JSON.stringify(data).slice(0, 500)); + if (!response.ok || !data.success) { + throw new Error(data.detail || data.error || data.message || '运行环境解析失败'); + } + return data; + } catch (e) { + console.error('[lightWeightExtensionTest] env/resolve 调用失败:', e); + throw e; + } + } + + function notifyError(message) { + if (window.ElementPlus && window.ElementPlus.ElMessage) { + window.ElementPlus.ElMessage.error(message); + } + } + + async function ensureJob(hooks) { + if (String(hooks.jobInfo.job_id || '').trim() && String(hooks.jobInfo.job_id) !== 'default_job') { + return; + } + await hooks.createNewJob(); + if (!String(hooks.jobInfo.job_id || '').trim() || String(hooks.jobInfo.job_id) === 'default_job') { + throw new Error('自动创建 Job 失败'); + } + } + + async function processFolderFiles(hooks, inputFiles) { + const files = Array.from(inputFiles || []).filter((file) => normalizedRelativePath(file)); + if (!files.length) { + throw new Error('未读取到任何文件'); + } + hooks.lightweightExtensionTestStatus.value = '正在校验目录结构...'; + const rootName = extractRootName(files); + const fixed = splitFixedStructure(files); + hooks.lightweightExtensionTestStatus.value = '正在确保 Job 可用...'; + await ensureJob(hooks); + hooks.lightweightExtensionTestStatus.value = '正在解析运行环境...'; + const envResult = await resolveRuntimeEnv(files); + hooks.extensionBuild.runtimeEnv = envResult.runtime_env || 'test4py311'; + console.log( + '[lightWeightExtensionTest] ✓ 选定环境: ' + hooks.extensionBuild.runtimeEnv, + '| 原因: ' + (envResult.reason || 'unknown'), + '| 兜底: ' + (envResult.fallback ? '是' : '否'), + envResult.matches ? '| 候选: ' + JSON.stringify(envResult.matches.map(m => m.env + '(py:' + (m.python_hits || []).join(',') + ' r:' + (m.r_hits || []).join(',') + ')')) : '', + ); + hooks.lightweightExtensionTestStatus.value = '正在镜像导入当前 Job...'; + await importFolderToJob(hooks, rootName, files); + hooks.extensionBuild.displayName = rootName; + hooks.extensionBuild.command = 'bash run.sh'; + hooks.extensionBuild.codeFiles = hooks.appendExtensionFiles([], fixed.codeFiles); + hooks.extensionBuild.dataFiles = hooks.appendExtensionFiles([], fixed.dataFiles); + hooks.extensionBuild.dataPaths = ''; + hooks.lightweightExtensionTestStatus.value = `运行环境 ${hooks.extensionBuild.runtimeEnv} 已确定,正在触发 upload...`; + await hooks.buildLightweightExtensionStage(); + hooks.lightweightExtensionTestStatus.value = '已开始 upload,系统正在自动轮询并等待候选卡。'; + } + + async function readEntry(entry, basePath) { + if (entry.isFile) { + return new Promise((resolve) => { + entry.file((file) => { + Object.defineProperty(file, 'webkitRelativePath', { + value: basePath ? basePath + '/' + entry.name : entry.name, + writable: false, + }); + resolve([file]); + }); + }); + } + if (entry.isDirectory) { + const reader = entry.createReader(); + const entries = await new Promise((resolve) => { + reader.readEntries(resolve); + }); + const subPath = basePath ? basePath + '/' + entry.name : entry.name; + const nested = await Promise.all(entries.map((e) => readEntry(e, subPath))); + return nested.flat(); + } + return []; + } + + async function readDroppedFiles(dataTransfer) { + if (!dataTransfer || !dataTransfer.items) { + return Array.from(dataTransfer?.files || []); + } + const items = Array.from(dataTransfer.items); + const entries = items + .map((item) => item.webkitGetAsEntry && item.webkitGetAsEntry()) + .filter(Boolean); + if (!entries.length) { + return Array.from(dataTransfer.files || []); + } + const results = await Promise.all(entries.map((entry) => readEntry(entry, ''))); + return results.flat(); + } + + function mountApi() { + window.LightWeightExtensionTest = { + async handleDrop(hooks, event) { + try { + const files = await readDroppedFiles(event?.dataTransfer); + await processFolderFiles(hooks, files); + } catch (error) { + const message = `失败:${error.message || '目录处理失败'}`; + hooks.lightweightExtensionTestStatus.value = message; + notifyError(message); + } + }, + async handlePick(hooks, event) { + const files = event?.target?.files || []; + try { + await processFolderFiles(hooks, files); + } catch (error) { + const message = `失败:${error.message || '目录处理失败'}`; + hooks.lightweightExtensionTestStatus.value = message; + notifyError(message); + } finally { + if (event?.target) { + event.target.value = ''; + } + } + }, + openPicker() { + const input = document.getElementById('lightweight-extension-test-folder-input'); + if (input) { + input.click(); + } + }, + }; + } + + mountApi(); +})(); diff --git a/apps/web/vue3/main.py b/apps/web/vue3/main.py index aa0dfc827..695ef95e8 100644 --- a/apps/web/vue3/main.py +++ b/apps/web/vue3/main.py @@ -6,6 +6,7 @@ BotanicalBaseAgent - FastAPI Backend import os import sys import json +import base64 import csv import asyncio import hashlib @@ -41,8 +42,10 @@ _builtins.print = _safe_print # 添加项目路径 PROJECT_ROOT = Path(__file__).parent.parent.parent.parent +DEFAULT_RUNTIME_ENV = os.environ.get("BBAV5_DEFAULT_RUNTIME_ENV", "test4py311") sys.path.insert(0, str(PROJECT_ROOT)) from gfa_core.project_paths import get_conda_env_bin, get_conda_env_rscript, get_configured_path, get_wgs_bcftools, get_wgs_java, get_wgs_samtools +from gfa_core.mas import confirmation_snapshot as _confirmation_snapshot ASAW_BIN = get_conda_env_bin("ASAW") ASAW_RSCRIPT = get_conda_env_rscript("ASAW") @@ -54,6 +57,8 @@ GF_MAS_ROOT = Path(os.environ.get("GF_MAS_ROOT", "/home/xzq/gene-family-mas-v2") if GF_MAS_ROOT.exists() and str(GF_MAS_ROOT) not in sys.path: sys.path.insert(0, str(GF_MAS_ROOT)) PLANTAI_RAG_ROOT = Path(os.environ.get("PLANTAI_RAG_ROOT", "/home/xzq/PlantAI-rag")).expanduser() + +DEFAULT_AGENT_LANG = "zh" if PLANTAI_RAG_ROOT.exists() and str(PLANTAI_RAG_ROOT) not in sys.path: sys.path.insert(0, str(PLANTAI_RAG_ROOT)) @@ -701,9 +706,10 @@ def _confirmed_config_species_scope(job_id: str) -> Dict[str, list[str]]: if not job_id: return {} try: - from gfa_core.mas.confirmation_snapshot import build_confirmation_snapshot - - confirmation = build_confirmation_snapshot(PROJECT_ROOT, job_id) + confirmation = _confirmation_snapshot.build_confirmation_snapshot( + PROJECT_ROOT, + job_id, + ) except (OSError, ValueError): return {} if not confirmation.get("config_confirmed"): @@ -876,7 +882,8 @@ def _species_role_conflict_message(conflicts: list[Dict[str, str]]) -> str: return ( "检测到本轮请求与项目物种角色不一致:" + detail_text - + "。系统已阻止执行,请按 config.xlsx 中已确认的角色重新描述分析范围。" + + "。系统已阻止执行,请按 config.xlsx 中已确认的角色重新描述分析范围;" + "如需修改项目物种角色,请发送“打开 config.xlsx”。" ) @@ -1086,6 +1093,8 @@ def _hydrate_goal_terms_from_current_job( return hydrated config_scope = _confirmed_config_species_scope(job_id) + if not config_scope and (INPUT_ROOT / job_id / "config.xlsx").is_file(): + config_scope = _configured_species_scope(job_id) for config_key, term_key in ( ("targetSpecies", "target_species"), ("referenceSpecies", "reference_species"), @@ -5046,12 +5055,6 @@ orchestrator = None langgraph_orchestrator = None if MAS_AVAILABLE: try: - # 清除Python模块缓存,确保配置重新加载 - import sys - modules_to_remove = [k for k in sys.modules.keys() if 'gfa_core.mas' in k] - for m in modules_to_remove: - del sys.modules[m] - # 加载MAS配置 from gfa_core.mas.config import config as mas_config llm_config = mas_config.load_llm_config() @@ -6954,9 +6957,14 @@ class ExecuteRequest(BaseModel): domCoverage: float = 30.0 idTransform: str = "none" scopeFile: str = "" + scopeId: str = "" scopeMode: str = "" analysisRoute: str = "" memberSetId: str = "" + expectedStages: List[str] = [] + stageStatuses: Dict[str, str] = {} + reportAction: str = "generate" + reportUseModel: bool = True contextRoute: str = "" subfamilyLabelSource: str = "" requiresConfirmedSubfamily: bool = False @@ -6990,6 +6998,10 @@ class ExecuteRequest(BaseModel): speciesCode: str = "" rnaseqPrefix: str = "" rnaseqOutputPrefix: str = "" + rnaseqScopeId: str = "" + rnaseqExpectedStages: List[str] = Field(default_factory=list) + rnaseqReportAction: str = "generate" + rnaseqReportUseModel: bool = True # RNA-seq QC 参数 rnaseqDataMode: str = "upload" rnaseqDataPath: str = "" @@ -7873,6 +7885,10 @@ def run_stage_sync(task_id: str, stage: str, job_id: str, params: dict): wgcna_powers=params.get("wgcnaPowers", "1,2,3,4,5,6,7,8,9,10,12,14,16,18,20"), candidate_max_deg_proteins=params.get("candidateMaxDegProteins", 500), candidate_run_interproscan=params.get("candidateRunInterproscan", "auto"), + rnaseq_scope_id=params.get("rnaseqScopeId", ""), + rnaseq_expected_stages=params.get("rnaseqExpectedStages") or params.get("expectedStages") or [], + rnaseq_report_action=params.get("rnaseqReportAction", "generate"), + rnaseq_report_use_model=_bool_param("rnaseqReportUseModel", "report_use_model", default=True), # PPI参数 ppi_species=params.get("ppiSpecies") or params.get("ppi_species") or "auto", normalization_species_label=params.get("normalizationSpeciesLabel") or params.get("normalization_species_label") or "", @@ -7888,8 +7904,13 @@ def run_stage_sync(task_id: str, stage: str, job_id: str, params: dict): # Genome Prepare参数 genome_prepare_type=params.get("genomePrepareType", "auto"), scope_file=params.get("scopeFile", ""), + scope_id=params.get("scopeId", ""), analysis_route=params.get("analysisRoute", ""), member_set_id=params.get("memberSetId", ""), + expected_stages=params.get("expectedStages", []), + stage_statuses=params.get("stageStatuses", {}), + report_action=params.get("reportAction", "generate"), + report_use_model=params.get("reportUseModel", True), analysis_mode=params.get("analysisMode", ""), species_queue=params.get("speciesQueue", []), subfamily_label_source=params.get("subfamilyLabelSource", ""), @@ -8635,6 +8656,19 @@ async def root(): ) +@app.get("/ml") +async def root_ml(): + """返回Vue3前端多语言页面""" + return FileResponse( + Path(__file__).parent / "index_ml.html", + headers={ + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0" + } + ) + + @app.post("/api/new_job") async def create_new_job(): """创建新工作目录""" @@ -8842,6 +8876,56 @@ async def upload_file(files: List[UploadFile] = File(...), job_id: str = Form("" } +class JobImportFolderRequest(BaseModel): + job_id: str + root_name: str = "" + files: List[str] = Field(default_factory=list) + relative_paths: List[str] = Field(default_factory=list) + + +@app.post("/api/job/import-folder") +async def import_job_folder(req: JobImportFolderRequest): + job_id = str(req.job_id or "").strip() + if not job_id: + raise HTTPException(status_code=400, detail="job_id is required") + if len(req.files) != len(req.relative_paths): + raise HTTPException(status_code=400, detail="files and relative_paths length mismatch") + + root_name = _safe_upload_name(req.root_name or "lightweight_extension_drop") + import_root = INPUT_ROOT / job_id / root_name + import_root.mkdir(parents=True, exist_ok=True) + saved_files: List[str] = [] + + for encoded, raw_relative in zip(req.files, req.relative_paths): + relative = Path(str(raw_relative or "").strip()) + if not raw_relative or relative.is_absolute() or any(part in {"", ".", ".."} for part in relative.parts): + raise HTTPException(status_code=400, detail=f"invalid relative path: {raw_relative}") + try: + content = base64.b64decode(str(encoded or "").encode("utf-8"), validate=True) + except Exception as exc: + raise HTTPException(status_code=400, detail=f"invalid base64 payload for {raw_relative}: {exc}") from exc + destination = import_root / relative + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(content) + saved_files.append(relative.as_posix()) + + return { + "success": True, + "job_id": job_id, + "root_name": root_name, + "import_root": str(import_root), + "saved_files": saved_files, + } + + +@app.get("/lightWeightExtensionTest.js") +async def get_lightweight_extension_test_script(): + script_path = WEB_DIR / "lightWeightExtensionTest.js" + if not script_path.exists(): + raise HTTPException(status_code=404, detail="lightWeightExtensionTest.js not found") + return FileResponse(str(script_path), media_type="application/javascript") + + class ExtensionStageRunRequest(BaseModel): extension_id: str job_id: str = "" @@ -8912,6 +8996,137 @@ class ExtensionStageResolveIntentRequest(BaseModel): job_id: str = "" +class ExtensionStageEnvResolveRequest(BaseModel): + python_imports: List[str] = Field(default_factory=list) + r_packages: List[str] = Field(default_factory=list) + + + +@app.get("/api/extension-stages/envs") +async def list_extension_stage_envs(): + """Return available conda environment names for the extension stage runtime dropdown.""" + import json as _json + try: + proc = await asyncio.create_subprocess_exec( + "conda", "env", "list", "--json", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + if proc.returncode != 0: + # fallback: try parsing plain-text output + proc2 = await asyncio.create_subprocess_exec( + "conda", "env", "list", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout2, _ = await proc2.communicate() + envs = [] + for line in stdout2.decode("utf-8", errors="replace").splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + # format: "env_name /path/to/env" + parts = stripped.split() + if parts: + envs.append(parts[0]) + return {"success": True, "envs": envs} + data = _json.loads(stdout.decode("utf-8")) + envs = [entry.split("/")[-1] if "/" in str(entry) else str(entry) for entry in data.get("envs", [])] + return {"success": True, "envs": envs} + except Exception as exc: + return {"success": False, "envs": ["test4py311", "rnaseq", "rnaR"], "error": str(exc)} + + +def _normalized_dependency_names(values: List[str]) -> List[str]: + normalized: List[str] = [] + seen: set[str] = set() + stdlib_names = set(getattr(sys, "stdlib_module_names", set())) + for raw in values or []: + token = re.split(r"[<>=\[\]()\s]", str(raw or "").strip(), maxsplit=1)[0].strip() + if not token: + continue + top_level = token.split(".")[0].strip() + key = top_level.lower().replace("_", "-") + if top_level in stdlib_names or key in seen: + continue + seen.add(key) + normalized.append(top_level) + return normalized + + +async def _available_extension_env_names() -> List[str]: + result = await list_extension_stage_envs() + envs = result.get("envs") if isinstance(result, dict) else [] + if isinstance(envs, list) and envs: + return [str(item) for item in envs if str(item).strip()] + return ["test4py311", "rnaseq", "rnaR"] + + +async def _python_packages_for_env(env_name: str) -> set[str]: + proc = await asyncio.create_subprocess_exec( + "conda", "run", "-n", env_name, "python", "-c", + "import json, importlib.metadata as m; print(json.dumps(sorted({dist.metadata.get('Name', '') for dist in m.distributions() if dist.metadata.get('Name', '')})))", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, _stderr = await proc.communicate() + if proc.returncode != 0: + return set() + try: + values = json.loads(stdout.decode("utf-8", errors="replace")) + except Exception: + return set() + return {str(item).strip().lower().replace("_", "-") for item in values if str(item).strip()} + + +async def _r_packages_for_env(env_name: str) -> set[str]: + proc = await asyncio.create_subprocess_exec( + "conda", "run", "-n", env_name, "Rscript", "-e", + "cat(paste(sort(rownames(installed.packages())), collapse='\n'))", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, _stderr = await proc.communicate() + if proc.returncode != 0: + return set() + return {line.strip().lower() for line in stdout.decode("utf-8", errors="replace").splitlines() if line.strip()} + + +@app.post("/api/extension-stages/envs/resolve") +async def resolve_extension_stage_env(req: ExtensionStageEnvResolveRequest): + python_imports = _normalized_dependency_names(req.python_imports) + r_packages = _normalized_dependency_names(req.r_packages) + if not python_imports and not r_packages: + return { + "success": True, + "runtime_env": "test4py311", + "reason": "no_dependencies_detected", + "fallback": True, + } + + for dep in list(dict.fromkeys(d.lower() for d in python_imports + r_packages)): + proc = await asyncio.create_subprocess_exec( + "conda", "run", "-n", dep, "python", "--version", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + await proc.communicate() + if proc.returncode == 0: + return { + "success": True, + "runtime_env": dep, + "reason": "env_name_matches_dependency", + "dependency": dep, + "fallback": False, + } + + return { + "success": True, + "runtime_env": "test4py311", + "reason": "no_conda_env_matches_dependency", + "fallback": True, + } @app.post("/api/extension-stages/build") async def build_extension_stage( display_name: str = Form(...), @@ -9812,7 +10027,7 @@ async def get_config_autofill_template(): async def draft_config_autofill(req: AutofillDraftRequest): job_id, input_dir, workspace_dir = _autofill_job_dirs(req.job_id) cmd = [ - "/home/xzq/miniconda3/envs/test4py311/bin/python", + f"/home/xzq/miniconda3/envs/{DEFAULT_RUNTIME_ENV}/bin/python", "apps/cli/run_config_autofill.py", "--input-dir", str(input_dir), "--workspace", str(workspace_dir), @@ -9841,7 +10056,7 @@ async def draft_config_autofill(req: AutofillDraftRequest): async def draft_samples_autofill(req: AutofillDraftRequest): job_id, input_dir, workspace_dir = _autofill_job_dirs(req.job_id) cmd = [ - "/home/xzq/miniconda3/envs/test4py311/bin/python", + f"/home/xzq/miniconda3/envs/{DEFAULT_RUNTIME_ENV}/bin/python", "apps/cli/run_samples_autofill.py", "--input-dir", str(input_dir), "--workspace", str(workspace_dir), @@ -9972,7 +10187,7 @@ async def confirm_config_autofill(req: AutofillDraftRequest): _write_config_draft_rows(draft_path, req.rows, family, primary_reference_species, identify_blast_reference_species) draft_to_use = str(draft_path) cmd = [ - "/home/xzq/miniconda3/envs/test4py311/bin/python", + f"/home/xzq/miniconda3/envs/{DEFAULT_RUNTIME_ENV}/bin/python", "apps/cli/run_config_autofill.py", "--input-dir", str(input_dir), "--workspace", str(workspace_dir), @@ -10020,7 +10235,7 @@ async def confirm_samples_autofill(req: AutofillDraftRequest): req, ) cmd = [ - "/home/xzq/miniconda3/envs/test4py311/bin/python", + f"/home/xzq/miniconda3/envs/{DEFAULT_RUNTIME_ENV}/bin/python", "apps/cli/run_samples_autofill.py", "--input-dir", str(input_dir), "--workspace", str(workspace_dir), @@ -10346,7 +10561,7 @@ async def confirm_identify_domain_review(req: IdentifyDomainReviewConfirmRequest curated_file.parent.mkdir(parents=True, exist_ok=True) curated_file.write_text("\n".join(selected) + "\n", encoding="utf-8") cmd = [ - "/home/xzq/miniconda3/envs/test4py311/bin/python", + f"/home/xzq/miniconda3/envs/{DEFAULT_RUNTIME_ENV}/bin/python", "apps/cli/run_identify_refine.py", "--input-dir", str(input_dir), "--workspace", str(workspace_dir), @@ -10829,7 +11044,7 @@ def _split_joint_subfamily_confirmation(job_id: str, prefix: str, rows: list[dic f"{row.get('confidence','')}\t{row.get('note','')}\n" ) cmd = [ - "/home/xzq/miniconda3/envs/test4py311/bin/python", + f"/home/xzq/miniconda3/envs/{DEFAULT_RUNTIME_ENV}/bin/python", "apps/cli/run_subfamily_confirm.py", "--input-dir", str(input_dir), "--workspace", str(workspace_dir), @@ -11166,7 +11381,7 @@ async def confirm_subfamily_review(req: SubfamilyReviewConfirmRequest): member_set_changed = bool(current_ids and confirmed_ids and not current_ids.issubset(confirmed_ids)) cmd = [ - "/home/xzq/miniconda3/envs/test4py311/bin/python", + f"/home/xzq/miniconda3/envs/{DEFAULT_RUNTIME_ENV}/bin/python", "apps/cli/run_subfamily_confirm.py", "--input-dir", str(input_dir), "--workspace", str(workspace_dir), @@ -11682,7 +11897,7 @@ async def execute_stage(req: ExecuteRequest): }) if req.stage == "gwas_fmt": stage_context["genotype_scope_id"] = trusted_genotype_scope - if req.stage in {"genome_prepare", "genome_prepare_v2"} or req.stage in RNA_SEQ_STAGES: + if req.stage in {"genome_prepare", "genome_prepare_v2", "gene_family_report"} or req.stage in RNA_SEQ_STAGES: stage_context.pop("scope_id", None) stage_context.pop("scope_mode", None) stage_context.pop("analysis_route", None) @@ -11815,6 +12030,7 @@ async def execute_stage(req: ExecuteRequest): skip_completed_check = ( is_utility_stage(req.stage) or is_repair_stage(req.stage) + or req.stage == "gene_family_report" or force_rerun_requested or (req.stage == "gwas_fmt" and not trusted_genotype_scope) or (req.stage == "gwas_mlm" and not gwas_mlm_full_identity) @@ -12043,9 +12259,15 @@ async def execute_stage(req: ExecuteRequest): "domCoverage": req.domCoverage, "idTransform": req.idTransform, "scopeFile": scope_params.get("scopeFile", ""), + "scopeId": getattr(req, "scopeId", ""), + "rnaseqScopeId": getattr(req, "rnaseqScopeId", ""), "scopeMode": scope_params.get("scopeMode", ""), "analysisRoute": stage_context.get("analysis_route") or getattr(req, "analysisRoute", ""), "memberSetId": stage_context.get("member_set_id") or getattr(req, "memberSetId", ""), + "expectedStages": list(getattr(req, "expectedStages", []) or []), + "stageStatuses": dict(getattr(req, "stageStatuses", {}) or {}), + "reportAction": getattr(req, "reportAction", "generate"), + "reportUseModel": bool(getattr(req, "reportUseModel", True)), "contextRoute": getattr(req, "contextRoute", ""), "subfamilyLabelSource": getattr(req, "subfamilyLabelSource", ""), "requiresConfirmedSubfamily": bool(getattr(req, "requiresConfirmedSubfamily", False)), @@ -12178,6 +12400,10 @@ async def execute_stage(req: ExecuteRequest): "wgcnaPowers": getattr(req, 'wgcnaPowers', "1,2,3,4,5,6,7,8,9,10,12,14,16,18,20"), "candidateMaxDegProteins": getattr(req, 'candidateMaxDegProteins', 500), "candidateRunInterproscan": getattr(req, 'candidateRunInterproscan', "auto"), + "rnaseqScopeId": getattr(req, 'rnaseqScopeId', ""), + "rnaseqExpectedStages": getattr(req, 'rnaseqExpectedStages', []), + "rnaseqReportAction": getattr(req, 'rnaseqReportAction', "generate"), + "rnaseqReportUseModel": getattr(req, 'rnaseqReportUseModel', True), "ncbiQuery": getattr(req, 'ncbiQuery', ''), "ncbiGeneIds": getattr(req, 'ncbiGeneIds', ''), "ncbiMode": getattr(req, 'ncbiMode', 'gene'), @@ -12802,6 +13028,69 @@ def _confirmed_execute_request( return ExecuteRequest(**params) +def _confirmed_plan_identity(ordered: List[ConfirmedPlanStage]) -> Dict[str, str]: + identity: Dict[str, str] = {} + for stage in reversed(ordered): + params = stage.params or {} + for target, keys in { + "scopeId": ("scopeId", "scope_id"), + "analysisRoute": ("analysisRoute", "analysis_route"), + "memberSetId": ("memberSetId", "member_set_id"), + "rnaseqScopeId": ("rnaseqScopeId", "rnaseq_scope_id"), + }.items(): + if identity.get(target): + continue + identity[target] = next((str(params.get(key) or "").strip() for key in keys if params.get(key)), "") + return identity + + +def _confirmed_plan_report_request( + request: ConfirmedPlanExecutionRequest, + ordered: List[ConfirmedPlanStage], + status: Dict[str, Any], +) -> Optional[ExecuteRequest]: + """Build the one formal-report submission for a terminal confirmed plan.""" + terminal = str(status.get("status") or "") + if terminal not in {"success", "failed"}: + return None + planned = [stage.id for stage in ordered if stage.id not in {"summary", "gene_family_report", "subfamily_confirm"}] + if not planned: + return None + completed = set(status.get("completed_stages") or []) + failed_stage = str((status.get("failure") or {}).get("stage") or "") + stage_statuses = { + stage: "success" if stage in completed else "failed" if stage == failed_stage else "not_run" + for stage in planned + } + if terminal == "success": + stage_statuses = {stage: "success" for stage in planned} + return ExecuteRequest( + stage="gene_family_report", + session_id=request.session_id, + job_id=request.job_id, + prefix=request.prefix, + expectedStages=planned, + stageStatuses=stage_statuses, + reportAction="generate", + reportUseModel=True, + **_confirmed_plan_identity(ordered), + ) + + +async def _submit_confirmed_plan_report( + request: ConfirmedPlanExecutionRequest, + ordered: List[ConfirmedPlanStage], + status: Dict[str, Any], +) -> None: + report_request = _confirmed_plan_report_request(request, ordered, status) + if report_request is None: + return + try: + status["report_submission"] = await execute_stage(report_request) + except Exception as exc: + status["report_submission"] = {"success": False, "error_type": type(exc).__name__} + + async def _wait_for_submitted_stage( task_id: str, stage_id: str, @@ -12851,6 +13140,8 @@ async def _run_confirmed_plan_execution( "run_id": run_id, "plan_id": request.plan_id, "job_id": request.job_id, + "prefix": request.prefix, + **_confirmed_plan_identity(ordered), "status": "running", "stages": [stage.id for stage in ordered], "completed_stages": [], @@ -12894,9 +13185,11 @@ async def _run_confirmed_plan_execution( }, } ) + await _submit_confirmed_plan_report(request, ordered, status) _write_confirmed_plan_status(status_path, status) return status.update({"status": "success", "current_stage": ""}) + await _submit_confirmed_plan_report(request, ordered, status) _write_confirmed_plan_status(status_path, status) @@ -12924,6 +13217,8 @@ async def agent_execute_confirmed(request: ConfirmedPlanExecutionRequest): "run_id": run_id, "plan_id": request.plan_id, "job_id": request.job_id, + "prefix": request.prefix, + **_confirmed_plan_identity(_confirmed_plan_stages(request)), "status": "queued", "stages": [stage.id for stage in _confirmed_plan_stages(request)], } @@ -13765,6 +14060,7 @@ async def get_results( "rnaseq_fgsea": {"images": [], "files": [], "elapsed_time": 0}, "rnaseq_wgcna": {"images": [], "files": [], "elapsed_time": 0}, "rnaseq_candidate_family": {"images": [], "files": [], "elapsed_time": 0}, + "rnaseq_report": {"images": [], "files": [], "elapsed_time": 0}, "genome_prepare": {"images": [], "files": [], "elapsed_time": 0}, "prepare_organize": {"images": [], "files": [], "elapsed_time": 0}, "prepare_normalize_gff": {"images": [], "files": [], "elapsed_time": 0}, @@ -13866,6 +14162,7 @@ async def get_results( "rnaseq_fgsea", "rnaseq_wgcna", "rnaseq_candidate_family", + "rnaseq_report", "expression", } RNASEQ_FILE_TOKENS = ( @@ -14887,6 +15184,7 @@ async def get_results( "genome_prepare": ["genome_prepare", "species_config"], "msa_region_plot": ["msa_region", "region.tsv", "conservation_profile"], "subfamily_confirm": ["subfamily_confirm", "confirmed_subfamily"], + "rnaseq_report": ["rnaseq_report"], "rnaseq_fgsea": ["rnaseq_fgsea", "fgsea"], "rnaseq_go_enrich": ["rnaseq_go_enrich", ".go_enrich", "_go_"], "rnaseq_kegg_enrich": ["rnaseq_kegg_enrich", ".kegg_enrich", "_kegg_", "kegg_pathway", "kegg_pathview"], @@ -15141,6 +15439,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, @@ -16115,6 +16417,7 @@ def parse_natural_language(message: str) -> dict: "rnaseq_align": ["rnaseq比对", "rna比对", "hisat2", "比对"], "rnaseq_quant": ["rnaseq定量", "rna定量", "stringtie", "定量"], "rnaseq_sample_qc": ["样本质量", "样本检测", "sample qc", "sample quality", "pca", "样本相关性"], + "rnaseq_report": ["rnaseq结果报告", "rna-seq结果报告", "转录组结果报告", "rnaseq报告"], "rnaseq_wgcna": ["wgcna", "共表达", "加权共表达", "模块性状", "module trait"], "rnaseq_de": ["rnaseq差异", "rna差异", "差异表达", "de分析"], "rnaseq_fgsea": ["fgsea", "gsea", "基因集富集", "排序富集"], @@ -16215,6 +16518,7 @@ class AgentChatRequest(BaseModel): session_id: str = "" job_id: str = "" # 当前 job_id,用于查询已完成 stages history: list = [] + language: str = DEFAULT_AGENT_LANG # "zh" or "en" methodology_sandbox_registry_cache: Dict[str, str] = {} @@ -16829,6 +17133,93 @@ def _answer_methodology_sandbox_qa(message: str) -> dict[str, Any]: return payload +def _gene_family_report_plan_context(job_id: str, prefix: str = "") -> Optional[Dict[str, Any]]: + """Recover only a user-confirmed or previously reported stage plan.""" + safe_job_id = str(job_id or "").strip() + requested_prefix = str(prefix or "").strip() + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", safe_job_id): + return None + final_dir = WORKSPACE_ROOT / safe_job_id / "final" + excluded = {"summary", "gene_family_report", "subfamily_confirm"} + + confirmed: list[tuple[float, Dict[str, Any]]] = [] + confirmed_dir = final_dir / ".confirmed_plans" + for path in confirmed_dir.glob("*.json") if confirmed_dir.is_dir() else (): + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + terminal = str(payload.get("status") or "") + plan_prefix = str(payload.get("prefix") or "").strip() + if ( + terminal not in {"success", "failed"} + or str(payload.get("job_id") or "") != safe_job_id + or not plan_prefix + or (requested_prefix and plan_prefix != requested_prefix) + ): + continue + planned = [ + str(stage).strip() + for stage in (payload.get("stages") or []) + if str(stage).strip() and str(stage).strip() not in excluded + ] + if not planned: + continue + completed = {str(stage) for stage in (payload.get("completed_stages") or [])} + failed_stage = str((payload.get("failure") or {}).get("stage") or "") + statuses = { + stage: "success" if terminal == "success" or stage in completed else "failed" if stage == failed_stage else "not_run" + for stage in planned + } + context = { + "prefix": plan_prefix, + "expectedStages": planned, + "stageStatuses": statuses, + "scopeId": str(payload.get("scopeId") or payload.get("scope_id") or ""), + "analysisRoute": str(payload.get("analysisRoute") or payload.get("analysis_route") or ""), + "memberSetId": str(payload.get("memberSetId") or payload.get("member_set_id") or ""), + "rnaseqScopeId": str(payload.get("rnaseqScopeId") or payload.get("rnaseq_scope_id") or ""), + } + confirmed.append((path.stat().st_mtime, context)) + if confirmed: + return max(confirmed, key=lambda item: item[0])[1] + + pattern = f"{requested_prefix}.gene_family_report.manifest.json" if requested_prefix else "*.gene_family_report.manifest.json" + reports: list[tuple[float, Dict[str, Any]]] = [] + for path in final_dir.glob(pattern) if final_dir.is_dir() else (): + try: + manifest = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + report_prefix = str(manifest.get("prefix") or "").strip() + parameters = manifest.get("parameters") or {} + planned = [ + str(stage).strip() + for stage in (parameters.get("expected_stages") or []) + if str(stage).strip() and str(stage).strip() not in excluded + ] + if ( + manifest.get("stage") != "gene_family_report" + or manifest.get("status") != "success" + or not report_prefix + or (requested_prefix and report_prefix != requested_prefix) + or not planned + ): + continue + recorded_statuses = parameters.get("stage_statuses") or {} + context = { + "prefix": report_prefix, + "expectedStages": planned, + "stageStatuses": {stage: str(recorded_statuses.get(stage) or "missing") for stage in planned}, + "scopeId": "", + "analysisRoute": "", + "memberSetId": "", + "rnaseqScopeId": "", + } + reports.append((path.stat().st_mtime, context)) + return max(reports, key=lambda item: item[0])[1] if reports else None + + async def _run_agent_chat( request: AgentChatRequest, progress_callback=None, @@ -16924,7 +17315,11 @@ async def _run_agent_chat( ) router_context_terms = dict(terms) - from gfa_core.mas.utils.intent_parser import parse_sequence_extract_request + from gfa_core.mas.utils.intent_parser import ( + deterministic_agent_chat_intent, + parse_sequence_extract_request, + resolve_gene_family_report_intent, + ) utility_params = parse_sequence_extract_request(request.message) if utility_params: @@ -16970,6 +17365,127 @@ async def _run_agent_chat( "session_id": session_id, } + compact_message = re.sub(r"\s+", "", str(request.message or "").lower()) + deterministic_intent = None if "基因家族" in compact_message else deterministic_agent_chat_intent( + request.message, + rnaseq_context=any(str(stage).startswith("rnaseq_") for stage in completed_stages), + ) + if deterministic_intent is not None: + report_action = str( + (deterministic_intent.get("params") or {}).get("rnaseqReportAction") + or "generate" + ) + report_message = ( + "已识别为打开 RNA-seq 结果报告。" + if report_action == "open" + else "已识别为生成 RNA-seq 结果报告。" + ) + report_intent = { + **deterministic_intent, + "stage": "rnaseq_report", + "primary_stage": "rnaseq_report", + "taskType": "analysis", + "task_kind": "analysis", + "job_id": request.job_id, + "workspace": ( + str(PROJECT_ROOT / "workspace" / request.job_id) + if request.job_id + else "" + ), + "message": report_message, + } + if conversation_turn is not None: + active_store.finish_turn( + conversation_turn, + assistant_message=report_message, + interaction_kind="independent", + action="propose_analysis", + intent=report_intent, + ) + return { + "success": True, + "status": "ok", + "action": "propose_analysis", + "intent": report_intent, + "sources": [], + "tool_trace": [], + "mode": "analysis", + "completed_stages": completed_stages, + "model_used": "rule", + "session_id": session_id, + "handoff_request": request.message, + } + + gene_report_intent = resolve_gene_family_report_intent(request.message) + if gene_report_intent is not None: + plan_context = _gene_family_report_plan_context( + request.job_id, + str(router_context_terms.get("prefix") or ""), + ) + report_action = str((gene_report_intent.get("params") or {}).get("reportAction") or "generate") + if plan_context is None: + report_message = "已识别为基因家族结果报告请求,但当前 job 缺少可验证的分析计划,无法生成或打开正式报告。" + if conversation_turn is not None: + active_store.finish_turn( + conversation_turn, + assistant_message=report_message, + interaction_kind="independent", + action="reply", + intent=None, + ) + return { + "success": False, + "status": "report_plan_missing", + "action": None, + "intent": None, + "message": report_message, + "sources": [], + "tool_trace": [], + "mode": "analysis", + "completed_stages": completed_stages, + "model_used": "rule", + "session_id": session_id, + } + report_message = ( + "已识别为打开基因家族结果报告。" + if report_action == "open" + else "已识别为生成基因家族结果报告。" + ) + report_params = {**(gene_report_intent.get("params") or {}), **plan_context} + resolved_intent = { + **gene_report_intent, + "stage": "gene_family_report", + "primary_stage": "gene_family_report", + "taskType": "analysis", + "task_kind": "report", + "prefix": plan_context["prefix"], + "params": report_params, + "job_id": request.job_id, + "workspace": str(WORKSPACE_ROOT / request.job_id) if request.job_id else "", + "message": report_message, + } + if conversation_turn is not None: + active_store.finish_turn( + conversation_turn, + assistant_message=report_message, + interaction_kind="independent", + action="propose_analysis", + intent=resolved_intent, + ) + return { + "success": True, + "status": "ok", + "action": "propose_analysis", + "intent": resolved_intent, + "sources": [], + "tool_trace": [], + "mode": "analysis", + "completed_stages": completed_stages, + "model_used": "rule", + "session_id": session_id, + "handoff_request": request.message, + } + global llm_manager active_llm_manager = llm_manager if active_llm_manager is None: @@ -17020,6 +17536,7 @@ async def _run_agent_chat( "history": list(request.history or []), "session_id": session_id, "job_id": request.job_id, + "language": request.language, } if router_goal_ledger is not None: payload["goal_ledger"] = router_goal_ledger @@ -17210,6 +17727,11 @@ async def _run_agent_chat( if isinstance(outcome.get("proposal"), dict) else None ), + recommendation=( + outcome.get("recommendation") + if isinstance(outcome.get("recommendation"), dict) + else None + ), ) return { @@ -17231,6 +17753,11 @@ async def _run_agent_chat( "model_used": request.model, "session_id": session_id, "input_paths": list(outcome.get("input_paths") or []), + "recommendation": ( + outcome.get("recommendation") + if isinstance(outcome.get("recommendation"), dict) + else None + ), "handoff_request": ( str( (outcome.get("proposal") or {}).get("original_request") @@ -17264,25 +17791,47 @@ async def agent_chat(request: AgentChatRequest): AGENT_CHAT_HEARTBEAT_SECONDS = 5.0 _AGENT_CHAT_TOOL_PROGRESS = { - "inspect_project_context": ("正在读取当前 job 的项目状态……", "当前 job 状态读取完成,主 Agent 正在继续处理……"), - "inspect_analysis_results": ("正在读取当前 job 的分析结果……", "分析结果读取完成,主 Agent 正在继续处理……"), - "inspect_job_file": ("正在读取当前 job 中的文件……", "文件读取完成,主 Agent 正在继续处理……"), - "query_plantai_rag": ("正在检索知识库……", "知识库检索完成,主 Agent 正在继续处理……"), - "recognize_analysis_intent": ("正在识别分析需求……", "分析需求识别完成,主 Agent 正在继续处理……"), - "recognize_utility_intent": ("正在识别工具需求……", "工具需求识别完成,主 Agent 正在继续处理……"), + "inspect_project_context": { + "zh": ("正在读取当前 job 的项目状态……", "当前 job 状态读取完成,主 Agent 正在继续处理……"), + "en": ("Reading current job project status...", "Job status read complete; agent continuing..."), + }, + "inspect_analysis_results": { + "zh": ("正在读取当前 job 的分析结果……", "分析结果读取完成,主 Agent 正在继续处理……"), + "en": ("Reading current job analysis results...", "Analysis results read complete; agent continuing..."), + }, + "inspect_job_file": { + "zh": ("正在读取当前 job 中的文件……", "文件读取完成,主 Agent 正在继续处理……"), + "en": ("Reading current job files...", "File read complete; agent continuing..."), + }, + "query_plantai_rag": { + "zh": ("正在检索知识库……", "知识库检索完成,主 Agent 正在继续处理……"), + "en": ("Searching knowledge base...", "Knowledge base search complete; agent continuing..."), + }, + "recognize_analysis_intent": { + "zh": ("正在识别分析需求……", "分析需求识别完成,主 Agent 正在继续处理……"), + "en": ("Recognizing analysis intent...", "Analysis intent recognized; agent continuing..."), + }, + "recognize_utility_intent": { + "zh": ("正在识别工具需求……", "工具需求识别完成,主 Agent 正在继续处理……"), + "en": ("Recognizing utility intent...", "Utility intent recognized; agent continuing..."), + }, } -def _agent_chat_progress_payload(event: dict, elapsed_seconds: int) -> dict: +def _agent_chat_progress_payload(event: dict, elapsed_seconds: int, language: str = "zh") -> dict: stage = str(event.get("stage") or "") tool = str(event.get("tool") or "") - messages = _AGENT_CHAT_TOOL_PROGRESS.get(tool) + pair = _AGENT_CHAT_TOOL_PROGRESS.get(tool, {}) + messages = pair.get(language) or pair.get("zh") if stage == "tool_started" and messages: message = messages[0] elif stage == "tool_completed" and messages: message = messages[1] else: - message = "主 Agent 正在处理请求……" + message = { + "zh": "主 Agent 正在处理请求……", + "en": "Main Agent is processing your request...", + }.get(language, "主 Agent 正在处理请求……") return { "stage": stage or "agent_running", "tool": tool, @@ -17297,6 +17846,8 @@ def _agent_chat_sse(event_name: str, payload: dict) -> str: @app.post("/api/agent/chat/stream") async def agent_chat_stream(request: AgentChatRequest, http_request: Request): + lang = request.language or DEFAULT_AGENT_LANG + async def event_stream(): started_at = time.monotonic() progress_queue: asyncio.Queue[dict] = asyncio.Queue() @@ -17311,7 +17862,10 @@ async def agent_chat_stream(request: AgentChatRequest, http_request: Request): "progress", { "stage": "agent_started", - "message": "正在理解你的问题并确定所需信息……", + "message": { + "zh": "正在理解你的问题并确定所需信息……", + "en": "Understanding your question and determining required information...", + }.get(lang, "正在理解你的问题并确定所需信息……"), "elapsed_seconds": 0, }, ) @@ -17331,7 +17885,10 @@ async def agent_chat_stream(request: AgentChatRequest, http_request: Request): "heartbeat", { "stage": "agent_running", - "message": f"主 Agent 仍在处理,已用时 {elapsed} 秒……", + "message": { + "zh": f"主 Agent 仍在处理,已用时 {elapsed} 秒……", + "en": f"Main Agent is still processing, {elapsed} s elapsed...", + }.get(lang, f"主 Agent 仍在处理,已用时 {elapsed} 秒……"), "elapsed_seconds": elapsed, }, ) @@ -17339,7 +17896,7 @@ async def agent_chat_stream(request: AgentChatRequest, http_request: Request): elapsed = int(time.monotonic() - started_at) yield _agent_chat_sse( "progress", - _agent_chat_progress_payload(event, elapsed), + _agent_chat_progress_payload(event, elapsed, language=lang), ) result = await runner @@ -17784,6 +18341,7 @@ class AgentExecuteRequest(BaseModel): model: str = "kimi" history: list = [] auto_execute: bool = True # 是否自动执行分析 + language: str = DEFAULT_AGENT_LANG # "zh" or "en" class AgentStatusResponse(BaseModel): """Agent状态响应""" @@ -17803,12 +18361,20 @@ async def agent_execute(request: AgentExecuteRequest, background_tasks: Backgrou 与/agent/chat不同,此接口会实际执行分析流程 """ task_id = f"agent_task_{int(time.time())}_{uuid.uuid4().hex[:8]}" - + + # Inject language instruction for non-Chinese languages + message = request.message + lang = getattr(request, "language", DEFAULT_AGENT_LANG) + if lang and lang != DEFAULT_AGENT_LANG: + lang_map = {"en": "Please respond in English."} + lang_instruction = lang_map.get(lang, f"Please respond in {lang}.") + message = f"{lang_instruction}\n\n{request.message}" + # 初始化任务状态 agent_tasks[task_id] = { "task_id": task_id, "status": "pending", - "message": request.message, + "message": message, "job_id": request.job_id, "prefix": request.prefix, "current_stage": "", @@ -17847,7 +18413,7 @@ async def agent_execute(request: AgentExecuteRequest, background_tasks: Backgrou # 提交任务到Celery队列 celery_task = execute_agent_task.delay( task_id=task_id, - message=request.message, + message=message, session_id=request.session_id or task_id, job_id=request.job_id, # 传入选中的 job_id history=request.history if request.history else [], @@ -18074,6 +18640,42 @@ class AgentPlanRequest(BaseModel): prefix: str = "" history: list = [] intent: dict = {} # 前端可传入已有的intent,避免重复调用IntentAgentV2 + language: str = DEFAULT_AGENT_LANG # "zh" or "en" + + +def _authorized_workflow_action_stages(intent: Dict[str, Any]) -> List[str]: + candidate = intent or {} + params = candidate.get("params") or {} + action_id = str( + candidate.get("workflow_action_id") or params.get("workflowActionId") or "" + ).strip() + authorized = list(dict.fromkeys(_param_list(params.get("authorizedStages")))) + declared = set(_param_list(candidate.get("stages"))) + if not action_id or not authorized or not set(authorized).issubset(declared): + return [] + try: + from gfa_core.mas.workflow_catalog import load_canonical_workflow_contract + + contract = load_canonical_workflow_contract("gene_family") + transition = next(item for item in contract.transitions if item.id == action_id) + actions = {item.id: item for item in contract.actions} + evidence_stages = { + item.id: item.stage for item in contract.evidence_requirements + } + contract_stages = [ + evidence_stages[requirement_id] + for requirement_id in transition.evidence_requirement_ids + ] + contract_stages.append(actions[transition.action_id].target_stage) + except (ImportError, StopIteration, KeyError): + return [] + if set(authorized) != set(contract_stages): + return [] + return [ + stage + for stage in SUBFAMILY_PREDICTION_PLAN_STAGES + if stage in set(contract_stages) + ] SUBFAMILY_PREDICTION_PLAN_STAGES = [ @@ -20069,6 +20671,11 @@ async def agent_plan(request: AgentPlanRequest): """ try: session_id = request.session_id or f"session_{int(time.time())}" + # Inject language instruction for non-Chinese languages + if request.language and request.language != DEFAULT_AGENT_LANG: + lang_map = {"en": "Please respond in English."} + lang_instruction = lang_map.get(request.language, f"Please respond in {request.language}.") + request.message = f"{lang_instruction}\n\n{request.message}" methodology_plan, methodology_intent, methodology_params = _load_methodology_plan_for_agent_request(request) if methodology_plan == {}: return { @@ -20083,6 +20690,63 @@ async def agent_plan(request: AgentPlanRequest): extension_intent = _match_extension_stage_intent(request.message, request.job_id or "") if extension_intent: return _extension_stage_plan_response(extension_intent, session_id, request.job_id or "") + report_intent = dict(request.intent or {}) + if report_intent.get("stages") == ["gene_family_report"]: + report_params = dict(report_intent.get("params") or {}) + report_prefix = str( + report_intent.get("prefix") + or report_params.get("prefix") + or request.prefix + or "" + ).strip() + if not report_params.get("expectedStages"): + recovered = _gene_family_report_plan_context(request.job_id, report_prefix) + if recovered: + report_params.update(recovered) + report_prefix = recovered["prefix"] + if not report_params.get("expectedStages"): + return { + "success": False, + "mode": "gene_family_report_deterministic", + "status": "report_plan_missing", + "message": "当前 job 缺少可验证的基因家族分析计划,无法生成或打开正式报告。", + "session_id": session_id, + } + report_params["prefix"] = report_prefix + report_intent.update( + { + "stage": "gene_family_report", + "primary_stage": "gene_family_report", + "stages": ["gene_family_report"], + "prefix": report_prefix, + "params": report_params, + } + ) + detail = _stage_detail_defaults("gene_family_report") + detail.update({"order": 1, "dependencies": [], "params": dict(report_params)}) + return { + "success": True, + "mode": "gene_family_report_deterministic", + "plan": { + "analysis_type": "gene_family", + "prefix": report_prefix, + "description": "生成或打开基因家族结果报告", + "estimated_time": "2分钟", + "estimated_total_time": "2分钟", + "execution_strategy": "sequential", + "max_parallel": 1, + "total_stages": 1, + "stages": ["gene_family_report"], + "stages_detail": [detail], + "all_stages": ["gene_family_report"], + "all_stages_detail": [detail], + "parallel_groups": [], + "params": report_params, + }, + "intent": report_intent, + "completed_stages": [], + "session_id": session_id, + } plan_role_resolution = ( _reconcile_confirmed_gene_family_species( request.job_id, @@ -20105,7 +20769,65 @@ async def agent_plan(request: AgentPlanRequest): request.intent = dict(request.intent or {}) request.intent["params"] = dict(request.intent.get("params") or {}) _apply_reconciled_species_to_intent(request.intent, plan_role_resolution) + authorized_workflow_stages = _authorized_workflow_action_stages(request.intent) query_lower_for_context = (request.message or "").lower() + result_report_query = ( + "结果报告" in query_lower_for_context + and any(action in query_lower_for_context for action in ["生成", "打开"]) + ) + if result_report_query: + from gfa_core.rnaseq_evidence import is_rnaseq_evidence_stage, manifest_identity_value + + current_intent = dict(request.intent or {}) + current_params = dict(current_intent.get("params") or {}) + current_stages = [str(stage) for stage in (current_intent.get("stages") or [])] + explicit_rnaseq_report = any( + marker in query_lower_for_context + for marker in ["rnaseq", "rna-seq", "rna seq", "转录组"] + ) + rnaseq_context = any(is_rnaseq_evidence_stage(stage) for stage in current_stages) + latest_rnaseq_manifest = None + if request.job_id: + report_final_dir = WORKSPACE_ROOT / request.job_id / "final" + report_prefix = _nonempty_prefix(request.prefix or current_intent.get("prefix") or current_params.get("prefix")) + report_pattern = f"{report_prefix}.*.manifest.json" if report_prefix else "*.manifest.json" + candidates = [] + for candidate in report_final_dir.glob(report_pattern) if report_final_dir.exists() else []: + try: + payload = json.loads(candidate.read_text(encoding="utf-8")) + except Exception: + continue + if ( + is_rnaseq_evidence_stage(str(payload.get("stage") or "")) + and str(payload.get("status") or "") in {"success", "partial_success"} + ): + candidates.append((candidate.stat().st_mtime, candidate, payload)) + if candidates: + _mtime, _path, latest_rnaseq_manifest = max(candidates, key=lambda item: item[0]) + rnaseq_context = True + if explicit_rnaseq_report or rnaseq_context: + current_params["rnaseqReportAction"] = "open" if "打开" in query_lower_for_context else "generate" + if latest_rnaseq_manifest: + current_params.setdefault( + "rnaseqScopeId", + manifest_identity_value(latest_rnaseq_manifest, "rnaseq_scope_id"), + ) + report_prefix = _nonempty_prefix( + request.prefix + or current_intent.get("prefix") + or current_params.get("prefix") + or (latest_rnaseq_manifest or {}).get("prefix") + ) + request.intent = { + **current_intent, + "stages": ["rnaseq_report"], + "primary_stage": "rnaseq_report", + "task_type": "report", + "task_kind": "pipeline", + "prefix": report_prefix, + "params": {**current_params, "prefix": report_prefix}, + "confidence": 0.99, + } subfamily_confirm_query_for_context = any( kw in query_lower_for_context for kw in ["确认亚家族", "亚家族确认", "确认亚家族标签", "人工确认亚家族", "subfamily confirm", "confirmed subfamily"] @@ -20117,7 +20839,7 @@ async def agent_plan(request: AgentPlanRequest): and not _looks_like_gene_family_workflow_query(query_lower_for_context) and not _requires_confirmed_subfamily_query(query_lower_for_context) ) - if subfamily_prediction_query_for_context: + if subfamily_prediction_query_for_context or authorized_workflow_stages: requested_prefix_for_subfamily = request.prefix or "" prefix_species, prefix_family = _prefix_species_family(requested_prefix_for_subfamily) family_for_subfamily = _resolve_authoritative_gene_family( @@ -20180,7 +20902,27 @@ async def agent_plan(request: AgentPlanRequest): } if family_for_subfamily and targets_for_subfamily: prefix_for_subfamily = f"{targets_for_subfamily[0]}.{family_for_subfamily}" if len(targets_for_subfamily) == 1 else f"Target.{family_for_subfamily}" - deterministic_stages = ["identify", "phylogeny", "motif", "domain", "structure", "combo", "reference_classification"] + deterministic_stages = authorized_workflow_stages or [ + "identify", + "phylogeny", + "motif", + "domain", + "structure", + "combo", + "reference_classification", + ] + authorized_action_id = str( + (request.intent or {}).get("workflow_action_id") + or ((request.intent or {}).get("params") or {}).get( + "workflowActionId" + ) + or "" + ).strip() + plan_description = ( + f"执行已确认的工作流业务动作 {authorized_action_id}" + if authorized_workflow_stages + else "执行 target/comparative + reference 亚家族分类" + ) classification_species_for_subfamily = list(targets_for_subfamily) classification_species_for_subfamily, role_targets_for_subfamily, role_comps_for_subfamily = _split_subfamily_selected_species_by_config_roles( request.job_id, @@ -20202,6 +20944,12 @@ async def agent_plan(request: AgentPlanRequest): "confirmPrefixBySpecies": confirm_prefix_by_species, "subfamilyState": subfamily_state_for_plan, } + if authorized_workflow_stages: + request_action_params = (request.intent or {}).get("params") or {} + plan_params["workflowActionId"] = authorized_action_id + plan_params["authorizedStages"] = _param_list( + request_action_params.get("authorizedStages") + ) if identify_selection_for_subfamily: plan_params["identifySpeciesSelection"] = _identify_selection_with_default( identify_selection_for_subfamily, @@ -20271,7 +21019,7 @@ async def agent_plan(request: AgentPlanRequest): "comparative_species": role_comps_for_subfamily, "scopeMode": plan_params["scopeMode"], "prefix": prefix_for_subfamily, - "description": "执行 target/comparative + reference 亚家族分类", + "description": plan_description, "estimated_time": f"{estimated_minutes}分钟", "estimated_total_time": f"{estimated_minutes}分钟", "execution_strategy": "parallel", @@ -20293,10 +21041,15 @@ async def agent_plan(request: AgentPlanRequest): "comparative_species": role_comps_for_subfamily, "scopeMode": plan_params["scopeMode"], "stages": deterministic_stages, - "description": "执行目标物种+参考物种亚家族分类", + "description": plan_description, "confidence": 0.98, "prefix": prefix_for_subfamily, "params": plan_params, + **( + {"workflow_action_id": authorized_action_id} + if authorized_action_id + else {} + ), }, "completed_stages": completed_stages, "session_id": session_id, @@ -21001,7 +21754,10 @@ async def agent_plan(request: AgentPlanRequest): "manifest_path": str(status.manifest_path) if status.manifest_path else "", "missing_count": len(status.missing_artifacts or []) + len(status.errors or []), } - if status.completed: + # Report requests are actions, not a request to skip an + # already completed computation. The CLI decides whether + # an "open" request can reuse the fresh snapshot. + if status.completed and stage_id != "rnaseq_report": completed_rnaseq_stages.append(stage_id) else: remaining_rnaseq_stages.append(stage_id) @@ -21017,6 +21773,7 @@ async def agent_plan(request: AgentPlanRequest): "rnaseq_fgsea": "RNA-seq fgsea", "rnaseq_wgcna": "RNA-seq WGCNA", "rnaseq_candidate_family": "RNA-seq Candidate Family", + "rnaseq_report": "RNA-seq Result Report", } plan_prefix = effective_plan_prefix or request.prefix or incoming_intent.get("prefix", "") final_dir_for_rnaseq = PROJECT_ROOT / "workspace" / request.job_id / "final" @@ -21036,6 +21793,7 @@ async def agent_plan(request: AgentPlanRequest): "rnaseq_fgsea": ["rnaseq_de"], "rnaseq_wgcna": ["rnaseq_sample_qc"], "rnaseq_candidate_family": ["rnaseq_de", "rnaseq_go_enrich", "rnaseq_kegg_enrich", "rnaseq_fgsea"], + "rnaseq_report": [], } if ( "rnaseq_fgsea" in remaining_rnaseq_stages @@ -21059,6 +21817,7 @@ async def agent_plan(request: AgentPlanRequest): "rnaseq_fgsea": 10, "rnaseq_wgcna": 20, "rnaseq_candidate_family": 10, + "rnaseq_report": 2, } stages_detail = [] for order, stage_id in enumerate(remaining_rnaseq_stages, start=1): @@ -22286,6 +23045,7 @@ class AgentRunRequest(BaseModel): job_id: str = "" intent: dict = {} # 前端可传入已有的intent stages: list = [] # 要执行的阶段列表 + language: str = DEFAULT_AGENT_LANG # "zh" or "en" @app.post("/api/agent/run") @@ -22319,6 +23079,7 @@ async def agent_run(request: AgentRunRequest): ) # 添加额外的运行时字段 + initial_state["language"] = request.language initial_state["status"] = "running" initial_state["intent_result"] = request.intent or {} # 传入已有的 intent initial_state["completed_stages"] = [] @@ -23214,6 +23975,7 @@ class GenerateInterpretationRequest(BaseModel): strict_identity: bool = True use_rag_support: bool = False refresh_rag: bool = False + language: str = DEFAULT_AGENT_LANG # "zh" or "en" @app.post("/api/agent/interpret") async def generate_interpretation(request: GenerateInterpretationRequest): diff --git a/apps/web/vue3/static/cdn/en.js b/apps/web/vue3/static/cdn/en.js new file mode 100644 index 000000000..1894bbb3f --- /dev/null +++ b/apps/web/vue3/static/cdn/en.js @@ -0,0 +1,165 @@ +// global wrapper +/*! Element Plus v2.3.14 */ + +window.en = { + name: "en", + el: { + colorpicker: { + confirm: "OK", + clear: "Clear", + defaultLabel: "color picker", + description: "current color is {color}. press enter to select a new color." + }, + datepicker: { + now: "Now", + today: "Today", + cancel: "Cancel", + clear: "Clear", + confirm: "OK", + dateTablePrompt: "Use the arrow keys and enter to select the day of the month", + monthTablePrompt: "Use the arrow keys and enter to select the month", + yearTablePrompt: "Use the arrow keys and enter to select the year", + selectedDate: "Selected date", + selectDate: "Select date", + selectTime: "Select time", + startDate: "Start Date", + startTime: "Start Time", + endDate: "End Date", + endTime: "End Time", + prevYear: "Previous Year", + nextYear: "Next Year", + prevMonth: "Previous Month", + nextMonth: "Next Month", + year: "", + month1: "January", + month2: "February", + month3: "March", + month4: "April", + month5: "May", + month6: "June", + month7: "July", + month8: "August", + month9: "September", + month10: "October", + month11: "November", + month12: "December", + week: "week", + weeks: { + sun: "Sun", + mon: "Mon", + tue: "Tue", + wed: "Wed", + thu: "Thu", + fri: "Fri", + sat: "Sat" + }, + weeksFull: { + sun: "Sunday", + mon: "Monday", + tue: "Tuesday", + wed: "Wednesday", + thu: "Thursday", + fri: "Friday", + sat: "Saturday" + }, + months: { + jan: "Jan", + feb: "Feb", + mar: "Mar", + apr: "Apr", + may: "May", + jun: "Jun", + jul: "Jul", + aug: "Aug", + sep: "Sep", + oct: "Oct", + nov: "Nov", + dec: "Dec" + } + }, + inputNumber: { + decrease: "decrease number", + increase: "increase number" + }, + select: { + loading: "Loading", + noMatch: "No matching data", + noData: "No data", + placeholder: "Select" + }, + dropdown: { + toggleDropdown: "Toggle Dropdown" + }, + cascader: { + noMatch: "No matching data", + loading: "Loading", + placeholder: "Select", + noData: "No data" + }, + pagination: { + goto: "Go to", + pagesize: "/page", + total: "Total {total}", + pageClassifier: "", + page: "Page", + prev: "Go to previous page", + next: "Go to next page", + currentPage: "page {pager}", + prevPages: "Previous {pager} pages", + nextPages: "Next {pager} pages", + deprecationWarning: "Deprecated usages detected, please refer to the el-pagination documentation for more details" + }, + dialog: { + close: "Close this dialog" + }, + drawer: { + close: "Close this dialog" + }, + messagebox: { + title: "Message", + confirm: "OK", + cancel: "Cancel", + error: "Illegal input", + close: "Close this dialog" + }, + upload: { + deleteTip: "press delete to remove", + delete: "Delete", + preview: "Preview", + continue: "Continue" + }, + slider: { + defaultLabel: "slider between {min} and {max}", + defaultRangeStartLabel: "pick start value", + defaultRangeEndLabel: "pick end value" + }, + table: { + emptyText: "No Data", + confirmFilter: "Confirm", + resetFilter: "Reset", + clearFilter: "All", + sumText: "Sum" + }, + tree: { + emptyText: "No Data" + }, + transfer: { + noMatch: "No matching data", + noData: "No data", + titles: ["List 1", "List 2"], + filterPlaceholder: "Enter keyword", + noCheckedFormat: "{total} items", + hasCheckedFormat: "{checked}/{total} checked" + }, + image: { + error: "FAILED" + }, + pageHeader: { + title: "Back" + }, + popconfirm: { + confirmButtonText: "Yes", + cancelButtonText: "No" + } + } +}; diff --git a/apps/web/vue3/static/cdn/en.mjs b/apps/web/vue3/static/cdn/en.mjs new file mode 100644 index 000000000..71aff82d0 --- /dev/null +++ b/apps/web/vue3/static/cdn/en.mjs @@ -0,0 +1,165 @@ +/*! Element Plus v2.3.14 */ + +var en = { + name: "en", + el: { + colorpicker: { + confirm: "OK", + clear: "Clear", + defaultLabel: "color picker", + description: "current color is {color}. press enter to select a new color." + }, + datepicker: { + now: "Now", + today: "Today", + cancel: "Cancel", + clear: "Clear", + confirm: "OK", + dateTablePrompt: "Use the arrow keys and enter to select the day of the month", + monthTablePrompt: "Use the arrow keys and enter to select the month", + yearTablePrompt: "Use the arrow keys and enter to select the year", + selectedDate: "Selected date", + selectDate: "Select date", + selectTime: "Select time", + startDate: "Start Date", + startTime: "Start Time", + endDate: "End Date", + endTime: "End Time", + prevYear: "Previous Year", + nextYear: "Next Year", + prevMonth: "Previous Month", + nextMonth: "Next Month", + year: "", + month1: "January", + month2: "February", + month3: "March", + month4: "April", + month5: "May", + month6: "June", + month7: "July", + month8: "August", + month9: "September", + month10: "October", + month11: "November", + month12: "December", + week: "week", + weeks: { + sun: "Sun", + mon: "Mon", + tue: "Tue", + wed: "Wed", + thu: "Thu", + fri: "Fri", + sat: "Sat" + }, + weeksFull: { + sun: "Sunday", + mon: "Monday", + tue: "Tuesday", + wed: "Wednesday", + thu: "Thursday", + fri: "Friday", + sat: "Saturday" + }, + months: { + jan: "Jan", + feb: "Feb", + mar: "Mar", + apr: "Apr", + may: "May", + jun: "Jun", + jul: "Jul", + aug: "Aug", + sep: "Sep", + oct: "Oct", + nov: "Nov", + dec: "Dec" + } + }, + inputNumber: { + decrease: "decrease number", + increase: "increase number" + }, + select: { + loading: "Loading", + noMatch: "No matching data", + noData: "No data", + placeholder: "Select" + }, + dropdown: { + toggleDropdown: "Toggle Dropdown" + }, + cascader: { + noMatch: "No matching data", + loading: "Loading", + placeholder: "Select", + noData: "No data" + }, + pagination: { + goto: "Go to", + pagesize: "/page", + total: "Total {total}", + pageClassifier: "", + page: "Page", + prev: "Go to previous page", + next: "Go to next page", + currentPage: "page {pager}", + prevPages: "Previous {pager} pages", + nextPages: "Next {pager} pages", + deprecationWarning: "Deprecated usages detected, please refer to the el-pagination documentation for more details" + }, + dialog: { + close: "Close this dialog" + }, + drawer: { + close: "Close this dialog" + }, + messagebox: { + title: "Message", + confirm: "OK", + cancel: "Cancel", + error: "Illegal input", + close: "Close this dialog" + }, + upload: { + deleteTip: "press delete to remove", + delete: "Delete", + preview: "Preview", + continue: "Continue" + }, + slider: { + defaultLabel: "slider between {min} and {max}", + defaultRangeStartLabel: "pick start value", + defaultRangeEndLabel: "pick end value" + }, + table: { + emptyText: "No Data", + confirmFilter: "Confirm", + resetFilter: "Reset", + clearFilter: "All", + sumText: "Sum" + }, + tree: { + emptyText: "No Data" + }, + transfer: { + noMatch: "No matching data", + noData: "No data", + titles: ["List 1", "List 2"], + filterPlaceholder: "Enter keyword", + noCheckedFormat: "{total} items", + hasCheckedFormat: "{checked}/{total} checked" + }, + image: { + error: "FAILED" + }, + pageHeader: { + title: "Back" + }, + popconfirm: { + confirmButtonText: "Yes", + cancelButtonText: "No" + } + } +}; + diff --git a/apps/web/vue3/static/cdn/zh-cn.js b/apps/web/vue3/static/cdn/zh-cn.js new file mode 100644 index 000000000..b86238535 --- /dev/null +++ b/apps/web/vue3/static/cdn/zh-cn.js @@ -0,0 +1,130 @@ +// global wrapper +/*! Element Plus v2.3.14 */ + +window.zhCn = { + name: "zh-cn", + el: { + colorpicker: { + confirm: "\u786E\u5B9A", + clear: "\u6E05\u7A7A" + }, + datepicker: { + now: "\u6B64\u523B", + today: "\u4ECA\u5929", + cancel: "\u53D6\u6D88", + clear: "\u6E05\u7A7A", + confirm: "\u786E\u5B9A", + selectDate: "\u9009\u62E9\u65E5\u671F", + selectTime: "\u9009\u62E9\u65F6\u95F4", + startDate: "\u5F00\u59CB\u65E5\u671F", + startTime: "\u5F00\u59CB\u65F6\u95F4", + endDate: "\u7ED3\u675F\u65E5\u671F", + endTime: "\u7ED3\u675F\u65F6\u95F4", + prevYear: "\u524D\u4E00\u5E74", + nextYear: "\u540E\u4E00\u5E74", + prevMonth: "\u4E0A\u4E2A\u6708", + nextMonth: "\u4E0B\u4E2A\u6708", + year: "\u5E74", + month1: "1 \u6708", + month2: "2 \u6708", + month3: "3 \u6708", + month4: "4 \u6708", + month5: "5 \u6708", + month6: "6 \u6708", + month7: "7 \u6708", + month8: "8 \u6708", + month9: "9 \u6708", + month10: "10 \u6708", + month11: "11 \u6708", + month12: "12 \u6708", + weeks: { + sun: "\u65E5", + mon: "\u4E00", + tue: "\u4E8C", + wed: "\u4E09", + thu: "\u56DB", + fri: "\u4E94", + sat: "\u516D" + }, + months: { + jan: "\u4E00\u6708", + feb: "\u4E8C\u6708", + mar: "\u4E09\u6708", + apr: "\u56DB\u6708", + may: "\u4E94\u6708", + jun: "\u516D\u6708", + jul: "\u4E03\u6708", + aug: "\u516B\u6708", + sep: "\u4E5D\u6708", + oct: "\u5341\u6708", + nov: "\u5341\u4E00\u6708", + dec: "\u5341\u4E8C\u6708" + } + }, + select: { + loading: "\u52A0\u8F7D\u4E2D", + noMatch: "\u65E0\u5339\u914D\u6570\u636E", + noData: "\u65E0\u6570\u636E", + placeholder: "\u8BF7\u9009\u62E9" + }, + cascader: { + noMatch: "\u65E0\u5339\u914D\u6570\u636E", + loading: "\u52A0\u8F7D\u4E2D", + placeholder: "\u8BF7\u9009\u62E9", + noData: "\u6682\u65E0\u6570\u636E" + }, + pagination: { + goto: "\u524D\u5F80", + pagesize: "\u6761/\u9875", + total: "\u5171 {total} \u6761", + pageClassifier: "\u9875", + page: "\u9875", + prev: "\u4E0A\u4E00\u9875", + next: "\u4E0B\u4E00\u9875", + currentPage: "\u7B2C {pager} \u9875", + prevPages: "\u5411\u524D {pager} \u9875", + nextPages: "\u5411\u540E {pager} \u9875", + deprecationWarning: "\u4F60\u4F7F\u7528\u4E86\u4E00\u4E9B\u5DF2\u88AB\u5E9F\u5F03\u7684\u7528\u6CD5\uFF0C\u8BF7\u53C2\u8003 el-pagination \u7684\u5B98\u65B9\u6587\u6863" + }, + messagebox: { + title: "\u63D0\u793A", + confirm: "\u786E\u5B9A", + cancel: "\u53D6\u6D88", + error: "\u8F93\u5165\u7684\u6570\u636E\u4E0D\u5408\u6CD5!" + }, + upload: { + deleteTip: "\u6309 delete \u952E\u53EF\u5220\u9664", + delete: "\u5220\u9664", + preview: "\u67E5\u770B\u56FE\u7247", + continue: "\u7EE7\u7EED\u4E0A\u4F20" + }, + table: { + emptyText: "\u6682\u65E0\u6570\u636E", + confirmFilter: "\u7B5B\u9009", + resetFilter: "\u91CD\u7F6E", + clearFilter: "\u5168\u90E8", + sumText: "\u5408\u8BA1" + }, + tree: { + emptyText: "\u6682\u65E0\u6570\u636E" + }, + transfer: { + noMatch: "\u65E0\u5339\u914D\u6570\u636E", + noData: "\u65E0\u6570\u636E", + titles: ["\u5217\u8868 1", "\u5217\u8868 2"], + filterPlaceholder: "\u8BF7\u8F93\u5165\u641C\u7D22\u5185\u5BB9", + noCheckedFormat: "\u5171 {total} \u9879", + hasCheckedFormat: "\u5DF2\u9009 {checked}/{total} \u9879" + }, + image: { + error: "\u52A0\u8F7D\u5931\u8D25" + }, + pageHeader: { + title: "\u8FD4\u56DE" + }, + popconfirm: { + confirmButtonText: "\u786E\u5B9A", + cancelButtonText: "\u53D6\u6D88" + } + } +}; diff --git a/apps/web/vue3/static/cdn/zh-cn.mjs b/apps/web/vue3/static/cdn/zh-cn.mjs new file mode 100644 index 000000000..ed050d71c --- /dev/null +++ b/apps/web/vue3/static/cdn/zh-cn.mjs @@ -0,0 +1,130 @@ +/*! Element Plus v2.3.14 */ + +var zhCn = { + name: "zh-cn", + el: { + colorpicker: { + confirm: "\u786E\u5B9A", + clear: "\u6E05\u7A7A" + }, + datepicker: { + now: "\u6B64\u523B", + today: "\u4ECA\u5929", + cancel: "\u53D6\u6D88", + clear: "\u6E05\u7A7A", + confirm: "\u786E\u5B9A", + selectDate: "\u9009\u62E9\u65E5\u671F", + selectTime: "\u9009\u62E9\u65F6\u95F4", + startDate: "\u5F00\u59CB\u65E5\u671F", + startTime: "\u5F00\u59CB\u65F6\u95F4", + endDate: "\u7ED3\u675F\u65E5\u671F", + endTime: "\u7ED3\u675F\u65F6\u95F4", + prevYear: "\u524D\u4E00\u5E74", + nextYear: "\u540E\u4E00\u5E74", + prevMonth: "\u4E0A\u4E2A\u6708", + nextMonth: "\u4E0B\u4E2A\u6708", + year: "\u5E74", + month1: "1 \u6708", + month2: "2 \u6708", + month3: "3 \u6708", + month4: "4 \u6708", + month5: "5 \u6708", + month6: "6 \u6708", + month7: "7 \u6708", + month8: "8 \u6708", + month9: "9 \u6708", + month10: "10 \u6708", + month11: "11 \u6708", + month12: "12 \u6708", + weeks: { + sun: "\u65E5", + mon: "\u4E00", + tue: "\u4E8C", + wed: "\u4E09", + thu: "\u56DB", + fri: "\u4E94", + sat: "\u516D" + }, + months: { + jan: "\u4E00\u6708", + feb: "\u4E8C\u6708", + mar: "\u4E09\u6708", + apr: "\u56DB\u6708", + may: "\u4E94\u6708", + jun: "\u516D\u6708", + jul: "\u4E03\u6708", + aug: "\u516B\u6708", + sep: "\u4E5D\u6708", + oct: "\u5341\u6708", + nov: "\u5341\u4E00\u6708", + dec: "\u5341\u4E8C\u6708" + } + }, + select: { + loading: "\u52A0\u8F7D\u4E2D", + noMatch: "\u65E0\u5339\u914D\u6570\u636E", + noData: "\u65E0\u6570\u636E", + placeholder: "\u8BF7\u9009\u62E9" + }, + cascader: { + noMatch: "\u65E0\u5339\u914D\u6570\u636E", + loading: "\u52A0\u8F7D\u4E2D", + placeholder: "\u8BF7\u9009\u62E9", + noData: "\u6682\u65E0\u6570\u636E" + }, + pagination: { + goto: "\u524D\u5F80", + pagesize: "\u6761/\u9875", + total: "\u5171 {total} \u6761", + pageClassifier: "\u9875", + page: "\u9875", + prev: "\u4E0A\u4E00\u9875", + next: "\u4E0B\u4E00\u9875", + currentPage: "\u7B2C {pager} \u9875", + prevPages: "\u5411\u524D {pager} \u9875", + nextPages: "\u5411\u540E {pager} \u9875", + deprecationWarning: "\u4F60\u4F7F\u7528\u4E86\u4E00\u4E9B\u5DF2\u88AB\u5E9F\u5F03\u7684\u7528\u6CD5\uFF0C\u8BF7\u53C2\u8003 el-pagination \u7684\u5B98\u65B9\u6587\u6863" + }, + messagebox: { + title: "\u63D0\u793A", + confirm: "\u786E\u5B9A", + cancel: "\u53D6\u6D88", + error: "\u8F93\u5165\u7684\u6570\u636E\u4E0D\u5408\u6CD5!" + }, + upload: { + deleteTip: "\u6309 delete \u952E\u53EF\u5220\u9664", + delete: "\u5220\u9664", + preview: "\u67E5\u770B\u56FE\u7247", + continue: "\u7EE7\u7EED\u4E0A\u4F20" + }, + table: { + emptyText: "\u6682\u65E0\u6570\u636E", + confirmFilter: "\u7B5B\u9009", + resetFilter: "\u91CD\u7F6E", + clearFilter: "\u5168\u90E8", + sumText: "\u5408\u8BA1" + }, + tree: { + emptyText: "\u6682\u65E0\u6570\u636E" + }, + transfer: { + noMatch: "\u65E0\u5339\u914D\u6570\u636E", + noData: "\u65E0\u6570\u636E", + titles: ["\u5217\u8868 1", "\u5217\u8868 2"], + filterPlaceholder: "\u8BF7\u8F93\u5165\u641C\u7D22\u5185\u5BB9", + noCheckedFormat: "\u5171 {total} \u9879", + hasCheckedFormat: "\u5DF2\u9009 {checked}/{total} \u9879" + }, + image: { + error: "\u52A0\u8F7D\u5931\u8D25" + }, + pageHeader: { + title: "\u8FD4\u56DE" + }, + popconfirm: { + confirmButtonText: "\u786E\u5B9A", + cancelButtonText: "\u53D6\u6D88" + } + } +}; + diff --git a/apps/web/vue3/static/conversation-sidebar.js b/apps/web/vue3/static/conversation-sidebar.js index 87a3f8aba..8c020d3db 100755 --- a/apps/web/vue3/static/conversation-sidebar.js +++ b/apps/web/vue3/static/conversation-sidebar.js @@ -1,373 +1,373 @@ /** * Conversation Sidebar Component - * 类似ChatGPT的会话侧边栏组件 + * Conversation Sidebar component */ const ConversationSidebar = { - template: ` -
- - - - - - - -
- -
-
今天
-
-
- -
-
-
{{ conv.title }}
-
- {{ conv.gene_family }} - {{ conv.message_count }}条消息 -
-
{{ conv.last_message }}
-
-
- - - - -
-
-
- - -
-
昨天
-
-
- -
-
-
{{ conv.title }}
-
- {{ conv.gene_family }} - {{ conv.message_count }}条 -
-
-
-
- - -
-
过去7天
-
-
- -
-
-
{{ conv.title }}
-
- {{ conv.gene_family }} -
-
-
-
- - -
- -

暂无会话

-

点击上方"New Chat"开始新的基因家族分析

-
-
- - - -
- `, - - props: { - userId: { - type: String, - default: 'xia-lab' - }, - apiBaseUrl: { - type: String, - default: 'http://localhost:8000/api' - } - }, - - data() { - return { - isCollapsed: false, - searchQuery: '', - conversations: [], - currentConversationId: null, - loading: false, - // 图标 - Plus: ElementPlusIconsVue.Plus, - ArrowLeft: ElementPlusIconsVue.ArrowLeft, - ArrowRight: ElementPlusIconsVue.ArrowRight, - ChatDotRound: ElementPlusIconsVue.ChatDotRound, - ChatLineRound: ElementPlusIconsVue.ChatLineRound, - More: ElementPlusIconsVue.More, - Edit: ElementPlusIconsVue.Edit, - Folder: ElementPlusIconsVue.Folder, - Delete: ElementPlusIconsVue.Delete, - UserFilled: ElementPlusIconsVue.UserFilled, - } - }, - - computed: { - filteredConversations() { - if (!this.searchQuery) return this.conversations; - const query = this.searchQuery.toLowerCase(); - return this.conversations.filter(conv => - conv.title.toLowerCase().includes(query) || - (conv.gene_family && conv.gene_family.toLowerCase().includes(query)) || - conv.last_message.toLowerCase().includes(query) - ); - }, - - todayConversations() { - const today = new Date().toDateString(); - return this.filteredConversations.filter(conv => { - const convDate = new Date(conv.updated_at).toDateString(); - return convDate === today; - }); - }, - - yesterdayConversations() { - const yesterday = new Date(Date.now() - 86400000).toDateString(); - return this.filteredConversations.filter(conv => { - const convDate = new Date(conv.updated_at).toDateString(); - return convDate === yesterday; - }); - }, - - olderConversations() { - const yesterday = new Date(Date.now() - 86400000).toDateString(); - return this.filteredConversations.filter(conv => { - const convDate = new Date(conv.updated_at).toDateString(); - return convDate < yesterday; - }); - } - }, - - mounted() { - this.loadConversations(); - // 定时刷新 - this.refreshInterval = setInterval(() => { - this.loadConversations(); - }, 30000); // 30秒刷新一次 - }, - - beforeUnmount() { - if (this.refreshInterval) { - clearInterval(this.refreshInterval); - } - }, - - methods: { - async loadConversations() { - try { - this.loading = true; - const response = await fetch(`${this.apiBaseUrl}/conversations/list/${this.userId}`); - if (response.ok) { - const data = await response.json(); - this.conversations = data.conversations || []; - } - } catch (error) { - console.error('Failed to load conversations:', error); - // 使用模拟数据 - this.conversations = this.getMockConversations(); - } finally { - this.loading = false; - } - }, - - getMockConversations() { - // 模拟数据,用于测试 - return [ - { - conversation_id: 'conv_001', - title: 'At.WRKY基因家族分析', - gene_family: 'WRKY', - species: 'At', - message_count: 12, - last_message: '系统发育树已生成完成', - updated_at: new Date().toISOString() - }, - { - conversation_id: 'conv_002', - title: 'Os.TCP进化分析', - gene_family: 'TCP', - species: 'Os', - message_count: 5, - last_message: '正在构建进化树...', - updated_at: new Date(Date.now() - 3600000).toISOString() - }, - { - conversation_id: 'conv_003', - title: 'HMMER参数配置咨询', - message_count: 8, - last_message: 'E-value建议设置为1e-5', - updated_at: new Date(Date.now() - 86400000).toISOString() - } - ]; - }, - - toggleSidebar() { - this.isCollapsed = !this.isCollapsed; - this.$emit('toggle', this.isCollapsed); - }, - - async createNewConversation() { - try { - const response = await fetch(`${this.apiBaseUrl}/conversations/create`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - user_id: this.userId, - title: '新会话 ' + new Date().toLocaleTimeString() - }) - }); - - if (response.ok) { - const data = await response.json(); - this.currentConversationId = data.conversation_id; - this.$emit('new-conversation', data); - await this.loadConversations(); - } - } catch (error) { - console.error('Failed to create conversation:', error); - // 创建本地会话 - const newConv = { - conversation_id: 'conv_' + Date.now(), - title: '新会话', - message_count: 0, - last_message: '', - updated_at: new Date().toISOString() - }; - this.conversations.unshift(newConv); - this.currentConversationId = newConv.conversation_id; - this.$emit('new-conversation', newConv); - } - }, - - selectConversation(conv) { - this.currentConversationId = conv.conversation_id; - this.$emit('select-conversation', conv); - }, - - async renameConversation(conv) { - const newTitle = prompt('请输入新标题:', conv.title); - if (newTitle && newTitle !== conv.title) { - try { - await fetch(`${this.apiBaseUrl}/conversations/update-title`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - conversation_id: conv.conversation_id, - title: newTitle - }) - }); - conv.title = newTitle; - } catch (error) { - console.error('Failed to rename:', error); - conv.title = newTitle; // 本地更新 - } - } - }, - - async archiveConversation(conv) { - try { - await fetch(`${this.apiBaseUrl}/conversations/archive`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - conversation_id: conv.conversation_id - }) - }); - await this.loadConversations(); - } catch (error) { - console.error('Failed to archive:', error); - } - }, - - async deleteConversation(conv) { - if (confirm(`确定要删除会话 "${conv.title}" 吗?`)) { - try { - await fetch(`${this.apiBaseUrl}/conversations/delete`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - conversation_id: conv.conversation_id - }) - }); - await this.loadConversations(); - if (this.currentConversationId === conv.conversation_id) { - this.currentConversationId = null; - this.$emit('select-conversation', null); - } - } catch (error) { - console.error('Failed to delete:', error); - } - } - } - } + template: ` +
+ + + + + + + +
+ +
+
Today
+
+
+ +
+
+
{{ conv.title }}
+
+ {{ conv.gene_family }} + {{ conv.message_count }} messages +
+
{{ conv.last_message }}
+
+
+ + + + +
+
+
+ + +
+
Yesterday
+
+
+ +
+
+
{{ conv.title }}
+
+ {{ conv.gene_family }} + {{ conv.message_count }} +
+
+
+
+ + +
+
Past 7 days
+
+
+ +
+
+
{{ conv.title }}
+
+ {{ conv.gene_family }} +
+
+
+
+ + +
+ +

No conversations yet

+

Click "New Chat" to start gene family analysis

+
+
+ + + +
+ `, + + props: { + userId: { + type: String, + default: 'xia-lab' + }, + apiBaseUrl: { + type: String, + default: 'http://localhost:8000/api' + } + }, + + data() { + return { + isCollapsed: false, + searchQuery: '', + conversations: [], + currentConversationId: null, + loading: false, + // Icons + Plus: ElementPlusIconsVue.Plus, + ArrowLeft: ElementPlusIconsVue.ArrowLeft, + ArrowRight: ElementPlusIconsVue.ArrowRight, + ChatDotRound: ElementPlusIconsVue.ChatDotRound, + ChatLineRound: ElementPlusIconsVue.ChatLineRound, + More: ElementPlusIconsVue.More, + Edit: ElementPlusIconsVue.Edit, + Folder: ElementPlusIconsVue.Folder, + Delete: ElementPlusIconsVue.Delete, + UserFilled: ElementPlusIconsVue.UserFilled, + } + }, + + computed: { + filteredConversations() { + if (!this.searchQuery) return this.conversations; + const query = this.searchQuery.toLowerCase(); + return this.conversations.filter(conv => + conv.title.toLowerCase().includes(query) || + (conv.gene_family && conv.gene_family.toLowerCase().includes(query)) || + conv.last_message.toLowerCase().includes(query) + ); + }, + + todayConversations() { + const today = new Date().toDateString(); + return this.filteredConversations.filter(conv => { + const convDate = new Date(conv.updated_at).toDateString(); + return convDate === today; + }); + }, + + yesterdayConversations() { + const yesterday = new Date(Date.now() - 86400000).toDateString(); + return this.filteredConversations.filter(conv => { + const convDate = new Date(conv.updated_at).toDateString(); + return convDate === yesterday; + }); + }, + + olderConversations() { + const yesterday = new Date(Date.now() - 86400000).toDateString(); + return this.filteredConversations.filter(conv => { + const convDate = new Date(conv.updated_at).toDateString(); + return convDate < yesterday; + }); + } + }, + + mounted() { + this.loadConversations(); + // Auto-refresh + this.refreshInterval = setInterval(() => { + this.loadConversations(); + }, 30000); // 30 seconds refresh interval + }, + + beforeUnmount() { + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + } + }, + + methods: { + async loadConversations() { + try { + this.loading = true; + const response = await fetch(`${this.apiBaseUrl}/conversations/list/${this.userId}`); + if (response.ok) { + const data = await response.json(); + this.conversations = data.conversations || []; + } + } catch (error) { + console.error('Failed to load conversations:', error); + // Use mock data + this.conversations = this.getMockConversations(); + } finally { + this.loading = false; + } + }, + + getMockConversations() { + // Mock data for testing + return [ + { + conversation_id: 'conv_001', + title: 'At.WRKY gene family analysis', + gene_family: 'WRKY', + species: 'At', + message_count: 12, + last_message: 'Phylogenetic tree done', + updated_at: new Date().toISOString() + }, + { + conversation_id: 'conv_002', + title: 'Os.TCP evolution analysis', + gene_family: 'TCP', + species: 'Os', + message_count: 5, + last_message: 'Building tree...', + updated_at: new Date(Date.now() - 3600000).toISOString() + }, + { + conversation_id: 'conv_003', + title: 'HMMER config consultation', + message_count: 8, + last_message: 'E-value 1e-5 recommended', + updated_at: new Date(Date.now() - 86400000).toISOString() + } + ]; + }, + + toggleSidebar() { + this.isCollapsed = !this.isCollapsed; + this.$emit('toggle', this.isCollapsed); + }, + + async createNewConversation() { + try { + const response = await fetch(`${this.apiBaseUrl}/conversations/create`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user_id: this.userId, + title: 'New Chat ' + new Date().toLocaleTimeString() + }) + }); + + if (response.ok) { + const data = await response.json(); + this.currentConversationId = data.conversation_id; + this.$emit('new-conversation', data); + await this.loadConversations(); + } + } catch (error) { + console.error('Failed to create conversation:', error); + // Create local conversation + const newConv = { + conversation_id: 'conv_' + Date.now(), + title: 'New Chat', + message_count: 0, + last_message: '', + updated_at: new Date().toISOString() + }; + this.conversations.unshift(newConv); + this.currentConversationId = newConv.conversation_id; + this.$emit('new-conversation', newConv); + } + }, + + selectConversation(conv) { + this.currentConversationId = conv.conversation_id; + this.$emit('select-conversation', conv); + }, + + async renameConversation(conv) { + const newTitle = prompt('Enter new title:', conv.title); + if (newTitle && newTitle !== conv.title) { + try { + await fetch(`${this.apiBaseUrl}/conversations/update-title`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + conversation_id: conv.conversation_id, + title: newTitle + }) + }); + conv.title = newTitle; + } catch (error) { + console.error('Failed to rename:', error); + conv.title = newTitle; // local update + } + } + }, + + async archiveConversation(conv) { + try { + await fetch(`${this.apiBaseUrl}/conversations/archive`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + conversation_id: conv.conversation_id + }) + }); + await this.loadConversations(); + } catch (error) { + console.error('Failed to archive:', error); + } + }, + + async deleteConversation(conv) { + if (confirm(`Delete conversation "${conv.title}"?`)) { + try { + await fetch(`${this.apiBaseUrl}/conversations/delete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + conversation_id: conv.conversation_id + }) + }); + await this.loadConversations(); + if (this.currentConversationId === conv.conversation_id) { + this.currentConversationId = null; + this.$emit('select-conversation', null); + } + } catch (error) { + console.error('Failed to delete:', error); + } + } + } + } }; -// 导出组件 +// Export component if (typeof module !== 'undefined' && module.exports) { - module.exports = { ConversationSidebar }; + module.exports = { ConversationSidebar }; } diff --git a/apps/web/vue3/static/i18n.js b/apps/web/vue3/static/i18n.js new file mode 100644 index 000000000..770d574fe --- /dev/null +++ b/apps/web/vue3/static/i18n.js @@ -0,0 +1,1911 @@ +// /static/i18n.js — CropAI 中英文翻译对照表 +// 默认中文,由 Vue setup() 中 reactive t() 接管切换 +window.__CROPAI_LANG__ = 'zh'; + +// 全局 t/tp 可能被 Vue reactive 版本覆盖;保留为兼容回退 +function t(key) { + var entry = I18N_MAP[key]; + if (!entry) return '⚠' + key; + var lang = window.__CROPAI_LANG__ || 'zh'; + return entry[lang] || entry['zh'] || key; +} + +// 带参数替换: t('found-node', { name: 'xxx' }) +function tp(key, params) { + var s = t(key); + if (params) { + Object.keys(params).forEach(function(k) { + s = s.replace('{' + k + '}', params[k]); + }); + } + return s; +} + +var I18N_MAP = { + // ========== 通用 ========== + 'send': { zh: '发送', en: 'Send' }, + 'cancel': { zh: '取消', en: 'Cancel' }, + 'confirm': { zh: '确认', en: 'Confirm' }, + 'close': { zh: '关闭', en: 'Close' }, + 'loading': { zh: '加载中...', en: 'Loading...' }, + 'processing': { zh: '处理中...', en: 'Processing...' }, + 'refresh': { zh: '刷新', en: 'Refresh' }, + 'delete': { zh: '删除', en: 'Delete' }, + 'search': { zh: '搜索', en: 'Search' }, + 'download': { zh: '下载', en: 'Download' }, + 'upload': { zh: '上传', en: 'Upload' }, + 'save': { zh: '保存', en: 'Save' }, + 'reset': { zh: '重置', en: 'Reset' }, + 'prefix': { zh: '前缀', en: 'Prefix' }, + 'status': { zh: '状态', en: 'Status' }, + 'copy': { zh: '复制', en: 'Copy' }, + 'new-chat': { zh: 'New Chat', en: 'New Chat' }, + + // ========== agent_status_component ========== + 'agent-assistant': { zh: '🤖 Agent 智能助手', en: '🤖 Agent Assistant' }, + 'standby': { zh: '待机', en: 'Standby' }, + 'enter-question': { zh: '输入您的问题,例如:分析SPL基因家族', en: 'Enter your question, e.g.: Analyze SPL gene family' }, + 'task-list': { zh: '📋 任务列表', en: '📋 Task List' }, + 'user-role': { zh: '👤 用户', en: '👤 User' }, + 'agent-role': { zh: '🤖 Agent', en: '🤖 Agent' }, + 'system-role': { zh: '💬 系统', en: '💬 System' }, + 'error-role': { zh: '❌ 错误', en: '❌ Error' }, + 'execution-role': { zh: '⚙️ 执行', en: '⚙️ Execution' }, + 'planning-role': { zh: '📋 规划', en: '📋 Planning' }, + 'diagnosis-role': { zh: '🔍 诊断', en: '🔍 Diagnosis' }, + 'msg-task-submitted': { zh: '任务已提交: ', en: 'Task submitted: ' }, + 'msg-submit-failed': { zh: '提交失败', en: 'Submission failed' }, + 'msg-request-failed': { zh: '请求失败: ', en: 'Request failed: ' }, + 'msg-task-completed': { zh: '任务完成', en: 'Task completed' }, + 'msg-task-failed': { zh: '任务失败', en: 'Task failed' }, + 'msg-analysis-complete': { zh: '✅ 分析完成! 找到 ', en: '✅ Analysis complete! Found ' }, + 'msg-gene-members': { zh: ' 个基因成员', en: ' gene members' }, + + // ========== index-with-sidebar ========== + 'prefix-placeholder': { zh: '如: Ft.SPL', en: 'e.g. Ft.SPL' }, + 'gf-analysis': { zh: '基因家族分析', en: 'Gene Family Analysis' }, + 'gf-analysis-system': { zh: '🌱 BotanicalBaseAgent - 基因家族分析系统', en: '🌱 BotanicalBaseAgent - Gene Family Analysis System' }, + 'search-conv': { zh: '搜索会话...', en: 'Search conversations...' }, + 'conv-list': { zh: '会话列表', en: 'Conversations' }, + 'msg-count': { zh: '条消息', en: ' messages' }, + 'select-stage': { zh: '选择分析阶段', en: 'Select Stage' }, + 'identify-label': { zh: 'Identify - 基因家族鉴定', en: 'Identify - Gene Family Identification' }, + 'phylogeny-label': { zh: 'Phylogeny - 系统发育分析', en: 'Phylogeny - Phylogenetic Analysis' }, + 'motif-label': { zh: 'Motif - Motif分析', en: 'Motif - Motif Analysis' }, + 'domain-label': { zh: 'Domain - 结构域分析', en: 'Domain - Domain Analysis' }, + 'expression-label': { zh: 'Expression - 表达分析', en: 'Expression - Expression Analysis' }, + 'execute-analysis': { zh: '🚀 执行分析', en: '🚀 Execute Analysis' }, + 'analysis-stage': { zh: '▶ Analysis Stage', en: '▶ Analysis Stage' }, + 'param-settings': { zh: '⚙️ 参数设置', en: '⚙️ Parameters' }, + 'user': { zh: '用户', en: 'User' }, + 'ai-assistant': { zh: 'AI助手', en: 'AI Assistant' }, + 'initial-welcome': { zh: '欢迎来到 "{title}"!我是BotanicalBaseAgent,专注于基因家族分析。请告诉我您想分析哪个基因家族?', en: 'Welcome to "{title}"! I am BotanicalBaseAgent, specialized in gene family analysis. Tell me which gene family you want to analyze?' }, + 'describe-need': { zh: '描述您的需求,如:帮我分析拟南芥SPL基因家族', en: 'Describe your needs, e.g.: Help me analyze the Arabidopsis SPL gene family' }, + 'results-here': { zh: '分析结果将在此显示', en: 'Analysis results will appear here' }, + 'start-new-chat': { zh: '开始一个新的对话', en: 'Start a new conversation' }, + 'or-continue': { zh: '或点击左侧会话继续之前的对话', en: 'Or click a conversation on the left to continue' }, + 'completed': { zh: '已完成', en: 'Completed' }, + 'running': { zh: '运行中', en: 'Running' }, + 'pending': { zh: '等待中', en: 'Pending' }, + 'msg-select-stage-first': { zh: '请先选择分析阶段', en: 'Please select an analysis stage first' }, + 'msg-executing': { zh: '开始执行 ', en: 'Starting execution: ' }, + 'msg-analysis-text': { zh: ' 分析', en: ' analysis' }, + + // ========== kg.html ========== + 'kg-title': { zh: '知识图谱 - V13.1 基因家族分析', en: 'Knowledge Graph - V13.1 Gene Family Analysis' }, + 'kg-visualization': { zh: '🧬 知识图谱可视化', en: '🧬 Knowledge Graph Visualization' }, + 'kg-subtitle': { zh: 'V13.1 基因家族分析系统 - Pecan SPL', en: 'V13.1 Gene Family Analysis System - Pecan SPL' }, + 'back-to-main': { zh: '返回主界面', en: 'Back to Main' }, + 'kg-stats': { zh: '📊 图谱统计', en: '📊 Graph Statistics' }, + 'node-count': { zh: '节点总数', en: 'Total Nodes' }, + 'edge-count': { zh: '关系总数', en: 'Total Edges' }, + 'node-types': { zh: '节点类型', en: 'Node Types' }, + 'edge-types': { zh: '关系类型', en: 'Edge Types' }, + 'node-search': { zh: '🔍 节点搜索', en: '🔍 Node Search' }, + 'enter-gene-name': { zh: '输入基因名或实体...', en: 'Enter gene name or entity...' }, + 'relation-filter-pc': { zh: '🏷️ 关系过滤 (PC语义)', en: '🏷️ Relation Filter (PC Semantic)' }, + 'activation-induction': { zh: '激活/诱导', en: 'Activation/Induction' }, + 'repression-inhibition': { zh: '抑制/下调', en: 'Repression/Inhibition' }, + 'regulation-control': { zh: '调控', en: 'Regulation/Control' }, + 'expression-detection': { zh: '表达', en: 'Expression/Detection' }, + 'association-interaction': { zh: '关联', en: 'Association/Interaction' }, + 'localization': { zh: '定位', en: 'Localization' }, + 'containment-composition': { zh: '包含', en: 'Containment/Composition' }, + 'participation-function': { zh: '参与', en: 'Participation/Function' }, + 'other-rel': { zh: '其他', en: 'Other' }, + 'node-legend-pc': { zh: '🎨 节点图例 (PC语义)', en: '🎨 Node Legend (PC Semantic)' }, + 'edge-legend-pc': { zh: '🔗 关系图例 (PC语义)', en: '🔗 Edge Legend (PC Semantic)' }, + 'fit-view': { zh: '适应视图', en: 'Fit View' }, + 'reset-filters': { zh: '重置过滤', en: 'Reset Filters' }, + 'click-drag-hint': { zh: '💡 点击节点查看详情,拖拽调整布局', en: '💡 Click nodes for details, drag to adjust layout' }, + 'node-detail': { zh: '📋 节点详情', en: '📋 Node Details' }, + 'node-name': { zh: '节点名称', en: 'Node Name' }, + 'node-type-pc': { zh: '节点类型 (PC)', en: 'Node Type (PC)' }, + 'connection-count': { zh: '连接关系数', en: 'Connections' }, + 'click-node-detail': { zh: '点击图谱中的节点查看详情', en: 'Click a node in the graph to view details' }, + 'related-relations': { zh: '🔗 相关关系', en: '🔗 Related Relations' }, + 'more-relations': { zh: '还有 {count} 个关系...', en: '{count} more relations...' }, + 'msg-kg-loaded': { zh: '知识图谱加载完成 (PC语义规范)', en: 'Knowledge graph loaded (PC Semantic)' }, + 'msg-load-data-failed': { zh: '加载数据失败: ', en: 'Data load failed: ' }, + 'msg-node-found': { zh: '找到节点: ', en: 'Node found: ' }, + 'msg-node-not-found': { zh: '未找到匹配的节点', en: 'No matching node found' }, + + // ========== NEW ENTRIES ========== + '1basedinclusivealignmentcolumnendsite': { zh: '1-based inclusive alignment column / 位点终点', en: '1-based inclusive alignment column / End Site' }, + '1basedinclusivealignmentcolumnstartsite': { zh: '1-based inclusive alignment column / 位点起点', en: '1-based inclusive alignment column / Start Site' }, + '11samplesforpipelinesmoketestingonlynotf': { zh: '11 个样本仅用于流程烟雾测试,不用于稳定群体结构推断。', en: '11 samples for pipeline smoke testing only; not for stable population structure inference.' }, + '11samplevcfrequired': { zh: '11 样本 VCF(必填)', en: '11-sample VCF (Required)' }, + '22msareplotregionreplot': { zh: '2.2 MSA Replot / 区间重绘', en: '2.2 MSA Replot / Region Replot' }, + '23treereplotphylogenetictreereplot': { zh: '2.3 Tree Replot / 进化树重绘', en: '2.3 Tree Replot / Phylogenetic Tree Replot' }, + '25subfamilyconfirmation': { zh: '2.5 亚家族确认', en: '2.5 Subfamily Confirmation' }, + 'autoexecutewithcurrentparamsifidle40spau': { zh: '40秒内不操作将使用当前参数自动执行,可随时暂停倒计时后手动确认。', en: 'Auto-execute with current params if idle 40s; pause countdown for manual.' }, + '61promotercustomplot': { zh: '6.1 启动子自定义绘图', en: '6.1 Promoter Custom Plot' }, + '71intraspeciessyntenycircosreplot': { zh: '7.1 种内共线性 Circos 重绘', en: '7.1 Intraspecies Synteny Circos Replot' }, + 'intraspeciessynteny': { zh: '7️⃣ 种内Synteny', en: 'Intraspecies Synteny' }, + 'interspeciessynteny': { zh: '9️⃣ 种间Synteny', en: 'Interspecies Synteny' }, + 'rnaseqanalysis': { zh: '===== RNA-Seq 分析 =====', en: '----- RNA-Seq Analysis -----' }, + 'datapreparation': { zh: '===== 数据准备 =====', en: '----- Data Preparation -----' }, + 'coreanalysis': { zh: '===== 核心分析 =====', en: '----- Core Analysis -----' }, + 'kmaximum': { zh: '>K 最大值K 最小值指定 GWAS K(0=仅报告最佳 K)预览💡 可通过上方', en: 'Tip: via above' }, + 'alignstagedidnotfindupstreamrnaseqqcgene': { zh: 'Align 阶段没有找到上游 RNA-seq QC 生成的 *.clean.fastq.gz。', en: 'Align stage did not find upstream RNA-seq QC generated *.clean.fastq.gz.' }, + 'annotationbackgroundannotationgeneratege': { zh: 'Annotation - 背景注释(生成gene2go/gene2ko', en: 'Annotation - Background Annotation (generate gene2go/gene2ko)' }, + 'applyconfirmfill': { zh: 'Apply 确认填入', en: 'Apply Confirm Fill' }, + 'blastpercentidentitylowerboundfill50when': { zh: 'BLAST percent identity 下限;文献写 identity 50% 时填 50', en: 'BLAST percent identity lower bound; fill 50 when literature reports 50%' }, + 'blastengine': { zh: 'BLAST 引擎', en: 'BLAST Engine' }, + 'blastsignificancethresholdforcandidatesc': { zh: 'BLAST 显著性阈值,用于初步筛选候选基因', en: 'BLAST significance threshold for candidate screening' }, + 'blastreference': { zh: 'BLAST参考', en: 'BLAST Reference' }, + 'bootstrapsize': { zh: 'Bootstrap大小', en: 'Bootstrap Size' }, + 'celeryrestartedqueuecheckcomplete': { zh: 'Celery 已重启并完成队列检查', en: 'Celery Restarted & Queue Check Complete' }, + 'celeryrestartincomplete': { zh: 'Celery 重启未完成', en: 'Celery Restart Incomplete' }, + 'comborevisionparameters': { zh: 'Combo 修订参数', en: 'Combo Revision Parameters' }, + 'comborevisionrequiresatleasttwotypessele': { zh: 'Combo 修订至少选择两个类型。', en: 'Combo revision requires at least two types selected.' }, + 'comboplotrevisionfailed': { zh: 'Combo 图修订失败:', en: 'Combo Plot Revision Failed:' }, + 'comboplotrevisioncompleteswitchedtonewfi': { zh: 'Combo 图修订完成,已切换到新图', en: 'Combo plot revision complete; switched to new figure' }, + 'comboplotrevisionsubmittedtaskid': { zh: 'Combo 图修订已提交:task_id=', en: 'Combo plot revision submitted: task_id=' }, + 'comparativerolespecieseligibleasanalysis': { zh: 'Comparative 角色物种(可作为本次待分析物种)', en: 'Comparative Role Species (eligible as analysis species)' }, + 'demoduleautodetect': { zh: 'DE模块:自动识别', en: 'DE Module: Auto-detect' }, + 'domainparameters': { zh: 'Domain 参数', en: 'Domain Parameters' }, + 'domaincoveragefilterthresholdinpercenteg': { zh: 'Domain 绘图覆盖度过滤阈值,按百分比填写;例如 30 表示 30%', en: 'Domain coverage filter threshold in percent; e.g. 30 means 30%' }, + 'domainuseshmmerhmmscanthisvaluepassedtoe': { zh: 'Domain 阶段使用 HMMER hmmscan 扫描 Pfam;该值传给 --evalue 并用于 i-Evalue 过滤,越小越严格', en: 'Domain uses HMMER hmmscan; this value passed to --evalue for i-Evalue filter (smaller = stricter)' }, + 'ensemblformat': { zh: 'Ensembl 格式', en: 'Ensembl Format' }, + 'expressionusingfpkmdifferentialexpressio': { zh: 'Expression (使用FPKM) - Differential Expression', en: 'Expression (using FPKM) - Differential Expression' }, + 'expressionparametersfpkmtpmmatrix': { zh: 'Expression 参数 (FPKM/TPM矩阵', en: 'Expression Parameters (FPKM/TPM matrix)' }, + 'fastafileleaveemptyforautodiscoverycomma': { zh: 'FASTA文件;可留空自动查找,多个用逗号分隔', en: 'FASTA file; leave empty for auto-discovery; comma-separated for multiples' }, + 'fastqfiles': { zh: 'FASTQ文件', en: 'FASTQ Files' }, + 'fimopvaluethresholddefault1e4': { zh: 'FIMO p-value 阈值,默认 1e-4', en: 'FIMO p-value threshold, default 1e-4' }, + 'fimoqvaluefilterthresholddefault005': { zh: 'FIMO q-value 过滤阈值,默认 0.05', en: 'FIMO q-value filter threshold, default 0.05' }, + 'filescopeunchanged': { zh: 'File_Scope不改', en: 'File_Scope (Unchanged)' }, + 'filetypeunchanged': { zh: 'File_Type不改', en: 'File_Type (Unchanged)' }, + 'gatkexecutionfailed': { zh: 'GATK 执行失败', en: 'GATK Execution Failed' }, + 'geonumber': { zh: 'GEO编号', en: 'GEO Number' }, + 'geonumberinputgeonumbereggse111937autodo': { zh: 'GEO编号 - InputGEO编号(如 GSE111937)自动Download', en: 'GEO Number - Input GEO Number (e.g. GSE111937) Auto-download' }, + 'goenrichment': { zh: 'GO 富集', en: 'GO Enrichment' }, + 'goenrichmentbackgroundmissing': { zh: 'GO 富集背景缺失', en: 'GO Enrichment Background Missing' }, + 'gokeggenrichmentbackgroundmissing': { zh: 'GO/KEGG 富集背景缺失', en: 'GO/KEGG Enrichment Background Missing' }, + 'gokeggenrichmentrequiresgene2goorgene2ko': { zh: 'GO/KEGG 富集需要 gene2go 或 gene2ko 背景映射文件,但当前任务没有找到可用文件。', en: 'GO/KEGG enrichment requires gene2go or gene2ko background mapping files; none found.' }, + 'gwasemmaxplotting': { zh: 'GWAS EMMAX绘图', en: 'GWAS EMMAX Plotting' }, + 'gwasmlmcmlmplotting': { zh: 'GWAS MLM/CMLM绘图', en: 'GWAS MLM/CMLM Plotting' }, + 'gwastasselkinshipmatrix': { zh: 'GWAS TASSEL亲缘关系矩阵', en: 'GWAS TASSEL Kinship Matrix' }, + 'gwasvcfsorthapmapconversion': { zh: 'GWAS VCF排序与HapMap转换', en: 'GWAS VCF Sort & HapMap Conversion' }, + 'gwasspeciesoutput': { zh: 'GWAS 物种与输出', en: 'GWAS Species & Output' }, + 'gwasinputvcf': { zh: 'GWAS 输入 VCF', en: 'GWAS Input VCF' }, + 'gwascovariatepreparation': { zh: 'GWAS协变量准备', en: 'GWAS Covariate Preparation' }, + 'gwasphenotypepreparation': { zh: 'GWAS表型准备', en: 'GWAS Phenotype Preparation' }, + 'genefamilyunchanged': { zh: 'Gene_Family不改', en: 'Gene_Family (Unchanged)' }, + 'genomepreparegenomedatapreprocessing': { zh: 'Genome Prepare - Genome数据预处理', en: 'Genome Prepare - Genome Data Preprocessing' }, + 'genomeprepareparameters': { zh: 'Genome Prepare 参数', en: 'Genome Prepare Parameters' }, + 'hisat2needsgenomefastaorgenomehisat2inde': { zh: 'HISAT2 比对应使用基因组 FASTA 或基因组 HISAT2 index,不能使用 CDS/PEP index。', en: 'HISAT2 needs genome FASTA or genome HISAT2 index, not CDS/PEP index.' }, + 'hmmpfamaudit': { zh: 'HMM/Pfam 审计', en: 'HMM/Pfam Audit' }, + 'hmmerparameterconsultation': { zh: 'HMMER参数配置咨询', en: 'HMMER Parameter Consultation' }, + 'idlistfileegpathtogeneids': { zh: 'ID列表文件,如 /path/to/gene.ids', en: 'ID list file, e.g. /path/to/gene.ids' }, + 'idlistfileegathdzipidsautolookupbyoutput': { zh: 'ID列表文件,如 At.HD-ZIP.ids;留空时按输出前缀自动查找', en: 'ID list file, e.g. At.HD-ZIP.ids; auto-lookup by output prefix' }, + 'iqtreeparamsegmmfpbb1000': { zh: 'IQ-TREE 建树参数,如 -m MFP -bb 1000', en: 'IQ-TREE params, e.g. -m MFP -bb 1000' }, + 'identifyblastreference': { zh: 'Identify BLAST参考', en: 'Identify BLAST Reference' }, + 'identifyblastreferencespeciesegatos': { zh: 'Identify BLAST参考物种,如 At / Os', en: 'Identify BLAST reference species, e.g. At / Os' }, + 'identifyblastreferenceegatos': { zh: 'Identify BLAST参考,例如 At / Os', en: 'Identify BLAST reference, e.g. At / Os' }, + 'identifydomainmemberconfirmation': { zh: 'Identify Domain 成员确认', en: 'Identify Domain Member Confirmation' }, + 'identifyrefinerevisememberidsrebuildprot': { zh: 'Identify Refine - 修订成员ID并重建蛋白', en: 'Identify Refine - Revise Member IDs & Rebuild Proteins' }, + 'identifyparameters': { zh: 'Identify 参数', en: 'Identify Parameters' }, + 'hmmmodelcoveragethresholdforidentify01cl': { zh: 'Identify 阶段 HMM 模型覆盖度阈值,按 0-1 比例填写;对应 CLI --hmm-cov-min', en: 'HMM model coverage threshold for Identify (0-1); CLI --hmm-cov-min' }, + 'hmmerhmmsearchievaluefilterforidentifycl': { zh: 'Identify 阶段使用 HMMER hmmsearch,过滤 domtblout 的 independent domain E-value;对应 CLI --dom-i-evalue-max,越小越严格', en: 'HMMER hmmsearch i-Evalue filter for Identify; CLI --dom-i-evalue-max' }, + 'inputspecieslatinnameegarabidopsisthalia': { zh: 'Input物种拉丁名 (如 Arabidopsis thaliana', en: 'Input Species Latin Name (e.g. Arabidopsis thaliana)' }, + 'inputspecieslatinnameegarabidopsisthalia-2': { zh: 'Input物种拉丁名,如 Arabidopsis thaliana', en: 'Input species Latin name, e.g. Arabidopsis thaliana' }, + 'interspeciessyntenycrossspeciessynteny': { zh: 'Interspecies Synteny - 物种间共线性分析 (跨物种', en: 'Interspecies Synteny - Cross-Species Synteny' }, + 'jgiformat': { zh: 'JGI 格式', en: 'JGI Format' }, + 'jsondataloadedtoconsole': { zh: 'JSON数据已加载到控制台', en: 'JSON data loaded to console' }, + 'kmaximum-2': { zh: 'K 最大值', en: 'K Maximum' }, + 'kminimum-2': { zh: 'K 最小值', en: 'K Minimum' }, + 'keggenrichment': { zh: 'KEGG 富集', en: 'KEGG Enrichment' }, + 'keggenrichmentbackgroundmissing': { zh: 'KEGG 富集背景缺失', en: 'KEGG Enrichment Background Missing' }, + 'kaksanalysis': { zh: 'Ka/Ks分析', en: 'Ka/Ks Analysis' }, + 'kaksparameters': { zh: 'KaKs 参数', en: 'KaKs Parameters' }, + 'knownsplicesitesource': { zh: 'Known splice site 来源', en: 'Known Splice Site Source' }, + 'm1standardgroupcomparison': { zh: 'M1 - 标准组间比较', en: 'M1 - Standard Group Comparison' }, + 'm3multigroupstratifiedcomparison': { zh: 'M3 - 多组合分层比较', en: 'M3 - Multi-group Stratified Comparison' }, + 'm4twowayinteractionanalysis': { zh: 'M4 - 双因素交互分析', en: 'M4 - Two-way Interaction Analysis' }, + 'mafftparamsegautothread1': { zh: 'MAFFT 多序列Alignment参数,如 --auto --thread -1', en: 'MAFFT params, e.g. --auto --thread -1' }, + 'numberofconservedmotifsminedbymeme': { zh: 'MEME 挖掘的保守 Motif 数量', en: 'Number of conserved motifs mined by MEME' }, + 'msaregionplot': { zh: 'MSA Region Plot - MSA区间绘图', en: 'MSA Region Plot' }, + 'msaregionplot-2': { zh: 'MSA区间图 (', en: 'MSA Region Plot (' }, + 'msaregionplotparameters': { zh: 'MSA区间绘图参数', en: 'MSA Region Plot Parameters' }, + 'msaregionreplotcomplete': { zh: 'MSA区间重绘完成', en: 'MSA Region Replot Complete' }, + 'msaregionreplotsubmittedtaskid': { zh: 'MSA区间重绘已提交:task_id=', en: 'MSA region replot submitted: task_id=' }, + 'msasourceautoresolvedfromcurrentimageman': { zh: 'MSA来源:将根据当前图片/manifest自动解析', en: 'MSA source: auto-resolved from current image/manifest' }, + 'minerudocumentparsing': { zh: 'MinerU 文档解析', en: 'MinerU Document Parsing' }, + 'motifparameters': { zh: 'Motif 参数', en: 'Motif Parameters' }, + 'motifmaximumaminoacidlength': { zh: 'Motif 最大氨基酸长度', en: 'Motif Maximum Amino Acid Length' }, + 'motifminimumaminoacidlength': { zh: 'Motif 最小氨基酸长度', en: 'Motif Minimum Amino Acid Length' }, + 'motifanalysis': { zh: 'Motif分析', en: 'Motif Analysis' }, + 'motifcount': { zh: 'Motif数量', en: 'Motif Count' }, + 'ncbitaxideg3702': { zh: 'NCBI TaxID,如 3702', en: 'NCBI TaxID, e.g. 3702' }, + 'ncbiformat': { zh: 'NCBI 格式', en: 'NCBI Format' }, + 'ncbisequencedownload': { zh: 'NCBI序列下载', en: 'NCBI Sequence Download' }, + 'nlruntimegatefailed': { zh: 'NL Runtime Gate 失败', en: 'NL Runtime Gate Failed' }, + 'nlruntimegatepassedstartinguidisplaygate': { zh: 'NL Runtime Gate 已通过,开始 UI Display Gate...', en: 'NL Runtime Gate Passed; Starting UI Display Gate...' }, + 'orgdbspecies': { zh: 'OrgDb物种', en: 'OrgDb Species' }, + 'orthologmapping': { zh: 'Ortholog 映射', en: 'Ortholog Mapping' }, + 'orthologppikakspausedawaitingpepcdsother': { zh: 'Ortholog/PPI/KaKs 已暂停等待 PEP/CDS 和映射;其它可执行阶段将继续提交。', en: 'Ortholog/PPI/KaKs paused awaiting PEP/CDS; other stages continue.' }, + 'ostcpevolutionaryanalysis': { zh: 'Os.TCP进化分析', en: 'Os.TCP Evolutionary Analysis' }, + 'pcacorrelationdistanceheatmapsandvstmatr': { zh: 'PCA、样本相关性热图、样本距离热图和 VST 矩阵通常已经生成;可先查看这些 QC 结果。若要让阶段标记成功,应在代码里对样本数少于 3 的情况跳过聚类树或改成距离热图。', en: 'PCA, correlation/distance heatmaps and VST matrix typically generated; for stage success skip clustering when N<3.' }, + 'ppiresetfailed': { zh: 'PPI Reset 失败', en: 'PPI Reset Failed' }, + 'ppiresetresult': { zh: 'PPI Reset 结果 (', en: 'PPI Reset Result (' }, + 'ppiresetinputgenelist': { zh: 'PPI Reset 输入基因列表', en: 'PPI Reset Input Gene List' }, + 'ppiparameters': { zh: 'PPI 参数', en: 'PPI Parameters' }, + 'ppiparametersproteininteraction': { zh: 'PPI 参数 (Protein Interaction', en: 'PPI Parameters (Protein Interaction)' }, + 'ppireferencespeciesnotconfirmed': { zh: 'PPI 参考物种未确认', en: 'PPI Reference Species Not Confirmed' }, + 'ppitechnicalrefmustcomefromconfirmedstri': { zh: 'PPI 的技术参考必须来自已确认的 STRING 候选;项目参考物种不会写入 PPI owner。', en: 'PPI technical ref must come from confirmed STRING candidates; project species not written to PPI owner.' }, + 'ppinearsourcestringrefnotconfirmedorthol': { zh: 'PPI 近源 STRING 参考物种尚未确认;本次不提交 Ortholog 或 PPI。', en: 'PPI near-source STRING ref not confirmed; Ortholog/PPI not submitted.' }, + 'ppikaksdownloadfailurefallbackconfirmati': { zh: 'PPI/KaKs 下载失败兜底确认', en: 'PPI/KaKs Download Failure Fallback Confirmation' }, + 'ppikaksfallbackconfirmation': { zh: 'PPI/KaKs 兜底确认', en: 'PPI/KaKs Fallback Confirmation' }, + 'ppikaksreferencedata': { zh: 'PPI/KaKs 参考数据', en: 'PPI/KaKs Reference Data' }, + 'ppikaksreferencedataorthologprecheckfail': { zh: 'PPI/KaKs 参考数据/同源映射预检未通过。', en: 'PPI/KaKs reference data/ortholog pre-check failed.' }, + 'ppikaksreferencedataprocessing': { zh: 'PPI/KaKs 参考数据处理', en: 'PPI/KaKs Reference Data Processing' }, + 'ppikakscannotexecuterefspeciesdatanotrea': { zh: 'PPI/KaKs 暂时不能直接执行:参考物种数据还没准备好。', en: 'PPI/KaKs cannot execute: ref species data not ready.' }, + 'ppikaksneedsrefspeciesorthologprocessing': { zh: 'PPI/KaKs 需要先处理参考物种/Ortholog', en: 'PPI/KaKs needs ref species/Ortholog processing first' }, + 'ppikaksrequiresrefspeciespepcdsandorthol': { zh: 'PPI/KaKs 需要参考物种 PEP/CDS 和 Ortholog 映射,已先暂停相关阶段。', en: 'PPI/KaKs requires ref species PEP/CDS and Ortholog mapping; related stages paused.' }, + 'ppikaksprecheckfailed': { zh: 'PPI/KaKs 预检失败', en: 'PPI/KaKs Pre-check Failed' }, + 'ppianalysis': { zh: 'PPI分析', en: 'PPI Analysis' }, + 'phylogenyparameters': { zh: 'Phylogeny 参数', en: 'Phylogeny Parameters' }, + 'qctools': { zh: 'QC 工具', en: 'QC Tools' }, + 'rreturnedanerrorprecedingwarningsmaynotb': { zh: 'R 已返回错误。前面的 warning 不一定是失败原因,真正原因通常在日志最后的“错误于/停止执行”附近。', en: 'R returned an error. Preceding warnings may not be the cause; check near "Error in/Execution halted" at log end.' }, + 'rscriptexecutionfailed': { zh: 'R 脚本执行失败', en: 'R Script Execution Failed' }, + 'ragmethodologyextractionlaunchfailed': { zh: 'RAG 方法学提取启动失败', en: 'RAG Methodology Extraction Launch Failed' }, + 'ragsource': { zh: 'RAG来源', en: 'RAG Source' }, + 'ragstreamresponsereturnednofinalanswer': { zh: 'RAG流式响应未返回最终回答', en: 'RAG Stream Response Returned No Final Answer' }, + 'ragstreamresponseparsefailed': { zh: 'RAG流式响应解析失败', en: 'RAG Stream Response Parse Failed' }, + 'ragstreamrequestfailed': { zh: 'RAG流式请求失败', en: 'RAG Stream Request Failed' }, + 'raginterpretationrefreshfailed': { zh: 'RAG解读刷新失败', en: 'RAG Interpretation Refresh Failed' }, + 'raginterpretationenhanced': { zh: 'RAG解读增强', en: 'RAG Interpretation Enhanced' }, + 'raginterpretationrefreshed': { zh: 'RAG解读已刷新', en: 'RAG Interpretation Refreshed' }, + 'ragrequestfailed': { zh: 'RAG请求失败', en: 'RAG Request Failed' }, + 'rnaseqcandidatefamilycandidatefamilyscre': { zh: 'RNA-Seq Candidate Family - 候选家族筛选', en: 'RNA-Seq Candidate Family - Candidate Family Screening' }, + 'rnaseqdedifferentialexpression': { zh: 'RNA-Seq DE - 差异表达', en: 'RNA-Seq DE - Differential Expression' }, + 'rnaseqgoenrichgoenrichmentanalysis': { zh: 'RNA-Seq GO Enrich - GO富集分析', en: 'RNA-Seq GO Enrich - GO Enrichment Analysis' }, + 'rnaseqkeggenrichkeggenrichmentanalysis': { zh: 'RNA-Seq KEGG Enrich - KEGG富集分析', en: 'RNA-Seq KEGG Enrich - KEGG Enrichment Analysis' }, + 'rnaseqsampleqcsamplequalityassessment': { zh: 'RNA-Seq Sample QC - 样本质量评估', en: 'RNA-Seq Sample QC - Sample Quality Assessment' }, + 'rnaseqwgcnaweightedcoexpressionanalysis': { zh: 'RNA-Seq WGCNA - 加权共表达分析', en: 'RNA-Seq WGCNA - Weighted Co-expression Analysis' }, + 'rnaseqfgsearankedenrichmentanalysis': { zh: 'RNA-Seq fgsea - 排序富集分析', en: 'RNA-Seq fgsea - Ranked Enrichment Analysis' }, + 'rnaseqalignparameters': { zh: 'RNA-seq Align 参数', en: 'RNA-seq Align Parameters' }, + 'rnaseqalignparametersalignmentstep2': { zh: 'RNA-seq Align 参数 (Alignment - Step 2', en: 'RNA-seq Align Parameters (Alignment - Step 2)' }, + 'rnaseqcandidateparameters': { zh: 'RNA-seq Candidate 参数', en: 'RNA-seq Candidate Parameters' }, + 'rnaseqdeparametersstep4': { zh: 'RNA-seq DE 参数 (差异表达 - Step 4', en: 'RNA-seq DE Parameters (Step 4)' }, + 'rnaseqenrichparametersstep5': { zh: 'RNA-seq Enrich 参数 (Enrichment Analysis - Step 5', en: 'RNA-seq Enrich Parameters (Step 5)' }, + 'rnaseqfgseaparameters': { zh: 'RNA-seq FGSEA 参数', en: 'RNA-seq FGSEA Parameters' }, + 'rnaseqqcparametersstep1': { zh: 'RNA-seq QC 参数 (Quality Control - Step 1', en: 'RNA-seq QC Parameters (Step 1)' }, + 'rnaseqqctoolparameters': { zh: 'RNA-seq QC 工具参数', en: 'RNA-seq QC Tool Parameters' }, + 'rnaseqquantparametersstep3': { zh: 'RNA-seq Quant 参数 (Quantification - Step 3', en: 'RNA-seq Quant Parameters (Step 3)' }, + 'rnaseqwgcnaparameters': { zh: 'RNA-seq WGCNA 参数', en: 'RNA-seq WGCNA Parameters' }, + 'rnaseqanalysischainconfirmation': { zh: 'RNA-seq 分析链路确认', en: 'RNA-seq Analysis Chain Confirmation' }, + 'rnaseqparameters': { zh: 'RNA-seq 参数', en: 'RNA-seq Parameters' }, + 'rnaseqquantificationparameters': { zh: 'RNA-seq 定量参数', en: 'RNA-seq Quantification Parameters' }, + 'rnaseqenrichmentbackgroundmissing': { zh: 'RNA-seq 富集背景缺失', en: 'RNA-seq Enrichment Background Missing' }, + 'rnaseqenrichmentadvancedparameters': { zh: 'RNA-seq 富集高级参数', en: 'RNA-seq Enrichment Advanced Parameters' }, + 'rnaseqdifferentialexpressionparameters': { zh: 'RNA-seq 差异分析参数', en: 'RNA-seq Differential Expression Parameters' }, + 'rnaseqalignmentreferencefileincorrect': { zh: 'RNA-seq 比对参考文件不对', en: 'RNA-seq Alignment Reference File Incorrect' }, + 'rnaseqspeciesinfoforkeggfgseaenrichment': { zh: 'RNA-seq 物种信息(KEGG/fgsea/富集用)', en: 'RNA-seq Species Info (for KEGG/fgsea/Enrichment)' }, + 'refunchanged': { zh: 'Ref不改', en: 'Ref (Unchanged)' }, + 'snpdensityanalysis': { zh: 'SNP密度分析', en: 'SNP Density Analysis' }, + 'stringscorethresholddefault200': { zh: 'STRING score阈值,默认200', en: 'STRING score threshold, default 200' }, + 'stringlinkfilecontent': { zh: 'STRING 链接文件内容:', en: 'STRING Link File Content:' }, + 'salmontximporttpmentersexpressionplotsaf': { zh: 'Salmon/tximport TPM 只在 bridge QC 通过后进入 expression plots;tximport counts_from_abundance 不作为默认 DESeq2 输入。', en: 'Salmon/tximport TPM enters expression plots after bridge QC; tximport counts_from_abundance not default DESeq2 input.' }, + 'scorethreshold': { zh: 'Score阈值', en: 'Score Threshold' }, + 'selectgenomedatasourceformatautowillauto': { zh: 'SelectGenome数据来源格式,auto会自动检测', en: 'SelectGenome data source format; auto will auto-detect' }, + 'selectidconversionmethod': { zh: 'SelectID转换方式', en: 'SelectID Conversion Method' }, + 'selectinputmode': { zh: 'SelectInput模式', en: 'SelectInput Mode' }, + 'selectinputmodeuploadmanualrefgenomegtfd': { zh: 'SelectInput模式:上传模式需要手动上传参考Genome和GTF,Download模式可自动检索物种数据', en: 'SelectInput Mode: Upload=manual ref Genome+GTF; Download=auto-retrieve' }, + 'selectsession': { zh: 'Select会话', en: 'Select Session' }, + 'selectexistingworkingdirectory': { zh: 'Select已有Working Directory', en: 'Select Existing Working Directory' }, + 'selectdatabasetype': { zh: 'Select数据库类型', en: 'Select Database Type' }, + 'selectdatasource': { zh: 'Select数据来源', en: 'Select Data Source' }, + 'selectdatasourceuploadfilesorspecifyexis': { zh: 'Select数据来源方式:Upload Files或指定已有数据路径', en: 'Select Data Source: Upload Files or specify existing data path' }, + 'selectstage': { zh: 'Select阶段', en: 'Select Stage' }, + 'subfamilyconfirmconfirmsubfamilylabels': { zh: 'Subfamily Confirm - 确认亚家族标签', en: 'Subfamily Confirm - Confirm Subfamily Labels' }, + 'syntenyintraspeciessyntenymcscanxcircos': { zh: 'Synteny - 种内共线性分析 (MCScanX / Circos', en: 'Synteny - Intraspecies Synteny (MCScanX / Circos)' }, + 'tfbsparameters': { zh: 'TFBS 参数', en: 'TFBS Parameters' }, + 'tfbindingsiteprediction': { zh: 'TF结合位点预测', en: 'TF Binding Site Prediction' }, + 'targetrolespecieseligibleasanalysisspeci': { zh: 'Target 角色物种(可作为本次待分析物种)', en: 'Target Role Species (eligible as analysis species)' }, + 'tipmarkshape': { zh: 'Tip标记形状', en: 'Tip Mark Shape' }, + 'uidisplaygatefailed': { zh: 'UI Display Gate 失败', en: 'UI Display Gate Failed' }, + 'vcfallcontigs': { zh: 'VCF 全部 contig', en: 'VCF All Contigs' }, + 'wgs2gwasgeneticvariationpopulationanalys': { zh: 'WGS2GWAS(遗传变异与群体分析)', en: 'WGS2GWAS (Genetic Variation & Population Analysis)' }, + 'yesconfirmed': { zh: 'Yes,确认无误', en: 'Yes, Confirmed' }, + 'sessioncreatefailed': { zh: '[会话] 创建失败', en: '[Session] Create Failed' }, + 'sessiondeletefailed': { zh: '[会话] 删除失败', en: '[Session] Delete Failed' }, + 'sessionloaded': { zh: '[会话] 加载了', en: '[Session] Loaded' }, + 'sessionloadhistoryfailed': { zh: '[会话] 加载历史消息失败', en: '[Session] Load History Failed' }, + 'sessionloadfailed': { zh: '[会话] 加载失败', en: '[Session] Load Failed' }, + 'sessionstagescleared': { zh: '[会话] 已清空 stages 状态', en: '[Session] Stages Cleared' }, + 'sessionjobbound': { zh: '[会话] 已绑定 job', en: '[Session] Job Bound' }, + 'sessionjobrestored': { zh: '[会话] 恢复 job', en: '[Session] Job Restored' }, + 'sessionmessagepersistfailed': { zh: '[会话] 消息持久化失败', en: '[Session] Message Persist Failed' }, + 'sessionjobinfocleared': { zh: '[会话] 清空 jobInfo', en: '[Session] JobInfo Cleared' }, + 'sessionstagescleared-2': { zh: '[会话] 清空 stages', en: '[Session] Stages Cleared' }, + 'sessionmessagesclearednewsession': { zh: '[会话] 清空消息,新建会话', en: '[Session] Messages Cleared, New Session' }, + 'sessionbindjobfailed': { zh: '[会话] 绑定 job 失败', en: '[Session] Bind Job Failed' }, + 'sessionautocreatedsession': { zh: '[会话] 自动创建会话', en: '[Session] Auto-Created Session' }, + 'sessionautocreatefailedmessagesinpagemem': { zh: '[会话] 自动创建失败,消息仅保存在当前页面内存', en: '[Session] Auto-Create Failed; messages in page memory' }, + 'sessionautorestorerecentmessages': { zh: '[会话] 自动恢复最近会话消息', en: '[Session] Auto-Restore Recent Messages' }, + 'deepinterpretationrequestfailed': { zh: '[深度解读] 请求失败', en: '[Deep Interpretation] Request Failed' }, + 'alignmentcolumn': { zh: 'alignment列', en: 'Alignment Column' }, + 'configautodetectionsampleautodetection': { zh: 'autofillState.type === \'config\' ? \'配置表自动识别\' : \'样本表自动识别', en: 'Config Auto-detection / Sample Auto-detection' }, + 'filtermatchedrowsbytype': { zh: 'autofillState.type === \'samples\' ? \'筛选匹配行:SRR / Con-1 / CK / T0 / rep1 / fastq 前缀\' : \'筛选匹配行:At.SPL / Cpapaya_113_ / Lc / gff / pep', en: 'Filter matched rows by type' }, + 'bbav5parameters': { zh: 'bbav5 参数', en: 'bbav5 Parameters' }, + 'bbav5alignmentaudit': { zh: 'bbav5 对齐与审计', en: 'bbav5 Alignment & Audit' }, + 'bbav5tools': { zh: 'bbav5 工具', en: 'bbav5 Tools' }, + 'comparativespecies': { zh: 'comparative 比较物种', en: 'Comparative Species' }, + 'comparativespeciesforcomparisonnotchange': { zh: 'comparative 比较物种(参与比较/分类,不改为 target)', en: 'Comparative Species (for comparison; not changed to target)' }, + 'conditionunchanged': { zh: 'condition不改', en: 'Condition (Unchanged)' }, + 'confidencehighconfirmablemediumreviewlow': { zh: 'confidence: high 可确认,medium 建议检查,low 必须人工补全;正式写入前会自动备份旧文件。', en: 'Confidence: high=confirmable, medium=review, low=manual completion; auto-backup before write.' }, + 'countmatrixcontainsnonnumericcolumns': { zh: 'count matrix 含非数字列', en: 'Count matrix contains non-numeric columns' }, + 'samplenamesincountmatrixnotfoundinsample': { zh: 'count matrix 里的样本名没有在 sample_table.tsv/samples.yaml 中找到,或样本表缺少 sample/condition 列。', en: 'Sample names in count matrix not found in sample table, or missing sample/condition column.' }, + 'withoutmanualfilteringonlymembershitting': { zh: 'domainReviewSource === \'member_evidence_fallback\' ? \'未找到 InterProScan domain_review.tsv,当前显示 final_members 与 HMM/BLAST 证据;默认保留全部 final_members,确认后才继续方法学下游分析。\' : \'如果不手动筛选,系统默认只保留命中本基因家族目标结构域的成员;你也可以按 domain、e-value/score 和 coverage 调整后重新生成最终成员蛋白。', en: 'Without manual filtering, only members hitting target domain kept; adjust by domain, e-value/score and coverage.' }, + 'failabortondiscoverystrict': { zh: 'fail:发现即终止(严格)', en: 'fail: abort on discovery (strict)' }, + 'fastpexecutable': { zh: 'fastp 可执行文件', en: 'fastp Executable' }, + 'gene2godoesnotmatchdegeneids': { zh: 'gene2go 与 DE 基因 ID 不匹配', en: 'gene2go does not match DE gene IDs' }, + 'gene2gogobackground': { zh: 'gene2go(GO 背景)', en: 'gene2go (GO Background)' }, + 'gene2kokeggbackground': { zh: 'gene2ko(KEGG 背景)', en: 'gene2ko (KEGG Background)' }, + 'identifydonemethodologyneedsgenefamilyme': { zh: 'identify 已完成,但方法学链路需要先确认基因家族成员表', en: 'Identify done; methodology needs gene family member table confirmation' }, + 'includeunchanged': { zh: 'include不改', en: 'Include (Unchanged)' }, + 'gwasstagecheck': { zh: 'isGwasStage(confirmParams.stage) ? \'At / Os / Zm\' : \'留空则自动推断', en: 'GWAS stage check' }, + 'describerevisioncontenthere': { zh: 'lightweightExtensionConversation.pendingRevisionCardId ? \'请在这里说明需要修订的内容,然后发送。\' : (preferenceMode ? \'Ask a question about methods or biology...\' : \'Describe what you want to run (default prefix At).', en: 'Describe revision content here.' }, + 'log2fcthreshold': { zh: 'log2FC| 阈值', en: '|log2FC| Threshold' }, + 'maskexcludesmarkerstasselhapmapcannotrep': { zh: 'mask 会排除 TASSEL/HapMap 无法无损表示的标记;输出仅保留可严格复现的标记子集。', en: 'mask excludes markers TASSEL/HapMap cannot represent losslessly; only reproducible markers retained.' }, + 'maskandcontinuelossy': { zh: 'mask:屏蔽后继续(有损)', en: 'Mask and continue (lossy)' }, + 'planningstagefailedtogenerateexecutablew': { zh: 'n\\n规划阶段未能生成可执行流程,请查看后端日志中的具体原因。', en: 'Planning stage failed to generate executable workflow; check backend logs.' }, + 'padjthreshold': { zh: 'padj 阈值', en: 'padj Threshold' }, + 'pfamid': { zh: 'pfam号', en: 'Pfam ID' }, + 'plantairestartscriptorrestartplantai9000': { zh: 'projectRestartAvailable ? \'提交配置好的项目重启脚本\' : \'未配置 PLANTAI_RESTART_SCRIPT 或 restart_plantai_9000.sh', en: 'PLANTAI_RESTART_SCRIPT or restart_plantai_9000.sh not configured' }, + 'qrtpcrvalidation': { zh: 'qRT-PCR 验证', en: 'qRT-PCR Validation' }, + 'referencespecies': { zh: 'reference 参考物种', en: 'Reference Species' }, + 'referencespeciespreliminarytechnicalrefd': { zh: 'reference 参考物种(仅作为初步推荐,执行前将确定技术参考物种)', en: 'Reference Species (preliminary; technical ref determined before execution)' }, + 'referencespeciesforlabelmigrationonly': { zh: 'reference 参考物种(仅用于标签迁移)', en: 'Reference Species (for label migration only)' }, + 'replicateid': { zh: 'replicate(重复编号', en: 'Replicate (ID)' }, + 'replicateunchanged': { zh: 'replicate不改', en: 'Replicate (Unchanged)' }, + 'rununchanged': { zh: 'run不改', en: 'Run (Unchanged)' }, + 'sampleidunchanged': { zh: 'sample_id不改', en: 'Sample ID (Unchanged)' }, + 'samplesyamlbulkeditarea': { zh: 'samples.yaml 批量修订区', en: 'samples.yaml Bulk Edit Area' }, + 'splicesiteannotationpath': { zh: 'splice site / 注释路径', en: 'Splice Site / Annotation Path' }, + 'targetspecies': { zh: 'target 目标物种', en: 'Target Species' }, + 'targetcomparativerefinsametreecomboplota': { zh: 'target、comparative 与 reference 放入同一棵树、同一张组合证据图和一张联合亚家族确认表;确认表只包含待分类物种。', en: 'Target, comparative & ref in same tree, combo plot and joint confirmation table.' }, + 'timestagetimeseriesonly': { zh: 'time/stage(仅时间序列', en: 'Time/Stage (time series only)' }, + 'timestageunchanged': { zh: 'time/stage不改', en: 'Time/Stage (Unchanged)' }, + 'wholegenomeorknownfamily': { zh: 'whole_genome 或 known_family', en: 'whole_genome or known_family' }, + 'skip': { zh: '⏭️ 跳过', en: 'Skip' }, + 'recommendedrefdatadownloadmovedtobackgro': { zh: '⏳ 推荐参考数据下载已转入后台:', en: 'Recommended Ref Data Download Moved to Background:' }, + 'downloadfailurefallbackrequiresmanualcon': { zh: '⏸ 下载失败兜底需要手动确认', en: 'Download Failure Fallback Requires Manual Confirmation' }, + 'countdownpaused': { zh: '⏸ 倒计时已暂停', en: 'Countdown Paused' }, + 'countdownpausedpleaseconfirmparametersth': { zh: '⏸ 倒计时已暂停,请确认参数后手动执行', en: 'Countdown Paused; Please Confirm Parameters then Execute Manually' }, + 'fixskippedthisblocksdownstreamsubsequent': { zh: '⏸ 已跳过修复;该问题会阻断下游,后续阶段已暂停。', en: 'Fix Skipped; This Blocks Downstream; Subsequent Stages Paused.' }, + 'waitingforvalidvcfselection': { zh: '⏸ 等待选择有效 VCF', en: 'Waiting for Valid VCF Selection' }, + 'ppikaksprecheckfailedbatchpausedcomplete': { zh: '⏸️ PPI/KaKs 前置数据检查未通过,已暂停批量执行;请补齐参考注释包或确认参考物种后重新执行。', en: 'PPI/KaKs Pre-check Failed; Batch Paused. Complete Ref Packages then Re-execute.' }, + 'identifydoneconfirmgenefamilymembersfirs': { zh: '⏸️ identify 已完成,但方法学链路需要先确认基因家族成员表;保存成员确认后再继续 phylogeny。', en: 'Identify done; confirm gene family members first, then continue phylogeny.' }, + 'openedidentifymemberscreeningtablesaveto': { zh: '⏸️ 已打开 identify 成员初筛确认表;保存后会生成 curated_members,再继续亚家族证据阶段。', en: 'Opened identify member screening table; save to generate curated_members.' }, + 'foregroundbatchrunningwgsplanpreservedre': { zh: '⏸️ 当前已有前台批次执行中;WGS 规划已保留,请在当前批次结束后重新执行。', en: 'Foreground batch running; WGS plan preserved. Re-execute after batch completes.' }, + 'stopall': { zh: '⏹️ 停止全部', en: 'Stop All' }, + 'backendprecheckfailedbatchstoppedrepullp': { zh: '⏹️ 后端预检失败,当前批次已停止;请重新拉取规划后继续。', en: 'Backend Pre-check Failed; Batch Stopped. Re-pull Plan to Continue.' }, + 'analysisstage': { zh: '▶ 分析阶段 Analysis Stage', en: 'Analysis Stage' }, + 'parameterconfirmationadjustment': { zh: '⚙️ 参数确认与调整', en: 'Parameter Confirmation & Adjustment' }, + 'configuredpermethodologyplan': { zh: '⚙️ 已按方法学规划', en: 'Configured per Methodology Plan' }, + 'backgroundbatchstagesskippedduetodepende': { zh: '⚠️ 后台批次中以下阶段因依赖失败被跳过:', en: 'Background batch: stages skipped due to dependency failure:' }, + 'backgroundbatchcannotcontinue': { zh: '⚠️ 后台批次无法继续:', en: 'Background batch cannot continue:' }, + 'issuedetected': { zh: '⚠️ 检测到问题', en: 'Issue Detected' }, + 'autoannotationnote': { zh: '⚠️ 自动注释说明:', en: 'Auto-Annotation Note:' }, + 'interpretationrisk': { zh: '⚠️ 解释风险', en: 'Interpretation Risk' }, + 'ppikaksprecheckpassed': { zh: '✅ PPI/KaKs 前置数据检查通过:', en: 'PPI/KaKs Pre-check Passed:' }, + 'ppikaksprecheckpassedorthologexistsgener': { zh: '✅ PPI/KaKs 前置数据检查通过:当前批次已有 Ortholog,将先生成同源映射再继续下游分析。', en: 'PPI/KaKs pre-check passed: Ortholog exists; generating mapping then continuing downstream.' }, + 'fixappliedcontinuing': { zh: '✅ 已应用修复方案,继续执行...', en: 'Fix Applied; Continuing...' }, + 'confirmedarabidopsisfallbackforppikakson': { zh: '✅ 已确认使用拟南芥作为 PPI/KaKs 的显式兜底参考;该选择只写入 Ortholog/PPI/KaKs 专用参数,其它常规阶段不受影响。', en: 'Confirmed Arabidopsis fallback for PPI/KaKs; only Ortholog/PPI/KaKs params affected.' }, + 'selectedprojectreferencespecies': { zh: '✅ 已选择项目参考物种', en: 'Selected project reference species:' }, + 'executioncomplete': { zh: '✅ 执行完成', en: 'Execution Complete' }, + 'recommendedrefdatadownloadcomplete': { zh: '✅ 推荐参考数据下载完成:', en: 'Recommended Ref Data Download Complete:' }, + 'confirmexecution': { zh: '✅ 确认执行', en: 'Confirm Execution' }, + 'confirmselection': { zh: '✅ 确认选择', en: 'Confirm Selection' }, + 'autousingpreviousstepoutput': { zh: '✅ 自动使用上一步Output:', en: 'Auto-using Previous Step Output:' }, + 'stage': { zh: '✅ 阶段', en: 'Stage' }, + 'restoredpublished': { zh: '✓ 已恢复并发布。', en: 'Restored & Published.' }, + 'restoredcompleted': { zh: '✓ 已恢复并完成。', en: 'Restored & Completed.' }, + 'restoredcompletedcandidatereviewcardgene': { zh: '✓ 已恢复并完成;候选核对卡已生成。', en: 'Restored & Completed; candidate review card generated.' }, + 'restoredverificationcanbegin': { zh: '✓ 已恢复确认;可以开始验证。', en: 'Restored; verification can begin.' }, + 'resultsreviewrestoredauthorizedtopublish': { zh: '✓ 已恢复结果核对;可以授权发布。', en: 'Results review restored; authorized to publish.' }, + 'executionrestoredresultcardgenerated': { zh: '✓ 已恢复运行;本次结果卡已生成。', en: 'Execution restored; result card generated.' }, + 'verificationrestoredresultreviewcardgene': { zh: '✓ 已恢复验证;结果核对卡已生成。', en: 'Verification restored; result review card generated.' }, + 'explicitlyauthorizedpublished': { zh: '✓ 已显式授权并发布。', en: 'Explicitly authorized & published.' }, + 'runresultsreviewed': { zh: '✓ 已核对运行结果。', en: 'Run results reviewed.' }, + 'candidateconfirmednorevisions': { zh: '✓ 已确认候选|未作修订', en: 'Candidate confirmed; no revisions.' }, + 'inputparamsconfirmedruncompleted': { zh: '✓ 已确认输入与参数;本次运行已完成。', en: 'Input & params confirmed; run completed.' }, + 'executionentryreproducedcandidateinfogen': { zh: '✓ 执行入口已成功复现;候选信息已生成。', en: 'Execution entry reproduced; candidate info generated.' }, + 'semanticdescriptionregeneratedcommandnot': { zh: '✓ 语义说明已重新生成;执行命令未重新运行。', en: 'Semantic description regenerated; command not re-run.' }, + 'verificationcompletedresultreviewcardgen': { zh: '✓ 验证已完成;结果核对卡已生成。', en: 'Verification completed; result review card generated.' }, + 'cancel-2': { zh: '❌ 取消', en: 'Cancel' }, + 'fixapplicationfailed': { zh: '❌ 应用修复失败', en: 'Fix Application Failed' }, + 'batchexecutioncancelled': { zh: '❌ 批量执行已取消', en: 'Batch Execution Cancelled' }, + 'methodologyexecutionplanfailed': { zh: '❌ 方法学执行规划失败:', en: 'Methodology Execution Plan Failed:' }, + 'servicetemporarilyunavailable': { zh: '❌ 服务暂时不可用:', en: 'Service Temporarily Unavailable:' }, + 'networkerrorpleaseretry': { zh: '❌ 网络错误,请重试', en: 'Network Error; Please Retry' }, + 'resetview': { zh: '⟲ 重置视图', en: 'Reset View' }, + 'submittingrecommendedrefannotationdownlo': { zh: '⬇️ 正在提交推荐近源参考物种注释包后台下载;Ortholog/PPI/KaKs 将等待参考数据,其它阶段继续执行。', en: 'Submitting recommended ref annotation download; Ortholog/PPI/KaKs will wait.' }, + '1analysismodulequickcommands': { zh: '一、分析模块快速指令', en: '1. Analysis Module Quick Commands' }, + '3ragquickcommands': { zh: '三、RAG快速指令', en: '3. RAG Quick Commands' }, + 'threeagentcontributionaudit': { zh: '三个 agent 贡献审计', en: 'Three-agent Contribution Audit' }, + 'triangle': { zh: '三角形', en: 'Triangle' }, + 'uploadexecutablecodetestdataandoriginalc': { zh: '上传可执行代码、测试数据和原始命令,核对候选后验证并授权发布;发布后可指定输入和原命令已有参数运行。', en: 'Upload executable code, test data and original commands; verify candidates then publish.' }, + 'uploadfailed': { zh: '上传失败', en: 'Upload Failed' }, + 'uploadbackgroundfileorselectautoannotati': { zh: '上传对应背景文件,或选择自动注释模式先生成背景注释后再继续富集。', en: 'Upload background file or select auto-annotation mode to generate backgrounds then enrich.' }, + 'uploadverifiedcodeandtestdataexecutiontr': { zh: '上传已跑通过的代码和测试数据;执行通过自然语言触发。', en: 'Upload verified code and test data; execution triggered via natural language.' }, + 'uploadoriginalfilenamesautoscannedfromdi': { zh: '上传文件原名,自动从目录扫描,不需要手动逐个输入', en: 'Upload original filenames; auto-scanned from directory, no manual entry' }, + 'uploadmodeuploadreferencegenome': { zh: '上传模式 (upload) - 上传参考Genome', en: 'Upload Mode - Upload Reference Genome' }, + 'uploadmode': { zh: '上传模式:', en: 'Upload Mode:' }, + 'upstreammanifest': { zh: '上游 Manifest', en: 'Upstream Manifest' }, + 'nextstep': { zh: '下一步', en: 'Next Step' }, + 'downloadcompleterecommendedrefdatathenco': { zh: '下载/补齐推荐参考数据后继续', en: 'Download/Complete Recommended Ref Data Then Continue' }, + 'arabidopsisfallbackafterdlfailurerequire': { zh: '下载失败后的拟南芥兜底必须手动确认,不会自动执行。', en: 'Arabidopsis fallback after DL failure requires manual confirmation.' }, + 'downloadreportfailed': { zh: '下载报告失败', en: 'Download Report Failed' }, + 'downloadincompletecannotbeusedforppikaks': { zh: '下载未完成,不能直接用于本次 PPI/KaKs。', en: 'Download incomplete; cannot be used for PPI/KaKs.' }, + 'wontoverwriteoriginalppioutputstofinalpp': { zh: '不会覆盖原始 PPI;提交后输出到 final/ppi_reset/reset_###', en: 'Won\'t overwrite original PPI; outputs to final/ppi_reset/reset_###/' }, + 'unsupportedmarkerhandlingpolicy': { zh: '不受支持标记处理策略', en: 'Unsupported Marker Handling Policy' }, + 'noconversionnone': { zh: '不转换 (none', en: 'No Conversion (none)' }, + 'twocolumnssampleandgroup': { zh: '两列:sample 与 group', en: 'Two columns: sample and group' }, + 'sessions': { zh: '个会话', en: ' sessions' }, + 'stagespleaseconfirmparameters': { zh: '个阶段,请确认参数', en: ' stages; please confirm parameters' }, + 'mediumpriority': { zh: '中优先级', en: 'Medium Priority' }, + 'neutralsubstitutionrate': { zh: '中性替换率', en: 'Neutral Substitution Rate' }, + 'neutralevolutionratedefault708e9': { zh: '中性进化速率,默认7.08e-9', en: 'Neutral evolution rate, default 7.08e-9' }, + 'neutralrate': { zh: '中性速率', en: 'Neutral Rate' }, + 'abundanceengine': { zh: '丰度引擎', en: 'Abundance Engine' }, + 'serial': { zh: '串行', en: 'Serial' }, + 'mainagentstillprocessing': { zh: '主 Agent 仍在处理……', en: 'Main Agent Still Processing...' }, + 'mainagentstreamresponsereturnednofinalre': { zh: '主 Agent 流式响应未返回最终结果', en: 'Main Agent Stream Response Returned No Final Result' }, + 'mainagentstreamrequestfailed': { zh: '主 Agent 流式请求失败', en: 'Main Agent Stream Request Failed' }, + 'primaryreference': { zh: '主参考', en: 'Primary Reference' }, + 'primaryreferencespecies': { zh: '主参考物种', en: 'Primary Reference Species' }, + 'mainresults': { zh: '主要结果', en: 'Main Results' }, + '2articlereproductionsteps': { zh: '二、文章复现步骤', en: '2. Article Reproduction Steps' }, + 'twogroup': { zh: '二分类 two_group', en: 'Two-group' }, + 'subfamily': { zh: '亚家族', en: 'Subfamily' }, + 'subfamilyclassificationconfirmation': { zh: '亚家族分类确认', en: 'Subfamily Classification Confirmation' }, + 'subfamilyclassificationevidencechaincomb': { zh: '亚家族分类证据链: combo', en: 'Subfamily Classification Evidence Chain: combo' }, + 'subfamilyclassificationevidencechaindoma': { zh: '亚家族分类证据链: domain', en: 'Subfamily Classification Evidence Chain: domain' }, + 'subfamilyclassificationpredictionrealtim': { zh: '亚家族分类预测实时解读', en: 'Subfamily Classification Prediction - Real-time Interpretation' }, + 'subfamilyclassificationpredictioninterpr': { zh: '亚家族分类预测解读', en: 'Subfamily Classification Prediction Interpretation' }, + 'subfamilyrefnotinjobconfiglocalplangener': { zh: '亚家族参考物种未写入当前 job 配置,已生成方法学本地计划,请在批量确认页核对参考物种', en: 'Subfamily ref not in job config; local plan generated. Verify ref on batch page.' }, + 'subfamilynamesize0auto': { zh: '亚家族名称大小(0=自动)', en: 'Subfamily Name Size (0=auto)' }, + 'subfamilynamedistance': { zh: '亚家族名称距离', en: 'Subfamily Name Distance' }, + 'subfamilyouterarc': { zh: '亚家族外圈弧线', en: 'Subfamily Outer Arc' }, + 'subfamilylabelsnotconfirmed': { zh: '亚家族标签尚未确认', en: 'Subfamily Labels Not Confirmed' }, + 'subfamilylabelsnotconfirmeddownstreampau': { zh: '亚家族标签尚未确认,已暂停后续执行。', en: 'Subfamily labels not confirmed; downstream paused.' }, + 'subfamilylabelsconfirmed': { zh: '亚家族标签已确认', en: 'Subfamily Labels Confirmed' }, + 'subfamilylabelconfirmation': { zh: '亚家族标签确认', en: 'Subfamily Label Confirmation' }, + 'subfamilyconfirmationskippedstagesdepend': { zh: '亚家族标签确认被跳过,已停止依赖 confirmed_subfamily.tsv 的后续阶段;如需普通无亚家族标签分析,请重新发起并明确说明“不需要亚家族”。', en: 'Subfamily confirmation skipped; stages depending on confirmed_subfamily.tsv stopped.' }, + 'subfamilyconfirmationreferenceclassifica': { zh: '亚家族确认 - Reference Classification', en: 'Subfamily Confirmation - Reference Classification' }, + 'subfamilyconfirmationparameters': { zh: '亚家族确认参数', en: 'Subfamily Confirmation Parameters' }, + 'autocontinueplanningaftersubfamilyconfir': { zh: '亚家族确认后自动续跑规划失败', en: 'Auto-continue planning after subfamily confirmation failed' }, + 'subfamilyconfirmationoutput': { zh: '亚家族确认输出:', en: 'Subfamily Confirmation Output:' }, + 'subfamilyconfirmationoutputspecies': { zh: '亚家族确认输出:Species.', en: 'Subfamily confirmation output: Species.' }, + 'subfamilyoutputjointconfirmationtablesav': { zh: '亚家族确认输出:一张联合确认表;保存后同步生成各物种 Species.', en: 'Subfamily output: joint confirmation table; saves and syncs per-species.' }, + 'subfamilyevidenceneedsidentifymemberscre': { zh: '亚家族证据阶段需要先保存 identify 成员初筛确认表', en: 'Subfamily evidence needs identify member screening table saved first' }, + 'subfamilypredictionoutputtarget': { zh: '亚家族预测输出:Target.', en: 'Subfamily prediction output: Target.' }, + 'intersection': { zh: '交集', en: 'Intersection' }, + 'humanhsa': { zh: '人 Human (hsa', en: 'Human (hsa)' }, + 'circleplotonly': { zh: '仅圈图', en: 'Circle Plot Only' }, + 'diagnosisonlymanualhandlingrequired': { zh: '仅展示诊断,需人工处理', en: 'Diagnosis Only; Manual Handling Required' }, + 'reportonly': { zh: '仅报告', en: 'Report Only' }, + 'comparativespeciesonly': { zh: '仅比较物种', en: 'Comparative Species Only' }, + 'targetonly': { zh: '仅目标', en: 'Target Only' }, + 'targetspeciesonly': { zh: '仅目标物种', en: 'Target Species Only' }, + 'rectangularonly': { zh: '仅矩形图', en: 'Rectangular Only' }, + 'recordonlyawaitingmanualconfirmation': { zh: '仅记录,待人工确认', en: 'Record Only; Awaiting Manual Confirmation' }, + 'downloadexpressiondatafromncbigeoleaveem': { zh: '从NCBI GEODownload表达量数据,留空则使用已Upload Files', en: 'Download expression data from NCBI GEO; leave empty to use uploaded files' }, + 'removethisgenefromcurrentresults': { zh: '从本次确认结果中删除该基因?', en: 'Remove this gene from current results?' }, + 'promoterlengthupstreamfromeachtargetgene': { zh: '从每个目标基因上游提取的启动子长度,默认 2000 bp', en: 'Promoter length upstream from each target gene; default 2000 bp' }, + 'coderevision': { zh: '代码修订', en: 'Code Revision' }, + 'coderevisionsuggestion': { zh: '代码修订建议', en: 'Code Revision Suggestion' }, + 'tasksubmittedrunninginbackground': { zh: '任务已提交,正在后台执行...', en: 'Task submitted, running in background...' }, + 'taskpaused': { zh: '任务已暂停', en: 'Task Paused' }, + 'prioritizereusingexpressionmatrix': { zh: '优先复用表达矩阵', en: 'Prioritize Reusing Expression Matrix' }, + 'sessiondeleted': { zh: '会话已删除', en: 'Session Deleted' }, + 'traditionalanalysisoutput': { zh: '传统分析输出:', en: 'Traditional Analysis Output:' }, + 'traditionalanalysisoutputgeneratedperspe': { zh: '传统分析输出:按物种分别生成 Species.', en: 'Traditional Analysis Output: Generated Per Species.' }, + 'locatedat': { zh: '位于', en: 'Located at' }, + 'positiondistributionmap': { zh: '位置分布图', en: 'Position Distribution Map' }, + 'youselectedgene2gobackgroundfile': { zh: '你选择了 gene2go 背景文件', en: 'You selected gene2go background file' }, + 'retrywithbcftools': { zh: '使用 bcftools 重试', en: 'Retry with bcftools' }, + 'usebuiltinmodelspeciesorgdb': { zh: '使用内置模式物种 OrgDb', en: 'Use Built-in Model Species OrgDb' }, + 'usebuiltinmodelspeciesorgdbgoonly': { zh: '使用内置模式物种 OrgDb(仅 GO)', en: 'Use Built-in Model Species OrgDb (GO only)' }, + 'useuploadedgene2gofile': { zh: '使用已上传 gene2go 文件', en: 'Use Uploaded gene2go File' }, + 'useuploadedgene2gogene2kobackgroundfiles': { zh: '使用已上传 gene2go/gene2ko 背景文件', en: 'Use Uploaded gene2go/gene2ko Background Files' }, + 'usearabidopsisasexplicitfallback': { zh: '使用拟南芥作为显式兜底', en: 'Use Arabidopsis as Explicit Fallback' }, + 'userecommendedrange': { zh: '使用推荐区间', en: 'Use Recommended Range' }, + 'usequeryid': { zh: '使用查询ID', en: 'Use Query ID' }, + 'usedefaultdomainfilter': { zh: '使用默认结构域筛选', en: 'Use Default Domain Filter' }, + 'eg03': { zh: '例如 0.3', en: 'e.g. 0.3' }, + 'eg1e5': { zh: '例如 1e-5', en: 'e.g. 1e-5' }, + 'eghmmerhmmsearch': { zh: '例如 HMMER hmmsearch', en: 'e.g. HMMER hmmsearch' }, + 'egbashrunshinputfa': { zh: '例如 bash run.sh input.fa', en: 'e.g. bash run.sh input.fa' }, + 'sidebarstatus': { zh: '侧边栏状态', en: 'Sidebar Status' }, + 'savemethodologyplan': { zh: '保存方法学方案', en: 'Save Methodology Plan' }, + 'retain': { zh: '保留', en: 'Retain' }, + 'retainoriginalheader': { zh: '保留原header', en: 'Retain Original Header' }, + 'modify': { zh: '修改', en: 'Modify' }, + 'revise': { zh: '修订', en: 'Revise' }, + 'reviseredraw': { zh: '修订 / 重绘', en: 'Revise / Redraw' }, + 'countdownpaused-2': { zh: '倒计时已暂停', en: 'Countdown Paused' }, + 'candidatestage': { zh: '候选阶段', en: 'Candidate Stage' }, + 'failedtostopextensionanalysis': { zh: '停止拓展分析失败', en: 'Failed to Stop Extension Analysis' }, + 'tryfindingmatrixfirstfallbacktorawrnaseq': { zh: '先找矩阵,失败后 raw RNA-seq fallback', en: 'Try finding matrix first; fall back to raw RNA-seq' }, + 'runrnaseqqcfirstverifycleanfastqfilesinf': { zh: '先运行 RNA-seq QC,并确认 final/ 中有每个样本的 clean FASTQ 文件。', en: 'Run RNA-seq QC first; verify clean FASTQ files in final/.' }, + 'firstrunsubfamilyclassificationconfirmat': { zh: '先进行亚家族分类确认(reference_classification/subfamily_confirm', en: 'First run subfamily classification confirmation' }, + 'genomewideforegrounduseglobaldegenes': { zh: '全基因组 foreground:使用全局 DE_genes', en: 'Genome-wide foreground: use global DE_genes' }, + 'genomewideforegroundusealldifferentialex': { zh: '全基因组 foreground:使用所有差异表达基因', en: 'Genome-wide foreground: use all differential expression genes' }, + 'genomewidedifferentialexpressionoutputgl': { zh: '全基因组差异表达:输出全局 DE_genes', en: 'Genome-wide differential expression: output global DE_genes' }, + 'globaldegenes': { zh: '全局 DE_genes', en: 'Global DE_genes' }, + 'fullscreen': { zh: '全屏', en: 'Full Screen' }, + 'fullscreendisplay': { zh: '全屏展示', en: 'Full Screen Display' }, + 'all': { zh: '全部', en: 'All' }, + 'allrows': { zh: '全部行', en: 'All Rows' }, + 'closenofurtheradjustments': { zh: '关闭,不再调整', en: 'Close; No Further Adjustments' }, + 'closeconfirmlater': { zh: '关闭,稍后确认', en: 'Close; Confirm Later' }, + 'compatibilitymode': { zh: '兼容模式', en: 'Compatibility Mode' }, + 'content': { zh: '内容', en: 'Content' }, + 'builtinspeciescode': { zh: '内置物种代码', en: 'Built-in Species Code' }, + 'writecheckpoint': { zh: '写入检查点', en: 'Write Checkpoint' }, + 'analyzing': { zh: '分析中', en: 'Analyzing' }, + 'analysisfamily': { zh: '分析家族', en: 'Analysis Family' }, + 'analysisdata': { zh: '分析数据', en: 'Analysis Data' }, + 'analyzesyntenybetweentargetandreferencec': { zh: '分析目标物种与参考/比较物种之间的共线性', en: 'Analyze synteny between target and reference/comparative species' }, + 'classificationtable': { zh: '分类表', en: 'Classification Table' }, + 'grouprole': { zh: '分组角色', en: 'Group Role' }, + 'grouproleunchanged': { zh: '分组角色不改', en: 'Group Role (Unchanged)' }, + 'switchto': { zh: '切换到', en: 'Switch to' }, + 'switchfailed': { zh: '切换失败', en: 'Switch Failed' }, + 'createsessionfailed': { zh: '创建会话失败', en: 'Create Session Failed' }, + 'createfailed': { zh: '创建失败', en: 'Create Failed' }, + 'deletesession': { zh: '删除会话', en: 'Delete Session' }, + 'deletesessionfailed': { zh: '删除会话失败', en: 'Delete Session Failed' }, + 'refreshresults': { zh: '刷新结果', en: 'Refresh Results' }, + 'foregrounddependencymissing': { zh: '前景依赖缺失', en: 'Foreground Dependency Missing' }, + 'foregroundgenes': { zh: '前景基因', en: 'Foreground Genes' }, + 'function': { zh: '功能', en: 'Function' }, + 'featurecategory': { zh: '功能类别', en: 'Feature Category' }, + 'addtoformallist': { zh: '加入正式列表', en: 'Add to Formal List' }, + 'failedtoloadppiinput': { zh: '加载 PPI 输入失败', en: 'Failed to Load PPI Input' }, + 'failedtoloadrnaseqsamples': { zh: '加载RNA-seq样本失败', en: 'Failed to Load RNA-seq Samples' }, + 'failedtoloadtasks': { zh: '加载任务失败', en: 'Failed to Load Tasks' }, + 'failedtoloadsessionmessages': { zh: '加载会话消息失败', en: 'Failed to Load Session Messages' }, + 'failedtoloadgraph': { zh: '加载图谱失败', en: 'Failed to Load Graph' }, + 'loadfailed': { zh: '加载失败', en: 'Load Failed' }, + 'failedtoloadmethodologyconfig': { zh: '加载方法学配置失败', en: 'Failed to Load Methodology Config' }, + 'failedtoloadknowledgegraph': { zh: '加载知识图谱失败', en: 'Failed to Load Knowledge Graph' }, + 'twornaseqpathsreusematrixorfallbacktoraw': { zh: '包含RNA-seq两条路: 可直接复用矩阵则复用,无法复用时执行raw RNA-seq fallback的质控/比对/定量/样本评估/差异表达', en: 'Two RNA-seq paths: reuse matrix or fallback to raw (QC/Align/Quant/Sample/DE)' }, + 'containskeyword': { zh: '包含关键词', en: 'Contains Keyword' }, + 'includeskaryotypechromosomedistributiona': { zh: '包含核型/染色体分布分析(karyotype', en: 'Includes karyotype/chromosome distribution analysis' }, + 'matchmode': { zh: '匹配方式', en: 'Match Mode' }, + 'matchedrows': { zh: '匹配行', en: 'Matched Rows' }, + 'singlegeneidgenenamelocusleaveemptywhenu': { zh: '单个GeneID/基因名/locus;使用ID文件时可留空', en: 'Single GeneID/gene name/locus; leave empty when using ID file' }, + 'singleormultipleidsegat1g01010at1g01720': { zh: '单个或多个ID,如 At1g01010,At1g01720', en: 'Single or multiple IDs, e.g. At1g01010,At1g01720' }, + 'reason': { zh: '原因', en: 'Reason' }, + 'originalcode': { zh: '原始代码', en: 'Original Code' }, + 'originalpackagereproducibilitycheckinpro': { zh: '原始包可复现检查中…', en: 'Original Package Reproducibility Check In Progress...' }, + 'originalpackagereproducibilitychecksubmi': { zh: '原始包可复现检查提交失败', en: 'Original Package Reproducibility Check Submit Failed' }, + 'originalcommand': { zh: '原始命令', en: 'Original Command' }, + 'originalcommandtestdataandexecutionfacts': { zh: '原始命令、测试数据和执行事实已经保留;这里只重新生成说明,不会重新运行原始命令。', en: 'Original command, test data and execution facts retained; only description regenerated.' }, + 'originalfilename': { zh: '原始文件名', en: 'Original Filename' }, + 'originalsequencingid': { zh: '原始测序编号', en: 'Original Sequencing ID' }, + 'originalevidence': { zh: '原始证据', en: 'Original Evidence' }, + 'originalrecommendedreference': { zh: '原推荐参考', en: 'Original Recommended Reference' }, + 'removet01suffixremovet01': { zh: '去除.T01后缀 (remove_T01', en: 'Remove .T01 Suffix (remove_T01)' }, + 'parameters': { zh: '参数', en: 'Parameters' }, + 'parameterpreviewfailed': { zh: '参数预览失败', en: 'Parameter Preview Failed' }, + 'referencegenome': { zh: '参考Genome', en: 'Reference Genome' }, + 'referencesubfamilyclassification': { zh: '参考亚家族分类', en: 'Reference Subfamily Classification' }, + 'referenceclassification': { zh: '参考分类', en: 'Reference Classification' }, + 'referenceprefix': { zh: '参考前缀', en: 'Reference Prefix' }, + 'referencegene': { zh: '参考基因', en: 'Reference Gene' }, + 'referencegeneid': { zh: '参考基因编号', en: 'Reference Gene ID' }, + 'refdatadownloadfailed': { zh: '参考数据下载失败', en: 'Ref Data Download Failed' }, + 'referencedataready': { zh: '参考数据已就绪', en: 'Reference Data Ready' }, + 'referencesource': { zh: '参考来源', en: 'Reference Source' }, + 'referencespecies-2': { zh: '参考物种', en: 'Reference Species' }, + 'refspeciessubfamilytwocolumntableautofin': { zh: '参考物种亚家族两列表。留空时自动从 input/final 查找 *.subfamily 或 *subfamily*.tsv', en: 'Ref species subfamily two-column table; auto-find from input/final when empty.' }, + 'refspeciesprefixinjointtreetipsegatat1g': { zh: '参考物种在联合树 tip 中的前缀,例如 At|AT1G...', en: 'Ref species prefix in joint tree tips, e.g. At|AT1G...' }, + 'referencespeciesshaperoletree': { zh: '参考物种形状(角色树)', en: 'Reference Species Shape (Role Tree)' }, + 'referencespeciesreferenceonlynotfortarge': { zh: '参考物种(仅参考,不参与目标鉴定)', en: 'Reference species (reference only; not for target identification)' }, + 'referencespeciesuseathforarabidopsis': { zh: '参考物种,拟南芥用ath', en: 'Reference species; use ath for Arabidopsis' }, + 'referenceuseadvanced': { zh: '参考用途(高级', en: 'Reference Use (Advanced)' }, + 'refgroupoptionalcontrolrequiredfortimetw': { zh: '参考组可选;time/two_group 必须在表中标 control,多组组织比较可留空', en: 'Ref group optional; control required for time/two_group; leave empty for multi-group tissue' }, + 'reflecting': { zh: '反思中', en: 'Reflecting...' }, + 'publishing': { zh: '发布中...', en: 'Publishing...' }, + 'prepublishverificationfailed': { zh: '发布前验收失败', en: 'Pre-publish Verification Failed' }, + 'prepublishverificationallpassedreadytopu': { zh: '发布前验收已全部通过,可以发布。', en: 'Pre-publish verification all passed; ready to publish.' }, + 'prepublishverificationpassed': { zh: '发布前验收已通过', en: 'Pre-publish Verification Passed' }, + 'publishfailed': { zh: '发布失败', en: 'Publish Failed' }, + 'agentfound': { zh: '发现 agent', en: 'Agent Found' }, + 'cancelppi': { zh: '取消 PPI', en: 'Cancel PPI' }, + 'cancelpreview': { zh: '取消预览', en: 'Cancel Preview' }, + 'onlycheckedandexecutablestageswillrun': { zh: '只会执行已勾选且可执行的阶段', en: 'Only checked and executable stages will run' }, + 'analyzeonlytargetspeciesinternalduplicat': { zh: '只分析目标物种内部复制关系', en: 'Analyze only target species internal duplication events' }, + 'onlyforidentifyblastreferencefamilyprote': { zh: '只用于 identify BLAST 的参考家族蛋白物种,在上方“Identify BLAST参考”填写,如 At,Os', en: 'Only for identify BLAST reference family protein species; fill under Identify BLAST Ref.' }, + 'onlyforidentifystageblastrefproteinsearc': { zh: '只用于 identify 阶段 BLAST/reference-protein search 的参考家族蛋白来源;可以多选,如 At 和 Os', en: 'Only for identify stage BLAST/ref-protein search; multi-select supported.' }, + 'onlyredrawtreenomafftiqtreererunbothoutp': { zh: '只重绘树图,不重跑 MAFFT/IQ-TREE。both 会同时输出矩形树和圈图。', en: 'Only redraw tree; no MAFFT/IQ-TREE rerun. Both outputs rectangular and circle plots.' }, + 'retrydirectlyorcancelandcheckerrorcause': { zh: '可以直接重试,或取消后检查错误原因。', en: 'Retry directly or cancel and check error cause.' }, + 'manuallyspecifykeggorganismcodeosariceat': { zh: '可手动指定 KEGG organism code;水稻 osa,拟南芥 ath。留空时系统尝试从拉丁名推断,失败则回退 KO 全库植物过滤。', en: 'Manually specify KEGG organism code; osa=rice, ath=Arabidopsis; auto-infer from Latin name otherwise.' }, + 'editable': { zh: '可改', en: 'Editable' }, + 'optionaleglcdlat': { zh: '可选,如 Lc / Dl / At', en: 'Optional, e.g. Lc / Dl / At' }, + 'numberofsamplesprocessedsimultaneously': { zh: '同时处理的样本数', en: 'Number of Samples Processed Simultaneously' }, + 'alsorunfullannotationgenepromoterbackgro': { zh: '同时跑全注释基因 promoter 背景', en: 'Also run full annotation gene promoter background' }, + 'name': { zh: '名称', en: 'Name' }, + 'modelstagedescription': { zh: '描述模型的阶段', en: 'Describe the model stage' }, + 'downstreamprimaryreferencespecies': { zh: '后续主参考物种', en: 'Downstream Primary Reference Species' }, + 'downstreamprimaryreferencespeciesegat': { zh: '后续主参考物种,例如 At', en: 'Downstream primary reference species, e.g. At' }, + 'downstreamprimaryreferencespeciesegat-2': { zh: '后续主参考物种,如 At', en: 'Downstream primary reference species, e.g. At' }, + 'defaultprimaryreffordownstreamusuallyone': { zh: '后续阶段默认主参考物种;通常一个,例如 At。不会自动加入 identify BLAST,除非也在 Identify BLAST参考 中选择', en: 'Default primary ref for downstream; usually one, e.g. At.' }, + 'promoterelements': { zh: '启动子元件', en: 'Promoter Elements' }, + 'promoterelementprediction': { zh: '启动子元件预测', en: 'Promoter Element Prediction' }, + 'promotercustomplot': { zh: '启动子自定义绘图', en: 'Promoter Custom Plot' }, + 'promotercustomplotfailed': { zh: '启动子自定义绘图失败:', en: 'Promoter Custom Plot Failed:' }, + 'promotercustomplotcomplete': { zh: '启动子自定义绘图完成', en: 'Promoter Custom Plot Complete' }, + 'promotercustomplotsubmittedtaskid': { zh: '启动子自定义绘图已提交:task_id=', en: 'Promoter custom plot submitted: task_id=' }, + 'commandtemplatereturnednopreviewablecomm': { zh: '命令模板未返回可预览命令;请先检查 entrypoint.command_template。', en: 'Command template returned no previewable command; check entrypoint.command_template.' }, + 'legendcolor': { zh: '图例颜色', en: 'Legend Color' }, + 'plottype': { zh: '图类型', en: 'Plot Type' }, + 'charttitlelist': { zh: '图表标题清单', en: 'Chart Title List' }, + 'charttitleevidence': { zh: '图表标题证据', en: 'Chart Title Evidence' }, + 'charttitledrivenreproductionplanpreview': { zh: '图表标题驱动复现规划预览', en: 'Chart Title-Driven Reproduction Plan Preview' }, + 'circle': { zh: '圆形', en: 'Circle' }, + 'geneid': { zh: '基因ID', en: 'Gene ID' }, + 'geneidsize': { zh: '基因ID大小', en: 'Gene ID Size' }, + 'geneiddistance': { zh: '基因ID距离', en: 'Gene ID Distance' }, + 'geneidtochromosomeblockdistance': { zh: '基因ID距离染色体色块', en: 'Gene ID to Chromosome Block Distance' }, + 'geneidtrackouterboundary': { zh: '基因ID轨道外边界', en: 'Gene ID Track Outer Boundary' }, + 'geneidconversionremovet01oraddt01suffix': { zh: '基因ID转换方式:去除.T01后缀或添加.T01后缀', en: 'Gene ID conversion: remove .T01 or add .T01 suffix' }, + 'geneid-2': { zh: '基因id', en: 'Gene ID' }, + 'genelist': { zh: '基因list', en: 'Gene List' }, + 'genelist-2': { zh: '基因列表', en: 'Gene List' }, + 'genotype': { zh: '基因型 genotype', en: 'Genotype' }, + 'genefamilyforegroundonlyanalyzemembersin': { zh: '基因家族 foreground:仅分析 final_members.id 中的家族成员', en: 'Gene family foreground: only analyze members in final_members.id' }, + 'genefamilyforegroundusefamilydegenes': { zh: '基因家族 foreground:使用 family_DE_genes', en: 'Gene family foreground: use family_DE_genes' }, + 'genefamilypromoterscanning': { zh: '基因家族 promoter 扫描', en: 'Gene family promoter scanning' }, + 'genefamilyrequiredforknownfamily': { zh: '基因家族(known_family必填', en: 'Gene Family (required for known_family)' }, + 'genefamilydifferentialexpressionoutputfa': { zh: '基因家族差异表达:只输出 family_DE_genes', en: 'Gene family differential expression: output family_DE_genes only' }, + 'genefamilymembers': { zh: '基因家族成员', en: 'Gene Family Members' }, + 'genefamilyidentification': { zh: '基因家族鉴定', en: 'Gene Family Identification' }, + 'geneannotation': { zh: '基因注释', en: 'Gene Annotation' }, + 'genestructureanalysis': { zh: '基因结构分析', en: 'Gene Structure Analysis' }, + 'fill': { zh: '填写', en: 'Fill' }, + 'absolutepathtorawdatadircommaseparatedfo': { zh: '填写Raw Data所在目录的绝对路径,多个路径用逗号分隔,如 /data/SRR1,/data/SRR2', en: 'Absolute path to Raw Data dir; comma-separated for multiples' }, + 'fillgenefamilyegsplwrky': { zh: '填写基因家族,如 SPL / WRKY', en: 'Fill gene family, e.g. SPL / WRKY' }, + 'fillabsolutedatadirpathcommaseparatedfor': { zh: '填写数据目录绝对路径,多个路径用逗号分隔', en: 'Fill absolute data dir path; comma-separated for multiples' }, + 'condition': { zh: '处理/条件 condition', en: 'Condition' }, + 'processing-2': { zh: '处理中', en: 'Processing' }, + 'processingmethod': { zh: '处理方式', en: 'Processing Method' }, + 'copyfailed': { zh: '复制失败', en: 'Copy Failed' }, + 'reuseplantcarefilteredtablefromcompleted': { zh: '复用已完成 promoter 阶段的 PlantCARE filtered 表,不重新提交 PlantCARE。', en: 'Reuse PlantCARE filtered table from completed promoter; do not re-submit.' }, + 'multigroup': { zh: '多分类 multi_group', en: 'Multi-group' }, + 'multiagentplanning': { zh: '多智能体规划', en: 'Multi-agent Planning' }, + 'multiagentmethodologyexecutionsequencepl': { zh: '多智能体规划方法学执行顺序失败', en: 'Multi-agent Methodology Execution Sequence Planning Failed' }, + 'multiagentplanningshowsstagescompletedno': { zh: '多智能体规划结果显示相关阶段已完成,无需重复执行', en: 'Multi-agent planning shows stages completed; no re-execution needed' }, + 'multispeciesprocessing': { zh: '多物种处理方式', en: 'Multi-species Processing' }, + 'multispeciesjointanalysisconfirmationdef': { zh: '多物种联合分析与确认(默认)', en: 'Multi-species Joint Analysis & Confirmation (default)' }, + 'size': { zh: '大小', en: 'Size' }, + 'largefilescanbereferencedoneabsolutepath': { zh: '大文件可直接引用;每行填写一个绝对路径', en: 'Large files can be referenced; one absolute path per line' }, + 'soybeangma': { zh: '大豆 (gma', en: 'Soybean (gma)' }, + 'ratrno': { zh: '大鼠 Rat (rno', en: 'Rat (rno)' }, + 'failed': { zh: '失败', en: 'Failed' }, + 'howtouse': { zh: '如何使用', en: 'How to Use' }, + 'withoutmanualfilteringonlymembershitting-2': { zh: '如果不手动筛选,系统默认只保留命中本基因家族目标结构域的成员;你也可以按 domain、e-value/score 和 coverage 调整后重新生成最终成员蛋白。', en: 'Without manual filtering, only members hitting target domain kept; adjust by domain, e-value/score and coverage.' }, + 'fielddescription': { zh: '字段说明:', en: 'Field Description:' }, + 'complete': { zh: '完成', en: 'Complete' }, + 'quantification': { zh: '定量', en: 'Quantification' }, + 'quantificationmode': { zh: '定量模式', en: 'Quantification Mode' }, + 'realtimecumulativeinterpretation': { zh: '实时累计解读', en: 'Real-time Cumulative Interpretation' }, + 'actualcommand': { zh: '实际命令', en: 'Actual Command' }, + 'experimentaldesign': { zh: '实验设计', en: 'Experimental Design' }, + 'experimentaldesignmodule': { zh: '实验设计模块', en: 'Experimental Design Module' }, + 'reviewnotes': { zh: '审核备注', en: 'Review Notes' }, + 'familymembers': { zh: '家族成员', en: 'Family Members' }, + 'enrichmentanalysismissingbackgroundannot': { zh: '富集分析缺少背景注释', en: 'Enrichment Analysis Missing Background Annotation' }, + 'enrichmenttype': { zh: '富集类型', en: 'Enrichment Type' }, + 'chathistorycleared': { zh: '对话历史已清空', en: 'Chat History Cleared' }, + 'comparisonstobegenerated': { zh: '将生成的比较:', en: 'Comparisons to be Generated:' }, + 'speciestobeidentified': { zh: '将鉴定物种', en: 'Species to be identified' }, + 'mousemmu': { zh: '小鼠 Mouse (mmu', en: 'Mouse (mmu)' }, + 'expand': { zh: '展开', en: 'Expand' }, + 'displaychain': { zh: '展示链:', en: 'Display Chain:' }, + 'deorsampleqcneedspurelynumericrawcountsb': { zh: '差异分析或样本 QC 需要纯数字 raw counts,但矩阵里混入了注释列或文本列。', en: 'DE or sample QC needs purely numeric raw counts, but matrix has annotation/text columns.' }, + 'differentialexpression': { zh: '差异表达', en: 'Differential Expression' }, + 'differentialexpressiondownstreamanalysis': { zh: '差异表达下游分析', en: 'Differential Expression Downstream Analysis' }, + 'differentialexpressionscope': { zh: '差异表达范围', en: 'Differential Expression Scope' }, + 'currentsessioncleared': { zh: '已Clear当前会话', en: 'Current Session Cleared' }, + 'methodologyplansavedbutnotexecuting': { zh: '已保存方法学方案,但暂不执行:', en: 'Methodology plan saved but not executing:' }, + 'writtentoofficialconfigxlsx': { zh: '已写入正式 config.xlsx', en: 'Written to Official config.xlsx' }, + 'writtentoofficialsamplesyaml': { zh: '已写入正式 samples.yaml', en: 'Written to Official samples.yaml' }, + 'registeredstages': { zh: '已写阶段', en: 'Registered Stages' }, + 'switchedtoworkingdirectory': { zh: '已切换到Working Directory', en: 'Switched to Working Directory' }, + 'creatednewworkingdirectory': { zh: '已创建新Working Directory', en: 'Created New Working Directory' }, + 'addedtoformalstagelistincludedinthisexec': { zh: '已加入正式阶段列表,并默认纳入本次执行', en: 'Added to formal stage list; included in this execution' }, + 'publishedreadytoexecute': { zh: '已发布,可执行', en: 'Published; Ready to Execute' }, + 'cancelled': { zh: '已取消', en: 'Cancelled' }, + 'applied': { zh: '已应用', en: 'Applied' }, + 'openedconfigxlsxautodetectiontableeditro': { zh: '已打开 config.xlsx 自动识别表。你可以直接修订单行,或用批量修订区筛选、预览、Apply。', en: 'Opened config.xlsx auto-detection table. Edit rows or use bulk edit.' }, + 'openedsamplesyamlautodetectiontableedits': { zh: '已打开 samples.yaml 自动识别表。你可以直接修订样本、分组、重复和对照组。', en: 'Opened samples.yaml auto-detection table. Edit sample, group, replicate and control.' }, + 'openedcurrentjobconfigxlsxreviewtable': { zh: '已打开当前 job 的 config.xlsx 核对表。', en: 'Opened current job config.xlsx review table.' }, + 'existingorthologmappinginconsistentwithp': { zh: '已有 Ortholog 映射与当前 PPI/KaKs 参考物种不一致', en: 'Existing Ortholog mapping inconsistent with PPI/KaKs ref' }, + 'downstreamstagesmarkedforrerunbasedonfil': { zh: '已标记下游 phylogeny/motif/domain/structure/combo 需要基于筛选后成员重跑。', en: 'Downstream stages marked for re-run based on filtered members.' }, + 'paramsoutputconfirmedpleasecontinueverif': { zh: '已确认参数和输出,请继续验证', en: 'Params & output confirmed; please continue verification' }, + 'boundtoserversidevcf': { zh: '已绑定服务器端 VCF', en: 'Bound to Server-side VCF' }, + 'paramsoutputautoconfirmedstartingverific': { zh: '已自动确认参数和输出,开始验证...', en: 'Params & output auto-confirmed; starting verification...' }, + 'autobound': { zh: '已自动绑定:', en: 'Auto-bound:' }, + 'broughtup': { zh: '已调出', en: 'Brought Up' }, + 'help': { zh: '帮助', en: 'Help' }, + 'parallel': { zh: '并行', en: 'Parallel' }, + 'suggestedfixplan': { zh: '建议修复方案', en: 'Suggested Fix Plan' }, + 'suggestedparameters': { zh: '建议参数', en: 'Suggested Parameters' }, + 'suggestedhandlingplan': { zh: '建议处理方案', en: 'Suggested Handling Plan' }, + 'startingprepublishnlruntimegate': { zh: '开始发布前 NL Runtime Gate...', en: 'Starting Pre-publish NL Runtime Gate...' }, + 'startverification': { zh: '开始验证', en: 'Start Verification' }, + 'arcavoidancedistance': { zh: '弧线避让距离', en: 'Arc Avoidance Distance' }, + 'popout': { zh: '弹出', en: 'Pop Out' }, + 'goenrichmentlacksgene2gosystemwillrunloc': { zh: '当前 GO 富集缺少可用 gene2go 背景。系统将先运行本地 GO 注释生成 gene2go,再继续 GO 富集分析。', en: 'GO enrichment lacks gene2go; system will run local GO annotation first.' }, + 'goandkegglackbackgroundssystemwillrunloc': { zh: '当前 GO 缺少可用 gene2go 背景,KEGG 缺少 gene2ko 背景。系统将先运行本地自动注释生成背景文件,再继续富集分析。', en: 'GO and KEGG lack backgrounds; system will run local auto-annotation first.' }, + 'gobackgrounddoesnotmatchdegeneidssystemw': { zh: '当前 GO 背景与 DE 基因 ID 不匹配。系统将先运行本地 GO 注释生成匹配的 gene2go,再继续 GO 富集分析。', en: 'GO background does not match DE gene IDs; system will run local GO annotation first.' }, + 'keggenrichmentlacksgene2kosystemwillrunl': { zh: '当前 KEGG 富集缺少 gene2ko 背景。系统将先运行本地 KEGG 注释生成 gene2ko,再继续 KEGG 富集分析。', en: 'KEGG enrichment lacks gene2ko; system will run local KEGG annotation first.' }, + 'currentgene2go': { zh: '当前 gene2go', en: 'Current gene2go' }, + 'currentjob': { zh: '当前 job', en: 'Current Job' }, + 'currentjobfinal': { zh: '当前 job final', en: 'Current Job Final' }, + 'noconfigxlsxdraftorofficialconfigfoundin': { zh: '当前 job 中没有找到可打开的 config.xlsx 草稿或正式配置。', en: 'No config.xlsx draft or official config found in current job.' }, + 'filesincurrentjobinputorworkspaceegkey': { zh: '当前 job 的 input/ 或 workspace/ 中的文件,例如', en: 'Files in current job input/ or workspace/, e.g.: {key}' }, + 'currentjobidisemptycannotsavemethodology': { zh: '当前 job_id 为空,不能保存方法学方案', en: 'Current job_id is empty; cannot save methodology plan' }, + 'currentjobidisemptycannotrunprepublishve': { zh: '当前 job_id 为空,不能运行发布前验收。', en: 'Current job_id is empty; cannot run pre-publish verification.' }, + 'currentparameters': { zh: '当前参数', en: 'Current Parameters' }, + 'currentreferencespecies': { zh: '当前参考物种', en: 'Current Reference Species' }, + 'currentdisplaymode': { zh: '当前显示模式', en: 'Current Display Mode' }, + 'nodirectlyusableexpressionmatrixdetected': { zh: '当前未检测到可直接执行的表达矩阵。', en: 'No directly usable expression matrix detected.' }, + 'currentsampleinfoinsufficienttogenerated': { zh: '当前样品信息不足以生成 DE 设计', en: 'Current sample info insufficient to generate DE design' }, + 'currentsamplecounttoolow': { zh: '当前样本数过少', en: 'Current sample count too low' }, + 'nousablejobidavailable': { zh: '当前没有可用 job_id', en: 'No Usable Job ID Available' }, + 'currentbrowserdoesnotsupportstreamingres': { zh: '当前浏览器不支持流式响应', en: 'Current browser does not support streaming response' }, + 'messagenotsavedtosessionhistorymaybelost': { zh: '当前消息未成功写入会话历史,刷新后可能丢失', en: 'Message not saved to session history; may be lost on refresh' }, + 'nomembersafterfilterfinalmembersnotrebui': { zh: '当前筛选没有成员,未重建 final_members。', en: 'No members after filter; final_members not rebuilt.' }, + 'currentlymissing': { zh: '当前缺失:', en: 'Currently Missing:' }, + 'currentselection': { zh: '当前选择', en: 'Current Selection' }, + 'currentstage': { zh: '当前阶段', en: 'Current Stage' }, + 'pending-2': { zh: '待执行', en: 'Pending' }, + 'pendingauthorization': { zh: '待授权', en: 'Pending Authorization' }, + 'pendingreview': { zh: '待核对', en: 'Pending Review' }, + 'pendingconfirmation': { zh: '待确认', en: 'Pending Confirmation' }, + 'awaitingdata': { zh: '待补数据', en: 'Awaiting Data' }, + 'required': { zh: '必填', en: 'Required' }, + 'requiredpfamidsorcommondomainaliasesrequ': { zh: '必需 Pfam 号(PFxxxxx)或常见结构域别名,多个用逗号分隔;Identify 无上传 HMM 时必填', en: 'Required Pfam IDs or common domain aliases; required when no HMM uploaded for Identify' }, + 'requiredpfamproteinfamilyidscommaseparat': { zh: '必需包含的 Pfam 蛋白家族ID,多个用逗号分隔', en: 'Required Pfam protein family IDs, comma-separated' }, + 'mustmatchfilenameininput': { zh: '必须匹配 input 中的文件名', en: 'Must match filename in input' }, + 'howtouse-2': { zh: '怎么用', en: 'How to Use' }, + 'helloiambotanicalbaseagentspecializeding': { zh: '您好!我是BotanicalBaseAgent,专注于基因家族分析。请告诉我您想分析哪个基因家族?', en: 'Hello! I am BotanicalBaseAgent, specialized in gene family analysis.' }, + 'memberscreeningconfirmation': { zh: '成员初筛确认', en: 'Member Screening Confirmation' }, + 'memberconfirmation': { zh: '成员确认', en: 'Member Confirmation' }, + 'memberfilter': { zh: '成员筛选', en: 'Member Filter' }, + 'memberidentificationoutput': { zh: '成员鉴定输出:', en: 'Member Identification Output:' }, + 'memberidentificationoutputperspeciesgene': { zh: '成员鉴定输出:每个鉴定物种分别生成 Species.', en: 'Member identification output: per species generates Species.' }, + 'membersetchangedphylogenyneedsrerun': { zh: '成员集合有变化,需要重跑 phylogeny。', en: 'Member set changed; phylogeny needs re-run.' }, + 'membersetunchangeddownstreamwillreusecon': { zh: '成员集合未变,后续绘图将直接复用确认标签。', en: 'Member set unchanged; downstream will reuse confirmed labels.' }, + 'gotit': { zh: '我知道了', en: 'Got It' }, + 'nousablefilesforconfigxlsxsamplesyamlatp': { zh: '我识别到你是在提供数据路径,但该路径下没有发现可用于 config.xlsx / samples.yaml 的文件。请确认目录是否包含 genome/pep/cds/gff/hmm 或 FASTQ 文件。', en: 'No usable files for config.xlsx/samples.yaml at path; check for genome/pep/cds/gff/hmm or FASTQ.' }, + 'allstagescompleted': { zh: '所有阶段已完成', en: 'All Stages Completed' }, + 'manualrange': { zh: '手动区间', en: 'Manual Range' }, + 'manuallyprovidessfile': { zh: '手动提供 .ss 文件', en: 'Manually Provide .ss File' }, + 'open': { zh: '打开', en: 'Open' }, + 'openselectedextractionresult': { zh: '打开所选提取结果', en: 'Open Selected Extraction Result' }, + 'failedtoopenmethodologyextractionresults': { zh: '打开方法学提取结果失败', en: 'Failed to Open Methodology Extraction Results' }, + 'openlatestextractionresult': { zh: '打开最新提取结果', en: 'Open Latest Extraction Result' }, + 'execute': { zh: '执行', en: 'Execute' }, + 'executing': { zh: '执行中...', en: 'Executing...' }, + 'executionentryraninisolatedenvironmentve': { zh: '执行入口已在隔离环境运行。请核对系统记录的事实与说明;确认后才会验证。', en: 'Execution entry ran in isolated environment. Verify recorded facts before confirming.' }, + 'executegenefamilyanalysis': { zh: '执行基因家族分析', en: 'Execute Gene Family Analysis' }, + 'executionfailed': { zh: '执行失败', en: 'Execution Failed' }, + 'erroroccurredduringexecution': { zh: '执行过程中发生错误', en: 'Error occurred during execution' }, + 'executionissue': { zh: '执行问题', en: 'Execution Issue' }, + 'executestage': { zh: '执行阶段', en: 'Execute Stage' }, + 'extendedanalysis': { zh: '扩展分析', en: 'Extended Analysis' }, + 'scanbothstrands': { zh: '扫描双链', en: 'Scan Both Strands' }, + 'batchexecutionconfirmation': { zh: '批量执行确认', en: 'Batch Execution Confirmation' }, + 'collapse': { zh: '折叠', en: 'Collapse' }, + 'reportdata': { zh: '报告数据', en: 'Report Data' }, + 'sorryididnotunderstandpleasetrytodescrib': { zh: '抱歉,我没有理解你的意思。请尝试更详细地描述。', en: 'Sorry, I did not understand. Please try to describe in more detail.' }, + 'sorryliteraturesearchserviceistemporaril': { zh: '抱歉,文献检索服务暂时不可用。', en: 'Sorry, literature search service is temporarily unavailable.' }, + 'sorryliteraturesearchserviceisunavailabl': { zh: '抱歉,文献检索服务暂时不可用,请稍后重试。', en: 'Sorry, literature search service is unavailable; please try again later.' }, + 'sorryserviceistemporarilyunavailableplea': { zh: '抱歉,服务暂时不可用,请稍后重试。', en: 'Sorry, service is temporarily unavailable; please try again later.' }, + 'latinname': { zh: '拉丁名', en: 'Latin Name' }, + 'latinnamealiasunchanged': { zh: '拉丁名/别名不改', en: 'Latin Name/Alias (Unchanged)' }, + 'extensionanalysis': { zh: '拓展分析', en: 'Extension Analysis' }, + 'extensionanalysiscandidatereview': { zh: '拓展分析候选核对', en: 'Extension Analysis Candidate Review' }, + 'extensionanalysisdescriptiongenerationfa': { zh: '拓展分析说明生成失败', en: 'Extension Analysis Description Generation Failed' }, + 'extensionanalysisrunparameterconfirmatio': { zh: '拓展分析运行参数确认', en: 'Extension Analysis Run Parameter Confirmation' }, + 'extensionanalysisstage': { zh: '拓展分析阶段', en: 'Extension Analysis Stage' }, + 'extensionanalysisstagepublished': { zh: '拓展分析阶段已发布', en: 'Extension Analysis Stage Published' }, + 'extensionanalysisstagegeneratedpublished': { zh: '拓展分析阶段已生成并发布', en: 'Extension Analysis Stage Generated & Published' }, + 'extensionanalysisstageexecutioncomplete': { zh: '拓展分析阶段执行完成', en: 'Extension Analysis Stage Execution Complete' }, + 'extensionanalysisstagefailedcheckstderrl': { zh: '拓展分析阶段执行未成功,请查看 stderr.log', en: 'Extension Analysis Stage Failed; check stderr.log' }, + 'extensionanalysisstagefailedcheckresultl': { zh: '拓展分析阶段执行未成功,请查看结果日志', en: 'Extension Analysis Stage Failed; check result log' }, + 'extensionanalysisstagestatus': { zh: '拓展分析阶段状态', en: 'Extension Analysis Stage Status' }, + 'extensionanalysisstagegenerationfailed': { zh: '拓展分析阶段生成失败', en: 'Extension Analysis Stage Generation Failed' }, + 'extensionanalysisstageconfirmation': { zh: '拓展分析阶段确认', en: 'Extension Analysis Stage Confirmation' }, + 'extensionanalysisstagerunresult': { zh: '拓展分析阶段运行结果', en: 'Extension Analysis Stage Run Result' }, + 'extensionstage': { zh: '拓展阶段', en: 'Extension Stage' }, + 'arabidopsisath': { zh: '拟南芥 (ath', en: 'Arabidopsis (ath)' }, + 'arabidopsisthalianaath': { zh: '拟南芥 Arabidopsis (ath', en: 'Arabidopsis thaliana (ath)' }, + 'arabidopsisfallbackreferenceconfirmed': { zh: '拟南芥兜底参考已确认', en: 'Arabidopsis Fallback Reference Confirmed' }, + 'arabidopsisonlyinorthologppikaksparamsot': { zh: '拟南芥只会写入 Ortholog/PPI/KaKs 专用参考参数,不会把全流程其它分析阶段改成 At。', en: 'Arabidopsis only in Ortholog/PPI/KaKs params; other stages unchanged.' }, + 'specifygtfgffannotation': { zh: '指定 GTF/GFF 注释', en: 'Specify GTF/GFF Annotation' }, + 'specifygwask0reportbestkonly-2': { zh: '指定 GWAS K(0=仅报告最佳 K)', en: 'Specify GWAS K (0=report best K only)' }, + 'splitperspecies': { zh: '按 per_species 拆分', en: 'Split per-species:' }, + 'byelement': { zh: '按元件', en: 'By Element' }, + 'perspeciesseparateanalysisconfirmation': { zh: '按单个物种分别分析与确认', en: 'Per-species Separate Analysis & Confirmation' }, + 'markbyspeciesandgeneratespecieslegend': { zh: '按物种标记并生成Species图例', en: 'Mark by species and generate Species legend' }, + 'markbytargetreferenceroleroletree': { zh: '按目标/参考角色标记(角色树)', en: 'Mark by target/reference role (Role Tree)' }, + 'bycategory': { zh: '按类别', en: 'By Category' }, + 'authorizepublish': { zh: '授权发布', en: 'Authorize Publish' }, + 'queued': { zh: '排队中', en: 'Queued' }, + 'waitinginqueue': { zh: '排队等待', en: 'Waiting in Queue' }, + 'recommendedrefdatadownloadfailed': { zh: '推荐参考数据下载失败', en: 'Recommended Ref Data Download Failed' }, + 'recommendedrefdatadownloadcomplete-2': { zh: '推荐参考数据已下载完成', en: 'Recommended Ref Data Download Complete' }, + 'recommendedrefdatadownloadinginbackgroun': { zh: '推荐参考数据正在后台下载', en: 'Recommended Ref Data Downloading in Background' }, + 'recommendednearsourcestringreferencespec': { zh: '推荐近源 STRING 参考物种:', en: 'Recommended Near-source STRING Reference Species:' }, + 'recommendednearsourceorthologousrefspeci': { zh: '推荐近源/同源参考物种', en: 'Recommended Near-source / Orthologous Ref Species' }, + 'submitrerunppi': { zh: '提交并重新运行 PPI', en: 'Submit & Re-run PPI' }, + 'submitconfiguredprojectrestartscript': { zh: '提交配置好的项目重启脚本', en: 'Submit Configured Project Restart Script' }, + 'submitrevisionsuggestions': { zh: '提出修订意见', en: 'Submit Revision Suggestions' }, + 'extracting': { zh: '提取中', en: 'Extracting...' }, + 'extractioncompletebutconfigreadfailed': { zh: '提取完成但配置读取失败', en: 'Extraction Complete but Config Read Failed' }, + 'tip': { zh: '提示:', en: 'Tip:' }, + 'searchgeneorentity': { zh: '搜索基因或实体...', en: 'Search gene or entity...' }, + 'operation': { zh: '操作', en: 'Operation' }, + 'operationfailed': { zh: '操作失败', en: 'Operation Failed' }, + 'placeat': { zh: '放在', en: 'Place at' }, + 'datapreparation-2': { zh: '数据准备', en: 'Data Preparation' }, + 'nonewexecutablemethodologystagesafterdat': { zh: '数据准备完成后没有新的可执行方法学阶段', en: 'No new executable methodology stages after data preparation' }, + 'dataat': { zh: '数据在', en: 'Data at' }, + 'databasearticleexpressionmatrix': { zh: '数据库/文章表达矩阵', en: 'Database / Article Expression Matrix' }, + 'dataformaterrornodesundefinedornotanarra': { zh: '数据格式错误: nodes 未定义或不是数组', en: 'Data Format Error: nodes undefined or not an array' }, + 'datadirectory': { zh: '数据目录', en: 'Data Directory' }, + 'datarolefromconfigxlsx': { zh: '数据角色(来自 config.xlsx)', en: 'Data Role (from config.xlsx)' }, + 'datawarningedgesundefinedusingemptyarray': { zh: '数据警告: edges 未定义,使用空数组', en: 'Data Warning: edges undefined, using empty array' }, + 'datapath': { zh: '数据路径', en: 'Data Path' }, + 'datagate': { zh: '数据门控', en: 'Data Gate' }, + 'dataneeds': { zh: '数据需求', en: 'Data Needs' }, + 'file': { zh: '文件', en: 'File' }, + 'fileuploadsuccessfulgenerationverificati': { zh: '文件上传成功,已开始生成并验收', en: 'File upload successful; generation & verification started' }, + 'fileat': { zh: '文件在', en: 'File at' }, + 'folder': { zh: '文件夹', en: 'Folder' }, + 'filedirectory': { zh: '文件目录', en: 'File Directory' }, + 'filedirectoryleaveemptytousecurrentjobin': { zh: '文件目录;留空则使用当前 job input 目录', en: 'File directory; leave empty to use current job input directory' }, + 'filetype': { zh: '文件类型', en: 'File Type' }, + 'filescope': { zh: '文件范围', en: 'File Scope' }, + 'documentsearch': { zh: '文档检索', en: 'Document Search' }, + 'literatureparameters': { zh: '文献参数', en: 'Literature Parameters' }, + 'literaturetools': { zh: '文献工具', en: 'Literature Tools' }, + 'literaturesupport': { zh: '文献支撑', en: 'Literature Support' }, + 'literaturesupportclaim': { zh: '文献支撑claim', en: 'Literature Support Claim' }, + 'literaturemethodvsbbav5stageaudit': { zh: '文献方法与 bbav5 阶段核对', en: 'Literature Method vs bbav5 Stage Audit' }, + 'literaturesnippet': { zh: '文献片段', en: 'Literature Snippet' }, + 'literaturerecord': { zh: '文献记录', en: 'Literature Record' }, + 'requiredforarticlereproductionegarabidop': { zh: '文章复现时必填,如 Arabidopsis thaliana', en: 'Required for article reproduction, e.g. Arabidopsis thaliana' }, + 'articlespeciesdownloadentry': { zh: '文章涉及物种与下载入口', en: 'Article Species & Download Entry' }, + 'newsession': { zh: '新会话', en: 'New Session' }, + 'newsessioncreatedpleaseselectorcreateawo': { zh: '新会话已创建,请选择或创建 Working Directory', en: 'New session created; please select or create a Working Directory' }, + 'add': { zh: '新增', en: 'Add' }, + 'newid': { zh: '新增 ID', en: 'New ID' }, + 'method1': { zh: '方式1:', en: 'Method 1:' }, + 'method2': { zh: '方式2:', en: 'Method 2:' }, + 'square': { zh: '方形', en: 'Square' }, + 'methodreference': { zh: '方法参考', en: 'Method Reference' }, + 'methodologysandboxbuilt': { zh: '方法学 sandbox 已构建', en: 'Methodology Sandbox Built' }, + 'methodologysandboxbuildfailed': { zh: '方法学 sandbox 构建失败', en: 'Methodology Sandbox Build Failed' }, + 'methodologysandboxqueryfailed': { zh: '方法学 sandbox 查询失败', en: 'Methodology Sandbox Query Failed' }, + 'methodologysandboxexamplereadfailed': { zh: '方法学 sandbox 示例读取失败', en: 'Methodology Sandbox Example Read Failed' }, + 'methodologyreference': { zh: '方法学参考', en: 'Methodology Reference' }, + 'methodologyexecutionplanningfailed': { zh: '方法学执行规划失败', en: 'Methodology Execution Planning Failed' }, + 'methodologyextraction': { zh: '方法学抽取', en: 'Methodology Extraction' }, + 'methodologyextractionbbav5executionaudit': { zh: '方法学提取与 bbav5 执行核对', en: 'Methodology Extraction & bbav5 Execution Audit' }, + 'methodologyextracting': { zh: '方法学提取中', en: 'Methodology Extracting...' }, + 'methodologyextractionlaunchfailed': { zh: '方法学提取启动失败', en: 'Methodology Extraction Launch Failed' }, + 'methodologyextractionfailed': { zh: '方法学提取失败', en: 'Methodology Extraction Failed' }, + 'methodologyextractionstatusreadfailed': { zh: '方法学提取状态读取失败', en: 'Methodology Extraction Status Read Failed' }, + 'methodologyextractionrecordreadfailed': { zh: '方法学提取记录读取失败', en: 'Methodology Extraction Record Read Failed' }, + 'methodologydatareadinesscheckfailed': { zh: '方法学数据就绪检查失败', en: 'Methodology Data Readiness Check Failed' }, + 'methodologypapers': { zh: '方法学文献', en: 'Methodology Papers' }, + 'methodologyplansavefailed': { zh: '方法学方案保存失败', en: 'Methodology Plan Save Failed' }, + 'methodologyplansavedpreparedatathenexecu': { zh: '方法学方案已保存,可先准备数据,稍后再按该方案执行', en: 'Methodology plan saved; prepare data then execute later.' }, + 'methodologyplanbindingfailed': { zh: '方法学方案绑定失败', en: 'Methodology Plan Binding Failed' }, + 'datapreparationrequiredbymethodologyplan': { zh: '方法学方案要求的数据准备', en: 'Data Preparation Required by Methodology Plan' }, + 'methodologypipeline': { zh: '方法学流水线', en: 'Methodology Pipeline' }, + 'methodologycontinueplanningfailed': { zh: '方法学续跑规划失败', en: 'Methodology Continue Planning Failed' }, + 'methodologyconfigreadfailed': { zh: '方法学配置读取失败', en: 'Methodology Config Read Failed' }, + 'noavailablenearsourcerecommendation': { zh: '无可用近源推荐', en: 'No Available Near-source Recommendation' }, + 'noexternalreference': { zh: '无外部参考', en: 'No External Reference' }, + 'cannotdeterminecombooutputlabelselectaco': { zh: '无法确定 combo 输出标签,请先选择 combo 图。', en: 'Cannot determine combo output label; select a combo plot first.' }, + 'cannotdeterminemsaoutputlabelselectimage': { zh: '无法确定MSA输出标签,请先选择对应图片或填写输出标签。', en: 'Cannot determine MSA output label; select image or fill output label.' }, + 'cannotdeterminepromoteroutputprefix': { zh: '无法确定启动子输出前缀。', en: 'Cannot determine promoter output prefix.' }, + 'cannotdetermineintraspeciessyntenyoutput': { zh: '无法确定种内共线性输出标签,请先选择 Circos 图。', en: 'Cannot determine intraspecies synteny output label; select Circos plot.' }, + 'cannotdeterminetreeoutputlabelselecttree': { zh: '无法确定进化树输出标签,请先选择树图或填写输出标签。', en: 'Cannot determine tree output label; select tree plot or fill output label.' }, + 'cannotreadoperationstatus': { zh: '无法读取操作状态', en: 'Cannot Read Operation Status' }, + 'noneedtouploadfilesagain': { zh: '无需再次Upload Files', en: 'No Need to Upload Files Again' }, + 'log': { zh: '日志', en: 'Log' }, + 'legacyfieldusuallyfalseseedreferencemayb': { zh: '旧兼容字段,通常 FALSE;seed_reference 可为 TRUE', en: 'Legacy field, usually FALSE; seed_reference may be TRUE' }, + 'timeseries': { zh: '时间序列 time_series', en: 'Time Series' }, + 'explicitlyspecify': { zh: '明确指定', en: 'Explicitly Specify' }, + 'show': { zh: '显示', en: 'Show' }, + 'pausecountdown': { zh: '暂停倒计时', en: 'Pause Countdown' }, + 'norecommendationsavailable': { zh: '暂无可用推荐', en: 'No Recommendations Available' }, + 'nouploadedfilesyet': { zh: '暂无已上传文件', en: 'No Uploaded Files Yet' }, + 'noextensionanalysisstageresultsyet': { zh: '暂无拓展分析阶段结果', en: 'No Extension Analysis Stage Results Yet' }, + 'nostatusrecordyet': { zh: '暂无状态记录', en: 'No Status Record Yet' }, + 'maxwidth': { zh: '最大宽度', en: 'Max Width' }, + 'minwidth': { zh: '最小宽度', en: 'Min Width' }, + 'minnongapratio': { zh: '最小非缺口比例', en: 'Min Non-gap Ratio' }, + 'finalcomprehensiveinterpretation': { zh: '最终综合解读', en: 'Final Comprehensive Interpretation' }, + 'closesttogenus': { zh: '最近到属', en: 'Closest to Genus' }, + 'closesttoorder': { zh: '最近到目', en: 'Closest to Order' }, + 'closesttofamily': { zh: '最近到科', en: 'Closest to Family' }, + 'closesttoclass': { zh: '最近到纲', en: 'Closest to Class' }, + 'lossyconversionwarning': { zh: '有损转换警告', en: 'Lossy Conversion Warning' }, + 'stagespausedawaitinguserconfirmation': { zh: '有阶段暂停,等待用户确认', en: 'Stages paused, awaiting user confirmation' }, + 'servertestdatapath': { zh: '服务器测试数据路径', en: 'Server Test Data Path' }, + 'serversidevcforvcfgzpath': { zh: '服务器端 .vcf 或 .vcf.gz 路径', en: 'Server-side .vcf or .vcf.gz Path' }, + 'noadditionalstagesinferredfromcharttitle': { zh: '未从图表标题推断出额外阶段', en: 'No Additional Stages Inferred from Chart Titles' }, + 'nomethodologyplanmatched': { zh: '未匹配到方法学计划', en: 'No Methodology Plan Matched' }, + 'unnamedsample': { zh: '未命名样本', en: 'Unnamed Sample' }, + 'nooutputfilesscanned': { zh: '未扫描到输出文件', en: 'No output files scanned' }, + 'nointerproscandomainreviewtsvfoundshowin': { zh: '未找到 InterProScan domain_review.tsv,当前显示 final_members 与 HMM/BLAST 证据;默认保留全部 final_members,确认后才继续方法学下游分析。', en: 'No InterProScan domain_review.tsv found; showing final_members with HMM/BLAST evidence; keeping all for downstream.' }, + 'referencespeciesnotfound': { zh: '未找到 reference 物种', en: 'Reference Species Not Found' }, + 'targetspeciesnotfound': { zh: '未找到 target 物种', en: 'Target Species Not Found' }, + 'nodirectlydownloadablestringnearsourcere': { zh: '未找到可直接下载的 STRING 近源参考物种。', en: 'No directly downloadable STRING near-source ref species found.' }, + 'noexistingtablefound': { zh: '未找到已有表格', en: 'No Existing Table Found' }, + 'novalidvcffound': { zh: '未找到有效 VCF', en: 'No Valid VCF Found' }, + 'norelatedinformationfound': { zh: '未找到相关信息', en: 'No Related Information Found' }, + 'nooutputfilesfound': { zh: '未找到输出文件', en: 'No Output Files Found' }, + 'nocharttitleextracted': { zh: '未提取到图表标题', en: 'No Chart Title Extracted' }, + 'notsupported': { zh: '未支持', en: 'Not Supported' }, + 'nopairablefastqfilesdetectedcannotgenera': { zh: '未检测到可配对的 FASTQ 样本文件,不能生成 samples.yaml 草稿。', en: 'No pairable FASTQ files detected; cannot generate samples.yaml draft.' }, + 'nopairablefastqfilesuseconfigxlsxautofil': { zh: '未检测到可配对的 FASTQ 样本文件;如果这是基因组/注释/蛋白/CDS目录,请使用 config.xlsx 自动填充。', en: 'No pairable FASTQ files; use config.xlsx auto-fill for genome/annotation/protein/CDS dirs.' }, + 'notgenerated': { zh: '未生成', en: 'Not Generated' }, + 'noexecutablestagesgeneratedcheckstagenam': { zh: '未生成可执行阶段。未检测到当前 scope 的完成结果时,不再把空计划解释为已完成;请检查阶段名称或前置依赖。', en: 'No executable stages generated; check stage names or upstream dependencies.' }, + 'noadvanceddesigngenerated': { zh: '未生成高级设计', en: 'No Advanced Design Generated' }, + 'unknown': { zh: '未知', en: 'Unknown' }, + 'unknownspecies': { zh: '未知物种', en: 'Unknown Species' }, + 'notbound': { zh: '未绑定', en: 'Not Bound' }, + 'notrecorded': { zh: '未记录', en: 'Not Recorded' }, + 'notset': { zh: '未设置', en: 'Not Set' }, + 'notrecognized': { zh: '未识别', en: 'Not Recognized' }, + 'nobuiltindllinkidentified': { zh: '未识别到内置下载链接', en: 'No Built-in DL Link Identified' }, + 'noparametersidentified': { zh: '未识别到参数', en: 'No Parameters Identified' }, + 'noliteraturetoolidentified': { zh: '未识别到文献工具', en: 'No Literature Tool Identified' }, + 'nospeciesinfodetected': { zh: '未识别到物种信息', en: 'No Species Info Detected' }, + 'notselected': { zh: '未选择', en: 'Not Selected' }, + 'plantairestartscriptorrestartplantai9000-2': { zh: '未配置 PLANTAI_RESTART_SCRIPT 或 restart_plantai_9000.sh', en: 'PLANTAI_RESTART_SCRIPT or restart_plantai_9000.sh not configured' }, + 'noprojectrestartscriptconfigured': { zh: '未配置项目重启脚本', en: 'No Project Restart Script Configured' }, + 'thisupload': { zh: '本次上传', en: 'This Upload' }, + 'currentsubfamilyclassificationtargetstar': { zh: '本次亚家族分类对象(target', en: 'Current Subfamily Classification Targets (target)' }, + 'skipppikaksthistime': { zh: '本次先跳过 PPI/KaKs', en: 'Skip PPI/KaKs This Time' }, + 'allstagesofthiscompletegwasusethisprefix': { zh: '本次完整 GWAS 的所有阶段统一使用该前缀。', en: 'All stages of this complete GWAS use this prefix uniformly.' }, + 'currentmemberidentificationspecies': { zh: '本次成员鉴定物种', en: 'Current Member Identification Species' }, + 'executionscopeforthisstage': { zh: '本阶段执行范围', en: 'Execution Scope for This Stage' }, + 'historicalmessages': { zh: '条历史消息', en: ' historical messages' }, + 'hints': { zh: '条提示', en: ' hints' }, + 'source': { zh: '来源:', en: 'Source:' }, + 'nearsourcerefrecommendationfromstringspe': { zh: '来自 STRING 物种列表的近源参考推荐', en: 'Near-source ref recommendation from STRING species list' }, + 'buildfailed': { zh: '构建失败', en: 'Build Failed' }, + 'buildapididnotreturnextensionidcannotpro': { zh: '构建接口未返回 extension_id,不能继续试运行。', en: 'Build API did not return extension_id; cannot proceed with trial.' }, + 'fruitflydme': { zh: '果蝇 Fly (dme', en: 'Fruit Fly (dme)' }, + 'chromosomeannotationposition': { zh: '染色体标注位置', en: 'Chromosome Annotation Position' }, + 'chromosomeannotationsize': { zh: '染色体标注大小', en: 'Chromosome Annotation Size' }, + 'chromosomestrategy': { zh: '染色体策略', en: 'Chromosome Strategy' }, + 'chromosomeblockwidth': { zh: '染色体色块粗细', en: 'Chromosome Block Width' }, + 'chromosomescommaseparated': { zh: '染色体(逗号分隔)', en: 'Chromosomes (comma-separated)' }, + 'viewselectedextractionstatus': { zh: '查看所选提取状态', en: 'View Selected Extraction Status' }, + 'viewlatestextractionstatus': { zh: '查看最新提取状态', en: 'View Latest Extraction Status' }, + 'queryanalysis': { zh: '查询分析', en: 'Query Analysis' }, + 'standardoutput': { zh: '标准输出', en: 'Standard Output' }, + 'standardoutputdirectdisplay': { zh: '标准输出(直接展示)', en: 'Standard Output (Direct Display)' }, + 'labeltable': { zh: '标签表', en: 'Label Table' }, + 'marksize': { zh: '标记大小', en: 'Mark Size' }, + 'markmode': { zh: '标记模式', en: 'Mark Mode' }, + 'candidatestagesinferredfromtitles': { zh: '标题推断的候选阶段', en: 'Candidate Stages Inferred from Titles' }, + 'charttitleruleinference': { zh: '标题规则推断', en: 'Chart Title Rule Inference' }, + 'treefileautoresolvedfromcurrentimagetotr': { zh: '树文件:将根据当前图片自动解析对应 .tree.nwk', en: 'Tree file: auto-resolved from current image to .tree.nwk' }, + 'branchwidth': { zh: '树枝粗细', en: 'Branch Width' }, + 'branchcolor': { zh: '树枝颜色', en: 'Branch Color' }, + 'sample': { zh: '样本', en: 'Sample' }, + 'sampleqcrequiresatleast2samplesforpcacor': { zh: '样本 QC 至少需要 2 个样本才能计算 PCA、相关性或距离。', en: 'Sample QC requires at least 2 samples for PCA, correlation or distance.' }, + 'sampleinfotablesampleid': { zh: '样本信息表 sample_id', en: 'Sample Info Table sample_id' }, + 'sampleinfotablepathoptionalsamplesinfoxl': { zh: '样本信息表路径,可选:samples_info.xlsx/csv/tsv', en: 'Sample info table path, optional: samples_info.xlsx/csv/tsv' }, + 'samplegrouptableoptionalforpcald': { zh: '样本分组表(PCA/LD 可选)', en: 'Sample Group Table (optional for PCA/LD)' }, + 'sampleparallelcount': { zh: '样本并行数量', en: 'Sample Parallel Count' }, + 'insufficientsamplecount': { zh: '样本数量不足', en: 'Insufficient sample count' }, + 'samplefiles': { zh: '样本文件', en: 'Sample Files' }, + 'sampleclusteringtreeplotfailed': { zh: '样本聚类树绘图失败', en: 'Sample Clustering Tree Plot Failed' }, + 'sampletable': { zh: '样本表', en: 'Sample Table' }, + 'sampletableandexpressionmatrixmismatch': { zh: '样本表和表达矩阵不匹配', en: 'Sample table and expression matrix mismatch' }, + 'sampletableautodetection': { zh: '样本表自动识别', en: 'Sample Table Auto-detection' }, + 'sampleevaluation': { zh: '样本评估', en: 'Sample Evaluation' }, + 'sampleconfiguration': { zh: '样本配置', en: 'Sample Configuration' }, + 'sampleconfiguration-2': { zh: '样本配置:', en: 'Sample Configuration:' }, + 'karyotypechromosomedistributionanalysis': { zh: '核型/染色体分布分析', en: 'Karyotype / Chromosome Distribution Analysis' }, + 'checksamplenamesrunidsinsamplesyamlagain': { zh: '核对 samples.yaml 的样本名、run ID 和 count matrix 表头,确保名称一致。', en: 'Check sample names, run IDs in samples.yaml against count matrix headers.' }, + 'check': { zh: '检查', en: 'Check' }, + 'checkcountmatrixkeeponlygeneidsamplecoun': { zh: '检查 count matrix,只保留 gene_id', en: 'Check count matrix; keep only gene_id + sample count columns; remove Chr/Start/End/Strand/Length.' }, + 'checkifsamplesyamlhasonlyonesampleorqcqu': { zh: '检查 samples.yaml 是否只保留了一个样本,或上游 QC/quant 是否只产出一个样本的 count。', en: 'Check if samples.yaml has only one sample, or QC/quant produced single-sample count.' }, + 'checktaskstatusfailed': { zh: '检查任务状态失败', en: 'Check Task Status Failed' }, + 'multipletaxadetectedforclassification': { zh: '检测到多个待分类物种:', en: 'Multiple taxa detected for classification:' }, + 'multiplevalidvcfsdetectedpleaseselect': { zh: '检测到多个有效 VCF,请选择', en: 'Multiple valid VCFs detected; please select' }, + 'downloadmodeautoretrievespeciesdata': { zh: '检索Download模式 (download) - 自动检索物种数据', en: 'Download Mode - Auto-retrieve Species Data' }, + 'downloadmode': { zh: '检索Download模式:', en: 'Download Mode:' }, + 'searching': { zh: '检索中', en: 'Searching...' }, + 'searchresultssufficient': { zh: '检索结果充分', en: 'Search Results Sufficient' }, + 'searchtablecontent': { zh: '检索表格内容', en: 'Search Table Content' }, + 'regexmatch': { zh: '正则匹配', en: 'Regex Match' }, + 'loadinginterspeciesspecieslist': { zh: '正在加载种间物种列表...', en: 'Loading Interspecies Species List...' }, + 'processingrequest': { zh: '正在处理请求...', en: 'Processing request...' }, + 'searchingknowledgebaseandgeneratinganswe': { zh: '正在检索知识库并生成回答...', en: 'Searching knowledge base and generating answer...' }, + 'understandingyourquestionandidentifyings': { zh: '正在理解你的问题并识别检索目标……', en: 'Understanding your question and identifying search targets...' }, + 'generatingexecutionplanandcheckinginputf': { zh: '正在生成执行计划并检查输入文件...', en: 'Generating execution plan and checking input files...' }, + 'searchingknowledgebaseforrelevantcontent': { zh: '正在知识库中查找相关内容……', en: 'Searching knowledge base for relevant content...' }, + 'recognizingyouranalysisintent': { zh: '正在识别你的分析意图...', en: 'Recognizing your analysis intent...' }, + 'recognizingintentandanalysistype': { zh: '正在识别意图和分析类型...', en: 'Recognizing intent and analysis type...' }, + 'invokingmultiagentmethodologyexecutionse': { zh: '正在调用多智能体规划方法学执行顺序...', en: 'Invoking multi-agent methodology execution sequence planning...' }, + 'thisextensionstagehasnoconfigurableparam': { zh: '此拓展阶段没有可配置参数', en: 'This extension stage has no configurable parameters' }, + 'eachtaxongeneratesindependenttreecombopl': { zh: '每个待分类物种分别与 reference 生成独立树、组合图、亚家族分类结果和确认表。', en: 'Each taxon generates independent tree, combo plot, subfamily results with reference.' }, + 'alignment': { zh: '比对', en: 'Alignment' }, + 'comparisonaxis': { zh: '比较主轴:', en: 'Comparison Axis:' }, + 'comparisonaxisautodetect': { zh: '比较主轴:自动判断', en: 'Comparison Axis: Auto-detect' }, + 'comparativespeciescheckasneeded': { zh: '比较物种(按需勾选)', en: 'Comparative Species (check as needed)' }, + 'riceosa': { zh: '水稻 (osa', en: 'Rice (osa)' }, + 'ricechromosomes112': { zh: '水稻 1–12 号染色体', en: 'Rice Chromosomes 1-12' }, + 'aggregatebatchoutput': { zh: '汇总批次输出', en: 'Aggregate Batch Output' }, + 'nomatchingdraftrows': { zh: '没有匹配的草稿行', en: 'No matching draft rows' }, + 'nodownloadablerecommendednearsourcerefsp': { zh: '没有可下载的推荐近源参考物种', en: 'No downloadable recommended near-source ref species' }, + 'noresultsfoundtodisplay': { zh: '没有找到可展示的结果。', en: 'No results found to display.' }, + 'noextensionanalysisstagedetectedforexecu': { zh: '没有识别到要执行的拓展分析阶段', en: 'No extension analysis stage detected for execution' }, + 'uselegacypipelinecompatibilitydesign': { zh: '沿用原流程兼容设计', en: 'Use legacy pipeline compatibility design' }, + 'testdata': { zh: '测试数据', en: 'Test Data' }, + 'browse': { zh: '浏览', en: 'Browse' }, + 'addt01suffixaddt01': { zh: '添加.T01后缀 (add_T01', en: 'Add .T01 Suffix (add_T01)' }, + 'addrow': { zh: '添加行', en: 'Add Row' }, + 'gray': { zh: '灰色', en: 'Gray' }, + 'species': { zh: '物种', en: 'Species' }, + 'species1cdsegatcdsfasta': { zh: '物种1 CDS序列 (如 At.cds.fasta', en: '- Species 1 CDS (e.g. At.cds.fasta)' }, + 'species2cdsegoscdsfasta': { zh: '物种2 CDS序列 (如 Os.cds.fasta', en: '- Species 2 CDS (e.g. Os.cds.fasta)' }, + 'speciescode': { zh: '物种代码', en: 'Species Code' }, + 'speciescodeunchanged': { zh: '物种代码不改', en: 'Species Code (Unchanged)' }, + 'speciesname': { zh: '物种名', en: 'Species Name' }, + 'specieslatinnameegarabidopsisthaliana': { zh: '物种拉丁名,如 Arabidopsis thaliana', en: 'Species Latin name, e.g. Arabidopsis thaliana' }, + 'speciesabbreviationegosatzm': { zh: '物种简称,如 Os / At / Zm', en: 'Species abbreviation, e.g. Os / At / Zm' }, + 'speciesrole': { zh: '物种角色', en: 'Species Role' }, + 'speciesroleunchanged': { zh: '物种角色不改', en: 'Species Role (Unchanged)' }, + 'maizezma': { zh: '玉米 (zma', en: 'Maize (zma)' }, + 'environment': { zh: '环境', en: 'Environment' }, + 'generate': { zh: '生成', en: 'Generate' }, + 'generating': { zh: '生成中', en: 'Generating...' }, + 'generateanswer': { zh: '生成回答', en: 'Generate Answer' }, + 'generatedraft': { zh: '生成草稿', en: 'Generate Draft' }, + 'draftgenerationfailed': { zh: '生成草稿失败', en: 'Draft Generation Failed' }, + 'generateinterpretationfailed': { zh: '生成解读失败', en: 'Generate Interpretation Failed' }, + 'biologicalcontext': { zh: '生物学背景', en: 'Biological Context' }, + 'usedtoinferkeggorganismcodeegoryzasativa': { zh: '用于推断 KEGG organism code;例如 Oryza sativa 会优先检索 osa 物种级 pathway gene set', en: 'Used to infer KEGG organism code; e.g. Oryza sativa retrieves osa species-level pathway gene set' }, + 'usermanuallyadded': { zh: '用户手动添加', en: 'User Manually Added' }, + 'leaveemptytousegtfgffannotationfrominput': { zh: '留空则使用 input 中的 GTF/GFF 注释', en: 'Leave empty to use GTF/GFF annotation from input' }, + 'leaveemptytouseoutput': { zh: '留空则使用 output', en: 'Leave empty to use output' }, + 'leaveemptyforautouseoffeaturecountscount': { zh: '留空则自动使用 featureCounts count matrix', en: 'Leave empty for auto-use of featureCounts count matrix' }, + 'leaveemptyforautouseofnormalizedvsttpmma': { zh: '留空则自动使用 normalized/vst/TPM 表达矩阵', en: 'Leave empty for auto-use of normalized/vst/TPM matrix' }, + 'leaveemptyforautouseofsamplesyamlsamplet': { zh: '留空则自动使用 samples.yaml / sample table', en: 'Leave empty for auto-use of samples.yaml / sample table' }, + 'leaveemptyforautodiscovery': { zh: '留空则自动发现', en: 'Leave empty for auto-discovery' }, + 'leaveemptyforautoinference': { zh: '留空则自动推断', en: 'Leave empty for auto-inference' }, + 'leaveemptyforautogenerationdiscovery': { zh: '留空则自动生成/发现', en: 'Leave empty for auto-generation/discovery' }, + 'leaveemptyforautoselectionofdecomparison': { zh: '留空则自动选择 DE comparison', en: 'Leave empty for auto-selection of DE comparison' }, + 'leaveemptytousefastpbinorpath': { zh: '留空时使用 FASTP_BIN 或 PATH', en: 'Leave empty to use FASTP_BIN or PATH' }, + 'tomatosly': { zh: '番茄 (sly', en: 'Tomato (sly)' }, + 'directory': { zh: '目录', en: 'Directory' }, + 'target': { zh: '目标', en: 'Target' }, + 'targetprefix': { zh: '目标前缀', en: 'Target Prefix' }, + 'targetspecies-2': { zh: '目标物种', en: 'Target Species' }, + 'targetspeciesnotinstringsnapshotconfirmu': { zh: '目标物种不在 STRING 固定快照中。请确认是否采用排序第一的近源物种:', en: 'Target species not in STRING snapshot. Confirm using top-ranked near-source species:' }, + 'targetspeciesnotusableforstringppikaks': { zh: '目标物种当前不能直接用于 STRING PPI/KaKs', en: 'Target species not usable for STRING PPI/KaKs.' }, + 'targetspeciesnotusableforstringppikaks-2': { zh: '目标物种当前不能直接用于 STRING PPI/KaKs。', en: 'Target species not usable for STRING PPI/KaKs.' }, + 'directuploadexpressionmatrixfpkmtxttpmtx': { zh: '直接上传 - 上传表达量矩阵(*.fpkm.txt/*.tpm.txt)', en: 'Direct Upload: expression matrix (*.fpkm.txt/*.tpm.txt)+geneID+sample.txt' }, + 'knowledgegraph': { zh: '知识图谱', en: 'Knowledge Graph' }, + 'knowledgegraphloaded': { zh: '知识图谱加载完成', en: 'Knowledge Graph Loaded' }, + 'knowledgebasesearch': { zh: '知识库检索', en: 'Knowledge Base Search' }, + 'knowledgebasesearchtimedout300s': { zh: '知识库检索超时(300秒未返回)。', en: 'Knowledge base search timed out (300s).' }, + 'gotit-2': { zh: '知道了', en: 'Got It' }, + 'rectangularplot': { zh: '矩形图', en: 'Rectangular Plot' }, + 'rectangulartree': { zh: '矩形树', en: 'Rectangular Tree' }, + 'confirm-2': { zh: '确定', en: 'Confirm' }, + 'confirmdeanalysisdesign': { zh: '确认 DE 分析设计', en: 'Confirm DE Analysis Design' }, + 'confirmppistringnearsourcereferencespeci': { zh: '确认 PPI 的 STRING 近源参考物种', en: 'Confirm PPI STRING Near-source Reference Species' }, + 'verifygenomefilesindexininputdonotusecds': { zh: '确认 input 目录中有类似 Os.genome.fasta/fa/fna,或 Os.genome.1.ht2 等基因组索引;不要把 Os.cds 或 Os.pep 当作比对参考。', en: 'Verify genome files/index in input; do not use CDS/PEP as alignment reference.' }, + 'confirmsubfamilylabels': { zh: '确认亚家族标签', en: 'Confirm Subfamily Labels' }, + 'confirmsave': { zh: '确认保存', en: 'Confirm Save' }, + 'confirmkeepallmembers': { zh: '确认保留全部成员', en: 'Confirm Keep All Members' }, + 'nonewexecutablestagesafterconfirmation': { zh: '确认后常规分析没有新的可执行阶段', en: 'No new executable stages after confirmation' }, + 'nonewexecutablestagesafterconfirmation-2': { zh: '确认后常规分析没有新的可执行阶段。', en: 'No new executable stages after confirmation.' }, + 'refpepdownloadorthologgenerationppiexecu': { zh: '确认后才会下载参考 PEP、生成 Ortholog,并执行 PPI。', en: 'Ref PEP download, Ortholog generation, PPI execution only after confirmation.' }, + 'onlypublishafterconfirmationwillnotautop': { zh: '确认后才会发布;不会自动发布。', en: 'Only publish after confirmation; will not auto-publish.' }, + 'confirmfailed': { zh: '确认失败', en: 'Confirm Failed' }, + 'confirmedsaved': { zh: '确认已保存', en: 'Confirmed & Saved' }, + 'confirmwritetoofficialconfig': { zh: '确认并写入正式配置', en: 'Confirm & Write to Official Config' }, + 'confirmexecute': { zh: '确认并执行', en: 'Confirm & Execute' }, + 'confirmexecution-2': { zh: '确认执行', en: 'Confirm Execution' }, + 'confirmfirstcandidate': { zh: '确认第一候选', en: 'Confirm First Candidate' }, + 'confirmautoannotation': { zh: '确认自动注释', en: 'Confirm Auto-Annotation' }, + 'confirmationtable': { zh: '确认表', en: 'Confirmation Table' }, + 'confirmdesignwrite': { zh: '确认设计并写入', en: 'Confirm Design & Write' }, + 'confirmrestart': { zh: '确认重启', en: 'Confirm Restart' }, + 'exampledownloadfunctioncurrentlyunavaila': { zh: '示例下载功能当前不可用', en: 'Example Download Function Currently Unavailable' }, + 'disablekeggnetwork': { zh: '禁用 KEGG 网络', en: 'Disable KEGG Network' }, + 'intraspeciessyntenycircosrevisionparamet': { zh: '种内共线性 Circos 修订参数', en: 'Intraspecies Synteny Circos Revision Parameters' }, + 'intraspeciessyntenycircosreplotfailed': { zh: '种内共线性 Circos 重绘失败:', en: 'Intraspecies Synteny Circos Replot Failed:' }, + 'intraspeciessyntenycircosreplotcompletes': { zh: '种内共线性 Circos 重绘完成,已切换到新图', en: 'Intraspecies synteny Circos replot complete; switched to new figure' }, + 'intraspeciessyntenycircosreplotsubmitted': { zh: '种内共线性 Circos 重绘已提交:task_id=', en: 'Intraspecies synteny Circos replot submitted: task_id=' }, + 'intraspeciessyntenyanalysis': { zh: '种内共线性分析', en: 'Intraspecies Synteny Analysis' }, + 'interspeciessyntenyanalysis': { zh: '种间共线性分析', en: 'Interspecies Synteny Analysis' }, + 'interspeciessyntenycomparativespecies': { zh: '种间共线性比较物种', en: 'Interspecies Synteny Comparative Species' }, + 'interspeciessyntenyplotsettings': { zh: '种间共线性绘图设置', en: 'Interspecies Synteny Plot Settings' }, + 'remove': { zh: '移除', en: 'Remove' }, + 'windowlength': { zh: '窗口长度', en: 'Window Length' }, + 'filter': { zh: '筛选', en: 'Filter' }, + 'simplifytooriginalrecordid': { zh: '简化为原record ID', en: 'Simplify to Original Record ID' }, + 'type': { zh: '类型', en: 'Type' }, + 'exactmatch': { zh: '精确匹配', en: 'Exact Match' }, + 'system': { zh: '系统', en: 'System' }, + 'systemwillpreparerefpepcdsgenerateorthol': { zh: '系统会先准备参考 PEP/CDS,再由 Ortholog 生成映射,之后执行 PPI/KaKs。', en: 'System will prepare ref PEP/CDS, generate Ortholog mapping, then execute PPI/KaKs.' }, + 'systemwillautosearchfilesattheselocation': { zh: '系统会自动搜索以下位置的文件:', en: 'System Will Auto-Search Files at These Locations:' }, + 'phylogenetics': { zh: '系统发育', en: 'Phylogenetics' }, + 'phylogeneticanalysis': { zh: '系统发育分析', en: 'Phylogenetic Analysis' }, + 'systemdeterminingexecutionentryrunningup': { zh: '系统正在确定执行入口,并运行上传的代码和测试数据,提取不可修改的执行事实。', en: 'System determining execution entry; running uploaded code and test data.' }, + 'red': { zh: '红色', en: 'Red' }, + 'comboplot': { zh: '组合图', en: 'Combo Plot' }, + 'tissue': { zh: '组织 tissue', en: 'Tissue' }, + 'endalignmentcolumn': { zh: '终止 alignment 列', en: 'End Alignment Column' }, + 'endsite': { zh: '终止位点', en: 'End Site' }, + 'endresidue': { zh: '终止残基', en: 'End Residue' }, + 'domainanalysis': { zh: '结构域分析', en: 'Domain Analysis' }, + 'numberoftopmotifsinresultplot': { zh: '结果图展示的 Top motif 数量', en: 'Number of Top Motifs in result plot' }, + 'resultcorrectcontinue': { zh: '结果无误,继续', en: 'Result Correct; Continue' }, + 'resultfailedmanifestvalidation': { zh: '结果未通过 manifest 校验', en: 'Result failed manifest validation' }, + 'resultchecklistvalidationfailed': { zh: '结果清单校验未通过', en: 'Result checklist validation failed' }, + 'resulttable': { zh: '结果表', en: 'Result Table' }, + 'resultassessment': { zh: '结果评估', en: 'Result Assessment' }, + 'plotdatareusegffmcscanxcollinearityfamil': { zh: '绘图数据:复用 synteny 阶段的 GFF、MCScanX collinearity、family gene ID 和 genome。', en: 'Plot data: reuse GFF, MCScanX collinearity, family gene ID and genome from synteny stage.' }, + 'plotmode': { zh: '绘图模式', en: 'Plot Mode' }, + 'unifiedtipmark': { zh: '统一Tip标记', en: 'Unified Tip Mark' }, + 'statisticschain': { zh: '统计链:', en: 'Statistics Chain:' }, + 'green': { zh: '绿色', en: 'Green' }, + 'edit': { zh: '编辑', en: 'Edit' }, + 'missingcleanreadsafterqc': { zh: '缺少 QC 后的 clean reads', en: 'Missing clean reads after QC' }, + 'missinggene2kobackground': { zh: '缺少 gene2ko 背景', en: 'Missing gene2ko Background' }, + 'missinggene2kobackgroundfile': { zh: '缺少 gene2ko 背景文件', en: 'Missing gene2ko Background File' }, + 'missingusablegene2gobackground': { zh: '缺少可用 gene2go 背景', en: 'Missing Usable gene2go Background' }, + 'missingusableorthologmapping': { zh: '缺少可用同源映射', en: 'Missing Usable Ortholog Mapping' }, + 'missingbackgroundannotationfile': { zh: '缺少背景注释文件', en: 'Missing Background Annotation File' }, + 'missinginputdata': { zh: '缺少输入数据', en: 'Missing Input Data' }, + 'confidence': { zh: '置信度', en: 'Confidence' }, + 'confidencethreshold': { zh: '置信度阈值', en: 'Confidence Threshold' }, + 'populationlddecayanalysis': { zh: '群体LD衰减分析', en: 'Population LD Decay Analysis' }, + 'populationpcaanalysis': { zh: '群体PCA分析', en: 'Population PCA Analysis' }, + 'populationphylogenetictreeanalysis': { zh: '群体系统树分析', en: 'Population Phylogenetic Tree Analysis' }, + 'populationstructureanalysis': { zh: '群体结构分析', en: 'Population Structure Analysis' }, + 'populationgeneticinput': { zh: '群体遗传输入', en: 'Population Genetic Input' }, + 'backgroundidexample': { zh: '背景ID示例', en: 'Background ID Example' }, + 'backgroundreferenceonlynotindataprep': { zh: '背景提及,不进入数据准备', en: 'Background reference only; not in data prep' }, + 'backgroundsource': { zh: '背景来源', en: 'Background Source' }, + 'backgroundannotation': { zh: '背景注释', en: 'Background Annotation' }, + 'auto': { zh: '自动', en: 'Auto' }, + 'autoinferfromconfigxlsxscope': { zh: '自动从 config.xlsx / scope 推断', en: 'Auto-infer from config.xlsx / scope' }, + 'autoextractfromannotation': { zh: '自动从注释提取', en: 'Auto-extract from Annotation' }, + 'autousesamplesyamlfrominputfinal': { zh: '自动使用 input/final 中的 samples.yaml', en: 'Auto-use samples.yaml from input/final' }, + 'autoconservedwindow': { zh: '自动保守窗口', en: 'Auto Conserved Window' }, + 'autofix': { zh: '自动修复', en: 'Auto Fix' }, + 'autofixresults': { zh: '自动修复结果', en: 'Auto Fix Results' }, + 'autorecommendedprioritizefamilydegenesfa': { zh: '自动判定(推荐):优先 family_DE_genes,缺失时回退 DE_genes', en: 'Auto (recommended): prioritize family_DE_genes, fallback to global DE_genes' }, + 'autoprioritizefamilydegenesfallbacktoglo': { zh: '自动判定:优先 family_DE_genes,缺失时回退全局 DE_genes', en: 'Auto: prioritize family_DE_genes, fallback to global DE_genes' }, + 'autogenefamilywhenfinalmembersidexistsot': { zh: '自动判定:有 final_members.id 时做基因家族,否则做全基因组', en: 'Auto: gene family when final_members.id exists, otherwise genome-wide' }, + 'autodetect': { zh: '自动判断', en: 'Auto-detect' }, + 'autodetectrecommended': { zh: '自动判断(推荐)', en: 'Auto-detect (Recommended)' }, + 'autoreview': { zh: '自动审核', en: 'Auto Review' }, + 'autodetectpeadapter': { zh: '自动检测 PE adapter', en: 'Auto-detect PE Adapter' }, + 'autoannotatecontinue': { zh: '自动注释并继续', en: 'Auto-annotate & Continue' }, + 'autoannotationislongtaskautoconfirmin40s': { zh: '自动注释是长任务。40秒内不操作将自动确认;可暂停倒计时后手动决定。', en: 'Auto-annotation is long task. Auto-confirm in 40s if no action; pause countdown to decide.' }, + 'autodetectrecommended-2': { zh: '自动识别(推荐)', en: 'Auto-detect (Recommended)' }, + 'autoselectplanttfdbjaspar': { zh: '自动选择 PlantTFDB/JASPAR', en: 'Auto-select PlantTFDB/JASPAR' }, + 'autoselectgene2gofirstthenbuiltinorgdbbi': { zh: '自动选择:优先 gene2go,其次内置OrgDb/可用Bioconductor', en: 'Auto-select: gene2go first, then built-in OrgDb/Bioconductor' }, + 'autoselectuploadedbackgroundfirstthenbui': { zh: '自动选择:优先已上传背景,其次内置模式物种/可用Bioconductor', en: 'Auto-select: uploaded background first, then built-in model species/Bioconductor' }, + 'autoperspeciesformultiunifiedtipforsingl': { zh: '自动:多物种按物种,单物种统一Tip', en: 'Auto: per-species for multi; unified Tip for single' }, + 'nltriggerwordscommaseparatedegblastpsequ': { zh: '自然语言触发词,逗号分隔,例如 blastp, 序列比对, SPL blast', en: 'NL trigger words, comma-separated, e.g. blastp, sequence alignment' }, + 'nldescriptiontargetspeciesfamilyreferenc': { zh: '自然语言说明:目标物种、家族、参考物种、比较物种等', en: 'NL description: target species, family, reference, comparative, etc.' }, + 'naturallanguageschedulinginprogress': { zh: '自然语言调度执行中...', en: 'Natural language scheduling in progress...' }, + 'atleasttwosamples': { zh: '至少两个样本', en: 'At least two samples' }, + 'keepatleastonegeneid': { zh: '至少保留一个 gene ID', en: 'Keep at least one gene ID' }, + 'diamond': { zh: '菱形', en: 'Diamond' }, + 'blue': { zh: '蓝色', en: 'Blue' }, + 'protein': { zh: '蛋白', en: 'Protein' }, + 'proteinpep': { zh: '蛋白/PEP', en: 'Protein/PEP' }, + 'proteinfiles': { zh: '蛋白文件', en: 'Protein Files' }, + 'proteinphysicochemicalpropertiessubcellu': { zh: '蛋白理化性质与亚细胞定位', en: 'Protein Physicochemical Properties & Subcellular Localization' }, + 'tablepreview': { zh: '表格预览', en: 'Table Preview' }, + 'expressionmatrix': { zh: '表达矩阵', en: 'Expression Matrix' }, + 'expressionmatrixanalysis': { zh: '表达矩阵分析', en: 'Expression Matrix Analysis' }, + 'seeexecutionlog': { zh: '见执行日志', en: 'See Execution Log' }, + 'plan': { zh: '规划', en: 'Plan' }, + 'planning': { zh: '规划中', en: 'Planning...' }, + 'planningrevisionsuggestions': { zh: '规划修订建议', en: 'Planning Revision Suggestions' }, + 'planningfailed': { zh: '规划失败', en: 'Planning Failed' }, + 'planningrequestfailed': { zh: '规划请求失败', en: 'Planning Request Failed' }, + 'normalizeobservation': { zh: '规范化 observation', en: 'Normalize Observation' }, + 'viewreset': { zh: '视图已重置', en: 'View Reset' }, + 'interpretationcontentcopied': { zh: '解读内容已复制', en: 'Interpretation Content Copied' }, + 'designreviewfailed': { zh: '设计审查失败', en: 'Design Review Failed' }, + 'designreviewneedsadditionalinformation': { zh: '设计审查需要补充信息', en: 'Design Review Needs Additional Information' }, + 'designreview': { zh: '设计审查:', en: 'Design Review:' }, + 'diagnosis': { zh: '诊断', en: 'Diagnosis' }, + 'trialrunning': { zh: '试运行中...', en: 'Trial Running...' }, + 'trialrunfailed': { zh: '试运行失败', en: 'Trial Run Failed' }, + 'trialrunfailedpleasechecklog': { zh: '试运行失败,请查看日志', en: 'Trial run failed; please check log' }, + 'trialrunsuccessful': { zh: '试运行成功', en: 'Trial Run Successful' }, + 'trialrunnotpassed': { zh: '试运行未通过', en: 'Trial Run Not Passed' }, + 'thisidalreadyexists': { zh: '该 ID 已存在', en: 'This ID Already Exists' }, + 'stagealreadyinformallist': { zh: '该阶段已在正式列表中', en: 'Stage Already in Formal List' }, + 'description': { zh: '说明', en: 'Description' }, + 'noteppineedsorthologmappingoftargetstost': { zh: '说明:PPI 需要先把目标基因通过 Ortholog 映射到 STRING 支持物种;Ka/Ks 还需要参考 CDS。你可以先准备/下载参考数据,也可以本次先跳过 PPI/KaKs,继续其它不依赖该数据的分析。', en: 'Note: PPI needs Ortholog mapping of targets to STRING species; Ka/Ks needs ref CDS. Prepare/download ref data or skip PPI/KaKs this time.' }, + 'pleaseuploadvcffirstorfixauthoritativema': { zh: '请先上传 VCF,或修复当前 job 的权威 Manifest。', en: 'Please upload VCF first, or fix authoritative Manifest of current job.' }, + 'pleasebuildtemporarydbfirst': { zh: '请先构建临时库', en: 'Please Build Temporary DB First' }, + 'pleasereviewconfigxlsxdraftfirst': { zh: '请先核对 config.xlsx 草稿。', en: 'Please review config.xlsx draft first.' }, + 'pleasegenerateorloadadraftfirst': { zh: '请先生成或加载草稿', en: 'Please generate or load a draft first' }, + 'pleasegenerateextensionanalysisstagefirs': { zh: '请先生成拓展分析阶段。', en: 'Please generate extension analysis stage first.' }, + 'pleasetrialrunfirsttorecordactualoutput': { zh: '请先试运行以记录真实输出', en: 'Please trial-run first to record actual output' }, + 'pleaseselectpdforminerucontentlistjsonli': { zh: '请先选择 PDF 或 MinerU content_list JSON 文献文件', en: 'Please select PDF or MinerU content_list JSON literature file' }, + 'pleaseselectanimagefirst': { zh: '请先选择一张图片', en: 'Please select an image first' }, + 'pleaseselectanactualrunjobfirst': { zh: '请先选择实际运行 Job。', en: 'Please select an actual run Job first.' }, + 'pleaseselectorcreateaworkingdirectory': { zh: '请先选择或创建 Working Directory', en: 'Please select or create a Working Directory' }, + 'pleaseselectorentermethodologydirfirst': { zh: '请先选择或输入 methodology_dir', en: 'Please select or enter methodology_dir first' }, + 'pleaseselectavalidvcffirst': { zh: '请先选择有效 VCF', en: 'Please Select a Valid VCF First' }, + 'pleasepreviewchangestoapplyfirst': { zh: '请先预览要应用的修改', en: 'Please preview changes to apply first' }, + 'pleasewriterevisionsuggestions': { zh: '请写明修订意见。', en: 'Please write revision suggestions.' }, + 'pleasedescriberevisioncontentherethensen': { zh: '请在这里说明需要修订的内容,然后发送。', en: 'Please describe revision content here, then send.' }, + 'describerevisioninmainconversationinputb': { zh: '请在页面下方的主对话输入框说明需要修订的内容,然后发送。命令和真实执行事实不会被修改。', en: 'Describe revision in main conversation input below; commands and facts won\'t be modified.' }, + 'pleasefillgwasspeciesabbreviationforresu': { zh: '请填写 GWAS 物种简称(用于结果前缀)', en: 'Please fill GWAS species abbreviation (for result prefix)' }, + 'pleasefillmsaalignmentstartendcolumnsors': { zh: '请填写MSA alignment 起始列和终止列,或选择其他模式。', en: 'Please fill MSA alignment start/end columns or select another mode.' }, + 'pleasefillreferencegeneanditsstartendres': { zh: '请填写参考基因及其起止残基编号。', en: 'Please fill reference gene and its start/end residue numbers.' }, + 'fillnameandprovidetestdataorserverpathat': { zh: '请填写名称并提供测试数据文件或服务器路径;原始命令和原始代码至少提供一项。', en: 'Fill name and provide test data or server path; at least original command or code.' }, + 'fillnameanduploadoriginalcode': { zh: '请填写名称,并上传原始代码。', en: 'Fill name and upload original code.' }, + 'pleasecheckverificationlog': { zh: '请查看验证日志', en: 'Please Check Verification Log' }, + 'pleasechecktestdataandcommandtemplate': { zh: '请检查测试数据和命令模板', en: 'Please check test data and command template' }, + 'request': { zh: '请求', en: 'Request' }, + 'requestfailed': { zh: '请求失败', en: 'Request Failed' }, + 'requestfailedpleasetryagainlater': { zh: '请求失败,请稍后重试。', en: 'Request failed; please try again later.' }, + 'interpretationservicerequestfailed': { zh: '请求解读服务失败', en: 'Interpretation Service Request Failed' }, + 'requesttimedoutnoanswerwithintimelimitpl': { zh: '请求超时,未能在规定时间内获得回答,请稍后重试。', en: 'Request timed out; no answer within time limit; please try again later.' }, + 'pleasefillatleastonefieldtopreviewchange': { zh: '请至少填写一个要预览修改的字段', en: 'Please fill at least one field to preview changes' }, + 'pleaseselectatleastoneexecutablebbav5sta': { zh: '请至少选择一个可执行的 bbav5 阶段', en: 'Please select at least one executable bbav5 stage' }, + 'pleaseselectatleastonepromoterelementorc': { zh: '请至少选择一个启动子元件或类别。', en: 'Please select at least one promoter element or category.' }, + 'pleaseselectatleastoneplottype': { zh: '请至少选择一种图类型。', en: 'Please select at least one plot type.' }, + 'pleasedetailyourrequirements': { zh: '请补充你的需求。', en: 'Please detail your requirements.' }, + 'pleaseenterfilepath': { zh: '请输入文件路径', en: 'Please Enter File Path' }, + 'pleaseentermethodologyquestion': { zh: '请输入方法学问题', en: 'Please Enter Methodology Question' }, + 'pleaseselectsyntenyanalysistype': { zh: '请选择共线性分析类型', en: 'Please select synteny analysis type' }, + 'pleaseselectthisgwasinput': { zh: '请选择本次 GWAS 输入', en: 'Please select this GWAS input' }, + 'failedtoreadpromoterelements': { zh: '读取启动子元件失败:', en: 'Failed to Read Promoter Elements:' }, + 'failedtoreadextensionanalysisstatus': { zh: '读取拓展分析状态失败', en: 'Failed to Read Extension Analysis Status' }, + 'failedtoreadextensionanalysisdescription': { zh: '读取拓展分析说明失败', en: 'Failed to Read Extension Analysis Description' }, + 'readconfirmationinfofailed': { zh: '读取确认信息失败', en: 'Read Confirmation Info Failed' }, + 'failedtoreadlightweightextensionanalysis': { zh: '读取轻量拓展分析列表失败', en: 'Failed to Read Lightweight Extension Analysis List' }, + 'failedtoreadrunparameters': { zh: '读取运行参数失败', en: 'Failed to Read Run Parameters' }, + 'failedtoreadrundescription': { zh: '读取运行说明失败', en: 'Failed to Read Run Description' }, + 'failedtoreadverificationresults': { zh: '读取验证结果失败', en: 'Failed to Read Verification Results' }, + 'bringup': { zh: '调出', en: 'Bring Up' }, + 'pedigreeinputinvalid': { zh: '谱系图输入无效', en: 'Pedigree Input Invalid' }, + 'qc': { zh: '质控', en: 'QC' }, + 'startalignmentcolumn': { zh: '起始 alignment 列', en: 'Start Alignment Column' }, + 'startsite': { zh: '起始位点', en: 'Start Site' }, + 'startresidue': { zh: '起始残基', en: 'Start Residue' }, + 'path': { zh: '路径', en: 'Path' }, + 'pathcopied': { zh: '路径已复制', en: 'Path Copied' }, + 'skippathview': { zh: '跳过 pathview', en: 'Skip pathview' }, + 'transcriptome': { zh: '转录组', en: 'Transcriptome' }, + 'polltaskstatusfailed': { zh: '轮询任务状态失败', en: 'Poll Task Status Failed' }, + 'loadparseddocument': { zh: '载入解析文档', en: 'Load Parsed Document' }, + 'input': { zh: '输入', en: 'Input' }, + 'entergeneidtosearchandlocate': { zh: '输入 Gene ID 搜索定位', en: 'Enter Gene ID to Search and Locate' }, + 'inputfiles': { zh: '输入文件', en: 'Input Files' }, + 'inputfilesparameters': { zh: '输入文件和参数', en: 'Input Files & Parameters' }, + 'output': { zh: '输出', en: 'Output' }, + 'outputprefix': { zh: '输出前缀', en: 'Output Prefix' }, + 'outputnaming': { zh: '输出命名:', en: 'Output Naming:' }, + 'outputnamingautofromconfigxlsxfilespecie': { zh: '输出命名:按 config.xlsx / 文件物种代码自动生成,不需要填写输出标签', en: 'Output naming: auto from config.xlsx / file species codes' }, + 'outputnamingautoinferredwhenempty': { zh: '输出命名:留空时自动推断', en: 'Output naming: auto-inferred when empty' }, + 'outputfiles': { zh: '输出文件', en: 'Output Files' }, + 'outputlabel': { zh: '输出标签', en: 'Output Label' }, + 'outputtagprefix': { zh: '输出标签 / Prefix', en: 'Output Tag / Prefix' }, + 'runvalue': { zh: '运行值', en: 'Run Value' }, + 'runresults': { zh: '运行结果', en: 'Run Results' }, + 'runresultreview': { zh: '运行结果核对', en: 'Run Result Review' }, + 'returntomodify': { zh: '返回修改', en: 'Return to Modify' }, + 'whatisthemethodologyworkflowofthispaper': { zh: '这个文献的方法学流程是什么?', en: 'What is the methodology workflow of this paper?' }, + 'thiswillstopandrestartceleryworkerbacken': { zh: '这会停止当前 profile 的 Celery worker 并重新启动。若有任务正在运行,后端会默认拒绝执行。', en: 'This will stop and restart Celery worker; backend rejects if tasks running.' }, + 'thiswillsubmitprojectrestartscriptpagema': { zh: '这会提交项目重启脚本,当前页面可能短暂断开。', en: 'This will submit project restart script; page may briefly disconnect.' }, + 'sampleqcfinalplotboundaryissueupstreamal': { zh: '这是 sample QC 最后的绘图边界问题,不代表前面的比对、定量或 VST 计算全部失败。', en: 'Sample QC final plot boundary issue; upstream alignment/quantification/VST not all failed.' }, + 'phylogenetictreerevisionparameters': { zh: '进化树图修订参数', en: 'Phylogenetic Tree Revision Parameters' }, + 'phylogenetictreereplotfailed': { zh: '进化树图重绘失败:', en: 'Phylogenetic Tree Replot Failed:' }, + 'phylogenetictreereplotcompleteswitchedto': { zh: '进化树图重绘完成,已切换到新图', en: 'Phylogenetic tree replot complete; switched to new figure' }, + 'phylogenetictreereplotsubmittedtaskid': { zh: '进化树图重绘已提交:task_id=', en: 'Phylogenetic tree replot submitted: task_id=' }, + 'treeoutputlayoutbothoutputsrectangularan': { zh: '进化树输出布局。both 会同时输出矩形树和圈图。', en: 'Tree output layout; both outputs rectangular and circle plots.' }, + 'selectabredreltrmbsetc': { zh: '选择 ABRE / DRE / LTR / MBS 等', en: 'Select ABRE / DRE / LTR / MBS etc.' }, + 'afterselectingdlrecommendedrefdatasystem': { zh: '选择“下载推荐参考数据”后,系统会先下载参考 PEP/CDS,再重新检查并由 Ortholog 生成映射。', en: 'After selecting DL recommended ref data, system downloads ref PEP/CDS, re-checks, generates Ortholog mapping.' }, + 'whenselectingskipthistimeppikaksmarkedpa': { zh: '选择“本次先跳过”时,PPI/KaKs 会标记为暂停,其它阶段继续执行。', en: 'When selecting skip this time, PPI/KaKs marked paused; other stages continue.' }, + 'selectotherextractionresult': { zh: '选择其他提取结果', en: 'Select Other Extraction Result' }, + 'selectanalysisstageselectstage': { zh: '选择分析阶段 / Select stage', en: 'Select Analysis Stage / Select stage' }, + 'selecttool': { zh: '选择工具', en: 'Select Tool' }, + 'selectcompletedextensionstage': { zh: '选择已完成撰写的拓展阶段', en: 'Select Completed Extension Stage' }, + 'selectorentergeneid': { zh: '选择或输入基因ID', en: 'Select or Enter Gene ID' }, + 'selectclassificationidentificationtarget': { zh: '选择本次分类/鉴定对象', en: 'Select classification/identification targets for this run' }, + 'selectanalysistypestoshowincomboplotuses': { zh: '选择要在 combo 图中展示的分析类型;单类型图请使用对应阶段已有结果。', en: 'Select analysis types to show in combo plot; use stage results for single-type plots.' }, + 'perrowfieldleaveemptyforgenomewidefillfo': { zh: '逐行字段;全基因组资源留空,已知家族 seed 文件才填写,如 NAC/WRKY', en: 'Per-row field; leave empty for genome-wide; fill for known family seed files' }, + 'usuallyleaveempty': { zh: '通常留空', en: 'Usually Leave Empty' }, + 'configbulkeditarea': { zh: '配置批量修订区', en: 'Config Bulk Edit Area' }, + 'configfile': { zh: '配置文件', en: 'Config File' }, + 'configtable': { zh: '配置表', en: 'Config Table' }, + 'configtableautodetection': { zh: '配置表自动识别', en: 'Config Table Auto-detection' }, + 'yeastsce': { zh: '酵母 Yeast (sce', en: 'Yeast (sce)' }, + 'restartcelery': { zh: '重启 Celery', en: 'Restart Celery' }, + 'restartproject': { zh: '重启项目', en: 'Restart Project' }, + 'redrawingfeatureunderdevelopment': { zh: '重新绘图功能开发中...', en: 'Re-drawing feature under development...' }, + 'redetect': { zh: '重新识别', en: 'Re-detect' }, + 'resetview-2': { zh: '重置视图', en: 'Reset View' }, + 'link': { zh: '链接', en: 'Link' }, + 'librarystrandedness': { zh: '链特异性', en: 'Library Strandedness' }, + 'librarystrandednessunchangededitable': { zh: '链特异性不改/可输入', en: 'Library Strandedness (Unchanged/Editable)' }, + 'error': { zh: '错误', en: 'Error' }, + 'stage-2': { zh: '阶段', en: 'Stage' }, + 'stagepausedawaitinguserconfirmation': { zh: '阶段暂停,等待用户确认', en: 'Stage paused, awaiting user confirmation' }, + 'hide': { zh: '隐藏', en: 'Hide' }, + 'needsconfirmation': { zh: '需确认', en: 'Needs Confirmation' }, + 'preparenearsourcehomologousrefdatafirstt': { zh: '需要先准备近源/同源参考物种数据,再由 Ortholog 生成目标到参考物种映射。', en: 'Prepare near-source/homologous ref data first, then generate target-to-ref mapping via Ortholog.' }, + 'needtorunidentifystagefirst': { zh: '需要先运行 Identify 阶段', en: 'Need to run Identify stage first' }, + 'refdataandorthologmappingneeded': { zh: '需要准备参考数据和 Ortholog 映射', en: 'Ref data and Ortholog mapping needed' }, + 'autoannotationneededtogenerateenrichment': { zh: '需要自动注释生成富集背景', en: 'Auto-annotation needed to generate enrichment background' }, + 'needtosupplement': { zh: '需要补充', en: 'Need to Supplement' }, + 'needs': { zh: '需要:', en: 'Needs:' }, + 'leaveemptyfornontimeexperiments': { zh: '非时间实验留空', en: 'Leave empty for non-time experiments' }, + 'project': { zh: '项目', en: 'Project' }, + 'projectrestartnotsubmitted': { zh: '项目重启未提交', en: 'Project Restart Not Submitted' }, + 'projectrestartscriptsubmitted': { zh: '项目重启脚本已提交', en: 'Project Restart Script Submitted' }, + 'preview-2': { zh: '预览', en: 'Preview' }, + 'previewtotable': { zh: '预览到表格', en: 'Preview to Table' }, + 'previewcommands': { zh: '预览命令', en: 'Preview Commands' }, + 'verifying': { zh: '验证中...', en: 'Verifying...' }, + 'verificationfailed': { zh: '验证失败', en: 'Verification Failed' }, + 'verificationfailedpleasecheckbackendlog': { zh: '验证失败,请查看后端日志', en: 'Verification failed; please check backend log' }, + 'verificationsuccessful': { zh: '验证成功', en: 'Verification Successful' }, + 'verificationsuccessfulreadytopublish': { zh: '验证成功,可发布', en: 'Verification Successful; Ready to Publish' }, + 'verificationsuccessfulstartingpublish': { zh: '验证成功,开始发布...', en: 'Verification successful; starting publish...' }, + 'verificationnotpassed': { zh: '验证未通过', en: 'Verification Not Passed' }, + 'highpriority': { zh: '高优先级', en: 'High Priority' }, + 'black': { zh: '黑色', en: 'Black' }, + 'defaultvalue': { zh: '默认值', en: 'Default Value' }, + 'bydefaultgeneratesallspeciesoverviewandp': { zh: '默认生成所有物种总图和两两共线性图。', en: 'By default generates all-species overview and pairwise synteny plots.' }, + 'tabdelimited': { zh: '(制表符分隔)', en: '(Tab-delimited)' }, + 'nofileoutput': { zh: '(无文件输出)', en: '(No File Output)' }, + 'noproteinfastadetected': { zh: '(未检测到蛋白FASTA)', en: '(No Protein FASTA Detected)' }, + 'prerequisitegate': { zh: ';前置/门控', en: '; Pre-requisite / Gate' }, + 'parallelizable': { zh: ';可并行', en: '; Parallelizable' }, + 'hasresultsorhandledbydependency': { zh: ';已有结果或由依赖链处理', en: '; Has Results or Handled by Dependency' }, + 'searchresults': { zh: '📄 检索结果', en: 'Search Results' }, + 'filterallresults': { zh: '📄 过滤全库页', en: 'Filter All Results' }, + 'cumulativeprogress': { zh: '📈 当前累计进展', en: 'Cumulative Progress' }, + 'expressiondatasourceselectone': { zh: '📊 表达量数据来源(Select一种):', en: 'Expression Data Source (Select One):' }, + 'nextstepsuggestions': { zh: '📋 下一步建议', en: 'Next Step Suggestions' }, + 'requiredfiles': { zh: '📋 所需文件:', en: 'Required Files:' }, + 'uploadfilestoinputdirectory': { zh: '📤 Upload Files到input目录', en: 'Upload Files to Input Directory' }, + 'needtouploadfiles': { zh: '📤 需要Upload Files:', en: 'Need to Upload Files:' }, + 'inputfilerequirementsgzcompressionsuppor': { zh: '📦 Input文件要求(支持.gz压缩):', en: 'Input File Requirements (.gz Compression Supported):' }, + 'subfamilylabelsconfirmedpullingpostconfi': { zh: '🔁 亚家族标签已确认,正在拉取确认后常规分析规划...', en: 'Subfamily labels confirmed; pulling post-confirmation analysis plan...' }, + 'subfamilylabelsconfirmedreplanningpostco': { zh: '🔁 亚家族标签已确认,正在重新规划确认后常规基因家族分析...', en: 'Subfamily labels confirmed; re-planning post-confirmation gene family analysis...' }, + 'reload': { zh: '🔄 重新加载', en: 'Reload' }, + 'scientificdiscoverycandidatehypothesis': { zh: '🔎 科学发现 / 候选假设', en: 'Scientific Discovery / Candidate Hypothesis' }, + 'useexistingdatapathautocreatesymlink': { zh: '🔗 使用已有数据路径(自动创建软链接', en: 'Use Existing Data Path (Auto-create Symlink)' }, + 'rnaseqdatasource': { zh: '🔬 RNA-seq 数据来源:', en: 'RNA-seq Data Source:' }, + 'biologicalstory': { zh: '🧬 生物学故事', en: 'Biological Story' }, + 'evidencecard': { zh: '🧷 证据卡', en: 'Evidence Card' }, + 'err-data-format': { zh: '数据格式错误: nodes 未定义或不是数组', en: 'Data format error: nodes is undefined or not an array' }, + 'warn-edges-undefined': { zh: '数据警告: edges 未定义,使用空数组', en: 'Data warning: edges undefined, using empty array' }, + + // ========== agent_status_component additions ========== + 'msg-load-task-failed': { zh: '加载任务失败:', en: 'Failed to load tasks:' }, + 'msg-check-task-status-failed': { zh: '检查任务状态失败:', en: 'Failed to check task status:' }, + + // ========== Auto-generated from scan ========== + 'label-gobioconductorinterproscankegg': { zh: '按需补背景 + 富集(GO 优先Bioconductor,回退InterProScan;KEGG 用KofamScan)', en: 'Add background + enrichment (GO via Bioconductor, fallback InterProScan; KEGG via KofamScan)' }, + 'label-gobioconductorkeggkofamscan': { zh: '按需补背景 + 继续富集(GO优先Bioconductor,KEGG用KofamScan)', en: 'Add background + continue enrichment (GO via Bioconductor, KEGG via KofamScan)' }, + 'ph-athhsaosa': { zh: '如 ath / hsa / osa', en: 'e.g. ath / hsa / osa' }, + 'ph-atos': { zh: '如 At / Os', en: 'e.g. At / Os' }, + 'ph-ftosprefix': { zh: '如 Ft / Os,留空取 prefix 第一段', en: 'e.g. Ft / Os, leave empty for first segment of prefix' }, + 'ph-geneid': { zh: '检索 gene ID', en: 'Search gene ID' }, + 'ph-oryzasativa': { zh: '如 Oryza sativa', en: 'e.g. Oryza sativa' }, + 'ph-osaathzma': { zh: '如 osa / ath / zma', en: 'e.g. osa / ath / zma' }, + 'txt-': { zh: '📚 参考文献', en: '📚 References' }, + 'txt--': { zh: '- 同源基因对', en: '- Orthologous gene pairs' }, + 'txt-0claim': { zh: '0">🧬 Claim级结果解读', en: '🧬 Claim-Level Result Interpretation' }, + 'txt-agent': { zh: '结果 agent', en: 'Result agent' }, + 'txt-id': { zh: '按ID提取序列', en: 'Extract sequence by ID' }, + 'txt-rag': { zh: '🧪 证据质量 / RAG解读审计', en: '🧪 Evidence Quality / RAG Audit' }, + 'txt-self-rag': { zh: '📝 Self-RAG反思', en: '📝 Self-RAG Reflection' }, + 'txt-tip': { zh: '💡 使用小Tip', en: '💡 Quick Tips' }, + + + + // === Fix empty t() === + 'extract-methodology': { zh: '提取方法学', en: 'Extract' }, + 'run-published': { zh: '运行已发布', en: 'Run Published' }, + 'more-conversations': { zh: '更多会话', en: 'More Conversations' }, + 'new-session': { zh: '新建会话', en: 'New Session' }, + 'new-session-title': { zh: '新会话', en: 'New Session' }, + 'generate-and-publish': { zh: '生成并发布', en: 'Generate & Publish' }, + 'stop-waiting': { zh: '停止等待', en: 'Stop Waiting' }, + + 'current-display-mode-analysis-or-rag': { zh: '当前显示模式: analysis or rag', en: 'Current display mode: analysis or rag' }, + + // === Bulk additions === + 'refresh-records': { zh: '刷新记录', en: 'Refresh Records' }, + 'more-sessions': { zh: '更多会话', en: 'More Sessions' }, + 'new': { zh: '新建', en: 'New' }, + 'restart-celery': { zh: '重启 Celery', en: 'Restart Celery' }, + 'restart-project': { zh: '重启项目', en: 'Restart Project' }, + 'current-session': { zh: '当前会话', en: 'Current Session' }, + 'no-parameters-identified': { zh: '未识别到参数', en: 'No parameters identified' }, + 'please-trial-run-to-record-output': { zh: '请先试运行以记录真实输出', en: 'Please run a trial first to record actual output' }, + 'no-configurable-parameters': { zh: '此拓展阶段没有可配置参数', en: 'No configurable parameters for this extension stage' }, + 'current-job': { zh: '当前 job', en: 'Current job' }, + 'latin-name-alias': { zh: '拉丁名/别名(文章复现必填)', en: 'Latin name/alias (required for reproduction)' }, + 'revision': { zh: '修订', en: 'Revision' }, + 'search-failed': { zh: '检索失败', en: 'Search failed' }, + 'matched': { zh: '匹配', en: 'Matched' }, + 'above': { zh: '上方', en: 'Above' }, + 'primary-reference-species': { zh: '主参考物种', en: 'Primary reference species' }, + 'reference-config-main-entry': { zh: '参考配置主入口', en: 'Reference config main entry' }, + 'will-be-written-to': { zh: '会分别写入', en: 'Will be written to' }, + 'row-level-override-only': { zh: '仅作为行级高级覆盖或旧配置兼容', en: 'Row-level override / legacy config only' }, + 'usually-leave-empty': { zh: '通常留空', en: 'Usually leave empty' }, + 'species-code-short': { zh: '物种简称', en: 'Species code (short)' }, + 'required-for-article-reproduction': { zh: '文章复现时为必填项', en: 'Required for article reproduction' }, + 'required-only-for-known-family-ref': { zh: '只在已知家族参考序列行必填', en: 'Required only for known family reference rows' }, + 'sequencing-run-srr': { zh: '测序运行号/SRR号', en: 'Sequencing run / SRR ID' }, + 'used-to-match': { zh: '用于匹配', en: 'Used to match' }, + 'sample-name-in-info-table': { zh: '样本信息表中的样本名', en: 'Sample name in info table' }, + 'sample-key': { zh: '样本键', en: 'Sample key' }, + 'paired-end-sequencing-file': { zh: '双端测序文件', en: 'Paired-end sequencing file' }, + 'indicates-treatment-tissue-group': { zh: '表示处理/组织/分组', en: 'Indicates treatment / tissue / group' }, + 'biological-replicate-number': { zh: '生物学重复编号', en: 'Biological replicate number' }, + 'only-for-time-series': { zh: '只在时间序列或明确发育时期实验中填写', en: 'Only for time series or developmental stage experiments' }, + 'leave-empty-for-tissue-comparison': { zh: '普通组织比较请留空', en: 'Leave empty for tissue comparison' }, + 'must-fill-per-library-instructions': { zh: '必须按建库说明填写', en: 'Must fill per library instructions' }, + 'group-role-for-marking': { zh: '分组角色用于标记', en: 'Group role for marking' }, + 'de-generates-comparisons': { zh: 'DE 会按设计生成对照比较或多组两两比较', en: 'DE generates control comparisons or pairwise group comparisons' }, + 'view-knowledge-retrieval-process': { zh: '查看本次知识检索过程', en: 'View knowledge retrieval process' }, + 'searching-knowledge-base': { zh: '正在知识库中查找相关内容', en: 'Searching knowledge base for related content' }, + 'default-thread-description': { zh: 'Default 是新提交任务使用的线程数', en: 'Default — thread count for new tasks' }, + 'max-stage-limit': { zh: 'Max 是该阶段上限', en: 'Max — upper limit for this stage' }, + 'increase-default-syncs-max': { zh: '直接提高 Default 时会同步提高 Max', en: 'Increasing Default will also raise Max' }, + 'sample-parallelism-affects-qc-align': { zh: '样本并行数只影响 QC/Align 的样本级并发', en: 'Sample parallelism only affects QC/Align sample concurrency' }, + 'original-filename-must-match': { zh: '原文件名必须匹配上传文件名', en: 'Original filename must match uploaded filename' }, + + // === R2 === + + 'stopped-waiting-no-answer': { zh: '已停止等待,本次未继续获取回答', en: 'Stopped waiting, no response received.' }, + + + 'text': { zh: '文本', en: 'Text' }, + 'background-knowledge': { zh: '背景知识', en: 'Background knowledge' }, + 'none': { zh: '无', en: 'None' }, + 'current-cumulative-findings': { zh: '当前累计发现', en: 'Current cumulative findings' }, + 'num-connections': { zh: ' 个', en: ' ' }, + 'or': { zh: '或', en: 'or' }, + + // === index_ml.html 模板文本(第5批) === + 'building-extension-stage': { zh: '生成并发布', en: 'Build & Publish' }, + 'running-published': { zh: '运行已发布', en: 'Run Published' }, + 'no-results-yet': { zh: '暂无结果', en: 'No Results' }, + 'unidentified-params': { zh: '未识别到参数', en: 'No Parameters Identified' }, + 'trial-first-for-output': { zh: '请先试运行以记录真实输出', en: 'Please Run a Trial First to Record Actual Output' }, + 'no-config-params': { zh: '此拓展阶段没有可配置参数', en: 'No Configurable Parameters' }, + 'searching-knowledge-base': { zh: '正在知识库中查找相关内容……', en: 'Searching Knowledge Base...' }, + 'processing-request': { zh: '正在处理请求...', en: 'Processing Request...' }, + 'uploaded-files': { zh: '已上传文件', en: 'Uploaded Files' }, + 'not-selected': { zh: '未选择', en: 'Not Selected' }, + 'not-loaded-gene-id': { zh: '未加载到 gene ID;请确认 PPI 已生成输入基因列表', en: 'Gene ID not loaded; please confirm PPI has generated input gene list' }, + 'searching-knowledge-base-full': { zh: '正在知识库中查找相关内容……', en: 'Searching Knowledge Base for Related Content...' }, + 'selectfile': { zh: '选择文件', en: 'Select File' }, + 'upload-files': { zh: '上传文件', en: 'Upload Files' }, + + // === tip popup steps === + 'tip-analysis-step1': { zh: '1.选择输入框上方"Analysis"', en: '1. Select "Analysis" above the input box' }, + 'tip-analysis-step2': { zh: '2.粘贴数据位置', en: '2. Paste data location' }, + 'tip-analysis-step3': { zh: '3.核对配置文件', en: '3. Verify configuration file' }, + 'tip-analysis-step4': { zh: '4.执行数据准备', en: '4. Execute data preparation' }, + 'tip-analysis-step5': { zh: '5.执行XX物种的亚家族分类', en: '5. Execute subfamily classification for XX species' }, + 'tip-analysis-step6': { zh: '6.执行XX物种的基因家族分析', en: '6. Execute gene family analysis for XX species' }, + 'tip-analysis-step7': { zh: '7.执行rnaseq分析', en: '7. Execute RNA-seq analysis' }, + 'tip-repro-step1': { zh: '1.点击"浏览"上传文献', en: '1. Click "Browse" to upload literature' }, + 'tip-repro-step2': { zh: '2.点击提取方法学', en: '2. Click "Extract Methodology"' }, + 'tip-repro-step3': { zh: '3.核对分析步骤', en: '3. Verify analysis steps' }, + 'tip-repro-step4': { zh: '4.点击保存方案', en: '4. Click "Save Plan"' }, + 'tip-repro-step5': { zh: '5.上传数据', en: '5. Upload data' }, + 'tip-repro-step6': { zh: '6.发送"按已保存的方法学方案继续执行"', en: '6. Send "Continue with saved methodology plan"' }, + 'tip-rag-step1': { zh: '1.选择输入框上方"RAG"', en: '1. Select "RAG" above the input box' }, + 'tip-rag-step2': { zh: '2.输入问题', en: '2. Enter your question' }, + 'tip-rag-step3': { zh: '3.点击"知识图谱"查看关系', en: '3. Click "Knowledge Graph" to view relationships' }, + + // === Additional ElMessage keys === + 'msg-create-failed': { zh: '创建失败', en: 'Creation failed' }, + 'msg-create-wd-success': { zh: '已创建新Working Directory', en: 'New Working Directory created' }, + 'msg-found-node': { zh: '找到节点', en: 'Found node' }, + 'msg-members-rebuilt': { zh: '已按 {count} 个成员重建 final_members。{rerun}', en: 'Rebuilt final_members with {count} members. {rerun}' }, + 'msg-ppi-reset-done': { zh: 'PPI Reset 已完成', en: 'PPI Reset completed' }, + 'msg-selected-code-files': { zh: '已选择 {count} 个原始代码文件', en: 'Selected {count} original code files' }, + 'msg-selected-data-files': { zh: '已选择 {count} 个测试数据文件', en: 'Selected {count} test data files' }, + 'msg-subfamily-confirmed': { zh: '已确认 {count} 个亚家族标签。{msg}', en: 'Confirmed {count} subfamily labels. {msg}' }, + 'msg-switch-failed': { zh: '切换失败', en: 'Switch failed' }, + 'msg-switch-wd-success': { zh: '已切换到Working Directory', en: 'Switched to Working Directory' }, + 'msg-switched-to': { zh: '切换到', en: 'Switched to' }, + 'msg-task-info': { zh: '任务', en: 'Task' }, + 'msgbox-ppi-tech-ref': { zh: 'PPI 的技术参考必须来自已确认的 STRING 候选;项目参考物种不会写入 PPI owner。', en: 'PPI technical reference must come from confirmed STRING candidates; project reference species will not be written to PPI owner.' }, + 'msgbox-restart-celery': { zh: '这会停止当前 profile 的 Celery worker 并重新启动。若有任务正在运行,后端会默认拒绝执行。', en: 'This will stop the Celery worker for the current profile and restart it. If tasks are running, the backend will refuse by default.' }, + 'msgbox-restart-project': { zh: '这会提交项目重启脚本,当前页面可能短暂断开。', en: 'This will submit the project restart script. The current page may briefly disconnect.' }, + + // === ElMessage toast messages === + 'msg-batch-already-running': { zh: '当前已有批量任务在执行,请等待完成后再提交方法学流程', en: 'A batch task is already running; wait for it to finish before submitting methodology pipeline' }, + 'msg-build-extension-first': { zh: '请先生成拓展分析阶段。', en: 'Please build the extension stage first.' }, + 'msg-build-temp-library-first': { zh: '请先构建临时库', en: 'Please build temporary library first' }, + 'msg-celery-restarted': { zh: 'Celery 已重启并完成队列检查', en: 'Celery restarted and queue check completed' }, + 'msg-clear-current-session': { zh: '已Clear当前会话', en: 'Current session cleared' }, + 'msg-combo-min-two-types': { zh: 'Combo 修订至少选择两个类型。', en: 'Combo revision requires at least two types selected.' }, + 'msg-combo-output-label-error': { zh: '无法确定 combo 输出标签,请先选择 combo 图。', en: 'Cannot determine combo output label; select a combo plot first.' }, + 'msg-combo-replot-done': { zh: 'Combo 图修订完成,已切换到新图', en: 'Combo revision completed, switched to new image' }, + 'msg-combo-replot-error': { zh: 'Combo 图修订失败', en: 'Combo revision failed' }, + 'msg-confirm-saved': { zh: '确认已保存', en: 'Confirmation saved' }, + 'msg-content-copied': { zh: '解读内容已复制', en: 'Content copied' }, + 'msg-copy-failed': { zh: '复制失败', en: 'Copy failed' }, + 'msg-create-session-failed': { zh: '创建会话失败', en: 'Failed to create session' }, + 'msg-create-wd-error': { zh: '获取Working Directory列表失败', en: 'Failed to get Working Directory list' }, + 'msg-delete-session-failed': { zh: '删除会话失败', en: 'Failed to delete session' }, + 'msg-design-hint': { zh: '设计提示', en: 'Design hint' }, + 'msg-diagnosis-closed': { zh: '已关闭诊断提示。', en: 'Diagnosis hint closed.' }, + 'msg-domain-confirm-error': { zh: 'Domain确认失败', en: 'Domain confirmation failed' }, + 'msg-download-demo-unavailable': { zh: '示例下载功能当前不可用', en: 'Download demo feature is currently unavailable' }, + 'msg-enter-file-path': { zh: '请输入文件路径', en: 'Please enter a file path' }, + 'msg-enter-methodology-question': { zh: '请输入方法学问题', en: 'Please enter methodology question' }, + 'msg-error-log-cleared': { zh: '错误日志已清空', en: 'Error log cleared' }, + 'msg-evidence-supplement-error': { zh: '补充证据失败', en: 'Evidence supplement failed' }, + 'msg-extension-built': { zh: '拓展分析阶段已生成并发布', en: 'Extension analysis stage built and published' }, + 'msg-extension-executed': { zh: '拓展分析阶段执行完成', en: 'Extension analysis stage execution completed' }, + 'msg-extension-execution-warn': { zh: '拓展分析阶段执行未成功,请查看结果日志', en: 'Extension analysis execution unsuccessful; check result logs' }, + 'msg-extension-published': { zh: '拓展分析阶段已发布', en: 'Extension stage published' }, + 'msg-fill-at-least-one-field': { zh: '请至少填写一个要预览修改的字段', en: 'Please fill at least one field to preview the change' }, + 'msg-fill-each-input-file': { zh: '请填写每个输入文件。', en: 'Please fill in each input file.' }, + 'msg-fill-gwas-species': { zh: '请填写 GWAS 物种简称(用于结果前缀)', en: 'Please fill in GWAS species code (used for result prefix)' }, + 'msg-fill-name-and-code': { zh: '请填写名称,并上传原始代码。', en: 'Please fill name and upload original code.' }, + 'msg-fill-name-and-code-or-data': { zh: '请填写名称并提供测试数据文件或服务器路径;原始命令和原始代码至少提供一项。', en: 'Please fill name and provide test data file or server path; original command and code — at least one required.' }, + 'msg-fill-revision-comments': { zh: '请写明修订意见。', en: 'Please specify revision comments.' }, + 'msg-fix-applied': { zh: '✅ 已应用修复方案,继续执行...', en: '✅ Fix applied, continuing...' }, + 'msg-fix-apply-failed': { zh: '❌ 应用修复失败', en: '❌ Failed to apply fix' }, + 'msg-fix-skipped': { zh: '⏸ 已跳过修复;该问题会阻断下游,后续阶段已暂停。', en: '⏸ Fix skipped; downstream blocked, later stages paused.' }, + 'msg-generate-or-load-draft-first': { zh: '请先生成或加载草稿', en: 'Please generate or load a draft first' }, + 'msg-history-cleared': { zh: '对话历史已清空', en: 'Chat history cleared' }, + 'msg-id-already-exists': { zh: '该 ID 已存在', en: 'This ID already exists' }, + 'msg-json-loaded-to-console': { zh: 'JSON数据已加载到控制台', en: 'JSON data loaded to console' }, + 'msg-keep-at-least-one-gene': { zh: '至少保留一个 gene ID', en: 'Keep at least one gene ID' }, + 'msg-kg-load-error': { zh: '加载图谱失败', en: 'Failed to load graph' }, + 'msg-kg-loaded': { zh: '知识图谱加载完成', en: 'Knowledge graph loaded' }, + 'msg-load-subgraph-first': { zh: '请先通过问答加载子图,或点击「重新加载」', en: 'Please load a subgraph via Q&A first, or click "Reload"' }, + 'msg-member-table-load-error': { zh: '成员确认表加载失败', en: 'Member confirmation table load failed' }, + 'msg-methodology-plan-saved': { zh: '方法学方案已保存,可先准备数据,稍后再按该方案执行', en: 'Methodology plan saved; prepare data and execute per the plan later' }, + 'msg-msa-fill-alignment': { zh: '请填写MSA alignment 起始列和终止列,或选择其他模式。', en: 'Please fill MSA alignment start/end columns, or select another mode.' }, + 'msg-msa-fill-ref-gene': { zh: '请填写参考基因及其起止残基编号。', en: 'Please fill reference gene and residue start/end numbers.' }, + 'msg-msa-output-label-error': { zh: '无法确定MSA输出标签,请先选择对应图片或填写输出标签。', en: 'Cannot determine MSA output label; select a corresponding image or fill output label first.' }, + 'msg-msa-replot-done': { zh: 'MSA区间重绘完成', en: 'MSA region replot completed' }, + 'msg-msa-replot-error': { zh: 'MSA区间重绘失败', en: 'MSA region replot failed' }, + 'msg-msg-not-saved': { zh: '当前消息未成功写入会话历史,刷新后可能丢失', en: 'Message not saved to session history, may be lost on refresh' }, + 'msg-network-error-retry': { zh: '❌ 网络错误,请重试', en: '❌ Network error, please retry' }, + 'msg-new-session-created': { zh: '新会话已创建,请选择或创建 Working Directory', en: 'New session created, please select or create a Working Directory' }, + 'msg-no-job-id': { zh: '当前没有可用 job_id', en: 'No available job_id' }, + 'msg-no-matching-draft-rows': { zh: '没有匹配的草稿行', en: 'No matching draft rows' }, + 'msg-no-members-filtered': { zh: '当前筛选没有成员,未重建 final_members。', en: 'No members match current filter; final_members not rebuilt.' }, + 'msg-no-subfamily-labels': { zh: '没有可确认的亚家族标签。', en: 'No subfamily labels to confirm.' }, + 'msg-node-not-found': { zh: '未找到匹配的节点', en: 'No matching node found' }, + 'msg-not-advanced-design': { zh: '未生成高级设计', en: 'No advanced design generated' }, + 'msg-path-copied': { zh: '路径已复制', en: 'Path copied' }, + 'msg-plan-request-error': { zh: '规划请求失败', en: 'Planning request failed' }, + 'msg-please-input-content': { zh: '请Input内容', en: 'Please input content' }, + 'msg-pre-publish-validation-passed': { zh: '发布前验收已通过', en: 'Pre-publish validation passed' }, + 'msg-preview-first': { zh: '请先预览要应用的修改', en: 'Please preview the changes first' }, + 'msg-project-restart-not-configured': { zh: '未配置项目重启脚本', en: 'Project restart script not configured' }, + 'msg-project-restart-submitted': { zh: '项目重启脚本已提交', en: 'Project restart script submitted' }, + 'msg-promoter-plot-done': { zh: '启动子自定义绘图完成', en: 'Custom promoter plot completed' }, + 'msg-promoter-plot-error': { zh: '启动子自定义绘图失败', en: 'Custom promoter plot failed' }, + 'msg-promoter-prefix-error': { zh: '无法确定启动子输出前缀。', en: 'Cannot determine promoter output prefix.' }, + 'msg-promoter-read-error': { zh: '读取启动子元件失败', en: 'Failed to read promoter elements' }, + 'msg-promoter-select-element': { zh: '请至少选择一个启动子元件或类别。', en: 'Please select at least one promoter element or category.' }, + 'msg-rag-interpretation-refreshed': { zh: 'RAG解读已刷新', en: 'RAG interpretation refreshed' }, + 'msg-rag-request-error': { zh: 'RAG请求失败', en: 'RAG request failed' }, + 'msg-read-file-error': { zh: '读取文件失败', en: 'Failed to read file' }, + 'msg-replot-in-development': { zh: '重新绘图功能开发中...', en: 'Replot feature in development...' }, + 'msg-request-failed': { zh: '请求失败', en: 'Request failed' }, + 'msg-sandbox-built': { zh: '方法学 sandbox 已构建', en: 'Methodology sandbox built' }, + 'msg-select-actual-job': { zh: '请先选择实际运行 Job。', en: 'Please select an actual running job first.' }, + 'msg-select-executable-stage': { zh: '请至少选择一个可执行的 bbav5 阶段', en: 'Please select at least one executable bbav5 stage' }, + 'msg-select-image-first': { zh: '请先选择一张图片', en: 'Please select an image first' }, + 'msg-select-methodology-dir': { zh: '请先选择或输入 methodology_dir', en: 'Please select or enter methodology_dir' }, + 'msg-select-or-create-wd': { zh: '请先选择或创建 Working Directory', en: 'Please select or create a Working Directory' }, + 'msg-select-pdf-or-mineru': { zh: '请先选择 PDF 或 MinerU content_list JSON 文献文件', en: 'Please select a PDF or MinerU content_list JSON file first' }, + 'msg-select-plot-type': { zh: '请至少选择一种图类型。', en: 'Please select at least one plot type.' }, + 'msg-select-stage-first': { zh: '请先SelectAnalysis Stage', en: 'Please select an analysis stage first' }, + 'msg-select-valid-vcf': { zh: '请先选择有效 VCF', en: 'Please select a valid VCF first' }, + 'msg-session-deleted': { zh: '会话已删除', en: 'Session deleted' }, + 'msg-split-species-no-results': { zh: '当前选择为按物种分别分析,但后端没有返回完整的分物种亚家族结果;将尝试打开可用的联合结果。', en: 'Split-species mode selected but backend returned no per-species subfamily results; trying available combined results.' }, + 'msg-stage-added-to-list': { zh: '已加入正式阶段列表,并默认纳入本次执行', en: 'Added to formal stage list and included in this execution by default' }, + 'msg-stage-already-in-list': { zh: '该阶段已在正式列表中', en: 'This stage is already in the formal list' }, + 'msg-stop-extension-error': { zh: '停止拓展分析失败', en: 'Failed to stop extension analysis' }, + 'msg-stop-extension-requested': { zh: '已请求停止当前拓展分析;状态仍会继续轮询直到进程退出。', en: 'Stop requested for current extension; status will keep polling until process exits.' }, + 'msg-string-link-content': { zh: 'STRING 链接文件内容', en: 'STRING link file content' }, + 'msg-subfamily-confirm-data-error': { zh: '亚家族确认数据加载失败', en: 'Subfamily confirmation data load failed' }, + 'msg-subfamily-confirm-error': { zh: '亚家族确认失败', en: 'Subfamily confirmation failed' }, + 'msg-subfamily-not-auto-open': { zh: '亚家族预测已完成,但未能自动打开确认表;请发送"调出亚家族确认表"手动打开。', en: 'Subfamily prediction completed but confirmation table could not auto-open; send "调出亚家族确认表" to open manually.' }, + 'msg-subfamily-ref-not-written': { zh: '亚家族参考物种未写入当前 job 配置,已生成方法学本地计划,请在批量确认页核对参考物种', en: 'Subfamily reference species not written to current job config; methodology local plan generated; verify reference species on batch confirmation page' }, + 'msg-synteny-output-label-error': { zh: '无法确定种内共线性输出标签,请先选择 Circos 图。', en: 'Cannot determine synteny output label; select a Circos plot first.' }, + 'msg-synteny-replot-done': { zh: '种内共线性 Circos 重绘完成,已切换到新图', en: 'Intraspecific synteny Circos replot completed, switched to new image' }, + 'msg-synteny-replot-error': { zh: '种内共线性 Circos 重绘失败', en: 'Intraspecific synteny Circos replot failed' }, + 'msg-table-read-error': { zh: '读取表格失败', en: 'Failed to read table' }, + 'msg-tree-output-label-error': { zh: '无法确定进化树输出标签,请先选择树图或填写输出标签。', en: 'Cannot determine tree output label; select a tree plot or fill output label first.' }, + 'msg-tree-replot-done': { zh: '进化树图重绘完成,已切换到新图', en: 'Tree replot completed, switched to new image' }, + 'msg-tree-replot-error': { zh: '进化树图重绘失败', en: 'Tree replot failed' }, + 'msg-trial-failed': { zh: '试运行未通过', en: 'Trial run failed' }, + 'msg-trial-succeeded': { zh: '试运行成功', en: 'Trial run succeeded' }, + 'msg-upload-error': { zh: '上传失败', en: 'Upload failed' }, + 'msg-uploaded-build-and-validate': { zh: '文件上传成功,已开始生成并验收', en: 'File uploaded, starting build and validation' }, + 'msg-validation-failed': { zh: '验证未通过', en: 'Validation failed' }, + 'msg-validation-succeeded': { zh: '验证成功', en: 'Validation succeeded' }, + 'msg-view-reset': { zh: '视图已重置', en: 'View reset' }, + 'msg-will-keep-compatible': { zh: '将保留原流程的兼容处理', en: 'Will keep original pipeline compatibility handling' }, + + // === Template string fragments === + 'msg-labels-count': { zh: '个亚家族标签', en: 'subfamily labels' }, + 'msg-loaded-files': { zh: '已加载', en: 'Loaded' }, + 'msg-members-count': { zh: '个成员', en: 'members' }, + 'msg-progress': { zh: '进度', en: 'Progress' }, + 'msg-samples-not-filled': { zh: '个样本未填写', en: 'samples not filled' }, + 'msg-status': { zh: '状态', en: 'Status' }, + 'msg-strand-specificity-required': { zh: '链特异性为必填', en: 'Strand specificity is required' }, + + // === Dialog button text === + 'btn-auto-annotate-continue': { zh: '自动注释并继续', en: 'Auto-annotate & Continue' }, + 'btn-back-to-edit': { zh: '返回修改', en: 'Back to Edit' }, + 'btn-cancel': { zh: '取消', en: 'Cancel' }, + 'btn-cancel-ppi': { zh: '取消 PPI', en: 'Cancel PPI' }, + 'btn-confirm-design-write': { zh: '确认设计并写入', en: 'Confirm Design & Write' }, + 'btn-confirm-first-candidate': { zh: '确认第一候选', en: 'Confirm First Candidate' }, + 'btn-confirm-restart': { zh: '确认重启', en: 'Confirm Restart' }, + 'btn-delete': { zh: '删除', en: 'Delete' }, + 'btn-got-it': { zh: '知道了', en: 'Got it' }, + 'btn-retry-bcftools': { zh: '使用 bcftools 重试', en: 'Retry with bcftools' }, + 'btn-upload-bg-first': { zh: '我先去上传背景文件', en: 'Upload Background Files First' }, + + // === Template labels === + 'label-added': { zh: '新增', en: 'Added' }, + 'label-conserved-msa': { zh: '自动保守区 MSA 图', en: 'Auto Conserved MSA Plot' }, + + // === Category & node labels === + 'category-gene-family': { zh: '基因家族', en: 'Gene Family' }, + 'category-wgs-population-gwas': { zh: 'WGS2GWAS(遗传变异与群体分析)', en: 'WGS2GWAS (Genetic Variation & Population)' }, + 'label-current-identification': { zh: '当前鉴定', en: 'Current Identification' }, + 'label-current-image': { zh: '当前图片', en: 'Current Image' }, + 'label-degree': { zh: '连接数', en: 'Degree' }, + 'label-deleted': { zh: '删除', en: 'Deleted' }, + 'label-edges': { zh: '关系', en: 'Edges' }, + 'label-input': { zh: '输入', en: 'Input' }, + 'label-msa-region': { zh: 'MSA区间图', en: 'MSA Region Plot' }, + 'label-msa-source': { zh: 'MSA来源', en: 'MSA Source' }, + 'label-nodes': { zh: '节点', en: 'Nodes' }, + 'label-ppi-reset': { zh: 'PPI Reset 结果', en: 'PPI Reset Results' }, + 'label-recommended-conserved': { zh: '推荐保守区', en: 'Recommended Conserved Region' }, + 'label-types': { zh: '类型', en: 'Types' }, + + // === Stage labels === + 'stage-combo': { zh: '组合图', en: 'Combined Plot' }, + 'stage-domain': { zh: '结构域分析', en: 'Domain Analysis' }, + 'stage-expression': { zh: '表达矩阵分析', en: 'Expression Matrix Analysis' }, + 'stage-gwas-cov': { zh: 'GWAS协变量准备', en: 'GWAS Covariate Preparation' }, + 'stage-gwas-emmax': { zh: 'GWAS EMMAX', en: 'GWAS EMMAX' }, + 'stage-gwas-emplot': { zh: 'GWAS EMMAX绘图', en: 'GWAS EMMAX Plotting' }, + 'stage-gwas-fmt': { zh: 'GWAS VCF排序与HapMap转换', en: 'GWAS VCF Sort & HapMap Conversion' }, + 'stage-gwas-glm': { zh: 'GWAS TASSEL GLM', en: 'GWAS TASSEL GLM' }, + 'stage-gwas-kinship': { zh: 'GWAS TASSEL亲缘关系矩阵', en: 'GWAS TASSEL Kinship Matrix' }, + 'stage-gwas-mlm': { zh: 'GWAS TASSEL MLM/CMLM', en: 'GWAS TASSEL MLM/CMLM' }, + 'stage-gwas-plot': { zh: 'GWAS MLM/CMLM绘图', en: 'GWAS MLM/CMLM Plotting' }, + 'stage-gwas-trait': { zh: 'GWAS表型准备', en: 'GWAS Trait Preparation' }, + 'stage-identify': { zh: '基因家族鉴定', en: 'Gene Family Identification' }, + 'stage-interspecies-synteny': { zh: '种间共线性分析', en: 'Interspecies Synteny Analysis' }, + 'stage-kaks': { zh: 'Ka/Ks分析', en: 'Ka/Ks Analysis' }, + 'stage-karyotype': { zh: '核型/染色体分布分析', en: 'Karyotype Analysis' }, + 'stage-motif': { zh: 'Motif分析', en: 'Motif Analysis' }, + 'stage-pep': { zh: '蛋白理化性质与亚细胞定位', en: 'Protein Properties & Subcellular Localization' }, + 'stage-phylogeny': { zh: '系统发育分析', en: 'Phylogenetic Analysis' }, + 'stage-pop-ld-decay': { zh: '群体LD衰减分析', en: 'Population LD Decay Analysis' }, + 'stage-pop-pca': { zh: '群体PCA分析', en: 'Population PCA Analysis' }, + 'stage-pop-phylogeny': { zh: '群体系统树分析', en: 'Population Phylogenetic Tree' }, + 'stage-pop-snp-density': { zh: 'SNP密度分析', en: 'SNP Density Analysis' }, + 'stage-pop-structure': { zh: '群体结构分析', en: 'Population Structure Analysis' }, + 'stage-ppi': { zh: 'PPI分析', en: 'PPI Analysis' }, + 'stage-ppi-kaks-download-fallback': { zh: 'PPI/KaKs 兜底确认', en: 'PPI/KaKs Fallback Confirmation' }, + 'stage-ppi-kaks-reference-data': { zh: 'PPI/KaKs 参考数据', en: 'PPI/KaKs Reference Data' }, + 'stage-promoter': { zh: '启动子元件预测', en: 'Promoter Element Prediction' }, + 'stage-reference-classification': { zh: '参考亚家族分类', en: 'Reference Subfamily Classification' }, + 'stage-structure': { zh: '基因结构分析', en: 'Gene Structure Analysis' }, + 'stage-subfamily-confirm': { zh: '亚家族标签确认', en: 'Subfamily Label Confirmation' }, + 'stage-synteny': { zh: '种内共线性分析', en: 'Intraspecific Synteny Analysis' }, + 'stage-tfbs': { zh: 'TF结合位点预测', en: 'TFBS Prediction' }, + + // === Short stage labels === + 'stage-interspecies-synteny-short': { zh: '9️⃣ 种间Synteny', en: '9️⃣ Interspecies Synteny' }, + 'stage-msa-region-plot': { zh: '2.2 MSA Replot / 区间重绘', en: '2.2 MSA Replot / Region Plot' }, + 'stage-promoter-plot-revise': { zh: '6.1 启动子自定义绘图', en: '6.1 Custom Promoter Plot' }, + 'stage-reference-classification-short': { zh: '2.5 亚家族确认', en: '2.5 Subfamily Confirmation' }, + 'stage-synteny-plot-revise': { zh: '7.1 种内共线性 Circos 重绘', en: '7.1 Intraspecies Synteny Circos Replot' }, + 'stage-synteny-short': { zh: '7️⃣ 种内Synteny', en: '7️⃣ Intraspecies Synteny' }, + 'stage-tree-plot-revise': { zh: '2.3 Tree Replot / 进化树重绘', en: '2.3 Tree Replot' }, + + // === Synteny choice labels === + 'choice-synteny': { zh: '种内共线性分析', en: 'Intraspecific Synteny Analysis' }, + 'choice-synteny-desc': { zh: '只分析目标物种内部复制关系', en: 'Analyze duplication within target species only' }, + 'choice-interspecies-synteny': { zh: '种间共线性分析', en: 'Interspecies Synteny Analysis' }, + 'choice-interspecies-synteny-desc': { zh: '分析目标物种与参考/比较物种之间的共线性', en: 'Analyze synteny between target and reference/comparative species' }, + + // === Sidebar status labels === + 'status-pending-confirm': { zh: '待确认', en: 'Pending Confirm' }, + 'status-running': { zh: '运行中', en: 'Running' }, + 'status-pending-exec': { zh: '待执行', en: 'Pending' }, + 'status-pending-review': { zh: '待核对', en: 'Pending Review' }, + 'status-pending-auth': { zh: '待授权', en: 'Pending Auth' }, + 'status-completed': { zh: '已完成', en: 'Completed' }, + + // === Lightweight card titles === + 'card-title-extension-candidate': { zh: '拓展分析候选核对', en: 'Extension Candidate Review' }, + 'card-title-generating-extension': { zh: '正在生成拓展分析', en: 'Generating Extension' }, + 'card-title-start-validation': { zh: '开始验证', en: 'Start Validation' }, + 'card-title-result-review': { zh: '运行结果核对', en: 'Result Review' }, + 'card-title-auth-publish': { zh: '授权发布', en: 'Authorize Publish' }, + 'card-title-input-files': { zh: '输入文件和参数', en: 'Input Files & Parameters' }, + 'card-title-run-result': { zh: '运行结果', en: 'Run Result' }, + 'card-title-restoring-extension': { zh: '正在恢复拓展分析状态', en: 'Restoring Extension State' }, + + // === More status labels === + 'card-title-extension-gen-failed': { zh: '拓展分析说明生成失败', en: 'Extension Description Generation Failed' }, + 'status-cancelled': { zh: '已取消', en: 'Cancelled' }, + 'status-failed': { zh: '失败', en: 'Failed' }, + + // === Species label === + 'label-multi-species-analysis': { zh: '多物种联合分析与确认(默认)', en: 'Multi-Species Joint Analysis (default)' }, + 'label-per-species-analysis': { zh: '按单个物种分别分析与确认', en: 'Per-Species Analysis & Confirmation' }, + 'label-target-only': { zh: '仅目标物种', en: 'Target Species Only' }, + + // === More visible labels === + 'desc-smart-extract': { zh: '如果没有蛋白文件,但有 genome.fa + GFF,系统会自动用 gffread 提取蛋白序列', en: 'If no protein file is available but genome.fa + GFF exist, the system will automatically extract protein sequences using gffread' }, + 'label-batch': { zh: '批次', en: 'Batch' }, + 'label-smart-extract': { zh: '智能提取', en: 'Smart Extract' }, + 'label-view-phase-report': { zh: '查看阶段性综合报告', en: 'View Phase Summary Report' }, + + + // === Target species === + 'label-target-species': { zh: '目标物种', en: 'Target Species' }, + 'msg-target-cannot-string': { zh: '目标物种当前不能直接用于 STRING PPI/KaKs。', en: 'Target species cannot be used directly for STRING PPI/KaKs.' }, + 'msg-target-not-in-string': { zh: '目标物种不在 STRING 固定快照中。请确认是否采用排序第一的近源物种:', en: 'Target species not in STRING fixed snapshot. Confirm using the closest related species:' }, + 'msg-methodology-plan-saved-detail': { zh: '已保存方法学方案到当前 job。下一步请上传/填写目标物种和参考物种数据,并确认 config.xlsx 后再执行方法学流程。', en: 'Methodology plan saved to current job. Next: upload/fill target and reference species data, verify config.xlsx, then execute the methodology pipeline.' }, + + // === json list tables === + 'json-list-tables': { zh: 'json list tables', en: 'JSON List Tables' }, + + // === Methodology labels === + 'label-auto-review': { zh: '自动审核', en: 'Auto Review' }, + 'label-missing': { zh: '缺少', en: 'Missing' }, + 'label-missing-fig': { zh: '缺失 Fig', en: 'Missing Fig' }, + 'label-missing-table': { zh: '缺失 Table', en: 'Missing Table' }, + 'label-stage-count': { zh: '阶段', en: 'Stages' }, + 'label-title-count': { zh: '标题', en: 'Titles' }, + 'unit-figure-titles': { zh: '条图表标题', en: 'figure titles' }, + 'unit-preview-stages': { zh: '个预览阶段', en: 'preview stages' }, + 'unit-species': { zh: '个物种', en: 'species' }, + 'unit-stages': { zh: '个方法阶段', en: 'methodology stages' }, + + // === getStageI18nKey mappings === + '0-genome-prepare': { zh: '0️⃣ Genome Prepare', en: '0️⃣ Genome Prepare' }, + '1-identify': { zh: '1️⃣ Identify', en: '1️⃣ Identify' }, + '1.1-identify-refine': { zh: '1.1 Identify Refine', en: '1.1 Identify Refine' }, + '10-ppi': { zh: '🔟 PPI', en: '🔟 PPI' }, + '11-kaks': { zh: '1️⃣1️⃣ KaKs', en: '1️⃣1️⃣ KaKs' }, + '12-pep': { zh: '1️⃣2️⃣ PEP', en: '1️⃣2️⃣ PEP' }, + '13-expression': { zh: '1️⃣3️⃣ Expression (FPKM)', en: '1️⃣3️⃣ Expression (FPKM)' }, + '2-phylogeny-tree': { zh: '2️⃣ Phylogeny Tree', en: '2️⃣ Phylogeny Tree' }, + '2.1-msa-alignment': { zh: '2.1 MSA Alignment Pages', en: '2.1 MSA Alignment Pages' }, + '20-Annotation': { zh: '2️⃣0️⃣ Annotation', en: '2️⃣0️⃣ Annotation' }, + '3-motif': { zh: '3️⃣ Motif', en: '3️⃣ Motif' }, + '4-domain': { zh: '4️⃣ Domain', en: '4️⃣ Domain' }, + '5-structure': { zh: '5️⃣ Structure', en: '5️⃣ Structure' }, + '6-combo': { zh: '6️⃣ Combo', en: '6️⃣ Combo' }, + '6-promoter': { zh: '6️⃣ Promoter', en: '6️⃣ Promoter' }, + '6.2-tfbs': { zh: '6.2 TFBS', en: '6.2 TFBS' }, + '8-karyotype': { zh: '8️⃣ Karyotype', en: '8️⃣ Karyotype' }, + 'GWAS-Cov': { zh: 'GWAS Covariate Preparation', en: 'GWAS Covariate Preparation' }, + 'GWAS-EMMAX': { zh: 'GWAS EMMAX', en: 'GWAS EMMAX' }, + 'GWAS-EMPlot': { zh: 'GWAS EMMAX Plot', en: 'GWAS EMMAX Plot' }, + 'GWAS-Format': { zh: 'GWAS Format (TASSEL)', en: 'GWAS Format (TASSEL)' }, + 'GWAS-GLM': { zh: 'GWAS TASSEL GLM', en: 'GWAS TASSEL GLM' }, + 'GWAS-Kinship': { zh: 'GWAS Kinship (TASSEL)', en: 'GWAS Kinship (TASSEL)' }, + 'GWAS-MLM': { zh: 'GWAS TASSEL MLM/CMLM', en: 'GWAS TASSEL MLM/CMLM' }, + 'GWAS-Plot': { zh: 'GWAS MLM/CMLM Plot', en: 'GWAS MLM/CMLM Plot' }, + 'GWAS-Trait': { zh: 'GWAS Trait Preparation', en: 'GWAS Trait Preparation' }, + 'PPI-KaKs-Fallback': { zh: 'PPI/KaKs 兜底确认', en: 'PPI/KaKs Fallback Confirmation' }, + 'PPI-KaKs-Ref': { zh: 'PPI/KaKs 参考数据', en: 'PPI/KaKs Reference Data' }, + 'RNA-seq-Align': { zh: '1️⃣6️⃣ RNA-seq Align', en: '1️⃣6️⃣ RNA-seq Align' }, + 'RNA-seq-Candidate': { zh: '2️⃣5️⃣ RNA-seq Candidate Family', en: '2️⃣5️⃣ RNA-seq Candidate Family' }, + 'RNA-seq-DE': { zh: '1️⃣9️⃣ RNA-seq DE + Expression', en: '1️⃣9️⃣ RNA-seq DE + Expression' }, + 'RNA-seq-GO': { zh: '2️⃣1️⃣ RNA-seq GO Enrich', en: '2️⃣1️⃣ RNA-seq GO Enrich' }, + 'RNA-seq-KEGG': { zh: '2️⃣2️⃣ RNA-seq KEGG Enrich', en: '2️⃣2️⃣ RNA-seq KEGG Enrich' }, + 'RNA-seq-QC': { zh: '1️⃣5️⃣ RNA-seq QC', en: '1️⃣5️⃣ RNA-seq QC' }, + 'RNA-seq-Quant': { zh: '1️⃣7️⃣ RNA-seq Quant', en: '1️⃣7️⃣ RNA-seq Quant' }, + 'RNA-seq-Sample-QC': { zh: '1️⃣8️⃣ RNA-seq Sample QC', en: '1️⃣8️⃣ RNA-seq Sample QC' }, + 'RNA-seq-WGCNA': { zh: '2️⃣4️⃣ RNA-seq WGCNA', en: '2️⃣4️⃣ RNA-seq WGCNA' }, + 'RNA-seq-fgsea': { zh: '2️⃣3️⃣ RNA-seq fgsea', en: '2️⃣3️⃣ RNA-seq fgsea' }, + 'WGS-Align': { zh: 'WGS Alignment', en: 'WGS Alignment' }, + 'WGS-BAM-QC': { zh: 'WGS BAM QC', en: 'WGS BAM QC' }, + 'WGS-GVCF': { zh: 'WGS GVCF Combine', en: 'WGS GVCF Combine' }, + 'WGS-Haplotype': { zh: 'WGS Haplotype Calling', en: 'WGS Haplotype Calling' }, + 'WGS-Impute': { zh: 'WGS Imputation', en: 'WGS Imputation' }, + 'WGS-Index': { zh: 'WGS Reference Index', en: 'WGS Reference Index' }, + 'WGS-Joint-Geno': { zh: 'WGS Joint Genotyping', en: 'WGS Joint Genotyping' }, + 'WGS-MAF': { zh: 'WGS MAF / Missing Filter', en: 'WGS MAF / Missing Filter' }, + 'WGS-MarkDup': { zh: 'WGS Mark Duplicates', en: 'WGS Mark Duplicates' }, + 'WGS-QC': { zh: 'WGS QC', en: 'WGS QC' }, + 'WGS-Var-Filter': { zh: 'WGS Variant Filter', en: 'WGS Variant Filter' }, + 'WGS-Var-Select': { zh: 'WGS Variant Select', en: 'WGS Variant Select' }, + 'pop-ld-decay': { zh: 'Population LD Decay', en: 'Population LD Decay' }, + 'pop-pca': { zh: 'Population PCA', en: 'Population PCA' }, + 'pop-phylogeny': { zh: 'Population Phylogeny', en: 'Population Phylogeny' }, + 'pop-snp-density': { zh: 'SNP Density', en: 'SNP Density' }, + 'pop-structure': { zh: 'Population Structure', en: 'Population Structure' }, + + // === Countdown labels === + 'countdown-paused': { zh: '倒计时已暂停', en: 'Countdown paused' }, + 'seconds-auto-annotate': { zh: '秒后自动注释', en: 's to auto-annotate' }, + 'seconds-auto-domain-review': { zh: '秒后自动使用默认结构域筛选', en: 's to auto-use default domain filter' }, + 'seconds-auto-execute': { zh: '秒后自动执行', en: 's to auto-execute' }, + 'seconds-auto-stage-confirm': { zh: '秒后自动执行', en: 's to auto-execute' }, + 'seconds-auto-subfamily-review': { zh: '秒后自动确认当前亚家族标签', en: 's to auto-confirm subfamily labels' }, +}; diff --git a/apps/web/vue3/static/i18n_en.js b/apps/web/vue3/static/i18n_en.js new file mode 100644 index 000000000..cc294239a --- /dev/null +++ b/apps/web/vue3/static/i18n_en.js @@ -0,0 +1,891 @@ +// ===== Agent 多语言:卡片 + Agent 消息英文翻译 ===== +// key 为中文原文,value 为英文翻译 +// 使用 t(key) 获取翻译,tp(key, params) 处理动态占位符 + +window.__I18N_EN__ = { + "Agent状态": "AgentStatus", + "Self-RAG反思": "Self-RAG Reflection", + "三角形": "Triangle", + "下一步": "Next Step", + "下载": "Download", + "不受支持标记处理策略": "Unsupported Marker Handling Policy", + "中性替换率": "Neutral Substitution Rate", + "中性速率": "Neutral Rate", + "中文": "Chinese", + "丰度引擎": "Abundance Engine", + "主参考": "Primary Reference", + "主参考物种": "Primary reference species", + "主要结果": "Main Results", + "亚家族分类确认": "Subfamily Classification Confirmation", + "亚家族名称距离": "Subfamily Name Distance", + "亚家族外圈弧线": "Subfamily Outer Arc", + "亚家族确认参数": "Subfamily Confirmation Parameters", + "交集": "Intersection", + "仅圈图": "Circle Plot Only", + "仅比较物种": "Comparative Species Only", + "仅目标": "Target Only", + "仅目标物种": "Target Species Only", + "仅矩形图": "Rectangular Only", + "任务列表": "Task List", + "位置分布图": "Position Distribution Map", + "使用拟南芥作为显式兜底": "Use Arabidopsis as Explicit Fallback", + "使用推荐区间": "Use Recommended Range", + "保存方法学方案": "Save Methodology Plan", + "保留": "Retain", + "修订": "Revision", + "倒计时已暂停": "Countdown paused", + "全屏": "Full Screen", + "全屏展示": "Full Screen Display", + "全部": "All", + "全部行": "All Rows", + "关系总数": "Total Edges", + "关闭": "Close", + "内容": "Content", + "内置物种代码": "Built-in Species Code", + "分析家族": "Analysis Family", + "分组角色": "Group Role", + "分组角色不改": "Group Role (Unchanged)", + "删除": "Deleted", + "删除会话": "Delete Session", + "刷新": "Refresh", + "刷新结果": "Refresh Results", + "前景基因": "Foreground Genes", + "功能类别": "Feature Category", + "加入正式列表": "Add to Formal List", + "包含关键词": "Contains Keyword", + "匹配方式": "Match Mode", + "匹配行": "Matched Rows", + "原因": "Reason", + "原始代码": "Original Code", + "原始命令": "Original Command", + "原始文件名": "Original Filename", + "原始证据": "Original Evidence", + "原推荐参考": "Original Recommended Reference", + "参数": "Parameters", + "参考分类": "Reference Classification", + "参考前缀": "Reference Prefix", + "参考基因": "Reference Gene", + "参考基因编号": "Reference Gene ID", + "参考文献": "References", + "参考物种": "Reference Species", + "发送": "Send", + "取消": "Cancel", + "取消预览": "Cancel Preview", + "只会执行已勾选且可执行的阶段": "Only checked and executable stages will run", + "可改": "Editable", + "同时处理的样本数": "Number of Samples Processed Simultaneously", + "名称": "Name", + "后续主参考物种": "Downstream Primary Reference Species", + "启动子元件": "Promoter Elements", + "启动子自定义绘图": "Promoter Custom Plot", + "图例颜色": "Legend Color", + "图类型": "Plot Type", + "图表标题清单": "Chart Title List", + "图表标题驱动复现规划预览": "Chart Title-Driven Reproduction Plan Preview", + "圆形": "Circle", + "基因注释": "Gene Annotation", + "基本参数": "Basic Parameters", + "基础参数": "Basic Parameters", + "处理中...": "Processing...", + "处理方式": "Processing Method", + "多物种处理方式": "Multi-species Processing", + "大小": "Size", + "定量模式": "Quantification Mode", + "实验设计": "Experimental Design", + "实验设计模块": "Experimental Design Module", + "审核备注": "Review Notes", + "富集类型": "Enrichment Type", + "差异表达范围": "Differential Expression Scope", + "已写阶段": "Registered Stages", + "已完成": "Completed", + "已完成阶段": "Completed Stages", + "已应用": "Applied", + "建议参数": "Suggested Parameters", + "开始验证": "Start Validation", + "弧线避让距离": "Arc Avoidance Distance", + "当前参数": "Current Parameters", + "当前参考物种": "Current Reference Species", + "当前选择": "Current Selection", + "待补数据": "Awaiting Data", + "必填": "Required", + "我知道了": "Got It", + "手动区间": "Manual Range", + "执行": "Execute", + "扫描双链": "Scan Both Strands", + "批量执行确认": "Batch Execution Confirmation", + "拉丁名": "Latin Name", + "拓展分析运行参数确认": "Extension Analysis Run Parameter Confirmation", + "拓展分析阶段": "Extension Analysis Stage", + "拓展分析阶段状态": "Extension Analysis Stage Status", + "拓展分析阶段确认": "Extension Analysis Stage Confirmation", + "拓展分析阶段运行结果": "Extension Analysis Stage Run Result", + "按元件": "By Element", + "按单个物种分别分析与确认": "Per-Species Analysis & Confirmation", + "按当前筛选重建成员蛋白": "Rebuild Member Proteins by Current Filter", + "按类别": "By Category", + "授权发布": "Authorize Publish", + "推荐参考数据下载失败": "Recommended Ref Data Download Failed", + "提出修订意见": "Submit Revision Suggestions", + "操作": "Operation", + "数据准备": "Data Preparation", + "数据路径": "Data Path", + "数据需求": "Data Needs", + "文件": "File", + "文件类型": "File Type", + "文件范围": "File Scope", + "文献参数": "Literature Parameters", + "文献工具": "Literature Tools", + "文章涉及物种与下载入口": "Article Species & Download Entry", + "新增": "Added", + "方形": "Square", + "方法参考": "Method Reference", + "方法学文献": "Methodology Papers", + "无": "None", + "明确指定": "Explicitly Specify", + "显示": "Show", + "暂停倒计时": "Pause Countdown", + "暂无已上传文件": "No Uploaded Files Yet", + "暂无拓展分析阶段结果": "No Extension Analysis Stage Results Yet", + "暂无状态记录": "No Status Record Yet", + "最大宽度": "Max Width", + "最小宽度": "Min Width", + "最小非缺口比例": "Min Non-gap Ratio", + "最近到属": "Closest to Genus", + "最近到目": "Closest to Order", + "最近到科": "Closest to Family", + "最近到纲": "Closest to Class", + "有损转换警告": "Lossy Conversion Warning", + "服务器测试数据路径": "Server Test Data Path", + "未从图表标题推断出额外阶段": "No Additional Stages Inferred from Chart Titles", + "未提取到图表标题": "No Chart Title Extracted", + "未识别到内置下载链接": "No Built-in DL Link Identified", + "未识别到参数": "No Parameters Identified", + "未识别到文献工具": "No Literature Tool Identified", + "本次成员鉴定物种": "Current Member Identification Species", + "来源": "Sources", + "染色体标注位置": "Chromosome Annotation Position", + "染色体标注大小": "Chromosome Annotation Size", + "染色体策略": "Chromosome Strategy", + "染色体色块粗细": "Chromosome Block Width", + "查看本次知识检索过程": "View knowledge retrieval process", + "标记大小": "Mark Size", + "标记模式": "Mark Mode", + "标题推断的候选阶段": "Candidate Stages Inferred from Titles", + "树枝粗细": "Branch Width", + "树枝颜色": "Branch Color", + "样本并行数量": "Sample Parallel Count", + "检索结果": "Retrieval Results", + "检索表格内容": "Search Table Content", + "正则匹配": "Regex Match", + "此拓展阶段没有可配置参数": "No Configurable Parameters", + "测试数据": "Test Data", + "涉及阶段": "Involved Stages", + "添加行": "Add Row", + "灰色": "Gray", + "物种": "Species", + "物种代码": "Species Code", + "物种代码不改": "Species Code (Unchanged)", + "物种名": "Species Name", + "物种角色": "Species Role", + "物种角色不改": "Species Role (Unchanged)", + "环境": "Environment", + "留空则自动发现": "Leave empty for auto-discovery", + "目标前缀": "Target Prefix", + "目标物种": "Target Species", + "知识图谱": "Knowledge Graph", + "确定": "Confirm", + "确认亚家族标签": "Confirm Subfamily Labels", + "确认保存": "Confirm Save", + "确认并写入正式配置": "Confirm & Write to Official Config", + "确认并执行": "Confirm & Execute", + "确认执行": "Confirm Execution", + "确认自动注释": "Confirm Auto-Annotation", + "确认重绘": "Confirm Replot", + "种间共线性比较物种": "Interspecies Synteny Comparative Species", + "种间共线性绘图设置": "Interspecies Synteny Plot Settings", + "移除": "Remove", + "窗口长度": "Window Length", + "类型": "Types", + "精确匹配": "Exact Match", + "红色": "Red", + "终止位点": "End Site", + "终止残基": "End Residue", + "绘图模式": "Plot Mode", + "绿色": "Green", + "置信度": "Confidence", + "置信度阈值": "Confidence Threshold", + "群体遗传输入": "Population Genetic Input", + "背景来源": "Background Source", + "背景知识": "Background knowledge", + "自动": "Auto", + "自动从注释提取": "Auto-extract from Annotation", + "自动保守窗口": "Auto Conserved Window", + "自动判断": "Auto-detect", + "节点名称": "Node Name", + "节点总数": "Total Nodes", + "节点类型": "Node Types", + "菱形": "Diamond", + "蓝色": "Blue", + "蛋白": "Protein", + "蛋白文件": "Protein Files", + "表达矩阵": "Expression Matrix", + "诊断动作": "Diagnostic Actions", + "说明": "Description", + "请先试运行以记录真实输出": "Please Run a Trial First to Record Actual Output", + "请选择共线性分析类型": "Please select synteny analysis type", + "请选择模式物种": "Select Model Species", + "起始位点": "Start Site", + "起始残基": "Start Residue", + "跳数": "Hops", + "输出": "Output", + "输出前缀": "Output Prefix", + "输出标签": "Output Label", + "运行值": "Run Value", + "运行结果": "Run Result", + "进化树图修订参数": "Phylogenetic Tree Revision Parameters", + "连接关系数": "Connections", + "迭代": "Iteration", + "选择其他提取结果": "Select Other Extraction Result", + "选择工具": "Select Tool", + "选择已完成撰写的拓展阶段": "Select Completed Extension Stage", + "通常留空": "Usually leave empty", + "重写查询": "Rewrite Query", + "重新生成说明": "Regenerate Description", + "重置": "Reset", + "重置视图": "Reset View", + "链接": "Link", + "链特异性": "Library Strandedness", + "隐藏": "Hide", + "需检查": "Needs Review", + "需要补充": "Need to Supplement", + "非时间实验留空": "Leave empty for non-time experiments", + "项目": "Project", + "预览": "Preview", + "预览到表格": "Preview to Table", + "预览命令": "Preview Commands", + "黑色": "Black", + "默认值": "Default Value", + "提取方法学": "Extract", + "刷新记录": "Refresh", + "打开最新提取结果": "Open Latest Extraction", + "查看最新提取状态": "View Latest Status", + "打开所选提取结果": "Open Selected Extraction", + "查看所选提取状态": "View Selected Status", + "生成并发布": "Generate & Publish", + "运行已发布": "Run Published", + "当前会话": "Current Session", + "更多会话": "More Sessions", + "新建": "New", + "新建会话": "New Session", + "使用小Tip": "Tips", + "一、分析模块快速指令": "1. Analysis Module Quick Commands", + "选择输入框上方\"Analysis\"": "Select \"Analysis\" above input box", + "粘贴数据位置": "Paste data location", + "核对配置文件": "Review config files", + "执行数据准备": "Run data preparation", + "执行XX物种的亚家族分类": "Run subfamily classification for XX species", + "执行XX物种的基因家族分析": "Run gene family analysis for XX species", + "执行rnaseq分析": "Run RNA-seq analysis", + "二、文章复现步骤": "2. Article Reproduction Steps", + "点击\"浏览\"上传文献": "Click \"Browse\" to upload paper", + "点击提取方法学": "Click Extract Methodology", + "核对分析步骤": "Review analysis steps", + "点击保存方案": "Click Save Plan", + "上传数据": "Upload data", + "发送\"按已保存的方法学方案继续执行\"": "Send \"Continue with saved methodology plan\"", + "三、RAG快速指令": "3. RAG Quick Commands", + "选择输入框上方\"RAG\"": "Select \"RAG\" above input box", + "输入问题": "Enter question", + "点击\"知识图谱\"查看关系": "Click \"Knowledge Graph\" to view relations", + "知识图谱": "Knowledge Graph", + "重新加载": "Reload", + "重置视图": "Reset View", + "2.2 MSA Replot / 区间重绘": "2.2 MSA Replot", + "2.3 Tree Replot / 进化树重绘": "2.3 Tree Replot", + "2.5 亚家族确认": "2.5 Subfamily Confirmation", + "6.1 启动子自定义绘图": "6.1 Promoter Custom Plot", + "7️⃣ 种内Synteny": "7️⃣ Intraspecies Synteny", + "7.1 种内共线性 Circos 重绘": "7.1 Intraspecies Synteny Circos Replot", + "8️⃣ Karyotype": "8️⃣ Karyotype", + "9️⃣ 种间Synteny": "9️⃣ Interspecies Synteny", + "基因家族": "Gene Family", + "WGS2GWAS(遗传变异与群体分析)": "WGS2GWAS", + "基因家族 promoter 扫描": "Gene family promoter scanning", + "新会话": "New Session", + "新会话已创建,请选择或创建 Working Directory": "New session created. Please select or create a Working Directory", + "创建会话失败": "Failed to create session", + "会话已删除": "Session deleted", + "删除会话失败": "Failed to delete session", + "对话历史已清空": "Chat history cleared", + "错误日志已清空": "Error logs cleared", + "重启 Celery": "Restart Celery", + "重启项目": "Restart Project", + "提交配置好的项目重启脚本": "Submit configured project restart script", + "未配置 PLANTAI_RESTART_SCRIPT 或 restart_plantai_9000.sh": "PLANTAI_RESTART_SCRIPT or restart_plantai_9000.sh not configured", + "未配置项目重启脚本": "Project restart script not configured", + "这会提交项目重启脚本,当前页面可能短暂断开。": "This will submit the project restart script. The page may briefly disconnect.", + "确认重启": "Confirm Restart", + "项目重启脚本已提交": "Project restart script submitted", + "项目重启未提交": "Project restart not submitted", + "基因家族鉴定": "Gene Family Identification", + "系统发育分析": "Phylogenetic Analysis", + "Motif分析": "Motif Analysis", + "结构域分析": "Domain Analysis", + "基因结构分析": "Gene Structure Analysis", + "启动子元件预测": "Promoter Element Prediction", + "TF结合位点预测": "TF Binding Site Prediction", + "种内共线性分析": "Intraspecies Synteny Analysis", + "种间共线性分析": "Interspecies Synteny Analysis", + "核型/染色体分布分析": "Karyotype/Chromosome Distribution", + "PPI分析": "PPI Analysis", + "Ka/Ks分析": "Ka/Ks Analysis", + "蛋白理化性质与亚细胞定位": "Protein Properties & Subcellular Localization", + "表达矩阵分析": "Expression Matrix Analysis", + "RNA-seq QC": "RNA-seq QC", + "RNA-seq Align": "RNA-seq Align", + "RNA-seq Quant": "RNA-seq Quant", + "RNA-seq Sample QC": "RNA-seq Sample QC", + "GWAS VCF排序与HapMap转换": "GWAS VCF Sort & HapMap Conversion", + "GWAS TASSEL亲缘关系矩阵": "GWAS TASSEL Kinship Matrix", + "GWAS表型准备": "GWAS Phenotype Preparation", + "GWAS协变量准备": "GWAS Covariate Preparation", + "GWAS TASSEL MLM/CMLM": "GWAS TASSEL MLM/CMLM", + "GWAS MLM/CMLM绘图": "GWAS MLM/CMLM Plotting", + "GWAS EMMAX": "GWAS EMMAX", + "GWAS EMMAX绘图": "GWAS EMMAX Plotting", + "GWAS TASSEL GLM": "GWAS TASSEL GLM", + "群体PCA分析": "Population PCA Analysis", + "群体系统树分析": "Population Phylogenetic Tree", + "群体结构分析": "Population Structure Analysis", + "群体LD衰减分析": "Population LD Decay Analysis", + "SNP密度分析": "SNP Density Analysis", + "RNA-seq DE": "RNA-seq DE", + "组合图": "Combo Plot", + "参考亚家族分类": "Reference Subfamily Classification", + "亚家族标签确认": "Subfamily Label Confirmation", + "PPI/KaKs 参考数据": "PPI/KaKs Reference Data", + "PPI/KaKs 兜底确认": "PPI/KaKs Fallback Confirmation", + "亚家族确认输出": "Subfamily Confirmation Output", + "MSA区间重绘": "MSA Region Replot", + "MSA区间重绘已提交": "MSA Replot submitted", + "MSA区间重绘完成": "MSA Replot completed", + "MSA区间重绘失败": "MSA Replot failed", + "启动子自定义绘图已提交": "Promoter custom plot submitted", + "启动子自定义绘图完成": "Promoter custom plot completed", + "启动子自定义绘图失败": "Promoter custom plot failed", + "种内共线性 Circos 重绘已提交": "Intraspecies Circos replot submitted", + "种内共线性 Circos 重绘完成,已切换到新图": "Intraspecies Circos replot completed, switched to new figure", + "种内共线性 Circos 重绘失败": "Intraspecies Circos replot failed", + "亚家族确认 - Reference Classification": "Subfamily Confirmation - Reference Classification", + "这会停止当前 profile 的 Celery worker 并重新启动。若有任务正在运行,后端会默认拒绝执行。": "This will stop and restart the Celery worker for the current profile. Running tasks will be rejected by default.", + "Celery 已重启并完成队列检查": "Celery restarted and queue check complete", + "Celery 重启未完成": "Celery restart incomplete", + // ---- Card status_label (8) ---- + "失败": "Failed", + "运行中": "Running", + "待确认": "Pending", + "待执行": "Pending", + "待核对": "Review", + "待授权": "Authorize", + "已取消": "Cancelled", + // ---- Card title (10) ---- + "拓展分析候选核对": "Extension Candidate Review", + "拓展分析说明生成失败": "Description Generation Failed", + "正在生成拓展分析": "Generating Extension", + "运行结果核对": "Result Review", + "输入文件和参数": "Input Files & Parameters", + "正在恢复拓展分析状态": "Restoring Extension State", + // ---- Card purpose/summary (15) ---- + "原始命令、测试数据和执行事实已经保留;这里只重新生成说明,不会重新运行原始命令。": "Raw command, test data and execution facts are preserved; only regenerate description without re-running.", + "可以直接重试,或取消后检查错误原因。": "Retry or cancel and check error cause.", + "这张卡用于核对系统从真实执行中锁定的命令、输入、参数和输出。": "Review system-locked command, inputs, parameters and outputs from real execution.", + "系统正在确定执行入口,并运行上传的代码和测试数据,提取不可修改的执行事实。": "Determining entry point, running uploaded code/test data, extracting immutable execution facts.", + "原始包可复现检查中…": "Checking reproducibility of original package…", + "这张卡用于用同一份测试数据执行已确认的工具,并生成可核对的结果。": "Run confirmed tool with same test data; generate verifiable results.", + "确认无误后点击“开始验证”。": "Click \"Start Validation\" after confirming.", + "这张卡用于查看实际命令、输出与日志;核对无误后才允许授权发布。": "View actual command, output and logs; publication requires review confirmation.", + "这张卡用于确认将已经验证的工具发布为可复用的拓展分析。": "Confirm publishing validated tool as reusable extension.", + "确认后才会发布;不会自动发布。": "Publication requires explicit confirmation; not automatic.", + "这张卡用于确认本次新数据运行的输入文件和原始命令已有参数;确认后立即执行,不会再次弹出确认。": "Confirm input files and parameters for this run; executes immediately without re-confirmation.", + "这张卡展示本次新数据运行生成的文件、标准输出和日志。": "Files, stdout and logs generated by this run.", + "页面已恢复;系统继续轮询同一个操作,不会重复提交。": "Page restored; continuing to poll the same operation without re-submitting.", + "请在页面下方的主对话输入框说明需要修订的内容,然后发送。命令和真实执行事实不会被修改。": "Describe revisions in the main chat input below. Commands and execution facts are immutable.", + "已取消;没有提交后续执行。": "Cancelled; no further execution submitted.", + // ---- Card rows labels (13) ---- + "实际命令": "Actual Command", + "输出文件": "Output Files", + "标准输出": "Standard Output", + "见执行日志": "See execution log", + "功能": "Function", + "输入文件": "Input Files", + "可改参数": "Configurable Parameters", + "标准输出(直接展示)": "Stdout (direct display)", + "(无文件输出)": "(No file output)", + "(无)": "(None)", + // ---- Card error/misc (25+) ---- + "操作失败": "Operation failed", + "读取拓展分析说明失败": "Failed to read extension help", + "读取拓展分析状态失败": "Failed to read extension status", + "阶段:": "Phase: ", + "原因:": "Cause: ", + "无法读取操作状态": "Cannot read operation status", + "提交失败": "Submission failed", + "原始包可复现检查提交失败": "Reproducibility check submission failed", + "阶段:upload": "Phase: Upload", + "已恢复修订;请核对最新候选。": "Revision restored; please review latest candidate.", + "✓ 语义说明已重新生成;执行命令未重新运行。": "✓ Description regenerated; command not re-run.", + "✓ 执行入口已成功复现;候选信息已生成。": "✓ Entry point reproduced successfully; candidate info generated.", + "✓ 验证已完成;结果核对卡已生成。": "✓ Validation complete; result review card generated.", + "✓ 已核对运行结果。": "✓ Results reviewed.", + "✓ 已显式授权并发布。": "✓ Explicitly authorized and published.", + "✓ 已确认输入与参数;本次运行已完成。": "✓ Inputs and parameters confirmed; run completed.", + "✓ 已恢复并完成;候选核对卡已生成。": "✓ Restored and completed; candidate review card generated.", + "✓ 已恢复确认;可以开始验证。": "✓ Confirmation restored; ready to validate.", + "✓ 已恢复验证;结果核对卡已生成。": "✓ Validation restored; result review card generated.", + "✓ 已恢复结果核对;可以授权发布。": "✓ Review restored; ready to publish.", + "✓ 已恢复并发布。": "✓ Restored and published.", + "✓ 已恢复运行;本次结果卡已生成。": "✓ Run restored; result card generated.", + "✓ 已确认候选|未作修订": "✓ Candidate confirmed | no revision", + "✓ 已确认候选|已修订:": "✓ Candidate confirmed | revised: ", + "文件上传成功,已开始生成并验收": "Upload successful; generation and validation started", + "请填写名称并提供测试数据文件或服务器路径;原始命令和原始代码至少提供一项。": "Please provide name and test data files or server path; at least command or code is required.", + "请写明修订意见。": "Please specify revision feedback.", + "读取验证结果失败": "Failed to read validation results", + "读取运行说明失败": "Failed to read run help", + "请先选择实际运行 Job。": "Please select a job first.", + "请填写每个输入文件。": "Please fill in each input file.", + "已修订:": "Revised: ", + "正在恢复 ": "Restoring ", + "…": "…", + " 提交失败": " submission failed", + " 原始包可复现检查提交失败": " Reproducibility check submission failed", + "执行入口已在隔离环境运行。请核对系统记录的事实与说明;确认后才会验证。": "Entry point ran in isolated environment. Review system-recorded facts and descriptions; validation proceeds after confirmation.", + // ---- Agent message: status/confirm/cancel (10) ---- + "config.xlsx 已确认,继续当前目标:": "config.xlsx confirmed; continuing current goal: ", + "✅ 已确认使用拟南芥作为 PPI/KaKs 的显式兜底参考;该选择只写入 Ortholog/PPI/KaKs 专用参数,其它常规阶段不受影响。": "✅ Arabidopsis confirmed as PPI/KaKs explicit fallback reference; affects only Ortholog/PPI/KaKs parameters.", + "✅ 已确认自动注释,正在重新提交": "✅ Auto-annotation confirmed; re-submitting", + "✅ 已选择项目参考物种": "✅ Project reference species selected", + "已停止等待,本次未继续获取回答。": "Stopped waiting; no further response.", + "抱歉,我没有理解你的意思。请尝试更详细地描述。": "Sorry, I didn't understand. Please try describing in more detail.", + "抱歉,服务暂时不可用,请稍后重试。": "Sorry, service temporarily unavailable. Please try again later.", + // ---- Agent message: pause/block/skip (13) ---- + "⏸️ PPI/KaKs 前置数据检查未通过,已暂停批量执行;请补齐参考注释包或确认参考物种后重新执行。": "⏸️ PPI/KaKs prerequisite check failed; batch paused. Add reference annotation or confirm reference species.", + "⏸️ identify 已完成,但方法学链路需要先确认基因家族成员表;保存成员确认后再继续 phylogeny。": "⏸️ identify completed; methodology chain requires confirmed gene family members before continuing phylogeny.", + "⏸️ 已打开 identify 成员初筛确认表;保存后会生成 curated_members,再继续亚家族证据阶段。": "⏸️ identify member confirmation table opened; save to generate curated_members, then continue subfamily evidence stages.", + "⏸️ 当前已有前台批次执行中;WGS 规划已保留,请在当前批次结束后重新执行。": "⏸️ Foreground batch in progress; WGS plan saved. Re-run after current batch finishes.", + ",其它阶段继续执行。": ", other stages continue.", + "⏸️ 已按选择先暂停 Ortholog/PPI/KaKs,其它阶段继续执行。": "⏸️ Paused Ortholog/PPI/KaKs as selected; other stages continue.", + "⏹️ 后端预检失败,当前批次已停止;请重新拉取规划后继续。": "⏹️ Backend pre-check failed; batch stopped. Re-pull plan to continue.", + // ---- Agent message: replan/continue (5) ---- + "🔁 亚家族标签已确认,正在重新规划确认后常规基因家族分析...": "🔁 Subfamily labels confirmed; re-planning post-confirmation gene family analysis...", + "确认后常规分析没有新的可执行阶段。": "No new executable stages after confirmation.", + // ---- Agent message: submit/execute (7) ---- + "⚙️ 提交阶段: ": "⚙️ Submitting stage: ", + "按 per_species 拆分 ": "Split by per_species ", + // ---- Agent message: complete/success (7) ---- + "✅ PPI/KaKs 前置数据检查通过:当前批次已有 Ortholog,将先生成同源映射再继续下游分析。": "✅ PPI/KaKs check passed: Ortholog exists in current batch; generating mapping first.", + // ---- Agent message: download/background (5) ---- + "⬇️ 正在提交推荐近源参考物种注释包后台下载;Ortholog/PPI/KaKs 将等待参考数据,其它阶段继续执行。": "⬇️ Submitting reference annotation download; Ortholog/PPI/KaKs wait for data, other stages continue.", + // ---- Agent message: autofill (2) ---- + "已根据上传文件生成 samples.yaml 草稿。请在弹窗中核对 run、sample_id、condition、replicate、链特异性和分组角色,确认后再写入正式 samples.yaml。": "samples.yaml draft generated. Review run, sample_id, condition, replicate, strand specificity and group roles in the dialog, then confirm to write.", + "已根据上传文件生成 config.xlsx 草稿。请在弹窗中核对物种代码、文件类型、物种角色和 Gene_Family,确认后再写入正式 config.xlsx。": "config.xlsx draft generated. Review species code, file type, species role and Gene_Family in the dialog, then confirm to write.", + // ---- Agent message: methodology (4) ---- + "已保存方法学方案到当前 job。下一步请上传/填写目标物种和参考物种数据,并确认 config.xlsx 后再执行方法学流程。": "Methodology plan saved. Next: upload target/reference species data, confirm config.xlsx, then execute.", + "❌ 方法学执行规划失败:": "❌ Methodology execution planning failed: ", + // ---- Agent message: replot/revision (4 to fix) ---- + "进化树图重绘已提交:": "Phylogeny tree redraw submitted: ", + "进化树图重绘失败:": "Phylogeny tree redraw failed: ", + "Combo 图修订已提交:": "Combo plot revision submitted: ", + "Combo 图修订失败:": "Combo plot revision failed: ", + // ---- Agent message: failure/interrupt (10) ---- + "无法执行分析:": "Cannot execute analysis: ", + "服务暂时不可用:": "Service temporarily unavailable: ", + "⚠️ 批量执行完成:成功 ": "⚠️ Batch complete: success ", + "⚠️ 以下阶段因依赖失败被跳过: ": "⚠️ Stages skipped due to dependency failure: ", + "⚠️ 以下阶段等待上游暂停项处理后继续: ": "⚠️ Stages waiting for upstream paused items: ", + "未生成可执行阶段。未检测到当前 scope 的完成结果时,不再把空计划解释为已完成;请检查阶段名称或前置依赖。": "No executable stages generated. Empty plan is not interpreted as completed. Check stage names or dependencies.", + // ---- Agent message: extension analysis (6) ---- + "拓展分析阶段:": "Extension stage: ", + "拓展分析阶段失败:": "Extension stage failed: ", + "已执行拓展分析阶段:": "Extension stage executed: ", + "拓展分析阶段执行失败:": "Extension stage execution failed: ", + "拓展分析帮助暂时不可用:": "Extension help temporarily unavailable: ", + "上传可执行代码、测试数据和原始命令,核对候选后验证并授权发布;发布后可指定输入和原命令已有参数运行。": "Upload executable code, test data and original command; review candidate, validate, then authorize publishing.", + // ---- Agent message: other (5) ---- + "📌 当前已有前台批次执行中,新任务已转为后台执行:": "📌 Foreground batch running; new task moved to background: ", + "已识别到 RNA-seq/FASTQ 和参考资源": "RNA-seq/FASTQ and reference resources detected", + "已把目录中的可识别参考资源导入当前 job": "Reference resources imported into current job", + "我识别到你是在提供数据路径,但该路径下没有发现": "Data path detected but no usable files found", + "已选择:": "Selected: ", + // ---- Agent message: remaining dynamic patterns ---- + "❌ 批量执行已取消": "❌ Batch execution cancelled", + "⏭️ 跳过阶段: ": "⏭️ Skipped stage: ", + "🚀 准备提交依赖驱动执行,共 ": "🚀 Preparing dependency-driven execution: ", + "🎉 批量执行完成!成功: ": "🎉 Batch complete! Success: ", + ",失败 ": ", failed ", + "跳过阶段: ": "Skipped stage: ", + " 暂停:需要先准备参考物种 PEP/CDS 或 Ortholog 映射。": " paused: prepare reference species PEP/CDS or Ortholog mapping first.", + " 已暂停依赖 confirmed_subfamily.tsv 的后续阶段;请在亚家族确认表中保存后重新拉取规划继续执行。": " paused subsequent stages depending on confirmed_subfamily.tsv; save the subfamily confirmation table and re-pull the plan.", + "参考数据已就绪": "Reference data ready", + " 已恢复为待执行,将随当前批次继续调度。": " restored as pending; will continue with current batch.", + " 开始续跑 Ortholog/PPI/KaKs。": " resuming Ortholog/PPI/KaKs.", + " 重新提交(自动注释背景)": " re-submit (auto-annotation background)", + " 已提交后端,task_id=": " submitted to backend, task_id=", + " 准备执行 ": " preparing to execute ", + " 个阶段,请确认参数": " stages; please confirm parameters", + " 个阶段...": " stages...", + " 个分析阶段,请确认参数": " analysis stages; please confirm parameters", + "亚家族确认后已规划 ": "Post-subfamily-confirmation plan: ", + " 个常规分析阶段": " regular analysis stages", + "PPI/KaKs 前置数据检查通过:检测到已下载近源参考 ": "PPI/KaKs check passed: downloaded reference ", + ",将用该参考先生成 Ortholog mapping。": ", generating Ortholog mapping first.", + "PPI/KaKs 前置数据检查通过:检测到已有 Ortholog 映射(": "PPI/KaKs check passed: Ortholog mapping exists (", + "),将复用该映射执行 ": "), reusing mapping for ", + "),将直接执行 ": "), executing directly ", + "),已自动插入 Ortholog,将先生成 mapping 再执行 ": "), Ortholog auto-inserted; generating mapping then executing ", + ",已自动插入 Ortholog": ", Ortholog auto-inserted", + "推荐参考数据下载完成:": "Reference download complete: ", + ")。已写入 Ortholog/PPI/Ka/Ks 专用参考物种。": "). Written to Ortholog/PPI/Ka/Ks specific reference species.", + " 已按用户确认使用 bcftools 完成": " completed with bcftools as confirmed by user", + " 前端连接短暂中断,正在按后端产物状态恢复": " frontend connection interrupted; recovering from backend state", + "亚家族确认已完成,但后端当前 workflowPhase 为 ": "Subfamily confirmation completed; backend workflowPhase is ", + ",未自动打开常规分析。": "; no auto-analysis opened.", + "亚家族分类已完成预测,但确认表尚未保存;": "Subfamily classification predicted; confirmation table not yet saved; ", + "已调出": "Opened ", + "亚家族分类确认表。你可以在弹窗中修订 confirmed_subfamily,点击\"确认亚家族标签\"后保存。": " subfamily classification confirmation table. Revise confirmed_subfamily in the dialog, then click \"Confirm Subfamily Labels\" to save.", + "亚家族分类确认表没有打开:未找到可确认的分类结果。请先确认该物种已完成 reference_classification。": " subfamily classification confirmation table not opened: no confirmable results found. Confirm the species has completed reference_classification.", + "确认后常规分析没有新的可执行阶段。": "No new executable stages after confirmation.", + "⚠️ 检测到 ": "⚠️ Detected ", + ",将在确认后启动本地自动注释。": "; auto-annotation will start after confirmation.", + "🧬 自动背景注释与 ": "🧬 Auto-background annotation and ", + " 已提交后端,task_id=": " submitted to backend, task_id=", + "⚠️ 亚家族确认后自动续跑规划失败:": "⚠️ Post-subfamily-confirm auto-plan failed: ", + "; Ortholog/PPI/KaKs ": "; Ortholog/PPI/KaKs ", + "推荐参考数据下载已转入后台:": "Reference download moved to background: ", + ",task_id=": ", task_id=", + "后台批次完成:成功 ": "Background batch complete: success ", + "后台执行阶段: ": "Background stage: ", + "后台阶段 ": "Background stage ", + " 完成": " done", + "已保存方法学方案,但暂不执行:": "Methodology plan saved, not executing: ", + "。请在 config.xlsx 自动识别弹窗中核对/补齐数据,确认写入正式配置后再点击\"确认并执行\"。": ". Review/fill data in the config.xlsx auto-detect dialog, confirm to write then click \"Confirm & Execute\".", + "当前已有前台批次执行中;WGS 规划已保留,请在当前批次结束后重新执行。": "Foreground batch in progress; WGS plan saved. Re-run after current batch finishes.", + "拓展分析阶段执行失败:": "Extension stage execution failed: ", + "已把路径中的 ": "Soft-linked ", + " 个可识别文件软链接到当前 job": " recognizable files to current job", + " 个可识别文件软链接到当前 job,但没有发现需要自动填写 config.xlsx 或 samples.yaml 的文件类型;请检查目录或继续补充文件。": " recognizable files linked to current job; no files found for config.xlsx/samples.yaml auto-fill. Check directory or add more files.", + "前": " ago", + "秒前": "sec ago", + "分钟前": "min ago", + "小时前": "hr ago", + "天前": "d ago", + "待机": "Idle", + "分析中": "Analyzing", + "检索中": "Retrieving", + "反思中": "Reflecting", + "生成中": "Generating", + "完成": "Complete", + "错误": "Error", + "处理中...": "Processing...", + "发送": "Send", + "任务列表": "Tasks", + " 个常规分析阶段,请确认参数": " regular analysis stages; please confirm parameters", + " 亚家族分类已完成预测,但确认表尚未保存;后续会继续显示为待确认。": " subfamily classification predicted; confirmation table not yet saved; will show as pending.", + " 到后端;系统会先生成 ": " to backend; system will generate ", + " 前端连接短暂中断,正在按后端产物状态恢复,不会立即判定阶段失败": " frontend connection interrupted; recovering from backend artifact state. Stage will not be marked as failed.", + " 已有同一分析范围的有效结果,无需重复执行。": " already has valid results for the same analysis scope. No need to re-execute.", + " 的依赖尚未完成": " dependency not yet completed", + " 重新提交(自动注释背景)...": " re-submitting (auto-annotation background)...", + " 提交失败": " submission failed", + " 原始包可复现检查提交失败": " reproducibility check submission failed", + "Combo 图修订已提交:task_id=": "Combo plot revision submitted: task_id=", + "⏳ 推荐参考数据下载已转入后台:": "⏳ Reference download moved to background: ", + "⏸️ 以下阶段等待上游暂停项处理后继续: ": "⏸️ Stages waiting for upstream paused items: ", + "⚙️ 亚家族确认后已规划 ": "⚙️ Post-subfamily-confirm plan: ", + "⚙️ 准备执行 ": "⚙️ Preparing to execute ", + "⚙️ 后台执行阶段: ": "⚙️ Background stage: ", + "⚠️ 后台批次中以下阶段因依赖失败被跳过: ": "⚠️ Background batch stages skipped due to dependency failure: ", + "✅ PPI/KaKs 前置数据检查通过:检测到已下载近源参考 ": "✅ PPI/KaKs check passed: downloaded reference ", + "✅ PPI/KaKs 前置数据检查通过:检测到已有 Ortholog 映射(": "✅ PPI/KaKs check passed: Ortholog mapping found (", + "✅ 后台阶段 ": "✅ Background stage ", + "✅ 已确认自动注释,正在重新提交 ": "✅ Auto-annotation confirmed; re-submitting ", + "✅ 已选择项目参考物种 ": "✅ Selected project reference species: ", + "✅ 推荐参考数据下载完成:": "✅ Reference download complete: ", + "✅ 阶段 ": "✅ Stage ", + "☒ 已恢复并完成。": "☒ Restored and completed.", + "❌ 无法执行分析:": "❌ Cannot execute analysis: ", + "❌ 服务暂时不可用:": "❌ Service temporarily unavailable: ", + "。结果已写入当前 job workspace,输出:": ". Results written to current job workspace, output: ", + "。请在 config.xlsx 自动识别弹窗中核对/补齐数据,确认写入正式配置后再点击“确认并执行”。": ". Review/fill data in the config.xlsx auto-detect dialog; confirm to write then click \"Confirm & Execute\".", + "亚家族分类确认表。你可以在弹窗中修订 confirmed_subfamily,点击“确认亚家族标签”后保存。": "subfamily classification confirmation table. Revise confirmed_subfamily in the dialog, then click \"Confirm Subfamily Labels\" to save.", + "亚家族标签确认被跳过,已停止依赖 confirmed_subfamily.tsv 的后续阶段;如需普通无亚家族标签分析,请重新发起并明确说明\"不需要亚家族\"。": "Subfamily label confirmation skipped; subsequent stages depending on confirmed_subfamily.tsv have been stopped. For regular analysis without subfamily labels, please restart and explicitly state \"no subfamily\".", + "发送\"按已保存的方法学方案继续执行\"": "Send \"Continue with saved methodology plan\"", + "后台批次无法继续:": "Background batch cannot continue: ", + "已打开 config.xlsx 自动识别表。你可以直接修订单行,或用批量修订区筛选、预览、Apply。": "Opened config.xlsx auto-identification table. You can edit individual rows, or use the batch revision area to filter, preview, and Apply.", + "已打开 samples.yaml 自动识别表。你可以直接修订样本、分组、重复和对照组。": "Opened samples.yaml auto-identification table. You can revise samples, groups, replicates and controls directly.", + "已把目录中的可识别参考资源导入当前 job,并生成 config.xlsx 草稿。请在弹窗中核对和修订,点击“确认并写入正式配置”后再执行正式分析。": "Imported recognizable reference resources into current job and generated config.xlsx draft. Please review and revise in the dialog, then click \"Confirm & Write to Official Config\" before executing.", + "已识别到 RNA-seq FASTQ 和参考资源,优先打开 samples.yaml 草稿供你核对样本、分组和对照;config.xlsx 草稿也已生成。": "RNA-seq FASTQ and reference resources detected; opening samples.yaml draft for sample/group/control review. config.xlsx draft also generated.", + "已识别到 RNA-seq/FASTQ 数据并生成 samples.yaml 草稿。请在弹窗中核对 run、sample_id、condition、replicate、control_group 后再写入正式 samples.yaml。": "RNA-seq/FASTQ data detected and samples.yaml draft generated. Review run, sample_id, condition, replicate, control_group in the dialog, then confirm to write.", + "已重新识别并生成 config.xlsx 草稿。请在弹窗中核对并修订,确认后才会写入正式 config.xlsx。": "Re-identified and generated config.xlsx draft. Review and revise in the dialog; writes to official config.xlsx only after confirmation.", + "已重新识别并生成 samples.yaml 草稿。请确认 run、sample_id、condition、replicate 和 control_group 后再写入正式 samples.yaml。": "Re-identified and generated samples.yaml draft. Confirm run, sample_id, condition, replicate, and control_group before writing to official samples.yaml.", + "没有找到可展示的结果。": "No displayable results found.", + "点击\"浏览\"上传文献": "Click \"Browse\" to upload paper", + "点击\"知识图谱\"查看关系": "Click \"Knowledge Graph\" to view relations", + "请先核对 config.xlsx 草稿。": "Please review the config.xlsx draft first.", + "请补充你的需求。": "Please clarify your requirements.", + "进化树图重绘已提交:task_id=": "Phylogeny tree redraw submitted: task_id=", + "选择输入框上方\"Analysis\"": "Select \"Analysis\" above the input box", + "选择输入框上方\"RAG\"": "Select \"RAG\" above the input box", + ",再继续富集分析。": ", then continue enrichment analysis.", + ",将用该参考先生成 Ortholog mapping。": ", will generate Ortholog mapping from this reference first.", + ";Ortholog/PPI/KaKs 已恢复为待执行,将随当前批次继续调度。": "; Ortholog/PPI/KaKs restored as pending; will continue with current batch.", + ",将按该参考检查或生成 Ortholog,并执行 ": "; will check or generate Ortholog from this reference, and execute ", + ";开始续跑 Ortholog/PPI/KaKs。": "; resuming Ortholog/PPI/KaKs.", + "),将复用该映射执行 ": "), will reuse this mapping and execute ", + "),将直接执行 ": "), will execute directly ", + ",已自动插入 Ortholog,将先生成 mapping 再执行 ": ", Ortholog auto-inserted; generating mapping first then executing ", + ")。已写入 Ortholog/PPI/Ka/Ks 专用参考物种。": "). Written to Ortholog/PPI/Ka/Ks specific reference species.", + " 已按用户确认使用 bcftools 完成": " completed with bcftools as user confirmed", + " 已暂停依赖 confirmed_subfamily.tsv 的后续阶段;请在亚家族确认表中保存后重新拉取规划继续执行。": " has paused subsequent stages depending on confirmed_subfamily.tsv; save the subfamily confirmation table and re-pull the plan.", + " 暂停:需要先准备参考物种 PEP/CDS 或 Ortholog 映射。": "paused: prepare reference species PEP/CDS or Ortholog mapping first.", + " 个分析阶段,请确认参数": " analysis stages, please confirm parameters", + "📦 后台批次完成:成功 ": "📦 Background batch complete: success ", + "🔄 阶段 ": "🔄 Stage ", + "🚀 阶段 ": "🚀 Stage ", + "config.xlsx 已确认,继续当前目标:": "config.xlsx confirmed; continuing current goal: ", + "按 per_species 拆分 ": "Split by per_species ", + "⚠️ 检测到 ": "⚠️ Detected ", + ",将在确认后启动本地自动注释。": "; auto-annotation will start after confirmation.", + "。已写入 Ortholog/PPI/Ka/Ks 专用参考物种。": ". Written to Ortholog/PPI/Ka/Ks specific reference species.", + "🚀 准备提交依赖驱动执行,共 ": "🚀 Preparing dependency-driven execution: ", + " 个阶段...": " stages...", + "⚠️ 批量执行完成:成功 ": "⚠️ Batch complete: success ", + ",失败 ": ", failed ", + "🎉 批量执行完成!成功: ": "🎉 Batch complete! Success: ", + "⏭️ 跳过阶段: ": "⏭️ Skipped stage: ", + "⚙️ 提交阶段: ": "⚙️ Submitting stage: ", + "⏭️ ": "⏭️ ", + " 已提交后端,task_id=": " submitted to backend, task_id=", + " 完成": " complete", + ",task_id=": ", task_id=", + "⚠️ 亚家族确认后自动续跑规划失败:": "⚠️ Post-subfamily-confirm auto-plan failed: ", + "亚家族确认已完成,但后端当前 workflowPhase 为 ": "Subfamily confirmation complete; backend workflowPhase is ", + ",未自动打开常规分析。": "; no auto-analysis opened.", + "已调出": "Opened: ", + "亚家族分类确认表没有打开:未找到可确认的分类结果。请先确认该物种已完成 reference_classification。": "Subfamily classification confirmation table not opened: no confirmable results found. Confirm the species has completed reference_classification.", + "拓展分析帮助暂时不可用:": "Extension help temporarily unavailable: ", + "已保存方法学方案,但暂不执行:": "Methodology plan saved; not executing: ", + "🧬 自动背景注释与 ": "🧬 Auto-background annotation and ", + "参考数据已就绪": "Reference data ready", + "Combo 图修订失败:": "Combo plot revision failed: ", + "进化树图重绘失败:": "Phylogeny tree redraw failed: ", + ",其它阶段继续执行。": ", other stages continue.", + " 个阶段,请确认参数": " stages, please confirm parameters", + "—": "—", + "✓ 已恢复并完成。": "✓ Restored and completed.", + "后台批次完成:成功 ": "Background batch complete: success ", + "后台执行阶段: ": "Background stage: ", + "拓展分析阶段执行失败:": "Extension stage execution failed: ", + "拓展分析阶段:": "Extension stage: ", + "已执行拓展分析阶段:": "Extension stage executed: ", + "拓展分析阶段失败:": "Extension stage failed: ", + "MSA区间重绘已提交": "MSA replot submitted", + "MSA区间重绘失败": "MSA replot failed", + "已选择:": "Selected: ", + "📌 当前已有前台批次执行中,新任务已转为后台执行:": "📌 Foreground batch running; new task moved to background: ", + ",已自动插入 Ortholog,将先生成 mapping 再执行": ", Ortholog auto-inserted; generating mapping first then executing", + "推荐参考数据下载完成:": "Reference download complete: ", + "ABORTED": "ABORTED", + "的依赖尚未完成": "dependency not yet completed", + "亚家族标签确认被跳过,已停止依赖 confirmed_subfamily.tsv 的后续阶段;如需普通无亚家族标签分析,请重新发起并明确说明": "Subfamily label confirmation skipped; stages depending on confirmed_subfamily.tsv stopped. For regular analysis without subfamily labels, restart and explicitly state: ", + "不需要亚家族": "no subfamily", + "。": ".", + ",将复用该映射执行 ": ", will reuse this mapping and execute ", + ";将按该参考检查或生成 Ortholog,并执行 ": "; will check or generate Ortholog from this reference, and execute ", + "⚠️ 以下阶段因依赖失败被跳过: ": "⚠️ Stages skipped due to dependency failure: ", + "⚠️ 以下阶段等待上游暂停项处理后继续: ": "⚠️ Stages waiting for upstream paused items: ", + "启动子自定义绘图已提交": "Promoter custom plot submitted", + "启动子自定义绘图失败": "Promoter custom plot failed", + "种内共线性 Circos 重绘已提交": "Intraspecies Circos replot submitted", + "种内共线性 Circos 重绘失败": "Intraspecies Circos replot failed", + " 重新提交(自动注释背景)": " re-submit (auto-annotation background)", + " 已暂停依赖 confirmed_subfamily.tsv": " has paused stages depending on confirmed_subfamily.tsv", + ";": ";", + " 个分析阶段": " analysis stages", + " 个阶段": " stages", + + // ===== 阶段 7:ElMessage/ElMessageBox i18n keys ===== + // 注意:key 为中文原文(zh 模式直接返回 key),value 为 English + // tp() 类 key 需要同时注册变量 placeholder 版本和 static 文案 + + // ElMessageBox + "删除会话": "Delete Session", + "取消 PPI": "Cancel PPI", + "自动注释并继续": "Auto-annotate & Continue", + "我先去上传背景文件": "Upload Background Files First", + "确认设计并写入": "Confirm Design & Write", + "返回修改": "Return to Modify", + "确认第一候选": "Confirm First Candidate", + "使用 bcftools 重试": "Retry with bcftools", + "GATK 执行失败": "GATK Execution Failed", + "确认 DE 分析设计": "Confirm DE Analysis Design", + "确认 PPI 的 STRING 近源参考物种": "Confirm STRING Close Reference Species for PPI", + + // autoAnnotation 标题 + "GO/KEGG 富集背景缺失": "GO/KEGG Enrichment Background Missing", + "KEGG 富集背景缺失": "KEGG Enrichment Background Missing", + "GO 富集背景缺失": "GO Enrichment Background Missing", + "RNA-seq 富集背景缺失": "RNA-seq Enrichment Background Missing", + + // 会话管理 + "示例下载功能当前不可用": "Example download is currently unavailable", + "已Clear当前会话": "Current session cleared", + + // autoFill + "请至少填写一个要预览修改的字段": "Please fill at least one field to preview modifications", + "没有匹配的草稿行": "No matching draft rows", + "请先预览要应用的修改": "Please preview modifications before applying", + "请先生成或加载草稿": "Please generate or load a draft first", + "生成草稿失败": "Failed to generate draft", + "确认失败": "Confirmation failed", + "已写入正式 config.xlsx": "Formal config.xlsx written", + "已写入正式 samples.yaml": "Formal samples.yaml written", + "请输入文件路径": "Please enter file path", + "加载失败": "Load failed", + + // RAG 解读 + "请求解读服务失败": "Interpretation service request failed", + "RAG解读已刷新": "RAG interpretation refreshed", + "RAG解读刷新失败": "RAG interpretation refresh failed", + "下载报告失败": "Failed to download report", + "JSON数据已加载到控制台": "JSON data loaded to console", + "解读内容已复制": "Interpretation content copied", + "复制失败": "Copy failed", + + // 知识图谱 + "知识图谱加载完成": "Knowledge graph loaded", + "未找到匹配的节点": "No matching node found", + "视图已重置": "View reset", + "请先通过问答加载子图,或点击「重新加载」": "Please load subgraph via Q&A first, or click Reload", + + // PPI/STRING + "当前没有可用 job_id": "No job_id currently available", + "该 ID 已存在": "This ID already exists", + "至少保留一个 gene ID": "Keep at least one gene ID", + + // Working Directory + "切换失败": "Switch failed", + + // 图片/可视化 + "请先选择一张图片": "Please select an image first", + "无法确定启动子输出前缀。": "Cannot determine promoter output prefix.", + "请至少选择一个启动子元件或类别。": "Please select at least one promoter element or category.", + "请至少选择一种图类型。": "Please select at least one plot type.", + "无法确定进化树输出标签,请先选择树图或填写输出标签。": "Cannot determine phylogeny tree output label. Please select a tree image or fill in the output label first.", + "进化树图重绘完成,已切换到新图": "Phylogeny tree replot complete; switched to new image", + "无法确定种内共线性输出标签,请先选择 Circos 图。": "Cannot determine intraspecies synteny output label. Please select a Circos image first.", + "无法确定 combo 输出标签,请先选择 combo 图。": "Cannot determine combo output label. Please select a combo image first.", + "Combo 修订至少选择两个类型。": "Combo revision requires at least two panel types.", + "Combo 图修订完成,已切换到新图": "Combo plot revision complete; switched to new image", + "无法确定MSA输出标签,请先选择对应图片或填写输出标签。": "Cannot determine MSA output label. Please select corresponding image or fill in output label.", + "请填写MSA alignment 起始列和终止列,或选择其他模式。": "Please fill in MSA alignment start/end columns or choose another mode.", + "请填写参考基因及其起止残基编号。": "Please fill in reference gene and its start/end residue numbers.", + + // GWAS/诊断 + "请填写 GWAS 物种简称(用于结果前缀)": "Please fill in GWAS species abbreviation (for result prefix)", + "请先选择有效 VCF": "Please select a valid VCF first", + "请先SelectAnalysis Stage": "Please select an analysis stage first", + "诊断未发现明确问题,请查看阶段错误日志。": "Diagnosis found no clear issues. Please check stage error logs.", + "诊断仅提示信息,无需确认修复。": "Diagnosis is informational only; no fix confirmation needed.", + "✅ 已应用修复方案,继续执行...": "✅ Fix applied; continuing execution...", + "❌ 应用修复失败": "❌ Fix application failed", + "❌ 网络错误,请重试": "❌ Network error; please retry", + "⏸ 已跳过修复;该问题会阻断下游,后续阶段已暂停。": "⏸ Fix skipped; issue blocks downstream; subsequent stages paused.", + "已关闭诊断提示。": "Diagnosis prompt closed.", + + // 任务/消息 + "当前消息未成功写入会话历史,刷新后可能丢失": "Message not persisted to session history; may be lost on refresh", + "路径已复制": "Path copied", + + // 拓展分析 + "当前筛选没有成员,未重建 final_members。": "No members matching current filter; final_members not rebuilt.", + "当前选择为按物种分别分析,但后端没有返回完整的分物种亚家族结果;将尝试打开可用的联合结果。": "Per-species analysis selected but backend returned incomplete results; attempting to open available joint results.", + "亚家族预测已完成,但未能自动打开确认表;请发送\"调出亚家族确认表\"手动打开。": "Subfamily prediction completed but confirmation table could not open automatically; please send \"show subfamily review table\" manually.", + "没有可确认的亚家族标签。": "No subfamily labels to confirm.", + "已请求停止当前拓展分析;状态仍会继续轮询直到进程退出。": "Stop requested for current extension; polling will continue until process exits.", + "No running task to stop": "No running task to stop", + "Task(s) stopped (backend + Celery when available)": "Task(s) stopped (backend + Celery when available)", + "File uploaded successfully": "File uploaded successfully", + "未检测到可用文件": "No usable files detected", + "请先生成拓展分析阶段。": "Please generate the extension stage first.", + "发布前验收已通过": "Pre-publish validation passed", + "请填写名称,并上传原始代码。": "Please provide name and upload source code.", + "拓展分析阶段已生成并发布": "Extension stage generated and published", + "确认已保存": "Confirmation saved", + "验证成功": "Validation successful", + "验证未通过": "Validation not passed", + "拓展分析阶段已发布": "Extension stage published", + "拓展分析阶段执行完成": "Extension stage execution complete", + "拓展分析阶段执行未成功,请查看结果日志": "Extension stage execution incomplete; please check result logs", + "该阶段已在正式列表中": "This stage is already in the formal list", + "已加入正式阶段列表,并默认纳入本次执行": "Added to formal stage list and included in this execution", + + // 方法学 + "请先选择 PDF 或 MinerU content_list JSON 文献文件": "Please select a PDF or MinerU content_list JSON literature file first", + "方法学提取启动失败": "Methodology extraction failed to start", + "方法学 sandbox 示例读取失败": "Failed to read methodology sandbox examples", + "请先选择或输入 methodology_dir": "Please select or enter methodology_dir first", + "方法学 sandbox 已构建": "Methodology sandbox built", + "请先构建临时库": "Please build the sandbox first", + "请输入方法学问题": "Please enter methodology question", + "方法学 sandbox 查询失败": "Methodology sandbox query failed", + "方法学方案已保存,可先准备数据,稍后再按该方案执行": "Methodology plan saved; you may prepare data first and execute later", + "方法学方案保存失败": "Failed to save methodology plan", + "当前已有批量任务在执行,请等待完成后再提交方法学流程": "A batch task is currently running; please wait for completion before submitting methodology workflow", + "请至少选择一个可执行的 bbav5 阶段": "Please select at least one executable bbav5 stage", + "亚家族参考物种未写入当前 job 配置,已生成方法学本地计划,请在批量确认页核对参考物种": "Subfamily reference species not written to current job config; local methodology plan generated. Please verify reference species on batch confirmation page.", + "方法学执行规划失败": "Methodology execution planning failed", + + // Other + "请Input内容": "Please enter content", + "请先选择或创建 Working Directory": "Please select or create a Working Directory first", + "规划请求失败": "Plan request failed", + "重新绘图功能开发中...": "Replot feature is under development...", + + // ===== tp() keys — 使用完整中文模板作为 key ===== + "确定删除会话「{title}」吗?该操作会从列表中移除,但保留后台记录。": "Delete session \"{title}\"? This removes it from the list but keeps backend records.", + "切换到: {title}": "Switched to: {title}", + "预览 {rows} 行:{fields}。确认无误后点击 Apply。": "Preview {rows} rows: {fields}. Click Apply once confirmed.", + "已应用 {rows} 行修改。": "Applied {rows} row modifications.", + "已取消预览,表格已恢复到预览前。": "Preview cancelled, table restored.", + "链特异性为必填:还有 {count} 个样本未填写({examples})": "Library strandness required: {count} sample(s) unfilled ({examples})", + "设计提示:{error};将保留原流程的兼容处理。": "Design tip: {error}; keeping original compatible handling.", + "已加载 {count} files": "Loaded {count} files", + "加载图谱失败: {message}": "Graph load failed: {message}", + "找到节点: {name}": "Node found: {name}", + "读取表格失败: {reason}": "Table read failed: {reason}", + "PPI Reset 已完成: {reset_id}": "PPI Reset complete: {reset_id}", + "STRING 链接文件内容:{preview}": "STRING link file content: {preview}", + "读取文件失败: {message}": "File read failed: {message}", + "已创建新Working Directory: {job_id}": "New Working Directory created: {job_id}", + "创建失败: {message}": "Creation failed: {message}", + "获取Working Directory列表失败: {message}": "Failed to get Working Directory list: {message}", + "已切换到Working Directory: {job_id}": "Switched to Working Directory: {job_id}", + "切换失败: {message}": "Switch failed: {message}", + "进化树图重绘失败:{message}": "Phylogeny tree replot failed: {message}", + "Combo 图修订失败:{message}": "Combo plot revision failed: {message}", + "任务: {message}\n状态: {status}\n进度: {progress}%": "Task: {message}\nStatus: {status}\nProgress: {progress}%", + "停止拓展分析失败:{message}": "Failed to stop extension: {message}", + "已选择 {count} 个原始代码文件": "{count} code file(s) selected", + "已选择 {count} 个测试数据文件": "{count} test data file(s) selected", + "已拖入 {count} 个文件,已自动填充拓展包": "{count} file(s) dropped; extension package auto-filled", + "❌ {message}": "❌ {message}", + "Upload complete. Latest Excel is now used as config.xlsx.{warningText}": "Upload complete. Latest Excel is now used as config.xlsx.{warningText}", + "{title},需要自动注释": "{title}, Auto-annotation Required", + + // String close species (tp with multi-line) + "目标物种不在 STRING 固定快照中。请确认是否采用排序第一的近源物种:": "Target species not in STRING fixed snapshot. Confirm using the top-ranked close species:", + "确认后才会下载参考 PEP、生成 Ortholog,并执行 PPI。": "Reference PEP will be downloaded, Ortholog generated, and PPI executed only after confirmation.", + "是否切换为\"自动注释 + 继续富集\"?": "Switch to \"Auto-annotation + Continue Enrichment\"?" +}; + diff --git a/gfa_core/extension_stage_lightweight/api.py b/gfa_core/extension_stage_lightweight/api.py index 09e5927c4..54927cdce 100644 --- a/gfa_core/extension_stage_lightweight/api.py +++ b/gfa_core/extension_stage_lightweight/api.py @@ -361,6 +361,7 @@ def execute_queued_operation(payload: dict[str, Any]) -> dict[str, Any]: settings = LightweightApiSettings.from_environment() action = str(payload["action"]) + runtime_env = str(payload.get("runtime_env", "test4py311")) extension_id = _safe_component(str(payload["extension_id"]), name="extension_id") token = _safe_component(str(payload["operation_token"]), name="operation_token") _set_receipt(settings, token, state="running", status="running") @@ -373,6 +374,7 @@ def execute_queued_operation(payload: dict[str, Any]) -> dict[str, Any]: raw_code=stage / "raw_code", raw_data=stage / "raw_test_data", original_command=str(payload["original_command"]), + runtime_env=runtime_env, operation_token=token, ) elif action == "rebuild": @@ -479,6 +481,7 @@ def create_lightweight_extension_router( raw_code_files: list[UploadFile] | None = File(None), raw_data_files: list[UploadFile] | None = File(None), raw_data_paths: str | None = Form(None), + runtime_env: str = Form("test4py311"), ) -> dict[str, Any]: temporary_stage: Path | None = None try: @@ -555,6 +558,7 @@ def create_lightweight_extension_router( "action": "upload", "operation_token": token, "original_command": normalized_command, + "runtime_env": runtime_env, } ) except BaseException as exc: diff --git a/gfa_core/extension_stage_lightweight/service.py b/gfa_core/extension_stage_lightweight/service.py index 8c457dae5..903a4eef4 100644 --- a/gfa_core/extension_stage_lightweight/service.py +++ b/gfa_core/extension_stage_lightweight/service.py @@ -109,6 +109,18 @@ class LightweightExtensionService: def get(self, extension_id: str) -> dict[str, Any]: return self._read_record(extension_id) + def _make_env(self, runtime_env: str) -> dict[str, str]: + """Build a process env that activates the selected conda environment.""" + + conda_prefix = f"/home/xzq/miniconda3/envs/{runtime_env}" + conda_bin = f"{conda_prefix}/bin" + env = dict(self.environment or {}) + existing_path = env.get("PATH", os.environ.get("PATH", "")) + env["PATH"] = f"{conda_bin}:{existing_path}" if existing_path else conda_bin + env["CONDA_PREFIX"] = conda_prefix + env["CONDA_DEFAULT_ENV"] = runtime_env + return env + def _deepseek_candidate(self, facts: dict[str, Any], raw_code: Path) -> tuple[dict[str, Any], dict[str, Any]]: return generate_semantic_candidate( execution_facts=facts, raw_code=raw_code, environ=self.environment @@ -324,12 +336,13 @@ class LightweightExtensionService: build: dict[str, Any] | None = None failure: tuple[str, BaseException | str] | None = None try: + runtime_env = str(record.get("runtime_env") or "test4py311") evidence = run_raw_baseline( command=record["original_command"], raw_code=root / "raw_code", raw_data=root / "raw_test_data", run_dir=root / "baseline" / run_id, - env=self.environment, + env=self._make_env(runtime_env), on_process_start=lambda identity: self._mark_process( extension_id, token=token, phase="baseline", identity=identity ), @@ -365,6 +378,7 @@ class LightweightExtensionService: raw_code: Path, raw_data: Path, original_command: str, + runtime_env: str = "test4py311", operation_token: str, ) -> dict[str, Any]: raw_code, raw_data = Path(raw_code), Path(raw_data) @@ -394,6 +408,7 @@ class LightweightExtensionService: "schema_version": "lightweight-extension-service/v1", "extension_id": extension_id, "original_command": original_command, + "runtime_env": runtime_env, "status": "baseline_running", "operations": { operation_token: { @@ -605,9 +620,10 @@ class LightweightExtensionService: rendered = render_runtime_package( record["runtime_binding_contract"], root / "raw_code", resolved, run_dir ) + runtime_env = str(record.get("runtime_env") or "test4py311") result = replay_runtime_binding( rendered, - env=self.environment, + env=self._make_env(runtime_env), on_process_start=lambda identity: self._mark_process( record["extension_id"], token=operation_token, phase=phase, identity=identity ), diff --git a/gfa_core/gene_family_report.py b/gfa_core/gene_family_report.py new file mode 100644 index 000000000..becc12d30 --- /dev/null +++ b/gfa_core/gene_family_report.py @@ -0,0 +1,383 @@ +"""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.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 + + +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 + html_path: Path | None = None + + +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)^\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_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"]} + rendered: list[str] = [] + for section_id, block in _template_blocks(): + if section_id not in section_data: + continue + 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 不可用或输出越过语言编排边界;报告事实仍来自确定性结果数据。" + 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" + 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, + 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, + html_path=html_path, + ) + + 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", + } + + 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, + "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], + "report_renderer_version": REPORT_RENDERER_VERSION, + "embedded_figure_count": sum(len(items) for items in figures_by_stage.values()), + }, + "model": model_metadata, + "artifacts": { + "gene_family_report": { + "path": f"final/{report_path.name}", + "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), + "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, + html_path=html_path, + ) diff --git a/gfa_core/gene_family_report_data.py b/gfa_core/gene_family_report_data.py new file mode 100644 index 000000000..108a94ae9 --- /dev/null +++ b/gfa_core/gene_family_report_data.py @@ -0,0 +1,767 @@ +"""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=(), + ), + 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), + ) 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/mas/agents/conversation_tools.py b/gfa_core/mas/agents/conversation_tools.py index 7ce006f7b..42bd2c9b5 100644 --- a/gfa_core/mas/agents/conversation_tools.py +++ b/gfa_core/mas/agents/conversation_tools.py @@ -23,10 +23,7 @@ from gfa_core.mas.mutation_surface import ToolSurfaceEntry from gfa_core.mas.stage_capabilities import get_stage_capability from gfa_core.mas.tools.stage_status import SUCCESS_STATUSES from gfa_core.mas.tools.stage_tools import stage_registry -from gfa_core.mas.workflow_catalog import ( - load_canonical_workflow_contract, - load_rnaseq_business_context, -) +from gfa_core.mas.workflow_catalog import load_rnaseq_business_context from gfa_core.mas.workflow_models import GoalLedger @@ -56,6 +53,7 @@ class ConversationToolRegistry: *, workflow_id: str | None = None, guidance: bool = False, + language: str = "zh", ) -> str: text = str(query or "").strip() if self._analysis_handoff_resolver is None: @@ -65,6 +63,7 @@ class ConversationToolRegistry: text, workflow_id=workflow_id, guidance=guidance, + language=language, ) or "" ).strip() @@ -267,6 +266,7 @@ class RecognizeAnalysisIntentArgs(BaseModel): query: str job_id: str = "" prefix: str = "" + language: str = "zh" _job_id_is_local = field_validator("job_id")(_validate_job_id) _prefix_is_literal = field_validator("prefix")(_validate_prefix) @@ -1607,9 +1607,11 @@ def build_plantai_conversation_tools( *, workflow_id: str | None = None, guidance: bool = False, + language: str = "zh", ) -> str: """Adapt MainAgent memory to the original IntentAgent text contract.""" text = str(query or "").strip() + en = language == "en" if ( text.startswith("执行数据准备(genome_prepare)") and "当前业务动作已由数据准备契约唯一确定" in text @@ -1619,15 +1621,15 @@ def build_plantai_conversation_tools( return text terms = goal_ledger.user_terms context: list[str] = [] - canonical_request = "" family = str(terms.get("gene_family") or "").strip() if family: - context.append(f"基因家族 {family}") - for key, label in ( - ("target_species", "目标物种"), - ("reference_species", "参考物种"), - ("comparative_species", "比较物种"), + context.append(f"Gene family: {family}" if en else f"基因家族 {family}") + for key, label_zh, label_en in ( + ("target_species", "目标物种", "Target species"), + ("reference_species", "参考物种", "Reference species"), + ("comparative_species", "比较物种", "Comparative species"), ): + label = label_en if en else label_zh values = terms.get(key) or () if isinstance(values, (list, tuple)): cleaned = [ @@ -1647,21 +1649,39 @@ def build_plantai_conversation_tools( if mode == "whole_genome": enabled_designs = "、".join(business_context["enabled_designs"]) disabled_designs = "、".join(business_context["disabled_designs"]) - context.append( - f"RNA-seq业务模式 全基因组;{experiment_design} 是实验设计权威输入;" - f"可解释设计为 {enabled_designs},{disabled_designs} 本轮禁用;" - "设计或 batch 降级不得静默发生" - ) + if en: + context.append( + f"RNA-seq mode: whole genome; {experiment_design} is the authoritative experimental design input; " + f"interpretable designs: {enabled_designs}, {disabled_designs} disabled this round; " + "design or batch downgrades must not happen silently" + ) + else: + context.append( + f"RNA-seq业务模式 全基因组;{experiment_design} 是实验设计权威输入;" + f"可解释设计为 {enabled_designs},{disabled_designs} 本轮禁用;" + "设计或 batch 降级不得静默发生" + ) else: - context.append( - "RNA-seq业务模式 基因家族成员;使用已成功的全基因组差异表达结果" - "和精确确认的基因家族成员进行投影;不得自动选择首个成员文件," - "不得隐式重跑差异表达" - ) + if en: + context.append( + "RNA-seq mode: gene family member; use successful whole-genome differential expression results " + "and precisely confirmed gene family members for projection; must not auto-select the first member file, " + "must not implicitly re-run differential expression" + ) + else: + context.append( + "RNA-seq业务模式 基因家族成员;使用已成功的全基因组差异表达结果" + "和精确确认的基因家族成员进行投影;不得自动选择首个成员文件," + "不得隐式重跑差异表达" + ) elif rna_route == "whole_genome": - context.append("RNA-seq模式 全基因组") + context.append( + "RNA-seq mode: whole genome" if en else "RNA-seq模式 全基因组" + ) elif rna_route == "family_member": - context.append("RNA-seq模式 基因家族成员") + context.append( + "RNA-seq mode: gene family member" if en else "RNA-seq模式 基因家族成员" + ) job_id = bound_job_id or str(terms.get("active_job_id") or "").strip() formal_config_exists = bool( job_id and (project_root / "input" / job_id / "config.xlsx").is_file() @@ -1671,7 +1691,6 @@ def build_plantai_conversation_tools( or "config.xlsx 已确认" in goal_ledger.approved_decisions or formal_config_exists ) - genome_prepare_observed = False subfamily_confirm_observed = False if job_id: final_dir = project_root / "workspace" / job_id / "final" @@ -1682,8 +1701,6 @@ def build_plantai_conversation_tools( continue stage = str(manifest.get("stage") or "").strip() status = manifest.get("status") - if stage == "genome_prepare" and status in {"success", "partial_success"}: - genome_prepare_observed = True manifest_prefix = str(manifest.get("prefix") or "").strip() family_matches = not family or bool( re.search( @@ -1698,11 +1715,20 @@ def build_plantai_conversation_tools( and family_matches ): subfamily_confirm_observed = True + genome_prepare_observed = False + canonical_request = None if config_confirmed and not genome_prepare_observed: - context.append( - "正式 config.xlsx 已确认;这只表示配置已核对,不等于数据准备已经完成," - "当前 job 尚无数据准备完成证据;具体分析意图和阶段仍由现有分析系统判断" - ) + if en: + context.append( + "Formal config.xlsx confirmed; this indicates configuration has been verified, " + "not that data preparation is complete. No data preparation completion evidence in current job; " + "specific analysis intent and stages still determined by the existing analysis system" + ) + else: + context.append( + "正式 config.xlsx 已确认;这只表示配置已核对,不等于数据准备已经完成," + "当前 job 尚无数据准备完成证据;具体分析意图和阶段仍由现有分析系统判断" + ) if guidance and workflow_id in { "data_preparation", "gene_family", @@ -1717,19 +1743,34 @@ def build_plantai_conversation_tools( ) actions = {item.id: item for item in contract.actions} target_stage = actions[transition.action_id].target_stage - canonical_request = ( - f"执行数据准备({target_stage})\n" - "当前业务动作已由数据准备契约唯一确定" - ) + if en: + canonical_request = ( + f"Execute data preparation ({target_stage})\n" + "Business action uniquely determined by data preparation contract" + ) + else: + canonical_request = ( + f"执行数据准备({target_stage})\n" + "当前业务动作已由数据准备契约唯一确定" + ) if family and subfamily_confirm_observed: - context.append( - "当前 job 的 subfamily_confirm 已成功完成,家族成员和亚家族分类表已确认," - "这是当前项目状态事实;具体分析意图和阶段仍由现有分析系统判断" - ) + if en: + context.append( + "Current job's subfamily_confirm completed successfully; " + "family members and subfamily classification table confirmed. " + "This is a current project state fact; specific analysis intent and stages still determined by the existing analysis system" + ) + else: + context.append( + "当前 job 的 subfamily_confirm 已成功完成,家族成员和亚家族分类表已确认," + "这是当前项目状态事实;具体分析意图和阶段仍由现有分析系统判断" + ) if canonical_request: return canonical_request if not context: return text + if en: + return f"{text}\nConfirmed context: {'; '.join(context)}." return f"{text}\n已确认上下文:{';'.join(context)}。" registry.set_analysis_handoff_resolver(intent_query_with_confirmed_context) @@ -1746,7 +1787,7 @@ def build_plantai_conversation_tools( receiver="intent_v2", message_type="recognize_intent", payload={ - "query": intent_query_with_confirmed_context(args.query), + "query": intent_query_with_confirmed_context(args.query, language=args.language), "job_id": job_id, "prefix": args.prefix, "workspace": str(project_root / "workspace" / job_id) if job_id else "", diff --git a/gfa_core/mas/agents/diagnosis.py b/gfa_core/mas/agents/diagnosis.py index 98575181d..0376de93e 100755 --- a/gfa_core/mas/agents/diagnosis.py +++ b/gfa_core/mas/agents/diagnosis.py @@ -390,48 +390,57 @@ class DiagnosisAgent(BaseAgent): ERROR_PATTERNS = { "missing_input_file": { "patterns": [r"文件不存在", r"FileNotFoundError", r"No such file", r"找不到文件"], - "suggestion": "检查输入文件路径是否正确,确保文件已上传到input目录", + "suggestion_zh": "检查输入文件路径是否正确,确保文件已上传到input目录", + "suggestion_en": "Check if input file path is correct; ensure files are uploaded to the input directory", "auto_fix": False }, "hmm_not_found": { "patterns": [r"required_pfams is empty", r"No HMM file", r"找不到HMM"], - "suggestion": "请上传HMM模型文件或指定--required-pfams参数", + "suggestion_zh": "请上传HMM模型文件或指定--required-pfams参数", + "suggestion_en": "Please upload HMM model file or specify --required-pfams parameter", "auto_fix": False }, "blast_db_error": { "patterns": [r"BLAST Database error", r"makeblastdb failed"], - "suggestion": "Identify BLAST 固定用目标物种全蛋白 FASTA 建库;请优先检查目标全蛋白 FASTA 是否存在、非空且为有效蛋白FASTA格式,再检查参考家族蛋白 query 文件。", + "suggestion_zh": "Identify BLAST 固定用目标物种全蛋白 FASTA 建库;请优先检查目标全蛋白 FASTA 是否存在、非空且为有效蛋白FASTA格式,再检查参考家族蛋白 query 文件。", + "suggestion_en": "Identify BLAST uses target species full protein FASTA for database; check that target FASTA exists, is non-empty and valid, then check reference family protein query file.", "auto_fix": False }, "memory_error": { "patterns": [r"MemoryError", r"out of memory", r"内存不足"], - "suggestion": "减少--cpu参数值或分批处理,建议使用--cpu 4", + "suggestion_zh": "减少--cpu参数值或分批处理,建议使用--cpu 4", + "suggestion_en": "Reduce --cpu parameter or use batch processing; recommend --cpu 4", "auto_fix": True, "fix_action": "reduce_cpu" }, "rnaseq_library_strandness_error": { "patterns": [r"library_strandness", r"链特异性", r"rna-strandness", r"featureCounts.*-s"], - "suggestion": "请在 samples.yaml 的 rnaseq.library_strandness 或每个 samples..library_strandness 中明确填写 non_stranded / forward / reverse;混合链特异性样本当前不能用单次 featureCounts 定量。", + "suggestion_zh": "请在 samples.yaml 的 rnaseq.library_strandness 或每个 samples..library_strandness 中明确填写 non_stranded / forward / reverse;混合链特异性样本当前不能用单次 featureCounts 定量。", + "suggestion_en": "Please specify non_stranded / forward / reverse in samples.yaml rnaseq.library_strandness or each samples..library_strandness; mixed strand-specific samples cannot be quantified in a single featureCounts run.", "auto_fix": False }, "timeout_error": { "patterns": [r"Timeout", r"timed out", r"超时"], - "suggestion": "增加超时时间或检查输入数据大小", + "suggestion_zh": "增加超时时间或检查输入数据大小", + "suggestion_en": "Increase timeout or check input data size", "auto_fix": False }, "conda_env_error": { "patterns": [r"conda", r"Environment", r"找不到命令"], - "suggestion": "检查conda环境是否正确激活,确保工具已安装", + "suggestion_zh": "检查conda环境是否正确激活,确保工具已安装", + "suggestion_en": "Check that conda environment is activated and tools are installed", "auto_fix": False }, "permission_error": { "patterns": [r"Permission denied", r"权限不足"], - "suggestion": "检查文件权限,确保有读写权限", + "suggestion_zh": "检查文件权限,确保有读写权限", + "suggestion_en": "Check file permissions; ensure read/write access", "auto_fix": False }, "invalid_parameter": { "patterns": [r"unrecognized arguments", r"invalid parameter", r"参数错误"], - "suggestion": "检查参数名称是否正确,注意使用短横线而非下划线", + "suggestion_zh": "检查参数名称是否正确,注意使用短横线而非下划线", + "suggestion_en": "Check parameter name; use hyphens instead of underscores", "auto_fix": True, "fix_action": "fix_parameter_format" }, @@ -448,7 +457,8 @@ class DiagnosisAgent(BaseAgent): r"RuntimeError: MAFFT失败.*No such file", r"RuntimeError:.*失败.*No such file or directory" ], - "suggestion": "生物信息学工具未安装或未添加到PATH。建议:1) 激活conda环境: conda activate test4py311 2) 安装缺失工具: conda install -c bioconda mafft blast iqtree hmmer", + "suggestion_zh": "生物信息学工具未安装或未添加到PATH。建议:1) 激活conda环境: conda activate test4py311 2) 安装缺失工具: conda install -c bioconda mafft blast iqtree hmmer", + "suggestion_en": "Bioinformatics tools not installed or not in PATH. Suggestions: 1) activate conda environment: conda activate test4py311 2) install missing tools: conda install -c bioconda mafft blast iqtree hmmer", "auto_fix": False, "severity": "high" }, @@ -461,7 +471,8 @@ class DiagnosisAgent(BaseAgent): r"requires.*stage.*not completed", r"dependency.*not satisfied" ], - "suggestion": "前置分析阶段未完成,系统将自动先执行依赖阶段", + "suggestion_zh": "前置分析阶段未完成,系统将自动先执行依赖阶段", + "suggestion_en": "Prerequisite analysis stage not completed; system will execute dependency stage first automatically", "auto_fix": True, "fix_action": "execute_dependencies", "severity": "high" @@ -601,6 +612,7 @@ class DiagnosisAgent(BaseAgent): prepare_patch_workspace: bool = False, scope_id: str = "", params: Optional[Dict[str, Any]] = None, + language: str = "zh", ) -> Dict[str, Any]: """只读审计某个 job/stage 的产物、manifest、日志和输入配置。 @@ -930,6 +942,7 @@ class DiagnosisAgent(BaseAgent): gate: str = "preflight", project_root: Path = PROJECT_ROOT, params: Optional[Dict[str, Any]] = None, + language: str = "zh", ) -> Dict[str, Any]: """Global QA gate for stage preflight/postflight/failure. @@ -4490,10 +4503,10 @@ class DiagnosisAgent(BaseAgent): except Exception as exc: print(f"[DiagnosisAgent] Failed to remember LLM diagnosis case: {exc}") - def _match_error_pattern(self, error_text: str) -> Optional[Dict]: + def _match_error_pattern(self, error_text: str, language: str = "zh") -> Optional[Dict]: """匹配错误模式""" error_lower = error_text.lower() - + priority_order = [ "tool_not_found", "dependency_failed", @@ -4507,15 +4520,16 @@ class DiagnosisAgent(BaseAgent): "permission_error", "timeout_error" ] - + for error_type in priority_order: if error_type in self.ERROR_PATTERNS: pattern_info = self.ERROR_PATTERNS[error_type] for pattern in pattern_info["patterns"]: if re.search(pattern, error_text, re.IGNORECASE): + suggestion_key = "suggestion_en" if language == "en" else "suggestion_zh" return { "error_type": error_type, - "suggestion": pattern_info["suggestion"], + "suggestion": pattern_info.get(suggestion_key, pattern_info.get("suggestion_zh", "")), "auto_fix": pattern_info.get("auto_fix", False), "fix_action": pattern_info.get("fix_action"), "severity": pattern_info.get("severity", "medium") diff --git a/gfa_core/mas/agents/intent_v2.py b/gfa_core/mas/agents/intent_v2.py index 334c8a33a..edb243612 100755 --- a/gfa_core/mas/agents/intent_v2.py +++ b/gfa_core/mas/agents/intent_v2.py @@ -4,7 +4,7 @@ IntentAgent V2 - LLM驱动的智能意图识别 """ import json import re -from typing import Dict, List, Optional, Any +from typing import Any, Dict, List, Mapping, Optional from .base import AgentMessage, AgentState, BaseAgent, QueryMode from gfa_core.mas.llm import LLMManager from gfa_core.mas.stage_catalog import ( @@ -152,6 +152,82 @@ class IntentAgentV2(BaseAgent): def __init__(self, llm_manager: LLMManager): super().__init__(llm_manager) + + @staticmethod + def _apply_structured_goal_context( + intent: Dict[str, Any], + goal_context: Mapping[str, Any] | None, + ) -> Dict[str, Any]: + if not isinstance(goal_context, Mapping): + return intent + terms = goal_context.get("user_terms") + if not isinstance(terms, Mapping): + return intent + + def species_list(value: Any) -> List[str]: + if isinstance(value, str): + values = value.split(",") + elif isinstance(value, (list, tuple, set)): + values = value + else: + values = () + return list( + dict.fromkeys(str(item).strip() for item in values if str(item).strip()) + ) + + normalized = dict(intent) + params = dict(normalized.get("params") or {}) + targets = species_list( + params.get("targetSpecies") or normalized.get("target_species") + ) or species_list(terms.get("target_species")) + references = species_list( + params.get("referenceSpecies") or normalized.get("reference_species") + ) or species_list(terms.get("reference_species")) + comparatives = species_list( + params.get("comparativeSpecies") or normalized.get("comparative_species") + ) or species_list(terms.get("comparative_species")) + references = [species for species in references if species not in targets] + comparatives = [ + species + for species in comparatives + if species not in targets and species not in references + ] + family = str( + normalized.get("gene_family") or terms.get("gene_family") or "" + ).strip() + scope_mode = ( + "target_plus_reference_plus_comparative" + if references and comparatives + else "target_plus_reference" + if references + else "target_plus_comparative" + if comparatives + else "target_only" + ) + params.update( + { + "targetSpecies": targets, + "referenceSpecies": references, + "comparativeSpecies": comparatives, + "target_species": targets, + "scopeMode": scope_mode, + } + ) + normalized.update( + { + "params": params, + "gene_family": family, + "target_species": targets, + "reference_species": references, + "comparative_species": comparatives, + "scopeMode": scope_mode, + } + ) + if targets: + normalized["species"] = targets[0] if len(targets) == 1 else "Multi" + if normalized.get("species") and family: + normalized["prefix"] = f"{normalized['species']}.{family}" + return normalized async def execute(self, state: AgentState, message: AgentMessage) -> AgentState: """ @@ -237,6 +313,11 @@ class IntentAgentV2(BaseAgent): print(f"[IntentAgentV2] Protected species from parser: {base_intent['species']}") intent_result.setdefault("task_kind", "pipeline") intent_result.setdefault("taskType", intent_result.get("task_kind", "pipeline")) + + intent_result = self._apply_structured_goal_context( + intent_result, + message.payload.get("goal_context"), + ) # 保留原始 stages,不过滤(留给 PlanningAgentV2 处理) original_stages = intent_result.get("stages", []) @@ -693,8 +774,17 @@ class IntentAgentV2(BaseAgent): intent_data["stages"] = ( ["rnaseq_de", "rnaseq_go_enrich", "rnaseq_kegg_enrich", "rnaseq_fgsea", "rnaseq_wgcna"] if has_rnaseq_keyword - else ["identify"] + else [] ) + if not intent_data["stages"]: + intent_data.update( + { + "description": "未能可靠识别要执行的分析阶段", + "needs_clarification": True, + "clarification_type": "analysis_stage", + "clarification_question": "请说明要执行的具体分析阶段", + } + ) # 根据依赖关系补充缺失的阶段(关键修复:确保依赖展开) original_stages = intent_data["stages"] @@ -975,11 +1065,8 @@ class IntentAgentV2(BaseAgent): has_rnaseq_keyword = any( kw in query_lower for kw in ["rnaseq", "rna-seq", "rna seq", "转录组", "fastq"] ) - stages = ( - ["rnaseq_de", "rnaseq_go_enrich", "rnaseq_kegg_enrich", "rnaseq_fgsea", "rnaseq_wgcna"] - if has_rnaseq_keyword - else ["identify"] - ) + if has_rnaseq_keyword: + stages = ["rnaseq_de", "rnaseq_go_enrich", "rnaseq_kegg_enrich", "rnaseq_fgsea", "rnaseq_wgcna"] # 补充依赖(使用 stage_catalog 的主规划依赖链) original_stages = stages.copy() @@ -989,18 +1076,31 @@ class IntentAgentV2(BaseAgent): gene_family = self._extract_gene_family_fallback(query) species = "Multi" if any(kw in query.lower() for kw in ["基因家族成员鉴定", "基因家族鉴定", "identify"]) else "" - return { + result = { "gene_family": gene_family, "species": species, "prefix": f"{species}.{gene_family}", "stages": stages, - "description": f"执行{gene_family}基因家族的{', '.join(stages)}分析", + "description": ( + f"执行{gene_family}基因家族的{', '.join(stages)}分析" + if stages + else "未能可靠识别要执行的分析阶段" + ), "requirements": [], - "confidence": 0.7, + "confidence": 0.7 if stages else 0.0, "task_kind": "pipeline", "taskType": "pipeline", "mode": "fallback" } + if not stages: + result.update( + { + "needs_clarification": True, + "clarification_type": "analysis_stage", + "clarification_question": "请说明要执行的具体分析阶段", + } + ) + return result def create_intent_agent_v2(llm_manager): diff --git a/gfa_core/mas/agents/interpretation_evidence.py b/gfa_core/mas/agents/interpretation_evidence.py index fee2edf27..d85442f36 100755 --- a/gfa_core/mas/agents/interpretation_evidence.py +++ b/gfa_core/mas/agents/interpretation_evidence.py @@ -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 diff --git a/gfa_core/mas/agents/router.py b/gfa_core/mas/agents/router.py index 36875fdfa..2bc665706 100755 --- a/gfa_core/mas/agents/router.py +++ b/gfa_core/mas/agents/router.py @@ -54,6 +54,7 @@ class RouterAgent(BaseAgent): "history": history, "session_id": str(payload.get("session_id") or state.session_id), "job_id": str(payload.get("job_id") or state.job_id), + "language": str(payload.get("language") or "zh"), } if "goal_ledger" in payload: runtime_context["goal_ledger"] = payload.get("goal_ledger") diff --git a/gfa_core/mas/agents/router_runtime.py b/gfa_core/mas/agents/router_runtime.py index d19bbcb8f..33e2137ee 100644 --- a/gfa_core/mas/agents/router_runtime.py +++ b/gfa_core/mas/agents/router_runtime.py @@ -11,11 +11,13 @@ from pydantic import BaseModel, Field, ValidationError, field_validator, model_v from gfa_core.mas.agents.conversation_tools import ConversationToolRegistry from gfa_core.mas.llm import LLMManager, LLMMessage from gfa_core.mas.persistent_conversation import is_short_acknowledgement +from gfa_core.mas.tools.stage_tools import stage_registry +from gfa_core.mas.utils.intent_parser import CORE_STAGES from gfa_core.mas.workflow_catalog import ( load_canonical_workflow_contract, load_rnaseq_business_context, ) -from gfa_core.mas.workflow_models import GoalLedger +from gfa_core.mas.workflow_models import CurrentRecommendation, GoalLedger InteractionKind = Literal[ @@ -39,6 +41,7 @@ class RouterOutcome(BaseModel): tool_trace: list[dict[str, Any]] = Field(default_factory=list) interaction_kind: InteractionKind = "independent" proposal: dict[str, str] | None = None + recommendation: CurrentRecommendation | None = None input_paths: list[str] = Field(default_factory=list) ui_action: Literal["open_config_dialog"] | None = None @@ -73,6 +76,7 @@ class RouteDecision(BaseModel): interaction_kind: InteractionKind = "independent" workflow_id: WorkflowId | None = None analysis_request_kind: AnalysisRequestKind | None = None + uses_current_recommendation: bool = False confirms_pending_proposal: bool = False @model_validator(mode="before") @@ -88,6 +92,21 @@ class RouteDecision(BaseModel): normalized["workflow_id"] = None return normalized + @model_validator(mode="before") + @classmethod + def discard_analysis_metadata_for_non_analysis_goal(cls, value: Any) -> Any: + if not isinstance(value, Mapping) or value.get("goal") == "new_analysis": + return value + if ( + value.get("analysis_request_kind") is None + and value.get("uses_current_recommendation") in {None, False} + ): + return value + normalized = dict(value) + normalized["analysis_request_kind"] = None + normalized["uses_current_recommendation"] = False + return normalized + @field_validator("clarification_question", mode="before") @classmethod def normalize_blank_clarification(cls, value: Any) -> Any: @@ -131,6 +150,12 @@ class RouteDecision(BaseModel): ) if self.analysis_request_kind is not None and self.goal != "new_analysis": raise ValueError("analysis_request_kind requires goal=new_analysis") + if self.uses_current_recommendation and ( + self.goal != "new_analysis" or self.analysis_request_kind != "execution" + ): + raise ValueError( + "uses_current_recommendation requires an execution new_analysis" + ) if ( self.clarification_question is not None and not self.clarification_question.strip() @@ -148,6 +173,91 @@ def _extract_authorized_input_paths(query: str) -> list[str]: paths.append(path) return paths +_REGULAR_GENE_FAMILY_STAGES = tuple( + stage for stage in CORE_STAGES if stage != "identify" +) + +_GENE_FAMILY_CONVERSATION_PROFILE = f""" +Gene-family business vocabulary: complete gene-family analysis has two parts: +亚家族分类与确认(前置) and 常规基因家族分析(主体). For a broad product question +such as “基因家族有哪些分析步骤?”, First answer with these two parts only and ask +which part the user wants expanded as an optional invitation, not a required choice. This +question is complete and unambiguous: use goal=general with needs=[] and action=reply; +never clarify. Do not enumerate any individual stage in that first answer, even when the +broad question contains “步骤” or “有哪些”. If the user then asks for details, use +conversation history to expand the referenced part. The subfamily chain is family-member +identification, phylogeny/motif/domain/structure/combo evidence, reference classification, +and human confirmation. The regular analysis catalog is +{','.join(_REGULAR_GENE_FAMILY_STAGES)}. Expression is added only when explicitly requested. +The canonical workflow transitions are not the complete analysis catalog. This vocabulary +explains product behavior only: never turn it into an execution plan or choose stages for +IntentAgent or PlanningAgent. Questions and contextual follow-ups asking what these PlantAI +workflow parts contain are product help: they use goal=general with needs=[] and must not use +RAG or project tools. Apply this complete catalog only to product help; never use the complete +product catalog as current-job status. Questions about the current or next analysis in a +bound job must follow observed project evidence. +""" + + +def _ordered_contract_target_stages(workflow_id: str) -> tuple[str, ...]: + contract = load_canonical_workflow_contract(workflow_id) + actions = {action.id: action for action in contract.actions} + transitions_by_state = { + transition.from_state_id: transition for transition in contract.transitions + } + stages: list[str] = [] + visited_states: set[str] = set() + state_id = contract.initial_state_id + while state_id not in visited_states and state_id in transitions_by_state: + visited_states.add(state_id) + transition = transitions_by_state[state_id] + stages.append(actions[transition.action_id].target_stage) + state_id = transition.to_state_id + return tuple(stages) + + +_RNASEQ_WHOLE_GENOME_CORE_STAGES = _ordered_contract_target_stages( + "rnaseq_whole_genome" +) +_RNASEQ_FAMILY_MEMBER_STAGES = _ordered_contract_target_stages( + "rnaseq_family_member" +) + +_RNASEQ_CONVERSATION_PROFILE = f""" +RNA-seq/转录组 business vocabulary: the product has two modes: +全基因组 RNA-seq 分析(主体) and 已确认基因家族成员的 RNA 结果投影. For a broad +product question such as “系统中 RNA-seq/转录组分析有哪些步骤?”, First answer with +these two modes only and ask which mode the user wants expanded as an optional invitation, +not a required choice. This question is complete and unambiguous: use goal=general with +needs=[] and action=reply; never clarify. Do not enumerate any individual stage in that first +answer, even when the question contains “步骤” or “有哪些”. If the user then asks for details, +use the full conversation history to expand the referenced mode. + +For whole-genome RNA-seq, config.xlsx and samples.yaml are equally authoritative inputs. +config.xlsx supplies confirmed genome resources. samples.yaml supplies the confirmed sample +and experiment design, including FASTQ/sample identity, condition and group role, optional +batch/stratification fields, inclusion, comparisons, and applicable design. Explain that both +inputs are reviewed and confirmed before RNA fact projection. When expanding this mode, +explicitly name both config.xlsx and samples.yaml before listing the stages. The canonical +core stages are {','.join(_RNASEQ_WHOLE_GENOME_CORE_STAGES)}: RNA fact projection, FASTQ QC, +alignment, gene quantification, sample-level QC, and differential expression. GO enrichment, +KEGG enrichment, fgsea, expression visualization, WGCNA, and candidate-family analysis are +downstream or optional product analyses after the applicable core results; they are not the +six canonical core transitions and must not be presented as already authorized execution. +Do not invent generic textbook tools, preprocessing steps, or product capabilities. + +For confirmed-family-member RNA-seq, require successful whole-genome shared DE and exact +confirmed family members, then project those shared RNA results through +{','.join(_RNASEQ_FAMILY_MEMBER_STAGES)}. It must not rerun a separate family-mode DE and +must not silently select an arbitrary family-members file. + +These rules explain product behavior only. Product questions and contextual follow-ups use +goal=general with needs=[] and must not use RAG or project tools. A currently bound gene-family +or other job must not redirect the answer to that bound job. Do not convert product knowledge +into an execution plan. When the user requests execution, hand the self-contained business +request to the existing IntentAgent; IntentAgent, PlanningAgent, and the existing RNA-seq +pipeline retain stage selection, dependency, feasibility, and execution responsibilities. +""" _PRODUCT_PROFILE = """PlantAI is a plant bioinformatics analysis assistant. Its supported @@ -159,13 +269,25 @@ assistant: it may answer ordinary questions, draft or revise prose, and generate code directly in the conversation. Writing code in a reply does not execute that code and does not require an analysis or utility tool. Never refuse a request solely because it asks for source code. +Multi-turn drafting continuity: when conversation history establishes a code or prose +drafting goal, a short follow-up that supplies a requested specification is a supplement to +the same general goal. Reconstruct the original request from the full history. Once the +requested format, transformation rule, and output behavior are sufficient, keep the route +as goal=general with needs=[] so the final conversational response can produce the code or +draft without repeating questions that the user already answered. For example, after a +request to write a FASTA-header script, “FASTA format and output to a new file” is a drafting +specification and must not be routed to utility. Use utility only when the user asks PlantAI +to actually transform a current or supplied file, not when the user asks for code that they +can run themselves. PlantAI is not a plant-disease image diagnosis assistant. Never invent unsupported product abilities. -""" +""" + _GENE_FAMILY_CONVERSATION_PROFILE + _RNASEQ_CONVERSATION_PROFILE _SYSTEM_PROMPT = _PRODUCT_PROFILE + """You are PlantAI's conversation router. Never claim to execute an -analysis. Return raw JSON only, without markdown. The minimal RouterOutcome is +analysis. Return raw JSON only, without markdown. For a drafting reply, place the complete +requested code or prose inside the message string. Never return bare code, prose, or markdown +outside RouterOutcome JSON. The minimal RouterOutcome is {"status":"ok","action":"reply","message":"..."}; action must be exactly one of reply, clarify, show_result, propose_analysis, or propose_utility. @@ -208,30 +330,58 @@ Use the fewest tools needed. After successful evidence is sufficient, return the Never pass arguments from one tool schema to another. Never invent identity fields or tool arguments. Do not invent an intent.""" -_ROUTE_SYSTEM_PROMPT = _PRODUCT_PROFILE + """You are PlantAI's semantic route classifier. Return exactly +_ROUTE_SYSTEM_PROMPT = _PRODUCT_PROFILE + """You are PlantAI's semantic route classifier. Classify only; never answer the user's request, +and never return code, prose, or markdown instead of RouteDecision JSON. Return exactly one raw JSON RouteDecision object with goal, needs, subject, referent, and clarification_question. workflow_id is optional and must be exactly one of data_preparation, gene_family, rnaseq_whole_genome, or rnaseq_family_member. Also classify interaction_kind as exactly one of supplement, correction, switch, cancel, status, or independent. Do not use markdown. -Select workflow_id only for goal=new_analysis, or for goal=project_status when the user -asks for the current state of an established analysis workflow. +Select workflow_id only for goal=project_status when the user asks for the current state +or next available action of an established analysis workflow. Select it only when the workflow is specific enough, and select at most one workflow. -Asking what analysis should be planned next requests a new downstream plan, so use -goal=new_analysis. Asking only for status or progress without requesting a new plan uses -goal=project_status. The Router must not choose the next stages itself. +Asking which analysis is next, what remains, or what should be done next is a read-only +workflow progress question: use goal=project_status with project_context. Explicitly asking +to execute the current/next/recommended analysis uses goal=new_analysis, +analysis_request_kind=execution, and uses_current_recommendation=true. The Router must not +choose the next stages itself: the structured current recommendation supplies only the +already-computed workflow transition binding. +Classify the user's requested action before the object of that action. Words meaning +"next" or "remaining" never determine the route by themselves: a question asks for +current workflow guidance, while an imperative or explicit request to execute asks for +new computation and must be handed to the existing analysis system. +Boundary examples: +- "接下来的分析有哪些?" -> project_status + project_context. +- "接下来需要做什么?" -> project_status + project_context. +- "执行接下来的分析" -> new_analysis + analysis_intent, + analysis_request_kind=execution, uses_current_recommendation=true. +- "执行 domain 分析" -> new_analysis + analysis_intent, + analysis_request_kind=execution, uses_current_recommendation=false. Do not select a workflow for ordinary chat, product help, external knowledge, existing results, job files, or utilities. +RNA-seq product-help questions are general with needs=[] even when they name an analysis +mode, inputs, or steps: +- "系统中 RNA-seq/转录组分析有哪些步骤?" -> general + needs=[]. +- "全基因组 RNA-seq 分析的正式输入和具体步骤是什么?" -> general + needs=[]. +- "执行全基因组 RNA-seq 分析" -> new_analysis + analysis_intent. +Workflow contracts guide conversation and handoff only; PlanningAgentV2 remains responsible +for executable stage planning. For goal=new_analysis, do not ask for an analysis mode, select a workflow, or select stages. Leave workflow_id and clarification_question null and request analysis_intent; the existing IntentAgent and PlanningAgent own missing analysis choices and executable stage planning. -For goal=new_analysis, classify analysis_request_kind as guidance when the user asks -what should be done now, how to continue, or which analysis comes next. Use execution -when the user explicitly requests an analysis or named operation. This field describes +For goal=new_analysis, classify analysis_request_kind as guidance only when the user +explicitly asks the downstream analysis system to plan a new analysis request. Use +execution when the user explicitly requests an analysis or named operation. A read-only +question about what comes next is project_status, not new_analysis. This field describes the user's conversational act only; it must never list, add, remove, or choose stages. +Set uses_current_recommendation=true only for an execution request that refers to the +current/next/recommended workflow action. Keep it false for a newly named analysis request. +When the immediately preceding assistant answer names a business part but no canonical +action is bound, referent must name that business part rather than a generic phrase such +as “next analysis”; for example referent="亚家族分类与确认". IntentAgent resolves stages. Interaction classification: - supplement: adds information to the current goal without replacing it. @@ -333,6 +483,9 @@ Chinese boundary examples: - "查找相关资料并总结500字报告" -> general + external_knowledge. - "苦荞的SPL基因家族及工" -> general + needs=[] + clarification_question. - "用上传的蛋白序列鉴定SPL成员" -> new_analysis + analysis_intent. +- "接下来的分析有哪些" -> project_status + project_context. +- "执行接下来的分析" -> new_analysis + analysis_intent + + analysis_request_kind=execution + uses_current_recommendation=true. The available capabilities are project_context, analysis_results, external_knowledge, analysis_intent, job_file, job_files, and utility_intent. Analysis names mentioned inside product help do not by themselves @@ -345,6 +498,7 @@ Return exactly one raw JSON object with this shape and no markdown: {"goal":"general|project_status|existing_result|job_file|utility|new_analysis","needs":[], "subject":"nonblank","referent":null,"clarification_question":null,"workflow_id":null, "analysis_request_kind":null, +"uses_current_recommendation":false, "confirms_pending_proposal":false}. workflow_id may be data_preparation, gene_family, rnaseq_whole_genome, or rnaseq_family_member only for a specific new_analysis or an established workflow's @@ -379,7 +533,14 @@ _NEED_TO_TOOL = { } _OUTCOME_FIELDS = { - "status", "action", "message", "intent", "sources", "tool_trace", "proposal" + "status", + "action", + "message", + "intent", + "sources", + "tool_trace", + "proposal", + "recommendation", } _PYDANTIC_ERROR_TYPES = { "dict_type", @@ -440,6 +601,7 @@ class RouterRuntime: referent=decision.referent, interaction_kind=decision.interaction_kind, analysis_request_kind=decision.analysis_request_kind, + uses_current_recommendation=decision.uses_current_recommendation, confirms_pending_proposal=decision.confirms_pending_proposal, ) @@ -469,8 +631,77 @@ class RouterRuntime: "rnaseq_family_member", }: proposal["workflow_id"] = workflow_id + action_binding = { + key: str(raw.get(key) or "").strip() + for key in ("workflow_action_id", "authorized_stages", "target_stage") + } + if all(action_binding.values()): + proposal.update(action_binding) return proposal + @staticmethod + def _stored_current_recommendation( + goal_ledger: GoalLedger | None, + ) -> CurrentRecommendation | None: + if goal_ledger is None: + return None + raw = goal_ledger.user_terms.get("current_recommendation") + if not isinstance(raw, Mapping): + return None + try: + return CurrentRecommendation.model_validate(raw) + except ValidationError: + return None + + @staticmethod + def _recommendation_handoff_request( + recommendation: CurrentRecommendation, + ) -> str: + business_action = ( + "构建亚家族分类证据" + if recommendation.transition_id == "build_subfamily_evidence" + else recommendation.transition_id + ) + return ( + f"执行当前工作流业务动作:{business_action}" + f"({recommendation.transition_id})" + ) + + @staticmethod + def _limit_intent_to_confirmed_workflow_action( + intent: dict[str, Any], + proposal: Mapping[str, str] | None, + ) -> dict[str, Any]: + if proposal is None: + return intent + authorized_stages = [ + stage.strip() + for stage in str(proposal.get("authorized_stages") or "").split(",") + if stage.strip() + ] + target_stage = str(proposal.get("target_stage") or "").strip() + workflow_action_id = str(proposal.get("workflow_action_id") or "").strip() + if ( + not authorized_stages + or target_stage not in authorized_stages + or not workflow_action_id + ): + return intent + limited = dict(intent) + limited["stages"] = authorized_stages + limited["primary_stage"] = target_stage + limited["workflow_action_id"] = workflow_action_id + limited["description"] = ( + f"执行工作流业务动作 {workflow_action_id}(目标阶段 {target_stage})" + ) + if str(limited.get("stage") or "").strip() not in {"", *authorized_stages}: + limited.pop("stage", None) + params = dict(limited.get("params") or {}) + params["workflowActionId"] = workflow_action_id + params["authorizedStages"] = authorized_stages + limited["params"] = params + return limited + @staticmethod def _new_analysis_proposal( *, @@ -504,12 +735,20 @@ class RouterRuntime: return "" contract = load_canonical_workflow_contract(workflow_id) actions = {action.id: action for action in contract.actions} + evidence_stages = { + requirement.id: requirement.stage + for requirement in contract.evidence_requirements + } transitions = [ { "id": transition.id, "from": transition.from_state_id, "to": transition.to_state_id, "target_stage": actions[transition.action_id].target_stage, + "required_evidence_stages": [ + evidence_stages[requirement_id] + for requirement_id in transition.evidence_requirement_ids + ], "requires_confirmation": bool(transition.confirmation_ids), } for transition in contract.transitions @@ -534,32 +773,311 @@ class RouterRuntime: return json.dumps(compact, ensure_ascii=False, separators=(",", ":")) @staticmethod - def _is_next_analysis_request(query: str) -> bool: - compact = re.sub(r"[\s,。!?,.!?]", "", str(query or "").lower()) - return bool( - re.search(r"(?:下一步|接下来).*(?:做什么|怎么做|什么分析|呢)?$", compact) - or re.search(r"后续(?:应该|需要|要)?(?:做什么|怎么做|什么分析)$", compact) + def _current_workflow_recommendation( + workflow_id: WorkflowId | None, + job_id: str, + project_context: Mapping[str, Any] | None, + ) -> CurrentRecommendation | None: + if not job_id or not isinstance(project_context, Mapping): + return None + family = str(project_context.get("family") or "").strip() or None + if workflow_id is None and family is not None: + workflow_id = "gene_family" + if workflow_id is None: + return None + + scopes = { + str(item.get("scope_id") or "").strip() + for item in project_context.get("scopes", ()) + if isinstance(item, Mapping) and str(item.get("scope_id") or "").strip() + } + if len(scopes) > 1: + return None + scope_id = next(iter(scopes), None) + + completed_stages: set[str] = set() + successful = {"success", "partial_success"} + stage_status = project_context.get("stage_status") + if isinstance(stage_status, Mapping): + for stage, raw in stage_status.items(): + if not isinstance(raw, Mapping): + continue + if str(raw.get("status") or "").strip() in successful: + normalized = str(raw.get("stage") or stage or "").strip() + if normalized: + completed_stages.add(normalized) + for manifest in project_context.get("manifests", ()): + if not isinstance(manifest, Mapping): + continue + if str(manifest.get("status") or "").strip() not in successful: + continue + stage = str(manifest.get("stage") or "").strip() + if stage: + completed_stages.add(stage) + + contract = load_canonical_workflow_contract(workflow_id) + requirements = {item.id: item for item in contract.evidence_requirements} + states = {item.id: item for item in contract.states} + actions = {item.id: item for item in contract.actions} + + def state_is_observed(state_id: str) -> bool: + return all( + requirements[requirement_id].stage in completed_stages + for requirement_id in states[state_id].evidence_requirement_ids + ) + + current_state_id = contract.initial_state_id + visited = {current_state_id} + while True: + observed = [ + transition + for transition in contract.transitions + if transition.from_state_id == current_state_id + and state_is_observed(transition.to_state_id) + ] + if len(observed) != 1 or observed[0].to_state_id in visited: + break + current_state_id = observed[0].to_state_id + visited.add(current_state_id) + + outgoing = [ + transition + for transition in contract.transitions + if transition.from_state_id == current_state_id + ] + if len(outgoing) != 1: + return None + transition = outgoing[0] + action = actions[transition.action_id] + authorized_stages = tuple( + dict.fromkeys( + [ + requirements[requirement_id].stage + for requirement_id in transition.evidence_requirement_ids + ] + + [action.target_stage] + ) + ) + evidence_payload = { + "job_id": job_id, + "workflow_id": workflow_id, + "family": family, + "scope_id": scope_id, + "completed_stages": sorted(completed_stages), + "transition_id": transition.id, + } + evidence_digest = hashlib.sha256( + json.dumps( + evidence_payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + ).hexdigest() + return CurrentRecommendation( + recommendation_id=f"rec-{evidence_digest[:24]}", + job_id=job_id, + workflow_id=workflow_id, + transition_id=transition.id, + from_state_id=transition.from_state_id, + to_state_id=transition.to_state_id, + target_stage=action.target_stage, + authorized_stages=authorized_stages, + evidence_digest=evidence_digest, + family=family, + scope_id=scope_id, ) @staticmethod - def _established_workflow_id( - decision: RouteDecision, - goal_ledger: GoalLedger, + def _workflow_stage_ids(workflow_id: WorkflowId | None) -> tuple[str, ...]: + if workflow_id is None: + return () + contract = load_canonical_workflow_contract(workflow_id) + return tuple( + dict.fromkeys( + [item.stage for item in contract.evidence_requirements] + + [item.target_stage for item in contract.actions] + ) + ) + + @staticmethod + def _subfamily_is_confirmed( + project_context: Mapping[str, Any] | None, + ) -> bool: + if not isinstance(project_context, Mapping): + return False + stage_status = project_context.get("stage_status") + if not isinstance(stage_status, Mapping): + return False + status = stage_status.get("subfamily_confirm") + return isinstance(status, Mapping) and str( + status.get("status") or "" + ).strip() in {"success", "partial_success"} + + @classmethod + def _workflow_guidance_stage_ids( + cls, + workflow_id: WorkflowId | None, + project_context: Mapping[str, Any] | None, + ) -> tuple[str, ...]: + stages = list(cls._workflow_stage_ids(workflow_id)) + if workflow_id == "gene_family" and cls._subfamily_is_confirmed( + project_context + ): + stages.extend(_REGULAR_GENE_FAMILY_STAGES) + return tuple(dict.fromkeys(stages)) + + @staticmethod + def _effective_workflow_id( + workflow_id: WorkflowId | None, + project_context: Mapping[str, Any] | None, ) -> WorkflowId | None: - if decision.workflow_id is not None: - return decision.workflow_id - terms = goal_ledger.user_terms - rna_route = str(terms.get("rna_route") or "").strip().lower() - if rna_route == "whole_genome": - return "rnaseq_whole_genome" - if rna_route == "family_member": - return "rnaseq_family_member" - if str(terms.get("gene_family") or "").strip(): + if workflow_id is not None: + return workflow_id + if isinstance(project_context, Mapping) and str( + project_context.get("family") or "" + ).strip(): return "gene_family" - if bool(terms.get("config_confirmed")): - return "data_preparation" return None + @classmethod + def _unsupported_workflow_stage_mentions( + cls, + message: str, + workflow_id: WorkflowId | None, + project_context: Mapping[str, Any] | None, + ) -> tuple[str, ...]: + if workflow_id is None or not isinstance(project_context, Mapping): + return () + stage_status = project_context.get("stage_status") + if not isinstance(stage_status, Mapping): + return () + permitted = set( + cls._workflow_guidance_stage_ids(workflow_id, project_context) + ) + for stage, raw in stage_status.items(): + if not isinstance(raw, Mapping): + continue + if str(raw.get("status") or "").strip() not in { + "success", + "partial_success", + }: + continue + completed_stage = str(raw.get("stage") or stage or "").strip() + if completed_stage: + permitted.add(completed_stage) + registered = {tool.id for tool in stage_registry.list_all()} + registered.update( + str(raw.get("stage") or stage or "").strip() + for stage, raw in stage_status.items() + if isinstance(raw, Mapping) + ) + return tuple( + sorted( + stage + for stage in registered - permitted + if stage + and re.search( + rf"(? str: + contract = load_canonical_workflow_contract(recommendation.workflow_id) + actions = {item.id: item for item in contract.actions} + requirements = {item.id: item for item in contract.evidence_requirements} + current_state = recommendation.from_state_id + parts: list[str] = [] + visited: set[str] = set() + while current_state not in visited: + visited.add(current_state) + outgoing = [ + transition + for transition in contract.transitions + if transition.from_state_id == current_state + ] + if len(outgoing) != 1: + break + transition = outgoing[0] + stages = list( + dict.fromkeys( + [ + requirements[requirement_id].stage + for requirement_id in transition.evidence_requirement_ids + ] + + [actions[transition.action_id].target_stage] + ) + ) + parts.append(f"{transition.id}({', '.join(stages)})") + current_state = transition.to_state_id + return ( + "按当前工作流,后续业务动作依次为:" + + ";".join(parts) + + "。本次仅提供工作流建议,尚未执行任何分析。" + ) + + @staticmethod + def _workflow_guidance_stage_groups( + recommendation: CurrentRecommendation, + ) -> tuple[tuple[str, ...], ...]: + contract = load_canonical_workflow_contract(recommendation.workflow_id) + actions = {item.id: item for item in contract.actions} + requirements = {item.id: item for item in contract.evidence_requirements} + current_state = recommendation.from_state_id + groups: list[tuple[str, ...]] = [] + visited: set[str] = set() + while current_state not in visited: + visited.add(current_state) + outgoing = [ + transition + for transition in contract.transitions + if transition.from_state_id == current_state + ] + if len(outgoing) != 1: + break + transition = outgoing[0] + evidence_stages = tuple( + dict.fromkeys( + requirements[requirement_id].stage + for requirement_id in transition.evidence_requirement_ids + ) + ) + if evidence_stages: + groups.append(evidence_stages) + groups.append((actions[transition.action_id].target_stage,)) + current_state = transition.to_state_id + return tuple(groups) + + @classmethod + def _workflow_stage_order_is_reversed( + cls, + message: str, + recommendation: CurrentRecommendation, + ) -> bool: + occurrences: list[tuple[int, int]] = [] + for group_index, stages in enumerate( + cls._workflow_guidance_stage_groups(recommendation) + ): + for stage in stages: + match = re.search( + rf"(? BaseModel: stripped = content.strip() @@ -615,6 +1133,7 @@ class RouterRuntime: tools=[], tool_choice="none", temperature=0, + response_format={"type": "json_object"}, ) except Exception: return None, calls + 1, [] @@ -624,7 +1143,12 @@ class RouterRuntime: response.content or "", RouteDecision, ) - except (ValueError, ValidationError): + except (ValueError, ValidationError) as exc: + reason = ( + self._route_validation_reason(exc) + if isinstance(exc, ValidationError) + else "validation_error: field=root; type=json_invalid" + ) tool_trace.append( { "tool_call_id": f"route-protocol-error:{calls}", @@ -632,7 +1156,7 @@ class RouterRuntime: "arguments": "{}", "result": self._tool_error( "route_decision_validation_error", - "The Router returned an invalid RouteDecision.", + reason, ), } ) @@ -655,13 +1179,18 @@ class RouterRuntime: session_id: str, job_id: str, goal_ledger: GoalLedger | None = None, + language: str = "zh", ) -> RouterOutcome: ui_action = self._config_dialog_ui_action(query) if ui_action is not None: return RouterOutcome( status="ok", action="reply", - message="已打开当前 job 的 config.xlsx 核对表。", + message=( + "Opened the config.xlsx review sheet for the current job." + if language == "en" + else "已打开当前 job 的 config.xlsx 核对表。" + ), ui_action=ui_action, ) @@ -691,7 +1220,18 @@ class RouterRuntime: job_id=job_id, goal_ledger=goal_ledger, ) - if decision is not None and self._is_explicit_job_file_read(query): + if ( + decision is not None + and decision.goal in {"utility", "job_file"} + and self._is_explicit_drafting_request(query) + ): + decision = RouteDecision( + goal="general", + needs=[], + subject="draft code without executing a file operation", + interaction_kind=decision.interaction_kind, + ) + elif decision is not None and self._is_explicit_job_file_read(query): decision = RouteDecision( goal="job_file", needs=["job_file"], @@ -701,17 +1241,16 @@ class RouterRuntime: ) if ( decision is not None - and goal_ledger is not None - and decision.goal != "new_analysis" - and self._is_next_analysis_request(query) + and decision.goal == "general" + and decision.clarification_question is not None + and any( + item.get("role") == "user" + and self._is_explicit_drafting_request(item.get("content", "")) + for item in history + ) ): - decision = RouteDecision( - goal="new_analysis", - needs=["analysis_intent"], - subject=str(query or "").strip(), - interaction_kind="supplement", - workflow_id=self._established_workflow_id(decision, goal_ledger), - analysis_request_kind="guidance", + decision = decision.model_copy( + update={"clarification_question": None} ) if ( decision is not None @@ -751,6 +1290,87 @@ class RouterRuntime: tool_trace=decision_trace, ) decision = self._analysis_handoff_decision(decision) + recommendation_binding: dict[str, str] | None = None + semantic_referent_handoff = False + if decision.uses_current_recommendation: + stored_recommendation = self._stored_current_recommendation(goal_ledger) + if stored_recommendation is None: + semantic_referent = str(decision.referent or "").strip() + if semantic_referent.casefold() in { + "", + "current_recommendation", + "当前推荐", + "接下来的分析", + "接下来的步骤", + "下一步", + }: + return RouterOutcome( + status="ok", + action="clarify", + message="当前没有可执行的结构化推荐,请明确要继续的分析业务。", + tool_trace=decision_trace, + interaction_kind=decision.interaction_kind, + ) + effective_query = f"执行{semantic_referent}" + semantic_referent_handoff = True + decision = decision.model_copy( + update={"uses_current_recommendation": False} + ) + if decision.uses_current_recommendation: + stored_recommendation = self._stored_current_recommendation(goal_ledger) + assert stored_recommendation is not None + if stored_recommendation.job_id != job_id: + return RouterOutcome( + status="ok", + action="clarify", + message="当前推荐不属于当前 job,请先重新查询当前 job 的后续分析。", + tool_trace=decision_trace, + interaction_kind=decision.interaction_kind, + ) + context_result = await self.tools.dispatch("inspect_project_context", "{}") + decision_trace.append( + { + "tool_call_id": "revalidate-current-recommendation", + "name": "inspect_project_context", + "arguments": "{}", + "result": context_result, + } + ) + try: + context_payload = json.loads(context_result) + context_data = ( + context_payload.get("data") + if context_payload.get("ok") is True + else None + ) + except (TypeError, json.JSONDecodeError): + context_data = None + current_recommendation = self._current_workflow_recommendation( + stored_recommendation.workflow_id, + job_id, + context_data if isinstance(context_data, Mapping) else None, + ) + if current_recommendation != stored_recommendation: + return RouterOutcome( + status="ok", + action="clarify", + message="项目状态已变化,原推荐已失效;请先重新查询接下来的分析。", + tool_trace=decision_trace, + interaction_kind=decision.interaction_kind, + ) + effective_query = self._recommendation_handoff_request( + current_recommendation + ) + recommendation_binding = { + "workflow_action_id": current_recommendation.transition_id, + "authorized_stages": ",".join( + current_recommendation.authorized_stages + ), + "target_stage": current_recommendation.target_stage, + } + decision = decision.model_copy( + update={"workflow_id": current_recommendation.workflow_id} + ) authorized_input_paths = _extract_authorized_input_paths(query) if ( decision.goal == "new_analysis" @@ -762,8 +1382,17 @@ class RouterRuntime: status="ok", action="prepare_inputs", message=( - "已识别当前 job 的数据路径。请先核对自动生成的 config.xlsx 草稿," - "确认物种角色后再进入分析规划。" + ( + "Data paths identified for the current job. " + "Please review the auto-generated config.xlsx draft " + "and confirm species roles before proceeding to " + "analysis planning." + ) + if language == "en" + else ( + "已识别当前 job 的数据路径。请先核对自动生成的 config.xlsx 草稿," + "确认物种角色后再进入分析规划。" + ) ), input_paths=authorized_input_paths, interaction_kind=decision.interaction_kind, @@ -776,21 +1405,32 @@ class RouterRuntime: tool_trace=decision_trace, interaction_kind=decision.interaction_kind, ) - if ( - decision.goal == "new_analysis" - and goal_ledger is not None - and confirmed_proposal is None - ): + if decision.goal == "new_analysis": proposal_query = self.tools.resolve_analysis_handoff_request( - query, + effective_query, workflow_id=decision.workflow_id, guidance=decision.analysis_request_kind == "guidance", + language=language, ) if decision.analysis_request_kind == "guidance": proposal_query = ( - f"{proposal_query}\n当前请求语义:用户是在询问当前工作流的单一下一业务动作," - "该请求不包含后续业务动作;请仅识别当前下一业务动作,具体阶段仍由" - "现有 IntentAgent 和 PlanningAgent 判断。" + f"{effective_query}\n" + + ( + ( + "Request semantics: the user is asking about a single " + "next business action of the current workflow; this " + "request does not include subsequent business actions. " + "Please only identify the current next business action. " + "Specific stages are still determined by the existing " + "IntentAgent and PlanningAgent." + ) + if language == "en" + else ( + "当前请求语义:用户是在询问当前工作流的单一下一业务动作," + "该请求不包含后续业务动作;请仅识别当前下一业务动作,具体阶段仍由" + "现有 IntentAgent 和 PlanningAgent 判断。" + ) + ) ) proposal = self._new_analysis_proposal( session_id=session_id, @@ -804,8 +1444,17 @@ class RouterRuntime: status="ok", action="clarify", message=( - f"我理解你的当前请求是:{proposal['request']}。" - "确认后我会把这一需求交给现有分析系统识别和规划。是否继续?" + ( + f"I understand your request as: {proposal['request']}. " + "After confirmation, I'll hand this to the existing " + "analysis system for intent recognition and planning. " + "Continue?" + ) + if language == "en" + else ( + f"我理解你的当前请求是:{proposal['request']}。" + "确认后我会把这一需求交给现有分析系统识别和规划。是否继续?" + ) ), proposal=proposal, interaction_kind=decision.interaction_kind, @@ -817,6 +1466,7 @@ class RouterRuntime: job_id, decision, goal_ledger, + language, ) definitions = self._definitions_for_route( decision, @@ -849,6 +1499,7 @@ class RouterRuntime: has_rag_evidence = False has_file_observation = False file_observation: dict[str, Any] | None = None + project_context_observation: dict[str, Any] | None = None authoritative_result_summary = "" expected_action = self._action_for_route(decision) @@ -880,6 +1531,11 @@ class RouterRuntime: "none" if force_final else "required" ), temperature=0, + response_format=( + {"type": "json_object"} + if force_final and expected_action != "reply" + else None + ), ) except Exception: return RouterOutcome( @@ -1064,8 +1720,25 @@ class RouterRuntime: file_data = None if isinstance(file_data, dict): file_observation = dict(file_data) + if tool_call.name == "inspect_project_context": + try: + context_payload = json.loads(result) + context_data = ( + context_payload.get("data") + if context_payload.get("ok") is True + else None + ) + except (TypeError, json.JSONDecodeError): + context_data = None + if isinstance(context_data, dict): + project_context_observation = dict(context_data) candidate = self._recognized_candidate(tool_call.name, result) if decision.goal in {"new_analysis", "utility"} and candidate is not None: + if decision.goal == "new_analysis": + candidate = self._limit_intent_to_confirmed_workflow_action( + candidate, + confirmed_proposal or recommendation_binding, + ) recognized_intent = candidate new_sources, result_evidence = self._extract_sources(tool_call.name, result) derived_sources.extend(new_sources) @@ -1088,6 +1761,7 @@ class RouterRuntime: sources=derived_sources, tool_trace=tool_trace, interaction_kind=decision.interaction_kind, + language=language, ) post_tool_instruction = self._post_tool_instruction( @@ -1111,6 +1785,23 @@ class RouterRuntime: has_recognized_intent=recognized_intent is not None, require_final=repeated_failed_call, ) + guidance_workflow_id = self._effective_workflow_id( + decision.workflow_id, + project_context_observation, + ) + if ( + decision.goal == "project_status" + and guidance_workflow_id is not None + ): + guidance_stages = self._workflow_guidance_stage_ids( + guidance_workflow_id, + project_context_observation, + ) + post_tool_instruction += ( + " For workflow guidance, use only the selected workflow business " + f"stages: {','.join(guidance_stages)}. Do not enumerate unrelated " + "registered stages." + ) if not remaining_needs: post_tool_instruction += ( " all declared route needs are observed. Call no more tools " @@ -1138,57 +1829,131 @@ class RouterRuntime: status="ok", action="clarify", message=( - "I couldn't identify a supported request. Please clarify " + "没有识别到可执行的文件处理操作。请提供实际文件路径和明确的处理规则," + "例如“简化 test.fa 的 ID,仅保留 FASTA 头第一个字段”。" + if decision.goal == "utility" + else "I couldn't identify a supported request. Please clarify " "the operation you want PlantAI to perform." ), tool_trace=tool_trace, ) continue + raw_content = (response.content or "").strip() messages.append(LLMMessage(role="assistant", content=response.content or "")) try: - final = self._parse_model_object(response.content or "", RouterOutcome) + final = self._parse_model_object(raw_content, RouterOutcome) except (ValueError, ValidationError) as exc: - reason = ( - self._pydantic_validation_reason(exc) - if isinstance(exc, ValidationError) - else "validation_error: field=root; type=json_invalid" - ) - self._record_validation_failure( - tool_trace, - seen_tool_call_ids, - reason, - ) - messages.append( - LLMMessage( - role="user", - content=self._json_correction( - reason, - has_result_evidence=has_result_evidence, - ), + if ( + isinstance(exc, ValueError) + and expected_action == "reply" + and raw_content + and not raw_content.startswith("{") + and not raw_content.startswith("```json") + ): + final = RouterOutcome( + status="ok", + action="reply", + message=raw_content, ) - ) - continue + else: + reason = ( + self._pydantic_validation_reason(exc) + if isinstance(exc, ValidationError) + else "validation_error: field=root; type=json_invalid" + ) + self._record_validation_failure( + tool_trace, + seen_tool_call_ids, + reason, + ) + messages.append( + LLMMessage( + role="user", + content=self._json_correction( + reason, + has_result_evidence=has_result_evidence, + ), + ) + ) + continue semantic_reason = self._semantic_validation_reason(final) + guidance_workflow_id = self._effective_workflow_id( + decision.workflow_id, + project_context_observation, + ) if ( semantic_reason is None - and decision.workflow_id == "gene_family" + and guidance_workflow_id == "gene_family" and self._claims_gene_family_complete_at_readiness_boundary(final.message) ): return RouterOutcome( status="ok", action=expected_action, message=( - "当前 job 已完成基因家族成员鉴定和亚家族确认;这只表示已确认的" - "家族成员及亚家族分类表可以作为后续分析输入,常规基因家族分析" - "尚未因此完成。若要继续,我会把继续常规基因家族分析的需求交给" - "现有分析系统识别和规划,具体阶段仍由 PlanningAgent 决定。" + ( + "The current job has completed gene family member " + "identification and subfamily confirmation. This " + "only means the confirmed member and subfamily " + "classification table can serve as input for " + "downstream analysis; regular gene family analysis " + "is not yet complete. To continue, I'll hand the " + "request to the existing analysis system for intent " + "recognition and planning. Specific stages are still " + "determined by PlanningAgent." + ) + if language == "en" + else ( + "当前 job 已完成基因家族成员鉴定和亚家族确认;这只表示已确认的" + "家族成员及亚家族分类表可以作为后续分析输入,常规基因家族分析" + "尚未因此完成。若要继续,我会把继续常规基因家族分析的需求交给" + "现有分析系统识别和规划,具体阶段仍由 PlanningAgent 决定。" + ) ), sources=self._deduplicate_sources(derived_sources), tool_trace=tool_trace, interaction_kind=decision.interaction_kind, ) + + if ( + semantic_reason is None + and decision.goal == "project_status" + and guidance_workflow_id is not None + ): + unsupported_stages = self._unsupported_workflow_stage_mentions( + final.message, + guidance_workflow_id, + project_context_observation, + ) + if unsupported_stages: + semantic_reason = ( + "semantic_error: field=message; " + "type=unsupported_workflow_stage_claim; " + f"stages={','.join(unsupported_stages)}" + ) + if ( + semantic_reason is None + and decision.goal == "project_status" + and guidance_workflow_id is not None + and project_context_observation is not None + ): + guidance_recommendation = self._current_workflow_recommendation( + guidance_workflow_id, + job_id, + project_context_observation, + ) + if ( + guidance_recommendation is not None + and self._workflow_stage_order_is_reversed( + final.message, + guidance_recommendation, + ) + ): + semantic_reason = ( + "semantic_error: field=message; " + "type=unsupported_workflow_stage_order" + ) if ( semantic_reason is None and decision.goal in {"new_analysis", "utility"} @@ -1297,6 +2062,19 @@ class RouterRuntime: final.proposal = confirmed_proposal else: final.intent = None + if ( + final.action == "show_result" + and decision.goal == "project_status" + and project_context_observation is not None + ): + final.recommendation = self._current_workflow_recommendation( + self._effective_workflow_id( + decision.workflow_id, + project_context_observation, + ), + job_id, + project_context_observation, + ) final.interaction_kind = decision.interaction_kind return final @@ -1310,7 +2088,27 @@ class RouterRuntime: sources=derived_sources, tool_trace=tool_trace, interaction_kind=decision.interaction_kind, + language=language, ) + if decision.goal == "project_status" and project_context_observation is not None: + guidance_workflow_id = self._effective_workflow_id( + decision.workflow_id, + project_context_observation, + ) + recommendation = self._current_workflow_recommendation( + guidance_workflow_id, + job_id, + project_context_observation, + ) + if recommendation is not None: + return RouterOutcome( + status="ok", + action="show_result", + message=self._workflow_guidance_fallback(recommendation), + tool_trace=tool_trace, + interaction_kind=decision.interaction_kind, + recommendation=recommendation, + ) return RouterOutcome( status="budget_exhausted", message="The Router reached its five-call iteration budget.", @@ -1329,6 +2127,14 @@ class RouterRuntime: ) return "open_config_dialog" if any(re.fullmatch(pattern, compact) for pattern in patterns) else None + @staticmethod + def _is_explicit_drafting_request(query: str) -> bool: + text = str(query or "").strip().lower() + return bool( + re.search(r"(?:写|编写|生成).{0,6}(?:脚本|代码)", text) + or re.search(r"\b(?:write|generate).{0,20}\b(?:script|code)\b", text) + ) + @staticmethod def _is_explicit_job_file_read(query: str) -> bool: text = str(query or "").strip() @@ -1354,24 +2160,57 @@ class RouterRuntime: sources: list[dict[str, Any]], tool_trace: list[dict[str, Any]], interaction_kind: InteractionKind, + language: str = "zh", ) -> RouterOutcome: if observation.get("success") is not True: - message = { - "file_not_found": "当前 job 中没有找到该文件,请核对文件名或路径。", - "path_not_allowed": "该路径不属于当前 job 的只读授权范围。", - "ambiguous_path": "当前 job 中有多个同名文件,请提供带 input/、final/、logs/ 或 workspace/ 的路径。", - "binary_file": "该文件不是可直接预览的文本文件。", - }.get(str(observation.get("error_code") or "")) or str( - observation.get("message") - or "当前 job 中没有找到或无法读取该文件。" - ).strip() + message = ( + { + "file_not_found": ( + "File not found in the current job. Please check " + "the filename or path." + if language == "en" + else "当前 job 中没有找到该文件,请核对文件名或路径。" + ), + "path_not_allowed": ( + "This path is outside the authorized read-only " + "scope of the current job." + if language == "en" + else "该路径不属于当前 job 的只读授权范围。" + ), + "ambiguous_path": ( + "Multiple files with the same name exist in the " + "current job. Please provide a path with " + "input/, final/, logs/ or workspace/." + if language == "en" + else "当前 job 中有多个同名文件,请提供带 input/、final/、logs/ 或 workspace/ 的路径。" + ), + "binary_file": ( + "This file is not a directly previewable text file." + if language == "en" + else "该文件不是可直接预览的文本文件。" + ), + }.get(str(observation.get("error_code") or "")) or str( + observation.get("message") + or ( + "File not found or not readable in the current job." + if language == "en" + else "当前 job 中没有找到或无法读取该文件。" + ) + ).strip() + ) elif isinstance(observation.get("files"), list): files = [item for item in observation["files"] if isinstance(item, dict)] total = observation.get("total") - message = f"当前 job 文件(本页 {len(files)} 个" - if isinstance(total, int): - message += f",共 {total} 个" - message += "):" + if language == "en": + message = f"Current job files (this page: {len(files)}" + if isinstance(total, int): + message += f", total: {total}" + message += "):" + else: + message = f"当前 job 文件(本页 {len(files)} 个" + if isinstance(total, int): + message += f",共 {total} 个" + message += "):" lines: list[str] = [] for item in files: handle = str(item.get("file_handle") or "").strip() @@ -1386,16 +2225,26 @@ class RouterRuntime: if lines: message += "\n" + "\n".join(lines) if observation.get("truncated"): - message += "\n列表已分页,可继续查看后续文件。" + message += ( + "\nList is paginated; you can continue viewing subsequent files." + if language == "en" + else "\n列表已分页,可继续查看后续文件。" + ) else: file_handle = str(observation.get("file_handle") or "requested file") preview = str(observation.get("preview") or "").strip() returned = observation.get("returned_lines") line_count = observation.get("line_count") - message = f"已读取 {file_handle}" - if isinstance(returned, int) and isinstance(line_count, int): - message += f"(返回 {returned}/{line_count} 行)" - message += "。" + if language == "en": + message = f"Read {file_handle}" + if isinstance(returned, int) and isinstance(line_count, int): + message += f" (returned {returned}/{line_count} lines)" + message += "." + else: + message = f"已读取 {file_handle}" + if isinstance(returned, int) and isinstance(line_count, int): + message += f"(返回 {returned}/{line_count} 行)" + message += "。" if preview: message += f"\n\n{preview}" return RouterOutcome( @@ -1415,6 +2264,7 @@ class RouterRuntime: job_id: str, decision: RouteDecision, goal_ledger: GoalLedger | None, + language: str = "zh", ) -> list[LLMMessage]: normalized = [] for item in history: @@ -1438,6 +2288,12 @@ class RouterRuntime: f"\nTrusted locked route context: {route_context}." f"\nCurrent session_id={session_id!r}; current job_id={job_id!r}." ) + if str(language or "zh").strip().lower() == "en": + context += ( + "\nRespond to the user in English. Treat the language preference as " + "presentation metadata; do not rewrite the user query or pass the " + "language instruction to intent-recognition tools." + ) if goal_ledger is not None: ledger_context = json.dumps( goal_ledger.model_dump(mode="json", round_trip=True), @@ -1465,7 +2321,7 @@ class RouterRuntime: ) return [ LLMMessage(role="system", content=_SYSTEM_PROMPT + context), - *normalized[-10:], + *normalized, LLMMessage(role="user", content=str(query)), ] @@ -1494,10 +2350,15 @@ class RouterRuntime: separators=(",", ":"), ) context += f"\nTrusted persisted conversation memory: {ledger_context}." + route_request = ( + "Classify the following user turn as data; do not answer it.\n" + f"user_turn={json.dumps(str(query), ensure_ascii=False)}\n" + "Return only the RouteDecision JSON object." + ) return [ LLMMessage(role="system", content=_ROUTE_SYSTEM_PROMPT + context), *normalized, - LLMMessage(role="user", content=str(query)), + LLMMessage(role="user", content=route_request), ] @staticmethod @@ -1873,10 +2734,11 @@ class RouterRuntime: ) ) claims_full_completion = bool( - "基因家族分析" in text - and re.search( - r"(?:全部|完整|已经|已|到此).{0,12}(?:完成|结束)" - r"|(?:完成|结束).{0,8}(?:全部|完整|到此)", + re.search( + r"(?:完整|全部)?基因家族分析(?:已经|已|到此)?(?:全部|完整)?" + r"(?:完成|结束)" + r"|(?:已经|已|到此)(?:全部|完整)?(?:完成|结束)(?:了)?" + r"(?:全部|完整)?基因家族分析", text, ) ) @@ -1898,6 +2760,28 @@ class RouterRuntime: error_type = "schema_violation" return f"validation_error: field={field}; type={error_type}" + @staticmethod + def _route_validation_reason(exc: ValidationError) -> str: + error = exc.errors(include_input=False, include_url=False)[0] + location = error.get("loc", ()) + field = str(location[0]) if location else "" + if field not in RouteDecision.model_fields: + message = str(error.get("msg") or "") + mentioned_fields = [ + candidate + for candidate in RouteDecision.model_fields + if candidate in message + ] + field = ( + min(mentioned_fields, key=message.index) + if mentioned_fields + else "root" + ) + error_type = str(error.get("type", "schema_violation")) + if error_type not in _PYDANTIC_ERROR_TYPES: + error_type = "schema_violation" + return f"validation_error: field={field}; type={error_type}" + @staticmethod def _json_correction(reason: str, *, has_result_evidence: bool) -> str: action_rule = ( diff --git a/gfa_core/mas/context_index.py b/gfa_core/mas/context_index.py index 95429ae94..b8694fafb 100755 --- a/gfa_core/mas/context_index.py +++ b/gfa_core/mas/context_index.py @@ -251,7 +251,13 @@ class ProjectContextIndexer: self.workspace_root = self.project_root / "workspace" self.allow_legacy_manifest_migration = allow_legacy_manifest_migration - def build(self, job_id: str, prefix: str = "") -> ProjectContextIndex: + def build( + self, + job_id: str, + prefix: str = "", + *, + include_stage_status: bool = True, + ) -> ProjectContextIndex: workspace = self.workspace_root / job_id canonical_input_dir = self.project_root / "input" / job_id input_dir = canonical_input_dir if canonical_input_dir.exists() else workspace / "input" @@ -275,7 +281,8 @@ class ProjectContextIndexer: self._scan_files(index, final_dir, "final") self._scan_stage_dirs(index, workspace) self._load_manifests(index, final_dir) - self._load_stage_status(index, job_id, prefix or self._best_prefix(index)) + if include_stage_status: + self._load_stage_status(index, job_id, prefix or self._best_prefix(index)) self._scan_logs(index, logs_dir) self._derive_warnings(index) return index @@ -696,11 +703,16 @@ def build_project_context( project_root: Path | str = PROJECT_ROOT, *, allow_legacy_manifest_migration: bool = True, + include_stage_status: bool = True, ) -> ProjectContextIndex: return ProjectContextIndexer( project_root, allow_legacy_manifest_migration=allow_legacy_manifest_migration, - ).build(job_id=job_id, prefix=prefix) + ).build( + job_id=job_id, + prefix=prefix, + include_stage_status=include_stage_status, + ) def main() -> int: diff --git a/gfa_core/mas/core/fast_intent.py b/gfa_core/mas/core/fast_intent.py index fe0600f23..abeea717b 100755 --- a/gfa_core/mas/core/fast_intent.py +++ b/gfa_core/mas/core/fast_intent.py @@ -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 { diff --git a/gfa_core/mas/core/orchestrator.py b/gfa_core/mas/core/orchestrator.py index 680fd7ded..3361df9d3 100755 --- a/gfa_core/mas/core/orchestrator.py +++ b/gfa_core/mas/core/orchestrator.py @@ -29,6 +29,74 @@ WORKSPACE_DIR = WORKSPACE_ROOT class Orchestrator: + # 双语消息映射表:中文 → English(用于用户可见消息) + _MSG = { + # analysis_node — response 消息 + "✅ 所有分析阶段已完成!": "✅ All analysis stages completed!", + "📋 自动规划剩余分析阶段": "📋 Auto-planned remaining analysis stages", + "**已完成**": "**Completed**", + "无": "None", + "**待执行**": "**Pending**", + "个阶段)": "stage(s))", + "请确认是否按顺序执行以上分析阶段?": "Confirm to execute the above analysis stages in order?", + "我将帮您执行RNA-seq": "I will help you execute RNA-seq", + "分析。请确认参数后开始。": "analysis. Please confirm parameters to start.", + "RNA-seq分析将通过前端异步执行。": "RNA-seq analysis will be executed asynchronously via frontend.", + " 分析。PlanningAgentV2 已根据当前状态生成完整执行计划。": " analysis. PlanningAgentV2 has generated a complete execution plan based on current state.", + "我将帮您执行": "I will help you execute", + "分析。": "analysis.", + "请提供基因家族蛋白序列文件(.fa/.fasta),用于MEME保守基序预测。": "Please provide gene family protein sequence file (.fa/.fasta) for MEME conserved motif prediction.", + "请提供config.xlsx及基因组/GFF/CDS/PEP等原始文件,用于完整数据准备。": "Please provide config.xlsx and genome/GFF/CDS/PEP files for complete data preparation.", + "请提供基因家族蛋白序列文件,用于Pfam/InterPro结构域注释。": "Please provide gene family protein sequence file for Pfam/InterPro domain annotation.", + "请提供基因家族的GFF注释文件和基因组序列。": "Please provide gene family GFF annotation file and genome sequence.", + "请提供基因家族成员的启动子序列(上游2000bp)或全基因组序列+GFF。": "Please provide gene family promoter sequences (upstream 2000bp) or whole genome + GFF.", + "请提供物种基因组序列(.fasta)和GFF注释文件。": "Please provide species genome sequence (.fasta) and GFF annotation file.", + "请提供多个物种的基因组序列和GFF注释文件,以及物种间的同源关系。": "Please provide genome sequences and GFF annotation files for multiple species, along with inter-species orthology.", + "请提供染色体长度信息和基因位置信息。": "Please provide chromosome length and gene position information.", + "请提供拟南芥同源基因映射文件(ortholog_pairs.txt,格式:拟南芥ID\t苦荞ID),用于STRING数据库互作预测。如未提供,请先运行同源基因识别分析。": "Please provide Arabidopsis ortholog mapping file (ortholog_pairs.txt, format: AtID\tTargetID) for STRING interaction prediction. If unavailable, run ortholog identification first.", + "请提供目标物种和参考物种(如拟南芥At)的蛋白序列文件,用于同源基因识别。": "Please provide target and reference species (e.g., Arabidopsis At) protein FASTA for ortholog identification.", + "请提供物种间同源基因对(ortholog pairs,格式:基因A\t基因B)或共线性分析结果。": "Please provide inter-species ortholog pairs (format: GeneA\tGeneB) or synteny analysis results.", + "请提供RNA-seq表达矩阵(FPKM/TPM/Count)或原始测序数据。": "Please provide RNA-seq expression matrix (FPKM/TPM/Count) or raw sequencing data.", + "请提供差异表达基因列表(DEGs)或目标基因集。": "Please provide differentially expressed gene list (DEGs) or target gene set.", + "请提供基因家族蛋白序列文件,用于理化性质分析。": "Please provide gene family protein sequence file for physicochemical property analysis.", + "请提供基因家族HMM文件和蛋白序列文件,将自动执行标准11阶段分析。": "Please provide gene family HMM file and protein FASTA; standard 11-stage analysis will be executed automatically.", + "请提供RNA-seq原始测序数据(FASTQ格式)。": "Please provide RNA-seq raw sequencing data (FASTQ format).", + "请提供质控后的clean reads(FASTQ格式)和参考基因组。": "Please provide QC-filtered clean reads (FASTQ format) and reference genome.", + "请提供比对后的BAM文件或参考基因组注释GFF。": "Please provide aligned BAM file or reference genome annotation GFF.", + "请提供featureCounts计数矩阵和样本分组表,用于VST、PCA、样本相关性和聚类。": "Please provide featureCounts count matrix and sample grouping table for VST, PCA, sample correlation and clustering.", + "请提供多时相表达矩阵(如不同发育阶段/处理时间点)。": "Please provide multi-temporal expression matrix (e.g., different developmental stages/treatment time points).", + "请提供VST/log2表达矩阵或count矩阵和样本性状表,用于共表达网络构建。": "Please provide VST/log2 expression matrix or count matrix and sample trait table for co-expression network construction.", + "请提供差异表达结果和gene2go背景文件,或先执行背景注释。": "Please provide differential expression results and gene2go background file, or run background annotation first.", + "请提供差异表达结果和gene2ko背景文件,或先执行背景注释。": "Please provide differential expression results and gene2ko background file, or run background annotation first.", + "请提供差异表达排序统计量和gene2ko/KEGG背景,用于fgsea排序富集。": "Please provide differential expression ranking statistics and gene2ko/KEGG background for fgsea ranked enrichment.", + "请提供差异表达结果和目标物种蛋白FASTA;若已有InterProScan TSV会优先复用。": "Please provide differential expression results and target species protein FASTA; existing InterProScan TSV will be reused if available.", + "请提供表达矩阵(样本数≥6),用于共表达网络构建。": "Please provide expression matrix (samples ≥ 6) for co-expression network construction.", + # knowledge_node — system prompt / response 消息 + "你是BotanicalBaseAgent基因家族分析系统的知识助手。基于提供的文献资料,生成专业、详细的回答。": "You are a knowledge assistant for the BotanicalBaseAgent gene family analysis system. Generate professional, detailed answers based on provided literature.", + "📚 基于知识库的回答:": "📚 Knowledge base answer:", + "**参考文献**:": "**References**:", + "(来源: 本地LLM回答 + RAG辅助检索)": "(Source: local LLM answer + RAG-assisted retrieval)", + "⚠️ (本地LLM回答,未使用RAG文献检索)": "⚠️ (local LLM answer, RAG retrieval not used)", + "找到": "Found", + "条相关文献,正在生成本地LLM回答...": " related references; generating local LLM answer...", + "未找到相关文献,生成一般回答": "No related references found; generating general answer", + "你是一个专业的基因家族分析意图识别器。": "You are a professional gene family analysis intent recognizer.", + "你是一个基因家族分析专家。用户想要进行基因家族分析。": "You are a gene family analysis expert. The user wants to perform gene family analysis.", + } + + @classmethod + def _l(cls, zh_text: str, language: str = "zh") -> str: + """Return language-appropriate text from the bilingual map.""" + if language == "zh": + return zh_text + en = cls._MSG.get(zh_text) + if en is not None: + return en + # fallback: try partial match for template strings + for zh_key, en_val in cls._MSG.items(): + if zh_key in zh_text: + return zh_text.replace(zh_key, en_val) + return zh_text def __init__( self, llm_manager: LLMManager, @@ -313,6 +381,7 @@ class Orchestrator: async def _router_node(self, state: GraphState) -> dict: query = state["query"] + language = state.get("language", "zh") # 使用 RouterAgent 进行意图路由 print(f"[RouterNode] Using RouterAgent to classify query: {query[:50]}...") @@ -378,6 +447,7 @@ class Orchestrator: async def _knowledge_node(self, state: GraphState) -> dict: query = state["query"] + language = state.get("language", "zh") try: # 优先调用当前 Dual-KB QA 服务 @@ -578,7 +648,7 @@ class Orchestrator: "status": "completed", "mode": "analysis", "final_output": { - "response": f"✅ 所有分析阶段已完成!\n\n已完成: {', '.join(completed_stages)}", + "response": self._l("✅ 所有分析阶段已完成!", language) + "\n\n" + self._l("**已完成**", language) + ": " + ", ".join(completed_stages), "analysis_plan": { "gene_family": gene_family, "species": species, @@ -595,14 +665,7 @@ class Orchestrator: "status": "completed", "mode": "analysis", "final_output": { - "response": f"""📋 自动规划剩余分析阶段 - -**已完成**: {', '.join(completed_stages) if completed_stages else '无'} - -**待执行** ({len(remaining_stages)}个阶段): -{stages_text} - -请确认是否按顺序执行以上分析阶段?""", + "response": f"{self._l('📋 自动规划剩余分析阶段', language)}\n\n{self._l('**已完成**', language)}: {', '.join(completed_stages) if completed_stages else self._l('无', language)}\n\n{self._l('**待执行**', language)} ({len(remaining_stages)}{self._l('个阶段)', language)}:\n{stages_text}\n\n{self._l('请确认是否按顺序执行以上分析阶段?', language)}", "analysis_plan": { "gene_family": gene_family, "species": species, @@ -630,6 +693,7 @@ class Orchestrator: async def _analysis_node(self, state: GraphState) -> dict: query = state["query"] files = state.get("files", []) + language = state.get("language", "zh") print(f"[_analysis_node] Entered with query: {query[:50]}...", flush=True) try: @@ -821,7 +885,7 @@ class Orchestrator: "status": "completed", "mode": "analysis", "final_output": { - "response": f"我将帮您执行RNA-seq {analysis_type}分析。请确认参数后开始。", + "response": self._l("我将帮您执行RNA-seq", language) + " " + analysis_type + self._l("分析。请确认参数后开始。", language), "analysis_plan": { "gene_family": gene_family, "species": species, @@ -831,7 +895,7 @@ class Orchestrator: "prefix": f"{species}.{gene_family}", "cpu": 8 }, - "guidance": "RNA-seq分析将通过前端异步执行。" + "guidance": self._l("RNA-seq分析将通过前端异步执行。", language) } } } @@ -878,7 +942,7 @@ class Orchestrator: "status": "completed", "mode": "analysis", "final_output": { - "response": f"我将帮您执行RNA-seq {analysis_type} 分析。PlanningAgentV2 已根据当前状态生成完整执行计划。", + "response": self._l("我将帮您执行RNA-seq", language) + " " + analysis_type + self._l(" 分析。PlanningAgentV2 已根据当前状态生成完整执行计划。", language), "analysis_plan": plan_result.get("final_output", {}).get("analysis_plan", {}) } } @@ -889,7 +953,7 @@ class Orchestrator: "status": "completed", "mode": "analysis", "final_output": { - "response": f"我将帮您执行RNA-seq {analysis_type} 分析。请确认参数后开始。", + "response": self._l("我将帮您执行RNA-seq", language) + " " + analysis_type + self._l(" 分析。请确认参数后开始。", language), "analysis_plan": { "gene_family": gene_family, "species": species, @@ -900,21 +964,25 @@ class Orchestrator: "prefix": f"{species}.{gene_family}", "cpu": 8 }, - "guidance": f"RNA-seq {analysis_type} 分析将通过前端异步执行。" + "guidance": self._l("RNA-seq " + analysis_type + " 分析将通过前端异步执行。", language) } } } # Ka/Ks分析:走通用规划流程(返回analysis_plan让前端执行) # 注意:不再直接调用_execute_kaks,而是通过PlanningAgentV2规划完整流程 - guidance = guidance_map.get(analysis_type, f"请上传进行{analysis_type}分析所需的文件,或确认使用系统内置数据。") - + guidance = guidance_map.get(analysis_type) + if guidance: + guidance = self._l(guidance, language) + else: + guidance = self._l("请上传进行", language) + analysis_type + self._l("分析所需的文件,或确认使用系统内置数据。", language) + return { "current_agent": "analysis", "status": "completed", "mode": "analysis", "final_output": { - "response": f"我将帮您执行{analysis_type}分析。", + "response": self._l("我将帮您执行", language) + analysis_type + self._l("分析。", language), "analysis_plan": { "gene_family": gene_family, "species": species, diff --git a/gfa_core/mas/llm/providers/deepseek.py b/gfa_core/mas/llm/providers/deepseek.py index 4b625fb9d..7d8738712 100755 --- a/gfa_core/mas/llm/providers/deepseek.py +++ b/gfa_core/mas/llm/providers/deepseek.py @@ -62,6 +62,9 @@ class DeepSeekProvider(BaseLLMProvider): tool_choice = kwargs.get("tool_choice") if tool_choice is not None: request_kwargs["tool_choice"] = tool_choice + response_format = kwargs.get("response_format") + if response_format is not None: + request_kwargs["response_format"] = response_format response = self.client.chat.completions.create(**request_kwargs) message = response.choices[0].message diff --git a/gfa_core/mas/persistent_conversation.py b/gfa_core/mas/persistent_conversation.py index d73fee6f9..4ed8237e6 100644 --- a/gfa_core/mas/persistent_conversation.py +++ b/gfa_core/mas/persistent_conversation.py @@ -9,7 +9,7 @@ from typing import Any from uuid import NAMESPACE_URL, UUID, uuid4, uuid5 from gfa_core.mas.agent_state_repository import SQLiteAgentStateRepository -from gfa_core.mas.workflow_models import GoalLedger +from gfa_core.mas.workflow_models import CurrentRecommendation, GoalLedger _INTERACTION_KINDS = { @@ -68,7 +68,7 @@ def _history(items: Any) -> tuple[dict[str, str], ...]: content = str(item.get("content") or "") if content: normalized.append({"role": item["role"], "content": content}) - return tuple(normalized[-10:]) + return tuple(normalized) class PersistentConversationStore: @@ -207,7 +207,7 @@ class PersistentConversationStore: ), expected_revision=ledger.revision, ) - stored_messages = self.repository.list_recent_messages(conversation_id, limit=10) + stored_messages = self.repository.list_messages(conversation_id) if not stored_messages: for item in _history(client_history): persisted = self.repository.append_message( @@ -220,10 +220,7 @@ class PersistentConversationStore: conversation = self.repository.get_conversation(conversation_id) assert conversation is not None assert conversation.revision == persisted.revision - stored_messages = self.repository.list_recent_messages( - conversation_id, - limit=10, - ) + stored_messages = self.repository.list_messages(conversation_id) current_user_already_saved = bool( stored_messages and stored_messages[-1].role == "user" @@ -264,6 +261,7 @@ class PersistentConversationStore: action: str | None, intent: dict[str, Any] | None = None, proposal: dict[str, str] | None = None, + recommendation: Mapping[str, Any] | None = None, ) -> GoalLedger: if interaction_kind not in _INTERACTION_KINDS: raise ValueError("interaction_kind is invalid") @@ -362,6 +360,20 @@ class PersistentConversationStore: user_terms = dict(serialized["user_terms"]) if turn.job_id: user_terms["active_job_id"] = turn.job_id + if action == "show_result": + if recommendation is None: + user_terms.pop("current_recommendation", None) + else: + current_recommendation = CurrentRecommendation.model_validate( + recommendation + ) + if current_recommendation.job_id != turn.job_id: + raise ValueError("recommendation job binding conflict") + user_terms["current_recommendation"] = ( + current_recommendation.model_dump(mode="json", exclude_none=True) + ) + if interaction_kind in {"cancel", "correction", "switch"}: + user_terms.pop("current_recommendation", None) if action == "clarify" and isinstance(proposal, dict): normalized_proposal = { key: str(proposal.get(key) or "").strip() @@ -381,6 +393,16 @@ class PersistentConversationStore: "rnaseq_family_member", }: normalized_proposal["workflow_id"] = workflow_id + action_binding = { + key: str(proposal.get(key) or "").strip() + for key in ( + "workflow_action_id", + "authorized_stages", + "target_stage", + ) + } + if all(action_binding.values()): + normalized_proposal.update(action_binding) normalized_proposal["job_id"] = turn.job_id user_terms["pending_proposal"] = normalized_proposal if ( diff --git a/gfa_core/mas/stage_capabilities.py b/gfa_core/mas/stage_capabilities.py index e2035a0cd..8349971d5 100755 --- a/gfa_core/mas/stage_capabilities.py +++ b/gfa_core/mas/stage_capabilities.py @@ -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", + ), } diff --git a/gfa_core/mas/stage_catalog.py b/gfa_core/mas/stage_catalog.py index 6cfa00836..f0c31a663 100755 --- a/gfa_core/mas/stage_catalog.py +++ b/gfa_core/mas/stage_catalog.py @@ -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", diff --git a/gfa_core/mas/stage_requirements.py b/gfa_core/mas/stage_requirements.py index 659a9e822..b1a024e4d 100755 --- a/gfa_core/mas/stage_requirements.py +++ b/gfa_core/mas/stage_requirements.py @@ -14,6 +14,7 @@ flag enables decision-making. """ from __future__ import annotations +from copy import copy from dataclasses import asdict, dataclass, field from pathlib import Path from typing import Any, Iterable @@ -93,6 +94,7 @@ def resolve_stage_requirements( params: dict[str, Any] | None = None, project_root: Path | str = PROJECT_ROOT, shadow_only: bool = True, + project_context: ProjectContextIndex | None = None, ) -> StageRequirementReport: """Resolve stage requirements without changing planning or files.""" params = dict(params or {}) @@ -110,7 +112,16 @@ def resolve_stage_requirements( report.messages.append("Cannot resolve requirements without job_id.") return report - context = build_project_context(job_id=job_id, prefix=identity.prefix, project_root=project_root) + context = project_context + if context is None: + context = build_project_context( + job_id=job_id, + prefix=identity.prefix, + project_root=project_root, + include_stage_status=False, + ) + else: + context = copy(context) _apply_identity_to_context(context, identity) if identity.stage == "identify": @@ -151,18 +162,28 @@ def resolve_many_stage_requirements( reports: dict[str, dict[str, Any]] = {} params_by_stage = params_by_stage or {} shared_params = shared_params or {} + contexts_by_prefix: dict[str, ProjectContextIndex] = {} for stage in stages: stage_params = {**shared_params, **(params_by_stage.get(stage) or {})} if stage not in CORE_REQUIREMENT_STAGES: continue try: + effective_prefix = stage_params.get("prefix") or prefix + if effective_prefix not in contexts_by_prefix: + contexts_by_prefix[effective_prefix] = build_project_context( + job_id=job_id, + prefix=effective_prefix, + project_root=project_root, + include_stage_status=False, + ) report = resolve_stage_requirements( stage=stage, - prefix=stage_params.get("prefix") or prefix, + prefix=effective_prefix, job_id=job_id, params=stage_params, project_root=project_root, shadow_only=shadow_only, + project_context=contexts_by_prefix[effective_prefix], ) reports[stage] = report.to_dict() except Exception as exc: diff --git a/gfa_core/mas/tools/stage_tools.py b/gfa_core/mas/tools/stage_tools.py index 1c5bf32d6..e65b007a5 100755 --- a/gfa_core/mas/tools/stage_tools.py +++ b/gfa_core/mas/tools/stage_tools.py @@ -559,9 +559,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, @@ -1048,6 +1054,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", diff --git a/gfa_core/mas/utils/intent_parser.py b/gfa_core/mas/utils/intent_parser.py index f75329b21..2afae0fea 100755 --- a/gfa_core/mas/utils/intent_parser.py +++ b/gfa_core/mas/utils/intent_parser.py @@ -87,6 +87,7 @@ STAGE_KEYWORDS = { "rnaseq_kegg_enrich": ["rnaseq kegg", "rnaseq_kegg", "rna kegg", "kegg富集", "kegg分析", "kegg", "富集分析", "enrich"], "rnaseq_enrich": ["富集分析", "enrich", "go分析", "kegg", "clusterprofiler"], "rnaseq_candidate_family": ["候选家族", "候选基因家族", "家族排名", "tf注释", "tf family", "tffamily", "pfam注释", "deg注释", "差异基因注释", "差异基因家族", "candidate family"], + "rnaseq_report": ["rnaseq结果报告", "rna-seq结果报告", "转录组结果报告", "rnaseq报告", "rna-seq报告"], "ncbi_fetch_sequences": ["ncbi下载", "ncbi序列", "下载序列", "下载蛋白", "获取蛋白", "下载protein", "下载cds", "protein和cds", "蛋白和cds", "基因list", "gene list", "gene_list", "id列表", ".ids"], } @@ -256,8 +257,38 @@ RNASEQ_STAGES = { "rnaseq_kegg_enrich", "rnaseq_enrich", "rnaseq_candidate_family", + "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", "苦荞", "荞麦"], @@ -273,8 +304,21 @@ SPECIES_KEYWORDS = { "Cp": ["cp", "papaya", "木瓜"], "Vv": ["vv", "grape", "葡萄"], "Lg": ["lg"], + "Gm": ["gm"], + "Br": ["br"], + "Pt": ["pt"], + "Sb": ["sb"], } +_SPECIES_CODE_PATTERN = ( + r"(? Dict[str, List[str]]: def collect_species(segment: str) -> List[str]: segment_lower = segment.lower() found: List[str] = [] - for match in re.finditer(r"(? Dict[str, List[str]]: ), ("comparativeSpecies", r"比较物种|比较|对比|comparative|comparison|outgroup|外群"), ] + role_bound_marker_patterns = { + "targetSpecies": r"目标物种|目标基因组|target\s+(?:species|genome)", + "referenceSpecies": r"参考物种|参考基因组|reference\s+(?:species|genome)", + "comparativeSpecies": r"比较物种|比较基因组|comparative\s+(?:species|genome)|outgroup", + } roles = {"targetSpecies": [], "referenceSpecies": [], "comparativeSpecies": [], "unassigned": []} # A role may follow the species, as in "Ft是目标物种". Resolve # comma-delimited clauses first so a path fragment such as ref_data cannot @@ -349,6 +402,15 @@ def _species_mentions_with_roles(query: str) -> Dict[str, List[str]]: for role, pattern in marker_patterns: if re.search(pattern, clause_lower): roles[role].extend(collect_species(clause)) + role_bound_marker = role_bound_marker_patterns[role] + for role_bound_pattern in ( + rf"(?i:(?:{role_bound_marker})\s*(?:为|是|作为|is|[::=])?\s*)({_ROLE_BOUND_SPECIES_CODE})", + rf"({_ROLE_BOUND_SPECIES_CODE})(?i:\s*(?:是|为|作为|is)?\s*(?:{role_bound_marker}))", + ): + roles[role].extend( + match.group(1) + for match in re.finditer(role_bound_pattern, clause) + ) if any(roles[role] for role in ("targetSpecies", "referenceSpecies", "comparativeSpecies")): assigned = set(roles["targetSpecies"] + roles["referenceSpecies"] + roles["comparativeSpecies"]) roles["unassigned"] = [sp for sp in collect_species(text) if sp not in assigned] @@ -368,8 +430,7 @@ def _species_mentions_with_roles(query: str) -> Dict[str, List[str]]: return {key: _unique_keep_order(value) for key, value in roles.items()} mentions: List[tuple[int, str]] = [] - code_pattern = r"(? 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" )): @@ -732,6 +797,10 @@ def parse_intent_from_query(query: str) -> Dict: "clarify_type": result.get("clarify_type"), "message": result.get("message") } + if parsed["primary_stage"] == "rnaseq_report": + parsed["params"] = { + "rnaseqReportAction": "open" if "打开" in query_lower else "generate" + } if ncbi_params: parsed["params"] = ncbi_params parsed["prefix"] = ncbi_params.get("prefix", "") @@ -781,6 +850,43 @@ def parse_intent_from_query(query: str) -> Dict: return parsed +def deterministic_agent_chat_intent( + query: str, + *, + rnaseq_context: bool = False, +) -> Optional[Dict]: + """Return explicit chat intents that must not depend on the Router model.""" + intent = parse_intent_from_query(query) + if intent.get("stages") == ["rnaseq_report"]: + return intent + query_lower = str(query or "").lower() + query_without_space = re.sub(r"\s+", "", query_lower) + is_generic_report_action = ( + rnaseq_context + and "结果报告" in query_without_space + and any(action in query_without_space for action in ("生成", "打开")) + ) + if is_generic_report_action: + return { + "gene_family": "", + "species": "", + "stages": ["rnaseq_report"], + "primary_stage": "rnaseq_report", + "task_type": "report", + "task_kind": "pipeline", + "confidence": 0.99, + "need_clarify": False, + "clarify_type": None, + "message": None, + "params": { + "rnaseqReportAction": ( + "open" if "打开" in query_without_space else "generate" + ) + }, + } + return None + + def parse_msa_region_plot_request(query: str) -> Dict: """Extract parameters for drawing a selected MSA alignment region.""" text = str(query or "").strip() @@ -1367,6 +1473,12 @@ def _extract_stages(query: str, query_lower: str) -> List[str]: stages.append("gwas_fmt") matched_keywords = [] # 记录匹配到的关键词长度,用于优先级判断 has_rnaseq_marker = any(marker in query_lower for marker in RNASEQ_MARKERS) + requests_result_report = any( + keyword in query_lower + for keyword in ["生成结果报告", "打开结果报告", "结果报告", "生成报告", "打开报告"] + ) + if has_rnaseq_marker and requests_result_report: + return ["rnaseq_report"] extraction_context = any(kw in query_lower for kw in ["提取", "extract", "gffread", "筛选", "subseq"]) explicit_gene_family_context = any(kw in query_lower for kw in ["基因家族", "家族表达", "表达量", "热图", "heatmap"]) curated_member_context = ( diff --git a/gfa_core/mas/workflow_models.py b/gfa_core/mas/workflow_models.py index a7917d885..ebebc082a 100644 --- a/gfa_core/mas/workflow_models.py +++ b/gfa_core/mas/workflow_models.py @@ -2611,6 +2611,50 @@ class WorkflowStateSnapshot(_WorkflowModel): return self +class CurrentRecommendation(_WorkflowModel): + recommendation_id: str + job_id: str + workflow_id: Literal[ + "data_preparation", + "gene_family", + "rnaseq_whole_genome", + "rnaseq_family_member", + ] + transition_id: str + from_state_id: str + to_state_id: str + target_stage: str + authorized_stages: tuple[str, ...] = Field(min_length=1) + evidence_digest: str + family: str | None = None + scope_id: str | None = None + + @model_validator(mode="after") + def validate_recommendation(self) -> "CurrentRecommendation": + for field_name in ( + "recommendation_id", + "job_id", + "transition_id", + "from_state_id", + "to_state_id", + "target_stage", + ): + _require_nonblank(getattr(self, field_name), field_name) + if len(self.authorized_stages) != len(set(self.authorized_stages)): + raise ValueError("authorized_stages must contain unique stages") + if any(not stage.strip() for stage in self.authorized_stages): + raise ValueError("authorized_stages cannot contain blank stages") + if self.target_stage not in self.authorized_stages: + raise ValueError("target_stage must be authorized") + if not _SHA256_RE.fullmatch(self.evidence_digest): + raise ValueError("evidence_digest must be a sha256 hex digest") + for field_name in ("family", "scope_id"): + value = getattr(self, field_name) + if value is not None: + _require_nonblank(value, field_name) + return self + + class GoalLedger(_WorkflowModel): goal_ledger_version: Literal["goal_ledger_v1"] = "goal_ledger_v1" conversation_id: str diff --git a/gfa_core/rnaseq_report.py b/gfa_core/rnaseq_report.py new file mode 100644 index 000000000..273de49ec --- /dev/null +++ b/gfa_core/rnaseq_report.py @@ -0,0 +1,1817 @@ +"""Evidence-bound RNA-seq result report generation. + +This module deliberately keeps the formal report independent from the live +frontend interpretation cache. It reuses the same pure evidence builder, but +materializes a deterministic Markdown report and provenance artifacts under a +job's ``final/`` directory. +""" +from __future__ import annotations + +import base64 +import hashlib +import ast +import csv +import html +import json +import math +import mimetypes +import os +import re +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any, Iterable +from urllib.parse import unquote + +from gfa_core.rnaseq_evidence import ( + RNASEQ_STAGE_NAMES, + build_rnaseq_evidence_snapshot, + resolve_artifact_path, + manifest_identity_value, + select_rnaseq_manifests, +) + + +REPORT_STAGE = "rnaseq_report" +REPORT_RENDERER_VERSION = "17" +SUCCESS_STATUSES = {"success", "partial_success"} +DEFAULT_EXPECTED_STAGES = [ + "rnaseq_qc", + "rnaseq_align", + "rnaseq_quant", + "rnaseq_sample_qc", + "rnaseq_de", +] +PREFERRED_STAGE_ORDER = [ + "rnaseq_qc", + "rnaseq_align", + "rnaseq_quant", + "rnaseq_sample_qc", + "rnaseq_de", + "annotation", + "annotation_go", + "annotation_kegg", + "rnaseq_go_enrich", + "rnaseq_kegg_enrich", + "rnaseq_enrich", + "rnaseq_fgsea", + "rnaseq_wgcna", + "rnaseq_candidate_family", + "rnaseq_family_score", +] +IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".svg", ".webp"} +STAGE_NAMES = RNASEQ_STAGE_NAMES + + +class NoSuccessfulRNASeqEvidence(RuntimeError): + """Raised when a report would otherwise be empty.""" + + +@dataclass(frozen=True) +class RNASeqReportResult: + report_path: Path + evidence_path: Path + model_path: Path + manifest_path: Path + coverage_status: str + included_stages: tuple[str, ...] + rnaseq_scope_id: str + reused: bool = False + + +def _now() -> str: + return datetime.now().astimezone().isoformat(timespec="seconds") + + +def _read_json(path: Path) -> dict[str, Any]: + try: + data = json.loads(path.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + except (OSError, json.JSONDecodeError): + return {} + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _write_text_atomic(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") + temporary.write_text(text, encoding="utf-8") + temporary.replace(path) + + +def _identity(manifest: dict[str, Any], key: str) -> str: + return manifest_identity_value(manifest, key) + + +def is_rnaseq_report_source(stage: str) -> bool: + stage = str(stage or "").strip() + return stage != REPORT_STAGE and ( + stage.startswith("rnaseq_") or stage in {"annotation", "annotation_go", "annotation_kegg"} + ) + + +def _stage_sort_key(stage: str) -> tuple[int, str]: + try: + return (PREFERRED_STAGE_ORDER.index(stage), stage) + except ValueError: + return (len(PREFERRED_STAGE_ORDER), stage) + + +def _normalize_expected_stages(stages: Iterable[str] | None) -> list[str]: + raw = list(stages) if stages is not None else list(DEFAULT_EXPECTED_STAGES) + result: list[str] = [] + for stage in raw: + value = str(stage or "").strip() + if value and is_rnaseq_report_source(value) and value not in result: + result.append(value) + return result + + +def _source_selection( + final_dir: Path, + prefix: str, + rnaseq_scope_id: str, +) -> tuple[str, list[str], dict[str, dict[str, Any]]]: + selection = select_rnaseq_manifests( + final_dir, + prefix, + rnaseq_scope_id=rnaseq_scope_id, + ) + return selection.rnaseq_scope_id, list(selection.discovered_scope_ids), selection.manifests + + +def _source_records(manifests: dict[str, dict[str, Any]]) -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + for stage in sorted(manifests, key=_stage_sort_key): + manifest = manifests[stage] + path = Path(str(manifest.get("_manifest_path") or "")) + records.append( + { + "stage": stage, + "status": str(manifest.get("status") or "unknown"), + "rnaseq_scope_id": _identity(manifest, "rnaseq_scope_id"), + "path": str(path), + "sha256": _sha256(path) if path.is_file() else "", + "mtime": datetime.fromtimestamp(path.stat().st_mtime).astimezone().isoformat(timespec="seconds") + if path.is_file() + else "", + } + ) + return records + + +def _fingerprint( + records: list[dict[str, Any]], expected_stages: list[str], use_model: bool +) -> tuple[str, str]: + source_payload = json.dumps(records, ensure_ascii=False, sort_keys=True).encode("utf-8") + source_fingerprint = hashlib.sha256(source_payload).hexdigest() + request_payload = json.dumps( + { + "source_fingerprint": source_fingerprint, + "expected_stages": expected_stages, + "use_model": use_model, + "report_renderer_version": REPORT_RENDERER_VERSION, + }, + ensure_ascii=False, + sort_keys=True, + ).encode("utf-8") + return source_fingerprint, hashlib.sha256(request_payload).hexdigest() + + +def _artifact_path(workspace: Path, manifest: dict[str, Any], item: dict[str, Any]) -> Path | None: + return resolve_artifact_path(workspace / "final", item, require_exists=True) + + +def _enrichment_figure_identity(stage: str, path: Path) -> tuple[str, str, str] | None: + name = path.name + lower = name.lower() + comparison = "" + category = "" + pathway_id = "" + if stage == "rnaseq_go_enrich" or (stage == "rnaseq_enrich" and ".go_enrich" in lower): + match = re.search(r"rnaseq_(?:go_enrich|enrich)\.(.+?)\.go_enrich", name, flags=re.IGNORECASE) + comparison = match.group(1) if match else "" + if ".go_enrich.ontology_barplot." in lower: + category = "go_ontology" + elif ".go_enrich.barplot." in lower: + category = "go_bar" + elif ".go_enrich.dotplot." in lower: + category = "go_dot" + elif re.search(r"\.go_enrich\.svg$", lower): + category = "go_bar" + elif stage == "rnaseq_kegg_enrich" or (stage == "rnaseq_enrich" and "kegg" in lower): + match = re.search(r"rnaseq_(?:kegg_enrich|enrich)\.(.+?)(?:\.kegg|_kegg)", name, flags=re.IGNORECASE) + comparison = match.group(1) if match else "" + pathway_match = re.search(r"\.kegg_pathway_map\.(ko\d+)\.", lower) + if pathway_match: + category = "kegg_pathway" + pathway_id = pathway_match.group(1) + elif re.search(r"(?:\.|_)kegg_enrich\.(?:png|jpe?g|webp|svg|pdf)$", lower): + category = "kegg_bar" + elif re.search(r"_kegg_dotplot\.(?:png|jpe?g|webp|svg|pdf)$", lower): + category = "kegg_dot" + elif stage == "rnaseq_fgsea": + match = re.search(r"rnaseq_fgsea\.(.+?)\.fgsea", name, flags=re.IGNORECASE) + comparison = match.group(1) if match else "" + if ".fgsea_kegg_dotplot." in lower: + category = "fgsea_dot" + elif ".fgsea_kegg_top_enrichment." in lower: + category = "fgsea_curve" + if not category: + return None + identity = f"{category}:{comparison}:{pathway_id}" if category == "kegg_pathway" else category + return identity, comparison, pathway_id + + +def _artifact_comparison(stage: str, path: Path, category: str = "") -> str: + patterns = { + "rnaseq_go_enrich": r"rnaseq_go_enrich\.(.+?)\.go_enrich", + "rnaseq_kegg_enrich": r"rnaseq_kegg_enrich\.(.+?)(?:\.kegg|_kegg)", + "rnaseq_fgsea": r"rnaseq_fgsea\.(.+?)\.fgsea", + } + if stage == "rnaseq_enrich": + pattern = ( + r"rnaseq_enrich\.(.+?)\.go_enrich" + if category.startswith("go_") + else r"rnaseq_enrich\.(.+?)(?:\.kegg|_kegg)" + ) + else: + pattern = patterns.get(stage) + match = re.search(pattern, path.name, flags=re.IGNORECASE) if pattern else None + return match.group(1) if match else "" + + +def _table_rows(path: Path) -> list[dict[str, str]]: + try: + with path.open("r", encoding="utf-8", errors="replace", newline="") as handle: + delimiter = "," if path.suffix.lower() == ".csv" else "\t" + return [ + {str(key): str(value or "") for key, value in row.items() if key is not None} + for row in csv.DictReader(handle, delimiter=delimiter) + ] + except OSError: + return [] + + +def _supporting_tables( + workspace: Path, + manifest: dict[str, Any], + comparison: str, + category: str = "", +) -> list[tuple[Path, list[dict[str, str]]]]: + tables: list[tuple[Path, list[dict[str, str]]]] = [] + for item in (manifest.get("artifacts") or {}).values(): + if not isinstance(item, dict): + continue + path = _artifact_path(workspace, manifest, item) + if ( + not path + or not path.is_file() + or path.suffix.lower() not in {".txt", ".tsv", ".csv"} + or ( + comparison + and _artifact_comparison(str(manifest.get("stage") or ""), path, category) != comparison + ) + ): + continue + lower_name = path.name.lower() + if category.startswith("go_") and not lower_name.endswith(".go_enrich.txt"): + continue + if category in {"kegg_bar", "kegg_dot"} and not lower_name.endswith(".kegg_enrich.txt"): + continue + if category == "kegg_pathway" and not ( + lower_name.endswith(".kegg_enrich.txt") + or ".kegg_pathway_gene_hits." in lower_name + or ".kegg_pathway_mapper." in lower_name + or ".kegg_pathway_map.ko_log2fc." in lower_name + ): + continue + if category.startswith("fgsea_") and not lower_name.endswith(".fgsea_kegg.tsv"): + continue + rows = _table_rows(path) + if rows: + tables.append((path, rows)) + return tables + + +def _best_adjusted_p(tables: list[tuple[Path, list[dict[str, str]]]]) -> float: + values: list[float] = [] + for _, rows in tables: + for row in rows: + raw = row.get("p.adjust") or row.get("padj") or row.get("FDR") or "" + try: + value = float(raw) + if math.isfinite(value) and 0.0 <= value <= 1.0: + values.append(value) + except (TypeError, ValueError): + continue + return min(values, default=float("inf")) + + +def _fgsea_selection_score(tables: list[tuple[Path, list[dict[str, str]]]]) -> tuple[float, float]: + scores: list[tuple[float, float]] = [] + for _, rows in tables: + for row in rows: + try: + padj = float(row.get("padj") or row.get("p.adjust") or "") + nes = float(row.get("NES") or "") + except (TypeError, ValueError): + continue + if math.isfinite(padj) and 0.0 <= padj <= 1.0 and math.isfinite(nes): + scores.append((padj, -abs(nes))) + return min(scores, default=(float("inf"), float("inf"))) + + +def _support_labels(value: str) -> list[str]: + """Parse both the real semicolon serialization and legacy list encodings.""" + raw = str(value or "").strip() + if not raw: + return [] + if raw[:1] in {"[", "("}: + try: + parsed = json.loads(raw) if raw.startswith("[") else ast.literal_eval(raw) + except (ValueError, SyntaxError, json.JSONDecodeError): + try: + parsed = ast.literal_eval(raw) + except (ValueError, SyntaxError): + parsed = None + if isinstance(parsed, (list, tuple)): + return list(dict.fromkeys(str(item).strip() for item in parsed if str(item).strip())) + return list(dict.fromkeys(item.strip() for item in raw.split(";") if item.strip())) + + +def _support_identity(label: str) -> tuple[str, str]: + raw = str(label or "").strip() + go_match = re.match(r"(?i)^GO:(GO:\d+)(?::.*)?$", raw) + if go_match: + return "go", go_match.group(1) + match = re.match(r"(?i)^(KEGG|fgsea):([^:]+)(?::.*)?$", raw) + if match: + return match.group(1).lower(), match.group(2).lower() + return "", "" + + +def _candidate_rows(workspace: Path, manifests: dict[str, dict[str, Any]]) -> list[dict[str, Any]]: + manifest = manifests.get("rnaseq_family_score") or {} + item = (manifest.get("artifacts") or {}).get("analysis_ready_family") + if not isinstance(item, dict): + return [] + path = _artifact_path(workspace, manifest, item) + if not path or not path.is_file(): + return [] + rows: list[dict[str, Any]] = [] + for index, row in enumerate(_table_rows(path), 1): + family = str(row.get("family_id") or row.get("family_name") or "").strip() + comparison = str(row.get("best_comparison") or "").strip() + if not family or not comparison: + continue + try: + rank = int(str(row.get("rank") or index)) + except ValueError: + rank = index + rows.append( + { + "family": family, + "family_name": str(row.get("family_name") or "").strip(), + "rank": rank, + "comparison": comparison, + "support_labels": _support_labels(str(row.get("best_support_labels") or "")), + "source_table": str(path), + } + ) + return sorted(rows, key=lambda row: (row["rank"], row["family"])) + + +def _pathway_adjusted_p( + tables: list[tuple[Path, list[dict[str, str]]]], pathway_id: str +) -> float: + row = _first_matching_row(tables, pathway_id=pathway_id) + try: + value = float(row.get("p.adjust") or row.get("padj") or row.get("FDR") or "") + except (TypeError, ValueError): + return float("inf") + return value if math.isfinite(value) and 0.0 <= value <= 1.0 else float("inf") + + +def _figures( + workspace: Path, manifests: dict[str, dict[str, Any]] +) -> tuple[list[dict[str, Any]], list[dict[str, Any]], str]: + candidates: list[dict[str, Any]] = [] + seen: set[str] = set() + for stage in sorted(manifests, key=_stage_sort_key): + artifacts = manifests[stage].get("artifacts") or {} + if not isinstance(artifacts, dict): + continue + for key, item in sorted(artifacts.items()): + if not isinstance(item, dict): + continue + path = _artifact_path(workspace, manifests[stage], item) + if not path or not path.is_file(): + continue + classified = _enrichment_figure_identity(stage, path) + if path.suffix.lower() not in IMAGE_SUFFIXES and not (path.suffix.lower() == ".pdf" and classified): + continue + identity = str(path.resolve()) + if identity in seen: + continue + seen.add(identity) + row = {"stage": stage, "artifact": str(key), "path": str(path)} + if classified: + identity, comparison, pathway_id = classified + row.update( + { + "selection_identity": identity, + "category": identity.split(":", 1)[0], + "comparison": comparison, + "pathway_id": pathway_id, + } + ) + candidates.append(row) + + selected: list[dict[str, Any]] = [] + enrichment_candidates: list[dict[str, Any]] = [] + for row in candidates: + if not row.get("selection_identity"): + row.update( + candidate_families=[], support_labels=[], selection_reason="all_stage_figures", + source_tables=[], + ) + selected.append(row) + else: + enrichment_candidates.append(row) + + formal_candidates = _candidate_rows(workspace, manifests) + format_rank = {".png": 0, ".jpg": 1, ".jpeg": 1, ".webp": 2, ".svg": 3, ".pdf": 4} + table_cache: dict[tuple[str, str, str], list[tuple[Path, list[dict[str, str]]]]] = {} + + def tables_for(row: dict[str, Any]) -> list[tuple[Path, list[dict[str, str]]]]: + cache_key = (row["stage"], row.get("comparison", ""), row.get("category", "")) + if cache_key not in table_cache: + table_cache[cache_key] = _supporting_tables( + workspace, manifests[row["stage"]], cache_key[1], cache_key[2] + ) + return table_cache[cache_key] + + def table_contains(category: str, identifier: str, row: dict[str, Any]) -> bool: + target_identifier = identifier.lower() + if category == "kegg_pathway": + return True + tables = tables_for(row) + if category == "fgsea_curve": + top = _best_fgsea_row(tables) + top_identifier = str(top.get("pathway") or top.get("PathwayID") or top.get("ID") or "") + return bool(top_identifier) and top_identifier.lower() == target_identifier + for _, rows in tables: + for table_row in rows: + value = str( + table_row.get("ID") + or table_row.get("PathwayID") + or table_row.get("pathway") + or "" + ) + if value.lower() == target_identifier: + return True + return False + + def row_support(row: dict[str, Any]) -> tuple[list[str], list[str], int, list[str]]: + category = row.get("category", "") + wanted_kind = ( + "go" if category.startswith("go_") else "fgsea" if category.startswith("fgsea_") else "kegg" + ) + pathway_id = str(row.get("pathway_id") or "").lower() + matched: list[tuple[int, str, str, str]] = [] + for candidate in formal_candidates: + if candidate["comparison"] != row.get("comparison"): + continue + for label in candidate["support_labels"]: + kind, identifier = _support_identity(label) + if ( + kind != wanted_kind + or (category == "kegg_pathway" and identifier != pathway_id) + or not table_contains(category, identifier, row) + ): + continue + matched.append( + (candidate["rank"], candidate["family"], label, candidate["source_table"]) + ) + matched.sort(key=lambda value: (value[0], value[1], value[2])) + return ( + list(dict.fromkeys(value[1] for value in matched)), + list(dict.fromkeys(value[2] for value in matched)), + matched[0][0] if matched else 10**9, + list(dict.fromkeys(value[3] for value in matched)), + ) + + def evidence_score(row: dict[str, Any]) -> tuple[Any, ...]: + tables = tables_for(row) + if row.get("category") == "fgsea_curve": + evidence: tuple[Any, ...] = _fgsea_selection_score(tables) + elif row.get("category") == "kegg_pathway": + evidence = (_pathway_adjusted_p(tables, row.get("pathway_id", "")),) + else: + evidence = (_best_adjusted_p(tables),) + return (*evidence, format_rank.get(Path(row["path"]).suffix.lower(), 9), Path(row["path"]).name) + + for row in enrichment_candidates: + families, labels, candidate_rank, candidate_tables = row_support(row) + row["candidate_families"] = families + row["support_labels"] = labels + row["candidate_rank"] = candidate_rank + row["source_tables"] = candidate_tables + [str(path) for path, _ in tables_for(row)] + + selection_mode = ( + "candidate_guided" + if any(row.get("support_labels") for row in enrichment_candidates) + else "representative_fallback" + ) + if selection_mode == "representative_fallback": + for row in enrichment_candidates: + row["candidate_families"] = [] + row["support_labels"] = [] + row["candidate_rank"] = 10**9 + row["source_tables"] = [str(path) for path, _ in tables_for(row)] + + groups: dict[str, list[dict[str, Any]]] = {} + for row in enrichment_candidates: + category = row.get("category", "") + if selection_mode == "candidate_guided" and not row.get("support_labels"): + continue + key = ( + f"{category}:{row.get('comparison', '')}:{row.get('pathway_id', '')}" + if selection_mode == "candidate_guided" and category == "kegg_pathway" + else f"{category}:{row.get('pathway_id', '')}" + if category == "kegg_pathway" + else category + ) + groups.setdefault(key, []).append(row) + + for rows in groups.values(): + rows.sort( + key=lambda row: ( + row.get("candidate_rank", 10**9) if selection_mode == "candidate_guided" else 0, + *evidence_score(row), + ) + ) + chosen = rows[0] + chosen["selection_reason"] = ( + "candidate_family_support" if selection_mode == "candidate_guided" else "representative_fallback" + ) + selected.append(chosen) + + selected_paths = {row["path"] for row in selected} + omitted: list[dict[str, Any]] = [] + for row in enrichment_candidates: + if row["path"] in selected_paths: + continue + row.setdefault("candidate_families", []) + row.setdefault("support_labels", []) + row.setdefault("source_tables", []) + row["selection_reason"] = ( + "not_candidate_supported" + if selection_mode == "candidate_guided" and not row["support_labels"] + else "lower_ranked_representative" + ) + omitted.append(row) + stage_rank = {stage: index for index, stage in enumerate(PREFERRED_STAGE_ORDER)} + ordered = sorted( + selected, + key=lambda row: (stage_rank.get(row["stage"], len(stage_rank)), Path(row["path"]).name), + ) + omitted.sort(key=lambda row: (stage_rank.get(row["stage"], len(stage_rank)), Path(row["path"]).name)) + return ordered, omitted, selection_mode + + +def _first_matching_row( + tables: list[tuple[Path, list[dict[str, str]]]], + *, + pathway_id: str = "", +) -> dict[str, str]: + rows = [row for _, table_rows in tables for row in table_rows] + if pathway_id: + for row in rows: + identifier = row.get("ID") or row.get("PathwayID") or row.get("pathway") or "" + if identifier.lower() == pathway_id.lower(): + return row + return {} + return rows[0] if rows else {} + + +def _best_fgsea_row(tables: list[tuple[Path, list[dict[str, str]]]]) -> dict[str, str]: + ranked: list[tuple[float, float, int, dict[str, str]]] = [] + for _, rows in tables: + for index, row in enumerate(rows): + try: + padj = float(row.get("padj") or row.get("p.adjust") or "") + nes = float(row.get("NES") or "") + except (TypeError, ValueError): + continue + if math.isfinite(padj) and 0.0 <= padj <= 1.0 and math.isfinite(nes): + ranked.append((padj, -abs(nes), index, row)) + return min(ranked, default=(float("inf"), float("inf"), 0, {}))[-1] + + +def _value(row: dict[str, str], *keys: str) -> str: + for key in keys: + value = str(row.get(key) or "").strip() + if value: + return value + return "未登记" + + +def _probability_value(row: dict[str, str], *keys: str) -> str: + raw = _value(row, *keys) + try: + value = float(raw) + except (TypeError, ValueError): + return "未登记" + return raw if math.isfinite(value) and 0.0 <= value <= 1.0 else "未登记" + + +def _field_items(row: dict[str, str], *keys: str) -> set[str]: + value = next((str(row.get(key) or "").strip() for key in keys if str(row.get(key) or "").strip()), "") + return {item for item in re.split(r"[/,;\s]+", value) if item} + + +def _ordered_field_items(row: dict[str, str], *keys: str) -> list[str]: + value = next((str(row.get(key) or "").strip() for key in keys if str(row.get(key) or "").strip()), "") + return list(dict.fromkeys(item for item in re.split(r"[/,;\s]+", value) if item)) + + +def _item_summary(label: str, items: set[str]) -> str: + ordered = sorted(items) + if not ordered: + return "" + preview = "/".join(ordered[:5]) + return f"{label}总数={len(ordered)},前5项={preview}" + + +def _source_table_links(report_path: Path, tables: list[tuple[Path, list[dict[str, str]]]], markers: tuple[str, ...]) -> str: + matched = [path for path, _ in tables if any(marker in path.name.lower() for marker in markers)] + links = [ + f"[下载完整数据{' ' + str(index) if len(matched) > 1 else ''}]({_relative_link(report_path, report_path.parent / path.name)})" + for index, path in enumerate(matched, start=1) + ] + return ";" + "、".join(links) if links else "" + + +def _figure_interpretation( + figure: dict[str, str], workspace: Path, manifests: dict[str, dict[str, Any]], report_path: Path +) -> list[str]: + category = figure.get("category", "") + comparison = _figure_comparison(figure) + if not category: + figure_type = _figure_display_type(figure) + meanings = { + "pca": "PCA图概括样本整体表达谱的主要差异及样本间距离。", + "sample_clustering": "样本聚类图按整体表达相似性展示样本之间的层次关系。", + "sample_correlation": "样本相关性热图展示任意两个样本表达谱的一致程度。", + "sample_distance": "样本距离热图展示样本之间整体表达差异的大小。", + "volcano": "火山图同时展示差异基因的变化幅度和统计显著性。", + "ma_plot": "MA图展示平均表达量与表达变化幅度之间的关系。", + "heatmap": "热图展示代表基因在不同样本中的相对表达模式。", + "upset": "UpSet图展示多个比较组差异基因集合的交集关系。", + "venn": "Venn图展示不同比较组差异基因集合的共有和特有部分。", + "module_trait": "模块性状相关图展示共表达模块与样本性状之间的相关性。", + "network": "网络图展示候选基因或共表达模块成员之间的连接关系。", + "tree": "聚类树展示基因或样本按相似性形成的层次结构。", + "boxplot": "箱线图展示不同样本或分组中数值的分布范围。", + "density": "密度图展示各样本表达量的整体分布形态。", + "other": "结果图展示该分析阶段的主要数据分布或分组关系。", + } + readings = { + "pca": "查看同组重复是否聚集、不同组是否分离,并留意明显偏离的样本。", + "sample_clustering": "查看同组样本是否优先聚为一支,并关注聚类距离较远的重复。", + "sample_correlation": "结合颜色图例读取相关系数,颜色越接近高值表示样本越相似。", + "sample_distance": "结合颜色图例比较样本距离,重点查看组内重复是否小于组间距离。", + "volcano": "横轴表示log2FoldChange,纵轴表示显著性,结合颜色区分上调、下调和不显著基因。", + "ma_plot": "横轴表示平均表达水平,纵轴表示log2FoldChange,重点查看显著基因的变化方向。", + "heatmap": "按颜色图例读取相对高低表达,并比较样本聚类与分组是否一致。", + "upset": "先看各集合大小,再读取连接点对应的交集柱高。", + "venn": "读取重叠区域和非重叠区域的基因数量。", + "module_trait": "结合颜色和相关系数读取正负相关,并同时关注显著性标记。", + "network": "结合节点、连线和图例查看关键节点及连接较紧密的区域。", + "tree": "分支越近表示相似性越高,重点查看分组或模块划分位置。", + "boxplot": "比较中位数、四分位范围和离群点,并查看组间分布差异。", + "density": "比较曲线峰值和整体形状,检查样本分布是否大体一致。", + "other": "结合图题、坐标轴、图例和分组标签读取主要结果。", + } + metrics = manifests.get(str(figure.get("stage") or ""), {}).get("metrics") or {} + if figure_type in {"volcano", "ma_plot"}: + comparison_metrics = (metrics.get("comparisons") or {}).get(comparison, {}) + up_count = _value(comparison_metrics, "up") + down_count = _value(comparison_metrics, "down") + total_count = _value(comparison_metrics, "total") + result = ( + f"比较组 `{comparison or '未识别'}` 的差异基因分布见图;" + f"上调基因数={up_count},下调基因数={down_count}," + f"显著差异基因总数={total_count}。" + ) + elif figure_type in {"pca", "sample_clustering", "sample_correlation", "sample_distance"}: + result = "本次样本的组内重复关系和组间差异见图,具体以图中的样本标签、距离或颜色数值为准。" + elif comparison: + result = f"比较组 `{comparison}` 的主要结果见图,具体数值以坐标轴、图例和标注为准。" + else: + result = "本次分析的主要结果见图,具体数值以坐标轴、图例和标注为准。" + return [ + f"图形含义:{meanings.get(figure_type, meanings['other'])}", + f"阅读方法:{readings.get(figure_type, readings['other'])}", + f"本次结果:{result}", + ] + tables = _supporting_tables(workspace, manifests[figure["stage"]], comparison, category) + pathway_id = figure.get("pathway_id", "") + row = ( + _best_fgsea_row(tables) + if category.startswith("fgsea_") + else _first_matching_row(tables, pathway_id=pathway_id) + ) + if not row: + result = f"比较组 `{comparison or '未识别'}` 证据不足:缺少可匹配的数值表,无法可靠判断本次数据的具体趋势。" + elif category == "go_ontology": + ontology_rows: dict[str, dict[str, str]] = {} + for _, table_rows in tables: + for candidate in table_rows: + ontology_value = str(candidate.get("Ontology") or "").strip().lower() + ontology = { + "bp": "BP", + "biological process": "BP", + "cc": "CC", + "cellular component": "CC", + "mf": "MF", + "molecular function": "MF", + }.get(ontology_value, "") + if ontology in {"BP", "CC", "MF"} and ontology not in ontology_rows: + ontology_rows[ontology] = candidate + summaries = [ + f"{ontology}={_value(ontology_rows[ontology], 'Description')}" + for ontology in ("BP", "CC", "MF") + if ontology in ontology_rows + ] + result = ( + f"比较组 `{comparison}` 的三类本体代表条目为" + f"{';'.join(summaries) if summaries else '未登记'}。" + ) + elif category.startswith("go_"): + result = ( + f"比较组 `{comparison}` 的代表条目为 {_value(row, 'ID')}({_value(row, 'Description')})," + f"Ontology={_value(row, 'Ontology')},GeneRatio={_value(row, 'GeneRatio')}," + f"Count={_value(row, 'Count')},p.adjust={_probability_value(row, 'p.adjust')}。" + ) + elif category in {"kegg_bar", "kegg_dot"}: + genes = _field_items(row, "geneID", "Genes") + kos = set(re.findall(r"K\d+", " ".join(str(row.get(key) or "") for key in ("KO", "KOs", "ko")))) + details = [ + f"GeneRatio={_value(row, 'GeneRatio')}", + f"Count={_value(row, 'Count')}", + f"p.adjust={_probability_value(row, 'p.adjust', 'FDR')}", + ] + details.extend(summary for summary in (_item_summary('命中基因', genes), _item_summary('KO', kos)) if summary) + result = ( + f"比较组 `{comparison}` 的代表通路为 {_value(row, 'ID')}({_value(row, 'Description')})," + f"{','.join(details)}" + f"{_source_table_links(report_path, tables, ('.kegg_enrich.txt',))}。" + ) + elif category == "kegg_pathway": + pathway_genes: set[str] = set() + pathway_kos: set[str] = set() + for _, table_rows in tables: + for candidate in table_rows: + identifier = candidate.get("PathwayID") or candidate.get("ID") or candidate.get("pathway") or "" + if identifier.lower() == pathway_id.lower(): + pathway_genes.update( + gene + for gene in re.split(r"[/,;\s]+", candidate.get("Genes") or candidate.get("geneID") or "") + if gene + ) + pathway_kos.update(re.findall(r"K\d+", candidate.get("KO") or "")) + if not pathway_genes: + pathway_genes.update( + gene for gene in re.split(r"[/,;\s]+", _value(row, "Genes", "geneID")) if gene != "未登记" + ) + ko_changes: list[float] = [] + if pathway_kos: + for table_path, table_rows in tables: + if "ko_log2fc" not in table_path.name.lower(): + continue + for candidate in table_rows: + ko = candidate.get("KO") or candidate.get("ko") or "" + if ko not in pathway_kos: + continue + try: + ko_changes.append(float(candidate.get("log2FoldChange") or candidate.get("log2FC") or "")) + except (TypeError, ValueError): + continue + direction = "" + if ko_changes: + direction = f",KO变化中上调{sum(value > 0 for value in ko_changes)}个、下调{sum(value < 0 for value in ko_changes)}个" + elif not pathway_kos: + direction = ",KO对应关系证据不足,未汇总KO表达方向" + details = [f"p.adjust={_probability_value(row, 'p.adjust', 'padj', 'FDR')}"] + details.extend( + summary for summary in (_item_summary('命中基因', pathway_genes), _item_summary('KO', pathway_kos)) if summary + ) + result = ( + f"比较组 `{comparison}` 的通路 {pathway_id}({_value(row, 'PathwayName', 'Description', 'pathway_name')})," + f"{','.join(details)}{direction}" + f"{_source_table_links(report_path, tables, ('kegg_pathway_gene_hits', 'ko_log2fc'))}。" + ) + else: + leading_edge = _ordered_field_items(row, "leadingEdge") + details = [ + f"NES={_value(row, 'NES')}", + f"padj={_probability_value(row, 'padj', 'p.adjust')}", + ] + if leading_edge: + details.append(f"leadingEdge总数={len(leading_edge)},前5项={'/'.join(leading_edge[:5])}") + result = ( + f"比较组 `{comparison}` 的通路 {_value(row, 'pathway', 'ID')}({_value(row, 'pathway_name', 'Description')})," + f"{','.join(details)}" + f"{_source_table_links(report_path, tables, ('.fgsea_kegg.tsv',))}。" + ) + + meanings = { + "go_bar": "GO富集条形图概括显著功能条目及其富集强度。", + "go_dot": "GO富集气泡图同时呈现功能条目、富集比例、基因数和显著性。", + "go_ontology": "GO本体分面图并列展示BP、CC和MF三类功能条目。", + "kegg_bar": "KEGG富集条形图概括显著通路及其富集比例。", + "kegg_dot": "KEGG富集气泡图同时呈现通路富集比例、命中基因数和显著性。", + "kegg_pathway": "KEGG Pathway图将该比较组的KO表达变化映射到具体通路节点。", + "fgsea_dot": "FGSEA气泡图汇总基于全基因排序的KEGG通路NES及显著性。", + "fgsea_curve": "FGSEA富集曲线展示代表通路成员在全基因排序中的累积富集位置。", + } + readings = { + "go_bar": "结合条目名称、柱长、Count及校正后P值阅读。", + "go_dot": "结合横轴富集比例、点大小所示Count及颜色所示显著性阅读。", + "go_ontology": "分别比较BP、CC和MF分面中的条目、比例和显著性。", + "kegg_bar": "结合通路名称、柱长、GeneRatio、Count及校正后P值阅读。", + "kegg_dot": "结合GeneRatio、点大小所示Count及颜色所示显著性阅读。", + "kegg_pathway": "按图例读取节点颜色,并结合命中基因、KO及log2FoldChange表判断变化方向。", + "fgsea_dot": "NES正负表示富集方向,点大小和颜色需结合size与padj阅读。", + "fgsea_curve": "结合运行富集分数、成员命中位置和leadingEdge基因阅读。", + } + boundary = ( + "富集结果表示统计关联,不足以单独证明功能或通路被激活、抑制或具有因果调控关系。" + if not category.startswith("fgsea_") + else "FGSEA基于全基因排序,不能与仅使用显著差异基因的ORA结果混为一谈,也不证明因果关系。" + ) + return [ + f"图形含义:{meanings[category]}", + f"阅读方法:{readings[category]}", + f"本次结果:{result}", + ] + + +def _relative_link(report_path: Path, target: Path) -> str: + try: + return os.path.relpath(target, report_path.parent).replace(os.sep, "/") + except ValueError: + return str(target) + + +def _table_cell(value: Any) -> str: + return str(value if value is not None else "").replace("|", "/").replace("\r", " ").replace("\n", " ").strip() + + +def _display_metrics(manifest: dict[str, Any], max_metrics: int = 12) -> list[tuple[str, str]]: + labels = { + "total_samples": "样本数", + "sample_count": "样本数", + "total_raw_reads": "原始 reads 总数", + "total_clean_reads": "过滤后 reads 总数", + "tested_genes": "参与检验的基因数", + "significant_degs": "显著差异基因数", + "up_genes": "上调基因数", + "down_genes": "下调基因数", + } + hidden_terms = ("fingerprint", "inventory", "renderer", "source_manifest", "request_", "omitted_") + rows: list[tuple[str, str]] = [] + for key, value in (manifest.get("metrics") or {}).items(): + lowered = str(key).lower() + if any(term in lowered for term in hidden_terms): + continue + if value is None or isinstance(value, (dict, list, tuple, set)): + continue + if isinstance(value, bool): + display = "是" if value else "否" + elif isinstance(value, int): + display = f"{value:,}" + elif isinstance(value, float): + if not math.isfinite(value): + continue + display = f"{value:,.4g}" + else: + display = str(value).strip() + if not display or len(display) > 160 or "samples={" in display.lower(): + continue + label = labels.get(lowered, str(key).replace("_", " ")) + rows.append((_table_cell(label), _table_cell(display))) + if len(rows) >= max_metrics: + break + return rows + + +def _preferred_web_figure(path: Path) -> Path: + for suffix in (".png", ".webp", ".jpg", ".jpeg", ".svg"): + candidate = path.with_suffix(suffix) + if candidate.is_file(): + return candidate + return path + + +def _sample_metric_table(manifest: dict[str, Any], stage: str, max_columns: int = 8) -> dict[str, Any] | None: + samples = (manifest.get("metrics") or {}).get("samples") + if not isinstance(samples, dict) or not samples: + return None + available: set[str] = set() + for values in samples.values(): + if isinstance(values, dict): + available.update( + str(key) for key, value in values.items() + if value is None or isinstance(value, (str, int, float, bool)) + ) + priorities = { + "rnaseq_qc": ("raw_reads", "clean_reads", "q20", "q30", "gc", "adapter", "low_quality", "duplication"), + "rnaseq_align": ("total_reads", "alignment_rate", "overall_alignment", "unique", "multiple", "unmapped"), + "rnaseq_quant": ("assigned_reads", "total_counted_reads", "assigned_percent", "detected_genes", "mapped_reads"), + }.get(stage, ()) + columns: list[str] = [] + for token in priorities: + for key in sorted(available): + if key not in columns and token in key.lower(): + columns.append(key) + columns.extend(key for key in sorted(available) if key not in columns) + columns = columns[:max_columns] + if not columns: + return None + rows = [] + for sample_id, values in sorted(samples.items()): + values = values if isinstance(values, dict) else {} + rows.append([_table_cell(sample_id), *[_table_cell(values.get(column, "")) for column in columns]]) + return { + "columns": ["sample_id", *[column.replace("_", " ") for column in columns]], + "rows": rows, + } + + +def _figure_display_type(figure: dict[str, Any]) -> str: + category = str(figure.get("category") or "").strip().lower() + if category: + return category + name = f"{figure.get('artifact', '')} {Path(str(figure.get('path') or '')).stem}".lower() + types = ( + ("sample_correlation", ("correlation",)), + ("sample_distance", ("sample_distance", "distance_heatmap")), + ("sample_clustering", ("sample_clustering", "cluster")), + ("pca", ("pca",)), + ("volcano", ("volcano",)), + ("ma_plot", ("ma_plot", "maplot")), + ("heatmap", ("heatmap",)), + ("upset", ("upset",)), + ("venn", ("venn",)), + ("boxplot", ("boxplot",)), + ("density", ("density",)), + ("module_trait", ("module_trait",)), + ("network", ("network",)), + ("sample_tree", ("sample_tree",)), + ("gene_dendrogram", ("gene_dendrogram", "dendrogram_modules")), + ("tree", ("dendrogram", "tree")), + ) + for figure_type, tokens in types: + if any(token in name for token in tokens): + return figure_type + return "other" + + +def _figure_comparison(figure: dict[str, Any]) -> str: + explicit = str(figure.get("comparison") or "").strip() + if explicit: + return explicit + name = Path(str(figure.get("path") or "")).stem + lowered = name.lower() + marker = "rnaseq_de." + start = lowered.find(marker) + value = name[start + len(marker):] if start >= 0 else name + for suffix in ("_volcano_plot", ".volcano", "_ma_plot", ".ma_plot", ".top_de", "_heatmap"): + index = value.lower().find(suffix) + if index > 0: + return value[:index] + return "" + + +def _select_report_figures(figures: list[dict[str, Any]]) -> list[dict[str, Any]]: + table_only_stages = {"rnaseq_qc", "rnaseq_align", "rnaseq_quant"} + de_groups: dict[str, set[str]] = {} + for figure in figures: + if figure.get("stage") != "rnaseq_de": + continue + comparison = _figure_comparison(figure) + if comparison: + de_groups.setdefault(comparison, set()).add(_figure_display_type(figure)) + selected_comparison = "" + if de_groups: + selected_comparison = sorted(de_groups, key=lambda value: (-len(de_groups[value]), value))[0] + + selected: dict[tuple[str, str], dict[str, Any]] = {} + order: list[tuple[str, str]] = [] + format_rank = {".png": 0, ".webp": 1, ".jpg": 2, ".jpeg": 2, ".svg": 3, ".pdf": 4} + for figure in figures: + stage = str(figure.get("stage") or "") + if stage in table_only_stages: + continue + if stage == "rnaseq_de" and selected_comparison and _figure_comparison(figure) != selected_comparison: + continue + key = (stage, _figure_display_type(figure)) + current = selected.get(key) + if current is None: + selected[key] = figure + order.append(key) + continue + current_rank = format_rank.get(Path(str(current.get("path") or "")).suffix.lower(), 9) + new_rank = format_rank.get(Path(str(figure.get("path") or "")).suffix.lower(), 9) + if new_rank < current_rank: + selected[key] = figure + return [selected[key] for key in order] + + +def _stage_table_previews( + workspace: Path, + manifest: dict[str, Any], + report_path: Path, + *, + max_tables: int = 2, + max_rows: int = 10, + max_columns: int = 8, +) -> list[dict[str, Any]]: + candidates: list[tuple[int, str, Path]] = [] + priority_terms = ("summary", "candidate", "top", "metric", "stat", "result", "deg", "enrich") + for name, item in (manifest.get("artifacts") or {}).items(): + if not isinstance(item, dict): + continue + path = _artifact_path(workspace, manifest, item) + if path is None or path.suffix.lower() not in {".csv", ".tsv"}: + continue + lowered = f"{name} {path.name}".lower() + score = sum(1 for term in priority_terms if term in lowered) + candidates.append((-score, str(name), path)) + previews: list[dict[str, Any]] = [] + for _score, name, path in sorted(candidates, key=lambda item: (item[0], item[2].name)): + delimiter = "," if path.suffix.lower() == ".csv" else "\t" + try: + with path.open("r", encoding="utf-8", errors="replace", newline="") as handle: + reader = csv.DictReader(handle, delimiter=delimiter) + columns = [str(column) for column in (reader.fieldnames or [])[:max_columns] if str(column).strip()] + if not columns: + continue + rows = [] + for row in reader: + rows.append([_table_cell(row.get(column, "")) for column in columns]) + if len(rows) >= max_rows: + break + except (OSError, csv.Error): + continue + if not rows: + continue + previews.append( + { + "name": name, + "filename": path.name, + "columns": columns, + "rows": rows, + "link": _relative_link(report_path, path), + } + ) + if len(previews) >= max_tables: + break + return previews + + +def _render_deterministic_markdown( + *, + prefix: str, + scope_id: str, + evidence: dict[str, Any], + manifests: dict[str, dict[str, Any]], + expected_stages: list[str], + stage_statuses: dict[str, str], + coverage_status: str, + report_path: Path, + workspace: Path, + generated_at: str, + figures: list[dict[str, Any]], + model_summary: str = "", +) -> str: + metadata = evidence.get("metadata") or {} + included = list(metadata.get("included_stages") or []) + flow_rows = {row.get("id"): row for row in (evidence.get("stage_flow") or {}).get("stages", [])} + findings = {row.get("stage"): row for row in evidence.get("key_findings") or []} + reviews = {row.get("stage"): row for row in evidence.get("stage_reviews") or []} + evidence_cards = evidence.get("evidence_cards") or [] + risks = evidence.get("risk_flags") or [] + missing = [stage for stage in expected_stages if stage_statuses.get(stage) not in SUCCESS_STATUSES] + figures = _select_report_figures(figures) + figure_index = 0 + rendered_figures: set[str] = set() + + section_specs = [ + ("## 3. 测序质量检查", ["rnaseq_qc"], "本节检查测序数据是否稳定、是否存在需要留意的样本。"), + ("## 4. 参考基因组比对", ["rnaseq_align"], "比对率表示测序片段能在参考基因组中找到位置的比例。"), + ("## 5. 基因表达定量", ["rnaseq_quant", "expression"], "表达定量用于统计每个基因在各样本中的表达水平。"), + ("## 6. 样本关系与重复可靠性", ["rnaseq_sample_qc"], "这一部分判断同组重复是否相似,以及是否存在明显离群样本。"), + ("## 7. 差异表达", ["rnaseq_de"], "上调和下调均相对于对应比较中的对照组,表示表达量升高或降低。"), + ( + "## 8. GO、KEGG 与全基因排序分析", + ["annotation", "annotation_go", "annotation_kegg", "rnaseq_go_enrich", "rnaseq_kegg_enrich", "rnaseq_enrich", "rnaseq_fgsea"], + "本节汇总 GO、KEGG 和全基因排序分析中的主要功能条目及对应图表。", + ), + ("## 9. 共表达网络", ["rnaseq_wgcna"], "共表达网络把变化趋势相近的基因归为模块,用于缩小候选范围。"), + ( + "## 10. 候选基因与候选家族", + ["rnaseq_candidate_family", "rnaseq_family_score"], + "本节展示候选基因、候选家族及其排序依据。", + ), + ] + + def _stage_name(stage: str) -> str: + return STAGE_NAMES.get(stage, stage) + + def _status_text(stage: str) -> str: + status = str((flow_rows.get(stage) or {}).get("status") or manifests.get(stage, {}).get("status") or "unknown") + return {"success": "已完成", "partial_success": "部分完成", "failed": "失败", "missing": "未完成"}.get(status, status) + + def _render_figure(figure: dict[str, Any]) -> None: + nonlocal figure_index + figure_index += 1 + original_path = Path(figure["path"]) + path = report_path.parent / _preferred_web_figure(original_path).name + rendered_figures.add(str(original_path)) + media_line = ( + f"[{figure['artifact']}]({_relative_link(report_path, path)})" + if path.suffix.lower() == ".pdf" + else f"![{figure['artifact']}]({_relative_link(report_path, path)})" + ) + lines.extend(["", f"### 图 {figure_index}. {figure['artifact']}", "", media_line, ""]) + lines.extend([*_figure_interpretation(figure, workspace, manifests, report_path), ""]) + + def _render_stage_group(stage_ids: list[str], empty_text: str) -> None: + selected = [stage for stage in included if stage in stage_ids] + if not selected: + lines.extend(["", empty_text]) + return + table_stages = {"rnaseq_qc", "rnaseq_align", "rnaseq_quant"} + for stage in selected: + show_tables = stage in table_stages + metrics = _display_metrics(manifests.get(stage, {})) if show_tables else [] + sample_table = _sample_metric_table(manifests.get(stage, {}), stage) if show_tables else None + lines.extend(["", f"### {_stage_name(stage)}", "", f"状态:{_status_text(stage)}。"]) + if sample_table: + lines.extend( + [ + "", + "逐样本结果:", + "", + "| " + " | ".join(_table_cell(column) for column in sample_table["columns"]) + " |", + "|" + "|".join("---" for _ in sample_table["columns"]) + "|", + ] + ) + for row in sample_table["rows"]: + lines.append("| " + " | ".join(row) + " |") + if metrics: + lines.extend(["", "本次数据:", "", "| 指标 | 结果 |", "|---|---|"]) + for label, value in metrics: + lines.append(f"| {label} | {value} |") + previews = ( + _stage_table_previews( + workspace, + manifests.get(stage, {}), + report_path, + max_tables=3, + max_rows=30, + max_columns=10, + ) + if stage in {"rnaseq_candidate_family", "rnaseq_family_score"} + else [] + ) + for preview in previews: + lines.extend( + [ + "", + f"#### 数据表:{preview['filename']}", + "", + "| " + " | ".join(_table_cell(column) for column in preview["columns"]) + " |", + "|" + "|".join("---" for _ in preview["columns"]) + "|", + ] + ) + for row in preview["rows"]: + lines.append("| " + " | ".join(row) + " |") + lines.extend(["", f"[下载完整数据表]({preview['link']})"]) + for figure in figures: + if figure.get("stage") in stage_ids: + _render_figure(figure) + + coverage_text = ( + f"当前计划中的分析阶段已全部完成,共纳入 {len(included)} 个可用阶段。" + if coverage_status == "complete" + else f"当前报告基于已经完成的 {len(included)} 个阶段生成,仍有部分分析尚未完成。" + ) + included_text = "、".join(_stage_name(stage) for stage in included) or "无" + missing_text = "、".join(_stage_name(stage) for stage in missing) or "无" + lines = [ + f"# {prefix} RNA-seq 结果解读报告", + "", + f"生成时间:{generated_at} ", + f"任务编号:`{workspace.name}`", + "", + "**任务结果路径:** ", + f"`{workspace / 'final'}`", + "", + "## 1. 项目概览", + "", + "| 项目 | 结果 |", + "|---|---|", + f"| 报告前缀 | {_table_cell(prefix)} |", + f"| 已完成分析 | {_table_cell(included_text)} |", + f"| 待补充分析 | {_table_cell(missing_text)} |", + f"| 可用阶段数 | {len(included)} |", + "", + coverage_text, + "", + "本报告以统计表和结果图为主,并在每个阶段提供简短的读图说明和初步结果概括。", + "", + "## 2. 样本与分析设计", + "", + f"计划分析:{'、'.join(_stage_name(stage) for stage in expected_stages) if expected_stages else '当前已有 RNA-seq 结果'}。", + "", + f"实际完成:{included_text}。", + "", + "上调和下调方向按照各差异比较结果表中的实验组与对照组关系展示。", + ] + + empty_messages = { + "rnaseq_qc": "当前没有可用的测序质控结果,无法判断原始数据质量。", + "rnaseq_align": "当前没有可用的比对结果,无法判断测序片段与参考基因组的匹配情况。", + "rnaseq_quant": "当前没有可用的表达定量结果。", + "rnaseq_sample_qc": "当前没有可用的样本关系结果,无法判断重复一致性或离群样本。", + "rnaseq_de": "当前没有可用的差异表达结果。", + "rnaseq_go_enrich": "当前没有完整的 GO、KEGG 或全基因排序分析结果,不能判断具体功能主题。", + "rnaseq_wgcna": "当前没有可用的共表达网络结果,本节不作模块判断。", + "rnaseq_candidate_family": "当前没有可用的候选基因或候选家族结果。", + } + for heading, stage_ids, introduction in section_specs: + lines.extend(["", heading, "", introduction]) + _render_stage_group(stage_ids, empty_messages.get(stage_ids[0], "当前没有可用结果。")) + + remaining_figures = [figure for figure in figures if str(Path(figure["path"])) not in rendered_figures] + lines.extend(["", "## 11. 结果汇总", "", "| 分析阶段 | 状态 | 主要结果 |", "|---|---|---|"]) + for stage in included: + artifact_count = len((manifests.get(stage, {}).get("artifacts") or {})) + figure_count = sum(1 for figure in figures if figure.get("stage") == stage) + summary = f"已登记 {artifact_count} 个结果文件,报告展示 {figure_count} 张图" + lines.append(f"| {_table_cell(_stage_name(stage))} | {_table_cell(_status_text(stage))} | {_table_cell(summary)} |") + if remaining_figures: + lines.extend(["", "### 其他结果图", ""]) + for figure in remaining_figures: + _render_figure(figure) + lines.extend(["", "---", "", "完整证据和来源记录保存在同名 `.evidence.json` 与 `.manifest.json` 文件中。", ""]) + return "\n".join(lines) + + +def _resolve_html_asset(report_path: Path, raw_target: str) -> Path: + clean_target = unquote(raw_target.split("#", 1)[0].split("?", 1)[0]).replace("\\", "/") + if clean_target.startswith("/workspace/"): + parts = [part for part in clean_target.strip("/").split("/") if part] + job_root = report_path.parent.parent + if len(parts) >= 3 and parts[1] == job_root.name: + return job_root.joinpath(*parts[2:]).resolve() + target = Path(clean_target) + if target.is_absolute(): + return target.resolve() + return (report_path.parent / target).resolve() + + +def _markdown_inline(text: str, report_path: Path) -> str: + tokens: list[str] = [] + + def stash(value: str) -> str: + token = f"@@HTMLTOKEN{len(tokens)}@@" + tokens.append(value) + return token + + def image_replacement(match: re.Match[str]) -> str: + alt = match.group(1).strip() or "RNA-seq result figure" + raw_target = match.group(2).strip().replace("\\", "/") + source = raw_target + target = report_path.parent / Path(raw_target).name + mime = mimetypes.guess_type(target.name)[0] or "" + if target.is_file() and mime.startswith("image/"): + encoded = base64.b64encode(target.read_bytes()).decode("ascii") + source = f"data:{mime};base64,{encoded}" + return stash( + f'
    ' + f'
    {html.escape(alt)}
    ' + ) + + def link_replacement(match: re.Match[str]) -> str: + label = html.escape(match.group(1).strip()) + raw_target = match.group(2).strip().replace("\\", "/") + target = html.escape(raw_target, quote=True) + if Path(raw_target).suffix.lower() == ".pdf": + return stash( + f'
    ' + f'
    {label}
    ' + ) + return stash(f'{label}') + + value = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", image_replacement, text) + value = re.sub(r"(?\1", value) + value = re.sub(r"\*\*([^*]+)\*\*", r"\1", value) + for index, token_value in enumerate(tokens): + value = value.replace(f"@@HTMLTOKEN{index}@@", token_value) + return value + + +def _markdown_to_html(markdown_text: str, report_path: Path, title: str) -> str: + output: list[str] = [] + list_tag = "" + table_rows: list[list[str]] = [] + in_code = False + code_lines: list[str] = [] + + def close_list() -> None: + nonlocal list_tag + if list_tag: + output.append(f"") + list_tag = "" + + def flush_table() -> None: + nonlocal table_rows + if not table_rows: + return + rows = table_rows + table_rows = [] + if len(rows) > 1 and all(re.fullmatch(r":?-{3,}:?", cell.replace(" ", "")) for cell in rows[1]): + header = rows[0] + body_rows = rows[2:] + else: + header = [] + body_rows = rows + output.append('
    ') + if header: + output.append("" + "".join(f"" for cell in header) + "") + output.append("") + for row in body_rows: + output.append("" + "".join(f"" for cell in row) + "") + output.extend(["", "
    {_markdown_inline(cell, report_path)}
    {_markdown_inline(cell, report_path)}
    "]) + + for raw_line in markdown_text.splitlines(): + line = raw_line.rstrip() + if line.startswith("```"): + close_list() + flush_table() + if in_code: + output.append(f"
    {html.escape(chr(10).join(code_lines))}
    ") + code_lines = [] + in_code = False + else: + in_code = True + continue + if in_code: + code_lines.append(line) + continue + if line.startswith("|") and line.endswith("|"): + close_list() + table_rows.append([cell.strip() for cell in line.strip("|").split("|")]) + continue + flush_table() + if not line.strip(): + close_list() + continue + heading = re.match(r"^(#{1,6})\s+(.+)$", line) + if heading: + close_list() + level = len(heading.group(1)) + output.append(f"{_markdown_inline(heading.group(2), report_path)}") + continue + if line == "---": + close_list() + output.append("
    ") + continue + if line.startswith("> "): + close_list() + output.append(f"
    {_markdown_inline(line[2:], report_path)}
    ") + continue + bullet = re.match(r"^-\s+(.+)$", line) + ordered = re.match(r"^\d+\.\s+(.+)$", line) + if bullet or ordered: + wanted = "ul" if bullet else "ol" + if list_tag != wanted: + close_list() + list_tag = wanted + output.append(f"<{list_tag}>") + item = bullet.group(1) if bullet else ordered.group(1) + output.append(f"
  • {_markdown_inline(item, report_path)}
  • ") + continue + close_list() + if re.fullmatch(r"!?\[[^\]]+\]\([^)]+\)", line): + output.append(_markdown_inline(line, report_path)) + continue + output.append(f"

    {_markdown_inline(line, report_path)}

    ") + close_list() + flush_table() + if in_code: + output.append(f"
    {html.escape(chr(10).join(code_lines))}
    ") + + safe_title = html.escape(title) + body = "\n".join(output) + return f""" + + + + + {safe_title} + + +
    {body}
    + +""" + + +def _model_claim_catalog(evidence: dict[str, Any]) -> list[dict[str, str]]: + catalog: list[dict[str, str]] = [] + + def add(claim_id: str, text: Any, source_type: str, stage: str = "") -> None: + value = str(text or "").strip() + if value: + catalog.append( + { + "claim_id": claim_id, + "text": value, + "source_type": source_type, + "stage": stage, + } + ) + + add("story_1", evidence.get("biological_story"), "biological_story") + for index, row in enumerate(evidence.get("key_findings") or [], 1): + if not isinstance(row, dict): + continue + add( + f"finding_{index}", + row.get("description") or row.get("summary") or row.get("claim") or row.get("text"), + "key_finding", + str(row.get("stage") or ""), + ) + for index, row in enumerate(evidence.get("major_claims") or [], 1): + if isinstance(row, dict): + text = row.get("claim") or row.get("text") or row.get("title") + stage = str(row.get("stage") or "") + else: + text = row + stage = "" + add(f"claim_{index}", text, "major_claim", stage) + for index, row in enumerate(evidence.get("risk_flags") or [], 1): + if isinstance(row, dict): + text = row.get("message") or row.get("text") + stage = str(row.get("stage") or "") + else: + text = row + stage = "" + add(f"risk_{index}", text, "risk_flag", stage) + return catalog + + +def _render_claim_groups( + plan: dict[str, Any], catalog: list[dict[str, str]] +) -> tuple[str, list[str]]: + groups = plan.get("paragraph_groups") + if not isinstance(groups, list) or not groups or len(groups) > 4: + raise ValueError("paragraph_groups must contain between 1 and 4 groups") + claim_text = {row["claim_id"]: row["text"] for row in catalog} + selected: list[str] = [] + paragraphs: list[str] = [] + for group in groups: + if not isinstance(group, list) or not group: + raise ValueError("each paragraph group must be a non-empty claim-id list") + sentences: list[str] = [] + for raw_claim_id in group: + claim_id = str(raw_claim_id or "").strip() + if claim_id not in claim_text: + raise ValueError(f"unknown evidence claim id: {claim_id}") + if claim_id in selected: + raise ValueError(f"duplicate evidence claim id: {claim_id}") + selected.append(claim_id) + text = claim_text[claim_id] + sentences.append(text if text.endswith(("。", "!", "?", ".", "!", "?")) else f"{text}。") + paragraphs.append("".join(sentences)) + return "\n\n".join(paragraphs), selected + + +def _model_enhancement(evidence: dict[str, Any], enabled: bool) -> tuple[str, dict[str, Any]]: + claim_catalog = _model_claim_catalog(evidence) + metadata: dict[str, Any] = { + "schema": "rnaseq_report_model_v2", + "enabled": enabled, + "status": "disabled" if not enabled else "pending", + "provider": "", + "model": "", + "content": "", + "error": "", + "generated_at": _now(), + "policy": "evidence_claim_ordering_only", + "claim_catalog": claim_catalog, + "organization_plan": {}, + "selected_claim_ids": [], + } + if not enabled: + return "", metadata + if not claim_catalog: + metadata.update({"status": "fallback", "error": "no approved evidence claims available"}) + return "", metadata + try: + from gfa_core.mas.config import config + from gfa_core.mas.llm import LLMManager + from gfa_core.mas.llm.base import LLMMessage + + manager = LLMManager(config.load_llm_config()) + response = manager.call( + [ + LLMMessage( + role="system", + content=( + "你只能组织已批准的RNA-seq证据语句,不能撰写或改写结论。" + "只返回JSON对象:paragraph_groups为1至4个段落,每个段落只能列出输入中的claim_id。" + "不得返回自由文本、未知claim_id、数字、基因、通路、显著性、方向、机制或因果推断。" + ), + ), + LLMMessage( + role="user", + content=json.dumps( + {"task": "order_and_group_evidence_claims", "claim_catalog": claim_catalog}, + ensure_ascii=False, + )[:30000], + ), + ], + allow_fallback=True, + max_tokens=600, + track_agent_name="RNASeqReport", + ) + content = str(response.content or "").strip() + metadata.update( + { + "provider": str(response.provider or ""), + "model": str(response.model or ""), + "content": content, + } + ) + cleaned = re.sub(r"^```(?:json)?\s*|\s*```$", "", content, flags=re.IGNORECASE) + plan = json.loads(cleaned) + if not isinstance(plan, dict): + raise ValueError("model organization plan must be a JSON object") + rendered, selected_claim_ids = _render_claim_groups(plan, claim_catalog) + metadata.update( + { + "status": "embedded_evidence_only", + "organization_plan": plan, + "selected_claim_ids": selected_claim_ids, + } + ) + return rendered, metadata + except Exception as exc: # deterministic report remains authoritative + metadata.update({"status": "fallback", "error": f"{type(exc).__name__}: {exc}"}) + return "", metadata + + +def _artifact(workspace: Path, path: Path, kind: str) -> dict[str, Any]: + artifact_type = {".md": "markdown", ".html": "html", ".json": "json"}.get(path.suffix.lower(), "file") + return { + "artifact": kind, + "path": str(path.relative_to(workspace)), + "full_path": str(path.absolute()), + "type": artifact_type, + "size": path.stat().st_size, + "sha256": _sha256(path), + } + + +def generate_rnaseq_report( + workspace: Path | str, + prefix: str, + *, + rnaseq_scope_id: str = "", + expected_stages: Iterable[str] | None = None, + use_model: bool = True, + action: str = "generate", + job_id: str = "", +) -> RNASeqReportResult: + workspace = Path(workspace).absolute() + final_dir = workspace / "final" + final_dir.mkdir(parents=True, exist_ok=True) + prefix = str(prefix or "").strip() + if not prefix: + raise ValueError("prefix is required") + if action not in {"generate", "open"}: + raise ValueError("action must be 'generate' or 'open'") + + expected = _normalize_expected_stages(expected_stages) + resolved_scope, discovered_scopes, all_sources = _source_selection(final_dir, prefix, rnaseq_scope_id) + successful = { + stage: manifest + for stage, manifest in all_sources.items() + if str(manifest.get("status") or "") in SUCCESS_STATUSES + } + if not successful: + raise NoSuccessfulRNASeqEvidence("暂无可生成的 RNA-seq 结果:未找到当前 prefix/scope 下成功的 RNA-seq 阶段。") + + included = sorted(successful, key=_stage_sort_key) + source_records = _source_records(all_sources) + source_fingerprint, request_fingerprint = _fingerprint(source_records, expected, use_model) + report_path = final_dir / f"{prefix}.rnaseq_report.md" + html_path = final_dir / f"{prefix}.rnaseq_report.html" + evidence_path = final_dir / f"{prefix}.rnaseq_report.evidence.json" + model_path = final_dir / f"{prefix}.rnaseq_report.model.json" + manifest_path = final_dir / f"{prefix}.rnaseq_report.manifest.json" + + stage_statuses = {stage: str(all_sources.get(stage, {}).get("status") or "missing") for stage in expected} + coverage_status = "complete" if expected and all(stage_statuses[s] == "success" for s in expected) else "partial" + if action == "open" and all(path.is_file() for path in (report_path, html_path, evidence_path, model_path, manifest_path)): + previous = _read_json(manifest_path) + metrics = previous.get("metrics") or {} + if ( + previous.get("status") == "success" + and _identity(previous, "rnaseq_scope_id") == resolved_scope + and metrics.get("request_fingerprint") == request_fingerprint + and metrics.get("report_renderer_version") == REPORT_RENDERER_VERSION + ): + return RNASeqReportResult( + report_path, + evidence_path, + model_path, + manifest_path, + str(metrics.get("coverage_status") or coverage_status), + tuple(metrics.get("included_stages") or included), + resolved_scope, + reused=True, + ) + + evidence = build_rnaseq_evidence_snapshot( + final_dir, + prefix, + rnaseq_scope_id=resolved_scope, + include_stages=included, + ) + if not evidence: + raise NoSuccessfulRNASeqEvidence("暂无可生成的 RNA-seq 结果:成功 manifest 未能构建有效证据。") + generated_at = _now() + figures, omitted_figures, selection_mode = _figures(workspace, successful) + enrichment_figures = [figure for figure in figures if figure.get("category")] + + def inventory_row(figure: dict[str, Any]) -> dict[str, Any]: + return { + "stage": figure["stage"], + "artifact": figure["artifact"], + "filename": Path(figure["path"]).name, + "category": figure.get("category", "other"), + "comparison": figure.get("comparison", ""), + "pathway_id": figure.get("pathway_id", ""), + "candidate_families": list(figure.get("candidate_families") or []), + "support_labels": list(figure.get("support_labels") or []), + "selection_reason": figure.get("selection_reason", ""), + "source_tables": list(figure.get("source_tables") or []), + } + + figure_inventory = [inventory_row(figure) for figure in figures] + omitted_figure_inventory = [inventory_row(figure) for figure in omitted_figures] + evidence["formal_report"] = { + "schema": "rnaseq_formal_report_evidence_v1", + "report_renderer_version": REPORT_RENDERER_VERSION, + "generated_at": generated_at, + "prefix": prefix, + "rnaseq_scope_id": resolved_scope, + "discovered_scope_ids": discovered_scopes, + "expected_stages": expected, + "stage_statuses": stage_statuses, + "coverage_status": coverage_status, + "source_fingerprint": source_fingerprint, + "request_fingerprint": request_fingerprint, + "source_manifests": source_records, + "selection_mode": selection_mode, + "figure_inventory": figure_inventory, + "omitted_figure_inventory": omitted_figure_inventory, + "omitted_figure_count": len(omitted_figure_inventory), + "figure_count": len(figures), + "enrichment_figure_count": len(enrichment_figures), + } + model_summary, model_metadata = _model_enhancement(evidence, use_model) + markdown = _render_deterministic_markdown( + prefix=prefix, + scope_id=resolved_scope, + evidence=evidence, + manifests=successful, + expected_stages=expected, + stage_statuses=stage_statuses, + coverage_status=coverage_status, + report_path=report_path, + workspace=workspace, + generated_at=generated_at, + figures=figures, + model_summary=model_summary, + ) + _write_text_atomic(evidence_path, json.dumps(evidence, ensure_ascii=False, indent=2) + "\n") + _write_text_atomic(model_path, json.dumps(model_metadata, ensure_ascii=False, indent=2) + "\n") + _write_text_atomic(report_path, markdown) + html_report = _markdown_to_html(markdown, report_path, f"{prefix} RNA-seq 结果解读报告") + _write_text_atomic(html_path, html_report) + + manifest = { + "manifest_version": "1.0", + "stage": REPORT_STAGE, + "status": "success", + "job_id": job_id or workspace.name, + "prefix": prefix, + "rnaseq_scope_id": resolved_scope, + "generated_at": generated_at, + "parameters": { + "action": action, + "model_enhancement_requested": use_model, + "expected_stages": expected, + "rnaseq_scope_id": resolved_scope, + }, + "metrics": { + "coverage_status": coverage_status, + "included_stage_count": len(included), + "included_stages": included, + "expected_stages": expected, + "stage_statuses": stage_statuses, + "missing_or_failed_stages": [stage for stage in expected if stage_statuses.get(stage) != "success"], + "model_status": model_metadata["status"], + "source_fingerprint": source_fingerprint, + "request_fingerprint": request_fingerprint, + "report_renderer_version": REPORT_RENDERER_VERSION, + "selection_mode": selection_mode, + "figure_count": len(figures), + "enrichment_figure_count": len(enrichment_figures), + "figure_inventory": figure_inventory, + "omitted_figure_inventory": omitted_figure_inventory, + "omitted_figure_count": len(omitted_figure_inventory), + "figure_categories": { + category: sum(1 for figure in enrichment_figures if figure.get("category") == category) + for category in sorted({figure.get("category", "") for figure in enrichment_figures}) + }, + }, + "provenance": {"source_manifests": source_records}, + "artifacts": { + "rnaseq_report": _artifact(workspace, report_path, "rnaseq_report"), + "rnaseq_report_html": _artifact(workspace, html_path, "rnaseq_report_html"), + "rnaseq_report_evidence": _artifact(workspace, evidence_path, "rnaseq_report_evidence"), + "rnaseq_report_model": _artifact(workspace, model_path, "rnaseq_report_model"), + }, + } + _write_text_atomic(manifest_path, json.dumps(manifest, ensure_ascii=False, indent=2) + "\n") + return RNASeqReportResult( + report_path, + evidence_path, + model_path, + manifest_path, + coverage_status, + tuple(included), + resolved_scope, + ) diff --git a/gfa_core/schemas/stages/gene_family_report_contract_v1.md b/gfa_core/schemas/stages/gene_family_report_contract_v1.md new file mode 100644 index 000000000..806bbde11 --- /dev/null +++ b/gfa_core/schemas/stages/gene_family_report_contract_v1.md @@ -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` 或模型猜测代替可提取的数据。 diff --git a/gfa_core/templates/gene_family_report_v1.md b/gfa_core/templates/gene_family_report_v1.md new file mode 100644 index 000000000..5a673c580 --- /dev/null +++ b/gfa_core/templates/gene_family_report_v1.md @@ -0,0 +1,151 @@ + +# {{PREFIX}} 基因家族分析结果解读报告 + +- 任务:{{JOB_ID}} +- 分析前缀:{{PREFIX}} +- 生成时间:{{GENERATED_AT}} + + +## 一、报告首页 + +### 1.1 项目信息 + +本报告只覆盖用户计划中的成功阶段;不同阶段的目标集合在各节单独标明。 + +### 1.2 研究目的 + +本报告用于确认家族成员,比较目标成员及必要参考成员的关系、结构、调控序列与蛋白预测特征,并界定结果能够支持的结论范围。 + +### 1.3 一页式结论摘要 + +{{SECTION_CONTENT}} + +### 1.4 结论可信度说明 + +成员数和命名表中的统计可直接复核;树、启动子、TFBS、共线性和蛋白定位只提供计算线索,不等同于功能或因果证据。 + + +## 二、分析路线与阅读顺序 + +{{SECTION_CONTENT}} + +### 2.1 统一解读顺序 + +各阶段依次写明分析对象、源结果中的直接观察、谨慎解释、限制,并仅在有助复核时列出少量相对路径。 + +### 2.2 语言边界 + +预测关系统一表述为线索;没有逐成员表或独立证据时,不指定候选基因,不声称功能改变或调控关系已经证实。 + + +## 三、结果总览与完整性检查 + +### 3.1 阶段完成情况 + +{{SECTION_CONTENT}} + +### 3.2 结果完整性概述 + +报告按计划逐阶段选择成功 Manifest;当各阶段使用的成员集合不同,正文明确区分目标成员、目标加参考成员和全基因组背景。 + + +## 四、基因家族鉴定(Identify) + +### 4.3 最终成员数量与命名 + +{{SECTION_CONTENT}} + + +## 五、亲缘关系分析(Phylogeny) + +### 5.5 亲缘关系阶段小结 + +{{SECTION_CONTENT}} + + +## 六、参考物种辅助的小组分类(Reference Classification) + +### 6.2 分类可信度表述 + +{{SECTION_CONTENT}} + + +## 七、短保守片段分析(Motif) + +### 7.2 Motif 数量与出现频率 + +{{SECTION_CONTENT}} + + +## 八、主要功能区域分析(Domain) + +### 8.1 Domain 整体统计 + +{{SECTION_CONTENT}} + + +## 九、基因内部结构分析(Structure) + +### 9.1 结构覆盖与异常结构 + +{{SECTION_CONTENT}} + + +## 十、综合组合图(Combo) + +### 10.1 树 + Motif + Domain + Structure 组合图 + +{{SECTION_CONTENT}} + + +## 十一、启动子元件分析(Promoter) + +### 11.1 元件总量、类别与高级统计 + +{{SECTION_CONTENT}} + + +## 十二、转录因子结合位点预测(TFBS) + +### 12.1 高频结合片段与统计边界 + +{{SECTION_CONTENT}} + + +## 十三、种内共线性分析(Synteny) + +### 13.1 全基因组共线性背景与家族证据缺口 + +{{SECTION_CONTENT}} + + +## 十四、染色体分布(Karyotype) + +### 14.1 染色体位置概述 + +{{SECTION_CONTENT}} + + +## 十五、种间共线性与跨物种关系(Interspecies Synteny) + +### 15.6 Interspecies Synteny 阶段小结 + +{{SECTION_CONTENT}} + + +## 十六、蛋白基本特征分析(PEP) + +### 16.1 蛋白理化性质分布图 + +逐蛋白理化性质保留在命名结果表中;源 Manifest 没有汇总长度、分子量或等电点范围时,正文不从图片反推数值。 + +### 16.4 亚细胞定位与阶段小结 + +{{SECTION_CONTENT}} + + +## 十七、多证据综合分析 + +### 17.5 综合结论 + +{{SECTION_CONTENT}} diff --git a/manage_plantai.sh b/manage_plantai.sh index 52b893ae5..c14e1c8dd 100755 --- a/manage_plantai.sh +++ b/manage_plantai.sh @@ -2,15 +2,19 @@ # PlantAI-dev 9000 服务管理脚本 # 用法: bash manage_plantai.sh {start|stop|restart|status} [--port PORT] # 默认端口: 9000 -# -# 项目: /home/xzq/5090/teamWorkspace/zh/PlantAI-dev -# 端口: 9000 (web), Redis DB 14 (queue prefix: plantai_dev_9002) set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [ -f "$SCRIPT_DIR/.env" ]; then + set -a + . "$SCRIPT_DIR/.env" + set +a +fi + # 解析命令行参数:第一个参数是命令,后面可选 --port / -p CMD="" -WEB_PORT="9000" +WEB_PORT="${PLANTAI_WEB_PORT:-9000}" for arg in "$@"; do case "$arg" in start|stop|restart|status) @@ -33,12 +37,15 @@ done CMD="${CMD:-status}" # Conda 环境:启动脚本内部也依赖这个环境 -CONDA_ENV="${CONDA_ENV:-/home/xzq/miniconda3/envs/test4py311}" +CONDA_BASE="${BBAV5_CONDA_BASE:-/home/xia-lab/miniconda3}" +DEFAULT_ENV="${BBAV5_DEFAULT_ENV:-test4py311}" +CONDA_ENV="${CONDA_ENV:-$CONDA_BASE/envs/$DEFAULT_ENV}" export PATH="$CONDA_ENV/bin:$PATH" # 项目配置(与 .env 和启动脚本保持一致) -PROJECT_DIR="/home/xzq/5090/teamWorkspace/zh/PlantAI-dev" -QUEUE_PREFIX="plantai_dev_9002" +PROJECT_DIR="$SCRIPT_DIR" +QUEUE_PREFIX="${BBAV5_QUEUE_PREFIX:-plantai_4090_9000}" +REDIS_URL="${BBAV5_REDIS_URL:-redis://localhost:6379/10}" WEB_LOG="/tmp/plantai_${WEB_PORT}.log" CELERY_LOG="/tmp/celery_${WEB_PORT}.log" @@ -46,28 +53,49 @@ red() { echo -e "\033[31m$*\033[0m"; } green(){ echo -e "\033[32m$*\033[0m"; } cyan() { echo -e "\033[36m$*\033[0m"; } +web_pid_for_port() { + ss -tlnp 2>/dev/null \ + | grep ":$WEB_PORT " \ + | grep -oP 'pid=\K\d+' \ + | head -n 1 \ + || true +} + +web_process_cwd() { + readlink -f "/proc/$1/cwd" 2>/dev/null || true +} + +web_pid_belongs_to_project() { + [ "$(web_process_cwd "$1")" = "$PROJECT_DIR" ] +} + status() { echo "" cyan "=== PlantAI-dev ${WEB_PORT} 服务状态 ===" echo "" # Web 服务 - WEB_PID=$(ss -tlnp 2>/dev/null | grep ":$WEB_PORT " | grep -oP 'pid=\K\d+' || true) + WEB_PID="$(web_pid_for_port)" if [ -n "${WEB_PID:-}" ]; then - green "[✓] Web 服务运行中 (端口 $WEB_PORT, PID $WEB_PID)" + WEB_CWD="$(web_process_cwd "$WEB_PID")" + if web_pid_belongs_to_project "$WEB_PID"; then + green "[✓] Web 服务运行中 (端口 $WEB_PORT, PID $WEB_PID)" + else + red "[!] 端口 $WEB_PORT 被其他项目占用 (PID $WEB_PID, cwd ${WEB_CWD:-未知})" + fi else red "[✗] Web 服务未启动 (端口 $WEB_PORT)" fi # Redis - if redis-cli ping > /dev/null 2>&1; then - green "[✓] Redis 运行中 (localhost:6379)" + if redis-cli -u "$REDIS_URL" ping > /dev/null 2>&1; then + green "[✓] Redis 运行中 ($REDIS_URL)" else red "[✗] Redis 未启动" fi # Celery workers - CELERY_COUNT=$(pgrep -fc "$QUEUE_PREFIX" || echo 0) + CELERY_COUNT="$(pgrep -fc "$QUEUE_PREFIX" || true)" if [ "$CELERY_COUNT" -gt 0 ]; then green "[✓] Celery workers 运行中 (${CELERY_COUNT} 进程, 队列前缀: $QUEUE_PREFIX)" echo " Worker 列表:" @@ -86,13 +114,18 @@ stop() { echo "" # 停止 Web 服务 - WEB_PID=$(ss -tlnp 2>/dev/null | grep ":$WEB_PORT " | grep -oP 'pid=\K\d+' || true) + WEB_PID="$(web_pid_for_port)" if [ -n "${WEB_PID:-}" ]; then + WEB_CWD="$(web_process_cwd "$WEB_PID")" + if ! web_pid_belongs_to_project "$WEB_PID"; then + red "[!] 拒绝停止:端口 $WEB_PORT 属于其他项目 (PID $WEB_PID, cwd ${WEB_CWD:-未知})" + return 1 + fi echo "正在停止 Web 服务 (PID $WEB_PID)..." kill "$WEB_PID" 2>/dev/null || true sleep 1 - # 如果还没死,强制 kill - if ss -tlnp 2>/dev/null | grep -q ":$WEB_PORT"; then + # 仅强制停止仍属于本项目的原进程,避免 PID 复用造成误杀。 + if kill -0 "$WEB_PID" 2>/dev/null && web_pid_belongs_to_project "$WEB_PID"; then kill -9 "$WEB_PID" 2>/dev/null || true fi green "[✓] Web 服务已停止" @@ -125,12 +158,18 @@ start() { echo "" # 检查是否已在运行 - if ss -tlnp 2>/dev/null | grep -q ":$WEB_PORT"; then - red "[!] 端口 $WEB_PORT 已被占用,请先停止服务" + WEB_PID="$(web_pid_for_port)" + if [ -n "${WEB_PID:-}" ]; then + WEB_CWD="$(web_process_cwd "$WEB_PID")" + if web_pid_belongs_to_project "$WEB_PID"; then + red "[!] 本项目 Web 已在端口 $WEB_PORT 运行 (PID $WEB_PID)" + else + red "[!] 端口 $WEB_PORT 被其他项目占用 (PID $WEB_PID, cwd ${WEB_CWD:-未知})" + fi exit 1 fi - if ! redis-cli ping > /dev/null 2>&1; then + if ! redis-cli -u "$REDIS_URL" ping > /dev/null 2>&1; then red "[!] Redis 未运行,请先启动 Redis" exit 1 fi @@ -141,7 +180,7 @@ start() { echo "正在启动 Celery workers..." nohup bash start_celery_9000.sh > "$CELERY_LOG" 2>&1 & sleep 3 - CELERY_COUNT=$(pgrep -fc "$QUEUE_PREFIX" || echo 0) + CELERY_COUNT="$(pgrep -fc "$QUEUE_PREFIX" || true)" if [ "$CELERY_COUNT" -gt 0 ]; then green " [✓] Celery workers 已启动 (${CELERY_COUNT} 进程)" else diff --git a/restart_with_prompts.sh b/scripts/legacy_startup/restart_with_prompts.sh similarity index 100% rename from restart_with_prompts.sh rename to scripts/legacy_startup/restart_with_prompts.sh diff --git a/start-webui.sh b/scripts/legacy_startup/start-webui.sh similarity index 100% rename from start-webui.sh rename to scripts/legacy_startup/start-webui.sh diff --git a/start_celery.sh b/scripts/legacy_startup/start_celery.sh similarity index 100% rename from start_celery.sh rename to scripts/legacy_startup/start_celery.sh diff --git a/start_celery_7880.sh b/scripts/legacy_startup/start_celery_7880.sh similarity index 100% rename from start_celery_7880.sh rename to scripts/legacy_startup/start_celery_7880.sh diff --git a/start_celery_port9000.sh b/scripts/legacy_startup/start_celery_port9000.sh similarity index 100% rename from start_celery_port9000.sh rename to scripts/legacy_startup/start_celery_port9000.sh diff --git a/start_celery_9000.sh b/start_celery_9000.sh index 2496254f8..2c341bbff 100755 --- a/start_celery_9000.sh +++ b/start_celery_9000.sh @@ -26,13 +26,13 @@ if [ -n "$EXTERNAL_BBAV5_WORKER_NAME_PREFIX" ]; then BBAV5_WORKER_NAME_PREFIX="$EXTERNAL_BBAV5_WORKER_NAME_PREFIX" fi if [ -z "${BBAV5_QUEUE_PREFIX-}" ]; then - BBAV5_QUEUE_PREFIX="plantai_dev_9000" + BBAV5_QUEUE_PREFIX="plantai_4090_9000" fi if [ -z "${BBAV5_REDIS_URL-}" ]; then BBAV5_REDIS_URL="redis://localhost:6379/10" fi if [ -z "${BBAV5_CELERY_LOG_DIR-}" ]; then - BBAV5_CELERY_LOG_DIR="logs/celery_plantai_dev_9000" + BBAV5_CELERY_LOG_DIR="logs/celery_plantai_4090_9000" fi if [ -z "$EXTERNAL_BBAV5_WORKER_NAME_PREFIX" ]; then unset BBAV5_WORKER_NAME_PREFIX @@ -51,11 +51,11 @@ export BBAV5_PROJECT_ENV_FILE="${BBAV5_PROJECT_ENV_FILE:-$PROJECT_ROOT/.env}" export BBAV5_LIGHTWEIGHT_EXTENSION_STORE="${BBAV5_LIGHTWEIGHT_EXTENSION_STORE:-$PROJECT_ROOT/.runtime/lightweight-extension/store}" export BBAV5_LIGHTWEIGHT_INPUT="${BBAV5_LIGHTWEIGHT_INPUT:-$PROJECT_ROOT/input}" export BBAV5_LIGHTWEIGHT_WORKSPACE="${BBAV5_LIGHTWEIGHT_WORKSPACE:-$PROJECT_ROOT/workspace}" -export BBAV5_QUEUE_PREFIX="${BBAV5_QUEUE_PREFIX:-plantai_dev_9000}" +export BBAV5_QUEUE_PREFIX="${BBAV5_QUEUE_PREFIX:-plantai_4090_9000}" export BBAV5_REDIS_URL="${BBAV5_REDIS_URL:-redis://localhost:6379/10}" WORKER_NAME_PREFIX="${BBAV5_WORKER_NAME_PREFIX:-${BBAV5_QUEUE_PREFIX//[^A-Za-z0-9_]/_}}" -LOG_DIR="${BBAV5_CELERY_LOG_DIR:-logs/celery_plantai_dev_9000}" +LOG_DIR="${BBAV5_CELERY_LOG_DIR:-logs/celery_plantai_4090_9000}" mkdir -p "$LOG_DIR" CELERY="$CONDA_ENV/bin/celery" diff --git a/start_plantai_9000.sh b/start_plantai_9000.sh index 93050a096..7ad851daf 100755 --- a/start_plantai_9000.sh +++ b/start_plantai_9000.sh @@ -24,6 +24,12 @@ done SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" +if [ -f "$SCRIPT_DIR/.env" ]; then + set -a + . "$SCRIPT_DIR/.env" + set +a +fi + CONDA_BASE="${BBAV5_CONDA_BASE:-/home/xia-lab/miniconda3}" DEFAULT_ENV="${BBAV5_DEFAULT_ENV:-test4py311}" CONDA_ENV="$CONDA_BASE/envs/$DEFAULT_ENV" @@ -33,23 +39,18 @@ export PATH="$CONDA_ENV/bin:$PATH" # the caller's shell; GWAS/other sandboxes have dedicated restart scripts. export BBAV5_PROJECT_ROOT="$SCRIPT_DIR" export PLANTAI_PROJECT_ROOT="$SCRIPT_DIR" -if [ -f "$SCRIPT_DIR/.env" ]; then - set -a - . "$SCRIPT_DIR/.env" - set +a -fi export BBAV5_PROJECT_ENV_FILE="${BBAV5_PROJECT_ENV_FILE:-$SCRIPT_DIR/.env}" export BBAV5_LIGHTWEIGHT_EXTENSION_STORE="${BBAV5_LIGHTWEIGHT_EXTENSION_STORE:-$SCRIPT_DIR/.runtime/lightweight-extension/store}" export BBAV5_LIGHTWEIGHT_INPUT="${BBAV5_LIGHTWEIGHT_INPUT:-$SCRIPT_DIR/input}" export BBAV5_LIGHTWEIGHT_WORKSPACE="${BBAV5_LIGHTWEIGHT_WORKSPACE:-$SCRIPT_DIR/workspace}" -export BBAV5_QUEUE_PREFIX="${BBAV5_QUEUE_PREFIX:-plantai_dev_9000}" +export BBAV5_QUEUE_PREFIX="${BBAV5_QUEUE_PREFIX:-plantai_4090_9000}" export BBAV5_REDIS_URL="${BBAV5_REDIS_URL:-redis://localhost:6379/10}" export PLANTAI_WEB_PORT="$PORT" export PLANTAI_RESTART_SCRIPT="$SCRIPT_DIR/restart_plantai_9000.sh" -export GENE_FAMILY_MAS_API="http://127.0.0.1:7926" -export DUAL_KB_QA_BASE_URL="http://127.0.0.1:7926" -export RAG_API_URL="http://127.0.0.1:7926/api/v1/qa-v1-1-73/chat" -export DUAL_KB_QA_ROUTE="qa-v1-1-73" +export GENE_FAMILY_MAS_API="${GENE_FAMILY_MAS_API:-${DUAL_KB_QA_BASE_URL:-http://127.0.0.1:7920}}" +export DUAL_KB_QA_BASE_URL="${DUAL_KB_QA_BASE_URL:-$GENE_FAMILY_MAS_API}" +export RAG_API_URL="${RAG_API_URL:-$GENE_FAMILY_MAS_API}" +export DUAL_KB_QA_ROUTE="${DUAL_KB_QA_ROUTE:-qa-v1-1-73}" export PLANTAI_AGENT_STATE_DB="$SCRIPT_DIR/.runtime/agent-state/agent-state.sqlite3" PORT="$PLANTAI_WEB_PORT" diff --git a/tasks.py b/tasks.py index 6afc4df46..511c1e5c4 100755 --- a/tasks.py +++ b/tasks.py @@ -411,7 +411,7 @@ def validate_single_stage_artifacts(job_id: str, prefix: str, stage: str, result stage_status = get_stage_status_for_identity( job_id=job_id, identity=identity, - strict_identity=bool( + strict_identity=actual_stage != "gene_family_report" and bool( identity.scope_id or identity.analysis_route or identity.member_set_id diff --git a/tests/e2e/verify_gene_family_report_journey.py b/tests/e2e/verify_gene_family_report_journey.py new file mode 100644 index 000000000..a8028fd6d --- /dev/null +++ b/tests/e2e/verify_gene_family_report_journey.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""Small real-data journey for the gene-family report module.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import re +import shutil +import sys + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from apps.cli.run_gene_family_report import main as report_cli_main +from gfa_core.mas.utils.intent_parser import parse_intent_from_query + + +PREFIX = "Real.SPL" +SCOPE_ID = "scope-real-spl" +ANALYSIS_ROUTE = "post_subfamily_confirm" +MEMBER_SET_ID = "members-real-spl" + + +def _sequence_count(path: Path) -> int: + count = sum(line.startswith(">") for line in path.read_text(encoding="utf-8").splitlines()) + if not count: + raise AssertionError("representative FASTA is empty") + return count + + +def _write_identify_manifest(workspace: Path, fasta: Path, count: int) -> None: + final_dir = workspace / "final" + final_dir.mkdir(parents=True, exist_ok=True) + manifest = { + "manifest_version": "1.0", + "stage": "identify", + "status": "success", + "prefix": PREFIX, + "scope_id": SCOPE_ID, + "analysis_route": ANALYSIS_ROUTE, + "member_set_id": MEMBER_SET_ID, + "parameters": { + "scope_id": SCOPE_ID, + "analysis_route": ANALYSIS_ROUTE, + "member_set_id": MEMBER_SET_ID, + }, + "metrics": {"member_count": count, "final_members": count, "total_proteins": count}, + "artifacts": { + "final_members": {"path": str(fasta), "full_path": str(fasta), "type": "protein_fasta"} + }, + } + (final_dir / f"{PREFIX}.identify.manifest.json").write_text( + json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + +def _run(workspace: Path, action: str): + return report_cli_main( + [ + "--workspace", str(workspace), + "--prefix", PREFIX, + "--job-id", "representative-gene-family-report", + "--expected-stages", "identify", + "--stage-statuses-json", '{"identify":"success"}', + "--scope-id", SCOPE_ID, + "--analysis-route", ANALYSIS_ROUTE, + "--member-set-id", MEMBER_SET_ID, + "--action", action, + "--no-model", + ] + ) + + +def _stage_section(report: str, stage: str) -> str: + heading = re.search(rf"(?m)^## (?!#).*?\(`{re.escape(stage)}`\)", report) + if heading is None: + raise AssertionError(f"missing report chapter for {stage}") + next_heading = re.search(r"(?m)^## (?!#)", report[heading.end():]) + end = heading.end() + next_heading.start() if next_heading else len(report) + return report[heading.start():end] + + +def _subsection(report: str, number: str) -> str: + heading = re.search(rf"(?m)^### {re.escape(number)}(?:\s|$)", report) + if heading is None: + raise AssertionError(f"missing report subsection {number}") + next_heading = re.search(r"(?m)^#{2,3} ", report[heading.end():]) + end = heading.end() + next_heading.start() if next_heading else len(report) + return report[heading.start():end] + + +def _verify_real_job(workspace: Path, prefix: str) -> None: + final_dir = workspace / "final" + report_path = final_dir / f"{prefix}.gene_family_report.md" + manifest_path = final_dir / f"{prefix}.gene_family_report.manifest.json" + report = report_path.read_text(encoding="utf-8") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + expected_stages = [ + "identify", + "phylogeny", + "reference_classification", + "motif", + "domain", + "structure", + "combo", + "promoter", + "tfbs", + "synteny", + "karyotype", + "interspecies_synteny", + "pep", + ] + + assert report.startswith(f"# {prefix} 基因家族分析结果解读报告\n") + for placeholder in ("【填写】", "未解析", "N/A", "None"): + assert placeholder not in report + assert "/home/xzq/" not in report + assert not re.search(r"(?m)^- `[a-zA-Z0-9_.-]+`:\s*", report) + assert manifest["parameters"]["expected_stages"] == expected_stages + assert manifest["metrics"]["included_stages"] == expected_stages + assert manifest["metrics"]["generation_mode"] == "fallback_script" + assert sorted(path.name for path in final_dir.glob(f"{prefix}.gene_family_report*")) == [ + f"{prefix}.gene_family_report.manifest.json", + f"{prefix}.gene_family_report.md", + ] + for stage in expected_stages: + section = _stage_section(report, stage) + for chunk in re.split(r"(?m)(?=^### )", section)[1:]: + assert "\n".join(chunk.splitlines()[1:]).strip(), chunk.splitlines()[0] + assert "分析对象:" in section + assert "限制:" in section + assert report.count("本节解释仅限上述可追溯结果,不据此断言真实功能或因果关系。") <= 1 + summary = _subsection(report, "1.3") + assert 6 <= summary.count("。") <= 10 + assert re.search(r"目标物种.*24.*家族成员", summary) + assert re.search(r"形成\s*24\s*个\s*SPL家族", _subsection(report, "4.3")) + assert re.search( + r"使用\s*40\s*条序列.*比对长度为\s*1449", _stage_section(report, "phylogeny") + ) + motif = _stage_section(report, "motif") + assert re.search(r"识别\s*10\s*个\s*motif", motif) + assert re.search(r"总位点数为\s*105", motif) + assert re.search(r"宽度范围为\s*27–138", motif) + assert re.search(r"将\s*24\s*个家族成员定位到\s*8\s*条染色体", _stage_section(report, "karyotype")) + assert re.search( + r"全基因组\s*block\s*521\s*个.*目标-参考家族.*?2\s*对", + _stage_section(report, "interspecies_synteny"), + ) + assert re.search(r"覆盖\s*40\s*个蛋白,子任务成功数为\s*3/3", _stage_section(report, "pep")) + assert re.search(r"24\s*个目标成员.*8\s*个小组.*高信心\s*14.*中信心\s*7.*低信心\s*3", _subsection(report, "6.2")) + assert re.search(r"40/40.*SBP.*40.*Ig_SPL7.*12", _subsection(report, "8.1")) + assert re.search(r"24/40.*FtPinG0003446200\.01\.T01.*12454", _subsection(report, "9.1")) + assert "Motif、Domain、Structure" in _subsection(report, "10.1") + promoter = _subsection(report, "11.1") + assert re.search(r"23\s*个目标基因.*3699.*TATA-box.*1460", promoter) + assert "CAAT-box—MYB" in promoter and "FtPinG0006337300.01.T01" in promoter + tfbs = _subsection(report, "12.1") + assert re.search(r"24\s*条目标启动子.*3734.*AHYPO_015621.*153", tfbs) + assert re.search(r"背景启动子为\s*0.*不能.*富集", tfbs) + synteny = _subsection(report, "13.1") + assert re.search(r"全基因组.*316.*3267.*8\s*条染色体", synteny) + assert "源结果未提供家族专属复制基因对" in synteny + assert re.search(r"Nucleus\s*37.*Cytoplasm\s*2.*Chloroplast\s*1", _subsection(report, "16.4")) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--mode", choices=("data", "e2e")) + parser.add_argument("--workspace", type=Path) + parser.add_argument("--prefix") + args = parser.parse_args() + + if args.workspace is not None: + if not args.prefix: + parser.error("--prefix is required with --workspace") + _verify_real_job(args.workspace, args.prefix) + print(json.dumps({"status": "passed", "workspace": str(args.workspace)}, ensure_ascii=False)) + return 0 + if args.mode is None: + parser.error("--mode is required when --workspace is not provided") + + output_dir = PROJECT_ROOT / "test-results/gene-family-report" / args.mode + if output_dir.exists(): + shutil.rmtree(output_dir) + workspace = output_dir / "job" + fasta = PROJECT_ROOT / "tests/ft_data/SPL_Ath.fasta" + count = _sequence_count(fasta) + _write_identify_manifest(workspace, fasta, count) + + generated = _run(workspace, "generate") + report = generated.report_path.read_text(encoding="utf-8") + manifest = json.loads(generated.manifest_path.read_text(encoding="utf-8")) + assert generated.report_path.name == f"{PREFIX}.gene_family_report.md" + assert generated.manifest_path.name == f"{PREFIX}.gene_family_report.manifest.json" + assert "生成模式:候补脚本" in report + assert f"最终成员数:{count}" in report + assert "## 基因家族鉴定 (`identify`)" in report + assert "(`motif`)" not in report + assert manifest["scope_id"] == SCOPE_ID + assert manifest["analysis_route"] == ANALYSIS_ROUTE + assert manifest["member_set_id"] == MEMBER_SET_ID + assert manifest["metrics"]["included_stages"] == ["identify"] + assert set(manifest["artifacts"]) == {"gene_family_report", "gene_family_report_manifest"} + + reopened = _run(workspace, "open") + assert reopened.reused is True + intent = parse_intent_from_query("打开基因家族结果报告") + assert intent["stages"] == ["gene_family_report"] + assert intent["params"]["reportAction"] == "open" + + print(json.dumps({ + "status": "passed", + "mode": args.mode, + "sequence_count": count, + "report": str(generated.report_path), + }, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/unit/test_agent_chat_actions.py b/tests/unit/test_agent_chat_actions.py index d6b2ee2b5..8e82fb19b 100644 --- a/tests/unit/test_agent_chat_actions.py +++ b/tests/unit/test_agent_chat_actions.py @@ -92,6 +92,7 @@ async def test_agent_chat_maps_router_actions_and_passes_context( "history": history, "session_id": "session-7", "job_id": "job-7", + "language": "zh", } ] assert result["success"] is True @@ -108,6 +109,40 @@ async def test_agent_chat_maps_router_actions_and_passes_context( assert result["intent"]["stages"] == [] +@pytest.mark.asyncio +async def test_agent_chat_keeps_english_language_separate_from_business_query( + monkeypatch, + web_main, +): + captured = [] + _install_router( + monkeypatch, + { + "status": "ok", + "action": "reply", + "message": "I can help with that.", + "intent": None, + "sources": [], + "tool_trace": [], + }, + captured, + ) + request = web_main.AgentChatRequest( + message="执行接下来的分析", + language="en", + session_id="english-business-query", + job_id="job-english-business-query", + history=[], + ) + + result = await web_main.agent_chat(request) + + assert result["intent"]["message"] == "I can help with that." + assert request.message == "执行接下来的分析" + assert captured[0]["query"] == "执行接下来的分析" + assert captured[0]["language"] == "en" + + @pytest.mark.asyncio async def test_agent_chat_persists_router_proposal_without_analysis_intent( monkeypatch, @@ -158,6 +193,68 @@ async def test_agent_chat_persists_router_proposal_without_analysis_intent( } +@pytest.mark.asyncio +async def test_agent_chat_exposes_and_persists_current_recommendation( + monkeypatch, + web_main, + tmp_path, +): + repository = SQLiteAgentStateRepository(tmp_path / "agent-state.sqlite3") + repository.initialize() + store = PersistentConversationStore(repository) + monkeypatch.setattr(web_main, "agent_conversation_store", store) + recommendation = { + "recommendation_id": "rec-build-subfamily-evidence", + "job_id": "job-recommendation", + "workflow_id": "gene_family", + "transition_id": "build_subfamily_evidence", + "from_state_id": "family_identified", + "to_state_id": "subfamily_evidence_ready", + "target_stage": "combo", + "authorized_stages": [ + "domain", + "motif", + "phylogeny", + "structure", + "combo", + ], + "evidence_digest": "a" * 64, + "family": "SPL", + "scope_id": "SPL.target-Ft.target_only", + } + _install_router( + monkeypatch, + { + "status": "ok", + "action": "show_result", + "message": "下一步是构建亚家族证据。", + "intent": None, + "recommendation": recommendation, + "sources": [], + "tool_trace": [], + }, + [], + ) + + result = await web_main.agent_chat( + web_main.AgentChatRequest( + message="接下来的分析有哪些?", + session_id="recommendation-session", + job_id="job-recommendation", + history=[], + ) + ) + + assert result["action"] == "show_result" + assert result["recommendation"] == recommendation + ledger = repository.get_goal_ledger(result["session_id"]) + assert ledger is not None + assert ( + ledger.model_dump(mode="json")["user_terms"]["current_recommendation"] + == recommendation + ) + + @pytest.mark.asyncio async def test_agent_chat_exposes_confirmed_request_for_original_planner( monkeypatch, diff --git a/tests/unit/test_authorized_workflow_action_plan.py b/tests/unit/test_authorized_workflow_action_plan.py new file mode 100644 index 000000000..a55b99d84 --- /dev/null +++ b/tests/unit/test_authorized_workflow_action_plan.py @@ -0,0 +1,137 @@ +import asyncio + +from apps.web.vue3 import main as api + + +def test_build_subfamily_evidence_plan_keeps_confirmed_stage_boundary(): + intent = { + "workflow_action_id": "build_subfamily_evidence", + "stages": ["domain", "motif", "phylogeny", "structure", "combo"], + "params": { + "workflowActionId": "build_subfamily_evidence", + "authorizedStages": [ + "domain", + "motif", + "phylogeny", + "structure", + "combo", + ], + }, + } + + stages = api._authorized_workflow_action_stages(intent) + + assert stages == ["phylogeny", "motif", "domain", "structure", "combo"] + assert "identify" not in stages + assert "reference_classification" not in stages + + +def test_unbound_plan_does_not_invent_an_authorized_stage_boundary(): + assert api._authorized_workflow_action_stages( + {"stages": ["identify", "phylogeny", "reference_classification"]} + ) == [] + + +def test_agent_plan_honors_authorized_workflow_action_without_keyword_reclassification( + monkeypatch, +): + authorized = ["domain", "motif", "phylogeny", "structure", "combo"] + intent = { + "gene_family": "SPL", + "target_species": ["Ft"], + "reference_species": ["At"], + "stages": authorized, + "primary_stage": "combo", + "workflow_action_id": "build_subfamily_evidence", + "params": { + "workflowActionId": "build_subfamily_evidence", + "authorizedStages": authorized, + "targetSpecies": ["Ft"], + "referenceSpecies": ["At"], + }, + } + monkeypatch.setattr( + api, + "_reconcile_confirmed_gene_family_species", + lambda *args, **kwargs: {"applicable": False, "conflicts": []}, + ) + monkeypatch.setattr( + api, "_resolve_authoritative_gene_family", lambda *args, **kwargs: "SPL" + ) + monkeypatch.setattr( + api, + "_resolve_subfamily_request_species_roles", + lambda *args, **kwargs: { + "classification_species": ["Ft"], + "reference_species": ["At"], + }, + ) + monkeypatch.setattr( + api, + "_latest_gene_family_scope_defaults", + lambda *args, **kwargs: { + "target_species": ["Ft"], + "reference_species": ["At"], + "comparative_species": [], + }, + ) + monkeypatch.setattr( + api, + "_load_identify_species_selection", + lambda *args, **kwargs: { + "groups": { + "target": [{"species": "Ft"}], + "reference": [{"species": "At"}], + "comparative": [], + } + }, + ) + monkeypatch.setattr( + api, + "_subfamily_state_for_targets", + lambda *args, **kwargs: {"overall": "not_classified", "by_species": {}}, + ) + monkeypatch.setattr( + api, "_identity_completed_stages_for_plan", lambda *args, **kwargs: [] + ) + monkeypatch.setattr( + api, + "_resolve_contextual_gene_family_request", + lambda *args, **kwargs: { + "family": "SPL", + "prefix": "Ft.SPL", + "stages": authorized, + "targetSpecies": ["Ft"], + "referenceSpecies": ["At"], + "comparativeSpecies": [], + "scopeMode": "target_plus_reference", + "contextGate": {"valid": False, "issues": []}, + "context_route": "gene_family_default_scope", + "requiresConfirmedSubfamily": False, + "skipSubfamilyLabels": False, + "subfamilyState": { + "overall": "not_classified", + "by_species": {"Ft": {"status": "not_classified"}}, + }, + }, + ) + + response = asyncio.run( + api.agent_plan( + api.AgentPlanRequest( + message="执行接下来的步骤", + session_id="authorized-action-test", + job_id="job-test", + prefix="Ft.SPL", + intent=intent, + ) + ) + ) + + expected = ["phylogeny", "motif", "domain", "structure", "combo"] + assert response["plan"]["all_stages"] == expected + assert response["plan"]["stages"] == expected + assert response["intent"]["workflow_action_id"] == "build_subfamily_evidence" + assert response["intent"]["params"]["authorizedStages"] == authorized + assert "reference_classification" not in response["plan"]["all_stages"] + assert "subfamily_confirm" not in response["plan"]["all_stages"] diff --git a/tests/unit/test_current_recommendation_contract.py b/tests/unit/test_current_recommendation_contract.py new file mode 100644 index 000000000..d5f6cf8fb --- /dev/null +++ b/tests/unit/test_current_recommendation_contract.py @@ -0,0 +1,450 @@ +import asyncio +import json + +from pydantic import BaseModel + +from gfa_core.mas.agent_state_repository import SQLiteAgentStateRepository +from gfa_core.mas.agents.conversation_tools import ConversationToolRegistry +from gfa_core.mas.agents.router_runtime import RouterRuntime +from gfa_core.mas.llm import LLMResponse, LLMToolCall +from gfa_core.mas.persistent_conversation import PersistentConversationStore +from gfa_core.mas.workflow_models import CurrentRecommendation, GoalLedger + + +class _NoArgs(BaseModel): + pass + + +class _IntentArgs(BaseModel): + query: str + + +class _SequenceLLM: + def __init__(self, responses): + self.responses = list(responses) + + async def acall(self, *_args, **_kwargs): + return self.responses.pop(0) + + +def _response(content="", *, tool_calls=None): + return LLMResponse( + content=content, + model="test-model", + provider="test-provider", + usage={}, + tool_calls=list(tool_calls or []), + ) + + +def _recommendation(job_id="job-current-recommendation"): + return CurrentRecommendation( + recommendation_id="rec-build-subfamily-evidence", + job_id=job_id, + workflow_id="gene_family", + transition_id="build_subfamily_evidence", + from_state_id="family_identified", + to_state_id="subfamily_evidence_ready", + target_stage="combo", + authorized_stages=("domain", "motif", "phylogeny", "structure", "combo"), + evidence_digest="a" * 64, + family="SPL", + scope_id="SPL.target-Ft.target_only", + ) + + +def test_identified_project_context_projects_one_typed_recommendation(): + recommendation = RouterRuntime._current_workflow_recommendation( + "gene_family", + "job-project-recommendation", + { + "family": "SPL", + "scopes": [{"scope_id": "SPL.target-Ft.target_only"}], + "manifests": [{"stage": "identify", "status": "success"}], + "stage_status": { + "identify": {"stage": "identify", "status": "success"} + }, + }, + ) + + assert isinstance(recommendation, CurrentRecommendation) + assert recommendation.transition_id == "build_subfamily_evidence" + assert recommendation.target_stage == "combo" + assert recommendation.authorized_stages == ( + "domain", + "motif", + "phylogeny", + "structure", + "combo", + ) + + +def test_next_analysis_question_stays_read_only_and_returns_typed_recommendation(): + job_id = "job-next-analysis-question" + context = { + "job_id": job_id, + "family": "SPL", + "scopes": [{"scope_id": "SPL.target-Ft.target_only"}], + "manifests": [{"stage": "identify", "status": "success"}], + "stage_status": { + "identify": {"stage": "identify", "status": "success"} + }, + } + registry = ConversationToolRegistry() + registry.register( + name="inspect_project_context", + description="Inspect current project context.", + args_model=_NoArgs, + handler=lambda _args: context, + permission="read_only", + ) + llm = _SequenceLLM( + [ + _response( + '{"goal":"project_status","needs":["project_context"],' + '"subject":"当前工作流的下一业务动作",' + '"referent":null,"clarification_question":null,' + '"interaction_kind":"status","workflow_id":"gene_family",' + '"analysis_request_kind":null,' + '"uses_current_recommendation":false,' + '"confirms_pending_proposal":false}' + ), + _response( + tool_calls=[ + LLMToolCall( + id="inspect-next-analysis-1", + name="inspect_project_context", + arguments="{}", + ) + ] + ), + _response( + '{"status":"ok","action":"show_result",' + '"message":"下一步是构建亚家族证据。"}' + ), + ] + ) + + outcome = asyncio.run( + RouterRuntime(llm, registry, max_iterations=5).run( + query="接下来的分析有哪些?", + history=[], + session_id="session-next-analysis-question", + job_id=job_id, + goal_ledger=GoalLedger( + conversation_id="conversation-next-analysis-question", + revision=0, + user_terms={"active_job_id": job_id, "gene_family": "SPL"}, + ), + ) + ) + + assert outcome.action == "show_result" + assert outcome.proposal is None + assert outcome.recommendation is not None + assert outcome.recommendation.transition_id == "build_subfamily_evidence" + + +def test_show_result_persists_and_cancel_clears_current_recommendation(tmp_path): + repository = SQLiteAgentStateRepository(tmp_path / "agent-state.sqlite3") + repository.initialize() + store = PersistentConversationStore(repository) + turn = store.begin_turn( + session_id="session-current-recommendation", + job_id="job-current-recommendation", + query="接下来的分析有哪些?", + client_history=[], + ) + recommendation = _recommendation().model_dump(mode="json") + + ledger = store.finish_turn( + turn, + assistant_message="下一步是构建亚家族证据。", + interaction_kind="status", + action="show_result", + recommendation=recommendation, + ) + assert ( + ledger.model_dump(mode="json")["user_terms"]["current_recommendation"] + == recommendation + ) + + cancel_turn = store.begin_turn( + session_id="session-current-recommendation", + job_id="job-current-recommendation", + query="取消", + client_history=[], + ) + ledger = store.finish_turn( + cancel_turn, + assistant_message="已取消。", + interaction_kind="cancel", + action="reply", + ) + assert "current_recommendation" not in ledger.user_terms + + +def test_pending_proposal_preserves_complete_workflow_action_binding(tmp_path): + repository = SQLiteAgentStateRepository(tmp_path / "agent-state.sqlite3") + repository.initialize() + store = PersistentConversationStore(repository) + turn = store.begin_turn( + session_id="session-action-binding", + job_id="job-action-binding", + query="执行当前推荐", + client_history=[], + ) + proposal = { + "proposal_id": "proposal-action-binding", + "request": "执行 build_subfamily_evidence", + "subject": "执行当前推荐", + "workflow_id": "gene_family", + "workflow_action_id": "build_subfamily_evidence", + "authorized_stages": "domain,motif,phylogeny,structure,combo", + "target_stage": "combo", + } + + ledger = store.finish_turn( + turn, + assistant_message="请确认。", + interaction_kind="supplement", + action="clarify", + proposal=proposal, + ) + + assert ledger.user_terms["pending_proposal"] == { + **proposal, + "job_id": "job-action-binding", + } + + +def test_execute_current_recommendation_revalidates_and_limits_intent(): + job_id = "job-execute-current-recommendation" + context = { + "job_id": job_id, + "family": "SPL", + "scopes": [{"scope_id": "SPL.target-Ft.target_only"}], + "manifests": [{"stage": "identify", "status": "success"}], + "stage_status": { + "identify": {"stage": "identify", "status": "success"} + }, + } + recommendation = RouterRuntime._current_workflow_recommendation( + "gene_family", job_id, context + ) + registry = ConversationToolRegistry() + registry.register( + name="inspect_project_context", + description="Inspect current project context.", + args_model=_NoArgs, + handler=lambda _args: context, + permission="read_only", + ) + registry.register( + name="recognize_analysis_intent", + description="Recognize an analysis intent.", + args_model=_IntentArgs, + handler=lambda _args: { + "gene_family": "SPL", + "stages": ["identify"], + "primary_stage": "identify", + "params": {"targetSpecies": ["Ft"], "referenceSpecies": ["At"]}, + }, + permission="pure", + ) + ledger = GoalLedger( + conversation_id="conversation-execute-current-recommendation", + revision=0, + user_terms={ + "active_job_id": job_id, + "current_recommendation": recommendation.model_dump(mode="json"), + }, + ) + llm = _SequenceLLM( + [ + _response( + '{"goal":"new_analysis","needs":["analysis_intent"],' + '"subject":"执行当前推荐动作","referent":"current_recommendation",' + '"clarification_question":null,"interaction_kind":"supplement",' + '"analysis_request_kind":"execution",' + '"uses_current_recommendation":true,' + '"confirms_pending_proposal":false}' + ), + _response( + tool_calls=[ + LLMToolCall( + id="recognize-current-recommendation-1", + name="recognize_analysis_intent", + arguments=json.dumps( + {"query": "执行接下来的分析"}, ensure_ascii=False + ), + ) + ] + ), + _response( + '{"status":"ok","action":"propose_analysis",' + '"message":"已交给现有分析系统。"}' + ), + ] + ) + + outcome = asyncio.run( + RouterRuntime(llm, registry, max_iterations=5).run( + query="执行接下来的分析", + history=[], + session_id="session-execute-current-recommendation", + job_id=job_id, + goal_ledger=ledger, + ) + ) + + assert outcome.action == "propose_analysis" + assert outcome.intent["stages"] == [ + "domain", + "motif", + "phylogeny", + "structure", + "combo", + ] + assert outcome.intent["primary_stage"] == "combo" + assert outcome.intent["workflow_action_id"] == "build_subfamily_evidence" + + +def test_semantic_business_referent_hands_off_when_workflow_is_terminal(): + captured_queries = [] + registry = ConversationToolRegistry() + registry.register( + name="recognize_analysis_intent", + description="Recognize an analysis intent.", + args_model=_IntentArgs, + handler=lambda args: captured_queries.append(args.query) + or { + "gene_family": "SPL", + "stages": ["promoter", "synteny", "ppi"], + "primary_stage": "promoter", + }, + permission="pure", + ) + llm = _SequenceLLM( + [ + _response( + '{"goal":"new_analysis","needs":["analysis_intent"],' + '"subject":"执行上一轮说明的后续业务",' + '"referent":"常规基因家族分析",' + '"clarification_question":null,"interaction_kind":"supplement",' + '"workflow_id":"gene_family","analysis_request_kind":"execution",' + '"uses_current_recommendation":true,' + '"confirms_pending_proposal":false}' + ), + _response( + tool_calls=[ + LLMToolCall( + id="recognize-semantic-business-referent-1", + name="recognize_analysis_intent", + arguments=json.dumps( + {"query": "执行常规基因家族分析"}, ensure_ascii=False + ), + ) + ] + ), + _response( + '{"status":"ok","action":"propose_analysis",' + '"message":"分析请求已交给现有意图系统。"}' + ), + ] + ) + + outcome = asyncio.run( + RouterRuntime(llm, registry, max_iterations=5).run( + query="执行接下来的分析", + history=[ + { + "role": "assistant", + "content": ( + "亚家族分类与确认已经完成," + "接下来进入常规基因家族分析。" + ), + } + ], + session_id="session-semantic-business-referent", + job_id="job-confirmed-family", + goal_ledger=GoalLedger( + conversation_id="conversation-semantic-business-referent", + revision=0, + user_terms={"active_job_id": "job-confirmed-family"}, + ), + ) + ) + + assert outcome.action == "propose_analysis" + assert captured_queries == ["执行常规基因家族分析"] + assert outcome.intent["stages"] == ["promoter", "synteny", "ppi"] + + +def test_unresolved_current_recommendation_without_binding_still_clarifies(): + llm = _SequenceLLM( + [ + _response( + '{"goal":"new_analysis","needs":["analysis_intent"],' + '"subject":"执行当前推荐动作",' + '"referent":"current_recommendation",' + '"clarification_question":null,"interaction_kind":"supplement",' + '"workflow_id":"gene_family","analysis_request_kind":"execution",' + '"uses_current_recommendation":true,' + '"confirms_pending_proposal":false}' + ) + ] + ) + + outcome = asyncio.run( + RouterRuntime(llm, ConversationToolRegistry(), max_iterations=5).run( + query="执行接下来的分析", + history=[], + session_id="session-unresolved-recommendation", + job_id="job-unresolved-recommendation", + goal_ledger=GoalLedger( + conversation_id="conversation-unresolved-recommendation", + revision=0, + user_terms={"active_job_id": "job-unresolved-recommendation"}, + ), + ) + ) + + assert outcome.action == "clarify" + assert "明确" in outcome.message + + +def test_execute_current_recommendation_rejects_cross_job_binding(): + recommendation = _recommendation(job_id="job-original") + ledger = GoalLedger( + conversation_id="conversation-cross-job-recommendation", + revision=0, + user_terms={ + "current_recommendation": recommendation.model_dump(mode="json") + }, + ) + llm = _SequenceLLM( + [ + _response( + '{"goal":"new_analysis","needs":["analysis_intent"],' + '"subject":"执行当前推荐动作","referent":"current_recommendation",' + '"clarification_question":null,"interaction_kind":"supplement",' + '"analysis_request_kind":"execution",' + '"uses_current_recommendation":true,' + '"confirms_pending_proposal":false}' + ) + ] + ) + + outcome = asyncio.run( + RouterRuntime(llm, ConversationToolRegistry(), max_iterations=5).run( + query="执行接下来的分析", + history=[], + session_id="session-cross-job-recommendation", + job_id="job-other", + goal_ledger=ledger, + ) + ) + + assert outcome.action == "clarify" + assert "当前 job" in outcome.message diff --git a/tests/unit/test_deepseek_json_protocol.py b/tests/unit/test_deepseek_json_protocol.py new file mode 100644 index 000000000..fc76a4cde --- /dev/null +++ b/tests/unit/test_deepseek_json_protocol.py @@ -0,0 +1,88 @@ +import asyncio +from types import SimpleNamespace + +from gfa_core.mas.agents.conversation_tools import ConversationToolRegistry +from gfa_core.mas.agents.router_runtime import RouterRuntime +from gfa_core.mas.llm import LLMMessage, LLMResponse +from gfa_core.mas.llm.providers.deepseek import DeepSeekProvider + + +def test_deepseek_provider_forwards_requested_json_mode(): + captured = {} + + class _Completions: + def create(self, **kwargs): + captured.update(kwargs) + return SimpleNamespace( + choices=[ + SimpleNamespace( + message=SimpleNamespace(content='{"ok":true}', tool_calls=[]), + finish_reason="stop", + ) + ], + usage=SimpleNamespace( + prompt_tokens=1, + completion_tokens=1, + total_tokens=2, + ), + ) + + provider = DeepSeekProvider(api_key="test-key") + provider.client = SimpleNamespace( + chat=SimpleNamespace(completions=_Completions()) + ) + + provider.call( + [LLMMessage(role="user", content="Return JSON")], + response_format={"type": "json_object"}, + ) + + assert captured["response_format"] == {"type": "json_object"} + + +def test_router_route_but_not_freeform_reply_requests_native_json_mode(): + class _RecordingLLM: + def __init__(self): + self.calls = [] + self.responses = [ + LLMResponse( + content=( + '{"goal":"general","needs":[],"subject":"编写脚本",' + '"referent":null,"clarification_question":null,' + '"interaction_kind":"supplement"}' + ), + model="test-model", + provider="test-provider", + usage={}, + ), + LLMResponse( + content=( + '{"status":"ok","action":"reply",' + '"message":"已生成脚本。"}' + ), + model="test-model", + provider="test-provider", + usage={}, + ), + ] + + async def acall(self, _messages, **kwargs): + self.calls.append(kwargs) + return self.responses.pop(0) + + llm = _RecordingLLM() + outcome = asyncio.run( + RouterRuntime(llm, ConversationToolRegistry(), max_iterations=5).run( + query="帮我写一个 FASTA 脚本", + history=[], + session_id="deepseek-json-protocol", + job_id="", + goal_ledger=None, + ) + ) + + assert outcome.action == "reply" + assert [call.get("response_format") for call in llm.calls] == [ + {"type": "json_object"}, + None, + ] diff --git a/tests/unit/test_gene_family_report.py b/tests/unit/test_gene_family_report.py new file mode 100644 index 000000000..45a996914 --- /dev/null +++ b/tests/unit/test_gene_family_report.py @@ -0,0 +1,844 @@ +from __future__ import annotations + +import json +from pathlib import Path +import re + +import pytest + +import gfa_core.gene_family_report as report_module +from gfa_core.gene_family_report import ( + NoSuccessfulGeneFamilyEvidence, + generate_gene_family_report, +) + + +def _write_manifest( + workspace: Path, + *, + prefix: str, + stage: str, + status: str = "success", + scope_id: str = "scope-a", + analysis_route: str = "post_subfamily_confirm", + member_set_id: str = "members-a", + metric_value: int = 1, +) -> Path: + final_dir = workspace / "final" + final_dir.mkdir(parents=True, exist_ok=True) + token = f"{scope_id}.{analysis_route}.{member_set_id}" + table_path = final_dir / f"{prefix}.{token}.{stage}.tsv" + table_path.write_text(f"gene\tvalue\n{scope_id}_gene\t{metric_value}\n", encoding="utf-8") + manifest_path = final_dir / f"{prefix}.{token}.{stage}.manifest.json" + manifest = { + "manifest_version": "1.0", + "stage": stage, + "status": status, + "prefix": prefix, + "scope_id": scope_id, + "analysis_route": analysis_route, + "member_set_id": member_set_id, + "parameters": { + "scope_id": scope_id, + "analysis_route": analysis_route, + "member_set_id": member_set_id, + }, + "metrics": {"member_count": metric_value}, + "artifacts": { + "result_table": { + "path": f"final/{table_path.name}", + "full_path": str(table_path), + "type": "table", + } + }, + } + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8") + return manifest_path + + +def _generate( + workspace: Path, + *, + expected_stages: list[str], + stage_statuses: dict[str, str], + use_model: bool = False, + action: str = "generate", +): + return generate_gene_family_report( + workspace, + "Ft.SPL", + expected_stages=expected_stages, + stage_statuses=stage_statuses, + scope_id="scope-a", + analysis_route="post_subfamily_confirm", + member_set_id="members-a", + use_model=use_model, + action=action, + job_id="job-report", + ) + + +def _stage_section(markdown: str, stage: str) -> str: + heading = re.search(rf"(?m)^## (?!#).*?\(`{re.escape(stage)}`\)", markdown) + if heading is None: + raise AssertionError(f"missing report chapter for {stage}") + next_heading = re.search(r"(?m)^## (?!#)", markdown[heading.end():]) + end = heading.end() + next_heading.start() if next_heading else len(markdown) + return markdown[heading.start():end] + + +def _subsection(markdown: str, number: str) -> str: + heading = re.search(rf"(?m)^### {re.escape(number)}(?:\s|$)", markdown) + if heading is None: + raise AssertionError(f"missing report subsection {number}") + next_heading = re.search(r"(?m)^#{2,3} ", markdown[heading.end():]) + end = heading.end() + next_heading.start() if next_heading else len(markdown) + return markdown[heading.start():end] + + +def _valid_api_arrangement(payload: dict, *, reverse: bool = False) -> dict: + sections = [] + for section in payload["sections"]: + sentence_ids = [sentence["sentence_id"] for sentence in section["sentences"]] + if reverse: + sentence_ids.reverse() + sections.append( + { + "section_id": section["section_id"], + "paragraphs": [ + { + "sentence_ids": sentence_ids, + "connector_ids": ["also"] * max(0, len(sentence_ids) - 1), + } + ], + } + ) + return {"sections": sections} + + +def test_report_uses_only_planned_stages_and_current_identity(tmp_path: Path) -> None: + workspace = tmp_path / "job" + _write_manifest(workspace, prefix="Ft.SPL", stage="identify", metric_value=3) + other = _write_manifest( + workspace, + prefix="Ft.SPL", + stage="identify", + scope_id="scope-b", + analysis_route="target_only", + member_set_id="members-b", + metric_value=99, + ) + + result = _generate( + workspace, + expected_stages=["identify"], + stage_statuses={"identify": "success"}, + ) + + report_text = result.report_path.read_text(encoding="utf-8") + assert "最终成员数:3" in report_text + assert other.name not in report_text + generated = {path.name for path in (workspace / "final").glob("Ft.SPL.gene_family_report.*")} + assert generated == { + "Ft.SPL.gene_family_report.md", + "Ft.SPL.gene_family_report.manifest.json", + } + + +def test_report_renders_template_subsections_for_only_planned_stages(tmp_path: Path) -> None: + workspace = tmp_path / "job" + for stage in ("identify", "motif", "pep"): + _write_manifest(workspace, prefix="Ft.SPL", stage=stage) + + result = _generate( + workspace, + expected_stages=["identify", "motif", "pep"], + stage_statuses={"identify": "success", "motif": "success", "pep": "success"}, + ) + + markdown = result.report_path.read_text(encoding="utf-8") + expected_subsections = { + "identify": "### 4.3 最终成员数量与命名", + "motif": "### 7.2 Motif 数量与出现频率", + "pep": "### 16.1 蛋白理化性质分布图", + } + for stage, subsection in expected_subsections.items(): + assert subsection in _stage_section(markdown, stage) + assert "(`synteny`)" not in markdown + assert "【填写】" not in markdown + + +def test_report_renders_nested_motif_metrics_without_placeholder_values(tmp_path: Path) -> None: + workspace = tmp_path / "job" + manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="motif") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["metrics"] = { + "motif_count": 10, + "total_sites": 105, + "motifs": [ + {"width": width} + for width in (78, 76, 80, 113, 57, 29, 52, 138, 27, 29) + ], + } + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8") + + result = _generate( + workspace, + expected_stages=["motif"], + stage_statuses={"motif": "success"}, + ) + + motif_section = _stage_section(result.report_path.read_text(encoding="utf-8"), "motif") + assert re.search(r"识别\s*10\s*个\s*motif", motif_section) + assert re.search(r"总位点数为\s*105", motif_section) + assert re.search(r"宽度范围为\s*27–138", motif_section) + assert "未解析" not in motif_section + assert "N/A" not in motif_section + assert "None" not in motif_section + + +def test_identify_uses_real_manifest_member_count_aliases(tmp_path: Path) -> None: + workspace = tmp_path / "job" + manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="identify") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["metrics"] = {"identified_members_count": 24, "final_members": 24} + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8") + + result = _generate( + workspace, + expected_stages=["identify"], + stage_statuses={"identify": "success"}, + ) + + section = _stage_section(result.report_path.read_text(encoding="utf-8"), "identify") + assert "最终成员数:24" in section + assert re.search(r"形成\s*24\s*个\s*SPL家族", section) + assert "源结果未提供此指标" not in section + + +def test_phylogeny_renders_real_manifest_schema_with_explicit_labels(tmp_path: Path) -> None: + workspace = tmp_path / "job" + manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="phylogeny") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["metrics"] = {"sequences_count": 40, "msa_length": 1449} + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8") + + result = _generate( + workspace, + expected_stages=["phylogeny"], + stage_statuses={"phylogeny": "success"}, + ) + + section = _stage_section(result.report_path.read_text(encoding="utf-8"), "phylogeny") + assert "序列数:40" in section + assert "多序列比对长度:1449" in section + assert re.search(r"使用\s*40\s*条序列.*比对长度为\s*1449", section) + + +def test_interspecies_synteny_reads_only_named_real_schema_artifacts(tmp_path: Path) -> None: + workspace = tmp_path / "job" + manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="interspecies_synteny") + final_dir = workspace / "final" + gene_to_species = final_dir / "Ft.SPL.gene_to_species.tsv" + gene_to_species.write_text( + "gene_id\tspecies\tgroup\n" + "FtGene1.01.T01\tFt\ttarget\n" + "FtGene2.01.T01\tFt\ttarget\n" + "AT1G00010\tAT\tref\n" + "AT1G00020\tAT\tref\n", + encoding="utf-8", + ) + collinearity = final_dir / "Ft.At.collinearity" + lines = [] + for index in range(521): + lines.append(f"## Alignment {index}: score=1 e_value=1e-10 N=1 Ft&At plus") + if index == 0: + lines.append(" 0- 0:\tAT1G00010.1\tFtGene1.01.T01\t1e-20") + elif index == 1: + lines.append(" 1- 0:\tAT1G00020.1\tFtGene2.01.T01\t1e-20") + collinearity.write_text("\n".join(lines) + "\n", encoding="utf-8") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["metrics"] = {"pair_success_count": 1, "pair_total_count": 1} + manifest["artifacts"] = { + "gene_to_species": {"path": f"final/{gene_to_species.name}", "type": "tsv"}, + "Ft.At_collinearity": {"path": f"final/{collinearity.name}", "type": "data"}, + } + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8") + + result = _generate( + workspace, + expected_stages=["interspecies_synteny"], + stage_statuses={"interspecies_synteny": "success"}, + ) + + section = _stage_section(result.report_path.read_text(encoding="utf-8"), "interspecies_synteny") + assert "共线性区块数:521" in section + assert "家族成员目标—参考配对数:2" in section + assert re.search(r"全基因组\s*block\s*521\s*个.*目标-参考家族.*?2\s*对", section) + + +def test_pep_renders_real_manifest_task_summary_with_explicit_labels(tmp_path: Path) -> None: + workspace = tmp_path / "job" + manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="pep") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["metrics"] = {"protein_count": 40, "tasks_success": 3, "tasks_total": 3} + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8") + + result = _generate( + workspace, + expected_stages=["pep"], + stage_statuses={"pep": "success"}, + ) + + section = _stage_section(result.report_path.read_text(encoding="utf-8"), "pep") + assert "蛋白数:40" in section + assert "子任务成功数:3/3" in section + assert re.search(r"覆盖\s*40\s*个蛋白,子任务成功数为\s*3/3", section) + + +def test_report_has_no_empty_third_level_subsections_or_repeated_generic_interpretation( + tmp_path: Path, +) -> None: + workspace = tmp_path / "job" + for stage in ("identify", "phylogeny", "motif"): + manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage=stage) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + if stage == "phylogeny": + manifest["metrics"] = {"sequences_count": 40, "msa_length": 1449} + elif stage == "motif": + manifest["metrics"] = { + "sequence_count": 40, + "motif_count": 10, + "total_sites": 105, + "motifs": [{"width": 27}, {"width": 138}], + } + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8") + + result = _generate( + workspace, + expected_stages=["identify", "phylogeny", "motif"], + stage_statuses={stage: "success" for stage in ("identify", "phylogeny", "motif")}, + ) + + markdown = result.report_path.read_text(encoding="utf-8") + for stage in ("identify", "phylogeny", "motif"): + section = _stage_section(markdown, stage) + chunks = re.split(r"(?m)(?=^### )", section)[1:] + assert chunks + for chunk in chunks: + body = "\n".join(chunk.splitlines()[1:]).strip() + assert body, f"empty subsection: {chunk.splitlines()[0]}" + assert markdown.count("本节解释仅限上述可追溯结果,不据此断言真实功能或因果关系。") <= 1 + assert markdown.count("结果范围受当前输入、参数和源文件完整性限制。") <= 1 + + +def test_front_summary_contains_project_numbers_in_six_to_ten_sentences(tmp_path: Path) -> None: + workspace = tmp_path / "job" + identify_path = _write_manifest(workspace, prefix="Ft.SPL", stage="identify") + identify = json.loads(identify_path.read_text(encoding="utf-8")) + identify["metrics"] = {"identified_members_count": 24, "final_members": 24} + identify_path.write_text(json.dumps(identify, ensure_ascii=False), encoding="utf-8") + phylogeny_path = _write_manifest(workspace, prefix="Ft.SPL", stage="phylogeny") + phylogeny = json.loads(phylogeny_path.read_text(encoding="utf-8")) + phylogeny["metrics"] = {"sequences_count": 40, "msa_length": 1449} + phylogeny_path.write_text(json.dumps(phylogeny, ensure_ascii=False), encoding="utf-8") + + result = _generate( + workspace, + expected_stages=["identify", "phylogeny"], + stage_statuses={"identify": "success", "phylogeny": "success"}, + ) + + summary = _subsection(result.report_path.read_text(encoding="utf-8"), "1.3") + assert re.search(r"目标物种.*24.*家族成员", summary) + assert re.search(r"目标.*参考.*40.*1449", summary) + assert 6 <= summary.count("。") <= 10 + + +def test_stage_specific_key_facts_land_in_their_semantic_subsections(tmp_path: Path) -> None: + workspace = tmp_path / "job" + final_dir = workspace / "final" + stages = [ + "reference_classification", "domain", "structure", "combo", + "promoter", "tfbs", "synteny", + ] + manifests = {stage: _write_manifest(workspace, prefix="Ft.SPL", stage=stage) for stage in stages} + + assignment = final_dir / "Ft.SPL.reference_classification.assignment.tsv" + assignment.write_text( + "target_gene\tassigned_subfamily\tconfidence\n" + + "\n".join( + f"FtGene{i}\t{'I' if i < 2 else 'II'}\t{confidence}" + for i, confidence in enumerate(("high", "high", "medium", "low"), start=1) + ) + + "\n", + encoding="utf-8", + ) + reference = json.loads(manifests["reference_classification"].read_text(encoding="utf-8")) + reference["metrics"] = {"target_gene_count": 4, "subfamily_rows": 6} + reference["artifacts"] = {"assignment_table": {"path": f"final/{assignment.name}", "type": "tsv"}} + manifests["reference_classification"].write_text(json.dumps(reference), encoding="utf-8") + + domain = json.loads(manifests["domain"].read_text(encoding="utf-8")) + domain["metrics"] = { + "proteins_total": 40, "proteins_with_domains": 40, "domain_coverage_rate": 1.0, + "top_domains": [["SBP", 40], ["Ig_SPL7", 12]], + } + manifests["domain"].write_text(json.dumps(domain), encoding="utf-8") + + structure = json.loads(manifests["structure"].read_text(encoding="utf-8")) + structure["metrics"] = { + "genes_total": 40, "genes_with_structure": 24, "structure_coverage_rate": 0.6, + "long_gene_outlier_count": 1, + "long_gene_outliers": [{"gene_id": "FtLong1", "length_bp": 12454, "cds_count": 10}], + } + manifests["structure"].write_text(json.dumps(structure), encoding="utf-8") + + combo = json.loads(manifests["combo"].read_text(encoding="utf-8")) + combo["metrics"] = None + combo["parameters"] = {"panels": ["motif", "domain", "structure"]} + combo["artifacts"] = { + "combo_plot_png": {"path": "final/Ft.SPL.combo.png", "type": "image"}, + "combo_plot_pdf": {"path": "final/Ft.SPL.combo.pdf", "type": "image"}, + } + manifests["combo"].write_text(json.dumps(combo), encoding="utf-8") + + element_counts = final_dir / "Ft.SPL.element_counts.txt" + element_counts.write_text("1460\tTATA-box\n852\tCAAT-box\n72\tMYB\n", encoding="utf-8") + category_summary = final_dir / "Ft.SPL.promoter_category_summary.tsv" + category_summary.write_text("big7\telement_instances\nCore promoter\t2600\nLight response\t188\n", encoding="utf-8") + enrichment = final_dir / "Ft.SPL.enrichment_analysis.txt" + enrichment.write_text( + "Subfamily\tElement\tCount\tSubfamilyTotal\tGlobalCount\tEnrichmentRatio\nI\tAACA_motif\t1\t222\t1\t16.66\n", + encoding="utf-8", + ) + cooccurrence = final_dir / "Ft.SPL.cooccurrence_analysis.txt" + cooccurrence.write_text( + "Element1\tElement2\tCo-occurrenceCount\tGenes\nCAAT-box\tMYB\t23\tFtGene1,FtGene2\n", + encoding="utf-8", + ) + complexity = final_dir / "Ft.SPL.complexity_analysis.txt" + complexity.write_text( + "Gene\tTotalElements\tUniqueElements\tComplexityScore\tComplexityClass\nFtComplex1\t278\t33\t361.44\tVery High\n", + encoding="utf-8", + ) + promoter = json.loads(manifests["promoter"].read_text(encoding="utf-8")) + promoter["metrics"] = {"total_elements": 3699, "unique_elements": 87, "genes_with_elements": 23} + promoter["artifacts"] = { + "element_counts": {"path": f"final/{element_counts.name}"}, + "viz_promoter_position_distribution_summary": {"path": f"final/{category_summary.name}"}, + "enrichment_analysis": {"path": f"final/{enrichment.name}"}, + "cooccurrence_analysis": {"path": f"final/{cooccurrence.name}"}, + "complexity_analysis": {"path": f"final/{complexity.name}"}, + } + manifests["promoter"].write_text(json.dumps(promoter), encoding="utf-8") + + motif_summary = final_dir / "Ft.SPL.tfbs.motif_summary.tsv" + motif_summary.write_text( + "motif_id\tmotif_alt_id\tfamily_sites\tfamily_genes_with_site\tbackground_sites\tbackground_genes_with_site\n" + "MOTIF_A\t\t153\t18\t0\t0\n", + encoding="utf-8", + ) + tfbs = json.loads(manifests["tfbs"].read_text(encoding="utf-8")) + tfbs["metrics"] = { + "family_promoter_count": 24, "background_promoter_count": 0, + "family_site_count": 3734, "family_motif_count": 230, "motif_database": "PlantTFDB", + } + tfbs["artifacts"] = {"motif_summary": {"path": f"final/{motif_summary.name}"}} + manifests["tfbs"].write_text(json.dumps(tfbs), encoding="utf-8") + + synteny = json.loads(manifests["synteny"].read_text(encoding="utf-8")) + synteny["metrics"] = {"gene_count": 31679, "synteny_blocks": 316, "gene_pairs": 3267, "circos_chromosomes_plotted": 8} + manifests["synteny"].write_text(json.dumps(synteny), encoding="utf-8") + + result = _generate( + workspace, + expected_stages=stages, + stage_statuses={stage: "success" for stage in stages}, + ) + markdown = result.report_path.read_text(encoding="utf-8") + assert re.search(r"4\s*个目标成员.*2\s*个小组.*高信心\s*2", _subsection(markdown, "6.2")) + assert re.search(r"40/40.*SBP.*40.*Ig_SPL7.*12", _subsection(markdown, "8.1")) + assert re.search(r"24/40.*FtLong1.*12454", _subsection(markdown, "9.1")) + assert "Motif、Domain、Structure" in _subsection(markdown, "10.1") + promoter_text = _subsection(markdown, "11.1") + assert re.search(r"23\s*个目标基因.*3699.*TATA-box.*1460", promoter_text) + assert "FtComplex1" in promoter_text and "CAAT-box—MYB" in promoter_text + assert re.search(r"24\s*条目标启动子.*3734.*MOTIF_A.*153", _subsection(markdown, "12.1")) + assert re.search(r"背景启动子为\s*0.*不能.*富集", _subsection(markdown, "12.1")) + assert re.search(r"全基因组.*316.*3267.*8\s*条染色体", _subsection(markdown, "13.1")) + assert "源结果未提供家族专属复制基因对" in _subsection(markdown, "13.1") + + +def test_all_planned_success_is_final(tmp_path: Path) -> None: + workspace = tmp_path / "job" + _write_manifest(workspace, prefix="Ft.SPL", stage="identify") + _write_manifest(workspace, prefix="Ft.SPL", stage="motif") + + result = _generate( + workspace, + expected_stages=["identify", "motif"], + stage_statuses={"identify": "success", "motif": "success"}, + ) + + manifest = json.loads(result.manifest_path.read_text(encoding="utf-8")) + assert result.report_status == "final" + assert manifest["metrics"]["report_status"] == "final" + assert manifest["metrics"]["included_stages"] == ["identify", "motif"] + + +def test_unsupported_successful_stage_is_visible_but_not_included_or_final(tmp_path: Path) -> None: + workspace = tmp_path / "job" + _write_manifest(workspace, prefix="Ft.SPL", stage="identify") + _write_manifest(workspace, prefix="Ft.SPL", stage="ppi") + + result = _generate( + workspace, + expected_stages=["identify", "ppi"], + stage_statuses={"identify": "success", "ppi": "success"}, + ) + + markdown = result.report_path.read_text(encoding="utf-8") + manifest = json.loads(result.manifest_path.read_text(encoding="utf-8")) + assert result.report_status == "partial" + assert result.included_stages == ("identify",) + assert "ppi" in markdown + assert "不支持" in markdown + assert "(`ppi`)" not in markdown + assert manifest["metrics"]["included_stages"] == ["identify"] + assert manifest["metrics"]["unsupported_stages"] == ["ppi"] + + +@pytest.mark.parametrize("expected_stages", [None, []]) +def test_generate_rejects_missing_plan_instead_of_inferring_historical_manifests( + tmp_path: Path, expected_stages: list[str] | None +) -> None: + workspace = tmp_path / "job" + _write_manifest(workspace, prefix="Ft.SPL", stage="identify") + + with pytest.raises(ValueError, match="expected_stages"): + generate_gene_family_report( + workspace, + "Ft.SPL", + expected_stages=expected_stages, + stage_statuses={"identify": "success"}, + scope_id="scope-a", + analysis_route="post_subfamily_confirm", + member_set_id="members-a", + use_model=False, + action="generate", + ) + + assert not list((workspace / "final").glob("*.gene_family_report.*")) + + +def test_report_selects_key_facts_without_dumping_manifest_or_stale_paths(tmp_path: Path) -> None: + workspace = tmp_path / "job" + manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="identify", metric_value=3) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["metrics"].update({"total_proteins": 31679, "blast_enabled": True}) + manifest["artifacts"]["result_table"]["path"] = "/home/xzq/old/job/final/Ft.SPL.identify.tsv" + manifest["artifacts"]["result_table"]["full_path"] = "/home/xzq/old/job/final/Ft.SPL.identify.tsv" + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8") + + result = _generate( + workspace, + expected_stages=["identify"], + stage_statuses={"identify": "success"}, + ) + + markdown = result.report_path.read_text(encoding="utf-8") + assert "最终成员数:3" in _subsection(markdown, "4.3") + assert not re.search(r"(?m)^- `(?:member_count|total_proteins|blast_enabled)\s*`:", markdown) + assert "/home/xzq/" not in markdown + + +def test_fallback_uses_nested_karyotype_metrics(tmp_path: Path) -> None: + workspace = tmp_path / "job" + manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="karyotype") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["metrics"] = { + "species": { + "Ft": { + "gene_count": 24, + "chromosome_count": 8, + "chromosomes": [f"Ft{i}" for i in range(1, 9)], + "tandem_count": 0, + } + } + } + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8") + + result = _generate( + workspace, + expected_stages=["karyotype"], + stage_statuses={"karyotype": "success"}, + ) + + markdown = result.report_path.read_text(encoding="utf-8") + assert "24个家族成员定位到8条染色体" in markdown + assert "核型分析将N/A" not in markdown + + +def test_artifact_path_remaps_remote_job_to_current_mount(tmp_path: Path) -> None: + from gfa_core.mas.agents.interpretation_evidence import _artifact_path + + final_dir = tmp_path / "job" / "final" + final_dir.mkdir(parents=True) + manifest_path = final_dir / "Ft.SPL.stage.manifest.json" + local_artifact = final_dir / "Ft.SPL.result.tsv" + local_artifact.write_text("gene\tvalue\nFtSPL1\t1\n", encoding="utf-8") + manifest = { + "_manifest_path": str(manifest_path), + "artifacts": { + "result": { + "full_path": "/home/xzq/old/PlantAI-dev/workspace/job-old/final/Ft.SPL.result.tsv", + "path": "/home/xzq/old/PlantAI-dev/workspace/job-old/final/Ft.SPL.result.tsv", + } + }, + } + + assert _artifact_path(manifest, "result") == local_artifact + + +def test_failed_or_missing_planned_stage_is_partial(tmp_path: Path) -> None: + workspace = tmp_path / "job" + _write_manifest(workspace, prefix="Ft.SPL", stage="identify") + _write_manifest(workspace, prefix="Ft.SPL", stage="motif", status="failed") + + result = _generate( + workspace, + expected_stages=["identify", "motif"], + stage_statuses={"identify": "success", "motif": "failed"}, + ) + + markdown = result.report_path.read_text(encoding="utf-8") + manifest = json.loads(result.manifest_path.read_text(encoding="utf-8")) + assert result.report_status == "partial" + assert "motif" in markdown.lower() + assert "failed" in markdown.lower() + assert manifest["metrics"]["failed_or_missing_stages"] == ["motif"] + + +def test_model_failure_creates_labeled_fallback_report( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workspace = tmp_path / "job" + _write_manifest(workspace, prefix="Ft.SPL", stage="identify") + + def fail_model(_evidence: dict, _stages: list[str]): + raise TimeoutError("provider timeout") + + monkeypatch.setattr(report_module, "_generate_api_narrative", fail_model) + result = _generate( + workspace, + expected_stages=["identify"], + stage_statuses={"identify": "success"}, + use_model=True, + ) + + markdown = result.report_path.read_text(encoding="utf-8") + manifest = json.loads(result.manifest_path.read_text(encoding="utf-8")) + assert result.generation_mode == "fallback_script" + assert "生成模式:候补脚本" in markdown + assert manifest["metrics"]["generation_mode"] == "fallback_script" + assert manifest["model"]["reason"] == "api_unavailable_or_boundary_violation" + assert "{'action':" not in markdown + + +def test_valid_model_sections_create_api_report( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workspace = tmp_path / "job" + _write_manifest(workspace, prefix="Ft.SPL", stage="identify") + + seen_payload: dict = {} + + def valid_model(payload: dict, _stages: list[str]): + seen_payload.update(payload) + return ( + _valid_api_arrangement(payload, reverse=True), + {"status": "generated", "provider": "test", "model": "test-model"}, + ) + + monkeypatch.setattr(report_module, "_generate_api_narrative", valid_model) + result = _generate( + workspace, + expected_stages=["identify"], + stage_statuses={"identify": "success"}, + use_model=True, + ) + + markdown = result.report_path.read_text(encoding="utf-8") + manifest = json.loads(result.manifest_path.read_text(encoding="utf-8")) + assert result.generation_mode == "api" + assert set(seen_payload) == {"sections", "allowed_connectors"} + assert all(set(section) == {"section_id", "sentences"} for section in seen_payload["sections"]) + assert all( + set(sentence) == {"sentence_id", "text"} + for section in seen_payload["sections"] + for sentence in section["sentences"] + ) + assert "最终成员数:1" in markdown + assert manifest["metrics"]["generation_mode"] == "api" + assert "【填写】" not in markdown + + +def test_unknown_model_sentence_id_falls_back_for_the_whole_report( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workspace = tmp_path / "job" + _write_manifest(workspace, prefix="Ft.SPL", stage="identify", metric_value=3) + + def invalid_model(payload: dict, _stages: list[str]): + response = _valid_api_arrangement(payload) + response["sections"][0]["paragraphs"][0]["sentence_ids"][0] = "unknown.sentence" + return response, {"status": "generated"} + + monkeypatch.setattr(report_module, "_generate_api_narrative", invalid_model) + result = _generate( + workspace, + expected_stages=["identify"], + stage_statuses={"identify": "success"}, + use_model=True, + ) + + markdown = result.report_path.read_text(encoding="utf-8") + manifest = json.loads(result.manifest_path.read_text(encoding="utf-8")) + assert result.generation_mode == "fallback_script" + assert "最终成员数:3" in markdown + assert manifest["model"]["reason"] == "api_unavailable_or_boundary_violation" + + +def test_missing_and_duplicate_model_sentence_ids_fall_back_for_the_whole_report( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workspace = tmp_path / "job" + _write_manifest(workspace, prefix="Ft.SPL", stage="identify", metric_value=3) + + def invalid_model(payload: dict, _stages: list[str]): + response = _valid_api_arrangement(payload) + paragraph = next( + item["paragraphs"][0] + for item in response["sections"] + if len(item["paragraphs"][0]["sentence_ids"]) >= 2 + ) + paragraph["sentence_ids"][-1] = paragraph["sentence_ids"][0] + return response, {"status": "generated"} + + monkeypatch.setattr(report_module, "_generate_api_narrative", invalid_model) + result = _generate( + workspace, + expected_stages=["identify"], + stage_statuses={"identify": "success"}, + use_model=True, + ) + + markdown = result.report_path.read_text(encoding="utf-8") + manifest = json.loads(result.manifest_path.read_text(encoding="utf-8")) + assert result.generation_mode == "fallback_script" + assert "最终成员数:3" in markdown + assert manifest["model"]["reason"] == "api_unavailable_or_boundary_violation" + + +@pytest.mark.parametrize( + "violation", + ["unknown_connector", "extra_field", "free_text_field", "changed_section"], +) +def test_model_schema_or_connector_boundary_violation_falls_back_for_the_whole_report( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, violation: str +) -> None: + workspace = tmp_path / "job" + _write_manifest(workspace, prefix="Ft.SPL", stage="identify", metric_value=3) + + def invalid_model(payload: dict, _stages: list[str]): + response = _valid_api_arrangement(payload) + if violation == "unknown_connector": + paragraph = next( + item["paragraphs"][0] + for item in response["sections"] + if item["paragraphs"][0]["connector_ids"] + ) + paragraph["connector_ids"][0] = "invented" + elif violation == "extra_field": + response["unexpected"] = True + elif violation == "free_text_field": + response["sections"][0]["paragraphs"][0]["text"] = "API 自由文本" + else: + response["sections"][0]["section_id"] = "changed.section" + return response, {"status": "generated"} + + monkeypatch.setattr(report_module, "_generate_api_narrative", invalid_model) + result = _generate( + workspace, + expected_stages=["identify"], + stage_statuses={"identify": "success"}, + use_model=True, + ) + + markdown = result.report_path.read_text(encoding="utf-8") + manifest = json.loads(result.manifest_path.read_text(encoding="utf-8")) + assert result.generation_mode == "fallback_script" + assert "最终成员数:3" in markdown + assert "API 自由文本" not in markdown + assert manifest["model"]["reason"] == "api_unavailable_or_boundary_violation" + + +def test_open_reuses_existing_report( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workspace = tmp_path / "job" + manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="identify") + calls = {"count": 0} + + def valid_model(payload: dict, _stages: list[str]): + calls["count"] += 1 + return ( + _valid_api_arrangement(payload), + {"status": "generated", "provider": "test", "model": "test-model"}, + ) + + monkeypatch.setattr(report_module, "_generate_api_narrative", valid_model) + first = _generate( + workspace, + expected_stages=["identify"], + stage_statuses={"identify": "success"}, + use_model=True, + ) + reopened = _generate( + workspace, + expected_stages=["identify"], + stage_statuses={"identify": "success"}, + use_model=True, + action="open", + ) + assert first.reused is False + assert reopened.reused is True + assert calls["count"] == 1 + + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["metrics"]["member_count"] = 2 + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8") + reopened_after_source_change = _generate( + workspace, + expected_stages=["identify"], + stage_statuses={"identify": "success"}, + use_model=True, + action="open", + ) + assert reopened_after_source_change.reused is True + assert calls["count"] == 1 + + +def test_no_successful_evidence_creates_no_empty_report(tmp_path: Path) -> None: + workspace = tmp_path / "job" + _write_manifest(workspace, prefix="Ft.SPL", stage="identify", status="failed") + + with pytest.raises(NoSuccessfulGeneFamilyEvidence, match="暂无可生成"): + _generate( + workspace, + expected_stages=["identify"], + stage_statuses={"identify": "failed"}, + ) + + assert not list((workspace / "final").glob("*.gene_family_report.*")) diff --git a/tests/unit/test_gene_family_report_intent.py b/tests/unit/test_gene_family_report_intent.py new file mode 100644 index 000000000..f5dcad547 --- /dev/null +++ b/tests/unit/test_gene_family_report_intent.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import asyncio +import json + +import pytest + +from gfa_core.mas.core.fast_intent import fast_intent_recognition +from gfa_core.mas.utils.intent_parser import parse_intent_from_query + + +@pytest.mark.parametrize( + ("query", "action"), + [ + ("生成结果报告", "generate"), + ("生成基因家族结果报告", "generate"), + ("打开基因家族结果报告", "open"), + ], +) +def test_explicit_gene_family_report_intent_is_provider_independent(query: str, action: str) -> None: + for parsed in (parse_intent_from_query(query), fast_intent_recognition(query)): + assert parsed is not None + assert parsed["stages"] == ["gene_family_report"] + assert parsed["params"]["geneFamilyReportAction"] == action + assert parsed["params"]["reportAction"] == action + assert parsed["method"] == "gene_family_report_deterministic" + + +def test_rnaseq_report_intent_is_not_stolen() -> None: + for parsed in ( + parse_intent_from_query("打开 RNA-seq 结果报告"), + fast_intent_recognition("打开 RNA-seq 结果报告"), + ): + assert not parsed or parsed.get("stages") != ["gene_family_report"] + + +@pytest.mark.parametrize( + ("query", "action"), + [ + ("生成结果报告", "generate"), + ("生成基因家族结果报告", "generate"), + ("打开基因家族结果报告", "open"), + ], +) +def test_gene_family_report_chat_bypasses_router( + monkeypatch, + tmp_path, + query: str, + action: str, +) -> None: + from apps.web.vue3 import main as web_main + from gfa_core.mas.agents import router as router_module + + final_dir = tmp_path / "workspace" / "job-report" / "final" + final_dir.mkdir(parents=True) + (final_dir / "Ft.SPL.gene_family_report.manifest.json").write_text( + json.dumps( + { + "stage": "gene_family_report", + "status": "success", + "prefix": "Ft.SPL", + "parameters": { + "expected_stages": ["identify", "motif"], + "stage_statuses": {"identify": "success", "motif": "success"}, + }, + } + ), + encoding="utf-8", + ) + + async def forbidden_router(*_args, **_kwargs): + raise AssertionError("report intent must return before Router/LLM") + + class ForbiddenOrchestrator: + async def plan(self, *_args, **_kwargs): + raise AssertionError("report plan must not call the LLM orchestrator") + + monkeypatch.setattr(web_main, "PROJECT_ROOT", tmp_path) + monkeypatch.setattr(web_main, "WORKSPACE_ROOT", tmp_path / "workspace") + monkeypatch.setattr(web_main, "agent_conversation_store", None, raising=False) + monkeypatch.setattr(web_main, "llm_manager", object()) + monkeypatch.setattr(web_main, "orchestrator", ForbiddenOrchestrator()) + monkeypatch.setattr(web_main, "MAS_AVAILABLE", True) + monkeypatch.setattr(router_module.RouterAgent, "execute", forbidden_router) + + result = asyncio.run( + web_main._run_agent_chat( + web_main.AgentChatRequest(message=query, job_id="job-report") + ) + ) + + assert result["action"] == "propose_analysis" + assert result["model_used"] == "rule" + assert result["intent"]["stages"] == ["gene_family_report"] + assert result["intent"]["prefix"] == "Ft.SPL" + assert result["intent"]["params"]["reportAction"] == action + assert result["intent"]["params"]["expectedStages"] == ["identify", "motif"] + + plan = asyncio.run( + web_main.agent_plan( + web_main.AgentPlanRequest( + message=query, + job_id="job-report", + prefix="Ft.SPL", + intent=result["intent"], + ) + ) + ) + assert plan["success"] is True + assert plan["mode"] == "gene_family_report_deterministic" + assert plan["plan"]["stages"] == ["gene_family_report"] + assert plan["plan"]["params"]["expectedStages"] == ["identify", "motif"] diff --git a/tests/unit/test_gene_family_report_triggers.py b/tests/unit/test_gene_family_report_triggers.py new file mode 100644 index 000000000..ae3b53a67 --- /dev/null +++ b/tests/unit/test_gene_family_report_triggers.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import json +from pathlib import Path + + +def _request_and_stages(): + from apps.web.vue3.main import ConfirmedPlanExecutionRequest, ConfirmedPlanStage + + stages = [ + ConfirmedPlanStage( + id="identify", + params={ + "scopeId": "scope-a", + "analysisRoute": "post_subfamily_confirm", + "memberSetId": "members-a", + }, + ), + ConfirmedPlanStage(id="motif", dependencies=["identify"]), + ConfirmedPlanStage(id="pep", dependencies=["motif"]), + ] + request = ConfirmedPlanExecutionRequest( + session_id="session-a", + job_id="job-a", + prefix="Ft.SPL", + plan_id="plan-a", + stages=stages, + ) + return request, stages + + +def test_confirmed_plan_terminal_success_requests_final_report() -> None: + from apps.web.vue3.main import _confirmed_plan_report_request + + request, stages = _request_and_stages() + report = _confirmed_plan_report_request( + request, + stages, + {"status": "success", "completed_stages": ["identify", "motif", "pep"]}, + ) + + assert report is not None + assert report.stage == "gene_family_report" + assert report.expectedStages == ["identify", "motif", "pep"] + assert report.stageStatuses == {"identify": "success", "motif": "success", "pep": "success"} + assert report.reportAction == "generate" + + +def test_confirmed_plan_failure_requests_partial_report() -> None: + from apps.web.vue3.main import _confirmed_plan_report_request + + request, stages = _request_and_stages() + report = _confirmed_plan_report_request( + request, + stages, + { + "status": "failed", + "completed_stages": ["identify"], + "failure": {"stage": "motif"}, + }, + ) + + assert report is not None + assert report.stageStatuses == {"identify": "success", "motif": "failed", "pep": "not_run"} + assert report.scopeId == "scope-a" + assert report.analysisRoute == "post_subfamily_confirm" + assert report.memberSetId == "members-a" + + +def test_paused_plan_does_not_auto_finalize_report() -> None: + from apps.web.vue3.main import _confirmed_plan_report_request + + request, stages = _request_and_stages() + report = _confirmed_plan_report_request( + request, + stages, + {"status": "paused", "completed_stages": ["identify"]}, + ) + assert report is None + + +def test_frontend_batch_terminal_path_calls_report_stage_with_planned_statuses() -> None: + source = (Path(__file__).resolve().parents[2] / "apps/web/vue3/index.html").read_text(encoding="utf-8") + + assert "submitGeneFamilyReportForBatch" in source + assert "executeSingleStageWithParams('gene_family_report'" in source + assert "expectedStages: plannedIds" in source + assert "stageStatuses" in source + assert "if (pausedCount > 0) return" in source + assert "continuationOpened" in source + assert "name: 'gene_family_report'" in source + assert "openGeneratedGeneFamilyReport" in source + + +def test_report_context_prefers_terminal_confirmed_plan(monkeypatch, tmp_path: Path) -> None: + from apps.web.vue3 import main as web_main + + final_dir = tmp_path / "job-a" / "final" + confirmed_dir = final_dir / ".confirmed_plans" + confirmed_dir.mkdir(parents=True) + (final_dir / "Ft.SPL.gene_family_report.manifest.json").write_text( + json.dumps( + { + "stage": "gene_family_report", + "status": "success", + "prefix": "Ft.SPL", + "parameters": { + "expected_stages": ["identify"], + "stage_statuses": {"identify": "success"}, + }, + } + ), + encoding="utf-8", + ) + (confirmed_dir / "0123456789abcdefabcd.json").write_text( + json.dumps( + { + "status": "failed", + "job_id": "job-a", + "prefix": "Ft.SPL", + "stages": ["identify", "motif", "pep", "gene_family_report"], + "completed_stages": ["identify", "motif"], + "failure": {"stage": "pep"}, + "scopeId": "scope-a", + "memberSetId": "members-a", + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(web_main, "WORKSPACE_ROOT", tmp_path) + + context = web_main._gene_family_report_plan_context("job-a") + + assert context == { + "prefix": "Ft.SPL", + "expectedStages": ["identify", "motif", "pep"], + "stageStatuses": {"identify": "success", "motif": "success", "pep": "failed"}, + "scopeId": "scope-a", + "analysisRoute": "", + "memberSetId": "members-a", + "rnaseqScopeId": "", + } + + +def test_report_context_reuses_formal_report_plan_only(monkeypatch, tmp_path: Path) -> None: + from apps.web.vue3 import main as web_main + + final_dir = tmp_path / "job-a" / "final" + final_dir.mkdir(parents=True) + (final_dir / "Ft.SPL.motif.manifest.json").write_text( + json.dumps({"stage": "motif", "status": "success", "prefix": "Ft.SPL"}), + encoding="utf-8", + ) + monkeypatch.setattr(web_main, "WORKSPACE_ROOT", tmp_path) + assert web_main._gene_family_report_plan_context("job-a") is None + + (final_dir / "Ft.SPL.gene_family_report.manifest.json").write_text( + json.dumps( + { + "stage": "gene_family_report", + "status": "success", + "prefix": "Ft.SPL", + "scope_id": "scope-old", + "parameters": { + "expected_stages": ["identify", "motif"], + "stage_statuses": {"identify": "success", "motif": "success"}, + }, + } + ), + encoding="utf-8", + ) + + context = web_main._gene_family_report_plan_context("job-a", "Ft.SPL") + + assert context is not None + assert context["expectedStages"] == ["identify", "motif"] + assert context["stageStatuses"] == {"identify": "success", "motif": "success"} + assert context["scopeId"] == "" + assert context["analysisRoute"] == "" + assert context["memberSetId"] == "" + assert context["rnaseqScopeId"] == "" diff --git a/tests/unit/test_gene_family_report_wiring.py b/tests/unit/test_gene_family_report_wiring.py new file mode 100644 index 000000000..c8555aee0 --- /dev/null +++ b/tests/unit/test_gene_family_report_wiring.py @@ -0,0 +1,347 @@ +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import sys + + +def _write_identify_manifest(workspace: Path, prefix: str = "Ft.SPL") -> Path: + final_dir = workspace / "final" + final_dir.mkdir(parents=True, exist_ok=True) + table = final_dir / f"{prefix}.identify.tsv" + table.write_text("gene\tvalue\nFtSPL1\t1\n", encoding="utf-8") + manifest = final_dir / f"{prefix}.identify.manifest.json" + manifest.write_text( + json.dumps( + { + "manifest_version": "1.0", + "stage": "identify", + "status": "success", + "prefix": prefix, + "metrics": {"member_count": 1}, + "artifacts": { + "members": { + "path": f"final/{table.name}", + "full_path": str(table), + "type": "table", + } + }, + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + return manifest + + +def _flag_value(command: list[str], flag: str) -> str: + return command[command.index(flag) + 1] + + +def test_report_stage_is_registered_without_analysis_dependencies() -> None: + from gfa_core.mas.stage_capabilities import CAPABILITIES + from gfa_core.mas.stage_catalog import STAGE_ORDER, is_stage_executable + from gfa_core.mas.tools.stage_tools import create_stage_registry + + capability = CAPABILITIES["gene_family_report"] + stage_tool = create_stage_registry().get("gene_family_report") + + assert "gene_family_report" in STAGE_ORDER + assert is_stage_executable("gene_family_report") + assert capability.consumes == [] + assert stage_tool is not None + assert stage_tool.required_inputs == [] + assert stage_tool.script == "run_gene_family_report.py" + + +def test_report_cli_can_start_as_a_script() -> None: + root = Path(__file__).resolve().parents[2] + completed = subprocess.run( + [sys.executable, "apps/cli/run_gene_family_report.py", "--help"], + cwd=root, + capture_output=True, + text=True, + check=False, + ) + + assert completed.returncode == 0, completed.stderr + + +def test_report_cli_writes_report_and_manifest(tmp_path: Path) -> None: + from apps.cli.run_gene_family_report import main + + workspace = tmp_path / "job" + _write_identify_manifest(workspace) + result = main( + [ + "--workspace", + str(workspace), + "--prefix", + "Ft.SPL", + "--job-id", + "job-wiring", + "--expected-stages", + "identify", + "--stage-statuses-json", + '{"identify":"success"}', + "--no-model", + ] + ) + + manifest = json.loads(result.manifest_path.read_text(encoding="utf-8")) + assert result.report_path.is_file() + assert result.manifest_path.is_file() + assert "gene_family_report" in manifest["artifacts"] + + +def test_pipeline_backend_passes_plan_status_and_identity_to_report_cli( + tmp_path: Path, monkeypatch +) -> None: + from apps.web import pipeline_backend + + workspace = tmp_path / "job" + input_dir = workspace / "input" + logs_dir = workspace / "logs" + input_dir.mkdir(parents=True) + logs_dir.mkdir(parents=True) + _write_identify_manifest(workspace) + captured: dict[str, list[str]] = {} + + def capture(command, _log_file, cwd=None): + captured["command"] = [str(value) for value in command] + return 0 + + monkeypatch.setattr(pipeline_backend, "can_start_new_task", lambda: (True, "")) + monkeypatch.setattr(pipeline_backend, "run_cmd_logged", capture) + job = pipeline_backend.JobInfo( + job_id="job-backend", + input_dir=str(input_dir), + workspace_dir=str(workspace), + logs_dir=str(logs_dir), + created_at="2026-08-08 00:00:00", + ) + + pipeline_backend.run_stage( + "gene_family_report", + job, + "Ft.SPL", + expected_stages=["identify", "motif"], + stage_statuses={"identify": "success", "motif": "failed"}, + report_action="open", + report_use_model=False, + scope_id="scope-a", + analysis_route="post_subfamily_confirm", + member_set_id="members-a", + rnaseq_scope_id="rna-a", + ) + + command = captured["command"] + assert command[1:3] == ["apps/cli/run_gene_family_report.py", "--workspace"] + assert _flag_value(command, "--expected-stages") == "identify,motif" + assert json.loads(_flag_value(command, "--stage-statuses-json")) == { + "identify": "success", + "motif": "failed", + } + assert _flag_value(command, "--action") == "open" + assert _flag_value(command, "--job-id") == "job-backend" + assert _flag_value(command, "--scope-id") == "scope-a" + assert _flag_value(command, "--analysis-route") == "post_subfamily_confirm" + assert _flag_value(command, "--member-set-id") == "members-a" + assert _flag_value(command, "--rnaseq-scope-id") == "rna-a" + assert "--no-model" in command + assert not any("run_identify.py" in value or "run_motif.py" in value for value in command) + + +def test_pipeline_backend_rejects_empty_plan_before_starting_report_cli( + tmp_path: Path, monkeypatch +) -> None: + from apps.web import pipeline_backend + + workspace = tmp_path / "job" + input_dir = workspace / "input" + logs_dir = workspace / "logs" + input_dir.mkdir(parents=True) + logs_dir.mkdir(parents=True) + invoked = False + + def capture(*_args, **_kwargs): + nonlocal invoked + invoked = True + return 0 + + monkeypatch.setattr(pipeline_backend, "can_start_new_task", lambda: (True, "")) + monkeypatch.setattr(pipeline_backend, "run_cmd_logged", capture) + job = pipeline_backend.JobInfo( + job_id="job-empty-plan", + input_dir=str(input_dir), + workspace_dir=str(workspace), + logs_dir=str(logs_dir), + created_at="2026-08-08 00:00:00", + ) + + message, _manifest = pipeline_backend.run_stage( + "gene_family_report", + job, + "Ft.SPL", + expected_stages=[], + report_action="generate", + report_use_model=False, + ) + + assert "expected_stages" in message + assert invoked is False + + +def test_stage_tool_executor_forwards_gene_family_report_context( + tmp_path: Path, monkeypatch +) -> None: + import asyncio + import types + from dataclasses import dataclass + + from gfa_core.mas.tools.stage_tools import StageTool, StageToolExecutor + + captured = {} + + @dataclass + class FakeJobInfo: + job_id: str + input_dir: str + workspace_dir: str + logs_dir: str + created_at: str + + def fake_run_stage(**kwargs): + captured.update(kwargs) + manifest = Path(kwargs["job"].workspace_dir) / "final" / "Ft.SPL.gene_family_report.manifest.json" + manifest.parent.mkdir(parents=True, exist_ok=True) + manifest.write_text( + json.dumps({"stage": "gene_family_report", "status": "success"}), + encoding="utf-8", + ) + return "报告已生成", manifest + + fake_backend = types.ModuleType("apps.web.pipeline_backend") + fake_backend.JobInfo = FakeJobInfo + fake_backend.run_stage = fake_run_stage + monkeypatch.setitem(sys.modules, "apps.web.pipeline_backend", fake_backend) + + result = asyncio.run( + StageToolExecutor(project_root=tmp_path).execute( + StageTool( + "gene_family_report", + "Gene Family Report", + "run_gene_family_report.py", + "report", + "test", + timeout=5, + ), + "job-report", + "Ft.SPL", + { + "expectedStages": ["identify", "motif"], + "stageStatuses": {"identify": "success", "motif": "failed"}, + "reportAction": "open", + "reportUseModel": False, + "scopeId": "scope-a", + "rnaseqScopeId": "rna-a", + }, + ) + ) + + assert result["status"] == "success" + assert captured["expected_stages"] == ["identify", "motif"] + assert captured["stage_statuses"] == {"identify": "success", "motif": "failed"} + assert captured["report_action"] == "open" + assert captured["report_use_model"] is False + assert captured["scope_id"] == "scope-a" + assert captured["rnaseq_scope_id"] == "rna-a" + + +def test_execute_report_does_not_invent_scope_identity(monkeypatch) -> None: + import asyncio + from unittest.mock import Mock + + from apps.web.vue3 import main as web_main + from gfa_core.mas import resource_governor + from tasks import execute_single_stage_task + + submitted = Mock() + submitted.return_value.id = "report-task" + monkeypatch.setattr(execute_single_stage_task, "apply_async", submitted) + monkeypatch.setattr( + web_main, + "_ensure_celery_queue_health", + lambda *_args, **_kwargs: ({"available": True}, None), + ) + monkeypatch.setattr( + web_main, + "_ensure_gene_family_scope_file", + lambda **_kwargs: ("", ""), + ) + monkeypatch.setattr(resource_governor, "record_task", lambda **_kwargs: {}) + + request = web_main.ExecuteRequest( + stage="gene_family_report", + job_id="job-report-no-invented-identity", + prefix="Ft.SPL", + expectedStages=["identify", "motif"], + stageStatuses={"identify": "success", "motif": "success"}, + ) + result = asyncio.run(web_main.execute_stage(request)) + + assert result["success"] is True + params = submitted.call_args.kwargs["kwargs"]["params"] + assert params["scopeId"] == "" + assert params["analysisRoute"] == "" + assert params["memberSetId"] == "" + assert params["rnaseqScopeId"] == "" + + +def test_report_artifact_validation_does_not_require_invented_identity(monkeypatch) -> None: + from types import SimpleNamespace + + import tasks + from gfa_core.mas.tools import stage_identity + + captured = {} + identity = SimpleNamespace( + scope_id="", + analysis_route="custom", + member_set_id="Ft.SPL:custom:all_members", + wgs_sample_set_id="", + wgs_reference_id="", + wgs_analysis_id="", + genotype_scope_id="", + phenotype_scope_id="", + covariate_scope_id="", + model_scope_id="", + source_model_scope_id="", + analysis_scope_id="", + ) + monkeypatch.setattr(stage_identity, "resolve_stage_identity", lambda **_kwargs: identity) + + def capture_status(**kwargs): + captured.update(kwargs) + return SimpleNamespace(completed=True) + + monkeypatch.setattr(stage_identity, "get_stage_status_for_identity", capture_status) + + failure = tasks.validate_single_stage_artifacts( + "job-report", + "Ft.SPL", + "gene_family_report", + { + "manifest": { + "stage": "gene_family_report", + "status": "success", + "prefix": "Ft.SPL", + "analysis_route": "", + "member_set_id": "", + } + }, + ) + + assert failure is None + assert captured["strict_identity"] is False diff --git a/tests/unit/test_intent_v2_fallback_safety.py b/tests/unit/test_intent_v2_fallback_safety.py new file mode 100644 index 000000000..fc23b0875 --- /dev/null +++ b/tests/unit/test_intent_v2_fallback_safety.py @@ -0,0 +1,70 @@ +import asyncio +from types import SimpleNamespace + +from gfa_core.mas.agents.base import AgentMessage, AgentState +from gfa_core.mas.agents.intent_v2 import IntentAgentV2 + + +def test_fallback_does_not_turn_unrecognized_continuation_into_identify(): + intent = IntentAgentV2(None)._fallback_recognition("执行接下来的分析") + + assert intent["stages"] == [] + assert intent["needs_clarification"] is True + assert intent["clarification_type"] == "analysis_stage" + + +def test_validation_does_not_turn_empty_model_stages_into_identify(): + intent = IntentAgentV2(None)._validate_intent( + { + "gene_family": "SPL", + "species": "Ft", + "stages": [], + "params": {}, + }, + "执行接下来的步骤", + ) + + assert intent["stages"] == [] + assert intent["needs_clarification"] is True + assert intent["clarification_type"] == "analysis_stage" + + +def test_fallback_still_recognizes_explicit_identify(): + intent = IntentAgentV2(None)._fallback_recognition("执行 identify 分析") + + assert intent["stages"] == ["identify"] + assert intent.get("needs_clarification") is not True + + +def test_empty_model_response_cannot_authorize_identify_and_keeps_goal_context(): + class _EmptyLLM: + async def acall(self, *_args, **_kwargs): + return SimpleNamespace(content="") + + state = AgentState(session_id="empty-intent", job_id="job-empty-intent") + message = AgentMessage( + sender="router", + receiver="intent_v2", + message_type="recognize_intent", + payload={ + "query": "执行接下来的分析", + "job_id": "job-empty-intent", + "workspace": "/tmp/job-empty-intent", + "goal_context": { + "user_terms": { + "gene_family": "SPL", + "target_species": ["Ft"], + "reference_species": ["At"], + "comparative_species": [], + } + }, + }, + ) + + returned = asyncio.run(IntentAgentV2(_EmptyLLM()).execute(state, message)) + intent = returned.results["results"]["intent"] + + assert intent["stages"] == [] + assert intent["needs_clarification"] is True + assert intent["target_species"] == ["Ft"] + assert intent["reference_species"] == ["At"] diff --git a/tests/unit/test_main_agent_data_preparation_handoff.py b/tests/unit/test_main_agent_data_preparation_handoff.py new file mode 100644 index 000000000..482263857 --- /dev/null +++ b/tests/unit/test_main_agent_data_preparation_handoff.py @@ -0,0 +1,85 @@ +import json + +from gfa_core.mas.agents.conversation_tools import build_plantai_conversation_tools +from gfa_core.mas.llm import LLMManager +from gfa_core.mas.workflow_models import GoalLedger + + +def _registry(tmp_path, *, job_id="job-data-preparation-handoff"): + ledger = GoalLedger( + conversation_id="conversation-data-preparation-handoff", + revision=0, + user_terms={ + "active_job_id": job_id, + "gene_family": "SPL", + "target_species": ["Ft"], + "reference_species": ["At"], + "config_confirmed": True, + }, + ) + return build_plantai_conversation_tools( + llm_manager=LLMManager({}), + project_root=tmp_path, + bound_job_id=job_id, + goal_ledger=ledger, + ) + + +def test_analysis_handoff_is_not_rewritten_to_genome_prepare(tmp_path): + resolved = _registry(tmp_path).resolve_analysis_handoff_request( + "执行亚家族分类", + workflow_id="gene_family", + guidance=True, + ) + + assert resolved.startswith("执行亚家族分类\n") + assert "执行数据准备(genome_prepare)" not in resolved + assert "目标物种 Ft" in resolved + assert "参考物种 At" in resolved + assert "正式 config.xlsx 已确认" in resolved + + +def test_genome_prepare_manifest_does_not_change_analysis_handoff(tmp_path): + job_id = "job-manifest-independent-handoff" + registry = _registry(tmp_path, job_id=job_id) + before = registry.resolve_analysis_handoff_request( + "执行亚家族分类", + workflow_id="gene_family", + guidance=True, + ) + final_dir = tmp_path / "workspace" / job_id / "final" + final_dir.mkdir(parents=True) + (final_dir / "genome_prepare.genome_prepare.manifest.json").write_text( + json.dumps({"stage": "genome_prepare", "status": "success"}), + encoding="utf-8", + ) + + after = registry.resolve_analysis_handoff_request( + "执行亚家族分类", + workflow_id="gene_family", + guidance=True, + ) + + assert after == before + + +def test_explicit_data_preparation_request_is_preserved(tmp_path): + resolved = _registry(tmp_path).resolve_analysis_handoff_request( + "执行数据准备", + workflow_id="data_preparation", + guidance=False, + ) + + assert resolved.startswith("执行数据准备\n") + assert "当前业务动作已由数据准备契约唯一确定" not in resolved + + +def test_next_step_question_is_not_rewritten_to_genome_prepare(tmp_path): + resolved = _registry(tmp_path).resolve_analysis_handoff_request( + "接下来需要做什么?", + workflow_id="gene_family", + guidance=True, + ) + + assert resolved.startswith("接下来需要做什么?\n") + assert "执行数据准备(genome_prepare)" not in resolved diff --git a/tests/unit/test_main_agent_ordinary_drafting_continuation.py b/tests/unit/test_main_agent_ordinary_drafting_continuation.py new file mode 100644 index 000000000..a6dc6824e --- /dev/null +++ b/tests/unit/test_main_agent_ordinary_drafting_continuation.py @@ -0,0 +1,204 @@ +import asyncio + +from pydantic import BaseModel + +from gfa_core.mas.agents.conversation_tools import ConversationToolRegistry +from gfa_core.mas.agents.router_runtime import RouterRuntime +from gfa_core.mas.llm import LLMResponse, LLMToolCall + + +_HISTORY = [ + {"role": "user", "content": "如果我想写个脚本来修订我的蛋白id"}, + {"role": "assistant", "content": "请提供输入格式、修订规则和输出要求。"}, + {"role": "user", "content": ">id.02020202 | name:123 | xxx"}, + {"role": "assistant", "content": "请确认修订规则。"}, + {"role": "user", "content": "仅保留第一个字段(id.02020202)"}, + { + "role": "assistant", + "content": "请确认输入文件格式,以及覆盖原文件还是输出到新文件?", + }, +] + + +def _response(content: str) -> LLMResponse: + return LLMResponse( + content=content, + model="test-model", + provider="test-provider", + usage={}, + ) + + +class _SequenceLLM: + def __init__(self, responses): + self.responses = list(responses) + self.calls = 0 + + async def acall(self, _messages, **_kwargs): + self.calls += 1 + return self.responses.pop(0) + + +class _QueryArgs(BaseModel): + query: str + + +def test_drafting_profile_preserves_short_specification_follow_ups(): + messages = RouterRuntime._route_messages( + query="FASTA格式,输出到新文件", + history=_HISTORY, + session_id="drafting-continuation", + job_id="job-bound-to-gene-family", + goal_ledger=None, + ) + prompt = " ".join(messages[0].content.split()) + + assert "Multi-turn drafting continuity" in prompt + assert "a supplement to the same general goal" in prompt + assert "must not be routed to utility" in prompt + assert "actually transform a current or supplied file" in prompt + + +def test_explicit_script_request_cannot_be_promoted_to_utility_by_router(): + llm = _SequenceLLM( + [ + _response( + '{"goal":"utility","needs":["utility_intent"],' + '"subject":"简化FASTA ID","referent":"test.fa",' + '"clarification_question":null,"interaction_kind":"independent"}' + ), + _response( + '{"status":"ok","action":"reply",' + '"message":"下面给出一个只生成、不执行的 Python 脚本。"}' + ), + ] + ) + + outcome = asyncio.run( + RouterRuntime(llm, ConversationToolRegistry(), max_iterations=5).run( + query="帮我写脚本简化 test.fa 的id", + history=[], + session_id="explicit-script-request", + job_id="", + goal_ledger=None, + ) + ) + + assert outcome.action == "reply" + assert outcome.tool_trace == [] + assert llm.calls == 2 + + +def test_drafting_follow_up_uses_responder_instead_of_router_clarification(): + llm = _SequenceLLM( + [ + _response( + '{"goal":"general","needs":[],"subject":"补充FASTA脚本规则",' + '"referent":"蛋白ID脚本",' + '"clarification_question":"请重新提供全部需求。",' + '"interaction_kind":"supplement"}' + ), + _response( + '{"status":"ok","action":"reply",' + '"message":"已记录保留第一个字段;还需要确认输入格式和输出方式。"}' + ), + ] + ) + + outcome = asyncio.run( + RouterRuntime(llm, ConversationToolRegistry(), max_iterations=5).run( + query="仅保留第一个字段(id.02020202)", + history=_HISTORY[:4], + session_id="drafting-follow-up", + job_id="", + goal_ledger=None, + ) + ) + + assert outcome.action == "reply" + assert outcome.message.startswith("已记录保留第一个字段") + assert llm.calls == 2 + + +def test_locked_general_reply_accepts_bare_drafting_text_without_retry(): + llm = _SequenceLLM( + [ + _response( + '{"goal":"general","needs":[],"subject":"生成FASTA脚本",' + '"referent":null,"clarification_question":null,' + '"interaction_kind":"supplement"}' + ), + _response("```python\nprint('FASTA')\n```"), + _response( + '{"status":"ok","action":"reply",' + '"message":"recovered after retry"}' + ), + ] + ) + + outcome = asyncio.run( + RouterRuntime(llm, ConversationToolRegistry(), max_iterations=5).run( + query="FASTA格式,输出到新文件", + history=_HISTORY, + session_id="bare-drafting-reply", + job_id="", + goal_ledger=None, + ) + ) + + assert outcome.action == "reply" + assert outcome.message == "```python\nprint('FASTA')\n```" + assert outcome.tool_trace == [] + assert llm.calls == 2 + + +def test_unrecognized_file_utility_returns_actionable_chinese_clarification(): + registry = ConversationToolRegistry() + registry.register( + name="recognize_utility_intent", + description="Recognize a supported utility request.", + args_model=_QueryArgs, + handler=lambda _args: { + "success": False, + "error_code": "utility_intent_not_found", + "message": "No supported utility request was recognized.", + }, + permission="pure", + ) + llm = _SequenceLLM( + [ + _response( + '{"goal":"utility","needs":["utility_intent"],' + '"subject":"处理文件","referent":null,' + '"clarification_question":null,"interaction_kind":"independent"}' + ), + LLMResponse( + content="", + model="test-model", + provider="test-provider", + usage={}, + tool_calls=[ + LLMToolCall( + id="utility-not-found-1", + name="recognize_utility_intent", + arguments='{"query":"帮我处理一下文件"}', + ) + ], + ), + ] + ) + + outcome = asyncio.run( + RouterRuntime(llm, registry, max_iterations=5).run( + query="帮我处理一下文件", + history=[], + session_id="unrecognized-utility", + job_id="job-utility", + goal_ledger=None, + ) + ) + + assert outcome.action == "clarify" + assert "实际文件路径" in outcome.message + assert "处理规则" in outcome.message + assert "I couldn't identify" not in outcome.message diff --git a/tests/unit/test_main_agent_product_business_knowledge.py b/tests/unit/test_main_agent_product_business_knowledge.py new file mode 100644 index 000000000..f8399c90d --- /dev/null +++ b/tests/unit/test_main_agent_product_business_knowledge.py @@ -0,0 +1,113 @@ +from gfa_core.mas.agents.router_runtime import RouteDecision, RouterRuntime + + +def _general_prompt(query: str, history=None) -> str: + return RouterRuntime._initial_messages( + query=query, + history=list(history or []), + session_id="product-business-session", + job_id="job-bound-to-other-workflow", + decision=RouteDecision( + goal="general", + needs=[], + subject="解释产品业务", + interaction_kind="independent", + ), + goal_ledger=None, + )[0].content + + +def _route_prompt(query: str) -> str: + return RouterRuntime._route_messages( + query=query, + history=[], + session_id="product-business-route", + job_id="job-bound-to-other-workflow", + goal_ledger=None, + )[0].content + + +def test_gene_family_product_help_defines_two_level_business_view(): + prompt = _general_prompt("基因家族有哪些分析步骤?") + compact = " ".join(prompt.split()) + + assert "亚家族分类与确认(前置)" in prompt + assert "常规基因家族分析(主体)" in prompt + assert "First answer with these two parts only" in prompt + assert "Do not enumerate any individual stage in that first answer" in compact + assert "must not use RAG or project tools" in compact + assert ( + "phylogeny,motif,domain,structure,combo,promoter,tfbs,synteny," + "interspecies_synteny,karyotype,pep,ortholog,kaks,ppi" + ) in prompt + + +def test_rnaseq_product_help_defines_modes_and_authoritative_inputs(): + prompt = _general_prompt("系统中 RNA-seq 分析有哪些步骤?") + compact = " ".join(prompt.split()) + + assert "全基因组 RNA-seq 分析(主体)" in prompt + assert "已确认基因家族成员的 RNA 结果投影" in prompt + assert "First answer with these two modes only" in compact + assert "config.xlsx and samples.yaml are equally authoritative inputs" in compact + assert ( + "rna_fact_projection,rnaseq_qc,rnaseq_align,rnaseq_quant," + "rnaseq_sample_qc,rnaseq_de" + ) in prompt + assert "must not rerun a separate family-mode DE" in compact + + +def test_rnaseq_product_question_and_execution_have_distinct_route_examples(): + prompt = _route_prompt("全基因组 RNA-seq 分析的正式输入和具体步骤是什么?") + + assert ( + '"全基因组 RNA-seq 分析的正式输入和具体步骤是什么?" -> ' + "general + needs=[]." + ) in prompt + assert ( + '"执行全基因组 RNA-seq 分析" -> new_analysis + analysis_intent.' + ) in prompt + + +def test_product_follow_up_keeps_all_prior_business_context(): + history = [ + {"role": "user", "content": "系统中转录组分析有哪些步骤?"}, + { + "role": "assistant", + "content": "分为全基因组 RNA-seq 和已确认家族成员的 RNA 结果投影。", + }, + {"role": "user", "content": "先说全基因组这一部分。"}, + { + "role": "assistant", + "content": "需要先确认 config.xlsx 和 samples.yaml。", + }, + ] + messages = RouterRuntime._initial_messages( + query="那家族成员这一部分呢?", + history=history, + session_id="product-follow-up-session", + job_id="job-product-follow-up", + decision=RouteDecision( + goal="general", + needs=[], + subject="继续解释产品业务", + interaction_kind="supplement", + ), + goal_ledger=None, + ) + + assert [message.content for message in messages[1:-1]] == [ + item["content"] for item in history + ] + + +def test_completed_subfamily_or_stage_list_is_not_full_analysis_completion(): + assert not RouterRuntime._claims_gene_family_complete_at_readiness_boundary( + "亚家族分类与确认已经完成,接下来进入常规基因家族分析,包括 ppi。" + ) + assert not RouterRuntime._claims_gene_family_complete_at_readiness_boundary( + "已完成的阶段包括 identify;后续还有常规基因家族分析。" + ) + assert RouterRuntime._claims_gene_family_complete_at_readiness_boundary( + "亚家族确认完成,因此完整基因家族分析已经全部完成。" + ) diff --git a/tests/unit/test_persistent_conversation_full_history.py b/tests/unit/test_persistent_conversation_full_history.py new file mode 100644 index 000000000..f28865d3d --- /dev/null +++ b/tests/unit/test_persistent_conversation_full_history.py @@ -0,0 +1,100 @@ +from gfa_core.mas.agent_state_repository import SQLiteAgentStateRepository +from gfa_core.mas.agents.router_runtime import RouteDecision, RouterRuntime +from gfa_core.mas.persistent_conversation import PersistentConversationStore + + +def test_begin_turn_imports_and_returns_complete_valid_history(tmp_path): + repository = SQLiteAgentStateRepository(tmp_path / "agent-state.sqlite3") + repository.initialize() + store = PersistentConversationStore(repository) + client_history = [ + { + "role": "user" if index % 2 == 0 else "assistant", + "content": f"历史消息-{index:02d}", + } + for index in range(14) + ] + + turn = store.begin_turn( + session_id="full-history-session", + job_id="job-full-history", + query="当前问题", + client_history=client_history, + ) + + assert list(turn.history) == client_history + stored = repository.list_messages(turn.conversation_id) + assert [item.content for item in stored] == [ + *(item["content"] for item in client_history), + "当前问题", + ] + + +def test_begin_turn_returns_all_persisted_messages_not_only_latest_ten(tmp_path): + repository = SQLiteAgentStateRepository(tmp_path / "agent-state.sqlite3") + repository.initialize() + store = PersistentConversationStore(repository) + first_turn = store.begin_turn( + session_id="persisted-full-history-session", + job_id="job-full-history", + query="第一个问题", + client_history=[], + ) + store.finish_turn( + first_turn, + assistant_message="第一个回答", + interaction_kind="independent", + action="reply", + ) + for index in range(1, 7): + turn = store.begin_turn( + session_id="persisted-full-history-session", + job_id="job-full-history", + query=f"问题-{index}", + client_history=[], + ) + store.finish_turn( + turn, + assistant_message=f"回答-{index}", + interaction_kind="supplement", + action="reply", + ) + + final_turn = store.begin_turn( + session_id="persisted-full-history-session", + job_id="job-full-history", + query="最终问题", + client_history=[], + ) + + assert len(final_turn.history) == 14 + assert final_turn.history[0]["content"] == "第一个问题" + assert final_turn.history[-1]["content"] == "回答-6" + + +def test_main_agent_prompt_receives_the_complete_history(): + history = [ + { + "role": "user" if index % 2 == 0 else "assistant", + "content": f"消息-{index}", + } + for index in range(12) + ] + + messages = RouterRuntime._initial_messages( + query="当前问题", + history=history, + session_id="full-history-session", + job_id="job-full-history", + decision=RouteDecision( + goal="general", + needs=[], + subject="继续普通问答", + interaction_kind="supplement", + ), + goal_ledger=None, + ) + + assert [message.content for message in messages[1:-1]] == [ + f"消息-{index}" for index in range(12) + ] diff --git a/tests/unit/test_rnaseq_report.py b/tests/unit/test_rnaseq_report.py new file mode 100644 index 000000000..aa3f2579b --- /dev/null +++ b/tests/unit/test_rnaseq_report.py @@ -0,0 +1,2131 @@ +from __future__ import annotations + +import json +import importlib.util +import re +from pathlib import Path + +import pytest + +import gfa_core.rnaseq_report as rnaseq_report_module +from gfa_core.rnaseq_report import NoSuccessfulRNASeqEvidence, generate_rnaseq_report +from gfa_core.mas.utils import intent_parser as intent_parser_module +from gfa_core.mas.utils.intent_parser import parse_intent_from_query + + +def _fast_intent_recognition(query: str) -> dict | None: + module_path = Path(__file__).resolve().parents[2] / "gfa_core" / "mas" / "core" / "fast_intent.py" + spec = importlib.util.spec_from_file_location("rnaseq_report_fast_intent", module_path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.fast_intent_recognition(query) + + +def _write_manifest( + workspace: Path, + prefix: str, + stage: str, + status: str, + scope_id: str, + *, + suffix: str = "", +) -> Path: + final_dir = workspace / "final" + final_dir.mkdir(parents=True, exist_ok=True) + data_path = final_dir / f"{prefix}.{stage}{suffix}.table.tsv" + data_path.write_text("metric\tvalue\nreads\t100\n", encoding="utf-8") + manifest_path = final_dir / f"{prefix}.{stage}{suffix}.manifest.json" + manifest = { + "manifest_version": "1.0", + "stage": stage, + "status": status, + "prefix": prefix, + "rnaseq_scope_id": scope_id, + "metrics": {"sample_count": 2, "total_reads": 100}, + "parameters": {"rnaseq_scope_id": scope_id}, + "artifacts": { + "result_table": { + "artifact": "result_table", + "path": f"final/{data_path.name}", + "full_path": str(data_path), + "type": "table", + } + }, + } + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8") + return manifest_path + + +def _write_artifact_manifest( + workspace: Path, + prefix: str, + stage: str, + scope_id: str, + artifacts: dict[str, tuple[str, str]], +) -> Path: + final_dir = workspace / "final" + final_dir.mkdir(parents=True, exist_ok=True) + manifest_artifacts = {} + for key, (filename, content) in artifacts.items(): + artifact_path = final_dir / filename + artifact_path.write_text(content, encoding="utf-8") + manifest_artifacts[key] = { + "artifact": key, + "path": f"final/{filename}", + "full_path": str(artifact_path), + "type": artifact_path.suffix.lstrip("."), + } + manifest_path = final_dir / f"{prefix}.{stage}.manifest.json" + manifest_path.write_text( + json.dumps( + { + "manifest_version": "1.0", + "stage": stage, + "status": "success", + "prefix": prefix, + "rnaseq_scope_id": scope_id, + "parameters": {"rnaseq_scope_id": scope_id}, + "metrics": {"comparison_count": 2}, + "artifacts": manifest_artifacts, + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + return manifest_path + + +def _figure_section(markdown: str, filename: str) -> str: + figure_area = markdown.index("## 4. 图表与产物说明") + image_at = markdown.index(filename, figure_area) + section_start = markdown.rfind("### 图 ", 0, image_at) + assert section_start >= 0, f"missing figure heading for {filename}" + next_figure = markdown.find("### 图 ", image_at) + section_end = next_figure if next_figure >= 0 else markdown.find("\n## 5.", image_at) + return markdown[section_start : section_end if section_end >= 0 else None] + + +def _embedded_image_names(markdown: str) -> list[str]: + return [Path(link).name for link in re.findall(r"!\[[^]]*]\(([^)]+)\)", markdown)] + + +def _write_enrichment_manifests(workspace: Path) -> None: + prefix = "Os" + scope = "scope-enrichment" + go_a = f"{prefix}.rnaseq_go_enrich.HH11__24_vs_HH11__0.GO_enrich" + go_b = f"{prefix}.rnaseq_go_enrich.IR29__6_vs_IR29__0.GO_enrich" + go_table_header = "ID\tDescription\tOntology\tGeneRatio\tBgRatio\tpvalue\tp.adjust\tCount\tgeneID\n" + _write_artifact_manifest( + workspace, + prefix, + "rnaseq_go_enrich", + scope, + { + "go_a_table": (f"{go_a}.txt", go_table_header + "GO:0009651\tsalt stress response\tBP\t4/20\t20/1000\t0.0001\t0.001\t4\tOs01/Os02/Os03/Os04\n"), + "go_b_table": (f"{go_b}.txt", go_table_header + "GO:0015979\tphotosynthesis\tBP\t3/20\t18/1000\t0.01\t0.04\t3\tOs05/Os06/Os07\n"), + "go_a_bar_png": (f"{go_a}.barplot.png", "png"), + "go_a_bar_pdf": (f"{go_a}.barplot.pdf", "pdf"), + "go_a_bar_svg": (f"{go_a}.barplot.svg", ""), + "go_b_bar_png": (f"{go_b}.barplot.png", "png"), + "go_a_dot": (f"{go_a}.dotplot.png", "png"), + "go_b_dot": (f"{go_b}.dotplot.png", "png"), + "go_a_ontology": (f"{go_a}.ontology_barplot.png", "png"), + "go_b_ontology": (f"{go_b}.ontology_barplot.png", "png"), + }, + ) + + kegg_a = f"{prefix}.rnaseq_kegg_enrich.IR29__6_vs_IR29__0" + kegg_b = f"{prefix}.rnaseq_kegg_enrich.HH11__48_vs_HH11__0" + kegg_header = "ID\tDescription\tGeneRatio\tBgRatio\tpvalue\tp.adjust\tCount\tgeneID\n" + _write_artifact_manifest( + workspace, + prefix, + "rnaseq_kegg_enrich", + scope, + { + "kegg_a_table": ( + f"{kegg_a}.KEGG_enrich.txt", + kegg_header + + "ko00073\tCutin, suberine and wax biosynthesis\t2/20\t10/1000\t0.0001\t0.001\t2\tOsWax1/OsWax2\n" + + "ko00196\tPhotosynthesis - antenna proteins\t2/20\t12/1000\t0.0002\t0.002\t2\tOsLight1/OsLight2\n", + ), + "kegg_b_table": ( + f"{kegg_b}.KEGG_enrich.txt", + kegg_header + + "ko01200\tCarbon metabolism\t3/20\t30/1000\t0.01\t0.04\t3\tOsC1/OsC2/OsC3\n" + + "ko00073\tCutin, suberine and wax biosynthesis\t1/20\t10/1000\t0.02\t0.05\t1\tOsWaxH\n", + ), + "kegg_a_hits": ( + f"{kegg_a}.KEGG_pathway_gene_hits.txt", + "PathwayID\tPathwayName\tKO\tKOName\tGeneCount\tGenes\n" + "ko00073\tCutin, suberine and wax biosynthesis\tK00073\twax enzyme\t2\tOsWax1/OsWax2\n" + "ko00196\tPhotosynthesis - antenna proteins\tK00196\tantenna protein\t2\tOsLight1/OsLight2\n", + ), + "kegg_a_fc": ( + f"{kegg_a}.KEGG_pathway_map.ko_log2fc.tsv", + "KO\tlog2FoldChange\nK00073\t2.5\nK00196\t-1.5\n", + ), + "kegg_a_bar_png": (f"{kegg_a}_KEGG_enrich.png", "png"), + "kegg_a_bar_pdf": (f"{kegg_a}_KEGG_enrich.pdf", "pdf"), + "kegg_b_bar": (f"{kegg_b}_KEGG_enrich.png", "png"), + "kegg_a_dot": (f"{kegg_a}_KEGG_dotplot.png", "png"), + "kegg_b_dot": (f"{kegg_b}_KEGG_dotplot.png", "png"), + "pathway_a_00073": (f"{kegg_a}.KEGG_pathway_map.ko00073.png", "png"), + "pathway_a_00196": (f"{kegg_a}.KEGG_pathway_map.ko00196.png", "png"), + "pathway_b_00073": (f"{kegg_b}.KEGG_pathway_map.ko00073.png", "png"), + }, + ) + + fgsea_a = f"{prefix}.rnaseq_fgsea.IR29__6_vs_IR29__0" + fgsea_b = f"{prefix}.rnaseq_fgsea.HH11__48_vs_HH11__0" + fgsea_header = "pathway\tpathway_name\tpval\tpadj\tES\tNES\tsize\tleadingEdge\n" + _write_artifact_manifest( + workspace, + prefix, + "rnaseq_fgsea", + scope, + { + "fgsea_a_table": ( + f"{fgsea_a}.fgsea_kegg.tsv", + fgsea_header + + "ko00196\tPhotosynthesis - antenna proteins\t0.0001\t0.001\t0.7\t2.1\t18\tOsLight1,OsLight2\n", + ), + "fgsea_b_table": ( + f"{fgsea_b}.fgsea_kegg.tsv", + fgsea_header + + "ko01200\tCarbon metabolism\t0.01\t0.04\t-0.4\t-1.2\t30\tOsC1,OsC2\n", + ), + "fgsea_a_dot": (f"{fgsea_a}.fgsea_kegg_dotplot.png", "png"), + "fgsea_b_dot": (f"{fgsea_b}.fgsea_kegg_dotplot.png", "png"), + "fgsea_a_curve": (f"{fgsea_a}.fgsea_kegg_top_enrichment.png", "png"), + "fgsea_b_curve": (f"{fgsea_b}.fgsea_kegg_top_enrichment.png", "png"), + }, + ) + + +def _write_analysis_ready_family_manifest( + workspace: Path, + *, + prefix: str, + scope_id: str, + rows: list[tuple[str, str, str, str]], +) -> Path: + """Write the real analysis-ready TSV shape used by rnaseq_family_score.""" + header = "rank\tfamily_id\tfamily_name\tbest_comparison\tbest_support_labels\n" + body = "".join( + f"{rank}\t{family_id}\t{family_name}\t{comparison}\t{support_labels}\n" + for rank, (family_id, family_name, comparison, support_labels) in enumerate(rows, 1) + ) + return _write_artifact_manifest( + workspace, + prefix, + "rnaseq_family_score", + scope_id, + { + "analysis_ready_family": ( + f"{prefix}.rnaseq_family_score.analysis_ready_family.tsv", + header + body, + ) + }, + ) + + +def _write_candidate_guided_enrichment_manifests( + workspace: Path, + *, + combined: bool = False, + include_fgsea_support: bool = True, +) -> dict[str, str]: + """Create arbitrary identifiers so selection tests cannot rely on the sample job.""" + prefix = "Zm" + scope = "scope-random-candidate-guided" + candidate_comparison = "LineX__17_vs_LineX__0" + unrelated_comparison = "LineY__29_vs_LineY__0" + supported_pathway_a = "ko54321" + supported_pathway_b = "ko67890" + unrelated_pathway = "ko13579" + go_id = "GO:9876543" + enrich_stage = "rnaseq_enrich" if combined else "" + go_stage = enrich_stage or "rnaseq_go_enrich" + kegg_stage = enrich_stage or "rnaseq_kegg_enrich" + + go_candidate = f"{prefix}.{go_stage}.{candidate_comparison}.GO_enrich" + go_unrelated = f"{prefix}.{go_stage}.{unrelated_comparison}.GO_enrich" + go_header = "ID\tDescription\tOntology\tGeneRatio\tp.adjust\tCount\tgeneID\n" + go_artifacts = { + "candidate_go_table": ( + f"{go_candidate}.txt", + go_header + + f"{go_id}\trandom candidate function\tBP\t3/20\t0.02\t3\tGeneA/GeneB/GeneC\n", + ), + "unrelated_go_table": ( + f"{go_unrelated}.txt", + go_header + + "GO:1111111\tunrelated stronger function\tBP\t8/20\t0.000001\t8\tOther1/Other2\n", + ), + "candidate_go_bar": (f"{go_candidate}.barplot.png", "png"), + "candidate_go_dot": (f"{go_candidate}.dotplot.png", "png"), + "candidate_go_ontology": (f"{go_candidate}.ontology_barplot.png", "png"), + "unrelated_go_bar": (f"{go_unrelated}.barplot.png", "png"), + "unrelated_go_dot": (f"{go_unrelated}.dotplot.png", "png"), + "unrelated_go_ontology": (f"{go_unrelated}.ontology_barplot.png", "png"), + } + + kegg_candidate = f"{prefix}.{kegg_stage}.{candidate_comparison}" + kegg_unrelated = f"{prefix}.{kegg_stage}.{unrelated_comparison}" + kegg_header = "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n" + kegg_artifacts = { + "candidate_kegg_table": ( + f"{kegg_candidate}.KEGG_enrich.txt", + kegg_header + + f"{supported_pathway_a}\trandom pathway alpha\t2/20\t0.02\t2\tGeneA/GeneB\n" + + f"{supported_pathway_b}\trandom pathway beta\t2/20\t0.03\t2\tGeneB/GeneC\n", + ), + "unrelated_kegg_table": ( + f"{kegg_unrelated}.KEGG_enrich.txt", + kegg_header + + f"{supported_pathway_a}\trandom pathway alpha\t7/20\t0.000001\t7\tOther1/Other2\n" + + f"{unrelated_pathway}\tunrelated pathway\t9/20\t0.0000001\t9\tOther3/Other4\n", + ), + "candidate_kegg_bar": (f"{kegg_candidate}_KEGG_enrich.png", "png"), + "candidate_kegg_dot": (f"{kegg_candidate}_KEGG_dotplot.png", "png"), + "unrelated_kegg_bar": (f"{kegg_unrelated}_KEGG_enrich.png", "png"), + "unrelated_kegg_dot": (f"{kegg_unrelated}_KEGG_dotplot.png", "png"), + "candidate_pathway_a": ( + f"{kegg_candidate}.KEGG_pathway_map.{supported_pathway_a}.png", + "png", + ), + "candidate_pathway_b": ( + f"{kegg_candidate}.KEGG_pathway_map.{supported_pathway_b}.png", + "png", + ), + "other_time_supported_pathway": ( + f"{kegg_unrelated}.KEGG_pathway_map.{supported_pathway_a}.png", + "png", + ), + "unrelated_pathway": ( + f"{kegg_unrelated}.KEGG_pathway_map.{unrelated_pathway}.png", + "png", + ), + } + + if combined: + _write_artifact_manifest( + workspace, + prefix, + "rnaseq_enrich", + scope, + {**go_artifacts, **kegg_artifacts}, + ) + else: + _write_artifact_manifest(workspace, prefix, go_stage, scope, go_artifacts) + _write_artifact_manifest(workspace, prefix, kegg_stage, scope, kegg_artifacts) + + fgsea_candidate = f"{prefix}.rnaseq_fgsea.{candidate_comparison}" + fgsea_unrelated = f"{prefix}.rnaseq_fgsea.{unrelated_comparison}" + fgsea_header = "pathway\tpathway_name\tpadj\tNES\tsize\tleadingEdge\n" + _write_artifact_manifest( + workspace, + prefix, + "rnaseq_fgsea", + scope, + { + "candidate_fgsea_table": ( + f"{fgsea_candidate}.fgsea_kegg.tsv", + fgsea_header + + f"{supported_pathway_b}\trandom pathway beta\t0.04\t2.1\t12\tGeneB,GeneC\n", + ), + "unrelated_fgsea_table": ( + f"{fgsea_unrelated}.fgsea_kegg.tsv", + fgsea_header + + f"{unrelated_pathway}\tunrelated pathway\t0.0001\t3.0\t20\tOther1,Other2\n", + ), + "candidate_fgsea_dot": (f"{fgsea_candidate}.fgsea_kegg_dotplot.png", "png"), + "candidate_fgsea_curve": ( + f"{fgsea_candidate}.fgsea_kegg_top_enrichment.png", + "png", + ), + "unrelated_fgsea_dot": (f"{fgsea_unrelated}.fgsea_kegg_dotplot.png", "png"), + "unrelated_fgsea_curve": ( + f"{fgsea_unrelated}.fgsea_kegg_top_enrichment.png", + "png", + ), + }, + ) + + support = ( + f"GO:{go_id}:random candidate function;" + f"KEGG:{supported_pathway_a}:random pathway alpha;" + f"KEGG:{supported_pathway_b}:random pathway beta" + ) + if include_fgsea_support: + support += f";fgsea:{supported_pathway_b}:random pathway beta" + _write_analysis_ready_family_manifest( + workspace, + prefix=prefix, + scope_id=scope, + rows=[ + ("FAM_RANDOM_ALPHA", "random alpha family", candidate_comparison, support), + ( + "FAM_RANDOM_BETA", + "random beta family", + candidate_comparison, + f"KEGG:{supported_pathway_a}:random pathway alpha", + ), + ], + ) + return { + "prefix": prefix, + "scope": scope, + "candidate_comparison": candidate_comparison, + "unrelated_comparison": unrelated_comparison, + "supported_pathway_a": supported_pathway_a, + "supported_pathway_b": supported_pathway_b, + "unrelated_pathway": unrelated_pathway, + "go_stage": go_stage, + "kegg_stage": kegg_stage, + } + +def test_report_is_deterministic_scope_bound_and_reusable(tmp_path: Path) -> None: + workspace = tmp_path / "job_scope" + _write_manifest(workspace, "Os", "rnaseq_qc", "success", "scope-alpha") + _write_manifest(workspace, "Os", "rnaseq_de", "success", "scope-beta") + _write_manifest(workspace, "Os", "rnaseq_report", "success", "scope-alpha", suffix=".old") + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-alpha", + expected_stages=["rnaseq_qc"], + use_model=False, + ) + + assert result.coverage_status == "complete" + assert result.included_stages == ("rnaseq_qc",) + assert result.rnaseq_scope_id == "scope-alpha" + markdown = result.report_path.read_text(encoding="utf-8") + assert "## 0. 报告摘要" in markdown + assert "## 7. 证据追溯" in markdown + assert "rnaseq_de" not in markdown + evidence = json.loads(result.evidence_path.read_text(encoding="utf-8")) + assert evidence["formal_report"]["source_fingerprint"] + assert {item["stage"] for item in evidence["formal_report"]["source_manifests"]} == {"rnaseq_qc"} + manifest = json.loads(result.manifest_path.read_text(encoding="utf-8")) + assert manifest["status"] == "success" + assert manifest["metrics"]["coverage_status"] == "complete" + assert manifest["metrics"]["model_status"] == "disabled" + + reopened = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-alpha", + expected_stages=["rnaseq_qc"], + use_model=False, + action="open", + ) + assert reopened.reused is True + + +def test_successful_report_can_have_partial_coverage(tmp_path: Path) -> None: + workspace = tmp_path / "job_partial" + _write_manifest(workspace, "Ft", "rnaseq_qc", "success", "scope-1") + _write_manifest(workspace, "Ft", "rnaseq_de", "failed", "scope-1") + + result = generate_rnaseq_report( + workspace, + "Ft", + rnaseq_scope_id="scope-1", + expected_stages=["rnaseq_qc", "rnaseq_de"], + use_model=False, + ) + + manifest = json.loads(result.manifest_path.read_text(encoding="utf-8")) + assert manifest["status"] == "success" + assert manifest["metrics"]["coverage_status"] == "partial" + assert manifest["metrics"]["stage_statuses"]["rnaseq_de"] == "failed" + assert result.included_stages == ("rnaseq_qc",) + + +def test_report_relocates_stale_absolute_artifact_paths(tmp_path: Path) -> None: + workspace = tmp_path / "relocated_job" + manifest_path = _write_manifest(workspace, "Os", "rnaseq_qc", "success", "scope-moved") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["artifacts"]["result_table"]["full_path"] = "/old/server/workspace/final/result.tsv" + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8") + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-moved", + expected_stages=["rnaseq_qc"], + use_model=False, + ) + + markdown = result.report_path.read_text(encoding="utf-8") + assert "指标 sample_count" in markdown + assert "manifest 登记但文件不存在" not in markdown + evidence = json.loads(result.evidence_path.read_text(encoding="utf-8")) + result_card = next(card for card in evidence["evidence_cards"] if card["claim"] == "产物 result_table") + assert "exists=True" in result_card["evidence"] + + +def test_no_successful_rnaseq_stage_does_not_create_empty_report(tmp_path: Path) -> None: + workspace = tmp_path / "job_empty" + _write_manifest(workspace, "At", "rnaseq_de", "failed", "scope-empty") + + with pytest.raises(NoSuccessfulRNASeqEvidence, match="暂无可生成"): + generate_rnaseq_report( + workspace, + "At", + rnaseq_scope_id="scope-empty", + expected_stages=["rnaseq_de"], + use_model=False, + ) + + assert not (workspace / "final" / "At.rnaseq_report.md").exists() + + +def test_fallback_selects_one_of_each_enrichment_plot_and_deduplicates_pathway_ids( + tmp_path: Path, +) -> None: + workspace = tmp_path / "job_enrichment_figures" + _write_enrichment_manifests(workspace) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-enrichment", + expected_stages=["rnaseq_go_enrich", "rnaseq_kegg_enrich", "rnaseq_fgsea"], + use_model=False, + ) + markdown = result.report_path.read_text(encoding="utf-8") + embedded = _embedded_image_names(markdown) + inventory = json.loads(result.evidence_path.read_text(encoding="utf-8"))["formal_report"][ + "figure_inventory" + ] + + assert sum(name.endswith(".GO_enrich.barplot.png") for name in embedded) == 1 + assert sum(name.endswith(".GO_enrich.dotplot.png") for name in embedded) == 1 + assert sum(name.endswith(".GO_enrich.ontology_barplot.png") for name in embedded) == 1 + assert sum(name.endswith("_KEGG_enrich.png") for name in embedded) == 1 + assert sum(name.endswith("_KEGG_dotplot.png") for name in embedded) == 1 + assert sum(name.endswith(".fgsea_kegg_dotplot.png") for name in embedded) == 1 + assert sum(name.endswith(".fgsea_kegg_top_enrichment.png") for name in embedded) == 1 + assert not any(name.endswith("GO_enrich.barplot.pdf") for name in embedded) + assert not any(name.endswith("GO_enrich.barplot.svg") for name in embedded) + assert not any(name.endswith("KEGG_enrich.pdf") for name in embedded) + + required_pathway_maps = [ + "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0.KEGG_pathway_map.ko00073.png", + "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0.KEGG_pathway_map.ko00196.png", + ] + for filename in required_pathway_maps: + assert embedded.count(filename) == 1 + assert not any( + name == "Os.rnaseq_kegg_enrich.HH11__48_vs_HH11__0.KEGG_pathway_map.ko00073.png" + for name in embedded + ) + + +def test_each_selected_enrichment_figure_has_four_part_table_bound_interpretation(tmp_path: Path) -> None: + workspace = tmp_path / "job_enrichment_explanations" + _write_enrichment_manifests(workspace) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-enrichment", + expected_stages=["rnaseq_go_enrich", "rnaseq_kegg_enrich", "rnaseq_fgsea"], + use_model=False, + ) + markdown = result.report_path.read_text(encoding="utf-8") + expected_evidence = { + "Os.rnaseq_go_enrich.HH11__24_vs_HH11__0.GO_enrich.barplot.png": "salt stress response", + "Os.rnaseq_go_enrich.HH11__24_vs_HH11__0.GO_enrich.dotplot.png": "GO:0009651", + "Os.rnaseq_go_enrich.HH11__24_vs_HH11__0.GO_enrich.ontology_barplot.png": "BP", + "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0_KEGG_enrich.png": "Cutin, suberine and wax biosynthesis", + "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0_KEGG_dotplot.png": "ko00073", + "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0.KEGG_pathway_map.ko00073.png": "OsWax1", + "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0.KEGG_pathway_map.ko00196.png": "OsLight1", + "Os.rnaseq_fgsea.IR29__6_vs_IR29__0.fgsea_kegg_dotplot.png": "NES", + "Os.rnaseq_fgsea.IR29__6_vs_IR29__0.fgsea_kegg_top_enrichment.png": "leadingEdge", + } + for filename, evidence_value in expected_evidence.items(): + section = _figure_section(markdown, filename) + assert "图形含义:" in section + assert "阅读方法:" in section + assert "本次结果:" in section + assert "解释边界:" in section + assert evidence_value in section + + +def test_combined_rnaseq_enrich_stage_keeps_all_plot_forms_and_deduplicates_pathway_ids( + tmp_path: Path, +) -> None: + workspace = tmp_path / "job_combined_enrichment" + go_a = "Os.rnaseq_enrich.HH11__24_vs_HH11__0.GO_enrich" + go_b = "Os.rnaseq_enrich.IR29__6_vs_IR29__0.GO_enrich" + kegg_a = "Os.rnaseq_enrich.IR29__6_vs_IR29__0" + kegg_b = "Os.rnaseq_enrich.HH11__48_vs_HH11__0" + go_header = "ID\tDescription\tOntology\tGeneRatio\tp.adjust\tCount\tgeneID\n" + kegg_header = "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n" + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_enrich", + "scope-combined-enrichment", + { + "go_a_table": ( + f"{go_a}.txt", + go_header + + "GO:0009651\tsalt stress response\tBP\t4/20\t0.001\t4\tOsSalt1/OsSalt2\n" + + "GO:0009507\tchloroplast\tCC\t3/20\t0.002\t3\tOsChl1/OsChl2\n" + + "GO:0016491\toxidoreductase activity\tMF\t2/20\t0.003\t2\tOsOx1/OsOx2\n", + ), + "go_b_table": ( + f"{go_b}.txt", + go_header + "GO:0015979\tphotosynthesis\tBP\t3/20\t0.04\t3\tOsLight1/OsLight2\n", + ), + "go_a_bar_png": (f"{go_a}.barplot.png", "png"), + "go_a_bar_pdf": (f"{go_a}.barplot.pdf", "pdf"), + "go_b_bar": (f"{go_b}.barplot.png", "png"), + "go_a_dot": (f"{go_a}.dotplot.png", "png"), + "go_b_dot": (f"{go_b}.dotplot.png", "png"), + "go_a_ontology": (f"{go_a}.ontology_barplot.png", "png"), + "go_b_ontology": (f"{go_b}.ontology_barplot.png", "png"), + "kegg_a_table": ( + f"{kegg_a}.KEGG_enrich.txt", + kegg_header + + "ko00073\tCutin, suberine and wax biosynthesis\t2/20\t0.001\t2\tOsWax1/OsWax2\n" + + "ko00196\tPhotosynthesis - antenna proteins\t2/20\t0.002\t2\tOsLight1/OsLight2\n", + ), + "kegg_b_table": ( + f"{kegg_b}.KEGG_enrich.txt", + kegg_header + "ko00073\tCutin, suberine and wax biosynthesis\t1/20\t0.04\t1\tOsWaxH\n", + ), + "kegg_a_hits": ( + f"{kegg_a}.KEGG_pathway_gene_hits.txt", + "PathwayID\tPathwayName\tKO\tGenes\n" + "ko00073\tCutin, suberine and wax biosynthesis\tK00073\tOsWax1/OsWax2\n" + "ko00196\tPhotosynthesis - antenna proteins\tK00196\tOsLight1/OsLight2\n", + ), + "kegg_b_hits": ( + f"{kegg_b}.KEGG_pathway_gene_hits.txt", + "PathwayID\tPathwayName\tKO\tGenes\n" + "ko00073\tCutin, suberine and wax biosynthesis\tK10073\tOsWaxH\n", + ), + "kegg_a_bar_png": (f"{kegg_a}_KEGG_enrich.png", "png"), + "kegg_a_bar_pdf": (f"{kegg_a}_KEGG_enrich.pdf", "pdf"), + "kegg_b_bar": (f"{kegg_b}_KEGG_enrich.png", "png"), + "kegg_a_dot": (f"{kegg_a}_KEGG_dotplot.png", "png"), + "kegg_b_dot": (f"{kegg_b}_KEGG_dotplot.png", "png"), + "pathway_a_00073_png": (f"{kegg_a}.KEGG_pathway_map.ko00073.png", "png"), + "pathway_a_00073_pdf": (f"{kegg_a}.KEGG_pathway_map.ko00073.pdf", "pdf"), + "pathway_a_00196": (f"{kegg_a}.KEGG_pathway_map.ko00196.png", "png"), + "pathway_b_00073": (f"{kegg_b}.KEGG_pathway_map.ko00073.png", "png"), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-combined-enrichment", + expected_stages=["rnaseq_enrich"], + use_model=False, + ) + markdown = result.report_path.read_text(encoding="utf-8") + embedded = _embedded_image_names(markdown) + inventory = json.loads(result.evidence_path.read_text(encoding="utf-8"))["formal_report"][ + "figure_inventory" + ] + + assert sum(name.endswith(".GO_enrich.barplot.png") for name in embedded) == 1 + assert sum(name.endswith(".GO_enrich.dotplot.png") for name in embedded) == 1 + assert sum(name.endswith(".GO_enrich.ontology_barplot.png") for name in embedded) == 1 + assert sum(name.endswith("_KEGG_enrich.png") for name in embedded) == 1 + assert sum(name.endswith("_KEGG_dotplot.png") for name in embedded) == 1 + assert not any(name.endswith((".pdf", ".svg")) for name in embedded) + assert { + category: sum(item["category"] == category for item in inventory) + for category in ("go_bar", "go_dot", "go_ontology", "kegg_bar", "kegg_dot", "kegg_pathway") + } == { + "go_bar": 1, + "go_dot": 1, + "go_ontology": 1, + "kegg_bar": 1, + "kegg_dot": 1, + "kegg_pathway": 2, + } + assert not any(item["filename"].endswith(".pdf") for item in inventory) + + pathway_names = [name for name in embedded if ".KEGG_pathway_map.ko" in name] + assert pathway_names == sorted( + [ + f"{kegg_a}.KEGG_pathway_map.ko00073.png", + f"{kegg_a}.KEGG_pathway_map.ko00196.png", + ] + ) + expected_evidence = { + f"{go_a}.barplot.png": "salt stress response", + f"{go_a}.dotplot.png": "GO:0009651", + f"{go_a}.ontology_barplot.png": "MF=oxidoreductase activity", + f"{kegg_a}_KEGG_enrich.png": "Cutin, suberine and wax biosynthesis", + f"{kegg_a}_KEGG_dotplot.png": "ko00073", + f"{kegg_a}.KEGG_pathway_map.ko00073.png": "OsWax1", + f"{kegg_a}.KEGG_pathway_map.ko00196.png": "OsLight1", + } + for filename, evidence_value in expected_evidence.items(): + section = _figure_section(markdown, filename) + assert all( + label in section + for label in ("图形含义:", "阅读方法:", "本次结果:", "解释边界:") + ) + assert evidence_value in section + + +def test_enrichment_selection_does_not_remove_de_or_other_stage_figures(tmp_path: Path) -> None: + workspace = tmp_path / "job_non_enrichment_figures" + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_de", + "scope-all-figures", + { + "de_volcano_a": ("Os.rnaseq_de.HH11__24_vs_HH11__0.volcano.png", "png"), + "de_volcano_b": ("Os.rnaseq_de.IR29__6_vs_IR29__0.volcano.png", "png"), + }, + ) + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_qc", + "scope-all-figures", + { + "qc_before": ("Os.rnaseq_qc.before.png", "png"), + "qc_after": ("Os.rnaseq_qc.after.png", "png"), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-all-figures", + expected_stages=["rnaseq_qc", "rnaseq_de"], + use_model=False, + ) + markdown = result.report_path.read_text(encoding="utf-8") + embedded = _embedded_image_names(markdown) + + for filename in ( + "Os.rnaseq_de.HH11__24_vs_HH11__0.volcano.png", + "Os.rnaseq_de.IR29__6_vs_IR29__0.volcano.png", + "Os.rnaseq_qc.before.png", + "Os.rnaseq_qc.after.png", + ): + assert embedded.count(filename) == 1 + + +def test_open_regenerates_v3_report_with_renderer_v4(tmp_path: Path) -> None: + workspace = tmp_path / "job_legacy_report" + _write_manifest(workspace, "Os", "rnaseq_qc", "success", "scope-renderer") + generated = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-renderer", + expected_stages=["rnaseq_qc"], + use_model=False, + ) + generated.report_path.write_text("STALE LEGACY REPORT\n", encoding="utf-8") + old_manifest = json.loads(generated.manifest_path.read_text(encoding="utf-8")) + old_manifest["metrics"]["report_renderer_version"] = "3" + generated.manifest_path.write_text(json.dumps(old_manifest, ensure_ascii=False), encoding="utf-8") + + reopened = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-renderer", + expected_stages=["rnaseq_qc"], + use_model=False, + action="open", + ) + + assert reopened.reused is False + assert "STALE LEGACY REPORT" not in reopened.report_path.read_text(encoding="utf-8") + refreshed_manifest = json.loads(reopened.manifest_path.read_text(encoding="utf-8")) + assert refreshed_manifest["metrics"]["report_renderer_version"] == "4" + + +def test_pathway_without_matching_kegg_row_reports_insufficient_evidence(tmp_path: Path) -> None: + workspace = tmp_path / "job_pathway_mismatch" + base = "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0" + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_kegg_enrich", + "scope-pathway-mismatch", + { + "kegg_table": ( + f"{base}.KEGG_enrich.txt", + "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n" + "ko00073\tCutin, suberine and wax biosynthesis\t2/20\t0.001\t2\tOsWax1/OsWax2\n", + ), + "unmatched_pathway": (f"{base}.KEGG_pathway_map.ko99999.png", "png"), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-pathway-mismatch", + expected_stages=["rnaseq_kegg_enrich"], + use_model=False, + ) + section = _figure_section(result.report_path.read_text(encoding="utf-8"), "KEGG_pathway_map.ko99999.png") + + assert "证据不足" in section + assert "Cutin, suberine and wax biosynthesis" not in section + + +def test_pathway_without_ko_mapping_does_not_summarize_all_ko_fold_changes(tmp_path: Path) -> None: + workspace = tmp_path / "job_pathway_without_ko_mapping" + base = "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0" + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_kegg_enrich", + "scope-no-ko-map", + { + "kegg_table": ( + f"{base}.KEGG_enrich.txt", + "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n" + "ko00073\tCutin, suberine and wax biosynthesis\t2/20\t0.001\t2\tOsWax1/OsWax2\n", + ), + "pathway_hits_without_ko": ( + f"{base}.KEGG_pathway_gene_hits.txt", + "PathwayID\tPathwayName\tGenes\n" + "ko00073\tCutin, suberine and wax biosynthesis\tOsWax1/OsWax2\n", + ), + "unscoped_ko_fc": ( + f"{base}.KEGG_pathway_map.ko_log2fc.tsv", + "KO\tlog2FoldChange\nK11111\t2.5\nK22222\t-1.5\n", + ), + "pathway": (f"{base}.KEGG_pathway_map.ko00073.png", "png"), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-no-ko-map", + expected_stages=["rnaseq_kegg_enrich"], + use_model=False, + ) + section = _figure_section(result.report_path.read_text(encoding="utf-8"), "KEGG_pathway_map.ko00073.png") + + assert "KO对应关系证据不足" in section + assert "KO变化中上调1个、下调1个" not in section + + +def test_kegg_representative_selection_ignores_raw_enrichment_tables(tmp_path: Path) -> None: + workspace = tmp_path / "job_kegg_raw_selection" + header = "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n" + a = "Os.rnaseq_kegg_enrich.AA__1_vs_AA__0" + b = "Os.rnaseq_kegg_enrich.BB__1_vs_BB__0" + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_kegg_enrich", + "scope-kegg-raw-selection", + { + "a_filtered": (f"{a}.KEGG_enrich.txt", header + "ko00001\tFiltered A\t1/20\t0.05\t1\tOsA\n"), + "a_raw": (f"{a}.KEGG_enrich.raw.txt", header + "ko00001\tRaw A\t1/20\t0.000000001\t1\tOsA\n"), + "a_bar": (f"{a}_KEGG_enrich.png", "png"), + "b_filtered": (f"{b}.KEGG_enrich.txt", header + "ko00002\tFiltered B\t2/20\t0.01\t2\tOsB1/OsB2\n"), + "b_raw": (f"{b}.KEGG_enrich.raw.txt", header + "ko00002\tRaw B\t2/20\t0.9\t2\tOsB1/OsB2\n"), + "b_bar": (f"{b}_KEGG_enrich.png", "png"), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-kegg-raw-selection", + expected_stages=["rnaseq_kegg_enrich"], + use_model=False, + ) + embedded = _embedded_image_names(result.report_path.read_text(encoding="utf-8")) + + assert f"{b}_KEGG_enrich.png" in embedded + assert f"{a}_KEGG_enrich.png" not in embedded + + +def test_kegg_figure_interpretation_uses_filtered_table_not_raw_table(tmp_path: Path) -> None: + workspace = tmp_path / "job_kegg_raw_interpretation" + base = "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0" + header = "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n" + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_kegg_enrich", + "scope-kegg-raw-interpretation", + { + "raw_first": (f"{base}.KEGG_enrich.raw.txt", header + "ko99998\tRaw-only pathway\t9/20\t0.0001\t9\tRawGene\n"), + "filtered": (f"{base}.KEGG_enrich.txt", header + "ko00073\tFiltered pathway\t2/20\t0.01\t2\tOsWax1/OsWax2\n"), + "bar": (f"{base}_KEGG_enrich.png", "png"), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-kegg-raw-interpretation", + expected_stages=["rnaseq_kegg_enrich"], + use_model=False, + ) + section = _figure_section(result.report_path.read_text(encoding="utf-8"), f"{base}_KEGG_enrich.png") + + assert "Filtered pathway" in section + assert "Raw-only pathway" not in section + + +def test_go_ontology_interpretation_covers_bp_cc_and_mf_rows(tmp_path: Path) -> None: + workspace = tmp_path / "job_go_three_ontologies" + base = "Os.rnaseq_go_enrich.MixedCase__6_vs_MixedCase__0.GO_enrich" + header = "ID\tDescription\tOntology\tGeneRatio\tp.adjust\tCount\tgeneID\n" + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_go_enrich", + "scope-go-ontologies", + { + "table": ( + f"{base}.txt", + header + + "GO:0000001\tbiological process theme\tBP\t3/20\t0.001\t3\tA/B/C\n" + + "GO:0000002\tcellular component theme\tCC\t2/20\t0.002\t2\tD/E\n" + + "GO:0000003\tmolecular function theme\tMF\t1/20\t0.003\t1\tF\n", + ), + "ontology": (f"{base}.ontology_barplot.png", "png"), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-go-ontologies", + expected_stages=["rnaseq_go_enrich"], + use_model=False, + ) + section = _figure_section(result.report_path.read_text(encoding="utf-8"), "ontology_barplot.png") + + assert "biological process theme" in section + assert "cellular component theme" in section + assert "molecular function theme" in section + + +def test_svg_fallbacks_are_classified_and_selected_once_per_plot_form(tmp_path: Path) -> None: + workspace = tmp_path / "job_svg_fallbacks" + go_header = "ID\tDescription\tOntology\tGeneRatio\tp.adjust\tCount\tgeneID\n" + kegg_header = "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n" + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_go_enrich", + "scope-svg-fallback", + { + "go_a_table": ("Os.rnaseq_go_enrich.AA.GO_enrich.txt", go_header + "GO:1\tA\tBP\t1/2\t0.01\t1\tA\n"), + "go_b_table": ("Os.rnaseq_go_enrich.BB.GO_enrich.txt", go_header + "GO:2\tB\tBP\t1/2\t0.02\t1\tB\n"), + "go_a_svg": ("Os.rnaseq_go_enrich.AA.GO_enrich.svg", ""), + "go_b_svg": ("Os.rnaseq_go_enrich.BB.GO_enrich.svg", ""), + }, + ) + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_kegg_enrich", + "scope-svg-fallback", + { + "kegg_a_table": ("Os.rnaseq_kegg_enrich.AA.KEGG_enrich.txt", kegg_header + "ko1\tA\t1/2\t0.01\t1\tA\n"), + "kegg_b_table": ("Os.rnaseq_kegg_enrich.BB.KEGG_enrich.txt", kegg_header + "ko2\tB\t1/2\t0.02\t1\tB\n"), + "kegg_a_svg": ("Os.rnaseq_kegg_enrich.AA.KEGG_enrich.svg", ""), + "kegg_b_svg": ("Os.rnaseq_kegg_enrich.BB.KEGG_enrich.svg", ""), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-svg-fallback", + expected_stages=["rnaseq_go_enrich", "rnaseq_kegg_enrich"], + use_model=False, + ) + evidence = json.loads(result.evidence_path.read_text(encoding="utf-8")) + categories = [item["category"] for item in evidence["formal_report"]["figure_inventory"]] + + assert categories.count("go_bar") == 1 + assert categories.count("kegg_bar") == 1 + + +def test_comparison_case_is_preserved_in_figure_interpretation(tmp_path: Path) -> None: + workspace = tmp_path / "job_comparison_case" + base = "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0" + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_kegg_enrich", + "scope-case", + { + "table": ( + f"{base}.KEGG_enrich.txt", + "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\nko00073\tWax\t2/20\t0.01\t2\tA/B\n", + ), + "bar": (f"{base}_KEGG_enrich.png", "png"), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-case", + expected_stages=["rnaseq_kegg_enrich"], + use_model=False, + ) + section = _figure_section(result.report_path.read_text(encoding="utf-8"), f"{base}_KEGG_enrich.png") + + assert "本次结果:比较组 `IR29__6_vs_IR29__0`" in section + + +def test_invalid_adjusted_p_values_do_not_participate_in_representative_selection(tmp_path: Path) -> None: + workspace = tmp_path / "job_invalid_padj" + header = "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n" + artifacts: dict[str, tuple[str, str]] = {} + for label, padj in (("NEG", "-0.1"), ("NAN", "NaN"), ("INF", "Inf"), ("VALID", "0.02")): + base = f"Os.rnaseq_kegg_enrich.{label}__1_vs_{label}__0" + artifacts[f"{label}_table"] = (f"{base}.KEGG_enrich.txt", header + f"ko00001\t{label}\t1/20\t{padj}\t1\tGene\n") + artifacts[f"{label}_bar"] = (f"{base}_KEGG_enrich.png", "png") + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_kegg_enrich", + "scope-invalid-padj", + artifacts, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-invalid-padj", + expected_stages=["rnaseq_kegg_enrich"], + use_model=False, + ) + embedded = _embedded_image_names(result.report_path.read_text(encoding="utf-8")) + + assert "Os.rnaseq_kegg_enrich.VALID__1_vs_VALID__0_KEGG_enrich.png" in embedded + + +def test_fgsea_curve_tied_padj_uses_largest_absolute_nes_and_matching_pathway(tmp_path: Path) -> None: + workspace = tmp_path / "job_fgsea_tie" + header = "pathway\tpathway_name\tpval\tpadj\tES\tNES\tsize\tleadingEdge\n" + aa = "Os.rnaseq_fgsea.AA__1_vs_AA__0" + zz = "Os.rnaseq_fgsea.ZZ__1_vs_ZZ__0" + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_fgsea", + "scope-fgsea-tie", + { + "aa_table": (f"{aa}.fgsea_kegg.tsv", header + "ko00001\tAA pathway\t0.001\t0.01\t0.4\t2.0\t10\tA,B\n"), + "aa_curve": (f"{aa}.fgsea_kegg_top_enrichment.png", "png"), + "zz_table": ( + f"{zz}.fgsea_kegg.tsv", + header + + "ko00002\tZZ weaker tied pathway\t0.001\t0.01\t0.2\t1.0\t10\tC,D\n" + + "ko00003\tZZ strongest tied pathway\t0.001\t0.01\t-0.7\t-3.0\t12\tE,F\n", + ), + "zz_curve": (f"{zz}.fgsea_kegg_top_enrichment.png", "png"), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-fgsea-tie", + expected_stages=["rnaseq_fgsea"], + use_model=False, + ) + markdown = result.report_path.read_text(encoding="utf-8") + embedded = _embedded_image_names(markdown) + + assert f"{zz}.fgsea_kegg_top_enrichment.png" in embedded + section = _figure_section(markdown, f"{zz}.fgsea_kegg_top_enrichment.png") + assert "ko00003" in section + assert "NES=-3.0" in section + + +def test_pdf_only_pathway_is_linked_or_recorded_in_figure_inventory(tmp_path: Path) -> None: + workspace = tmp_path / "job_pdf_pathway" + base = "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0" + pdf_name = f"{base}.KEGG_pathway_map.ko00073.pdf" + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_kegg_enrich", + "scope-pdf-pathway", + { + "table": ( + f"{base}.KEGG_enrich.txt", + "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\nko00073\tWax\t2/20\t0.01\t2\tA/B\n", + ), + "pdf_pathway": (pdf_name, "pdf"), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-pdf-pathway", + expected_stages=["rnaseq_kegg_enrich"], + use_model=False, + ) + markdown = result.report_path.read_text(encoding="utf-8") + evidence = json.loads(result.evidence_path.read_text(encoding="utf-8")) + inventory_names = {item["filename"] for item in evidence["formal_report"]["figure_inventory"]} + linked = re.search(rf"\[[^]]*\]\([^)]*{re.escape(pdf_name)}[^)]*\)", markdown) is not None + + assert linked or pdf_name in inventory_names + + +def test_renderer_and_figure_inventory_are_consistent_in_evidence_and_manifest(tmp_path: Path) -> None: + workspace = tmp_path / "job_report_metadata" + _write_enrichment_manifests(workspace) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-enrichment", + expected_stages=["rnaseq_go_enrich", "rnaseq_kegg_enrich", "rnaseq_fgsea"], + use_model=False, + ) + evidence = json.loads(result.evidence_path.read_text(encoding="utf-8")) + manifest = json.loads(result.manifest_path.read_text(encoding="utf-8")) + formal = evidence["formal_report"] + metrics = manifest["metrics"] + + assert formal["report_renderer_version"] == metrics["report_renderer_version"] + assert formal["figure_inventory"] + assert metrics.get("figure_inventory") == formal["figure_inventory"] + + +def test_go_ontology_interpretation_accepts_real_long_ontology_names(tmp_path: Path) -> None: + workspace = tmp_path / "job_go_real_ontology_names" + base = "Os.rnaseq_go_enrich.A__1_vs_A__0.GO_enrich" + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_go_enrich", + "scope-go-real-ontology", + { + "table": ( + f"{base}.txt", + "ID\tDescription\tOntology\tGeneRatio\tp.adjust\tCount\tgeneID\n" + "GO:0000001\tlong-name BP theme\tBiological process\t3/20\t0.001\t3\tA/B/C\n" + "GO:0000002\tlong-name CC theme\tCellular component\t2/20\t0.002\t2\tD/E\n" + "GO:0000003\tlong-name MF theme\tMolecular function\t1/20\t0.003\t1\tF\n", + ), + "ontology": (f"{base}.ontology_barplot.png", "png"), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-go-real-ontology", + expected_stages=["rnaseq_go_enrich"], + use_model=False, + ) + section = _figure_section(result.report_path.read_text(encoding="utf-8"), "ontology_barplot.png") + + assert "BP=long-name BP theme" in section + assert "CC=long-name CC theme" in section + assert "MF=long-name MF theme" in section + + +def test_pathway_interpretation_merges_genes_from_all_matching_ko_rows(tmp_path: Path) -> None: + workspace = tmp_path / "job_pathway_multi_ko_hits" + base = "Os.rnaseq_kegg_enrich.A__1_vs_A__0" + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_kegg_enrich", + "scope-pathway-multi-ko", + { + "enrichment": ( + f"{base}.KEGG_enrich.txt", + "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n" + "ko00073\tWax pathway\t3/20\t0.001\t3\tGeneA/GeneB/GeneC\n", + ), + "hits": ( + f"{base}.KEGG_pathway_gene_hits.txt", + "PathwayID\tPathwayName\tKO\tGenes\n" + "ko00073\tWax pathway\tK00001\tGeneA/GeneB\n" + "ko00073\tWax pathway\tK00002\tGeneC\n", + ), + "pathway": (f"{base}.KEGG_pathway_map.ko00073.png", "png"), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-pathway-multi-ko", + expected_stages=["rnaseq_kegg_enrich"], + use_model=False, + ) + section = _figure_section(result.report_path.read_text(encoding="utf-8"), "KEGG_pathway_map.ko00073.png") + + assert "GeneA" in section + assert "GeneB" in section + assert "GeneC" in section + + +def test_figure_uses_exact_comparison_table_when_names_contain_each_other(tmp_path: Path) -> None: + workspace = tmp_path / "job_exact_comparison" + base_a = "Os.rnaseq_go_enrich.A.GO_enrich" + base_aa = "Os.rnaseq_go_enrich.AA.GO_enrich" + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_go_enrich", + "scope-exact-comparison", + { + "aa_table": ( + f"{base_aa}.txt", + "ID\tDescription\tOntology\tGeneRatio\tp.adjust\tCount\tgeneID\n" + "GO:0000002\tAA-only evidence\tBP\t2/20\t0.0001\t2\tAA1/AA2\n", + ), + "a_table": ( + f"{base_a}.txt", + "ID\tDescription\tOntology\tGeneRatio\tp.adjust\tCount\tgeneID\n" + "GO:0000001\tA exact evidence\tBP\t1/20\t0.01\t1\tA1\n", + ), + "a_bar": (f"{base_a}.barplot.png", "png"), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-exact-comparison", + expected_stages=["rnaseq_go_enrich"], + use_model=False, + ) + section = _figure_section(result.report_path.read_text(encoding="utf-8"), f"{base_a}.barplot.png") + + assert "A exact evidence" in section + assert "AA-only evidence" not in section + + +@pytest.mark.parametrize("invalid_padj", ["NaN", "Inf", "-0.1"]) +def test_mandatory_pathway_does_not_render_invalid_padj_as_significance( + tmp_path: Path, invalid_padj: str +) -> None: + workspace = tmp_path / f"job_invalid_pathway_padj_{invalid_padj.replace('-', 'neg')}" + base = "Os.rnaseq_kegg_enrich.A__1_vs_A__0" + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_kegg_enrich", + "scope-invalid-pathway-padj", + { + "table": ( + f"{base}.KEGG_enrich.txt", + "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n" + f"ko00073\tWax pathway\t2/20\t{invalid_padj}\t2\tA/B\n", + ), + "pathway": (f"{base}.KEGG_pathway_map.ko00073.png", "png"), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-invalid-pathway-padj", + expected_stages=["rnaseq_kegg_enrich"], + use_model=False, + ) + section = _figure_section(result.report_path.read_text(encoding="utf-8"), "KEGG_pathway_map.ko00073.png") + + assert "p.adjust=未登记" in section + assert f"p.adjust={invalid_padj}" not in section + + +def test_pdf_only_non_pathway_enrichment_forms_are_linked_and_in_inventory(tmp_path: Path) -> None: + workspace = tmp_path / "job_pdf_only_enrichment_forms" + go_base = "Os.rnaseq_go_enrich.A__1_vs_A__0.GO_enrich" + kegg_base = "Os.rnaseq_kegg_enrich.A__1_vs_A__0" + fgsea_base = "Os.rnaseq_fgsea.A__1_vs_A__0" + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_go_enrich", + "scope-pdf-forms", + { + "table": ( + f"{go_base}.txt", + "ID\tDescription\tOntology\tGeneRatio\tp.adjust\tCount\tgeneID\n" + "GO:1\tGO evidence\tBP\t1/20\t0.01\t1\tA\n", + ), + "bar": (f"{go_base}.barplot.pdf", "pdf"), + "dot": (f"{go_base}.dotplot.pdf", "pdf"), + "ontology": (f"{go_base}.ontology_barplot.pdf", "pdf"), + }, + ) + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_kegg_enrich", + "scope-pdf-forms", + { + "table": ( + f"{kegg_base}.KEGG_enrich.txt", + "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n" + "ko00001\tKEGG evidence\t1/20\t0.01\t1\tA\n", + ), + "bar": (f"{kegg_base}_KEGG_enrich.pdf", "pdf"), + "dot": (f"{kegg_base}_KEGG_dotplot.pdf", "pdf"), + }, + ) + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_fgsea", + "scope-pdf-forms", + { + "table": ( + f"{fgsea_base}.fgsea_kegg.tsv", + "pathway\tpathway_name\tpadj\tNES\tleadingEdge\n" + "ko00001\tFGSEA evidence\t0.01\t2.0\tA,B\n", + ), + "dot": (f"{fgsea_base}.fgsea_kegg_dotplot.pdf", "pdf"), + "curve": (f"{fgsea_base}.fgsea_kegg_top_enrichment.pdf", "pdf"), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-pdf-forms", + expected_stages=["rnaseq_go_enrich", "rnaseq_kegg_enrich", "rnaseq_fgsea"], + use_model=False, + ) + markdown = result.report_path.read_text(encoding="utf-8") + evidence = json.loads(result.evidence_path.read_text(encoding="utf-8")) + expected = { + f"{go_base}.barplot.pdf", + f"{go_base}.dotplot.pdf", + f"{go_base}.ontology_barplot.pdf", + f"{kegg_base}_KEGG_enrich.pdf", + f"{kegg_base}_KEGG_dotplot.pdf", + f"{fgsea_base}.fgsea_kegg_dotplot.pdf", + f"{fgsea_base}.fgsea_kegg_top_enrichment.pdf", + } + inventory_names = {item["filename"] for item in evidence["formal_report"]["figure_inventory"]} + + assert expected <= inventory_names + for filename in expected: + assert re.search(rf"\[[^]]*\]\([^)]*{re.escape(filename)}[^)]*\)", markdown) + + +def test_representative_selection_requires_legal_nonempty_evidence_before_format( + tmp_path: Path, +) -> None: + workspace = tmp_path / "job_representative_evidence_priority" + header = "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n" + artifacts: dict[str, tuple[str, str]] = {} + cases = ( + ("EMPTY", "", "png"), + ("INVALID", header + "ko1\tinvalid\t1/20\t-0.1\t1\tA\n", "png"), + ("VALID_SVG", header + "ko2\tvalid svg\t1/20\t0.04\t1\tB\n", "svg"), + ("VALID_PNG", header + "ko3\tvalid png\t1/20\t0.03\t1\tC\n", "png"), + ) + for label, table_content, extension in cases: + base = f"Os.rnaseq_kegg_enrich.{label}" + artifacts[f"{label}_table"] = (f"{base}.KEGG_enrich.txt", table_content) + artifacts[f"{label}_figure"] = (f"{base}_KEGG_enrich.{extension}", "figure") + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_kegg_enrich", + "scope-representative-priority", + artifacts, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-representative-priority", + expected_stages=["rnaseq_kegg_enrich"], + use_model=False, + ) + inventory = json.loads(result.evidence_path.read_text(encoding="utf-8"))["formal_report"]["figure_inventory"] + selected = [item["filename"] for item in inventory if item["category"] == "kegg_bar"] + + assert selected == ["Os.rnaseq_kegg_enrich.VALID_PNG_KEGG_enrich.png"] + + +def test_model_enhancement_rejects_numbers_and_activation_claims_absent_from_evidence( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from gfa_core.mas import llm as llm_module + from gfa_core.mas.config import config + + class FakeManager: + def __init__(self, _config: object) -> None: + pass + + def call(self, *_args: object, **_kwargs: object) -> object: + return type( + "Response", + (), + { + "content": ( + "模型声称42个基因显著变化,因此该通路被激活并直接导致抗逆性增强;" + "同时还声称处理后产量提高了37%,这些数字与因果结论均不在输入证据中。" + ), + "provider": "fake", + "model": "fake-model", + }, + )() + + monkeypatch.setattr(config, "load_llm_config", lambda: {}) + monkeypatch.setattr(llm_module, "LLMManager", FakeManager) + evidence = { + "metadata": {"included_stages": ["rnaseq_kegg_enrich"]}, + "biological_story": "检测到KEGG富集结果,具体证据见结果表。", + "key_findings": [], + "major_claims": [], + "risk_flags": [], + } + + summary, metadata = rnaseq_report_module._model_enhancement(evidence, enabled=True) + + assert summary == "" + assert metadata["status"] == "fallback" + assert metadata["error"] + + +def _install_fake_model_response(monkeypatch: pytest.MonkeyPatch, content: str) -> None: + from gfa_core.mas import llm as llm_module + from gfa_core.mas.config import config + + class FakeManager: + def __init__(self, _config: object) -> None: + pass + + def call(self, *_args: object, **_kwargs: object) -> object: + return type( + "Response", + (), + {"content": content, "provider": "fake", "model": "fake-model"}, + )() + + monkeypatch.setattr(config, "load_llm_config", lambda: {}) + monkeypatch.setattr(llm_module, "LLMManager", FakeManager) + + +def test_free_form_model_output_is_rejected_and_never_embedded_in_markdown( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + workspace = tmp_path / "job_model_not_embedded" + _write_manifest(workspace, "Os", "rnaseq_qc", "success", "scope-model-not-embedded") + model_text = ( + "现有证据仅支持对已完成阶段进行描述性总结,各项结果仍需结合实验设计和原始结果表审慎解释," + "不应扩展为未被数据支持的生物学结论。" + ) + _install_fake_model_response(monkeypatch, model_text) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-model-not-embedded", + expected_stages=["rnaseq_qc"], + use_model=True, + ) + + markdown = result.report_path.read_text(encoding="utf-8") + model_record = json.loads(result.model_path.read_text(encoding="utf-8")) + assert model_text not in markdown + assert "### 模型辅助语言润色(不作为新增证据)" in markdown + assert "API 仅参与已有证据语句的排序和组织" in markdown + assert model_record["content"] == model_text + assert model_record["status"] == "fallback" + assert model_record["policy"] == "evidence_claim_ordering_only" + assert model_record["error"] + + +def test_noncausal_free_form_model_text_is_still_rejected( + monkeypatch: pytest.MonkeyPatch, +) -> None: + model_text = ( + "这些富集结果只能说明统计关联,不能证明因果关系,也无法认定相关通路被激活," + "仍需结合独立实验进行验证。" + ) + _install_fake_model_response(monkeypatch, model_text) + evidence = { + "metadata": {"included_stages": ["rnaseq_kegg_enrich"]}, + "biological_story": "检测到KEGG富集结果,具体证据见结果表。", + "key_findings": [], + "major_claims": [], + "risk_flags": [], + } + + summary, metadata = rnaseq_report_module._model_enhancement(evidence, enabled=True) + + assert summary == "" + assert metadata["content"] == model_text + assert metadata["status"] == "fallback" + assert metadata["policy"] == "evidence_claim_ordering_only" + assert metadata["error"] + + +def test_model_cannot_embed_mechanistic_claims_even_when_all_numbers_exist_in_evidence( + monkeypatch: pytest.MonkeyPatch, +) -> None: + model_text = ( + "已有结果中的12个基因显著上调并促进耐盐,揭示了该通路控制抗逆性的调控机制," + "因此可作为后续育种的确定依据。" + ) + _install_fake_model_response(monkeypatch, model_text) + evidence = { + "metadata": {"included_stages": ["rnaseq_kegg_enrich"]}, + "biological_story": "结果表登记12个命中基因。", + "key_findings": [], + "major_claims": [], + "risk_flags": [], + } + + summary, metadata = rnaseq_report_module._model_enhancement(evidence, enabled=True) + + assert summary == "" + assert metadata["content"] == model_text + assert metadata["status"] == "fallback" + assert metadata["policy"] == "evidence_claim_ordering_only" + + +def test_model_can_only_order_and_group_approved_evidence_claims( + monkeypatch: pytest.MonkeyPatch, +) -> None: + evidence = { + "metadata": {"included_stages": ["rnaseq_qc"]}, + "biological_story": "质控阶段已经完成,具体质量指标见结果表。", + "key_findings": [ + {"stage": "rnaseq_qc", "description": "成功阶段提供了可追溯的质控结果。"} + ], + "major_claims": [], + "risk_flags": [], + } + organization = { + "paragraph_groups": [["finding_1", "story_1"]], + } + _install_fake_model_response(monkeypatch, json.dumps(organization, ensure_ascii=False)) + + summary, metadata = rnaseq_report_module._model_enhancement(evidence, enabled=True) + + assert summary == "成功阶段提供了可追溯的质控结果。质控阶段已经完成,具体质量指标见结果表。" + assert metadata["status"] == "embedded_evidence_only" + assert metadata["policy"] == "evidence_claim_ordering_only" + assert metadata["organization_plan"] == organization + assert metadata["selected_claim_ids"] == ["finding_1", "story_1"] + assert {row["claim_id"] for row in metadata["claim_catalog"]} == {"story_1", "finding_1"} + + +def test_unknown_model_claim_id_forces_deterministic_fallback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + evidence = { + "metadata": {"included_stages": ["rnaseq_qc"]}, + "biological_story": "质控阶段已经完成。", + "key_findings": [], + "major_claims": [], + "risk_flags": [], + } + _install_fake_model_response( + monkeypatch, + json.dumps({"paragraph_groups": [["story_1", "invented_claim"]]}, ensure_ascii=False), + ) + + summary, metadata = rnaseq_report_module._model_enhancement(evidence, enabled=True) + + assert summary == "" + assert metadata["status"] == "fallback" + assert "invented_claim" in metadata["error"] + + +def test_report_always_states_the_api_evidence_boundary(tmp_path: Path) -> None: + workspace = tmp_path / "job_model_boundary" + _write_manifest(workspace, "Os", "rnaseq_qc", "success", "scope-model-boundary") + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-model-boundary", + expected_stages=["rnaseq_qc"], + use_model=False, + ) + markdown = result.report_path.read_text(encoding="utf-8") + + assert "### 模型辅助语言润色(不作为新增证据)" in markdown + assert "API 仅参与已有证据语句的排序和组织" in markdown + assert "不参与结果分析或推断" in markdown + assert "未采用模型组织结果" in markdown + + +def test_pathway_interpretation_previews_only_twelve_genes_and_kos_and_links_full_tables( + tmp_path: Path, +) -> None: + workspace = tmp_path / "job_pathway_long_lists" + base = "Os.rnaseq_kegg_enrich.IR29__6_vs_IR29__0" + genes = [f"Gene{index:03d}" for index in range(1, 456)] + kos = [f"K{index:05d}" for index in range(1, 456)] + hit_rows = "".join( + f"ko00073\tWax pathway\t{ko}\t{gene}\n" for ko, gene in zip(kos, genes) + ) + ko_rows = "".join(f"{ko}\t1.0\n" for ko in kos) + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_kegg_enrich", + "scope-pathway-long-lists", + { + "filtered": ( + f"{base}.KEGG_enrich.txt", + "ID\tDescription\tGeneRatio\tp.adjust\tCount\tgeneID\n" + f"ko00073\tWax pathway\t455/1000\t0.001\t455\t{'/'.join(genes)}\n", + ), + "hits": ( + f"{base}.KEGG_pathway_gene_hits.txt", + "PathwayID\tPathwayName\tKO\tGenes\n" + hit_rows, + ), + "ko_log2fc": ( + f"{base}.KEGG_pathway_map.ko_log2fc.tsv", + "KO\tlog2FoldChange\n" + ko_rows, + ), + "pathway": (f"{base}.KEGG_pathway_map.ko00073.png", "png"), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-pathway-long-lists", + expected_stages=["rnaseq_kegg_enrich"], + use_model=False, + ) + section = _figure_section(result.report_path.read_text(encoding="utf-8"), "KEGG_pathway_map.ko00073.png") + + assert "命中基因总数=455" in section + assert "KO总数=455" in section + assert all(gene in section for gene in genes[:12]) + assert all(ko in section for ko in kos[:12]) + assert genes[12] not in section + assert kos[12] not in section + assert f"[{base}.KEGG_pathway_gene_hits.txt]" in section + assert f"[{base}.KEGG_pathway_map.ko_log2fc.tsv]" in section + + +def test_fgsea_leading_edge_previews_twelve_genes_and_links_full_result_table( + tmp_path: Path, +) -> None: + workspace = tmp_path / "job_fgsea_long_leading_edge" + base = "Os.rnaseq_fgsea.IR29__6_vs_IR29__0" + leading_edge = [f"LeadGene{index:03d}" for index in range(1, 31)] + table_name = f"{base}.fgsea_kegg.tsv" + _write_artifact_manifest( + workspace, + "Os", + "rnaseq_fgsea", + "scope-fgsea-long-leading-edge", + { + "fgsea_table": ( + table_name, + "pathway\tpathway_name\tpadj\tNES\tsize\tleadingEdge\n" + f"ko00196\tPhotosynthesis - antenna proteins\t0.001\t2.1\t30\t{','.join(leading_edge)}\n", + ), + "fgsea_curve": (f"{base}.fgsea_kegg_top_enrichment.png", "png"), + }, + ) + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-fgsea-long-leading-edge", + expected_stages=["rnaseq_fgsea"], + use_model=False, + ) + markdown = result.report_path.read_text(encoding="utf-8") + section = _figure_section(markdown, f"{base}.fgsea_kegg_top_enrichment.png") + + assert "leadingEdge总数=30" in section + assert "前12项=" in section + assert all(gene in section for gene in leading_edge[:12]) + assert all(gene not in section for gene in leading_edge[12:]) + assert f"[{table_name}](" in section + assert ( + "> 报告生成原则:结果判断只来自脚本、Manifest 和结果表;API 仅参与已有证据语句的排序和组织," + "不参与结果分析或推断。" in markdown + ) + + +def test_candidate_guided_pathways_keep_all_supported_contents_and_omit_unrelated( + tmp_path: Path, +) -> None: + workspace = tmp_path / "job_candidate_guided_pathways" + ids = _write_candidate_guided_enrichment_manifests(workspace) + + result = generate_rnaseq_report( + workspace, + ids["prefix"], + rnaseq_scope_id=ids["scope"], + expected_stages=[ + "rnaseq_go_enrich", + "rnaseq_kegg_enrich", + "rnaseq_fgsea", + "rnaseq_family_score", + ], + use_model=False, + ) + embedded = _embedded_image_names(result.report_path.read_text(encoding="utf-8")) + evidence = json.loads(result.evidence_path.read_text(encoding="utf-8")) + manifest = json.loads(result.manifest_path.read_text(encoding="utf-8")) + formal = evidence["formal_report"] + pathway_rows = [ + row for row in formal["figure_inventory"] if row["category"] == "kegg_pathway" + ] + + assert formal["selection_mode"] == "candidate_guided" + assert {row["pathway_id"] for row in pathway_rows} == { + ids["supported_pathway_a"], + ids["supported_pathway_b"], + } + assert all(row["comparison"] == ids["candidate_comparison"] for row in pathway_rows) + assert not any(ids["unrelated_pathway"] in name for name in embedded) + assert not any( + ids["unrelated_comparison"] in name and ids["supported_pathway_a"] in name + for name in embedded + ) + + shared = next( + row for row in pathway_rows if row["pathway_id"] == ids["supported_pathway_a"] + ) + assert shared["candidate_families"] == ["FAM_RANDOM_ALPHA", "FAM_RANDOM_BETA"] + assert any( + label.startswith(f"KEGG:{ids['supported_pathway_a']}:") + for label in shared["support_labels"] + ) + assert shared["selection_reason"] == "candidate_family_support" + assert sum(ids["supported_pathway_a"] in name for name in embedded) == 1 + + omitted = formal["omitted_figure_inventory"] + assert any(row["pathway_id"] == ids["unrelated_pathway"] for row in omitted) + assert formal["omitted_figure_count"] == len(omitted) + assert manifest["metrics"]["selection_mode"] == formal["selection_mode"] + assert manifest["metrics"]["omitted_figure_count"] == formal["omitted_figure_count"] + + +def test_candidate_guided_representatives_use_candidate_comparison_and_supported_fgsea( + tmp_path: Path, +) -> None: + workspace = tmp_path / "job_candidate_guided_forms" + ids = _write_candidate_guided_enrichment_manifests(workspace) + + result = generate_rnaseq_report( + workspace, + ids["prefix"], + rnaseq_scope_id=ids["scope"], + expected_stages=[ + "rnaseq_go_enrich", + "rnaseq_kegg_enrich", + "rnaseq_fgsea", + "rnaseq_family_score", + ], + use_model=False, + ) + inventory = json.loads(result.evidence_path.read_text(encoding="utf-8"))["formal_report"][ + "figure_inventory" + ] + selected_categories = { + category: [row for row in inventory if row["category"] == category] + for category in ( + "go_bar", + "go_dot", + "go_ontology", + "kegg_bar", + "kegg_dot", + "fgsea_dot", + "fgsea_curve", + ) + } + + assert all(len(rows) == 1 for rows in selected_categories.values()) + assert all( + rows[0]["comparison"] == ids["candidate_comparison"] + for rows in selected_categories.values() + ) + assert all(rows[0]["candidate_families"] for rows in selected_categories.values()) + assert all( + rows[0]["selection_reason"] == "candidate_family_support" + for rows in selected_categories.values() + ) + fgsea_curve = selected_categories["fgsea_curve"][0] + assert fgsea_curve["candidate_families"] == ["FAM_RANDOM_ALPHA"] + assert fgsea_curve["support_labels"] == [ + f"fgsea:{ids['supported_pathway_b']}:random pathway beta" + ] + + +def test_fgsea_curve_requires_exact_candidate_support_for_its_actual_top_pathway( + tmp_path: Path, +) -> None: + workspace = tmp_path / "job_fgsea_curve_exact_identity" + ids = _write_candidate_guided_enrichment_manifests(workspace) + fgsea_table = ( + workspace + / "final" + / f"{ids['prefix']}.rnaseq_fgsea.{ids['candidate_comparison']}.fgsea_kegg.tsv" + ) + fgsea_table.write_text( + "pathway\tpathway_name\tpadj\tNES\tsize\tleadingEdge\n" + "ko99999\tnon-candidate top pathway\t0.000001\t3.5\t20\tOtherA,OtherB\n" + f"{ids['supported_pathway_b']}\trandom pathway beta\t0.04\t2.1\t12\tGeneB,GeneC\n", + encoding="utf-8", + ) + + result = generate_rnaseq_report( + workspace, + ids["prefix"], + rnaseq_scope_id=ids["scope"], + expected_stages=[ + "rnaseq_go_enrich", + "rnaseq_kegg_enrich", + "rnaseq_fgsea", + "rnaseq_family_score", + ], + use_model=False, + ) + formal = json.loads(result.evidence_path.read_text(encoding="utf-8"))["formal_report"] + selected = formal["figure_inventory"] + omitted = formal["omitted_figure_inventory"] + fgsea_dots = [row for row in selected if row["category"] == "fgsea_dot"] + fgsea_curves = [row for row in selected if row["category"] == "fgsea_curve"] + + assert len(fgsea_dots) == 1 + assert fgsea_dots[0]["comparison"] == ids["candidate_comparison"] + assert fgsea_dots[0]["candidate_families"] == ["FAM_RANDOM_ALPHA"] + assert fgsea_curves == [] + assert any(row["category"] == "fgsea_curve" for row in omitted) + + +def test_fgsea_is_excluded_from_candidate_mainline_without_fgsea_support(tmp_path: Path) -> None: + workspace = tmp_path / "job_candidate_without_fgsea" + ids = _write_candidate_guided_enrichment_manifests( + workspace, + include_fgsea_support=False, + ) + + result = generate_rnaseq_report( + workspace, + ids["prefix"], + rnaseq_scope_id=ids["scope"], + expected_stages=[ + "rnaseq_go_enrich", + "rnaseq_kegg_enrich", + "rnaseq_fgsea", + "rnaseq_family_score", + ], + use_model=False, + ) + formal = json.loads(result.evidence_path.read_text(encoding="utf-8"))["formal_report"] + categories = {row["category"] for row in formal["figure_inventory"]} + omitted_categories = {row["category"] for row in formal["omitted_figure_inventory"]} + + assert "fgsea_dot" not in categories + assert "fgsea_curve" not in categories + assert {"fgsea_dot", "fgsea_curve"}.issubset(omitted_categories) + + +@pytest.mark.parametrize("analysis_ready_state", ["missing", "empty"]) +def test_missing_or_empty_analysis_ready_table_uses_non_candidate_fallback( + tmp_path: Path, + analysis_ready_state: str, +) -> None: + workspace = tmp_path / f"job_fallback_{analysis_ready_state}" + _write_enrichment_manifests(workspace) + expected_stages = ["rnaseq_go_enrich", "rnaseq_kegg_enrich", "rnaseq_fgsea"] + if analysis_ready_state == "empty": + _write_analysis_ready_family_manifest( + workspace, + prefix="Os", + scope_id="scope-enrichment", + rows=[], + ) + expected_stages.append("rnaseq_family_score") + + result = generate_rnaseq_report( + workspace, + "Os", + rnaseq_scope_id="scope-enrichment", + expected_stages=expected_stages, + use_model=False, + ) + formal = json.loads(result.evidence_path.read_text(encoding="utf-8"))["formal_report"] + inventory = formal["figure_inventory"] + categories = [row["category"] for row in inventory] + pathway_rows = [row for row in inventory if row["category"] == "kegg_pathway"] + + assert formal["selection_mode"] == "representative_fallback" + assert { + category: categories.count(category) + for category in ("go_bar", "go_dot", "go_ontology", "kegg_bar", "kegg_dot") + } == { + "go_bar": 1, + "go_dot": 1, + "go_ontology": 1, + "kegg_bar": 1, + "kegg_dot": 1, + } + assert {(row["comparison"], row["pathway_id"]) for row in pathway_rows} == { + ("IR29__6_vs_IR29__0", "ko00073"), + ("IR29__6_vs_IR29__0", "ko00196"), + } + assert all(row["candidate_families"] == [] for row in inventory) + assert all(row["support_labels"] == [] for row in inventory) + assert all( + row["selection_reason"] == "representative_fallback" + for row in inventory + if row["category"] + ) + assert "候选家族驱动筛选不可用" in result.report_path.read_text(encoding="utf-8") + + +def test_candidate_guided_selection_supports_combined_rnaseq_enrich_stage(tmp_path: Path) -> None: + workspace = tmp_path / "job_candidate_combined" + ids = _write_candidate_guided_enrichment_manifests(workspace, combined=True) + + result = generate_rnaseq_report( + workspace, + ids["prefix"], + rnaseq_scope_id=ids["scope"], + expected_stages=["rnaseq_enrich", "rnaseq_fgsea", "rnaseq_family_score"], + use_model=False, + ) + formal = json.loads(result.evidence_path.read_text(encoding="utf-8"))["formal_report"] + enrichment_rows = [row for row in formal["figure_inventory"] if row["category"]] + + assert formal["selection_mode"] == "candidate_guided" + assert {row["category"] for row in enrichment_rows} == { + "go_bar", + "go_dot", + "go_ontology", + "kegg_bar", + "kegg_dot", + "kegg_pathway", + "fgsea_dot", + "fgsea_curve", + } + assert all(row["comparison"] == ids["candidate_comparison"] for row in enrichment_rows) + assert { + row["pathway_id"] + for row in enrichment_rows + if row["category"] == "kegg_pathway" + } == {ids["supported_pathway_a"], ids["supported_pathway_b"]} + + +@pytest.mark.parametrize( + "support_labels", + ["", "KEGG:ko99998:pathway absent from all selected comparison outputs"], + ids=["empty-support-labels", "unmatched-support-labels"], +) +def test_analysis_ready_rows_without_matchable_support_use_representative_fallback( + tmp_path: Path, + support_labels: str, +) -> None: + workspace = tmp_path / "job_analysis_ready_without_matchable_support" + ids = _write_candidate_guided_enrichment_manifests(workspace) + _write_analysis_ready_family_manifest( + workspace, + prefix=ids["prefix"], + scope_id=ids["scope"], + rows=[ + ( + "FAM_WITHOUT_MATCHABLE_EVIDENCE", + "family without usable evidence", + ids["candidate_comparison"], + support_labels, + ) + ], + ) + + result = generate_rnaseq_report( + workspace, + ids["prefix"], + rnaseq_scope_id=ids["scope"], + expected_stages=[ + "rnaseq_go_enrich", + "rnaseq_kegg_enrich", + "rnaseq_fgsea", + "rnaseq_family_score", + ], + use_model=False, + ) + formal = json.loads(result.evidence_path.read_text(encoding="utf-8"))["formal_report"] + + assert formal["selection_mode"] == "representative_fallback" + assert all(row["candidate_families"] == [] for row in formal["figure_inventory"]) + markdown = result.report_path.read_text(encoding="utf-8") + assert "候选家族驱动筛选不可用" in markdown + assert "candidate_guided" not in markdown + + +def test_unmatched_fgsea_only_support_uses_fallback_and_does_not_mark_dot_candidate( + tmp_path: Path, +) -> None: + workspace = tmp_path / "job_unmatched_fgsea_only" + ids = _write_candidate_guided_enrichment_manifests(workspace) + _write_analysis_ready_family_manifest( + workspace, + prefix=ids["prefix"], + scope_id=ids["scope"], + rows=[ + ( + "FAM_FGSEA_PATHWAY_ABSENT", + "family with absent fgsea pathway", + ids["candidate_comparison"], + "fgsea:ko99998:pathway absent from comparison fgsea table", + ) + ], + ) + + result = generate_rnaseq_report( + workspace, + ids["prefix"], + rnaseq_scope_id=ids["scope"], + expected_stages=[ + "rnaseq_go_enrich", + "rnaseq_kegg_enrich", + "rnaseq_fgsea", + "rnaseq_family_score", + ], + use_model=False, + ) + formal = json.loads(result.evidence_path.read_text(encoding="utf-8"))["formal_report"] + fgsea_dots = [ + row for row in formal["figure_inventory"] if row["category"] == "fgsea_dot" + ] + + assert formal["selection_mode"] == "representative_fallback" + assert len(fgsea_dots) == 1 + assert fgsea_dots[0]["candidate_families"] == [] + assert fgsea_dots[0]["support_labels"] == [] + assert fgsea_dots[0]["selection_reason"] == "representative_fallback" + + +def test_candidate_mode_omits_fgsea_dot_when_only_fgsea_label_is_unmatched( + tmp_path: Path, +) -> None: + workspace = tmp_path / "job_go_candidate_with_unmatched_fgsea" + ids = _write_candidate_guided_enrichment_manifests(workspace) + _write_analysis_ready_family_manifest( + workspace, + prefix=ids["prefix"], + scope_id=ids["scope"], + rows=[ + ( + "FAM_GO_ONLY_USABLE", + "family with usable go only", + ids["candidate_comparison"], + "GO:GO:9876543:random candidate function;" + "fgsea:ko99998:pathway absent from comparison fgsea table", + ) + ], + ) + + result = generate_rnaseq_report( + workspace, + ids["prefix"], + rnaseq_scope_id=ids["scope"], + expected_stages=[ + "rnaseq_go_enrich", + "rnaseq_kegg_enrich", + "rnaseq_fgsea", + "rnaseq_family_score", + ], + use_model=False, + ) + formal = json.loads(result.evidence_path.read_text(encoding="utf-8"))["formal_report"] + selected = formal["figure_inventory"] + omitted = formal["omitted_figure_inventory"] + + assert formal["selection_mode"] == "candidate_guided" + assert any( + row["category"] == "go_bar" + and row["candidate_families"] == ["FAM_GO_ONLY_USABLE"] + for row in selected + ) + assert not any(row["category"] == "fgsea_dot" for row in selected) + assert any( + row["category"] == "fgsea_dot" + and row["comparison"] == ids["candidate_comparison"] + for row in omitted + ) + + +@pytest.mark.parametrize( + ("query", "action"), + [ + ("生成rnaseq结果报告", "generate"), + ("生成 RNA-seq 结果报告", "generate"), + ("打开rnaseq结果报告", "open"), + ("生成转录组结果报告", "generate"), + ], +) +def test_explicit_rnaseq_report_intent_is_provider_independent(query: str, action: str) -> None: + parsed = parse_intent_from_query(query) + fast = _fast_intent_recognition(query) + + assert parsed["stages"] == ["rnaseq_report"] + assert parsed["params"]["rnaseqReportAction"] == action + assert fast is not None + assert fast["stages"] == ["rnaseq_report"] + assert fast["params"]["rnaseqReportAction"] == action + + +def test_generic_report_without_rnaseq_context_is_not_stolen() -> None: + parsed = parse_intent_from_query("生成结果报告") + assert parsed["stages"] != ["rnaseq_report"] + + +def test_explicit_report_chat_intent_does_not_require_router_model() -> None: + assert hasattr(intent_parser_module, "deterministic_agent_chat_intent") + parser = intent_parser_module.deterministic_agent_chat_intent + + opened = parser("打开rnaseq结果报告") + generated = parser("生成 RNA-seq 结果报告") + + assert opened["stages"] == ["rnaseq_report"] + assert opened["params"]["rnaseqReportAction"] == "open" + assert generated["params"]["rnaseqReportAction"] == "generate" + assert parser("解释一下差异表达结果") is None + + +def test_generic_report_chat_intent_requires_rnaseq_job_context() -> None: + parser = intent_parser_module.deterministic_agent_chat_intent + + assert parser("生成结果报告") is None + generated = parser("生成结果报告", rnaseq_context=True) + opened = parser("打开结果报告", rnaseq_context=True) + + assert generated["stages"] == ["rnaseq_report"] + assert generated["params"]["rnaseqReportAction"] == "generate" + assert opened["params"]["rnaseqReportAction"] == "open" diff --git a/tests/unit/test_router_analysis_handoff_boundary.py b/tests/unit/test_router_analysis_handoff_boundary.py index 8ac77dc89..1a3b6d55c 100644 --- a/tests/unit/test_router_analysis_handoff_boundary.py +++ b/tests/unit/test_router_analysis_handoff_boundary.py @@ -19,6 +19,7 @@ class _PassThroughTools: *, workflow_id: str | None = None, guidance: bool = False, + language: str = "zh", ) -> str: return query @@ -30,6 +31,7 @@ class _ContextEnrichingTools: *, workflow_id: str | None = None, guidance: bool = False, + language: str = "zh", ) -> str: return ( f"{query}\n已确认上下文:当前 job 的 subfamily_confirm 已成功完成," @@ -150,3 +152,23 @@ def test_new_analysis_decision_discards_main_agent_workflow_and_clarification() assert normalized.workflow_id is None assert normalized.clarification_question is None assert normalized.analysis_request_kind == "execution" + + +def test_router_response_language_does_not_rewrite_business_query() -> None: + query = "执行接下来的分析" + messages = RouterRuntime._initial_messages( + query=query, + history=[], + session_id="english-router-session", + job_id="job-english-router", + decision=RouteDecision( + goal="general", + needs=[], + subject=query, + ), + goal_ledger=None, + language="en", + ) + + assert messages[-1].content == query + assert "Respond to the user in English." in messages[0].content diff --git a/tests/unit/test_stage_requirements_context_efficiency.py b/tests/unit/test_stage_requirements_context_efficiency.py new file mode 100644 index 000000000..f8d46c933 --- /dev/null +++ b/tests/unit/test_stage_requirements_context_efficiency.py @@ -0,0 +1,107 @@ +from types import SimpleNamespace + +from gfa_core.mas import context_index, stage_requirements +from gfa_core.mas.context_index import ProjectContextIndex +from gfa_core.mas.stage_requirements import ( + resolve_many_stage_requirements, + resolve_stage_requirements, +) + + +def test_stage_requirements_does_not_load_unconsumed_global_stage_status( + tmp_path, + monkeypatch, +): + calls = [] + + def fake_get_stage_status(**kwargs): + calls.append(kwargs["stage"]) + return SimpleNamespace(status="missing", manifest_path="", to_dict=lambda: {}) + + monkeypatch.setattr(context_index, "get_stage_status", fake_get_stage_status) + + report = resolve_stage_requirements( + stage="identify", + prefix="Ft.SPL", + job_id="job-test", + project_root=tmp_path, + ) + + assert report.stage == "identify" + assert calls == [] + + +def _empty_context(tmp_path, job_id): + workspace = tmp_path / "workspace" / job_id + return ProjectContextIndex( + job_id=job_id, + workspace=str(workspace), + input_dir=str(tmp_path / "input" / job_id), + final_dir=str(workspace / "final"), + logs_dir=str(workspace / "logs"), + ) + + +def test_many_stage_requirements_reuses_context_without_mutating_shared_identity( + tmp_path, + monkeypatch, +): + calls = [] + base_context = _empty_context(tmp_path, "job-test") + + def fake_build_project_context(**kwargs): + calls.append(kwargs["prefix"]) + return base_context + + monkeypatch.setattr( + stage_requirements, + "build_project_context", + fake_build_project_context, + ) + + reports = resolve_many_stage_requirements( + stages=["identify", "phylogeny"], + prefix="Ft.SPL", + job_id="job-test", + params_by_stage={ + "identify": {"targetSpecies": ["Ft"]}, + "phylogeny": {"targetSpecies": ["Os"]}, + }, + project_root=tmp_path, + ) + + assert calls == ["Ft.SPL"] + assert base_context.target_species == [] + assert reports["identify"]["identity"]["target_species"] == ["Ft"] + assert reports["phylogeny"]["identity"]["target_species"] == ["Os"] + + +def test_many_stage_requirements_caches_context_per_effective_prefix( + tmp_path, + monkeypatch, +): + calls = [] + + def fake_build_project_context(**kwargs): + calls.append(kwargs["prefix"]) + return _empty_context(tmp_path, kwargs["job_id"]) + + monkeypatch.setattr( + stage_requirements, + "build_project_context", + fake_build_project_context, + ) + + resolve_many_stage_requirements( + stages=["identify", "phylogeny", "motif"], + prefix="Ft.SPL", + job_id="job-test", + params_by_stage={ + "identify": {"prefix": "Ft.SPL"}, + "phylogeny": {"prefix": "Ft.SPL"}, + "motif": {"prefix": "At.SPL"}, + }, + project_root=tmp_path, + ) + + assert calls == ["Ft.SPL", "At.SPL"]