Summary: - Fix pg-boss queue conflict (duplicate key violation on queue_pkey) - Add global error listener to prevent process crash - Reduce connection pool from 10 to 4 - Add graceful shutdown handling (SIGTERM/SIGINT) - Fix researchWorker recursive call bug in catch block - Make screeningWorker idempotent using upsert Security Standards (v1.1): - Prohibit recursive retry in Worker catch blocks - Prohibit payload bloat (only store fileKey/ID in job.data) - Require Worker idempotency (upsert + unique constraint) - Recommend task-specific expireInSeconds settings - Document graceful shutdown pattern New Features: - PKB signed URL endpoint for document preview/download - pg_bigm installation guide for Docker - Dockerfile.postgres-with-extensions for pgvector + pg_bigm Documentation: - Update Postgres-Only async task processing guide (v1.1) - Add troubleshooting SQL queries - Update safety checklist Tested: Local verification passed
583 lines
15 KiB
TypeScript
583 lines
15 KiB
TypeScript
/**
|
||
* 全文复筛 - 详情与复核抽屉组件
|
||
*
|
||
* 功能:
|
||
* 1. Tab1: 双模型AI判断对比(12字段)
|
||
* 2. Tab2: PDF全文预览(MVP占位符)
|
||
* 3. Tab3: 12字段详细证据
|
||
* 4. 底部: 人工决策表单
|
||
*/
|
||
|
||
import { useState } from 'react';
|
||
import {
|
||
Drawer,
|
||
Tabs,
|
||
Card,
|
||
Row,
|
||
Col,
|
||
Tag,
|
||
Form,
|
||
Radio,
|
||
Input,
|
||
Button,
|
||
Space,
|
||
Divider,
|
||
Alert,
|
||
Table,
|
||
Collapse,
|
||
} from 'antd';
|
||
import type { TableColumnsType } from 'antd';
|
||
import {
|
||
CheckCircleOutlined,
|
||
CloseCircleOutlined,
|
||
ExclamationCircleOutlined,
|
||
FilePdfOutlined,
|
||
} from '@ant-design/icons';
|
||
import ConclusionTag from './ConclusionTag';
|
||
|
||
const { TextArea } = Input;
|
||
|
||
// 12字段结果类型
|
||
interface FieldResult {
|
||
completeness: 'complete' | 'partial' | 'missing';
|
||
extractability: 'extractable' | 'difficult' | 'impossible';
|
||
evidence: string;
|
||
pageNumber?: number;
|
||
}
|
||
|
||
// 全文复筛结果类型
|
||
interface FulltextResult {
|
||
resultId: string;
|
||
literatureId: string;
|
||
literature: {
|
||
id: string;
|
||
pmid?: string;
|
||
title: string;
|
||
authors?: string;
|
||
journal?: string;
|
||
year?: number;
|
||
};
|
||
modelAResult: {
|
||
modelName: string;
|
||
fields: Record<string, FieldResult>;
|
||
overall: {
|
||
conclusion: 'include' | 'exclude';
|
||
confidence: number;
|
||
reason: string;
|
||
};
|
||
};
|
||
modelBResult: {
|
||
modelName: string;
|
||
fields: Record<string, FieldResult>;
|
||
overall: {
|
||
conclusion: 'include' | 'exclude';
|
||
confidence: number;
|
||
reason: string;
|
||
};
|
||
};
|
||
conflict: {
|
||
isConflict: boolean;
|
||
severity: 'high' | 'medium' | 'low';
|
||
conflictFields: string[];
|
||
};
|
||
review: {
|
||
finalDecision: 'include' | 'exclude' | null;
|
||
};
|
||
}
|
||
|
||
interface Props {
|
||
visible: boolean;
|
||
result: FulltextResult | null;
|
||
onClose: () => void;
|
||
onSubmitReview: (resultId: string, decision: 'include' | 'exclude', note?: string) => void;
|
||
isReviewing: boolean;
|
||
}
|
||
|
||
// 12字段名称映射
|
||
const FIELD_NAMES: Record<string, string> = {
|
||
field_1: '研究设计',
|
||
field_2: '随机化方法',
|
||
field_3: '分配隐藏',
|
||
field_4: '盲法',
|
||
field_5: '研究对象',
|
||
field_6: '样本量',
|
||
field_7: '干预措施',
|
||
field_8: '对照措施',
|
||
field_9: '结局指标',
|
||
field_10: '结果数据',
|
||
field_11: '统计方法',
|
||
field_12: '结果完整性',
|
||
};
|
||
|
||
const FulltextDetailDrawer: React.FC<Props> = ({
|
||
visible,
|
||
result,
|
||
onClose,
|
||
onSubmitReview,
|
||
isReviewing,
|
||
}) => {
|
||
const [form] = Form.useForm();
|
||
const [activeTab, setActiveTab] = useState('comparison');
|
||
|
||
if (!result) return null;
|
||
|
||
// 处理提交
|
||
const handleSubmit = async () => {
|
||
try {
|
||
const values = await form.validateFields();
|
||
onSubmitReview(result.resultId, values.decision, values.note);
|
||
form.resetFields();
|
||
} catch (error) {
|
||
// 验证失败
|
||
}
|
||
};
|
||
|
||
// Tab1: 双模型对比表格
|
||
const renderComparisonTab = () => {
|
||
const fieldsData = Object.entries(FIELD_NAMES).map(([key, name]) => ({
|
||
key,
|
||
fieldName: name,
|
||
modelA: result.modelAResult.fields[key],
|
||
modelB: result.modelBResult.fields[key],
|
||
}));
|
||
|
||
const columns: TableColumnsType<any> = [
|
||
{
|
||
title: '字段',
|
||
dataIndex: 'fieldName',
|
||
key: 'fieldName',
|
||
width: 120,
|
||
},
|
||
{
|
||
title: `${result.modelAResult.modelName} - 完整性`,
|
||
key: 'modelA_completeness',
|
||
width: 120,
|
||
align: 'center',
|
||
render: (_, record) => {
|
||
const completeness = record.modelA?.completeness;
|
||
const colorMap = {
|
||
complete: 'success',
|
||
partial: 'warning',
|
||
missing: 'error',
|
||
};
|
||
return completeness ? (
|
||
<Tag color={colorMap[completeness as keyof typeof colorMap]}>{completeness}</Tag>
|
||
) : (
|
||
'-'
|
||
);
|
||
},
|
||
},
|
||
{
|
||
title: `${result.modelBResult.modelName} - 完整性`,
|
||
key: 'modelB_completeness',
|
||
width: 120,
|
||
align: 'center',
|
||
render: (_, record) => {
|
||
const completeness = record.modelB?.completeness;
|
||
const colorMap = {
|
||
complete: 'success',
|
||
partial: 'warning',
|
||
missing: 'error',
|
||
};
|
||
return completeness ? (
|
||
<Tag color={colorMap[completeness as keyof typeof colorMap]}>{completeness}</Tag>
|
||
) : (
|
||
'-'
|
||
);
|
||
},
|
||
},
|
||
{
|
||
title: '冲突',
|
||
key: 'conflict',
|
||
width: 80,
|
||
align: 'center',
|
||
render: (_, record) => {
|
||
const isConflict =
|
||
record.modelA?.completeness !== record.modelB?.completeness;
|
||
return isConflict ? (
|
||
<ExclamationCircleOutlined style={{ color: '#faad14', fontSize: 18 }} />
|
||
) : (
|
||
<CheckCircleOutlined style={{ color: '#52c41a', fontSize: 18 }} />
|
||
);
|
||
},
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div>
|
||
{/* 整体结论对比 */}
|
||
<Row gutter={16} className="mb-6">
|
||
<Col span={12}>
|
||
<Card
|
||
size="small"
|
||
title={
|
||
<div className="flex items-center justify-between">
|
||
<Tag color="blue">{result.modelAResult.modelName}</Tag>
|
||
<ConclusionTag conclusion={result.modelAResult.overall.conclusion} />
|
||
</div>
|
||
}
|
||
>
|
||
<div className="text-sm">
|
||
<div className="mb-2">
|
||
置信度: {(result.modelAResult.overall.confidence * 100).toFixed(0)}%
|
||
</div>
|
||
<div className="text-gray-600">{result.modelAResult.overall.reason}</div>
|
||
</div>
|
||
</Card>
|
||
</Col>
|
||
<Col span={12}>
|
||
<Card
|
||
size="small"
|
||
title={
|
||
<div className="flex items-center justify-between">
|
||
<Tag color="purple">{result.modelBResult.modelName}</Tag>
|
||
<ConclusionTag conclusion={result.modelBResult.overall.conclusion} />
|
||
</div>
|
||
}
|
||
>
|
||
<div className="text-sm">
|
||
<div className="mb-2">
|
||
置信度: {(result.modelBResult.overall.confidence * 100).toFixed(0)}%
|
||
</div>
|
||
<div className="text-gray-600">{result.modelBResult.overall.reason}</div>
|
||
</div>
|
||
</Card>
|
||
</Col>
|
||
</Row>
|
||
|
||
{/* 12字段对比表格 */}
|
||
<Table
|
||
columns={columns}
|
||
dataSource={fieldsData}
|
||
rowKey="key"
|
||
pagination={false}
|
||
size="small"
|
||
bordered
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// Tab2: PDF预览(MVP占位符)
|
||
const renderPdfTab = () => {
|
||
return (
|
||
<div className="text-center py-12">
|
||
<FilePdfOutlined style={{ fontSize: 64, color: '#d9d9d9' }} />
|
||
<div className="mt-4 text-gray-500">
|
||
<p className="text-lg font-semibold">PDF预览功能</p>
|
||
<p className="text-sm mt-2">该功能将在后续版本中实现</p>
|
||
</div>
|
||
<Alert
|
||
message="技术债务 #15"
|
||
description="PDF预览与高亮功能已列入技术债务清单,将在MVP之后的迭代中实现"
|
||
type="info"
|
||
showIcon
|
||
className="mt-6 max-w-md mx-auto"
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// Tab3: 12字段详细证据
|
||
const renderFieldsDetailTab = () => {
|
||
const fieldsData = Object.entries(FIELD_NAMES).map(([key, name]) => ({
|
||
key,
|
||
label: name,
|
||
children: (
|
||
<Row gutter={16}>
|
||
<Col span={12}>
|
||
<Card size="small" title={<Tag color="blue">{result.modelAResult.modelName}</Tag>}>
|
||
{result.modelAResult.fields[key] ? (
|
||
<div className="text-sm space-y-2">
|
||
<div>
|
||
<span className="font-semibold">完整性: </span>
|
||
<Tag
|
||
color={
|
||
result.modelAResult.fields[key].completeness === 'complete'
|
||
? 'success'
|
||
: result.modelAResult.fields[key].completeness === 'partial'
|
||
? 'warning'
|
||
: 'error'
|
||
}
|
||
>
|
||
{result.modelAResult.fields[key].completeness}
|
||
</Tag>
|
||
</div>
|
||
<div>
|
||
<span className="font-semibold">可提取性: </span>
|
||
<Tag>{result.modelAResult.fields[key].extractability}</Tag>
|
||
</div>
|
||
<div>
|
||
<span className="font-semibold">证据: </span>
|
||
<div className="mt-1 p-2 bg-gray-50 rounded text-gray-700">
|
||
{result.modelAResult.fields[key].evidence || '未提供证据'}
|
||
</div>
|
||
</div>
|
||
{result.modelAResult.fields[key].pageNumber && (
|
||
<div className="text-xs text-gray-500">
|
||
页码: {result.modelAResult.fields[key].pageNumber}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="text-gray-400">无数据</div>
|
||
)}
|
||
</Card>
|
||
</Col>
|
||
<Col span={12}>
|
||
<Card size="small" title={<Tag color="purple">{result.modelBResult.modelName}</Tag>}>
|
||
{result.modelBResult.fields[key] ? (
|
||
<div className="text-sm space-y-2">
|
||
<div>
|
||
<span className="font-semibold">完整性: </span>
|
||
<Tag
|
||
color={
|
||
result.modelBResult.fields[key].completeness === 'complete'
|
||
? 'success'
|
||
: result.modelBResult.fields[key].completeness === 'partial'
|
||
? 'warning'
|
||
: 'error'
|
||
}
|
||
>
|
||
{result.modelBResult.fields[key].completeness}
|
||
</Tag>
|
||
</div>
|
||
<div>
|
||
<span className="font-semibold">可提取性: </span>
|
||
<Tag>{result.modelBResult.fields[key].extractability}</Tag>
|
||
</div>
|
||
<div>
|
||
<span className="font-semibold">证据: </span>
|
||
<div className="mt-1 p-2 bg-gray-50 rounded text-gray-700">
|
||
{result.modelBResult.fields[key].evidence || '未提供证据'}
|
||
</div>
|
||
</div>
|
||
{result.modelBResult.fields[key].pageNumber && (
|
||
<div className="text-xs text-gray-500">
|
||
页码: {result.modelBResult.fields[key].pageNumber}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="text-gray-400">无数据</div>
|
||
)}
|
||
</Card>
|
||
</Col>
|
||
</Row>
|
||
),
|
||
}));
|
||
|
||
return <Collapse items={fieldsData} />;
|
||
};
|
||
|
||
return (
|
||
<Drawer
|
||
title={
|
||
<div>
|
||
<div className="text-lg font-bold">全文复筛 - 详情与复核</div>
|
||
<div className="text-sm text-gray-500 font-normal mt-1">
|
||
{result.literature.title}
|
||
</div>
|
||
</div>
|
||
}
|
||
placement="right"
|
||
width="80%"
|
||
onClose={onClose}
|
||
open={visible}
|
||
footer={
|
||
<div className="flex justify-between items-center">
|
||
<Button onClick={onClose}>取消</Button>
|
||
<Button type="primary" onClick={handleSubmit} loading={isReviewing}>
|
||
提交复核
|
||
</Button>
|
||
</div>
|
||
}
|
||
>
|
||
{/* 文献元信息 */}
|
||
<Card size="small" className="mb-4">
|
||
<div className="text-sm space-y-1">
|
||
<div>
|
||
<span className="font-semibold">PMID:</span> {result.literature.pmid || '-'}
|
||
</div>
|
||
<div>
|
||
<span className="font-semibold">作者:</span> {result.literature.authors || '-'}
|
||
</div>
|
||
<div>
|
||
<span className="font-semibold">期刊:</span> {result.literature.journal || '-'} (
|
||
{result.literature.year || '-'})
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
{/* 冲突提示 */}
|
||
{result.conflict.isConflict && (
|
||
<Alert
|
||
message="存在模型判断冲突"
|
||
description={`冲突严重程度: ${result.conflict.severity.toUpperCase()} | 冲突字段: ${result.conflict.conflictFields.join(', ')}`}
|
||
type="warning"
|
||
showIcon
|
||
className="mb-4"
|
||
/>
|
||
)}
|
||
|
||
{/* Tab内容 */}
|
||
<Tabs
|
||
activeKey={activeTab}
|
||
onChange={setActiveTab}
|
||
items={[
|
||
{
|
||
key: 'comparison',
|
||
label: '双模型对比',
|
||
children: renderComparisonTab(),
|
||
},
|
||
{
|
||
key: 'pdf',
|
||
label: 'PDF全文预览',
|
||
children: renderPdfTab(),
|
||
},
|
||
{
|
||
key: 'fields',
|
||
label: '12字段详情',
|
||
children: renderFieldsDetailTab(),
|
||
},
|
||
]}
|
||
/>
|
||
|
||
<Divider />
|
||
|
||
{/* 人工决策表单 */}
|
||
<Card title="人工复核决策" size="small">
|
||
<Form form={form} layout="vertical">
|
||
<Form.Item
|
||
name="decision"
|
||
label="最终决策"
|
||
rules={[{ required: true, message: '请选择决策' }]}
|
||
>
|
||
<Radio.Group>
|
||
<Space direction="vertical">
|
||
<Radio value="include">
|
||
<CheckCircleOutlined style={{ color: '#52c41a' }} /> 纳入
|
||
</Radio>
|
||
<Radio value="exclude">
|
||
<CloseCircleOutlined style={{ color: '#ff4d4f' }} /> 排除
|
||
</Radio>
|
||
</Space>
|
||
</Radio.Group>
|
||
</Form.Item>
|
||
|
||
<Form.Item
|
||
noStyle
|
||
shouldUpdate={(prevValues, currentValues) =>
|
||
prevValues.decision !== currentValues.decision
|
||
}
|
||
>
|
||
{({ getFieldValue }) =>
|
||
getFieldValue('decision') === 'exclude' ? (
|
||
<Form.Item
|
||
name="exclusionReason"
|
||
label="排除原因"
|
||
rules={[{ required: true, message: '请输入排除原因' }]}
|
||
>
|
||
<Input placeholder="例如:P不匹配、S不是RCT、12字段不完整等" />
|
||
</Form.Item>
|
||
) : null
|
||
}
|
||
</Form.Item>
|
||
|
||
<Form.Item name="note" label="复核备注(可选)">
|
||
<TextArea
|
||
rows={3}
|
||
placeholder="记录复核理由、特殊情况说明等..."
|
||
maxLength={500}
|
||
showCount
|
||
/>
|
||
</Form.Item>
|
||
</Form>
|
||
</Card>
|
||
</Drawer>
|
||
);
|
||
};
|
||
|
||
export default FulltextDetailDrawer;
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|