feat(rvw,asl): RVW V3.0 smart review + ASL deep research history + stability

RVW module (V3.0 Smart Review Enhancement):
- Add LLM data validation via PromptService (RVW_DATA_VALIDATION)
- Add ClinicalAssessmentSkill with FINER-based evaluation (RVW_CLINICAL)
- Remove all numeric scores from UI (editorial, methodology, overall)
- Implement partial_completed status with Promise.allSettled
- Add error_details JSON field to ReviewTask for granular failure info
- Fix overallStatus logic: warning status now counts as success
- Restructure ForensicsReport: per-table LLM results, remove top-level block
- Refactor ClinicalReport: structured collapsible sections
- Increase all skill timeouts to 300s for long manuscripts (20+ pages)
- Increase DataForensics LLM timeout to 180s, pg-boss to 15min
- Executor default fallback timeout 30s -> 60s

ASL module:
- Add deep research history with sidebar accordion UI
- Implement waterfall flow for historical task display
- Upgrade Unifuncs DeepSearch API from S2 to S3 with fallback
- Add ASL_SR module seed for admin configurability
- Fix new search button inconsistency

Docs:
- Update RVW module status to V3.0
- Update deployment changelist
- Add 0305 deployment summary

DB Migration:
- Add error_details JSONB column to rvw_schema.review_tasks

Tested: All 4 review modules verified, partial completion working
Made-with: Cursor
This commit is contained in:
2026-03-07 19:24:21 +08:00
parent 91ae80888e
commit 87655ea7e6
46 changed files with 2929 additions and 511 deletions

View File

@@ -2,13 +2,19 @@
* Deep Research V2.0 主页面 — 瀑布流布局
*
* Phase 0: Landing全屏居中搜索
* Phase 1+: 配置 策略 执行 结果,依次累积展示
* Phase 1+: 配置 -> 策略 -> 执行 -> 结果,依次累积展示
*
* 支持从 URL 参数 :taskId 恢复历史任务。
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import { message } from 'antd';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Button, Typography, message } from 'antd';
import { CheckCircleFilled, DownloadOutlined, PlusOutlined, ClockCircleOutlined, CloseCircleFilled } from '@ant-design/icons';
import { getAccessToken } from '../../../framework/auth/api';
import { aslApi } from '../api';
import { useDeepResearchTask } from '../hooks/useDeepResearchTask';
import { useASLLayout } from '../components/ASLLayout';
import LandingView from '../components/deep-research/LandingView';
import SetupPanel from '../components/deep-research/SetupPanel';
import StrategyConfirm from '../components/deep-research/StrategyConfirm';
@@ -19,28 +25,74 @@ import type { IntentSummary, GenerateRequirementResponse } from '../types/deepRe
type Phase = 0 | 1 | 2 | 3 | 4;
const DeepResearchPage = () => {
const { taskId: urlTaskId } = useParams<{ taskId?: string }>();
const navigate = useNavigate();
const { refreshHistory } = useASLLayout();
const [phase, setPhase] = useState<Phase>(0);
const [query, setQuery] = useState('');
const [taskId, setTaskId] = useState<string | null>(null);
const [generatedRequirement, setGeneratedRequirement] = useState('');
const [intentSummary, setIntentSummary] = useState<IntentSummary | null>(null);
const [generating, setGenerating] = useState(false);
const [restoredFromUrl, setRestoredFromUrl] = useState(false);
const strategyRef = useRef<HTMLDivElement>(null);
const terminalRef = useRef<HTMLDivElement>(null);
const resultsRef = useRef<HTMLDivElement>(null);
// 轮询 hookphase >= 3 或从 URL 恢复的任务)
const shouldPoll = phase >= 3 || (restoredFromUrl && !!taskId);
const { task, isRunning, isCompleted, isFailed } = useDeepResearchTask({
taskId,
enabled: phase >= 3,
enabled: shouldPoll,
});
// 从 URL :taskId 恢复历史任务,或 URL 失去 taskId 时重置
useEffect(() => {
if (isCompleted && phase === 3) {
if (urlTaskId && urlTaskId !== taskId) {
setTaskId(urlTaskId);
setRestoredFromUrl(true);
} else if (!urlTaskId && taskId) {
setPhase(0);
setTaskId(null);
setQuery('');
setGeneratedRequirement('');
setIntentSummary(null);
setRestoredFromUrl(false);
}
}, [urlTaskId]); // eslint-disable-line react-hooks/exhaustive-deps
// 恢复的任务加载完成后设置正确的 phase
useEffect(() => {
if (!restoredFromUrl || !task) return;
setQuery(task.query || '');
if (task.confirmedRequirement) {
setGeneratedRequirement(task.confirmedRequirement);
}
if (task.aiIntentSummary) {
setIntentSummary(task.aiIntentSummary);
}
if (task.status === 'completed') {
setPhase(4);
} else if (task.status === 'running' || task.status === 'pending') {
setPhase(3);
} else if (task.status === 'failed') {
setPhase(4);
} else if (task.status === 'draft') {
setPhase(2);
}
}, [restoredFromUrl, task]);
// 正常流程中:执行完成自动进入结果页
useEffect(() => {
if (isCompleted && phase === 3 && !restoredFromUrl) {
setPhase(4);
setTimeout(() => resultsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 150);
}
}, [isCompleted, phase]);
}, [isCompleted, phase, restoredFromUrl]);
const scrollTo = (ref: React.RefObject<HTMLDivElement | null>) => {
setTimeout(() => ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 150);
@@ -52,6 +104,7 @@ const DeepResearchPage = () => {
setGeneratedRequirement('');
setIntentSummary(null);
setTaskId(null);
setRestoredFromUrl(false);
}, []);
const handleSetupSubmit = useCallback(async (
@@ -86,10 +139,13 @@ const DeepResearchPage = () => {
setPhase(3);
scrollTo(terminalRef);
message.success('Deep Research 已启动');
// 任务进入 pending 后刷新侧边栏历史列表
navigate(`/literature/research/deep/${taskId}`, { replace: true });
refreshHistory();
} catch (err: any) {
message.error(err.message || '启动失败');
}
}, [taskId]);
}, [taskId, navigate, refreshHistory]);
const handleNewSearch = useCallback(() => {
setPhase(0);
@@ -97,9 +153,69 @@ const DeepResearchPage = () => {
setTaskId(null);
setGeneratedRequirement('');
setIntentSummary(null);
setRestoredFromUrl(false);
navigate('/literature/research/deep', { replace: true });
window.scrollTo({ top: 0, behavior: 'smooth' });
}, []);
}, [navigate]);
// ── 从 URL 恢复的任务:直接展示结果或执行中状态 ──
if (restoredFromUrl && task) {
const isFinished = task.status === 'completed' || task.status === 'failed';
return (
<div className="h-full overflow-auto bg-gray-50 pb-16">
<div className="max-w-5xl mx-auto px-6 pt-6">
{/* 顶部横幅:完成/失败状态 + 导出按钮 */}
{isFinished && (
<HistoryBanner task={task} onNewSearch={handleNewSearch} />
)}
{!isFinished && (
<Card size="small" className="mb-4 !bg-blue-50 !border-blue-200">
<div className="flex items-center gap-3">
<ClockCircleOutlined className="text-lg text-blue-500" />
<Typography.Text strong>{task.query} ...</Typography.Text>
</div>
</Card>
)}
{/* 检索需求书PICOS + 完整指令) */}
{task.confirmedRequirement && task.aiIntentSummary && (
<div className="mt-4">
<StrategyConfirm
generatedRequirement={task.confirmedRequirement}
intentSummary={task.aiIntentSummary}
onConfirm={() => {}}
readOnly
/>
</div>
)}
{/* 执行日志 */}
<div className="mt-4">
<AgentTerminal task={task} isRunning={!isFinished && isRunning} isFailed={isFailed} />
</div>
{/* 结果区(报告 + 文献表格),横幅已在顶部展示 */}
{isFinished && (
<div className="mt-4">
<ResultsView task={task} onNewSearch={handleNewSearch} hideBanner />
</div>
)}
</div>
</div>
);
}
// 恢复中但 task 还未加载 — loading
if (restoredFromUrl && !task) {
return (
<div className="h-full flex items-center justify-center bg-gray-50">
<div className="text-slate-400 text-sm">...</div>
</div>
);
}
// ── 正常新建流程 ──
if (phase === 0) {
return (
<div className="h-full overflow-auto bg-gray-50">
@@ -158,4 +274,79 @@ const DeepResearchPage = () => {
);
};
// ── 历史记录顶部横幅(完成/失败 + 导出) ──
const { Text } = Typography;
function HistoryBanner({ task, onNewSearch }: { task: any; onNewSearch: () => void }) {
const [exporting, setExporting] = useState(false);
const isCompleted = task.status === 'completed';
const handleExportWord = async () => {
setExporting(true);
try {
const token = getAccessToken();
const res = await fetch(`/api/v1/asl/research/tasks/${task.taskId}/export-word`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
const disposition = res.headers.get('Content-Disposition') || '';
const filenameMatch = disposition.match(/filename\*?=(?:UTF-8'')?([^;]+)/);
const filename = filenameMatch ? decodeURIComponent(filenameMatch[1]) : 'DeepResearch.docx';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
message.success('导出成功');
} catch (err: any) {
message.error(err.message || '导出失败');
} finally {
setExporting(false);
}
};
if (!isCompleted) {
return (
<Card size="small" className="mb-4 !bg-red-50 !border-red-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<CloseCircleFilled className="text-lg text-red-500" />
<div>
<Text strong></Text>
<Text type="secondary" className="text-sm ml-2">{task.query}</Text>
</div>
</div>
<Button icon={<PlusOutlined />} onClick={onNewSearch}></Button>
</div>
</Card>
);
}
return (
<Card size="small" className="mb-4 !bg-green-50 !border-green-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<CheckCircleFilled className="text-xl text-green-500" />
<div>
<Text strong>Deep Research </Text>
<Text type="secondary" className="text-sm ml-2">
{task.query} {task.resultCount || 0}
{task.completedAt && ` · ${new Date(task.completedAt).toLocaleString()}`}
</Text>
</div>
</div>
<div className="flex gap-2">
<Button icon={<DownloadOutlined />} loading={exporting} onClick={handleExportWord}>
Word
</Button>
<Button icon={<PlusOutlined />} onClick={onNewSearch}></Button>
</div>
</div>
</Card>
);
}
export default DeepResearchPage;