feat(ssa): Complete Phase 2A frontend integration - multi-step workflow end-to-end

Phase 2A: WorkflowPlannerService, WorkflowExecutorService, Python data quality, 6 bug fixes, DescriptiveResultView, multi-step R code/Word export, MVP UI reuse. V11 UI: Gemini-style, multi-task, single-page scroll, Word export. Architecture: Block-based rendering consensus (4 block types). New R tools: chi_square, correlation, descriptive, logistic_binary, mann_whitney, t_test_paired. Docs: dev summary, block-based plan, status updates, task list v2.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-20 23:09:27 +08:00
parent 23b422f758
commit 428a22adf2
62 changed files with 15416 additions and 299 deletions

View File

@@ -0,0 +1,430 @@
/**
* SSA Workflow Routes (Phase 2A)
*
* 多步骤工作流 API
* - POST /plan - 生成工作流计划
* - POST /:workflowId/execute - 执行工作流
* - GET /:workflowId/status - 获取执行状态
* - GET /:workflowId/stream - SSE 实时进度
*/
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { logger } from '../../../common/logging/index.js';
import { workflowPlannerService } from '../services/WorkflowPlannerService.js';
import { workflowExecutorService } from '../services/WorkflowExecutorService.js';
import { dataProfileService } from '../services/DataProfileService.js';
// 请求类型定义
interface PlanWorkflowBody {
sessionId: string;
userQuery: string;
}
interface ExecuteWorkflowParams {
workflowId: string;
}
interface WorkflowStatusParams {
workflowId: string;
}
interface GenerateProfileBody {
sessionId: string;
}
export default async function workflowRoutes(app: FastifyInstance) {
/**
* POST /workflow/plan
* 生成多步骤工作流计划
*/
app.post<{ Body: PlanWorkflowBody }>(
'/plan',
async (request, reply) => {
const { sessionId, userQuery } = request.body;
if (!sessionId || !userQuery) {
return reply.status(400).send({
success: false,
error: 'sessionId and userQuery are required'
});
}
try {
logger.info('[SSA:API] Planning workflow', { sessionId, userQuery });
const plan = await workflowPlannerService.planWorkflow(sessionId, userQuery);
return reply.send({
success: true,
plan
});
} catch (error: any) {
logger.error('[SSA:API] Workflow planning failed', {
sessionId,
error: error.message
});
return reply.status(500).send({
success: false,
error: error.message
});
}
}
);
/**
* POST /workflow/:workflowId/execute
* 执行工作流
*/
app.post<{ Params: ExecuteWorkflowParams; Body: { sessionId: string } }>(
'/:workflowId/execute',
async (request, reply) => {
const { workflowId } = request.params;
const { sessionId } = request.body;
if (!sessionId) {
return reply.status(400).send({
success: false,
error: 'sessionId is required'
});
}
try {
logger.info('[SSA:API] Executing workflow', { workflowId, sessionId });
const result = await workflowExecutorService.executeWorkflow(workflowId, sessionId);
return reply.send({
success: true,
result
});
} catch (error: any) {
logger.error('[SSA:API] Workflow execution failed', {
workflowId,
error: error.message
});
return reply.status(500).send({
success: false,
error: error.message
});
}
}
);
/**
* GET /workflow/:workflowId/status
* 获取工作流状态
*/
app.get<{ Params: WorkflowStatusParams }>(
'/:workflowId/status',
async (request, reply) => {
const { workflowId } = request.params;
try {
const status = await workflowExecutorService.getWorkflowStatus(workflowId);
if (!status) {
return reply.status(404).send({
success: false,
error: 'Workflow not found'
});
}
return reply.send({
success: true,
workflow: status
});
} catch (error: any) {
logger.error('[SSA:API] Get workflow status failed', {
workflowId,
error: error.message
});
return reply.status(500).send({
success: false,
error: error.message
});
}
}
);
/**
* GET /workflow/:workflowId/stream
* SSE 实时进度流 - 连接后自动开始执行
*/
app.get<{ Params: WorkflowStatusParams }>(
'/:workflowId/stream',
async (request, reply) => {
const { workflowId } = request.params;
logger.info('[SSA:SSE] Stream connected', { workflowId });
// 设置 SSE 响应头
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*'
});
// 发送初始连接确认
reply.raw.write(`data: ${JSON.stringify({ type: 'connected', workflowId })}\n\n`);
// 发送心跳
const heartbeat = setInterval(() => {
reply.raw.write(':heartbeat\n\n');
}, 15000);
let isCompleted = false;
// 监听进度事件
const onProgress = (message: any) => {
// 添加 workflowId 到消息中
const enrichedMessage = { ...message, workflowId };
reply.raw.write(`data: ${JSON.stringify(enrichedMessage)}\n\n`);
// 如果工作流完成,标记并清理
if (message.type === 'workflow_complete' || message.type === 'workflow_error') {
isCompleted = true;
cleanup();
}
};
workflowExecutorService.on('progress', onProgress);
// 清理函数
const cleanup = () => {
clearInterval(heartbeat);
workflowExecutorService.off('progress', onProgress);
if (!isCompleted) {
reply.raw.write(`data: ${JSON.stringify({ type: 'disconnected' })}\n\n`);
}
reply.raw.end();
};
// 客户端断开连接时清理
request.raw.on('close', cleanup);
// 获取 workflow 的 session_id 并启动执行
try {
const workflow = await import('../../../config/database.js').then(m =>
m.prisma.ssaWorkflow.findUnique({
where: { id: workflowId },
select: { sessionId: true, status: true }
})
);
if (!workflow) {
reply.raw.write(`data: ${JSON.stringify({
type: 'workflow_error',
error: 'Workflow not found',
workflowId
})}\n\n`);
cleanup();
return;
}
// 如果已完成,直接返回状态
if (workflow.status === 'completed' || workflow.status === 'failed') {
reply.raw.write(`data: ${JSON.stringify({
type: 'workflow_complete',
status: workflow.status,
workflowId
})}\n\n`);
cleanup();
return;
}
// 异步启动执行(不阻塞 SSE 连接)
logger.info('[SSA:SSE] Starting workflow execution', { workflowId, sessionId: workflow.sessionId });
workflowExecutorService.executeWorkflow(workflowId, workflow.sessionId)
.catch((error: any) => {
logger.error('[SSA:SSE] Workflow execution failed', { workflowId, error: error.message });
reply.raw.write(`data: ${JSON.stringify({
type: 'workflow_error',
error: error.message,
workflowId
})}\n\n`);
cleanup();
});
} catch (error: any) {
logger.error('[SSA:SSE] Failed to start workflow', { workflowId, error: error.message });
reply.raw.write(`data: ${JSON.stringify({
type: 'workflow_error',
error: error.message,
workflowId
})}\n\n`);
cleanup();
}
}
);
/**
* POST /workflow/profile
* 生成数据画像
*/
app.post<{ Body: GenerateProfileBody }>(
'/profile',
async (request, reply) => {
const { sessionId } = request.body;
if (!sessionId) {
return reply.status(400).send({
success: false,
error: 'sessionId is required'
});
}
try {
logger.info('[SSA:API] Generating data profile', { sessionId });
const result = await dataProfileService.generateProfileFromSession(sessionId);
if (!result.success || !result.profile) {
// 如果画像生成失败,返回基于 session schema 的简化版本
const session = await import('../../../config/database.js').then(m => m.prisma.ssaSession.findUnique({
where: { id: sessionId }
}));
if (session?.dataSchema) {
const schema = session.dataSchema as any;
const fallbackProfile = generateFallbackProfile(schema, session.title || 'data.csv');
return reply.send({
success: true,
profile: fallbackProfile
});
}
return reply.send({
success: false,
error: result.error || 'Profile generation failed'
});
}
// 转换为前端期望的格式
const frontendProfile = convertToFrontendFormat(result.profile, result.quality);
return reply.send({
success: true,
profile: frontendProfile
});
} catch (error: any) {
logger.error('[SSA:API] Profile generation failed', {
sessionId,
error: error.message
});
return reply.status(500).send({
success: false,
error: error.message
});
}
}
);
}
/**
* 将后端 DataProfile 转换为前端期望的格式
*/
function convertToFrontendFormat(profile: any, quality?: any) {
const summary = profile.summary || {};
const columns = profile.columns || [];
return {
file_name: 'data.csv',
row_count: summary.totalRows || 0,
column_count: summary.totalColumns || 0,
total_cells: (summary.totalRows || 0) * (summary.totalColumns || 0),
missing_cells: summary.totalMissingCells || 0,
missing_ratio: (summary.overallMissingRate || 0) / 100,
duplicate_rows: 0,
duplicate_ratio: 0,
numeric_columns: summary.numericColumns || 0,
categorical_columns: summary.categoricalColumns || 0,
datetime_columns: summary.datetimeColumns || 0,
quality_score: quality?.score || 85,
quality_grade: quality?.grade || 'B',
columns: columns.map((col: any) => ({
name: col.name,
dtype: col.type,
inferred_type: col.type,
non_null_count: col.totalCount - (col.missingCount || 0),
null_count: col.missingCount || 0,
null_ratio: (col.missingRate || 0) / 100,
unique_count: col.uniqueCount || 0,
unique_ratio: col.uniqueCount ? col.uniqueCount / col.totalCount : 0,
sample_values: col.topValues?.slice(0, 5).map((v: any) => v.value) || [],
mean: col.mean,
std: col.std,
min: col.min,
max: col.max,
median: col.median,
q1: col.q1,
q3: col.q3,
skewness: col.skewness,
kurtosis: col.kurtosis,
outlier_count: col.outlierCount,
outlier_ratio: col.outlierRate,
top_categories: col.topValues?.map((v: any) => ({
value: v.value,
count: v.count,
ratio: v.percentage / 100
}))
})),
warnings: quality?.issues || [],
recommendations: quality?.recommendations || [],
generated_at: new Date().toISOString()
};
}
/**
* 基于 Schema 生成简化版 fallback profile
*/
function generateFallbackProfile(schema: any, fileName: string) {
const columns = schema.columns || [];
const rowCount = schema.rowCount || 0;
const numericCols = columns.filter((c: any) => c.type === 'numeric');
const categoricalCols = columns.filter((c: any) => c.type === 'categorical');
const totalMissing = columns.reduce((sum: number, c: any) => sum + (c.nullCount || 0), 0);
const totalCells = rowCount * columns.length;
return {
file_name: fileName,
row_count: rowCount,
column_count: columns.length,
total_cells: totalCells,
missing_cells: totalMissing,
missing_ratio: totalCells > 0 ? totalMissing / totalCells : 0,
duplicate_rows: 0,
duplicate_ratio: 0,
numeric_columns: numericCols.length,
categorical_columns: categoricalCols.length,
datetime_columns: 0,
quality_score: 80,
quality_grade: 'B',
columns: columns.map((col: any) => ({
name: col.name,
dtype: col.type,
inferred_type: col.type,
non_null_count: rowCount - (col.nullCount || 0),
null_count: col.nullCount || 0,
null_ratio: rowCount > 0 ? (col.nullCount || 0) / rowCount : 0,
unique_count: col.uniqueValues || 0,
unique_ratio: rowCount > 0 ? (col.uniqueValues || 0) / rowCount : 0,
sample_values: []
})),
warnings: totalMissing > 0 ? [`数据中存在 ${totalMissing} 个缺失值`] : [],
recommendations: ['建议检查数据完整性后再进行分析'],
generated_at: new Date().toISOString()
};
}