/** * SSA 分析相关的自定义 Hook (Updated for Unified Record Architecture) * * uploadData / generatePlan 仍由 SSAChatPane 使用。 * executeAnalysis / exportReport 保留但已切换到 record-based 存储。 */ import { useCallback, useState } from 'react'; import apiClient from '@/common/api/axios'; import { getAccessToken } from '@/framework/auth/api'; import { useSSAStore } from '../stores/ssaStore'; import type { AnalysisPlan, ExecutionResult, SSAMessage } from '../types'; import { Document, Packer, Paragraph, Table, TableRow, TableCell, TextRun, HeadingLevel, WidthType, BorderStyle, AlignmentType, ImageRun, } from 'docx'; const API_BASE = '/api/v1/ssa'; interface UploadResult { sessionId: string; schema: { columns: Array<{ name: string; type: string; uniqueValues?: number; nullCount?: number; }>; rowCount: number; }; } interface DownloadResult { blob: Blob; filename: string; } interface UseAnalysisReturn { uploadData: (file: File) => Promise; generatePlan: (query: string) => Promise; executePlan: (planId: string) => Promise; executeAnalysis: () => Promise; downloadCode: () => Promise; exportReport: () => void; isUploading: boolean; isExecuting: boolean; uploadProgress: number; } export function useAnalysis(): UseAnalysisReturn { const { currentSession, addMessage, setLoading, setExecuting, isExecuting, setError, addRecord, updateRecord, } = useSSAStore(); const [isUploading, setIsUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); const uploadData = useCallback(async (file: File): Promise => { setIsUploading(true); setUploadProgress(0); const formData = new FormData(); formData.append('file', file); try { const token = getAccessToken(); const headers: HeadersInit = {}; if (token) { headers['Authorization'] = `Bearer ${token}`; } const response = await fetch(`${API_BASE}/sessions`, { method: 'POST', headers, body: formData, }); 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; } catch (error) { setError(error instanceof Error ? error.message : '上传出错'); throw error; } finally { setIsUploading(false); } }, [setError]); const generatePlan = useCallback( async (query: string): Promise => { if (!currentSession) throw new Error('请先上传数据'); setLoading(true); try { const userMessage: SSAMessage = { id: crypto.randomUUID(), role: 'user', content: query, createdAt: new Date().toISOString(), }; addMessage(userMessage); const response = await apiClient.post( `${API_BASE}/sessions/${currentSession.id}/plan`, { query } ); const plan: AnalysisPlan = response.data; const recordId = addRecord(query, { workflow_id: plan.id || `legacy_${Date.now()}`, title: plan.title || plan.toolName || '统计分析', total_steps: 1, steps: [{ step_number: 1, tool_code: plan.toolCode || 'unknown', tool_name: plan.toolName || '统计分析', description: plan.description || '', params: plan.parameters || {}, }], } as any); const planMessage: SSAMessage = { id: crypto.randomUUID(), role: 'assistant', content: `已生成分析方案:${plan.toolName}\n\n${plan.description}`, artifactType: 'sap', recordId, createdAt: new Date().toISOString(), }; addMessage(planMessage); addMessage({ id: crypto.randomUUID(), role: 'assistant', content: '请确认数据映射并执行分析。', artifactType: 'confirm', createdAt: new Date().toISOString(), }); return plan; } catch (error) { setError(error instanceof Error ? error.message : '生成计划出错'); throw error; } finally { setLoading(false); } }, [currentSession, addMessage, setLoading, setError, addRecord] ); const executePlan = useCallback( async (_planId: string): Promise => { if (!currentSession) throw new Error('请先上传数据'); const record = (() => { const s = useSSAStore.getState(); return s.currentRecordId ? s.analysisHistory.find((r) => r.id === s.currentRecordId) ?? null : null; })(); const planStep = record?.plan?.steps?.[0]; if (!planStep) throw new Error('请先生成分析计划'); setExecuting(true); const rid = record!.id; updateRecord(rid, { status: 'executing', steps: [], progress: 0 }); try { const response = await apiClient.post( `${API_BASE}/sessions/${currentSession.id}/execute`, { plan: { tool_code: planStep.tool_code, tool_name: planStep.tool_name, params: planStep.params, }, } ); const result: ExecutionResult = response.data; updateRecord(rid, { status: 'completed', progress: 100, steps: [{ step_number: 1, tool_code: planStep.tool_code, tool_name: planStep.tool_name, status: 'success', result: result as any, started_at: new Date().toISOString(), completed_at: new Date().toISOString(), duration_ms: result.executionMs, logs: [], }], }); addMessage({ id: crypto.randomUUID(), role: 'assistant', content: result.interpretation || '分析完成,请查看右侧结果面板。', artifactType: 'result', recordId: rid, createdAt: new Date().toISOString(), }); return result; } catch (error: any) { updateRecord(rid, { status: 'error' }); const errorData = error.response?.data; const errorMessage = errorData?.user_hint || errorData?.error || (error instanceof Error ? error.message : '执行出错'); setError(errorMessage); throw new Error(errorMessage); } finally { setExecuting(false); } }, [currentSession, addMessage, setExecuting, setError, updateRecord] ); const executeAnalysis = useCallback(async (): Promise => { return executePlan('current'); }, [executePlan]); const downloadCode = useCallback(async (): Promise => { if (!currentSession) throw new Error('请先上传数据'); const response = await apiClient.get( `${API_BASE}/sessions/${currentSession.id}/download-code`, { responseType: 'blob' } ); const contentDisposition = response.headers['content-disposition']; let filename = `analysis_${currentSession.id}.R`; if (contentDisposition) { const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/); if (filenameMatch) { let extractedName = filenameMatch[1].replace(/['"]/g, ''); try { extractedName = decodeURIComponent(extractedName); } catch { /* ok */ } if (extractedName) filename = extractedName; } } return { blob: response.data, filename }; }, [currentSession]); const exportReport = useCallback(async () => { const state = useSSAStore.getState(); const record = state.currentRecordId ? state.analysisHistory.find((r) => r.id === state.currentRecordId) ?? null : null; if (!record) { setError('暂无分析结果可导出'); return; } const steps = record.steps.filter( (s) => (s.status === 'success' || s.status === 'warning') && s.result ); if (steps.length === 0) { setError('暂无分析结果可导出'); return; } const session = state.currentSession; const mountedFile = state.mountedFile; const conclusion = record.conclusionReport; await exportWorkflowReport(steps, record.plan, conclusion, session, mountedFile); }, [setError]); const exportWorkflowReport = async ( steps: any[], wfPlan: any, conclusion: any, session: any, mountedFile: any ) => { const now = new Date(); const dateStr = now.toLocaleString('zh-CN'); const dataFileName = mountedFile?.name || session?.title || '数据文件'; const createTableRow = (cells: string[], isHeader = false) => { return new TableRow({ children: cells.map(text => new TableCell({ children: [new Paragraph({ children: [new TextRun({ text: String(text ?? '-'), bold: isHeader })], })], width: { size: 100 / cells.length, type: WidthType.PERCENTAGE }, })), }); }; const tableBorders = { top: { style: BorderStyle.SINGLE, size: 1 }, bottom: { style: BorderStyle.SINGLE, size: 1 }, left: { style: BorderStyle.SINGLE, size: 1 }, right: { style: BorderStyle.SINGLE, size: 1 }, insideHorizontal: { style: BorderStyle.SINGLE, size: 1 }, insideVertical: { style: BorderStyle.SINGLE, size: 1 }, }; const sections: (Paragraph | Table)[] = []; let sectionNum = 1; sections.push( new Paragraph({ text: '统计分析报告', heading: HeadingLevel.TITLE, alignment: AlignmentType.CENTER }), new Paragraph({ text: '' }), new Paragraph({ children: [ new TextRun({ text: '研究课题:', bold: true }), new TextRun(wfPlan?.title || session?.title || '统计分析'), ]}), new Paragraph({ children: [ new TextRun({ text: '数据文件:', bold: true }), new TextRun(dataFileName), ]}), new Paragraph({ children: [ new TextRun({ text: '生成时间:', bold: true }), new TextRun(dateStr), ]}), new Paragraph({ children: [ new TextRun({ text: '分析步骤:', bold: true }), new TextRun(`共 ${steps.length} 个步骤`), ]}), new Paragraph({ text: '' }), ); if (conclusion?.executive_summary) { sections.push( new Paragraph({ text: `${sectionNum++}. 摘要`, heading: HeadingLevel.HEADING_1 }), new Paragraph({ text: conclusion.executive_summary }), new Paragraph({ text: '' }), ); } for (const step of steps) { const r = step.result as any; sections.push( new Paragraph({ text: `${sectionNum++}. 步骤 ${step.step_number}: ${step.tool_name}`, heading: HeadingLevel.HEADING_1 }), ); if (step.duration_ms) { sections.push(new Paragraph({ children: [ new TextRun({ text: `执行耗时:${step.duration_ms}ms`, italics: true, color: '666666' }), ]})); } const isDescStep = step.tool_code === 'ST_DESCRIPTIVE' || r?.summary; if (isDescStep && (r?.summary || r?.variables)) { if (r?.summary) { sections.push( new Paragraph({ text: `总观测数: ${r.summary.n_total ?? '-'}, 分析变量数: ${r.summary.n_variables ?? '-'}, 数值变量: ${r.summary.n_numeric ?? '-'}, 分类变量: ${r.summary.n_categorical ?? '-'}` }), new Paragraph({ text: '' }), ); } if (r?.variables && typeof r.variables === 'object') { const classifyExportVar = (v: any): 'numeric' | 'categorical' | 'unknown' => { if (!v) return 'unknown'; if (v.type === 'numeric') return 'numeric'; if (v.type === 'categorical') return 'categorical'; if (v.mean !== undefined) return 'numeric'; if (v.levels && Array.isArray(v.levels)) return 'categorical'; if (v.by_group) { const first = Object.values(v.by_group)[0] as any; if (first?.mean !== undefined) return 'numeric'; if (first?.levels) return 'categorical'; } return 'unknown'; }; const varEntries = Object.entries(r.variables); const numericVars = varEntries.filter(([, v]) => classifyExportVar(v) === 'numeric'); const catVars = varEntries.filter(([, v]) => classifyExportVar(v) === 'categorical'); if (numericVars.length > 0) { const numRows: TableRow[] = [createTableRow(['变量', 'N', '均值 ± 标准差', '中位数', 'Q1', 'Q3', '最小值', '最大值'], true)]; for (const [varName, rawVs] of numericVars) { const vs = rawVs as any; if (vs.by_group) { for (const [gName, gStats] of Object.entries(vs.by_group)) { const g = gStats as any; numRows.push(createTableRow([ `${vs.variable || varName} [${gName}]`, String(g.n ?? '-'), g.formatted || (g.mean !== undefined ? `${g.mean} ± ${g.sd}` : '-'), String(g.median ?? '-'), String(g.q1 ?? '-'), String(g.q3 ?? '-'), String(g.min ?? '-'), String(g.max ?? '-'), ])); } } else { numRows.push(createTableRow([ vs.variable || varName, String(vs.n ?? '-'), vs.formatted || (vs.mean !== undefined ? `${vs.mean} ± ${vs.sd}` : '-'), String(vs.median ?? '-'), String(vs.q1 ?? '-'), String(vs.q3 ?? '-'), String(vs.min ?? '-'), String(vs.max ?? '-'), ])); } } sections.push( new Paragraph({ text: '数值变量统计', heading: HeadingLevel.HEADING_2 }), new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, borders: tableBorders, rows: numRows }), new Paragraph({ text: '' }), ); } if (catVars.length > 0) { sections.push(new Paragraph({ text: '分类变量统计', heading: HeadingLevel.HEADING_2 })); for (const [varName, rawVs] of catVars) { const vs = rawVs as any; if (vs.by_group) { for (const [gName, gStats] of Object.entries(vs.by_group)) { const g = gStats as any; const levels = g.levels || []; if (levels.length > 0) { sections.push( new Paragraph({ children: [new TextRun({ text: `${vs.variable || varName} [${gName}]`, bold: true }), new TextRun(` (N=${g.n ?? '-'})`)] }), new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, borders: tableBorders, rows: [ createTableRow(['类别', '频数', '百分比'], true), ...levels.map((lv: any) => createTableRow([String(lv.level), String(lv.n), `${lv.pct}%`])), ]}), new Paragraph({ text: '' }), ); } } } else { const levels = vs.levels || []; if (levels.length > 0) { sections.push( new Paragraph({ children: [new TextRun({ text: `${vs.variable || varName}`, bold: true }), new TextRun(` (N=${vs.n ?? '-'}, 缺失=${vs.missing ?? 0})`)] }), new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, borders: tableBorders, rows: [ createTableRow(['类别', '频数', '百分比'], true), ...levels.map((lv: any) => createTableRow([String(lv.level), String(lv.n), `${lv.pct}%`])), ]}), new Paragraph({ text: '' }), ); } } } } } } else { const statsRows = [ createTableRow(['指标', '值'], true), createTableRow(['统计方法', r?.method || step.tool_name]), ]; if (r?.statistic !== undefined) statsRows.push(createTableRow(['统计量', Number(r.statistic).toFixed(4)])); if (r?.p_value !== undefined) statsRows.push(createTableRow(['P 值', r.p_value_fmt || (r.p_value < 0.001 ? '< 0.001' : Number(r.p_value).toFixed(4))])); if (r?.effect_size !== undefined) { const esStr = typeof r.effect_size === 'object' ? Object.entries(r.effect_size).map(([k, v]) => `${k}=${Number(v).toFixed(3)}`).join(', ') : Number(r.effect_size).toFixed(3); statsRows.push(createTableRow(['效应量', esStr])); } if (r?.conf_int) statsRows.push(createTableRow(['95% CI', `[${r.conf_int.map((v: number) => v.toFixed(4)).join(', ')}]`])); sections.push(new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, borders: tableBorders, rows: statsRows })); sections.push(new Paragraph({ text: '' })); } if (r?.group_stats?.length > 0) { sections.push( new Paragraph({ text: '分组统计', heading: HeadingLevel.HEADING_2 }), new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, borders: tableBorders, rows: [ createTableRow(['分组', 'N', '均值', '标准差'], true), ...r.group_stats.map((g: any) => createTableRow([ String(g.group), String(g.n), g.mean !== undefined ? Number(g.mean).toFixed(4) : '-', g.sd !== undefined ? Number(g.sd).toFixed(4) : '-', ])), ], }), new Paragraph({ text: '' }), ); } if (r?.coefficients && Array.isArray(r.coefficients) && r.coefficients.length > 0) { sections.push( new Paragraph({ text: '回归系数', heading: HeadingLevel.HEADING_2 }), new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, borders: tableBorders, rows: [ createTableRow(['变量', '估计值', 'OR', '95% CI', 'P 值'], true), ...r.coefficients.map((c: any) => createTableRow([ c.variable || c.term || '-', Number(c.estimate || c.coef || 0).toFixed(4), c.OR !== undefined ? Number(c.OR).toFixed(4) : '-', c.ci_lower !== undefined ? `[${Number(c.ci_lower).toFixed(3)}, ${Number(c.ci_upper).toFixed(3)}]` : '-', c.p_value_fmt || (c.p_value !== undefined ? (c.p_value < 0.001 ? '< 0.001' : Number(c.p_value).toFixed(4)) : '-'), ])), ], }), new Paragraph({ text: '' }), ); } if (r?.plots?.length > 0) { for (const plot of r.plots) { const imageBase64 = typeof plot === 'string' ? plot : plot.imageBase64; if (imageBase64) { try { const base64Data = imageBase64.replace(/^data:image\/\w+;base64,/, ''); const imageBuffer = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0)); sections.push( new Paragraph({ children: [new ImageRun({ data: imageBuffer, transformation: { width: 450, height: 300 }, type: 'png' })], alignment: AlignmentType.CENTER, }), new Paragraph({ text: '' }), ); } catch { /* skip */ } } } } } if (conclusion?.key_findings?.length > 0) { sections.push( new Paragraph({ text: `${sectionNum++}. 主要发现`, heading: HeadingLevel.HEADING_1 }), ...conclusion.key_findings.map((f: string) => new Paragraph({ text: `• ${f}` })), new Paragraph({ text: '' }), ); } if (conclusion?.recommendations?.length > 0) { sections.push( new Paragraph({ text: `${sectionNum++}. 建议`, heading: HeadingLevel.HEADING_1 }), ...conclusion.recommendations.map((rec: string) => new Paragraph({ text: `• ${rec}` })), new Paragraph({ text: '' }), ); } sections.push( new Paragraph({ text: '' }), new Paragraph({ children: [ new TextRun({ text: '本报告由智能统计分析系统自动生成', italics: true, color: '666666' }), ]}), new Paragraph({ children: [ new TextRun({ text: `总执行耗时: ${steps.reduce((s: number, st: any) => s + (st.duration_ms || 0), 0)}ms`, italics: true, color: '666666' }), ]}), ); const doc = new Document({ sections: [{ children: sections }] }); const dateTimeStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}`; const safeFileName = dataFileName.replace(/\.(csv|xlsx|xls)$/i, '').replace(/[^a-zA-Z0-9\u4e00-\u9fa5_-]/g, '_'); const blob = await Packer.toBlob(doc); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `统计分析报告_${safeFileName}_${dateTimeStr}.docx`; a.click(); URL.revokeObjectURL(url); }; return { uploadData, generatePlan, executePlan, executeAnalysis, downloadCode, exportReport, isUploading, isExecuting, uploadProgress, }; } export default useAnalysis;