-
PPI/KaKs 参考数据处理
+
+
Confirm the STRING reference species for PPI
+
+
+
Ranked related STRING species
+
+
+ -
+
+ {{ candidate.name }}
+ Recommended
+ {{ candidate.code }}
+
+
+ STRING TaxID: {{ candidate.taxon || '-' }}
+ LCA: {{ candidate.lca || '-' }}
+ Topology distance: {{ candidate.distance || '-' }}
+ STRING version: {{ candidate.version || '-' }}
+
+
+
+
No related STRING species was returned.
+
+
+
+
+
+
+
PPI/KaKs reference data and Ortholog mapping required
+
+
+
+
+
+
Target species
+
{{ confirmParams.ppiKaksTargetSpecies || 'Unknown' }}
+
+
+
Requested stages
+
{{ (confirmParams.ppiKaksRequestedStages || []).map(getStageLabel).join('、') || 'PPI/KaKs' }}
+
+
+
Current reference species
+
{{ confirmParams.ppiKaksCurrentReferenceSpecies || 'Not selected' }}
+
+
+
Ortholog mapping
+
{{ confirmParams.ppiKaksOrthologMapping || 'Not generated' }}
+
+
+
+
Data requirements
+
+
+ Required data
+ {{ String(item).toUpperCase() }}
+
+
+ Missing data
+ {{ String(item).toUpperCase() }}
+ None
+
+
+
+
+
Recommended related STRING species
+
+
+ -
+
+ {{ candidate.name }}
+ {{ candidate.code }}
+ STRING TaxID {{ candidate.taxon }}
+
+
+ LCA: {{ candidate.lca }}
+
Topology distance: {{ candidate.distance }}
+
Relationship: {{ candidate.relationship }}
+
Source: {{ candidate.source }}
+
STRING version: {{ candidate.version }}
+
+
+
+
No related STRING species was returned.
+
+
+
+
+
+
+
PPI and Ka/Ks Reference Species Data
+
+
@@ -5076,25 +5235,25 @@
-
目标物种
-
{{ confirmParams.ppiKaksTargetSpecies || '未识别' }}
+
Target species
+
{{ confirmParams.ppiKaksTargetSpecies || 'Unknown' }}
-
涉及阶段
+
Requested stages
{{ (confirmParams.ppiKaksRequestedStages || []).map(getStageLabel).join('、') || 'PPI/KaKs' }}
-
当前参考物种
-
{{ confirmParams.ppiKaksCurrentReferenceSpecies || '未选择' }}
+
Current reference species
+
{{ confirmParams.ppiKaksCurrentReferenceSpecies || 'Not selected' }}
-
Ortholog 映射
-
{{ confirmParams.ppiKaksOrthologMapping ? '已检测到' : '未生成' }}
+
Ortholog mapping
+
{{ confirmParams.ppiKaksOrthologMapping ? 'Detected' : 'Not generated' }}
-
推荐近源/同源参考物种
+
Recommended related reference species
@@ -5102,74 +5261,74 @@
{{ confirmParams.ppiKaksReferenceRecommendationCode }}
STRING taxon {{ confirmParams.ppiKaksReferenceRecommendationTaxon }}
-
{{ confirmParams.ppiKaksReferenceRecommendationMeta || '来自 STRING 物种列表的近源参考推荐' }}
+
{{ confirmParams.ppiKaksReferenceRecommendationMeta || 'Related reference recommended from the STRING species list' }}
-
未找到可直接下载的 STRING 近源参考物种。
+
No downloadable related STRING reference species was found.
-
数据需求
+
Data requirements
- 需要:
+ Required:
{{ item }}
pep
- 当前缺失:
+ Missing:
{{ item }}
- 无
+ None
-
处理方式
+
Action
- 下载/补齐推荐参考数据后继续
- 直接使用项目参考物种 {{ confirmParams.ppiKaksProjectReferenceSpecies }}
- 本次先跳过 PPI/KaKs
+ Download the recommended PEP and CDS, then continue
+ Use project reference {{ confirmParams.ppiKaksProjectReferenceSpecies }}
+ Skip PPI/KaKs for this run
- 系统会先准备参考 PEP/CDS,再由 Ortholog 生成映射,之后执行 PPI/KaKs。
+ PPI downloads PEP and CDS once. Ka/Ks reuses that bundle and its selected Ortholog mapping.
-
PPI/KaKs 下载失败兜底确认
+
PPI and Ka/Ks Download Failure Fallback
-
原推荐参考
+
Original recommendation
{{ confirmParams.ppiKaksReferenceRecommendationName || confirmParams.ppiKaksReferenceRecommendationText }}
{{ confirmParams.ppiKaksReferenceRecommendationCode }}
-
{{ confirmParams.ppiKaksReferenceRecommendationMeta || '下载未完成,不能直接用于本次 PPI/KaKs。' }}
+
{{ confirmParams.ppiKaksReferenceRecommendationMeta || 'The download is incomplete and cannot be used for this PPI/KaKs run.' }}
-
未记录推荐参考物种。
+
No recommended reference species was recorded.
-
下一步
+
Next step
- 使用拟南芥作为显式兜底
- 本次先跳过 PPI/KaKs
+ Use Arabidopsis thaliana as the explicit fallback
+ Skip PPI/KaKs for this run
- 拟南芥只会写入 Ortholog/PPI/KaKs 专用参考参数,不会把全流程其它分析阶段改成 At。
+ Arabidopsis is written only to the Ortholog/PPI/KaKs reference parameters; other stages are unchanged.
@@ -5923,6 +6082,17 @@
KaKs 参数
+
+
参考物种与同源映射来源
+
+
+ 复用本次 PPI 技术参考 {{ confirmParams.kaksPpiReferenceSpecies }} 及其专属映射
+
+
+ 使用项目参考物种 {{ confirmParams.kaksProjectReferenceSpecies }},单独生成 KaKs 映射
+
+
+
@@ -6371,7 +6541,9 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓
💡 提示:请先选择有效 VCF;当前不会自动执行。
- 💡 提示:下载失败后的拟南芥兜底必须手动确认,不会自动执行。
+ 💡 Tip: The Arabidopsis fallback requires manual confirmation and will not run automatically.
+ 💡 Tip: PEP and CDS will be downloaded together before Ortholog mapping and PPI.
+ 💡 Tip: PPI prepares PEP and CDS with its Ortholog mapping; Ka/Ks can reuse the same reference identity.
💡 提示:40秒内不操作将使用当前参数自动执行,可随时暂停倒计时后手动确认。
@@ -6382,19 +6554,28 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓
⏱️ {{ stageConfirmCountdown }}秒后自动执行
-
+
+
+
@@ -7952,8 +8133,10 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓
combo: '组合图',
reference_classification: '参考亚家族分类',
subfamily_confirm: '亚家族标签确认',
- ppi_kaks_reference_data: 'PPI/KaKs 参考数据',
- ppi_kaks_download_fallback: 'PPI/KaKs 兜底确认'
+ ppi_string_reference_confirmation: 'Confirm the STRING reference species for PPI',
+ ppi_kaks_reference_status: 'PPI/KaKs Reference Status',
+ ppi_kaks_reference_data: 'PPI and Ka/Ks Reference Species Data',
+ ppi_kaks_download_fallback: 'PPI and Ka/Ks Download Failure Fallback'
};
return labels[stageId] || stageId || '';
};
@@ -9311,7 +9494,9 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓
message: '',
percentage: 0,
indeterminate: true,
- status: ''
+ status: '',
+ bytesDownloaded: 0,
+ totalBytes: 0
});
// 共线性模糊意图选择
@@ -9738,7 +9923,7 @@ compare: [[flesh, peel]]`;
'ppi': ['identify'],
// Ka/Ks
- 'kaks': ['identify', 'ortholog'],
+ 'kaks': ['identify'],
// 组合图
'combo': ['identify', 'phylogeny', 'motif', 'structure', 'domain'],
@@ -9954,9 +10139,13 @@ compare: [[flesh, peel]]`;
ppiKaksReferenceRecommendationCode: '',
ppiKaksReferenceRecommendationTaxon: '',
ppiKaksReferenceRecommendationMeta: '',
+ ppiKaksReferenceCandidates: [],
ppiKaksDownloadError: '',
// KaKs 参数
kaksNeutralRate: 7.08e-9,
+ kaksReferenceSource: 'project',
+ kaksPpiReferenceSpecies: '',
+ kaksProjectReferenceSpecies: '',
// Genome Prepare 参数
genomePrepareType: 'auto',
scopeMode: '',
@@ -12118,6 +12307,13 @@ compare: [[flesh, peel]]`;
nextParams.referenceSpecies = [];
nextParams.comparativeSpecies = [];
['scopeId', 'scope_id', 'scopeFile', 'scope_file', 'memberSetId', 'member_set_id', 'stageContext', 'stage_context'].forEach(key => delete nextParams[key]);
+ } else if (stageId === "ppi") {
+ nextParams.referenceSpecies = [];
+ nextParams.refSpeciesList = [];
+ nextParams.comparativeSpecies = [];
+ nextParams.scopeMode = "target_only";
+ nextParams.analysisRoute = "single_species";
+ ['scopeId', 'scope_id', 'scopeFile', 'scope_file', 'memberSetId', 'member_set_id', 'stageContext', 'stage_context'].forEach(key => delete nextParams[key]);
} else if (stageId === "interspecies_synteny") {
nextParams.referenceSpecies = [...stageRefs];
nextParams.comparativeSpecies = [...stageComps];
@@ -12565,12 +12761,20 @@ compare: [[flesh, peel]]`;
const applyPpiOwnedOrthologFieldsToRequest = (requestBody = {}, stageId = '', stageParams = {}) => {
const owner = String(stageParams.dependencyOwner || requestBody.dependencyOwner || '').trim().toLowerCase();
const storageKey = String(stageParams.ppiReferenceStorageKey || requestBody.ppiReferenceStorageKey || '').trim();
- if (stageId !== 'ortholog' || owner !== 'ppi') return requestBody;
+ if (stageId !== 'ortholog' || !['ppi', 'kaks'].includes(owner)) return requestBody;
+ if (owner === 'kaks') {
+ requestBody.dependencyOwner = 'kaks';
+ requestBody.requestedStages = ['kaks'];
+ requestBody.autoInsertedFor = ['kaks'];
+ return requestBody;
+ }
requestBody.ppiReferenceTaxonId = stageParams.ppiReferenceTaxonId || requestBody.ppiReferenceTaxonId || '';
requestBody.ppiReferenceStorageKey = storageKey;
requestBody.ppiReferenceSelectionMode = stageParams.ppiReferenceSelectionMode || requestBody.ppiReferenceSelectionMode || '';
requestBody.ppiReferenceUserConfirmed = stageParams.ppiReferenceUserConfirmed === true || requestBody.ppiReferenceUserConfirmed === true;
requestBody.dependencyOwner = 'ppi';
+ requestBody.requestedStages = ['ppi'];
+ requestBody.autoInsertedFor = ['ppi'];
delete requestBody.referenceSpecies;
delete requestBody.refSpeciesList;
delete requestBody.scopeMode;
@@ -12580,18 +12784,53 @@ compare: [[flesh, peel]]`;
return requestBody;
};
+ const canonicalizePpiOwnerBiologicalScope = (requestBody = {}) => {
+ const targets = uniqueList(requestBody.targetSpecies || requestBody.target_species || []);
+ const prefix = String(requestBody.prefix || '').trim();
+ const family = String(
+ requestBody.geneFamily
+ || requestBody.family
+ || (prefix.includes('.') ? prefix.split('.').slice(1).join('.') : '')
+ ).trim();
+ requestBody.targetSpecies = targets;
+ requestBody.referenceSpecies = [];
+ requestBody.refSpeciesList = [];
+ requestBody.comparativeSpecies = [];
+ requestBody.scopeMode = 'target_only';
+ requestBody.scope_mode = 'target_only';
+ requestBody.analysisRoute = 'single_species';
+ requestBody.analysis_route = 'single_species';
+ requestBody.scopeFile = '';
+ requestBody.scope_file = '';
+ const scopeId = scopeIdForRoles(family, targets, [], [], 'target_only');
+ if (scopeId) {
+ requestBody.scopeId = scopeId;
+ requestBody.scope_id = scopeId;
+ const memberSet = memberSetForScope(scopeId, 'single_species', 'ppi');
+ requestBody.memberSetId = memberSet;
+ requestBody.member_set_id = memberSet;
+ }
+ return requestBody;
+ };
+
const applyPpiOwnerFieldsToRequest = (requestBody = {}, stageId = '', stageParams = {}, batchParams = {}, planParams = {}) => {
if (stageId !== 'ppi') return requestBody;
delete requestBody.ppiKaksReferenceSpecies;
delete requestBody.ppiReferenceSpecies;
delete requestBody.referenceBundleSpecies;
const firstDefined = (...values) => values.find(value => value !== undefined && value !== null && value !== '');
- requestBody.ppiReferenceTaxonId = firstDefined(stageParams.ppiReferenceTaxonId, batchParams.ppiReferenceTaxonId, planParams.ppiReferenceTaxonId, '');
- requestBody.ppiReferenceStorageKey = firstDefined(stageParams.ppiReferenceStorageKey, batchParams.ppiReferenceStorageKey, planParams.ppiReferenceStorageKey, '');
- requestBody.ppiReferenceSelectionMode = firstDefined(stageParams.ppiReferenceSelectionMode, batchParams.ppiReferenceSelectionMode, planParams.ppiReferenceSelectionMode, '');
- requestBody.ppiReferenceUserConfirmed = Boolean(firstDefined(stageParams.ppiReferenceUserConfirmed, batchParams.ppiReferenceUserConfirmed, planParams.ppiReferenceUserConfirmed, false));
+ const confirmedFallbackOwner = batchParams.ppiReferenceSelectionMode === 'model_fallback'
+ && batchParams.ppiReferenceUserConfirmed === true
+ && String(batchParams.ppiReferenceTaxonId || '').trim()
+ && String(batchParams.ppiReferenceStorageKey || '').trim();
+ const primaryOwner = confirmedFallbackOwner ? batchParams : stageParams;
+ const secondaryOwner = confirmedFallbackOwner ? stageParams : batchParams;
+ requestBody.ppiReferenceTaxonId = firstDefined(primaryOwner.ppiReferenceTaxonId, secondaryOwner.ppiReferenceTaxonId, planParams.ppiReferenceTaxonId, '');
+ requestBody.ppiReferenceStorageKey = firstDefined(primaryOwner.ppiReferenceStorageKey, secondaryOwner.ppiReferenceStorageKey, planParams.ppiReferenceStorageKey, '');
+ requestBody.ppiReferenceSelectionMode = firstDefined(primaryOwner.ppiReferenceSelectionMode, secondaryOwner.ppiReferenceSelectionMode, planParams.ppiReferenceSelectionMode, '');
+ requestBody.ppiReferenceUserConfirmed = Boolean(firstDefined(primaryOwner.ppiReferenceUserConfirmed, secondaryOwner.ppiReferenceUserConfirmed, planParams.ppiReferenceUserConfirmed, false));
requestBody.dependencyOwner = 'ppi';
- return requestBody;
+ return canonicalizePpiOwnerBiologicalScope(requestBody);
};
const applyGeneFamilyContextToRequest = (requestBody, stageId, stageParams = {}) => {
@@ -12618,20 +12857,24 @@ compare: [[flesh, peel]]`;
requestBody.geneFamily = firstDefined(stageParams.geneFamily, stageParams.family, stageContext.family, confirmParams.geneFamily, batchParams.geneFamily, batchPlanParams.geneFamily, '');
requestBody.targetSpecies = firstNonEmptyList(stageParams.targetSpecies, stageParams.target_species, stageContext.target_species, confirmParams.targetSpecies, batchParams.targetSpecies, batchPlanParams.targetSpecies);
requestBody.referenceSpecies = firstNonEmptyList(stageParams.referenceSpecies, stageParams.reference_species, stageContext.reference_species, confirmParams.referenceSpecies, batchParams.referenceSpecies, batchPlanParams.referenceSpecies);
- const technicalReferenceSpecies = firstNonEmptyList(
- stageParams.ppiKaksReferenceSpecies,
- stageParams.ppiReferenceSpecies,
- stageParams.referenceBundleSpecies,
- requestBody.ppiKaksReferenceSpecies,
- requestBody.ppiReferenceSpecies,
- requestBody.referenceBundleSpecies,
- batchParams.ppiKaksReferenceSpecies,
- batchParams.ppiReferenceSpecies,
- batchParams.referenceBundleSpecies,
- batchPlanParams.ppiKaksReferenceSpecies,
- batchPlanParams.ppiReferenceSpecies,
- batchPlanParams.referenceBundleSpecies
- );
+ const projectOwnedKaks = stageParams.kaksReferenceSource === 'project'
+ && (stageId === 'kaks' || stageParams.dependencyOwner === 'kaks');
+ const technicalReferenceSpecies = projectOwnedKaks
+ ? []
+ : firstNonEmptyList(
+ stageParams.ppiKaksReferenceSpecies,
+ stageParams.ppiReferenceSpecies,
+ stageParams.referenceBundleSpecies,
+ requestBody.ppiKaksReferenceSpecies,
+ requestBody.ppiReferenceSpecies,
+ requestBody.referenceBundleSpecies,
+ batchParams.ppiKaksReferenceSpecies,
+ batchParams.ppiReferenceSpecies,
+ batchParams.referenceBundleSpecies,
+ batchPlanParams.ppiKaksReferenceSpecies,
+ batchPlanParams.ppiReferenceSpecies,
+ batchPlanParams.referenceBundleSpecies
+ );
if (['ortholog', 'kaks'].includes(stageId) && technicalReferenceSpecies.length) {
requestBody.referenceSpecies = technicalReferenceSpecies;
requestBody.refSpeciesList = technicalReferenceSpecies;
@@ -13481,9 +13724,9 @@ compare: [[flesh, peel]]`;
const formatPpiReferenceRecommendations = (recommendations = []) => {
const items = Array.isArray(recommendations) ? recommendations : [];
- if (!items.length) return '无可用近源推荐';
+ if (!items.length) return 'No related reference recommendation is available';
return items.map((item, idx) => {
- const name = item.species_name || item.speciesCode || item.species_code || '未知物种';
+ const name = item.species_name || item.speciesCode || item.species_code || 'Unknown species';
const code = item.species_code || '';
const taxon = item.taxon_id || '';
const relation = item.relationship || '';
@@ -13492,6 +13735,30 @@ compare: [[flesh, peel]]`;
}).join('\n');
};
+ const normalizePpiReferenceCandidates = (source = {}, fallbackVersion = '') => {
+ const candidates = Array.isArray(source)
+ ? source
+ : (Array.isArray(source.candidates)
+ ? source.candidates
+ : (Array.isArray(source.species_resolution?.candidates)
+ ? source.species_resolution.candidates
+ : (Array.isArray(source.diagnostics?.reference_recommendations)
+ ? source.diagnostics.reference_recommendations
+ : [])));
+ const versionFallback = fallbackVersion || source.string_version || source.species_resolution?.string_version || '';
+ const text = value => value === undefined || value === null ? '' : String(value);
+ return candidates.map(candidate => ({
+ name: text(candidate.selected_string_name || candidate.species_name || candidate.name || 'Unknown species'),
+ code: text(candidate.species_code || candidate.speciesCode || candidate.code),
+ taxon: text(candidate.selected_string_taxon_id || candidate.taxon_id || candidate.string_taxon_id),
+ lca: text(candidate.lca_name || candidate.lca_taxon_name || candidate.lca_taxon_id),
+ distance: text(candidate.topology_distance ?? candidate.distance_edges ?? candidate.distance),
+ version: text(candidate.string_version || versionFallback),
+ relationship: text(candidate.relationship),
+ source: text(candidate.source)
+ }));
+ };
+
const formatPpiSpeciesResolutionBlockMessage = (resolution = {}) => {
const diagnostics = resolution.diagnostics || {};
const recommendations = diagnostics.reference_recommendations || [];
@@ -13501,17 +13768,17 @@ compare: [[flesh, peel]]`;
const targetText = (resolution.target_species || []).join(', ') || '-';
const stageText = (resolution.requested_stages || diagnostics.dependency_owner_stages || []).join(', ') || 'ppi';
const lines = [
- 'PPI/KaKs 暂时不能直接执行:参考物种数据还没准备好。',
- `目标物种:${targetText}`,
- `涉及阶段:${stageText}`,
- `需要的数据:${requiredBundle.join(', ') || 'pep'}${missingRequired.length ? `;当前缺失:${missingRequired.join(', ')}` : ''}`,
- `当前参考物种:${resolution.reference_species || '未选择'}`,
- `当前 Ortholog 映射:${resolution.ortholog_mapping_source || '未生成'}`,
+ 'PPI/KaKs cannot run yet because the reference species data are not ready.',
+ `Target species: ${targetText}`,
+ `Requested stages: ${stageText}`,
+ `Required data: PEP, CDS${missingRequired.length ? `; missing: ${missingRequired.join(', ')}` : ''}`,
+ `Current reference species: ${resolution.reference_species || 'Not selected'}`,
+ `Current Ortholog mapping: ${resolution.ortholog_mapping_source || 'Not generated'}`,
'',
- '推荐近源 STRING 参考物种:',
+ 'Recommended related STRING reference species:',
formatPpiReferenceRecommendations(recommendations),
'',
- '说明:PPI 需要先把目标基因通过 Ortholog 映射到 STRING 支持物种;Ka/Ks 还需要参考 CDS。你可以先准备/下载参考数据,也可以本次先跳过 PPI/KaKs,继续其它不依赖该数据的分析。'
+ 'PPI prepares reference PEP and CDS and generates its Ortholog mapping. Ka/Ks can then reuse that same bundle and mapping.'
];
return lines.join('\n');
};
@@ -13527,32 +13794,16 @@ compare: [[flesh, peel]]`;
? resolution.candidates
: (Array.isArray(resolution.species_resolution?.candidates) ? resolution.species_resolution.candidates : []);
if (!candidates.length) return null;
- const candidateLines = candidates.map((candidate, index) => {
- const name = candidate.selected_string_name || candidate.species_name || '-';
- const taxon = candidate.selected_string_taxon_id || candidate.taxon_id || '-';
- const lca = candidate.lca_name || candidate.lca_taxon_name || candidate.lca_taxon_id || '-';
- const distance = candidate.topology_distance ?? candidate.distance_edges ?? candidate.distance ?? '-';
- const version = candidate.string_version || resolution.string_version || resolution.species_resolution?.string_version || '-';
- return `${index + 1}. ${name} / STRING TaxID ${taxon} / LCA ${lca} / 拓扑边数 ${distance} / STRING ${version}`;
- });
const selected = candidates[0];
const selectedTaxon = String(selected.selected_string_taxon_id || selected.taxon_id || '').trim();
if (!selectedTaxon) return null;
- try {
- await ElMessageBox.confirm(
- [
- '目标物种不在 STRING 固定快照中。请确认是否采用排序第一的近源物种:',
- '',
- ...candidateLines,
- '',
- '确认后才会下载参考 PEP、生成 Ortholog,并执行 PPI。'
- ].join('\n'),
- '确认 PPI 的 STRING 近源参考物种',
- { type: 'warning', confirmButtonText: '确认第一候选', cancelButtonText: '取消 PPI' }
- );
- } catch (_err) {
- return null;
- }
+ const accepted = await showStageConfirmDialog({
+ id: 'ppi_string_reference_confirmation',
+ name: 'Confirm the STRING reference species for PPI',
+ dependencies: [],
+ params: { resolution, candidates }
+ }, 1, 1);
+ if (!accepted) return null;
return {
taxonId: selectedTaxon,
storageKey: `string_taxon_${selectedTaxon}`,
@@ -13570,17 +13821,17 @@ compare: [[flesh, peel]]`;
const stageText = (resolution.requested_stages || resolution.diagnostics?.dependency_owner_stages || []).join(', ') || 'ppi';
const recText = recommendation
? `${recommendation.species_name || recommendation.species_code} (${recommendation.species_code || '-'})${recommendation.taxon_id ? ` / STRING taxon ${recommendation.taxon_id}` : ''}`
- : '暂无可用推荐';
+ : 'No recommendation is available';
return [
- '目标物种当前不能直接用于 STRING PPI/KaKs。',
+ 'The target species cannot be used directly for STRING PPI/KaKs.',
'',
- `目标物种:${targetText}`,
- `涉及阶段:${stageText}`,
- `推荐近源参考:${recText}`,
- `需要数据:${requiredBundle.join(', ') || 'pep'}${missingRequired.length ? `;缺失:${missingRequired.join(', ')}` : ''}`,
+ `Target species: ${targetText}`,
+ `Requested stages: ${stageText}`,
+ `Recommended related reference: ${recText}`,
+ `Required data: PEP, CDS${missingRequired.length ? `; missing: ${missingRequired.join(', ')}` : ''}`,
'',
- '选择“下载推荐参考数据”后,系统会先下载参考 PEP/CDS,再重新检查并由 Ortholog 生成映射。',
- '选择“本次先跳过”时,PPI/KaKs 会标记为暂停,其它阶段继续执行。'
+ 'Download prepares PEP and CDS together, then generates Ortholog mapping.',
+ 'Skip pauses PPI/KaKs while other independent stages continue.'
].join('\n');
};
@@ -13591,7 +13842,7 @@ compare: [[flesh, peel]]`;
const missing = Array.isArray(resolution.missing_required_bundle)
? resolution.missing_required_bundle
: [];
- return bundle.length ? bundle : (missing.length ? missing : ['pep']);
+ return Array.from(new Set(['pep', 'cds', ...bundle, ...missing]));
};
const ppiKaksRecommendationText = (recommendation = {}) => {
@@ -13604,7 +13855,7 @@ compare: [[flesh, peel]]`;
return [main || code || name, reason].filter(Boolean).join(' / ');
};
- const referenceBundleErrorMessage = (data = {}, fallback = '参考数据下载失败') => {
+ const referenceBundleErrorMessage = (data = {}, fallback = 'Reference species data download failed') => {
const detail = data?.detail;
if (detail && typeof detail === 'object') {
return detail.message || detail.error || JSON.stringify(detail);
@@ -13619,7 +13870,7 @@ compare: [[flesh, peel]]`;
confirmParams.ppiKaksReferenceDecision = 'skip';
await showStageConfirmDialog({
id: 'ppi_kaks_reference_data',
- name: 'PPI/KaKs 参考数据',
+ name: 'PPI and Ka/Ks Reference Species Data',
dependencies: [],
params: {
resolution,
@@ -13635,7 +13886,7 @@ compare: [[flesh, peel]]`;
}
const accepted = await showStageConfirmDialog({
id: 'ppi_kaks_reference_data',
- name: 'PPI/KaKs 参考数据',
+ name: 'PPI and Ka/Ks Reference Species Data',
dependencies: [],
params: {
resolution,
@@ -13654,13 +13905,13 @@ compare: [[flesh, peel]]`;
const requestPpiDownloadFallbackDecision = async (error, resolution = {}, recommendation = {}, requestedStages = []) => {
const accepted = await showStageConfirmDialog({
id: 'ppi_kaks_download_fallback',
- name: 'PPI/KaKs 兜底确认',
+ name: 'PPI and Ka/Ks Download Failure Fallback',
dependencies: [],
params: {
resolution,
recommendation,
requestedStages,
- downloadError: error?.message || String(error || '参考数据下载失败'),
+ downloadError: error?.message || String(error || 'Reference species data download failed'),
defaultDecision: 'skip'
}
}, 1, 1);
@@ -13686,7 +13937,8 @@ compare: [[flesh, peel]]`;
};
const applyArabidopsisFallbackForPpiKaks = (requestedStages = [], reason = '') => {
- if (requestedStages.includes('ppi')) {
+ const appliesToPpi = requestedStages.includes('ppi');
+ if (appliesToPpi) {
applyPpiOwnerSelectionToBatch({
taxonId: '3702',
storageKey: 'At',
@@ -13694,10 +13946,7 @@ compare: [[flesh, peel]]`;
userConfirmed: true
}, {});
}
- if (requestedStages.includes('kaks')) {
- mergePpiReferenceSpeciesIntoBatch('At', ['kaks'], 'kaks_download_failed_arabidopsis_fallback', true);
- }
- const affected = new Set(['ortholog', ...(requestedStages || [])]);
+ const affected = new Set(appliesToPpi ? ['ortholog', 'ppi'] : []);
batchExecuteStages.value = (batchExecuteStages.value || []).map(stage => {
const stageId = getStageId(stage);
if (!affected.has(stageId)) return stage;
@@ -13711,7 +13960,6 @@ compare: [[flesh, peel]]`;
}
};
});
- clearPpiKaksReferencePause(requestedStages);
};
const resumePpiKaksPausedStages = async (requestedStages = [], reason = '') => {
@@ -13721,7 +13969,7 @@ compare: [[flesh, peel]]`;
clearPpiKaksReferencePause(requestedStages);
retryStages.forEach(stage => {
const stageId = getStageId(stage);
- if (!stageId || stageExecutionStatus.value[stageId] === 'completed') return;
+ if (!stageId || stageExecutionStatus.value[stageId] !== 'paused') return;
stageExecutionStatus.value[stageId] = 'pending';
});
if (isBatchExecuting.value) {
@@ -13739,38 +13987,80 @@ compare: [[flesh, peel]]`;
}
};
+ const ppiKaksFallbackRecoveryOwners = (
+ requestedStages = [], stages = [], statuses = {}, pauseReasons = {}
+ ) => {
+ const owners = new Set((requestedStages || []).filter(Boolean));
+ if (!owners.has('kaks') || owners.has('ppi')) return Array.from(owners);
+
+ const ppiStage = (stages || []).find(stage => {
+ const stageId = typeof stage === 'object' ? stage?.id : stage;
+ return stageId === 'ppi';
+ });
+ if (!ppiStage || statuses.ppi !== 'paused') return Array.from(owners);
+
+ const dependencies = typeof ppiStage === 'object' && Array.isArray(ppiStage.dependencies)
+ ? ppiStage.dependencies
+ : [];
+ if (!dependencies.includes('ortholog')) return Array.from(owners);
+
+ const pause = pauseReasons.ppi || {};
+ const inheritedOrthologPause = statuses.ortholog === 'paused'
+ && pause.type === 'dependency_paused'
+ && pause.reason === 'upstream_stage_paused';
+ const explicitPpiReferencePause = pause.type === 'ppi_kaks_reference_data_required';
+ if (inheritedOrthologPause || explicitPpiReferencePause) owners.add('ppi');
+ return Array.from(owners);
+ };
+
const handlePpiReferenceDownloadFailure = async (err, resolution = {}, recommendation = {}, requestedStages = []) => {
- const decision = await requestPpiDownloadFallbackDecision(err, resolution, recommendation, requestedStages);
+ const fallbackStages = ppiKaksFallbackRecoveryOwners(
+ requestedStages,
+ batchExecuteStages.value || [],
+ stageExecutionStatus.value || {},
+ stagePauseReasons.value || {}
+ );
+ const decision = await requestPpiDownloadFallbackDecision(err, resolution, recommendation, fallbackStages);
if (decision === 'ath') {
- applyArabidopsisFallbackForPpiKaks(requestedStages, err?.message || String(err || 'reference_bundle_download_failed'));
- addAgentMessage('assistant', '✅ 已确认使用拟南芥作为 PPI/KaKs 的显式兜底参考;该选择只写入 Ortholog/PPI/KaKs 专用参数,其它常规阶段不受影响。');
- if (requestedStages.includes('ppi')) {
- const requestBody = buildPpiKaksDependencyRequest(['ppi']);
- await orchestratePpiOwnerAfterReferenceReady({
- requestBody,
- requestedStages: ['ppi'],
- runPreflight: runPpiSpeciesResolutionPreflight,
- dependencyStatus: dependencyId => stageExecutionStatus.value[dependencyId],
- waitForDependency: waitForPpiProducerDependency,
- ensureOrtholog: async (nextResolution, nextRequest) => {
- const ready = ensureOrthologStageForPpiKaks(
- batchExecuteStages.value || [], ['ppi'], nextRequest, nextResolution
- );
- return {
- ready: Boolean(ready),
- producerStage: (batchExecuteStages.value || []).find(stage => getStageId(stage) === 'ortholog') || null
- };
- },
- executeProducer: executePpiOwnedOrthologProducer,
- resume: async () => resumePpiKaksPausedStages(['ppi'], '拟南芥兜底参考已确认')
- });
+ if (!fallbackStages.includes('ppi')) {
+ return false;
}
- if (requestedStages.includes('kaks')) {
- await resumePpiKaksPausedStages(['kaks'], '拟南芥兜底参考已确认');
+ applyArabidopsisFallbackForPpiKaks(['ppi'], err?.message || String(err || 'reference_bundle_download_failed'));
+ addAgentMessage('assistant', '✅ 已确认使用拟南芥作为 PPI 的显式兜底参考;该选择只写入 PPI 专用 Ortholog 参数,其它阶段不受影响。');
+ const requestBody = buildPpiKaksDependencyRequest(['ppi']);
+ const orchestrated = await orchestratePpiOwnerAfterReferenceReady({
+ requestBody,
+ requestedStages: ['ppi'],
+ runPreflight: runPpiSpeciesResolutionPreflight,
+ dependencyStatus: dependencyId => stageExecutionStatus.value[dependencyId],
+ waitForDependency: waitForPpiProducerDependency,
+ ensureOrtholog: async (nextResolution, nextRequest) => {
+ const ready = ensureOrthologStageForPpiKaks(
+ batchExecuteStages.value || [], ['ppi'], nextRequest, nextResolution
+ );
+ return {
+ ready: Boolean(ready),
+ producerStage: getPpiKaksInternalOrthologProducer('ppi')
+ };
+ },
+ executeProducer: executePpiOwnedOrthologProducer,
+ resume: async () => resumePpiKaksPausedStages(['ppi'], '拟南芥兜底参考已确认')
+ });
+ if (orchestrated !== true) {
+ const pauseMessage = '拟南芥兜底参考尚未形成可用的 PPI Ortholog 映射;PPI 保持暂停。';
+ storePpiKaksReferencePause(['ppi'], {}, pauseMessage);
+ addAgentMessage('assistant', `⏸️ ${pauseMessage}`);
+ return false;
}
return true;
}
const pauseMessage = `推荐参考数据下载失败:${err?.message || err}。已先暂停 Ortholog/PPI/KaKs,其它阶段继续;后续可上传参考 PEP/CDS 后重新执行。`;
+ if (fallbackStages.includes('ppi')) {
+ batchExecuteParams.value = {
+ ...(batchExecuteParams.value || {}),
+ ppiExplicitlySkipped: true
+ };
+ }
storePpiKaksReferencePause(requestedStages, resolution, pauseMessage);
addAgentMessage('assistant', `⏸️ ${pauseMessage}`);
return false;
@@ -13778,7 +14068,7 @@ compare: [[flesh, peel]]`;
const downloadRecommendedPpiReferenceBundle = async (resolution = {}, requestedStages = []) => {
const recommendation = preferredPpiReferenceRecommendation(resolution);
- if (!recommendation) throw new Error('没有可下载的推荐近源参考物种');
+ if (!recommendation) throw new Error('No downloadable related reference species is available');
const selectedTaxon = String(recommendation.selected_string_taxon_id || recommendation.taxon_id || '').trim();
const storageKey = String(resolution.ppi_reference_storage_key || (selectedTaxon ? `string_taxon_${selectedTaxon}` : recommendation.species_code || '')).trim();
const response = await fetch('/api/reference-bundle/download', {
@@ -13797,7 +14087,7 @@ compare: [[flesh, peel]]`;
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data.success === false) {
- throw new Error(referenceBundleErrorMessage(data, `参考数据下载失败(HTTP ${response.status})`));
+ throw new Error(referenceBundleErrorMessage(data, `Reference species data download failed (HTTP ${response.status})`));
}
if (data.async === true && data.task_id) {
return { recommendation, task: data };
@@ -13805,20 +14095,46 @@ compare: [[flesh, peel]]`;
return { recommendation, result: data };
};
+ const formatReferenceDownloadBytes = (bytes) => {
+ const value = Number(bytes);
+ if (!Number.isFinite(value) || value <= 0) return '';
+ const units = ['B', 'KB', 'MB', 'GB'];
+ const unitIndex = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
+ const scaled = value / (1024 ** unitIndex);
+ return `${unitIndex === 0 ? Math.round(scaled) : scaled.toFixed(1)} ${units[unitIndex]}`;
+ };
+
const updateReferenceBundleDownloadProgress = (data = {}, recommendation = {}) => {
const result = data.result || {};
const progress = data.progress || result.progress || {};
+ const previous = referenceBundleDownloadState.value || {};
const rawPercentage = progress.percentage ?? data.percentage ?? result.percentage;
- const percentage = Number.isFinite(Number(rawPercentage)) ? Math.max(0, Math.min(100, Math.round(Number(rawPercentage)))) : 0;
- const speciesLabel = recommendation.species_name || recommendation.species_code || data.species_name || data.species_code || result.species_name || result.species_code || '';
+ const status = String(data.status || result.status || 'running').toLowerCase();
+ const success = ['success', 'completed'].includes(status);
+ const percentage = success
+ ? 100
+ : (Number.isFinite(Number(rawPercentage)) ? Math.max(0, Math.min(100, Math.round(Number(rawPercentage)))) : 0);
+ const taskId = data.task_id || data.id || previous.taskId || '';
+ const isNewTask = Boolean(taskId && previous.taskId && taskId !== previous.taskId);
+ const rawBytesDownloaded = progress.bytes_downloaded ?? data.bytes_downloaded ?? result.bytes_downloaded;
+ const rawTotalBytes = progress.total_bytes ?? data.total_bytes ?? result.total_bytes;
+ const bytesDownloaded = Number.isFinite(Number(rawBytesDownloaded))
+ ? Math.max(0, Number(rawBytesDownloaded))
+ : (isNewTask ? 0 : Number(previous.bytesDownloaded || 0));
+ const totalBytes = Number.isFinite(Number(rawTotalBytes))
+ ? Math.max(0, Number(rawTotalBytes))
+ : (isNewTask ? 0 : Number(previous.totalBytes || 0));
+ const speciesLabel = recommendation.species_name || recommendation.species_code || data.species_name || data.species_code || result.species_name || result.species_code || previous.speciesLabel || '';
referenceBundleDownloadState.value = {
visible: true,
- taskId: data.task_id || data.id || referenceBundleDownloadState.value.taskId || '',
+ taskId,
speciesLabel,
- message: progress.message || data.message || result.message || '正在后台下载参考物种注释包',
+ message: progress.message || data.message || result.message || 'Downloading PPI and Ka/Ks reference species PEP and CDS',
percentage,
- indeterminate: progress.indeterminate !== undefined ? Boolean(progress.indeterminate) : rawPercentage === undefined || rawPercentage === null,
- status: String(data.status || result.status || 'running').toLowerCase()
+ indeterminate: success ? false : (progress.indeterminate !== undefined ? Boolean(progress.indeterminate) : rawPercentage === undefined || rawPercentage === null),
+ status,
+ bytesDownloaded,
+ totalBytes
};
};
@@ -13830,7 +14146,9 @@ compare: [[flesh, peel]]`;
message: '',
percentage: 0,
indeterminate: true,
- status: ''
+ status: '',
+ bytesDownloaded: 0,
+ totalBytes: 0
};
};
@@ -13840,7 +14158,7 @@ compare: [[flesh, peel]]`;
const response = await fetch(`/api/task/${encodeURIComponent(taskId)}`);
const data = await response.json().catch(() => ({}));
if (!response.ok) {
- throw new Error(referenceBundleErrorMessage(data, `参考数据下载状态查询失败(HTTP ${response.status})`));
+ throw new Error(referenceBundleErrorMessage(data, `Reference species data status request failed (HTTP ${response.status})`));
}
updateReferenceBundleDownloadProgress(data, options.recommendation || {});
const status = String(data.status || data.result?.status || '').toLowerCase();
@@ -13848,7 +14166,7 @@ compare: [[flesh, peel]]`;
return data.result || data;
}
if (['failed', 'failure', 'cancelled', 'revoked'].includes(status)) {
- const err = new Error(data.message || data.result?.message || data.result?.error || '参考数据下载失败');
+ const err = new Error(data.message || data.result?.message || data.result?.error || 'Reference species data download failed');
err.task = data;
err.result = data.result || {};
throw err;
@@ -13994,6 +14312,7 @@ compare: [[flesh, peel]]`;
suppressNeedsReferenceDataAlert: true
});
if (!preflight.allowed) return false;
+ let readyResolution = preflight.resolution;
if (preflight.resolution?.status === 'needs_ortholog') {
const identifyStatus = typeof options.dependencyStatus === 'function'
? String(options.dependencyStatus('identify') || '')
@@ -14013,18 +14332,60 @@ compare: [[flesh, peel]]`;
const producerSucceeded = await options.executeProducer(producerStage);
if (producerSucceeded !== true) return false;
}
+ const postProducerPreflight = await options.runPreflight(requestBody, {
+ requestedStages,
+ allowNeedsOrtholog: false,
+ ignoreExistingOrtholog: false,
+ suppressNeedsReferenceDataAlert: false
+ });
+ if (!postProducerPreflight.allowed || postProducerPreflight.resolution?.status !== 'ready') {
+ return false;
+ }
+ readyResolution = postProducerPreflight.resolution;
}
- await options.resume(preflight.resolution, requestBody);
+ await options.resume(readyResolution, requestBody);
return true;
};
+ const ppiKaksInternalOrthologIdentityKey = (producerStage = {}, fallbackOwner = 'ppi') => {
+ const producerParams = producerStage.params || {};
+ return [
+ String(producerParams.dependencyOwner || fallbackOwner || 'ppi').trim().toLowerCase(),
+ String(producerParams.ppiReferenceTaxonId || '').trim(),
+ String(producerParams.ppiReferenceStorageKey || '').trim()
+ ].join('::');
+ };
+
const executePpiOwnedOrthologProducer = async (producerStage) => {
if (!producerStage || getStageId(producerStage) !== 'ortholog') return false;
- if (stageExecutionStatus.value.ortholog !== 'running') {
- stageExecutionStatus.value.ortholog = 'pending';
+ const producerParams = producerStage.params || {};
+ const owner = String(producerParams.dependencyOwner || 'ppi').trim().toLowerCase();
+ const producerIdentity = ppiKaksInternalOrthologIdentityKey(producerStage, owner);
+ if (ppiKaksInternalOrthologStatus.get(producerIdentity) === 'completed') return true;
+ const inFlight = ppiKaksInternalOrthologPromises.get(producerIdentity);
+ if (inFlight) return inFlight;
+ const executionPromise = (async () => {
+ ppiKaksInternalOrthologStatus.set(producerIdentity, 'running');
+ try {
+ addAgentMessage('assistant', `⚙️ 正在内部生成 ${getStageLabel(owner)} 专属同源映射...`);
+ await executeStageDirect('ortholog', producerParams);
+ ppiKaksInternalOrthologStatus.set(producerIdentity, 'completed');
+ addAgentMessage('assistant', `✅ ${getStageLabel(owner)} 专属同源映射生成完成。`);
+ return true;
+ } catch (err) {
+ ppiKaksInternalOrthologStatus.set(producerIdentity, 'failed');
+ addAgentMessage('assistant', `❌ ${getStageLabel(owner)} 专属同源映射生成失败:${err?.message || err}`);
+ return false;
+ }
+ })();
+ ppiKaksInternalOrthologPromises.set(producerIdentity, executionPromise);
+ try {
+ return await executionPromise;
+ } finally {
+ if (ppiKaksInternalOrthologPromises.get(producerIdentity) === executionPromise) {
+ ppiKaksInternalOrthologPromises.delete(producerIdentity);
+ }
}
- await executeStagesWithDependencies([producerStage], new Set(['ortholog']));
- return stageExecutionStatus.value.ortholog === 'completed';
};
const waitForPpiProducerDependency = async (dependencyId) => {
@@ -14038,19 +14399,30 @@ compare: [[flesh, peel]]`;
if (!taskId) return;
(async () => {
try {
- updateReferenceBundleDownloadProgress({ status: 'running', task_id: taskId, message: '推荐参考数据正在后台下载' }, recommendation);
+ updateReferenceBundleDownloadProgress({ status: 'running', task_id: taskId, message: 'Downloading PPI and Ka/Ks reference species PEP and CDS' }, recommendation);
const result = await pollReferenceBundleDownloadTask(taskId, { recommendation });
- const refCode = recommendation.species_code || result.species_code || '';
- const ownerSelection = ppiOwnerSelectionFromResolution(resolution);
- if (requestedStages.includes('ppi') && ownerSelection) {
+ const storageKey = result.species_code || resolution.ppi_reference_storage_key || recommendation.species_code || '';
+ const displayCode = recommendation.species_code || result.species_code || '';
+ const recoveryStages = ppiKaksFallbackRecoveryOwners(
+ requestedStages,
+ batchExecuteStages.value || [],
+ stageExecutionStatus.value || {},
+ stagePauseReasons.value || {}
+ );
+ const ownerSelection = ppiOwnerSelectionFromResolution({
+ ...resolution,
+ selected_taxon_id: result.taxon_id || recommendation.selected_string_taxon_id || recommendation.taxon_id || resolution.selected_taxon_id || '',
+ ppi_reference_storage_key: storageKey,
+ ppi_reference_user_confirmed: true
+ });
+ if (recoveryStages.includes('ppi') && ownerSelection) {
applyPpiOwnerSelectionToBatch(ownerSelection, {});
}
- if (requestedStages.includes('kaks') && refCode) {
- mergePpiReferenceSpeciesIntoBatch(refCode, ['kaks']);
+ if (recoveryStages.includes('kaks') && storageKey) {
+ mergePpiReferenceSpeciesIntoBatch(storageKey, ['kaks']);
}
- clearReferenceBundleDownloadProgress();
- addAgentMessage('assistant', `✅ 推荐参考数据下载完成:${recommendation.species_name || result.species_name || refCode} (${refCode || 'reference'})。已写入 Ortholog/PPI/Ka/Ks 专用参考物种。`);
- if (requestedStages.includes('ppi')) {
+ addAgentMessage('assistant', `✅ PPI and Ka/Ks reference species data are ready: ${recommendation.species_name || result.species_name || displayCode} (${displayCode || 'reference'}).`);
+ if (recoveryStages.includes('ppi')) {
const requestBody = buildPpiKaksDependencyRequest(['ppi']);
await orchestratePpiOwnerAfterReferenceReady({
requestBody,
@@ -14064,18 +14436,18 @@ compare: [[flesh, peel]]`;
);
return {
ready: Boolean(ready),
- producerStage: (batchExecuteStages.value || []).find(stage => getStageId(stage) === 'ortholog') || null
+ producerStage: getPpiKaksInternalOrthologProducer('ppi')
};
},
executeProducer: executePpiOwnedOrthologProducer,
- resume: async () => resumePpiKaksPausedStages(['ppi'], '推荐参考数据已下载完成')
+ resume: async () => resumePpiKaksPausedStages(['ppi'], 'Reference PEP and CDS are ready')
});
}
- if (requestedStages.includes('kaks')) {
- await resumePpiKaksPausedStages(['kaks'], '推荐参考数据已下载完成');
+ if (recoveryStages.includes('kaks')) {
+ await resumePpiKaksPausedStages(['kaks'], 'Reference PEP and CDS are ready');
}
} catch (err) {
- updateReferenceBundleDownloadProgress({ status: 'failed', message: err?.message || '参考数据下载失败' }, recommendation);
+ updateReferenceBundleDownloadProgress({ ...(err?.task || {}), status: 'failed', message: err?.message || 'Reference species data download failed' }, recommendation);
await handlePpiReferenceDownloadFailure(err, resolution || err.result?.resolution || {}, recommendation, requestedStages);
}
})();
@@ -14089,7 +14461,8 @@ compare: [[flesh, peel]]`;
});
};
- const ppiNeedsNearReferenceDecision = (resolution = {}) => {
+ const ppiNeedsNearReferenceDecision = (resolution = {}, requestedStages = []) => {
+ if (!requestedStages.includes('ppi')) return false;
const selected = String(resolution.selected_string_species || '').toLowerCase();
return resolution.status === 'needs_ortholog'
&& selected === 'ath'
@@ -14115,10 +14488,15 @@ compare: [[flesh, peel]]`;
ppiReferenceStorageKey: requestBody.ppiReferenceStorageKey || requestBody.ppi_reference_storage_key || '',
ppiReferenceSelectionMode: requestBody.ppiReferenceSelectionMode || requestBody.ppi_reference_selection_mode || '',
ppiReferenceUserConfirmed: requestBody.ppiReferenceUserConfirmed === true || requestBody.ppi_reference_user_confirmed === true,
- dependencyOwner: requestBody.dependencyOwner || requestBody.dependency_owner || requestedStages[0] || 'ppi',
+ dependencyOwner: requestBody.dependencyOwner || requestBody.dependency_owner
+ || requestBody.orthologMappingOwner || requestBody.ortholog_mapping_owner
+ || requestedStages[0] || 'ppi',
ignoreExistingOrtholog: options.ignoreExistingOrtholog === true,
requestedStages
};
+ if (requestedStages.length === 1 && requestedStages[0] === 'ppi') {
+ canonicalizePpiOwnerBiologicalScope(payload);
+ }
const response = await fetch('/api/ppi/species-resolution', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -14126,35 +14504,37 @@ compare: [[flesh, peel]]`;
});
const resolution = await response.json().catch(() => ({}));
if (!response.ok || resolution.success === false) {
- const message = `PPI/KaKs 依赖预检失败:${resolution.message || resolution.detail || response.statusText || response.status}`;
- await ElMessageBox.alert(message, 'PPI/KaKs 预检失败', { type: 'error', confirmButtonText: '知道了' });
+ const message = `PPI/KaKs dependency preflight failed: ${resolution.message || resolution.detail || response.statusText || response.status}`;
+ await ElMessageBox.alert(message, 'PPI/KaKs preflight failed', { type: 'error', confirmButtonText: 'OK' });
return { allowed: false, resolution, message };
}
if (resolution.status === 'ready') {
return { allowed: true, resolution, message: '' };
}
if (options.allowNeedsOrtholog === true && resolution.status === 'needs_ortholog') {
- if (!ppiNeedsNearReferenceDecision(resolution)) {
+ if (!ppiNeedsNearReferenceDecision(resolution, requestedStages)) {
return { allowed: true, resolution, message: '' };
}
}
const message = formatPpiSpeciesResolutionBlockMessage(resolution);
if (
options.suppressNeedsReferenceDataAlert === true
- && (resolution.status === 'needs_reference_data' || ppiNeedsNearReferenceDecision(resolution))
+ && (resolution.status === 'needs_reference_data' || ppiNeedsNearReferenceDecision(resolution, requestedStages))
) {
return { allowed: false, resolution, message };
}
- await ElMessageBox.alert(message, 'PPI/KaKs 需要先处理参考物种/Ortholog', {
- type: resolution.status === 'invalid_mapping' ? 'error' : 'warning',
- confirmButtonText: '知道了'
- });
+ await showStageConfirmDialog({
+ id: 'ppi_kaks_reference_status',
+ name: 'PPI/KaKs Reference Status',
+ dependencies: [],
+ params: { resolution, requestedStages }
+ }, 1, 1);
addAgentMessage('assistant', message);
return { allowed: false, resolution, message };
};
const ppiKaksDependencyStages = new Set(['ppi', 'kaks']);
- const ppiKaksTemporaryCircuitBreakerEnabled = true;
+ const ppiKaksTemporaryCircuitBreakerEnabled = false;
const isPpiKaksTemporarilyUnavailable = stageId => (
ppiKaksTemporaryCircuitBreakerEnabled
&& ppiKaksDependencyStages.has(String(stageId || ''))
@@ -14284,26 +14664,33 @@ compare: [[flesh, peel]]`;
return true;
};
- const buildPpiKaksDependencyRequest = (stageIds = []) => {
+ const buildPpiKaksDependencyRequest = (stageIds = [], stageParamsOverride = null) => {
const batchParams = batchExecuteParams.value || {};
const planParams = batchParams.plan?.params || {};
const dependencyStage = (batchExecuteStages.value || []).find(stage => getStageId(stage) === stageIds[0]) || {};
- const stageParams = typeof dependencyStage === 'object' ? (dependencyStage.params || {}) : {};
+ const storedStageParams = typeof dependencyStage === 'object' ? (dependencyStage.params || {}) : {};
+ const stageParams = stageParamsOverride && typeof stageParamsOverride === 'object'
+ ? stageParamsOverride
+ : storedStageParams;
const ownerStageId = stageIds[0] || 'ppi';
const isPpiOwner = ownerStageId === 'ppi';
+ const mappingOwner = stageParams.orthologMappingOwner || ownerStageId;
+ const usesPpiOwner = mappingOwner === 'ppi';
const prefix = stageParams.prefix || batchParams.prefix || planParams.prefix || params.prefix || executionPrefix(ownerStageId);
- const technicalReferenceSpecies = firstNonEmptyList(
- stageParams.ppiKaksReferenceSpecies,
- stageParams.ppiReferenceSpecies,
- stageParams.referenceBundleSpecies,
- batchParams.ppiKaksReferenceSpecies,
- batchParams.ppiReferenceSpecies,
- batchParams.referenceBundleSpecies,
- planParams.ppiKaksReferenceSpecies,
- planParams.ppiReferenceSpecies,
- planParams.referenceBundleSpecies
- );
- return {
+ const technicalReferenceSpecies = (!isPpiOwner && stageParams.kaksReferenceSource === 'project')
+ ? []
+ : firstNonEmptyList(
+ stageParams.ppiKaksReferenceSpecies,
+ stageParams.ppiReferenceSpecies,
+ stageParams.referenceBundleSpecies,
+ batchParams.ppiKaksReferenceSpecies,
+ batchParams.ppiReferenceSpecies,
+ batchParams.referenceBundleSpecies,
+ planParams.ppiKaksReferenceSpecies,
+ planParams.ppiReferenceSpecies,
+ planParams.referenceBundleSpecies
+ );
+ const requestBody = {
stage: ownerStageId,
prefix,
scopeFile: stageParams.scopeFile || stageParams.scope_file || batchParams.scopeFile || batchParams.scope_file || planParams.scopeFile || planParams.scope_file || '',
@@ -14317,11 +14704,11 @@ compare: [[flesh, peel]]`;
ppiKaksReferenceSpecies: isPpiOwner ? [] : technicalReferenceSpecies,
ppiReferenceSpecies: isPpiOwner ? '' : (technicalReferenceSpecies[0] || stageParams.ppiReferenceSpecies || batchParams.ppiReferenceSpecies || planParams.ppiReferenceSpecies || ''),
referenceBundleSpecies: isPpiOwner ? '' : (technicalReferenceSpecies[0] || stageParams.referenceBundleSpecies || batchParams.referenceBundleSpecies || planParams.referenceBundleSpecies || ''),
- ppiReferenceTaxonId: isPpiOwner ? (stageParams.ppiReferenceTaxonId || batchParams.ppiReferenceTaxonId || planParams.ppiReferenceTaxonId || '') : '',
- ppiReferenceStorageKey: isPpiOwner ? (stageParams.ppiReferenceStorageKey || batchParams.ppiReferenceStorageKey || planParams.ppiReferenceStorageKey || '') : '',
- ppiReferenceSelectionMode: isPpiOwner ? (stageParams.ppiReferenceSelectionMode || batchParams.ppiReferenceSelectionMode || planParams.ppiReferenceSelectionMode || '') : '',
- ppiReferenceUserConfirmed: isPpiOwner && (stageParams.ppiReferenceUserConfirmed === true || batchParams.ppiReferenceUserConfirmed === true || planParams.ppiReferenceUserConfirmed === true),
- dependencyOwner: ownerStageId,
+ ppiReferenceTaxonId: usesPpiOwner ? (stageParams.ppiReferenceTaxonId || batchParams.ppiReferenceTaxonId || planParams.ppiReferenceTaxonId || '') : '',
+ ppiReferenceStorageKey: usesPpiOwner ? (stageParams.ppiReferenceStorageKey || batchParams.ppiReferenceStorageKey || planParams.ppiReferenceStorageKey || '') : '',
+ ppiReferenceSelectionMode: usesPpiOwner ? (stageParams.ppiReferenceSelectionMode || batchParams.ppiReferenceSelectionMode || planParams.ppiReferenceSelectionMode || '') : '',
+ ppiReferenceUserConfirmed: usesPpiOwner && (stageParams.ppiReferenceUserConfirmed === true || batchParams.ppiReferenceUserConfirmed === true || planParams.ppiReferenceUserConfirmed === true),
+ dependencyOwner: mappingOwner,
projectReferenceSpecies: firstNonEmptyList(
stageParams.primaryReferenceSpecies,
batchParams.primaryReferenceSpecies,
@@ -14333,8 +14720,37 @@ compare: [[flesh, peel]]`;
geneFamily: stageParams.geneFamily || stageParams.family || batchParams.geneFamily || planParams.geneFamily || params.geneFamily || ((prefix || '').split('.')[1] || ''),
requestedStages: stageIds
};
+ return isPpiOwner ? canonicalizePpiOwnerBiologicalScope(requestBody) : requestBody;
};
+ const ppiKaksInternalOrthologProducers = new Map();
+ const ppiKaksInternalOrthologStatus = new Map();
+ const ppiKaksInternalOrthologPromises = new Map();
+ const rememberPpiKaksInternalOrthologProducer = (owner, producerStage) => {
+ const key = String(owner || '').trim().toLowerCase();
+ if (!key) return null;
+ if (!producerStage) {
+ const previousProducer = ppiKaksInternalOrthologProducers.get(key);
+ ppiKaksInternalOrthologProducers.delete(key);
+ if (previousProducer) {
+ const previousIdentity = ppiKaksInternalOrthologIdentityKey(previousProducer, key);
+ if (!ppiKaksInternalOrthologPromises.has(previousIdentity)) {
+ ppiKaksInternalOrthologStatus.delete(previousIdentity);
+ }
+ }
+ return null;
+ }
+ const producerIdentity = ppiKaksInternalOrthologIdentityKey(producerStage, key);
+ ppiKaksInternalOrthologProducers.set(key, producerStage);
+ if (!ppiKaksInternalOrthologPromises.has(producerIdentity)) {
+ ppiKaksInternalOrthologStatus.set(producerIdentity, 'pending');
+ }
+ return producerStage;
+ };
+ const getPpiKaksInternalOrthologProducer = (owner) => (
+ ppiKaksInternalOrthologProducers.get(String(owner || '').trim().toLowerCase()) || null
+ );
+
const buildPpiOwnedOrthologStage = (requestBody = {}, ownerState = {}, requestedStages = ['ppi']) => {
const storageKey = String(
ownerState.ppiReferenceStorageKey
@@ -14369,6 +14785,19 @@ compare: [[flesh, peel]]`;
return { id: 'ortholog', name: 'Ortholog', dependencies: ['identify'], params };
};
+ const buildKaksOwnedOrthologStage = (stageParams = {}) => ({
+ id: 'ortholog',
+ name: 'Ortholog',
+ dependencies: ['identify'],
+ params: {
+ ...stageParams,
+ dependencyOwner: 'kaks',
+ autoInsertedFor: ['kaks'],
+ requestedStages: ['kaks'],
+ referencePolicySource: 'kaks_owner_ortholog_producer'
+ }
+ });
+
const attachOrthologDependencyToOwners = (requestedStages = []) => {
batchExecuteStages.value = (batchExecuteStages.value || []).map(stage => {
const stageId = getStageId(stage);
@@ -14387,77 +14816,32 @@ compare: [[flesh, peel]]`;
};
const ensureOrthologStageForPpiKaks = (stages, requestedStages, requestBody, resolution = {}) => {
- const stageIds = stages.map(stage => getStageId(stage)).filter(Boolean);
const recoveredReferenceApplied = applyResolvedPpiReferenceToBatch(resolution, requestedStages, requestBody);
if (recoveredReferenceApplied) {
addAgentMessage('assistant', `✅ PPI/KaKs 前置数据检查通过:检测到已下载近源参考 ${resolution.reference_species},将用该参考先生成 Ortholog mapping。`);
}
const mappingLabel = ppiOrthologMappingLabel(resolution);
if (mappingLabel && resolution?.status !== 'needs_ortholog') {
+ requestedStages.forEach(owner => rememberPpiKaksInternalOrthologProducer(owner, null));
addAgentMessage('assistant', `✅ PPI/KaKs 前置数据检查通过:检测到已有 Ortholog 映射(${mappingLabel}),将复用该映射执行 ${requestedStages.join('、')}。`);
return true;
}
- attachOrthologDependencyToOwners(requestedStages);
- if (stageIds.includes('ortholog')) {
- if (requestedStages.includes('ppi')) {
- const existingOrtholog = (batchExecuteStages.value || stages).find(stage => getStageId(stage) === 'ortholog');
- const existingOwners = ppiKaksOwnedOrthologOwners(existingOrtholog, batchExecuteParams.value || {});
- if (!existingOwners.length && (batchExecuteParams.value?.intentStages || []).includes('ortholog')) {
- addAgentMessage('assistant', '⏸️ 已保留用户显式请求的独立 Ortholog;PPI 所需的专属参考映射本次单独暂停。');
- return false;
- }
- const ownerState = {
- ppiReferenceTaxonId: requestBody.ppiReferenceTaxonId || '',
- ppiReferenceStorageKey: requestBody.ppiReferenceStorageKey || '',
- ppiReferenceSelectionMode: requestBody.ppiReferenceSelectionMode || '',
- ppiReferenceUserConfirmed: requestBody.ppiReferenceUserConfirmed === true
- };
- batchExecuteStages.value = (batchExecuteStages.value || stages).map(stage => (
- getStageId(stage) === 'ortholog'
- ? buildPpiOwnedOrthologStage(requestBody, ownerState, requestedStages)
- : stage
- ));
- }
- addAgentMessage('assistant', '✅ PPI/KaKs 前置数据检查通过:当前批次已有 Ortholog,将先生成同源映射再继续下游分析。');
- return true;
- }
- const insertAt = Math.max(0, stages.findIndex(stage => ppiKaksDependencyStages.has(getStageId(stage))));
- const orthologStage = requestedStages.includes('ppi')
- ? buildPpiOwnedOrthologStage(requestBody, requestBody, requestedStages)
- : {
- id: 'ortholog',
- name: 'Ortholog',
- dependencies: ['identify'],
- params: {
- ...requestBody,
- autoInsertedFor: requestedStages,
- requestedStages,
- referencePolicySource: 'ppi_kaks_dependency_preflight'
- }
- };
- const nextStages = [...(batchExecuteStages.value || stages)];
- nextStages.splice(insertAt < 0 ? 0 : insertAt, 0, orthologStage);
- batchExecuteStages.value = nextStages;
- stageExecutionStatus.value.ortholog = 'pending';
- if (Array.isArray(batchExecuteParams.value?.completed_stages)) {
- batchExecuteParams.value = {
- ...(batchExecuteParams.value || {}),
- completed_stages: batchExecuteParams.value.completed_stages.filter(stageId => stageId !== 'ortholog')
- };
- }
+ if (!requestedStages.includes('ppi')) return true;
+ const orthologStage = buildPpiOwnedOrthologStage(requestBody, requestBody, ['ppi']);
+ rememberPpiKaksInternalOrthologProducer('ppi', orthologStage);
const reason = resolution?.issue_code === 'stale_ortholog_reference_conflict'
? '已有 Ortholog 映射与当前 PPI/KaKs 参考物种不一致'
: '缺少可用同源映射';
- addAgentMessage('assistant', `✅ PPI/KaKs 前置数据检查通过:${reason},已自动插入 Ortholog,将先生成 mapping 再执行 ${requestedStages.join('、')}。`);
+ addAgentMessage('assistant', `✅ PPI 前置数据检查通过:${reason};执行 PPI 时将先在内部生成专属同源映射。`);
return true;
};
- const ensurePpiKaksDependencyOwner = async (ownerStageId) => {
+ const ensurePpiKaksDependencyOwner = async (ownerStageId, stageParamsOverride = null) => {
const stages = batchExecuteStages.value || [];
const stageIds = stages.map(stage => getStageId(stage)).filter(Boolean);
if (!ppiKaksDependencyStages.has(ownerStageId) || !stageIds.includes(ownerStageId)) return true;
const requestedStages = [ownerStageId];
- const requestBody = buildPpiKaksDependencyRequest(requestedStages);
+ const requestBody = buildPpiKaksDependencyRequest(requestedStages, stageParamsOverride);
let preflight = await runPpiSpeciesResolutionPreflight(requestBody, {
requestedStages,
allowNeedsOrtholog: true,
@@ -14468,6 +14852,10 @@ compare: [[flesh, peel]]`;
const ownerSelection = await confirmNearestPpiReferenceCandidate(preflight.resolution);
if (!ownerSelection) {
const pauseMessage = 'PPI 近源 STRING 参考物种尚未确认;本次不提交 Ortholog 或 PPI。';
+ batchExecuteParams.value = {
+ ...(batchExecuteParams.value || {}),
+ ppiExplicitlySkipped: true
+ };
storePpiKaksReferencePause(requestedStages, preflight.resolution, pauseMessage);
addAgentMessage('assistant', `⏸️ ${pauseMessage}`);
return true;
@@ -14484,9 +14872,15 @@ compare: [[flesh, peel]]`;
!preflight.allowed
&& (
preflight.resolution?.status === 'needs_reference_data'
- || ppiNeedsNearReferenceDecision(preflight.resolution)
+ || ppiNeedsNearReferenceDecision(preflight.resolution, requestedStages)
)
) {
+ if (ownerStageId === 'kaks') {
+ const pauseMessage = 'Ka/Ks cannot reuse the selected PPI reference bundle or Ortholog mapping. Ka/Ks will not start another download.';
+ storePpiKaksReferencePause(requestedStages, preflight.resolution, pauseMessage);
+ addAgentMessage('assistant', `⏸️ ${pauseMessage}`);
+ return false;
+ }
const decision = await requestPpiReferenceDataDecision(preflight.resolution, requestBody);
if (decision === 'project_reference') {
if (ownerStageId === 'ppi') {
@@ -14515,7 +14909,7 @@ compare: [[flesh, peel]]`;
}
if (decision === 'download') {
try {
- addAgentMessage('assistant', '⬇️ 正在提交推荐近源参考物种注释包后台下载;Ortholog/PPI/KaKs 将等待参考数据,其它阶段继续执行。');
+ addAgentMessage('assistant', '⬇️ Starting PPI and Ka/Ks reference species PEP/CDS download. PPI and Ka/Ks will wait; other stages continue.');
const download = await downloadRecommendedPpiReferenceBundle(preflight.resolution, requestedStages);
if (download.task?.task_id) {
const recommendation = download.recommendation || {};
@@ -14534,18 +14928,33 @@ compare: [[flesh, peel]]`;
}
const recommendation = download.recommendation || {};
const result = download.result || {};
- const refCode = recommendation.species_code || result.species_code || '';
- const ownerSelection = ppiOwnerSelectionFromResolution(preflight.resolution);
- if (ownerStageId === 'ppi' && ownerSelection) {
- applyPpiOwnerSelectionToBatch(ownerSelection, requestBody);
+ const storageKey = result.species_code || preflight.resolution.ppi_reference_storage_key || recommendation.species_code || '';
+ const displayCode = recommendation.species_code || result.species_code || '';
+ const recoveryStages = ppiKaksFallbackRecoveryOwners(
+ requestedStages,
+ batchExecuteStages.value || [],
+ stageExecutionStatus.value || {},
+ stagePauseReasons.value || {}
+ );
+ const ownerSelection = ppiOwnerSelectionFromResolution({
+ ...preflight.resolution,
+ selected_taxon_id: result.taxon_id || recommendation.selected_string_taxon_id || recommendation.taxon_id || preflight.resolution.selected_taxon_id || '',
+ ppi_reference_storage_key: storageKey,
+ ppi_reference_user_confirmed: true
+ });
+ if (recoveryStages.includes('ppi') && ownerSelection) {
+ applyPpiOwnerSelectionToBatch(ownerSelection, ownerStageId === 'ppi' ? requestBody : {});
}
- if (ownerStageId === 'kaks' && refCode) {
- mergePpiReferenceSpeciesIntoBatch(refCode, ['kaks']);
+ if (recoveryStages.includes('kaks') && storageKey) {
+ mergePpiReferenceSpeciesIntoBatch(storageKey, ['kaks']);
}
- addAgentMessage('assistant', `✅ 推荐参考数据下载完成:${recommendation.species_name || refCode} (${refCode})。已写入 Ortholog/PPI/Ka/Ks 专用参考物种。`);
- if (ownerStageId === 'ppi') {
+ addAgentMessage('assistant', `✅ 推荐参考数据下载完成:${recommendation.species_name || displayCode} (${displayCode})。已写入 Ortholog/PPI/Ka/Ks 专用参考物种。`);
+ if (recoveryStages.includes('ppi')) {
+ const ppiRequestBody = ownerStageId === 'ppi'
+ ? requestBody
+ : buildPpiKaksDependencyRequest(['ppi']);
await orchestratePpiOwnerAfterReferenceReady({
- requestBody,
+ requestBody: ppiRequestBody,
requestedStages: ['ppi'],
runPreflight: runPpiSpeciesResolutionPreflight,
dependencyStatus: dependencyId => stageExecutionStatus.value[dependencyId],
@@ -14556,13 +14965,14 @@ compare: [[flesh, peel]]`;
);
return {
ready: Boolean(ready),
- producerStage: (batchExecuteStages.value || []).find(stage => getStageId(stage) === 'ortholog') || null
+ producerStage: getPpiKaksInternalOrthologProducer('ppi')
};
},
executeProducer: executePpiOwnedOrthologProducer,
resume: async () => resumePpiKaksPausedStages(['ppi'], '推荐参考数据已下载完成')
});
- } else {
+ }
+ if (recoveryStages.includes('kaks')) {
await resumePpiKaksPausedStages(['kaks'], '推荐参考数据已下载完成');
}
return true;
@@ -14604,7 +15014,7 @@ compare: [[flesh, peel]]`;
const ensureOrthologProducerForPpiKaks = async () => {
const requestedStages = (batchExecuteStages.value || [])
.map(stage => getStageId(stage))
- .filter(stageId => ppiKaksDependencyStages.has(stageId));
+ .filter(stageId => stageId === 'ppi');
return preflightPpiKaksDependencyOwners(requestedStages, async ownerStageId => {
try {
const ready = await ensurePpiKaksDependencyOwner(ownerStageId);
@@ -14626,6 +15036,15 @@ compare: [[flesh, peel]]`;
});
};
+ const prepareKaksSelectedReference = async (stageParams = {}) => {
+ const ready = await ensurePpiKaksDependencyOwner('kaks', stageParams);
+ if (!ready || ppiKaksDependencyPauses().kaks) return false;
+ if (stageParams.orthologMappingOwner === 'ppi') return true;
+ const producerStage = buildKaksOwnedOrthologStage(stageParams);
+ rememberPpiKaksInternalOrthologProducer('kaks', producerStage);
+ return executePpiOwnedOrthologProducer(producerStage);
+ };
+
const submitExecuteRequestWithAutoAnnotation = async (requestBody, stageId) => {
const currentStageId = stageId || requestBody.stage || '';
@@ -14681,7 +15100,7 @@ compare: [[flesh, peel]]`;
return {
success: false,
status: preflight.resolution?.status || 'ppi_kaks_dependency_resolution_blocked',
- message: preflight.message || 'PPI/KaKs 参考数据/同源映射预检未通过。',
+ message: preflight.message || 'PPI/KaKs reference data or Ortholog mapping preflight failed.',
ppi_species_resolution: preflight.resolution
};
}
@@ -15174,10 +15593,12 @@ compare: [[flesh, peel]]`;
runningStages.value.clear();
// 启动依赖驱动的并行执行
- await executeStagesWithDependencies(stages, plannedStageIds);
-
- isBatchExecuting.value = false;
- currentBatchStage.value = '';
+ try {
+ await executeStagesWithDependencies(stages, plannedStageIds);
+ } finally {
+ isBatchExecuting.value = false;
+ currentBatchStage.value = '';
+ }
if (!shouldStopBatchExecute.value) {
// 只统计当前批次 stages 中的 completed 数量(避免累积历史状态)
@@ -15212,6 +15633,13 @@ compare: [[flesh, peel]]`;
}
};
+ const ppiSettledForKaks = (stages = [], statuses = {}, batchParams = {}) => {
+ const hasPpi = (stages || []).some(stage => getStageId(stage) === 'ppi');
+ if (!hasPpi) return true;
+ if (batchParams.ppiExplicitlySkipped === true) return true;
+ return ['completed', 'failed'].includes(String(statuses.ppi || ''));
+ };
+
// 依赖驱动的并行执行核心逻辑
const executeStagesWithDependencies = async (stages, plannedStageIds = null) => {
const currentPlannedStageIds = plannedStageIds || new Set(stages.map(stage => getStageId(stage)).filter(Boolean));
@@ -15261,6 +15689,11 @@ compare: [[flesh, peel]]`;
if (stageId === 'rnaseq_fgsea' && !currentPlannedStageIds.has('rnaseq_kegg_enrich')) {
deps = deps.filter(dep => dep !== 'rnaseq_kegg_enrich');
}
+ if (stageId === 'kaks' && !ppiSettledForKaks(
+ stages,
+ stageExecutionStatus.value,
+ batchExecuteParams.value || {}
+ )) return false;
const depsCompleted = deps.every(dep => (
stageExecutionStatus.value[dep] === 'completed' ||
(!currentPlannedStageIds.has(dep) && stageExecutionStatus.value[dep] !== 'failed')
@@ -15393,6 +15826,20 @@ compare: [[flesh, peel]]`;
addAgentMessage("assistant", "亚家族标签确认被跳过,已停止依赖 confirmed_subfamily.tsv 的后续阶段;如需普通无亚家族标签分析,请重新发起并明确说明“不需要亚家族”。");
};
+ const kaksReferenceChoiceParams = (source = 'project', ppiReference = '', projectReference = '') => {
+ const usePpi = source === 'ppi' && Boolean(String(ppiReference || '').trim());
+ const reference = String(usePpi ? ppiReference : projectReference || '').trim();
+ return {
+ kaksReferenceSource: usePpi ? 'ppi' : 'project',
+ referenceSpecies: reference ? [reference] : [],
+ refSpeciesList: reference ? [reference] : [],
+ ppiKaksReferenceSpecies: usePpi ? [reference] : [],
+ ppiReferenceSpecies: usePpi ? reference : '',
+ referenceBundleSpecies: usePpi ? reference : '',
+ orthologMappingOwner: usePpi ? 'ppi' : 'kaks'
+ };
+ };
+
const confirmedStageParamsForExecution = (stageId, stageParams = {}) => {
const merged = { ...(stageParams || {}) };
if (confirmParams.prefix) merged.prefix = confirmParams.prefix;
@@ -15465,6 +15912,14 @@ compare: [[flesh, peel]]`;
merged.tfbsTopMotifs = Number(confirmParams.tfbsTopMotifs || 30);
merged.tfbsScanBothStrands = confirmParams.tfbsScanBothStrands !== false;
}
+ if (stageId === 'kaks') {
+ merged.kaksNeutralRate = confirmParams.kaksNeutralRate || 7.08e-9;
+ Object.assign(merged, kaksReferenceChoiceParams(
+ confirmParams.kaksReferenceSource,
+ confirmParams.kaksPpiReferenceSpecies,
+ confirmParams.kaksProjectReferenceSpecies
+ ));
+ }
return merged;
};
@@ -15518,6 +15973,7 @@ compare: [[flesh, peel]]`;
stageExecutionStatus.value[stageId] = 'running';
clearStagePause(stageId);
currentBatchStage.value = stageId;
+ let confirmedStageParams = {};
console.log(`[DEBUG-STAGE] ${stageId}: added to runningStages, status set to running`);
console.log(`[DEBUG-STAGE] runningStages:`, Array.from(runningStages.value));
@@ -15555,10 +16011,41 @@ compare: [[flesh, peel]]`;
// 执行阶段
console.log(`[DEBUG-STAGE] ${stageId}: calling executeSingleStageWithParams`);
const stageParams = (typeof stage === 'object' ? (stage.params || {}) : {});
- const confirmedStageParams = confirmedStageParamsForExecution(stageAction, stageParams);
+ confirmedStageParams = confirmedStageParamsForExecution(stageAction, stageParams);
if (typeof stage === 'object') {
stage.params = { ...(stage.params || {}), ...confirmedStageParams };
}
+ if (stageAction === 'ppi') {
+ const producerStage = getPpiKaksInternalOrthologProducer('ppi');
+ if (producerStage) {
+ const producerSucceeded = await executePpiOwnedOrthologProducer(producerStage);
+ if (!producerSucceeded) {
+ throw new Error('PPI 专属同源映射生成失败,PPI 未提交。');
+ }
+ }
+ const readinessRequest = buildPpiKaksDependencyRequest(['ppi']);
+ const readiness = await runPpiSpeciesResolutionPreflight(readinessRequest, {
+ requestedStages: ['ppi'],
+ allowNeedsOrtholog: false,
+ ignoreExistingOrtholog: false,
+ suppressNeedsReferenceDataAlert: false
+ });
+ if (!readiness.allowed || readiness.resolution?.status !== 'ready') {
+ throw new Error('PPI 专属同源映射完成后仍未通过同一身份预检,PPI 未提交。');
+ }
+ }
+ if (stageAction === 'kaks') {
+ const kaksReferenceReady = await prepareKaksSelectedReference(confirmedStageParams);
+ if (!kaksReferenceReady) {
+ stageExecutionStatus.value[stageId] = 'paused';
+ recordStagePause(stageId, ppiKaksDependencyPauses().kaks || {
+ type: 'ppi_kaks_reference_data_required',
+ reason: 'kaks_selected_reference_not_ready',
+ message: 'KaKs 选择的参考物种或专属同源映射尚未准备好。'
+ });
+ return;
+ }
+ }
const expressionPause = shouldPauseForExpressionDataSource(stageAction, confirmedStageParams);
if (expressionPause) {
stageExecutionStatus.value[stageId] = 'paused';
@@ -15822,6 +16309,24 @@ compare: [[flesh, peel]]`;
confirmParams.identifySpeciesOptions = null;
confirmParams.geneFamilyScopeOptions = null;
confirmParams.pipelineReferenceSpecies = uniqueList(mergedStageParams.referenceSpecies || batchExecuteParams.value.referenceSpecies || []);
+ if (stageId === 'ppi_string_reference_confirmation' || stageId === 'ppi_kaks_reference_status') {
+ const resolution = mergedStageParams.resolution || {};
+ const bundleStatus = resolution.reference_bundle_status || resolution.diagnostics?.reference_bundle_status || {};
+ const targetSpecies = mergedStageParams.targetSpecies || resolution.target_species || resolution.targetSpecies || '';
+ confirmParams.ppiKaksTargetSpecies = Array.isArray(targetSpecies) ? targetSpecies.join(', ') : targetSpecies;
+ confirmParams.ppiKaksRequestedStages = Array.isArray(mergedStageParams.requestedStages)
+ ? [...mergedStageParams.requestedStages]
+ : (Array.isArray(resolution.requested_stages) ? [...resolution.requested_stages] : ['ppi']);
+ confirmParams.ppiKaksRequiredFiles = ppiKaksRequiredFilesFromResolution(resolution);
+ confirmParams.ppiKaksMissingFiles = Array.isArray(bundleStatus.missing_required) ? [...bundleStatus.missing_required] : [];
+ confirmParams.ppiKaksResolutionStatus = resolution.reason || resolution.message || resolution.status || '';
+ confirmParams.ppiKaksCurrentReferenceSpecies = resolution.reference_species || '';
+ confirmParams.ppiKaksOrthologMapping = resolution.ortholog_mapping_source || '';
+ confirmParams.ppiKaksReferenceCandidates = normalizePpiReferenceCandidates(
+ Array.isArray(mergedStageParams.candidates) ? mergedStageParams.candidates : resolution,
+ resolution.string_version || resolution.species_resolution?.string_version || ''
+ );
+ }
if (stageId === 'ppi_kaks_reference_data') {
const resolution = mergedStageParams.resolution || {};
const recommendation = mergedStageParams.recommendation || preferredPpiReferenceRecommendation(resolution) || {};
@@ -15837,7 +16342,7 @@ compare: [[flesh, peel]]`;
? [...mergedStageParams.requiredFiles]
: ppiKaksRequiredFilesFromResolution(resolution);
confirmParams.ppiKaksMissingFiles = Array.isArray(bundleStatus.missing_required) ? [...bundleStatus.missing_required] : [];
- confirmParams.ppiKaksResolutionStatus = resolution.reason || resolution.message || resolution.status || '需要准备参考数据和 Ortholog 映射';
+ confirmParams.ppiKaksResolutionStatus = resolution.reason || resolution.message || resolution.status || 'Reference PEP/CDS and Ortholog mapping are required';
confirmParams.ppiKaksCurrentReferenceSpecies = resolution.reference_species || '';
confirmParams.ppiKaksOrthologMapping = resolution.ortholog_mapping_source || '';
confirmParams.ppiKaksReferenceRecommendationText = ppiKaksRecommendationText(recommendation);
@@ -15967,6 +16472,27 @@ compare: [[flesh, peel]]`;
confirmParams.ppiSpecies = 'auto';
confirmParams.ppiScore = 200;
}
+ if (stageId === 'kaks') {
+ const ppiReference = stageExecutionStatus.value.ppi === 'completed'
+ ? String(
+ mergedStageParams.ppiReferenceStorageKey
+ || batchExecuteParams.value.ppiReferenceStorageKey
+ || batchExecuteParams.value.plan?.params?.ppiReferenceStorageKey
+ || ''
+ ).trim()
+ : '';
+ const projectReference = firstNonEmptyList(
+ mergedStageParams.primaryReferenceSpecies,
+ mergedStageParams.referenceSpecies,
+ batchExecuteParams.value.primaryReferenceSpecies,
+ batchExecuteParams.value.plan?.params?.primaryReferenceSpecies,
+ batchExecuteParams.value.plan?.params?.referenceSpecies
+ )[0] || '';
+ confirmParams.kaksPpiReferenceSpecies = ppiReference;
+ confirmParams.kaksProjectReferenceSpecies = projectReference;
+ confirmParams.kaksReferenceSource = stageParams.kaksReferenceSource
+ || (ppiReference ? 'ppi' : 'project');
+ }
if (stageId === 'ncbi_fetch_sequences') {
const ncbiParams = { ...batchExecuteParams.value, ...stageParams };
const ncbiDefaults = ncbiDefaultsForParams(ncbiParams);
@@ -17448,6 +17974,7 @@ compare: [[flesh, peel]]`;
if (stageName === 'kaks') {
requestBody.kaksNeutralRate = confirmParams.kaksNeutralRate || 7.08e-9;
+ requestBody.orthologMappingOwner = activeStageParams.orthologMappingOwner || '';
}
if (stageName === 'reference_classification') {
@@ -17938,11 +18465,14 @@ compare: [[flesh, peel]]`;
const jobId = jobInfo.job_id;
const batchParams = batchExecuteParams.value || {};
const batchPlanParams = batchParams.plan?.params || {};
- const disableBatchIdentityFallback = stageParams.resetMemberSetId === true || stageParams.analysisMode === 'per_species';
- let route = stageParams.analysisRoute || stageParams.analysis_route || (!disableBatchIdentityFallback ? (batchParams.analysisRoute || batchPlanParams.analysisRoute) : '') || '';
- let memberSet = stageParams.memberSetId || stageParams.member_set_id || (!disableBatchIdentityFallback ? (batchParams.memberSetId || batchPlanParams.memberSetId) : '') || '';
- const scopeId = stageParams.scopeId || stageParams.scope_id || (!disableBatchIdentityFallback ? (batchParams.scopeId || batchParams.scope_id || batchPlanParams.scopeId || batchPlanParams.scope_id) : '') || '';
- const scopeFile = stageParams.scopeFile || stageParams.scope_file || (!disableBatchIdentityFallback ? (batchParams.scopeFile || batchParams.scope_file || batchPlanParams.scopeFile || batchPlanParams.scope_file) : '') || '';
+ const identityStageParams = stageName === 'ppi'
+ ? canonicalizePpiOwnerBiologicalScope({ ...batchPlanParams, ...batchParams, ...stageParams })
+ : stageParams;
+ const disableBatchIdentityFallback = identityStageParams.resetMemberSetId === true || identityStageParams.analysisMode === 'per_species';
+ let route = identityStageParams.analysisRoute || identityStageParams.analysis_route || (!disableBatchIdentityFallback ? (batchParams.analysisRoute || batchPlanParams.analysisRoute) : '') || '';
+ let memberSet = identityStageParams.memberSetId || identityStageParams.member_set_id || (!disableBatchIdentityFallback ? (batchParams.memberSetId || batchPlanParams.memberSetId) : '') || '';
+ const scopeId = identityStageParams.scopeId || identityStageParams.scope_id || (!disableBatchIdentityFallback ? (batchParams.scopeId || batchParams.scope_id || batchPlanParams.scopeId || batchPlanParams.scope_id) : '') || '';
+ const scopeFile = identityStageParams.scopeFile || identityStageParams.scope_file || (!disableBatchIdentityFallback ? (batchParams.scopeFile || batchParams.scope_file || batchPlanParams.scopeFile || batchPlanParams.scope_file) : '') || '';
if (stageName === 'subfamily_confirm') {
route = 'confirmed_subfamily';
memberSet = scopeId ? memberSetForScope(scopeId, route, stageName) : '';
@@ -21716,10 +22246,13 @@ compare: [[flesh, peel]]`;
const jobId = jobInfo.job_id;
const batchParams = batchExecuteParams.value || {};
const batchPlanParams = batchParams.plan?.params || {};
- const disableBatchIdentityFallback = stageParams.resetMemberSetId === true || stageParams.analysisMode === 'per_species';
- let route = stageParams.analysisRoute || stageParams.analysis_route || (!disableBatchIdentityFallback ? (batchParams.analysisRoute || batchPlanParams.analysisRoute) : '') || '';
- let memberSet = stageParams.memberSetId || stageParams.member_set_id || (!disableBatchIdentityFallback ? (batchParams.memberSetId || batchPlanParams.memberSetId) : '') || '';
- const scopeId = stageParams.scopeId || stageParams.scope_id || (!disableBatchIdentityFallback ? (batchParams.scopeId || batchParams.scope_id || batchPlanParams.scopeId || batchPlanParams.scope_id) : '') || '';
+ const identityStageParams = stageId === 'ppi'
+ ? canonicalizePpiOwnerBiologicalScope({ ...batchPlanParams, ...batchParams, ...stageParams })
+ : stageParams;
+ const disableBatchIdentityFallback = identityStageParams.resetMemberSetId === true || identityStageParams.analysisMode === 'per_species';
+ let route = identityStageParams.analysisRoute || identityStageParams.analysis_route || (!disableBatchIdentityFallback ? (batchParams.analysisRoute || batchPlanParams.analysisRoute) : '') || '';
+ let memberSet = identityStageParams.memberSetId || identityStageParams.member_set_id || (!disableBatchIdentityFallback ? (batchParams.memberSetId || batchPlanParams.memberSetId) : '') || '';
+ const scopeId = identityStageParams.scopeId || identityStageParams.scope_id || (!disableBatchIdentityFallback ? (batchParams.scopeId || batchParams.scope_id || batchPlanParams.scopeId || batchPlanParams.scope_id) : '') || '';
if (stageId === 'subfamily_confirm') {
route = 'confirmed_subfamily';
memberSet = scopeId ? memberSetForScope(scopeId, route, stageId) : '';
@@ -21784,6 +22317,7 @@ compare: [[flesh, peel]]`;
}
if (stageId === 'kaks') {
requestBody.kaksNeutralRate = stageParams.kaksNeutralRate || confirmParams.kaksNeutralRate || 7.08e-9;
+ requestBody.orthologMappingOwner = stageParams.orthologMappingOwner || '';
}
if (stageId === 'identify') {
const batchParams = batchExecuteParams.value || {};
@@ -21899,17 +22433,21 @@ compare: [[flesh, peel]]`;
if (['ortholog', 'kaks'].includes(stageId)) {
const batchParams = batchExecuteParams.value || {};
const planParams = batchParams.plan?.params || {};
- const technicalReferenceSpecies = uniqueSpeciesList(
- stageParams.ppiKaksReferenceSpecies,
- stageParams.ppiReferenceSpecies,
- stageParams.referenceBundleSpecies,
- batchParams.ppiKaksReferenceSpecies,
- batchParams.ppiReferenceSpecies,
- batchParams.referenceBundleSpecies,
- planParams.ppiKaksReferenceSpecies,
- planParams.ppiReferenceSpecies,
- planParams.referenceBundleSpecies
- );
+ const projectOwnedKaks = stageParams.kaksReferenceSource === 'project'
+ && (stageId === 'kaks' || stageParams.dependencyOwner === 'kaks');
+ const technicalReferenceSpecies = projectOwnedKaks
+ ? []
+ : uniqueSpeciesList(
+ stageParams.ppiKaksReferenceSpecies,
+ stageParams.ppiReferenceSpecies,
+ stageParams.referenceBundleSpecies,
+ batchParams.ppiKaksReferenceSpecies,
+ batchParams.ppiReferenceSpecies,
+ batchParams.referenceBundleSpecies,
+ planParams.ppiKaksReferenceSpecies,
+ planParams.ppiReferenceSpecies,
+ planParams.referenceBundleSpecies
+ );
if (technicalReferenceSpecies.length) {
requestBody.ppiKaksReferenceSpecies = technicalReferenceSpecies;
requestBody.ppiReferenceSpecies = technicalReferenceSpecies[0];
@@ -22742,7 +23280,8 @@ compare: [[flesh, peel]]`;
job_id: jobInfo.job_id, // 传递当前 job_id 用于查询已完成阶段
prefix: normalizedPrefix(params.prefix || ''),
history: agentSession.messages.slice(-10),
- intent: data.intent // 传递 chat 返回的 intent,避免重复调用 IntentAgentV2
+ // 分析规划必须由原规划链重新识别;utility 才复用已验证参数。
+ ...(data.action === 'propose_utility' ? { intent: data.intent } : {})
})
}, 180000, '多智能体规划');
console.log('[DEBUG] Plan API response:', planData);
@@ -23480,6 +24019,8 @@ compare: [[flesh, peel]]`;
batchExecuteParams,
batchExecuteIndex,
referenceBundleDownloadState,
+ formatReferenceDownloadBytes,
+ clearReferenceBundleDownloadProgress,
batchExecuteCountdown,
isBatchExecuting,
shouldStopBatchExecute,
diff --git a/apps/web/vue3/main.py b/apps/web/vue3/main.py
index b727ff36f..aa0dfc827 100644
--- a/apps/web/vue3/main.py
+++ b/apps/web/vue3/main.py
@@ -1479,18 +1479,62 @@ def _canonicalize_species_codes_for_job_strict(job_id: str, species_codes: list[
def _reference_bundle_species_available(job_id: str, species_code: str, require_cds: bool = False) -> tuple[bool, str]:
- """Return whether a downloaded job-local reference bundle can satisfy PPI/KaKs."""
+ """Return whether job-local or config-resolved reference data can satisfy PPI/KaKs."""
code = str(species_code or "").strip()
if not job_id or not code:
return False, "missing job_id/species_code"
- species_dir = INPUT_ROOT / job_id / code
+ input_dir = INPUT_ROOT / job_id
+ species_dir = input_dir / code
pep_path = species_dir / f"{code}.pep.fasta"
cds_path = species_dir / f"{code}.cds.fasta"
- if not pep_path.exists() or pep_path.stat().st_size <= 0:
+ local_pep_ready = pep_path.exists() and pep_path.stat().st_size > 0
+ local_cds_ready = cds_path.exists() and cds_path.stat().st_size > 0
+ if local_pep_ready and (not require_cds or local_cds_ready):
+ return True, str(species_dir)
+
+ try:
+ scope = build_gene_family_scope(
+ input_dir=input_dir,
+ workspace=WORKSPACE_ROOT / job_id,
+ prefix="",
+ target_species=[],
+ reference_species=[code],
+ comparative_species=[],
+ scope_mode="target_plus_reference",
+ )
+ species_files = scope.files.get(code, {})
+
+ def has_sequence(file_type: str) -> bool:
+ return any(
+ record.get("path")
+ and Path(str(record["path"])).exists()
+ and Path(str(record["path"])).stat().st_size > 0
+ for record in species_files.get(file_type, [])
+ )
+
+ if has_sequence("pep") and (not require_cds or has_sequence("cds")):
+ return True, str(input_dir)
+ except Exception:
+ pass
+
+ if not local_pep_ready:
return False, f"missing PEP: {pep_path}"
- if require_cds and (not cds_path.exists() or cds_path.stat().st_size <= 0):
- return False, f"missing CDS: {cds_path}"
- return True, str(species_dir)
+ return False, f"missing CDS: {cds_path}"
+
+
+def _technical_reference_bundle_lookup_code(req: Any, species_code: str) -> str:
+ """Resolve the local bundle identity without changing the biological species code."""
+ code = str(species_code or "").strip()
+ storage_key = str(getattr(req, "ppiReferenceStorageKey", "") or "").strip()
+ if not storage_key or not bool(getattr(req, "ppiReferenceUserConfirmed", False)):
+ return code
+ stage = str(getattr(req, "stage", "") or "").strip().lower()
+ if stage == "ppi":
+ return storage_key
+ mapping_owner = str(getattr(req, "orthologMappingOwner", "") or "").strip().lower()
+ if stage == "kaks" and mapping_owner == "ppi":
+ return storage_key
+ return code
def _normalize_species_alias_for_job(value: str) -> str:
@@ -1682,6 +1726,23 @@ def _looks_like_gene_family_workflow_query(message: str) -> bool:
return any(token in text for token in ["基因家族分析", "家族分析", "完整分析", "全流程", "全部阶段", "完整流程"])
+def _full_gene_family_workflow_query_text(message: str, history: list[Any]) -> str:
+ """Recover the user's workflow request across the MainAgent confirmation turn."""
+ current = str(message or "").strip()
+ if _looks_like_gene_family_workflow_query(current):
+ return current
+ for item in reversed(history or []):
+ if not isinstance(item, dict):
+ continue
+ role = str(item.get("role") or item.get("sender") or "").strip().lower()
+ if role not in {"user", "human"}:
+ continue
+ text = str(item.get("content") or item.get("message") or item.get("text") or "").strip()
+ if _looks_like_gene_family_workflow_query(text):
+ return text
+ return ""
+
+
def _looks_like_intraspecies_synteny_query(message: str) -> bool:
text = str(message or "").lower()
return (
@@ -1985,7 +2046,7 @@ def _downstream_gene_family_stages_for_context(message: str) -> list[str]:
stages = [
"phylogeny", "motif", "domain", "structure", "combo",
"promoter", "tfbs", "synteny", "interspecies_synteny", "karyotype", "pep",
- "ortholog", "kaks", "ppi",
+ "ppi", "kaks",
]
if any(token in text for token in ["表达", "expression", "rna"]):
stages.append("expression")
@@ -1995,7 +2056,6 @@ def _downstream_gene_family_stages_for_context(message: str) -> list[str]:
if any(token in text for token in ["种间", "比较物种", "interspecies"]):
stages.append("interspecies_synteny")
if any(token in text for token in ["kaks", "ka/ks", "选择压力"]):
- stages.append("ortholog")
stages.append("kaks")
if any(token in text for token in ["ppi", "互作"]):
stages.append("ppi")
@@ -2066,6 +2126,9 @@ def _resolve_contextual_gene_family_request(
confirmed = _confirmed_subfamily_species(job_id, family)
confirmed_species = [item["species"] for item in confirmed if item.get("source") == "confirmed"]
mentioned = _extract_species_codes_from_text_for_job(job_id, text)
+ has_explicit_scope = bool(
+ mentioned or structured_targets or structured_refs or structured_comps
+ )
prefix_species = ""
if request_prefix and "." in request_prefix:
prefix_species = request_prefix.split(".", 1)[0].strip()
@@ -2458,7 +2521,7 @@ def _stage_detail_defaults(stage_id: str) -> Dict[str, Any]:
"interspecies_synteny": ["identify"],
"karyotype": ["identify", "synteny"],
"ortholog": ["identify"],
- "kaks": ["identify", "ortholog"],
+ "kaks": ["identify"],
"ppi": ["identify"],
"expression": ["identify"],
}
@@ -2485,18 +2548,16 @@ FULL_GENE_FAMILY_WORKFLOW_STAGES = [
"interspecies_synteny",
"karyotype",
"pep",
- "ortholog",
- "kaks",
"ppi",
+ "kaks",
]
FORMAL_GENE_FAMILY_CORE_STAGES = {"phylogeny", "motif", "domain", "structure", "combo"}
-FULL_GENE_FAMILY_TARGET_ONLY_STAGES = {"promoter", "tfbs", "synteny", "karyotype", "pep"}
+FULL_GENE_FAMILY_TARGET_ONLY_STAGES = {"promoter", "tfbs", "synteny", "karyotype", "pep", "ppi"}
FULL_GENE_FAMILY_REFERENCE_STAGES = FORMAL_GENE_FAMILY_CORE_STAGES | {
"interspecies_synteny",
"ortholog",
"kaks",
- "ppi",
}
SCOPE_IDENTITY_PARAM_KEYS = (
"scopeFile",
@@ -2624,6 +2685,8 @@ def _normalize_full_gene_family_workflow_plan(
if sid not in ordered:
ordered.append(sid)
for sid in stage_ids:
+ if sid == "ortholog":
+ continue
if sid and sid not in ordered:
ordered.append(sid)
@@ -2652,6 +2715,8 @@ def _normalize_full_gene_family_workflow_plan(
detail["dependencies"] = ["phylogeny", "motif", "domain", "structure"]
detail_params["comboPanels"] = ["motif", "domain", "structure"]
detail_params["comboPanelSource"] = "regular_gene_family"
+ if sid in {"ppi", "kaks"}:
+ detail["dependencies"] = ["identify"]
if sid in GENE_FAMILY_SCOPE_STAGES:
detail["params"] = enrich_stage_params_with_context(sid, plan_prefix, detail_params)
else:
@@ -5178,6 +5243,7 @@ def _prepare_execute_technical_reference_scope(
stage == "ortholog"
and str(getattr(req, "dependencyOwner", "") or "").strip().lower() == "ppi"
)
+ ppi_consumer_intent = stage == "ppi"
if ppi_owner_intent:
missing_fields = []
if not bool(getattr(req, "ppiReferenceUserConfirmed", False)):
@@ -5198,6 +5264,8 @@ def _prepare_execute_technical_reference_scope(
)
ppi_owned_ortholog = ppi_owner_intent
if ppi_owned_ortholog:
+ req.analysisRoute = "single_species"
+ req.memberSetId = ""
technical_reference_species = [
str(getattr(req, "ppiReferenceStorageKey", "") or "").strip()
]
@@ -5256,12 +5324,22 @@ def _prepare_execute_technical_reference_scope(
scope_params["scope_id"] = original_scope_id
scope_params["refSpeciesList"] = list(identity.reference_species)
scope_params["_validatedPpiOwnerScopeId"] = original_scope_id
+ req.memberSetId = f"{original_scope_id}:single_species:all_members"
else:
scope_params["scopeMode"] = "target_only"
scope_params["referenceSpecies"] = []
scope_params["refSpeciesList"] = []
scope_params["comparativeSpecies"] = []
scope_params["scopeFile"] = ""
+ elif ppi_consumer_intent:
+ req.analysisRoute = "single_species"
+ req.memberSetId = ""
+ technical_reference_species = []
+ scope_params["scopeMode"] = "target_only"
+ scope_params["referenceSpecies"] = []
+ scope_params["refSpeciesList"] = []
+ scope_params["comparativeSpecies"] = []
+ scope_params["scopeFile"] = ""
else:
technical_reference_species = []
if stage in {"ortholog", "ppi", "kaks"}:
@@ -6898,6 +6976,7 @@ class ExecuteRequest(BaseModel):
ppiReferenceSelectionMode: str = ""
ppiReferenceUserConfirmed: bool = False
dependencyOwner: str = ""
+ orthologMappingOwner: str = ""
identifyBlastReferenceSpecies: List[str] = []
primaryReferenceSpecies: List[str] = []
refSpeciesList: List[str] = []
@@ -7802,6 +7881,7 @@ def run_stage_sync(task_id: str, stage: str, job_id: str, params: dict):
ppi_reference_selection_mode=params.get("ppiReferenceSelectionMode") or params.get("ppi_reference_selection_mode") or "",
ppi_reference_user_confirmed=_bool_param("ppiReferenceUserConfirmed", "ppi_reference_user_confirmed", default=False),
dependency_owner=params.get("dependencyOwner") or params.get("dependency_owner") or "",
+ ortholog_mapping_owner=params.get("orthologMappingOwner") or params.get("ortholog_mapping_owner") or "",
ppi_score=params.get("ppiScore", 200),
# KaKs参数
kaks_neutral_rate=params.get("kaksNeutralRate", 7.08e-9),
@@ -11434,7 +11514,12 @@ async def execute_stage(req: ExecuteRequest):
for raw_value in raw_values:
raw_code = str(raw_value or "").strip()
if raw_code and raw_code.lower() in technical_ref_lookup:
- available, reason = _reference_bundle_species_available(job_id, raw_code, require_cds=require_cds)
+ bundle_lookup_code = _technical_reference_bundle_lookup_code(req, raw_code)
+ available, reason = _reference_bundle_species_available(
+ job_id,
+ bundle_lookup_code,
+ require_cds=require_cds,
+ )
if not available:
raise ValueError(
f"PPI/KaKs 技术参考物种 {raw_code} 已选择但参考包不完整:{reason};"
@@ -12123,6 +12208,7 @@ async def execute_stage(req: ExecuteRequest):
"ppiScore": getattr(req, 'ppiScore', 200),
# KaKs参数
"kaksNeutralRate": getattr(req, 'kaksNeutralRate', 7.08e-9),
+ "orthologMappingOwner": getattr(req, 'orthologMappingOwner', ''),
# Genome Prepare参数
"genomePrepareType": getattr(req, 'genomePrepareType', 'auto'),
# Interspecies species selection
@@ -15246,7 +15332,7 @@ def _apply_reference_bundle_stale_watchdog(task: Dict[str, Any]) -> None:
progress = task.get("progress") if isinstance(task.get("progress"), dict) else {}
progress = {
"phase": progress.get("phase") or status or "running",
- "message": progress.get("message") or task.get("message") or "参考物种注释包仍在后台下载",
+ "message": progress.get("message") or task.get("message") or "PPI and Ka/Ks reference species data are still downloading",
"percentage": progress.get("percentage"),
"indeterminate": progress.get("indeterminate", True),
"pending_seconds": task["pending_seconds"],
@@ -15264,13 +15350,13 @@ def _apply_reference_bundle_stale_watchdog(task: Dict[str, Any]) -> None:
def run_reference_bundle_download_task(task_id: str, payload: Dict[str, Any]) -> None:
tasks[task_id].update({
"status": "running",
- "message": "正在后台下载参考物种注释包",
+ "message": "Downloading PPI and Ka/Ks reference species PEP and CDS",
"start_time": datetime.now().isoformat(),
"timeout_sec": int(payload.get("timeout_sec") or 300),
"payload": dict(payload),
"progress": {
"phase": "queued",
- "message": "参考物种注释包下载任务已启动",
+ "message": "PPI and Ka/Ks reference species data download started",
"percentage": 0,
"indeterminate": True,
},
@@ -15301,7 +15387,7 @@ def run_reference_bundle_download_task(task_id: str, payload: Dict[str, Any]) ->
progress.setdefault("indeterminate", percentage is None)
tasks[task_id].update({
"status": "running",
- "message": progress.get("message") or "正在后台下载参考物种注释包",
+ "message": progress.get("message") or "Downloading PPI and Ka/Ks reference species PEP and CDS",
"progress": progress,
})
_persist_reference_bundle_task(tasks[task_id])
@@ -15320,11 +15406,11 @@ def run_reference_bundle_download_task(task_id: str, payload: Dict[str, Any]) ->
success = result.get("status") == "success"
tasks[task_id].update({
"status": "success" if success else "failed",
- "message": "参考物种注释包下载完成" if success else "参考物种注释包下载未满足必需文件",
+ "message": "PPI and Ka/Ks reference species data are ready" if success else "PPI and Ka/Ks reference species data are incomplete",
"end_time": datetime.now().isoformat(),
"progress": {
"phase": "completed" if success else "failed",
- "message": "参考物种注释包下载完成" if success else "参考物种注释包下载未满足必需文件",
+ "message": "PPI and Ka/Ks reference species data are ready" if success else "PPI and Ka/Ks reference species data are incomplete",
"percentage": 100 if success else None,
"indeterminate": False if success else True,
},
@@ -15367,7 +15453,7 @@ async def download_reference_bundle_api(req: ReferenceBundleDownloadRequest):
"stage": "reference_bundle_download",
"job_id": req.job_id,
"status": "pending",
- "message": "参考物种注释包下载任务已提交",
+ "message": "PPI and Ka/Ks reference species data download submitted",
"created_at": datetime.now().isoformat(),
"species_code": req.speciesCode,
"species_name": req.speciesName,
@@ -17146,7 +17232,11 @@ async def _run_agent_chat(
"session_id": session_id,
"input_paths": list(outcome.get("input_paths") or []),
"handoff_request": (
- str((outcome.get("proposal") or {}).get("request") or "").strip()
+ str(
+ (outcome.get("proposal") or {}).get("original_request")
+ or (outcome.get("proposal") or {}).get("request")
+ or ""
+ ).strip()
if isinstance(outcome.get("proposal"), dict)
else request.message
) or request.message,
@@ -18269,9 +18359,13 @@ def _build_contextual_gene_family_plan_response(request: Any, session_id: str, c
detail["params"] = enrich_stage_params_with_context(stage_id, plan_prefix, detail_params)
stages_detail.append(detail)
- if _looks_like_gene_family_workflow_query(getattr(request, "message", "") or "") and not subfamily_prerequisite_mode:
+ full_workflow_query = _full_gene_family_workflow_query_text(
+ getattr(request, "message", "") or "",
+ getattr(request, "history", []) or [],
+ )
+ if full_workflow_query and not subfamily_prerequisite_mode:
stage_ids, stages_detail = _normalize_full_gene_family_workflow_plan(
- message=getattr(request, "message", "") or "",
+ message=full_workflow_query,
stage_ids=stage_ids,
stages_detail=stages_detail,
plan_params=plan_params,
@@ -21919,9 +22013,13 @@ async def agent_plan(request: AgentPlanRequest):
intent["gene_family"] = combo_family
full_workflow_normalized = False
- if _looks_like_gene_family_workflow_query(request.message or "") and not subfamily_prediction_query:
+ full_workflow_query = _full_gene_family_workflow_query_text(
+ request.message or "",
+ request.history or [],
+ )
+ if full_workflow_query and not subfamily_prediction_query:
stage_ids, stages_detail = _normalize_full_gene_family_workflow_plan(
- message=request.message or "",
+ message=full_workflow_query,
stage_ids=stage_ids,
stages_detail=stages_detail,
plan_params=plan_params,
@@ -21953,7 +22051,7 @@ async def agent_plan(request: AgentPlanRequest):
if full_workflow_normalized:
stage_ids, stages_detail = _normalize_full_gene_family_workflow_plan(
- message=request.message or "",
+ message=full_workflow_query,
stage_ids=stage_ids,
stages_detail=stages_detail,
plan_params=plan_params,
diff --git a/docs/superpowers/plans/2026-08-09-ppi-first-kaks-reference-choice.md b/docs/superpowers/plans/2026-08-09-ppi-first-kaks-reference-choice.md
new file mode 100644
index 000000000..0b57a53f9
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-09-ppi-first-kaks-reference-choice.md
@@ -0,0 +1,308 @@
+# PPI First, KaKs Reference Choice Implementation Plan
+
+> **Working repository:** `~/remote-projects/5090/teamWorkspace/sj-2/PlantAI-dev`
+>
+> **Goal:** In the normal full gene-family workflow, show PPI and KaKs but not their internal Ortholog producers. Run PPI first. After PPI reaches a real terminal outcome, open the normal KaKs confirmation dialog and let the user choose either the PPI technical reference or the configured project reference. Keep the two mappings and manifests isolated.
+
+## Task determination
+
+This is a temporary execution-closure task, not the long-term KaKs reference-quality project.
+
+The user-visible workflow is:
+
+```text
+normal gene-family plan
+ -> PPI (direct STRING or hidden PPI-owned Ortholog)
+ -> KaKs reference confirmation
+ -> PPI technical reference
+ -> configured project reference (for the current Ft case: At)
+ -> hidden KaKs-owned Ortholog only when an exact reusable mapping is absent
+ -> KaKs
+```
+
+The current failure has three connected causes:
+
+1. The deterministic full-workflow normalizer adds `ortholog` to the visible plan, while KaKs also has a hard `ortholog` dependency.
+2. The frontend inserts one visible Ortholog stage and lets PPI and KaKs compete for that stage's owner/reference state.
+3. All Ortholog producers materialize the same `final/
.orthologs.txt` and `.ortholog.manifest.json`; PPI-near-reference and KaKs-At runs can overwrite each other.
+
+Fixing only the plan list, only the popup order, or only the mapping filename is insufficient. These three seams must change together.
+
+## First-principles invariants
+
+1. PPI chooses a STRING route for PPI; KaKs chooses a comparative reference for KaKs. One choice must not silently mutate the other.
+2. Ortholog remains a supported explicit analysis stage, but is an internal producer when required by PPI or KaKs.
+3. KaKs waits for PPI sequencing without declaring a hard PPI dependency. PPI failure, explicit skip, or unavailable PPI reference must still release the KaKs dialog.
+4. A PPI-owned mapping and a KaKs-owned mapping may coexist for one prefix and different references.
+5. A mapping is reusable only when target/member FASTA identity, reference identity, reference PEP checksum, scope identity, and mapping checksum match.
+6. PPI remains PEP-only. Missing KaKs CDS must never prevent PPI submission or completion.
+7. The configured reference remains unchanged for unrelated stages. A KaKs selection is written only to KaKs-owned parameters/state.
+
+## Scope and assumptions
+
+### In scope
+
+- The normal/default full gene-family plan no longer contains implicit `ortholog`.
+- The normal plan orders PPI before KaKs.
+- KaKs has no visible/static hard dependency on Ortholog or PPI; runtime orchestration supplies its internal mapping.
+- The existing KaKs stage confirmation dialog gains two reference-source choices.
+- PPI-owned and KaKs-owned Ortholog outputs use separate namespaces.
+- Same-reference exact mapping reuse is supported.
+- PPI completion/failure/explicit skip releases KaKs; a reference download still running does not.
+- PPI/KaKs failures remain isolated from unrelated stages.
+
+### Preserved behavior
+
+- An explicit user request for “直系同源基因鉴定/Ortholog” still produces and displays the standalone Ortholog stage and canonical standalone outputs.
+- Direct-target STRING PPI remains direct and does not create Ortholog.
+- Existing PPI STRING species selection, ID normalization, PPI CLI, KaKs CLI, and scientific algorithms remain unchanged.
+- Methodology execution, subfamily workflow, main-Agent handoff, intent Agent, PlanningAgentV2 implementation, and analysis YAML are not redesigned.
+
+### Deferred
+
+- The generic high-quality KaKs reference selector using taxonomy, assembly quality, BUSCO, annotation completeness, and PEP/CDS quality.
+- Automatic ranking of multiple non-PPI KaKs candidate species.
+- Generic scheduler refactoring or frontend component extraction.
+
+## Reference-choice rules
+
+The KaKs dialog appears only after the PPI stage has settled for this batch.
+
+| PPI outcome | PPI-reference option | Config-reference option | KaKs behavior |
+|---|---|---|---|
+| PPI direct-target STRING | Hidden; there is no separate comparative PPI reference | Show configured reference | Continue with config choice |
+| PPI reference selected and PEP/CDS/mapping exact | Enabled | Show configured reference | Reuse PPI mapping if selected |
+| PPI reference selected, CDS missing | Enabled with “需补齐 CDS” | Show configured reference | If selected, download/complete only the KaKs bundle, then continue |
+| PPI reference selected, mapping missing/stale | Enabled if PEP/CDS can be prepared | Show configured reference | Run hidden KaKs-owned Ortholog |
+| PPI failed or was skipped | Show only if a separate PPI reference and usable files exist | Show configured reference | PPI outcome does not block confirmation |
+| Neither choice has PEP+CDS | Disabled with exact missing-file reasons | Disabled with exact missing-file reasons | Pause only KaKs |
+
+The reference choice is explicit. No silent fallback from one option to the other.
+
+## Task 1 — Freeze the visible planning contract
+
+**Files:**
+
+- Modify: `apps/web/vue3/main.py`
+- Modify: `gfa_core/mas/stage_catalog.py`
+- Modify: `apps/web/vue3/index.html`
+- Modify: `tests/unit/test_task_status_attachment.py`
+- Modify: `tests/unit/test_subfamily_prediction_plan.py` or add one focused full-workflow plan test
+- Modify: `tests/unit/test_ppi_frontend_display_contract.py`
+
+### Steps
+
+1. Add failing tests for the normal “执行基因家族分析” response:
+ - `ortholog` is absent from `plan.stages` and `stages_detail`;
+ - `ppi` and `kaks` are present;
+ - `ppi` appears before `kaks`;
+ - standalone explicit Ortholog remains supported.
+2. Remove implicit Ortholog from `_downstream_gene_family_stages_for_context()` and `FULL_GENE_FAMILY_WORKFLOW_STAGES`.
+3. Change the normal static KaKs dependency from `identify + ortholog` to `identify` in the normal planning catalog, plan-detail builder, and frontend dependency table.
+4. Keep the standalone Ortholog catalog entry and explicit Ortholog intent unchanged.
+5. Do not filter Ortholog out in the rendered frontend. The backend plan itself must be correct.
+
+### Acceptance gate
+
+- A normal full plan displays PPI and KaKs, never implicit Ortholog.
+- An explicit Ortholog request still displays Ortholog.
+- No frontend-only post-processing is used to disguise a wrong backend plan.
+
+## Task 2 — Give internal mappings owner-specific identities
+
+**Files:**
+
+- Modify: `gfa_core/ortholog_materialize.py`
+- Modify: `apps/web/pipeline_backend.py`
+- Modify: `apps/web/vue3/main.py` only for the narrow execution-request fields needed to carry mapping ownership
+- Modify: `tests/unit/test_ppi_ortholog_owner_identity_task5b.py`
+- Modify: `tests/unit/test_pipeline_backend_execution_env.py`
+
+### Output contract
+
+Use one path helper rather than repeating filename construction:
+
+```text
+standalone: final/.orthologs.txt
+PPI owner: final/.ppi.orthologs.txt
+KaKs owner: final/.kaks.orthologs.txt
+```
+
+Apply the same owner namespace to pairs, raw pairs, BLAST output, sidecar, and Ortholog manifest. Keep `manifest.stage = "ortholog"`; record `dependency_owner` and the effective mapping identity in parameters/provenance.
+
+### Steps
+
+1. Add failing coexistence tests that materialize PPI-owned Spinacia and KaKs-owned At mappings for the same prefix and assert both remain unchanged.
+2. Extend `materialize_ortholog_final_outputs()` with a narrow owner/output namespace. Empty owner preserves every current standalone filename.
+3. In `run_stage("ortholog")`, accept only `""`, `"ppi"`, or `"kaks"` as mapping owner and pass it to materialization.
+4. Centralize owner-aware candidate paths for preflight and execution. Do not let PPI or KaKs fall through to a different owner's mapping merely because that file exists.
+5. Add a narrow KaKs mapping-source owner field to `ExecuteRequest` and backend forwarding. Prefer an owner token over a client-supplied arbitrary filesystem path.
+6. Validate reuse against the existing Ortholog identity sidecar:
+ - selected reference storage/species identity;
+ - target/query FASTA identity;
+ - member identity;
+ - reference PEP checksum;
+ - scope identity;
+ - mapping checksum.
+7. Preserve canonical filenames and resolution for explicit standalone Ortholog.
+
+### Acceptance gate
+
+- PPI Spinacia and KaKs At mappings coexist.
+- Rerunning one owner does not change the other owner's file or Manifest checksum.
+- A stale/wrong-reference mapping is rejected rather than silently reused.
+- Existing standalone Ortholog tests still pass.
+
+## Task 3 — Sequence KaKs after PPI without a hard dependency
+
+**Files:**
+
+- Modify: `apps/web/vue3/index.html`
+- Modify: `tests/unit/test_ppi_owner_scheduling_task5.py`
+- Modify: `tests/unit/test_ppi_frontend_display_contract.py`
+
+### Steps
+
+1. Add scheduler tests for a batch containing both PPI and KaKs:
+ - KaKs is not submitted while PPI is pending, running, or paused for an active reference download;
+ - PPI completed releases KaKs;
+ - PPI failed releases KaKs;
+ - PPI explicitly skipped releases KaKs;
+ - unrelated stages continue while PPI waits.
+2. Add one narrow soft sequencing state to the existing batch state. Do not encode PPI as a KaKs dependency in `stageDependencies`.
+3. Release the gate only on a real PPI terminal outcome. Generic `paused` is not sufficient because an active background reference download is resumable.
+4. Remove the path that inserts/replaces a visible `ortholog` item in `batchExecuteStages` for PPI/KaKs ownership.
+5. Reuse the existing direct internal producer submission helper, but track its result under the owner stage rather than `stageExecutionStatus.ortholog`.
+6. Ensure PPI internal-producer failure pauses/fails only PPI and still releases KaKs confirmation when terminal.
+
+### Acceptance gate
+
+- There is no Ortholog row or Ortholog confirmation popup in the normal batch.
+- KaKs confirmation cannot race ahead of PPI.
+- PPI failure cannot cause KaKs to be dependency-failed.
+- Other unrelated stages are not serialized behind PPI.
+
+## Task 4 — Add the KaKs reference choice to the existing dialog
+
+**Files:**
+
+- Modify: `apps/web/vue3/index.html`
+- Modify: `apps/web/vue3/main.py` only if a read-only candidate-readiness payload is needed
+- Modify: `apps/web/pipeline_backend.py` only to return owner-aware readiness/identity data
+- Modify: `tests/unit/test_ppi_frontend_display_contract.py`
+- Modify: `tests/unit/test_ppi_snapshot_preflight_task3.py`
+
+### Steps
+
+1. Reuse the existing stage-confirmation dialog and its PPI reference card styles. Do not create a second visual system or a synthetic visible stage.
+2. When opening the KaKs confirmation dialog, build two candidates:
+ - `ppi_reference`: the separate PPI technical reference, only when one exists;
+ - `project_reference`: the configured reference from the confirmed project scope.
+3. For each candidate, show species label/code, source, PEP status, CDS status, mapping status, and the reason when unavailable.
+4. Store the confirmed choice only in KaKs stage params, for example `kaksReferenceSource`, `referenceSpecies/refSpeciesList`, and `orthologMappingOwner`. Do not update PPI owner fields or unrelated plan-level reference fields.
+5. If the PPI candidate lacks CDS, use the existing reference-bundle endpoint with `requestedStages=["kaks"]` to complete that selected species. PPI remains completed and is never reopened or downgraded.
+6. If the selected reference lacks an exact mapping:
+ - submit a hidden `dependencyOwner="kaks"` Ortholog producer;
+ - on success, submit KaKs with `orthologMappingOwner="kaks"`;
+ - if the exact PPI-owned mapping is reusable, submit KaKs with `orthologMappingOwner="ppi"` and do not recompute.
+7. If neither candidate can be made ready, pause only KaKs and retain a retryable, explicit reason.
+
+### Acceptance gate
+
+- For the Ft case, the dialog can show the PPI near-reference and configured At as separate choices.
+- Selecting At cannot change PPI's reference TaxID, mapping, Manifest, or artifacts.
+- Selecting the PPI reference cannot change config/project reference for other stages.
+- A missing CDS or mapping produces a KaKs-only action/pause.
+
+## Task 5 — Synchronize the minimum contracts
+
+**Files:**
+
+- Modify: `gfa_core/schemas/stages/ppi_contract_v1.md`
+- Modify: `gfa_core/schemas/stages/kaks_contract_v1.md`
+- Modify: `gfa_core/schemas/stages/ortholog_contract_v1.md`
+
+### Steps
+
+1. Document PPI-owned and KaKs-owned internal Ortholog identities and standalone Ortholog preservation.
+2. Document that PPI and KaKs readiness/reference state are independent.
+3. Document the KaKs reference-source choice and exact mapping-reuse rule.
+4. Document soft sequencing: PPI terminal outcome releases KaKs, but PPI success is not a KaKs prerequisite.
+
+Do not change CLI inputs or the scientific method contracts beyond these execution/provenance facts.
+
+## Task 6 — Verification without polluting the user's real case
+
+### Automated tests
+
+Run the focused tests first:
+
+```bash
+/home/xia-lab/miniconda3/envs/test4py311/bin/python -m pytest -q \
+ tests/unit/test_task_status_attachment.py \
+ tests/unit/test_ppi_frontend_display_contract.py \
+ tests/unit/test_ppi_owner_scheduling_task5.py \
+ tests/unit/test_ppi_ortholog_owner_identity_task5b.py \
+ tests/unit/test_ppi_snapshot_preflight_task3.py \
+ tests/unit/test_pipeline_backend_execution_env.py -k 'ppi or kaks or ortholog or full_workflow'
+```
+
+Then run the related gene-family planning/API suite discovered from failures. Do not broaden to unrelated WGS/RNA-seq work unless a shared contract test fails.
+
+### Isolated real-data matrix
+
+Create a dedicated test job ID/folder. Do not write PPI, KaKs, or Ortholog outputs into `job_1786184894919_b7d741/final` or any user acceptance job.
+
+Validate these routes:
+
+1. **Ft + PPI near reference + KaKs same reference**
+ - visible plan has PPI/KaKs and no Ortholog;
+ - PPI-owned mapping succeeds;
+ - KaKs dialog appears after PPI;
+ - choose PPI reference;
+ - exact mapping is reused;
+ - PPI and KaKs succeed.
+2. **Ft + PPI near reference + KaKs At**
+ - choose configured At;
+ - KaKs-owned At mapping is generated;
+ - PPI near-reference mapping and Manifest checksums remain unchanged;
+ - KaKs succeeds with At PEP/CDS.
+3. **PPI failure/skip isolation**
+ - force PPI to fail or explicitly skip it;
+ - KaKs dialog still appears;
+ - choose At and complete KaKs;
+ - unrelated runnable stages keep their own statuses.
+4. **PPI active download gate**
+ - while PPI reference download is running, KaKs dialog does not open;
+ - after PPI settles, it opens once.
+5. **Standalone Ortholog regression**
+ - explicitly request Ortholog;
+ - visible standalone stage and canonical outputs remain available.
+
+For each route capture request payloads, task statuses, owner-specific Manifests, mapping checksums, and final stage statuses in the dedicated test folder.
+
+### Live service verification
+
+After unit/API verification, restart only the sj-2 service on port 9016. Verify the served HTML contains the new contract, then exercise one isolated frontend job. Do not restart unrelated workers/services unless backend code loading proves it is necessary.
+
+## Stop rules
+
+Stop and re-diagnose if implementation requires any of the following:
+
+- changing PPI/STRING ID mapping or network analysis logic;
+- changing KaKs calculation logic or neutral-rate semantics;
+- modifying main-Agent/intent-Agent prompts or analysis YAML;
+- making PPI wait for CDS;
+- writing PPI reference into shared project reference fields;
+- overwriting another owner's mapping/Manifest;
+- hiding Ortholog only by frontend display filtering while it remains in the normal backend plan.
+
+## Final acceptance criteria
+
+- Normal full gene-family planning shows PPI and KaKs, not implicit Ortholog.
+- PPI is processed before KaKs reference confirmation.
+- KaKs offers the PPI technical reference and configured reference when valid.
+- PPI success is not required for KaKs to continue.
+- PPI and KaKs mappings/manifests are owner-isolated and reproducible.
+- At selection for KaKs does not mutate PPI; PPI-near-reference selection does not mutate unrelated stages.
+- No original user job is polluted by verification outputs.
diff --git a/docs/superpowers/plans/2026-08-09-ppi-owned-ortholog-reference-pep-identity.md b/docs/superpowers/plans/2026-08-09-ppi-owned-ortholog-reference-pep-identity.md
new file mode 100644
index 000000000..bdf77781e
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-09-ppi-owned-ortholog-reference-pep-identity.md
@@ -0,0 +1,252 @@
+# PPI 专属 Ortholog 参考 PEP 身份一致性修订计划
+
+> **执行约束:** 实施时必须继续遵循 `karpathy-guidelines`:先写可失败的回归测试,只做最小生产改动,每一步都用可观察证据验收;不得顺手重构前端、规划层、下载层或身份校验层。
+
+**目标:** 当 PPI 使用已下载近源物种或用户显式确认的 At 作为技术参考物种时,PPI 专属 Ortholog 的生产端与 PPI 二次预检必须使用同一份全蛋白组 PEP,使 Ortholog → PPI → Ka/Ks 能继续调度,同时保持普通独立 Ortholog 的原有 family PEP 语义不变。
+
+**架构边界:** PPI 的技术参考物种只属于 `dependency_owner=ppi` 的隐藏依赖,不进入公共规划阶段,也不改变 `target_only` 生物学 scope。修复点仅位于后端 Ortholog 输入解析分支;严格的路径、SHA256、taxon、storage key、mapping identity 校验继续保留。
+
+**技术栈:** Python 3.11、FastAPI、Celery、pytest;真实验证使用 sj-2 的 9016 Web/API 和对应 Celery workers。
+
+---
+
+## 1. 已确认的问题
+
+### 1.1 当前真实失败链
+
+真实任务 `job_1786243816439_04955b` 已给出完整证据链:
+
+1. 菠菜参考包下载尝试 3 次后因 `IncompleteRead(488265 bytes read)` 失败;重试机制工作正常。
+2. 用户显式选择 At 兜底后,PPI 专属 Ortholog 成功生成 24 对映射。
+3. 随后的 PPI 二次预检返回 `stale_ortholog_identity_conflict`,PPI 未提交。
+4. Ka/Ks 正确识别同一参考准备链,未再次启动下载,但因 PPI 映射未通过身份校验而暂停。
+
+### 1.2 根因不是“身份校验太严格”,而是生产者与消费者解析了不同文件
+
+| 环节 | 实际解析文件 | 结果 |
+|---|---|---|
+| PPI 专属 Ortholog 生产端 | `input/.../At.SPL.pep.fasta` | family PEP,9,929 bytes,SHA256 `42dca87b...` |
+| PPI 二次预检消费端 | `input/.../At/At.pep.fasta` | whole-proteome PEP,34,646,980 bytes,SHA256 `193f9d45...` |
+
+两者虽然都叫 At,但不是同一个输入身份。Ortholog Manifest 和 sidecar 如实记录了 family PEP;PPI 预检按全蛋白组 PEP 构建期望身份,因此拒绝复用是正确行为。
+
+### 1.3 精确代码位置
+
+生产端位于 `apps/web/pipeline_backend.py` 的 `stage == "ortholog"` 分支:
+
+```python
+subject_fasta = first_existing_scope_file(
+ ortholog_scope_data,
+ reference_species,
+ ["family_pep", "pep"],
+)
+if not subject_fasta:
+ subject_fasta = resolve_species_family_pep(...)
+if not subject_fasta:
+ subject_fasta = resolve_species_sequence_file(..., family_only=False)
+```
+
+即使 `ppi_owned_producer=True`,它仍优先选 family PEP。
+
+消费端 `_resolve_ppi_only_snapshot_preflight()` 经 `_ppi_mapping_identity_matches_current_inputs()` 使用 `resolve_species_sequence_file(..., family_only=False)`,解析的是技术参考物种的 whole-proteome PEP。
+
+### 1.4 最近三轮修订与本次失败的关系
+
+| 修订轮次 | 已产生的变化 | 当前结论 |
+|---|---|---|
+| PPI owner/scope 修订 | 隐藏 Ortholog 归 PPI 所有;公共 scope 保持 `target_only`;增加严格身份校验和二次预检 | 设计正确,必须保留 |
+| 下载进度展示修订 | Tip 旁显示下载任务、字节数和终态 | 只增强可见性,没有制造数据链问题 |
+| PEP+CDS 一次下载修订 | PPI 阶段一次准备 PEP+CDS;`IncompleteRead` 最多重试 3 次;Ka/Ks 不再重复下载 | 当前日志证明已生效;它暴露了此前项目记录中已暂缓的 PEP 身份不一致 |
+
+因此,本次不能回退上述三轮修订,也不能通过删除严格身份校验“让它通过”。
+
+## 2. 冻结的最小修订方案
+
+### 2.1 唯一生产代码改动
+
+**文件:** `apps/web/pipeline_backend.py`
+
+在 `stage == "ortholog"` 的 subject 解析处,仅对 `ppi_owned_producer` 分流:
+
+```python
+if ppi_owned_producer:
+ subject_fasta = resolve_species_sequence_file(
+ ws,
+ final_dir,
+ in_dir,
+ ortholog_scope_data,
+ reference_species,
+ family,
+ "pep",
+ family_only=False,
+ )
+else:
+ subject_fasta = first_existing_scope_file(
+ ortholog_scope_data,
+ reference_species,
+ ["family_pep", "pep"],
+ )
+ if not subject_fasta:
+ subject_fasta = resolve_species_family_pep(
+ ws, final_dir, in_dir, reference_species, family
+ )
+ if not subject_fasta:
+ subject_fasta = resolve_species_sequence_file(
+ ws,
+ final_dir,
+ in_dir,
+ ortholog_scope_data,
+ reference_species,
+ family,
+ "pep",
+ family_only=False,
+ )
+```
+
+该分流保证:
+
+- PPI 专属 Ortholog 与 PPI 预检调用同一个 whole-proteome resolver;
+- 菠菜缓存参考和 At 兜底都走同一规则,而不是写死 Ft、菠菜或 At;
+- 普通独立 Ortholog 仍优先使用 family PEP,不改变既有科研语义;
+- Ka/Ks 继续复用 PPI 已确认的 bundle/mapping owner,不新增第二次 Ortholog。
+
+### 2.2 明确禁止的改动
+
+本次不得修改:
+
+- `apps/web/vue3/index.html` 的规划、调度、弹窗和进度状态机;
+- `apps/web/vue3/main.py` 的接口契约和下载任务实现;
+- PlanningAgentV2、主 Agent、意图 Agent 或任何 YAML 阶段定义;
+- `gfa_core/ortholog_materialize.py` 的严格 identity 计算与比较;
+- `stage_context.reference_species`、公共 `scope_id` 或 PPI 的 `target_only` 语义;
+- PEP+CDS 一次下载、3 次重试、Ka/Ks 禁止二次下载的现有逻辑;
+- 全局调整 `first_existing_scope_file(["family_pep", "pep"])` 的优先级。
+
+## 3. 实施任务
+
+### Task 1:先建立能复现真实冲突的 RED 测试
+
+**文件:** `tests/unit/test_ppi_ortholog_owner_identity_task5b.py`
+
+- [ ] 增加 At 同时存在两套 PEP 的 fixture:
+ - `input/At.SPL.pep.fasta`:family PEP;
+ - `input/At/At.pep.fasta`:whole-proteome PEP;
+ - `input/At/At.cds.fasta`:与 whole PEP 使用相同 transcript ID,如 `AT5G16970.1`。
+- [ ] 以 `dependency_owner="ppi"`、`ppi_reference_storage_key="At"`、`ppi_reference_taxon_id="3702"` 执行隐藏 Ortholog。
+- [ ] 断言当前代码的 `--subject` 错误指向 `At.SPL.pep.fasta`,并使“必须指向 `At/At.pep.fasta`”的新断言先失败。
+- [ ] 断言生成映射后再次调用 `resolve_ppi_species_resolution()`;当前代码应因 reference PEP SHA 不同而不是 `ready`。
+
+运行:
+
+```bash
+/home/xia-lab/miniconda3/envs/test4py311/bin/python -m pytest -q \
+ tests/unit/test_ppi_ortholog_owner_identity_task5b.py \
+ -k "whole_reference_pep"
+```
+
+预期 RED:失败信息明确显示 expected `At/At.pep.fasta`,actual `At.SPL.pep.fasta`;不得用其他异常作为“红灯”。
+
+### Task 2:实施 PPI owner 专属的 resolver 分流
+
+**文件:** `apps/web/pipeline_backend.py`
+
+- [ ] 按 2.1 的代码结构修改 subject 解析。
+- [ ] 不改变 `references=[ppi_owner_storage_key]`、`_effective_ppi_owner_scope_id()`、`member_set_id=""`、owner-specific 输出命名。
+- [ ] 保留找不到 whole PEP 时的明确失败,不允许静默回退到 family PEP;否则生产端和预检会再次形成双重真相。
+- [ ] 运行语法检查:
+
+```bash
+/home/xia-lab/miniconda3/envs/test4py311/bin/python -m py_compile \
+ apps/web/pipeline_backend.py
+```
+
+### Task 3:补齐身份与非回归测试
+
+**文件:** `tests/unit/test_ppi_ortholog_owner_identity_task5b.py`
+
+- [ ] GREEN 断言 PPI-owned Ortholog 命令的 `--subject` 精确等于 whole-proteome PEP。
+- [ ] 断言 PPI owner sidecar 与 Manifest 的 `reference_pep_sha256`、`reference_storage_key`、`reference_taxon_id`、`mapping_path` 全部对应同一 whole PEP。
+- [ ] 断言二次 PPI preflight 返回 `status == "ready"`,不再出现 `stale_ortholog_identity_conflict`。
+- [ ] 断言 PPI Manifest 仍为 `scope_mode == "target_only"`,`stage_context.reference_species == []`,`selected_species == ["Ft"]`。
+- [ ] 新增普通独立 Ortholog 回归用例:当 family PEP 与 whole PEP 同时存在时,非 PPI owner 仍选择 family PEP。
+- [ ] 为直接相关的旧 fixture 补充 CDS,使其进入身份校验分支;不改与本问题无关的前端文本断言或 effective-member fixture。
+
+运行:
+
+```bash
+/home/xia-lab/miniconda3/envs/test4py311/bin/python -m pytest -q \
+ tests/unit/test_ppi_ortholog_owner_identity_task5b.py
+
+/home/xia-lab/miniconda3/envs/test4py311/bin/python -m pytest -q \
+ tests/unit/test_ppi_ortholog_owner_identity_task5b.py \
+ tests/unit/test_ppi_owner_scheduling_task5.py \
+ tests/unit/test_ppi_frontend_display_contract.py
+```
+
+验收时必须把修订前基线与修订后结果分开报告。已知旧前端字符串断言、旧 fixture 缺 CDS、effective-member 相关失败不得被伪装为本次新增回归,也不得借机修改生产代码。
+
+### Task 4:在独立验证 job 做真实 API 闭环
+
+**位置:** sj-2 独立创建 `job_codex_ppi_kaks_pep_identity_`,不得把验证产物写入用户当前 job 的 `final/`。
+
+- [ ] 从真实 Ft/At 案例只读复用以下输入:Ft 家族成员 PEP、At family PEP、At whole PEP、At whole CDS、scope/config。
+- [ ] 初次调用 `POST /api/ppi/species-resolution`,使用:
+
+```json
+{
+ "job_id": "job_codex_ppi_kaks_pep_identity_",
+ "prefix": "Ft.SPL",
+ "targetSpecies": ["Ft"],
+ "referenceSpecies": [],
+ "geneFamily": "SPL",
+ "ppiReferenceTaxonId": "3702",
+ "ppiReferenceStorageKey": "At",
+ "ppiReferenceSelectionMode": "explicit_fallback",
+ "ppiReferenceUserConfirmed": true,
+ "dependencyOwner": "ppi",
+ "requestedStages": ["ppi"]
+}
+```
+
+- [ ] 初次预检必须为 `needs_ortholog`,且不再要求下载完整 At bundle。
+- [ ] 调用 `POST /api/execute` 执行隐藏 `ortholog`,请求保留 `dependencyOwner="ppi"`、`targetSpecies=["Ft"]`、`referenceSpecies=[]` 和 PPI owner 字段。
+- [ ] 轮询任务直至成功,并检查后端命令日志:`--query` 为 Ft family PEP,`--subject` 为 `At/At.pep.fasta`。
+- [ ] 检查 PPI-owned Ortholog 产生有效映射、sidecar、Manifest,映射数量大于 0;所有 reference identity 字段一致。
+- [ ] 再次调用 `/api/ppi/species-resolution`,必须返回 `ready`,不得返回 stale identity conflict。
+- [ ] 调用 `/api/execute` 执行 PPI,轮询到 terminal success;检查 PPI Manifest 和网络产物,不以“任务已提交”代替成功。
+- [ ] 进入 Ka/Ks 确认路径,确认它复用 PPI 选择的参考 bundle 和 `orthologMappingOwner=ppi`;不得出现第二次参考下载或第二个 Ortholog producer。
+- [ ] 执行 Ka/Ks 至 terminal success,并核对 Ka/Ks Manifest 的 reference/mapping provenance。
+- [ ] 最后确认活动任务数为 0、前端计时漏斗停止、PPI/KaKs 的 terminal 状态能正常显示。
+
+### Task 5:覆盖近源物种路径并区分外部下载故障
+
+- [ ] 若 `string_taxon_3562` 已有完整且非损坏的 PEP+CDS bundle,在独立验证 job 重复 Task 4,要求 PPI-owned Ortholog 的 `--subject` 为 `string_taxon_3562` whole PEP,二次预检为 `ready`。
+- [ ] 若菠菜下载仍因远端 `IncompleteRead` 失败,只验收重试次数、字节进度、terminal failure 和 At 手动兜底入口;明确记录为外部下载失败,不得把它误判为本次 identity 修订失败。
+- [ ] 不为通过测试而在代码中预设 Ft、菠菜或 At;两条路径必须由同一 storage-key + whole-PEP 解析规则驱动。
+
+### Task 6:部署 sj-2 并做用户前端验收
+
+- [ ] 确认修改发生在 `/home/xzq/5090/teamWorkspace/sj-2/PlantAI-dev`,不是 sj 或主项目。
+- [ ] 重启 sj-2 的 9016 Web 服务及该项目全部 Celery workers;启动时显式使用远端 Conda 根 `/home/xzq/miniconda3`。
+- [ ] 检查 9016 健康接口、Web PID、Celery PID 和日志所指项目根均为 sj-2。
+- [ ] 用户前端验证前,只清理独立验证 job;不得删除用户任务或公共 data。
+- [ ] 用户用新 job 验收以下两条路径:
+ 1. 近源参考包可用:一次准备 PEP+CDS → 一个隐藏 PPI-owned Ortholog → PPI → Ka/Ks 确认与复用;
+ 2. 近源下载失败:显示进度和失败 → 用户确认 At → 一个隐藏 PPI-owned Ortholog → PPI → Ka/Ks 确认与复用。
+
+## 4. 完成判据
+
+只有同时满足以下条件,才能宣称修复完成:
+
+- PPI-owned Ortholog 的实际 `--subject` 与 PPI 预检解析出的 whole PEP 是同一路径、同一 SHA256;
+- Ortholog Manifest、identity sidecar、PPI preflight 的 taxon/storage/path/hash/mapping identity 完全一致;
+- PPI 二次预检为 `ready`,PPI 真实执行成功并生成有效产物;
+- Ka/Ks 在 PPI 后出现确认,复用已准备的 PEP+CDS 和 PPI mapping owner,不二次下载、不二次生成 Ortholog;
+- 公共规划中不出现 Ortholog,PPI 公共 scope 仍为 Ft `target_only`;
+- 独立 Ortholog 保持 family PEP 优先,既有分析语义不变;
+- 前端无无限旋转,所有 task 到达 terminal 状态;
+- 真实验证产物只存在独立测试目录,不污染用户 job。
+
+## 5. 回滚边界
+
+若新回归测试证明 `resolve_species_sequence_file(..., family_only=False)` 在某种 storage key 下不能稳定解析 whole PEP,只回滚本次单一分流并保留 RED 证据;不得回滚 owner/scope、严格 identity、PEP+CDS 一次下载或前端进度显示。此时应先补清 resolver 输入契约,再重新评估,不得用 family PEP 或禁用 SHA 校验作为临时通行证。
diff --git a/docs/superpowers/plans/2026-08-10-ppi-kaks-integration-commit-push.md b/docs/superpowers/plans/2026-08-10-ppi-kaks-integration-commit-push.md
new file mode 100644
index 000000000..98d74d36f
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-10-ppi-kaks-integration-commit-push.md
@@ -0,0 +1,43 @@
+# PPI/KaKs Integration Commit and Push Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Commit the validated PPI → owner-specific Ortholog → Ka/Ks workflow and push it to the main PlantAI repository without including runtime data.
+
+**Architecture:** Preserve the tested cross-layer change as one functional integration: frontend orchestration, API/backend identity resolution, owner-namespaced Ortholog materialization, contracts, and their tests. Keep input, workspace, downloaded bundles, logs, caches, and shared data outside Git.
+
+**Tech Stack:** Vue 3 single-page frontend, FastAPI, Celery, Python, pytest, Git.
+
+## Global Constraints
+
+- Push must be a normal fast-forward update; never force-push.
+- Do not stage `input/`, `workspace/`, `final/`, downloaded reference bundles, logs, PID files, caches, or shared `data` content.
+- Preserve standalone, PPI-owner, and KaKs-owner Ortholog namespaces.
+- Verify the relevant regression suite before committing and re-check the committed tree before pushing.
+
+---
+
+### Task 1: Clean the commit boundary
+
+**Files:** All currently modified tracked source, contract, and test files; the three PPI/KaKs plan/spec documents.
+
+- [ ] Remove whitespace errors without changing runtime behavior.
+- [ ] Update stale source-contract assertions to the current unified dialog and producer variable names.
+- [ ] Confirm no runtime outputs are staged.
+
+### Task 2: Verify the integrated workflow
+
+**Files:** `tests/unit/test_ppi*.py`, reference-bundle tests, handoff/snapshot tests, and the affected production files.
+
+- [ ] Run JavaScript syntax validation for `apps/web/vue3/index.html`.
+- [ ] Run the focused PPI/KaKs, reference download, owner identity, and context regression tests.
+- [ ] Run `git diff --check` and inspect the final staged file list.
+
+### Task 3: Commit and push
+
+**Files:** Only the verified source, contracts, tests, and documentation from Tasks 1–2.
+
+- [ ] Fetch `origin/master` and verify the push remains fast-forward.
+- [ ] Commit with a PPI/KaKs integration message.
+- [ ] Push the committed branch to `origin/master` without force.
+- [ ] Verify the remote master commit ID equals the local committed ID.
diff --git a/docs/superpowers/plans/2026-08-10-ppi-kaks-reference-route-handoff.md b/docs/superpowers/plans/2026-08-10-ppi-kaks-reference-route-handoff.md
new file mode 100644
index 000000000..8cc4fcebc
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-10-ppi-kaks-reference-route-handoff.md
@@ -0,0 +1,383 @@
+# PPI/KaKs Reference Route Handoff Implementation Plan
+
+**Goal:** 修复 PPI 完成后 Ka/Ks 的两条参考路线,使用户确认的参考物种与 Ortholog owner 原样传递到预检和执行,不发生二次推导、重复下载或错误暂停。
+
+**Architecture:** 保留现有规划、后端 API、CLI 和 owner 专属 Manifest 契约。PlanningAgent 只规划 PPI 与 Ka/Ks;前端收集并传递用户选择;后端 resolver 只验证所选路线;隐藏 Ortholog producer 只在所选 owner 缺少映射时执行。
+
+**Tech Stack:** Vue 3 内嵌脚本、FastAPI、pytest、Node 动态行为测试、Celery、现有 `/api/ppi/species-resolution` 与 `/api/execute`。
+
+## Global Constraints
+
+- 修订项目固定为 `/home/xzq/5090/teamWorkspace/sj-2/PlantAI-dev`,服务端口固定为 `9016`。
+- 不修改 MainAgent、PlanningAgent、阶段目录、CLI、分析算法、Manifest Schema 或后端 API 契约。
+- 规划继续只显示 PPI 和 Ka/Ks,不显示 Ortholog。
+- PPI 技术参考选择只属于 PPI;Ka/Ks 必须在 PPI settled 后单独确认 `reuse PPI` 或 `project reference`。
+- 用户确认是路线权威来源;前端不得在确认后从 batch/plan 全局状态重新推导另一条路线。
+- 真实验证使用隔离 job 与独立输出目录,不写入用户 job 的 `final/`。
+- 不实现跨 owner 哈希复用、页面刷新恢复、统一状态机、断点续传或其它扩展。
+- 完成修订和验证后再重启 sj-2 Web 与全部 Celery worker;不自动 commit/push。
+
+---
+
+### Task 1: 用精确行为测试锁定两条路线
+
+**Files:**
+- Modify: `tests/unit/test_ppi_owner_scheduling_task5.py`
+- Inspect only: `tests/unit/test_ppi_frontend_display_contract.py`
+
+**Interfaces:**
+- Consumes: `kaksReferenceChoiceParams`、`buildPpiKaksDependencyRequest`、`ppiNeedsNearReferenceDecision`、`prepareKaksSelectedReference`。
+- Produces: 精确复现 PPI 映射复用、At 独立映射、确认参数直传和 PPI-only 近源规则的 RED 测试。
+
+- [ ] **Step 1: 添加 PPI 近源复用路线测试**
+
+构造以下确认结果和 PPI owner 状态:
+
+```javascript
+const confirmed = kaksReferenceChoiceParams('ppi', 'string_taxon_3562', 'At');
+const ppiOwner = {
+ ppiReferenceTaxonId: '3562',
+ ppiReferenceStorageKey: 'string_taxon_3562',
+ ppiReferenceSelectionMode: 'tree_nearest',
+ ppiReferenceUserConfirmed: true
+};
+```
+
+断言 `buildPpiKaksDependencyRequest(['kaks'], confirmed)` 的关键结果为:
+
+```javascript
+{
+ requestedStages: ['kaks'],
+ dependencyOwner: 'ppi',
+ referenceSpecies: ['string_taxon_3562'],
+ ppiReferenceTaxonId: '3562',
+ ppiReferenceStorageKey: 'string_taxon_3562',
+ ppiReferenceSelectionMode: 'tree_nearest',
+ ppiReferenceUserConfirmed: true
+}
+```
+
+- [ ] **Step 2: 添加项目 At 路线测试**
+
+```javascript
+const confirmed = kaksReferenceChoiceParams('project', 'string_taxon_3562', 'At');
+```
+
+断言 `dependencyOwner='kaks'`、`referenceSpecies=['At']`,且不携带菠菜 PPI owner 身份。
+
+- [ ] **Step 3: 添加 PPI-only 近源规则测试**
+
+```javascript
+const resolution = {
+ status: 'needs_ortholog',
+ selected_string_species: 'ath',
+ diagnostics: {
+ reference_recommendations: [{species_code: 'string_taxon_3562'}]
+ }
+};
+```
+
+断言:
+
+```javascript
+ppiNeedsNearReferenceDecision(resolution, ['ppi']) === true;
+ppiNeedsNearReferenceDecision(resolution, ['kaks']) === false;
+```
+
+- [ ] **Step 4: 添加确认参数直传测试**
+
+Stub `ensurePpiKaksDependencyOwner`,执行 `prepareKaksSelectedReference(confirmed)`,断言精确调用:
+
+```javascript
+ensurePpiKaksDependencyOwner('kaks', confirmed);
+```
+
+- [ ] **Step 5: 执行 RED 验证**
+
+```bash
+/home/xzq/miniconda3/envs/test4py311/bin/python -m pytest -q \
+ tests/unit/test_ppi_owner_scheduling_task5.py \
+ -k 'kaks and (reuse or project or confirmed or near_reference)'
+```
+
+Expected: 新增测试按预期失败;不得因测试夹具或语法错误失败。
+
+---
+
+### Task 2: 让 Ka/Ks 预检消费用户确认后的唯一参数
+
+**Files:**
+- Modify: `apps/web/vue3/index.html:14534-14908`
+- Test: `tests/unit/test_ppi_owner_scheduling_task5.py`
+
+**Interfaces:**
+- Consumes: `confirmedStageParamsForExecution('kaks', stageParams)` 的返回值。
+- Produces: `buildPpiKaksDependencyRequest(stageIds, stageParamsOverride)` 与 `ensurePpiKaksDependencyOwner(ownerStageId, stageParamsOverride)`。
+
+- [ ] **Step 1: 让请求构造器接受确认参数,不新增抽象层**
+
+```javascript
+const buildPpiKaksDependencyRequest = (stageIds = [], stageParamsOverride = null) => {
+ const dependencyStage = (batchExecuteStages.value || [])
+ .find(stage => getStageId(stage) === stageIds[0]) || {};
+ const storedStageParams = typeof dependencyStage === 'object'
+ ? (dependencyStage.params || {})
+ : {};
+ const stageParams = stageParamsOverride && typeof stageParamsOverride === 'object'
+ ? stageParamsOverride
+ : storedStageParams;
+```
+
+- [ ] **Step 2: 按 mapping owner 决定是否保留 PPI 身份**
+
+```javascript
+const mappingOwner = stageParams.orthologMappingOwner || ownerStageId;
+const usesPpiOwner = mappingOwner === 'ppi';
+```
+
+仅 `usesPpiOwner` 时,从 `stageParams → batchParams → planParams` 读取 `ppiReferenceTaxonId`、`ppiReferenceStorageKey`、`ppiReferenceSelectionMode`、`ppiReferenceUserConfirmed`。项目 At 路线保持这些字段为空。
+
+- [ ] **Step 3: 让 owner 预检接受确认参数**
+
+```javascript
+const ensurePpiKaksDependencyOwner = async (ownerStageId, stageParamsOverride = null) => {
+ const requestedStages = [ownerStageId];
+ const requestBody = buildPpiKaksDependencyRequest(requestedStages, stageParamsOverride);
+```
+
+- [ ] **Step 4: Ka/Ks 准备函数传入确认参数**
+
+```javascript
+const prepareKaksSelectedReference = async (stageParams = {}) => {
+ const ready = await ensurePpiKaksDependencyOwner('kaks', stageParams);
+```
+
+后续保持:owner 为 `ppi` 时复用已有映射;owner 为 `kaks` 时只生成一次 Ka/Ks 专属 Ortholog。
+
+- [ ] **Step 5: 执行 GREEN 验证**
+
+```bash
+/home/xzq/miniconda3/envs/test4py311/bin/python -m pytest -q tests/unit/test_ppi_owner_scheduling_task5.py
+```
+
+Expected: 全文件通过;原有 PPI owner single-flight、running 状态、失败不 resume 行为仍通过。
+
+---
+
+### Task 3: 将近源优先规则限制为 PPI,并隔离 At 兜底
+
+**Files:**
+- Modify: `apps/web/vue3/index.html:13804-13927`
+- Modify: `apps/web/vue3/index.html:14336-14400`
+- Test: `tests/unit/test_ppi_owner_scheduling_task5.py`
+
+**Interfaces:**
+- Consumes: `requestedStages` 与 PPI fallback 用户确认结果。
+- Produces: PPI-only 近源决策和 fail-closed 的 At fallback。
+
+- [ ] **Step 1: 限制近源规则归属**
+
+```javascript
+const ppiNeedsNearReferenceDecision = (resolution = {}, requestedStages = []) => {
+ if (!requestedStages.includes('ppi')) return false;
+ const selected = String(resolution.selected_string_species || '').toLowerCase();
+ return resolution.status === 'needs_ortholog'
+ && selected === 'ath'
+ && ppiHasNearReferenceBeforeArabidopsis(resolution);
+};
+```
+
+在 `runPpiSpeciesResolutionPreflight` 和 `ensurePpiKaksDependencyOwner` 中均传入当前 `requestedStages`。
+
+- [ ] **Step 2: PPI下载失败后的At兜底不写Ka/Ks参数**
+
+删除 `applyArabidopsisFallbackForPpiKaks` 中针对 `requestedStages.includes('kaks')` 的参数写入;affected stage 只包含 PPI owner 及其隐藏 Ortholog producer。Ka/Ks 保持原状态,等待 PPI settled 后弹出自己的确认框。
+
+- [ ] **Step 3: fallback orchestration 失败时保持暂停**
+
+```javascript
+const orchestrated = await orchestratePpiOwnerAfterReferenceReady({
+ requestBody,
+ requestedStages: ['ppi'],
+ runPreflight: runPpiSpeciesResolutionPreflight,
+ dependencyStatus: dependencyId => stageExecutionStatus.value[dependencyId],
+ waitForDependency: waitForPpiProducerDependency,
+ ensureOrtholog: async (nextResolution, nextRequest) => {
+ const ready = ensureOrthologStageForPpiKaks(
+ batchExecuteStages.value || [],
+ ['ppi'],
+ nextRequest,
+ nextResolution
+ );
+ return {
+ ready: Boolean(ready),
+ producerStage: getPpiKaksInternalOrthologProducer('ppi')
+ };
+ },
+ executeProducer: executePpiOwnedOrthologProducer,
+ resume: async () => resumePpiKaksPausedStages(
+ ['ppi'],
+ '拟南芥兜底参考已确认'
+ )
+});
+if (orchestrated !== true) {
+ const pauseMessage = '拟南芥兜底参考尚未形成可用的 PPI Ortholog 映射;PPI 保持暂停。';
+ storePpiKaksReferencePause(['ppi'], {}, pauseMessage);
+ addAgentMessage('assistant', `⏸️ ${pauseMessage}`);
+ return false;
+}
+```
+
+不得在该 handler 中调用 `resumePpiKaksPausedStages(['kaks'])`。
+
+- [ ] **Step 4: 执行调度回归**
+
+```bash
+/home/xzq/miniconda3/envs/test4py311/bin/python -m pytest -q \
+ tests/unit/test_ppi_owner_scheduling_task5.py \
+ tests/unit/test_ppi_frontend_display_contract.py
+```
+
+Expected: 全部通过;规划中无 Ortholog,PPI 先于 Ka/Ks,Ka/Ks 仍受 `ppiSettledForKaks` 门控。
+
+---
+
+### Task 4: 后端 API 路线矩阵验证,不预先修改后端
+
+**Files:**
+- Inspect only: `apps/web/vue3/main.py:15185-15225`
+- Inspect only: `apps/web/pipeline_backend.py:2795-3177`
+- Inspect only: `apps/web/pipeline_backend.py:13398-13421`
+
+**Interfaces:**
+- Consumes: 前端最终生成的两个 Ka/Ks preflight payload。
+- Produces: 后端现有契约是否足够的证据。
+
+- [ ] **Step 1: 菠菜复用路线 API 验证**
+
+关键 payload:
+
+```json
+{
+ "job_id": "job_codex_ppi_kaks_route_20260810",
+ "prefix": "Ft.SPL",
+ "targetSpecies": ["Ft"],
+ "referenceSpecies": ["string_taxon_3562"],
+ "ppiReferenceTaxonId": "3562",
+ "ppiReferenceStorageKey": "string_taxon_3562",
+ "ppiReferenceSelectionMode": "tree_nearest",
+ "ppiReferenceUserConfirmed": true,
+ "dependencyOwner": "ppi",
+ "requestedStages": ["kaks"]
+}
+```
+
+Expected: `status=ready`,`ortholog_mapping_source` 指向 PPI owner 映射;不产生下载或新 Ortholog。
+
+- [ ] **Step 2: 项目 At 路线 API 验证**
+
+```json
+{
+ "job_id": "job_codex_ppi_kaks_route_20260810",
+ "prefix": "Ft.SPL",
+ "targetSpecies": ["Ft"],
+ "referenceSpecies": ["At"],
+ "dependencyOwner": "kaks",
+ "requestedStages": ["kaks"]
+}
+```
+
+Expected: At PEP/CDS存在且无Ka/Ks owner映射时返回 `status=needs_ortholog`,不是 `needs_reference_data`,也不受菠菜优先规则阻断。
+
+- [ ] **Step 3: 后端范围门禁**
+
+如果两个 API 结果符合预期,后端保持零改动。如果不符合,只记录确切请求与响应并停止,另行制定后端最小修订,不能顺手扩展。
+
+---
+
+### Task 5: 隔离真实数据闭环验证
+
+**Files:**
+- Create at runtime: `input/job_codex_ppi_kaks_route_20260810/`
+- Create at runtime: `workspace/job_codex_ppi_kaks_route_20260810/`
+- Verify only: `final/*.manifest.json`、logs、`/api/stage-status`
+
+**Interfaces:**
+- Consumes: Ft/SPL现有成员输入、At与菠菜参考PEP/CDS。
+- Produces: 两条Ka/Ks路线的真实结果,不污染用户job。
+
+- [ ] **Step 1: 创建隔离 job**
+
+逐文件只读复用 Ft/SPL 成员输入和参考 FASTA;不得把整个用户 input 目录软链接为可写目录。运行时缓存和结果全部写入隔离 job。
+
+- [ ] **Step 2: 路线 A:复用 PPI 菠菜映射**
+
+顺序:PPI owner preflight ready → Ka/Ks确认复用 → Ka/Ks preflight ready → `/api/execute` Ka/Ks。
+
+Expected: Ka/Ks确认后不下载、不生成第二个Ortholog,请求携带 `orthologMappingOwner=ppi`,Ka/Ks成功生成结果和manifest。
+
+- [ ] **Step 3: 路线 B:项目 At**
+
+顺序:Ka/Ks确认At → preflight needs_ortholog → 一次 Ka/Ks owner Ortholog → 二次 preflight ready → `/api/execute` Ka/Ks。
+
+Expected: At PEP/CDS已存在时不下载,只生成一次Ka/Ks owner Ortholog,请求携带 `orthologMappingOwner=kaks`,Ka/Ks成功生成结果和manifest。
+
+- [ ] **Step 4: 核对闭环身份**
+
+核对确认参数、preflight请求、Ortholog命令日志、Ortholog manifest、Ka/Ks执行请求、Ka/Ks日志、Ka/Ks manifest、`/api/stage-status` 的参考物种与 owner 全部一致。
+
+- [ ] **Step 5: 确认用户目录未被写入**
+
+比较验证前后用户 job `input/` 与 `final/` 文件清单和 mtime。Expected: 无新增 PPI、Ka/Ks、Ortholog 或缓存文件。
+
+---
+
+### Task 6: 范围审计、重启与前端验收
+
+**Files:**
+- Verify: git diff
+- Runtime: sj-2 Web 9016 and all sj-2 Celery workers
+
+- [ ] **Step 1: 审计改动范围**
+
+```bash
+git diff --name-only
+git diff --check
+```
+
+Expected code/test changes only:
+
+```text
+apps/web/vue3/index.html
+tests/unit/test_ppi_owner_scheduling_task5.py
+docs/superpowers/plans/2026-08-10-ppi-kaks-reference-route-handoff.md
+```
+
+如出现 `planning_v2.py`、`main.py`、`pipeline_backend.py`、CLI、Manifest Schema 或 stage catalog,停止并撤回本轮非必要修改。
+
+- [ ] **Step 2: 确认无运行任务后重启全部sj-2服务**
+
+先检查 `/api/tasks` 和 Celery active;确认空闲后重启 sj-2 Web 与 default/heavy/medium/light/rnaseq workers,不影响其它项目端口或队列。
+
+- [ ] **Step 3: 健康检查**
+
+Expected: 9016首页HTTP 200;`/api/celery/health` 返回sj-2五类worker在线;9016返回的 `index.html` hash 与磁盘文件一致。
+
+- [ ] **Step 4: 前端验收标准**
+
+路线 A:PPI菠菜成功 → Ka/Ks选择复用 → 无下载、无第二次Ortholog → Ka/Ks成功。
+
+路线 B:PPI菠菜成功 → Ka/Ks选择At → 无下载 → 一次Ka/Ks专属Ortholog → Ka/Ks成功。
+
+PPI下载失败路线:确认At → PPI At Ortholog和PPI完成 → Ka/Ks随后独立弹窗;失败时保持PPI暂停,不伪报恢复。
+
+## Completion Gate
+
+只有同时满足以下条件才可声明完成:
+
+1. 新增测试先RED后GREEN。
+2. 两条后端预检矩阵与预期一致。
+3. 两条真实Ka/Ks路线均执行成功。
+4. 用户job没有新增测试产物。
+5. 改动范围仅限前端调度、对应测试和本计划文档。
+6. 9016 Web与全部sj-2 Celery worker重启并健康。
diff --git a/docs/superpowers/plans/2026-08-10-ppi-kaks-unified-reference-dialogs.md b/docs/superpowers/plans/2026-08-10-ppi-kaks-unified-reference-dialogs.md
new file mode 100644
index 000000000..428c34fac
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-10-ppi-kaks-unified-reference-dialogs.md
@@ -0,0 +1,7 @@
+# PPI/KaKs Unified Reference Dialogs Implementation Plan
+
+1. Add focused frontend contract tests that require both messages to use `showStageConfirmDialog`, ordered candidate lists, grouped reference fields, and their original accept/cancel semantics.
+2. Add two display-only stage IDs and initialize their view model from the existing resolution objects.
+3. Add the two panels and dedicated footer actions to the existing stage-confirmation dialog.
+4. Replace only the two raw `ElMessageBox` calls with queued stage-confirmation calls.
+5. Run the focused tests, related PPI frontend regression tests, syntax/diff checks, then verify the served 9016 page after a safe restart.
diff --git a/docs/superpowers/specs/2026-08-10-ppi-kaks-unified-reference-dialogs-design.md b/docs/superpowers/specs/2026-08-10-ppi-kaks-unified-reference-dialogs-design.md
new file mode 100644
index 000000000..9a6f340ed
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-10-ppi-kaks-unified-reference-dialogs-design.md
@@ -0,0 +1,25 @@
+# PPI/KaKs Unified Reference Dialogs Design
+
+## Goal
+
+Render the two PPI/KaKs reference-species messages inside the existing stage-confirmation dialog shell so their information is readable and visually consistent with the rest of the application.
+
+## Scope
+
+- Replace the raw STRING reference confirmation message box with a stage-confirmation dialog.
+- Replace the raw reference-data/Ortholog warning alert with a read-only stage-confirmation dialog.
+- Show reference candidates as an ordered list.
+- Group target species, requested stages, required/missing files, current reference species, and Ortholog mapping into the existing PPI reference panel layout.
+- Normalize both topology-candidate and downloaded-reference recommendation field names for display only.
+
+## Non-goals
+
+- No changes to PPI/KaKs download, Ortholog generation, identity, scheduling, API payloads, backend validation, or analysis execution.
+- No changes to which candidate is selected: the first ranked candidate remains the confirmed candidate.
+- No new automatic confirmation behavior.
+
+## Interaction contract
+
+- STRING candidate dialog: `Cancel PPI` resolves false; `Confirm top candidate` resolves true.
+- Reference status dialog: `OK` only acknowledges the message; the preflight remains blocked exactly as before.
+- Both dialogs use the existing queue, modal shell, title, progress header, and Element Plus components.
diff --git a/gfa_core/mas/agents/planning_v2.py b/gfa_core/mas/agents/planning_v2.py
index 1237528aa..f76fb1f91 100755
--- a/gfa_core/mas/agents/planning_v2.py
+++ b/gfa_core/mas/agents/planning_v2.py
@@ -44,7 +44,7 @@ from gfa_core.wgs_upstream_manifest import (
)
-GF_TARGET_ONLY_STAGE_PARAMS = {"promoter", "synteny", "karyotype", "pep", "tfbs"}
+GF_TARGET_ONLY_STAGE_PARAMS = {"promoter", "synteny", "karyotype", "pep", "tfbs", "ppi"}
GF_REFERENCE_STAGE_PARAMS = {
"phylogeny",
"motif",
@@ -54,7 +54,6 @@ GF_REFERENCE_STAGE_PARAMS = {
"interspecies_synteny",
"ortholog",
"kaks",
- "ppi",
}
GF_SCOPE_IDENTITY_KEYS = (
"scopeFile",
@@ -1958,17 +1957,6 @@ class PlanningAgentV2(BaseAgent):
recommended_params = learning_recommendations.get("params", {})
print(f"[PlanningAgentV2] 应用Learning推荐参数: {recommended_params}")
- if "ppi" in stage_ids:
- intent_params = dict(intent_params or {})
- ppi_species = str(intent_params.get("ppiSpecies") or intent_params.get("ppi_species") or "").strip().lower()
- if ppi_species and ppi_species != "auto" and not intent_params.get("referenceSpecies"):
- ppi_ref_species = {"ath": ["At"], "osa": ["Os"], "zma": ["Zm"], "gma": ["Gm"], "sly": ["Sl"]}.get(ppi_species, [])
- if ppi_ref_species:
- intent_params["referenceSpecies"] = ppi_ref_species
- intent_params["reference_species"] = ppi_ref_species
- intent_params["refSpeciesList"] = ppi_ref_species
- intent_params.setdefault("scopeMode", "target_plus_reference")
-
for i, stage_id in enumerate(stage_ids):
info = self.STAGE_INFO.get(stage_id, {"name": stage_id, "estimated_time": 10, "cpu": 8})
deps = self.DEPENDENCIES.get(stage_id, [])
diff --git a/gfa_core/mas/agents/router_runtime.py b/gfa_core/mas/agents/router_runtime.py
index a2a5c948e..d19bbcb8f 100644
--- a/gfa_core/mas/agents/router_runtime.py
+++ b/gfa_core/mas/agents/router_runtime.py
@@ -458,6 +458,9 @@ class RouterRuntime:
}
if not all(proposal.values()):
return None
+ original_request = str(raw.get("original_request") or "").strip()
+ if original_request:
+ proposal["original_request"] = original_request
workflow_id = str(raw.get("workflow_id") or "").strip()
if workflow_id in {
"data_preparation",
@@ -474,6 +477,7 @@ class RouterRuntime:
session_id: str,
job_id: str,
query: str,
+ original_query: str,
subject: str,
workflow_id: WorkflowId | None = None,
) -> dict[str, str]:
@@ -487,6 +491,7 @@ class RouterRuntime:
proposal = {
"proposal_id": f"proposal-{digest}",
"request": request,
+ "original_request": str(original_query or "").strip() or request,
"subject": normalized_subject,
}
if workflow_id is not None:
@@ -791,6 +796,7 @@ class RouterRuntime:
session_id=session_id,
job_id=job_id,
query=proposal_query,
+ original_query=query,
subject=decision.subject,
workflow_id=decision.workflow_id,
)
diff --git a/gfa_core/mas/confirmation_snapshot.py b/gfa_core/mas/confirmation_snapshot.py
index d2d35ca47..af563ff60 100644
--- a/gfa_core/mas/confirmation_snapshot.py
+++ b/gfa_core/mas/confirmation_snapshot.py
@@ -21,6 +21,35 @@ def _is_beneath(path: Path, parent: Path) -> bool:
return True
+def _resolve_job_layout(root: Path, job_id: str) -> tuple[Path, Path, Path]:
+ logical_input_job = root / "input" / job_id
+ logical_workspace = root / "workspace" / job_id
+ input_root = (root / "input").resolve(strict=False)
+ workspace_root = (root / "workspace").resolve(strict=False)
+ input_job = logical_input_job.resolve(strict=False)
+ job_workspace = logical_workspace.resolve(strict=False)
+ if _is_beneath(input_job, input_root) and _is_beneath(
+ job_workspace,
+ workspace_root,
+ ):
+ return root, input_job, job_workspace
+
+ if not (logical_input_job.is_symlink() and logical_workspace.is_symlink()):
+ if not _is_beneath(input_job, input_root):
+ raise ValueError("input job path escapes project root")
+ raise ValueError("workspace job path escapes project root")
+
+ input_job = logical_input_job.resolve(strict=True)
+ job_workspace = logical_workspace.resolve(strict=True)
+ linked_root = input_job.parents[1]
+ if (
+ input_job != linked_root / "input" / job_id
+ or job_workspace != linked_root / "workspace" / job_id
+ ):
+ raise ValueError("linked job paths do not share one project root")
+ return linked_root, input_job, job_workspace
+
+
def _read_manifest(path: Path) -> dict[str, Any]:
payload = json.loads(path.read_text(encoding="utf-8"))
if type(payload) is not dict:
@@ -263,12 +292,7 @@ def build_confirmation_snapshot(
if not isinstance(job_id, str) or not _JOB_ID.fullmatch(job_id):
raise ValueError("invalid job_id")
root = Path(project_root).expanduser().resolve(strict=True)
- input_job = (root / "input" / job_id).resolve(strict=False)
- job_workspace = (root / "workspace" / job_id).resolve(strict=False)
- if not _is_beneath(input_job, (root / "input").resolve(strict=False)):
- raise ValueError("input job path escapes project root")
- if not _is_beneath(job_workspace, (root / "workspace").resolve(strict=False)):
- raise ValueError("workspace job path escapes project root")
+ root, input_job, job_workspace = _resolve_job_layout(root, job_id)
final_dir = job_workspace / "final"
result: dict[str, Any] = {
diff --git a/gfa_core/mas/persistent_conversation.py b/gfa_core/mas/persistent_conversation.py
index bca667594..d73fee6f9 100644
--- a/gfa_core/mas/persistent_conversation.py
+++ b/gfa_core/mas/persistent_conversation.py
@@ -368,6 +368,11 @@ class PersistentConversationStore:
for key in ("proposal_id", "request", "subject")
}
if all(normalized_proposal.values()):
+ original_request = str(
+ proposal.get("original_request") or ""
+ ).strip()
+ if original_request:
+ normalized_proposal["original_request"] = original_request
workflow_id = str(proposal.get("workflow_id") or "").strip()
if workflow_id in {
"data_preparation",
diff --git a/gfa_core/mas/stage_catalog.py b/gfa_core/mas/stage_catalog.py
index 9ce115883..6cfa00836 100755
--- a/gfa_core/mas/stage_catalog.py
+++ b/gfa_core/mas/stage_catalog.py
@@ -159,7 +159,7 @@ STAGE_DEPENDENCIES = {
"interspecies_synteny": ["identify"],
"karyotype": ["identify", "synteny"],
"ppi": ["identify"],
- "kaks": ["identify", "ortholog"],
+ "kaks": ["identify"],
"combo": ["identify", "phylogeny", "motif", "structure", "domain"],
"rnaseq_align": ["rnaseq_qc"],
"rnaseq_quant": ["rnaseq_align"],
diff --git a/gfa_core/mas/tools/stage_tools.py b/gfa_core/mas/tools/stage_tools.py
index cc76e2f87..1c5bf32d6 100755
--- a/gfa_core/mas/tools/stage_tools.py
+++ b/gfa_core/mas/tools/stage_tools.py
@@ -546,6 +546,7 @@ class StageToolExecutor:
ppi_reference_selection_mode=(extra_params or {}).get("ppiReferenceSelectionMode") or (extra_params or {}).get("ppi_reference_selection_mode") or "",
ppi_reference_user_confirmed=bool_param("ppiReferenceUserConfirmed", "ppi_reference_user_confirmed", default=False),
dependency_owner=(extra_params or {}).get("dependencyOwner") or (extra_params or {}).get("dependency_owner") or "",
+ ortholog_mapping_owner=(extra_params or {}).get("orthologMappingOwner") or (extra_params or {}).get("ortholog_mapping_owner") or "",
ppi_score=(extra_params or {}).get("ppiScore", 200),
genome_prepare_type=(extra_params or {}).get("genomePrepareType", "auto"),
kaks_neutral_rate=(extra_params or {}).get("kaksNeutralRate", 7.08e-9),
diff --git a/gfa_core/ortholog_materialize.py b/gfa_core/ortholog_materialize.py
index dca1e41f4..75de36ba0 100755
--- a/gfa_core/ortholog_materialize.py
+++ b/gfa_core/ortholog_materialize.py
@@ -9,6 +9,16 @@ from pathlib import Path
from typing import Any, Optional
+ORTHOLOG_DEPENDENCY_OWNERS = {"", "ppi", "kaks"}
+
+
+def ortholog_output_stem(prefix: str, dependency_owner: str = "") -> str:
+ owner = str(dependency_owner or "").strip().lower()
+ if owner not in ORTHOLOG_DEPENDENCY_OWNERS:
+ raise ValueError(f"unsupported Ortholog dependency owner: {dependency_owner}")
+ return f"{prefix}.{owner}" if owner else prefix
+
+
@dataclass
class OrthologRunRecord:
query_species: str
@@ -151,6 +161,7 @@ def materialize_ortholog_final_outputs(
*,
final_dir: Path,
prefix: str,
+ dependency_owner: str = "",
run_records: list[OrthologRunRecord],
job_id: str,
workspace: Path,
@@ -170,10 +181,12 @@ def materialize_ortholog_final_outputs(
final_dir.mkdir(parents=True, exist_ok=True)
scope_fields = dict(scope_fields or {})
+ owner = str(dependency_owner or "").strip().lower()
+ output_stem = ortholog_output_stem(prefix, owner)
- pairs_dst = final_dir / f"{prefix}.ortholog_pairs.txt"
- orthologs_dst = final_dir / f"{prefix}.orthologs.txt"
- raw_pairs_dst = final_dir / f"{prefix}.ortholog_pairs.raw.txt"
+ pairs_dst = final_dir / f"{output_stem}.ortholog_pairs.txt"
+ orthologs_dst = final_dir / f"{output_stem}.orthologs.txt"
+ raw_pairs_dst = final_dir / f"{output_stem}.ortholog_pairs.raw.txt"
# run_ortholog.py writes subjectqueryidentity.
# Downstream PPI expects query/source -> target/reference.
@@ -203,7 +216,7 @@ def materialize_ortholog_final_outputs(
pairs_handle.write(out_line)
orthologs_handle.write(out_line)
- blast_dst = final_dir / f"{prefix}.ortholog_blastp.txt"
+ blast_dst = final_dir / f"{output_stem}.ortholog_blastp.txt"
with blast_dst.open("w", encoding="utf-8") as blast_handle:
for record in run_records:
blast_src = record.blast_src
@@ -266,7 +279,7 @@ def materialize_ortholog_final_outputs(
"sha256": _sha256_file(blast_dst),
}
- ortholog_manifest = final_dir / f"{prefix}.ortholog.manifest.json"
+ ortholog_manifest = final_dir / f"{output_stem}.ortholog.manifest.json"
scope_file = str(scope_fields.pop("scope_file", "") or "").strip()
scope_data = dict(scope_fields)
@@ -283,6 +296,7 @@ def materialize_ortholog_final_outputs(
"stage": "ortholog",
"status": "success",
"prefix": prefix,
+ "dependency_owner": owner,
"job_id": job_id,
"workspace": str(workspace),
"workspace_root": str(workspace),
@@ -303,6 +317,7 @@ def materialize_ortholog_final_outputs(
],
"parameters": {
"prefix": prefix,
+ "dependency_owner": owner,
"blast_evalue": blast_evalue,
"cpu": cpu,
"reference_species": reference_species,
@@ -340,7 +355,7 @@ def materialize_ortholog_final_outputs(
if scope_id:
from gfa_core.gene_family_scope import sanitize_token
- scoped_manifest = final_dir / f"{prefix}.{sanitize_token(scope_id)}.ortholog.manifest.json"
+ scoped_manifest = final_dir / f"{output_stem}.{sanitize_token(scope_id)}.ortholog.manifest.json"
manifest_data["scope_manifest"] = str(scoped_manifest.resolve())
with scoped_manifest.open("w", encoding="utf-8") as handle:
json.dump(manifest_data, handle, ensure_ascii=False, indent=2)
diff --git a/gfa_core/schemas/stages/kaks_contract_v1.md b/gfa_core/schemas/stages/kaks_contract_v1.md
index 675d429c4..1c6ccf35a 100755
--- a/gfa_core/schemas/stages/kaks_contract_v1.md
+++ b/gfa_core/schemas/stages/kaks_contract_v1.md
@@ -27,6 +27,11 @@
- 若 reference CDS 在 config/scope 中缺失,可从已知 reference 数据目录解析,例如 At 的 TAIR10 CDS。
- KaKs 是 reference dependency owner:若缺少 ortholog mapping,可以要求调度器先自动插入 Ortholog;但若 reference CDS/PEP 不完整,必须先提示上传/下载完整参考注释包,不能只跑 Ortholog 后再失败。
- 若 PPI 与 KaKs 同批执行,reference resolver 必须按阶段分别检查;KaKs 使用 `requested_stages=["kaks"]` 检查 reference PEP/CDS 与 Ortholog,而其缺失状态不得覆盖 PPI 的独立就绪状态。
+- 普通计划不显示隐式 Ortholog,KaKs 也不声明对 Ortholog 或 PPI 的静态硬依赖。执行层仅用软顺序保证 KaKs 参数确认发生在 PPI 真实终态之后。
+- KaKs 确认必须显式选择参考来源:复用本次 PPI 技术参考,或使用项目配置参考。选择只写入 KaKs 参数,不得修改 PPI owner 或其它阶段的参考 scope。
+- 复用 PPI 参考时,只有 identity 完全匹配的 `final/{prefix}.ppi.orthologs.txt` 可用,并以 `orthologMappingOwner=ppi` 提交。
+- 选择项目参考时,缺少精确映射则运行 `dependency_owner=kaks` 的内部 Ortholog,输出 `final/{prefix}.kaks.orthologs.txt`,随后以 `orthologMappingOwner=kaks` 提交。
+- 两种 owner 都不得回落到另一 owner 或 standalone 映射。缺少所选参考的 PEP/CDS/映射只暂停 KaKs,不影响已完成 PPI 或其它阶段。
### Legacy 模式
diff --git a/gfa_core/schemas/stages/ortholog_contract_v1.md b/gfa_core/schemas/stages/ortholog_contract_v1.md
index 1f2198aba..c3d7a9fb8 100755
--- a/gfa_core/schemas/stages/ortholog_contract_v1.md
+++ b/gfa_core/schemas/stages/ortholog_contract_v1.md
@@ -59,6 +59,19 @@
| `{prefix}.ortholog.manifest.json` | json | 标准 manifest |
| `{prefix}.{scope_id}.ortholog.manifest.json` | json | scope 模式下的 scoped manifest 副本 |
+Owner namespace:
+
+| producer 身份 | 映射/manifest stem | 用途 |
+|---|---|---|
+| standalone (`dependency_owner=""`) | `{prefix}.ortholog*` | 用户显式请求的 Ortholog,保持原契约 |
+| PPI (`dependency_owner="ppi"`) | `{prefix}.ppi.ortholog*` | PPI STRING 技术参考专属内部映射 |
+| KaKs (`dependency_owner="kaks"`) | `{prefix}.kaks.ortholog*` | KaKs 显式项目参考专属内部映射 |
+
+- owner namespace 同时作用于 orthologs、pairs、raw pairs、BLAST、identity sidecar、标准 manifest 与 scoped manifest。
+- manifest 的 `stage` 仍为 `ortholog`;`parameters.dependency_owner` 记录 producer 身份。
+- standalone、PPI、KaKs 三套产物可为同一 prefix 并存;重跑一个 owner 不得覆盖或改变另一个 owner。
+- 下游按 owner token 解析候选路径,不接受前端传入任意文件路径,也不得跨 owner 回落。
+
## Manifest 最小字段
```json
@@ -104,6 +117,7 @@
- 单 target + `target_only` scope 时,`analysis_route` 通常为 `single_species`;含 reference 的 comparative scope 通常为 `multi_species`。
- postflight strict identity 诊断依赖上述字段;materialize 落盘时必须补齐,不能只写 `scope_id`。
- PPI-owned producer 的 `reference_taxon_id` 来自已确认 STRING TaxID,`reference_storage_key` 决定本地 PEP 路径及 mapping 的 Target_Species。
+- PPI-owned producer 的生物学 scope 必须与 PPI consumer 相同,固定为目标家族 `target_only`;技术参考物种只写入 owner/provenance 参数。
- sidecar 与当前请求必须在 `reference_taxon_id`、`reference_storage_key`、reference PEP SHA256、target/query PEP identity、scope/member identity、mapping path/SHA256 和 `mapping_identity` 上完全一致,才允许跳过 BLAST 并复用中间 pairs。
- sidecar 缺失、无法解析、字段缺失或任一 identity 冲突都视为不可复用;应按原 Ortholog 命令重新生成,不得根据“文件存在”推断身份。
- PPI preflight 不得只读取 final mapping 的 Target_Species 后判定 ready;还必须同时验证 final mapping sidecar、对应 Ortholog manifest,以及当前 reference PEP、target/query PEP、scope/member 和 mapping identity。任一输入变化或身份材料缺失均返回 `needs_ortholog` / stale identity conflict。
diff --git a/gfa_core/schemas/stages/ppi_contract_v1.md b/gfa_core/schemas/stages/ppi_contract_v1.md
index 56fe9eb18..962cef0cc 100755
--- a/gfa_core/schemas/stages/ppi_contract_v1.md
+++ b/gfa_core/schemas/stages/ppi_contract_v1.md
@@ -181,6 +181,17 @@ python skills/ppi/run.py \
## 下游阶段
- summary: 可汇总PPI结果
+## 内部 Ortholog 身份与调度
+
+- 普通基因家族计划只展示 PPI;PPI 所需的 Ortholog 是执行层内部 producer,不作为可见计划阶段或独立确认弹窗。
+- PPI 的生物学分析身份固定为目标家族成员 `target_only`;项目参考物种不得进入 PPI 的 `scope_id`、`analysis_route` 或 `member_set_id`。
+- STRING TaxID、技术参考 storage key 和同源映射参考只属于 PPI 技术溯源,不得被当作生物学 `referenceSpecies` 重建 PPI scope。
+- 非目标物种直连 STRING 路线使用 `dependency_owner=ppi`,映射输出为 `final/{prefix}.ppi.orthologs.txt`(及同 owner 的 pairs/raw/BLAST/manifest/sidecar)。
+- PPI 只能读取 PPI owner 映射;不得因 standalone 或 KaKs owner 文件存在而静默复用。
+- PPI 的 STRING 技术参考只属于 PPI。它不得覆盖项目参考物种或 KaKs 的显式参考选择。
+- PPI 成功、失败或用户明确跳过都会释放后续 KaKs 确认;正在进行的 PPI 参考包下载不是终态。
+- PPI-owned Ortholog 完成后必须用同一 target-only 身份重新执行预检;只有返回 `ready` 才能提交 PPI。
+
## 网络统计指标
| 指标 | 描述 |
diff --git a/tests/unit/test_agent_chat_actions.py b/tests/unit/test_agent_chat_actions.py
index 5aee446d4..d6b2ee2b5 100644
--- a/tests/unit/test_agent_chat_actions.py
+++ b/tests/unit/test_agent_chat_actions.py
@@ -122,6 +122,7 @@ async def test_agent_chat_persists_router_proposal_without_analysis_intent(
proposal = {
"proposal_id": "proposal-123",
"request": "执行 Ft 的数据准备",
+ "original_request": "执行数据准备",
"subject": "执行 Ft 的数据准备",
}
_install_router(
@@ -166,6 +167,7 @@ async def test_agent_chat_exposes_confirmed_request_for_original_planner(
proposal = {
"proposal_id": "proposal-next",
"request": "我已确认 config.xlsx,接下来应该做什么分析",
+ "original_request": "执行kaks分析",
"subject": "启动苦荞SPL基因家族分析",
}
_install_router(
@@ -192,7 +194,7 @@ async def test_agent_chat_exposes_confirmed_request_for_original_planner(
)
assert result["action"] == "propose_analysis"
- assert result["handoff_request"] == proposal["request"]
+ assert result["handoff_request"] == proposal["original_request"]
assert "尚未执行" in result["intent"]["message"]
diff --git a/tests/unit/test_analysis_plan_handoff_contract.py b/tests/unit/test_analysis_plan_handoff_contract.py
new file mode 100644
index 000000000..1cd633f3a
--- /dev/null
+++ b/tests/unit/test_analysis_plan_handoff_contract.py
@@ -0,0 +1,15 @@
+from pathlib import Path
+
+
+PROJECT_ROOT = Path(__file__).resolve().parents[2]
+
+
+def test_analysis_plan_handoff_does_not_forward_main_agent_intent() -> None:
+ source = (PROJECT_ROOT / "apps/web/vue3/index.html").read_text(encoding="utf-8")
+ start = source.index("const planData = await fetchJsonWithTimeout('/api/agent/plan'")
+ end = source.index("}, 180000, '多智能体规划');", start)
+ payload_source = source[start:end]
+
+ assert "message: data.handoff_request || userInput" in payload_source
+ assert "...(data.action === 'propose_utility' ? { intent: data.intent } : {})" in payload_source
+ assert "intent: data.intent //" not in payload_source
diff --git a/tests/unit/test_confirmation_snapshot_linked_job.py b/tests/unit/test_confirmation_snapshot_linked_job.py
new file mode 100644
index 000000000..ee3261936
--- /dev/null
+++ b/tests/unit/test_confirmation_snapshot_linked_job.py
@@ -0,0 +1,88 @@
+import importlib.util
+import json
+from pathlib import Path
+
+import pytest
+
+
+def _load_confirmation_snapshot_module():
+ module_path = (
+ Path(__file__).resolve().parents[2]
+ / "gfa_core"
+ / "mas"
+ / "confirmation_snapshot.py"
+ )
+ spec = importlib.util.spec_from_file_location(
+ "confirmation_snapshot_linked_job_test",
+ module_path,
+ )
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def _write_legacy_config_confirmation(source_root: Path, job_id: str) -> Path:
+ input_job = source_root / "input" / job_id
+ final_dir = source_root / "workspace" / job_id / "final"
+ input_job.mkdir(parents=True)
+ final_dir.mkdir(parents=True)
+ config_path = input_job / "config.xlsx"
+ config_path.write_bytes(b"confirmed config")
+ manifest_path = final_dir / "config_autofill.manifest.json"
+ manifest_path.write_text(
+ json.dumps(
+ {
+ "manifest_version": "1.0",
+ "stage": "config_autofill",
+ "status": "success",
+ "artifacts": {
+ "confirmed_config": {
+ "path": str(config_path),
+ "type": "xlsx",
+ }
+ },
+ }
+ ),
+ encoding="utf-8",
+ )
+ return config_path
+
+
+def test_snapshot_accepts_paired_job_links_to_one_project_root(tmp_path):
+ snapshot_module = _load_confirmation_snapshot_module()
+ job_id = "job_linked"
+ project_root = tmp_path / "sj-2"
+ source_root = tmp_path / "source"
+ config_path = _write_legacy_config_confirmation(source_root, job_id)
+ (project_root / "input").mkdir(parents=True)
+ (project_root / "workspace").mkdir(parents=True)
+ (project_root / "input" / job_id).symlink_to(source_root / "input" / job_id)
+ (project_root / "workspace" / job_id).symlink_to(
+ source_root / "workspace" / job_id
+ )
+
+ snapshot = snapshot_module.build_confirmation_snapshot(project_root, job_id)
+
+ assert snapshot["config_confirmed"] is True
+ assert snapshot["config"]["artifact_path"] == str(config_path.resolve())
+
+
+def test_snapshot_rejects_job_links_to_different_project_roots(tmp_path):
+ snapshot_module = _load_confirmation_snapshot_module()
+ job_id = "job_mismatch"
+ project_root = tmp_path / "sj-2"
+ input_source = tmp_path / "input-source"
+ workspace_source = tmp_path / "workspace-source"
+ (input_source / "input" / job_id).mkdir(parents=True)
+ (workspace_source / "workspace" / job_id / "final").mkdir(parents=True)
+ (project_root / "input").mkdir(parents=True)
+ (project_root / "workspace").mkdir(parents=True)
+ (project_root / "input" / job_id).symlink_to(
+ input_source / "input" / job_id
+ )
+ (project_root / "workspace" / job_id).symlink_to(
+ workspace_source / "workspace" / job_id
+ )
+
+ with pytest.raises(ValueError, match="linked job paths do not share one project root"):
+ snapshot_module.build_confirmation_snapshot(project_root, job_id)
diff --git a/tests/unit/test_kaks_reference_bundle_execute_validation.py b/tests/unit/test_kaks_reference_bundle_execute_validation.py
new file mode 100644
index 000000000..9c7417412
--- /dev/null
+++ b/tests/unit/test_kaks_reference_bundle_execute_validation.py
@@ -0,0 +1,93 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from openpyxl import Workbook
+
+from apps.web.vue3 import main
+
+
+def _write_config(input_dir: Path, pep: Path, cds: Path) -> None:
+ workbook = Workbook()
+ sheet = workbook.active
+ sheet.title = "config"
+ sheet.append(
+ [
+ "Species_Code",
+ "Species_Role",
+ "File_Type",
+ "Original_File_Name",
+ "Ref",
+ ]
+ )
+ sheet.append(["At", "reference", "pep", pep.name, True])
+ sheet.append(["At", "reference", "cds", cds.name, True])
+ workbook.save(input_dir / "config.xlsx")
+
+
+def test_execute_validation_accepts_config_resolved_reference_pep_and_cds(
+ tmp_path: Path,
+ monkeypatch,
+) -> None:
+ input_root = tmp_path / "input"
+ job_input = input_root / "job_at"
+ external = tmp_path / "external"
+ job_input.mkdir(parents=True)
+ external.mkdir()
+ pep = external / "Arabidopsis_thaliana.TAIR10.pep.all.fa"
+ cds = external / "Arabidopsis_thaliana.TAIR10.cds.all.fa"
+ pep.write_text(">AT1G01010\nMA\n", encoding="utf-8")
+ cds.write_text(">AT1G01010\nATGGCT\n", encoding="utf-8")
+ _write_config(job_input, pep, cds)
+ monkeypatch.setattr(main, "INPUT_ROOT", input_root)
+ monkeypatch.setenv("PLANTAI_REF_DATA_ROOT", str(external))
+
+ available, source = main._reference_bundle_species_available(
+ "job_at",
+ "At",
+ require_cds=True,
+ )
+
+ assert available is True
+ assert source == str(job_input)
+
+
+def test_execute_validation_keeps_accepting_downloaded_reference_bundle(
+ tmp_path: Path,
+ monkeypatch,
+) -> None:
+ input_root = tmp_path / "input"
+ species_dir = input_root / "job_spinach" / "string_taxon_3562"
+ species_dir.mkdir(parents=True)
+ (species_dir / "string_taxon_3562.pep.fasta").write_text(">S1\nMA\n", encoding="utf-8")
+ (species_dir / "string_taxon_3562.cds.fasta").write_text(">S1\nATGGCT\n", encoding="utf-8")
+ monkeypatch.setattr(main, "INPUT_ROOT", input_root)
+
+ available, source = main._reference_bundle_species_available(
+ "job_spinach",
+ "string_taxon_3562",
+ require_cds=True,
+ )
+
+ assert available is True
+ assert source == str(species_dir)
+
+
+def test_execute_validation_still_rejects_a_truly_incomplete_reference(
+ tmp_path: Path,
+ monkeypatch,
+) -> None:
+ input_root = tmp_path / "input"
+ species_dir = input_root / "job_incomplete" / "At"
+ species_dir.mkdir(parents=True)
+ (species_dir / "At.pep.fasta").write_text(">AT1G01010\nMA\n", encoding="utf-8")
+ monkeypatch.setattr(main, "INPUT_ROOT", input_root)
+
+ available, reason = main._reference_bundle_species_available(
+ "job_incomplete",
+ "At",
+ require_cds=True,
+ )
+
+ assert available is False
+ assert "missing CDS" in reason
diff --git a/tests/unit/test_persistent_conversation_job_isolation.py b/tests/unit/test_persistent_conversation_job_isolation.py
index 661eb8eec..d7909e203 100644
--- a/tests/unit/test_persistent_conversation_job_isolation.py
+++ b/tests/unit/test_persistent_conversation_job_isolation.py
@@ -51,3 +51,28 @@ def test_rebind_to_same_job_keeps_conversation_identity(tmp_path: Path) -> None:
)
assert conversation_id == turn.conversation_id
+
+
+def test_pending_proposal_preserves_original_analysis_request(tmp_path: Path) -> None:
+ store = _store(tmp_path)
+ turn = store.begin_turn(
+ session_id="kaks-session",
+ job_id="job_kaks",
+ query="执行kaks分析",
+ client_history=[],
+ )
+
+ ledger = store.finish_turn(
+ turn,
+ assistant_message="是否继续?",
+ interaction_kind="independent",
+ action="clarify",
+ proposal={
+ "proposal_id": "proposal-kaks",
+ "request": "执行kaks分析\n已确认上下文:subfamily_confirm 已完成。",
+ "original_request": "执行kaks分析",
+ "subject": "执行kaks分析",
+ },
+ )
+
+ assert ledger.user_terms["pending_proposal"]["original_request"] == "执行kaks分析"
diff --git a/tests/unit/test_pipeline_backend_execution_env.py b/tests/unit/test_pipeline_backend_execution_env.py
index 29f69f87b..976fec47d 100755
--- a/tests/unit/test_pipeline_backend_execution_env.py
+++ b/tests/unit/test_pipeline_backend_execution_env.py
@@ -26,7 +26,7 @@ def _materialize_trusted_ppi_mapping(
pairs = workspace / "ortholog" / f"{prefix}.{target}_to_{storage_key}_pairs.txt"
pairs.parent.mkdir(parents=True, exist_ok=True)
pairs.write_text(f"{subject_id}\t{query_id}\t91\n", encoding="utf-8")
- scope_id = build_scope_id(family, [target], [storage_key], [], "target_plus_reference")
+ scope_id = build_scope_id(family, [target], [], [], "target_only")
identity = build_ortholog_run_identity(
query_species=target, reference_taxon_id=taxon_id,
reference_storage_key=storage_key, query_fasta=query,
@@ -35,14 +35,15 @@ def _materialize_trusted_ppi_mapping(
)
materialize_ortholog_final_outputs(
final_dir=final_dir, prefix=prefix,
+ dependency_owner="ppi",
run_records=[OrthologRunRecord(target, storage_key, query, reference, pairs)],
job_id=job.job_id, workspace=workspace, reference_species=storage_key,
reference_taxon_id=taxon_id, reference_storage_key=storage_key,
ortholog_identity=identity,
scope_fields={
- "scope_id": scope_id, "scope_mode": "target_plus_reference",
- "target_species": [target], "reference_species": [storage_key],
- "selected_species": [target, storage_key], "family": family,
+ "scope_id": scope_id, "scope_mode": "target_only",
+ "target_species": [target], "reference_species": [],
+ "selected_species": [target], "family": family,
},
)
@@ -125,6 +126,49 @@ def test_reference_bundle_id_map_preserves_loc_and_numeric_geneid(tmp_path):
assert row["ncbi_gene_id"] == "110806180"
+def test_reference_bundle_id_map_reads_ensembl_gene_token_for_isoforms(tmp_path):
+ from gfa_core.id_resolution import ReferenceAliasIndex
+
+ pep_path = tmp_path / "At.pep.fasta"
+ cds_path = tmp_path / "At.cds.fasta"
+ output_path = tmp_path / "At.pep_cds_idmap.tsv"
+ pep_path.write_text(
+ ">AT1G02065.1 pep chromosome:TAIR10:1:365209:367443:1 "
+ "gene:AT1G02065 transcript:AT1G02065.1 gene_symbol:SPL8\nMAAA\n"
+ ">AT1G02065.2 pep chromosome:TAIR10:1:365165:367353:1 "
+ "gene:AT1G02065 transcript:AT1G02065.2 gene_symbol:SPL8\nMBBB\n",
+ encoding="utf-8",
+ )
+ cds_path.write_text(
+ ">AT1G02065.1 cds chromosome:TAIR10:1:365209:367443:1 "
+ "gene:AT1G02065 transcript:AT1G02065.1 gene_symbol:SPL8\nATGGCG\n"
+ ">AT1G02065.2 cds chromosome:TAIR10:1:365165:367353:1 "
+ "gene:AT1G02065 transcript:AT1G02065.2 gene_symbol:SPL8\nATGGCC\n",
+ encoding="utf-8",
+ )
+
+ backend._build_reference_bundle_id_map(
+ species_code="At",
+ pep_path=pep_path,
+ cds_path=cds_path,
+ output_path=output_path,
+ )
+
+ with output_path.open(encoding="utf-8", newline="") as handle:
+ rows = list(csv.DictReader(handle, delimiter="\t"))
+ assert {row["protein_id"]: row["gene_id"] for row in rows} == {
+ "AT1G02065.1": "AT1G02065",
+ "AT1G02065.2": "AT1G02065",
+ }
+
+ candidates = ReferenceAliasIndex.from_tsv(output_path, "At").candidates("AT1G02065")
+ assert {candidate.value for candidate in candidates} >= {
+ "AT1G02065",
+ "AT1G02065.1",
+ "AT1G02065.2",
+ }
+
+
def test_reference_bundle_download_extracts_ncbi_zip_and_unblocks_ppi_kaks_preflight(tmp_path, monkeypatch):
job = backend.JobInfo(
job_id="job_test_ref_bundle",
@@ -601,7 +645,7 @@ def test_ppi_species_resolution_selected_near_string_species_still_needs_referen
assert resolution["reference_data_source"] == ""
-def test_ppi_species_resolution_selected_near_string_species_with_pep_needs_ortholog(tmp_path):
+def test_ppi_species_resolution_selected_near_string_species_with_complete_bundle_needs_ortholog(tmp_path):
job = backend.JobInfo(
job_id="job_test",
input_dir=str(tmp_path / "input"),
@@ -615,6 +659,7 @@ def test_ppi_species_resolution_selected_near_string_species_with_pep_needs_orth
final_dir.mkdir(parents=True)
(input_dir / "soe").mkdir()
(input_dir / "soe" / "soe.pep.fasta").write_text(">SoeGene1\nMA\n", encoding="utf-8")
+ (input_dir / "soe" / "soe.cds.fasta").write_text(">SoeGene1\nATG\n", encoding="utf-8")
resolution = backend.resolve_ppi_species_resolution(
job=job,
@@ -692,7 +737,6 @@ def test_ppi_kaks_dependency_preflight_requires_reference_cds_before_ortholog(tm
final_dir.mkdir(parents=True)
(input_dir / "soe").mkdir()
(input_dir / "soe" / "soe.pep.fasta").write_text(">SoeGene1\nMA\n", encoding="utf-8")
-
resolution = backend.resolve_ppi_species_resolution(
job=job,
prefix="Ft.SPL",
@@ -790,6 +834,7 @@ def test_ppi_species_resolution_selected_near_string_species_with_mapping_is_rea
final_dir.mkdir(parents=True)
(input_dir / "soe").mkdir()
(input_dir / "soe" / "soe.pep.fasta").write_text(">SoeGene1\nMA\n", encoding="utf-8")
+ (input_dir / "soe" / "soe.cds.fasta").write_text(">SoeGene1\nATGGCT\n", encoding="utf-8")
_materialize_trusted_ppi_mapping(
job, prefix="Ft.SPL", target="Ft", family="SPL",
storage_key="soe", taxon_id="3562", query_id="FtGene1", subject_id="SoeGene1",
@@ -812,7 +857,7 @@ def test_ppi_species_resolution_selected_near_string_species_with_mapping_is_rea
assert resolution["status"] == "ready"
assert resolution["selected_string_species"] == "soe"
assert resolution["selected_taxon_id"] == "3562"
- assert resolution["ortholog_mapping_source"].endswith("Ft.SPL.orthologs.txt")
+ assert resolution["ortholog_mapping_source"].endswith("Ft.SPL.ppi.orthologs.txt")
assert resolution["decision"] == "ready_reference_mapping"
@@ -830,6 +875,7 @@ def test_ppi_species_resolution_can_ignore_existing_mapping_for_pre_ortholog_che
final_dir.mkdir(parents=True)
(input_dir / "soe").mkdir()
(input_dir / "soe" / "soe.pep.fasta").write_text(">SoeGene1\nMA\n", encoding="utf-8")
+ (input_dir / "soe" / "soe.cds.fasta").write_text(">SoeGene1\nATGGCT\n", encoding="utf-8")
_materialize_trusted_ppi_mapping(
job, prefix="Ft.SPL", target="Ft", family="SPL",
storage_key="soe", taxon_id="3562", query_id="FtGene1", subject_id="SoeGene1",
@@ -864,7 +910,7 @@ def test_ppi_species_resolution_can_ignore_existing_mapping_for_pre_ortholog_che
)
assert ready["status"] == "ready"
- assert ready["ortholog_mapping_source"].endswith("Ft.SPL.orthologs.txt")
+ assert ready["ortholog_mapping_source"].endswith("Ft.SPL.ppi.orthologs.txt")
assert pre_ortholog["status"] == "needs_ortholog"
assert pre_ortholog["ortholog_mapping_source"] == ""
assert pre_ortholog["selected_string_species"] == "soe"
@@ -970,7 +1016,7 @@ def test_kaks_preflight_recovers_non_ath_reference_and_prioritizes_missing_cds(t
assert resolution["reference_bundle_status"]["missing_required"] == ["cds"]
-def test_ppi_species_resolution_reports_needs_ortholog_when_reference_pep_exists(tmp_path):
+def test_ppi_species_resolution_requires_cds_when_only_reference_pep_exists(tmp_path):
job = backend.JobInfo(
job_id="job_test",
input_dir=str(tmp_path / "input"),
@@ -999,13 +1045,14 @@ def test_ppi_species_resolution_reports_needs_ortholog_when_reference_pep_exists
dependency_owner="ppi",
)
- assert resolution["status"] == "needs_ortholog"
- assert resolution["issue_code"] == "ppi_requires_ortholog_mapping"
+ assert resolution["status"] == "needs_reference_data"
+ assert resolution["issue_code"] == "ppi_reference_data_missing"
+ assert resolution["reference_bundle_status"]["missing_required"] == ["cds"]
assert resolution["reference_data_source"].endswith("soe/soe.pep.fasta")
assert resolution["selected_string_species"] == "soe"
-def test_ppi_reference_bundle_download_requests_only_pep(tmp_path, monkeypatch):
+def test_ppi_reference_bundle_download_requests_pep_and_cds(tmp_path, monkeypatch):
job = backend.JobInfo(
job_id="job_test",
input_dir=str(tmp_path / "input"),
@@ -1039,7 +1086,10 @@ def test_ppi_reference_bundle_download_requests_only_pep(tmp_path, monkeypatch):
monkeypatch.setattr(
backend,
"_extract_ncbi_reference_bundle_zip",
- lambda **kwargs: {"pep": str(tmp_path / "input" / "soe" / "soe.pep.fasta")},
+ lambda **kwargs: {
+ "pep": str(tmp_path / "input" / "soe" / "soe.pep.fasta"),
+ "cds": str(tmp_path / "input" / "soe" / "soe.cds.fasta"),
+ },
)
monkeypatch.setattr(
backend,
@@ -1047,8 +1097,8 @@ def test_ppi_reference_bundle_download_requests_only_pep(tmp_path, monkeypatch):
lambda **kwargs: {
"id_map_path": str(tmp_path / "input" / "soe" / "soe.pep_cds_idmap.tsv"),
"pep_records": 1,
- "cds_records": 0,
- "pep_cds_overlap": 0,
+ "cds_records": 1,
+ "pep_cds_overlap": 1,
},
)
@@ -1060,10 +1110,10 @@ def test_ppi_reference_bundle_download_requests_only_pep(tmp_path, monkeypatch):
requested_stages=["ppi"],
)
- assert captured["file_types"] == ["pep"]
+ assert captured["file_types"] == ["pep", "cds"]
assert result["status"] == "success"
assert result["missing_required"] == []
- assert result["requested_file_types"] == ["pep"]
+ assert result["requested_file_types"] == ["pep", "cds"]
def test_ppi_execution_scope_canonicalizes_full_species_names_before_mapping_filter(tmp_path, monkeypatch):
@@ -1323,6 +1373,70 @@ def test_ppi_projected_soe_rejects_missing_id_map(tmp_path, monkeypatch):
assert message == f"❌ PPI projected analysis requires species-matched ID map: {expected}"
+def test_ppi_projected_builds_missing_id_map_from_species_matched_pep_and_cds(tmp_path, monkeypatch):
+ job = backend.JobInfo(
+ job_id="job_test",
+ input_dir=str(tmp_path / "input"),
+ workspace_dir=str(tmp_path / "workspace"),
+ logs_dir=str(tmp_path / "workspace" / "logs"),
+ created_at="",
+ )
+ input_dir = tmp_path / "input"
+ workspace = tmp_path / "workspace"
+ final_dir = workspace / "final"
+ logs_dir = workspace / "logs"
+ soe_dir = input_dir / "soe"
+ soe_dir.mkdir(parents=True)
+ final_dir.mkdir(parents=True)
+ logs_dir.mkdir(parents=True)
+ (soe_dir / "soe.pep.fasta").write_text(">SoeGene1 [gene=SoeGene1]\nMAAA\n", encoding="utf-8")
+ (soe_dir / "soe.cds.fasta").write_text(">SoeGene1 [gene=SoeGene1]\nATGGCT\n", encoding="utf-8")
+ (final_dir / "Os.CRR.final_members.id").write_text("Os01t0810800-01\n", encoding="utf-8")
+ mapping_file = final_dir / "Os.CRR.ppi.orthologs.txt"
+ mapping_file.write_text("Os01t0810800-01\tSoeGene1\t90\tOs\tsoe\n", encoding="utf-8")
+ scope_file = final_dir / "Os.CRR.CRR.target-Os.target_only.scope.json"
+ scope_file.write_text(json.dumps({
+ "family": "CRR",
+ "prefix": "Os.CRR",
+ "scope_id": "CRR.target-Os.target_only",
+ "scope_mode": "target_only",
+ "target_species": ["Os"],
+ "reference_species": [],
+ "selected_species": ["Os"],
+ }), encoding="utf-8")
+ captured = {}
+
+ def fake_run_cmd_logged(cmd, log_path, cwd=None):
+ captured["cmd"] = cmd
+ _write_fake_ppi_outputs(workspace / "ppi")
+ return 0
+
+ monkeypatch.setattr(backend, "can_start_new_task", lambda *args, **kwargs: (True, "ok"))
+ monkeypatch.setattr(backend, "run_cmd_logged", fake_run_cmd_logged)
+ monkeypatch.setattr(backend, "download_string_network_image", lambda *args, **kwargs: None)
+
+ message, manifest = backend.run_stage(
+ "ppi",
+ job,
+ "Os.CRR",
+ ppi_species="auto",
+ ppi_reference_storage_key="soe",
+ ppi_reference_taxon_id="3562",
+ ppi_reference_user_confirmed=True,
+ dependency_owner="ppi",
+ ppi_score=400,
+ scope_file=str(scope_file),
+ analysis_route="single_species",
+ )
+
+ expected = soe_dir / "soe.pep_cds_idmap.tsv"
+ assert "✅ PPI analysis completed" in message
+ assert manifest and manifest.exists()
+ assert expected.is_file()
+ assert captured["cmd"][captured["cmd"].index("--id-map") + 1] == str(expected)
+ assert captured["cmd"][captured["cmd"].index("--normalization-species-label") + 1] == "soe"
+
+
def test_ppi_explicit_absolute_species_rejected_before_outside_id_map_lookup(tmp_path, monkeypatch):
job = backend.JobInfo(
job_id="job_test",
diff --git a/tests/unit/test_planning_ppi_scope_isolation.py b/tests/unit/test_planning_ppi_scope_isolation.py
new file mode 100644
index 000000000..faedbbbd8
--- /dev/null
+++ b/tests/unit/test_planning_ppi_scope_isolation.py
@@ -0,0 +1,54 @@
+from gfa_core.mas.agents.planning_v2 import PlanningAgentV2
+
+
+def test_ppi_species_does_not_change_unrelated_stage_biological_scope() -> None:
+ planner = PlanningAgentV2.__new__(PlanningAgentV2)
+
+ stages = planner._generate_default_stages(
+ ["motif", "ppi"],
+ "Ft.SPL",
+ intent_params={
+ "geneFamily": "SPL",
+ "targetSpecies": ["Ft"],
+ "scopeMode": "target_only",
+ "analysisRoute": "single_species",
+ "ppiSpecies": "ath",
+ },
+ )
+
+ stage_by_id = {stage["id"]: stage for stage in stages}
+ motif_params = stage_by_id["motif"]["params"]
+ ppi_params = stage_by_id["ppi"]["params"]
+
+ assert motif_params["targetSpecies"] == ["Ft"]
+ assert motif_params["scopeMode"] == "target_only"
+ assert motif_params["analysisRoute"] == "single_species"
+ assert not motif_params.get("referenceSpecies")
+ assert ppi_params["ppiSpecies"] == "ath"
+
+
+def test_ppi_plan_is_target_only_while_kaks_keeps_project_reference() -> None:
+ planner = PlanningAgentV2.__new__(PlanningAgentV2)
+
+ stages = planner._generate_default_stages(
+ ["ppi", "kaks"],
+ "Ft.SPL",
+ intent_params={
+ "geneFamily": "SPL",
+ "targetSpecies": ["Ft"],
+ "referenceSpecies": ["At"],
+ "scopeMode": "target_plus_reference",
+ "analysisRoute": "multi_species",
+ },
+ )
+
+ stage_by_id = {stage["id"]: stage for stage in stages}
+ ppi_params = stage_by_id["ppi"]["params"]
+ kaks_params = stage_by_id["kaks"]["params"]
+
+ assert ppi_params["targetSpecies"] == ["Ft"]
+ assert ppi_params["referenceSpecies"] == []
+ assert ppi_params["scopeMode"] == "target_only"
+ assert ppi_params["analysisRoute"] == "single_species"
+ assert kaks_params["referenceSpecies"] == ["At"]
+ assert kaks_params["scopeMode"] == "target_plus_reference"
diff --git a/tests/unit/test_ppi_frontend_display_contract.py b/tests/unit/test_ppi_frontend_display_contract.py
index 449dd2d47..ae0e8ea38 100755
--- a/tests/unit/test_ppi_frontend_display_contract.py
+++ b/tests/unit/test_ppi_frontend_display_contract.py
@@ -1,5 +1,7 @@
from pathlib import Path
+from apps.web.vue3 import main as web_main
+
REPO_ROOT = Path(__file__).resolve().parents[2]
@@ -37,12 +39,12 @@ def test_ppi_alphafold_links_remain_in_file_list_and_use_table_preview():
assert "tablePreviewFilteredRows" in html
-def test_natural_language_ppi_reset_intent_opens_reset_dialog():
+def test_ppi_result_action_opens_reset_dialog():
html = _index_html()
assert "isPpiResetIntent" in html
assert "raw.includes('基因id')" in html
- assert "openPpiResetDialog()" in html
- assert "PPI Reset 基因列表编辑弹窗" in html
+ assert '@click="openPpiResetDialog(stage)"' in html
+ assert 'title="PPI Reset 输入基因列表"' in html
def test_ppi_reset_uses_current_stage_prefix_before_default_execution_prefix():
@@ -91,7 +93,28 @@ def test_ppi_execute_path_runs_species_resolution_preflight_before_execute():
assert "Spinacia oleracea" not in html
assert "const shouldRunPpiKaksPreflight =" in html
assert "const currentIsDependencyOwner = ppiKaksDependencyStages.has(currentStageId) || ppiKaksDependencyStages.has(requestBody.stage)" in html
- assert "PPI/KaKs 需要先处理参考物种/Ortholog" in html
+ assert "id: 'ppi_kaks_reference_status'" in html
+ assert "PPI/KaKs reference data and Ortholog mapping required" in html
+
+
+def test_ppi_kaks_temporary_breaker_is_disabled_and_stage_failure_keeps_scheduler_alive():
+ html = _index_html()
+ assert "const ppiKaksTemporaryCircuitBreakerEnabled = false;" in html
+ assert "await ensureOrthologProducerForPpiKaks();" in html
+ assert "executeStageWithConfirmation(stage" in html
+ assert ".finally(() => scheduleCheck(0));" in html
+
+ catch_start = html.index(" } catch (err) {", html.index("const executeStageWithConfirmation = async"))
+ catch_end = html.index(" } finally {", catch_start)
+ ordinary_failure = html[catch_start:catch_end]
+ assert "stageExecutionStatus.value[stageId] = 'failed';" in ordinary_failure
+ assert "console.error('[ERROR] Stage failed:', stageId, err);" in ordinary_failure
+
+ scheduler_start = html.index("const executeStagesWithDependencies = async")
+ scheduler_end = html.index("const NCBI_SPECIES_DEFAULTS", scheduler_start)
+ scheduler = html[scheduler_start:scheduler_end]
+ assert "return deps.some(dep => stageExecutionStatus.value[dep] === 'failed');" in scheduler
+ assert "stageExecutionStatus.value[stageId] = 'failed';" in scheduler
def test_ortholog_scope_dialog_and_ppi_queue_preflight_are_wired():
@@ -103,16 +126,20 @@ def test_ortholog_scope_dialog_and_ppi_queue_preflight_are_wired():
assert "ppiHasNearReferenceBeforeArabidopsis" in html
-def test_ppi_kaks_dependency_owners_are_preflighted_independently():
+def test_ppi_is_preflighted_first_and_kaks_is_deferred_to_its_confirmation():
html = _index_html()
assert "const ppiKaksDependencyStages = new Set(['ppi', 'kaks'])" in html
- assert "const ensurePpiKaksDependencyOwner = async (ownerStageId) =>" in html
+ assert "const ensurePpiKaksDependencyOwner = async (ownerStageId, stageParamsOverride = null) =>" in html
assert "const ensureOrthologProducerForPpiKaks = async" in html
assert "for (const ownerStageId of requestedStages)" in html
assert "await ensurePpiKaksDependencyOwner(ownerStageId)" in html
assert "const requestedStages = [ownerStageId];" in html
assert "autoInsertedFor: requestedStages" in html
- assert "referencePolicySource: 'ppi_kaks_dependency_preflight'" in html
+ assert ".filter(stageId => stageId === 'ppi')" in html
+ assert "const prepareKaksSelectedReference = async" in html
+ assert "await ensurePpiKaksDependencyOwner('kaks', stageParams)" in html
+ assert "referencePolicySource: 'ppi_owner_ortholog_producer'" in html
+ assert "referencePolicySource: 'kaks_owner_ortholog_producer'" in html
assert "await ensureOrthologProducerForPpiKaks()" in html
@@ -125,18 +152,40 @@ def test_ppi_preflight_uses_current_stage_and_releases_direct_target_from_orthol
assert "releaseDirectTargetPpiFromOrtholog(preflight.resolution);" in html
-def test_ppi_needing_mapping_attaches_to_an_existing_ortholog_stage():
+def test_ppi_needing_mapping_uses_hidden_internal_ortholog_producer():
html = _index_html()
start = html.index("const ensureOrthologStageForPpiKaks =")
end = html.index("const ensurePpiKaksDependencyOwner =", start)
producer = html[start:end]
- attach = "attachOrthologDependencyToOwners(requestedStages);"
- existing = "if (stageIds.includes('ortholog'))"
- assert attach in producer
- assert producer.index(attach) < producer.index(existing)
+
+ assert "rememberPpiKaksInternalOrthologProducer('ppi'" in producer
+ assert "attachOrthologDependencyToOwners(requestedStages);" not in producer
+ assert "nextStages.splice" not in producer
+ assert "batchExecuteStages.value = nextStages" not in producer
-def test_ppi_static_dependencies_exclude_ortholog_while_kaks_keeps_it():
+def test_ppi_internal_ortholog_executes_without_visible_stage_scheduler():
+ html = _index_html()
+ start = html.index("const executePpiOwnedOrthologProducer =")
+ end = html.index("const waitForPpiProducerDependency =", start)
+ executor = html[start:end]
+
+ assert "executeStageDirect('ortholog', producerParams)" in executor
+ assert "executeStagesWithDependencies([producerStage]" not in executor
+ assert "stageExecutionStatus.value.ortholog" not in executor
+
+
+def test_kaks_confirmation_chooses_ppi_or_project_reference_and_mapping_owner():
+ html = _index_html()
+
+ assert 'v-model="confirmParams.kaksReferenceSource"' in html
+ assert 'label="ppi"' in html
+ assert 'label="project"' in html
+ assert "orthologMappingOwner: usePpi ? 'ppi' : 'kaks'" in html
+ assert "requestBody.orthologMappingOwner = stageParams.orthologMappingOwner" in html
+
+
+def test_ppi_and_kaks_static_dependencies_hide_internal_ortholog():
html = _index_html()
catalog = (REPO_ROOT / "gfa_core/mas/stage_catalog.py").read_text(encoding="utf-8")
main_py = (REPO_ROOT / "apps/web/vue3/main.py").read_text(encoding="utf-8")
@@ -144,11 +193,127 @@ def test_ppi_static_dependencies_exclude_ortholog_while_kaks_keeps_it():
assert "'ppi': ['identify']," in html
assert '"ppi": ["identify"],' in catalog
assert '"ppi": ["identify"],' in main_py
- assert "'kaks': ['identify', 'ortholog']," in html
- assert '"kaks": ["identify", "ortholog"],' in catalog
- assert '"kaks": ["identify", "ortholog"],' in main_py
+ assert "'kaks': ['identify']," in html
+ assert '"kaks": ["identify"],' in catalog
+ assert '"kaks": ["identify"],' in main_py
+ assert '"ortholog": ["identify"],' in catalog
+def test_normal_full_gene_family_plan_excludes_ortholog_and_orders_ppi_before_kaks():
+ main_py = (REPO_ROOT / "apps/web/vue3/main.py").read_text(encoding="utf-8")
+ catalog = (REPO_ROOT / "gfa_core/mas/stage_catalog.py").read_text(encoding="utf-8")
+
+ workflow_start = main_py.index("FULL_GENE_FAMILY_WORKFLOW_STAGES = [")
+ workflow_end = main_py.index("]", workflow_start)
+ workflow_block = main_py[workflow_start:workflow_end]
+ assert '"ortholog"' not in workflow_block
+ assert workflow_block.index('"ppi"') < workflow_block.index('"kaks"')
+
+ context_start = main_py.index("def _downstream_gene_family_stages_for_context")
+ context_end = main_py.index("def _resolve_contextual_gene_family_request", context_start)
+ context_block = main_py[context_start:context_end]
+ full_workflow_start = context_block.index("if _looks_like_gene_family_workflow_query(text):")
+ full_workflow_end = context_block.index("return stages", full_workflow_start)
+ full_workflow_block = context_block[full_workflow_start:full_workflow_end]
+ assert '"ortholog"' not in full_workflow_block
+ assert full_workflow_block.index('"ppi"') < full_workflow_block.index('"kaks"')
+
+ normalize_start = main_py.index("def _normalize_full_gene_family_workflow_plan")
+ normalize_end = main_py.index("def _", normalize_start + 4)
+ normalize_block = main_py[normalize_start:normalize_end]
+ assert 'if sid == "ortholog":' in normalize_block
+
+
+def test_confirmed_full_workflow_recovers_original_user_request_from_history():
+ history = [
+ {"role": "user", "content": "执行基因家族分析"},
+ {"role": "assistant", "content": "是否继续?"},
+ {"role": "user", "content": "继续"},
+ ]
+
+ assert web_main._full_gene_family_workflow_query_text("继续", history) == "执行基因家族分析"
+ assert web_main._full_gene_family_workflow_query_text("执行直系同源基因鉴定", []) == ""
+
+
+def test_confirmed_full_workflow_removes_visible_ortholog_and_stale_kaks_dependency():
+ stages = [
+ "phylogeny", "motif", "domain", "structure", "combo", "promoter",
+ "synteny", "interspecies_synteny", "karyotype", "pep", "ortholog", "kaks", "ppi",
+ ]
+ details = [web_main._stage_detail_defaults(stage) for stage in stages]
+ next(item for item in details if item["id"] == "kaks")["dependencies"] = ["identify", "ortholog"]
+
+ normalized_stages, normalized_details = web_main._normalize_full_gene_family_workflow_plan(
+ message="执行基因家族分析",
+ stage_ids=stages,
+ stages_detail=details,
+ plan_params={"targetSpecies": ["Ft"], "referenceSpecies": ["At"], "geneFamily": "SPL"},
+ plan_prefix="Ft.SPL",
+ )
+
+ assert "ortholog" not in normalized_stages
+ assert normalized_stages.index("ppi") < normalized_stages.index("kaks")
+ detail_by_id = {item["id"]: item for item in normalized_details}
+ assert detail_by_id["ppi"]["dependencies"] == ["identify"]
+ assert detail_by_id["kaks"]["dependencies"] == ["identify"]
+ assert detail_by_id["ppi"]["params"]["targetSpecies"] == ["Ft"]
+ assert detail_by_id["ppi"]["params"]["referenceSpecies"] == []
+ assert detail_by_id["ppi"]["params"]["scopeMode"] == "target_only"
+ assert detail_by_id["ppi"]["params"]["analysisRoute"] == "single_species"
+ assert detail_by_id["kaks"]["params"]["referenceSpecies"] == ["At"]
+
+
+def test_deterministic_context_exit_applies_confirmed_full_workflow_contract():
+ request = web_main.AgentPlanRequest(
+ message="继续",
+ prefix="Ft.SPL",
+ history=[
+ {"role": "user", "content": "执行基因家族分析"},
+ {"role": "assistant", "content": "是否继续?"},
+ {"role": "user", "content": "继续"},
+ ],
+ intent={"params": {"referenceSpecies": ["At"]}},
+ )
+ contextual_request = {
+ "family": "SPL",
+ "prefix": "Ft.SPL",
+ "stages": [
+ "phylogeny", "motif", "domain", "structure", "combo", "promoter",
+ "synteny", "interspecies_synteny", "karyotype", "pep", "ortholog", "kaks", "ppi",
+ ],
+ "targetSpecies": ["Ft"],
+ "referenceSpecies": ["At"],
+ "comparativeSpecies": [],
+ "scopeMode": "target_plus_reference",
+ "requiresConfirmedSubfamily": True,
+ "skipSubfamilyLabels": False,
+ "subfamilyState": {"by_species": {"Ft": {"status": "confirmed"}}},
+ "contextGate": {"status": "ready", "issues": []},
+ }
+
+ response = web_main._build_contextual_gene_family_plan_response(
+ request,
+ "test_confirmed_full_workflow",
+ contextual_request,
+ )
+
+ assert response["mode"] == "deterministic_context"
+ assert "ortholog" not in response["plan"]["stages"]
+ assert response["plan"]["stages"].index("ppi") < response["plan"]["stages"].index("kaks")
+ detail_by_id = {item["id"]: item for item in response["plan"]["stages_detail"]}
+ assert detail_by_id["kaks"]["dependencies"] == ["identify"]
+
+
+def test_kaks_request_carries_selected_ortholog_mapping_owner_to_backend():
+ main_py = (REPO_ROOT / "apps/web/vue3/main.py").read_text(encoding="utf-8")
+
+ assert 'orthologMappingOwner: str = ""' in main_py
+ assert '"orthologMappingOwner": getattr(req, \'orthologMappingOwner\', \'\')' in main_py
+ assert (
+ 'ortholog_mapping_owner=params.get("orthologMappingOwner") '
+ 'or params.get("ortholog_mapping_owner") or ""'
+ ) in main_py
+
def test_ppi_plan_generation_never_inserts_static_ortholog_dependency():
main_py = (REPO_ROOT / "apps/web/vue3/main.py").read_text(encoding="utf-8")
catalog = (REPO_ROOT / "gfa_core/mas/stage_catalog.py").read_text(encoding="utf-8")
@@ -180,7 +345,7 @@ def test_ppi_preflight_recovered_reference_is_written_to_ppi_owner_only():
def test_ready_preflight_propagates_recovered_reference_before_early_return():
html = _index_html()
- start = html.index("const ensurePpiKaksDependencyOwner = async (ownerStageId) =>")
+ start = html.index("const ensurePpiKaksDependencyOwner = async (ownerStageId, stageParamsOverride = null) =>")
end = html.index("const ensureOrthologProducerForPpiKaks = async () =>", start)
producer = html[start:end]
propagation = "applyResolvedPpiReferenceToBatch(preflight.resolution, requestedStages, requestBody);"
diff --git a/tests/unit/test_ppi_ortholog_owner_identity_task5b.py b/tests/unit/test_ppi_ortholog_owner_identity_task5b.py
index 953c38dac..61023006c 100644
--- a/tests/unit/test_ppi_ortholog_owner_identity_task5b.py
+++ b/tests/unit/test_ppi_ortholog_owner_identity_task5b.py
@@ -47,6 +47,9 @@ def test_ppi_owned_ortholog_resolves_storage_pep_and_preflight_reuses_matching_m
workbook.save(input_dir / "config.xlsx")
reference_pep = storage_dir / "string_taxon_4555.pep.fasta"
reference_pep.write_text(">Seita.1\nMA\n", encoding="utf-8")
+ (storage_dir / "string_taxon_4555.cds.fasta").write_text(
+ ">Seita.1\nATGGCT\n", encoding="utf-8"
+ )
query_pep = final_dir / "Sv.HKL.final_members.pep.fa"
query_pep.write_text(">SvGene1\nMA\n", encoding="utf-8")
scope_file = final_dir / "Sv.HKL.scope.json"
@@ -78,9 +81,17 @@ def test_ppi_owned_ortholog_resolves_storage_pep_and_preflight_reuses_matching_m
assert message.startswith("✅")
assert launched[0][launched[0].index("--subject") + 1] == str(reference_pep)
+ assert (final_dir / "Sv.HKL.ppi.orthologs.txt").exists()
+ assert not (final_dir / "Sv.HKL.orthologs.txt").exists()
manifest = json.loads(Path(manifest_path).read_text(encoding="utf-8"))
+ assert ".ppi." in Path(manifest_path).name
+ assert manifest["parameters"]["dependency_owner"] == "ppi"
assert manifest["parameters"]["reference_species"] == "string_taxon_4555"
assert manifest["parameters"]["reference_taxon_id"] == "4555"
+ assert manifest["scope_id"] == "HKL.target-Sv.target_only"
+ assert manifest["scope_mode"] == "target_only"
+ assert manifest["stage_context"]["reference_species"] == []
+ assert manifest["stage_context"]["selected_species"] == ["Sv"]
resolution = backend.resolve_ppi_species_resolution(
job=job, prefix="Sv.HKL", scope_file=str(scope_file),
target_species=["Sv"], family="HKL",
@@ -90,7 +101,6 @@ def test_ppi_owned_ortholog_resolves_storage_pep_and_preflight_reuses_matching_m
)
assert resolution["status"] == "ready"
assert resolution["mapping_reference_species"] == "string_taxon_4555"
-
reference_pep.write_text(">Seita.1\nMAGA\n", encoding="utf-8")
stale_reference = backend.resolve_ppi_species_resolution(
job=job, prefix="Sv.HKL", target_species=["Sv"], family="HKL",
@@ -111,6 +121,145 @@ def test_ppi_owned_ortholog_resolves_storage_pep_and_preflight_reuses_matching_m
assert stale_target["status"] == "needs_ortholog"
+def test_ppi_owner_effective_scope_ignores_project_and_technical_references() -> None:
+ assert backend._effective_ppi_owner_scope_id(
+ family="SPL",
+ selected_targets=["Ft"],
+ scope_data={
+ "scope_id": "SPL.target-Ft.ref-At.target_plus_reference",
+ "scope_mode": "target_plus_reference",
+ "target_species": ["Ft"],
+ "reference_species": ["At"],
+ "comparative_species": ["Os"],
+ },
+ reference_storage_key="string_taxon_3562",
+ ) == "SPL.target-Ft.target_only"
+
+
+def test_ppi_owned_ortholog_uses_whole_reference_pep_when_family_pep_also_exists(
+ tmp_path: Path, monkeypatch
+) -> None:
+ job = _job(tmp_path)
+ input_dir = Path(job.input_dir)
+ final_dir = Path(job.workspace_dir) / "final"
+ reference_dir = input_dir / "At"
+ reference_dir.mkdir()
+ whole_reference_pep = reference_dir / "At.pep.fasta"
+ whole_reference_pep.write_text(
+ ">AT5G16970.1 gene:AT5G16970\nMAGA\n", encoding="utf-8"
+ )
+ (reference_dir / "At.cds.fasta").write_text(
+ ">AT5G16970.1 gene:AT5G16970\nATGGCTGGCGCT\n", encoding="utf-8"
+ )
+ family_reference_pep = input_dir / "At.SPL.pep.fasta"
+ family_reference_pep.write_text(">AT5G16970.1\nMA\n", encoding="utf-8")
+ query_pep = final_dir / "Ft.SPL.final_members.pep.fa"
+ query_pep.write_text(">FtSPL1\nMA\n", encoding="utf-8")
+ scope_file = final_dir / "Ft.SPL.scope.json"
+ scope_file.write_text(json.dumps({
+ "family": "SPL",
+ "scope_id": "SPL.target-Ft.target_only",
+ "scope_mode": "target_only",
+ "target_species": ["Ft"],
+ "reference_species": [],
+ "comparative_species": [],
+ "selected_species": ["Ft"],
+ }), encoding="utf-8")
+ launched: list[list[str]] = []
+
+ def fake_run(cmd, *_args, **_kwargs):
+ launched.append(list(cmd))
+ run_prefix = cmd[cmd.index("--prefix") + 1]
+ pairs = Path(job.workspace_dir) / "ortholog" / f"{run_prefix}_pairs.txt"
+ pairs.write_text("AT5G16970.1\tFtSPL1\t91\n", encoding="utf-8")
+ return 0
+
+ monkeypatch.setattr(backend, "can_start_new_task", lambda: (True, "ok"))
+ monkeypatch.setattr(backend, "run_cmd_logged", fake_run)
+
+ message, manifest_path = backend.run_stage(
+ "ortholog",
+ job,
+ "Ft.SPL",
+ scope_file=str(scope_file),
+ ppi_reference_taxon_id="3702",
+ ppi_reference_storage_key="At",
+ ppi_reference_selection_mode="model_fallback",
+ ppi_reference_user_confirmed=True,
+ dependency_owner="ppi",
+ )
+
+ assert message.startswith("✅")
+ assert launched[0][launched[0].index("--subject") + 1] == str(whole_reference_pep)
+ manifest = json.loads(Path(manifest_path).read_text(encoding="utf-8"))
+ assert manifest["scope_mode"] == "target_only"
+ assert manifest["stage_context"]["reference_species"] == []
+
+ resolution = backend.resolve_ppi_species_resolution(
+ job=job,
+ prefix="Ft.SPL",
+ scope_file=str(scope_file),
+ target_species=["Ft"],
+ family="SPL",
+ ppi_reference_taxon_id="3702",
+ ppi_reference_storage_key="At",
+ ppi_reference_selection_mode="model_fallback",
+ ppi_reference_user_confirmed=True,
+ dependency_owner="ppi",
+ requested_stages=["ppi"],
+ )
+ assert resolution["status"] == "ready", resolution
+
+
+def test_independent_ortholog_keeps_family_reference_pep_priority(
+ tmp_path: Path, monkeypatch
+) -> None:
+ job = _job(tmp_path)
+ input_dir = Path(job.input_dir)
+ final_dir = Path(job.workspace_dir) / "final"
+ reference_dir = input_dir / "At"
+ reference_dir.mkdir()
+ whole_reference_pep = reference_dir / "At.pep.fasta"
+ whole_reference_pep.write_text(">AT5G16970.1\nMAGA\n", encoding="utf-8")
+ family_reference_pep = input_dir / "At.SPL.pep.fasta"
+ family_reference_pep.write_text(">AT5G16970.1\nMA\n", encoding="utf-8")
+ (final_dir / "Ft.SPL.final_members.pep.fa").write_text(
+ ">FtSPL1\nMA\n", encoding="utf-8"
+ )
+ scope_file = final_dir / "Ft.SPL.scope.json"
+ scope_file.write_text(json.dumps({
+ "family": "SPL",
+ "scope_id": "SPL.target-Ft.ref-At.target_plus_reference",
+ "scope_mode": "target_plus_reference",
+ "target_species": ["Ft"],
+ "reference_species": ["At"],
+ "comparative_species": [],
+ "selected_species": ["Ft", "At"],
+ }), encoding="utf-8")
+ launched: list[list[str]] = []
+
+ def fake_run(cmd, *_args, **_kwargs):
+ launched.append(list(cmd))
+ run_prefix = cmd[cmd.index("--prefix") + 1]
+ pairs = Path(job.workspace_dir) / "ortholog" / f"{run_prefix}_pairs.txt"
+ pairs.write_text("AT5G16970.1\tFtSPL1\t91\n", encoding="utf-8")
+ return 0
+
+ monkeypatch.setattr(backend, "can_start_new_task", lambda: (True, "ok"))
+ monkeypatch.setattr(backend, "run_cmd_logged", fake_run)
+
+ message, _manifest_path = backend.run_stage(
+ "ortholog",
+ job,
+ "Ft.SPL",
+ scope_file=str(scope_file),
+ )
+
+ assert message.startswith("✅")
+ assert launched[0][launched[0].index("--subject") + 1] == str(family_reference_pep)
+ assert launched[0][launched[0].index("--subject") + 1] != str(whole_reference_pep)
+
+
@pytest.mark.parametrize("scope_id_input", ["present", "empty", "missing"])
def test_ppi_owned_ortholog_preserves_target_only_biological_scope_identity(
tmp_path: Path, monkeypatch, scope_id_input: str
@@ -128,6 +277,9 @@ def test_ppi_owned_ortholog_preserves_target_only_biological_scope_identity(
workbook.save(input_dir / "config.xlsx")
reference_pep = storage_dir / "string_taxon_4555.pep.fasta"
reference_pep.write_text(">Seita.1\nMA\n", encoding="utf-8")
+ (storage_dir / "string_taxon_4555.cds.fasta").write_text(
+ ">Seita.1\nATGGCT\n", encoding="utf-8"
+ )
query_pep = final_dir / "Sv.HKL.final_members.pep.fa"
query_pep.write_text(">SvGene1\nMA\n", encoding="utf-8")
scope_id = "HKL.target-Sv.target_only"
@@ -170,7 +322,7 @@ def test_ppi_owned_ortholog_preserves_target_only_biological_scope_identity(
assert manifest["stage_context"]["scope_mode"] == "target_only"
assert manifest["stage_context"]["reference_species"] == []
- mapping = final_dir / "Sv.HKL.orthologs.txt"
+ mapping = final_dir / "Sv.HKL.ppi.orthologs.txt"
recorded = json.loads(Path(f"{mapping}.identity.json").read_text(encoding="utf-8"))
expected = build_ortholog_run_identity(
query_species="Sv", reference_taxon_id="4555",
@@ -279,3 +431,115 @@ def test_materialized_manifest_persists_owner_and_mapping_identity(tmp_path: Pat
"target_identity", "member_identity", "mapping_identity", "mapping_path",
):
assert manifest["parameters"][key]
+
+
+def test_ppi_and_kaks_owned_mappings_coexist_without_overwrite(tmp_path: Path) -> None:
+ final_dir = tmp_path / "final"
+ query = tmp_path / "Ft.pep.fa"
+ query.write_text(">Ft1\nMA\n", encoding="utf-8")
+
+ def materialize(owner: str, reference_species: str, reference_id: str) -> tuple[Path, Path]:
+ reference = tmp_path / f"{reference_species}.pep.fasta"
+ pairs = tmp_path / f"{owner}.pairs.txt"
+ reference.write_text(f">{reference_id}\nMA\n", encoding="utf-8")
+ pairs.write_text(f"{reference_id}\tFt1\t90\n", encoding="utf-8")
+ _, manifest_path = materialize_ortholog_final_outputs(
+ final_dir=final_dir,
+ prefix="Ft.SPL",
+ dependency_owner=owner,
+ run_records=[OrthologRunRecord("Ft", reference_species, query, reference, pairs)],
+ job_id="task5b",
+ workspace=tmp_path,
+ reference_species=reference_species,
+ reference_storage_key=reference_species,
+ scope_fields={
+ "scope_id": f"SPL.target-Ft.ref-{reference_species}.target_plus_reference",
+ "target_species": ["Ft"],
+ "reference_species": [reference_species],
+ },
+ )
+ return final_dir / f"Ft.SPL.{owner}.orthologs.txt", manifest_path
+
+ ppi_mapping, ppi_manifest = materialize("ppi", "string_taxon_3562", "Soe1")
+ ppi_mapping_before = ppi_mapping.read_bytes()
+ ppi_manifest_before = ppi_manifest.read_bytes()
+
+ kaks_mapping, kaks_manifest = materialize("kaks", "At", "AT1G01010")
+
+ assert ppi_mapping.exists()
+ assert kaks_mapping.exists()
+ assert ppi_mapping != kaks_mapping
+ assert ppi_mapping.read_bytes() == ppi_mapping_before
+ assert ppi_manifest.read_bytes() == ppi_manifest_before
+ assert json.loads(ppi_manifest.read_text(encoding="utf-8"))["parameters"]["dependency_owner"] == "ppi"
+ assert json.loads(kaks_manifest.read_text(encoding="utf-8"))["parameters"]["dependency_owner"] == "kaks"
+
+
+def test_owner_specific_mapping_candidates_never_fall_back_to_another_owner(tmp_path: Path) -> None:
+ final_dir = tmp_path / "final"
+ final_dir.mkdir()
+
+ ppi_candidates = backend.ortholog_mapping_candidates(
+ final_dir,
+ "Ft.SPL",
+ dependency_owner="ppi",
+ scope_id="scope-v1",
+ )
+
+ assert ppi_candidates == [
+ final_dir / "Ft.SPL.ppi.scope-v1.orthologs.txt",
+ final_dir / "Ft.SPL.ppi.scope-v1.ortholog_pairs.txt",
+ final_dir / "Ft.SPL.ppi.orthologs.txt",
+ final_dir / "Ft.SPL.ppi.ortholog_pairs.txt",
+ ]
+ assert all(".kaks." not in path.name for path in ppi_candidates)
+ assert all(path.name != "Ft.SPL.orthologs.txt" for path in ppi_candidates)
+
+
+def test_kaks_selected_ppi_owner_consumes_only_ppi_mapping(tmp_path: Path, monkeypatch) -> None:
+ job = _job(tmp_path)
+ input_dir = Path(job.input_dir)
+ final_dir = Path(job.workspace_dir) / "final"
+ reference = "string_taxon_3562"
+ scope_file = final_dir / "Ft.SPL.scope.json"
+ scope_file.write_text(json.dumps({
+ "family": "SPL",
+ "scope_id": f"SPL.target-Ft.ref-{reference}.target_plus_reference",
+ "scope_mode": "target_plus_reference",
+ "target_species": ["Ft"],
+ "reference_species": [reference],
+ "selected_species": ["Ft", reference],
+ }), encoding="utf-8")
+ (input_dir / "Ft.cds.fasta").write_text(">Ft1\nATGGCT\n", encoding="utf-8")
+ (input_dir / f"{reference}.cds.fasta").write_text(">PpiGene\nATGGCT\n", encoding="utf-8")
+ (input_dir / f"{reference}.pep.fasta").write_text(">PpiGene\nMA\n", encoding="utf-8")
+ (final_dir / "Ft.SPL.final_members.pep.fa").write_text(">Ft1\nMA\n", encoding="utf-8")
+ (final_dir / "Ft.SPL.ppi.orthologs.txt").write_text(
+ f"Ft1\tPpiGene\t91\tFt\t{reference}\n", encoding="utf-8"
+ )
+ (final_dir / "Ft.SPL.kaks.orthologs.txt").write_text(
+ f"Ft1\tWrongKaksGene\t88\tFt\t{reference}\n", encoding="utf-8"
+ )
+ observed_pairs: list[str] = []
+
+ def fake_run(cmd, *_args, **_kwargs):
+ pair_path = Path(cmd[cmd.index("--pairs") + 1])
+ observed_pairs.append(pair_path.read_text(encoding="utf-8"))
+ return 0
+
+ monkeypatch.setattr(backend, "can_start_new_task", lambda: (True, "ok"))
+ monkeypatch.setattr(backend, "run_cmd_logged", fake_run)
+
+ message, manifest_path = backend.run_stage(
+ "kaks",
+ job,
+ "Ft.SPL",
+ scope_file=str(scope_file),
+ ortholog_mapping_owner="ppi",
+ )
+
+ assert message.startswith("✅")
+ assert observed_pairs and "PpiGene" in observed_pairs[0]
+ assert "WrongKaksGene" not in observed_pairs[0]
+ manifest = json.loads(Path(manifest_path).read_text(encoding="utf-8"))
+ assert manifest["parameters"]["ortholog_mapping"].endswith("Ft.SPL.ppi.orthologs.txt")
diff --git a/tests/unit/test_ppi_owner_scheduling_task5.py b/tests/unit/test_ppi_owner_scheduling_task5.py
index e3cb6691d..867bf4af7 100644
--- a/tests/unit/test_ppi_owner_scheduling_task5.py
+++ b/tests/unit/test_ppi_owner_scheduling_task5.py
@@ -12,6 +12,7 @@ from openpyxl import Workbook
from apps.web import pipeline_backend as backend
from apps.web.vue3 import main as web_main
+from gfa_core.mas.tools.stage_tools import StageToolExecutor, StageToolRegistry
def _source_block(source: str, start: str, end: str) -> str:
@@ -29,6 +30,71 @@ def _run_node(source: str) -> dict:
return json.loads(completed.stdout)
+def _ppi_scope_js() -> str:
+ return """
+const uniqueList = value => [...new Set((Array.isArray(value) ? value : []).filter(Boolean))];
+const scopeIdForRoles = (family, targets, refs, comps, mode) => `${family}.target-${targets.join('-')}.${mode}`;
+const memberSetForScope = (scopeId, route) => `${scopeId}:${route}:all_members`;
+"""
+
+
+@pytest.mark.parametrize(
+ ("stage", "mapping_owner", "confirmed", "expected"),
+ [
+ ("ppi", "", True, "string_taxon_3562"),
+ ("kaks", "ppi", True, "string_taxon_3562"),
+ ("kaks", "kaks", True, "At"),
+ ("ppi", "", False, "soe"),
+ ],
+)
+def test_technical_reference_bundle_lookup_uses_storage_identity_only_for_ppi_owned_reference(
+ stage: str,
+ mapping_owner: str,
+ confirmed: bool,
+ expected: str,
+) -> None:
+ request = web_main.ExecuteRequest(
+ stage=stage,
+ ppiReferenceSpecies="soe",
+ ppiReferenceStorageKey="string_taxon_3562",
+ ppiReferenceUserConfirmed=confirmed,
+ orthologMappingOwner=mapping_owner,
+ )
+
+ assert web_main._technical_reference_bundle_lookup_code(request, "soe" if stage == "ppi" else "At") == expected
+
+
+def test_stage_tool_executor_forwards_kaks_ortholog_mapping_owner(
+ tmp_path: Path,
+ monkeypatch,
+) -> None:
+ captured: dict = {}
+
+ def fake_run_stage(**kwargs):
+ captured.update(kwargs)
+ manifest = Path(kwargs["job"].workspace_dir) / "final" / "Ft.SPL.kaks.manifest.json"
+ manifest.parent.mkdir(parents=True, exist_ok=True)
+ manifest.write_text(
+ json.dumps({"manifest_version": "1.0", "stage": "kaks", "status": "success", "prefix": "Ft.SPL"}),
+ encoding="utf-8",
+ )
+ return "KaKs complete", manifest
+
+ monkeypatch.setattr(backend, "run_stage", fake_run_stage)
+ tool = StageToolRegistry().get("kaks")
+ result = asyncio.run(
+ StageToolExecutor(project_root=tmp_path).execute(
+ tool,
+ "job_owner_forwarding",
+ "Ft.SPL",
+ extra_params={"orthologMappingOwner": "ppi"},
+ )
+ )
+
+ assert result["status"] == "success"
+ assert captured["ortholog_mapping_owner"] == "ppi"
+
+
def test_confirmed_numeric_reference_uses_storage_key_for_local_id_map(
tmp_path: Path,
monkeypatch,
@@ -139,6 +205,8 @@ def test_execute_scope_uses_confirmed_ppi_owner_storage_without_biological_scope
job_id="job_owner_scope",
prefix="Sv.HKL",
scopeMode="target_only",
+ analysisRoute="multi_species",
+ memberSetId="HKL.target-Sv.target_only:multi_species:all_members",
targetSpecies=["Sv"],
ppiReferenceTaxonId="4555",
ppiReferenceStorageKey="string_taxon_4555",
@@ -157,6 +225,8 @@ def test_execute_scope_uses_confirmed_ppi_owner_storage_without_biological_scope
assert technical_refs == ["string_taxon_4555"]
assert ppi_owned_ortholog is True
assert needs_fallback is False
+ assert request.analysisRoute == "single_species"
+ assert request.memberSetId == ""
assert scope_params == {
"scopeMode": "target_only", "targetSpecies": ["Sv"],
"referenceSpecies": [], "refSpeciesList": [],
@@ -164,6 +234,43 @@ def test_execute_scope_uses_confirmed_ppi_owner_storage_without_biological_scope
}
+def test_execute_scope_normalizes_ppi_consumer_to_target_only() -> None:
+ request = web_main.ExecuteRequest(
+ stage="ppi", job_id="job_ppi_consumer", prefix="Ft.SPL",
+ scopeFile="/stale/project.scope.json",
+ scopeMode="target_plus_reference", targetSpecies=["Ft"],
+ analysisRoute="multi_species",
+ memberSetId="SPL.target-Ft.target-reference-At:multi_species:all_members",
+ referenceSpecies=["At"], refSpeciesList=["At"], comparativeSpecies=["Os"],
+ ppiReferenceTaxonId="3562",
+ ppiReferenceStorageKey="string_taxon_3562",
+ ppiReferenceUserConfirmed=True,
+ dependencyOwner="ppi",
+ )
+ scope_params = {
+ "scopeFile": request.scopeFile,
+ "scopeMode": request.scopeMode,
+ "targetSpecies": request.targetSpecies,
+ "referenceSpecies": request.referenceSpecies,
+ "refSpeciesList": request.refSpeciesList,
+ "comparativeSpecies": request.comparativeSpecies,
+ }
+
+ technical_refs, ppi_owned_ortholog, needs_fallback = (
+ web_main._prepare_execute_technical_reference_scope(request, scope_params)
+ )
+
+ assert technical_refs == []
+ assert ppi_owned_ortholog is False
+ assert needs_fallback is False
+ assert request.analysisRoute == "single_species"
+ assert request.memberSetId == ""
+ assert scope_params == {
+ "scopeFile": "", "scopeMode": "target_only", "targetSpecies": ["Ft"],
+ "referenceSpecies": [], "refSpeciesList": [], "comparativeSpecies": [],
+ }
+
+
def test_execute_scope_keeps_ordinary_ortholog_reference_fallback_enabled() -> None:
request = web_main.ExecuteRequest(
stage="ortholog", job_id="job_ordinary_scope", prefix="Sv.HKL",
@@ -325,6 +432,8 @@ def test_execute_preserves_valid_target_only_scope_identity_for_confirmed_ppi_ow
assert submitted_params["scopeFile"] == str(scope_file)
assert submitted_params["stageContext"]["scope_id"] == "fixed-biological-target-only-scope"
assert submitted_params["scopeMode"] == "target_only"
+ assert submitted_params["analysisRoute"] == "single_species"
+ assert submitted_params["memberSetId"] == "fixed-biological-target-only-scope:single_species:all_members"
assert submitted_params["targetSpecies"] == ["Sv"]
assert submitted_params["referenceSpecies"] == []
assert submitted_params["refSpeciesList"] == []
@@ -418,7 +527,8 @@ def test_frontend_requires_confirmation_and_keeps_ppi_owner_state_isolated() ->
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
assert "const confirmNearestPpiReferenceCandidate = async" in html
assert "resolution.candidates" in html
- assert ".map((candidate, index) =>" in html
+ assert "const normalizePpiReferenceCandidates =" in html
+ assert 'v-for="(candidate, index) in confirmParams.ppiKaksReferenceCandidates"' in html
assert "ppiReferenceTaxonId" in html
assert "ppiReferenceStorageKey" in html
assert "ppiReferenceSelectionMode" in html
@@ -466,7 +576,7 @@ def test_ppi_owner_selection_never_mutates_shared_reference_scope() -> None:
assert "scopeMode:" not in selection_block
-def test_owner_request_reads_selection_and_keeps_kaks_independent() -> None:
+def test_owner_request_reads_selection_and_allows_kaks_to_reuse_ppi_owner() -> None:
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
request_block = _source_block(
html,
@@ -488,10 +598,32 @@ def test_owner_request_reads_selection_and_keeps_kaks_independent() -> None:
):
assert field in request_block
assert "const requestedStages = [ownerStageId]" in owner_block
- assert "dependencyOwner: ownerStageId" in request_block
+ assert "const mappingOwner = stageParams.orthologMappingOwner || ownerStageId" in request_block
+ assert "dependencyOwner: mappingOwner" in request_block
assert "ownerStageId === 'ppi'" in request_block
+def test_owner_resolution_and_dependency_request_return_live_values() -> None:
+ html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
+ selection_block = _source_block(
+ html,
+ "const ppiOwnerSelectionFromResolution =",
+ "const applyPpiOwnerSelectionToBatch =",
+ )
+ request_block = _source_block(
+ html,
+ "const buildPpiKaksDependencyRequest =",
+ "const ppiKaksInternalOrthologProducers =",
+ )
+
+ assert "return {" in selection_block
+ assert "const requestBody = {" in request_block
+ assert request_block.index("const requestBody = {") < request_block.index(
+ "return isPpiOwner ? canonicalizePpiOwnerBiologicalScope(requestBody) : requestBody"
+ )
+ assert "return {" not in request_block
+
+
def test_download_completion_reapplies_owner_without_shared_merge() -> None:
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
monitor_block = _source_block(
@@ -506,12 +638,109 @@ def test_download_completion_reapplies_owner_without_shared_merge() -> None:
)
assert "applyPpiOwnerSelectionToBatch(ownerSelection" in monitor_block
- assert "requestedStages.includes('kaks')" in monitor_block
- assert "mergePpiReferenceSpeciesIntoBatch(refCode, ['kaks'])" in monitor_block
- assert "mergePpiReferenceSpeciesIntoBatch(refCode, requestedStages)" not in monitor_block
+ assert "recoveryStages.includes('kaks')" in monitor_block
+ assert "mergePpiReferenceSpeciesIntoBatch(storageKey, ['kaks'])" in monitor_block
+ assert "mergePpiReferenceSpeciesIntoBatch(storageKey, recoveryStages)" not in monitor_block
assert "applyPpiOwnerSelectionToBatch(ownerSelection" in owner_block
+def test_kaks_download_success_uses_storage_key_and_recovers_shared_ppi_pause() -> None:
+ html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
+ recovery_helper = _source_block(
+ html,
+ "const ppiKaksFallbackRecoveryOwners =",
+ "const handlePpiReferenceDownloadFailure =",
+ )
+ monitor = _source_block(
+ html,
+ "const monitorPpiReferenceBundleDownload =",
+ "const ppiHasNearReferenceBeforeArabidopsis =",
+ )
+ result = _run_node(
+ recovery_helper
+ + monitor
+ + """
+const calls = {applied: [], merged: [], orchestrated: [], resumed: []};
+const batchExecuteStages = {value: [
+ {id: 'ortholog'},
+ {id: 'kaks', dependencies: ['ortholog']},
+ {id: 'ppi', dependencies: ['ortholog']}
+]};
+const stageExecutionStatus = {value: {ortholog: 'paused', kaks: 'paused', ppi: 'paused'}};
+const stagePauseReasons = {value: {
+ ppi: {type: 'dependency_paused', reason: 'upstream_stage_paused'}
+}};
+const getStageId = stage => typeof stage === 'object' ? stage.id : stage;
+const updateReferenceBundleDownloadProgress = () => {};
+const pollReferenceBundleDownloadTask = async () => ({
+ success: true,
+ species_code: 'string_taxon_3562',
+ species_name: 'Spinacia oleracea',
+ taxon_id: '3562',
+ requested_stages: ['kaks'],
+ files: {
+ pep: '/job/string_taxon_3562/string_taxon_3562.pep.fasta',
+ cds: '/job/string_taxon_3562/string_taxon_3562.cds.fasta'
+ }
+});
+const ppiOwnerSelectionFromResolution = resolution => ({
+ taxonId: resolution.selected_taxon_id,
+ storageKey: resolution.ppi_reference_storage_key,
+ selectionMode: 'tree_nearest',
+ userConfirmed: resolution.ppi_reference_user_confirmed
+});
+const applyPpiOwnerSelectionToBatch = selection => calls.applied.push(selection);
+const mergePpiReferenceSpeciesIntoBatch = (code, stages) => calls.merged.push({code, stages});
+const clearReferenceBundleDownloadProgress = () => {};
+const addAgentMessage = () => {};
+const buildPpiKaksDependencyRequest = stages => ({requestedStages: stages});
+const runPpiSpeciesResolutionPreflight = async () => ({allowed: true});
+const orchestratePpiOwnerAfterReferenceReady = async options => {
+ calls.orchestrated.push(options.requestedStages);
+ return true;
+};
+const resumePpiKaksPausedStages = async stages => calls.resumed.push(stages);
+const waitForPpiProducerDependency = async () => true;
+const ensureOrthologStageForPpiKaks = () => true;
+const executePpiOwnedOrthologProducer = async () => true;
+const handlePpiReferenceDownloadFailure = async () => false;
+
+monitorPpiReferenceBundleDownload(
+ 'task-3562',
+ {species_code: 'soe', species_name: 'Spinacia oleracea', selected_string_taxon_id: '3562'},
+ ['kaks'],
+ {ppi_reference_storage_key: 'string_taxon_3562'}
+);
+setTimeout(() => console.log(JSON.stringify(calls)), 20);
+"""
+ )
+
+ assert result["merged"] == [{"code": "string_taxon_3562", "stages": ["kaks"]}]
+ assert result["applied"] == [{
+ "taxonId": "3562",
+ "storageKey": "string_taxon_3562",
+ "selectionMode": "tree_nearest",
+ "userConfirmed": True,
+ }]
+ assert result["orchestrated"] == [["ppi"]]
+ assert result["resumed"] == [["kaks"]]
+
+
+def test_immediate_download_success_uses_storage_key_and_shared_pause_recovery() -> None:
+ html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
+ owner_block = _source_block(
+ html,
+ "const ensurePpiKaksDependencyOwner = async",
+ "const ensureOrthologProducerForPpiKaks = async",
+ )
+
+ assert "const storageKey = result.species_code || preflight.resolution.ppi_reference_storage_key" in owner_block
+ assert "const recoveryStages = ppiKaksFallbackRecoveryOwners(" in owner_block
+ assert "mergePpiReferenceSpeciesIntoBatch(storageKey, ['kaks'])" in owner_block
+ assert "if (recoveryStages.includes('ppi'))" in owner_block
+ assert "if (recoveryStages.includes('kaks'))" in owner_block
+
+
def test_arabidopsis_fallback_is_unselected_until_explicit_confirmation() -> None:
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
fallback_request = _source_block(
@@ -526,17 +755,122 @@ def test_arabidopsis_fallback_is_unselected_until_explicit_confirmation() -> Non
assert "confirmParams.ppiKaksFallbackDecision = 'skip'" in html
-def test_final_ppi_request_dynamically_preserves_owner_and_project_scope() -> None:
+def test_arabidopsis_fallback_does_not_preselect_kaks_reference_route() -> None:
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
- helper = _source_block(
+ fallback = _source_block(
html,
- "const applyPpiOwnerFieldsToRequest =",
- "const applyGeneFamilyContextToRequest =",
+ "const applyArabidopsisFallbackForPpiKaks =",
+ "const resumePpiKaksPausedStages =",
+ )
+
+ assert "kaksReferenceSource: 'project'" not in fallback
+ assert "referenceSpecies: ['At']" not in fallback
+ assert "orthologMappingOwner: 'kaks'" not in fallback
+
+
+def test_kaks_download_failure_recovers_only_ppi_paused_on_same_ortholog() -> None:
+ html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
+ helper_marker = "const ppiKaksFallbackRecoveryOwners ="
+ helper = (
+ _source_block(html, helper_marker, "const handlePpiReferenceDownloadFailure =")
+ if helper_marker in html
+ else ""
+ )
+ handler = _source_block(
+ html,
+ "const handlePpiReferenceDownloadFailure =",
+ "const downloadRecommendedPpiReferenceBundle =",
)
result = _run_node(
- helper
+ _ppi_scope_js()
+ + helper
+ + handler
+ + """
+const runCase = async ({stages, statuses, pauses}) => {
+ const calls = {dialog: null, fallback: null, orchestrated: [], resumed: []};
+ batchExecuteStages.value = stages;
+ stageExecutionStatus.value = statuses;
+ stagePauseReasons.value = pauses;
+ requestPpiDownloadFallbackDecision = async (_err, _resolution, _recommendation, owners) => {
+ calls.dialog = owners;
+ return 'ath';
+ };
+ applyArabidopsisFallbackForPpiKaks = owners => { calls.fallback = owners; };
+ orchestratePpiOwnerAfterReferenceReady = async options => {
+ calls.orchestrated.push(options.requestedStages);
+ return true;
+ };
+ resumePpiKaksPausedStages = async owners => { calls.resumed.push(owners); };
+ await handlePpiReferenceDownloadFailure(new Error('download failed'), {}, {}, ['kaks']);
+ return calls;
+};
+
+let batchExecuteStages = {value: []};
+let stageExecutionStatus = {value: {}};
+let stagePauseReasons = {value: {}};
+let requestPpiDownloadFallbackDecision;
+let applyArabidopsisFallbackForPpiKaks;
+let orchestratePpiOwnerAfterReferenceReady;
+let resumePpiKaksPausedStages;
+const addAgentMessage = () => {};
+const buildPpiKaksDependencyRequest = () => ({});
+const runPpiSpeciesResolutionPreflight = async () => ({allowed: true});
+const waitForPpiProducerDependency = async () => true;
+const ensureOrthologStageForPpiKaks = () => true;
+const getStageId = stage => typeof stage === 'object' ? stage.id : stage;
+const executePpiOwnedOrthologProducer = async () => true;
+const storePpiKaksReferencePause = () => {};
+
+(async () => {
+ const jointPaused = await runCase({
+ stages: [
+ {id: 'ortholog'},
+ {id: 'kaks', dependencies: ['ortholog']},
+ {id: 'ppi', dependencies: ['ortholog']}
+ ],
+ statuses: {ortholog: 'paused', kaks: 'paused', ppi: 'paused'},
+ pauses: {ppi: {type: 'dependency_paused', reason: 'upstream_stage_paused'}}
+ });
+ const completedPpi = await runCase({
+ stages: [{id: 'ortholog'}, {id: 'kaks'}, {id: 'ppi', dependencies: ['ortholog']}],
+ statuses: {ortholog: 'paused', kaks: 'paused', ppi: 'completed'},
+ pauses: {ppi: {type: 'dependency_paused', reason: 'upstream_stage_paused'}}
+ });
+ const kaksOnly = await runCase({
+ stages: [{id: 'ortholog'}, {id: 'kaks', dependencies: ['ortholog']}],
+ statuses: {ortholog: 'paused', kaks: 'paused'},
+ pauses: {}
+ });
+ console.log(JSON.stringify({jointPaused, completedPpi, kaksOnly}));
+})();
+"""
+ )
+
+ assert result["jointPaused"] == {
+ "dialog": ["kaks", "ppi"],
+ "fallback": ["ppi"],
+ "orchestrated": [["ppi"]],
+ "resumed": [],
+ }
+ for guarded_case in (result["completedPpi"], result["kaksOnly"]):
+ assert guarded_case == {
+ "dialog": ["kaks"],
+ "fallback": None,
+ "orchestrated": [],
+ "resumed": [],
+ }
+
+
+def test_final_ppi_request_dynamically_uses_target_only_biological_scope() -> None:
+ html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
+ helper = _source_block(html, "const canonicalizePpiOwnerBiologicalScope =", "const applyGeneFamilyContextToRequest =")
+ result = _run_node(
+ _ppi_scope_js()
+ + helper
+ """
const request = {
+ geneFamily: 'SPL', targetSpecies: ['Ft'], comparativeSpecies: ['Os'],
+ analysisRoute: 'multi_species', memberSetId: 'stale-member', scopeId: 'stale-scope', scopeFile: '/stale/scope.json',
referenceSpecies: ['ProjectRef'],
refSpeciesList: ['ProjectRef'],
scopeMode: 'target_plus_reference',
@@ -560,24 +894,27 @@ console.log(JSON.stringify(applyPpiOwnerFieldsToRequest(request, 'ppi', {}, batc
assert result["ppiReferenceSelectionMode"] == "tree_nearest"
assert result["ppiReferenceUserConfirmed"] is True
assert result["dependencyOwner"] == "ppi"
- assert result["referenceSpecies"] == ["ProjectRef"]
- assert result["refSpeciesList"] == ["ProjectRef"]
- assert result["scopeMode"] == "target_plus_reference"
+ assert result["targetSpecies"] == ["Ft"]
+ assert result["referenceSpecies"] == []
+ assert result["refSpeciesList"] == []
+ assert result["comparativeSpecies"] == []
+ assert result["scopeMode"] == "target_only"
+ assert result["analysisRoute"] == "single_species"
+ assert result["scopeId"] == "SPL.target-Ft.target_only"
+ assert result["memberSetId"] == "SPL.target-Ft.target_only:single_species:all_members"
+ assert result["scopeFile"] == ""
for forbidden in ("ppiKaksReferenceSpecies", "ppiReferenceSpecies", "referenceBundleSpecies"):
assert forbidden not in result
def test_joint_batch_ppi_request_dynamically_ignores_kaks_reference() -> None:
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
- helper = _source_block(
- html,
- "const applyPpiOwnerFieldsToRequest =",
- "const applyGeneFamilyContextToRequest =",
- )
+ helper = _source_block(html, "const canonicalizePpiOwnerBiologicalScope =", "const applyGeneFamilyContextToRequest =")
result = _run_node(
- helper
+ _ppi_scope_js()
+ + helper
+ """
-const request = { referenceSpecies: ['ProjectRef'], refSpeciesList: ['ProjectRef'] };
+const request = { geneFamily: 'SPL', targetSpecies: ['Ft'], referenceSpecies: ['ProjectRef'], refSpeciesList: ['ProjectRef'] };
const batch = {
ppiKaksReferenceSpecies: ['KaKsRef'],
ppiReferenceSpecies: 'KaKsRef',
@@ -592,7 +929,7 @@ console.log(JSON.stringify(applyPpiOwnerFieldsToRequest(request, 'ppi', {}, batc
"""
)
- assert result["referenceSpecies"] == ["ProjectRef"]
+ assert result["referenceSpecies"] == []
assert "KaKsRef" not in json.dumps(result)
assert result["ppiReferenceTaxonId"] == "4555"
@@ -609,10 +946,15 @@ def test_reference_ready_orchestration_dynamically_inserts_ortholog_before_resum
+ """
(async () => {
const calls = [];
+ let preflightCount = 0;
const ok = await orchestratePpiOwnerAfterReferenceReady({
requestBody: { stage: 'ppi' },
requestedStages: ['ppi'],
- runPreflight: async () => ({ allowed: true, resolution: { status: 'needs_ortholog' } }),
+ runPreflight: async () => {
+ calls.push('preflight');
+ preflightCount += 1;
+ return { allowed: true, resolution: { status: preflightCount === 1 ? 'needs_ortholog' : 'ready' } };
+ },
ensureOrtholog: async () => { calls.push('ortholog'); return true; },
resume: async () => { calls.push('resume'); }
});
@@ -621,7 +963,7 @@ def test_reference_ready_orchestration_dynamically_inserts_ortholog_before_resum
"""
)
- assert result == {"ok": True, "calls": ["ortholog", "resume"]}
+ assert result == {"ok": True, "calls": ["preflight", "ortholog", "preflight", "resume"]}
def test_ppi_owned_ortholog_uses_owner_storage_without_polluting_final_ppi_request() -> None:
@@ -631,15 +973,11 @@ def test_ppi_owned_ortholog_uses_owner_storage_without_polluting_final_ppi_reque
"const buildPpiOwnedOrthologStage =",
"const attachOrthologDependencyToOwners =",
)
- request_helper = _source_block(
- html,
- "const applyPpiOwnerFieldsToRequest =",
- "const applyGeneFamilyContextToRequest =",
- )
+ request_helper = _source_block(html, "const canonicalizePpiOwnerBiologicalScope =", "const applyGeneFamilyContextToRequest =")
ortholog_request_helper = _source_block(
html,
"const applyPpiOwnedOrthologFieldsToRequest =",
- "const applyPpiOwnerFieldsToRequest =",
+ "const canonicalizePpiOwnerBiologicalScope =",
)
direct_builder = _source_block(
html,
@@ -647,12 +985,14 @@ def test_ppi_owned_ortholog_uses_owner_storage_without_polluting_final_ppi_reque
"const executeStageDirect = async",
)
result = _run_node(
- producer_helper
+ _ppi_scope_js()
+ + producer_helper
+ ortholog_request_helper
+ request_helper
+ direct_builder
+ """
const request = {
+ geneFamily: 'SPL', targetSpecies: ['Ft'],
referenceSpecies: ['At'], refSpeciesList: ['At'], scopeMode: 'target_plus_reference',
ppiKaksReferenceSpecies: ['KaKsRef'], ppiReferenceSpecies: 'KaKsRef', referenceBundleSpecies: 'KaKsRef'
};
@@ -690,8 +1030,12 @@ console.log(JSON.stringify({ortholog, finalOrtholog, directOrtholog, ppi}));
"ppiKaksReferenceSpecies", "ppiReferenceSpecies", "referenceBundleSpecies",
):
assert forbidden not in payload
- assert result["ppi"]["referenceSpecies"] == ["At"]
- assert result["ppi"]["refSpeciesList"] == ["At"]
+ assert result["directOrtholog"]["requestedStages"] == ["ppi"]
+ assert result["directOrtholog"]["autoInsertedFor"] == ["ppi"]
+ assert result["ppi"]["referenceSpecies"] == []
+ assert result["ppi"]["refSpeciesList"] == []
+ assert result["ppi"]["scopeMode"] == "target_only"
+ assert result["ppi"]["scopeId"] == "SPL.target-Ft.target_only"
for forbidden in ("ppiKaksReferenceSpecies", "ppiReferenceSpecies", "referenceBundleSpecies"):
assert forbidden not in result["ppi"]
@@ -708,10 +1052,15 @@ def test_active_scheduler_executes_inserted_ppi_owned_ortholog_before_resume() -
+ """
(async () => {
const calls = [];
+ let preflightCount = 0;
const producer = { id: 'ortholog', params: { ppiReferenceStorageKey: 'string_taxon_4555' } };
const ok = await orchestratePpiOwnerAfterReferenceReady({
requestBody: { stage: 'ppi' }, requestedStages: ['ppi'], isBatchExecuting: true,
- runPreflight: async () => { calls.push('preflight'); return {allowed: true, resolution: {status: 'needs_ortholog'}}; },
+ runPreflight: async () => {
+ calls.push('preflight');
+ preflightCount += 1;
+ return {allowed: true, resolution: {status: preflightCount === 1 ? 'needs_ortholog' : 'ready'}};
+ },
ensureOrtholog: async () => { calls.push('ensure'); return {ready: true, producerStage: producer}; },
executeProducer: async stage => { calls.push(`execute:${stage.id}`); return true; },
resume: async () => { calls.push('resume'); }
@@ -723,10 +1072,350 @@ def test_active_scheduler_executes_inserted_ppi_owned_ortholog_before_resume() -
assert result == {
"ok": True,
- "calls": ["preflight", "ensure", "execute:ortholog", "resume"],
+ "calls": ["preflight", "ensure", "execute:ortholog", "preflight", "resume"],
}
+def test_kaks_soft_gate_releases_only_after_ppi_settles() -> None:
+ html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
+ helper = _source_block(
+ html,
+ "const ppiSettledForKaks =",
+ "const executeStagesWithDependencies = async",
+ )
+ result = _run_node(
+ helper
+ + """
+const stages = [{id: 'ppi'}, {id: 'kaks'}];
+const getStageId = stage => typeof stage === 'object' ? stage.id : stage;
+console.log(JSON.stringify({
+ pending: ppiSettledForKaks(stages, {ppi: 'pending'}),
+ running: ppiSettledForKaks(stages, {ppi: 'running'}),
+ paused: ppiSettledForKaks(stages, {ppi: 'paused'}),
+ completed: ppiSettledForKaks(stages, {ppi: 'completed'}),
+ failed: ppiSettledForKaks(stages, {ppi: 'failed'}),
+ noPpi: ppiSettledForKaks([{id: 'kaks'}], {})
+}));
+"""
+ )
+
+ assert result == {
+ "pending": False,
+ "running": False,
+ "paused": False,
+ "completed": True,
+ "failed": True,
+ "noPpi": True,
+ }
+
+
+def test_kaks_reference_choice_keeps_ppi_and_project_namespaces_separate() -> None:
+ html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
+ helper = _source_block(
+ html,
+ "const kaksReferenceChoiceParams =",
+ "const confirmedStageParamsForExecution =",
+ )
+ result = _run_node(
+ helper
+ + """
+console.log(JSON.stringify({
+ ppi: kaksReferenceChoiceParams('ppi', 'string_taxon_3562', 'At'),
+ project: kaksReferenceChoiceParams('project', 'string_taxon_3562', 'At')
+}));
+"""
+ )
+
+ assert result["ppi"] == {
+ "kaksReferenceSource": "ppi",
+ "referenceSpecies": ["string_taxon_3562"],
+ "refSpeciesList": ["string_taxon_3562"],
+ "ppiKaksReferenceSpecies": ["string_taxon_3562"],
+ "ppiReferenceSpecies": "string_taxon_3562",
+ "referenceBundleSpecies": "string_taxon_3562",
+ "orthologMappingOwner": "ppi",
+ }
+ assert result["project"] == {
+ "kaksReferenceSource": "project",
+ "referenceSpecies": ["At"],
+ "refSpeciesList": ["At"],
+ "ppiKaksReferenceSpecies": [],
+ "ppiReferenceSpecies": "",
+ "referenceBundleSpecies": "",
+ "orthologMappingOwner": "kaks",
+ }
+
+
+def test_kaks_preflight_uses_selected_ppi_mapping_owner() -> None:
+ html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
+ helper = _source_block(
+ html,
+ "const buildPpiKaksDependencyRequest =",
+ "const ppiKaksInternalOrthologProducers =",
+ )
+ result = _run_node(
+ helper
+ + """
+const batchExecuteParams = {value: {
+ prefix: 'Ft.SPL',
+ ppiReferenceTaxonId: '3562',
+ ppiReferenceStorageKey: 'string_taxon_3562',
+ ppiReferenceSelectionMode: 'tree_nearest',
+ ppiReferenceUserConfirmed: true
+}};
+const batchExecuteStages = {value: [{
+ id: 'kaks',
+ params: {
+ kaksReferenceSource: 'project',
+ orthologMappingOwner: 'kaks',
+ referenceSpecies: ['At'],
+ refSpeciesList: ['At']
+ }
+}]};
+const getStageId = stage => typeof stage === 'object' ? stage.id : stage;
+const firstNonEmptyList = (...values) => {
+ for (const value of values) {
+ if (Array.isArray(value) && value.length) return [...value];
+ if (typeof value === 'string' && value.trim()) return [value.trim()];
+ }
+ return [];
+};
+const params = {prefix: 'Ft.SPL', geneFamily: 'SPL'};
+const confirmParams = {};
+const executionPrefix = () => 'Ft.SPL';
+const canonicalizePpiOwnerBiologicalScope = value => value;
+const confirmedStageParams = {
+ kaksReferenceSource: 'ppi',
+ orthologMappingOwner: 'ppi',
+ referenceSpecies: ['string_taxon_3562'],
+ refSpeciesList: ['string_taxon_3562'],
+ ppiKaksReferenceSpecies: ['string_taxon_3562'],
+ ppiReferenceSpecies: 'string_taxon_3562',
+ referenceBundleSpecies: 'string_taxon_3562'
+};
+console.log(JSON.stringify(
+ buildPpiKaksDependencyRequest(['kaks'], confirmedStageParams)
+));
+"""
+ )
+
+ assert {
+ "dependencyOwner": result["dependencyOwner"],
+ "requestedStages": result["requestedStages"],
+ "referenceSpecies": result["referenceSpecies"],
+ "ppiReferenceTaxonId": result["ppiReferenceTaxonId"],
+ "ppiReferenceStorageKey": result["ppiReferenceStorageKey"],
+ "ppiReferenceSelectionMode": result["ppiReferenceSelectionMode"],
+ "ppiReferenceUserConfirmed": result["ppiReferenceUserConfirmed"],
+ } == {
+ "dependencyOwner": "ppi",
+ "requestedStages": ["kaks"],
+ "referenceSpecies": ["string_taxon_3562"],
+ "ppiReferenceTaxonId": "3562",
+ "ppiReferenceStorageKey": "string_taxon_3562",
+ "ppiReferenceSelectionMode": "tree_nearest",
+ "ppiReferenceUserConfirmed": True,
+ }
+
+
+def test_kaks_project_at_route_is_not_blocked_by_ppi_near_reference_policy() -> None:
+ html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
+ helper = _source_block(
+ html,
+ "const ppiHasNearReferenceBeforeArabidopsis =",
+ "const runPpiSpeciesResolutionPreflight =",
+ )
+ result = _run_node(
+ helper
+ + """
+const resolution = {
+ status: 'needs_ortholog',
+ selected_string_species: 'ath',
+ diagnostics: {
+ reference_recommendations: [{species_code: 'string_taxon_3562'}]
+ }
+};
+console.log(JSON.stringify({
+ ppi: ppiNeedsNearReferenceDecision(resolution, ['ppi']),
+ kaks: ppiNeedsNearReferenceDecision(resolution, ['kaks'])
+}));
+"""
+ )
+
+ assert result == {"ppi": True, "kaks": False}
+
+
+def test_prepare_kaks_preflights_the_exact_confirmed_stage_params() -> None:
+ html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
+ helper = _source_block(
+ html,
+ "const prepareKaksSelectedReference =",
+ "const submitExecuteRequestWithAutoAnnotation =",
+ )
+ result = _run_node(
+ helper
+ + """
+const confirmedStageParams = {
+ kaksReferenceSource: 'ppi',
+ orthologMappingOwner: 'ppi',
+ referenceSpecies: ['string_taxon_3562'],
+ ppiKaksReferenceSpecies: ['string_taxon_3562']
+};
+let captured = null;
+const ensurePpiKaksDependencyOwner = async (...args) => {
+ captured = args;
+ return true;
+};
+const ppiKaksDependencyPauses = () => ({});
+
+(async () => {
+ const ready = await prepareKaksSelectedReference(confirmedStageParams);
+ console.log(JSON.stringify({ready, captured}));
+})();
+"""
+ )
+
+ assert result == {
+ "ready": True,
+ "captured": [
+ "kaks",
+ {
+ "kaksReferenceSource": "ppi",
+ "orthologMappingOwner": "ppi",
+ "referenceSpecies": ["string_taxon_3562"],
+ "ppiKaksReferenceSpecies": ["string_taxon_3562"],
+ },
+ ],
+ }
+
+
+def test_submit_preflight_maps_kaks_selected_ortholog_owner_to_dependency_owner() -> None:
+ html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
+ helper = _source_block(
+ html,
+ "const runPpiSpeciesResolutionPreflight =",
+ "const ppiKaksDependencyStages =",
+ )
+ result = _run_node(
+ helper
+ + """
+const jobInfo = {job_id: 'job_1786243816439_04955b'};
+const executionPrefix = () => 'Ft.SPL';
+let capturedPayload = null;
+const fetch = async (_url, options) => {
+ capturedPayload = JSON.parse(options.body);
+ return {
+ ok: true,
+ json: async () => ({success: true, status: 'ready'})
+ };
+};
+
+(async () => {
+ const outcome = await runPpiSpeciesResolutionPreflight({
+ stage: 'kaks',
+ prefix: 'Ft.SPL',
+ orthologMappingOwner: 'ppi',
+ targetSpecies: ['Ft'],
+ referenceSpecies: ['string_taxon_3562']
+ }, {requestedStages: ['kaks']});
+ console.log(JSON.stringify({
+ allowed: outcome.allowed,
+ dependencyOwner: capturedPayload.dependencyOwner
+ }));
+})();
+"""
+ )
+
+ assert result == {"allowed": True, "dependencyOwner": "ppi"}
+
+
+def test_at_fallback_owner_orchestration_failure_stays_paused_and_returns_false() -> None:
+ html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
+ handler = _source_block(
+ html,
+ "const handlePpiReferenceDownloadFailure =",
+ "const downloadRecommendedPpiReferenceBundle =",
+ )
+ result = _run_node(
+ handler
+ + """
+const batchExecuteStages = {value: [{id: 'ppi', params: {}}]};
+const stageExecutionStatus = {value: {ppi: 'paused'}};
+const stagePauseReasons = {value: {}};
+const pauses = [];
+const ppiKaksFallbackRecoveryOwners = () => ['ppi'];
+const requestPpiDownloadFallbackDecision = async () => 'ath';
+const applyArabidopsisFallbackForPpiKaks = () => {};
+const buildPpiKaksDependencyRequest = () => ({stage: 'ppi'});
+const orchestratePpiOwnerAfterReferenceReady = async () => false;
+const runPpiSpeciesResolutionPreflight = async () => ({allowed: true});
+const waitForPpiProducerDependency = async () => true;
+const ensureOrthologStageForPpiKaks = () => true;
+const getPpiKaksInternalOrthologProducer = () => null;
+const executePpiOwnedOrthologProducer = async () => false;
+const resumePpiKaksPausedStages = async () => {};
+const addAgentMessage = () => {};
+const storePpiKaksReferencePause = (owners, resolution, message) => {
+ pauses.push({owners, resolution, message});
+};
+
+(async () => {
+ const accepted = await handlePpiReferenceDownloadFailure(
+ new Error('download failed'),
+ {},
+ {},
+ ['ppi']
+ );
+ console.log(JSON.stringify({accepted, pauses}));
+})();
+"""
+ )
+
+ assert result["accepted"] is False
+ assert result["pauses"] == [
+ {
+ "owners": ["ppi"],
+ "resolution": {},
+ "message": "拟南芥兜底参考尚未形成可用的 PPI Ortholog 映射;PPI 保持暂停。",
+ }
+ ]
+
+
+def test_kaks_missing_reference_data_does_not_open_download_decision() -> None:
+ html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
+ helper = _source_block(
+ html,
+ "const ensurePpiKaksDependencyOwner = async",
+ "const preflightPpiKaksDependencyOwners = async",
+ )
+ result = _run_node(
+ helper
+ + """
+const batchExecuteStages = {value: [{id: 'kaks', params: {orthologMappingOwner: 'ppi'}}]};
+const ppiKaksDependencyStages = new Set(['ppi', 'kaks']);
+const getStageId = stage => typeof stage === 'object' ? stage.id : stage;
+const buildPpiKaksDependencyRequest = () => ({stage: 'kaks'});
+const runPpiSpeciesResolutionPreflight = async () => ({
+ allowed: false,
+ resolution: {status: 'needs_reference_data', issue_code: 'kaks_reference_cds_missing'}
+});
+const ppiNeedsNearReferenceDecision = () => false;
+let decisionCalls = 0;
+const requestPpiReferenceDataDecision = async () => { decisionCalls += 1; return 'skip'; };
+const formatPpiSpeciesResolutionBlockMessage = () => 'reference data missing';
+const storePpiKaksReferencePause = () => {};
+const addAgentMessage = () => {};
+
+(async () => {
+ const ready = await ensurePpiKaksDependencyOwner('kaks');
+ console.log(JSON.stringify({ready, decisionCalls}));
+})();
+"""
+ )
+
+ assert result == {"ready": False, "decisionCalls": 0}
+
+
def test_orchestrator_waits_for_identify_and_does_not_resume_on_producer_failure() -> None:
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
helper = _source_block(
@@ -742,7 +1431,10 @@ def test_orchestrator_waits_for_identify_and_does_not_resume_on_producer_failure
let identify = 'running';
const base = {
requestBody: {stage: 'ppi'}, requestedStages: ['ppi'],
- runPreflight: async () => ({allowed: true, resolution: {status: 'needs_ortholog'}}),
+ runPreflight: async () => {
+ calls.push('preflight');
+ return {allowed: true, resolution: {status: calls.filter(item => item === 'preflight').length <= 2 ? 'needs_ortholog' : 'ready'}};
+ },
dependencyStatus: () => identify,
waitForDependency: async () => { calls.push('wait:identify'); identify = 'completed'; return true; },
ensureOrtholog: async () => ({ready: true, producerStage: {id: 'ortholog'}}),
@@ -762,5 +1454,333 @@ def test_orchestrator_waits_for_identify_and_does_not_resume_on_producer_failure
assert result == {
"failed": False,
"succeeded": True,
- "calls": ["wait:identify", "execute:failed", "execute:success", "resume"],
+ "calls": ["preflight", "wait:identify", "execute:failed", "preflight", "execute:success", "preflight", "resume"],
}
+
+
+def test_ppi_stage_error_path_keeps_confirmed_params_in_catch_scope_and_batch_has_final_cleanup() -> None:
+ html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
+ stage_start = html.index("const executeStageWithConfirmation = async")
+ stage_end = html.index("const NCBI_SPECIES_DEFAULTS =", stage_start)
+ stage_block = html[stage_start:stage_end]
+ try_index = stage_block.index("try {")
+ declaration_index = stage_block.index("let confirmedStageParams")
+ catch_index = stage_block.index("} catch (err)")
+
+ assert declaration_index < try_index < catch_index
+ assert "confirmedStageParams = confirmedStageParamsForExecution" in stage_block
+ assert "stageExecutionStatus.value[stageId] = 'failed'" in stage_block[catch_index:]
+
+ batch_start = html.index("const startBatchExecute = async")
+ batch_end = html.index("const ppiSettledForKaks =", batch_start)
+ batch_block = html[batch_start:batch_end]
+ execute_index = batch_block.index("await executeStagesWithDependencies")
+ finally_index = batch_block.index("finally", execute_index)
+ cleanup_index = batch_block.index("isBatchExecuting.value = false", finally_index)
+ assert execute_index < finally_index < cleanup_index
+
+
+def test_hidden_ppi_ortholog_reports_success_to_the_user() -> None:
+ html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
+ block = _source_block(
+ html,
+ "const executePpiOwnedOrthologProducer = async",
+ "const waitForPpiProducerDependency = async",
+ )
+ assert "专属同源映射生成完成" in block
+
+
+def test_arabidopsis_fallback_keeps_ppi_paused_until_owner_ortholog_is_ready() -> None:
+ html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
+ helper = _source_block(
+ html,
+ "const applyArabidopsisFallbackForPpiKaks =",
+ "const resumePpiKaksPausedStages =",
+ )
+ result = _run_node(
+ helper
+ + """
+const calls = {selection: null, clear: []};
+const batchExecuteStages = {value: [{id: 'ppi', params: {}}]};
+const stageExecutionStatus = {value: {ppi: 'paused'}};
+const getStageId = stage => typeof stage === 'object' ? stage.id : stage;
+const getStageLabel = stage => stage;
+const stageDependencies = {};
+const applyPpiOwnerSelectionToBatch = selection => { calls.selection = selection; };
+const mergePpiReferenceSpeciesIntoBatch = () => {};
+const clearPpiKaksReferencePause = stages => { calls.clear.push(stages); };
+
+applyArabidopsisFallbackForPpiKaks(['ppi'], 'download failed');
+console.log(JSON.stringify({calls, status: stageExecutionStatus.value.ppi}));
+"""
+ )
+
+ assert result == {
+ "calls": {
+ "selection": {
+ "taxonId": "3702",
+ "storageKey": "At",
+ "selectionMode": "model_fallback",
+ "userConfirmed": True,
+ },
+ "clear": [],
+ },
+ "status": "paused",
+ }
+
+
+def test_at_fallback_batch_owner_overrides_stale_spinach_stage_for_ppi_request() -> None:
+ html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
+ helper = _source_block(
+ html,
+ "const applyPpiOwnerFieldsToRequest =",
+ "const applyGeneFamilyContextToRequest =",
+ )
+ result = _run_node(
+ helper
+ + """
+const canonicalizePpiOwnerBiologicalScope = request => request;
+const request = {};
+applyPpiOwnerFieldsToRequest(request, 'ppi', {
+ ppiReferenceTaxonId: '3562',
+ ppiReferenceStorageKey: 'string_taxon_3562',
+ ppiReferenceSelectionMode: 'tree_nearest',
+ ppiReferenceUserConfirmed: true
+}, {
+ ppiReferenceTaxonId: '3702',
+ ppiReferenceStorageKey: 'At',
+ ppiReferenceSelectionMode: 'model_fallback',
+ ppiReferenceUserConfirmed: true
+}, {});
+console.log(JSON.stringify(request));
+"""
+ )
+
+ assert result["ppiReferenceTaxonId"] == "3702"
+ assert result["ppiReferenceStorageKey"] == "At"
+ assert result["ppiReferenceSelectionMode"] == "model_fallback"
+ assert result["ppiReferenceUserConfirmed"] is True
+
+
+def test_normal_spinach_ppi_owner_request_is_unchanged() -> None:
+ html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
+ helper = _source_block(
+ html,
+ "const applyPpiOwnerFieldsToRequest =",
+ "const applyGeneFamilyContextToRequest =",
+ )
+ result = _run_node(
+ helper
+ + """
+const canonicalizePpiOwnerBiologicalScope = request => request;
+const request = {};
+const spinach = {
+ ppiReferenceTaxonId: '3562',
+ ppiReferenceStorageKey: 'string_taxon_3562',
+ ppiReferenceSelectionMode: 'tree_nearest',
+ ppiReferenceUserConfirmed: true
+};
+applyPpiOwnerFieldsToRequest(request, 'ppi', spinach, spinach, {});
+console.log(JSON.stringify(request));
+"""
+ )
+
+ assert result["ppiReferenceTaxonId"] == "3562"
+ assert result["ppiReferenceStorageKey"] == "string_taxon_3562"
+ assert result["ppiReferenceSelectionMode"] == "tree_nearest"
+ assert result["ppiReferenceUserConfirmed"] is True
+
+
+def test_ppi_owned_ortholog_producer_is_single_flight_for_same_owner_and_reference_identity() -> None:
+ html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
+ helper = _source_block(
+ html,
+ "const executePpiOwnedOrthologProducer = async",
+ "const waitForPpiProducerDependency = async",
+ )
+ result = _run_node(
+ helper
+ + """
+const ppiKaksInternalOrthologStatus = new Map();
+const ppiKaksInternalOrthologPromises = new Map();
+const ppiKaksInternalOrthologIdentityKey = producerStage => {
+ const producerParams = producerStage.params || {};
+ return [
+ String(producerParams.dependencyOwner || 'ppi').trim().toLowerCase(),
+ String(producerParams.ppiReferenceTaxonId || '').trim(),
+ String(producerParams.ppiReferenceStorageKey || '').trim()
+ ].join('::');
+};
+const getStageId = stage => stage.id;
+const getStageLabel = stage => stage;
+const addAgentMessage = () => {};
+let executeCalls = 0;
+const executeStageDirect = async () => {
+ executeCalls += 1;
+ await new Promise(resolve => setTimeout(resolve, 10));
+};
+const producer = {id: 'ortholog', params: {
+ dependencyOwner: 'ppi',
+ ppiReferenceTaxonId: '3562',
+ ppiReferenceStorageKey: 'string_taxon_3562'
+}};
+
+(async () => {
+ const results = await Promise.all([
+ executePpiOwnedOrthologProducer(producer),
+ executePpiOwnedOrthologProducer(producer)
+ ]);
+ console.log(JSON.stringify({results, executeCalls}));
+})();
+"""
+ )
+
+ assert result == {"results": [True, True], "executeCalls": 1}
+
+
+def test_ppi_owned_ortholog_producer_does_not_reuse_in_flight_promise_for_new_reference_identity() -> None:
+ html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
+ helper = _source_block(
+ html,
+ "const executePpiOwnedOrthologProducer = async",
+ "const waitForPpiProducerDependency = async",
+ )
+ result = _run_node(
+ helper
+ + """
+const ppiKaksInternalOrthologStatus = new Map();
+const ppiKaksInternalOrthologPromises = new Map();
+const ppiKaksInternalOrthologIdentityKey = producerStage => {
+ const producerParams = producerStage.params || {};
+ return [
+ String(producerParams.dependencyOwner || 'ppi').trim().toLowerCase(),
+ String(producerParams.ppiReferenceTaxonId || '').trim(),
+ String(producerParams.ppiReferenceStorageKey || '').trim()
+ ].join('::');
+};
+const getStageId = stage => stage.id;
+const getStageLabel = stage => stage;
+const addAgentMessage = () => {};
+const executeCalls = [];
+const executeStageDirect = async (_stage, params) => {
+ executeCalls.push(params.ppiReferenceStorageKey);
+ if (params.ppiReferenceStorageKey === 'string_taxon_3562') {
+ await new Promise(resolve => setTimeout(resolve, 20));
+ throw new Error('old reference failed');
+ }
+ await new Promise(resolve => setTimeout(resolve, 5));
+};
+const oldProducer = {id: 'ortholog', params: {
+ dependencyOwner: 'ppi',
+ ppiReferenceTaxonId: '3562',
+ ppiReferenceStorageKey: 'string_taxon_3562'
+}};
+const atProducer = {id: 'ortholog', params: {
+ dependencyOwner: 'ppi',
+ ppiReferenceTaxonId: '3702',
+ ppiReferenceStorageKey: 'At'
+}};
+
+(async () => {
+ const results = await Promise.all([
+ executePpiOwnedOrthologProducer(oldProducer),
+ executePpiOwnedOrthologProducer(atProducer)
+ ]);
+ console.log(JSON.stringify({results, executeCalls}));
+})();
+"""
+ )
+
+ assert result == {
+ "results": [False, True],
+ "executeCalls": ["string_taxon_3562", "At"],
+ }
+
+
+def test_resetting_ppi_producer_clears_completed_identity_for_same_reference_rerun() -> None:
+ html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
+ execute_helper = _source_block(
+ html,
+ "const executePpiOwnedOrthologProducer = async",
+ "const waitForPpiProducerDependency = async",
+ )
+ remember_helper = _source_block(
+ html,
+ "const ppiKaksInternalOrthologProducers = new Map();",
+ "const getPpiKaksInternalOrthologProducer =",
+ )
+ result = _run_node(
+ execute_helper
+ + remember_helper
+ + """
+const ppiKaksInternalOrthologIdentityKey = producerStage => {
+ const producerParams = producerStage.params || {};
+ return [
+ String(producerParams.dependencyOwner || 'ppi').trim().toLowerCase(),
+ String(producerParams.ppiReferenceTaxonId || '').trim(),
+ String(producerParams.ppiReferenceStorageKey || '').trim()
+ ].join('::');
+};
+const getStageId = stage => stage.id;
+const getStageLabel = stage => stage;
+const addAgentMessage = () => {};
+let executeCalls = 0;
+const executeStageDirect = async () => { executeCalls += 1; };
+const atProducer = {id: 'ortholog', params: {
+ dependencyOwner: 'ppi',
+ ppiReferenceTaxonId: '3702',
+ ppiReferenceStorageKey: 'At'
+}};
+
+(async () => {
+ rememberPpiKaksInternalOrthologProducer('ppi', atProducer);
+ const first = await executePpiOwnedOrthologProducer(atProducer);
+ rememberPpiKaksInternalOrthologProducer('ppi', null);
+ rememberPpiKaksInternalOrthologProducer('ppi', atProducer);
+ const second = await executePpiOwnedOrthologProducer(atProducer);
+ console.log(JSON.stringify({first, second, executeCalls}));
+})();
+"""
+ )
+
+ assert result == {"first": True, "second": True, "executeCalls": 2}
+
+
+def test_resume_does_not_change_running_ppi_back_to_pending() -> None:
+ html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
+ helper = _source_block(
+ html,
+ "const resumePpiKaksPausedStages = async",
+ "const ppiKaksFallbackRecoveryOwners =",
+ )
+ result = _run_node(
+ helper
+ + """
+const batchExecuteStages = {value: [{id: 'ppi'}]};
+const stageExecutionStatus = {value: {ppi: 'running'}};
+const isBatchExecuting = {value: true};
+const shouldStopBatchExecute = {value: false};
+const currentBatchStage = {value: ''};
+const getStageId = stage => typeof stage === 'object' ? stage.id : stage;
+const ppiKaksPauseStageIds = stages => stages;
+const clearPpiKaksReferencePause = () => {};
+const addAgentMessage = () => {};
+const executeStagesWithDependencies = async () => {};
+
+(async () => {
+ await resumePpiKaksPausedStages(['ppi'], 'At ready');
+ console.log(JSON.stringify({status: stageExecutionStatus.value.ppi}));
+})();
+"""
+ )
+
+ assert result == {"status": "running"}
+
+
+def test_ppi_manifest_and_background_status_queries_use_canonical_owner_identity() -> None:
+ html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
+ manifest_block = _source_block(html, "const checkManifestExists = async", "const pollTaskUntilComplete = async")
+ background_block = _source_block(html, "const fetchStageCompleted = async", "const finalizeExecuteStageDirectRequest =")
+ expected = "canonicalizePpiOwnerBiologicalScope({ ...batchPlanParams, ...batchParams, ...stageParams })"
+ assert expected in manifest_block
+ assert expected in background_block
diff --git a/tests/unit/test_ppi_provenance_task6.py b/tests/unit/test_ppi_provenance_task6.py
index e30e1e482..5512f5e9a 100644
--- a/tests/unit/test_ppi_provenance_task6.py
+++ b/tests/unit/test_ppi_provenance_task6.py
@@ -65,6 +65,10 @@ def _assert_complete_identity(identity: dict) -> None:
def _prepare_owner_execution_fixture(job: backend.JobInfo) -> Path:
+ (Path(job.workspace_dir) / "final" / "Sv.HKL.final_members.id").write_text(
+ "SvGene1\n",
+ encoding="utf-8",
+ )
scope = Path(job.input_dir) / "ppi-owner.scope.json"
scope.write_text(
json.dumps(
@@ -79,7 +83,7 @@ def _prepare_owner_execution_fixture(job: backend.JobInfo) -> Path:
),
encoding="utf-8",
)
- mapping = Path(job.workspace_dir) / "final" / "Sv.HKL.owner-target-only.orthologs.txt"
+ mapping = Path(job.workspace_dir) / "final" / "Sv.HKL.ppi.orthologs.txt"
mapping.write_text(
"SvGene1\tSeita.1\t91\tSv\tstring_taxon_4555\n",
encoding="utf-8",
@@ -300,6 +304,7 @@ def test_waiting_preflights_write_decision_records_without_failed_stage_manifest
storage = Path(job.input_dir) / "string_taxon_4555"
storage.mkdir()
(storage / "string_taxon_4555.pep.fasta").write_text(">Seita1\nMA\n", encoding="utf-8")
+ (storage / "string_taxon_4555.cds.fasta").write_text(">Seita1\nATGGCT\n", encoding="utf-8")
needs_ortholog = _sv_preflight(job, confirmed=True)
assert [confirmation["status"], missing_pep["status"], needs_ortholog["status"]] == [
@@ -316,6 +321,37 @@ def test_waiting_preflights_write_decision_records_without_failed_stage_manifest
assert not (Path(job.workspace_dir) / "final" / "Sv.HKL.ppi.manifest.json").exists()
+def test_confirmed_arabidopsis_model_fallback_is_accepted_outside_nearest_candidates(
+ tmp_path: Path,
+) -> None:
+ job = _job(tmp_path, job_id="ft-arabidopsis-fallback")
+ storage = Path(job.input_dir) / "At"
+ storage.mkdir()
+ (storage / "At.pep.fasta").write_text(">AT1G01010.1\nMA\n", encoding="utf-8")
+
+ result = backend.resolve_ppi_species_resolution(
+ job=job,
+ prefix="Ft.SPL",
+ target_species=["Ft"],
+ family="SPL",
+ target_scientific_name="Fagopyrum tataricum",
+ ppi_target_tree_taxon_id="62330",
+ ppi_reference_taxon_id="3702",
+ ppi_reference_storage_key="At",
+ ppi_reference_selection_mode="model_fallback",
+ ppi_reference_user_confirmed=True,
+ dependency_owner="ppi",
+ ignore_existing_ortholog=True,
+ requested_stages=["ppi"],
+ )
+
+ assert result["status"] == "needs_ortholog"
+ assert result["selected_taxon_id"] == "3702"
+ assert result["reference_species"] == "At"
+ assert result["ppi_provenance"]["selection_mode"] == "model_fallback"
+ assert result["ppi_provenance"]["user_confirmation"]["confirmed"] is True
+
+
@pytest.mark.parametrize(
("selection_mode", "status", "expected"),
[
diff --git a/tests/unit/test_ppi_reference_stage_confirm_dialogs.py b/tests/unit/test_ppi_reference_stage_confirm_dialogs.py
new file mode 100644
index 000000000..e631db3d4
--- /dev/null
+++ b/tests/unit/test_ppi_reference_stage_confirm_dialogs.py
@@ -0,0 +1,127 @@
+from pathlib import Path
+import json
+import re
+import subprocess
+
+
+ROOT = Path(__file__).resolve().parents[2]
+HTML_PATH = ROOT / "apps/web/vue3/index.html"
+
+
+def _html() -> str:
+ return HTML_PATH.read_text(encoding="utf-8")
+
+
+def _function_block(html: str, start: str, end: str) -> str:
+ return html.split(start, 1)[1].split(end, 1)[0]
+
+
+def test_both_reference_messages_use_stage_confirm_dialog_shell():
+ html = _html()
+ nearest = _function_block(
+ html,
+ "const confirmNearestPpiReferenceCandidate = async",
+ "const ppiReferenceDecisionMessage =",
+ )
+ preflight = _function_block(
+ html,
+ "const runPpiSpeciesResolutionPreflight = async",
+ "const ppiKaksDependencyStages =",
+ )
+
+ assert "id: 'ppi_string_reference_confirmation'" in nearest
+ assert "showStageConfirmDialog" in nearest
+ assert "ElMessageBox.confirm" not in nearest
+ assert "id: 'ppi_kaks_reference_status'" in preflight
+ assert "showStageConfirmDialog" in preflight
+ assert "'PPI/KaKs reference data and Ortholog mapping required'" not in preflight
+
+
+def test_stage_confirm_shell_renders_grouped_fields_and_ordered_candidates():
+ html = _html()
+
+ assert "confirmParams.stage === 'ppi_string_reference_confirmation'" in html
+ assert "confirmParams.stage === 'ppi_kaks_reference_status'" in html
+ assert html.count('') >= 2
+ assert 'v-for="(candidate, index) in confirmParams.ppiKaksReferenceCandidates"' in html
+ for label in (
+ "Target species",
+ "Requested stages",
+ "Required data",
+ "Missing data",
+ "Current reference species",
+ "Ortholog mapping",
+ "STRING TaxID",
+ "LCA",
+ "Topology distance",
+ ):
+ assert label in html
+
+
+def test_special_dialogs_preserve_manual_actions_without_batch_controls():
+ html = _html()
+
+ assert "Confirm top candidate" in html
+ assert "Cancel PPI" in html
+ assert "Reference status acknowledged" in html
+ assert "stageConfirmCountdown > 0" in html
+
+
+def test_candidate_normalizer_supports_topology_and_recommendation_shapes():
+ html = _html()
+ match = re.search(
+ r"const normalizePpiReferenceCandidates = \(source = \{\}, fallbackVersion = ''\) => \{.*?\n\s*\};",
+ html,
+ re.S,
+ )
+ assert match, "normalizePpiReferenceCandidates helper is missing"
+ function_source = match.group(0).replace(
+ "const normalizePpiReferenceCandidates =",
+ "globalThis.normalizePpiReferenceCandidates =",
+ 1,
+ )
+ script = f"""
+{function_source}
+const topology = normalizePpiReferenceCandidates({{
+ candidates: [{{
+ selected_string_name: 'Spinacia oleracea',
+ selected_string_taxon_id: 3562,
+ lca_name: 'Caryophyllales',
+ topology_distance: 4,
+ string_version: '12.0'
+ }}]
+}});
+const recommendation = normalizePpiReferenceCandidates({{
+ diagnostics: {{ reference_recommendations: [{{
+ species_name: 'Arabidopsis thaliana',
+ species_code: 'ath',
+ taxon_id: 3702,
+ relationship: 'model_fallback',
+ source: 'project_reference'
+ }}] }}
+}}, '12.0');
+process.stdout.write(JSON.stringify({{ topology, recommendation }}));
+"""
+ completed = subprocess.run(
+ ["node", "-e", script],
+ cwd=ROOT,
+ text=True,
+ capture_output=True,
+ check=True,
+ )
+ data = json.loads(completed.stdout)
+
+ assert data["topology"][0] == {
+ "name": "Spinacia oleracea",
+ "code": "",
+ "taxon": "3562",
+ "lca": "Caryophyllales",
+ "distance": "4",
+ "version": "12.0",
+ "relationship": "",
+ "source": "",
+ }
+ assert data["recommendation"][0]["name"] == "Arabidopsis thaliana"
+ assert data["recommendation"][0]["taxon"] == "3702"
+ assert data["recommendation"][0]["relationship"] == "model_fallback"
+ assert data["recommendation"][0]["source"] == "project_reference"
diff --git a/tests/unit/test_ppi_snapshot_preflight_task3.py b/tests/unit/test_ppi_snapshot_preflight_task3.py
index afa473e06..da24b152d 100644
--- a/tests/unit/test_ppi_snapshot_preflight_task3.py
+++ b/tests/unit/test_ppi_snapshot_preflight_task3.py
@@ -58,7 +58,7 @@ def _materialize_trusted_mapping(
pairs = workspace / "ortholog" / f"{prefix}.{target}_to_{storage_key}_pairs.txt"
pairs.parent.mkdir(parents=True, exist_ok=True)
pairs.write_text(f"{subject_id}\t{query_id}\t91\n", encoding="utf-8")
- scope_id = build_scope_id(family, [target], [storage_key], [], "target_plus_reference")
+ scope_id = build_scope_id(family, [target], [], [], "target_only")
identity = build_ortholog_run_identity(
query_species=target, reference_taxon_id=taxon_id,
reference_storage_key=storage_key, query_fasta=query,
@@ -67,14 +67,15 @@ def _materialize_trusted_mapping(
)
materialize_ortholog_final_outputs(
final_dir=final_dir, prefix=prefix,
+ dependency_owner="ppi",
run_records=[OrthologRunRecord(target, storage_key, query, reference, pairs)],
job_id=job.job_id, workspace=workspace, reference_species=storage_key,
reference_taxon_id=taxon_id, reference_storage_key=storage_key,
ortholog_identity=identity,
scope_fields={
- "scope_id": scope_id, "scope_mode": "target_plus_reference",
- "target_species": [target], "reference_species": [storage_key],
- "selected_species": [target, storage_key], "family": family,
+ "scope_id": scope_id, "scope_mode": "target_only",
+ "target_species": [target], "reference_species": [],
+ "comparative_species": [], "selected_species": [target], "family": family,
},
)
@@ -168,8 +169,8 @@ def test_confirmed_nearest_without_pep_requests_only_reference_data(tmp_path: Pa
assert result["status"] == "needs_reference_data"
assert result["issue_code"] == "ppi_reference_data_missing"
- assert result["reference_bundle_status"]["required_files"] == ["pep"]
- assert result["reference_bundle_status"]["missing_required"] == ["pep"]
+ assert result["reference_bundle_status"]["required_files"] == ["pep", "cds"]
+ assert result["reference_bundle_status"]["missing_required"] == ["pep", "cds"]
assert result["required_next_stage"] == ""
assert result["selected_taxon_id"] == "4555"
@@ -179,6 +180,7 @@ def test_confirmed_nearest_with_pep_needs_ortholog(tmp_path: Path) -> None:
reference_dir = Path(job.input_dir) / "string_taxon_4555"
reference_dir.mkdir()
(reference_dir / "string_taxon_4555.pep.fasta").write_text(">Seita.1\nMA\n", encoding="utf-8")
+ (reference_dir / "string_taxon_4555.cds.fasta").write_text(">Seita.1\nATGGCT\n", encoding="utf-8")
result = _resolve_sv(
job,
@@ -200,6 +202,7 @@ def test_confirmed_nearest_with_matching_mapping_is_ready(tmp_path: Path) -> Non
reference_dir = Path(job.input_dir) / "string_taxon_4555"
reference_dir.mkdir()
(reference_dir / "string_taxon_4555.pep.fasta").write_text(">Seita.1\nMA\n", encoding="utf-8")
+ (reference_dir / "string_taxon_4555.cds.fasta").write_text(">Seita.1\nATGGCT\n", encoding="utf-8")
_materialize_trusted_mapping(
job, prefix="Sv.HKL", target="Sv", family="HKL",
storage_key="string_taxon_4555", taxon_id="4555",
diff --git a/tests/unit/test_reference_bundle_download_indicator.py b/tests/unit/test_reference_bundle_download_indicator.py
new file mode 100644
index 000000000..2f4cfdf35
--- /dev/null
+++ b/tests/unit/test_reference_bundle_download_indicator.py
@@ -0,0 +1,163 @@
+from __future__ import annotations
+
+import json
+import subprocess
+from pathlib import Path
+
+
+HTML_PATH = Path("apps/web/vue3/index.html")
+
+
+def _source_block(source: str, start: str, end: str) -> str:
+ return source[source.index(start) : source.index(end)]
+
+
+def _run_node(script: str) -> dict:
+ completed = subprocess.run(
+ ["node", "-e", script],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ return json.loads(completed.stdout.strip())
+
+
+def _progress_state_script(body: str) -> str:
+ html = HTML_PATH.read_text(encoding="utf-8")
+ updater = _source_block(
+ html,
+ "const updateReferenceBundleDownloadProgress =",
+ "const pollReferenceBundleDownloadTask =",
+ )
+ return f"""
+const referenceBundleDownloadState = {{value: {{
+ visible: false, taskId: '', speciesLabel: '', message: '',
+ percentage: 0, indeterminate: true, status: '',
+ bytesDownloaded: 0, totalBytes: 0
+}}}};
+{updater}
+{body}
+"""
+
+
+def test_unknown_total_keeps_indeterminate_progress_and_reports_downloaded_bytes() -> None:
+ result = _run_node(
+ _progress_state_script(
+ """
+updateReferenceBundleDownloadProgress({
+ id: 'task-download', status: 'running',
+ progress: {bytes_downloaded: 8388608, total_bytes: 0, indeterminate: true}
+}, {species_name: 'Spinacia oleracea'});
+console.log(JSON.stringify(referenceBundleDownloadState.value));
+"""
+ )
+ )
+
+ assert result["visible"] is True
+ assert result["speciesLabel"] == "Spinacia oleracea"
+ assert result["bytesDownloaded"] == 8388608
+ assert result["totalBytes"] == 0
+ assert result["indeterminate"] is True
+
+
+def test_failure_without_new_progress_preserves_last_downloaded_bytes() -> None:
+ result = _run_node(
+ _progress_state_script(
+ """
+updateReferenceBundleDownloadProgress({
+ id: 'task-download', status: 'running',
+ progress: {bytes_downloaded: 8388608, total_bytes: 0, indeterminate: true}
+}, {species_name: 'Spinacia oleracea'});
+updateReferenceBundleDownloadProgress({
+ status: 'failed', message: 'IncompleteRead(726589 bytes read)'
+}, {species_name: 'Spinacia oleracea'});
+console.log(JSON.stringify(referenceBundleDownloadState.value));
+"""
+ )
+ )
+
+ assert result["status"] == "failed"
+ assert result["message"] == "IncompleteRead(726589 bytes read)"
+ assert result["bytesDownloaded"] == 8388608
+ assert result["totalBytes"] == 0
+
+
+def test_clear_resets_terminal_download_state() -> None:
+ result = _run_node(
+ _progress_state_script(
+ """
+updateReferenceBundleDownloadProgress({
+ id: 'task-download', status: 'failed', message: 'download failed',
+ progress: {bytes_downloaded: 4096, total_bytes: 0, indeterminate: true}
+}, {species_name: 'Spinacia oleracea'});
+clearReferenceBundleDownloadProgress();
+console.log(JSON.stringify(referenceBundleDownloadState.value));
+"""
+ )
+ )
+
+ assert result == {
+ "visible": False,
+ "taskId": "",
+ "speciesLabel": "",
+ "message": "",
+ "percentage": 0,
+ "indeterminate": True,
+ "status": "",
+ "bytesDownloaded": 0,
+ "totalBytes": 0,
+ }
+
+
+def test_download_indicator_is_beside_tip_and_not_inside_batch_dialog() -> None:
+ html = HTML_PATH.read_text(encoding="utf-8")
+ toolbar = _source_block(
+ html,
+ "",
+ "",
+ )
+ batch_dialog = _source_block(
+ html,
+ "",
+ "",
+ )
+
+ assert 'class="reference-download-progress"' in toolbar
+ assert "referenceBundleDownloadState.visible" in toolbar
+ assert "PPI and Ka/Ks Reference Species Data Download" in toolbar
+ assert ':show-text="!referenceBundleDownloadState.indeterminate"' in toolbar
+ assert 'class="reference-download-progress"' not in batch_dialog
+ assert "referenceBundleDownloadState.visible" not in batch_dialog
+
+
+def test_download_byte_formatter_uses_binary_units() -> None:
+ html = HTML_PATH.read_text(encoding="utf-8")
+ assert "const formatReferenceDownloadBytes =" in html
+ formatter = _source_block(
+ html,
+ "const formatReferenceDownloadBytes =",
+ "const updateReferenceBundleDownloadProgress =",
+ )
+ result = _run_node(
+ formatter
+ + """
+console.log(JSON.stringify({
+ zero: formatReferenceDownloadBytes(0),
+ mib: formatReferenceDownloadBytes(8388608)
+}));
+"""
+ )
+
+ assert result == {"zero": "", "mib": "8.0 MB"}
+
+
+def test_monitor_retains_success_and_passes_failed_task_progress() -> None:
+ html = HTML_PATH.read_text(encoding="utf-8")
+ monitor = _source_block(
+ html,
+ "const monitorPpiReferenceBundleDownload =",
+ "const ppiHasNearReferenceBeforeArabidopsis =",
+ )
+
+ assert "clearReferenceBundleDownloadProgress();" not in monitor
+ assert "err?.task" in monitor
diff --git a/tests/unit/test_reference_bundle_download_progress.py b/tests/unit/test_reference_bundle_download_progress.py
index be7963803..c46e8d30c 100755
--- a/tests/unit/test_reference_bundle_download_progress.py
+++ b/tests/unit/test_reference_bundle_download_progress.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import http.client
import zipfile
@@ -73,5 +74,51 @@ def test_reference_bundle_download_reports_progress_events(tmp_path, monkeypatch
assert "validate_ids" in phases
assert phases[-1] == "completed"
assert manifest["requested_stages"] == ["ppi"]
- assert manifest["requested_file_types"] == ["pep"]
- assert "cds" not in manifest["files"]
+ assert manifest["requested_file_types"] == ["pep", "cds"]
+ assert "cds" in manifest["files"]
+
+
+def test_reference_bundle_download_retries_incomplete_read(tmp_path, monkeypatch):
+ from apps.web import pipeline_backend as backend
+
+ payload = b"complete archive"
+ calls = {"count": 0}
+
+ class FakeResponse:
+ def __init__(self, *, incomplete: bool):
+ self.incomplete = incomplete
+ self.headers = {"Content-Length": str(len(payload))}
+ self._sent = False
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *_args):
+ return False
+
+ def read(self, _size):
+ if self.incomplete:
+ raise http.client.IncompleteRead(b"partial", len(payload))
+ if self._sent:
+ return b""
+ self._sent = True
+ return payload
+
+ def fake_urlopen(_url, timeout):
+ calls["count"] += 1
+ return FakeResponse(incomplete=calls["count"] == 1)
+
+ monkeypatch.setattr(backend.urllib.request, "urlopen", fake_urlopen)
+ monkeypatch.setattr(backend.time, "sleep", lambda _seconds: None)
+ output_path = tmp_path / "reference.zip"
+
+ downloaded = backend._download_url_to_file(
+ "https://example.invalid/reference.zip",
+ output_path,
+ attempts=2,
+ )
+
+ assert calls["count"] == 2
+ assert downloaded == len(payload)
+ assert output_path.read_bytes() == payload
+ assert not output_path.with_suffix(".zip.part").exists()
diff --git a/tests/unit/test_reference_bundle_task_status.py b/tests/unit/test_reference_bundle_task_status.py
index 6ed85e61e..295f065b2 100755
--- a/tests/unit/test_reference_bundle_task_status.py
+++ b/tests/unit/test_reference_bundle_task_status.py
@@ -71,3 +71,45 @@ def test_reference_bundle_task_can_be_recovered_from_persisted_status(tmp_path,
assert recovered is not None
assert recovered["job_id"] == "job_ref_recover"
assert recovered["status"] == "failed"
+
+
+def test_reference_bundle_task_uses_english_user_messages(tmp_path, monkeypatch):
+ from apps.web import pipeline_backend
+ from apps.web.vue3 import main
+
+ monkeypatch.setattr(main, "WORKSPACE_ROOT", tmp_path / "workspace")
+ monkeypatch.setattr(main, "INPUT_ROOT", tmp_path / "input")
+ job_id = "job_ref_english"
+ task_id = "task_ref_english"
+ (tmp_path / "workspace" / job_id).mkdir(parents=True)
+ (tmp_path / "input" / job_id).mkdir(parents=True)
+ main.tasks[task_id] = {
+ "id": task_id,
+ "task_id": task_id,
+ "stage": "reference_bundle_download",
+ "job_id": job_id,
+ "status": "pending",
+ }
+ monkeypatch.setattr(
+ pipeline_backend,
+ "download_reference_annotation_bundle",
+ lambda **_kwargs: {
+ "status": "success",
+ "requested_file_types": ["pep", "cds"],
+ "files": {"pep": "ref.pep.fasta", "cds": "ref.cds.fasta"},
+ },
+ )
+
+ main.run_reference_bundle_download_task(
+ task_id,
+ {
+ "job_id": job_id,
+ "species_code": "string_taxon_3562",
+ "requested_stages": ["ppi"],
+ "timeout_sec": 30,
+ },
+ )
+
+ task = main.tasks.pop(task_id)
+ assert task["message"] == "PPI and Ka/Ks reference species data are ready"
+ assert task["progress"]["message"] == "PPI and Ka/Ks reference species data are ready"
diff --git a/tests/unit/test_router_analysis_handoff_boundary.py b/tests/unit/test_router_analysis_handoff_boundary.py
index e47e83eda..8ac77dc89 100644
--- a/tests/unit/test_router_analysis_handoff_boundary.py
+++ b/tests/unit/test_router_analysis_handoff_boundary.py
@@ -23,6 +23,20 @@ class _PassThroughTools:
return query
+class _ContextEnrichingTools:
+ @staticmethod
+ def resolve_analysis_handoff_request(
+ query: str,
+ *,
+ workflow_id: str | None = None,
+ guidance: bool = False,
+ ) -> str:
+ return (
+ f"{query}\n已确认上下文:当前 job 的 subfamily_confirm 已成功完成,"
+ "家族成员和亚家族分类表已确认。"
+ )
+
+
@pytest.mark.parametrize(
("query", "workflow_id", "clarification_question"),
(
@@ -77,10 +91,47 @@ def test_new_analysis_proposal_preserves_original_request_without_main_agent_pla
assert outcome.action == "clarify"
assert outcome.proposal is not None
assert outcome.proposal["request"] == query
+ assert outcome.proposal["original_request"] == query
assert "workflow_id" not in outcome.proposal
assert "全基因组还是" not in outcome.message
+def test_context_enrichment_does_not_replace_original_analysis_handoff() -> None:
+ runtime = RouterRuntime(object(), _ContextEnrichingTools(), max_iterations=5) # type: ignore[arg-type]
+
+ async def decide_route(**_: Any) -> tuple[RouteDecision, int, list[dict[str, Any]]]:
+ return (
+ RouteDecision(
+ goal="new_analysis",
+ needs=["analysis_intent"],
+ subject="执行kaks分析",
+ workflow_id="gene_family",
+ analysis_request_kind="execution",
+ ),
+ 1,
+ [],
+ )
+
+ runtime._decide_route = decide_route # type: ignore[method-assign]
+ outcome = asyncio.run(
+ runtime.run(
+ query="执行kaks分析",
+ history=[],
+ session_id="kaks-handoff-session",
+ job_id="job-kaks-handoff",
+ goal_ledger=GoalLedger(
+ conversation_id="kaks-handoff-session",
+ revision=0,
+ user_terms={"active_job_id": "job-kaks-handoff"},
+ ),
+ )
+ )
+
+ assert outcome.proposal is not None
+ assert "subfamily_confirm" in outcome.proposal["request"]
+ assert outcome.proposal["original_request"] == "执行kaks分析"
+
+
def test_new_analysis_decision_discards_main_agent_workflow_and_clarification() -> None:
decision = RouteDecision(
goal="new_analysis",
diff --git a/tests/unit/test_subfamily_prediction_plan.py b/tests/unit/test_subfamily_prediction_plan.py
index ff9f8f508..1e015d315 100755
--- a/tests/unit/test_subfamily_prediction_plan.py
+++ b/tests/unit/test_subfamily_prediction_plan.py
@@ -87,6 +87,63 @@ def test_subfamily_request_roles_do_not_promote_reference_to_target(monkeypatch)
}
+def test_contextual_gene_family_request_handles_explicit_multi_target_scope(monkeypatch):
+ main = _load_vue3_main()
+ monkeypatch.setattr(
+ main,
+ "_canonicalize_species_codes_for_job",
+ lambda _job_id, species: list(species),
+ )
+ monkeypatch.setattr(
+ main,
+ "_reconcile_confirmed_gene_family_species",
+ lambda *_args, **_kwargs: {"applicable": False, "conflicts": []},
+ )
+ monkeypatch.setattr(main, "_load_identify_species_selection", lambda _job_id: {})
+ monkeypatch.setattr(main, "_gene_family_from_config", lambda _path: "SPL")
+ monkeypatch.setattr(main, "_confirmed_subfamily_species", lambda *_args: [])
+ monkeypatch.setattr(
+ main,
+ "_extract_species_codes_from_text_for_job",
+ lambda *_args: [],
+ )
+ monkeypatch.setattr(
+ main,
+ "_subfamily_state_for_targets",
+ lambda *_args: {
+ "all_confirmed": False,
+ "by_species": {
+ "Ft": {"status": "not_classified"},
+ "At": {"status": "not_classified"},
+ },
+ "issues": [],
+ },
+ )
+ monkeypatch.setattr(
+ main,
+ "_validate_gene_family_stage_context",
+ lambda **_kwargs: {"valid": True, "issues": [], "evidence": {}},
+ )
+
+ contextual = main._resolve_contextual_gene_family_request(
+ "job-multi-target",
+ "执行基因家族分析",
+ requested_stages=["phylogeny"],
+ request_intent={
+ "gene_family": "SPL",
+ "target_species": ["Ft", "At"],
+ "reference_species": [],
+ "params": {
+ "targetSpecies": ["Ft", "At"],
+ "referenceSpecies": [],
+ },
+ },
+ )
+
+ assert contextual["targetSpecies"] == ["Ft", "At"]
+ assert contextual["needs_species_confirmation"] is False
+
+
def test_generic_target_reference_scope_does_not_guess_subfamily_from_stage_name():
scope = {
"scope_mode": "target_plus_reference",