Merge remote-tracking branch 'origin/master' into fix/ppi-kaks-enable

# Conflicts:
#	tests/unit/test_router_analysis_handoff_boundary.py
This commit is contained in:
potaser
2026-08-10 23:24:47 +08:00
68 changed files with 41004 additions and 668 deletions
+2 -1
View File
@@ -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
+70
View File
@@ -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()
+59 -3
View File
@@ -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()
+416
View File
@@ -0,0 +1,416 @@
<!-- Agent状态显示组件 - 可嵌入index.html -->
<template id="agent-status-component">
<div class="agent-status-panel">
<div class="agent-header">
<h3>{{ t('agent-assistant') }}</h3>
<span class="agent-mode">{{ currentMode }}</span>
</div>
<div class="agent-messages" ref="messageContainer">
<div v-for="(msg, index) in messages" :key="index"
:class="['message', msg.role]">
<div class="message-role">{{ getRoleLabel(msg.role) }}</div>
<div class="message-content" v-html="formatMessage(msg.content)"></div>
<div class="message-time">{{ formatTime(msg.timestamp) }}</div>
</div>
</div>
<div class="agent-input">
<textarea v-model="userInput"
:placeholder="t('enter-question')"
@keydown.enter.prevent="sendMessage"
rows="3"></textarea>
<button @click="sendMessage" :disabled="isProcessing">
{{ isProcessing ? t('processing') : t('send') }}
</button>
</div>
<div class="agent-tasks" v-if="tasks.length > 0">
<h4>{{ t('task-list') }}</h4>
<div v-for="task in tasks" :key="task.task_id"
:class="['task-item', task.status]"
@click="selectTask(task)">
<span class="task-status">{{ getStatusIcon(task.status) }}</span>
<span class="task-message">{{ task.message }}</span>
<span class="task-progress">{{ task.progress }}%</span>
</div>
</div>
</div>
</template>
<script src="/static/i18n.js"></script>
// 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, '<strong>$1</strong>')
.replace(/\n/g, '<br>');
},
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;
}
}
};
</script>
<style>
.agent-status-panel {
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
background: #f9f9f9;
height: 100%;
display: flex;
flex-direction: column;
}
.agent-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #eee;
}
.agent-header h3 {
margin: 0;
font-size: 16px;
}
.agent-mode {
font-size: 12px;
color: #666;
background: #e0e0e0;
padding: 2px 8px;
border-radius: 4px;
}
.agent-messages {
flex: 1;
overflow-y: auto;
max-height: 400px;
margin-bottom: 12px;
}
.message {
margin-bottom: 12px;
padding: 8px 12px;
border-radius: 8px;
background: white;
border-left: 3px solid #ddd;
}
.message.user {
border-left-color: #4CAF50;
background: #E8F5E9;
}
.message.assistant {
border-left-color: #2196F3;
background: #E3F2FD;
}
.message.error {
border-left-color: #f44336;
background: #FFEBEE;
}
.message.system {
border-left-color: #9E9E9E;
background: #F5F5F5;
font-size: 12px;
}
.message-role {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.message-content {
font-size: 14px;
line-height: 1.5;
}
.message-time {
font-size: 11px;
color: #999;
text-align: right;
margin-top: 4px;
}
.agent-input {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.agent-input textarea {
flex: 1;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
resize: none;
font-family: inherit;
}
.agent-input button {
padding: 8px 16px;
background: #2196F3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.agent-input button:disabled {
background: #ccc;
cursor: not-allowed;
}
.agent-tasks {
border-top: 1px solid #eee;
padding-top: 12px;
}
.agent-tasks h4 {
margin: 0 0 8px 0;
font-size: 14px;
}
.task-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
margin-bottom: 4px;
background: white;
}
.task-item:hover {
background: #f0f0f0;
}
.task-item.pending {
border-left: 3px solid #FFC107;
}
.task-item.running {
border-left: 3px solid #2196F3;
}
.task-item.completed {
border-left: 3px solid #4CAF50;
}
.task-item.failed {
border-left: 3px solid #f44336;
}
.task-status {
font-size: 14px;
}
.task-message {
flex: 1;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-progress {
font-size: 11px;
color: #666;
}
</style>
+391
View File
@@ -0,0 +1,391 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<script src="/static/i18n.js"></script>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BotanicalBaseAgent</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/element-plus@2.3.14/dist/index.css">
<script src="https://cdn.jsdelivr.net/npm/vue@3.3.4/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/element-plus@2.3.14/dist/index.full.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@element-plus/icons-vue@2.1.0/dist/index.iife.js"></script>
<link rel="stylesheet" href="conversation-sidebar.css">
<style>
* { box-sizing: border-box; }
body {
margin: 0;
font-family: 'Helvetica Neue', Arial, sans-serif;
background: #f5f7fa;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 15px 20px;
}
.header h1 { margin: 0; font-size: 22px; }
.main-container {
display: flex;
height: calc(100vh - 60px);
overflow: hidden;
}
/* 左侧控制面板 */
.left-panel {
width: 280px;
background: white;
border-right: 1px solid #e4e7ed;
display: flex;
flex-direction: column;
padding: 15px;
gap: 15px;
overflow-y: auto;
}
/* 中间内容区 */
.center-panel {
flex: 1;
display: flex;
flex-direction: column;
padding: 15px;
gap: 15px;
overflow: hidden;
}
/* 右侧结果区 */
.right-panel {
width: 350px;
background: white;
border-left: 1px solid #e4e7ed;
padding: 15px;
overflow-y: auto;
}
.panel-section {
background: white;
border-radius: 8px;
padding: 15px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.panel-title {
font-size: 14px;
font-weight: bold;
color: #409EFF;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 2px solid #409EFF;
}
.chat-area {
flex: 1;
background: white;
border-radius: 8px;
padding: 15px;
overflow-y: auto;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.chat-message {
margin-bottom: 15px;
padding: 12px 16px;
border-radius: 8px;
max-width: 80%;
}
.chat-message.user {
background: #ecf5ff;
margin-left: auto;
text-align: right;
}
.chat-message.assistant {
background: #f0f9eb;
}
.input-area {
display: flex;
gap: 10px;
}
.stage-tag {
display: inline-block;
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
margin: 4px;
}
.stage-tag.success { background: #67C23A; color: white; }
.stage-tag.running { background: #E6A23C; color: white; }
.stage-tag.pending { background: #909399; color: white; }
</style>
</head>
<body>
<div id="app">
<!-- Header -->
<div class="header">
<h1>{{ t('gf-analysis-system') }}</h1>
</div>
<div class="main-container">
<!-- 左侧:会话侧边栏 + 控制面板 -->
<div class="left-panel">
<!-- 会话侧边栏 -->
<div class="conversation-sidebar" style="width: 100%; margin: -15px; margin-bottom: 0;">
<div class="sidebar-header">
<button class="new-chat-btn" @click="createNewConversation" style="flex: 1;">
<el-icon><plus /></el-icon>
<span>New Chat</span>
</button>
</div>
<div class="search-box" style="padding: 12px;">
<el-input
v-model="searchQuery"
:placeholder="t('search-conv')"
prefix-icon="search"
clearable
size="small">
</el-input>
</div>
<div class="conversation-list" style="max-height: 300px;">
<div class="list-section">
<div class="section-title">{{ t('conv-list') }}</div>
<div v-for="conv in filteredConversations"
:key="conv.id"
class="conversation-item"
:class="{ active: currentConv?.id === conv.id }"
@click="selectConversation(conv)">
<div class="item-icon">
<el-icon><chat-dot-round /></el-icon>
</div>
<div class="item-content">
<div class="item-title">{{ conv.title }}</div>
<div class="item-meta">
<span class="gene-family" v-if="conv.gene_family">{{ conv.gene_family }}</span>
<span class="message-count">{{ conv.message_count }}{{ t('msg-count') }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 分析控制 -->
<div class="panel-section">
<div class="panel-title">{{ t('analysis-stage') }}</div>
<el-select v-model="selectedStage" :placeholder="t('select-stage')" style="width: 100%;">
<el-option :label="t('identify-label')" value="identify"></el-option>
<el-option :label="t('phylogeny-label')" value="phylogeny"></el-option>
<el-option :label="t('motif-label')" value="motif"></el-option>
<el-option :label="t('domain-label')" value="domain"></el-option>
<el-option :label="t('expression-label')" value="expression"></el-option>
</el-select>
<el-button type="primary" style="width: 100%; margin-top: 10px;" @click="executeAnalysis">
{{ t('execute-analysis') }}
</el-button>
</div>
<!-- 参数设置 -->
<div class="panel-section">
<div class="panel-title">{{ t('param-settings') }}</div>
<el-form :model="params" label-width="80px" size="small">
<el-form-item :label="t('prefix')">
<el-input v-model="params.prefix" :placeholder="t('prefix-placeholder')"></el-input>
</el-form-item>
<el-form-item label="CPU">
<el-input-number v-model="params.cpu" :min="1" :max="32" style="width: 100%;"></el-input-number>
</el-form-item>
</el-form>
</div>
</div>
<!-- 中间:对话区 -->
<div class="center-panel">
<div class="chat-area">
<div v-for="(msg, index) in messages" :key="index"
class="chat-message" :class="msg.role">
<div style="font-size: 12px; color: #666; margin-bottom: 4px;">
{{ msg.role === 'user' ? t('user') : t('ai-assistant') }} · {{ msg.time }}
</div>
<div>{{ msg.content }}</div>
</div>
<div v-if="messages.length === 0" style="text-align: center; color: #999; padding: 40px;">
<el-icon :size="48" style="display: block; margin-bottom: 10px;"><chat-line-round /></el-icon>
<p>{{ t('start-new-chat') }}</p>
<p style="font-size: 12px;">{{ t('or-continue') }}</p>
</div>
</div>
<div class="input-area">
<el-input
v-model="userInput"
type="textarea"
:rows="3"
:placeholder="t('describe-need')"
@keyup.enter.ctrl="sendMessage">
</el-input>
<el-button type="primary" size="large" @click="sendMessage" :disabled="!userInput.trim()">
{{ t('send') }}
</el-button>
</div>
</div>
<!-- 右侧:结果展示 -->
<div class="right-panel">
<div class="panel-title">📊 Analysis Results</div>
<div v-for="stage in stages" :key="stage.name" style="margin-bottom: 15px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<span style="font-weight: 500;">{{ stage.label }}</span>
<span class="stage-tag" :class="stage.status">{{ stage.statusText }}</span>
</div>
<el-progress :percentage="stage.progress" :status="stage.status"></el-progress>
</div>
<el-divider></el-divider>
<div style="text-align: center; color: #999; padding: 20px;">
<el-icon :size="48" style="display: block; margin-bottom: 10px;"><picture /></el-icon>
<p>{{ t('results-here') }}</p>
</div>
</div>
</div>
</div>
<script>
const { createApp, ref, reactive, computed } = Vue;
const app = createApp({
setup() {
const t = window.t;
document.title = 'BotanicalBaseAgent - ' + t('gf-analysis');
// 会话管理
const conversations = ref([
{ id: 1, title: 'Ft.SPL基因家族分析', gene_family: 'SPL', message_count: 12 },
{ id: 2, title: 'Os.TCP进化分析', gene_family: 'TCP', message_count: 5 },
{ id: 3, title: 'HMMER参数配置咨询', gene_family: null, message_count: 8 },
]);
const currentConv = ref(null);
const searchQuery = ref('');
const filteredConversations = computed(() => {
if (!searchQuery.value) return conversations.value;
const query = searchQuery.value.toLowerCase();
return conversations.value.filter(conv =>
conv.title.toLowerCase().includes(query) ||
(conv.gene_family && conv.gene_family.toLowerCase().includes(query))
);
});
const createNewConversation = () => {
const newConv = {
id: Date.now(),
title: t('new-chat') + ' ' + new Date().toLocaleTimeString(),
gene_family: null,
message_count: 0
};
conversations.value.unshift(newConv);
selectConversation(newConv);
};
const selectConversation = (conv) => {
currentConv.value = conv;
// 清空消息并添加欢迎语
messages.value = [{
role: 'assistant',
content: t('initial-welcome').replace('{title}', conv.title),
time: new Date().toLocaleTimeString()
}];
};
// 分析参数
const selectedStage = ref('');
const params = reactive({
prefix: 'Ft.SPL',
cpu: 8
});
// 消息
const messages = ref([]);
const userInput = ref('');
const sendMessage = () => {
if (!userInput.value.trim()) return;
// 添加用户消息
messages.value.push({
role: 'user',
content: userInput.value,
time: new Date().toLocaleTimeString()
});
// 更新当前会话消息数
if (currentConv.value) {
currentConv.value.message_count++;
}
// 模拟AI回复
setTimeout(() => {
messages.value.push({
role: 'assistant',
content: `Received: "${userInput.value}". I will help analyze the gene family. Confirm parameters and click "${t('execute-analysis')}".`,
time: new Date().toLocaleTimeString()
});
if (currentConv.value) {
currentConv.value.message_count++;
}
}, 500);
userInput.value = '';
};
// 分析阶段
const stages = ref([
{ name: 'identify', label: 'Identify', status: 'success', statusText: t('completed'), progress: 100 },
{ name: 'phylogeny', label: 'Phylogeny', status: 'running', statusText: t('running'), progress: 60 },
{ name: 'motif', label: 'Motif', status: 'pending', statusText: t('pending'), progress: 0 },
{ name: 'domain', label: 'Domain', status: 'pending', statusText: t('pending'), progress: 0 },
]);
const executeAnalysis = () => {
if (!selectedStage.value) {
ElementPlus.ElMessage.warning(t('msg-select-stage-first'));
return;
}
ElementPlus.ElMessage.success(t('msg-executing') + selectedStage.value + t('msg-analysis-text'));
};
return {
t,
conversations,
currentConv,
searchQuery,
filteredConversations,
createNewConversation,
selectConversation,
selectedStage,
params,
messages,
userInput,
sendMessage,
stages,
executeAnalysis
};
}
});
app.use(ElementPlus);
// 注册 i18n 翻译函数到 Vue 模板作用域
app.config.globalProperties.t = window.t || ((key) => key);
// 注册图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
}
app.mount('#app');
</script>
</body>
</html>
+193 -13
View File
@@ -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 @@
<el-divider style="margin: 12px 0;"></el-divider>
<div v-if="false" style="padding:10px; background:#fff; border:1px solid #dcdfe6; border-radius:0px;">
<div @dragover.prevent @drop.prevent="handleLightweightExtensionFolderDrop($event)"
style="padding:10px; background:#fff; border:1px dashed #409EFF; border-radius:0px;">
<div style="display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:8px;">
<el-tooltip content="上传已跑通过的代码和测试数据;执行通过自然语言触发。" placement="top">
<h4 style="margin:0; color:#303133; cursor:help;">拓展分析阶段</h4>
@@ -1413,10 +1414,13 @@
<span>名称</span><el-input v-model="extensionBuild.displayName" size="small" placeholder="例如 HMMER hmmsearch"></el-input>
<span>原始命令</span><el-input v-model="extensionBuild.command" size="small" placeholder="例如 bash run.sh input.fa"></el-input>
<span>环境</span>
<el-select v-model="extensionBuild.runtimeEnv" size="small">
<el-option label="test4py311" value="test4py311"></el-option>
<el-option label="rnaseq" value="rnaseq"></el-option>
<el-option label="rnaR" value="rnaR"></el-option>
<el-select v-model="extensionBuild.runtimeEnv" size="small" filterable>
<el-option
v-for="env in extensionEnvOptions"
:key="env"
:label="env"
:value="env">
</el-option>
</el-select>
<span>原始代码</span><input type="file" multiple @change="onExtensionCodeFilesChange" style="font-size:12px; min-width:0;">
<span v-if="extensionBuild.codeFiles.length"></span>
@@ -1801,6 +1805,20 @@
📂 Select
</el-button>
</div>
<!-- lightWeightExtensionTest:start -->
<div id="lightweight-extension-test-drop-zone"
style="display:none;margin-top:8px;border:1px dashed #409EFF;padding:10px;font-size:12px;color:#606266;background:#f8fbff;"
@dragover.prevent
@drop.prevent="handleLightweightExtensionFolderDrop($event)">
<div style="font-weight:600;color:#303133;">拖入固定结构拓展包</div>
<div style="margin-top:4px;line-height:1.5;">要求包含 raw_code/run.sh 与 raw_test_data/,系统会自动创建 Job、解析环境并开始 lightweight upload。</div>
<input id="lightweight-extension-test-folder-input" type="file" webkitdirectory directory multiple style="display:none" @change="handleLightweightExtensionFolderPick($event)">
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;margin-top:8px;">
<span>{{ lightweightExtensionTestStatus }}</span>
<el-button size="small" type="success" @click="openLightweightExtensionFolderPicker">选择文件夹</el-button>
</div>
</div>
<!-- lightWeightExtensionTest:end -->
</div>
</div>
@@ -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');
</script>
<!-- lightWeightExtensionTest:wiring:start -->
<script src="/lightWeightExtensionTest.js"></script>
<!-- lightWeightExtensionTest:wiring:end -->
<!-- 深度解读弹窗样式 -->
<style>
.interpretation-dialog .el-dialog__body { padding: 0; max-height: 70vh; overflow-y: auto; }
File diff suppressed because it is too large Load Diff
+712
View File
@@ -0,0 +1,712 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Knowledge Graph</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/element-plus@2.7.1/dist/index.css">
<script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/element-plus@2.7.1/dist/index.full.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@element-plus/icons-vue"></script>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<script src="/static/i18n.js"></script>
<style>
body { margin: 0; font-family: 'Helvetica Neue', Arial, sans-serif; background: #f5f7fa; }
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 15px 20px; display: flex; align-items: center; justify-content: space-between; }
.header h1 { margin: 0; font-size: 22px; }
.header .subtitle { font-size: 12px; opacity: 0.8; margin-top: 4px; }
.main-container { display: flex; padding: 15px; gap: 15px; height: calc(100vh - 70px); }
.left-panel { width: 300px; display: flex; flex-direction: column; gap: 10px; }
.control-section { background: white; border-radius: 8px; padding: 15px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.control-section h4 { margin: 0 0 10px 0; color: #409EFF; font-size: 14px; border-bottom: 2px solid #409EFF; padding-bottom: 8px; }
.stat-item { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #eee; }
.stat-item:last-child { border-bottom: none; }
.stat-label { color: #666; }
.stat-value { font-weight: bold; color: #409EFF; }
.filter-section { margin-top: 10px; }
.filter-tag { margin: 4px; cursor: pointer; }
.filter-tag.active { background: #409EFF; color: white; }
.main-panel { flex: 1; display: flex; flex-direction: column; background: white; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); overflow: hidden; }
.toolbar { padding: 10px 15px; border-bottom: 1px solid #eee; display: flex; align-items: center; gap: 10px; }
#graph-container { flex: 1; position: relative; }
.right-panel { width: 350px; background: white; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); padding: 15px; overflow-y: auto; }
.right-panel h4 { margin: 0 0 10px 0; color: #67C23A; border-bottom: 2px solid #67C23A; padding-bottom: 8px; }
.node-detail { padding: 10px; background: #f5f7fa; border-radius: 6px; margin-top: 10px; }
.node-detail .label { color: #666; font-size: 12px; margin-bottom: 4px; }
.node-detail .value { font-weight: 500; color: #333; }
.relation-list { margin-top: 10px; }
.relation-item { padding: 8px; background: #f0f9eb; border-radius: 4px; margin-bottom: 8px; font-size: 13px; }
.relation-item .source { color: #409EFF; font-weight: 500; }
.relation-item .rel { color: #67C23A; margin: 0 4px; }
.relation-item .target { color: #E6A23C; font-weight: 500; }
.search-box { margin-bottom: 10px; }
.legend { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.legend-item { display: flex; align-items: center; gap: 4px; font-size: 12px; }
.legend-color { width: 12px; height: 12px; border-radius: 50%; }
.legend-symbol { width: 12px; height: 12px; border-radius: 2px; }
.legend-line { width: 20px; height: 2px; }
/* PC语义分类样式 */
.pc-category-header {
font-size: 11px;
color: #666;
font-weight: bold;
margin: 8px 0 4px 0;
padding-left: 4px;
border-left: 3px solid #409EFF;
}
</style>
</head>
<body>
<div id="app">
<div class="header">
<div>
<h1>{{ t('kg-visualization') }}</h1>
<div class="subtitle">{{ t('kg-subtitle') }}</div>
</div>
<el-button type="primary" @click="goBack">
<el-icon><arrow-left /></el-icon> {{ t('back-to-main') }}
</el-button>
</div>
<div class="main-container">
<!-- 左侧面板 -->
<div class="left-panel">
<div class="control-section">
<h4>{{ t('kg-stats') }}</h4>
<div class="stat-item">
<span class="stat-label">{{ t('node-count') }}</span>
<span class="stat-value">{{ stats.nodes }}</span>
</div>
<div class="stat-item">
<span class="stat-label">{{ t('edge-count') }}</span>
<span class="stat-value">{{ stats.edges }}</span>
</div>
<div class="stat-item">
<span class="stat-label">{{ t('node-types') }}</span>
<span class="stat-value">{{ stats.nodeTypes }}</span>
</div>
<div class="stat-item">
<span class="stat-label">{{ t('edge-types') }}</span>
<span class="stat-value">{{ stats.edgeTypes }}</span>
</div>
</div>
<div class="control-section">
<h4>{{ t('node-search') }}</h4>
<el-input
v-model="searchQuery"
:placeholder="t('enter-gene-name')"
clearable
@keyup.enter="searchNode"
>
<template #append>
<el-button @click="searchNode">
<el-icon><search /></el-icon>
</el-button>
</template>
</el-input>
</div>
<div class="control-section">
<h4>{{ t('relation-filter-pc') }}</h4>
<div class="filter-section">
<div v-for="(types, category) in relationTypesByCategory" :key="category" style="margin-bottom: 10px;">
<div style="font-size: 12px; color: #666; margin-bottom: 5px; font-weight: bold;">
{{ 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') }}
</div>
<el-tag
v-for="type in types"
:key="type"
class="filter-tag"
:class="{ active: selectedRelations.includes(type) }"
@click="toggleRelationFilter(type)"
size="small"
:style="{ borderColor: relationEdgeStyles[category].color, color: selectedRelations.includes(type) ? '#fff' : relationEdgeStyles[category].color }"
>
{{ type }} ({{ relationCounts[type] }})
</el-tag>
</div>
</div>
</div>
<div class="control-section">
<h4>{{ t('node-legend-pc') }}</h4>
<div class="pc-category-header">Gene/Protein</div>
<div class="legend">
<div class="legend-item" v-for="type in ['gene', 'gene_id', 'gene_group', 'gene_family']" :key="type">
<div class="legend-symbol" :style="{ background: nodeColors[type] }"></div>
<span>{{ nodeTypeLabels[type] }}</span>
</div>
</div>
<div class="pc-category-header">Phenotype</div>
<div class="legend">
<div class="legend-item" v-for="type in ['phenotype', 'attribute']" :key="type">
<div class="legend-symbol" :style="{ background: nodeColors[type] }"></div>
<span>{{ nodeTypeLabels[type] }}</span>
</div>
</div>
<div class="pc-category-header">Cell/Organ/Organism</div>
<div class="legend">
<div class="legend-item" v-for="type in ['tissue', 'species']" :key="type">
<div class="legend-symbol" :style="{ background: nodeColors[type] }"></div>
<span>{{ nodeTypeLabels[type] }}</span>
</div>
</div>
<div class="pc-category-header">Biological Process</div>
<div class="legend">
<div class="legend-item" v-for="type in ['process', 'time', 'condition', 'stress']" :key="type">
<div class="legend-symbol" :style="{ background: nodeColors[type] }"></div>
<span>{{ nodeTypeLabels[type] }}</span>
</div>
</div>
<div class="pc-category-header">Genomic</div>
<div class="legend">
<div class="legend-item" v-for="type in ['location', 'sra_dataset', 'data_type', 'count']" :key="type">
<div class="legend-symbol" :style="{ background: nodeColors[type] }"></div>
<span>{{ nodeTypeLabels[type] }}</span>
</div>
</div>
<h4 style="margin-top: 15px;">🔗 {{ t('edge-legend-pc') }}</h4>
<div class="legend">
<div class="legend-item" v-for="(style, cat) in relationEdgeStyles" :key="cat">
<div class="legend-line" :style="{ background: style.color, height: style.width + 'px', borderTop: style.type === 'dashed' ? '2px dashed ' + style.color : 'none' }"></div>
<span>{{ 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' }}</span>
</div>
</div>
</div>
</div>
<!-- 主面板 -->
<div class="main-panel">
<div class="toolbar">
<el-button-group>
<el-button @click="fitGraph">
<el-icon><full-screen /></el-icon> {{ t('fit-view') }}
</el-button>
<el-button @click="resetFilters">
<el-icon><refresh /></el-icon> {{ t('reset-filters') }}
</el-button>
</el-button-group>
<el-divider direction="vertical"></el-divider>
<span style="color: #666; font-size: 13px;">
{{ t('click-drag-hint') }}
</span>
</div>
<div id="graph-container"></div>
</div>
<!-- 右侧面板 -->
<div class="right-panel">
<h4>{{ t('node-detail') }}</h4>
<div v-if="selectedNode" class="node-detail">
<div style="margin-bottom: 15px;">
<div class="label">{{ t('node-name') }}</div>
<div class="value" style="font-size: 18px; color: #409EFF;">{{ selectedNode.name }}</div>
</div>
<div style="margin-bottom: 15px;">
<div class="label">{{ t('node-type-pc') }}</div>
<div class="value">
<el-tag :type="getNodeTypeTag(selectedNode.category)" :style="{ backgroundColor: nodeColors[selectedNode.category] + '20', borderColor: nodeColors[selectedNode.category], color: nodeColors[selectedNode.category] }">
{{ nodeTypeLabels[selectedNode.category] || selectedNode.category }}
</el-tag>
</div>
</div>
<div>
<div class="label">{{ t('connection-count') }}</div>
<div class="value">{{ selectedNode.degree }}{{ t('num-connections') }}</div>
</div>
</div>
<div v-else style="color: #999; text-align: center; padding: 40px;">
{{ t('click-node-detail') }}
</div>
<template v-if="selectedNodeRelations.length > 0">
<h4 style="margin-top: 20px;">🔗 {{ t('related-relations') }} ({{ selectedNodeRelations.length }})</h4>
<div class="relation-list">
<div
v-for="rel in selectedNodeRelations.slice(0, 10)"
:key="rel.id"
class="relation-item"
:style="{ borderLeft: '3px solid ' + relationEdgeStyles[rel.category || relationCategories[rel.name] || 'other'].color }"
>
<span class="source">{{ rel.source }}</span>
<span class="rel" :style="{ color: relationEdgeStyles[rel.category || relationCategories[rel.name] || 'other'].color }">--{{ rel.name }}--></span>
<span class="target">{{ rel.target }}</span>
</div>
<div v-if="selectedNodeRelations.length > 10" style="text-align: center; color: #999; padding: 10px;">
{{ tp('more-relations', { count: selectedNodeRelations.length - 10 }) }}
</div>
</div>
</template>
</div>
</div>
</div>
<script>
const { createApp, ref, onMounted, computed } = Vue;
const { ElMessage } = ElementPlus;
createApp({
setup() {
const t = window.t || ((key) => key);
const tp = window.tp || ((key, params) => {
let s = t(key);
if (params) Object.keys(params).forEach(k => { s = s.replace('{' + k + '}', params[k]); });
return s;
});
document.title = t('kg-title');
const graphData = ref({ nodes: [], edges: [] });
const chart = ref(null);
const searchQuery = ref('');
const selectedNode = ref(null);
const selectedRelations = ref([]);
// PC (Pathway Commons) 语义分类颜色映射
const nodeColors = {
// Gene/Protein - 蓝色
'gene': '#5DADE2',
'gene_id': '#85C1E9',
'gene_group': '#AED6F1',
'gene_family': '#5DADE2',
// Phenotype - 绿色
'phenotype': '#58D68D',
'attribute': '#82E0AA',
// Cell/Organ/Organism - 粉色
'tissue': '#F5B7B1',
'species': '#F1948A',
// Process - 紫色菱形
'process': '#AF7AC5',
'time': '#C39BD3',
'condition': '#D7BDE2',
'stress': '#E8DAEF',
// Location - 浅绿色
'location': '#82E0AA',
// Data - 橙色(更显眼)
'sra_dataset': '#E67E22',
'data_type': '#F39C12',
'count': '#EAEDED',
'unknown': '#95A5A6'
,
'article': '#2ecc71',
'paper_title': '#9b59b6'
};
// PC 语义分类显示名称
const nodeTypeLabels = {
'gene': 'Gene/Protein',
'gene_id': 'Gene Identifier',
'gene_group': 'Gene Group',
'gene_family': 'Gene Family',
'phenotype': 'Phenotype',
'attribute': 'Attribute',
'tissue': 'Cell/Organ/Tissue',
'species': 'Organism',
'process': 'Biological Process',
'time': 'Time/Stage',
'condition': 'Condition',
'stress': 'Stress',
'location': 'Genomic Location',
'sra_dataset': 'SRA Dataset',
'geo_dataset': 'GEO Dataset',
'data_type': 'Data Type',
'count': 'Count/Statistic',
'article': 'Article (PMID)',
'paper_title': 'Paper Title',
'unknown': 'Other'
};
// PC 关系类型分类
const relationCategories = {
// Activation/Induction - 绿色箭头
'induces': 'activation',
'promote': 'activation',
'overexpression_increases': 'activation',
'enhanced_tolerance_to': 'activation',
// Repression/Inhibition - 红色T型
'down_regulated_during': 'repression',
'down_regulation_improves': 'repression',
// Regulation - 蓝色
'regulate': 'regulation',
'regulates': 'regulation',
'regulating': 'regulation',
// Expression - 橙色
'expressed_in': 'expression',
'expressed_during': 'expression',
'was_expressed_during': 'expression',
'expression_reached_peak_at': 'expression',
// Association - 紫色
'collinear_with': 'association',
'was_highly_orthologous_to': 'association',
'related_to': 'association',
'affect': 'association',
// Localization - 深绿
'located_on': 'localization',
'identified_in': 'localization',
// Containment - 青色
'contain': 'containment',
'had': 'containment',
'has_gene_id': 'containment',
'has_data_type': 'containment',
// Participation - 深红
'involved_in': 'participation',
'participate_in': 'participation',
'default': 'other'
};
const relationEdgeStyles = {
'activation': { color: '#27AE60', width: 2, type: 'solid' },
'repression': { color: '#E74C3C', width: 2, type: 'solid' },
'regulation': { color: '#3498DB', width: 2, type: 'solid' },
'expression': { color: '#E67E22', width: 1.5, type: 'solid' },
'association': { color: '#9B59B6', width: 1.5, type: 'dashed' },
'localization': { color: '#16A085', width: 1.5, type: 'solid' },
'containment': { color: '#1ABC9C', width: 1.5, type: 'solid' },
'participation': { color: '#C0392B', width: 2, type: 'solid' },
'other': { color: '#95A5A6', width: 1, type: 'solid' }
};
const stats = computed(() => ({
nodes: graphData.value.nodes.length,
edges: graphData.value.edges.length,
nodeTypes: Object.keys(nodeColors).length,
edgeTypes: relationTypes.value.length
}));
// 按 PC 语义分类的关系类型
const relationTypesByCategory = computed(() => {
const cats = {};
graphData.value.edges.forEach(e => {
const cat = relationCategories[e.relation] || 'other';
if (!cats[cat]) cats[cat] = [];
cats[cat].push(e.relation);
});
// 去重并排序
for (const cat in cats) {
cats[cat] = [...new Set(cats[cat])].sort();
}
return cats;
});
const relationTypes = computed(() => {
const types = new Set();
graphData.value.edges.forEach(e => types.add(e.relation));
return Array.from(types).sort();
});
const relationCounts = computed(() => {
const counts = {};
graphData.value.edges.forEach(e => {
counts[e.relation] = (counts[e.relation] || 0) + 1;
});
return counts;
});
const selectedNodeRelations = computed(() => {
if (!selectedNode.value) return [];
return graphData.value.edges.filter(e =>
e.source === selectedNode.value.id || e.target === selectedNode.value.id
);
});
const loadData = async () => {
try {
// 优先加载包含多组学数据的知识图谱
let response = await fetch('knowledge_graph_with_omics.json');
if (!response.ok) {
// 回退到标准知识图谱
response = await fetch('knowledge_graph.json');
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// 验证数据结构
if (!data.nodes || !Array.isArray(data.nodes)) {
throw new Error(t('err-data-format'));
}
if (!data.edges || !Array.isArray(data.edges)) {
console.warn(t('warn-edges-undefined'));
data.edges = [];
}
// 计算节点度数
const nodeDegrees = {};
data.edges.forEach(e => {
nodeDegrees[e.source] = (nodeDegrees[e.source] || 0) + 1;
nodeDegrees[e.target] = (nodeDegrees[e.target] || 0) + 1;
});
// 根据 PC 语义分类设置节点形状
const getNodeSymbol = (type) => {
const symbolMap = {
'gene': 'circle',
'gene_id': 'circle',
'gene_group': 'circle',
'gene_family': 'circle',
'phenotype': 'rect',
'attribute': 'rect',
'tissue': 'roundRect',
'species': 'roundRect',
'process': 'diamond',
'time': 'diamond',
'condition': 'diamond',
'stress': 'diamond',
'location': 'triangle',
'sra_dataset': 'pin',
'data_type': 'pin',
'count': 'pin'
};
return symbolMap[type] || 'circle';
};
graphData.value = {
nodes: data.nodes.map(n => ({
id: n.id,
name: n.id,
category: n.type || 'unknown',
degree: nodeDegrees[n.id] || 0,
symbol: getNodeSymbol(n.type),
symbolSize: (n.type === 'gene' ? 20 : n.type === 'sra_dataset' ? 18 : 12) + Math.min((nodeDegrees[n.id] || 0) * 0.3, 15),
itemStyle: {
color: nodeColors[n.type] || nodeColors.unknown,
borderColor: '#fff',
borderWidth: 1
}
})),
edges: data.edges.map((e, i) => {
const cat = relationCategories[e.relation] || 'other';
const style = relationEdgeStyles[cat];
return {
id: i,
source: e.source,
target: e.target,
name: e.relation,
relation: e.relation,
category: cat,
lineStyle: {
color: style.color,
width: style.width,
type: style.type,
curveness: 0.1
}
};
})
};
initChart();
ElMessage.success(t('msg-kg-loaded'));
} catch (error) {
ElMessage.error(t('msg-load-data-failed') + error.message);
}
};
const initChart = () => {
const container = document.getElementById('graph-container');
chart.value = echarts.init(container);
const option = {
tooltip: {
formatter: params => {
if (params.dataType === 'node') {
const pcType = nodeTypeLabels[params.data.category] || params.data.category;
return `<b>${params.name}</b><br/>PC: ${pcType}<br/>${t('connection-count').replace('Connections', 'Deg')}: ${params.data.degree}`;
}
const cat = params.data.category || relationCategories[params.name] || 'other';
const catName = cat === 'activation' ? 'Activation' :
cat === 'repression' ? 'Repression' :
cat === 'regulation' ? 'Regulation' :
cat === 'expression' ? 'Expression' :
cat === 'association' ? 'Association' :
cat === 'localization' ? 'Localization' :
cat === 'containment' ? 'Containment' :
cat === 'participation' ? 'Participation' : 'Other';
return `<b>${params.data.source}</b> <span style="color:${relationEdgeStyles[cat].color}">--${params.name}--></span> <b>${params.data.target}</b><br/><small>PC: ${catName}</small>`;
}
},
series: [{
type: 'graph',
layout: 'force',
data: graphData.value.nodes,
links: graphData.value.edges,
roam: true,
draggable: true,
label: {
show: true,
position: 'right',
formatter: '{b}'
},
force: {
repulsion: 300,
gravity: 0.1,
edgeLength: 100,
layoutAnimation: true
},
emphasis: {
focus: 'adjacency',
lineStyle: { width: 4 }
}
}]
};
chart.value.setOption(option);
chart.value.on('click', params => {
if (params.dataType === 'node') {
selectedNode.value = params.data;
} else {
selectedNode.value = null;
}
});
window.addEventListener('resize', () => chart.value.resize());
};
const searchNode = () => {
if (!searchQuery.value || !chart.value) return;
const node = graphData.value.nodes.find(n =>
n.id.toLowerCase().includes(searchQuery.value.toLowerCase())
);
if (node) {
chart.value.dispatchAction({
type: 'highlight',
seriesIndex: 0,
name: node.id
});
selectedNode.value = node;
ElMessage.success(t('msg-node-found') + node.name);
} else {
ElMessage.warning(t('msg-node-not-found'));
}
};
const toggleRelationFilter = (type) => {
const index = selectedRelations.value.indexOf(type);
if (index > -1) {
selectedRelations.value.splice(index, 1);
} else {
selectedRelations.value.push(type);
}
if (selectedRelations.value.length === 0) {
chart.value.setOption({
series: [{ data: graphData.value.nodes, links: graphData.value.edges }]
});
} else {
const filteredEdges = graphData.value.edges.filter(e =>
selectedRelations.value.includes(e.relation)
);
const connectedNodes = new Set();
filteredEdges.forEach(e => {
connectedNodes.add(e.source);
connectedNodes.add(e.target);
});
const filteredNodes = graphData.value.nodes.filter(n =>
connectedNodes.has(n.id)
);
chart.value.setOption({
series: [{ data: filteredNodes, links: filteredEdges }]
});
}
};
const fitGraph = () => {
if (chart.value) {
chart.value.dispatchAction({ type: 'restore' });
}
};
const resetFilters = () => {
selectedRelations.value = [];
selectedNode.value = null;
searchQuery.value = '';
if (chart.value) {
chart.value.setOption({
series: [{ data: graphData.value.nodes, links: graphData.value.edges }]
});
chart.value.dispatchAction({ type: 'restore' });
}
};
const getNodeTypeTag = (type) => {
const typeMap = { 'gene': '', 'gene_id': 'success', 'tissue': 'warning', 'location': 'info' };
return typeMap[type] || '';
};
const goBack = () => {
window.location.href = '/';
};
onMounted(() => {
loadData().then(() => {
// 检查URL参数,如果有highlight则自动搜索高亮
const urlParams = new URLSearchParams(window.location.search);
const highlightEntity = urlParams.get('highlight');
if (highlightEntity) {
searchQuery.value = highlightEntity;
setTimeout(() => {
searchNode();
}, 500); // 等待图表初始化完成
}
});
});
return {
t,
tp,
stats,
searchQuery,
selectedNode,
selectedRelations,
relationTypes,
relationTypesByCategory,
relationCounts,
relationEdgeStyles,
relationCategories,
nodeTypeLabels,
selectedNodeRelations,
nodeColors,
searchNode,
toggleRelationFilter,
fitGraph,
resetFilters,
getNodeTypeTag,
goBack
};
}
}).use(ElementPlus).mount('#app');
</script>
</body>
</html>
+304
View File
@@ -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();
})();
+801 -39
View File
File diff suppressed because it is too large Load Diff
+165
View File
@@ -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"
}
}
};
+165
View File
@@ -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"
}
}
};
+130
View File
@@ -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"
}
}
};
+130
View File
@@ -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"
}
}
};
+364 -364
View File
@@ -1,373 +1,373 @@
/**
* Conversation Sidebar Component
* 类似ChatGPT的会话侧边栏组件
* Conversation Sidebar component
*/
const ConversationSidebar = {
template: `
<div class="conversation-sidebar" :class="{ collapsed: isCollapsed }">
<!-- 头部:New Chat按钮 -->
<div class="sidebar-header">
<button class="new-chat-btn" @click="createNewConversation">
<el-icon><plus /></el-icon>
<span v-if="!isCollapsed">New Chat</span>
</button>
<button class="toggle-btn" @click="toggleSidebar">
<el-icon><arrow-left v-if="!isCollapsed" /><arrow-right v-else /></el-icon>
</button>
</div>
<!-- 搜索框 -->
<div class="search-box" v-if="!isCollapsed">
<el-input
v-model="searchQuery"
placeholder="搜索会话..."
prefix-icon="search"
clearable
size="small"
/>
</div>
<!-- 会话列表 -->
<div class="conversation-list" v-if="!isCollapsed">
<!-- 今日会话 -->
<div class="list-section" v-if="todayConversations.length > 0">
<div class="section-title">今天</div>
<div
v-for="conv in todayConversations"
:key="conv.conversation_id"
class="conversation-item"
:class="{ active: currentConversationId === conv.conversation_id }"
@click="selectConversation(conv)"
>
<div class="item-icon">
<el-icon><chat-dot-round /></el-icon>
</div>
<div class="item-content">
<div class="item-title">{{ conv.title }}</div>
<div class="item-meta">
<span class="gene-family" v-if="conv.gene_family">{{ conv.gene_family }}</span>
<span class="message-count">{{ conv.message_count }}条消息</span>
</div>
<div class="item-preview">{{ conv.last_message }}</div>
</div>
<div class="item-actions" @click.stop>
<el-dropdown trigger="click">
<el-icon><more /></el-icon>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="renameConversation(conv)">
<el-icon><edit /></el-icon>重命名
</el-dropdown-item>
<el-dropdown-item @click="archiveConversation(conv)">
<el-icon><folder /></el-icon>归档
</el-dropdown-item>
<el-dropdown-item divided @click="deleteConversation(conv)" type="danger">
<el-icon><delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
<!-- 昨日会话 -->
<div class="list-section" v-if="yesterdayConversations.length > 0">
<div class="section-title">昨天</div>
<div
v-for="conv in yesterdayConversations"
:key="conv.conversation_id"
class="conversation-item"
:class="{ active: currentConversationId === conv.conversation_id }"
@click="selectConversation(conv)"
>
<div class="item-icon">
<el-icon><chat-dot-round /></el-icon>
</div>
<div class="item-content">
<div class="item-title">{{ conv.title }}</div>
<div class="item-meta">
<span class="gene-family" v-if="conv.gene_family">{{ conv.gene_family }}</span>
<span class="message-count">{{ conv.message_count }}条</span>
</div>
</div>
</div>
</div>
<!-- 更早会话 -->
<div class="list-section" v-if="olderConversations.length > 0">
<div class="section-title">过去7天</div>
<div
v-for="conv in olderConversations"
:key="conv.conversation_id"
class="conversation-item"
:class="{ active: currentConversationId === conv.conversation_id }"
@click="selectConversation(conv)"
>
<div class="item-icon">
<el-icon><chat-dot-round /></el-icon>
</div>
<div class="item-content">
<div class="item-title">{{ conv.title }}</div>
<div class="item-meta">
<span class="gene-family" v-if="conv.gene_family">{{ conv.gene_family }}</span>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="filteredConversations.length === 0" class="empty-state">
<el-icon><chat-line-round /></el-icon>
<p>暂无会话</p>
<p class="hint">点击上方"New Chat"开始新的基因家族分析</p>
</div>
</div>
<!-- 底部:用户信息 -->
<div class="sidebar-footer" v-if="!isCollapsed">
<div class="user-info">
<el-avatar :size="32" :icon="UserFilled" />
<span class="username">{{ userId }}</span>
</div>
</div>
</div>
`,
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: `
<div class="conversation-sidebar" :class="{ collapsed: isCollapsed }">
<!-- Header: New Chat button -->
<div class="sidebar-header">
<button class="new-chat-btn" @click="createNewConversation">
<el-icon><plus /></el-icon>
<span v-if="!isCollapsed">New Chat</span>
</button>
<button class="toggle-btn" @click="toggleSidebar">
<el-icon><arrow-left v-if="!isCollapsed" /><arrow-right v-else /></el-icon>
</button>
</div>
<!-- Search box -->
<div class="search-box" v-if="!isCollapsed">
<el-input
v-model="searchQuery"
placeholder="Search conversations..."
prefix-icon="search"
clearable
size="small"
/>
</div>
<!-- Conversation list -->
<div class="conversation-list" v-if="!isCollapsed">
<!-- Todays conversations -->
<div class="list-section" v-if="todayConversations.length > 0">
<div class="section-title">Today</div>
<div
v-for="conv in todayConversations"
:key="conv.conversation_id"
class="conversation-item"
:class="{ active: currentConversationId === conv.conversation_id }"
@click="selectConversation(conv)"
>
<div class="item-icon">
<el-icon><chat-dot-round /></el-icon>
</div>
<div class="item-content">
<div class="item-title">{{ conv.title }}</div>
<div class="item-meta">
<span class="gene-family" v-if="conv.gene_family">{{ conv.gene_family }}</span>
<span class="message-count">{{ conv.message_count }} messages</span>
</div>
<div class="item-preview">{{ conv.last_message }}</div>
</div>
<div class="item-actions" @click.stop>
<el-dropdown trigger="click">
<el-icon><more /></el-icon>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="renameConversation(conv)">
<el-icon><edit /></el-icon>Rename
</el-dropdown-item>
<el-dropdown-item @click="archiveConversation(conv)">
<el-icon><folder /></el-icon>Archive
</el-dropdown-item>
<el-dropdown-item divided @click="deleteConversation(conv)" type="danger">
<el-icon><delete /></el-icon>Delete
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
<!-- Yesterdays conversations -->
<div class="list-section" v-if="yesterdayConversations.length > 0">
<div class="section-title">Yesterday</div>
<div
v-for="conv in yesterdayConversations"
:key="conv.conversation_id"
class="conversation-item"
:class="{ active: currentConversationId === conv.conversation_id }"
@click="selectConversation(conv)"
>
<div class="item-icon">
<el-icon><chat-dot-round /></el-icon>
</div>
<div class="item-content">
<div class="item-title">{{ conv.title }}</div>
<div class="item-meta">
<span class="gene-family" v-if="conv.gene_family">{{ conv.gene_family }}</span>
<span class="message-count">{{ conv.message_count }}</span>
</div>
</div>
</div>
</div>
<!-- Older conversations -->
<div class="list-section" v-if="olderConversations.length > 0">
<div class="section-title">Past 7 days</div>
<div
v-for="conv in olderConversations"
:key="conv.conversation_id"
class="conversation-item"
:class="{ active: currentConversationId === conv.conversation_id }"
@click="selectConversation(conv)"
>
<div class="item-icon">
<el-icon><chat-dot-round /></el-icon>
</div>
<div class="item-content">
<div class="item-title">{{ conv.title }}</div>
<div class="item-meta">
<span class="gene-family" v-if="conv.gene_family">{{ conv.gene_family }}</span>
</div>
</div>
</div>
</div>
<!-- Empty state -->
<div v-if="filteredConversations.length === 0" class="empty-state">
<el-icon><chat-line-round /></el-icon>
<p>No conversations yet</p>
<p class="hint">Click "New Chat" to start gene family analysis</p>
</div>
</div>
<!-- footer: user info -->
<div class="sidebar-footer" v-if="!isCollapsed">
<div class="user-info">
<el-avatar :size="32" :icon="UserFilled" />
<span class="username">{{ userId }}</span>
</div>
</div>
</div>
`,
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 };
}
File diff suppressed because it is too large Load Diff
+891
View File
@@ -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\"?"
};
@@ -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:
@@ -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
),
+383
View File
@@ -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)^<!-- GFA_SECTION ([a-z0-9_.]+) -->\s*$")
matches = list(marker.finditer(template))
if not matches:
raise ValueError(f"报告模板没有章节边界:{TEMPLATE_PATH}")
return [
(
match.group(1),
template[match.end(): matches[index + 1].start() if index + 1 < len(matches) else len(template)].strip(),
)
for index, match in enumerate(matches)
]
def _arranged_content(section: ReportSection, arranged: Mapping[str, Any]) -> str:
sentences = {sentence.id: sentence.text for sentence in section.sentences}
paragraphs: list[str] = []
for paragraph in arranged["paragraphs"]:
sentence_ids = paragraph["sentence_ids"]
text = sentences[sentence_ids[0]]
for connector_id, sentence_id in zip(paragraph["connector_ids"], sentence_ids[1:]):
text += f" {CONNECTORS[connector_id]}{sentences[sentence_id]}"
paragraphs.append(text)
return "\n\n".join(paragraphs)
def _render_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,
)
+767
View File
@@ -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),
)
+122
View File
@@ -0,0 +1,122 @@
# HTML rendering and representative-figure selection for gene-family reports.
from __future__ import annotations
import base64
import html
import mimetypes
from pathlib import Path
import re
from typing import Any, Iterable, Mapping
REPORT_RENDERER_VERSION = "gene_family_report_html_v4"
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".svg"}
STAGE_FIGURE_TEXT = {
"phylogeny": ("系统发育图展示目标成员与参考成员之间的聚类关系。", "结合分支拓扑、节点支持率和成员标签阅读,不把邻近关系直接等同于功能一致。", "目标与参考序列的聚类关系见图,具体分支以图中节点和标签为准。"),
"reference_classification": ("参考分类图展示目标成员与已知参考亚家族的相对位置。", "结合参考成员标签、目标成员所在分支及支持率判断分类线索。", "目标成员的参考辅助分类位置见图,分类结论以本节确定性结果为准。"),
"motif": ("Motif图展示各成员短保守片段的组成、顺序和相对位置。", "按成员逐行比较Motif颜色、编号、数量和排列,不把Motif相似直接写成功能相同。", "本次成员的Motif组成与排列见图,数量统计以本节结果数据为准。"),
"domain": ("Domain图展示蛋白主要功能区域的类型和位置。", "结合结构域名称、覆盖区间和成员间差异阅读。", "各成员的结构域组成见图,覆盖数量和比例以本节结果数据为准。"),
"structure": ("基因结构图展示外显子、内含子和基因长度的相对关系。", "结合图例比较外显子数量、结构排列和异常长基因。", "目标成员的基因结构差异见图,具体统计以本节结果数据为准。"),
"combo": ("综合组合图并列展示系统发育、Motif、Domain和基因结构信息。", "沿同一成员横向比较多个证据面板,关注一致模式与明显差异。", "多类结构证据的对应关系见图,不据此单独确认功能改变。"),
"promoter": ("启动子图展示顺式作用元件的类型、数量或位置分布。", "结合元件图例、位置坐标和成员标签比较,不把预测位点写成已验证调控。", "目标成员启动子元件的分布见图,数量和类别统计以本节结果数据为准。"),
"synteny": ("共线性图展示染色体区段和基因对之间的连线关系。", "结合染色体标签、连线和家族成员标记阅读,区分全基因组背景与家族证据。", "本次共线性背景见图,家族专属关系以本节可追溯结果为准。"),
"karyotype": ("染色体分布图展示家族成员在染色体上的物理位置。", "结合染色体编号、位置刻度和基因标签查看分布范围。", "目标家族成员的染色体位置见图,成员数和染色体数以本节结果数据为准。"),
"interspecies_synteny": ("种间共线性图展示目标物种与参考物种之间的保守区块和基因连线。", "结合物种、染色体、标记连线和成员标签阅读,不直接移植参考物种功能。", "跨物种共线性关系见图,目标与参考家族配对数以本节结果数据为准。"),
"pep": ("蛋白特征图展示理化性质、跨膜区域或亚细胞定位预测结果。", "根据具体图例比较成员分布,区分不同预测子任务。", "本次蛋白预测结果见图,具体数量与分类以本节结果数据为准。"),
"tfbs": ("Motif统计图展示不同基因的位点计数、全部Motif计数和高频Motif。", "结合基因标签、颜色图例、计数刻度和Motif名称比较分布。", "本次gene、all和top motif统计结果见图,具体计数以图例和源结果为准。"),
}
def _artifact_image(final_dir: Path, artifact: Any) -> Path | None:
if not isinstance(artifact, Mapping): return None
raw = str(artifact.get("full_path") or artifact.get("path") or "").strip()
target = final_dir / Path(raw).name if raw else None
return target if target and target.is_file() and target.suffix.lower() in IMAGE_SUFFIXES else None
def _kind(role: str, filename: str) -> str:
value = f"{role} {filename}".lower()
groups = (("gene_motif_count", ("gene_motif_heatmap",)), ("all_motif_count", ("all_motif_count_heatmap",)), ("top_motif", ("top_motifs_barplot",)), ("tree", ("tree",)), ("msa", ("msa_plot", "conserved_region")), ("synteny", ("synteny", "circos")), ("motif", ("motif",)), ("domain", ("domain",)), ("structure", ("structure",)), ("combo", ("combo",)), ("promoter_combined", ("promoter_v6_combined",)), ("promoter_position", ("promoter_position_distribution",)), ("promoter", ("promoter",)), ("karyotype", ("karyotype",)), ("tmhmm", ("tmhmm",)), ("subcellular", ("subcellular",)), ("properties", ("properties",)))
return next((kind for kind, tokens in groups if any(token in value for token in tokens)), role.lower())
def _rank(role: str, path: Path) -> tuple[int, int, str]:
value = f"{role} {path.name}".lower()
role_lower = role.lower()
preferred = -5 if role_lower == "synteny_marked_png" else -4 if "circular" in value else -2 if "marked" in value else -1 if "summary" in value else 0
return (preferred, 0, path.name)
def collect_stage_figures(final_dir: Path, stages: Iterable[str], manifests: Mapping[str, Mapping[str, Any]]) -> dict[str, list[dict[str, str]]]:
result = {}
for stage in stages:
selected = {}; seen = set()
for role, artifact in ((manifests.get(stage) or {}).get("artifacts") or {}).items():
target = _artifact_image(final_dir, artifact)
if target is None or target.name in seen: continue
seen.add(target.name); kind = _kind(str(role), target.name)
candidate = (_rank(str(role), target), {"role": str(role), "filename": target.name})
if kind not in selected or candidate[0] < selected[kind][0]: selected[kind] = candidate
if stage == "tfbs":
for role, pattern in (
("gene_motif_heatmap", "*gene_motif_heatmap.png"),
("all_motif_count_heatmap", "*all_motif_count_heatmap.png"),
("top_motifs_barplot", "*top_motifs_barplot.png"),
):
matches = sorted(final_dir.glob(pattern))
if not matches:
continue
target = matches[0]
kind = _kind(role, target.name)
selected[kind] = (_rank(role, target), {"role": role, "filename": target.name})
figures = [entry[1] for _, entry in sorted(selected.items(), key=lambda item: (item[1][0], item[0]))][:4]
if figures: result[stage] = figures
return result
def render_stage_figures(stage: str, figures: list[dict[str, str]]) -> str:
meaning, reading, result = STAGE_FIGURE_TEXT.get(stage, ("结果图展示该阶段的主要计算产物。", "结合图题、坐标轴、图例和成员标签阅读。", "本阶段代表结果见图,具体事实以本节确定性数据为准。"))
lines = ["", "### 结果图"]
for index, figure in enumerate(figures, 1):
title = figure["role"]
lines.extend(["", f"#### 图 {index}. {title}", "", f"![{title}]({figure['filename']})", "", f"图形含义:{meaning}", "", f"阅读方法:{reading}", "", f"本次结果:{result}"])
return "\n".join(lines)
def _inline(value: str, report_path: Path) -> str:
tokens = []
def stash(content):
token = f"@@GFAHTML{len(tokens)}@@"; tokens.append(content); return token
def image(match):
alt = match.group(1).strip() or "基因家族结果图"; target = report_path.parent / Path(match.group(2).strip().replace("\\", "/")).name
source = target.name; mime = mimetypes.guess_type(target.name)[0] or ""
if target.is_file() and mime.startswith("image/"): source = f"data:{mime};base64,{base64.b64encode(target.read_bytes()).decode('ascii')}"
return stash(f'<figure><img src="{html.escape(source, quote=True)}" alt="{html.escape(alt, quote=True)}" loading="lazy"><figcaption>{html.escape(alt)}</figcaption></figure>')
def link(match):
label = html.escape(match.group(1).strip()); target = Path(match.group(2).strip().replace("\\", "/")).name
return stash(f'<a href="{html.escape(target, quote=True)}" target="_blank" rel="noopener">{label}</a>')
value = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", image, value); value = re.sub(r"(?<!!)\[([^\]]+)\]\(([^)]+)\)", link, value)
value = re.sub(r"`([^`]+)`", lambda m: stash(f"<code>{html.escape(m.group(1))}</code>"), value); value = html.escape(value); value = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", value)
for index, content in enumerate(tokens): value = value.replace(f"@@GFAHTML{index}@@", content)
return value
def markdown_to_html(markdown: str, report_path: Path, title: str) -> str:
output = []; list_tag = ""; table_rows = []
def close_list():
nonlocal list_tag
if list_tag: output.append(f"</{list_tag}>"); list_tag = ""
def flush_table():
nonlocal table_rows
if not table_rows: return
rows, table_rows = table_rows, []
if len(rows) >= 2 and all(re.fullmatch(r":?-{3,}:?", cell.replace(" ", "")) for cell in rows[1]):
output.append('<div class="table-wrap"><table><thead><tr>' + ''.join(f"<th>{_inline(c, report_path)}</th>" for c in rows[0]) + '</tr></thead><tbody>'); output.extend('<tr>' + ''.join(f"<td>{_inline(c, report_path)}</td>" for c in row) + '</tr>' for row in rows[2:]); output.append('</tbody></table></div>')
else: output.extend(f"<p>{_inline(' | '.join(row), report_path)}</p>" for row in rows)
for raw in markdown.splitlines():
line = raw.strip()
if line.startswith("|") and line.endswith("|"): close_list(); table_rows.append([c.strip() for c in line.strip("|").split("|")]); continue
flush_table()
if not line: close_list(); continue
heading = re.match(r"^(#{1,6})\s+(.+)$", line)
if heading: close_list(); level=len(heading.group(1)); output.append(f"<h{level}>{_inline(heading.group(2), report_path)}</h{level}>"); continue
if line.startswith("> "): close_list(); output.append(f"<blockquote>{_inline(line[2:], report_path)}</blockquote>"); continue
item = re.match(r"^([-*]|\d+\.)\s+(.+)$", line)
if item:
tag = "ol" if item.group(1)[0].isdigit() else "ul"
if list_tag != tag: close_list(); list_tag=tag; output.append(f"<{tag}>")
output.append(f"<li>{_inline(item.group(2), report_path)}</li>"); continue
close_list(); rendered=_inline(line, report_path); output.append(rendered if line.startswith("![") else f"<p>{rendered}</p>")
close_list(); flush_table(); body="\n".join(output); safe_title=html.escape(title)
return f'''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{safe_title}</title><style>:root{{--ink:#17352c;--paper:#fffdf7;--line:#dce5dc;--accent:#157a57;--soft:#edf6ef;--warm:#f2e5c7}}*{{box-sizing:border-box}}html{{background:#e9efe9}}body{{margin:0;color:var(--ink);font-family:"Noto Serif SC","Source Han Serif SC","Microsoft YaHei",serif;line-height:1.85;background:radial-gradient(circle at top left,#f8edcf 0,transparent 28rem),linear-gradient(150deg,#edf4ec,#f6f2e8)}}main{{width:min(1120px,calc(100% - 32px));margin:32px auto 64px;padding:clamp(28px,5vw,72px);background:var(--paper);border:1px solid rgba(23,53,44,.12);border-radius:22px;box-shadow:0 24px 70px rgba(35,62,48,.14);overflow:hidden}}h1{{font-size:clamp(2rem,5vw,3.5rem);line-height:1.2;color:#0d4f39}}h2{{margin:3.2rem 0 1.1rem;padding-top:.7rem;border-top:3px solid var(--warm);font-size:clamp(1.45rem,3vw,2rem)}}h3{{color:var(--accent)}}p,h1,h2,h3,h4,a,figcaption,code{{overflow-wrap:anywhere;word-break:break-word}}blockquote{{margin:1.4rem 0;padding:1rem 1.25rem;border-left:5px solid var(--accent);background:var(--soft)}}code{{padding:.14em .4em;border-radius:5px;background:#edf0eb}}figure{{margin:1.6rem 0 2.2rem;padding:clamp(8px,2vw,18px);border:1px solid var(--line);border-radius:16px;background:#fff;box-shadow:0 10px 30px rgba(28,62,48,.09)}}img{{display:block;max-width:100%;height:auto;margin:auto;border-radius:10px}}figcaption{{margin-top:.7rem;text-align:center;color:#60726b}}a{{color:#087553}}.table-wrap{{overflow-x:auto;margin:1.4rem 0;border:1px solid var(--line);border-radius:12px}}table{{width:100%;border-collapse:collapse;background:#fff}}th,td{{padding:.72rem .85rem;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}}th{{background:var(--soft)}}@media(max-width:640px){{main{{width:100%;margin:0;padding:24px 18px 48px;border:0;border-radius:0}}}}@media print{{html,body{{background:#fff}}main{{width:100%;margin:0;padding:0;border:0;box-shadow:none}}figure{{break-inside:avoid}}}}</style></head><body><main>{body}</main></body></html>'''
+79 -38
View File
@@ -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 "",
+29 -15
View File
@@ -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.<sample>.library_strandness 中明确填写 non_stranded / forward / reverse;混合链特异性样本当前不能用单次 featureCounts 定量。",
"suggestion_zh": "请在 samples.yaml 的 rnaseq.library_strandness 或每个 samples.<sample>.library_strandness 中明确填写 non_stranded / forward / reverse;混合链特异性样本当前不能用单次 featureCounts 定量。",
"suggestion_en": "Please specify non_stranded / forward / reverse in samples.yaml rnaseq.library_strandness or each samples.<sample>.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")
+110 -10
View File
@@ -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):
+17 -7
View File
@@ -193,14 +193,24 @@ def _read_json(path: Path) -> Dict[str, Any]:
def _artifact_path(manifest: Dict[str, Any], artifact_key: str) -> Optional[Path]:
artifact = (manifest.get("artifacts") or {}).get(artifact_key) or {}
path_text = artifact.get("full_path") or artifact.get("path")
if path_text:
manifest_path = Path(str(manifest.get("_manifest_path") or ""))
job_dir = manifest_path.parent.parent if manifest_path else None
for path_text in (artifact.get("full_path"), artifact.get("path")):
if not path_text:
continue
path = Path(str(path_text))
if not path.is_absolute() and manifest.get("_manifest_path"):
job_dir = Path(str(manifest["_manifest_path"])).parent.parent
path = job_dir / path
if path.exists():
return path
candidates = [path]
if job_dir:
if not path.is_absolute():
candidates.append(job_dir / path)
else:
job_index = next((index for index, part in enumerate(path.parts) if part.startswith("job_")), None)
if job_index is not None:
candidates.append(job_dir.joinpath(*path.parts[job_index + 1 :]))
candidates.append(manifest_path.parent / path.name)
for candidate in candidates:
if candidate.exists():
return candidate
return None
+1
View File
@@ -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")
File diff suppressed because it is too large Load Diff
+15 -3
View File
@@ -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:
+7 -1
View File
@@ -4,7 +4,7 @@
import re
from typing import Optional, Dict, Any, List
from gfa_core.mas.stage_catalog import complete_dependencies as catalog_complete_dependencies
from gfa_core.mas.utils.intent_parser import is_gwas_fmt_intent, parse_gene_family_scope_selection, resolve_gwas_intent, resolve_population_intent
from gfa_core.mas.utils.intent_parser import is_gwas_fmt_intent, parse_gene_family_scope_selection, resolve_gene_family_report_intent, resolve_gwas_intent, resolve_population_intent
# 分析类型关键词映射(注意:key必须与orchestrator.py中的analysis_type匹配)
ANALYSIS_KEYWORDS = {
@@ -96,6 +96,12 @@ def fast_intent_recognition(query: str) -> Optional[Dict[str, Any]]:
None: 无法识别
"""
query_lower = query.lower()
report_intent = resolve_gene_family_report_intent(query)
if report_intent:
return {
"stage": "gene_family_report",
**report_intent,
}
population = resolve_population_intent(query)
if population:
return {
+85 -17
View File
@@ -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,
+3
View File
@@ -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
+29 -7
View File
@@ -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 (
+17
View File
@@ -812,6 +812,23 @@ CAPABILITIES: Dict[str, StageCapability] = {
repair_candidates=["manifest_recovery", "artifact_reresolve"],
rerun_policy="auto_after_manifest_recovery",
),
"gene_family_report": StageCapability(
stage="gene_family_report",
cli="apps/cli/run_gene_family_report.py",
env="test4py311",
category="report",
consumes=[],
provides=[
ArtifactProduct("gene_family_report", "markdown", "{prefix}.gene_family_report.md"),
ArtifactProduct("gene_family_report_evidence", "json", "{prefix}.gene_family_report.evidence.json"),
ArtifactProduct("gene_family_report_model", "json", "{prefix}.gene_family_report.model.json"),
ArtifactProduct("gene_family_report_manifest", "manifest", "{prefix}.gene_family_report.manifest.json"),
],
scope_modes=["target_only", "target_plus_reference", "all_selected"],
preflight_checks=["at_least_one_successful_planned_manifest"],
postflight_checks=["formal_report_four_files_exist"],
rerun_policy="open_reuses_fresh_generate_rebuilds",
),
}
+4
View File
@@ -78,6 +78,7 @@ PIPELINE_STAGES = {
"ppi",
"kaks",
"combo",
"gene_family_report",
"rnaseq_align",
"rnaseq_quant",
"rnaseq_sample_qc",
@@ -391,6 +392,7 @@ STAGE_ORDER = [
"rnaseq_fgsea",
"rnaseq_candidate_family",
"rnaseq_enrich",
"gene_family_report",
]
STAGE_INFO = {
@@ -441,6 +443,7 @@ STAGE_INFO = {
"msa": {"name": "多序列比对", "estimated_time": 10, "cpu": 4},
"enrichment": {"name": "富集分析", "estimated_time": 15, "cpu": 4},
"combo": {"name": "组合图", "estimated_time": 10, "cpu": 4},
"gene_family_report": {"name": "基因家族结果报告", "estimated_time": 2, "cpu": 1},
"genome_prepare": {"name": "基因组准备", "estimated_time": 10, "cpu": 4},
"ncbi_fetch_sequences": {"name": "NCBI序列下载", "estimated_time": 5, "cpu": 4},
"sequence_extract_by_id": {"name": "按ID提取序列", "estimated_time": 5, "cpu": 4},
@@ -529,6 +532,7 @@ EXECUTABLE_STAGES = {
"ppi",
"kaks",
"combo",
"gene_family_report",
"rnaseq_align",
"rnaseq_quant",
"rnaseq_sample_qc",
+23 -2
View File
@@ -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:
+22
View File
@@ -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",
+115 -3
View File
@@ -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"(?<![A-Za-z0-9])(?:"
+ "|".join(
sorted((re.escape(code) for code in SPECIES_KEYWORDS), key=len, reverse=True)
)
+ r")(?![A-Za-z0-9])"
)
_ROLE_BOUND_SPECIES_CODE = r"[A-Z][A-Za-z0-9_-]{1,15}"
SPECIES_TO_LATIN = {
"At": "Arabidopsis thaliana",
"Os": "Oryza sativa",
@@ -317,7 +361,11 @@ def _species_mentions_with_roles(query: str) -> Dict[str, List[str]]:
def collect_species(segment: str) -> List[str]:
segment_lower = segment.lower()
found: List[str] = []
for match in re.finditer(r"(?<![A-Za-z0-9])(?:Ft|At|Os|Ls|Zm|Sl|Lc|Dl|Ppy|Py|Cp|Vv|Lg)(?![A-Za-z0-9])", segment, flags=re.IGNORECASE):
for match in re.finditer(
_SPECIES_CODE_PATTERN,
segment,
flags=re.IGNORECASE,
):
code = canonical.get(match.group(0).lower())
if code:
found.append(code)
@@ -340,6 +388,11 @@ def _species_mentions_with_roles(query: str) -> 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"(?<![A-Za-z0-9])(?:Ft|At|Os|Ls|Zm|Sl|Lc|Dl|Ppy|Py|Cp|Vv|Lg)(?![A-Za-z0-9])"
for match in re.finditer(code_pattern, text, flags=re.IGNORECASE):
for match in re.finditer(_SPECIES_CODE_PATTERN, text, flags=re.IGNORECASE):
code = canonical.get(match.group(0).lower())
if code:
mentions.append((match.start(), code))
@@ -646,6 +707,10 @@ def parse_intent_from_query(query: str) -> Dict:
query_lower = query.lower()
query_without_space = re.sub(r"\s+", "", query_lower)
report_intent = resolve_gene_family_report_intent(query)
if report_intent:
return report_intent
if any(token.replace(" ", "") in query_without_space for token in (
"wgs全流程", "vcf全流程", "wgs full pipeline", "vcf full pipeline"
)):
@@ -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 = (
+44
View File
@@ -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
File diff suppressed because it is too large Load Diff
@@ -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` 或模型猜测代替可提取的数据。
+151
View File
@@ -0,0 +1,151 @@
<!-- GFA_SECTION common.front -->
# {{PREFIX}} 基因家族分析结果解读报告
- 任务:{{JOB_ID}}
- 分析前缀:{{PREFIX}}
- 生成时间:{{GENERATED_AT}}
## 一、报告首页
### 1.1 项目信息
本报告只覆盖用户计划中的成功阶段;不同阶段的目标集合在各节单独标明。
### 1.2 研究目的
本报告用于确认家族成员,比较目标成员及必要参考成员的关系、结构、调控序列与蛋白预测特征,并界定结果能够支持的结论范围。
### 1.3 一页式结论摘要
{{SECTION_CONTENT}}
### 1.4 结论可信度说明
成员数和命名表中的统计可直接复核;树、启动子、TFBS、共线性和蛋白定位只提供计算线索,不等同于功能或因果证据。
<!-- GFA_SECTION common.route -->
## 二、分析路线与阅读顺序
{{SECTION_CONTENT}}
### 2.1 统一解读顺序
各阶段依次写明分析对象、源结果中的直接观察、谨慎解释、限制,并仅在有助复核时列出少量相对路径。
### 2.2 语言边界
预测关系统一表述为线索;没有逐成员表或独立证据时,不指定候选基因,不声称功能改变或调控关系已经证实。
<!-- GFA_SECTION common.completeness -->
## 三、结果总览与完整性检查
### 3.1 阶段完成情况
{{SECTION_CONTENT}}
### 3.2 结果完整性概述
报告按计划逐阶段选择成功 Manifest;当各阶段使用的成员集合不同,正文明确区分目标成员、目标加参考成员和全基因组背景。
<!-- GFA_SECTION stage.identify -->
## 四、基因家族鉴定(Identify)
### 4.3 最终成员数量与命名
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.phylogeny -->
## 五、亲缘关系分析(Phylogeny)
### 5.5 亲缘关系阶段小结
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.reference_classification -->
## 六、参考物种辅助的小组分类(Reference Classification)
### 6.2 分类可信度表述
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.motif -->
## 七、短保守片段分析(Motif)
### 7.2 Motif 数量与出现频率
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.domain -->
## 八、主要功能区域分析(Domain)
### 8.1 Domain 整体统计
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.structure -->
## 九、基因内部结构分析(Structure)
### 9.1 结构覆盖与异常结构
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.combo -->
## 十、综合组合图(Combo)
### 10.1 树 + Motif + Domain + Structure 组合图
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.promoter -->
## 十一、启动子元件分析(Promoter)
### 11.1 元件总量、类别与高级统计
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.tfbs -->
## 十二、转录因子结合位点预测(TFBS)
### 12.1 高频结合片段与统计边界
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.synteny -->
## 十三、种内共线性分析(Synteny)
### 13.1 全基因组共线性背景与家族证据缺口
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.karyotype -->
## 十四、染色体分布(Karyotype)
### 14.1 染色体位置概述
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.interspecies_synteny -->
## 十五、种间共线性与跨物种关系(Interspecies Synteny)
### 15.6 Interspecies Synteny 阶段小结
{{SECTION_CONTENT}}
<!-- GFA_SECTION stage.pep -->
## 十六、蛋白基本特征分析(PEP)
### 16.1 蛋白理化性质分布图
逐蛋白理化性质保留在命名结果表中;源 Manifest 没有汇总长度、分子量或等电点范围时,正文不从图片反推数值。
### 16.4 亚细胞定位与阶段小结
{{SECTION_CONTENT}}
<!-- GFA_SECTION common.integrated -->
## 十七、多证据综合分析
### 17.5 综合结论
{{SECTION_CONTENT}}
+58 -19
View File
@@ -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
+4 -4
View File
@@ -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"
+11 -10
View File
@@ -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"
+1 -1
View File
@@ -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
@@ -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())
+97
View File
@@ -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,
@@ -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"]
@@ -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
+88
View File
@@ -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,
]
+844
View File
@@ -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.*"))
@@ -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"]
@@ -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"] == ""
@@ -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
@@ -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"]
@@ -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
@@ -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
@@ -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(
"亚家族确认完成,因此完整基因家族分析已经全部完成。"
)
@@ -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)
]
File diff suppressed because it is too large Load Diff
@@ -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
@@ -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"]