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

@@ -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);
}