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:
+2
-1
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
Executable
+416
@@ -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>
|
||||
Executable
+391
@@ -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
@@ -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
Executable
+712
@@ -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>
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -0,0 +1,122 @@
|
||||
# HTML rendering and representative-figure selection for gene-family reports.
|
||||
from __future__ import annotations
|
||||
import base64
|
||||
import html
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
REPORT_RENDERER_VERSION = "gene_family_report_html_v4"
|
||||
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".svg"}
|
||||
STAGE_FIGURE_TEXT = {
|
||||
"phylogeny": ("系统发育图展示目标成员与参考成员之间的聚类关系。", "结合分支拓扑、节点支持率和成员标签阅读,不把邻近关系直接等同于功能一致。", "目标与参考序列的聚类关系见图,具体分支以图中节点和标签为准。"),
|
||||
"reference_classification": ("参考分类图展示目标成员与已知参考亚家族的相对位置。", "结合参考成员标签、目标成员所在分支及支持率判断分类线索。", "目标成员的参考辅助分类位置见图,分类结论以本节确定性结果为准。"),
|
||||
"motif": ("Motif图展示各成员短保守片段的组成、顺序和相对位置。", "按成员逐行比较Motif颜色、编号、数量和排列,不把Motif相似直接写成功能相同。", "本次成员的Motif组成与排列见图,数量统计以本节结果数据为准。"),
|
||||
"domain": ("Domain图展示蛋白主要功能区域的类型和位置。", "结合结构域名称、覆盖区间和成员间差异阅读。", "各成员的结构域组成见图,覆盖数量和比例以本节结果数据为准。"),
|
||||
"structure": ("基因结构图展示外显子、内含子和基因长度的相对关系。", "结合图例比较外显子数量、结构排列和异常长基因。", "目标成员的基因结构差异见图,具体统计以本节结果数据为准。"),
|
||||
"combo": ("综合组合图并列展示系统发育、Motif、Domain和基因结构信息。", "沿同一成员横向比较多个证据面板,关注一致模式与明显差异。", "多类结构证据的对应关系见图,不据此单独确认功能改变。"),
|
||||
"promoter": ("启动子图展示顺式作用元件的类型、数量或位置分布。", "结合元件图例、位置坐标和成员标签比较,不把预测位点写成已验证调控。", "目标成员启动子元件的分布见图,数量和类别统计以本节结果数据为准。"),
|
||||
"synteny": ("共线性图展示染色体区段和基因对之间的连线关系。", "结合染色体标签、连线和家族成员标记阅读,区分全基因组背景与家族证据。", "本次共线性背景见图,家族专属关系以本节可追溯结果为准。"),
|
||||
"karyotype": ("染色体分布图展示家族成员在染色体上的物理位置。", "结合染色体编号、位置刻度和基因标签查看分布范围。", "目标家族成员的染色体位置见图,成员数和染色体数以本节结果数据为准。"),
|
||||
"interspecies_synteny": ("种间共线性图展示目标物种与参考物种之间的保守区块和基因连线。", "结合物种、染色体、标记连线和成员标签阅读,不直接移植参考物种功能。", "跨物种共线性关系见图,目标与参考家族配对数以本节结果数据为准。"),
|
||||
"pep": ("蛋白特征图展示理化性质、跨膜区域或亚细胞定位预测结果。", "根据具体图例比较成员分布,区分不同预测子任务。", "本次蛋白预测结果见图,具体数量与分类以本节结果数据为准。"),
|
||||
"tfbs": ("Motif统计图展示不同基因的位点计数、全部Motif计数和高频Motif。", "结合基因标签、颜色图例、计数刻度和Motif名称比较分布。", "本次gene、all和top motif统计结果见图,具体计数以图例和源结果为准。"),
|
||||
}
|
||||
|
||||
def _artifact_image(final_dir: Path, artifact: Any) -> Path | None:
|
||||
if not isinstance(artifact, Mapping): return None
|
||||
raw = str(artifact.get("full_path") or artifact.get("path") or "").strip()
|
||||
target = final_dir / Path(raw).name if raw else None
|
||||
return target if target and target.is_file() and target.suffix.lower() in IMAGE_SUFFIXES else None
|
||||
|
||||
def _kind(role: str, filename: str) -> str:
|
||||
value = f"{role} {filename}".lower()
|
||||
groups = (("gene_motif_count", ("gene_motif_heatmap",)), ("all_motif_count", ("all_motif_count_heatmap",)), ("top_motif", ("top_motifs_barplot",)), ("tree", ("tree",)), ("msa", ("msa_plot", "conserved_region")), ("synteny", ("synteny", "circos")), ("motif", ("motif",)), ("domain", ("domain",)), ("structure", ("structure",)), ("combo", ("combo",)), ("promoter_combined", ("promoter_v6_combined",)), ("promoter_position", ("promoter_position_distribution",)), ("promoter", ("promoter",)), ("karyotype", ("karyotype",)), ("tmhmm", ("tmhmm",)), ("subcellular", ("subcellular",)), ("properties", ("properties",)))
|
||||
return next((kind for kind, tokens in groups if any(token in value for token in tokens)), role.lower())
|
||||
|
||||
def _rank(role: str, path: Path) -> tuple[int, int, str]:
|
||||
value = f"{role} {path.name}".lower()
|
||||
role_lower = role.lower()
|
||||
preferred = -5 if role_lower == "synteny_marked_png" else -4 if "circular" in value else -2 if "marked" in value else -1 if "summary" in value else 0
|
||||
return (preferred, 0, path.name)
|
||||
|
||||
def collect_stage_figures(final_dir: Path, stages: Iterable[str], manifests: Mapping[str, Mapping[str, Any]]) -> dict[str, list[dict[str, str]]]:
|
||||
result = {}
|
||||
for stage in stages:
|
||||
selected = {}; seen = set()
|
||||
for role, artifact in ((manifests.get(stage) or {}).get("artifacts") or {}).items():
|
||||
target = _artifact_image(final_dir, artifact)
|
||||
if target is None or target.name in seen: continue
|
||||
seen.add(target.name); kind = _kind(str(role), target.name)
|
||||
candidate = (_rank(str(role), target), {"role": str(role), "filename": target.name})
|
||||
if kind not in selected or candidate[0] < selected[kind][0]: selected[kind] = candidate
|
||||
if stage == "tfbs":
|
||||
for role, pattern in (
|
||||
("gene_motif_heatmap", "*gene_motif_heatmap.png"),
|
||||
("all_motif_count_heatmap", "*all_motif_count_heatmap.png"),
|
||||
("top_motifs_barplot", "*top_motifs_barplot.png"),
|
||||
):
|
||||
matches = sorted(final_dir.glob(pattern))
|
||||
if not matches:
|
||||
continue
|
||||
target = matches[0]
|
||||
kind = _kind(role, target.name)
|
||||
selected[kind] = (_rank(role, target), {"role": role, "filename": target.name})
|
||||
figures = [entry[1] for _, entry in sorted(selected.items(), key=lambda item: (item[1][0], item[0]))][:4]
|
||||
if figures: result[stage] = figures
|
||||
return result
|
||||
|
||||
def render_stage_figures(stage: str, figures: list[dict[str, str]]) -> str:
|
||||
meaning, reading, result = STAGE_FIGURE_TEXT.get(stage, ("结果图展示该阶段的主要计算产物。", "结合图题、坐标轴、图例和成员标签阅读。", "本阶段代表结果见图,具体事实以本节确定性数据为准。"))
|
||||
lines = ["", "### 结果图"]
|
||||
for index, figure in enumerate(figures, 1):
|
||||
title = figure["role"]
|
||||
lines.extend(["", f"#### 图 {index}. {title}", "", f"", "", f"图形含义:{meaning}", "", f"阅读方法:{reading}", "", f"本次结果:{result}"])
|
||||
return "\n".join(lines)
|
||||
|
||||
def _inline(value: str, report_path: Path) -> str:
|
||||
tokens = []
|
||||
def stash(content):
|
||||
token = f"@@GFAHTML{len(tokens)}@@"; tokens.append(content); return token
|
||||
def image(match):
|
||||
alt = match.group(1).strip() or "基因家族结果图"; target = report_path.parent / Path(match.group(2).strip().replace("\\", "/")).name
|
||||
source = target.name; mime = mimetypes.guess_type(target.name)[0] or ""
|
||||
if target.is_file() and mime.startswith("image/"): source = f"data:{mime};base64,{base64.b64encode(target.read_bytes()).decode('ascii')}"
|
||||
return stash(f'<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>'''
|
||||
@@ -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 "",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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` 或模型猜测代替可提取的数据。
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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,
|
||||
]
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user