Files
AIclinicalresearch/frontend-v2/src/modules/asl/components/FulltextDetailDrawer.tsx
HaHafeng 61cdc97eeb feat(platform): Fix pg-boss queue conflict and add safety standards
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
2026-01-23 22:07:26 +08:00

583 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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. 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;