Files
AIclinicalresearch/frontend-v2/src/modules/dc/pages/tool-c/components/MultiMetricPanel.tsx
HaHafeng 40c2f8e148 feat(rag): Complete RAG engine implementation with pgvector
Major Features:
- Created ekb_schema (13th schema) with 3 tables: KB/Document/Chunk
- Implemented EmbeddingService (text-embedding-v4, 1024-dim vectors)
- Implemented ChunkService (smart Markdown chunking)
- Implemented VectorSearchService (multi-query + hybrid search)
- Implemented RerankService (qwen3-rerank)
- Integrated DeepSeek V3 QueryRewriter for cross-language search
- Python service: Added pymupdf4llm for PDF-to-Markdown conversion
- PKB: Dual-mode adapter (pgvector/dify/hybrid)

Architecture:
- Brain-Hand Model: Business layer (DeepSeek) + Engine layer (pgvector)
- Cross-language support: Chinese query matches English documents
- Small Embedding (1024) + Strong Reranker strategy

Performance:
- End-to-end latency: 2.5s
- Cost per query: 0.0025 RMB
- Accuracy improvement: +20.5% (cross-language)

Tests:
- test-embedding-service.ts: Vector embedding verified
- test-rag-e2e.ts: Full pipeline tested
- test-rerank.ts: Rerank quality validated
- test-query-rewrite.ts: Cross-language search verified
- test-pdf-ingest.ts: Real PDF document tested (Dongen 2003.pdf)

Documentation:
- Added 05-RAG-Engine-User-Guide.md
- Added 02-Document-Processing-User-Guide.md
- Updated system status documentation

Status: Production ready
2026-01-21 20:24:29 +08:00

532 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 多指标转换面板组件
*
* 功能:
* - 方向1时间点为行指标为列统计分析格式
* - 方向2时间点为列指标为行展示格式
* - 自动检测多个指标并分组
*/
import React, { useState, useEffect } from 'react';
import { Form, Select, Button, Alert, Table, Spin, Divider, Space, Card, Tag, message, Radio } from 'antd';
import { getAuthHeaders } from '../../../api/toolC';
const { Option } = Select;
interface MultiMetricPanelProps {
sessionId: string;
columns: string[];
onConfirm: (params: any) => void;
onCancel: () => void;
}
interface MetricGrouping {
success: boolean;
metric_groups?: Record<string, string[]>;
separator?: string;
timepoints?: string[];
confidence?: number;
message?: string;
}
export const MultiMetricPanel: React.FC<MultiMetricPanelProps> = ({
sessionId,
columns,
onConfirm,
onCancel,
}) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [detecting, setDetecting] = useState(false);
const [grouping, setGrouping] = useState<MetricGrouping | null>(null);
const [previewData, setPreviewData] = useState<any[]>([]);
const [previewColumns, setPreviewColumns] = useState<any[]>([]);
const [direction, setDirection] = useState<'to_long' | 'to_matrix'>('to_long'); // 转换方向
// 选中的列变化时,自动检测分组
useEffect(() => {
const valueVars = form.getFieldValue('valueVars');
if (valueVars && valueVars.length >= 2) {
detectGrouping(valueVars);
} else {
setGrouping(null);
setPreviewData([]);
setPreviewColumns([]);
}
}, []);
/**
* 自动检测多指标分组
*/
const detectGrouping = async (valueVars: string[]) => {
if (!valueVars || valueVars.length < 2) {
setGrouping(null);
return;
}
setDetecting(true);
try {
console.log(`[MultiMetricPanel] 检测多指标分组: ${valueVars.length}`);
const response = await fetch('/api/v1/dc/tool-c/multi-metric/detect', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
sessionId,
valueVars,
}),
});
const data = await response.json();
if (data.success) {
setGrouping(data.grouping);
console.log(`[MultiMetricPanel] 分组检测成功:`, data.grouping);
// 自动生成预览
generatePreview(
form.getFieldValue('idVars') || [],
valueVars,
data.grouping
);
} else {
console.error(`[MultiMetricPanel] 分组检测失败:`, data.error);
setGrouping({
success: false,
message: data.error || '分组检测失败'
});
}
} catch (error: any) {
console.error(`[MultiMetricPanel] 分组检测异常:`, error);
setGrouping({
success: false,
message: error.message || '分组检测异常'
});
} finally {
setDetecting(false);
}
};
/**
* 生成预览数据
*/
const generatePreview = async (idVars: string[], valueVars: string[], groupingData: MetricGrouping) => {
if (!groupingData.success || !idVars || idVars.length === 0) {
return;
}
setLoading(true);
try {
// 根据转换方向调用不同的API
const action = direction === 'to_long' ? 'multi_metric_to_long' : 'multi_metric_to_matrix';
const params = direction === 'to_long'
? {
idVars,
valueVars,
eventColName: form.getFieldValue('eventColName') || 'Event_Name',
}
: {
idVars,
valueVars,
metricColName: form.getFieldValue('metricColName') || '指标名',
};
// 调用preview API
const response = await fetch('/api/v1/dc/tool-c/quick-action/preview', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
sessionId,
action,
params,
}),
});
const data = await response.json();
if (data.success) {
const resultData = data.data.newDataPreview || [];
if (resultData.length > 0) {
// 生成列定义
const cols = Object.keys(resultData[0]).map((key) => ({
title: key,
dataIndex: key,
key,
width: 150,
}));
setPreviewColumns(cols);
setPreviewData(resultData.slice(0, 10)); // 只显示前10行
}
} else {
console.error(`[MultiMetricPanel] 预览失败:`, data.error);
}
} catch (error: any) {
console.error(`[MultiMetricPanel] 预览异常:`, error);
} finally {
setLoading(false);
}
};
/**
* 处理表单提交
*/
const handleSubmit = async () => {
try {
const values = await form.validateFields();
if (!grouping || !grouping.success) {
message.error('请先检测指标分组');
return;
}
setLoading(true);
console.log(`[MultiMetricPanel] 提交多指标转换 (${direction}):`, values);
// 根据转换方向调用不同的API
const action = direction === 'to_long' ? 'multi_metric_to_long' : 'multi_metric_to_matrix';
const params = direction === 'to_long'
? {
idVars: values.idVars,
valueVars: values.valueVars,
eventColName: values.eventColName || 'Event_Name',
}
: {
idVars: values.idVars,
valueVars: values.valueVars,
metricColName: values.metricColName || '指标名',
};
// 调用快速操作API执行转换
const response = await fetch('/api/v1/dc/tool-c/quick-action', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
sessionId,
action,
params,
}),
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error || '多指标转换失败');
}
const successMsg = direction === 'to_long' ? '多指标转长表成功!' : '多指标转矩阵成功!';
message.success(successMsg);
// 更新父组件数据
if (result.data?.newDataPreview) {
onConfirm(result.data.newDataPreview);
}
// 成功后关闭
onCancel();
} catch (error: any) {
console.error(`[MultiMetricPanel] 转换失败:`, error);
message.error(error.message || '执行失败');
} finally {
setLoading(false);
}
};
/**
* 处理值列变化
*/
const handleValueVarsChange = (valueVars: string[]) => {
form.setFieldsValue({ valueVars });
detectGrouping(valueVars);
};
/**
* 处理ID列变化
*/
const handleIdVarsChange = (idVars: string[]) => {
form.setFieldsValue({ idVars });
// 重新生成预览
const valueVars = form.getFieldValue('valueVars');
if (grouping && grouping.success && valueVars && valueVars.length >= 2) {
generatePreview(idVars, valueVars, grouping);
}
};
return (
<div>
<Form
form={form}
layout="vertical"
initialValues={{
idVars: [],
valueVars: [],
eventColName: 'Event_Name',
metricColName: '指标名',
}}
>
{/* 0. 转换方向 */}
<Card size="small" style={{ marginBottom: 16, backgroundColor: '#f0f5ff' }}>
<div style={{ marginBottom: 8 }}>
<strong>📍 </strong>
</div>
<Radio.Group
value={direction}
onChange={(e) => {
setDirection(e.target.value);
// 重新生成预览
const idVars = form.getFieldValue('idVars');
const valueVars = form.getFieldValue('valueVars');
if (grouping && grouping.success && idVars && valueVars && valueVars.length >= 2) {
generatePreview(idVars, valueVars, grouping);
}
}}
style={{ width: '100%' }}
>
<Space direction="vertical" style={{ width: '100%' }}>
<Radio value="to_long">
<div>
<strong></strong> <Tag color="blue"></Tag>
<div style={{ fontSize: '12px', color: '#666', marginTop: 4 }}>
GEE
</div>
</div>
</Radio>
<Radio value="to_matrix">
<div>
<strong></strong> <Tag color="green"></Tag>
<div style={{ fontSize: '12px', color: '#666', marginTop: 4 }}>
CRF核对
</div>
</div>
</Radio>
</Space>
</Radio.Group>
</Card>
{/* 1. 选择ID列 */}
<Form.Item
label="1⃣ 选择ID列"
name="idVars"
rules={[{ required: true, message: '请选择至少1个ID列' }]}
>
<Select
mode="multiple"
placeholder="选择保持不变的列Record_ID、Subject_ID"
onChange={handleIdVarsChange}
showSearch
filterOption={(input, option) =>
String(option?.label || option?.value || '').toLowerCase().includes(input.toLowerCase())
}
>
{columns.map((col) => (
<Option key={col} value={col}>
{col}
</Option>
))}
</Select>
</Form.Item>
{/* 2. 选择值列 */}
<Form.Item
label={
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
<span>2 </span>
<Space size="small">
<Button
type="link"
size="small"
onClick={() => {
const availableCols = columns.filter((col) => !form.getFieldValue('idVars')?.includes(col));
form.setFieldsValue({ valueVars: availableCols });
handleValueVarsChange(availableCols);
}}
style={{ padding: 0, height: 'auto' }}
>
</Button>
<Button
type="link"
size="small"
onClick={() => {
form.setFieldsValue({ valueVars: [] });
handleValueVarsChange([]);
}}
style={{ padding: 0, height: 'auto' }}
>
</Button>
</Space>
</div>
}
name="valueVars"
rules={[
{ required: true, message: '请选择至少2个值列' },
{
validator: (_, value) => {
if (value && value.length >= 2) {
return Promise.resolve();
}
return Promise.reject(new Error('至少需要选择2列才能进行转换'));
},
},
]}
extra="选择多个指标的多个时间点列例如FMA总得分_基线、FMA总得分_随访1、ADL总分_基线、ADL总分_随访1"
>
<Select
mode="multiple"
placeholder="选择多个指标的多个时间点列"
onChange={handleValueVarsChange}
showSearch
filterOption={(input, option) =>
String(option?.label || option?.value || '').toLowerCase().includes(input.toLowerCase())
}
loading={detecting}
>
{columns
.filter((col) => !form.getFieldValue('idVars')?.includes(col))
.map((col) => (
<Option key={col} value={col}>
{col}
</Option>
))}
</Select>
</Form.Item>
{/* 3. 自动检测结果 */}
{detecting && (
<div style={{ textAlign: 'center', padding: '20px 0' }}>
<Spin tip="正在检测指标分组..." />
</div>
)}
{!detecting && grouping && (
<Card
title="3⃣ 自动检测结果"
size="small"
style={{ marginBottom: 16 }}
type={grouping.success ? 'inner' : 'inner'}
>
{grouping.success ? (
<Space direction="vertical" style={{ width: '100%' }}>
<div>
<strong> {Object.keys(grouping.metric_groups || {}).length} </strong>
<div style={{ marginTop: 8 }}>
{Object.entries(grouping.metric_groups || {}).map(([metric, cols]) => (
<Tag key={metric} color="blue" style={{ marginBottom: 4 }}>
{metric} ({cols.length})
</Tag>
))}
</div>
</div>
<div>
<strong> {grouping.timepoints?.length || 0} </strong>
<div style={{ marginTop: 8 }}>
{grouping.timepoints?.slice(0, 5).map((tp) => (
<Tag key={tp} color="green" style={{ marginBottom: 4 }}>
{tp}
</Tag>
))}
{(grouping.timepoints?.length || 0) > 5 && (
<Tag color="default">... {(grouping.timepoints?.length || 0) - 5} </Tag>
)}
</div>
</div>
<div>
<strong> </strong>
<Tag color="orange">{grouping.separator || '(无)'}</Tag>
</div>
{grouping.confidence !== undefined && grouping.confidence < 1.0 && (
<Alert
message="提示"
description={grouping.message || '检测置信度较低,请检查选中的列是否正确'}
type="warning"
showIcon
/>
)}
</Space>
) : (
<Alert
message="检测失败"
description={grouping.message || '未能检测到有效的指标分组'}
type="error"
showIcon
/>
)}
</Card>
)}
{/* 4. 列名设置 */}
{direction === 'to_long' ? (
<Form.Item
label="4⃣ 时间点列名"
name="eventColName"
rules={[{ required: true, message: '请输入时间点列名' }]}
>
<Select placeholder="选择或输入时间点列名">
<Option value="Event_Name">Event_Name</Option>
<Option value="时间点"></Option>
<Option value="Timepoint">Timepoint</Option>
<Option value="Visit">Visit</Option>
</Select>
</Form.Item>
) : (
<Form.Item
label="4⃣ 指标列名"
name="metricColName"
rules={[{ required: true, message: '请输入指标列名' }]}
>
<Select placeholder="选择或输入指标列名">
<Option value="指标名"></Option>
<Option value="Metric">Metric</Option>
<Option value="指标"></Option>
<Option value="Variable">Variable</Option>
</Select>
</Form.Item>
)}
{/* 5. 预览结果 */}
{previewData.length > 0 && (
<>
<Divider>10</Divider>
<div style={{ maxHeight: '400px', overflow: 'auto' }}>
<Table
dataSource={previewData}
columns={previewColumns}
pagination={false}
size="small"
scroll={{ x: 'max-content' }}
loading={loading}
bordered
/>
</div>
</>
)}
</Form>
{/* 操作按钮 */}
<div style={{ marginTop: 16, textAlign: 'right' }}>
<Space>
<Button onClick={onCancel}></Button>
<Button
type="primary"
onClick={handleSubmit}
disabled={!grouping || !grouping.success}
loading={loading}
>
</Button>
</Space>
</div>
</div>
);
};