fix(ssa): harden spreadsheet upload recognition and guidance

Fix SSA data-context generation for Excel uploads by parsing xlsx/xls via extension-aware paths instead of UTF-8 fallback.
Add on-demand overview rebuild in Agent flow, align xls friendly prompts on frontend/backend, and surface backend upload errors to users.

Made-with: Cursor
This commit is contained in:
2026-03-10 21:37:34 +08:00
parent 4a4771fbbe
commit 08108e81cd
6 changed files with 131 additions and 17 deletions

View File

@@ -50,13 +50,27 @@ export default async function sessionRoutes(app: FastifyInstance) {
if (data) {
const buffer = await data.toBuffer();
const filename = data.filename;
const ext = filename.split('.').pop()?.toLowerCase() || '';
if (ext === 'xls') {
return reply.status(400).send({
error: '系统为了保证表格解析的稳定性,当前仅支持 .xlsx / .csv 格式。请您在本地 Excel/WPS 中打开该文件,选择“另存为 -> Excel 工作簿 (.xlsx)”后再次上传。',
});
}
const allowedExt = new Set(['csv', 'xlsx']);
if (!allowedExt.has(ext)) {
return reply.status(400).send({
error: `不支持的文件类型: .${ext || 'unknown'}。当前支持:.csv、.xlsx`,
});
}
const baseName = filename.replace(/\.(csv|xlsx?)$/i, '') || '数据';
const now = new Date();
title = `${baseName} ${now.getMonth() + 1}${now.getDate()}`;
// 生成存储 Key遵循 OSS 目录结构规范)
const uuid = crypto.randomUUID().replace(/-/g, '').substring(0, 16);
const ext = filename.split('.').pop()?.toLowerCase() || 'csv';
dataOssKey = `tenants/${tenantId}/users/${userId}/ssa/${uuid}.${ext}`;
// 上传到 OSS

View File

@@ -17,6 +17,7 @@ import { tokenTruncationService } from './TokenTruncationService.js';
import { methodConsultService } from './MethodConsultService.js';
import { askUserService, type AskUserResponse } from './AskUserService.js';
import { toolOrchestratorService } from './ToolOrchestratorService.js';
import { executeGetDataOverview } from './tools/GetDataOverviewTool.js';
import { agentPlannerService } from './AgentPlannerService.js';
import { agentCoderService } from './AgentCoderService.js';
import { codeRunnerService } from './CodeRunnerService.js';
@@ -456,14 +457,43 @@ export class ChatHandlerService {
}
// 3. 无挂起确认 — 检查是否是分析请求
const blackboard = await sessionBlackboardService.get(sessionId);
const hasData = !!blackboard?.dataOverview;
let blackboard = await sessionBlackboardService.get(sessionId);
let hasData = !!blackboard?.dataOverview;
const session = await (prisma as any).ssaSession.findUnique({
where: { id: sessionId },
select: { dataOssKey: true },
});
const hasUploadedData = !!session?.dataOssKey;
// 已上传但黑板未就绪:主动补建上下文,避免误提示“请先上传”
if (!hasData && hasUploadedData) {
try {
const overviewResult = await executeGetDataOverview(sessionId);
if (overviewResult.success) {
blackboard = await sessionBlackboardService.get(sessionId);
hasData = !!blackboard?.dataOverview;
}
} catch (e) {
logger.warn('[SSA:ChatHandler] Build data overview on demand failed', {
sessionId,
error: e instanceof Error ? e.message : String(e),
});
}
}
if (hasData && this.looksLikeAnalysisRequest(userContent)) {
return await this.agentGeneratePlan(sessionId, conversationId, userContent, writer, placeholderMessageId);
}
return await this.handleAgentChat(sessionId, conversationId, writer, placeholderMessageId, blackboard);
return await this.handleAgentChat(
sessionId,
conversationId,
writer,
placeholderMessageId,
blackboard,
hasUploadedData,
);
} catch (error: any) {
logger.error('[SSA:ChatHandler] Agent mode error', { sessionId, error: error.message });
await conversationService.markAssistantError(placeholderMessageId, error.message);
@@ -756,6 +786,7 @@ export class ChatHandlerService {
writer: StreamWriter,
placeholderMessageId: string,
blackboard: any,
hasUploadedData: boolean,
): Promise<HandleResult> {
let toolOutputs = '';
@@ -778,7 +809,9 @@ export class ChatHandlerService {
'注意:禁止编造或模拟任何分析结果和数值。',
blackboard?.dataOverview
? '用户已上传数据,你可以结合数据概览回答问题。当用户提出分析需求时,会自动触发分析流程。'
: '用户尚未上传数据。请引导用户先上传研究数据文件。',
: (hasUploadedData
? '用户数据已上传,系统正在构建数据上下文。请先收集分析目标和关键变量,不要再次要求上传文件。'
: '用户尚未上传数据。请引导用户先上传研究数据文件。'),
].join('\n');
const fullToolOutputs = toolOutputs ? `${toolOutputs}\n\n${agentHint}` : agentHint;

View File

@@ -249,18 +249,26 @@ export class DataProfileService {
} else if (session.dataOssKey) {
// 从 OSS 下载文件
const buffer = await storage.download(session.dataOssKey);
const content = buffer.toString('utf-8');
// 检测文件格式JSON 或 CSV
const trimmedContent = content.trim();
if (trimmedContent.startsWith('[') || trimmedContent.startsWith('{')) {
// JSON 格式
const data = JSON.parse(content);
return await this.generateProfile(sessionId, data);
} else {
// CSV 格式,直接发给 Python 解析(更高效、更可靠)
const ext = this.getFileExtensionFromOssKey(session.dataOssKey);
// 按文件扩展名处理,避免将 Excel 二进制误当作 UTF-8 文本
if (ext === 'csv') {
const content = buffer.toString('utf-8');
return await this.generateProfileFromCSV(sessionId, content);
}
if (ext === 'xlsx' || ext === 'xls') {
return await this.generateProfileFromExcel(sessionId, buffer);
}
// 兼容历史数据:未知扩展名时再尝试 JSON/CSV 兜底
const content = buffer.toString('utf-8');
const trimmedContent = content.trim();
if (trimmedContent.startsWith('[') || trimmedContent.startsWith('{')) {
const data = JSON.parse(content);
return await this.generateProfile(sessionId, data);
}
return await this.generateProfileFromCSV(sessionId, content);
} else {
throw new Error('No data available for session');
}
@@ -278,6 +286,38 @@ export class DataProfileService {
}
}
private getFileExtensionFromOssKey(ossKey: string): string {
const match = ossKey.toLowerCase().match(/\.([a-z0-9]+)$/);
return match?.[1] || '';
}
/**
* 从 Excel 二进制生成画像xlsx/xls
*/
private async generateProfileFromExcel(sessionId: string, buffer: Buffer): Promise<DataProfileResult> {
try {
const xlsx = await import('xlsx');
const workbook = xlsx.read(buffer, { type: 'buffer' });
const firstSheetName = workbook.SheetNames[0];
const firstSheet = workbook.Sheets[firstSheetName];
const rows = xlsx.utils.sheet_to_json(firstSheet, {
defval: null,
raw: false,
}) as Record<string, any>[];
return await this.generateProfile(sessionId, rows);
} catch (error: any) {
logger.error('[SSA:DataProfile] Excel profile generation failed', {
sessionId,
error: error.message,
});
return {
success: false,
error: error.message || 'Failed to parse Excel file',
};
}
}
/**
* 保存画像到 Session
*/