feat(ssa): Complete Phase I-IV intelligent dialogue and tool system development
Phase I - Session Blackboard + READ Layer: - SessionBlackboardService with Postgres-Only cache - DataProfileService for data overview generation - PicoInferenceService for LLM-driven PICO extraction - Frontend DataContextCard and VariableDictionaryPanel - E2E tests: 31/31 passed Phase II - Conversation Layer LLM + Intent Router: - ConversationService with SSE streaming - IntentRouterService (rule-first + LLM fallback, 6 intents) - SystemPromptService with 6-segment dynamic assembly - TokenTruncationService for context management - ChatHandlerService as unified chat entry - Frontend SSAChatPane and useSSAChat hook - E2E tests: 38/38 passed Phase III - Method Consultation + AskUser Standardization: - ToolRegistryService with Repository Pattern - MethodConsultService with DecisionTable + LLM enhancement - AskUserService with global interrupt handling - Frontend AskUserCard component - E2E tests: 13/13 passed Phase IV - Dialogue-Driven Analysis + QPER Integration: - ToolOrchestratorService (plan/execute/report) - analysis_plan SSE event for WorkflowPlan transmission - Dual-channel confirmation (ask_user card + workspace button) - PICO as optional hint for LLM parsing - E2E tests: 25/25 passed R Statistics Service: - 5 new R tools: anova_one, baseline_table, fisher, linear_reg, wilcoxon - Enhanced guardrails and block helpers - Comprehensive test suite (run_all_tools_test.js) Documentation: - Updated system status document (v5.9) - Updated SSA module status and development plan (v1.8) Total E2E: 107/107 passed (Phase I: 31, Phase II: 38, Phase III: 13, Phase IV: 25) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
102
backend/src/modules/ssa/routes/blackboard.routes.ts
Normal file
102
backend/src/modules/ssa/routes/blackboard.routes.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Phase I — Session Blackboard + READ Layer 路由
|
||||
*
|
||||
* 路由前缀: /sessions/:sessionId/blackboard
|
||||
*
|
||||
* GET / — 获取完整 SessionBlackboard
|
||||
* POST /data-overview — 触发 get_data_overview 工具
|
||||
* POST /variable-detail — 触发 get_variable_detail 工具
|
||||
* PATCH /variables/:name — 用户确认/修改变量类型
|
||||
*/
|
||||
|
||||
import { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
import { sessionBlackboardService } from '../services/SessionBlackboardService.js';
|
||||
import { executeGetDataOverview } from '../services/tools/GetDataOverviewTool.js';
|
||||
import { executeGetVariableDetail } from '../services/tools/GetVariableDetailTool.js';
|
||||
import type { ColumnType, PicoRole, VariableDictPatch } from '../types/session-blackboard.types.js';
|
||||
|
||||
export default async function blackboardRoutes(app: FastifyInstance) {
|
||||
|
||||
// GET /sessions/:sessionId/blackboard — 获取完整黑板
|
||||
app.get('/', async (req, reply) => {
|
||||
const { sessionId } = req.params as { sessionId: string };
|
||||
|
||||
const blackboard = await sessionBlackboardService.get(sessionId);
|
||||
if (!blackboard) {
|
||||
return reply.status(404).send({ error: 'Blackboard not found for this session' });
|
||||
}
|
||||
|
||||
const report = blackboard.dataOverview
|
||||
? sessionBlackboardService.generateFiveSectionReport(
|
||||
blackboard.dataOverview,
|
||||
blackboard.variableDictionary,
|
||||
)
|
||||
: null;
|
||||
|
||||
return reply.send({ blackboard, report });
|
||||
});
|
||||
|
||||
// POST /sessions/:sessionId/blackboard/data-overview — 执行 get_data_overview
|
||||
app.post('/data-overview', async (req, reply) => {
|
||||
const { sessionId } = req.params as { sessionId: string };
|
||||
|
||||
logger.info('[SSA:Route] Triggering data overview', { sessionId });
|
||||
|
||||
const result = await executeGetDataOverview(sessionId);
|
||||
|
||||
if (!result.success) {
|
||||
return reply.status(400).send({ error: result.error });
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
report: result.report,
|
||||
});
|
||||
});
|
||||
|
||||
// POST /sessions/:sessionId/blackboard/variable-detail — 执行 get_variable_detail
|
||||
app.post('/variable-detail', async (req, reply) => {
|
||||
const { sessionId } = req.params as { sessionId: string };
|
||||
const { variableName, confirmedType, label } = req.body as {
|
||||
variableName: string;
|
||||
confirmedType?: ColumnType;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
if (!variableName) {
|
||||
return reply.status(400).send({ error: 'variableName is required' });
|
||||
}
|
||||
|
||||
logger.info('[SSA:Route] Triggering variable detail', { sessionId, variableName });
|
||||
|
||||
const result = await executeGetVariableDetail(sessionId, variableName, confirmedType, label);
|
||||
|
||||
if (!result.success) {
|
||||
return reply.status(400).send({ error: result.error });
|
||||
}
|
||||
|
||||
return reply.send(result);
|
||||
});
|
||||
|
||||
// PATCH /sessions/:sessionId/blackboard/variables/:name — 更新变量字典条目
|
||||
app.patch('/variables/:name', async (req, reply) => {
|
||||
const { sessionId, name } = req.params as { sessionId: string; name: string };
|
||||
const body = req.body as {
|
||||
confirmedType?: ColumnType;
|
||||
label?: string;
|
||||
picoRole?: PicoRole | null;
|
||||
};
|
||||
|
||||
logger.info('[SSA:Route] Updating variable dictionary entry', { sessionId, name, body });
|
||||
|
||||
const dictPatch: VariableDictPatch = {};
|
||||
if (body.confirmedType !== undefined) dictPatch.confirmedType = body.confirmedType;
|
||||
if (body.label !== undefined) dictPatch.label = body.label;
|
||||
if (body.picoRole !== undefined) dictPatch.picoRole = body.picoRole;
|
||||
|
||||
await sessionBlackboardService.updateVariable(sessionId, name, dictPatch);
|
||||
|
||||
return reply.send({ success: true });
|
||||
});
|
||||
}
|
||||
217
backend/src/modules/ssa/routes/chat.routes.ts
Normal file
217
backend/src/modules/ssa/routes/chat.routes.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Phase II — 统一对话 API 路由
|
||||
*
|
||||
* POST /sessions/:id/chat — 统一对话入口(SSE 流式)
|
||||
* GET /sessions/:id/chat/history — 获取对话历史
|
||||
* GET /sessions/:id/chat/conversation — 获取 conversation 元信息
|
||||
*
|
||||
* SSE 格式:OpenAI Compatible(与前端 useAIStream 兼容)
|
||||
* 心跳:5 秒(H1)
|
||||
* 竞态保护:placeholder 占位(H3)
|
||||
*/
|
||||
|
||||
import { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
import { conversationService } from '../services/ConversationService.js';
|
||||
import { intentRouterService } from '../services/IntentRouterService.js';
|
||||
import { chatHandlerService } from '../services/ChatHandlerService.js';
|
||||
import { askUserService } from '../services/AskUserService.js';
|
||||
|
||||
function getUserId(request: FastifyRequest): string {
|
||||
const userId = (request as any).user?.userId;
|
||||
if (!userId) throw new Error('User not authenticated');
|
||||
return userId;
|
||||
}
|
||||
|
||||
export default async function chatRoutes(app: FastifyInstance) {
|
||||
|
||||
/**
|
||||
* POST /sessions/:id/chat
|
||||
* 统一对话入口 — SSE 流式响应
|
||||
*/
|
||||
app.post('/:id/chat', async (req, reply) => {
|
||||
const { id: sessionId } = req.params as { id: string };
|
||||
const userId = getUserId(req);
|
||||
const { content, enableDeepThinking, metadata } = req.body as {
|
||||
content: string;
|
||||
enableDeepThinking?: boolean;
|
||||
metadata?: Record<string, any>;
|
||||
};
|
||||
|
||||
if (!content?.trim()) {
|
||||
return reply.status(400).send({ error: '消息内容不能为空' });
|
||||
}
|
||||
|
||||
// SSE 响应头
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'X-Accel-Buffering': 'no',
|
||||
});
|
||||
|
||||
const writer = {
|
||||
write: (data: string) => {
|
||||
try {
|
||||
return reply.raw.write(data);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
end: () => {
|
||||
try { reply.raw.end(); } catch { /* ignore */ }
|
||||
},
|
||||
on: (event: string, handler: () => void) => {
|
||||
req.raw.on(event, handler);
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
// 1. 获取或创建 Conversation(延迟创建)
|
||||
const conversationId = await conversationService.getOrCreateConversation(sessionId, userId);
|
||||
|
||||
// 2. 保存用户消息
|
||||
await conversationService.saveUserMessage(conversationId, content.trim());
|
||||
|
||||
// ── H1 全局打断判定 ──
|
||||
const pending = await askUserService.getPending(sessionId);
|
||||
if (pending) {
|
||||
const askUserResponse = metadata?.askUserResponse
|
||||
? askUserService.parseResponse(metadata)
|
||||
: null;
|
||||
|
||||
if (askUserResponse) {
|
||||
// 正常回答问题(含 skip)
|
||||
const placeholderMsgId = await conversationService.createAssistantPlaceholder(
|
||||
conversationId, 'chat',
|
||||
);
|
||||
|
||||
const metaEvent = JSON.stringify({
|
||||
type: 'intent_classified',
|
||||
intent: 'chat',
|
||||
confidence: 1,
|
||||
source: 'ask_user_response',
|
||||
guardTriggered: false,
|
||||
});
|
||||
writer.write(`data: ${metaEvent}\n\n`);
|
||||
|
||||
const result = await chatHandlerService.handleAskUserResponse(
|
||||
sessionId, conversationId, askUserResponse, writer, placeholderMsgId,
|
||||
);
|
||||
|
||||
logger.info('[SSA:Chat] AskUser response handled', {
|
||||
sessionId, action: askUserResponse.action, success: result.success,
|
||||
});
|
||||
|
||||
writer.end();
|
||||
return;
|
||||
} else {
|
||||
// 用户无视卡片,强行打字转移话题
|
||||
await askUserService.clearPending(sessionId);
|
||||
logger.info('[SSA:Chat] 用户转移话题,已取消挂起的 ask_user 状态', { sessionId });
|
||||
}
|
||||
}
|
||||
// ── H1 结束 ──
|
||||
|
||||
// 3. 意图分类
|
||||
const intentResult = await intentRouterService.classify(content.trim(), sessionId);
|
||||
|
||||
// 发送意图元数据事件(前端可用于 UI 切换)
|
||||
const metaEvent = JSON.stringify({
|
||||
type: 'intent_classified',
|
||||
intent: intentResult.intent,
|
||||
confidence: intentResult.confidence,
|
||||
source: intentResult.source,
|
||||
guardTriggered: intentResult.guardTriggered || false,
|
||||
guardMessage: intentResult.guardMessage,
|
||||
});
|
||||
writer.write(`data: ${metaEvent}\n\n`);
|
||||
|
||||
// 4. 创建 assistant placeholder(H3 竞态保护)
|
||||
const placeholderMsgId = await conversationService.createAssistantPlaceholder(
|
||||
conversationId, intentResult.intent,
|
||||
);
|
||||
|
||||
// 5. 分发到意图处理器
|
||||
const result = await chatHandlerService.handle(
|
||||
sessionId, conversationId, content.trim(),
|
||||
intentResult, writer, placeholderMsgId,
|
||||
);
|
||||
|
||||
logger.info('[SSA:Chat] Request completed', {
|
||||
sessionId,
|
||||
intent: result.intent,
|
||||
success: result.success,
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('[SSA:Chat] Unhandled error', {
|
||||
sessionId,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
const errorEvent = JSON.stringify({
|
||||
type: 'error',
|
||||
code: 'CHAT_ERROR',
|
||||
message: error.message || '处理消息时发生错误',
|
||||
});
|
||||
try {
|
||||
writer.write(`data: ${errorEvent}\n\n`);
|
||||
} catch { /* ignore */ }
|
||||
} finally {
|
||||
writer.end();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /sessions/:id/chat/history
|
||||
* 获取对话历史消息
|
||||
*/
|
||||
app.get('/:id/chat/history', async (req, reply) => {
|
||||
const { id: sessionId } = req.params as { id: string };
|
||||
|
||||
const conversation = await conversationService.getConversationBySession(sessionId);
|
||||
if (!conversation) {
|
||||
return reply.send({ messages: [], conversationId: null });
|
||||
}
|
||||
|
||||
const messages = await conversationService.getMessages(conversation.id);
|
||||
|
||||
return reply.send({
|
||||
conversationId: conversation.id,
|
||||
messages: messages.map(m => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
thinkingContent: m.thinkingContent,
|
||||
intent: (m.metadata as any)?.intent,
|
||||
status: (m.metadata as any)?.status,
|
||||
createdAt: m.createdAt,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /sessions/:id/chat/conversation
|
||||
* 获取 conversation 元信息
|
||||
*/
|
||||
app.get('/:id/chat/conversation', async (req, reply) => {
|
||||
const { id: sessionId } = req.params as { id: string };
|
||||
|
||||
const conversation = await conversationService.getConversationBySession(sessionId);
|
||||
if (!conversation) {
|
||||
return reply.send({ conversation: null });
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
conversation: {
|
||||
id: conversation.id,
|
||||
title: conversation.title,
|
||||
messageCount: conversation.messageCount,
|
||||
createdAt: conversation.createdAt,
|
||||
updatedAt: conversation.updatedAt,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -13,6 +13,9 @@ import { prisma } from '../../../config/database.js';
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
import { storage } from '../../../common/storage/index.js';
|
||||
import { DataParserService } from '../services/DataParserService.js';
|
||||
import { executeGetDataOverview } from '../services/tools/GetDataOverviewTool.js';
|
||||
import { picoInferenceService } from '../services/PicoInferenceService.js';
|
||||
import { sessionBlackboardService } from '../services/SessionBlackboardService.js';
|
||||
|
||||
function getUserId(request: FastifyRequest): string {
|
||||
const userId = (request as any).user?.userId;
|
||||
@@ -87,6 +90,15 @@ export default async function sessionRoutes(app: FastifyInstance) {
|
||||
sessionId: session.id,
|
||||
hasFile: !!dataOssKey
|
||||
});
|
||||
|
||||
// Phase I: 文件上传后异步触发 data overview + PICO 推断(不阻塞响应)
|
||||
if (dataOssKey) {
|
||||
triggerDataOverviewAsync(session.id).catch((err) => {
|
||||
logger.error('[SSA:Session] Async data overview trigger failed', {
|
||||
sessionId: session.id, error: err.message,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 返回前端期望的格式
|
||||
return reply.send({
|
||||
@@ -135,4 +147,106 @@ export default async function sessionRoutes(app: FastifyInstance) {
|
||||
|
||||
return reply.send(messages);
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /sessions/:id/data-context/stream
|
||||
* Phase I: SSE 端点 — 实时推送 data overview + PICO 推断进度
|
||||
*/
|
||||
app.get('/:id/data-context/stream', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
});
|
||||
|
||||
const send = (type: string, data: any) => {
|
||||
reply.raw.write(`data: ${JSON.stringify({ type, ...data })}\n\n`);
|
||||
};
|
||||
|
||||
send('connected', { sessionId: id });
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
reply.raw.write(':heartbeat\n\n');
|
||||
}, 15000);
|
||||
|
||||
const cleanup = () => {
|
||||
clearInterval(heartbeat);
|
||||
reply.raw.end();
|
||||
};
|
||||
|
||||
req.raw.on('close', cleanup);
|
||||
|
||||
try {
|
||||
// Step 1: Data Overview
|
||||
send('data_overview_start', { message: '正在生成数据概览...' });
|
||||
const overviewResult = await executeGetDataOverview(id);
|
||||
|
||||
if (!overviewResult.success) {
|
||||
send('data_overview_error', { error: overviewResult.error });
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
send('data_overview_complete', {
|
||||
report: overviewResult.report,
|
||||
});
|
||||
|
||||
// Step 2: PICO Inference
|
||||
if (overviewResult.blackboard?.dataOverview && overviewResult.blackboard?.variableDictionary) {
|
||||
send('pico_start', { message: '正在推断 PICO 结构...' });
|
||||
|
||||
const pico = await picoInferenceService.inferFromOverview(
|
||||
id,
|
||||
overviewResult.blackboard.dataOverview,
|
||||
overviewResult.blackboard.variableDictionary,
|
||||
);
|
||||
|
||||
if (pico) {
|
||||
send('pico_complete', { picoInference: pico });
|
||||
} else {
|
||||
send('pico_skip', { message: 'PICO 推断跳过(LLM 不可用或推断失败)' });
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: 完成
|
||||
const finalBlackboard = await sessionBlackboardService.get(id);
|
||||
send('data_context_complete', {
|
||||
blackboard: finalBlackboard,
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('[SSA:SSE] Data context stream failed', { sessionId: id, error: error.message });
|
||||
send('data_context_error', { error: error.message });
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步触发 data overview(fire-and-forget,不阻塞 session 创建响应)
|
||||
*/
|
||||
async function triggerDataOverviewAsync(sessionId: string): Promise<void> {
|
||||
logger.info('[SSA:AutoTrigger] Starting async data overview', { sessionId });
|
||||
|
||||
const result = await executeGetDataOverview(sessionId);
|
||||
if (!result.success) {
|
||||
logger.warn('[SSA:AutoTrigger] Data overview failed', { sessionId, error: result.error });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('[SSA:AutoTrigger] Data overview complete, starting PICO inference', { sessionId });
|
||||
|
||||
if (result.blackboard?.dataOverview && result.blackboard?.variableDictionary) {
|
||||
await picoInferenceService.inferFromOverview(
|
||||
sessionId,
|
||||
result.blackboard.dataOverview,
|
||||
result.blackboard.variableDictionary,
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('[SSA:AutoTrigger] Full data context pipeline complete', { sessionId });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user