feat(market-intel): add opportunity alert plan preview
All checks were successful
CD Pipeline / deploy (push) Successful in 1m2s

This commit is contained in:
OoO
2026-05-18 19:18:00 +08:00
parent b5511e818f
commit 9ed0805697
8 changed files with 457 additions and 21 deletions

View File

@@ -576,6 +576,24 @@
</div>
</div>
<div class="market-intel-panel" data-market-intel-opportunity-alert>
<div class="market-intel-preview-head">
<div>
<p class="market-intel-muted momo-mono mb-1">OPPORTUNITY / ALERT CANDIDATE</p>
<h2 class="market-intel-preview-title">機會威脅告警候選</h2>
</div>
<button class="market-intel-icon-button" type="button" title="重新整理機會威脅告警候選" data-market-intel-opportunity-alert-refresh>
<i class="fas fa-rotate-right" aria-hidden="true"></i>
</button>
</div>
<div class="market-intel-preview-meta" data-market-intel-opportunity-alert-meta>
<span class="market-intel-pill">loading</span>
</div>
<div data-market-intel-opportunity-alert-body>
<div class="market-intel-empty">讀取機會威脅告警候選中...</div>
</div>
</div>
<div class="market-intel-panel" data-market-intel-migration>
<div class="market-intel-preview-head">
<div>
@@ -650,10 +668,11 @@
const opportunityRoot = document.querySelector('[data-market-intel-opportunity]');
const opportunityScoringRoot = document.querySelector('[data-market-intel-opportunity-scoring]');
const opportunityEvidenceRoot = document.querySelector('[data-market-intel-opportunity-evidence]');
const opportunityAlertRoot = document.querySelector('[data-market-intel-opportunity-alert]');
const migrationRoot = document.querySelector('[data-market-intel-migration]');
const approvalRoot = document.querySelector('[data-market-intel-approval]');
const deployRoot = document.querySelector('[data-market-intel-deploy]');
if (!root && !writerRoot && !cliRoot && !dbProbeRoot && !seedDiffRoot && !legacyBridgeRoot && !mcpReadinessRoot && !mcpPreflightRoot && !mcpActivationRoot && !mcpFetchGateRoot && !schedulerRoot && !matchReviewRoot && !opportunityRoot && !opportunityScoringRoot && !opportunityEvidenceRoot && !migrationRoot && !approvalRoot && !deployRoot) return;
if (!root && !writerRoot && !cliRoot && !dbProbeRoot && !seedDiffRoot && !legacyBridgeRoot && !mcpReadinessRoot && !mcpPreflightRoot && !mcpActivationRoot && !mcpFetchGateRoot && !schedulerRoot && !matchReviewRoot && !opportunityRoot && !opportunityScoringRoot && !opportunityEvidenceRoot && !opportunityAlertRoot && !migrationRoot && !approvalRoot && !deployRoot) return;
const meta = root ? root.querySelector('[data-market-intel-preview-meta]') : null;
const body = root ? root.querySelector('[data-market-intel-preview-body]') : null;
@@ -715,6 +734,10 @@
const opportunityEvidenceBody = opportunityEvidenceRoot ? opportunityEvidenceRoot.querySelector('[data-market-intel-opportunity-evidence-body]') : null;
const opportunityEvidenceRefresh = opportunityEvidenceRoot ? opportunityEvidenceRoot.querySelector('[data-market-intel-opportunity-evidence-refresh]') : null;
const opportunityEvidenceEndpoint = "{{ url_for('market_intel.market_intel_opportunity_evidence_plan') }}";
const opportunityAlertMeta = opportunityAlertRoot ? opportunityAlertRoot.querySelector('[data-market-intel-opportunity-alert-meta]') : null;
const opportunityAlertBody = opportunityAlertRoot ? opportunityAlertRoot.querySelector('[data-market-intel-opportunity-alert-body]') : null;
const opportunityAlertRefresh = opportunityAlertRoot ? opportunityAlertRoot.querySelector('[data-market-intel-opportunity-alert-refresh]') : null;
const opportunityAlertEndpoint = "{{ url_for('market_intel.market_intel_opportunity_alert_plan') }}";
const migrationMeta = migrationRoot ? migrationRoot.querySelector('[data-market-intel-migration-meta]') : null;
const migrationBody = migrationRoot ? migrationRoot.querySelector('[data-market-intel-migration-body]') : null;
const migrationRefresh = migrationRoot ? migrationRoot.querySelector('[data-market-intel-migration-refresh]') : null;
@@ -1958,6 +1981,129 @@
}
};
const renderOpportunityAlertMeta = data => {
opportunityAlertMeta.innerHTML = [
`mode=${data.mode || 'unknown'}`,
`ready=${data.ready_for_alert_candidates ? 'yes' : 'no'}`,
`channels=${data.channel_count || 0}`,
`telegram=${data.telegram_dispatched ? 'sent' : 'blocked'}`,
`llm=${data.llm_call_executed ? 'called' : 'blocked'}`
].map(item => `<span class="market-intel-pill">${escapeHtml(item)}</span>`).join('');
};
const renderOpportunityAlertBody = data => {
const blockers = (data.blocked_reasons || []).join(' / ');
const checks = Object.entries(data.gate_checks || {});
const channels = data.channels || [];
const gates = data.alert_gates || [];
const throttle = data.throttle_policy || {};
const sequence = data.operator_sequence || [];
const payload = data.payload_contract || {};
const renderCheck = ([name, passed]) => `
<div class="market-intel-check">
<div>
<strong>${escapeHtml(name)}</strong>
</div>
<span>${passed ? 'PASS' : 'BLOCK'}</span>
</div>
`;
const renderChannel = item => `
<article class="market-intel-operation">
<strong>${escapeHtml(item.key)}</strong>
<small>${escapeHtml(item.label)} / minimum=${escapeHtml(item.minimum_level)}</small>
<small>approval=${item.requires_operator_approval ? 'required' : 'not_required'} / target=${escapeHtml(item.dispatch_target)}</small>
</article>
`;
opportunityAlertBody.innerHTML = `
<div class="market-intel-empty mb-3">目前只定義告警候選 gate不建立 alert queue、不派送 Telegram、不呼叫 LLM、不寫 DB。${blockers ? `阻擋:${escapeHtml(blockers)}` : ''}</div>
<div class="market-intel-empty mb-3">dedupe=${escapeHtml(throttle.dedupe_window_hours || 'n/a')}h / telegram_daily=${escapeHtml(throttle.max_telegram_candidates_per_day || 0)} / ai_digest=${escapeHtml(throttle.max_ai_briefing_items_per_digest || 0)}</div>
<div class="market-intel-deploy-grid">
<div data-market-intel-opportunity-alert-checks>
<p class="market-intel-deploy-section-title">ALERT GATES</p>
<div class="market-intel-check-list">${
checks.length
? checks.map(renderCheck).join('')
: '<div class="market-intel-empty">尚未提供告警 gate。</div>'
}</div>
</div>
<div data-market-intel-opportunity-alert-channels>
<p class="market-intel-deploy-section-title">CHANNELS</p>
<div class="market-intel-operation-list">${
channels.length
? channels.map(renderChannel).join('')
: '<div class="market-intel-empty">尚未提供 channel。</div>'
}</div>
</div>
<div data-market-intel-opportunity-alert-escalation>
<p class="market-intel-deploy-section-title">ESCALATION</p>
<div class="market-intel-check-list">${
gates.length
? gates.map(item => `
<div class="market-intel-check">
<div>
<strong>${escapeHtml(item.key)}</strong>
<small>${escapeHtml(item.label)}</small>
</div>
<span>${escapeHtml((item.required_for || []).join('/'))}</span>
</div>
`).join('')
: '<div class="market-intel-empty">尚未提供升級 gate。</div>'
}</div>
</div>
<div data-market-intel-opportunity-alert-payload>
<p class="market-intel-deploy-section-title">PAYLOAD CONTRACT</p>
<div class="market-intel-check-list">
<div class="market-intel-check">
<div>
<strong>required_fields</strong>
<small>${escapeHtml((payload.required_fields || []).join(' / '))}</small>
</div>
<span>REQUIRED</span>
</div>
<div class="market-intel-check">
<div>
<strong>forbidden_fields</strong>
<small>${escapeHtml((payload.forbidden_fields || []).join(' / '))}</small>
</div>
<span>BLOCKED</span>
</div>
</div>
</div>
<div data-market-intel-opportunity-alert-sequence>
<p class="market-intel-deploy-section-title">OPERATOR SEQUENCE</p>
<div class="market-intel-check-list">${
sequence.length
? sequence.map((item, index) => `
<div class="market-intel-check">
<div>
<strong>${escapeHtml(`step_${index + 1}`)}</strong>
<small>${escapeHtml(item)}</small>
</div>
<span>REQUIRED</span>
</div>
`).join('')
: '<div class="market-intel-empty">尚未提供操作順序。</div>'
}</div>
</div>
</div>
`;
};
const loadOpportunityAlert = async () => {
if (!opportunityAlertMeta || !opportunityAlertBody) return;
opportunityAlertBody.innerHTML = '<div class="market-intel-empty">讀取機會威脅告警候選中...</div>';
try {
const response = await fetch(opportunityAlertEndpoint, { credentials: 'same-origin' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
renderOpportunityAlertMeta(data);
renderOpportunityAlertBody(data);
} catch (error) {
opportunityAlertMeta.innerHTML = '<span class="market-intel-pill">error</span>';
opportunityAlertBody.innerHTML = `<div class="market-intel-empty">機會威脅告警候選讀取失敗:${escapeHtml(error.message)}</div>`;
}
};
const renderMigrationMeta = data => {
const seedWriter = data.command_plan && data.command_plan.seed_writer_command
? data.command_plan.seed_writer_command
@@ -2237,6 +2383,9 @@
if (opportunityEvidenceRefresh) {
opportunityEvidenceRefresh.addEventListener('click', loadOpportunityEvidence);
}
if (opportunityAlertRefresh) {
opportunityAlertRefresh.addEventListener('click', loadOpportunityAlert);
}
if (migrationRefresh) {
migrationRefresh.addEventListener('click', loadMigration);
}
@@ -2261,6 +2410,7 @@
loadOpportunity();
loadOpportunityScoring();
loadOpportunityEvidence();
loadOpportunityAlert();
loadMigration();
loadApproval();
loadDeploy();