From 08108e81cdd7a6ff5b81a812e7146655c7e74337 Mon Sep 17 00:00:00 2001 From: HaHafeng Date: Tue, 10 Mar 2026 21:37:34 +0800 Subject: [PATCH] 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 --- .../src/modules/ssa/routes/session.routes.ts | 16 ++++- .../ssa/services/ChatHandlerService.ts | 41 +++++++++++-- .../ssa/services/DataProfileService.ts | 60 +++++++++++++++---- docs/05-部署文档/03-待部署变更清单.md | 4 +- .../modules/ssa/components/SSAChatPane.tsx | 16 +++++ .../src/modules/ssa/hooks/useAnalysis.ts | 11 +++- 6 files changed, 131 insertions(+), 17 deletions(-) diff --git a/backend/src/modules/ssa/routes/session.routes.ts b/backend/src/modules/ssa/routes/session.routes.ts index 38649e1b..0440a15d 100644 --- a/backend/src/modules/ssa/routes/session.routes.ts +++ b/backend/src/modules/ssa/routes/session.routes.ts @@ -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 diff --git a/backend/src/modules/ssa/services/ChatHandlerService.ts b/backend/src/modules/ssa/services/ChatHandlerService.ts index bdc47187..7683e2dd 100644 --- a/backend/src/modules/ssa/services/ChatHandlerService.ts +++ b/backend/src/modules/ssa/services/ChatHandlerService.ts @@ -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 { let toolOutputs = ''; @@ -778,7 +809,9 @@ export class ChatHandlerService { '注意:禁止编造或模拟任何分析结果和数值。', blackboard?.dataOverview ? '用户已上传数据,你可以结合数据概览回答问题。当用户提出分析需求时,会自动触发分析流程。' - : '用户尚未上传数据。请引导用户先上传研究数据文件。', + : (hasUploadedData + ? '用户数据已上传,系统正在构建数据上下文。请先收集分析目标和关键变量,不要再次要求上传文件。' + : '用户尚未上传数据。请引导用户先上传研究数据文件。'), ].join('\n'); const fullToolOutputs = toolOutputs ? `${toolOutputs}\n\n${agentHint}` : agentHint; diff --git a/backend/src/modules/ssa/services/DataProfileService.ts b/backend/src/modules/ssa/services/DataProfileService.ts index a478f9fa..daf84dd4 100644 --- a/backend/src/modules/ssa/services/DataProfileService.ts +++ b/backend/src/modules/ssa/services/DataProfileService.ts @@ -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 { + 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[]; + + 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 */ diff --git a/docs/05-部署文档/03-待部署变更清单.md b/docs/05-部署文档/03-待部署变更清单.md index c3d1a0c7..c7ff14d7 100644 --- a/docs/05-部署文档/03-待部署变更清单.md +++ b/docs/05-部署文档/03-待部署变更清单.md @@ -4,7 +4,7 @@ > **维护规则**: 每次修改 Schema / 新增依赖 / 改配置时,**立即**在此文档追加记录 > **Cursor Rule**: `.cursor/rules/deployment-change-tracking.mdc` 会自动提醒 > **最后清零**: 2026-03-09(0309 二次部署完成后清零) -> **本次变更**: 用户直授权限体系 + 运营埋点增强 + 运营看板 MAU/Token + AIA 附件格式优化(2026-03-10) +> **本次变更**: 用户直授权限体系 + 运营埋点增强 + 运营看板 MAU/Token + AIA 附件格式优化 + RVW 性能与导出增强(2026-03-10) --- @@ -30,6 +30,7 @@ | BE-5 | 运营看板增强(MAU/Token/最活跃用户) | `activity.service.ts`, `statsController.ts` | 重新构建镜像 | `getTodayOverview` 新增 MAU、apiTokenTotal、topActiveUser | | BE-6 | 埋点验证脚本 | `scripts/verify-activity-tracking.ts` | 无需部署 | `npm run test:tracking` 开发/运维自测用 | | BE-7 | AIA 附件格式能力更新(支持 `.xlsx/.csv`,`.doc/.xls` 友好提示) | `modules/aia/services/attachmentService.ts`, `modules/aia/services/conversationService.ts`, `modules/aia/types/index.ts`, `common/document/ExtractionClient.ts` | 重新构建镜像 | `.xlsx/.csv` 走 `document/to-markdown` 灰度解析;统一附件问答护栏,防止引用系统知识库 | +| BE-8 | RVW 审稿执行链路提速(4 模块受控并行 + 增量结果持久化) | `modules/rvw/skills/core/profile.ts`, `modules/rvw/skills/core/executor.ts`, `modules/rvw/workers/reviewWorker.ts`, `modules/rvw/controllers/reviewController.ts` | 重新构建镜像 | DataForensics 并入并行组;`maxConcurrency=4`;`getTaskDetail` 返回 `reviewProgress` 与模块级中间结果 | ### 前端变更 @@ -41,6 +42,7 @@ | FE-4 | 运营看板展示 MAU/Token/最活跃用户 | `StatsDashboardPage.tsx`, `statsApi.ts` | 重新构建镜像 | 新增 4 个统计卡片 | | FE-5 | 顶部导航点击埋点上报 | `TopNavigation.tsx` | 重新构建镜像 | 点击模块导航时 fire-and-forget 上报 | | FE-6 | AIA 上传交互更新(放开 `.xlsx/.csv`,`.doc/.xls` 友好提示) | `modules/aia/components/ChatWorkspace.tsx`, `modules/aia/constants.ts` | 重新构建镜像 | 上传白名单与提示文案同步后端,附件入口文案更新 | +| FE-7 | RVW 任务详情体验增强(先出先看 + 导出补齐数据验证表格) | `modules/rvw/components/TaskDetail.tsx`, `modules/rvw/types/index.ts` | 重新构建镜像 | 审查过程中展示已完成 Tab;Word 导出新增“数据验证”章节(汇总 + 表格明细 + 问题列表) | ### Python 微服务变更 diff --git a/frontend-v2/src/modules/ssa/components/SSAChatPane.tsx b/frontend-v2/src/modules/ssa/components/SSAChatPane.tsx index e6c05a52..a2cfca66 100644 --- a/frontend-v2/src/modules/ssa/components/SSAChatPane.tsx +++ b/frontend-v2/src/modules/ssa/components/SSAChatPane.tsx @@ -137,6 +137,22 @@ export const SSAChatPane: React.FC = () => { const file = e.target.files?.[0]; if (!file) return; + const ext = file.name.split('.').pop()?.toLowerCase() || ''; + if (ext === 'xls') { + const msg = '系统为了保证表格解析的稳定性,当前仅支持 .xlsx / .csv 格式。请您在本地 Excel/WPS 中打开该文件,选择“另存为 -> Excel 工作簿 (.xlsx)”后再次上传。'; + setUploadStatus('error'); + setError(msg); + addToast(msg, 'error'); + return; + } + if (!['csv', 'xlsx'].includes(ext)) { + const msg = `不支持的文件类型: .${ext || 'unknown'}。当前支持:.csv、.xlsx`; + setUploadStatus('error'); + setError(msg); + addToast(msg, 'error'); + return; + } + setUploadStatus('uploading'); setError(null); diff --git a/frontend-v2/src/modules/ssa/hooks/useAnalysis.ts b/frontend-v2/src/modules/ssa/hooks/useAnalysis.ts index e9f2d923..ecc3536e 100644 --- a/frontend-v2/src/modules/ssa/hooks/useAnalysis.ts +++ b/frontend-v2/src/modules/ssa/hooks/useAnalysis.ts @@ -91,7 +91,16 @@ export function useAnalysis(): UseAnalysisReturn { body: formData, }); - if (!response.ok) throw new Error('上传失败'); + if (!response.ok) { + let errMsg = '上传失败'; + try { + const errData = await response.json(); + errMsg = errData?.error || errData?.message || errMsg; + } catch { + // ignore parse error + } + throw new Error(errMsg); + } const result = await response.json(); setUploadProgress(100); return result;