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:
@@ -50,13 +50,27 @@ export default async function sessionRoutes(app: FastifyInstance) {
|
|||||||
if (data) {
|
if (data) {
|
||||||
const buffer = await data.toBuffer();
|
const buffer = await data.toBuffer();
|
||||||
const filename = data.filename;
|
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 baseName = filename.replace(/\.(csv|xlsx?)$/i, '') || '数据';
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
title = `${baseName} ${now.getMonth() + 1}月${now.getDate()}日`;
|
title = `${baseName} ${now.getMonth() + 1}月${now.getDate()}日`;
|
||||||
|
|
||||||
// 生成存储 Key(遵循 OSS 目录结构规范)
|
// 生成存储 Key(遵循 OSS 目录结构规范)
|
||||||
const uuid = crypto.randomUUID().replace(/-/g, '').substring(0, 16);
|
const uuid = crypto.randomUUID().replace(/-/g, '').substring(0, 16);
|
||||||
const ext = filename.split('.').pop()?.toLowerCase() || 'csv';
|
|
||||||
dataOssKey = `tenants/${tenantId}/users/${userId}/ssa/${uuid}.${ext}`;
|
dataOssKey = `tenants/${tenantId}/users/${userId}/ssa/${uuid}.${ext}`;
|
||||||
|
|
||||||
// 上传到 OSS
|
// 上传到 OSS
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { tokenTruncationService } from './TokenTruncationService.js';
|
|||||||
import { methodConsultService } from './MethodConsultService.js';
|
import { methodConsultService } from './MethodConsultService.js';
|
||||||
import { askUserService, type AskUserResponse } from './AskUserService.js';
|
import { askUserService, type AskUserResponse } from './AskUserService.js';
|
||||||
import { toolOrchestratorService } from './ToolOrchestratorService.js';
|
import { toolOrchestratorService } from './ToolOrchestratorService.js';
|
||||||
|
import { executeGetDataOverview } from './tools/GetDataOverviewTool.js';
|
||||||
import { agentPlannerService } from './AgentPlannerService.js';
|
import { agentPlannerService } from './AgentPlannerService.js';
|
||||||
import { agentCoderService } from './AgentCoderService.js';
|
import { agentCoderService } from './AgentCoderService.js';
|
||||||
import { codeRunnerService } from './CodeRunnerService.js';
|
import { codeRunnerService } from './CodeRunnerService.js';
|
||||||
@@ -456,14 +457,43 @@ export class ChatHandlerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. 无挂起确认 — 检查是否是分析请求
|
// 3. 无挂起确认 — 检查是否是分析请求
|
||||||
const blackboard = await sessionBlackboardService.get(sessionId);
|
let blackboard = await sessionBlackboardService.get(sessionId);
|
||||||
const hasData = !!blackboard?.dataOverview;
|
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)) {
|
if (hasData && this.looksLikeAnalysisRequest(userContent)) {
|
||||||
return await this.agentGeneratePlan(sessionId, conversationId, userContent, writer, placeholderMessageId);
|
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) {
|
} catch (error: any) {
|
||||||
logger.error('[SSA:ChatHandler] Agent mode error', { sessionId, error: error.message });
|
logger.error('[SSA:ChatHandler] Agent mode error', { sessionId, error: error.message });
|
||||||
await conversationService.markAssistantError(placeholderMessageId, error.message);
|
await conversationService.markAssistantError(placeholderMessageId, error.message);
|
||||||
@@ -756,6 +786,7 @@ export class ChatHandlerService {
|
|||||||
writer: StreamWriter,
|
writer: StreamWriter,
|
||||||
placeholderMessageId: string,
|
placeholderMessageId: string,
|
||||||
blackboard: any,
|
blackboard: any,
|
||||||
|
hasUploadedData: boolean,
|
||||||
): Promise<HandleResult> {
|
): Promise<HandleResult> {
|
||||||
let toolOutputs = '';
|
let toolOutputs = '';
|
||||||
|
|
||||||
@@ -778,7 +809,9 @@ export class ChatHandlerService {
|
|||||||
'注意:禁止编造或模拟任何分析结果和数值。',
|
'注意:禁止编造或模拟任何分析结果和数值。',
|
||||||
blackboard?.dataOverview
|
blackboard?.dataOverview
|
||||||
? '用户已上传数据,你可以结合数据概览回答问题。当用户提出分析需求时,会自动触发分析流程。'
|
? '用户已上传数据,你可以结合数据概览回答问题。当用户提出分析需求时,会自动触发分析流程。'
|
||||||
: '用户尚未上传数据。请引导用户先上传研究数据文件。',
|
: (hasUploadedData
|
||||||
|
? '用户数据已上传,系统正在构建数据上下文。请先收集分析目标和关键变量,不要再次要求上传文件。'
|
||||||
|
: '用户尚未上传数据。请引导用户先上传研究数据文件。'),
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
const fullToolOutputs = toolOutputs ? `${toolOutputs}\n\n${agentHint}` : agentHint;
|
const fullToolOutputs = toolOutputs ? `${toolOutputs}\n\n${agentHint}` : agentHint;
|
||||||
|
|||||||
@@ -249,18 +249,26 @@ export class DataProfileService {
|
|||||||
} else if (session.dataOssKey) {
|
} else if (session.dataOssKey) {
|
||||||
// 从 OSS 下载文件
|
// 从 OSS 下载文件
|
||||||
const buffer = await storage.download(session.dataOssKey);
|
const buffer = await storage.download(session.dataOssKey);
|
||||||
const content = buffer.toString('utf-8');
|
const ext = this.getFileExtensionFromOssKey(session.dataOssKey);
|
||||||
|
|
||||||
// 检测文件格式:JSON 或 CSV
|
// 按文件扩展名处理,避免将 Excel 二进制误当作 UTF-8 文本
|
||||||
const trimmedContent = content.trim();
|
if (ext === 'csv') {
|
||||||
if (trimmedContent.startsWith('[') || trimmedContent.startsWith('{')) {
|
const content = buffer.toString('utf-8');
|
||||||
// JSON 格式
|
|
||||||
const data = JSON.parse(content);
|
|
||||||
return await this.generateProfile(sessionId, data);
|
|
||||||
} else {
|
|
||||||
// CSV 格式,直接发给 Python 解析(更高效、更可靠)
|
|
||||||
return await this.generateProfileFromCSV(sessionId, content);
|
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 {
|
} else {
|
||||||
throw new Error('No data available for session');
|
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
|
* 保存画像到 Session
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
> **维护规则**: 每次修改 Schema / 新增依赖 / 改配置时,**立即**在此文档追加记录
|
> **维护规则**: 每次修改 Schema / 新增依赖 / 改配置时,**立即**在此文档追加记录
|
||||||
> **Cursor Rule**: `.cursor/rules/deployment-change-tracking.mdc` 会自动提醒
|
> **Cursor Rule**: `.cursor/rules/deployment-change-tracking.mdc` 会自动提醒
|
||||||
> **最后清零**: 2026-03-09(0309 二次部署完成后清零)
|
> **最后清零**: 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-5 | 运营看板增强(MAU/Token/最活跃用户) | `activity.service.ts`, `statsController.ts` | 重新构建镜像 | `getTodayOverview` 新增 MAU、apiTokenTotal、topActiveUser |
|
||||||
| BE-6 | 埋点验证脚本 | `scripts/verify-activity-tracking.ts` | 无需部署 | `npm run test:tracking` 开发/运维自测用 |
|
| 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-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-4 | 运营看板展示 MAU/Token/最活跃用户 | `StatsDashboardPage.tsx`, `statsApi.ts` | 重新构建镜像 | 新增 4 个统计卡片 |
|
||||||
| FE-5 | 顶部导航点击埋点上报 | `TopNavigation.tsx` | 重新构建镜像 | 点击模块导航时 fire-and-forget 上报 |
|
| FE-5 | 顶部导航点击埋点上报 | `TopNavigation.tsx` | 重新构建镜像 | 点击模块导航时 fire-and-forget 上报 |
|
||||||
| FE-6 | AIA 上传交互更新(放开 `.xlsx/.csv`,`.doc/.xls` 友好提示) | `modules/aia/components/ChatWorkspace.tsx`, `modules/aia/constants.ts` | 重新构建镜像 | 上传白名单与提示文案同步后端,附件入口文案更新 |
|
| 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 微服务变更
|
### Python 微服务变更
|
||||||
|
|
||||||
|
|||||||
@@ -137,6 +137,22 @@ export const SSAChatPane: React.FC = () => {
|
|||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
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');
|
setUploadStatus('uploading');
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,16 @@ export function useAnalysis(): UseAnalysisReturn {
|
|||||||
body: formData,
|
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();
|
const result = await response.json();
|
||||||
setUploadProgress(100);
|
setUploadProgress(100);
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
Reference in New Issue
Block a user