feat(ssa): Complete QPER architecture - Query, Planner, Execute, Reflection layers

Implement the full QPER intelligent analysis pipeline:

- Phase E+: Block-based standardization for all 7 R tools, DynamicReport renderer, Word export enhancement

- Phase Q: LLM intent parsing with dynamic Zod validation against real column names, ClarificationCard component, DataProfile is_id_like tagging

- Phase P: ConfigLoader with Zod schema validation and hot-reload API, DecisionTableService (4-dimension matching), FlowTemplateService with EPV protection, PlannedTrace audit output

- Phase R: ReflectionService with statistical slot injection, sensitivity analysis conflict rules, ConclusionReport with section reveal animation, conclusion caching API, graceful R error classification

End-to-end test: 40/40 passed across two complete analysis scenarios.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-21 18:15:53 +08:00
parent 428a22adf2
commit 371e1c069c
73 changed files with 9242 additions and 706 deletions

View File

@@ -983,9 +983,7 @@
.message-bubble .markdown-content h1 {
font-size: 1.3em;
}
.message-bubble .markdown-content h2 {
}.message-bubble .markdown-content h2 {
font-size: 1.2em;
}.message-bubble .markdown-content h3 {
font-size: 1.1em;
@@ -1022,4 +1020,4 @@
border-radius: 4px;
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
font-size: 0.9em;
}
}

View File

@@ -0,0 +1,69 @@
/**
* ClarificationCard — Phase Q 追问卡片组件
*
* 当 LLM 意图解析置信度低于阈值时,展示封闭式数据驱动选项卡。
* 用户点击选项后自动补全 ParsedQuery 缺失字段,触发重新规划。
*/
import React from 'react';
import { HelpCircle } from 'lucide-react';
export interface ClarificationOption {
label: string;
value: string;
description?: string;
}
export interface ClarificationCardData {
question: string;
options: ClarificationOption[];
}
interface ClarificationCardProps {
cards: ClarificationCardData[];
onSelect: (selections: Record<string, string>) => void;
disabled?: boolean;
}
export const ClarificationCard: React.FC<ClarificationCardProps> = ({
cards,
onSelect,
disabled = false,
}) => {
const handleOptionClick = (question: string, value: string) => {
if (disabled) return;
onSelect({ [question]: value });
};
if (!cards || cards.length === 0) return null;
return (
<div className="clarification-cards">
{cards.map((card, idx) => (
<div key={idx} className="clarification-card">
<div className="clarification-question">
<HelpCircle size={14} className="text-blue-500" />
<span>{card.question}</span>
</div>
<div className="clarification-options">
{card.options.map((opt, oi) => (
<button
key={oi}
className="clarification-option-btn"
onClick={() => handleOptionClick(card.question, opt.value)}
disabled={disabled}
title={opt.description}
>
<span className="option-label">{opt.label}</span>
{opt.description && (
<span className="option-desc">{opt.description}</span>
)}
</button>
))}
</div>
</div>
))}
</div>
);
};
export default ClarificationCard;

View File

@@ -1,14 +1,19 @@
/**
* 综合结论报告组件
*
* Phase 2A: 多步骤工作流执行完成后的综合结论展示
* 综合结论报告组件 (Phase R)
*
* 特性:
* - 逐 section 渐入动画reflection_complete 到达后依次展现)
* - 一键复制全文到剪贴板
* - 来源标识LLM / 规则引擎)
* - 折叠/展开详细步骤结果
*/
import React, { useState } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import type { ConclusionReport as ConclusionReportType, WorkflowStepResult } from '../types';
interface ConclusionReportProps {
report: ConclusionReportType;
stepResults?: WorkflowStepResult[];
animate?: boolean;
}
interface StepResultDetailProps {
@@ -26,9 +31,9 @@ const StepResultDetail: React.FC<StepResultDetailProps> = ({ stepSummary, stepRe
<span className="step-badge"> {stepSummary.step_number}</span>
<span className="tool-name">{stepSummary.tool_name}</span>
{stepSummary.p_value !== undefined && (
<span
<span
className="p-value-badge"
style={{
style={{
backgroundColor: stepSummary.is_significant ? '#ecfdf5' : '#f8fafc',
color: stepSummary.is_significant ? '#059669' : '#64748b',
}}
@@ -40,12 +45,11 @@ const StepResultDetail: React.FC<StepResultDetailProps> = ({ stepSummary, stepRe
</div>
<span className={`expand-arrow ${expanded ? 'rotated' : ''}`}></span>
</div>
<div className="detail-summary">{stepSummary.summary}</div>
{expanded && stepResult?.result && (
<div className="detail-content">
{/* 结果表格 */}
{stepResult.result.result_table && (
<div className="result-table-wrapper">
<table className="result-table">
@@ -69,14 +73,13 @@ const StepResultDetail: React.FC<StepResultDetailProps> = ({ stepSummary, stepRe
</div>
)}
{/* 图表 */}
{stepResult.result.plots && stepResult.result.plots.length > 0 && (
<div className="result-plots">
{stepResult.result.plots.map((plot, idx) => (
{(stepResult.result.plots as any[]).map((plot: any, idx: number) => (
<div key={idx} className="plot-item">
<div className="plot-title">{plot.title}</div>
<img
src={plot.imageBase64.startsWith('data:') ? plot.imageBase64 : `data:image/png;base64,${plot.imageBase64}`}
<img
src={plot.imageBase64?.startsWith('data:') ? plot.imageBase64 : `data:image/png;base64,${plot.imageBase64}`}
alt={plot.title}
className="plot-image"
/>
@@ -85,11 +88,10 @@ const StepResultDetail: React.FC<StepResultDetailProps> = ({ stepSummary, stepRe
</div>
)}
{/* 详细解释 */}
{stepResult.result.interpretation && (
<div className="interpretation-box">
<span className="interpretation-label">💡 :</span>
<p>{stepResult.result.interpretation}</p>
<span className="interpretation-label">:</span>
<p>{String(stepResult.result.interpretation)}</p>
</div>
)}
</div>
@@ -98,132 +100,246 @@ const StepResultDetail: React.FC<StepResultDetailProps> = ({ stepSummary, stepRe
);
};
export const ConclusionReport: React.FC<ConclusionReportProps> = ({ report, stepResults = [] }) => {
/**
* Build plain-text version of the report for clipboard
*/
function buildPlainText(report: ConclusionReportType): string {
const lines: string[] = [];
lines.push(report.title);
lines.push('');
lines.push('【综合结论】');
lines.push(report.executive_summary);
lines.push('');
if (report.key_findings.length > 0) {
lines.push('【主要发现】');
report.key_findings.forEach((f, i) => lines.push(`${i + 1}. ${f}`));
lines.push('');
}
if (report.step_summaries.length > 0) {
lines.push('【分析步骤】');
report.step_summaries.forEach(s => {
const sig = s.is_significant ? '(显著)' : '';
const pStr = s.p_value != null ? `, P=${s.p_value < 0.001 ? '<0.001' : s.p_value.toFixed(4)}` : '';
lines.push(` 步骤${s.step_number} ${s.tool_name}: ${s.summary}${pStr} ${sig}`);
});
lines.push('');
}
if (report.recommendations && report.recommendations.length > 0) {
lines.push('【建议】');
report.recommendations.forEach((r, i) => lines.push(`${i + 1}. ${r}`));
lines.push('');
}
if (report.limitations.length > 0) {
lines.push('【局限性】');
report.limitations.forEach((l, i) => lines.push(`${i + 1}. ${l}`));
lines.push('');
}
lines.push(`生成时间: ${new Date(report.generated_at).toLocaleString('zh-CN')}`);
if (report.source) {
lines.push(`生成方式: ${report.source === 'llm' ? 'AI 智能分析' : '规则引擎'}`);
}
return lines.join('\n');
}
export const ConclusionReport: React.FC<ConclusionReportProps> = ({
report,
stepResults = [],
animate = false,
}) => {
const [showFullReport, setShowFullReport] = useState(true);
const [copySuccess, setCopySuccess] = useState(false);
const [visibleSections, setVisibleSections] = useState<Set<string>>(
animate ? new Set() : new Set(['summary', 'findings', 'stats', 'steps', 'recommendations', 'limitations', 'methods'])
);
const animationTimer = useRef<ReturnType<typeof setTimeout>[]>([]);
useEffect(() => {
if (!animate) return;
const sections = ['summary', 'findings', 'stats', 'steps', 'recommendations', 'limitations', 'methods'];
const delays = [0, 300, 600, 900, 1100, 1300, 1500];
sections.forEach((section, idx) => {
const timer = setTimeout(() => {
setVisibleSections(prev => new Set([...prev, section]));
}, delays[idx]);
animationTimer.current.push(timer);
});
return () => {
animationTimer.current.forEach(clearTimeout);
};
}, [animate]);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(buildPlainText(report));
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
} catch {
const textarea = document.createElement('textarea');
textarea.value = buildPlainText(report);
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
}
}, [report]);
const getStepResult = (stepNumber: number): WorkflowStepResult | undefined => {
return stepResults.find(r => r.step_number === stepNumber);
};
const sectionClass = (name: string) =>
`conclusion-section ${visibleSections.has(name) ? 'conclusion-section-visible' : 'conclusion-section-hidden'}`;
return (
<div className="conclusion-report">
{/* 报告头部 */}
{/* Header */}
<div className="report-header">
<h2 className="report-title">📋 {report.title}</h2>
<span className="generated-time">
{new Date(report.generated_at).toLocaleString('zh-CN')}
</span>
</div>
{/* AI 总结摘要 - 始终显示 */}
<div className="executive-summary">
<div className="summary-header">
<span className="summary-icon">🤖</span>
<span className="summary-label">AI </span>
</div>
<div className="summary-content">
{report.executive_summary}
</div>
</div>
{/* 主要发现 */}
{report.key_findings.length > 0 && (
<div className="key-findings">
<div className="section-header">
<span className="section-icon">🎯</span>
<span className="section-title"></span>
<div className="report-header-left">
<h2 className="report-title">{report.title}</h2>
<div className="report-meta">
<span className="generated-time">
{new Date(report.generated_at).toLocaleString('zh-CN')}
</span>
{report.source && (
<span className={`source-badge source-${report.source}`}>
{report.source === 'llm' ? 'AI 生成' : '规则生成'}
</span>
)}
</div>
</div>
<button
className={`copy-btn ${copySuccess ? 'copy-success' : ''}`}
onClick={handleCopy}
title="复制全文"
>
{copySuccess ? '已复制' : '复制'}
</button>
</div>
{/* Executive Summary */}
<div className={sectionClass('summary')}>
<div className="executive-summary">
<div className="summary-header">
<span className="summary-label"></span>
</div>
<div className="summary-content">{report.executive_summary}</div>
</div>
</div>
{/* Key Findings */}
{report.key_findings.length > 0 && (
<div className={sectionClass('findings')}>
<div className="key-findings">
<div className="section-header">
<span className="section-title"></span>
</div>
<ul className="findings-list">
{report.key_findings.map((finding, idx) => (
<li key={idx}>{finding}</li>
))}
</ul>
</div>
<ul className="findings-list">
{report.key_findings.map((finding, idx) => (
<li key={idx}>{finding}</li>
))}
</ul>
</div>
)}
{/* 统计概览 */}
<div className="stats-overview">
<div className="stat-card">
<span className="stat-icon">📊</span>
<span className="stat-value">{report.statistical_summary.total_tests}</span>
<span className="stat-label"></span>
</div>
<div className="stat-card significant">
<span className="stat-icon"></span>
<span className="stat-value">{report.statistical_summary.significant_results}</span>
<span className="stat-label"></span>
</div>
<div className="stat-card">
<span className="stat-icon">🔬</span>
<span className="stat-value">{report.statistical_summary.methods_used.length}</span>
<span className="stat-label"></span>
{/* Stats Overview */}
<div className={sectionClass('stats')}>
<div className="stats-overview">
<div className="stat-card">
<span className="stat-value">{report.statistical_summary.total_tests}</span>
<span className="stat-label"></span>
</div>
<div className="stat-card significant">
<span className="stat-value">{report.statistical_summary.significant_results}</span>
<span className="stat-label"></span>
</div>
<div className="stat-card">
<span className="stat-value">{report.statistical_summary.methods_used.length}</span>
<span className="stat-label"></span>
</div>
</div>
</div>
{/* 展开/折叠按钮 */}
<button
{/* Toggle */}
<button
className="toggle-details-btn"
onClick={() => setShowFullReport(!showFullReport)}
>
{showFullReport ? '收起详细结果 ▲' : '展开详细结果 ▼'}
</button>
{/* 详细步骤结果 */}
{/* Step Results */}
{showFullReport && (
<div className="step-results-section">
<div className="section-header">
<span className="section-icon">📝</span>
<span className="section-title"></span>
</div>
<div className="step-results-list">
{report.step_summaries.map((stepSummary) => (
<StepResultDetail
key={stepSummary.step_number}
stepSummary={stepSummary}
stepResult={getStepResult(stepSummary.step_number)}
/>
))}
<div className={sectionClass('steps')}>
<div className="step-results-section">
<div className="section-header">
<span className="section-title"></span>
</div>
<div className="step-results-list">
{report.step_summaries.map((stepSummary) => (
<StepResultDetail
key={stepSummary.step_number}
stepSummary={stepSummary}
stepResult={getStepResult(stepSummary.step_number)}
/>
))}
</div>
</div>
</div>
)}
{/* 建议 */}
{report.recommendations.length > 0 && (
<div className="recommendations-section">
<div className="section-header">
<span className="section-icon">💡</span>
<span className="section-title"></span>
{/* Recommendations */}
{report.recommendations && report.recommendations.length > 0 && (
<div className={sectionClass('recommendations')}>
<div className="recommendations-section">
<div className="section-header">
<span className="section-title"></span>
</div>
<ul className="recommendations-list">
{report.recommendations.map((rec, idx) => (
<li key={idx}>{rec}</li>
))}
</ul>
</div>
<ul className="recommendations-list">
{report.recommendations.map((rec, idx) => (
<li key={idx}>{rec}</li>
))}
</ul>
</div>
)}
{/* 局限性 */}
{/* Limitations */}
{report.limitations.length > 0 && (
<div className="limitations-section">
<div className="section-header">
<span className="section-icon"></span>
<span className="section-title"></span>
<div className={sectionClass('limitations')}>
<div className="limitations-section">
<div className="section-header">
<span className="section-title"></span>
</div>
<ul className="limitations-list">
{report.limitations.map((lim, idx) => (
<li key={idx}>{lim}</li>
))}
</ul>
</div>
<ul className="limitations-list">
{report.limitations.map((lim, idx) => (
<li key={idx}>{lim}</li>
))}
</ul>
</div>
)}
{/* 使用的方法列表 */}
<div className="methods-used">
<span className="methods-label">使:</span>
<div className="methods-tags">
{report.statistical_summary.methods_used.map((method, idx) => (
<span key={idx} className="method-tag">{method}</span>
))}
{/* Methods */}
<div className={sectionClass('methods')}>
<div className="methods-used">
<span className="methods-label">使:</span>
<div className="methods-tags">
{report.statistical_summary.methods_used.map((method, idx) => (
<span key={idx} className="method-tag">{method}</span>
))}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,183 @@
/**
* DynamicReport — Block-based 动态报告渲染组件
*
* 消费后端/R 端返回的 ReportBlock[] 数组,按序渲染:
* markdown → 富文本段落
* table → 三线表
* image → base64 图片
* key_value → 键值对网格
*/
import React, { useState } from 'react';
import { ImageOff, Loader2 } from 'lucide-react';
import type { ReportBlock } from '../types';
interface DynamicReportProps {
blocks: ReportBlock[];
className?: string;
}
export const DynamicReport: React.FC<DynamicReportProps> = ({ blocks, className }) => {
if (!blocks || blocks.length === 0) return null;
return (
<div className={`dynamic-report ${className ?? ''}`}>
{blocks.map((block, idx) => (
<DynamicBlock key={idx} block={block} index={idx} />
))}
</div>
);
};
const DynamicBlock: React.FC<{ block: ReportBlock; index: number }> = ({ block, index }) => {
switch (block.type) {
case 'key_value':
return <KVBlock block={block} />;
case 'table':
return <TableBlock block={block} index={index} />;
case 'image':
return <ImageBlock block={block} index={index} />;
case 'markdown':
return <MarkdownBlock block={block} />;
default:
return null;
}
};
/* ─── key_value ─── */
const KVBlock: React.FC<{ block: ReportBlock }> = ({ block }) => {
const items = block.items ?? [];
if (items.length === 0) return null;
return (
<div className="dr-kv-section">
{block.title && <h4 className="dr-section-title">{block.title}</h4>}
<div className="result-stats-grid">
{items.map((item, i) => (
<div key={i} className="stat-card">
<div className="stat-label">{item.key}</div>
<div className="stat-value">{item.value}</div>
</div>
))}
</div>
</div>
);
};
/* ─── table ─── */
const TableBlock: React.FC<{ block: ReportBlock; index: number }> = ({ block, index }) => {
const headers = block.headers ?? [];
const rows = block.rows ?? [];
if (headers.length === 0 && rows.length === 0) return null;
return (
<div className="result-table-section">
{block.title && (
<h4 className="table-label">
Table {index + 1}. {block.title}
</h4>
)}
<div className="sci-table-wrapper">
<table className="sci-table">
{headers.length > 0 && (
<thead>
<tr>
{headers.map((h, i) => (
<th key={i}>{h}</th>
))}
</tr>
</thead>
)}
<tbody>
{rows.map((row, ri) => (
<tr key={ri}>
{row.map((cell, ci) => (
<td key={ci} className={isPValueCell(cell) ? 'p-value' : ''}>
{formatTableCell(cell)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{block.footnote && <p className="dr-table-footnote">{block.footnote}</p>}
</div>
);
};
/* ─── image ─── */
const ImageBlock: React.FC<{ block: ReportBlock; index: number }> = ({ block, index }) => {
const [hasError, setHasError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const src = React.useMemo(() => {
if (!block.data) return '';
if (block.data.startsWith('data:')) return block.data;
return `data:image/png;base64,${block.data}`;
}, [block.data]);
if (hasError || !block.data) {
return (
<div className="chart-error">
<ImageOff size={32} className="text-slate-300" />
<span></span>
</div>
);
}
return (
<div className="result-chart-section">
<h4 className="chart-label">
Figure {index + 1}. {block.title ?? block.alt ?? '可视化'}
</h4>
<div className="chart-image-wrapper">
{isLoading && (
<div className="chart-loading">
<Loader2 size={24} className="spin text-slate-400" />
</div>
)}
<img
src={src}
alt={block.alt ?? block.title ?? 'chart'}
className={`chart-image ${isLoading ? 'loading' : ''}`}
onLoad={() => setIsLoading(false)}
onError={() => { setHasError(true); setIsLoading(false); }}
/>
</div>
</div>
);
};
/* ─── markdown ─── */
const MarkdownBlock: React.FC<{ block: ReportBlock }> = ({ block }) => {
if (!block.content) return null;
return (
<div className="dr-markdown-section">
{block.title && <h4 className="dr-section-title">{block.title}</h4>}
<div className="dr-markdown-content">
{block.content.split('\n').map((line, i) => (
<p key={i}>{line}</p>
))}
</div>
</div>
);
};
/* ─── helpers ─── */
const isPValueCell = (cell: string | number): boolean => {
if (typeof cell === 'string') {
return cell.includes('<') || cell.includes('*');
}
return false;
};
const formatTableCell = (cell: string | number): string => {
if (cell === null || cell === undefined) return '-';
if (typeof cell === 'number') {
return Number.isInteger(cell) ? cell.toString() : cell.toFixed(4);
}
return String(cell);
};
export default DynamicReport;

View File

@@ -29,6 +29,8 @@ import { useWorkflow } from '../hooks/useWorkflow';
import type { SSAMessage } from '../types';
import { TypeWriter } from './TypeWriter';
import { DataProfileCard } from './DataProfileCard';
import { ClarificationCard } from './ClarificationCard';
import type { ClarificationCardData, IntentResult } from '../types';
export const SSAChatPane: React.FC = () => {
const navigate = useNavigate();
@@ -46,15 +48,21 @@ export const SSAChatPane: React.FC = () => {
error,
setError,
addToast,
addMessage,
selectAnalysisRecord,
dataProfile,
dataProfileLoading,
} = useSSAStore();
const { uploadData, generatePlan, isUploading, uploadProgress } = useAnalysis();
const { generateDataProfile, generateWorkflowPlan, isProfileLoading, isPlanLoading } = useWorkflow();
const { generateDataProfile, generateWorkflowPlan, parseIntent, handleClarify, isProfileLoading, isPlanLoading } = useWorkflow();
const [inputValue, setInputValue] = useState('');
const [uploadStatus, setUploadStatus] = useState<'idle' | 'uploading' | 'parsing' | 'success' | 'error'>('idle');
const [pendingClarification, setPendingClarification] = useState<{
cards: ClarificationCardData[];
originalQuery: string;
intent: IntentResult;
} | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const chatEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
@@ -143,20 +151,83 @@ export const SSAChatPane: React.FC = () => {
const handleSend = async () => {
if (!inputValue.trim()) return;
const query = inputValue;
setInputValue('');
try {
// Phase 2A: 如果已有 session使用多步骤工作流规划
if (currentSession?.id) {
await generateWorkflowPlan(currentSession.id, inputValue);
// Phase Q: 先做意图解析,低置信度时追问
const intentResp = await parseIntent(currentSession.id, query);
if (intentResp.needsClarification && intentResp.clarificationCards?.length > 0) {
addMessage({
id: crypto.randomUUID(),
role: 'user',
content: query,
createdAt: new Date().toISOString(),
});
addMessage({
id: crypto.randomUUID(),
role: 'assistant',
content: `我大致理解了你的意图(${intentResp.intent.reasoning}),但为了生成更精确的分析方案,想确认几个细节:`,
createdAt: new Date().toISOString(),
});
setPendingClarification({
cards: intentResp.clarificationCards,
originalQuery: query,
intent: intentResp.intent,
});
return;
}
// 置信度足够 → 直接生成工作流计划
await generateWorkflowPlan(currentSession.id, query);
} else {
// 没有数据时,使用旧流程
await generatePlan(inputValue);
await generatePlan(query);
}
setInputValue('');
} catch (err: any) {
addToast(err?.message || '生成计划失败', 'error');
}
};
const handleClarificationSelect = async (selections: Record<string, string>) => {
if (!currentSession?.id || !pendingClarification) return;
setPendingClarification(null);
const selectedLabel = Object.values(selections).join(', ');
addMessage({
id: crypto.randomUUID(),
role: 'user',
content: selectedLabel,
createdAt: new Date().toISOString(),
});
try {
const resp = await handleClarify(
currentSession.id,
pendingClarification.originalQuery,
selections
);
if (resp.needsClarification && resp.clarificationCards?.length > 0) {
addMessage({
id: crypto.randomUUID(),
role: 'assistant',
content: '还需要确认一下:',
createdAt: new Date().toISOString(),
});
setPendingClarification({
cards: resp.clarificationCards,
originalQuery: pendingClarification.originalQuery,
intent: resp.intent,
});
}
} catch (err: any) {
addToast(err?.message || '处理追问失败', 'error');
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
@@ -308,6 +379,20 @@ export const SSAChatPane: React.FC = () => {
旧的"数据挂载成功消息"已移除,避免在没有用户输入时就显示 SAP 卡片
*/}
{/* Phase Q: 追问卡片 */}
{pendingClarification && (
<div className="message-row assistant">
<div className="avatar-col"><Bot size={18} /></div>
<div className="msg-content">
<ClarificationCard
cards={pendingClarification.cards}
onSelect={handleClarificationSelect}
disabled={isLoading}
/>
</div>
</div>
)}
{/* 自动滚动锚点与占位垫片 - 解决底部输入框遮挡的终极方案 */}
<div ref={chatEndRef} className="scroll-spacer" />
</div>

View File

@@ -27,8 +27,10 @@ import {
import { useSSAStore } from '../stores/ssaStore';
import { useAnalysis } from '../hooks/useAnalysis';
import { useWorkflow } from '../hooks/useWorkflow';
import type { TraceStep } from '../types';
import type { TraceStep, ReportBlock } from '../types';
import { WorkflowTimeline } from './WorkflowTimeline';
import { DynamicReport } from './DynamicReport';
import { exportBlocksToWord } from '../utils/exportBlocksToWord';
type ExecutionPhase = 'planning' | 'executing' | 'completed' | 'error';
@@ -137,7 +139,21 @@ export const SSAWorkspacePane: React.FC = () => {
const handleExportReport = async () => {
try {
await exportReport();
// 优先使用 block-based 导出
const allBlocks = workflowSteps
.filter(s => s.status === 'success')
.flatMap(s => {
const r = s.result as any;
return s.reportBlocks ?? r?.report_blocks ?? [];
}) as ReportBlock[];
if (allBlocks.length > 0) {
await exportBlocksToWord(allBlocks, {
title: workflowPlan?.title || currentSession?.title || '统计分析报告',
});
} else {
await exportReport();
}
addToast('报告导出成功', 'success');
} catch (err: any) {
addToast(err?.message || '导出失败', 'error');
@@ -521,13 +537,13 @@ export const SSAWorkspacePane: React.FC = () => {
</div>
)}
{/* 各步骤结果汇总 - MVP 风格 */}
{/* 各步骤结果汇总 — 优先 Block-basedfallback 旧 MVP 风格 */}
{workflowSteps.filter(s => s.status === 'success' && s.result).map((step, stepIdx) => {
const r = step.result as any;
const pVal = r?.p_value ?? r?.pValue;
const pFmt = r?.p_value_fmt || (pVal !== undefined ? formatPValue(pVal) : undefined);
const isDescriptive = step.tool_code === 'ST_DESCRIPTIVE' || r?.method === '描述性统计';
const blocks: ReportBlock[] | undefined =
step.reportBlocks ?? r?.report_blocks;
const hasBlocks = blocks && blocks.length > 0;
return (
<div key={step.step_number} className="workflow-step-result">
<h4 className="table-label">
@@ -535,134 +551,11 @@ export const SSAWorkspacePane: React.FC = () => {
{step.duration_ms && <span className="step-duration-badge"> {step.duration_ms}ms</span>}
</h4>
{/* 描述性统计 - 专用渲染 */}
{isDescriptive ? (
<DescriptiveResultView result={r} />
{/* Block-based 渲染(优先) */}
{hasBlocks ? (
<DynamicReport blocks={blocks} />
) : (
<>
{/* 非描述性统计 - 统计量汇总 */}
<div className="result-stats-grid">
<div className="stat-card">
<div className="stat-label"></div>
<div className="stat-value">{r?.method || step.tool_name}</div>
</div>
{r?.statistic !== undefined && (
<div className="stat-card">
<div className="stat-label"></div>
<div className="stat-value">{Number(r.statistic).toFixed(4)}</div>
</div>
)}
{pVal !== undefined && (
<div className="stat-card highlight">
<div className="stat-label">P </div>
<div className={`stat-value ${pVal < 0.05 ? 'significant' : ''}`}>
{pFmt}
</div>
</div>
)}
{r?.effect_size !== undefined && (
<div className="stat-card">
<div className="stat-label"></div>
<div className="stat-value">
{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)}
</div>
</div>
)}
{r?.conf_int && (
<div className="stat-card">
<div className="stat-label">95% CI</div>
<div className="stat-value">[{r.conf_int.map((v: number) => v.toFixed(3)).join(', ')}]</div>
</div>
)}
{r?.coefficients && !Array.isArray(r.coefficients) && (
<div className="stat-card">
<div className="stat-label"></div>
<div className="stat-value">{Object.keys(r.coefficients).length}</div>
</div>
)}
</div>
{/* 分组统计表 */}
{r?.group_stats?.length > 0 && (
<div className="result-table-section">
<h4 className="table-label"></h4>
<div className="sci-table-wrapper">
<table className="sci-table">
<thead>
<tr><th></th><th>N</th><th></th><th></th></tr>
</thead>
<tbody>
{r.group_stats.map((g: any, i: number) => (
<tr key={i}>
<td>{g.group}</td>
<td>{g.n}</td>
<td>{g.mean !== undefined ? Number(g.mean).toFixed(4) : '-'}</td>
<td>{g.sd !== undefined ? Number(g.sd).toFixed(4) : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Logistic 回归系数表 */}
{r?.coefficients && Array.isArray(r.coefficients) && r.coefficients.length > 0 && (
<div className="result-table-section">
<h4 className="table-label"></h4>
<div className="sci-table-wrapper">
<table className="sci-table">
<thead>
<tr><th></th><th></th><th>OR</th><th>95% CI</th><th>P </th></tr>
</thead>
<tbody>
{r.coefficients.map((c: any, i: number) => (
<tr key={i}>
<td>{c.variable || c.term}</td>
<td>{Number(c.estimate || c.coef || 0).toFixed(4)}</td>
<td>{c.OR !== undefined ? Number(c.OR).toFixed(4) : '-'}</td>
<td>{c.ci_lower !== undefined ? `[${Number(c.ci_lower).toFixed(3)}, ${Number(c.ci_upper).toFixed(3)}]` : '-'}</td>
<td className={isPValue(c.p_value) ? 'p-value' : ''}>{c.p_value_fmt || formatPValue(c.p_value)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* 详细表格数据 result_table */}
{r?.result_table && (
<div className="result-table-section">
<div className="sci-table-wrapper">
<table className="sci-table">
<thead><tr>{r.result_table.headers.map((h: string, i: number) => <th key={i}>{h}</th>)}</tr></thead>
<tbody>{r.result_table.rows.map((row: any[], i: number) => (
<tr key={i}>{row.map((cell, j) => (
<td key={j} className={isPValue(cell) ? 'p-value' : ''}>{formatCell(cell)}</td>
))}</tr>
))}</tbody>
</table>
</div>
</div>
)}
</>
)}
{/* 图表 - 所有类型通用 */}
{r?.plots?.length > 0 && (
<div className="result-chart-section">
<h4 className="chart-label">Figure {stepIdx + 1}. </h4>
{r.plots.map((plot: any, plotIdx: number) => (
<ChartImage key={plotIdx} plot={
typeof plot === 'string'
? { type: 'chart', title: '统计图表', imageBase64: plot }
: plot
} />
))}
</div>
<LegacyStepResultView step={step} stepIdx={stepIdx} />
)}
</div>
);
@@ -1116,4 +1009,145 @@ const DescriptiveResultView: React.FC<{ result: any }> = ({ result }) => {
);
};
/**
* LegacyStepResultView — 旧版(非 Block-based步骤结果渲染
* 当 R 工具未返回 report_blocks 时作为 fallback 使用
*/
const LegacyStepResultView: React.FC<{ step: any; stepIdx: number }> = ({ step, stepIdx }) => {
const r = step.result as any;
if (!r) return null;
const pVal = r?.p_value ?? r?.pValue;
const pFmt = r?.p_value_fmt || (pVal !== undefined ? formatPValue(pVal) : undefined);
const isDescriptive = step.tool_code === 'ST_DESCRIPTIVE' || r?.method === '描述性统计';
return (
<>
{isDescriptive ? (
<DescriptiveResultView result={r} />
) : (
<>
<div className="result-stats-grid">
<div className="stat-card">
<div className="stat-label"></div>
<div className="stat-value">{r?.method || step.tool_name}</div>
</div>
{r?.statistic !== undefined && (
<div className="stat-card">
<div className="stat-label"></div>
<div className="stat-value">{Number(r.statistic).toFixed(4)}</div>
</div>
)}
{pVal !== undefined && (
<div className="stat-card highlight">
<div className="stat-label">P </div>
<div className={`stat-value ${pVal < 0.05 ? 'significant' : ''}`}>
{pFmt}
</div>
</div>
)}
{r?.effect_size !== undefined && (
<div className="stat-card">
<div className="stat-label"></div>
<div className="stat-value">
{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)}
</div>
</div>
)}
{r?.conf_int && (
<div className="stat-card">
<div className="stat-label">95% CI</div>
<div className="stat-value">[{r.conf_int.map((v: number) => v.toFixed(3)).join(', ')}]</div>
</div>
)}
{r?.coefficients && !Array.isArray(r.coefficients) && (
<div className="stat-card">
<div className="stat-label"></div>
<div className="stat-value">{Object.keys(r.coefficients).length}</div>
</div>
)}
</div>
{r?.group_stats?.length > 0 && (
<div className="result-table-section">
<h4 className="table-label"></h4>
<div className="sci-table-wrapper">
<table className="sci-table">
<thead>
<tr><th></th><th>N</th><th></th><th></th></tr>
</thead>
<tbody>
{r.group_stats.map((g: any, i: number) => (
<tr key={i}>
<td>{g.group}</td>
<td>{g.n}</td>
<td>{g.mean !== undefined ? Number(g.mean).toFixed(4) : '-'}</td>
<td>{g.sd !== undefined ? Number(g.sd).toFixed(4) : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{r?.coefficients && Array.isArray(r.coefficients) && r.coefficients.length > 0 && (
<div className="result-table-section">
<h4 className="table-label"></h4>
<div className="sci-table-wrapper">
<table className="sci-table">
<thead>
<tr><th></th><th></th><th>OR</th><th>95% CI</th><th>P </th></tr>
</thead>
<tbody>
{r.coefficients.map((c: any, i: number) => (
<tr key={i}>
<td>{c.variable || c.term}</td>
<td>{Number(c.estimate || c.coef || 0).toFixed(4)}</td>
<td>{c.OR !== undefined ? Number(c.OR).toFixed(4) : '-'}</td>
<td>{c.ci_lower !== undefined ? `[${Number(c.ci_lower).toFixed(3)}, ${Number(c.ci_upper).toFixed(3)}]` : '-'}</td>
<td className={isPValue(c.p_value) ? 'p-value' : ''}>{c.p_value_fmt || formatPValue(c.p_value)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{r?.result_table && (
<div className="result-table-section">
<div className="sci-table-wrapper">
<table className="sci-table">
<thead><tr>{r.result_table.headers.map((h: string, i: number) => <th key={i}>{h}</th>)}</tr></thead>
<tbody>{r.result_table.rows.map((row: any[], i: number) => (
<tr key={i}>{row.map((cell, j) => (
<td key={j} className={isPValue(cell) ? 'p-value' : ''}>{formatCell(cell)}</td>
))}</tr>
))}</tbody>
</table>
</div>
</div>
)}
</>
)}
{r?.plots?.length > 0 && (
<div className="result-chart-section">
<h4 className="chart-label">Figure {stepIdx + 1}. </h4>
{r.plots.map((plot: any, plotIdx: number) => (
<ChartImage key={plotIdx} plot={
typeof plot === 'string'
? { type: 'chart', title: '统计图表', imageBase64: plot }
: plot
} />
))}
</div>
)}
</>
);
};
export default SSAWorkspacePane;

View File

@@ -75,12 +75,21 @@ const StepItem: React.FC<StepItemProps> = ({ step, result, isLast, isCurrent })
<span className="step-number"> {step.step_number}</span>
<span className="tool-icon">{getToolIcon(step.tool_code)}</span>
<span className="tool-name">{step.tool_name}</span>
{step.is_sensitivity && (
<span className="sensitivity-badge"></span>
)}
{result?.duration_ms && (
<span className="step-duration">{result.duration_ms}ms</span>
)}
</div>
<div className="step-description">{step.description}</div>
{step.switch_condition && (
<div className="step-guardrail">
🛡 {step.switch_condition}
</div>
)}
{step.params && Object.keys(step.params).length > 0 && (
<div className="step-params">
@@ -146,6 +155,23 @@ export const WorkflowTimeline: React.FC<WorkflowTimelineProps> = ({
</div>
</div>
{plan.epv_warning && (
<div className="epv-warning-banner">
<span className="epv-icon"></span>
<span>{plan.epv_warning}</span>
</div>
)}
{plan.planned_trace?.fallbackTool && (
<div className="guardrail-banner">
<span className="guardrail-icon">🛡</span>
<span>
{plan.planned_trace.primaryTool}
&nbsp;&nbsp;{plan.planned_trace.switchCondition} {plan.planned_trace.fallbackTool}
</span>
</div>
)}
{isExecuting && (
<div className="timeline-progress">
<div className="progress-bar">

View File

@@ -13,13 +13,25 @@ import type {
WorkflowStepResult,
SSEMessage,
SSAMessage,
IntentResult,
ClarificationCardData,
} from '../types';
const API_BASE = '/api/v1/ssa';
interface IntentResponse {
success: boolean;
intent: IntentResult;
needsClarification: boolean;
clarificationCards: ClarificationCardData[];
plan?: WorkflowPlan;
}
interface UseWorkflowReturn {
generateDataProfile: (sessionId: string) => Promise<DataProfile>;
generateWorkflowPlan: (sessionId: string, query: string) => Promise<WorkflowPlan>;
parseIntent: (sessionId: string, query: string) => Promise<IntentResponse>;
handleClarify: (sessionId: string, userQuery: string, selections: Record<string, string>) => Promise<IntentResponse>;
executeWorkflow: (sessionId: string, workflowId: string) => Promise<void>;
cancelWorkflow: () => void;
isProfileLoading: boolean;
@@ -147,8 +159,78 @@ export function useWorkflow(): UseWorkflowReturn {
addToast
]);
/**
* Phase Q: 解析用户意图(不直接生成计划)
*/
const parseIntent = useCallback(async (
sessionId: string,
query: string
): Promise<IntentResponse> => {
setWorkflowPlanLoading(true);
setError(null);
try {
const response = await apiClient.post(`${API_BASE}/workflow/intent`, {
sessionId,
userQuery: query,
});
return response.data as IntentResponse;
} catch (error: any) {
const errorMsg = error.response?.data?.message || error.message || '意图解析失败';
setError(errorMsg);
throw error;
} finally {
setWorkflowPlanLoading(false);
}
}, [setWorkflowPlanLoading, setError]);
/**
* Phase Q: 处理用户追问回答
*/
const handleClarify = useCallback(async (
sessionId: string,
userQuery: string,
selections: Record<string, string>
): Promise<IntentResponse> => {
setWorkflowPlanLoading(true);
setError(null);
try {
const response = await apiClient.post(`${API_BASE}/workflow/clarify`, {
sessionId,
userQuery,
selections,
});
const data = response.data as IntentResponse;
if (data.plan) {
setWorkflowPlan(data.plan);
setActivePane('sap');
setWorkspaceOpen(true);
setIsWorkflowMode(true);
addMessage({
id: crypto.randomUUID(),
role: 'assistant',
content: `已生成分析方案:${data.plan.title}\n共 ${data.plan.total_steps} 个分析步骤`,
artifactType: 'sap',
createdAt: new Date().toISOString(),
});
}
return data;
} catch (error: any) {
const errorMsg = error.response?.data?.message || error.message || '处理追问失败';
setError(errorMsg);
throw error;
} finally {
setWorkflowPlanLoading(false);
}
}, [setWorkflowPlanLoading, setError, setWorkflowPlan, setActivePane, setWorkspaceOpen, setIsWorkflowMode, addMessage]);
const executeWorkflow = useCallback(async (
sessionId: string,
_sessionId: string,
workflowId: string
): Promise<void> => {
setExecuting(true);
@@ -238,12 +320,16 @@ export function useWorkflow(): UseWorkflowReturn {
if (stepNumber !== undefined) {
const result = message.result || message.data?.result;
const durationMs = message.duration_ms || message.durationMs || message.data?.duration_ms;
const reportBlocks = message.reportBlocks
|| (result as any)?.report_blocks
|| message.data?.reportBlocks;
updateWorkflowStep(stepNumber, {
status: message.status || message.data?.status || 'success',
completed_at: new Date().toISOString(),
duration_ms: durationMs,
result: result,
reportBlocks: reportBlocks || undefined,
});
const totalSteps = message.total_steps || message.totalSteps || 2;
@@ -365,6 +451,8 @@ export function useWorkflow(): UseWorkflowReturn {
return {
generateDataProfile,
generateWorkflowPlan,
parseIntent,
handleClarify,
executeWorkflow,
cancelWorkflow,
isProfileLoading: dataProfileLoading,

View File

@@ -2953,6 +2953,64 @@
text-align: center;
}
.workflow-timeline .sensitivity-badge {
display: inline-block;
padding: 1px 8px;
font-size: 11px;
font-weight: 600;
color: #d97706;
background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: 10px;
margin-left: 6px;
}
.workflow-timeline .step-guardrail {
margin-top: 4px;
padding: 4px 8px;
font-size: 12px;
color: #1d4ed8;
background: #eff6ff;
border-left: 3px solid #3b82f6;
border-radius: 4px;
}
.workflow-timeline .epv-warning-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
margin-bottom: 12px;
font-size: 13px;
color: #92400e;
background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: 8px;
}
.workflow-timeline .epv-warning-banner .epv-icon {
font-size: 16px;
flex-shrink: 0;
}
.workflow-timeline .guardrail-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
margin-bottom: 12px;
font-size: 13px;
color: #1e40af;
background: #eff6ff;
border: 1px solid #93c5fd;
border-radius: 8px;
}
.workflow-timeline .guardrail-banner .guardrail-icon {
font-size: 16px;
flex-shrink: 0;
}
.workflow-timeline .ready-hint {
font-size: 13px;
color: #64748b;
@@ -3671,3 +3729,222 @@
border-radius: 4px;
margin-left: auto;
}
/* ========================================== */
/* DynamicReport — Block-based 动态报告 */
/* ========================================== */
.dynamic-report {
display: flex;
flex-direction: column;
gap: 20px;
}
.dr-section-title {
font-size: 13px;
font-weight: 600;
color: #334155;
margin: 0 0 8px 0;
}
.dr-markdown-section {
line-height: 1.7;
}
.dr-markdown-content p {
margin: 0 0 4px 0;
font-size: 13px;
color: #475569;
}
.dr-markdown-content p:empty {
display: none;
}
.dr-table-footnote {
font-size: 11px;
color: #94a3b8;
margin: 4px 0 0 0;
font-style: italic;
}
.dr-kv-section {
margin: 0;
}
/* ========================================== */
/* ClarificationCard — Phase Q 追问卡片 */
/* ========================================== */
.clarification-cards {
display: flex;
flex-direction: column;
gap: 12px;
margin: 8px 0;
}
.clarification-card {
background: #f0f7ff;
border: 1px solid #bfdbfe;
border-radius: 12px;
padding: 14px 16px;
}
.clarification-question {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
color: #1e40af;
margin-bottom: 10px;
}
.clarification-options {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.clarification-option-btn {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
background: #ffffff;
border: 1px solid #93c5fd;
border-radius: 8px;
padding: 8px 14px;
cursor: pointer;
transition: all 0.15s ease;
text-align: left;
}
.clarification-option-btn:hover:not(:disabled) {
background: #dbeafe;
border-color: #3b82f6;
transform: translateY(-1px);
}
.clarification-option-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.clarification-option-btn .option-label {
font-size: 13px;
font-weight: 500;
color: #1e40af;
}
.clarification-option-btn .option-desc {
font-size: 11px;
color: #64748b;
line-height: 1.3;
}
/* ============================================
* Phase R: ConclusionReport 渐入动画 + 增强样式
* ============================================ */
.conclusion-section {
transition: opacity 0.4s ease, transform 0.4s ease;
}
.conclusion-section-hidden {
opacity: 0;
transform: translateY(12px);
}
.conclusion-section-visible {
opacity: 1;
transform: translateY(0);
}
.report-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
}
.report-header-left {
flex: 1;
}
.report-meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
}
.source-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 500;
}
.source-llm {
background: #ede9fe;
color: #7c3aed;
}
.source-rule_based {
background: #f0fdf4;
color: #16a34a;
}
.copy-btn {
padding: 6px 14px;
border: 1px solid #d1d5db;
border-radius: 6px;
background: #fff;
color: #374151;
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.copy-btn:hover {
background: #f3f4f6;
border-color: #9ca3af;
}
.copy-btn.copy-success {
background: #ecfdf5;
border-color: #86efac;
color: #16a34a;
}
.reflecting-indicator {
display: flex;
align-items: center;
gap: 10px;
padding: 16px;
background: linear-gradient(135deg, #faf5ff, #ede9fe);
border: 1px solid #ddd6fe;
border-radius: 10px;
margin: 12px 0;
}
.reflecting-indicator .reflecting-spinner {
width: 20px;
height: 20px;
border: 2px solid #c4b5fd;
border-top-color: #7c3aed;
border-radius: 50%;
animation: reflecting-spin 0.8s linear infinite;
}
@keyframes reflecting-spin {
to { transform: rotate(360deg); }
}
.reflecting-indicator .reflecting-text {
font-size: 13px;
color: #6d28d9;
font-weight: 500;
}

View File

@@ -233,6 +233,19 @@ export interface WorkflowStepDef {
params: Record<string, unknown>;
depends_on?: number[];
fallback_tool?: string;
is_sensitivity?: boolean;
switch_condition?: string | null;
}
/** P 层策略日志 */
export interface PlannedTrace {
matchedRule: string;
primaryTool: string;
fallbackTool: string | null;
switchCondition: string | null;
templateUsed: string;
reasoning: string;
epvWarning: string | null;
}
/** 工作流计划 */
@@ -245,6 +258,8 @@ export interface WorkflowPlan {
steps: WorkflowStepDef[];
estimated_time_seconds?: number;
created_at: string;
planned_trace?: PlannedTrace;
epv_warning?: string | null;
}
/** 工作流步骤执行状态 */
@@ -269,14 +284,16 @@ export interface WorkflowStepResult {
headers: string[];
rows: (string | number)[][];
};
report_blocks?: ReportBlock[];
plots?: PlotData[];
[key: string]: unknown;
};
reportBlocks?: ReportBlock[];
error?: string;
logs: string[];
}
/** 综合结论报告 */
/** 综合结论报告Phase R 统一格式,前后端对齐) */
export interface ConclusionReport {
workflow_id: string;
title: string;
@@ -297,10 +314,59 @@ export interface ConclusionReport {
recommendations: string[];
limitations: string[];
generated_at: string;
source?: 'llm' | 'rule_based';
}
/** SSE 消息类型 */
export type SSEMessageType = 'connected' | 'step_start' | 'step_progress' | 'step_complete' | 'step_error' | 'workflow_complete' | 'workflow_error';
// ============================================
// Block-based 输出协议(与后端 ReportBlock 对应)
// ============================================
export interface ReportBlock {
type: 'markdown' | 'table' | 'image' | 'key_value';
title?: string;
content?: string; // markdown
headers?: string[]; // table
rows?: (string | number)[][]; // table
footnote?: string; // table
data?: string; // image (base64 data URI)
alt?: string; // image
items?: { key: string; value: string }[]; // key_value
}
// ============================================
// Phase Q: Intent / Clarification 类型
// ============================================
export interface IntentResult {
goal: string;
outcome_var: string | null;
outcome_type: string | null;
predictor_vars: string[];
grouping_var: string | null;
design: string;
confidence: number;
reasoning: string;
needsClarification: boolean;
clarificationCards?: ClarificationCardData[];
}
export interface ClarificationCardData {
question: string;
options: ClarificationOptionData[];
}
export interface ClarificationOptionData {
label: string;
value: string;
description?: string;
}
/** SSE 消息类型Phase R 扩展qper_status + reflection_complete */
export type SSEMessageType =
| 'connected'
| 'step_start' | 'step_progress' | 'step_complete' | 'step_error'
| 'workflow_complete' | 'workflow_error'
| 'qper_status' | 'reflection_complete';
/** SSE 消息 */
export interface SSEMessage {
@@ -317,6 +383,7 @@ export interface SSEMessage {
durationMs?: number;
duration_ms?: number;
result?: Record<string, unknown>;
reportBlocks?: ReportBlock[];
// 兼容嵌套格式
data?: WorkflowStepResult & {
tool_code?: string;

View File

@@ -0,0 +1,284 @@
/**
* exportBlocksToWord — 将 ReportBlock[] 导出为 Word (.docx)
*
* 与 DynamicReport.tsx 对应:将 block-based 报告序列化为 docx 格式。
*/
import {
Document,
Packer,
Paragraph,
Table,
TableRow,
TableCell,
TextRun,
HeadingLevel,
WidthType,
BorderStyle,
AlignmentType,
ImageRun,
} from 'docx';
import type { ReportBlock, ConclusionReport } from '../types';
interface ExportOptions {
title?: string;
subtitle?: string;
generatedAt?: string;
conclusion?: ConclusionReport;
}
const TABLE_BORDERS = {
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 },
};
function makeRow(cells: string[], isHeader = false): TableRow {
return new TableRow({
children: cells.map(text =>
new TableCell({
children: [
new Paragraph({
children: [new TextRun({ text: String(text ?? '-'), bold: isHeader })],
}),
],
width: { size: Math.floor(100 / cells.length), type: WidthType.PERCENTAGE },
})
),
});
}
function blockToDocxElements(block: ReportBlock, index: number): (Paragraph | Table)[] {
const elements: (Paragraph | Table)[] = [];
switch (block.type) {
case 'key_value': {
if (block.title) {
elements.push(new Paragraph({ text: block.title, heading: HeadingLevel.HEADING_2 }));
}
const items = block.items ?? [];
if (items.length > 0) {
elements.push(
new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
borders: TABLE_BORDERS,
rows: [
makeRow(['指标', '值'], true),
...items.map(it => makeRow([it.key, it.value])),
],
})
);
elements.push(new Paragraph({ text: '' }));
}
break;
}
case 'table': {
const headers = block.headers ?? [];
const rows = block.rows ?? [];
if (headers.length > 0 || rows.length > 0) {
if (block.title) {
elements.push(
new Paragraph({
text: `Table ${index + 1}. ${block.title}`,
heading: HeadingLevel.HEADING_2,
})
);
}
const tableRows: TableRow[] = [];
if (headers.length > 0) {
tableRows.push(makeRow(headers.map(String), true));
}
for (const row of rows) {
tableRows.push(makeRow(row.map(c => (c === null || c === undefined ? '-' : String(c)))));
}
elements.push(
new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
borders: TABLE_BORDERS,
rows: tableRows,
})
);
if (block.footnote) {
elements.push(
new Paragraph({
children: [new TextRun({ text: block.footnote, italics: true, color: '999999', size: 18 })],
})
);
}
elements.push(new Paragraph({ text: '' }));
}
break;
}
case 'image': {
if (block.data) {
try {
const raw = block.data.replace(/^data:image\/\w+;base64,/, '');
const imageBuffer = Uint8Array.from(atob(raw), c => c.charCodeAt(0));
if (block.title) {
elements.push(
new Paragraph({
text: `Figure ${index + 1}. ${block.title}`,
heading: HeadingLevel.HEADING_2,
})
);
}
elements.push(
new Paragraph({
children: [
new ImageRun({ data: imageBuffer, transformation: { width: 450, height: 300 }, type: 'png' }),
],
alignment: AlignmentType.CENTER,
})
);
elements.push(new Paragraph({ text: '' }));
} catch {
/* skip broken image */
}
}
break;
}
case 'markdown': {
if (block.title) {
elements.push(new Paragraph({ text: block.title, heading: HeadingLevel.HEADING_2 }));
}
if (block.content) {
for (const line of block.content.split('\n')) {
if (line.trim()) {
elements.push(new Paragraph({ text: line }));
}
}
elements.push(new Paragraph({ text: '' }));
}
break;
}
}
return elements;
}
function conclusionToDocxElements(conclusion: ConclusionReport): (Paragraph | Table)[] {
const elements: (Paragraph | Table)[] = [];
elements.push(new Paragraph({ text: '综合结论', heading: HeadingLevel.HEADING_1 }));
elements.push(new Paragraph({ text: '' }));
// Executive Summary
elements.push(new Paragraph({ text: '摘要', heading: HeadingLevel.HEADING_2 }));
for (const line of conclusion.executive_summary.split('\n')) {
if (line.trim()) {
elements.push(new Paragraph({ text: line }));
}
}
elements.push(new Paragraph({ text: '' }));
// Key Findings
if (conclusion.key_findings.length > 0) {
elements.push(new Paragraph({ text: '主要发现', heading: HeadingLevel.HEADING_2 }));
for (const finding of conclusion.key_findings) {
elements.push(
new Paragraph({
children: [new TextRun({ text: `${finding}` })],
})
);
}
elements.push(new Paragraph({ text: '' }));
}
// Recommendations
if (conclusion.recommendations && conclusion.recommendations.length > 0) {
elements.push(new Paragraph({ text: '建议', heading: HeadingLevel.HEADING_2 }));
for (const rec of conclusion.recommendations) {
elements.push(
new Paragraph({
children: [new TextRun({ text: `${rec}` })],
})
);
}
elements.push(new Paragraph({ text: '' }));
}
// Limitations
if (conclusion.limitations.length > 0) {
elements.push(new Paragraph({ text: '局限性', heading: HeadingLevel.HEADING_2 }));
for (const lim of conclusion.limitations) {
elements.push(
new Paragraph({
children: [new TextRun({ text: `${lim}` })],
})
);
}
elements.push(new Paragraph({ text: '' }));
}
return elements;
}
export async function exportBlocksToWord(
blocks: ReportBlock[],
options: ExportOptions = {}
): Promise<void> {
const now = new Date();
const {
title = '统计分析报告',
subtitle,
generatedAt = now.toLocaleString('zh-CN'),
conclusion,
} = options;
const children: (Paragraph | Table)[] = [];
children.push(
new Paragraph({ text: title, heading: HeadingLevel.TITLE, alignment: AlignmentType.CENTER })
);
if (subtitle) {
children.push(new Paragraph({ text: subtitle, alignment: AlignmentType.CENTER }));
}
children.push(
new Paragraph({
children: [new TextRun({ text: `生成时间:${generatedAt}`, italics: true, color: '666666' })],
}),
new Paragraph({ text: '' })
);
// Phase R: Conclusion section at the top (if available)
if (conclusion) {
children.push(...conclusionToDocxElements(conclusion));
children.push(new Paragraph({ text: '详细分析结果', heading: HeadingLevel.HEADING_1 }));
children.push(new Paragraph({ text: '' }));
}
for (let i = 0; i < blocks.length; i++) {
children.push(...blockToDocxElements(blocks[i], i));
}
children.push(
new Paragraph({ text: '' }),
new Paragraph({
children: [
new TextRun({
text: `本报告由智能统计分析系统自动生成${conclusion?.source === 'llm' ? '(结论由 AI 生成)' : ''}`,
italics: true,
color: '666666',
}),
],
})
);
const doc = new Document({ sections: [{ children }] });
const blob = await Packer.toBlob(doc);
const dateStr = `${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 safeTitle = title.replace(/[^a-zA-Z0-9\u4e00-\u9fa5_-]/g, '_');
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${safeTitle}_${dateStr}.docx`;
a.click();
URL.revokeObjectURL(url);
}