feat: Add evaluation hooks, skill adaptation and team pipeline config

- Add EvaluationHook for post-execution agent evaluation
- Add SkillAdaptationHook for dynamic skill adaptation
- Add team/ directory with team coordination logic
- Add TEAM_PIPELINE.yaml for smoke_fullstack pipeline config
- Update RuntimeView, TraderView and RuntimeSettingsPanel UI
- Add runtimeApi and websocket services
- Add runtime_state.json to smoke_fullstack state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 18:52:12 +08:00
parent f4a2b7f3af
commit 4b5ac86b83
87 changed files with 5042 additions and 744 deletions

View File

@@ -41,6 +41,7 @@ export default function AgentCard({ agent, onClose, isClosing }) {
const rankMedal = agent.rank ? getRankMedal(agent.rank) : null;
const isPortfolioManager = agent.id === 'portfolio_manager';
const isRiskManager = agent.id === 'risk_manager';
const isValuationAnalyst = agent.id === 'valuation_analyst';
const displayName = isPortfolioManager ? '团队' : agent.name;
// Get model icon configuration
@@ -483,6 +484,78 @@ export default function AgentCard({ agent, onClose, isClosing }) {
</div>
</div>
)}
{/* Valuation Results Card - Only show for valuation_analyst */}
{isValuationAnalyst && agent.signals && agent.signals.length > 0 && (
<div style={{
display: 'flex',
gap: 6,
padding: '8px 12px',
background: '#f5f5f5',
border: '2px solid #7B1FA2'
}}>
<div style={{
fontSize: 10,
fontWeight: 700,
color: '#7B1FA2',
minWidth: 80,
textAlign: 'center'
}}>
估值分析
</div>
{agent.signals
.filter(signal => signal && signal.intrinsic_value != null)
.slice(0, 5)
.map((signal, idx) => {
const fairValue = signal.fair_value_range;
const hasValuation = signal.intrinsic_value || fairValue;
if (!hasValuation) return null;
return (
<div key={idx} style={{
fontSize: 9,
fontFamily: '"Courier New", monospace',
padding: '6px 8px',
background: '#ffffff',
border: '1px solid #7B1FA2',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 2,
minWidth: 90
}}>
<div style={{ fontWeight: 700, color: '#333' }}>
{signal.ticker}
</div>
{signal.intrinsic_value && (
<div style={{ color: '#00C853', fontSize: 10 }}>
内在 ${signal.intrinsic_value.toFixed(2)}
</div>
)}
{signal.value_gap_pct != null && (
<div style={{
color: signal.value_gap_pct > 0 ? '#00C853' : '#FF1744',
fontSize: 9
}}>
{signal.value_gap_pct > 0 ? '+' : ''}{signal.value_gap_pct.toFixed(1)}%
</div>
)}
{fairValue && (
<div style={{ fontSize: 8, color: '#666' }}>
区间 ${fairValue.bear?.toFixed(0) || '?'}-
${fairValue.bull?.toFixed(0) || '?'}
</div>
)}
{signal.valuation_methods && signal.valuation_methods.length > 0 && (
<div style={{ fontSize: 7, color: '#999' }}>
{signal.valuation_methods[0]}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>

View File

@@ -14,6 +14,11 @@ export default function RuntimeSettingsPanel({
initialCash,
marginRequirement,
enableMemory,
mode,
pollInterval,
startDate,
endDate,
enableMock,
watchlistSymbols,
watchlistInputValue,
watchlistSuggestions,
@@ -26,6 +31,11 @@ export default function RuntimeSettingsPanel({
onInitialCashChange,
onMarginRequirementChange,
onEnableMemoryChange,
onModeChange,
onPollIntervalChange,
onStartDateChange,
onEndDateChange,
onEnableMockChange,
onWatchlistInputChange,
onWatchlistInputKeyDown,
onWatchlistAdd,
@@ -405,6 +415,101 @@ export default function RuntimeSettingsPanel({
/>
<span style={{ fontSize: '11px', color: '#111111', fontWeight: 700 }}>启用长期记忆</span>
</label>
<label style={{ display: 'grid', gap: 4 }}>
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>运行模式</span>
<select
value={mode}
onChange={(e) => onModeChange(e.target.value)}
style={{
padding: '9px 10px',
borderRadius: 8,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
fontSize: '12px'
}}
>
<option value="live">实盘模式 (Live)</option>
<option value="backtest">回测模式 (Backtest)</option>
</select>
</label>
{mode === 'backtest' && (
<>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<label style={{ display: 'grid', gap: 4 }}>
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>回测开始日期</span>
<input
type="date"
value={startDate}
onChange={(e) => onStartDateChange(e.target.value)}
style={{
padding: '9px 10px',
borderRadius: 8,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
fontSize: '12px',
fontFamily: '"Courier New", monospace'
}}
/>
</label>
<label style={{ display: 'grid', gap: 4 }}>
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>回测结束日期</span>
<input
type="date"
value={endDate}
onChange={(e) => onEndDateChange(e.target.value)}
style={{
padding: '9px 10px',
borderRadius: 8,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
fontSize: '12px',
fontFamily: '"Courier New", monospace'
}}
/>
</label>
</div>
</>
)}
<label style={{ display: 'grid', gap: 4 }}>
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>轮询间隔()</span>
<input
type="number"
min="1"
max="300"
value={pollInterval}
onChange={(e) => onPollIntervalChange(e.target.value)}
style={{
padding: '9px 10px',
borderRadius: 8,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
fontSize: '12px',
fontFamily: '"Courier New", monospace'
}}
/>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 2 }}>
<input
type="checkbox"
checked={enableMock}
onChange={(e) => onEnableMockChange(e.target.checked)}
style={{
width: 16,
height: 16,
accentColor: '#0D47A1',
cursor: 'pointer'
}}
/>
<span style={{ fontSize: '11px', color: '#111111', fontWeight: 700 }}>启用模拟数据 (Mock)</span>
</label>
</div>
<div style={{

View File

@@ -67,6 +67,112 @@ function resolveApprovalTone(approval) {
return { border: '#D1D5DB', bg: '#FCFCFC', text: '#374151', badgeBg: '#E5E7EB' };
}
// 评估指标配置
const METRICS_CONFIG = {
hit_rate: {
label: '命中率',
icon: '◎',
goodThreshold: 0.7,
warnThreshold: 0.5
},
risk_violation: {
label: '风控违例',
icon: '⚠',
goodThreshold: 0.1,
warnThreshold: 0.3,
inverted: true // 值越小越好
},
decision_latency: {
label: '决策延迟',
icon: '◷',
goodThreshold: 5000,
warnThreshold: 10000,
inverted: true,
unit: 'ms'
},
signal_consistency: {
label: '信号一致性',
icon: '≡',
goodThreshold: 0.8,
warnThreshold: 0.6
}
};
function getMetricColor(value, config) {
if (value === null || value === undefined || isNaN(value)) {
return { color: '#9CA3AF', bg: '#F9FAFB', arrow: '-' };
}
const isInverted = config.inverted;
const effectiveValue = isInverted ? value : value;
const effectiveGood = isInverted ? config.goodThreshold : config.goodThreshold;
const effectiveWarn = isInverted ? config.warnThreshold : config.warnThreshold;
if (effectiveValue <= effectiveGood) {
return { color: '#059669', bg: '#ECFDF5', arrow: '↑' };
} else if (effectiveValue <= effectiveWarn) {
return { color: '#D97706', bg: '#FFFBEB', arrow: '→' };
} else {
return { color: '#DC2626', bg: '#FEF2F2', arrow: '↓' };
}
}
function MetricBadge({ metricKey, value }) {
const config = METRICS_CONFIG[metricKey];
if (!config) return null;
const displayValue = value !== null && value !== undefined && !isNaN(value)
? (config.unit === 'ms' ? `${Math.round(value)}${config.unit}` : `${(value * 100).toFixed(1)}%`)
: '-';
const { color, bg, arrow } = getMetricColor(value, config);
return (
<div style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
padding: '2px 6px',
background: bg,
border: `1px solid ${color}`,
borderRadius: 4,
fontSize: 10,
fontWeight: 600,
color: color
}}>
<span>{config.icon}</span>
<span>{displayValue}</span>
<span style={{ marginLeft: 2 }}>{arrow}</span>
</div>
);
}
function AgentMetricsPanel({ agent }) {
const extensions = agent.extensions || {};
const metrics = [
{ key: 'hit_rate', value: extensions.hit_rate },
{ key: 'risk_violation', value: extensions.risk_violation },
{ key: 'decision_latency', value: extensions.decision_latency },
{ key: 'signal_consistency', value: extensions.signal_consistency }
];
const hasMetrics = metrics.some(m => m.value !== null && m.value !== undefined && !isNaN(m.value));
if (!hasMetrics) return null;
return (
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: 6,
marginTop: 8,
paddingTop: 8,
borderTop: '1px dashed #E5E7EB'
}}>
{metrics.map(({ key, value }) => (
<MetricBadge key={key} metricKey={key} value={value} />
))}
</div>
);
}
function sectionTitle(label, action = null) {
return (
<div className="section-header" style={{ marginBottom: 0 }}>
@@ -315,6 +421,131 @@ export default function RuntimeView() {
)}
</section>
<section className="section" style={{ display: 'grid', gap: 12, marginBottom: 0 }}>
{sectionTitle('团队协作状态')}
<div style={{
border: '1px solid #000000',
background: '#FAFAFA',
padding: 12,
display: 'grid',
gap: 12
}}>
{/* 自动广播状态 */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 14 }}>&#128227;</span>
<span style={{ fontSize: 12, fontWeight: 600, color: '#111111' }}>自动广播</span>
</div>
<span style={{
padding: '4px 10px',
fontSize: 10,
fontWeight: 700,
textTransform: 'uppercase',
border: '1px solid #000000',
background: (runtimeState?.context?.auto_broadcast || runtimeState?.context?.team_config?.auto_broadcast)
? '#000000'
: '#FFFFFF',
color: (runtimeState?.context?.auto_broadcast || runtimeState?.context?.team_config?.auto_broadcast)
? '#FFFFFF'
: '#000000',
letterSpacing: '0.5px'
}}>
{(runtimeState?.context?.auto_broadcast || runtimeState?.context?.team_config?.auto_broadcast) ? '已启用' : '已关闭'}
</span>
</div>
{/* Fan-out Pipeline */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 14 }}>&#128101;</span>
<span style={{ fontSize: 12, fontWeight: 600, color: '#111111' }}>Fan-out Pipeline</span>
</div>
<span style={{
padding: '4px 10px',
fontSize: 11,
fontWeight: 700,
textTransform: 'uppercase',
border: '1px solid #2563EB',
background: '#EFF6FF',
color: '#2563EB',
letterSpacing: '0.5px'
}}>
{runtimeState?.context?.fanout_pipeline?.length || 0} Agents
</span>
</div>
{/* 活跃分析师列表 */}
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<span style={{ fontSize: 14 }}>&#128200;</span>
<span style={{ fontSize: 12, fontWeight: 600, color: '#111111' }}>活跃分析师</span>
</div>
{(() => {
const activeAnalysts = (runtimeState?.agents || []).filter(
(agent) => agent.status && agent.status !== 'idle' && agent.status !== 'stopped'
);
if (activeAnalysts.length === 0) {
return (
<div style={{
padding: 10,
border: '1px dashed #999999',
background: '#FAFAFA',
fontSize: 11,
color: '#9CA3AF'
}}>
当前无活跃分析师
</div>
);
}
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{activeAnalysts.map((agent) => (
<span
key={agent.agent_id}
style={{
padding: '6px 10px',
fontSize: 10,
fontWeight: 600,
border: '1px solid #059669',
background: '#ECFDF5',
color: '#059669',
textTransform: 'uppercase',
letterSpacing: '0.3px'
}}
>
{agent.agent_id}
</span>
))}
</div>
);
})()}
</div>
{/* 团队配置详情 */}
{runtimeState?.context?.team_config && (
<div style={{ marginTop: 4 }}>
<div style={{ fontSize: 10, color: '#6B7280', textTransform: 'uppercase', marginBottom: 6 }}>
团队配置
</div>
<pre style={{
margin: 0,
padding: 8,
background: '#FFFFFF',
border: '1px solid #E5E7EB',
fontSize: 10,
lineHeight: 1.5,
color: '#374151',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: '"Courier New", monospace'
}}>
{JSON.stringify(runtimeState.context.team_config, null, 2)}
</pre>
</div>
)}
</div>
</section>
<section className="section" style={{ display: 'grid', gap: 12, marginBottom: 0 }}>
{sectionTitle('待审批请求')}
<div style={{
@@ -479,6 +710,7 @@ export default function RuntimeView() {
<div style={{ fontSize: 10, color: '#6B7280', lineHeight: 1.5 }}>
更新时间: {agent.last_updated}
</div>
<AgentMetricsPanel agent={agent} />
</div>
)) : (
<div style={{ fontSize: 12, color: '#9CA3AF' }}>暂无 agent 状态</div>

View File

@@ -11,6 +11,8 @@ import ExplainSimilarDaysSection from './explain/ExplainSimilarDaysSection';
import ExplainSignalsSection from './explain/ExplainSignalsSection';
import ExplainSummarySection from './explain/ExplainSummarySection';
import ExplainTradesSection from './explain/ExplainTradesSection';
import ExplainInsiderSection from './explain/ExplainInsiderSection';
import ExplainTechnicalSection from './explain/ExplainTechnicalSection';
import { EVENT_CATEGORY_META, eventDateKey } from './explain/explainUtils';
import useExplainModel from './explain/useExplainModel';
import { formatDateTime, formatNumber, formatTickerPrice } from '../utils/formatters';
@@ -28,9 +30,13 @@ export default function StockExplainView({
selectedHistorySource,
explainEventsSnapshot,
newsSnapshot,
insiderTradesSnapshot,
technicalIndicatorsSnapshot,
onRequestRangeExplain,
onRequestNewsForDate,
onRequestStory,
onRequestInsiderTrades,
onRequestTechnicalIndicators,
currentDate,
onRequestSimilarDays,
onRequestStockEnrich
@@ -49,6 +55,8 @@ export default function StockExplainView({
const [isMaintenanceOpen, setIsMaintenanceOpen] = useState(false);
const [isStoryOpen, setIsStoryOpen] = useState(false);
const [isTradesOpen, setIsTradesOpen] = useState(false);
const [isInsiderOpen, setIsInsiderOpen] = useState(false);
const [isTechnicalOpen, setIsTechnicalOpen] = useState(true);
const [isSimilarDaysOpen, setIsSimilarDaysOpen] = useState(false);
const [enrichStartDate, setEnrichStartDate] = useState('');
const [enrichEndDate, setEnrichEndDate] = useState('');
@@ -163,6 +171,16 @@ export default function StockExplainView({
onRequestSimilarDays(selectedSymbol, selectedEventDate);
}, [onRequestSimilarDays, selectedEventDate, selectedSimilarDays, selectedSymbol]);
useEffect(() => {
if (!selectedSymbol || !onRequestTechnicalIndicators) {
return;
}
if (technicalIndicatorsSnapshot) {
return;
}
onRequestTechnicalIndicators(selectedSymbol);
}, [selectedSymbol, onRequestTechnicalIndicators, technicalIndicatorsSnapshot]);
useEffect(() => {
if (!selectedRangeWindow || !selectedSymbol || !onRequestRangeExplain) {
return;
@@ -368,6 +386,21 @@ export default function StockExplainView({
onToggle={() => setIsTradesOpen((prev) => !prev)}
/>
<ExplainInsiderSection
insiderTrades={insiderTradesSnapshot?.trades || []}
selectedSymbol={selectedSymbol}
isOpen={isInsiderOpen}
onToggle={() => setIsInsiderOpen((prev) => !prev)}
onRequest={onRequestInsiderTrades}
/>
<ExplainTechnicalSection
technicalIndicators={technicalIndicatorsSnapshot}
selectedSymbol={selectedSymbol}
isOpen={isTechnicalOpen}
onToggle={() => setIsTechnicalOpen((prev) => !prev)}
/>
<ExplainMentionsSection
recentMentions={recentMentions}
isOpen={isMentionsPanelOpen}

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import JSZip from 'jszip';
import { getModelIcon, getShortModelName } from '../utils/modelIcons';
export default function TraderView({
@@ -34,10 +35,14 @@ export default function TraderView({
onSkillToggle,
onWorkspaceFileChange,
onWorkspaceDraftChange,
onWorkspaceFileSave
onWorkspaceFileSave,
onUploadExternalSkill
}) {
const [expandedSkillKey, setExpandedSkillKey] = useState(null);
const [newLocalSkillName, setNewLocalSkillName] = useState('');
const [externalSkillFile, setExternalSkillFile] = useState(null);
const [isExternalSkillChecking, setIsExternalSkillChecking] = useState(false);
const [externalSkillCheck, setExternalSkillCheck] = useState({ type: null, text: '' });
const [isSkillPickerOpen, setIsSkillPickerOpen] = useState(false);
const selectedAgent = useMemo(
@@ -59,6 +64,50 @@ export default function TraderView({
const installedSkills = selectedAgentSkills.filter((item) => item.status !== 'available');
const availableSkills = selectedAgentSkills.filter((item) => item.status === 'available');
const validateExternalSkillZip = async (file) => {
if (!(file instanceof File)) {
setExternalSkillCheck({ type: 'error', text: '请选择 zip 文件' });
return false;
}
if (!file.name.toLowerCase().endsWith('.zip')) {
setExternalSkillCheck({ type: 'error', text: '仅支持 .zip 文件' });
return false;
}
setIsExternalSkillChecking(true);
setExternalSkillCheck({ type: null, text: '' });
try {
const zip = await JSZip.loadAsync(file);
const entries = Object.keys(zip.files);
const skillFilePath = entries.find((entry) => {
const item = zip.files[entry];
return !item.dir && /(^|\/)SKILL\.md$/i.test(entry);
});
if (!skillFilePath) {
setExternalSkillCheck({
type: 'error',
text: '压缩包中未检测到 SKILL.md请检查目录结构'
});
return false;
}
setExternalSkillCheck({
type: 'success',
text: `预检通过,检测到: ${skillFilePath}`
});
return true;
} catch (error) {
setExternalSkillCheck({
type: 'error',
text: `无法解析 zip: ${error?.message || '未知错误'}`
});
return false;
} finally {
setIsExternalSkillChecking(false);
}
};
return (
<div style={{
height: '100%',
@@ -679,6 +728,85 @@ export default function TraderView({
</div>
</div>
<div style={{
border: '1px solid #E5EAF1',
borderRadius: 12,
background: '#FCFDFE',
padding: 14,
display: 'grid',
gap: 10
}}>
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>上传外部技能包</div>
<div style={{ fontSize: 11, color: '#6B7280' }}>
支持上传 .zip包内需包含一个技能目录及 SKILL.md
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<input
type="file"
accept=".zip,application/zip"
onChange={async (e) => {
const file = e.target.files?.[0] || null;
setExternalSkillFile(file);
if (!file) {
setExternalSkillCheck({ type: null, text: '' });
return;
}
await validateExternalSkillZip(file);
}}
style={{
flex: 1,
minWidth: 220,
padding: '6px 8px',
borderRadius: 8,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
fontSize: 11
}}
/>
<button
type="button"
onClick={async () => {
if (!onUploadExternalSkill || !externalSkillFile) {
return;
}
const valid = await validateExternalSkillZip(externalSkillFile);
if (!valid) {
return;
}
await onUploadExternalSkill(externalSkillFile);
setExternalSkillFile(null);
setExternalSkillCheck({ type: null, text: '' });
}}
disabled={!isConnected || !externalSkillFile || isExternalSkillChecking || externalSkillCheck.type === 'error'}
style={{
padding: '8px 12px',
borderRadius: 8,
border: '1px solid #1565C0',
background: isConnected && externalSkillFile && !isExternalSkillChecking && externalSkillCheck.type !== 'error' ? '#EFF6FF' : '#E5E7EB',
color: '#1565C0',
fontSize: 11,
fontWeight: 700,
cursor: isConnected && externalSkillFile && !isExternalSkillChecking && externalSkillCheck.type !== 'error' ? 'pointer' : 'not-allowed',
whiteSpace: 'nowrap'
}}
>
{isExternalSkillChecking ? '预检中...' : '上传并安装'}
</button>
</div>
{externalSkillCheck.text ? (
<div
style={{
fontSize: 11,
color: externalSkillCheck.type === 'success' ? '#00C853' : '#FF5252',
fontFamily: '"Courier New", monospace'
}}
>
{externalSkillCheck.text}
</div>
) : null}
</div>
<div style={{
border: '1px solid #E5EAF1',
borderRadius: 12,