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:
2026-02-22 18:53:39 +08:00
parent bf10dec4c8
commit 3446909ff7
68 changed files with 11583 additions and 412 deletions

View 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 placeholderH3 竞态保护)
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,
},
});
});
}