Recover the next verified Phase 4 improvements without waiting on team teardown

The team run reached a quiescent state with no in-progress work but still had pending bookkeeping tasks, so the next safe step was to pull only the newly verified commits into main. This batch adds a frontend contract-view audit guard and the reusable contract cue UI so degradation and data-quality states are visible where the contract-first payload already exposes them.

Constraint: The team snapshot still has pending bookkeeping tasks, so do not treat it as terminal cleanup-ready
Rejected: Wait for terminal team shutdown before any further recovery | delays low-risk verified changes even though no workers are actively modifying code
Rejected: Pull the entire worker-3 checkpoint verbatim | unnecessary risk of reintroducing snapshot-only churn when only the frontend files are needed
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep frontend contract cue rendering centralized; avoid reintroducing page-specific ad-hoc degradation badges
Tested: python -m pytest web_dashboard/backend/tests/test_frontend_contract_view_audit.py web_dashboard/backend/tests/test_api_smoke.py web_dashboard/backend/tests/test_services_migration.py -q
Tested: npm run build (web_dashboard/frontend)
Not-tested: manual browser interaction with the new ContractCues component
Not-tested: final OMX team terminal shutdown path
This commit is contained in:
陈少杰 2026-04-14 01:19:01 +08:00
parent 11cbb7ce85
commit a245915f4e
5 changed files with 103 additions and 18 deletions

View File

@ -0,0 +1,34 @@
from pathlib import Path
import re
FRONTEND_SRC = Path(__file__).resolve().parents[2] / "frontend" / "src"
CONTRACT_VIEW = FRONTEND_SRC / "utils" / "contractView.js"
LEGACY_TOP_LEVEL_FIELDS = ("decision", "confidence", "quant_signal", "llm_signal")
DIRECT_FIELD_ACCESS = re.compile(r"(?:\?|)\.\s*(decision|confidence|quant_signal|llm_signal)\b")
def test_contract_view_reads_contract_result_before_compat_fields():
source = CONTRACT_VIEW.read_text()
assert "getResult(payload).decision ?? getCompat(payload).decision" in source
assert "getResult(payload).confidence ?? getCompat(payload).confidence" in source
assert "getResult(payload).signals?.quant?.rating ?? getCompat(payload).quant_signal" in source
assert "getResult(payload).signals?.llm?.rating ?? getCompat(payload).llm_signal" in source
def test_frontend_consumers_use_contract_view_helpers_for_signal_fields():
offenders: list[str] = []
for path in sorted(FRONTEND_SRC.rglob("*.js")) + sorted(FRONTEND_SRC.rglob("*.jsx")):
if path == CONTRACT_VIEW:
continue
matches = {
match.group(1)
for match in DIRECT_FIELD_ACCESS.finditer(path.read_text())
if match.group(1) in LEGACY_TOP_LEVEL_FIELDS
}
if matches:
offenders.append(f"{path.relative_to(FRONTEND_SRC)} -> {sorted(matches)}")
assert offenders == []

View File

@ -0,0 +1,51 @@
import {
getDataQualitySummary,
getDegradationSummary,
isDegradedPayload,
} from '../utils/contractView'
const cueStyle = {
display: 'inline-flex',
alignItems: 'center',
padding: '2px 8px',
borderRadius: 'var(--radius-pill)',
background: 'var(--hold-dim)',
color: 'var(--hold)',
fontSize: 11,
fontWeight: 600,
lineHeight: 1.4,
}
function formatCode(code) {
return String(code).replace(/_/g, ' ')
}
export default function ContractCues({ payload, style = null }) {
const dataQuality = getDataQualitySummary(payload)
const degradation = getDegradationSummary(payload)
const primaryReason = degradation?.reason_codes?.[0] || null
const dataQualityState = dataQuality?.state || null
const items = []
if (isDegradedPayload(payload)) {
items.push(primaryReason && primaryReason !== dataQualityState
? `降级 · ${formatCode(primaryReason)}`
: '降级结果')
}
if (dataQualityState) {
items.push(`数据 · ${formatCode(dataQualityState)}`)
}
if (items.length === 0) return null
return (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', ...style }}>
{items.map((item) => (
<span key={item} style={cueStyle}>
{item}
</span>
))}
</div>
)
}

View File

@ -2,11 +2,10 @@ import { useState, useEffect, useRef, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { Card, Progress, Badge, Empty, Button, Result, message } from 'antd' import { Card, Progress, Badge, Empty, Button, Result, message } from 'antd'
import DecisionBadge from '../components/DecisionBadge' import DecisionBadge from '../components/DecisionBadge'
import ContractCues from '../components/ContractCues'
import { StatusIcon } from '../components/StatusIcon' import { StatusIcon } from '../components/StatusIcon'
import { import {
getConfidence, getConfidence,
getDataQualitySummary,
getDegradationSummary,
getDecision, getDecision,
getDisplayDate, getDisplayDate,
getErrorMessage, getErrorMessage,
@ -36,8 +35,6 @@ export default function AnalysisMonitor() {
const quantSignal = getQuantSignal(task) const quantSignal = getQuantSignal(task)
const confidence = getConfidence(task) const confidence = getConfidence(task)
const displayDate = getDisplayDate(task) const displayDate = getDisplayDate(task)
const dataQuality = getDataQualitySummary(task)
const degradation = getDegradationSummary(task)
const errorMessage = getErrorMessage(task) const errorMessage = getErrorMessage(task)
const fetchInitialState = useCallback(async () => { const fetchInitialState = useCallback(async () => {
@ -196,16 +193,7 @@ export default function AnalysisMonitor() {
)} )}
</div> </div>
)} )}
{dataQuality?.state && ( <ContractCues payload={task} style={{ marginBottom: 12 }} />
<div style={{ marginBottom: 12, fontSize: 'var(--text-sm)', color: 'var(--hold)' }}>
数据质量: <strong>{dataQuality.state}</strong>
</div>
)}
{degradation?.degraded && degradation?.reason_codes?.length > 0 && (
<div style={{ marginBottom: 12, fontSize: 'var(--text-sm)', color: 'var(--hold)' }}>
降级原因: <strong>{degradation.reason_codes.join(', ')}</strong>
</div>
)}
{errorMessage && ( {errorMessage && (
<div style={{ marginBottom: 12, fontSize: 'var(--text-sm)', color: 'var(--sell)' }}> <div style={{ marginBottom: 12, fontSize: 'var(--text-sm)', color: 'var(--sell)' }}>
错误: {errorMessage} 错误: {errorMessage}

View File

@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useMemo } from 'react' import { useState, useEffect, useCallback, useMemo } from 'react'
import { Table, Button, Progress, Result, Card, message, Popconfirm, Tooltip } from 'antd' import { Table, Button, Progress, Result, Card, message, Popconfirm, Tooltip } from 'antd'
import { DeleteOutlined, CopyOutlined, SyncOutlined } from '@ant-design/icons' import { DeleteOutlined, CopyOutlined, SyncOutlined } from '@ant-design/icons'
import ContractCues from '../components/ContractCues'
import DecisionBadge from '../components/DecisionBadge' import DecisionBadge from '../components/DecisionBadge'
import { StatusIcon, StatusTag } from '../components/StatusIcon' import { StatusIcon, StatusTag } from '../components/StatusIcon'
import { getDecision, getErrorMessage } from '../utils/contractView' import { getDecision, getErrorMessage } from '../utils/contractView'
@ -107,8 +108,13 @@ export default function BatchManager() {
{ {
title: '决策', title: '决策',
key: 'decision', key: 'decision',
width: 80, width: 180,
render: (_, record) => <DecisionBadge decision={getDecision(record)} />, render: (_, record) => (
<div>
<DecisionBadge decision={getDecision(record)} />
<ContractCues payload={record} style={{ marginTop: 6 }} />
</div>
),
}, },
{ {
title: '任务ID', title: '任务ID',

View File

@ -8,6 +8,7 @@ import {
DownloadOutlined, SyncOutlined, AccountBookOutlined, DownloadOutlined, SyncOutlined, AccountBookOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { portfolioApi } from '../services/portfolioApi' import { portfolioApi } from '../services/portfolioApi'
import ContractCues from '../components/ContractCues'
import DecisionBadge from '../components/DecisionBadge' import DecisionBadge from '../components/DecisionBadge'
import { getDecision, getDisplayDate, isCompletedLikeStatus } from '../utils/contractView' import { getDecision, getDisplayDate, isCompletedLikeStatus } from '../utils/contractView'
@ -378,8 +379,13 @@ function RecommendationsTab() {
render: t => <span className="text-data">{t}</span> }, render: t => <span className="text-data">{t}</span> },
{ title: '名称', dataIndex: 'name', key: 'name', render: t => <span style={{ fontWeight: 500 }}>{t}</span> }, { title: '名称', dataIndex: 'name', key: 'name', render: t => <span style={{ fontWeight: 500 }}>{t}</span> },
{ {
title: '决策', key: 'decision', width: 80, title: '决策', key: 'decision', width: 180,
render: (_, record) => <DecisionBadge decision={getDecision(record)} />, render: (_, record) => (
<div>
<DecisionBadge decision={getDecision(record)} />
<ContractCues payload={record} style={{ marginTop: 6 }} />
</div>
),
}, },
{ title: '分析日期', key: 'analysis_date', width: 120, render: (_, record) => getDisplayDate(record) || '—' }, { title: '分析日期', key: 'analysis_date', width: 120, render: (_, record) => getDisplayDate(record) || '—' },
] ]