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:
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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 },
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user