feat(iit): harden QC pipeline consistency and release artifacts

Implement IIT quality workflow hardening across eQuery deduplication, guard metadata validation, timeline/readability improvements, and chat evidence fallbacks, then synchronize release and development documentation for deployment handoff.

Includes migration/scripts for open eQuery dedupe guards, orchestration/status semantics, report/tool readability fixes, and updated module status plus deployment checklist.

Made-with: Cursor
This commit is contained in:
2026-03-08 21:54:35 +08:00
parent ac724266c1
commit a666649fd4
57 changed files with 28637 additions and 316 deletions

View File

@@ -220,6 +220,7 @@ export interface Equery {
projectId: string;
recordId: string;
eventId?: string;
eventLabel?: string | null;
formName?: string;
fieldName?: string;
queryText: string;
@@ -308,6 +309,16 @@ export async function closeEquery(
return response.data.data;
}
/** 手动重开 eQuery */
export async function reopenEquery(
projectId: string,
equeryId: string,
data?: { reviewNote?: string }
): Promise<Equery> {
const response = await apiClient.post(`${BASE_URL}/${projectId}/equeries/${equeryId}/reopen`, data || {});
return response.data.data;
}
// ==================== 用户映射 ====================
/** 获取角色选项 */
@@ -743,6 +754,7 @@ export interface EqueryLogEntry {
id: string;
recordId: string;
eventId: string | null;
eventLabel?: string | null;
formName: string | null;
fieldName: string | null;
fieldLabel: string | null;

View File

@@ -49,6 +49,7 @@ const EligibilityTable: React.FC<Props> = ({ projectId }) => {
if (!data || data.subjects.length === 0) return <Empty description="暂无 D1 筛选入选数据" />;
const { summary, criteria, subjects } = data;
const ruleNameMap = new Map(criteria.map(c => [c.ruleId, c.ruleName]));
const criteriaColumns = [
{ title: '规则 ID', dataIndex: 'ruleId', width: 100 },
@@ -86,7 +87,13 @@ const EligibilityTable: React.FC<Props> = ({ projectId }) => {
title: '不合规条目',
dataIndex: 'failedCriteria',
render: (arr: string[]) =>
arr.length > 0 ? arr.map(id => <Tag key={id} color="error">{id}</Tag>) : <Text type="secondary"></Text>,
arr.length > 0
? arr.map(id => (
<Tag key={id} color="error">
{ruleNameMap.get(id) || id}
</Tag>
))
: <Text type="secondary"></Text>,
},
];

View File

@@ -164,7 +164,13 @@ const EqueryLogTable: React.FC<Props> = ({ projectId }) => {
expandable={{ expandedRowRender: expandedRow }}
columns={[
{ title: '受试者', dataIndex: 'recordId', width: 80 },
{ title: '事件', dataIndex: 'eventId', width: 120, ellipsis: true, render: (v: string | null) => v || '—' },
{
title: '事件',
dataIndex: 'eventLabel',
width: 140,
ellipsis: true,
render: (_: string | null, row: EqueryLogEntry) => row.eventLabel || row.eventId || '—',
},
{ title: '字段', dataIndex: 'fieldLabel', width: 120, ellipsis: true, render: (v: string | null) => v || '—' },
{ title: '质疑内容', dataIndex: 'queryText', ellipsis: true },
{

View File

@@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect } from 'react';
import { Input, Button, Spin, Typography, Avatar } from 'antd';
import { Input, Button, Spin, Typography, Avatar, Tag } from 'antd';
import {
SendOutlined,
RobotOutlined,
@@ -32,6 +32,86 @@ function stripToolCallXml(text: string): string {
return text;
}
interface StructuredAssistantContent {
conclusion?: string;
evidence: string[];
remainder?: string;
}
interface EvidenceTokenized {
tags: string[];
text: string;
}
function parseStructuredAssistantContent(raw: string): StructuredAssistantContent {
const text = stripToolCallXml(raw).trim();
if (!text) return { evidence: [] };
const lines = text.split(/\r?\n/).map(l => l.trim());
const conclusionLine = lines.find(l => /^结论[:]/.test(l));
const evidenceStart = lines.findIndex(l => /^证据[:]/.test(l));
let conclusion: string | undefined;
if (conclusionLine) {
conclusion = conclusionLine.replace(/^结论[:]\s*/, '').trim();
}
const evidence: string[] = [];
if (evidenceStart >= 0) {
for (let i = evidenceStart + 1; i < lines.length; i++) {
const ln = lines[i];
if (!ln) continue;
if (/^结论[:]/.test(ln)) continue;
if (/^[-*]\s+/.test(ln)) {
evidence.push(ln.replace(/^[-*]\s+/, '').trim());
} else if (/^\d+[.)、]\s+/.test(ln)) {
evidence.push(ln.replace(/^\d+[.)、]\s+/, '').trim());
}
}
}
// 没命中结构化格式时,回退为 markdown 渲染
if (!conclusion && evidence.length === 0) {
return { evidence: [], remainder: text };
}
const remainderParts: string[] = [];
for (const ln of lines) {
if (!ln) continue;
if (/^结论[:]/.test(ln) || /^证据[:]/.test(ln)) continue;
if (/^[-*]\s+/.test(ln) || /^\d+[.)、]\s+/.test(ln)) continue;
// 避免把结论正文重复放进 remainder
if (conclusion && ln === conclusion) continue;
remainderParts.push(ln);
}
return {
conclusion,
evidence,
remainder: remainderParts.length > 0 ? remainderParts.join('\n') : undefined,
};
}
function tokenizeEvidenceLine(line: string): EvidenceTokenized {
const tags: string[] = [];
const patterns = [
/\brecord_id\s*=\s*([A-Za-z0-9_-]+)/gi,
/\bevent_id\s*=\s*([A-Za-z0-9_,.-]+)/gi,
/\brule_id\s*=\s*([A-Za-z0-9_,.-]+)/gi,
/\bD[1-7]\b/g,
];
for (const pattern of patterns) {
let match: RegExpExecArray | null;
while ((match = pattern.exec(line)) !== null) {
tags.push(match[0]);
}
}
const uniqueTags = Array.from(new Set(tags));
return { tags: uniqueTags, text: line };
}
interface ChatMessage {
id: string;
role: 'user' | 'assistant';
@@ -163,11 +243,66 @@ const AiChatPage: React.FC = () => {
}}
>
{msg.role === 'assistant' ? (
<div className="chat-md-content">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{stripToolCallXml(msg.content)}
</ReactMarkdown>
</div>
(() => {
const structured = parseStructuredAssistantContent(msg.content);
return (
<div className="chat-md-content">
{structured.conclusion && (
<div style={{ marginBottom: 8 }}>
<Text strong style={{ color: '#0f172a' }}>
</Text>
<span style={{ marginLeft: 6 }}>{structured.conclusion}</span>
</div>
)}
{structured.evidence.length > 0 && (
<div
style={{
marginBottom: structured.remainder ? 8 : 0,
background: '#ffffff',
border: '1px solid #dbeafe',
borderRadius: 10,
padding: '8px 10px',
}}
>
<Text strong style={{ color: '#1d4ed8' }}></Text>
<ul style={{ margin: '6px 0 0 18px', padding: 0 }}>
{structured.evidence.map((ev, idx) => (
<li key={`${msg.id}-evi-${idx}`} style={{ marginBottom: 4 }}>
{(() => {
const tokenized = tokenizeEvidenceLine(ev);
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
{tokenized.tags.map((t, ti) => (
<Tag key={`${msg.id}-evi-tag-${idx}-${ti}`} color="blue" style={{ marginInlineEnd: 0 }}>
{t}
</Tag>
))}
<span>{tokenized.text}</span>
</div>
);
})()}
</li>
))}
</ul>
</div>
)}
{structured.remainder && (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{structured.remainder}
</ReactMarkdown>
)}
{!structured.conclusion && !structured.remainder && structured.evidence.length === 0 && (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{stripToolCallXml(msg.content)}
</ReactMarkdown>
)}
</div>
);
})()
) : (
<span style={{ whiteSpace: 'pre-wrap' }}>{msg.content}</span>
)}

View File

@@ -62,6 +62,8 @@ const EQueryPage: React.FC = () => {
const [stats, setStats] = useState<EqueryStats | null>(null);
const [loading, setLoading] = useState(false);
const [statusFilter, setStatusFilter] = useState<string | undefined>(undefined);
const [severityFilter, setSeverityFilter] = useState<string | undefined>(undefined);
const [recordIdFilter, setRecordIdFilter] = useState<string>('');
const [page, setPage] = useState(1);
// Respond modal
@@ -79,10 +81,25 @@ const EQueryPage: React.FC = () => {
setLoading(true);
try {
const [listResult, statsResult] = await Promise.all([
iitProjectApi.listEqueries(projectId, { status: statusFilter, page, pageSize: 20 }),
iitProjectApi.listEqueries(projectId, {
status: statusFilter,
severity: severityFilter,
recordId: recordIdFilter.trim() || undefined,
page,
pageSize: 20,
}),
iitProjectApi.getEqueryStats(projectId),
]);
setEqueries(listResult.items);
const sorted = [...listResult.items].sort((a, b) => {
const aNum = Number(a.recordId);
const bNum = Number(b.recordId);
const aValid = Number.isFinite(aNum);
const bValid = Number.isFinite(bNum);
if (aValid && bValid && aNum !== bNum) return aNum - bNum;
if (a.recordId !== b.recordId) return a.recordId.localeCompare(b.recordId, 'zh-CN', { numeric: true });
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
});
setEqueries(sorted);
setTotal(listResult.total);
setStats(statsResult);
} catch {
@@ -90,7 +107,7 @@ const EQueryPage: React.FC = () => {
} finally {
setLoading(false);
}
}, [projectId, statusFilter, page]);
}, [projectId, statusFilter, severityFilter, recordIdFilter, page]);
useEffect(() => {
fetchData();
@@ -125,7 +142,23 @@ const EQueryPage: React.FC = () => {
}
};
const handleReopen = async (equery: Equery) => {
try {
await iitProjectApi.reopenEquery(projectId, equery.id);
message.success('已重开');
fetchData();
} catch (err: any) {
message.error(err.message || '重开失败');
}
};
const columns: ColumnsType<Equery> = [
{
title: '序号',
key: 'index',
width: 72,
render: (_: unknown, __: Equery, index: number) => (page - 1) * 20 + index + 1,
},
{
title: '受试者',
dataIndex: 'recordId',
@@ -133,6 +166,13 @@ const EQueryPage: React.FC = () => {
width: 100,
render: (id: string) => <Text strong>{id}</Text>,
},
{
title: '访视点',
dataIndex: 'eventLabel',
key: 'eventId',
width: 180,
render: (_: string | undefined, row: Equery) => row.eventLabel || row.eventId || '—',
},
{
title: '状态',
dataIndex: 'status',
@@ -211,6 +251,15 @@ const EQueryPage: React.FC = () => {
</Button>
)}
{record.status === 'closed' && (
<Button
type="link"
size="small"
onClick={() => handleReopen(record)}
>
</Button>
)}
</Space>
),
},
@@ -269,6 +318,21 @@ const EQueryPage: React.FC = () => {
onChange={(v) => { setStatusFilter(v); setPage(1); }}
options={Object.entries(STATUS_CONFIG).map(([value, { label }]) => ({ value, label }))}
/>
<Select
placeholder="按严重程度过滤"
allowClear
style={{ width: 160 }}
value={severityFilter}
onChange={(v) => { setSeverityFilter(v); setPage(1); }}
options={Object.entries(SEVERITY_CONFIG).map(([value, { label }]) => ({ value, label }))}
/>
<Input
placeholder="按受试者号过滤"
style={{ width: 180 }}
value={recordIdFilter}
onChange={(e) => { setRecordIdFilter(e.target.value); setPage(1); }}
allowClear
/>
<Badge count={total} overflowCount={9999} style={{ backgroundColor: '#1677ff' }}>
<Text type="secondary"> eQuery</Text>
</Badge>
@@ -333,6 +397,9 @@ const EQueryPage: React.FC = () => {
<Col span={8}><Text type="secondary">:</Text> <Text strong>{detailTarget.recordId}</Text></Col>
<Col span={8}><Text type="secondary">:</Text> <Text code>{detailTarget.fieldName || '—'}</Text></Col>
<Col span={8}><Text type="secondary">:</Text> {detailTarget.formName || '—'}</Col>
<Col span={8}>
<Text type="secondary">访:</Text> {detailTarget.eventLabel || detailTarget.eventId || '—'}
</Col>
<Col span={8}>
<Text type="secondary">:</Text>{' '}
<Tag color={STATUS_CONFIG[detailTarget.status]?.color}>{STATUS_CONFIG[detailTarget.status]?.label}</Tag>