mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
* fix: polish run-flow topology layout and i18n * fix: prevent task panel layout squeeze * test: update run flow frontend coverage * fix: isolate market context run flow * fix: handle completed task flow refresh * feat: show running run-flow nodes * fix: show chip provider runs in flow * fix: stabilize stock run flow live updates * fix: refine run-flow history lookup and spacing * fix: update run-flow edge states and layout gaps * fix: polish run-flow task panel text
This commit is contained in:
@@ -698,6 +698,8 @@ def _format_sse_event(event_type: str, data: Dict[str, Any]) -> str:
|
||||
def _load_history_run_flow_by_query_id(
|
||||
query_id: str,
|
||||
*,
|
||||
code: Optional[str] = None,
|
||||
report_type: Optional[str] = None,
|
||||
fail_open: bool = False,
|
||||
) -> Optional[RunFlowSnapshot]:
|
||||
try:
|
||||
@@ -705,7 +707,11 @@ def _load_history_run_flow_by_query_id(
|
||||
from src.services.history_service import HistoryService
|
||||
|
||||
service = HistoryService(DatabaseManager.get_instance())
|
||||
return service.resolve_and_get_run_flow(query_id)
|
||||
return service.resolve_and_get_run_flow(
|
||||
query_id,
|
||||
code=code,
|
||||
report_type=report_type,
|
||||
)
|
||||
except Exception as e:
|
||||
if fail_open:
|
||||
logger.debug(
|
||||
@@ -740,8 +746,16 @@ def get_task_run_flow(task_id: str) -> RunFlowSnapshot:
|
||||
|
||||
if task:
|
||||
if task.status == TaskStatusEnum.COMPLETED:
|
||||
task_report_type = _history_report_type_for_task_flow(
|
||||
getattr(task, "report_type", None)
|
||||
)
|
||||
task_stock_code = _safe_task_flow_text(getattr(task, "stock_code", None), max_length=32)
|
||||
if task_report_type == "market_review":
|
||||
task_stock_code = "MARKET"
|
||||
history_snapshot = _load_history_run_flow_by_query_id(
|
||||
task_id,
|
||||
code=task_stock_code,
|
||||
report_type=task_report_type,
|
||||
fail_open=True,
|
||||
)
|
||||
if history_snapshot is not None:
|
||||
@@ -759,6 +773,31 @@ def get_task_run_flow(task_id: str) -> RunFlowSnapshot:
|
||||
raise api_error(404, "not_found", f"任务 {task_id} 不存在或已过期")
|
||||
|
||||
|
||||
def _safe_task_flow_text(value: Any, *, max_length: int) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
return text[:max_length]
|
||||
|
||||
|
||||
def _history_report_type_for_task_flow(value: Any) -> Optional[str]:
|
||||
text = _safe_task_flow_text(value, max_length=64)
|
||||
if text is None:
|
||||
return None
|
||||
normalized = text.lower().strip().replace("-", "_")
|
||||
aliases = {
|
||||
"detailed": "full",
|
||||
"simple": "simple",
|
||||
"full": "full",
|
||||
"brief": "brief",
|
||||
"market": "market_review",
|
||||
"market_review": "market_review",
|
||||
}
|
||||
return aliases.get(normalized, normalized)
|
||||
|
||||
|
||||
def _datetime_to_iso(value: Any) -> Optional[str]:
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
|
||||
@@ -57,7 +57,7 @@ class RunFlowNode(BaseModel):
|
||||
started_at: Optional[str] = Field(None, description="ISO timestamp when the node started")
|
||||
ended_at: Optional[str] = Field(None, description="ISO timestamp when the node ended")
|
||||
duration_ms: Optional[int] = Field(None, ge=0, description="Node duration in milliseconds")
|
||||
attempts: Optional[int] = Field(None, ge=1, description="Attempt count represented by this node")
|
||||
attempts: Optional[int] = Field(None, ge=0, description="Attempt count represented by this node")
|
||||
record_count: Optional[int] = Field(None, ge=0, description="Returned record count")
|
||||
message: Optional[str] = Field(None, description="Short sanitized status message")
|
||||
metadata: Dict[str, Any] = Field(default_factory=dict, description="Sanitized low-sensitivity metadata")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type React from 'react';
|
||||
import { useMemo, useId } from 'react';
|
||||
import { Badge, StatusDot, Tooltip } from '../common';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { Badge, StatusDot } from '../common';
|
||||
import { useUiLanguage } from '../../contexts/UiLanguageContext';
|
||||
import type { RunFlowEdge, RunFlowLane, RunFlowNode, RunFlowStatus } from '../../types/runFlow';
|
||||
import {
|
||||
@@ -13,12 +14,16 @@ import {
|
||||
RUN_FLOW_STATUS_STYLE,
|
||||
} from './utils';
|
||||
|
||||
type RunFlowT = ReturnType<typeof useUiLanguage>['t'];
|
||||
|
||||
interface RunFlowGraphProps {
|
||||
lanes: RunFlowLane[];
|
||||
nodes: RunFlowNode[];
|
||||
edges: RunFlowEdge[];
|
||||
selectedNodeId?: string | null;
|
||||
expandedNodeIds?: Set<string>;
|
||||
onSelectNode?: (node: RunFlowNode) => void;
|
||||
onToggleExpanded?: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
type PositionedNode = RunFlowNode & {
|
||||
@@ -28,9 +33,17 @@ type PositionedNode = RunFlowNode & {
|
||||
height: number;
|
||||
row: number;
|
||||
laneIndex: number;
|
||||
compact?: boolean;
|
||||
expandedGroupId?: string;
|
||||
};
|
||||
|
||||
interface DataSourceBlock {
|
||||
id: string;
|
||||
nodes: RunFlowNode[];
|
||||
}
|
||||
|
||||
type EdgePort = 'top' | 'right' | 'bottom' | 'left';
|
||||
type EdgeFocusLevel = 'none' | 'direct' | 'internal';
|
||||
|
||||
interface PortPoint {
|
||||
x: number;
|
||||
@@ -38,11 +51,30 @@ interface PortPoint {
|
||||
side: EdgePort;
|
||||
}
|
||||
|
||||
const LANE_WIDTH = 292;
|
||||
const NODE_WIDTH = 244;
|
||||
const NODE_HEIGHT = 124;
|
||||
interface LaneMetrics {
|
||||
laneWidth: number;
|
||||
nodeWidth: number;
|
||||
}
|
||||
|
||||
const DEFAULT_LANE_WIDTH = 260;
|
||||
const DEFAULT_NODE_WIDTH = 224;
|
||||
const LANE_METRICS: Record<string, LaneMetrics> = {
|
||||
entry: { laneWidth: 220, nodeWidth: 188 },
|
||||
data_source: { laneWidth: 292, nodeWidth: 244 },
|
||||
analysis: { laneWidth: 260, nodeWidth: 224 },
|
||||
artifact: { laneWidth: 220, nodeWidth: 188 },
|
||||
};
|
||||
const NODE_HEIGHT = 112;
|
||||
const COMPACT_NODE_HEIGHT = 96;
|
||||
const HEADER_HEIGHT = 42;
|
||||
const ROW_HEIGHT = 144;
|
||||
const ROW_HEIGHT = 140;
|
||||
const ENTRY_ROW_HEIGHT = 152;
|
||||
const ARTIFACT_ROW_HEIGHT = 152;
|
||||
const DATA_SOURCE_ATTEMPT_GAP = 42;
|
||||
const DATA_SOURCE_BLOCK_GAP = 40;
|
||||
const DATA_SOURCE_GROUP_X_PADDING = 18;
|
||||
const DATA_SOURCE_GROUP_TOP_PADDING = 18;
|
||||
const DATA_SOURCE_GROUP_BOTTOM_PADDING = 18;
|
||||
const LEFT_PADDING = 20;
|
||||
const TOP_PADDING = 18;
|
||||
const BOTTOM_PADDING = 30;
|
||||
@@ -55,6 +87,30 @@ const getEdgeStroke = (status: RunFlowStatus): string => {
|
||||
return 'hsl(var(--muted-text))';
|
||||
};
|
||||
|
||||
const getEdgeFocusRank = (level: EdgeFocusLevel): number => {
|
||||
if (level === 'internal') return 2;
|
||||
if (level === 'direct') return 1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const getEdgeStrokeWidth = (edge: RunFlowEdge, focusLevel: EdgeFocusLevel): number => {
|
||||
const isFallbackPath = edge.kind === 'fallback' || edge.kind === 'retry';
|
||||
if (focusLevel === 'internal') {
|
||||
return isFallbackPath ? 3.5 : 3;
|
||||
}
|
||||
if (focusLevel === 'direct') {
|
||||
return isFallbackPath ? 3 : 2.4;
|
||||
}
|
||||
return isFallbackPath ? 2.5 : 1.75;
|
||||
};
|
||||
|
||||
const getEdgeOpacity = (selectedNodeId: string | null | undefined, focusLevel: EdgeFocusLevel): number => {
|
||||
if (!selectedNodeId) return 0.68;
|
||||
if (focusLevel === 'internal') return 0.95;
|
||||
if (focusLevel === 'direct') return 0.82;
|
||||
return 0.18;
|
||||
};
|
||||
|
||||
const findAvailableRow = (occupiedRows: Set<number>, preferredRow: number): number => {
|
||||
const safePreferred = Math.max(0, preferredRow);
|
||||
for (let distance = 0; distance < 1000; distance += 1) {
|
||||
@@ -82,6 +138,43 @@ const getCenteredTrackOffset = (total: number, index: number, step = 12): number
|
||||
(index - (total - 1) / 2) * step
|
||||
);
|
||||
|
||||
const getLaneMetrics = (laneId: string): LaneMetrics => (
|
||||
LANE_METRICS[laneId] || { laneWidth: DEFAULT_LANE_WIDTH, nodeWidth: DEFAULT_NODE_WIDTH }
|
||||
);
|
||||
|
||||
const getLaneRowHeight = (laneId: string): number => (
|
||||
laneId === 'entry' ? ENTRY_ROW_HEIGHT : (laneId === 'artifact' ? ARTIFACT_ROW_HEIGHT : ROW_HEIGHT)
|
||||
);
|
||||
|
||||
const isExpandableNode = (node: RunFlowNode): boolean => node.metadata?.topologyGroup === 'provider_attempts';
|
||||
|
||||
const getEdgeLabel = (label: string | null | undefined, t: RunFlowT): string | null => {
|
||||
if (!label) return null;
|
||||
if (label === '调用') return t('runFlow.edgeLabel.invoke');
|
||||
if (label === '详情') return t('runFlow.edgeLabel.details');
|
||||
return label;
|
||||
};
|
||||
|
||||
const metadataString = (node: RunFlowNode, key: string): string | null => {
|
||||
const value = node.metadata?.[key];
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
};
|
||||
|
||||
const dataTypeFromNode = (node: RunFlowNode): string | null => metadataString(node, 'data_type');
|
||||
|
||||
const topologyParentIdFromNode = (node: RunFlowNode): string | null => metadataString(node, 'topologyParentId');
|
||||
|
||||
const topologyRoleFromNode = (node: RunFlowNode): string | null => metadataString(node, 'topologyRole');
|
||||
|
||||
const topologyOrderFromNode = (node: RunFlowNode): number | null => {
|
||||
const value = node.metadata?.topologyOrder;
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
};
|
||||
|
||||
const isExpandedProviderGroup = (node: RunFlowNode, expandedNodeIds?: Set<string>): boolean => (
|
||||
isExpandableNode(node) && (expandedNodeIds?.has(node.id) || node.metadata?.expanded === true)
|
||||
);
|
||||
|
||||
const portPoint = (node: PositionedNode, side: EdgePort, offset = 0): PortPoint => {
|
||||
if (side === 'top') {
|
||||
return { x: node.x + node.width / 2 + offset, y: node.y, side };
|
||||
@@ -110,7 +203,7 @@ const chooseEdgePorts = (
|
||||
: { startSide: 'top', endSide: 'bottom', vertical: true };
|
||||
}
|
||||
|
||||
if ((edge.kind === 'fallback' || edge.kind === 'retry') && isVerticalRelation && Math.abs(to.x - from.x) < LANE_WIDTH * 1.25) {
|
||||
if ((edge.kind === 'fallback' || edge.kind === 'retry') && isVerticalRelation && Math.abs(to.x - from.x) < DEFAULT_LANE_WIDTH * 1.25) {
|
||||
return to.y >= from.y
|
||||
? { startSide: 'bottom', endSide: 'top', vertical: true }
|
||||
: { startSide: 'top', endSide: 'bottom', vertical: true };
|
||||
@@ -162,12 +255,70 @@ const compareLaneNodes = (
|
||||
return getNodeDisplayOrder(left, leftOriginal) - getNodeDisplayOrder(right, rightOriginal);
|
||||
};
|
||||
|
||||
const buildDataSourceBlocks = (
|
||||
laneNodes: RunFlowNode[],
|
||||
originalIndex: Map<string, number>,
|
||||
expandedNodeIds?: Set<string>,
|
||||
): DataSourceBlock[] => {
|
||||
const expandedGroupByDataType = new Map<string, RunFlowNode>();
|
||||
const expandedGroupById = new Map<string, RunFlowNode>();
|
||||
laneNodes.forEach((node) => {
|
||||
const dataType = dataTypeFromNode(node);
|
||||
if (dataType && isExpandedProviderGroup(node, expandedNodeIds)) {
|
||||
expandedGroupByDataType.set(dataType, node);
|
||||
expandedGroupById.set(node.id, node);
|
||||
}
|
||||
});
|
||||
|
||||
const attemptGroupIdByNodeId = new Map<string, string>();
|
||||
const attemptsByGroupId = new Map<string, RunFlowNode[]>();
|
||||
laneNodes.forEach((node) => {
|
||||
if (isExpandableNode(node)) {
|
||||
return;
|
||||
}
|
||||
const explicitParentId = topologyParentIdFromNode(node);
|
||||
const providerAttemptLike = topologyRoleFromNode(node) === 'provider_attempt'
|
||||
|| Boolean(explicitParentId)
|
||||
|| node.id.startsWith('provider_');
|
||||
const dataType = dataTypeFromNode(node);
|
||||
const fallbackGroup = dataType ? expandedGroupByDataType.get(dataType) : null;
|
||||
const groupId = explicitParentId && expandedGroupById.has(explicitParentId)
|
||||
? explicitParentId
|
||||
: (providerAttemptLike ? fallbackGroup?.id : undefined);
|
||||
if (!groupId || !expandedGroupById.has(groupId)) {
|
||||
return;
|
||||
}
|
||||
const attempts = attemptsByGroupId.get(groupId) || [];
|
||||
attempts.push(node);
|
||||
attemptsByGroupId.set(groupId, attempts);
|
||||
attemptGroupIdByNodeId.set(node.id, groupId);
|
||||
});
|
||||
|
||||
const topLevelNodes = laneNodes
|
||||
.filter((node) => !attemptGroupIdByNodeId.has(node.id))
|
||||
.sort((left, right) => compareLaneNodes('data_source', left, right, originalIndex));
|
||||
|
||||
return topLevelNodes.map((node) => {
|
||||
if (!isExpandedProviderGroup(node, expandedNodeIds)) {
|
||||
return { id: node.id, nodes: [node] };
|
||||
}
|
||||
const attempts = [...(attemptsByGroupId.get(node.id) || [])].sort((left, right) => (
|
||||
(topologyOrderFromNode(left) ?? Number.MAX_SAFE_INTEGER) - (topologyOrderFromNode(right) ?? Number.MAX_SAFE_INTEGER)
|
||||
|| (nodeTimeOrder(left) ?? Number.MAX_SAFE_INTEGER) - (nodeTimeOrder(right) ?? Number.MAX_SAFE_INTEGER)
|
||||
|| getNodeDisplayOrder(left, originalIndex.get(left.id) ?? 0) - getNodeDisplayOrder(right, originalIndex.get(right.id) ?? 0)
|
||||
));
|
||||
return { id: node.id, nodes: [node, ...attempts] };
|
||||
});
|
||||
};
|
||||
|
||||
export const RunFlowGraph: React.FC<RunFlowGraphProps> = ({
|
||||
lanes,
|
||||
nodes,
|
||||
edges,
|
||||
selectedNodeId,
|
||||
expandedNodeIds,
|
||||
onSelectNode,
|
||||
onToggleExpanded,
|
||||
}) => {
|
||||
const arrowId = useId().replace(/:/g, '-');
|
||||
const { language, t } = useUiLanguage();
|
||||
@@ -189,8 +340,12 @@ export const RunFlowGraph: React.FC<RunFlowGraphProps> = ({
|
||||
const originalIndex = new Map<string, number>();
|
||||
const nodeById = new Map<string, RunFlowNode>();
|
||||
const laneIndexById = new Map<string, number>();
|
||||
const laneOffsets = new Map<string, number>();
|
||||
let nextLaneOffset = LEFT_PADDING;
|
||||
laneList.forEach((lane, index) => {
|
||||
laneIndexById.set(lane.id, index);
|
||||
laneOffsets.set(lane.id, nextLaneOffset);
|
||||
nextLaneOffset += getLaneMetrics(lane.id).laneWidth;
|
||||
});
|
||||
nodes.forEach((node, index) => {
|
||||
const items = grouped.get(node.lane) || [];
|
||||
@@ -200,6 +355,18 @@ export const RunFlowGraph: React.FC<RunFlowGraphProps> = ({
|
||||
nodeById.set(node.id, node);
|
||||
});
|
||||
|
||||
const dataSourceBlocks = buildDataSourceBlocks(grouped.get('data_source') || [], originalIndex, expandedNodeIds);
|
||||
const dataSourceNodeSequence = dataSourceBlocks.flatMap((block) => block.nodes);
|
||||
const expandedGroupIdByAttemptId = new Map<string, string>();
|
||||
dataSourceBlocks.forEach((block) => {
|
||||
if (block.nodes.length <= 1 || !isExpandedProviderGroup(block.nodes[0], expandedNodeIds)) {
|
||||
return;
|
||||
}
|
||||
block.nodes.slice(1).forEach((node) => {
|
||||
expandedGroupIdByAttemptId.set(node.id, block.nodes[0].id);
|
||||
});
|
||||
});
|
||||
|
||||
const validEdges = edges.filter((edge) => nodeById.has(edge.from) && nodeById.has(edge.to));
|
||||
const incomingByNode = new Map<string, RunFlowEdge[]>();
|
||||
const outgoingByNode = new Map<string, RunFlowEdge[]>();
|
||||
@@ -210,7 +377,9 @@ export const RunFlowGraph: React.FC<RunFlowGraphProps> = ({
|
||||
|
||||
const laneOrderByNode = new Map<string, number>();
|
||||
laneList.forEach((lane) => {
|
||||
const laneNodes = [...(grouped.get(lane.id) || [])].sort((left, right) => (
|
||||
const laneNodes = lane.id === 'data_source'
|
||||
? dataSourceNodeSequence
|
||||
: [...(grouped.get(lane.id) || [])].sort((left, right) => (
|
||||
compareLaneNodes(lane.id, left, right, originalIndex)
|
||||
));
|
||||
laneNodes.forEach((node, index) => {
|
||||
@@ -253,29 +422,83 @@ export const RunFlowGraph: React.FC<RunFlowGraphProps> = ({
|
||||
};
|
||||
|
||||
const positioned = new Map<string, PositionedNode>();
|
||||
let maxRow = 0;
|
||||
let maxY = HEADER_HEIGHT + TOP_PADDING;
|
||||
laneList.forEach((lane, lanePosition) => {
|
||||
const metrics = getLaneMetrics(lane.id);
|
||||
if (lane.id === 'data_source') {
|
||||
let yCursor = HEADER_HEIGHT + TOP_PADDING;
|
||||
let row = 0;
|
||||
dataSourceBlocks.forEach((block, blockIndex) => {
|
||||
block.nodes.forEach((node, nodeIndex) => {
|
||||
const compact = nodeIndex > 0 && expandedGroupIdByAttemptId.has(node.id);
|
||||
const height = compact ? COMPACT_NODE_HEIGHT : NODE_HEIGHT;
|
||||
positioned.set(node.id, {
|
||||
...node,
|
||||
x: laneOffsets.get(lane.id) ?? LEFT_PADDING,
|
||||
y: yCursor,
|
||||
width: metrics.nodeWidth,
|
||||
height,
|
||||
row,
|
||||
laneIndex: lanePosition,
|
||||
compact,
|
||||
expandedGroupId: expandedGroupIdByAttemptId.get(node.id),
|
||||
});
|
||||
maxY = Math.max(maxY, yCursor + height);
|
||||
yCursor += height;
|
||||
yCursor += nodeIndex < block.nodes.length - 1
|
||||
? DATA_SOURCE_ATTEMPT_GAP
|
||||
: (blockIndex < dataSourceBlocks.length - 1 ? DATA_SOURCE_BLOCK_GAP : 0);
|
||||
row += 1;
|
||||
});
|
||||
});
|
||||
maxY = Math.max(maxY, yCursor);
|
||||
return;
|
||||
}
|
||||
|
||||
const laneNodes = [...(grouped.get(lane.id) || [])].sort((left, right) => (
|
||||
resolvePreferredRow(left.id) - resolvePreferredRow(right.id)
|
||||
|| compareLaneNodes(lane.id, left, right, originalIndex)
|
||||
));
|
||||
const occupiedRows = new Set<number>();
|
||||
const rowHeight = getLaneRowHeight(lane.id);
|
||||
laneNodes.forEach((node) => {
|
||||
const row = findAvailableRow(occupiedRows, resolvePreferredRow(node.id));
|
||||
const y = HEADER_HEIGHT + TOP_PADDING + row * rowHeight;
|
||||
occupiedRows.add(row);
|
||||
maxRow = Math.max(maxRow, row);
|
||||
positioned.set(node.id, {
|
||||
...node,
|
||||
x: lanePosition * LANE_WIDTH + LEFT_PADDING,
|
||||
y: HEADER_HEIGHT + TOP_PADDING + row * ROW_HEIGHT,
|
||||
width: NODE_WIDTH,
|
||||
x: laneOffsets.get(lane.id) ?? LEFT_PADDING,
|
||||
y,
|
||||
width: metrics.nodeWidth,
|
||||
height: NODE_HEIGHT,
|
||||
row,
|
||||
laneIndex: lanePosition,
|
||||
});
|
||||
maxY = Math.max(maxY, y + NODE_HEIGHT);
|
||||
});
|
||||
});
|
||||
|
||||
const expandedGroups = dataSourceBlocks
|
||||
.map((block) => {
|
||||
if (block.nodes.length <= 1 || !isExpandedProviderGroup(block.nodes[0], expandedNodeIds)) {
|
||||
return null;
|
||||
}
|
||||
const groupNode = positioned.get(block.nodes[0].id);
|
||||
const lastNode = positioned.get(block.nodes[block.nodes.length - 1].id);
|
||||
if (!groupNode || !lastNode) return null;
|
||||
return {
|
||||
id: groupNode.id,
|
||||
x: groupNode.x - DATA_SOURCE_GROUP_X_PADDING,
|
||||
y: groupNode.y - DATA_SOURCE_GROUP_TOP_PADDING,
|
||||
width: groupNode.width + DATA_SOURCE_GROUP_X_PADDING * 2,
|
||||
height: lastNode.y + lastNode.height - groupNode.y + DATA_SOURCE_GROUP_TOP_PADDING + DATA_SOURCE_GROUP_BOTTOM_PADDING,
|
||||
};
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => Boolean(item));
|
||||
const expandedGroupBottom = expandedGroups.reduce((bottom, group) => (
|
||||
Math.max(bottom, group.y + group.height)
|
||||
), 0);
|
||||
|
||||
const sortEdgesForAnchors = (edgeItems: RunFlowEdge[], fromNodeId: string) => [...edgeItems].sort((left, right) => {
|
||||
const leftTarget = nodeById.get(left.to);
|
||||
const rightTarget = nodeById.get(right.to);
|
||||
@@ -314,10 +537,32 @@ export const RunFlowGraph: React.FC<RunFlowGraphProps> = ({
|
||||
positioned,
|
||||
incomingAnchors,
|
||||
outgoingAnchors,
|
||||
width: Math.max(laneList.length * LANE_WIDTH + LEFT_PADDING, LANE_WIDTH),
|
||||
height: HEADER_HEIGHT + TOP_PADDING + (maxRow + 1) * ROW_HEIGHT + BOTTOM_PADDING,
|
||||
laneOffsets,
|
||||
expandedGroups,
|
||||
width: Math.max(nextLaneOffset + LEFT_PADDING, DEFAULT_LANE_WIDTH),
|
||||
height: Math.max(maxY, expandedGroupBottom) + BOTTOM_PADDING,
|
||||
};
|
||||
}, [edges, laneList, nodes]);
|
||||
}, [edges, expandedNodeIds, laneList, nodes]);
|
||||
|
||||
const selectedNode = selectedNodeId ? layout.positioned.get(selectedNodeId) : null;
|
||||
const selectedDataType = selectedNode ? dataTypeFromNode(selectedNode) : null;
|
||||
const selectedProviderGroup = selectedDataType
|
||||
? Array.from(layout.positioned.values()).find((node) => (
|
||||
isExpandedProviderGroup(node, expandedNodeIds) && dataTypeFromNode(node) === selectedDataType
|
||||
))
|
||||
: null;
|
||||
const selectedRelatedNodeIds = new Set<string>();
|
||||
if (selectedNodeId) {
|
||||
selectedRelatedNodeIds.add(selectedNodeId);
|
||||
}
|
||||
if (selectedProviderGroup) {
|
||||
selectedRelatedNodeIds.add(selectedProviderGroup.id);
|
||||
Array.from(layout.positioned.values()).forEach((node) => {
|
||||
if (dataTypeFromNode(node) === selectedDataType && (node.id === selectedProviderGroup.id || node.expandedGroupId === selectedProviderGroup.id)) {
|
||||
selectedRelatedNodeIds.add(node.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const edgePaths = edges
|
||||
.map((edge, edgeIndex) => {
|
||||
@@ -337,17 +582,52 @@ export const RunFlowGraph: React.FC<RunFlowGraphProps> = ({
|
||||
const start = portPoint(from, ports.startSide, startOffset);
|
||||
const end = portPoint(to, ports.endSide, endOffset);
|
||||
const path = orthogonalPath(start, end);
|
||||
const relatedToSelected = Boolean(selectedNodeId && (edge.from === selectedNodeId || edge.to === selectedNodeId));
|
||||
const fromInSelectedGroup = selectedRelatedNodeIds.has(edge.from);
|
||||
const toInSelectedGroup = selectedRelatedNodeIds.has(edge.to);
|
||||
const internallyRelated = Boolean(selectedProviderGroup && fromInSelectedGroup && toInSelectedGroup);
|
||||
const directlyRelated = Boolean(
|
||||
edge.from === selectedNodeId
|
||||
|| edge.to === selectedNodeId
|
||||
|| (selectedProviderGroup && (fromInSelectedGroup || toInSelectedGroup)),
|
||||
);
|
||||
const focusLevel: EdgeFocusLevel = selectedNodeId
|
||||
? (internallyRelated ? 'internal' : (directlyRelated ? 'direct' : 'none'))
|
||||
: 'none';
|
||||
return {
|
||||
edge,
|
||||
path,
|
||||
labelX: (start.x + end.x) / 2,
|
||||
labelY: (start.y + end.y) / 2 - 6,
|
||||
relatedToSelected,
|
||||
labelX: ports.vertical ? Math.max(start.x, end.x) + 10 : (start.x + end.x) / 2,
|
||||
labelY: ports.vertical ? (start.y + end.y) / 2 + 4 : (start.y + end.y) / 2 - 8,
|
||||
labelAnchor: ports.vertical ? ('start' as const) : ('middle' as const),
|
||||
focusLevel,
|
||||
relatedToSelected: focusLevel !== 'none',
|
||||
};
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => Boolean(item));
|
||||
|
||||
const edgePathViews = edgePaths.reduce<Array<typeof edgePaths[number] & {
|
||||
displayLabel: string | null;
|
||||
showLabel: boolean;
|
||||
}>>((items, item) => {
|
||||
const displayLabel = getEdgeLabel(item.edge.label, t);
|
||||
const labelKey = `${item.edge.to}:${displayLabel || ''}`;
|
||||
const duplicateLabel = items.some((existing) => (
|
||||
existing.relatedToSelected
|
||||
&& getEdgeLabel(existing.edge.label, t)
|
||||
&& `${existing.edge.to}:${getEdgeLabel(existing.edge.label, t)}` === labelKey
|
||||
));
|
||||
items.push({
|
||||
...item,
|
||||
displayLabel,
|
||||
showLabel: Boolean(
|
||||
displayLabel
|
||||
&& (!selectedNodeId || item.relatedToSelected || item.edge.kind === 'fallback' || item.edge.kind === 'retry')
|
||||
&& !duplicateLabel,
|
||||
),
|
||||
});
|
||||
return items;
|
||||
}, []).sort((left, right) => getEdgeFocusRank(left.focusLevel) - getEdgeFocusRank(right.focusLevel));
|
||||
|
||||
return (
|
||||
<div className="home-subpanel overflow-hidden p-3" data-testid="run-flow-graph">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
@@ -379,71 +659,112 @@ export const RunFlowGraph: React.FC<RunFlowGraphProps> = ({
|
||||
<defs>
|
||||
<marker
|
||||
id={`${arrowId}-arrow`}
|
||||
markerWidth="8"
|
||||
markerHeight="8"
|
||||
refX="7"
|
||||
refY="4"
|
||||
markerWidth="4"
|
||||
markerHeight="4"
|
||||
refX="3.5"
|
||||
refY="2"
|
||||
orient="auto"
|
||||
markerUnits="strokeWidth"
|
||||
>
|
||||
<path d="M 0 0 L 8 4 L 0 8 z" fill="currentColor" />
|
||||
<path d="M 0 0 L 4 2 L 0 4 z" fill="currentColor" />
|
||||
</marker>
|
||||
</defs>
|
||||
{edgePaths.map(({ edge, path, labelX, labelY, relatedToSelected }) => (
|
||||
{edgePathViews.map(({ edge, path, labelX, labelY, labelAnchor, focusLevel, showLabel, displayLabel }) => (
|
||||
<g key={edge.id} style={{ color: getEdgeStroke(edge.status) }}>
|
||||
<path
|
||||
data-testid={`run-flow-edge-${edge.id}`}
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={edge.kind === 'fallback' || edge.kind === 'retry' ? 2.5 : 1.75}
|
||||
strokeWidth={getEdgeStrokeWidth(edge, focusLevel)}
|
||||
strokeDasharray={edge.kind === 'retry' ? '7 5' : edge.kind === 'fallback' ? '4 4' : undefined}
|
||||
markerEnd={`url(#${arrowId}-arrow)`}
|
||||
opacity={selectedNodeId ? (relatedToSelected ? 0.9 : 0.22) : 0.68}
|
||||
opacity={getEdgeOpacity(selectedNodeId, focusLevel)}
|
||||
/>
|
||||
{edge.label && (!selectedNodeId || relatedToSelected || edge.kind === 'fallback' || edge.kind === 'retry') ? (
|
||||
{showLabel ? (
|
||||
<text
|
||||
x={labelX}
|
||||
y={labelY}
|
||||
textAnchor="middle"
|
||||
textAnchor={labelAnchor}
|
||||
className="fill-muted-text text-[10px]"
|
||||
style={{ paintOrder: 'stroke', stroke: 'hsl(var(--card))', strokeWidth: 4 }}
|
||||
>
|
||||
{compactText(edge.label, 22)}
|
||||
{compactText(displayLabel, 22)}
|
||||
</text>
|
||||
) : null}
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{laneList.map((lane, index) => (
|
||||
{laneList.map((lane) => {
|
||||
const metrics = getLaneMetrics(lane.id);
|
||||
const left = layout.laneOffsets.get(lane.id) ?? LEFT_PADDING;
|
||||
return (
|
||||
<div
|
||||
key={`${lane.id}-band`}
|
||||
aria-hidden="true"
|
||||
className="absolute top-0 z-0 rounded-lg border border-subtle/70 bg-base/20"
|
||||
style={{
|
||||
left: index * LANE_WIDTH + LEFT_PADDING - 8,
|
||||
width: NODE_WIDTH + 16,
|
||||
left: left - 8,
|
||||
width: metrics.nodeWidth + 16,
|
||||
height: layout.height,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{layout.expandedGroups.map((group) => (
|
||||
<div
|
||||
key={group.id}
|
||||
data-testid={`run-flow-expanded-group-${group.id}`}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute rounded-lg border border-primary/25 bg-primary/7 shadow-inner"
|
||||
style={{
|
||||
left: group.x,
|
||||
top: group.y,
|
||||
width: group.width,
|
||||
height: group.height,
|
||||
zIndex: 5,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{laneList.map((lane, index) => (
|
||||
{laneList.map((lane) => {
|
||||
const metrics = getLaneMetrics(lane.id);
|
||||
const left = layout.laneOffsets.get(lane.id) ?? LEFT_PADDING;
|
||||
return (
|
||||
<div
|
||||
key={lane.id}
|
||||
className="absolute top-0 z-20 rounded-lg border border-subtle bg-base/75 px-3 py-2 text-xs font-medium text-secondary-text backdrop-blur-sm"
|
||||
style={{ left: index * LANE_WIDTH + LEFT_PADDING, width: NODE_WIDTH }}
|
||||
style={{ left, width: metrics.nodeWidth }}
|
||||
>
|
||||
{lane.label}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{Array.from(layout.positioned.values()).map((node) => {
|
||||
const style = RUN_FLOW_STATUS_STYLE[node.status] || RUN_FLOW_STATUS_STYLE.unknown;
|
||||
const selected = selectedNodeId === node.id;
|
||||
const statusLabel = getRunFlowStatusLabel(node.status, t);
|
||||
const expandable = isExpandableNode(node) && Boolean(onToggleExpanded);
|
||||
const expanded = Boolean(expandedNodeIds?.has(node.id));
|
||||
const compact = Boolean(node.compact);
|
||||
const nodeStateClass = selected
|
||||
? 'border-primary/85 bg-primary/8 shadow-lg ring-2 ring-primary/25'
|
||||
: compact
|
||||
? 'border-subtle/70 bg-base/70 ring-1 ring-white/5'
|
||||
: 'border-subtle/80 bg-elevated/92 ring-1 ring-white/5';
|
||||
const nodeDensityClass = compact
|
||||
? 'px-2.5 py-2 shadow-none hover:shadow-soft-card'
|
||||
: 'px-3 py-2 shadow-soft-card hover:shadow-lg';
|
||||
return (
|
||||
<Tooltip key={node.id} content={node.message || statusLabel} side="bottom">
|
||||
<div
|
||||
key={node.id}
|
||||
data-testid={`run-flow-node-${node.id}-wrapper`}
|
||||
className="absolute z-30"
|
||||
style={{ left: node.x, top: node.y, width: node.width, height: node.height }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`run-flow-node-${node.id}`}
|
||||
@@ -452,35 +773,55 @@ export const RunFlowGraph: React.FC<RunFlowGraphProps> = ({
|
||||
aria-label={t('runFlow.graph.nodeAria', { label: node.label, status: statusLabel })}
|
||||
data-layout-lane={node.laneIndex}
|
||||
data-layout-row={node.row}
|
||||
className={`absolute z-30 flex flex-col items-start rounded-lg border bg-elevated/92 px-3 py-2 text-left shadow-soft-card backdrop-blur-sm transition-all hover:-translate-y-0.5 hover:border-primary/60 hover:shadow-lg focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-cyan/15 ${
|
||||
selected ? 'border-primary/70 ring-2 ring-primary/20' : 'border-subtle'
|
||||
className={`box-border flex max-w-full min-w-0 flex-col items-start overflow-hidden rounded-lg border-2 text-left backdrop-blur-sm transition-all hover:-translate-y-0.5 hover:border-primary/60 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-cyan/15 ${nodeDensityClass} ${nodeStateClass} ${
|
||||
expandable ? 'pb-8' : ''
|
||||
}`}
|
||||
style={{ left: node.x, top: node.y, width: node.width, minHeight: node.height }}
|
||||
style={{ width: node.width, height: node.height }}
|
||||
>
|
||||
<span className="flex w-full min-w-0 items-start justify-between gap-2">
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-semibold text-foreground">{node.label}</span>
|
||||
<span className="min-w-0 max-w-full overflow-hidden">
|
||||
<span className="block max-w-full truncate text-sm font-semibold text-foreground">{node.label}</span>
|
||||
{node.provider ? (
|
||||
<span className="mt-0.5 block truncate text-xs text-muted-text">{node.provider}</span>
|
||||
<span className="mt-0.5 block max-w-full truncate text-xs text-muted-text">{node.provider}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<StatusDot tone={style.tone} pulse={style.pulse} className="mt-1 h-2 w-2" />
|
||||
</span>
|
||||
<span className="mt-2 flex w-full flex-wrap items-center gap-1.5">
|
||||
<span className="mt-2 flex w-full min-w-0 flex-wrap items-center gap-1.5">
|
||||
<Badge variant={style.badge} className="shadow-none">
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
{typeof node.durationMs === 'number' ? (
|
||||
<span className="text-[11px] text-muted-text">{formatDuration(node.durationMs, t)}</span>
|
||||
<span className="min-w-0 truncate text-[11px] text-muted-text">{formatDuration(node.durationMs, t)}</span>
|
||||
) : null}
|
||||
</span>
|
||||
{node.startedAt ? (
|
||||
<span className="mt-1 block w-full truncate text-[11px] text-muted-text">
|
||||
<span className="mt-1 block w-full min-w-0 truncate text-[11px] text-muted-text">
|
||||
{t('runFlow.graph.startedAt')}: {formatDateTime(node.startedAt, language, t)}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{expandable ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`run-flow-node-${node.id}-toggle`}
|
||||
aria-label={expanded ? t('runFlow.graph.collapseNode', { label: node.label }) : t('runFlow.graph.expandNode', { label: node.label })}
|
||||
aria-expanded={expanded}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleExpanded?.(node.id);
|
||||
}}
|
||||
className="absolute bottom-2 right-2 z-40 inline-flex h-[18px] items-center gap-0.5 rounded-md border border-subtle bg-base/80 px-1 text-[9px] font-medium leading-none text-secondary-text shadow-sm transition-colors hover:border-primary/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-cyan/15"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-2 w-2" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronRight className="h-2 w-2" aria-hidden="true" />
|
||||
)}
|
||||
{expanded ? t('runFlow.graph.collapse') : t('runFlow.graph.expand')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,15 @@ interface DetailItem {
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
const ALWAYS_HIDDEN_METADATA_KEYS = new Set(['attempts', 'context_blocks', 'topologyGroup', 'expanded']);
|
||||
const ALWAYS_HIDDEN_METADATA_KEYS = new Set([
|
||||
'attempts',
|
||||
'context_blocks',
|
||||
'topologyGroup',
|
||||
'expanded',
|
||||
'counts',
|
||||
'dataQuality',
|
||||
'packVersion',
|
||||
]);
|
||||
const TOPOLOGY_SUMMARY_METADATA_KEYS = new Set([
|
||||
'data_type',
|
||||
'dataType',
|
||||
@@ -49,12 +57,40 @@ const TOPOLOGY_SUMMARY_METADATA_KEYS = new Set([
|
||||
'contextStatusCounts',
|
||||
]);
|
||||
|
||||
type DetailRow = [string, string];
|
||||
|
||||
interface DataQualityMetadata {
|
||||
overallScore?: number;
|
||||
level?: string;
|
||||
blockScores?: Record<string, number>;
|
||||
}
|
||||
|
||||
const readDetailItems = (node: RunFlowNode, key: string): DetailItem[] => {
|
||||
const value = node.metadata?.[key];
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter((item): item is DetailItem => Boolean(item) && typeof item === 'object');
|
||||
};
|
||||
|
||||
const readNumberRecord = (value: unknown): Record<string, number> => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
return Object.entries(value as Record<string, unknown>).reduce<Record<string, number>>((items, [key, item]) => {
|
||||
if (typeof item === 'number' && Number.isFinite(item)) {
|
||||
items[key] = item;
|
||||
}
|
||||
return items;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const readDataQuality = (value: unknown): DataQualityMetadata | null => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const raw = value as Record<string, unknown>;
|
||||
return {
|
||||
overallScore: typeof raw.overallScore === 'number' ? raw.overallScore : undefined,
|
||||
level: typeof raw.level === 'string' ? raw.level : undefined,
|
||||
blockScores: readNumberRecord(raw.blockScores),
|
||||
};
|
||||
};
|
||||
|
||||
const shouldHideMetadataKey = (node: RunFlowNode, key: string): boolean => (
|
||||
ALWAYS_HIDDEN_METADATA_KEYS.has(key)
|
||||
|| (Boolean(node.metadata?.topologyGroup) && TOPOLOGY_SUMMARY_METADATA_KEYS.has(key))
|
||||
@@ -64,6 +100,14 @@ const isRunFlowStatus = (value: unknown): value is RunFlowStatus => (
|
||||
typeof value === 'string' && value in RUN_FLOW_STATUS_STYLE
|
||||
);
|
||||
|
||||
const hasFiniteNumber = (value: unknown): value is number => (
|
||||
typeof value === 'number' && Number.isFinite(value)
|
||||
);
|
||||
|
||||
const isContextPackNode = (node: RunFlowNode): boolean => (
|
||||
node.id === 'context_pack' || node.metadata?.topologyGroup === 'context_pack'
|
||||
);
|
||||
|
||||
export const RunFlowNodeDetails: React.FC<RunFlowNodeDetailsProps> = ({
|
||||
node,
|
||||
isExpanded = false,
|
||||
@@ -92,19 +136,69 @@ export const RunFlowNodeDetails: React.FC<RunFlowNodeDetailsProps> = ({
|
||||
));
|
||||
const attempts = readDetailItems(node, 'attempts');
|
||||
const contextBlocks = readDetailItems(node, 'context_blocks');
|
||||
const contextCounts = readNumberRecord(node.metadata?.counts || node.metadata?.context_status_counts);
|
||||
const contextStatusCounts = readNumberRecord(node.metadata?.context_status_counts);
|
||||
const dataQuality = readDataQuality(node.metadata?.dataQuality);
|
||||
const blockScores = dataQuality?.blockScores || {};
|
||||
const canToggleExpanded = node.metadata?.topologyGroup === 'provider_attempts' && Boolean(onToggleExpanded);
|
||||
const formatDetailStatus = (status: string | undefined) => (
|
||||
isRunFlowStatus(status) ? getRunFlowStatusLabel(status, t) : status || t('runFlow.valueUnavailable')
|
||||
);
|
||||
const detailRows = [
|
||||
[t('runFlow.nodeDetails.kind'), getRunFlowNodeKindLabel(node.kind, t)],
|
||||
[t('runFlow.nodeDetails.provider'), node.provider || t('runFlow.valueUnavailable')],
|
||||
[t('runFlow.nodeDetails.duration'), formatDuration(node.durationMs, t)],
|
||||
[t('runFlow.nodeDetails.attempts'), node.attempts ? String(node.attempts) : t('runFlow.valueUnavailable')],
|
||||
[t('runFlow.nodeDetails.recordCount'), typeof node.recordCount === 'number' ? String(node.recordCount) : t('runFlow.valueUnavailable')],
|
||||
[t('runFlow.nodeDetails.startedAt'), formatDateTime(node.startedAt, language, t)],
|
||||
[t('runFlow.nodeDetails.endedAt'), formatDateTime(node.endedAt, language, t)],
|
||||
];
|
||||
const detailRows: DetailRow[] = [[t('runFlow.nodeDetails.kind'), getRunFlowNodeKindLabel(node.kind, t)]];
|
||||
const addProviderRow = () => {
|
||||
if (node.provider) {
|
||||
detailRows.push([t('runFlow.nodeDetails.provider'), node.provider]);
|
||||
}
|
||||
};
|
||||
const addDurationRow = () => {
|
||||
if (hasFiniteNumber(node.durationMs)) {
|
||||
detailRows.push([t('runFlow.nodeDetails.duration'), formatDuration(node.durationMs, t)]);
|
||||
}
|
||||
};
|
||||
const addAttemptRow = () => {
|
||||
if (hasFiniteNumber(node.attempts)) {
|
||||
detailRows.push([t('runFlow.nodeDetails.attempts'), String(node.attempts)]);
|
||||
}
|
||||
};
|
||||
const addRecordRow = () => {
|
||||
if (hasFiniteNumber(node.recordCount)) {
|
||||
detailRows.push([t('runFlow.nodeDetails.recordCount'), String(node.recordCount)]);
|
||||
}
|
||||
};
|
||||
const addTimeRows = () => {
|
||||
if (node.startedAt) {
|
||||
detailRows.push([t('runFlow.nodeDetails.startedAt'), formatDateTime(node.startedAt, language, t)]);
|
||||
}
|
||||
if (node.endedAt) {
|
||||
detailRows.push([t('runFlow.nodeDetails.endedAt'), formatDateTime(node.endedAt, language, t)]);
|
||||
}
|
||||
};
|
||||
|
||||
if (isContextPackNode(node)) {
|
||||
if (typeof node.metadata?.packVersion === 'string') {
|
||||
detailRows.push([t('runFlow.nodeDetails.version'), node.metadata.packVersion]);
|
||||
}
|
||||
addTimeRows();
|
||||
} else if (node.kind === 'entry' || node.kind === 'queue') {
|
||||
addTimeRows();
|
||||
} else if (node.kind === 'data_source') {
|
||||
addProviderRow();
|
||||
addDurationRow();
|
||||
addAttemptRow();
|
||||
addRecordRow();
|
||||
addTimeRows();
|
||||
} else if (node.kind === 'analysis' || node.kind === 'model') {
|
||||
addProviderRow();
|
||||
addDurationRow();
|
||||
addAttemptRow();
|
||||
addRecordRow();
|
||||
addTimeRows();
|
||||
} else {
|
||||
addDurationRow();
|
||||
addAttemptRow();
|
||||
addRecordRow();
|
||||
addTimeRows();
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="home-subpanel p-4" data-testid="run-flow-node-details">
|
||||
@@ -217,6 +311,57 @@ export const RunFlowNodeDetails: React.FC<RunFlowNodeDetailsProps> = ({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{(dataQuality || Object.keys(contextCounts).length > 0 || Object.keys(contextStatusCounts).length > 0) ? (
|
||||
<div className="mt-4">
|
||||
<p className="label-uppercase">{t('runFlow.nodeDetails.contextQuality')}</p>
|
||||
<dl className="mt-2 grid grid-cols-2 gap-2 text-sm sm:grid-cols-4">
|
||||
{typeof dataQuality?.overallScore === 'number' ? (
|
||||
<div className="rounded-lg border border-subtle bg-base/35 px-3 py-2">
|
||||
<dt className="text-xs text-muted-text">{t('runFlow.nodeDetails.overallScore')}</dt>
|
||||
<dd className="mt-1 text-foreground">{dataQuality.overallScore}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{dataQuality?.level ? (
|
||||
<div className="rounded-lg border border-subtle bg-base/35 px-3 py-2">
|
||||
<dt className="text-xs text-muted-text">{t('runFlow.nodeDetails.qualityLevel')}</dt>
|
||||
<dd className="mt-1 text-foreground">{dataQuality.level}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{[
|
||||
['available', 'success', t('runFlow.nodeDetails.count.available')],
|
||||
['missing', null, t('runFlow.nodeDetails.count.missing')],
|
||||
['partial', null, t('runFlow.nodeDetails.count.partial')],
|
||||
['degraded', null, t('runFlow.nodeDetails.count.degraded')],
|
||||
['fallback', null, t('runFlow.nodeDetails.count.fallback')],
|
||||
['skipped', null, t('runFlow.nodeDetails.count.skipped')],
|
||||
].map(([key, statusKey, label]) => {
|
||||
const count = contextCounts[key || ''] ?? (statusKey ? contextStatusCounts[statusKey] : contextStatusCounts[key || '']);
|
||||
if (typeof count !== 'number') return null;
|
||||
return (
|
||||
<div key={key} className="rounded-lg border border-subtle bg-base/35 px-3 py-2">
|
||||
<dt className="text-xs text-muted-text">{label}</dt>
|
||||
<dd className="mt-1 text-foreground">{count}</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
|
||||
{Object.keys(blockScores).length > 0 ? (
|
||||
<div className="mt-3">
|
||||
<p className="text-xs font-medium text-muted-text">{t('runFlow.nodeDetails.blockScores')}</p>
|
||||
<dl className="mt-2 grid grid-cols-2 gap-2 text-sm sm:grid-cols-3">
|
||||
{Object.entries(blockScores).map(([key, score]) => (
|
||||
<div key={key} className="rounded-lg border border-subtle bg-base/35 px-3 py-2">
|
||||
<dt className="font-mono text-xs text-muted-text">{key}</dt>
|
||||
<dd className="mt-1 text-foreground">{score}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{metadata.length > 0 ? (
|
||||
<div className="mt-4">
|
||||
<p className="label-uppercase">{t('runFlow.nodeDetails.metadata')}</p>
|
||||
|
||||
@@ -159,14 +159,16 @@ export const RunFlowPanel: React.FC<RunFlowPanelProps> = ({ source, title }) =>
|
||||
className="border-dashed"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid min-w-0 grid-cols-1 gap-3 2xl:grid-cols-[minmax(0,1fr)_24rem]">
|
||||
<div className="grid min-w-0 grid-cols-1 gap-3 xl:grid-cols-[minmax(0,1fr)_19.25rem]" data-testid="run-flow-layout">
|
||||
<div className="min-w-0 space-y-3">
|
||||
<RunFlowGraph
|
||||
lanes={topology?.lanes || snapshot.lanes}
|
||||
nodes={topology?.nodes || snapshot.nodes}
|
||||
edges={topology?.edges || snapshot.edges}
|
||||
selectedNodeId={graphSelectedNodeId}
|
||||
expandedNodeIds={expandedGroupIds}
|
||||
onSelectNode={selectNode}
|
||||
onToggleExpanded={toggleExpandedGroup}
|
||||
/>
|
||||
<RunFlowNodeDetails
|
||||
node={selectedNode}
|
||||
@@ -178,7 +180,7 @@ export const RunFlowPanel: React.FC<RunFlowPanelProps> = ({ source, title }) =>
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-[20rem] 2xl:max-h-[calc(100vh-18rem)]">
|
||||
<div className="min-h-[20rem] xl:max-h-[calc(100vh-18rem)]" data-testid="run-flow-events-column">
|
||||
<RunFlowEventList
|
||||
events={topology?.events || snapshot.events}
|
||||
selectedNodeId={graphSelectedNodeId}
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('RunFlowEventList', () => {
|
||||
expect(screen.getByText('日线降级')).toBeInTheDocument();
|
||||
expect(screen.getByText('任务取消')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '降级/重试' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '降级回退/重试' }));
|
||||
|
||||
expect(screen.getByText('日线降级')).toBeInTheDocument();
|
||||
expect(screen.queryByText('任务创建')).not.toBeInTheDocument();
|
||||
|
||||
@@ -39,10 +39,30 @@ const edges: RunFlowEdge[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const positionedStyleFor = (testId: string): CSSStyleDeclaration => {
|
||||
return screen.getByTestId(`${testId}-wrapper`).style;
|
||||
};
|
||||
|
||||
const nodeStyleFor = (testId: string): CSSStyleDeclaration => {
|
||||
return screen.getByTestId(testId).style;
|
||||
};
|
||||
|
||||
const layoutRowFor = (testId: string): number => (
|
||||
Number(screen.getByTestId(testId).dataset.layoutRow)
|
||||
);
|
||||
|
||||
const topFor = (testId: string): number => (
|
||||
parseFloat(positionedStyleFor(testId).top)
|
||||
);
|
||||
|
||||
const heightFor = (testId: string): number => (
|
||||
parseFloat(positionedStyleFor(testId).height)
|
||||
);
|
||||
|
||||
describe('RunFlowGraph', () => {
|
||||
it('renders auto-layered lanes, edge legend labels, and clickable nodes', () => {
|
||||
const onSelectNode = vi.fn();
|
||||
render(
|
||||
const { container } = render(
|
||||
<RunFlowGraph
|
||||
lanes={lanes}
|
||||
nodes={nodes}
|
||||
@@ -53,13 +73,20 @@ describe('RunFlowGraph', () => {
|
||||
|
||||
expect(screen.getByText('入口')).toBeInTheDocument();
|
||||
expect(screen.getByText('数据来源')).toBeInTheDocument();
|
||||
expect(screen.getByText('降级')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('降级回退').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('降级输入')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('run-flow-node-news')).toHaveTextContent('开始');
|
||||
expect(screen.getByTestId('run-flow-node-news')).toHaveTextContent('2026');
|
||||
expect(screen.getByRole('button', { name: '新闻舆情 节点,状态 Fallback' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '新闻舆情 节点,状态 降级回退' })).toBeInTheDocument();
|
||||
const marker = container.querySelector('marker');
|
||||
expect(marker).toHaveAttribute('markerWidth', '4');
|
||||
expect(marker).toHaveAttribute('markerHeight', '4');
|
||||
expect(marker).toHaveAttribute('refX', '3.5');
|
||||
expect(marker?.querySelector('path')).toHaveAttribute('d', 'M 0 0 L 4 2 L 0 4 z');
|
||||
fireEvent.mouseEnter(screen.getByTestId('run-flow-node-news'));
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '新闻舆情 节点,状态 Fallback' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '新闻舆情 节点,状态 降级回退' }));
|
||||
|
||||
expect(onSelectNode).toHaveBeenCalledWith(expect.objectContaining({ id: 'news' }));
|
||||
});
|
||||
@@ -120,7 +147,9 @@ describe('RunFlowGraph', () => {
|
||||
|
||||
const paths = Array.from(container.querySelectorAll('svg g path'));
|
||||
|
||||
expect(paths.map((path) => path.getAttribute('opacity'))).toEqual(['0.9', '0.22', '0.22']);
|
||||
const opacities = paths.map((path) => path.getAttribute('opacity'));
|
||||
expect(opacities.filter((opacity) => opacity === '0.82')).toHaveLength(1);
|
||||
expect(opacities.filter((opacity) => opacity === '0.18')).toHaveLength(2);
|
||||
expect(screen.getByText('调度输入')).toBeInTheDocument();
|
||||
expect(screen.queryByText('报告输出')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('降级输出')).toBeInTheDocument();
|
||||
@@ -206,6 +235,36 @@ describe('RunFlowGraph', () => {
|
||||
expect(screen.getByTestId('run-flow-node-quote')).toHaveAttribute('data-layout-row');
|
||||
});
|
||||
|
||||
it('keeps entry lane nodes at the standard height with roomier vertical rhythm', () => {
|
||||
render(
|
||||
<RunFlowGraph
|
||||
lanes={lanes}
|
||||
nodes={[
|
||||
{
|
||||
id: 'request',
|
||||
lane: 'entry',
|
||||
kind: 'entry',
|
||||
label: '用户请求',
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: 'task_queue',
|
||||
lane: 'entry',
|
||||
kind: 'queue',
|
||||
label: '任务队列',
|
||||
status: 'success',
|
||||
},
|
||||
]}
|
||||
edges={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(heightFor('run-flow-node-request')).toBe(112);
|
||||
expect(heightFor('run-flow-node-task_queue')).toBe(112);
|
||||
expect(topFor('run-flow-node-task_queue')).toBe(topFor('run-flow-node-request') + 152);
|
||||
expect(nodeStyleFor('run-flow-node-request').height).toBe('112px');
|
||||
});
|
||||
|
||||
it('routes same-lane vertical edges from card bottom to the next card top', () => {
|
||||
const verticalNodes: RunFlowNode[] = [
|
||||
{
|
||||
@@ -230,6 +289,7 @@ describe('RunFlowGraph', () => {
|
||||
to: 'quote',
|
||||
kind: 'control',
|
||||
status: 'success',
|
||||
label: '详情',
|
||||
},
|
||||
];
|
||||
const { container } = render(
|
||||
@@ -237,17 +297,18 @@ describe('RunFlowGraph', () => {
|
||||
lanes={lanes}
|
||||
nodes={verticalNodes}
|
||||
edges={verticalEdges}
|
||||
selectedNodeId="daily"
|
||||
/>,
|
||||
);
|
||||
|
||||
const pathData = container.querySelector('svg g path')?.getAttribute('d') || '';
|
||||
const pathNumbers = pathData.match(/-?\d+(?:\.\d+)?/g)?.map(Number) || [];
|
||||
const [startX, startY, endY] = pathNumbers;
|
||||
const dailyNode = screen.getByTestId('run-flow-node-daily');
|
||||
const quoteNode = screen.getByTestId('run-flow-node-quote');
|
||||
const dailyCenterX = parseFloat(dailyNode.style.left) + parseFloat(dailyNode.style.width) / 2;
|
||||
const dailyBottom = parseFloat(dailyNode.style.top) + parseFloat(dailyNode.style.minHeight);
|
||||
const quoteTop = parseFloat(quoteNode.style.top);
|
||||
const dailyStyle = positionedStyleFor('run-flow-node-daily');
|
||||
const quoteStyle = positionedStyleFor('run-flow-node-quote');
|
||||
const dailyCenterX = parseFloat(dailyStyle.left) + parseFloat(dailyStyle.width) / 2;
|
||||
const dailyBottom = parseFloat(dailyStyle.top) + parseFloat(dailyStyle.height);
|
||||
const quoteTop = parseFloat(quoteStyle.top);
|
||||
|
||||
expect(pathData).toContain('V');
|
||||
expect(pathData).not.toContain('C');
|
||||
@@ -255,6 +316,10 @@ describe('RunFlowGraph', () => {
|
||||
expect(startY).toBeLessThan(endY);
|
||||
expect(startY).toBe(dailyBottom);
|
||||
expect(endY).toBe(quoteTop);
|
||||
const label = screen.getByText('详情');
|
||||
expect(label).toHaveAttribute('text-anchor', 'start');
|
||||
expect(parseFloat(label.getAttribute('x') || '0')).toBeGreaterThan(startX);
|
||||
expect(parseFloat(label.getAttribute('y') || '0')).toBeGreaterThan((startY + endY) / 2);
|
||||
});
|
||||
|
||||
it('routes cross-lane flow edges through side ports with orthogonal segments', () => {
|
||||
@@ -281,6 +346,7 @@ describe('RunFlowGraph', () => {
|
||||
to: 'llm',
|
||||
kind: 'data',
|
||||
status: 'success',
|
||||
label: '跨泳道',
|
||||
},
|
||||
];
|
||||
const { container } = render(
|
||||
@@ -288,18 +354,19 @@ describe('RunFlowGraph', () => {
|
||||
lanes={lanes}
|
||||
nodes={crossLaneNodes}
|
||||
edges={crossLaneEdges}
|
||||
selectedNodeId="request"
|
||||
/>,
|
||||
);
|
||||
|
||||
const pathData = container.querySelector('svg g path')?.getAttribute('d') || '';
|
||||
const pathNumbers = pathData.match(/-?\d+(?:\.\d+)?/g)?.map(Number) || [];
|
||||
const [startX, startY, , endY, endX] = pathNumbers;
|
||||
const requestNode = screen.getByTestId('run-flow-node-request');
|
||||
const llmNode = screen.getByTestId('run-flow-node-llm');
|
||||
const requestRight = parseFloat(requestNode.style.left) + parseFloat(requestNode.style.width);
|
||||
const requestCenterY = parseFloat(requestNode.style.top) + parseFloat(requestNode.style.minHeight) / 2;
|
||||
const llmLeft = parseFloat(llmNode.style.left);
|
||||
const llmCenterY = parseFloat(llmNode.style.top) + parseFloat(llmNode.style.minHeight) / 2;
|
||||
const requestStyle = positionedStyleFor('run-flow-node-request');
|
||||
const llmStyle = positionedStyleFor('run-flow-node-llm');
|
||||
const requestRight = parseFloat(requestStyle.left) + parseFloat(requestStyle.width);
|
||||
const requestCenterY = parseFloat(requestStyle.top) + parseFloat(requestStyle.height) / 2;
|
||||
const llmLeft = parseFloat(llmStyle.left);
|
||||
const llmCenterY = parseFloat(llmStyle.top) + parseFloat(llmStyle.height) / 2;
|
||||
|
||||
expect(pathData).toContain('H');
|
||||
expect(pathData).toContain('V');
|
||||
@@ -308,6 +375,9 @@ describe('RunFlowGraph', () => {
|
||||
expect(startY).toBe(requestCenterY);
|
||||
expect(endX).toBe(llmLeft);
|
||||
expect(endY).toBe(llmCenterY);
|
||||
const label = screen.getByText('跨泳道');
|
||||
expect(label).toHaveAttribute('text-anchor', 'middle');
|
||||
expect(parseFloat(label.getAttribute('y') || '0')).toBeLessThan((startY + endY) / 2);
|
||||
});
|
||||
|
||||
it('orders data-source lane cards by their observed timestamps', () => {
|
||||
@@ -353,4 +423,426 @@ describe('RunFlowGraph', () => {
|
||||
Number(screen.getByTestId('run-flow-node-late-news').dataset.layoutRow),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps compact lane and card widths consistent across lanes', () => {
|
||||
const laneWidthNodes: RunFlowNode[] = [
|
||||
{
|
||||
id: 'request',
|
||||
lane: 'entry',
|
||||
kind: 'entry',
|
||||
label: '用户请求',
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: 'news',
|
||||
lane: 'data_source',
|
||||
kind: 'data_source',
|
||||
label: '新闻舆情',
|
||||
status: 'success',
|
||||
provider: 'TushareFetcher -> AkshareFetcher -> TushareFetcher -> AkshareFetcher',
|
||||
},
|
||||
{
|
||||
id: 'save',
|
||||
lane: 'artifact',
|
||||
kind: 'artifact',
|
||||
label: '保存报告',
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: 'notification',
|
||||
lane: 'artifact',
|
||||
kind: 'notification',
|
||||
label: '推送通知 · report',
|
||||
status: 'skipped',
|
||||
},
|
||||
];
|
||||
render(
|
||||
<RunFlowGraph
|
||||
lanes={[
|
||||
...lanes,
|
||||
{ id: 'artifact', label: '产物', order: 4 },
|
||||
]}
|
||||
nodes={laneWidthNodes}
|
||||
edges={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(positionedStyleFor('run-flow-node-request').width).toBe('188px');
|
||||
expect(positionedStyleFor('run-flow-node-news').width).toBe('244px');
|
||||
expect(nodeStyleFor('run-flow-node-news').width).toBe('244px');
|
||||
expect(positionedStyleFor('run-flow-node-save').width).toBe('188px');
|
||||
expect(nodeStyleFor('run-flow-node-save').width).toBe('188px');
|
||||
expect(positionedStyleFor('run-flow-node-notification').width).toBe('188px');
|
||||
expect(nodeStyleFor('run-flow-node-notification').width).toBe('188px');
|
||||
});
|
||||
|
||||
it('shows an inline expand control for expandable topology groups without selecting the node', () => {
|
||||
const onSelectNode = vi.fn();
|
||||
const onToggleExpanded = vi.fn();
|
||||
render(
|
||||
<RunFlowGraph
|
||||
lanes={lanes}
|
||||
nodes={[
|
||||
{
|
||||
id: 'topology_data_news_search',
|
||||
lane: 'data_source',
|
||||
kind: 'data_source',
|
||||
label: '新闻舆情',
|
||||
status: 'fallback',
|
||||
metadata: { topologyGroup: 'provider_attempts' },
|
||||
},
|
||||
]}
|
||||
edges={[]}
|
||||
onSelectNode={onSelectNode}
|
||||
onToggleExpanded={onToggleExpanded}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '展开 新闻舆情 运行尝试' }));
|
||||
|
||||
expect(screen.getByTestId('run-flow-node-topology_data_news_search')).toHaveClass('pb-8');
|
||||
expect(onToggleExpanded).toHaveBeenCalledWith('topology_data_news_search');
|
||||
expect(onSelectNode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses clearer default and selected card states without changing border width', () => {
|
||||
render(
|
||||
<RunFlowGraph
|
||||
lanes={lanes}
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
selectedNodeId="news"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('run-flow-node-request')).toHaveClass(
|
||||
'border-2',
|
||||
'border-subtle/80',
|
||||
'ring-1',
|
||||
'ring-white/5',
|
||||
);
|
||||
expect(screen.getByTestId('run-flow-node-news')).toHaveClass(
|
||||
'border-2',
|
||||
'border-primary/85',
|
||||
'bg-primary/8',
|
||||
'ring-2',
|
||||
'ring-primary/25',
|
||||
);
|
||||
});
|
||||
|
||||
it('emphasizes selected provider group paths including internal fallback attempts', () => {
|
||||
render(
|
||||
<RunFlowGraph
|
||||
lanes={lanes}
|
||||
nodes={[
|
||||
{
|
||||
id: 'task_queue',
|
||||
lane: 'entry',
|
||||
kind: 'queue',
|
||||
label: '任务队列',
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: 'topology_data_realtime_quote',
|
||||
lane: 'data_source',
|
||||
kind: 'data_source',
|
||||
label: '实时行情',
|
||||
status: 'fallback',
|
||||
metadata: { topologyGroup: 'provider_attempts', data_type: 'realtime_quote', expanded: true },
|
||||
},
|
||||
{
|
||||
id: 'provider_realtime_tushare_1',
|
||||
lane: 'data_source',
|
||||
kind: 'data_source',
|
||||
label: '实时行情 · TushareFetcher',
|
||||
provider: 'TushareFetcher',
|
||||
status: 'success',
|
||||
metadata: { data_type: 'realtime_quote' },
|
||||
},
|
||||
{
|
||||
id: 'provider_realtime_akshare_2',
|
||||
lane: 'data_source',
|
||||
kind: 'data_source',
|
||||
label: '实时行情 · AkshareFetcher',
|
||||
provider: 'AkshareFetcher',
|
||||
status: 'success',
|
||||
metadata: { data_type: 'realtime_quote' },
|
||||
},
|
||||
{
|
||||
id: 'daily',
|
||||
lane: 'data_source',
|
||||
kind: 'data_source',
|
||||
label: '日线K线',
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: 'llm',
|
||||
lane: 'analysis',
|
||||
kind: 'model',
|
||||
label: 'LLM 生成',
|
||||
status: 'success',
|
||||
},
|
||||
]}
|
||||
edges={[
|
||||
{
|
||||
id: 'task-realtime',
|
||||
from: 'task_queue',
|
||||
to: 'topology_data_realtime_quote',
|
||||
kind: 'control',
|
||||
status: 'fallback',
|
||||
},
|
||||
{
|
||||
id: 'realtime-first',
|
||||
from: 'topology_data_realtime_quote',
|
||||
to: 'provider_realtime_tushare_1',
|
||||
kind: 'control',
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: 'realtime-fallback',
|
||||
from: 'provider_realtime_tushare_1',
|
||||
to: 'provider_realtime_akshare_2',
|
||||
kind: 'fallback',
|
||||
status: 'fallback',
|
||||
},
|
||||
{
|
||||
id: 'daily-llm',
|
||||
from: 'daily',
|
||||
to: 'llm',
|
||||
kind: 'data',
|
||||
status: 'success',
|
||||
},
|
||||
]}
|
||||
selectedNodeId="topology_data_realtime_quote"
|
||||
expandedNodeIds={new Set(['topology_data_realtime_quote'])}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('run-flow-edge-task-realtime')).toHaveAttribute('stroke-width', '2.4');
|
||||
expect(screen.getByTestId('run-flow-edge-task-realtime')).toHaveAttribute('opacity', '0.82');
|
||||
expect(screen.getByTestId('run-flow-edge-realtime-first')).toHaveAttribute('stroke-width', '3');
|
||||
expect(screen.getByTestId('run-flow-edge-realtime-first')).toHaveAttribute('opacity', '0.95');
|
||||
expect(screen.getByTestId('run-flow-edge-realtime-fallback')).toHaveAttribute('stroke-width', '3.5');
|
||||
expect(screen.getByTestId('run-flow-edge-realtime-fallback')).toHaveAttribute('opacity', '0.95');
|
||||
expect(screen.getByTestId('run-flow-edge-daily-llm')).toHaveAttribute('stroke-width', '1.75');
|
||||
expect(screen.getByTestId('run-flow-edge-daily-llm')).toHaveAttribute('opacity', '0.18');
|
||||
});
|
||||
|
||||
it('keeps expanded provider attempts grouped under their parent in compact cards', () => {
|
||||
const onToggleExpanded = vi.fn();
|
||||
render(
|
||||
<RunFlowGraph
|
||||
lanes={lanes}
|
||||
nodes={[
|
||||
{
|
||||
id: 'topology_data_realtime_quote',
|
||||
lane: 'data_source',
|
||||
kind: 'data_source',
|
||||
label: '实时行情',
|
||||
status: 'fallback',
|
||||
startedAt: '2026-06-08T10:00:00',
|
||||
metadata: { topologyGroup: 'provider_attempts', data_type: 'realtime_quote', expanded: true },
|
||||
},
|
||||
{
|
||||
id: 'daily',
|
||||
lane: 'data_source',
|
||||
kind: 'data_source',
|
||||
label: '日线K线',
|
||||
status: 'success',
|
||||
startedAt: '2026-06-08T10:00:01',
|
||||
},
|
||||
{
|
||||
id: 'provider_realtime_tushare_1',
|
||||
lane: 'data_source',
|
||||
kind: 'data_source',
|
||||
label: '实时行情 · TushareFetcher',
|
||||
provider: 'TushareFetcher',
|
||||
status: 'success',
|
||||
startedAt: '2026-06-08T10:00:02',
|
||||
metadata: { data_type: 'realtime_quote' },
|
||||
},
|
||||
{
|
||||
id: 'provider_realtime_akshare_2',
|
||||
lane: 'data_source',
|
||||
kind: 'data_source',
|
||||
label: '实时行情 · AkshareFetcher',
|
||||
provider: 'AkshareFetcher',
|
||||
status: 'success',
|
||||
startedAt: '2026-06-08T10:00:03',
|
||||
metadata: { data_type: 'realtime_quote' },
|
||||
},
|
||||
]}
|
||||
edges={[]}
|
||||
expandedNodeIds={new Set(['topology_data_realtime_quote'])}
|
||||
onToggleExpanded={onToggleExpanded}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(layoutRowFor('run-flow-node-provider_realtime_tushare_1')).toBe(
|
||||
layoutRowFor('run-flow-node-topology_data_realtime_quote') + 1,
|
||||
);
|
||||
expect(layoutRowFor('run-flow-node-provider_realtime_akshare_2')).toBe(
|
||||
layoutRowFor('run-flow-node-provider_realtime_tushare_1') + 1,
|
||||
);
|
||||
expect(layoutRowFor('run-flow-node-daily')).toBeGreaterThan(
|
||||
layoutRowFor('run-flow-node-provider_realtime_akshare_2'),
|
||||
);
|
||||
expect(topFor('run-flow-node-topology_data_realtime_quote')).toBeLessThan(
|
||||
topFor('run-flow-node-provider_realtime_tushare_1'),
|
||||
);
|
||||
expect(heightFor('run-flow-node-topology_data_realtime_quote')).toBe(112);
|
||||
expect(heightFor('run-flow-node-provider_realtime_tushare_1')).toBe(96);
|
||||
expect(topFor('run-flow-node-provider_realtime_tushare_1')).toBe(
|
||||
topFor('run-flow-node-topology_data_realtime_quote') + 112 + 42,
|
||||
);
|
||||
expect(topFor('run-flow-node-provider_realtime_akshare_2')).toBe(
|
||||
topFor('run-flow-node-provider_realtime_tushare_1') + 96 + 42,
|
||||
);
|
||||
expect(topFor('run-flow-node-daily')).toBe(
|
||||
topFor('run-flow-node-provider_realtime_akshare_2') + 96 + 40,
|
||||
);
|
||||
const groupBackground = screen.getByTestId('run-flow-expanded-group-topology_data_realtime_quote');
|
||||
const groupBackgroundTop = parseFloat(groupBackground.style.top);
|
||||
const groupBackgroundBottom = groupBackgroundTop + parseFloat(groupBackground.style.height);
|
||||
expect(groupBackgroundTop).toBe(topFor('run-flow-node-topology_data_realtime_quote') - 18);
|
||||
expect(groupBackgroundBottom).toBe(
|
||||
topFor('run-flow-node-provider_realtime_akshare_2') + heightFor('run-flow-node-provider_realtime_akshare_2') + 18,
|
||||
);
|
||||
const canvasMinHeight = parseFloat((groupBackground.parentElement as HTMLElement).style.minHeight);
|
||||
expect(canvasMinHeight).toBeGreaterThan(groupBackgroundBottom);
|
||||
expect(screen.getByTestId('run-flow-node-provider_realtime_tushare_1')).toHaveClass(
|
||||
'bg-base/70',
|
||||
'shadow-none',
|
||||
);
|
||||
expect(nodeStyleFor('run-flow-node-provider_realtime_tushare_1').width).toBe('244px');
|
||||
expect(nodeStyleFor('run-flow-node-provider_realtime_tushare_1').height).toBe('96px');
|
||||
const toggle = screen.getByTestId('run-flow-node-topology_data_realtime_quote-toggle');
|
||||
expect(toggle).toHaveClass('h-[18px]', 'gap-0.5', 'px-1', 'text-[9px]', 'leading-none');
|
||||
expect(toggle.querySelector('svg')).toHaveClass('h-2', 'w-2');
|
||||
});
|
||||
|
||||
it('keeps multiple expanded provider groups as non-interleaving layout blocks', () => {
|
||||
render(
|
||||
<RunFlowGraph
|
||||
lanes={lanes}
|
||||
nodes={[
|
||||
{
|
||||
id: 'topology_data_realtime_quote',
|
||||
lane: 'data_source',
|
||||
kind: 'data_source',
|
||||
label: '实时行情',
|
||||
status: 'fallback',
|
||||
startedAt: '2026-06-08T10:00:00',
|
||||
metadata: {
|
||||
topologyGroup: 'provider_attempts',
|
||||
topologyRole: 'provider_group',
|
||||
data_type: 'realtime_quote',
|
||||
expanded: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'topology_data_news_search',
|
||||
lane: 'data_source',
|
||||
kind: 'data_source',
|
||||
label: '新闻舆情',
|
||||
status: 'fallback',
|
||||
startedAt: '2026-06-08T10:00:01',
|
||||
metadata: {
|
||||
topologyGroup: 'provider_attempts',
|
||||
topologyRole: 'provider_group',
|
||||
data_type: 'news_search',
|
||||
expanded: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'daily',
|
||||
lane: 'data_source',
|
||||
kind: 'data_source',
|
||||
label: '日线K线',
|
||||
status: 'success',
|
||||
startedAt: '2026-06-08T10:00:02',
|
||||
},
|
||||
{
|
||||
id: 'provider_realtime_tushare_1',
|
||||
lane: 'data_source',
|
||||
kind: 'data_source',
|
||||
label: '实时行情 · TushareFetcher',
|
||||
status: 'success',
|
||||
startedAt: '2026-06-08T10:00:03',
|
||||
metadata: {
|
||||
data_type: 'realtime_quote',
|
||||
topologyParentId: 'topology_data_realtime_quote',
|
||||
topologyRole: 'provider_attempt',
|
||||
topologyOrder: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'provider_news_tavily_1',
|
||||
lane: 'data_source',
|
||||
kind: 'data_source',
|
||||
label: '新闻舆情 · Tavily',
|
||||
status: 'success',
|
||||
startedAt: '2026-06-08T10:00:04',
|
||||
metadata: {
|
||||
data_type: 'news_search',
|
||||
topologyParentId: 'topology_data_news_search',
|
||||
topologyRole: 'provider_attempt',
|
||||
topologyOrder: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'provider_realtime_akshare_2',
|
||||
lane: 'data_source',
|
||||
kind: 'data_source',
|
||||
label: '实时行情 · AkshareFetcher',
|
||||
status: 'success',
|
||||
startedAt: '2026-06-08T10:00:05',
|
||||
metadata: {
|
||||
data_type: 'realtime_quote',
|
||||
topologyParentId: 'topology_data_realtime_quote',
|
||||
topologyRole: 'provider_attempt',
|
||||
topologyOrder: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'provider_news_searxng_2',
|
||||
lane: 'data_source',
|
||||
kind: 'data_source',
|
||||
label: '新闻舆情 · SearXNG',
|
||||
status: 'failed',
|
||||
startedAt: '2026-06-08T10:00:06',
|
||||
metadata: {
|
||||
data_type: 'news_search',
|
||||
topologyParentId: 'topology_data_news_search',
|
||||
topologyRole: 'provider_attempt',
|
||||
topologyOrder: 2,
|
||||
},
|
||||
},
|
||||
]}
|
||||
edges={[]}
|
||||
expandedNodeIds={new Set(['topology_data_realtime_quote', 'topology_data_news_search'])}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(layoutRowFor('run-flow-node-provider_realtime_tushare_1')).toBe(
|
||||
layoutRowFor('run-flow-node-topology_data_realtime_quote') + 1,
|
||||
);
|
||||
expect(layoutRowFor('run-flow-node-provider_realtime_akshare_2')).toBe(
|
||||
layoutRowFor('run-flow-node-provider_realtime_tushare_1') + 1,
|
||||
);
|
||||
expect(layoutRowFor('run-flow-node-topology_data_news_search')).toBeGreaterThan(
|
||||
layoutRowFor('run-flow-node-provider_realtime_akshare_2'),
|
||||
);
|
||||
expect(layoutRowFor('run-flow-node-provider_news_tavily_1')).toBe(
|
||||
layoutRowFor('run-flow-node-topology_data_news_search') + 1,
|
||||
);
|
||||
expect(layoutRowFor('run-flow-node-provider_news_searxng_2')).toBe(
|
||||
layoutRowFor('run-flow-node-provider_news_tavily_1') + 1,
|
||||
);
|
||||
expect(layoutRowFor('run-flow-node-daily')).toBeGreaterThan(
|
||||
layoutRowFor('run-flow-node-provider_news_searxng_2'),
|
||||
);
|
||||
expect(screen.getByTestId('run-flow-expanded-group-topology_data_realtime_quote')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('run-flow-expanded-group-topology_data_news_search')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { RunFlowNode } from '../../../types/runFlow';
|
||||
import { RunFlowNodeDetails } from '../RunFlowNodeDetails';
|
||||
|
||||
describe('RunFlowNodeDetails', () => {
|
||||
it('hides provider metrics that do not apply to queue nodes', () => {
|
||||
const node: RunFlowNode = {
|
||||
id: 'task_queue',
|
||||
lane: 'entry',
|
||||
kind: 'queue',
|
||||
label: '任务队列',
|
||||
status: 'success',
|
||||
startedAt: '2026-06-08T22:14:25',
|
||||
message: '任务进入运行队列',
|
||||
};
|
||||
|
||||
render(<RunFlowNodeDetails node={node} />);
|
||||
|
||||
expect(screen.getByText('任务队列')).toBeInTheDocument();
|
||||
expect(screen.getByText('类型')).toBeInTheDocument();
|
||||
expect(screen.getByText('队列')).toBeInTheDocument();
|
||||
expect(screen.getByText('开始时间')).toBeInTheDocument();
|
||||
expect(screen.queryByText('提供方')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('耗时')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('尝试次数')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('记录数')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders ContextPack quality metadata as structured details instead of raw JSON', () => {
|
||||
const node: RunFlowNode = {
|
||||
id: 'context_pack',
|
||||
lane: 'analysis',
|
||||
kind: 'analysis',
|
||||
label: 'ContextPack',
|
||||
status: 'degraded',
|
||||
metadata: {
|
||||
topologyGroup: 'context_pack',
|
||||
packVersion: '1.0',
|
||||
counts: {
|
||||
available: 4,
|
||||
missing: 1,
|
||||
partial: 1,
|
||||
fallback: 0,
|
||||
},
|
||||
dataQuality: {
|
||||
overallScore: 91,
|
||||
level: 'good',
|
||||
blockScores: {
|
||||
quote: 100,
|
||||
dailyBars: 100,
|
||||
technical: 100,
|
||||
news: 35,
|
||||
},
|
||||
},
|
||||
context_status_counts: {
|
||||
success: 4,
|
||||
degraded: 1,
|
||||
skipped: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(<RunFlowNodeDetails node={node} />);
|
||||
|
||||
expect(screen.getByText('上下文质量')).toBeInTheDocument();
|
||||
expect(screen.getByText('综合评分')).toBeInTheDocument();
|
||||
expect(screen.getByText('91')).toBeInTheDocument();
|
||||
expect(screen.getByText('数据块评分')).toBeInTheDocument();
|
||||
expect(screen.getByText('news')).toBeInTheDocument();
|
||||
expect(screen.getByText('35')).toBeInTheDocument();
|
||||
expect(screen.getByText('版本')).toBeInTheDocument();
|
||||
expect(screen.getByText('1.0')).toBeInTheDocument();
|
||||
expect(screen.queryByText('提供方')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('耗时')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('尝试次数')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('记录数')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('counts')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('dataQuality')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('context_status_counts')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/overallScore/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps provider metrics visible for data source nodes', () => {
|
||||
const node: RunFlowNode = {
|
||||
id: 'topology_data_realtime_quote',
|
||||
lane: 'data_source',
|
||||
kind: 'data_source',
|
||||
label: '实时行情',
|
||||
provider: 'TushareFetcher -> AkshareFetcher',
|
||||
status: 'fallback',
|
||||
durationMs: 750,
|
||||
attempts: 2,
|
||||
recordCount: 39,
|
||||
};
|
||||
|
||||
render(<RunFlowNodeDetails node={node} />);
|
||||
|
||||
expect(screen.getByText('提供方')).toBeInTheDocument();
|
||||
expect(screen.getByText('TushareFetcher -> AkshareFetcher')).toBeInTheDocument();
|
||||
expect(screen.getByText('耗时')).toBeInTheDocument();
|
||||
expect(screen.getByText('750 ms')).toBeInTheDocument();
|
||||
expect(screen.getByText('尝试次数')).toBeInTheDocument();
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
expect(screen.getByText('记录数')).toBeInTheDocument();
|
||||
expect(screen.getByText('39')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -285,6 +285,8 @@ describe('RunFlowPanel', () => {
|
||||
|
||||
expect(await screen.findByTestId('run-flow-panel')).toBeInTheDocument();
|
||||
expect(screen.getByText('贵州茅台运行流')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('run-flow-layout')).toHaveClass('xl:grid-cols-[minmax(0,1fr)_19.25rem]');
|
||||
expect(screen.getByTestId('run-flow-events-column')).toHaveClass('xl:max-h-[calc(100vh-18rem)]');
|
||||
expect(screen.getByTestId('run-flow-graph')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('run-flow-events')).toBeInTheDocument();
|
||||
expect(await screen.findByTestId('run-flow-node-details')).toHaveTextContent('新闻舆情');
|
||||
@@ -294,7 +296,7 @@ describe('RunFlowPanel', () => {
|
||||
expect(screen.getByTestId('run-flow-node-details')).toHaveTextContent('LLM 生成');
|
||||
expect(screen.getByTestId('run-flow-node-details')).toHaveTextContent('DeepSeek');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '新闻舆情 节点,状态 Fallback' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '新闻舆情 节点,状态 降级回退' }));
|
||||
|
||||
expect(screen.getByTestId('run-flow-node-details')).toHaveTextContent('fallbackFrom');
|
||||
expect(screen.getByTestId('run-flow-node-details')).toHaveTextContent('Tushare');
|
||||
@@ -332,7 +334,7 @@ describe('RunFlowPanel', () => {
|
||||
|
||||
expect(await screen.findByTestId('run-flow-node-details')).toHaveTextContent('新闻舆情');
|
||||
expect(screen.getByText('保存')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '新闻舆情 节点,状态 Fallback' })).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.getByRole('button', { name: '新闻舆情 节点,状态 降级回退' })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('expands provider attempt groups from node details', async () => {
|
||||
|
||||
@@ -144,6 +144,7 @@ describe('buildRunFlowTopologyModel', () => {
|
||||
});
|
||||
expect(newsGroup?.metadata).toMatchObject({
|
||||
topologyGroup: 'provider_attempts',
|
||||
topologyRole: 'provider_group',
|
||||
data_type: 'news_search',
|
||||
success_count: 1,
|
||||
failed_count: 1,
|
||||
@@ -246,6 +247,61 @@ describe('buildRunFlowTopologyModel', () => {
|
||||
expect(model.events.find((event) => event.id === 'evt-normalized-block')?.nodeId).toBe('context_pack');
|
||||
});
|
||||
|
||||
it('keeps retry-only provider groups successful when every attempt succeeds', () => {
|
||||
const retryOnlySnapshot: RunFlowSnapshot = {
|
||||
...baseSnapshot,
|
||||
nodes: baseSnapshot.nodes.map((node) => (
|
||||
node.id === 'provider_news_search_tavily_1'
|
||||
? { ...node, status: 'success' as const }
|
||||
: node
|
||||
)),
|
||||
edges: baseSnapshot.edges.map((edge) => (
|
||||
edge.id === 'news-1-news-2'
|
||||
? { ...edge, kind: 'retry' as const, status: 'success' as const }
|
||||
: edge
|
||||
)),
|
||||
};
|
||||
|
||||
const model = buildRunFlowTopologyModel(retryOnlySnapshot);
|
||||
const newsGroup = model.nodes.find((node) => node.id === 'topology_data_news_search');
|
||||
|
||||
expect(newsGroup).toMatchObject({
|
||||
status: 'success',
|
||||
attempts: 2,
|
||||
});
|
||||
expect(newsGroup?.metadata).toMatchObject({
|
||||
success_count: 2,
|
||||
failed_count: 0,
|
||||
fallback_count: 0,
|
||||
retry_count: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks mixed success and failure without recovery transitions as degraded', () => {
|
||||
const degradedSnapshot: RunFlowSnapshot = {
|
||||
...baseSnapshot,
|
||||
edges: baseSnapshot.edges.map((edge) => (
|
||||
edge.id === 'news-1-news-2'
|
||||
? { ...edge, kind: 'data' as const, status: 'success' as const }
|
||||
: edge
|
||||
)),
|
||||
};
|
||||
|
||||
const model = buildRunFlowTopologyModel(degradedSnapshot);
|
||||
const newsGroup = model.nodes.find((node) => node.id === 'topology_data_news_search');
|
||||
|
||||
expect(newsGroup).toMatchObject({
|
||||
status: 'degraded',
|
||||
attempts: 2,
|
||||
});
|
||||
expect(newsGroup?.metadata).toMatchObject({
|
||||
success_count: 1,
|
||||
failed_count: 1,
|
||||
fallback_count: 0,
|
||||
retry_count: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('attaches context block states to ContextPack and remaps events', () => {
|
||||
const model = buildRunFlowTopologyModel(baseSnapshot);
|
||||
const contextPack = model.nodes.find((node) => node.id === 'context_pack');
|
||||
@@ -285,4 +341,66 @@ describe('buildRunFlowTopologyModel', () => {
|
||||
);
|
||||
expect(model.events.find((event) => event.id === 'evt-news-1')?.nodeId).toBe('provider_news_search_tavily_1');
|
||||
});
|
||||
|
||||
it('adds stable topology metadata to expanded provider attempts even when data_type is missing', () => {
|
||||
const snapshotWithoutDataType: RunFlowSnapshot = {
|
||||
...baseSnapshot,
|
||||
nodes: baseSnapshot.nodes.map((node) => {
|
||||
if (!node.id.startsWith('provider_')) {
|
||||
return node;
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
metadata: {},
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const model = buildRunFlowTopologyModel(snapshotWithoutDataType, {
|
||||
expandedGroupIds: new Set(['topology_data_news_search']),
|
||||
});
|
||||
const tavily = model.nodes.find((node) => node.id === 'provider_news_search_tavily_1');
|
||||
const searxng = model.nodes.find((node) => node.id === 'provider_news_search_searxng_2');
|
||||
|
||||
expect(tavily?.metadata).toMatchObject({
|
||||
data_type: 'news_search',
|
||||
topologyParentId: 'topology_data_news_search',
|
||||
topologyRole: 'provider_attempt',
|
||||
topologyOrder: 1,
|
||||
});
|
||||
expect(searxng?.metadata).toMatchObject({
|
||||
data_type: 'news_search',
|
||||
topologyParentId: 'topology_data_news_search',
|
||||
topologyRole: 'provider_attempt',
|
||||
topologyOrder: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps provider group running while any provider attempt is still running', () => {
|
||||
const runningSnapshot: RunFlowSnapshot = {
|
||||
...baseSnapshot,
|
||||
status: 'running',
|
||||
nodes: baseSnapshot.nodes.map((node) => {
|
||||
if (node.id === 'provider_news_search_tavily_1') {
|
||||
return {
|
||||
...node,
|
||||
status: 'success',
|
||||
};
|
||||
}
|
||||
if (node.id === 'provider_news_search_searxng_2') {
|
||||
return {
|
||||
...node,
|
||||
status: 'running',
|
||||
endedAt: null,
|
||||
};
|
||||
}
|
||||
return node;
|
||||
}),
|
||||
};
|
||||
|
||||
const model = buildRunFlowTopologyModel(runningSnapshot);
|
||||
const newsGroup = model.nodes.find((node) => node.id === 'topology_data_news_search');
|
||||
|
||||
expect(newsGroup?.status).toBe('running');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,11 +106,16 @@ const labelForDataType = (dataType: string, nodes: RunFlowNode[]): string => {
|
||||
|
||||
const groupStatus = (nodes: RunFlowNode[], edges: RunFlowEdge[]): RunFlowStatus => {
|
||||
const statuses = nodes.map((node) => node.status);
|
||||
if (statuses.length === 0) return 'unknown';
|
||||
if (statuses.includes('cancel_requested')) return 'cancel_requested';
|
||||
if (statuses.some((status) => status === 'running' || status === 'pending')) return 'running';
|
||||
if (statuses.every((status) => status === 'success')) return 'success';
|
||||
const hasSuccess = statuses.includes('success');
|
||||
const hasFallbackSignal = statuses.includes('fallback')
|
||||
|| edges.some((edge) => edge.kind === 'fallback' || edge.kind === 'retry');
|
||||
if (hasSuccess && hasFallbackSignal) return 'fallback';
|
||||
if (hasSuccess && statuses.some((status) => ['failed', 'timeout', 'degraded'].includes(status))) return 'degraded';
|
||||
const hasFailedOrTimeout = statuses.some((status) => status === 'failed' || status === 'timeout');
|
||||
const hasFallbackAttempt = statuses.includes('fallback');
|
||||
const hasRecoveryTransition = edges.some((edge) => edge.kind === 'fallback' || edge.kind === 'retry');
|
||||
if (hasSuccess && (hasFailedOrTimeout || hasFallbackAttempt) && hasRecoveryTransition) return 'fallback';
|
||||
if (hasSuccess && statuses.some((status) => ['failed', 'timeout', 'degraded', 'fallback'].includes(status))) return 'degraded';
|
||||
return statuses.reduce<RunFlowStatus>((winner, status) => (
|
||||
statusRank(status) > statusRank(winner) ? status : winner
|
||||
), 'unknown');
|
||||
@@ -159,6 +164,7 @@ const buildProviderGroupNode = (
|
||||
recordCount: firstDefinedRecordCount([...sortedAttempts].reverse()),
|
||||
metadata: {
|
||||
topologyGroup: 'provider_attempts',
|
||||
topologyRole: 'provider_group',
|
||||
expanded,
|
||||
data_type: dataType,
|
||||
provider_chain: providerChain,
|
||||
@@ -236,6 +242,25 @@ export const buildRunFlowTopologyModel = (
|
||||
nodeIdMap.set(node.id, options.expandedGroupIds?.has(groupId) ? node.id : groupId);
|
||||
});
|
||||
|
||||
const attemptTopologyById = new Map<string, {
|
||||
dataType: string;
|
||||
groupId: string;
|
||||
order: number;
|
||||
}>();
|
||||
attemptsByDataType.forEach((attempts, dataType) => {
|
||||
[...attempts]
|
||||
.sort((left, right) => (
|
||||
(nodeTime(left) ?? Number.MAX_SAFE_INTEGER) - (nodeTime(right) ?? Number.MAX_SAFE_INTEGER)
|
||||
))
|
||||
.forEach((node, index) => {
|
||||
attemptTopologyById.set(node.id, {
|
||||
dataType,
|
||||
groupId: `${PROVIDER_GROUP_PREFIX}${dataType}`,
|
||||
order: index + 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const providerGroupNodes = Array.from(attemptsByDataType.entries()).map(([dataType, attempts]) => {
|
||||
const attemptIds = new Set(attempts.map((node) => node.id));
|
||||
const attemptEdges = snapshot.edges.filter((edge) => attemptIds.has(edge.from) && attemptIds.has(edge.to));
|
||||
@@ -256,6 +281,22 @@ export const buildRunFlowTopologyModel = (
|
||||
|
||||
const visibleNodes = snapshot.nodes
|
||||
.filter((node) => !collapsedNodeIds.has(node.id))
|
||||
.map((node) => {
|
||||
const topology = attemptTopologyById.get(node.id);
|
||||
if (!topology) {
|
||||
return node;
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
metadata: {
|
||||
...(node.metadata || {}),
|
||||
data_type: topology.dataType,
|
||||
topologyParentId: topology.groupId,
|
||||
topologyRole: 'provider_attempt',
|
||||
topologyOrder: topology.order,
|
||||
},
|
||||
};
|
||||
})
|
||||
.map((node) => attachContextBlocksToPack(node, contextBlocks));
|
||||
|
||||
const nodes = [...visibleNodes, ...providerGroupNodes];
|
||||
|
||||
@@ -36,9 +36,10 @@ const TaskItem: React.FC<TaskItemProps> = ({ task, onOpenRunFlow }) => {
|
||||
const requestedPhaseVariant = task.analysisPhase === 'auto' ? 'default' : 'info';
|
||||
|
||||
return (
|
||||
<div className="home-subpanel flex items-center gap-3 px-3 py-2.5">
|
||||
{/* 状态图标 */}
|
||||
<div className="shrink-0">
|
||||
<div className="home-subpanel grid min-w-0 gap-2.5 px-3 py-2.5" data-testid="task-panel-item">
|
||||
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-2">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<div className="shrink-0 pt-1.5">
|
||||
{isProcessing ? (
|
||||
<StatusDot tone="info" pulse className="h-2.5 w-2.5" aria-label={t('taskPanel.processingAria')} />
|
||||
) : isCancelRequested ? (
|
||||
@@ -48,60 +49,19 @@ const TaskItem: React.FC<TaskItemProps> = ({ task, onOpenRunFlow }) => {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 任务信息 */}
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground truncate">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span className="max-w-full truncate text-sm font-medium text-foreground">
|
||||
{task.stockName || task.stockCode}
|
||||
</span>
|
||||
<span className="text-xs text-muted-text">
|
||||
<span className="shrink-0 text-xs text-muted-text">
|
||||
{task.stockCode}
|
||||
</span>
|
||||
</div>
|
||||
{task.message && (
|
||||
<p className="text-xs text-secondary-text truncate mt-0.5">
|
||||
{task.message}
|
||||
</p>
|
||||
)}
|
||||
{requestedPhaseLabel ? (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
||||
<Badge variant={requestedPhaseVariant} className="shrink-0 shadow-none" aria-label={requestedPhaseLabel}>
|
||||
{requestedPhaseLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-white/8">
|
||||
<div
|
||||
className="h-full rounded-full bg-cyan transition-[width] duration-300 ease-out"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="shrink-0 text-[11px] text-muted-text tabular-nums">
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
{traceId ? (
|
||||
<details className="group/task mt-2 text-xs">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-2 text-muted-text">
|
||||
<span>{t('taskPanel.diagnostics')}</span>
|
||||
<span className="font-mono text-[11px] text-secondary-text">
|
||||
{traceId.length > 18 ? `${traceId.slice(0, 10)}...` : traceId}
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 transition-transform group-open/task:rotate-180" aria-hidden="true" />
|
||||
</summary>
|
||||
<div className="mt-1 rounded-lg border border-subtle bg-base/50 px-2 py-1.5 text-muted-text">
|
||||
<span className="mr-1">Trace:</span>
|
||||
<code className="break-all font-mono text-[11px] text-secondary-text">
|
||||
{traceId}
|
||||
</code>
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 状态标签 */}
|
||||
<div className="relative z-10 flex flex-shrink-0 items-center gap-2">
|
||||
<div className="relative z-10 flex shrink-0 items-center gap-1.5">
|
||||
{onOpenRunFlow ? (
|
||||
<Tooltip content={t('taskPanel.openRunFlow')}>
|
||||
<span className="inline-flex">
|
||||
@@ -125,14 +85,62 @@ const TaskItem: React.FC<TaskItemProps> = ({ task, onOpenRunFlow }) => {
|
||||
) : null}
|
||||
<Badge
|
||||
variant={statusVariant}
|
||||
className="min-w-[4.75rem] justify-center gap-1.5 shadow-none"
|
||||
className="min-w-[4.75rem] max-w-[7rem] justify-center gap-1.5 whitespace-nowrap shadow-none"
|
||||
aria-label={t('taskPanel.statusAria', { status: statusLabel })}
|
||||
>
|
||||
<StatusDot tone={statusTone} pulse={isProcessing || isCancelRequested} className="h-1.5 w-1.5" />
|
||||
{statusLabel}
|
||||
<StatusDot tone={statusTone} pulse={isProcessing || isCancelRequested} className="h-1.5 w-1.5 shrink-0" />
|
||||
<span className="min-w-0 truncate">{statusLabel}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{task.message ? (
|
||||
<p className="min-w-0 truncate text-xs text-secondary-text">
|
||||
{task.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{requestedPhaseLabel ? (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<Badge variant={requestedPhaseVariant} className="max-w-full shrink-0 truncate shadow-none" aria-label={requestedPhaseLabel}>
|
||||
{requestedPhaseLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-white/8">
|
||||
<div
|
||||
className="h-full rounded-full bg-cyan transition-[width] duration-300 ease-out"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="shrink-0 text-[11px] text-muted-text tabular-nums">
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{traceId ? (
|
||||
<details className="group/task text-xs">
|
||||
<summary
|
||||
className="grid cursor-pointer list-none grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 text-muted-text"
|
||||
data-testid="task-panel-diagnostics-summary"
|
||||
>
|
||||
<span className="whitespace-nowrap">{t('taskPanel.diagnostics')}</span>
|
||||
<span className="min-w-0 truncate font-mono text-[11px] text-secondary-text">
|
||||
{traceId.length > 18 ? `${traceId.slice(0, 10)}...` : traceId}
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 transition-transform group-open/task:rotate-180" aria-hidden="true" />
|
||||
</summary>
|
||||
<div className="mt-1 rounded-lg border border-subtle bg-base/50 px-2 py-1.5 text-muted-text">
|
||||
<span className="mr-1">Trace:</span>
|
||||
<code className="break-all font-mono text-[11px] text-secondary-text">
|
||||
{traceId}
|
||||
</code>
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -72,6 +72,38 @@ describe('TaskPanel', () => {
|
||||
expect(container.querySelector('.home-subpanel')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps narrow sidebar task metadata in rows instead of squeezing diagnostics vertically', () => {
|
||||
render(
|
||||
<TaskPanel
|
||||
tasks={[
|
||||
{
|
||||
...baseTask,
|
||||
stockCode: '601869.SH',
|
||||
stockName: '长飞光纤',
|
||||
progress: 32,
|
||||
message: '长飞光纤: 请求阶段: 自动阶段',
|
||||
analysisPhase: 'auto',
|
||||
traceId: 'c5b9665a64e3b9f42ad9f',
|
||||
},
|
||||
]}
|
||||
onOpenRunFlow={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const item = screen.getByTestId('task-panel-item');
|
||||
expect(item).toHaveClass('grid');
|
||||
expect(item).not.toHaveClass('flex');
|
||||
expect(screen.getByText('长飞光纤')).toHaveClass('truncate');
|
||||
expect(screen.getByText('601869.SH')).toHaveClass('shrink-0');
|
||||
expect(screen.getByText('32%')).toBeInTheDocument();
|
||||
|
||||
const diagnosticsSummary = screen.getByTestId('task-panel-diagnostics-summary');
|
||||
expect(diagnosticsSummary).toHaveClass('grid-cols-[auto_minmax(0,1fr)_auto]');
|
||||
expect(screen.getByText('运行诊断')).toHaveClass('whitespace-nowrap');
|
||||
expect(screen.getByText('c5b9665a64...')).toHaveClass('truncate');
|
||||
expect(screen.getByRole('button', { name: '查看 长飞光纤 运行流' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the run-flow view from an active task icon button', () => {
|
||||
const onOpenRunFlow = vi.fn();
|
||||
render(
|
||||
|
||||
@@ -140,6 +140,92 @@ describe('useRunFlowSnapshot', () => {
|
||||
await waitFor(() => expect(analysisApi.getTaskFlow).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('updates started live flow nodes in place when finish events arrive', async () => {
|
||||
vi.mocked(analysisApi.getTaskFlow).mockResolvedValue(snapshot);
|
||||
|
||||
const { result } = renderHook(() => useRunFlowSnapshot({
|
||||
source: { type: 'task', taskId: 'task-1' },
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
await waitFor(() => expect(result.current.snapshot).not.toBeNull());
|
||||
|
||||
act(() => {
|
||||
taskStreamCalls.at(-1)?.onTaskFlowEvent?.(
|
||||
{
|
||||
taskId: 'task-1',
|
||||
stockCode: '600519',
|
||||
status: 'processing',
|
||||
progress: 30,
|
||||
reportType: 'detailed',
|
||||
createdAt: '2026-06-08T08:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'flow-provider-started',
|
||||
timestamp: '2026-06-08T08:00:01Z',
|
||||
severity: 'info',
|
||||
type: 'provider_run_started',
|
||||
nodeId: 'provider_daily_data_dailyfetcher_1',
|
||||
title: '日线K线开始',
|
||||
metadata: {
|
||||
provider: 'DailyFetcher',
|
||||
dataType: 'daily_data',
|
||||
node: {
|
||||
id: 'provider_daily_data_dailyfetcher_1',
|
||||
lane: 'data_source',
|
||||
kind: 'data_source',
|
||||
label: '日线K线 · DailyFetcher',
|
||||
status: 'running',
|
||||
provider: 'DailyFetcher',
|
||||
metadata: { dataType: 'daily_data' },
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
taskStreamCalls.at(-1)?.onTaskFlowEvent?.(
|
||||
{
|
||||
taskId: 'task-1',
|
||||
stockCode: '600519',
|
||||
status: 'processing',
|
||||
progress: 35,
|
||||
reportType: 'detailed',
|
||||
createdAt: '2026-06-08T08:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'flow-provider-finished',
|
||||
timestamp: '2026-06-08T08:00:02Z',
|
||||
severity: 'success',
|
||||
type: 'provider_run',
|
||||
nodeId: 'provider_daily_data_dailyfetcher_1',
|
||||
title: '日线K线成功',
|
||||
metadata: {
|
||||
provider: 'DailyFetcher',
|
||||
dataType: 'daily_data',
|
||||
node: {
|
||||
id: 'provider_daily_data_dailyfetcher_1',
|
||||
lane: 'data_source',
|
||||
kind: 'data_source',
|
||||
label: '日线K线 · DailyFetcher',
|
||||
status: 'success',
|
||||
provider: 'DailyFetcher',
|
||||
recordCount: 30,
|
||||
metadata: { dataType: 'daily_data' },
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const providerNodes = result.current.snapshot?.nodes.filter((node) => (
|
||||
node.id === 'provider_daily_data_dailyfetcher_1'
|
||||
));
|
||||
expect(providerNodes).toHaveLength(1);
|
||||
expect(providerNodes?.[0]).toEqual(expect.objectContaining({
|
||||
status: 'success',
|
||||
recordCount: 30,
|
||||
}));
|
||||
});
|
||||
|
||||
it('does not enable task stream for history snapshots', async () => {
|
||||
vi.mocked(historyApi.getRecordFlow).mockResolvedValue({ ...snapshot, status: 'success' });
|
||||
|
||||
@@ -565,4 +651,100 @@ describe('useRunFlowSnapshot', () => {
|
||||
expect(lateEvent?.metadata).not.toHaveProperty('node');
|
||||
expect(result.current.snapshot?.nodes.some((node) => node.id === 'provider_news_1')).toBe(true);
|
||||
});
|
||||
|
||||
it('skips replaying buffered events when refreshed snapshot already has a completed node', async () => {
|
||||
const initialRequest = createDeferred<RunFlowSnapshot>();
|
||||
const refreshedRequest = createDeferred<RunFlowSnapshot>();
|
||||
vi.mocked(analysisApi.getTaskFlow)
|
||||
.mockReturnValueOnce(initialRequest.promise)
|
||||
.mockReturnValueOnce(refreshedRequest.promise);
|
||||
|
||||
const { result } = renderHook(() => useRunFlowSnapshot({
|
||||
source: { type: 'task', taskId: 'task-1' },
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
act(() => {
|
||||
initialRequest.resolve(snapshot);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.snapshot?.events).toHaveLength(1));
|
||||
|
||||
const liveNotificationEvent = {
|
||||
id: 'flow-live-notification',
|
||||
timestamp: '2026-06-08T08:00:02Z',
|
||||
severity: 'warning' as const,
|
||||
type: 'notification_run' as const,
|
||||
nodeId: 'notification_report_1',
|
||||
title: '通知跳过',
|
||||
metadata: {
|
||||
channel: 'report',
|
||||
node: {
|
||||
id: 'notification_report_1',
|
||||
lane: 'artifact',
|
||||
kind: 'notification',
|
||||
label: '推送通知 · report',
|
||||
status: 'skipped',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
act(() => {
|
||||
taskStreamCalls.at(-1)?.onTaskFlowEvent?.(
|
||||
{
|
||||
taskId: 'task-1',
|
||||
stockCode: '600519',
|
||||
status: 'processing',
|
||||
progress: 99,
|
||||
reportType: 'detailed',
|
||||
createdAt: '2026-06-08T08:00:00Z',
|
||||
},
|
||||
liveNotificationEvent,
|
||||
);
|
||||
taskStreamCalls.at(-1)?.onTaskCompleted?.({
|
||||
taskId: 'task-1',
|
||||
stockCode: '600519',
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
reportType: 'detailed',
|
||||
createdAt: '2026-06-08T08:00:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => expect(analysisApi.getTaskFlow).toHaveBeenCalledTimes(2));
|
||||
|
||||
act(() => {
|
||||
refreshedRequest.resolve({
|
||||
...snapshot,
|
||||
status: 'success',
|
||||
nodes: [
|
||||
...snapshot.nodes,
|
||||
{
|
||||
id: 'notification_report_1',
|
||||
lane: 'artifact',
|
||||
kind: 'notification',
|
||||
label: '推送通知 · report',
|
||||
status: 'skipped',
|
||||
},
|
||||
],
|
||||
events: [
|
||||
...snapshot.events,
|
||||
{
|
||||
id: 'history-notification',
|
||||
timestamp: '2026-06-08T08:00:02Z',
|
||||
severity: 'warning',
|
||||
type: 'notification_run',
|
||||
nodeId: 'notification_report_1',
|
||||
title: '通知跳过',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.snapshot?.status).toBe('success'));
|
||||
|
||||
expect(result.current.snapshot?.events.some((event) => event.id === 'history-notification')).toBe(true);
|
||||
expect(result.current.snapshot?.events.some((event) => event.id === 'flow-live-notification')).toBe(false);
|
||||
expect(result.current.snapshot?.nodes.filter((node) => node.id === 'notification_report_1')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,6 +149,28 @@ const appendEdge = (
|
||||
];
|
||||
};
|
||||
|
||||
const refreshIncomingEdgeStatus = (
|
||||
edges: RunFlowEdge[],
|
||||
nodeId: string | null,
|
||||
status?: RunFlowEdge['status'],
|
||||
): RunFlowEdge[] => {
|
||||
if (!nodeId || !status) {
|
||||
return edges;
|
||||
}
|
||||
let changed = false;
|
||||
const refreshed = edges.map((edge) => {
|
||||
if (edge.to !== nodeId || edge.status === status) {
|
||||
return edge;
|
||||
}
|
||||
changed = true;
|
||||
return {
|
||||
...edge,
|
||||
status,
|
||||
};
|
||||
});
|
||||
return changed ? refreshed : edges;
|
||||
};
|
||||
|
||||
const providerTransitionKind = (
|
||||
previous: { provider: string | null; success: boolean; fallbackTo: string | null },
|
||||
current: { provider: string | null; success: boolean; fallbackFrom: string | null },
|
||||
@@ -191,12 +213,16 @@ const appendDerivedEdge = (
|
||||
return edges;
|
||||
}
|
||||
|
||||
if (displayEvent.type === 'provider_run') {
|
||||
if (displayEvent.type === 'provider_run' || displayEvent.type === 'provider_run_started') {
|
||||
const dataType = dataTypeFromEvent(displayEvent, node);
|
||||
const currentTime = eventTime(displayEvent);
|
||||
const previousEvent = events
|
||||
.filter((event) => {
|
||||
if (event.id === displayEvent.id || event.type !== 'provider_run' || !event.nodeId) {
|
||||
if (
|
||||
event.id === displayEvent.id
|
||||
|| (event.type !== 'provider_run' && event.type !== 'provider_run_started')
|
||||
|| !event.nodeId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (eventTime(event) >= currentTime) {
|
||||
@@ -231,7 +257,7 @@ const appendDerivedEdge = (
|
||||
return appendEdge(edges, previousNode.id, nodeId, transitionKind, node.status, label, message);
|
||||
}
|
||||
|
||||
if (displayEvent.type === 'llm_run') {
|
||||
if (displayEvent.type === 'llm_run' || displayEvent.type === 'llm_run_started') {
|
||||
const anchor = nodeById.has('analysis_pipeline') ? 'analysis_pipeline' : 'task_queue';
|
||||
return nodeById.has(anchor)
|
||||
? appendEdge(edges, anchor, nodeId, 'data', node.status, '生成')
|
||||
@@ -239,7 +265,7 @@ const appendDerivedEdge = (
|
||||
}
|
||||
|
||||
if (displayEvent.type === 'history_run') {
|
||||
const anchor = latestEventNodeId(events, nodeById, ['llm_run'], displayEvent)
|
||||
const anchor = latestEventNodeId(events, nodeById, ['llm_run', 'llm_run_started'], displayEvent)
|
||||
|| (nodeById.has('analysis_pipeline') ? 'analysis_pipeline' : 'task_queue');
|
||||
return nodeById.has(anchor)
|
||||
? appendEdge(edges, anchor, nodeId, 'data', node.status, '保存')
|
||||
@@ -248,7 +274,7 @@ const appendDerivedEdge = (
|
||||
|
||||
if (displayEvent.type === 'notification_run') {
|
||||
const anchor = latestEventNodeId(events, nodeById, ['history_run'], displayEvent)
|
||||
|| latestEventNodeId(events, nodeById, ['llm_run'], displayEvent)
|
||||
|| latestEventNodeId(events, nodeById, ['llm_run', 'llm_run_started'], displayEvent)
|
||||
|| (nodeById.has('analysis_pipeline') ? 'analysis_pipeline' : 'task_queue');
|
||||
return nodeById.has(anchor)
|
||||
? appendEdge(edges, anchor, nodeId, 'control', node.status, '通知')
|
||||
@@ -331,12 +357,16 @@ const mergeFlowEventIntoSnapshot = (
|
||||
const nodes = upsertNode(snapshot.nodes, node);
|
||||
const edges = eventAlreadyPresent
|
||||
? snapshot.edges
|
||||
: appendDerivedEdge(
|
||||
: refreshIncomingEdgeStatus(
|
||||
appendDerivedEdge(
|
||||
nodes,
|
||||
snapshot.edges,
|
||||
events,
|
||||
displayEvent,
|
||||
eventNodeId(displayEvent, node),
|
||||
),
|
||||
eventNodeId(displayEvent, node),
|
||||
node?.status,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -359,11 +389,34 @@ const rememberFlowEvent = (events: RunFlowEvent[], flowEvent: RunFlowEvent): Run
|
||||
.slice(-MAX_BUFFERED_FLOW_EVENTS);
|
||||
};
|
||||
|
||||
const ACTIVE_NODE_STATUSES = new Set(['pending', 'running', 'cancel_requested']);
|
||||
|
||||
const replayEventNodeId = (flowEvent: RunFlowEvent): string | null => {
|
||||
const nodeCandidate = flowEvent.metadata?.node;
|
||||
if (isRunFlowNode(nodeCandidate)) {
|
||||
return nodeCandidate.id;
|
||||
}
|
||||
return flowEvent.nodeId || null;
|
||||
};
|
||||
|
||||
const shouldReplayFlowEvent = (snapshot: RunFlowSnapshot, flowEvent: RunFlowEvent): boolean => {
|
||||
const nodeId = replayEventNodeId(flowEvent);
|
||||
if (!nodeId) {
|
||||
return true;
|
||||
}
|
||||
const existingNode = snapshot.nodes.find((node) => node.id === nodeId);
|
||||
return !existingNode || ACTIVE_NODE_STATUSES.has(existingNode.status);
|
||||
};
|
||||
|
||||
const replayFlowEvents = (
|
||||
snapshot: RunFlowSnapshot,
|
||||
flowEvents: RunFlowEvent[],
|
||||
): RunFlowSnapshot => flowEvents.reduce(
|
||||
(currentSnapshot, flowEvent) => mergeFlowEventIntoSnapshot(currentSnapshot, flowEvent),
|
||||
(currentSnapshot, flowEvent) => (
|
||||
shouldReplayFlowEvent(currentSnapshot, flowEvent)
|
||||
? mergeFlowEventIntoSnapshot(currentSnapshot, flowEvent)
|
||||
: currentSnapshot
|
||||
),
|
||||
snapshot,
|
||||
);
|
||||
|
||||
|
||||
@@ -244,8 +244,8 @@ const zh = {
|
||||
'runFlow.status.running': '运行中',
|
||||
'runFlow.status.success': '成功',
|
||||
'runFlow.status.failed': '失败',
|
||||
'runFlow.status.degraded': '降级',
|
||||
'runFlow.status.fallback': 'Fallback',
|
||||
'runFlow.status.degraded': '部分降级',
|
||||
'runFlow.status.fallback': '降级回退',
|
||||
'runFlow.status.timeout': '超时',
|
||||
'runFlow.status.cancelRequested': '请求取消',
|
||||
'runFlow.status.cancelled': '已取消',
|
||||
@@ -257,8 +257,10 @@ const zh = {
|
||||
'runFlow.severity.danger': '危险',
|
||||
'runFlow.edge.data': '数据',
|
||||
'runFlow.edge.control': '控制',
|
||||
'runFlow.edge.fallback': '降级',
|
||||
'runFlow.edge.fallback': '降级回退',
|
||||
'runFlow.edge.retry': '重试',
|
||||
'runFlow.edgeLabel.invoke': '调用',
|
||||
'runFlow.edgeLabel.details': '详情',
|
||||
'runFlow.nodeKind.entry': '入口',
|
||||
'runFlow.nodeKind.queue': '队列',
|
||||
'runFlow.nodeKind.dataSource': '数据源',
|
||||
@@ -267,7 +269,7 @@ const zh = {
|
||||
'runFlow.nodeKind.artifact': '产物',
|
||||
'runFlow.nodeKind.notification': '通知',
|
||||
'runFlow.summary.elapsed': '总耗时',
|
||||
'runFlow.summary.fallbackCount': '降级/重试',
|
||||
'runFlow.summary.fallbackCount': '降级回退/重试',
|
||||
'runFlow.summary.failedAttempts': '失败尝试',
|
||||
'runFlow.summary.dataSources': '数据源',
|
||||
'runFlow.summary.task': 'Task',
|
||||
@@ -278,13 +280,17 @@ const zh = {
|
||||
'runFlow.graph.description': '自动分层展示入口、数据来源、分析引擎和产物链路。',
|
||||
'runFlow.graph.nodeAria': '{label} 节点,状态 {status}',
|
||||
'runFlow.graph.startedAt': '开始',
|
||||
'runFlow.graph.expand': '展开',
|
||||
'runFlow.graph.collapse': '收起',
|
||||
'runFlow.graph.expandNode': '展开 {label} 运行尝试',
|
||||
'runFlow.graph.collapseNode': '收起 {label} 运行尝试',
|
||||
'runFlow.events.title': '事件流',
|
||||
'runFlow.events.count': '{count} 条事件',
|
||||
'runFlow.events.filters': '事件筛选',
|
||||
'runFlow.events.filter.all': '全部',
|
||||
'runFlow.events.filter.important': '关键',
|
||||
'runFlow.events.filter.problems': '失败/告警',
|
||||
'runFlow.events.filter.fallback': '降级/重试',
|
||||
'runFlow.events.filter.fallback': '降级回退/重试',
|
||||
'runFlow.events.filter.cancelled': '取消',
|
||||
'runFlow.events.openNode': '查看事件 {title} 关联节点',
|
||||
'runFlow.events.empty': '当前筛选下暂无事件。',
|
||||
@@ -292,7 +298,8 @@ const zh = {
|
||||
'runFlow.nodeDetails.title': '节点详情',
|
||||
'runFlow.nodeDetails.close': '关闭节点详情',
|
||||
'runFlow.nodeDetails.kind': '类型',
|
||||
'runFlow.nodeDetails.provider': 'Provider',
|
||||
'runFlow.nodeDetails.version': '版本',
|
||||
'runFlow.nodeDetails.provider': '提供方',
|
||||
'runFlow.nodeDetails.duration': '耗时',
|
||||
'runFlow.nodeDetails.attempts': '尝试次数',
|
||||
'runFlow.nodeDetails.recordCount': '记录数',
|
||||
@@ -308,6 +315,16 @@ const zh = {
|
||||
'runFlow.nodeDetails.column.duration': '耗时',
|
||||
'runFlow.nodeDetails.column.records': '记录',
|
||||
'runFlow.nodeDetails.column.time': '时间',
|
||||
'runFlow.nodeDetails.contextQuality': '上下文质量',
|
||||
'runFlow.nodeDetails.overallScore': '综合评分',
|
||||
'runFlow.nodeDetails.qualityLevel': '等级',
|
||||
'runFlow.nodeDetails.blockScores': '数据块评分',
|
||||
'runFlow.nodeDetails.count.available': '可用',
|
||||
'runFlow.nodeDetails.count.missing': '缺失',
|
||||
'runFlow.nodeDetails.count.partial': '部分',
|
||||
'runFlow.nodeDetails.count.degraded': '部分降级',
|
||||
'runFlow.nodeDetails.count.fallback': '降级回退',
|
||||
'runFlow.nodeDetails.count.skipped': '跳过',
|
||||
|
||||
'report.addToWatchlist': '加入自选',
|
||||
'report.removeFromWatchlist': '从自选删除',
|
||||
@@ -750,6 +767,8 @@ const en: Record<UiTextKey, string> = {
|
||||
'runFlow.edge.control': 'Control',
|
||||
'runFlow.edge.fallback': 'Fallback',
|
||||
'runFlow.edge.retry': 'Retry',
|
||||
'runFlow.edgeLabel.invoke': 'Invoke',
|
||||
'runFlow.edgeLabel.details': 'Details',
|
||||
'runFlow.nodeKind.entry': 'Entry',
|
||||
'runFlow.nodeKind.queue': 'Queue',
|
||||
'runFlow.nodeKind.dataSource': 'Data source',
|
||||
@@ -769,6 +788,10 @@ const en: Record<UiTextKey, string> = {
|
||||
'runFlow.graph.description': 'Auto-layered lanes show entry, data sources, analysis engines, and artifact paths.',
|
||||
'runFlow.graph.nodeAria': '{label} node, status {status}',
|
||||
'runFlow.graph.startedAt': 'Start',
|
||||
'runFlow.graph.expand': 'Expand',
|
||||
'runFlow.graph.collapse': 'Collapse',
|
||||
'runFlow.graph.expandNode': 'Expand {label} attempts',
|
||||
'runFlow.graph.collapseNode': 'Collapse {label} attempts',
|
||||
'runFlow.events.title': 'Event stream',
|
||||
'runFlow.events.count': '{count} events',
|
||||
'runFlow.events.filters': 'Event filters',
|
||||
@@ -783,6 +806,7 @@ const en: Record<UiTextKey, string> = {
|
||||
'runFlow.nodeDetails.title': 'Node details',
|
||||
'runFlow.nodeDetails.close': 'Close node details',
|
||||
'runFlow.nodeDetails.kind': 'Kind',
|
||||
'runFlow.nodeDetails.version': 'Version',
|
||||
'runFlow.nodeDetails.provider': 'Provider',
|
||||
'runFlow.nodeDetails.duration': 'Duration',
|
||||
'runFlow.nodeDetails.attempts': 'Attempts',
|
||||
@@ -799,6 +823,16 @@ const en: Record<UiTextKey, string> = {
|
||||
'runFlow.nodeDetails.column.duration': 'Duration',
|
||||
'runFlow.nodeDetails.column.records': 'Records',
|
||||
'runFlow.nodeDetails.column.time': 'Time',
|
||||
'runFlow.nodeDetails.contextQuality': 'Context quality',
|
||||
'runFlow.nodeDetails.overallScore': 'Overall score',
|
||||
'runFlow.nodeDetails.qualityLevel': 'Level',
|
||||
'runFlow.nodeDetails.blockScores': 'Block scores',
|
||||
'runFlow.nodeDetails.count.available': 'Available',
|
||||
'runFlow.nodeDetails.count.missing': 'Missing',
|
||||
'runFlow.nodeDetails.count.partial': 'Partial',
|
||||
'runFlow.nodeDetails.count.degraded': 'Degraded',
|
||||
'runFlow.nodeDetails.count.fallback': 'Fallback',
|
||||
'runFlow.nodeDetails.count.skipped': 'Skipped',
|
||||
|
||||
'report.addToWatchlist': 'Add to watchlist',
|
||||
'report.removeFromWatchlist': 'Remove from watchlist',
|
||||
|
||||
@@ -26,7 +26,7 @@ import pandas as pd
|
||||
import numpy as np
|
||||
from src.data.stock_index_loader import get_index_stock_name
|
||||
from src.data.stock_mapping import STOCK_NAME_MAP, is_meaningful_stock_name
|
||||
from src.services.run_diagnostics import record_provider_run
|
||||
from src.services.run_diagnostics import record_provider_run, record_provider_run_started
|
||||
from .fundamental_adapter import AkshareFundamentalAdapter
|
||||
from .yfinance_fundamental_adapter import YfinanceFundamentalAdapter
|
||||
|
||||
@@ -1206,6 +1206,11 @@ class DataFetcherManager:
|
||||
f"[数据源尝试 {attempt}/{total_fetchers}] [{fetcher.name}] "
|
||||
f"{market_label} {stock_code} {role}路由..."
|
||||
)
|
||||
record_provider_run_started(
|
||||
data_type="daily_data",
|
||||
provider=fetcher.name,
|
||||
operation="get_daily_data",
|
||||
)
|
||||
df = self._call_fetcher_method(
|
||||
fetcher,
|
||||
"get_daily_data",
|
||||
@@ -1273,6 +1278,11 @@ class DataFetcherManager:
|
||||
fallback_to = fetchers[attempt].name if attempt < total_fetchers else None
|
||||
try:
|
||||
logger.info(f"[数据源尝试 {attempt}/{total_fetchers}] [{fetcher.name}] 获取 {stock_code}...")
|
||||
record_provider_run_started(
|
||||
data_type="daily_data",
|
||||
provider=fetcher.name,
|
||||
operation="get_daily_data",
|
||||
)
|
||||
df = self._call_fetcher_method(
|
||||
fetcher,
|
||||
"get_daily_data",
|
||||
@@ -1638,26 +1648,51 @@ class DataFetcherManager:
|
||||
if source == "efinance":
|
||||
fetcher = self._get_fetcher_by_name("EfinanceFetcher", capability="realtime_quote")
|
||||
if fetcher is not None and hasattr(fetcher, 'get_realtime_quote'):
|
||||
record_provider_run_started(
|
||||
data_type="realtime_quote",
|
||||
provider=fetcher.name,
|
||||
operation="get_realtime_quote",
|
||||
)
|
||||
quote = self._call_fetcher_method(fetcher, 'get_realtime_quote', stock_code)
|
||||
|
||||
elif source == "akshare_em":
|
||||
fetcher = self._get_fetcher_by_name("AkshareFetcher", capability="realtime_quote")
|
||||
if fetcher is not None and hasattr(fetcher, 'get_realtime_quote'):
|
||||
record_provider_run_started(
|
||||
data_type="realtime_quote",
|
||||
provider=fetcher.name,
|
||||
operation="get_realtime_quote",
|
||||
)
|
||||
quote = self._call_fetcher_method(fetcher, 'get_realtime_quote', stock_code, source="em")
|
||||
|
||||
elif source == "akshare_sina":
|
||||
fetcher = self._get_fetcher_by_name("AkshareFetcher", capability="realtime_quote")
|
||||
if fetcher is not None and hasattr(fetcher, 'get_realtime_quote'):
|
||||
record_provider_run_started(
|
||||
data_type="realtime_quote",
|
||||
provider=fetcher.name,
|
||||
operation="get_realtime_quote",
|
||||
)
|
||||
quote = self._call_fetcher_method(fetcher, 'get_realtime_quote', stock_code, source="sina")
|
||||
|
||||
elif source in ("tencent", "akshare_qq"):
|
||||
fetcher = self._get_fetcher_by_name("AkshareFetcher", capability="realtime_quote")
|
||||
if fetcher is not None and hasattr(fetcher, 'get_realtime_quote'):
|
||||
record_provider_run_started(
|
||||
data_type="realtime_quote",
|
||||
provider=fetcher.name,
|
||||
operation="get_realtime_quote",
|
||||
)
|
||||
quote = self._call_fetcher_method(fetcher, 'get_realtime_quote', stock_code, source="tencent")
|
||||
|
||||
elif source == "tushare":
|
||||
fetcher = self._get_fetcher_by_name("TushareFetcher", capability="realtime_quote")
|
||||
if fetcher is not None and hasattr(fetcher, 'get_realtime_quote'):
|
||||
record_provider_run_started(
|
||||
data_type="realtime_quote",
|
||||
provider=fetcher.name,
|
||||
operation="get_realtime_quote",
|
||||
)
|
||||
quote = self._call_fetcher_method(fetcher, 'get_realtime_quote', raw_stock_code or stock_code)
|
||||
|
||||
provider_name = fetcher.name if fetcher is not None else source
|
||||
@@ -1807,6 +1842,11 @@ class DataFetcherManager:
|
||||
return None
|
||||
attempt_start = time.time()
|
||||
try:
|
||||
record_provider_run_started(
|
||||
data_type="realtime_quote",
|
||||
provider=fetcher.name,
|
||||
operation="get_realtime_quote",
|
||||
)
|
||||
q = self._call_fetcher_method(fetcher, 'get_realtime_quote', stock_code, **kw)
|
||||
if q is not None and q.has_basic_data():
|
||||
record_provider_run(
|
||||
@@ -1901,6 +1941,7 @@ class DataFetcherManager:
|
||||
|
||||
circuit_breaker = get_chip_circuit_breaker()
|
||||
|
||||
candidate_fetchers = []
|
||||
# 直接遍历管理器已经按 priority 排好序的数据源列表
|
||||
for fetcher in self._get_fetchers_snapshot():
|
||||
# 只处理实现了筹码分布逻辑的数据源
|
||||
@@ -1916,13 +1957,47 @@ class DataFetcherManager:
|
||||
logger.debug(f"[熔断] {fetcher_name} 筹码接口处于熔断状态,尝试下一个")
|
||||
continue
|
||||
|
||||
candidate_fetchers.append((fetcher, fetcher_name, source_key))
|
||||
|
||||
for index, (fetcher, fetcher_name, source_key) in enumerate(candidate_fetchers):
|
||||
fallback_to = (
|
||||
candidate_fetchers[index + 1][1]
|
||||
if index + 1 < len(candidate_fetchers)
|
||||
else None
|
||||
)
|
||||
attempt_start = time.time()
|
||||
try:
|
||||
record_provider_run_started(
|
||||
data_type="chip",
|
||||
provider=fetcher_name,
|
||||
operation="get_chip_distribution",
|
||||
)
|
||||
chip = self._call_fetcher_method(fetcher, 'get_chip_distribution', stock_code)
|
||||
latency_ms = int((time.time() - attempt_start) * 1000)
|
||||
if _is_meaningful_chip_distribution(chip):
|
||||
record_provider_run(
|
||||
data_type="chip",
|
||||
provider=fetcher_name,
|
||||
operation="get_chip_distribution",
|
||||
success=True,
|
||||
latency_ms=latency_ms,
|
||||
record_count=1,
|
||||
)
|
||||
circuit_breaker.record_success(source_key)
|
||||
logger.info(f"[筹码分布] {stock_code} 成功获取 (来源: {fetcher_name})")
|
||||
return chip
|
||||
else:
|
||||
record_provider_run(
|
||||
data_type="chip",
|
||||
provider=fetcher_name,
|
||||
operation="get_chip_distribution",
|
||||
success=False,
|
||||
latency_ms=latency_ms,
|
||||
error_type="empty",
|
||||
error_message="empty or incomplete chip distribution",
|
||||
fallback_to=fallback_to,
|
||||
record_count=0,
|
||||
)
|
||||
if chip is not None:
|
||||
logger.warning(
|
||||
"[筹码分布] %s 返回字段不完整或占位值,继续尝试下一个数据源",
|
||||
@@ -1931,6 +2006,17 @@ class DataFetcherManager:
|
||||
# 空结果或占位结果:释放 HALF_OPEN 探测名额,避免卡死
|
||||
circuit_breaker.record_inconclusive(source_key)
|
||||
except Exception as e:
|
||||
error_type, error_reason = summarize_exception(e)
|
||||
record_provider_run(
|
||||
data_type="chip",
|
||||
provider=fetcher_name,
|
||||
operation="get_chip_distribution",
|
||||
success=False,
|
||||
latency_ms=int((time.time() - attempt_start) * 1000),
|
||||
error_type=error_type,
|
||||
error_message=error_reason,
|
||||
fallback_to=fallback_to,
|
||||
)
|
||||
logger.warning(f"[筹码分布] {fetcher_name} 获取 {stock_code} 失败: {e}")
|
||||
circuit_breaker.record_failure(source_key, str(e))
|
||||
continue
|
||||
@@ -2017,16 +2103,60 @@ class DataFetcherManager:
|
||||
stock_code = normalize_stock_code(stock_code)
|
||||
if _market_tag(stock_code) != "cn":
|
||||
return []
|
||||
for fetcher in self._fetchers:
|
||||
if not hasattr(fetcher, "get_belong_board"):
|
||||
continue
|
||||
candidate_fetchers = [
|
||||
fetcher
|
||||
for fetcher in self._fetchers
|
||||
if hasattr(fetcher, "get_belong_board")
|
||||
]
|
||||
for index, fetcher in enumerate(candidate_fetchers):
|
||||
fallback_to = (
|
||||
candidate_fetchers[index + 1].name
|
||||
if index + 1 < len(candidate_fetchers)
|
||||
else None
|
||||
)
|
||||
start = time.time()
|
||||
try:
|
||||
record_provider_run_started(
|
||||
data_type="belong_boards",
|
||||
provider=fetcher.name,
|
||||
operation="get_belong_board",
|
||||
)
|
||||
raw_data = fetcher.get_belong_board(stock_code)
|
||||
boards = self._normalize_belong_boards(raw_data)
|
||||
if boards:
|
||||
record_provider_run(
|
||||
data_type="belong_boards",
|
||||
provider=fetcher.name,
|
||||
operation="get_belong_board",
|
||||
success=True,
|
||||
latency_ms=int((time.time() - start) * 1000),
|
||||
record_count=len(boards),
|
||||
)
|
||||
logger.info(f"[{fetcher.name}] 获取所属板块成功: {stock_code}, count={len(boards)}")
|
||||
return boards
|
||||
record_provider_run(
|
||||
data_type="belong_boards",
|
||||
provider=fetcher.name,
|
||||
operation="get_belong_board",
|
||||
success=False,
|
||||
latency_ms=int((time.time() - start) * 1000),
|
||||
error_type="empty",
|
||||
error_message="empty belong boards",
|
||||
fallback_to=fallback_to,
|
||||
record_count=0,
|
||||
)
|
||||
except Exception as e:
|
||||
error_type, error_reason = summarize_exception(e)
|
||||
record_provider_run(
|
||||
data_type="belong_boards",
|
||||
provider=fetcher.name,
|
||||
operation="get_belong_board",
|
||||
success=False,
|
||||
latency_ms=int((time.time() - start) * 1000),
|
||||
error_type=error_type,
|
||||
error_message=error_reason,
|
||||
fallback_to=fallback_to,
|
||||
)
|
||||
logger.debug(f"[{fetcher.name}] 获取所属板块失败: {e}")
|
||||
continue
|
||||
return []
|
||||
|
||||
@@ -9,6 +9,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- [修复] 问股从历史报告进入后的追问会持续携带当前标的,切回或重载已有会话时可从历史消息恢复基础当前标的,并由后端阻断未明确切换时的错误股票工具调用、交易所片段和指标缩写误路由。
|
||||
- [修复] 自选股加入和删除按等价股票代码匹配港股及大小写美股变体,避免 `00700`、`HK00700`、`00700.HK` 或 `aapl`、`AAPL` 被误判为不同标的。
|
||||
- [改进] #1390 P0 为个股分析与历史/回测展示新增可选八态 `action` / `action_label` 建议动作字段,保留 `operation_advice` 自由文本和 `decision_type=buy|hold|sell` 统计口径,不新增迁移或配置项。
|
||||
- [新功能] #1390 P1 新增独立 `DecisionSignal` 存储、Repository、Service 与 `/api/v1/decision-signals` API,支持按来源类型/市场/股票/动作/期限/阶段去重、按 `source_report_id` / `trace_id` 查询、同源过期信号续期且保留来源身份字段、禁止 expired 直接 PATCH 复活、价格计划校验、状态更新、懒过期、cache-only 持仓过滤、敏感信息脱敏、敏感 `trace_id` 拒绝和仅清理 `source_type=analysis` 历史绑定信号的历史删除联动。
|
||||
- [改进] #1390 P1 补充 Web decision-signals typed API wrapper 与契约隔离测试,暂不接入 UI。
|
||||
- [修复] #1390 收紧建议动作 legacy fallback:英文 `not to ...` 与 `avoid selling/reducing/trimming ...` 等否定/回避表达不再误判为买卖动作,Web 旧记录不再把中文金融上下文、`buy or sell`、多 guard 歧义文本或 `buyback` / `buy-back` / `buy back` / `selloff` / `sell-off` / `sell off` 等英文复合词渲染成 action badge,并在有结构化 `action` 时让回测/历史趋势等入口按界面语言显示 action 标签。
|
||||
- [改进] 完善运行时日志上下文,补充 logger name、触发来源、市场统计与实时行情预取链路状态,便于排查调度、API、Bot 和数据源降级路径。
|
||||
- [新功能] 新增分析任务与历史报告运行流快照 API,提供 lanes、nodes、edges、events、summary 等统一契约,并从任务队列、运行诊断和 AnalysisContextPack overview 构建脱敏数据流/信息流。
|
||||
- [新功能] Web 端为活跃任务、历史报告和大盘复盘报告补充运行流视图入口,支持查看运行摘要、拓扑节点、事件流和基础排障详情。
|
||||
- [修复] 修复历史报告运行流快照在混合时区事件时间戳下返回 500 的问题。
|
||||
- [改进] #1459 持仓管理页新增持仓账户删除入口,复用现有账户软删除接口,误建账户会从默认列表、快照、风险、录入入口和事件列表隐藏且不物理清理历史流水。
|
||||
- [修复] 修复运行流 live SSE 事件未复用快照层递归脱敏规则的问题,避免本地路径、prompt/raw response、代理头等敏感诊断字段在 refetch 前短暂暴露。
|
||||
- [修复] 修复 Web 首页分析任务卡片在窄侧栏下挤压股票信息、进度和运行诊断文案的问题。
|
||||
- [修复] 隔离个股分析自动生成的大盘上下文运行诊断,避免大盘复盘与个股报告共用 query_id 导致运行流重复展示“保存报告”和“推送通知”,并兼容通知跳过时 `attempts=0` 的运行流快照。
|
||||
- [改进] 运行流 active task 增加 provider 与 LLM started 实时事件,长耗时步骤开始时先显示 running 卡片,完成后复用同一节点更新结果,避免重复卡片。
|
||||
- [修复] 运行流为筹码分布补齐 provider started/result 事件,个股分析触发筹码数据源调用时可显示“筹码结构”运行卡片并记录降级尝试。
|
||||
- [修复] 修复个股运行流活跃任务后期 LLM/通知卡片临时重复、数据源聚合卡片过早显示成功,并为个股所属板块补齐运行流卡片。
|
||||
<!-- 新条目格式:- [类型] 描述(类型取值:新功能/改进/修复/文档/测试/chore)-->
|
||||
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->
|
||||
- [修复] 发布说明生成查询 PR 作者失败时保留降级并输出包含 PR 编号和异常类型的 warning,便于排查 token、权限、网络或 GitHub API 异常。
|
||||
|
||||
@@ -31,6 +31,7 @@ GET /api/v1/analysis/tasks/stream
|
||||
- 原有 task payload 字段保持不变。
|
||||
- 当本次进度更新来自运行诊断时,可选追加 `flow_event` 字段;旧客户端忽略该字段即可。
|
||||
- `flow_event` 使用与 `RunFlowSnapshot.events[]` 相同的脱敏事件结构:`id`、`timestamp`、`severity`、`type`、`node_id`、`title`、`message`、`metadata`。
|
||||
- active task 可追加 `provider_run_started` / `llm_run_started` 实时事件;这些事件只用于运行中的 running 卡片展示,完成后由同 `node_id` 的 `provider_run` / `llm_run` 结果事件覆盖状态,历史诊断仍以最终结果为准。
|
||||
- 后端 TaskQueue 只为每个 active task 保留最近 N 条运行流事件,避免内存无限增长;完整历史仍以 `context_snapshot.diagnostics` 和历史 RunFlowSnapshot 为准。
|
||||
|
||||
示例:
|
||||
@@ -96,6 +97,9 @@ GET /api/v1/history/{record_id}/flow
|
||||
|
||||
- `tasks/{task_id}/flow` 面向活跃任务。任务仍在内存队列中时优先返回当前任务快照;任务已完成时可按同一 `task_id/query_id` 尝试读取历史诊断。缺少诊断时返回 skeleton flow,不伪造 provider、LLM 或通知事件。
|
||||
- `history/{record_id}/flow` 面向历史报告,支持历史记录主键 ID 或可解析的 `query_id`。普通个股分析与 `MARKET/market_review` 大盘复盘复用同一 `RunFlowSnapshot` 契约。
|
||||
- 同一页面触发个股分析时,个股流程可按需生成或复用当日大盘上下文;这不是独立的个股分析步骤,而是 Prompt 背景生成。后台会用独立 `market_context_*` query_id 与 `scope=daily_market_context` 保存该大盘上下文,避免与个股报告共用 query_id。
|
||||
- 为兼容早期已写入的混合诊断,运行流会在读取历史时按报告类型做低风险过滤:`MARKET/market_review` 记录隐藏个股行情、日线、技术、基本面与筹码 provider 节点;个股记录隐藏首次个股行情前的大盘新闻搜索,以及首次个股 LLM 前的大盘保存/通知节点。
|
||||
- 通知跳过或未配置时允许 `attempts=0`,运行流展示为 skipped,不再因 Pydantic 校验失败导致 `/flow` 返回 500。
|
||||
- 快照顶层包含 `summary`、`lanes`、`nodes`、`edges`、`events` 和 `generated_at`。节点状态使用 `pending/running/success/failed/degraded/fallback/timeout/cancel_requested/cancelled/skipped/unknown`,其中用户取消类状态不会被映射成 `failed`。
|
||||
- 旧历史、缺失 `context_snapshot.diagnostics` 或证据不足时,后端返回 `unknown` 或 skeleton 节点;Web 端按空/未知状态展示,不影响报告详情读取。
|
||||
|
||||
|
||||
@@ -546,6 +546,11 @@ def _persist_market_review_history(
|
||||
diagnostic_snapshot = current_diagnostic_snapshot()
|
||||
if diagnostic_snapshot is not None:
|
||||
context_snapshot["diagnostics"] = diagnostic_snapshot
|
||||
context_snapshot["analysis_context_pack_overview"] = _build_market_review_context_overview(
|
||||
region=region,
|
||||
report_language=report_language,
|
||||
diagnostic_snapshot=diagnostic_snapshot,
|
||||
)
|
||||
|
||||
db = DatabaseManager.get_instance()
|
||||
saved = db.save_analysis_history(
|
||||
@@ -556,11 +561,10 @@ def _persist_market_review_history(
|
||||
context_snapshot=context_snapshot,
|
||||
save_snapshot=True,
|
||||
)
|
||||
saved_history_id = (
|
||||
saved
|
||||
if isinstance(saved, int) and not isinstance(saved, bool) and saved > 0
|
||||
else None
|
||||
)
|
||||
saved_history_id = _resolve_saved_market_review_history_id(
|
||||
db=db,
|
||||
query_id=history_query_id,
|
||||
) if saved else None
|
||||
record_history_run(
|
||||
report_saved=bool(saved),
|
||||
metadata_saved=bool(saved),
|
||||
@@ -582,6 +586,93 @@ def _persist_market_review_history(
|
||||
return 0
|
||||
|
||||
|
||||
def _build_market_review_context_overview(
|
||||
*,
|
||||
region: str,
|
||||
report_language: str,
|
||||
diagnostic_snapshot: Optional[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
"""Build a low-sensitivity overview block for market-review run-flow rendering."""
|
||||
warnings: list[str] = []
|
||||
counts = {
|
||||
"available": 1,
|
||||
"missing": 0,
|
||||
"not_supported": 0,
|
||||
"fallback": 0,
|
||||
"stale": 0,
|
||||
"estimated": 0,
|
||||
"partial": 0,
|
||||
"fetch_failed": 0,
|
||||
}
|
||||
metadata: Dict[str, Any] = {
|
||||
"trigger_source": "market_review",
|
||||
"scope": "market_review",
|
||||
"report_type": MARKET_REVIEW_REPORT_TYPE,
|
||||
}
|
||||
if isinstance(diagnostic_snapshot, dict):
|
||||
metadata["trigger_source"] = diagnostic_snapshot.get("trigger_source") or metadata["trigger_source"]
|
||||
metadata["scope"] = diagnostic_snapshot.get("scope") or metadata["scope"]
|
||||
|
||||
label = "Market review" if report_language == "en" else "大盘复盘"
|
||||
return {
|
||||
"pack_version": "market_review/1.0",
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"subject": {
|
||||
"code": MARKET_REVIEW_HISTORY_CODE,
|
||||
"stock_name": label,
|
||||
"market": region,
|
||||
},
|
||||
"blocks": [
|
||||
{
|
||||
"key": MARKET_REVIEW_REPORT_TYPE,
|
||||
"label": label,
|
||||
"status": "available",
|
||||
"source": MARKET_REVIEW_REPORT_TYPE,
|
||||
"warnings": warnings,
|
||||
"missing_reasons": [],
|
||||
}
|
||||
],
|
||||
"counts": counts,
|
||||
"warnings": warnings,
|
||||
"metadata": metadata,
|
||||
"data_quality": {
|
||||
"level": "good",
|
||||
"overall_score": 100,
|
||||
"available": 1,
|
||||
"total": 1,
|
||||
"missing": 0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _resolve_saved_market_review_history_id(
|
||||
*,
|
||||
db: object,
|
||||
query_id: str,
|
||||
) -> Optional[int]:
|
||||
"""Resolve the real AnalysisHistory primary key after save_analysis_history returns row count."""
|
||||
try:
|
||||
resolver = getattr(db, "get_latest_analysis_by_query_id", None)
|
||||
if not callable(resolver):
|
||||
return None
|
||||
record = resolver(
|
||||
query_id,
|
||||
code=MARKET_REVIEW_HISTORY_CODE,
|
||||
report_type=MARKET_REVIEW_REPORT_TYPE,
|
||||
)
|
||||
record_id = getattr(record, "id", None)
|
||||
return record_id if isinstance(record_id, int) and not isinstance(record_id, bool) else None
|
||||
except TypeError:
|
||||
try:
|
||||
record = db.get_latest_analysis_by_query_id(query_id)
|
||||
record_id = getattr(record, "id", None)
|
||||
return record_id if isinstance(record_id, int) and not isinstance(record_id, bool) else None
|
||||
except Exception:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _summarize_market_review(review_report: str, report_language: str) -> str:
|
||||
for line in (review_report or "").splitlines():
|
||||
text = line.strip().lstrip("#").strip()
|
||||
|
||||
@@ -65,6 +65,7 @@ from src.services.run_diagnostics import (
|
||||
get_current_diagnostic_context,
|
||||
record_history_run,
|
||||
record_llm_run,
|
||||
record_llm_run_started,
|
||||
record_notification_run,
|
||||
reset_run_diagnostic_context,
|
||||
sanitize_diagnostic_text,
|
||||
@@ -605,6 +606,10 @@ class StockAnalysisPipeline:
|
||||
self._emit_progress(64, f"{stock_name}:正在请求 LLM 生成报告")
|
||||
llm_started_at = time.monotonic()
|
||||
try:
|
||||
record_llm_run_started(
|
||||
model=getattr(self.config, "litellm_model", None),
|
||||
call_type="analysis",
|
||||
)
|
||||
result = self.analyzer.analyze(
|
||||
enhanced_context,
|
||||
news_context=news_context,
|
||||
@@ -1127,6 +1132,10 @@ class StockAnalysisPipeline:
|
||||
message = f"请分析股票 {code} ({stock_name}),并生成决策仪表盘报告。"
|
||||
llm_started_at = time.monotonic()
|
||||
try:
|
||||
record_llm_run_started(
|
||||
model=getattr(self.config, "agent_litellm_model", None),
|
||||
call_type="agent_analysis",
|
||||
)
|
||||
agent_result = executor.run(message, context=initial_context)
|
||||
except Exception as exc:
|
||||
record_llm_run(
|
||||
|
||||
@@ -25,6 +25,7 @@ from src.search_service import SearchService
|
||||
from src.core.market_profile import get_profile, MarketProfile
|
||||
from src.core.market_strategy import get_market_strategy_blueprint
|
||||
from src.schemas.market_light import MarketLightSnapshot
|
||||
from src.services.run_diagnostics import record_llm_run, record_llm_run_started
|
||||
from data_provider.base import DataFetcherManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -547,7 +548,35 @@ Focus on index trend, liquidity, and sector rotation to shape the next-session t
|
||||
|
||||
logger.info("[大盘] %s action=generate_review status=start", self._log_context())
|
||||
# Use the public generate_text() entry point - never access private analyzer attributes.
|
||||
llm_started_at = time.perf_counter()
|
||||
try:
|
||||
record_llm_run_started(
|
||||
provider="litellm",
|
||||
model=getattr(self.config, "litellm_model", None),
|
||||
call_type="market_review",
|
||||
)
|
||||
review = self.analyzer.generate_text(prompt, max_tokens=8192, temperature=0.7)
|
||||
except Exception as exc:
|
||||
record_llm_run(
|
||||
success=False,
|
||||
provider="litellm",
|
||||
model=getattr(self.config, "litellm_model", None),
|
||||
call_type="market_review",
|
||||
duration_ms=int((time.perf_counter() - llm_started_at) * 1000),
|
||||
error_type=type(exc).__name__,
|
||||
error_message=exc,
|
||||
)
|
||||
raise
|
||||
|
||||
record_llm_run(
|
||||
success=bool(review),
|
||||
provider="litellm",
|
||||
model=getattr(self.config, "litellm_model", None),
|
||||
call_type="market_review",
|
||||
duration_ms=int((time.perf_counter() - llm_started_at) * 1000),
|
||||
error_type=None if review else "EmptyResponse",
|
||||
error_message=None if review else "empty market review response",
|
||||
)
|
||||
|
||||
if review:
|
||||
logger.info(
|
||||
|
||||
@@ -38,7 +38,7 @@ from src.config import (
|
||||
normalize_news_strategy_profile,
|
||||
resolve_news_window_days,
|
||||
)
|
||||
from src.services.run_diagnostics import record_provider_run
|
||||
from src.services.run_diagnostics import record_provider_run, record_provider_run_started
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -3704,6 +3704,11 @@ class SearchService:
|
||||
|
||||
started_at = time.monotonic()
|
||||
try:
|
||||
record_provider_run_started(
|
||||
data_type="news_search",
|
||||
provider=provider.name,
|
||||
operation="search_stock_news",
|
||||
)
|
||||
response = provider.search(query, provider_max_results, days=search_days, **search_kwargs)
|
||||
except Exception as exc:
|
||||
self._record_news_search_run(
|
||||
|
||||
@@ -8,6 +8,7 @@ import logging
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Tuple
|
||||
@@ -17,6 +18,10 @@ from src.core.market_review_lock import (
|
||||
try_acquire_market_review_lock,
|
||||
)
|
||||
from src.report_language import normalize_report_language
|
||||
from src.services.run_diagnostics import (
|
||||
activate_run_diagnostic_context,
|
||||
reset_run_diagnostic_context,
|
||||
)
|
||||
from src.storage import DatabaseManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -413,23 +418,39 @@ class DailyMarketContextService:
|
||||
persist_market_review_history=persist_market_review_history,
|
||||
)
|
||||
|
||||
caller_query_id = (
|
||||
current_query_id.strip()
|
||||
if isinstance(current_query_id, str) and current_query_id.strip()
|
||||
else None
|
||||
)
|
||||
market_context_query_id = (
|
||||
f"market_context_{caller_query_id}_{region}"
|
||||
if caller_query_id
|
||||
else f"market_context_{uuid.uuid4().hex}_{region}"
|
||||
)
|
||||
|
||||
diagnostic_token = None
|
||||
try:
|
||||
diagnostic_token = activate_run_diagnostic_context(
|
||||
trace_id=market_context_query_id,
|
||||
query_id=market_context_query_id,
|
||||
stock_code=MARKET_REVIEW_HISTORY_CODE,
|
||||
trigger_source="daily_market_context",
|
||||
scope="daily_market_context",
|
||||
)
|
||||
result = run_market_review(
|
||||
config=config,
|
||||
notifier=notifier,
|
||||
analyzer=analyzer,
|
||||
search_service=search_service,
|
||||
query_id=(
|
||||
current_query_id.strip()
|
||||
if isinstance(current_query_id, str) and current_query_id.strip()
|
||||
else None
|
||||
),
|
||||
query_id=market_context_query_id,
|
||||
send_notification=False,
|
||||
merge_notification=False,
|
||||
override_region=region,
|
||||
return_structured=True,
|
||||
save_report_file=False,
|
||||
persist_history=persist_market_review_history,
|
||||
trigger_source="daily_market_context",
|
||||
)
|
||||
|
||||
if (
|
||||
@@ -451,11 +472,7 @@ class DailyMarketContextService:
|
||||
source="market_review_runtime",
|
||||
fallback_summary=fallback_summary,
|
||||
fallback_full_report=fallback_summary,
|
||||
query_id=(
|
||||
current_query_id.strip()
|
||||
if isinstance(current_query_id, str) and current_query_id.strip()
|
||||
else None
|
||||
),
|
||||
query_id=caller_query_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
@@ -465,6 +482,8 @@ class DailyMarketContextService:
|
||||
)
|
||||
return None
|
||||
finally:
|
||||
if diagnostic_token is not None:
|
||||
reset_run_diagnostic_context(diagnostic_token)
|
||||
if owns_lock:
|
||||
release_market_review_lock(lock_token)
|
||||
|
||||
|
||||
@@ -284,7 +284,13 @@ class HistoryService:
|
||||
**market_fields,
|
||||
}
|
||||
|
||||
def _resolve_record(self, record_id: str):
|
||||
def _resolve_record(
|
||||
self,
|
||||
record_id: str,
|
||||
*,
|
||||
code: Optional[str] = None,
|
||||
report_type: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Resolve a record_id parameter to an AnalysisHistory object.
|
||||
|
||||
@@ -304,8 +310,15 @@ class HistoryService:
|
||||
return record
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# Fall back to query_id lookup
|
||||
# Fall back to query_id lookup. Keep the old no-kwargs call for
|
||||
# unfiltered paths so existing test doubles and integrations remain compatible.
|
||||
if code is None and report_type is None:
|
||||
return self.db.get_latest_analysis_by_query_id(record_id)
|
||||
return self.db.get_latest_analysis_by_query_id(
|
||||
record_id,
|
||||
code=code,
|
||||
report_type=report_type,
|
||||
)
|
||||
|
||||
def resolve_and_get_detail(self, record_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
@@ -373,14 +386,20 @@ class HistoryService:
|
||||
stock_code=getattr(record, "code", None),
|
||||
)
|
||||
|
||||
def resolve_and_get_run_flow(self, record_id: str):
|
||||
def resolve_and_get_run_flow(
|
||||
self,
|
||||
record_id: str,
|
||||
*,
|
||||
code: Optional[str] = None,
|
||||
report_type: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Resolve record_id and return a sanitized run-flow snapshot.
|
||||
|
||||
Uses the same strict JSON parsing behavior as diagnostics so malformed
|
||||
persisted payloads surface as backend errors instead of partial graphs.
|
||||
"""
|
||||
record = self._resolve_record(record_id)
|
||||
record = self._resolve_record(record_id, code=code, report_type=report_type)
|
||||
if not record:
|
||||
return None
|
||||
|
||||
|
||||
@@ -322,6 +322,7 @@ class RunDiagnosticContext:
|
||||
query_id: Optional[str] = None
|
||||
stock_code: Optional[str] = None
|
||||
trigger_source: Optional[str] = None
|
||||
scope: Optional[str] = None
|
||||
provider_runs: List[ProviderRun] = field(default_factory=list)
|
||||
llm_runs: List[LLMRun] = field(default_factory=list)
|
||||
notification_runs: List[NotificationRun] = field(default_factory=list)
|
||||
@@ -329,17 +330,127 @@ class RunDiagnosticContext:
|
||||
event_sink: Optional[Callable[[Dict[str, Any]], None]] = None
|
||||
flow_event_index: int = 0
|
||||
provider_attempt_index_by_type: Dict[str, int] = field(default_factory=dict)
|
||||
provider_pending_attempt_index_by_key: Dict[str, List[int]] = field(default_factory=dict)
|
||||
llm_attempt_index_by_type: Dict[str, int] = field(default_factory=dict)
|
||||
llm_pending_attempt_index_by_key: Dict[str, List[int]] = field(default_factory=dict)
|
||||
llm_pending_attempt_index_by_call_type: Dict[str, List[int]] = field(default_factory=dict)
|
||||
|
||||
def record_provider_run(self, provider_run: ProviderRun) -> None:
|
||||
self.provider_runs.append(provider_run)
|
||||
data_type_key = _safe_event_key(provider_run.data_type) or "provider"
|
||||
pending_key = _provider_pending_key(
|
||||
provider_run.data_type,
|
||||
provider_run.provider,
|
||||
provider_run.operation,
|
||||
)
|
||||
pending_indexes = self.provider_pending_attempt_index_by_key.get(pending_key) or []
|
||||
if pending_indexes:
|
||||
attempt_index = pending_indexes.pop(0)
|
||||
if pending_indexes:
|
||||
self.provider_pending_attempt_index_by_key[pending_key] = pending_indexes
|
||||
else:
|
||||
self.provider_pending_attempt_index_by_key.pop(pending_key, None)
|
||||
else:
|
||||
attempt_index = self.provider_attempt_index_by_type.get(data_type_key, 0) + 1
|
||||
self.provider_attempt_index_by_type[data_type_key] = attempt_index
|
||||
self._emit_flow_event(_provider_flow_event(self, provider_run, attempt_index))
|
||||
|
||||
def record_provider_run_started(
|
||||
self,
|
||||
*,
|
||||
data_type: str,
|
||||
provider: str,
|
||||
operation: str,
|
||||
) -> None:
|
||||
data_type_key = _safe_event_key(data_type) or "provider"
|
||||
attempt_index = self.provider_attempt_index_by_type.get(data_type_key, 0) + 1
|
||||
self.provider_attempt_index_by_type[data_type_key] = attempt_index
|
||||
pending_key = _provider_pending_key(data_type, provider, operation)
|
||||
pending_indexes = self.provider_pending_attempt_index_by_key.get(pending_key) or []
|
||||
pending_indexes.append(attempt_index)
|
||||
self.provider_pending_attempt_index_by_key[pending_key] = pending_indexes
|
||||
self._emit_flow_event(
|
||||
_provider_started_flow_event(
|
||||
self,
|
||||
data_type=data_type,
|
||||
provider=provider,
|
||||
operation=operation,
|
||||
index=attempt_index,
|
||||
)
|
||||
)
|
||||
|
||||
def record_llm_run(self, llm_run: LLMRun) -> None:
|
||||
self.llm_runs.append(llm_run)
|
||||
self._emit_flow_event(_llm_flow_event(self, llm_run, len(self.llm_runs)))
|
||||
call_type_key = _safe_event_key(llm_run.call_type) or "analysis"
|
||||
pending_key = _llm_pending_key(llm_run.call_type, llm_run.provider, llm_run.model)
|
||||
pending_indexes = self.llm_pending_attempt_index_by_key.get(pending_key) or []
|
||||
if pending_indexes:
|
||||
attempt_index = pending_indexes.pop(0)
|
||||
if pending_indexes:
|
||||
self.llm_pending_attempt_index_by_key[pending_key] = pending_indexes
|
||||
else:
|
||||
self.llm_pending_attempt_index_by_key.pop(pending_key, None)
|
||||
self._remove_llm_pending_call_type_index(call_type_key, attempt_index)
|
||||
else:
|
||||
call_type_pending_indexes = self.llm_pending_attempt_index_by_call_type.get(call_type_key) or []
|
||||
if call_type_pending_indexes:
|
||||
attempt_index = call_type_pending_indexes.pop(0)
|
||||
if call_type_pending_indexes:
|
||||
self.llm_pending_attempt_index_by_call_type[call_type_key] = call_type_pending_indexes
|
||||
else:
|
||||
self.llm_pending_attempt_index_by_call_type.pop(call_type_key, None)
|
||||
self._remove_llm_pending_exact_index(attempt_index)
|
||||
else:
|
||||
attempt_index = self.llm_attempt_index_by_type.get(call_type_key, 0) + 1
|
||||
self.llm_attempt_index_by_type[call_type_key] = attempt_index
|
||||
self._emit_flow_event(_llm_flow_event(self, llm_run, attempt_index))
|
||||
|
||||
def _remove_llm_pending_call_type_index(self, call_type_key: str, attempt_index: int) -> None:
|
||||
pending_indexes = self.llm_pending_attempt_index_by_call_type.get(call_type_key) or []
|
||||
if attempt_index not in pending_indexes:
|
||||
return
|
||||
pending_indexes = [index for index in pending_indexes if index != attempt_index]
|
||||
if pending_indexes:
|
||||
self.llm_pending_attempt_index_by_call_type[call_type_key] = pending_indexes
|
||||
else:
|
||||
self.llm_pending_attempt_index_by_call_type.pop(call_type_key, None)
|
||||
|
||||
def _remove_llm_pending_exact_index(self, attempt_index: int) -> None:
|
||||
for pending_key, pending_indexes in list(self.llm_pending_attempt_index_by_key.items()):
|
||||
if attempt_index not in pending_indexes:
|
||||
continue
|
||||
pending_indexes = [index for index in pending_indexes if index != attempt_index]
|
||||
if pending_indexes:
|
||||
self.llm_pending_attempt_index_by_key[pending_key] = pending_indexes
|
||||
else:
|
||||
self.llm_pending_attempt_index_by_key.pop(pending_key, None)
|
||||
|
||||
def record_llm_run_started(
|
||||
self,
|
||||
*,
|
||||
call_type: str = "analysis",
|
||||
provider: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
) -> None:
|
||||
call_type_key = _safe_event_key(call_type) or "analysis"
|
||||
attempt_index = self.llm_attempt_index_by_type.get(call_type_key, 0) + 1
|
||||
self.llm_attempt_index_by_type[call_type_key] = attempt_index
|
||||
pending_key = _llm_pending_key(call_type, provider, model)
|
||||
pending_indexes = self.llm_pending_attempt_index_by_key.get(pending_key) or []
|
||||
pending_indexes.append(attempt_index)
|
||||
self.llm_pending_attempt_index_by_key[pending_key] = pending_indexes
|
||||
call_type_pending_indexes = self.llm_pending_attempt_index_by_call_type.get(call_type_key) or []
|
||||
call_type_pending_indexes.append(attempt_index)
|
||||
self.llm_pending_attempt_index_by_call_type[call_type_key] = call_type_pending_indexes
|
||||
self._emit_flow_event(
|
||||
_llm_started_flow_event(
|
||||
self,
|
||||
call_type=call_type,
|
||||
provider=provider,
|
||||
model=model,
|
||||
index=attempt_index,
|
||||
)
|
||||
)
|
||||
|
||||
def record_notification_run(self, notification_run: NotificationRun) -> None:
|
||||
self.notification_runs.append(notification_run)
|
||||
@@ -368,6 +479,7 @@ class RunDiagnosticContext:
|
||||
"query_id": self.query_id,
|
||||
"stock_code": self.stock_code,
|
||||
"trigger_source": self.trigger_source,
|
||||
"scope": self.scope,
|
||||
"provider_runs": [run.to_dict() for run in self.provider_runs],
|
||||
"llm_runs": [run.to_dict() for run in self.llm_runs],
|
||||
"notification_runs": [run.to_dict() for run in self.notification_runs],
|
||||
@@ -386,6 +498,7 @@ def activate_run_diagnostic_context(
|
||||
query_id: Optional[str] = None,
|
||||
stock_code: Optional[str] = None,
|
||||
trigger_source: Optional[str] = None,
|
||||
scope: Optional[str] = None,
|
||||
event_sink: Optional[Callable[[Dict[str, Any]], None]] = None,
|
||||
) -> Token:
|
||||
"""Activate a diagnostic context and return its reset token."""
|
||||
@@ -395,6 +508,7 @@ def activate_run_diagnostic_context(
|
||||
query_id=query_id,
|
||||
stock_code=stock_code,
|
||||
trigger_source=trigger_source,
|
||||
scope=scope,
|
||||
event_sink=event_sink,
|
||||
)
|
||||
return _CURRENT_CONTEXT.set(context)
|
||||
@@ -429,6 +543,7 @@ _DATA_TYPE_LABELS = {
|
||||
"news_search": "新闻舆情",
|
||||
"fundamental": "基本面",
|
||||
"fundamentals": "基本面",
|
||||
"belong_boards": "所属板块",
|
||||
"chip": "筹码结构",
|
||||
}
|
||||
|
||||
@@ -445,6 +560,21 @@ def _clean_metadata(value: Dict[str, Any]) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _provider_pending_key(data_type: Any, provider: Any, operation: Any) -> str:
|
||||
return "|".join(
|
||||
(
|
||||
_safe_event_key(data_type) or "provider",
|
||||
_safe_event_key(provider) or "unknown",
|
||||
_safe_event_key(operation) or "operation",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _llm_pending_key(call_type: Any, provider: Any, model: Any) -> str:
|
||||
_ = (provider, model)
|
||||
return _safe_event_key(call_type) or "analysis"
|
||||
|
||||
|
||||
def _flow_status_for_success(success: bool, *, fallback: bool = False, skipped: bool = False) -> str:
|
||||
if skipped:
|
||||
return "skipped"
|
||||
@@ -469,6 +599,49 @@ def _started_at_from_end_and_duration(end: Any, duration_ms: Optional[int]) -> O
|
||||
return (parsed - timedelta(milliseconds=duration_ms)).isoformat()
|
||||
|
||||
|
||||
def _provider_started_flow_event(
|
||||
context: RunDiagnosticContext,
|
||||
*,
|
||||
data_type: str,
|
||||
provider: str,
|
||||
operation: str,
|
||||
index: int,
|
||||
) -> Dict[str, Any]:
|
||||
data_type_key = _safe_event_key(data_type) or "provider"
|
||||
provider_key = _safe_event_key(provider) or "unknown"
|
||||
label = _DATA_TYPE_LABELS.get(data_type_key, data_type_key)
|
||||
node_id = f"provider_{data_type_key}_{provider_key}_{index}"
|
||||
timestamp = datetime.now().isoformat()
|
||||
message = f"{label} {provider} 调用中"
|
||||
return {
|
||||
"timestamp": timestamp,
|
||||
"severity": "info",
|
||||
"type": "provider_run_started",
|
||||
"node_id": node_id,
|
||||
"title": f"{label}开始",
|
||||
"message": sanitize_diagnostic_text(message, max_length=220),
|
||||
"metadata": _clean_metadata(
|
||||
{
|
||||
"trace_id": context.trace_id,
|
||||
"provider": provider,
|
||||
"data_type": data_type,
|
||||
"operation": operation,
|
||||
"node": {
|
||||
"id": node_id,
|
||||
"lane": "data_source",
|
||||
"kind": "data_source",
|
||||
"label": f"{label} · {provider}",
|
||||
"status": "running",
|
||||
"provider": provider,
|
||||
"started_at": timestamp,
|
||||
"attempts": 1,
|
||||
"message": message,
|
||||
},
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _provider_flow_event(
|
||||
context: RunDiagnosticContext,
|
||||
run: ProviderRun,
|
||||
@@ -522,6 +695,48 @@ def _provider_flow_event(
|
||||
}
|
||||
|
||||
|
||||
def _llm_started_flow_event(
|
||||
context: RunDiagnosticContext,
|
||||
*,
|
||||
call_type: str,
|
||||
provider: Optional[str],
|
||||
model: Optional[str],
|
||||
index: int,
|
||||
) -> Dict[str, Any]:
|
||||
call_type_key = _safe_event_key(call_type) or "analysis"
|
||||
display_model = model or provider or "unknown"
|
||||
node_id = f"llm_{call_type_key}_{index}"
|
||||
timestamp = datetime.now().isoformat()
|
||||
message = f"LLM {display_model} 调用中"
|
||||
return {
|
||||
"timestamp": timestamp,
|
||||
"severity": "info",
|
||||
"type": "llm_run_started",
|
||||
"node_id": node_id,
|
||||
"title": "LLM 开始",
|
||||
"message": sanitize_diagnostic_text(message, max_length=220),
|
||||
"metadata": _clean_metadata(
|
||||
{
|
||||
"trace_id": context.trace_id,
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"call_type": call_type,
|
||||
"node": {
|
||||
"id": node_id,
|
||||
"lane": "analysis",
|
||||
"kind": "model",
|
||||
"label": "LLM 生成",
|
||||
"status": "running",
|
||||
"provider": display_model,
|
||||
"started_at": timestamp,
|
||||
"attempts": 1,
|
||||
"message": message,
|
||||
},
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _llm_flow_event(
|
||||
context: RunDiagnosticContext,
|
||||
run: LLMRun,
|
||||
@@ -692,6 +907,27 @@ def record_provider_run(
|
||||
logger.warning("provider diagnostic record failed: %s", exc)
|
||||
|
||||
|
||||
def record_provider_run_started(
|
||||
*,
|
||||
data_type: str,
|
||||
provider: str,
|
||||
operation: str,
|
||||
) -> None:
|
||||
"""Emit a live provider-start event without changing persisted diagnostics."""
|
||||
context = get_current_diagnostic_context()
|
||||
if context is None:
|
||||
return
|
||||
|
||||
try:
|
||||
context.record_provider_run_started(
|
||||
data_type=data_type,
|
||||
provider=provider,
|
||||
operation=operation,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive fail-open guard
|
||||
logger.warning("provider started diagnostic record failed: %s", exc)
|
||||
|
||||
|
||||
def record_llm_run(
|
||||
*,
|
||||
success: bool,
|
||||
@@ -728,6 +964,27 @@ def record_llm_run(
|
||||
logger.warning("llm diagnostic record failed: %s", exc)
|
||||
|
||||
|
||||
def record_llm_run_started(
|
||||
*,
|
||||
provider: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
call_type: str = "analysis",
|
||||
) -> None:
|
||||
"""Emit a live LLM-start event without changing persisted diagnostics."""
|
||||
context = get_current_diagnostic_context()
|
||||
if context is None:
|
||||
return
|
||||
|
||||
try:
|
||||
context.record_llm_run_started(
|
||||
provider=provider,
|
||||
model=model,
|
||||
call_type=call_type,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive fail-open guard
|
||||
logger.warning("llm started diagnostic record failed: %s", exc)
|
||||
|
||||
|
||||
def record_notification_run(
|
||||
*,
|
||||
channel: str,
|
||||
|
||||
@@ -46,6 +46,7 @@ _DATA_TYPE_LABELS = {
|
||||
"news_search": "新闻舆情",
|
||||
"fundamental": "基本面",
|
||||
"fundamentals": "基本面",
|
||||
"belong_boards": "所属板块",
|
||||
"chip": "筹码结构",
|
||||
}
|
||||
|
||||
@@ -58,6 +59,7 @@ _DATA_TYPE_TO_BLOCK_KEY = {
|
||||
"news_search": "news",
|
||||
"fundamental": "fundamentals",
|
||||
"fundamentals": "fundamentals",
|
||||
"belong_boards": "fundamentals",
|
||||
"chip": "chip",
|
||||
}
|
||||
|
||||
@@ -153,6 +155,7 @@ def build_task_run_flow_snapshot(
|
||||
_as_list(getattr(task, "flow_events", None)),
|
||||
flow_status=flow_status,
|
||||
)
|
||||
_prune_active_skeleton_tail(nodes, edges)
|
||||
|
||||
summary = _build_summary(
|
||||
nodes,
|
||||
@@ -188,6 +191,7 @@ def build_history_run_flow_snapshot(
|
||||
snapshot = _as_mapping(context_snapshot if context_snapshot is not None else getattr(record, "context_snapshot", None))
|
||||
raw = _as_mapping(raw_result if raw_result is not None else getattr(record, "raw_result", None))
|
||||
diagnostics = _as_mapping(snapshot.get("diagnostics")) if snapshot else {}
|
||||
diagnostics = _normalize_history_diagnostics_for_record(record, snapshot, diagnostics)
|
||||
overview = extract_analysis_context_pack_overview(snapshot) if snapshot else None
|
||||
overview_metadata = overview.get("metadata") if isinstance((overview or {}).get("metadata"), Mapping) else {}
|
||||
|
||||
@@ -694,7 +698,7 @@ def _append_notification_runs(
|
||||
status=status,
|
||||
provider=channel,
|
||||
ended_at=timestamp,
|
||||
attempts=_safe_int(run.get("attempts")) or 1,
|
||||
attempts=_safe_int(run.get("attempts")) if _safe_int(run.get("attempts")) is not None else 1,
|
||||
message=message,
|
||||
metadata={
|
||||
"channel": channel,
|
||||
@@ -721,6 +725,91 @@ def _append_notification_runs(
|
||||
return count
|
||||
|
||||
|
||||
_STOCK_CONTEXT_PROVIDER_DATA_TYPES = {
|
||||
"realtime_quote",
|
||||
"daily_data",
|
||||
"daily_bars",
|
||||
"technical",
|
||||
"fundamental",
|
||||
"fundamentals",
|
||||
"belong_boards",
|
||||
"chip",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_history_diagnostics_for_record(
|
||||
record: Any,
|
||||
snapshot: Dict[str, Any],
|
||||
diagnostics: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
if not diagnostics:
|
||||
return diagnostics
|
||||
|
||||
normalized = dict(diagnostics)
|
||||
report_type = _safe_key(getattr(record, "report_type", None))
|
||||
code = _safe_text(getattr(record, "code", None), max_length=32)
|
||||
report_kind = _safe_key(snapshot.get("report_kind")) if snapshot else ""
|
||||
|
||||
if report_type == "market_review" or report_kind == "market_review" or (code or "").upper() == "MARKET":
|
||||
normalized["stock_code"] = "MARKET"
|
||||
normalized.setdefault("scope", "market_review")
|
||||
normalized["provider_runs"] = [
|
||||
run
|
||||
for run in _as_list(normalized.get("provider_runs"))
|
||||
if _safe_key(_as_mapping(run).get("data_type")) not in _STOCK_CONTEXT_PROVIDER_DATA_TYPES
|
||||
]
|
||||
return normalized
|
||||
|
||||
first_llm_at = _first_timestamp(_as_list(normalized.get("llm_runs")))
|
||||
if first_llm_at is not None:
|
||||
normalized["history_runs"] = [
|
||||
run
|
||||
for run in _as_list(normalized.get("history_runs"))
|
||||
if not _timestamp_before(_as_mapping(run).get("created_at"), first_llm_at)
|
||||
]
|
||||
normalized["notification_runs"] = [
|
||||
run
|
||||
for run in _as_list(normalized.get("notification_runs"))
|
||||
if not _timestamp_before(_as_mapping(run).get("created_at"), first_llm_at)
|
||||
]
|
||||
|
||||
first_stock_data_at = _first_timestamp(
|
||||
[
|
||||
run
|
||||
for run in _as_list(normalized.get("provider_runs"))
|
||||
if _safe_key(_as_mapping(run).get("data_type")) in _STOCK_CONTEXT_PROVIDER_DATA_TYPES
|
||||
]
|
||||
)
|
||||
if first_stock_data_at is not None:
|
||||
normalized["provider_runs"] = [
|
||||
run
|
||||
for run in _as_list(normalized.get("provider_runs"))
|
||||
if _safe_key(_as_mapping(run).get("data_type")) != "news_search"
|
||||
or not _timestamp_before(_as_mapping(run).get("created_at"), first_stock_data_at)
|
||||
]
|
||||
return normalized
|
||||
|
||||
|
||||
def _first_timestamp(items: List[Any]) -> Optional[datetime]:
|
||||
timestamps = [
|
||||
parsed
|
||||
for parsed in (_datetime_for_elapsed(_as_mapping(item).get("created_at")) for item in items)
|
||||
if parsed is not None
|
||||
]
|
||||
return min(timestamps) if timestamps else None
|
||||
|
||||
|
||||
def _timestamp_before(value: Any, boundary: datetime) -> bool:
|
||||
parsed = _datetime_for_elapsed(value)
|
||||
if parsed is None:
|
||||
return False
|
||||
if parsed.tzinfo is None and boundary.tzinfo is not None:
|
||||
parsed = parsed.replace(tzinfo=boundary.tzinfo)
|
||||
elif parsed.tzinfo is not None and boundary.tzinfo is None:
|
||||
boundary = boundary.replace(tzinfo=parsed.tzinfo)
|
||||
return parsed < boundary
|
||||
|
||||
|
||||
def _put_skeleton_tail(
|
||||
nodes: Dict[str, Dict[str, Any]],
|
||||
edges: List[Dict[str, Any]],
|
||||
@@ -771,6 +860,26 @@ def _put_skeleton_tail(
|
||||
_append_edge(edges, "history_save", "notification", "control", downstream_status, label="通知")
|
||||
|
||||
|
||||
def _prune_active_skeleton_tail(
|
||||
nodes: Dict[str, Dict[str, Any]],
|
||||
edges: List[Dict[str, Any]],
|
||||
) -> None:
|
||||
remove_node_ids = set()
|
||||
if "llm" in nodes and any(node_id.startswith("llm_") for node_id in nodes):
|
||||
remove_node_ids.add("llm")
|
||||
if "notification" in nodes and any(node_id.startswith("notification_") for node_id in nodes):
|
||||
remove_node_ids.add("notification")
|
||||
if not remove_node_ids:
|
||||
return
|
||||
for node_id in remove_node_ids:
|
||||
nodes.pop(node_id, None)
|
||||
edges[:] = [
|
||||
edge
|
||||
for edge in edges
|
||||
if edge.get("from") not in remove_node_ids and edge.get("to") not in remove_node_ids
|
||||
]
|
||||
|
||||
|
||||
def _append_task_events(events: List[Dict[str, Any]], task: Any, flow_status: str) -> None:
|
||||
_append_event(
|
||||
events,
|
||||
@@ -873,8 +982,9 @@ def _append_active_flow_events(
|
||||
)
|
||||
|
||||
event_type = _safe_key(event.get("type")) or "event"
|
||||
if node_id and node_id in nodes and node_id not in known_node_ids:
|
||||
if event_type == "provider_run":
|
||||
provider_data_type = None
|
||||
provider_run = None
|
||||
if event_type in {"provider_run", "provider_run_started"} and node_id and node_id in nodes:
|
||||
provider_data_type = _safe_key(metadata.get("data_type") or "provider")
|
||||
provider_run = {
|
||||
"provider": metadata.get("provider") or nodes[node_id].get("provider"),
|
||||
@@ -882,6 +992,18 @@ def _append_active_flow_events(
|
||||
"fallback_from": metadata.get("fallback_from"),
|
||||
"fallback_to": metadata.get("fallback_to"),
|
||||
}
|
||||
|
||||
if node_id and node_id in nodes and node_id in known_node_ids:
|
||||
_refresh_incoming_edge_status(edges, node_id, nodes[node_id].get("status"))
|
||||
if provider_data_type and provider_run:
|
||||
last_provider_node_by_type[provider_data_type] = (node_id, provider_run)
|
||||
elif event_type in {"llm_run", "llm_run_started"}:
|
||||
last_llm_node = node_id
|
||||
elif event_type == "history_run":
|
||||
last_history_node = node_id
|
||||
|
||||
if node_id and node_id in nodes and node_id not in known_node_ids:
|
||||
if provider_data_type and provider_run:
|
||||
previous_provider = last_provider_node_by_type.get(provider_data_type)
|
||||
if previous_provider:
|
||||
previous_provider_node, previous_provider_run = previous_provider
|
||||
@@ -897,7 +1019,7 @@ def _append_active_flow_events(
|
||||
else:
|
||||
_append_edge(edges, "task_queue", node_id, "control", nodes[node_id].get("status", "unknown"), label="调用")
|
||||
last_provider_node_by_type[provider_data_type] = (node_id, provider_run)
|
||||
elif event_type == "llm_run":
|
||||
elif event_type in {"llm_run", "llm_run_started"}:
|
||||
anchor = "analysis_pipeline" if "analysis_pipeline" in nodes else "task_queue"
|
||||
_append_edge(edges, anchor, node_id, "data", nodes[node_id].get("status", "unknown"), label="生成")
|
||||
last_llm_node = node_id
|
||||
@@ -1167,7 +1289,19 @@ def _append_edge(
|
||||
metadata: Optional[Any] = None,
|
||||
) -> None:
|
||||
edge_id = f"{from_node}_to_{to_node}_{kind}"
|
||||
if any(edge["id"] == edge_id for edge in edges):
|
||||
for edge in edges:
|
||||
if edge["id"] != edge_id:
|
||||
continue
|
||||
edge["status"] = _valid_status(status)
|
||||
safe_label = _safe_text(label, max_length=40)
|
||||
if safe_label:
|
||||
edge["label"] = safe_label
|
||||
safe_message = _safe_text(message, max_length=180)
|
||||
if safe_message:
|
||||
edge["message"] = safe_message
|
||||
safe_metadata = _sanitize_metadata(metadata or {})
|
||||
if safe_metadata:
|
||||
edge["metadata"] = safe_metadata
|
||||
return
|
||||
edges.append(
|
||||
{
|
||||
@@ -1183,6 +1317,19 @@ def _append_edge(
|
||||
)
|
||||
|
||||
|
||||
def _refresh_incoming_edge_status(
|
||||
edges: List[Dict[str, Any]],
|
||||
node_id: Optional[str],
|
||||
status: Optional[Any],
|
||||
) -> None:
|
||||
if not node_id or status is None:
|
||||
return
|
||||
valid_status = _valid_status(status)
|
||||
for edge in edges:
|
||||
if edge.get("to") == node_id:
|
||||
edge["status"] = valid_status
|
||||
|
||||
|
||||
def _append_event(
|
||||
events: List[Dict[str, Any]],
|
||||
event_type: str,
|
||||
|
||||
@@ -1811,7 +1811,13 @@ class DatabaseManager(metaclass=_DatabaseManagerMeta):
|
||||
)
|
||||
return list(results)
|
||||
|
||||
def get_latest_analysis_by_query_id(self, query_id: str) -> Optional[AnalysisHistory]:
|
||||
def get_latest_analysis_by_query_id(
|
||||
self,
|
||||
query_id: str,
|
||||
*,
|
||||
code: Optional[str] = None,
|
||||
report_type: Optional[str] = None,
|
||||
) -> Optional[AnalysisHistory]:
|
||||
"""
|
||||
根据 query_id 查询最新一条分析历史记录
|
||||
|
||||
@@ -1819,14 +1825,22 @@ class DatabaseManager(metaclass=_DatabaseManagerMeta):
|
||||
|
||||
Args:
|
||||
query_id: 分析记录关联的 query_id
|
||||
code: 可选股票代码过滤,用于区分同一 query_id 下的 MARKET 与个股记录
|
||||
report_type: 可选报告类型过滤
|
||||
|
||||
Returns:
|
||||
AnalysisHistory 对象,不存在返回 None
|
||||
"""
|
||||
with self.get_session() as session:
|
||||
conditions = [AnalysisHistory.query_id == query_id]
|
||||
if code:
|
||||
conditions.append(AnalysisHistory.code == code)
|
||||
if report_type:
|
||||
conditions.append(AnalysisHistory.report_type == report_type)
|
||||
|
||||
result = session.execute(
|
||||
select(AnalysisHistory)
|
||||
.where(AnalysisHistory.query_id == query_id)
|
||||
.where(and_(*conditions))
|
||||
.order_by(desc(AnalysisHistory.created_at))
|
||||
.limit(1)
|
||||
).scalars().first()
|
||||
|
||||
116
tests/test_belong_boards_run_flow.py
Normal file
116
tests/test_belong_boards_run_flow.py
Normal file
@@ -0,0 +1,116 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Regression tests for belong-board run-flow diagnostics."""
|
||||
|
||||
from src.services.run_diagnostics import (
|
||||
activate_run_diagnostic_context,
|
||||
current_diagnostic_snapshot,
|
||||
reset_run_diagnostic_context,
|
||||
)
|
||||
from data_provider.base import DataFetcherManager
|
||||
|
||||
|
||||
class _BoardFetcher:
|
||||
def __init__(self, name: str, result):
|
||||
self.name = name
|
||||
self.priority = 0
|
||||
self._result = result
|
||||
self.calls = 0
|
||||
|
||||
def get_belong_board(self, _stock_code: str):
|
||||
self.calls += 1
|
||||
return self._result
|
||||
|
||||
|
||||
class _FailingBoardFetcher(_BoardFetcher):
|
||||
def __init__(self, name: str, error: Exception):
|
||||
super().__init__(name, [])
|
||||
self._error = error
|
||||
|
||||
def get_belong_board(self, _stock_code: str):
|
||||
self.calls += 1
|
||||
raise self._error
|
||||
|
||||
|
||||
def _capture_belong_board_run(manager: DataFetcherManager):
|
||||
flow_events = []
|
||||
token = activate_run_diagnostic_context(
|
||||
trace_id="trace-boards",
|
||||
task_id="task-boards",
|
||||
query_id="query-boards",
|
||||
stock_code="600519",
|
||||
trigger_source="api",
|
||||
event_sink=flow_events.append,
|
||||
)
|
||||
try:
|
||||
boards = manager.get_belong_boards("600519")
|
||||
diagnostics = current_diagnostic_snapshot()
|
||||
finally:
|
||||
reset_run_diagnostic_context(token)
|
||||
return boards, diagnostics, flow_events
|
||||
|
||||
|
||||
def test_get_belong_boards_records_successful_provider_run():
|
||||
manager = DataFetcherManager(
|
||||
fetchers=[
|
||||
_BoardFetcher(
|
||||
"BoardFetcher",
|
||||
[{"name": "白酒", "type": "行业"}],
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
boards, diagnostics, flow_events = _capture_belong_board_run(manager)
|
||||
|
||||
assert boards
|
||||
assert diagnostics is not None
|
||||
provider_runs = diagnostics["provider_runs"]
|
||||
assert len(provider_runs) == 1
|
||||
assert provider_runs[0]["data_type"] == "belong_boards"
|
||||
assert provider_runs[0]["provider"] == "BoardFetcher"
|
||||
assert provider_runs[0]["operation"] == "get_belong_board"
|
||||
assert provider_runs[0]["success"] is True
|
||||
assert provider_runs[0]["record_count"] == len(boards)
|
||||
assert [event["type"] for event in flow_events] == ["provider_run_started", "provider_run"]
|
||||
assert flow_events[0]["node_id"] == flow_events[1]["node_id"]
|
||||
assert flow_events[0]["node_id"] == "provider_belong_boards_boardfetcher_1"
|
||||
|
||||
|
||||
def test_get_belong_boards_records_empty_attempt_and_fallback():
|
||||
manager = DataFetcherManager(
|
||||
fetchers=[
|
||||
_BoardFetcher("EmptyBoardFetcher", []),
|
||||
_BoardFetcher("FallbackBoardFetcher", [{"name": "电力设备", "type": "行业"}]),
|
||||
]
|
||||
)
|
||||
|
||||
boards, diagnostics, flow_events = _capture_belong_board_run(manager)
|
||||
|
||||
assert boards
|
||||
assert diagnostics is not None
|
||||
provider_runs = diagnostics["provider_runs"]
|
||||
assert [run["provider"] for run in provider_runs] == ["EmptyBoardFetcher", "FallbackBoardFetcher"]
|
||||
assert [run["success"] for run in provider_runs] == [False, True]
|
||||
assert provider_runs[0]["error_type"] == "empty"
|
||||
assert provider_runs[0]["fallback_to"] == "FallbackBoardFetcher"
|
||||
assert len(flow_events) == 4
|
||||
|
||||
|
||||
def test_get_belong_boards_records_exception_attempt_and_fallback():
|
||||
manager = DataFetcherManager(
|
||||
fetchers=[
|
||||
_FailingBoardFetcher("FailingBoardFetcher", RuntimeError("board source down")),
|
||||
_BoardFetcher("FallbackBoardFetcher", [{"name": "电力设备", "type": "行业"}]),
|
||||
]
|
||||
)
|
||||
|
||||
boards, diagnostics, flow_events = _capture_belong_board_run(manager)
|
||||
|
||||
assert boards
|
||||
assert diagnostics is not None
|
||||
provider_runs = diagnostics["provider_runs"]
|
||||
assert [run["provider"] for run in provider_runs] == ["FailingBoardFetcher", "FallbackBoardFetcher"]
|
||||
assert provider_runs[0]["success"] is False
|
||||
assert provider_runs[0]["error_type"] == "RuntimeError"
|
||||
assert provider_runs[0]["fallback_to"] == "FallbackBoardFetcher"
|
||||
assert provider_runs[1]["success"] is True
|
||||
assert len(flow_events) == 4
|
||||
@@ -20,6 +20,41 @@ class _ChipFetcher:
|
||||
return self._result
|
||||
|
||||
|
||||
class _FailingChipFetcher(_ChipFetcher):
|
||||
def __init__(self, name: str, priority: int, error: Exception):
|
||||
super().__init__(name, priority, None)
|
||||
self._error = error
|
||||
|
||||
def get_chip_distribution(self, stock_code: str):
|
||||
self.calls += 1
|
||||
raise self._error
|
||||
|
||||
|
||||
def _run_with_chip_diagnostics(manager: DataFetcherManager):
|
||||
from src.services.run_diagnostics import (
|
||||
activate_run_diagnostic_context,
|
||||
current_diagnostic_snapshot,
|
||||
reset_run_diagnostic_context,
|
||||
)
|
||||
|
||||
flow_events = []
|
||||
token = activate_run_diagnostic_context(
|
||||
trace_id="trace-chip",
|
||||
task_id="task-chip",
|
||||
query_id="query-chip",
|
||||
stock_code="600519",
|
||||
trigger_source="api",
|
||||
event_sink=flow_events.append,
|
||||
)
|
||||
try:
|
||||
with patch("src.config.get_config", return_value=SimpleNamespace(enable_chip_distribution=True)):
|
||||
chip = manager.get_chip_distribution("600519")
|
||||
diagnostics = current_diagnostic_snapshot()
|
||||
finally:
|
||||
reset_run_diagnostic_context(token)
|
||||
return chip, diagnostics, flow_events
|
||||
|
||||
|
||||
def test_manager_skips_placeholder_chip_distribution_and_tries_next_fetcher():
|
||||
get_chip_circuit_breaker().reset()
|
||||
empty_chip = ChipDistribution(code="600519")
|
||||
@@ -36,10 +71,26 @@ def test_manager_skips_placeholder_chip_distribution_and_tries_next_fetcher():
|
||||
]
|
||||
)
|
||||
|
||||
with patch("src.config.get_config", return_value=SimpleNamespace(enable_chip_distribution=True)):
|
||||
chip = manager.get_chip_distribution("600519")
|
||||
chip, diagnostics, flow_events = _run_with_chip_diagnostics(manager)
|
||||
|
||||
assert chip is valid_chip
|
||||
assert diagnostics is not None
|
||||
provider_runs = diagnostics["provider_runs"]
|
||||
assert [run["data_type"] for run in provider_runs] == ["chip", "chip"]
|
||||
assert [run["success"] for run in provider_runs] == [False, True]
|
||||
assert provider_runs[0]["fallback_to"] == "ValidFetcher"
|
||||
assert provider_runs[0]["record_count"] == 0
|
||||
assert provider_runs[1]["record_count"] == 1
|
||||
assert [event["type"] for event in flow_events] == [
|
||||
"provider_run_started",
|
||||
"provider_run",
|
||||
"provider_run_started",
|
||||
"provider_run",
|
||||
]
|
||||
assert flow_events[0]["node_id"] == flow_events[1]["node_id"]
|
||||
assert flow_events[2]["node_id"] == flow_events[3]["node_id"]
|
||||
assert flow_events[0]["node_id"] == "provider_chip_emptyfetcher_1"
|
||||
assert flow_events[2]["node_id"] == "provider_chip_validfetcher_2"
|
||||
|
||||
|
||||
def test_manager_accepts_zero_concentration_chip_distribution():
|
||||
@@ -61,9 +112,46 @@ def test_manager_accepts_zero_concentration_chip_distribution():
|
||||
fallback_fetcher = _ChipFetcher("FallbackFetcher", 1, fallback_chip)
|
||||
manager = DataFetcherManager(fetchers=[zero_fetcher, fallback_fetcher])
|
||||
|
||||
with patch("src.config.get_config", return_value=SimpleNamespace(enable_chip_distribution=True)):
|
||||
chip = manager.get_chip_distribution("600519")
|
||||
chip, diagnostics, flow_events = _run_with_chip_diagnostics(manager)
|
||||
|
||||
assert chip is zero_concentration_chip
|
||||
assert zero_fetcher.calls == 1
|
||||
assert fallback_fetcher.calls == 0
|
||||
assert diagnostics is not None
|
||||
assert len(diagnostics["provider_runs"]) == 1
|
||||
assert diagnostics["provider_runs"][0]["data_type"] == "chip"
|
||||
assert diagnostics["provider_runs"][0]["success"] is True
|
||||
assert [event["type"] for event in flow_events] == ["provider_run_started", "provider_run"]
|
||||
assert flow_events[0]["node_id"] == flow_events[1]["node_id"]
|
||||
|
||||
|
||||
def test_manager_records_failed_chip_attempt_and_falls_back_to_next_fetcher():
|
||||
get_chip_circuit_breaker().reset()
|
||||
valid_chip = ChipDistribution(
|
||||
code="600519",
|
||||
profit_ratio=0.61,
|
||||
avg_cost=12.3,
|
||||
concentration_90=0.13,
|
||||
)
|
||||
failing_fetcher = _FailingChipFetcher("FailingFetcher", 0, RuntimeError("temporary chip failure"))
|
||||
fallback_fetcher = _ChipFetcher("FallbackFetcher", 1, valid_chip)
|
||||
manager = DataFetcherManager(fetchers=[failing_fetcher, fallback_fetcher])
|
||||
|
||||
chip, diagnostics, flow_events = _run_with_chip_diagnostics(manager)
|
||||
|
||||
assert chip is valid_chip
|
||||
assert failing_fetcher.calls == 1
|
||||
assert fallback_fetcher.calls == 1
|
||||
assert diagnostics is not None
|
||||
provider_runs = diagnostics["provider_runs"]
|
||||
assert [run["provider"] for run in provider_runs] == ["FailingFetcher", "FallbackFetcher"]
|
||||
assert provider_runs[0]["success"] is False
|
||||
assert provider_runs[0]["error_type"] == "RuntimeError"
|
||||
assert provider_runs[0]["fallback_to"] == "FallbackFetcher"
|
||||
assert provider_runs[1]["success"] is True
|
||||
assert [event["type"] for event in flow_events] == [
|
||||
"provider_run_started",
|
||||
"provider_run",
|
||||
"provider_run_started",
|
||||
"provider_run",
|
||||
]
|
||||
|
||||
@@ -463,7 +463,7 @@ def test_reuses_same_run_history_when_saved_under_different_wall_clock_date() ->
|
||||
run_review.assert_not_called()
|
||||
|
||||
|
||||
def test_get_context_passes_current_query_id_to_market_review_when_generating() -> None:
|
||||
def test_get_context_uses_isolated_market_context_query_id_when_generating() -> None:
|
||||
db = MagicMock()
|
||||
db.get_analysis_history.return_value = []
|
||||
service = DailyMarketContextService(
|
||||
@@ -503,8 +503,10 @@ def test_get_context_passes_current_query_id_to_market_review_when_generating()
|
||||
|
||||
assert context is not None
|
||||
assert context.source == "market_review_runtime"
|
||||
assert context.query_id == "query-1381"
|
||||
run_review.assert_called_once()
|
||||
assert run_review.call_args.kwargs["query_id"] == "query-1381"
|
||||
assert run_review.call_args.kwargs["query_id"] == "market_context_query-1381_cn"
|
||||
assert run_review.call_args.kwargs["trigger_source"] == "daily_market_context"
|
||||
acquire_lock.assert_called_once()
|
||||
release_lock.assert_called_once_with(lock_token)
|
||||
|
||||
|
||||
@@ -431,6 +431,13 @@ class MarketReviewLocalizationTestCase(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(saved, 1)
|
||||
with DatabaseManager.get_instance().get_session() as session:
|
||||
row = session.query(AnalysisHistory).filter(
|
||||
AnalysisHistory.query_id == "market-task-001"
|
||||
).first()
|
||||
self.assertIsNotNone(row)
|
||||
snapshot = json.loads(row.context_snapshot or "{}")
|
||||
self.assertIn("analysis_context_pack_overview", snapshot)
|
||||
db = DatabaseManager.get_instance()
|
||||
with db.get_session() as session:
|
||||
row = session.query(AnalysisHistory).filter(
|
||||
|
||||
@@ -24,7 +24,11 @@ from src.services.run_flow import (
|
||||
from src.services.run_diagnostics import (
|
||||
activate_run_diagnostic_context,
|
||||
current_diagnostic_snapshot,
|
||||
record_llm_run,
|
||||
record_llm_run_started,
|
||||
record_notification_run,
|
||||
record_provider_run,
|
||||
record_provider_run_started,
|
||||
reset_run_diagnostic_context,
|
||||
)
|
||||
from src.services.task_queue import AnalysisTaskQueue, TaskInfo, TaskStatus
|
||||
@@ -191,7 +195,13 @@ class _FakeHistoryDb:
|
||||
def get_analysis_history_by_id(self, record_id: int):
|
||||
return self.record if self.record is not None and record_id == self.record.id else None
|
||||
|
||||
def get_latest_analysis_by_query_id(self, query_id: str):
|
||||
def get_latest_analysis_by_query_id(self, query_id: str, *, code: str | None = None, report_type: str | None = None):
|
||||
if self.record is None or query_id != self.record.query_id:
|
||||
return None
|
||||
if code is not None and self.record.code != code:
|
||||
return None
|
||||
if report_type is not None and self.record.report_type != report_type:
|
||||
return None
|
||||
return self.record if self.record is not None and query_id == self.record.query_id else None
|
||||
|
||||
|
||||
@@ -205,6 +215,10 @@ class _FakeMarketReviewDb:
|
||||
self.saved_context_snapshot = kwargs.get("context_snapshot")
|
||||
return self.save_result
|
||||
|
||||
def get_latest_analysis_by_query_id(self, query_id: str, *, code: str | None = None, report_type: str | None = None):
|
||||
_ = (query_id, code, report_type)
|
||||
return SimpleNamespace(id=42)
|
||||
|
||||
def update_analysis_history_diagnostics(self, *, query_id: str, code: str, diagnostics: dict) -> None:
|
||||
_ = (query_id, code)
|
||||
self.updated_diagnostics = diagnostics
|
||||
@@ -416,6 +430,213 @@ class RunFlowTestCase(unittest.TestCase):
|
||||
f"{node_id}.{field}",
|
||||
)
|
||||
|
||||
def test_active_started_events_update_same_provider_and_llm_nodes(self) -> None:
|
||||
flow_events: list[dict] = []
|
||||
token = activate_run_diagnostic_context(
|
||||
trace_id="trace-started",
|
||||
task_id="task-started",
|
||||
query_id="query-started",
|
||||
stock_code="600519",
|
||||
trigger_source="api",
|
||||
event_sink=flow_events.append,
|
||||
)
|
||||
try:
|
||||
record_provider_run_started(
|
||||
data_type="daily_data",
|
||||
provider="DailyFetcher",
|
||||
operation="get_daily_data",
|
||||
)
|
||||
record_provider_run(
|
||||
data_type="daily_data",
|
||||
provider="DailyFetcher",
|
||||
operation="get_daily_data",
|
||||
success=True,
|
||||
latency_ms=120,
|
||||
record_count=30,
|
||||
)
|
||||
record_llm_run_started(
|
||||
model="deepseek-chat",
|
||||
call_type="analysis",
|
||||
)
|
||||
record_llm_run(
|
||||
success=True,
|
||||
model="deepseek-chat",
|
||||
call_type="analysis",
|
||||
duration_ms=900,
|
||||
)
|
||||
finally:
|
||||
reset_run_diagnostic_context(token)
|
||||
|
||||
snapshot = build_task_run_flow_snapshot(
|
||||
TaskInfo(
|
||||
task_id="task-started",
|
||||
trace_id="trace-started",
|
||||
stock_code="600519",
|
||||
stock_name="贵州茅台",
|
||||
status=TaskStatus.PROCESSING,
|
||||
created_at=datetime(2026, 6, 8, 10, 0, 0),
|
||||
flow_events=flow_events,
|
||||
)
|
||||
)
|
||||
|
||||
provider_nodes = [node for node in snapshot.nodes if node.id == "provider_daily_data_dailyfetcher_1"]
|
||||
llm_nodes = [node for node in snapshot.nodes if node.id == "llm_analysis_1"]
|
||||
provider_edges = [
|
||||
edge for edge in snapshot.edges
|
||||
if edge.to_node == "provider_daily_data_dailyfetcher_1"
|
||||
]
|
||||
llm_edges = [
|
||||
edge for edge in snapshot.edges
|
||||
if edge.to_node == "llm_analysis_1"
|
||||
]
|
||||
|
||||
self.assertEqual(len(provider_nodes), 1)
|
||||
self.assertEqual(provider_nodes[0].status, "success")
|
||||
self.assertEqual(provider_nodes[0].record_count, 30)
|
||||
self.assertTrue(provider_edges)
|
||||
self.assertTrue(all(edge.status == "success" for edge in provider_edges))
|
||||
self.assertEqual(len(llm_nodes), 1)
|
||||
self.assertEqual(llm_nodes[0].status, "success")
|
||||
self.assertTrue(llm_edges)
|
||||
self.assertTrue(all(edge.status == "success" for edge in llm_edges))
|
||||
self.assertIn("provider_run_started", {event.type for event in snapshot.events})
|
||||
self.assertIn("llm_run_started", {event.type for event in snapshot.events})
|
||||
|
||||
def test_active_chip_started_event_updates_same_provider_node(self) -> None:
|
||||
flow_events: list[dict] = []
|
||||
token = activate_run_diagnostic_context(
|
||||
trace_id="trace-chip-started",
|
||||
task_id="task-chip-started",
|
||||
query_id="query-chip-started",
|
||||
stock_code="600519",
|
||||
trigger_source="api",
|
||||
event_sink=flow_events.append,
|
||||
)
|
||||
try:
|
||||
record_provider_run_started(
|
||||
data_type="chip",
|
||||
provider="ChipFetcher",
|
||||
operation="get_chip_distribution",
|
||||
)
|
||||
record_provider_run(
|
||||
data_type="chip",
|
||||
provider="ChipFetcher",
|
||||
operation="get_chip_distribution",
|
||||
success=True,
|
||||
latency_ms=80,
|
||||
record_count=1,
|
||||
)
|
||||
finally:
|
||||
reset_run_diagnostic_context(token)
|
||||
|
||||
snapshot = build_task_run_flow_snapshot(
|
||||
TaskInfo(
|
||||
task_id="task-chip-started",
|
||||
trace_id="trace-chip-started",
|
||||
stock_code="600519",
|
||||
stock_name="贵州茅台",
|
||||
status=TaskStatus.PROCESSING,
|
||||
created_at=datetime(2026, 6, 8, 10, 0, 0),
|
||||
flow_events=flow_events,
|
||||
)
|
||||
)
|
||||
|
||||
chip_nodes = [node for node in snapshot.nodes if node.id == "provider_chip_chipfetcher_1"]
|
||||
|
||||
self.assertEqual(len(chip_nodes), 1)
|
||||
self.assertEqual(chip_nodes[0].status, "success")
|
||||
self.assertEqual(chip_nodes[0].record_count, 1)
|
||||
self.assertEqual(chip_nodes[0].label, "筹码结构 · ChipFetcher")
|
||||
self.assertIn("provider_run_started", {event.type for event in snapshot.events})
|
||||
|
||||
def test_llm_started_and_result_match_by_call_type_when_model_alias_differs(self) -> None:
|
||||
flow_events: list[dict] = []
|
||||
token = activate_run_diagnostic_context(
|
||||
trace_id="trace-llm-alias",
|
||||
task_id="task-llm-alias",
|
||||
query_id="query-llm-alias",
|
||||
stock_code="600519",
|
||||
trigger_source="api",
|
||||
event_sink=flow_events.append,
|
||||
)
|
||||
try:
|
||||
record_llm_run_started(
|
||||
model="deepseek-chat",
|
||||
call_type="agent_analysis",
|
||||
)
|
||||
record_llm_run(
|
||||
success=True,
|
||||
model="deepseek/deepseek-chat",
|
||||
call_type="agent_analysis",
|
||||
duration_ms=98000,
|
||||
)
|
||||
finally:
|
||||
reset_run_diagnostic_context(token)
|
||||
|
||||
snapshot = build_task_run_flow_snapshot(
|
||||
TaskInfo(
|
||||
task_id="task-llm-alias",
|
||||
trace_id="trace-llm-alias",
|
||||
stock_code="600519",
|
||||
stock_name="贵州茅台",
|
||||
status=TaskStatus.PROCESSING,
|
||||
created_at=datetime(2026, 6, 8, 10, 0, 0),
|
||||
flow_events=flow_events,
|
||||
)
|
||||
)
|
||||
|
||||
llm_nodes = [node for node in snapshot.nodes if node.id.startswith("llm_agent_analysis")]
|
||||
|
||||
self.assertEqual([node.id for node in llm_nodes], ["llm_agent_analysis_1"])
|
||||
self.assertEqual(llm_nodes[0].status, "success")
|
||||
self.assertIn("llm_run_started", {event.type for event in snapshot.events})
|
||||
self.assertIn("llm_run", {event.type for event in snapshot.events})
|
||||
|
||||
def test_completed_active_snapshot_prunes_skeleton_tail_when_live_nodes_exist(self) -> None:
|
||||
flow_events: list[dict] = []
|
||||
token = activate_run_diagnostic_context(
|
||||
trace_id="trace-completed-live",
|
||||
task_id="task-completed-live",
|
||||
query_id="query-completed-live",
|
||||
stock_code="600519",
|
||||
trigger_source="api",
|
||||
event_sink=flow_events.append,
|
||||
)
|
||||
try:
|
||||
record_llm_run(
|
||||
success=True,
|
||||
model="deepseek/deepseek-chat",
|
||||
call_type="agent_analysis",
|
||||
duration_ms=98000,
|
||||
)
|
||||
record_notification_run(
|
||||
channel="report",
|
||||
status="not_configured",
|
||||
success=False,
|
||||
attempts=0,
|
||||
)
|
||||
finally:
|
||||
reset_run_diagnostic_context(token)
|
||||
|
||||
snapshot = build_task_run_flow_snapshot(
|
||||
TaskInfo(
|
||||
task_id="task-completed-live",
|
||||
trace_id="trace-completed-live",
|
||||
stock_code="600519",
|
||||
stock_name="贵州茅台",
|
||||
status=TaskStatus.COMPLETED,
|
||||
created_at=datetime(2026, 6, 8, 10, 0, 0),
|
||||
completed_at=datetime(2026, 6, 8, 10, 2, 0),
|
||||
flow_events=flow_events,
|
||||
)
|
||||
)
|
||||
node_ids = {node.id for node in snapshot.nodes}
|
||||
|
||||
self.assertIn("llm_agent_analysis_1", node_ids)
|
||||
self.assertIn("notification_report_1", node_ids)
|
||||
self.assertNotIn("llm", node_ids)
|
||||
self.assertNotIn("notification", node_ids)
|
||||
|
||||
def test_task_queue_stores_bounded_flow_events_and_broadcasts_task_progress(self) -> None:
|
||||
queue = AnalysisTaskQueue(max_workers=1)
|
||||
queue._max_flow_events_per_task = 2
|
||||
@@ -713,8 +934,143 @@ class RunFlowTestCase(unittest.TestCase):
|
||||
self.assertEqual(snapshot.stock_code, "MARKET")
|
||||
self.assertEqual(snapshot.task_id, "task-market")
|
||||
self.assertIn("history_run", {event.type for event in snapshot.events})
|
||||
notification = next(node for node in snapshot.nodes if node.id.startswith("notification_report"))
|
||||
self.assertEqual(notification.attempts, 0)
|
||||
self.assertTrue(snapshot.lanes)
|
||||
|
||||
def test_market_review_run_flow_filters_leaked_stock_provider_runs(self) -> None:
|
||||
context_snapshot = {
|
||||
"report_kind": "market_review",
|
||||
"diagnostics": {
|
||||
"trace_id": "trace-market",
|
||||
"query_id": "query-flow",
|
||||
"stock_code": "688521.SH",
|
||||
"provider_runs": [
|
||||
{
|
||||
"data_type": "daily_data",
|
||||
"provider": "StockFetcher",
|
||||
"success": True,
|
||||
"created_at": "2026-06-13T16:00:57",
|
||||
},
|
||||
{
|
||||
"data_type": "news_search",
|
||||
"provider": "Tavily",
|
||||
"success": True,
|
||||
"created_at": "2026-06-13T16:01:00",
|
||||
},
|
||||
],
|
||||
"llm_runs": [],
|
||||
"history_runs": [],
|
||||
"notification_runs": [],
|
||||
},
|
||||
}
|
||||
|
||||
snapshot = build_history_run_flow_snapshot(
|
||||
_history_record(
|
||||
context_snapshot=context_snapshot,
|
||||
code="MARKET",
|
||||
name="大盘复盘",
|
||||
report_type="market_review",
|
||||
)
|
||||
)
|
||||
|
||||
provider_labels = {node.label for node in snapshot.nodes if node.kind == "data_source"}
|
||||
self.assertEqual(snapshot.stock_code, "MARKET")
|
||||
self.assertNotIn("日线K线 · StockFetcher", provider_labels)
|
||||
self.assertIn("新闻舆情 · Tavily", provider_labels)
|
||||
|
||||
def test_stock_run_flow_filters_nested_market_context_artifacts(self) -> None:
|
||||
context_snapshot = {
|
||||
"diagnostics": {
|
||||
"trace_id": "trace-stock",
|
||||
"query_id": "query-flow",
|
||||
"stock_code": "688521.SH",
|
||||
"provider_runs": [
|
||||
{
|
||||
"data_type": "news_search",
|
||||
"provider": "MarketNews",
|
||||
"success": True,
|
||||
"created_at": "2026-06-13T16:01:00",
|
||||
},
|
||||
{
|
||||
"data_type": "realtime_quote",
|
||||
"provider": "Akshare",
|
||||
"success": True,
|
||||
"created_at": "2026-06-13T16:01:51",
|
||||
},
|
||||
{
|
||||
"data_type": "news_search",
|
||||
"provider": "StockNews",
|
||||
"success": True,
|
||||
"created_at": "2026-06-13T16:02:25",
|
||||
},
|
||||
],
|
||||
"llm_runs": [
|
||||
{
|
||||
"call_type": "analysis",
|
||||
"success": True,
|
||||
"created_at": "2026-06-13T16:03:54",
|
||||
}
|
||||
],
|
||||
"history_runs": [
|
||||
{
|
||||
"report_saved": True,
|
||||
"metadata_saved": True,
|
||||
"created_at": "2026-06-13T16:01:51",
|
||||
},
|
||||
{
|
||||
"report_saved": True,
|
||||
"metadata_saved": True,
|
||||
"created_at": "2026-06-13T16:04:12",
|
||||
},
|
||||
],
|
||||
"notification_runs": [
|
||||
{
|
||||
"channel": "report",
|
||||
"status": "skipped",
|
||||
"success": False,
|
||||
"attempts": 0,
|
||||
"created_at": "2026-06-13T16:01:51",
|
||||
},
|
||||
{
|
||||
"channel": "report",
|
||||
"status": "not_configured",
|
||||
"success": False,
|
||||
"attempts": 0,
|
||||
"created_at": "2026-06-13T16:04:12",
|
||||
},
|
||||
],
|
||||
},
|
||||
"analysis_context_pack_overview": _overview(
|
||||
blocks=[
|
||||
{
|
||||
"key": "quote",
|
||||
"label": "行情",
|
||||
"status": "available",
|
||||
"source": "Akshare",
|
||||
"warnings": [],
|
||||
"missing_reasons": [],
|
||||
}
|
||||
]
|
||||
),
|
||||
}
|
||||
|
||||
snapshot = build_history_run_flow_snapshot(_history_record(context_snapshot=context_snapshot))
|
||||
|
||||
self.assertEqual(
|
||||
[node.label for node in snapshot.nodes if node.label == "保存报告"],
|
||||
["保存报告"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[node.label for node in snapshot.nodes if node.label.startswith("推送通知")],
|
||||
["推送通知 · report"],
|
||||
)
|
||||
provider_labels = {node.label for node in snapshot.nodes if node.kind == "data_source"}
|
||||
self.assertNotIn("新闻舆情 · MarketNews", provider_labels)
|
||||
self.assertIn("新闻舆情 · StockNews", provider_labels)
|
||||
notification = next(node for node in snapshot.nodes if node.id.startswith("notification_report"))
|
||||
self.assertEqual(notification.attempts, 0)
|
||||
|
||||
def test_market_review_persist_records_diagnostics_without_bool_history_id(self) -> None:
|
||||
from src.core.market_review import _persist_market_review_history
|
||||
|
||||
@@ -742,10 +1098,11 @@ class RunFlowTestCase(unittest.TestCase):
|
||||
self.assertTrue(saved)
|
||||
self.assertIsNotNone(fake_db.saved_context_snapshot)
|
||||
self.assertIn("diagnostics", fake_db.saved_context_snapshot)
|
||||
self.assertIn("analysis_context_pack_overview", fake_db.saved_context_snapshot)
|
||||
self.assertIsNotNone(fake_db.updated_diagnostics)
|
||||
history_runs = fake_db.updated_diagnostics["history_runs"]
|
||||
self.assertTrue(history_runs)
|
||||
self.assertNotEqual(history_runs[-1].get("analysis_history_id"), True)
|
||||
self.assertEqual(history_runs[-1].get("analysis_history_id"), 42)
|
||||
|
||||
def test_flow_endpoints_return_404_for_missing_records(self) -> None:
|
||||
with self.assertRaises(HTTPException) as history_ctx:
|
||||
@@ -761,6 +1118,56 @@ class RunFlowTestCase(unittest.TestCase):
|
||||
get_task_run_flow("missing-task")
|
||||
self.assertEqual(task_ctx.exception.status_code, 404)
|
||||
|
||||
def test_completed_task_flow_refresh_uses_persisted_history_report_type_alias(self) -> None:
|
||||
task = TaskInfo(
|
||||
task_id="query-flow",
|
||||
trace_id="trace-flow",
|
||||
stock_code="600519",
|
||||
stock_name="贵州茅台",
|
||||
status=TaskStatus.COMPLETED,
|
||||
report_type="detailed",
|
||||
)
|
||||
queue = SimpleNamespace(get_task=lambda task_id: task)
|
||||
|
||||
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue), patch(
|
||||
"api.v1.endpoints.analysis._load_history_run_flow_by_query_id",
|
||||
return_value=None,
|
||||
) as load_history:
|
||||
snapshot = get_task_run_flow("query-flow")
|
||||
|
||||
self.assertEqual(snapshot.task_id, "query-flow")
|
||||
load_history.assert_called_once_with(
|
||||
"query-flow",
|
||||
code="600519",
|
||||
report_type="full",
|
||||
fail_open=True,
|
||||
)
|
||||
|
||||
def test_completed_market_review_task_flow_uses_market_history_filters(self) -> None:
|
||||
task = TaskInfo(
|
||||
task_id="market-query-flow",
|
||||
trace_id="trace-market-flow",
|
||||
stock_code="cn",
|
||||
stock_name="大盘复盘",
|
||||
status=TaskStatus.COMPLETED,
|
||||
report_type="market-review",
|
||||
)
|
||||
queue = SimpleNamespace(get_task=lambda task_id: task)
|
||||
|
||||
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue), patch(
|
||||
"api.v1.endpoints.analysis._load_history_run_flow_by_query_id",
|
||||
return_value=None,
|
||||
) as load_history:
|
||||
snapshot = get_task_run_flow("market-query-flow")
|
||||
|
||||
self.assertEqual(snapshot.task_id, "market-query-flow")
|
||||
load_history.assert_called_once_with(
|
||||
"market-query-flow",
|
||||
code="MARKET",
|
||||
report_type="market_review",
|
||||
fail_open=True,
|
||||
)
|
||||
|
||||
def test_run_flow_payload_redacts_errors_metadata_and_sensitive_paths(self) -> None:
|
||||
context_snapshot = {
|
||||
"diagnostics": _diagnostics(unsafe=True),
|
||||
|
||||
Reference in New Issue
Block a user