feat(core): finalize rvw stability updates and pending module changes
Summary: - Harden RVW prompt protocol handling and methodology review flow with 20-checkpoint coverage, divide-and-conquer execution, and timeout tuning - Update RVW frontend methodology report rendering to show real structured outputs and grouped checkpoint sections - Include pending backend/frontend updates across IIT admin, SSA, extraction forensics, and related integration files - Sync system and RVW status documentation, deployment checklist, and RVW architecture/plan docs Validation: - Verified lint diagnostics for touched RVW backend/frontend files show no new errors - Kept backup dump files and local test artifacts untracked Made-with: Cursor
This commit is contained in:
@@ -30,3 +30,8 @@ DIFY_API_KEY=your-dify-api-key
|
||||
# Server
|
||||
PORT=3001
|
||||
NODE_ENV=development
|
||||
|
||||
# RVW DataForensics Mode
|
||||
# false (default): extract tables + LLM review only
|
||||
# true: enable legacy rule-based validation (L1/L2) in addition to extraction
|
||||
RVW_FORENSICS_RULES_ENABLED=false
|
||||
|
||||
@@ -92,7 +92,7 @@ export interface ForensicsIssue {
|
||||
* 数据侦探配置
|
||||
*/
|
||||
export interface ForensicsConfig {
|
||||
checkLevel: 'L1' | 'L1_L2' | 'L1_L2_L25';
|
||||
checkLevel: 'EXTRACT_ONLY' | 'L1' | 'L1_L2' | 'L1_L2_L25';
|
||||
tolerancePercent: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,57 +42,102 @@ const RVW_FALLBACKS: Record<string, FallbackPrompt> = {
|
||||
},
|
||||
|
||||
RVW_METHODOLOGY: {
|
||||
content: `你是一位资深的医学统计学专家,负责评估稿件的方法学质量。
|
||||
|
||||
【评估框架】
|
||||
第一部分:科研设计评估(研究类型、对象、对照、质控)
|
||||
第二部分:统计学方法描述(软件、方法、混杂因素)
|
||||
第三部分:统计分析评估(方法正确性、结果描述)
|
||||
|
||||
请输出JSON格式的评估结果,包含overall_score和parts数组。`,
|
||||
content: `你是一位资深临床研究方法学专家与医学统计学审稿人,常年为《The Lancet》、《JAMA》或《中华医学杂志》等国内外顶尖期刊提供审稿意见。请对用户提供的手稿进行深度评估。你需要根据以下【20项核心检查点框架】,指出稿件存在的缺陷,并给出具体的、具有可操作性的修改建议。
|
||||
评估框架(20项核心检查点)
|
||||
一、科研设计评估 (Scientific Design)
|
||||
1. 设计类型界定:研究类型(如 RCT、队列、嵌套病例对照等)是否明确且分类准确。
|
||||
2. 纳入/排除标准:逻辑是否严密,是否能够有效界定目标人群。
|
||||
3. 样本代表性:抽样方法是否导致选择偏倚,基线特征描述是否详尽。
|
||||
4. 对照组设置:对照类型(空白、安慰剂、阳性药物等)的合理性及组间可比性。
|
||||
5. 干预与观察细节:干预措施的标准化程度,观察指标的定义是否遵循核心指标集。
|
||||
6. 效应指标选择:主要与次要结局指标是否具备临床重要性及测量学效度。
|
||||
7. 设计要素完整性:如随机方法、分配隐藏、盲法(受试者/研究者/评价者)的具体实现。
|
||||
8. 样本量估算:是否有基于效应量、检验效能($1-\\beta$)和 $\\alpha$ 水平的显性计算公式。
|
||||
9. 质控与伦理:数据监查、SOP 遵循情况及伦理批件/临床注册号。
|
||||
二、统计学方法描述评估 (Statistical Methodology)
|
||||
10. 基础参数明示:软件版本、资料类型(计量/计数/等级)、检验水准(单双侧)。
|
||||
11. 分布特征:描述性统计是否与分布特征匹配(正态 vs 偏态)。
|
||||
12. 多因素调整:混杂因素的选择依据,是否使用了 Cox、Logistic 回归等调整模型。
|
||||
13. 缺失值处理:是否说明了缺失数据的比例及处理方法(如多重插补、敏感性分析)。
|
||||
14. 一致性检查:描述的方法与结果部分实际使用的统计手段是否“对得上”。
|
||||
三、统计分析与结果评估 (Analysis & Results)
|
||||
15. 前提条件检验:是否进行了正态性检验、方差齐性检验或比例风险假设检验(PH)。
|
||||
16. 多重比较校正:涉及多个结局或亚组分析时,是否进行了 Bonferroni 等校正。
|
||||
17. 统计量规范性:是否报告了精确的P值、统计量(t, F, chi2)及 95% 置信区间(CI)。
|
||||
18. 效应量表达:是否提供了 OR、RR、HR 或 MD 等具有临床意义的效应量。
|
||||
19. 逻辑一致性:统计推断是否越过数据过度解读(例如将相关性直接描述为因果关系)。
|
||||
20. 图表准确性:图表是否能自明,数据是否与正文矛盾。
|
||||
输出要求
|
||||
请按以下格式输出你的审稿报告:
|
||||
1. 总体评价
|
||||
(简述研究的方法学严谨度及统计学规范性的总体印象)
|
||||
2. 详细问题清单与建议
|
||||
问题按严重问题与一般问题分类详细罗列,可修改的问题给出修改建议。
|
||||
3. 审稿结论
|
||||
(请从以下选项中选择:直接接收 / 小修 / 大修 / 拒稿)
|
||||
除特殊要求外,用中文回复。`,
|
||||
modelConfig: { model: 'deepseek-v3', temperature: 0.3 },
|
||||
},
|
||||
|
||||
RVW_DATA_VALIDATION: {
|
||||
content: `你正在处理的是医学科研稿件,请对附件中的表格进行核查,包括百分比计算是准确,统计检验方法使用是否正确,统计分析检验结果是否准确,卡方检验中如果适用fisher精确检验的条件,不给卡方值不是问题,请忽略。最终形成一个核查报告,重点列出核查出的问题。
|
||||
|
||||
请按表格逐个输出核查结果,使用以下格式:
|
||||
## 表N: <表格标题>
|
||||
<该表格的核查结论和发现的问题>`,
|
||||
content: `你是一位精通医学统计学与临床研究方法学的数据审计专家。你擅长从复杂的医学表格中捕捉逻辑矛盾、计算错误以及统计方法误用的细微痕迹。请对附件中的医学表格进行深度核查。你的核心任务是验证数据的内部逻辑性、计算的准确性以及统计推断的合规性。
|
||||
核查核心准则
|
||||
1. 构成比准确性:检查横向或纵向百分比计算是否正确。注意总数(N)与频数(n)的换算关系,确保四舍五入无误。
|
||||
2. 统计方法适配性:
|
||||
- 分类资料:检查横向或纵向百分比计算是否正确,评估是否根据样本量正确选择了准确的检验方法。
|
||||
- Fisher 确切概率法(重点):当总例数N < 40,或某个单元格的理论频数 T < 1 时,必须使用 Fisher 确切概率法。注意:若使用了 Fisher 检验,未报告chi^2值属于规范操作,请勿视其为缺陷。
|
||||
- 连续变量:是否是正态分布,如果是正态分布请检查均数、标准差(x̄ 和sd)与检验方法(t检验或方差分析)是否匹配;如果不是正态分布,请检查中位数、四分位数或极值或四分位间距与检验方法(非参数检验)是否匹配。
|
||||
- 回归分析:相关回归系数与标准误与p是否矛盾,是否存在HR、RR、OR等偏大(如大于5)或偏小(如小于0.2)。回归分析中的分类变量对照设置是否清楚。
|
||||
3. 结果一致性:检查 P值与统计量(如 t, chi^2, F)是否匹配,是否存在结论与数据方向矛盾的情况。
|
||||
4. 基线可比性:核查组间基线数据是否存在未解释的显著性差异。
|
||||
报告格式要求
|
||||
请逐一分析每个表格,并按以下格式输出核查报告:
|
||||
表N: <表格标题>
|
||||
1. 核心发现与问题清单
|
||||
- 计算错误:(例如:第 2 行对照组百分比计算应为 15.2% 而非 16.5%)
|
||||
- 方法误用:(例如:变量 A 的最小理论频数小于 1,应使用 Fisher 确切概率法而非卡方检验)
|
||||
- 逻辑矛盾:(例如:各分项之和不等于总样本量)
|
||||
- 规范性建议:(例如:建议增加 95% 置信区间报告)
|
||||
2. 最终核查结论
|
||||
- [ ] 通过(数据准确,方法得当)
|
||||
- [ ] 条件通过(存在文字规范或微小计算误差,不影响核心结论)
|
||||
- [ ] 未通过(存在严重统计错误或计算失误,需重算)
|
||||
如无特殊要求,请用中文回复。`,
|
||||
modelConfig: { model: 'deepseek-v3', temperature: 0.3 },
|
||||
},
|
||||
|
||||
RVW_CLINICAL: {
|
||||
content: `你作为临床研究设计智能顾问(CRD-IA),将依据 FINER 标准(可行性 Feasibility、创新性 Interesting、伦理性 Ethical、相关性 Relevant)对研究选题进行系统评估并用中文回答。
|
||||
第一步:研究问题的明确性评估
|
||||
1. 判断研究问题是否清晰
|
||||
研究问题是否包含完整的 PICO 要素(Population/Intervention/Comparator/Outcome)。
|
||||
若 PICO 不完整,提示研究者补充必要信息。
|
||||
2. 研究问题的完善与优化
|
||||
研究者已有明确的临床问题:通过对话识别其陈述中的关键信息,优化 PICO 框架。
|
||||
研究者尚未形成清晰的研究问题:询问其关注的疾病领域,并协助提出可供研究的具体问题。
|
||||
判断是否需进一步咨询专家:若研究问题仍不够明确,建议研究者寻求该领域专家的意见。
|
||||
第二步:研究问题的要素完整性验证
|
||||
CRD-IA 将按以下维度评估研究问题的完整性,确保其符合 FINER 标准,并依据 循证医学原则 和 ICH-GCP 规范 进行多维度价值评估。评估逻辑包括 假设解构 → 知识验证 → 缺陷识别 → 优化建议,所有结论需明确 证据等级(A/B/C类)。
|
||||
1. 创新性评估
|
||||
检索国际指南、PubMed 已发表论文,以及 ICTRP、ClinicalTrials.gov 近三年注册研究,分析研究选题的 相似度(相似度<30%为高创新)。
|
||||
识别研究假设中的 知识突破点,判断是否填补现有研究空白。
|
||||
content: `你作为临床首席科学家,将对一份手稿是否值得接收进行评价,核心在于评估其科学价值的增量与临床转化的逻辑。评价请比以下方面进行:
|
||||
一、重点关注的五个核心维度及具体要求
|
||||
1. 科学创新性与临床意义
|
||||
科学问题:科学问题是否明确,研究是否解决了一个真实存在的临床痛点?例如,是否发现了新的生物标志物,或者挑战了现有的治疗金标准。
|
||||
创新等级:是“从0到1”的发现(如揭示了全新的信号通路),还是“从1到1.1”的改进(如优化了现有的手术入路)
|
||||
临床转化潜力:研究结果能否在未来指导临床决策?在基础研究中,也应该说明其与疾病病理生理过程的关联度。
|
||||
2. 研究设计的严谨性
|
||||
对照组设置:是否设置了合理的阴性对照、阳性对照或空白对照?
|
||||
混杂因素:结合研究的因果关系,明确是否充分考虑并尽可能控制了混杂因素
|
||||
3. 结果的合理性
|
||||
研究结果必须是可重复且经得起推敲的,包括研究方法与相关分析是否有明确错误,相关结果与临床常规或既往文献结果是否一致。
|
||||
4. 逻辑架构与讨论深度
|
||||
结果与讨论的衔接:讨论部分是否仅仅是重复结果?好论文应该能将实验数据与前人研究进行对比,并客观讨论研究的局限性
|
||||
机制解释:在讨论中,作者是否通过相关研究或文献解释了因果关系的合理性及关键分子机制。
|
||||
5. 研究结论的合理性
|
||||
结论必要有本研究的结果支撑,结论可以延伸但不能超过本研究主要结果的范围,不盲目夸大研究结果与成果。
|
||||
二、评价过程可应用到的工具
|
||||
1. 科研性评价时,可以参考是否符合 FINER 标准,基本科学原理,若存在与已知科学常识矛盾的部分,应提示研究者重新审视理论基础,必要时检索国际指南、PubMed 已发表论文,以及 ICTRP、ClinicalTrials.gov 近三年注册研究,分析研究选题的相似度(相似度<30%为高创新)。识别研究假设中的知识突破点,判断是否填补现有研究空白。
|
||||
2. 临床价值评估
|
||||
通过 PubMed 检索该疾病的 疾病负担指数(参考最新 GBD 数据),判断该研究的 临床紧迫性。
|
||||
检索该疾病相关的 国际指南,明确指南是否指出该问题 需要进一步证据。
|
||||
评估研究者定义的 结局指标 是否与临床关注的核心获益一致;如偏离临床重点,应予以提示。
|
||||
3. 科学性评估
|
||||
研究假设是否 符合基本科学原理,若存在与已知科学常识矛盾的部分,应提示研究者重新审视理论基础。
|
||||
该研究问题能否通过 合理的研究设计 进行科学验证。
|
||||
4. 可行性评估
|
||||
进行 风险-受益比分析(基于 DECISION 模型),评估该研究是否存在 重大伦理风险,影响可行性。
|
||||
估算目标患者群体的 潜在样本量,若可能难以收集足够样本,应明确指出并建议调整研究方案。
|
||||
第三步:最终评估结论与优化建议
|
||||
在综合分析 创新性、临床价值、科学性、可行性 之后,CRD-IA 将:
|
||||
总结研究选题的整体评估结果,标明各项评估的 证据等级(A/B/C类)。
|
||||
提出优化建议,帮助研究者改进研究设计,使其更具科学价值、临床意义和可操作性。
|
||||
回答需要考虑聊天历史。
|
||||
如果过程中有不明确的问题,通过聊天让用户补充相关信息。除特殊要求外,用中文回复。`,
|
||||
通过 PubMed 检索该疾病的疾病负担指数(参考最新 GBD 数据),判断该研究的临床紧迫性。
|
||||
检索该疾病相关的国际指南,明确指南是否指出该问题需要进一步证据。
|
||||
评估研究者定义的结局指标是否与临床关注的核心获益一致;如偏离临床重点,应予以提示。
|
||||
输出要求
|
||||
请按以下格式输出你的审稿报告:
|
||||
1. 总体评价
|
||||
(简述研究的临床价值及方法学严谨度的总体印象)
|
||||
2. 详细问题清单与建议
|
||||
问题按严重问题与一般问题分类详细罗列,可修改的问题给出修改建议。
|
||||
3. 审稿结论
|
||||
(请从以下选项中选择:直接接收 / 小修 / 大修 / 拒稿)
|
||||
除特殊要求外,用中文回复。`,
|
||||
modelConfig: { model: 'deepseek-v3', temperature: 0.3 },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -23,6 +23,11 @@ interface ListMappingsQuery {
|
||||
search?: string;
|
||||
}
|
||||
|
||||
interface SearchUsersQuery {
|
||||
search?: string;
|
||||
limit?: string;
|
||||
}
|
||||
|
||||
// ==================== 控制器函数 ====================
|
||||
|
||||
/**
|
||||
@@ -227,6 +232,35 @@ export async function getRoleOptions(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 在项目内搜索可关联的平台用户
|
||||
*/
|
||||
export async function searchPlatformUsers(
|
||||
request: FastifyRequest<{ Params: ProjectIdParams; Querystring: SearchUsersQuery }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { projectId } = request.params;
|
||||
const search = (request.query.search || '').trim();
|
||||
const limit = request.query.limit ? parseInt(request.query.limit, 10) : 20;
|
||||
|
||||
const service = getIitUserMappingService(prisma);
|
||||
const users = await service.searchPlatformUsers(projectId, search, Number.isNaN(limit) ? 20 : limit);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: users,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('搜索平台用户失败', { error: message });
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户映射统计
|
||||
*/
|
||||
|
||||
@@ -12,6 +12,9 @@ export async function iitUserMappingRoutes(fastify: FastifyInstance) {
|
||||
// 获取项目的用户映射列表
|
||||
fastify.get('/:projectId/users', controller.listUserMappings);
|
||||
|
||||
// 搜索可关联的平台用户(项目成员添加专用)
|
||||
fastify.get('/:projectId/users/search', controller.searchPlatformUsers);
|
||||
|
||||
// 获取用户映射统计
|
||||
fastify.get('/:projectId/users/stats', controller.getUserMappingStats);
|
||||
|
||||
|
||||
@@ -29,11 +29,69 @@ export interface UserMappingListFilters {
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface PlatformUserOption {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
// ==================== 服务实现 ====================
|
||||
|
||||
export class IitUserMappingService {
|
||||
constructor(private prisma: PrismaClient) {}
|
||||
|
||||
/**
|
||||
* 搜索可关联的平台用户(用于 IIT 项目成员添加)
|
||||
*
|
||||
* 说明:
|
||||
* - 仅在项目所属租户内搜索,避免跨租户误选
|
||||
* - 不依赖 /api/admin/users 的 user:view / ops:user-ops 权限
|
||||
*/
|
||||
async searchPlatformUsers(projectId: string, keyword: string, limit = 20): Promise<PlatformUserOption[]> {
|
||||
const q = (keyword || '').trim();
|
||||
if (q.length < 2) return [];
|
||||
|
||||
const project = await this.prisma.iitProject.findFirst({
|
||||
where: { id: projectId, deletedAt: null },
|
||||
select: { tenantId: true },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new Error('项目不存在');
|
||||
}
|
||||
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: {
|
||||
status: 'active',
|
||||
...(project.tenantId ? { tenant_id: project.tenantId } : {}),
|
||||
OR: [
|
||||
{ name: { contains: q, mode: 'insensitive' } },
|
||||
{ phone: { contains: q, mode: 'insensitive' } },
|
||||
{ email: { contains: q, mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
email: true,
|
||||
role: true,
|
||||
},
|
||||
orderBy: [{ name: 'asc' }],
|
||||
take: Math.max(1, Math.min(limit, 50)),
|
||||
});
|
||||
|
||||
return users.map((u) => ({
|
||||
id: u.id,
|
||||
name: u.name || '',
|
||||
phone: u.phone || '',
|
||||
email: u.email || '',
|
||||
role: u.role || '',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目的用户映射列表
|
||||
*/
|
||||
|
||||
@@ -981,21 +981,31 @@ export class RedcapAdapter {
|
||||
forms: string[];
|
||||
}> = [];
|
||||
|
||||
const inferFormsFromRecord = (record: Record<string, any>): string[] => {
|
||||
const inferred = Object.keys(record)
|
||||
.filter((k) => k.endsWith('_complete') && k !== 'redcap_data_access_group')
|
||||
.map((k) => k.replace(/_complete$/, ''))
|
||||
.filter((k) => !!k && k !== 'record');
|
||||
return [...new Set(inferred)];
|
||||
};
|
||||
|
||||
for (const record of rawRecords) {
|
||||
const recordId = record.record_id;
|
||||
const eventName = record.redcap_event_name;
|
||||
// 非纵向项目通常不返回 redcap_event_name,统一落到 default 事件
|
||||
const eventName = record.redcap_event_name || 'default';
|
||||
|
||||
if (!recordId || !eventName) continue;
|
||||
if (!recordId) continue;
|
||||
|
||||
// 获取该事件包含的表单列表
|
||||
const forms = eventForms.has(eventName)
|
||||
const mappedForms = eventForms.has(eventName)
|
||||
? [...eventForms.get(eventName)!]
|
||||
: [];
|
||||
const forms = mappedForms.length > 0 ? mappedForms : inferFormsFromRecord(record);
|
||||
|
||||
results.push({
|
||||
recordId,
|
||||
eventName,
|
||||
eventLabel: eventLabels.get(eventName) || eventName,
|
||||
eventLabel: eventLabels.get(eventName) || (eventName === 'default' ? '默认事件' : eventName),
|
||||
data: record,
|
||||
forms,
|
||||
});
|
||||
|
||||
@@ -87,6 +87,12 @@ export interface QCRule {
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
category: RuleCategory;
|
||||
metadata?: Record<string, any>;
|
||||
/**
|
||||
* 规则逻辑结果语义(可选):
|
||||
* - compliance: jsonLogic=true 表示“通过”
|
||||
* - violation: jsonLogic=true 表示“违规”
|
||||
*/
|
||||
resultMode?: 'compliance' | 'violation';
|
||||
|
||||
// V3.1: 事件级质控支持
|
||||
/** 适用的事件列表,空数组或不设置表示适用所有事件 */
|
||||
@@ -437,7 +443,11 @@ export class HardRuleEngine {
|
||||
}
|
||||
|
||||
const guardedPass = this.forcePassByBusinessGuard(rule, fieldValue);
|
||||
const passed = guardedPass ? true : (jsonLogic.apply(rule.logic, data) as boolean);
|
||||
const logicResult = Boolean(jsonLogic.apply(rule.logic, data));
|
||||
const resultMode = this.resolveRuleResultMode(rule);
|
||||
const passed = guardedPass
|
||||
? true
|
||||
: (resultMode === 'violation' ? !logicResult : logicResult);
|
||||
const expectedValue = this.extractExpectedValue(rule.logic);
|
||||
const expectedCondition = this.describeLogic(rule.logic);
|
||||
const llmMessage = passed
|
||||
@@ -484,6 +494,27 @@ export class HardRuleEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 规则结果语义判定:
|
||||
* - 优先使用 metadata.resultMode / rule.resultMode
|
||||
* - 兼容 D1 历史“违规则命名”的规则(如:未满足/不符合/触发/冲突)
|
||||
*/
|
||||
private resolveRuleResultMode(rule: QCRule): 'compliance' | 'violation' {
|
||||
const modeFromMeta = String((rule.metadata as any)?.resultMode || (rule as any)?.resultMode || '').trim().toLowerCase();
|
||||
if (modeFromMeta === 'compliance' || modeFromMeta === 'violation') {
|
||||
return modeFromMeta as 'compliance' | 'violation';
|
||||
}
|
||||
|
||||
const dim = toDimensionCode(rule.category);
|
||||
if (dim === 'D1') {
|
||||
const hint = `${rule.name || ''} ${rule.message || ''}`;
|
||||
if (/(不符合|未满足|触发|冲突|异常|失败|不通过)/.test(hint)) {
|
||||
return 'violation';
|
||||
}
|
||||
}
|
||||
return 'compliance';
|
||||
}
|
||||
|
||||
/**
|
||||
* V2.1: 从 JSON Logic 中提取期望值
|
||||
*/
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ModelType } from '../../../common/llm/adapters/types.js';
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
import { prisma } from '../../../config/database.js';
|
||||
import { getPromptService } from '../../../common/prompt/index.js';
|
||||
import { composeRvwSystemPrompt } from './promptProtocols.js';
|
||||
|
||||
export interface ClinicalReviewResult {
|
||||
report: string;
|
||||
@@ -31,7 +32,7 @@ export async function reviewClinical(
|
||||
): Promise<ClinicalReviewResult> {
|
||||
try {
|
||||
const promptService = getPromptService(prisma);
|
||||
const { content: systemPrompt, isDraft } = await promptService.get(
|
||||
const { content: businessPrompt, isDraft } = await promptService.get(
|
||||
'RVW_CLINICAL',
|
||||
{},
|
||||
{ userId }
|
||||
@@ -42,8 +43,11 @@ export async function reviewClinical(
|
||||
}
|
||||
|
||||
const messages = [
|
||||
{ role: 'system' as const, content: systemPrompt },
|
||||
{ role: 'user' as const, content: `请对以下医学稿件进行临床专业评估:\n\n${text}` },
|
||||
{ role: 'system' as const, content: composeRvwSystemPrompt('clinical', businessPrompt) },
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: `请对以下医学稿件进行临床专业评估。\n\n稿件内容如下:\n${text}`,
|
||||
},
|
||||
];
|
||||
|
||||
logger.info('[RVW:Clinical] 开始临床专业评估', { modelType });
|
||||
@@ -58,11 +62,22 @@ export async function reviewClinical(
|
||||
responseLength: content.length,
|
||||
});
|
||||
|
||||
// 提取摘要:取第一段非标题文本(最多200字)
|
||||
// 优先提取“总体评价”段落作为摘要,提取失败再兜底首段文本
|
||||
let summary = '临床专业评估已完成';
|
||||
const overallMatch = content.match(/(?:^|\n)\s*(?:#*\s*)?1[\.\、]\s*总体评价[\s\S]*?(?:\n(?:#*\s*)?2[\.\、]\s*详细问题清单与建议|$)/);
|
||||
if (overallMatch) {
|
||||
const extracted = overallMatch[0]
|
||||
.replace(/(?:^|\n)\s*(?:#*\s*)?1[\.\、]\s*总体评价\s*/m, '')
|
||||
.trim();
|
||||
if (extracted) {
|
||||
summary = extracted.substring(0, 200);
|
||||
}
|
||||
} else {
|
||||
const lines = content.split('\n').filter(l => l.trim() && !l.startsWith('#'));
|
||||
const summary = lines.length > 0
|
||||
? lines[0].trim().substring(0, 200)
|
||||
: '临床专业评估已完成';
|
||||
if (lines.length > 0) {
|
||||
summary = lines[0].trim().substring(0, 200);
|
||||
}
|
||||
}
|
||||
|
||||
return { report: content, summary };
|
||||
} catch (error) {
|
||||
|
||||
@@ -14,6 +14,46 @@ import { prisma } from '../../../config/database.js';
|
||||
import { getPromptService } from '../../../common/prompt/index.js';
|
||||
import { EditorialReview } from '../types/index.js';
|
||||
import { parseJSONFromLLMResponse } from './utils.js';
|
||||
import { composeRvwSystemPrompt, getRvwProtocol } from './promptProtocols.js';
|
||||
|
||||
function isValidEditorialReview(result: unknown): result is EditorialReview {
|
||||
if (!result || typeof result !== 'object') return false;
|
||||
const data = result as Record<string, unknown>;
|
||||
if (typeof data.overall_score !== 'number') return false;
|
||||
if (!Array.isArray(data.items)) return false;
|
||||
if (typeof data.summary !== 'string') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function repairEditorialToJson(
|
||||
rawContent: string,
|
||||
modelType: ModelType
|
||||
): Promise<EditorialReview> {
|
||||
logger.warn('[RVW:Editorial] 首次解析失败,尝试 LLM 结构化修复');
|
||||
const llmAdapter = LLMFactory.getAdapter(modelType);
|
||||
const repairMessages = [
|
||||
{
|
||||
role: 'system' as const,
|
||||
content: `你是 JSON 结构化助手。你的唯一任务是把输入文本转换成目标 JSON。\n\n${getRvwProtocol('editorial')}`,
|
||||
},
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: `请将以下“稿约规范性评估文本”重组为目标 JSON。\n\n${rawContent}`,
|
||||
},
|
||||
];
|
||||
|
||||
const repaired = await llmAdapter.chat(repairMessages, {
|
||||
temperature: 0.1,
|
||||
maxTokens: 4000,
|
||||
});
|
||||
|
||||
const repairedContent = repaired.content ?? '';
|
||||
const parsed = parseJSONFromLLMResponse<EditorialReview>(repairedContent);
|
||||
if (!isValidEditorialReview(parsed)) {
|
||||
throw new Error('稿约规范性评估结果结构化修复失败(JSON字段不完整)');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 稿约规范性评估
|
||||
@@ -30,7 +70,7 @@ export async function reviewEditorialStandards(
|
||||
try {
|
||||
// 1. 从 PromptService 获取系统Prompt(支持灰度预览)
|
||||
const promptService = getPromptService(prisma);
|
||||
const { content: systemPrompt, isDraft } = await promptService.get(
|
||||
const { content: businessPrompt, isDraft } = await promptService.get(
|
||||
'RVW_EDITORIAL',
|
||||
{},
|
||||
{ userId }
|
||||
@@ -42,8 +82,8 @@ export async function reviewEditorialStandards(
|
||||
|
||||
// 2. 构建消息
|
||||
const messages = [
|
||||
{ role: 'system' as const, content: systemPrompt },
|
||||
{ role: 'user' as const, content: `请对以下稿件进行稿约规范性评估:\n\n${text}` },
|
||||
{ role: 'system' as const, content: composeRvwSystemPrompt('editorial', businessPrompt) },
|
||||
{ role: 'user' as const, content: `请对以下稿件进行稿约规范性评估。\n\n稿件内容如下:\n${text}` },
|
||||
];
|
||||
|
||||
// 3. 调用LLM
|
||||
@@ -59,15 +99,19 @@ export async function reviewEditorialStandards(
|
||||
responseLength: editContent.length
|
||||
});
|
||||
|
||||
// 4. 解析JSON响应
|
||||
// 4. 解析 JSON(失败则自动进入结构化修复)
|
||||
try {
|
||||
const result = parseJSONFromLLMResponse<EditorialReview>(editContent);
|
||||
|
||||
// 5. 验证响应格式
|
||||
if (!result || typeof result.overall_score !== 'number' || !Array.isArray(result.items)) {
|
||||
if (!isValidEditorialReview(result)) {
|
||||
throw new Error('LLM返回的数据格式不正确');
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (parseError) {
|
||||
logger.warn('[RVW:Editorial] 原始响应解析失败,进入修复流程', {
|
||||
reason: parseError instanceof Error ? parseError.message : 'Unknown parse error',
|
||||
});
|
||||
return await repairEditorialToJson(editContent, modelType);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[RVW:Editorial] 稿约规范性评估失败', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
|
||||
@@ -10,10 +10,415 @@
|
||||
import { LLMFactory } from '../../../common/llm/adapters/LLMFactory.js';
|
||||
import { ModelType } from '../../../common/llm/adapters/types.js';
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
import { createHash } from 'crypto';
|
||||
import { prisma } from '../../../config/database.js';
|
||||
import { getPromptService } from '../../../common/prompt/index.js';
|
||||
import { MethodologyReview } from '../types/index.js';
|
||||
import { MethodologyCheckpoint, MethodologyIssue, MethodologyPart, MethodologyReview } from '../types/index.js';
|
||||
import { parseJSONFromLLMResponse } from './utils.js';
|
||||
import { composeRvwSystemPrompt, getRvwProtocol } from './promptProtocols.js';
|
||||
|
||||
const METHODOLOGY_CONCLUSIONS = ['直接接收', '小修', '大修', '拒稿'] as const;
|
||||
type MethodologyConclusion = typeof METHODOLOGY_CONCLUSIONS[number];
|
||||
const METHODOLOGY_CHECKPOINT_ITEMS = [
|
||||
'设计类型界定',
|
||||
'纳入/排除标准',
|
||||
'样本代表性',
|
||||
'对照组设置',
|
||||
'干预与观察细节',
|
||||
'效应指标选择',
|
||||
'设计要素完整性',
|
||||
'样本量估算',
|
||||
'质控与伦理',
|
||||
'基础参数明示',
|
||||
'分布特征',
|
||||
'多因素调整',
|
||||
'缺失值处理',
|
||||
'一致性检查',
|
||||
'前提条件检验',
|
||||
'多重比较校正',
|
||||
'统计量规范性',
|
||||
'效应量表达',
|
||||
'逻辑一致性',
|
||||
'图表准确性',
|
||||
] as const;
|
||||
const METHODOLOGY_CHECKPOINT_STATUSES = ['pass', 'minor_issue', 'major_issue', 'not_mentioned'] as const;
|
||||
type MethodologyCheckpointStatus = typeof METHODOLOGY_CHECKPOINT_STATUSES[number];
|
||||
type SectionKey = 'A' | 'B' | 'C';
|
||||
|
||||
interface MethodologySectionDef {
|
||||
key: SectionKey;
|
||||
part: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface SectionReviewResult {
|
||||
part: string;
|
||||
score: number;
|
||||
issues: MethodologyIssue[];
|
||||
checkpoints: MethodologyCheckpoint[];
|
||||
}
|
||||
|
||||
const METHODOLOGY_SECTION_DEFS: MethodologySectionDef[] = [
|
||||
{ key: 'A', part: '科研设计评估', start: 1, end: 9 },
|
||||
{ key: 'B', part: '统计学方法描述评估', start: 10, end: 14 },
|
||||
{ key: 'C', part: '统计分析与结果评估', start: 15, end: 20 },
|
||||
];
|
||||
|
||||
function inferConclusionFromScore(score: number): MethodologyConclusion {
|
||||
if (score >= 90) return '直接接收';
|
||||
if (score >= 75) return '小修';
|
||||
if (score >= 60) return '大修';
|
||||
return '拒稿';
|
||||
}
|
||||
|
||||
function getCheckpointItemsBySection(section: MethodologySectionDef): Array<{ id: number; item: string }> {
|
||||
const result: Array<{ id: number; item: string }> = [];
|
||||
for (let id = section.start; id <= section.end; id += 1) {
|
||||
result.push({ id, item: METHODOLOGY_CHECKPOINT_ITEMS[id - 1] });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildSectionProtocol(section: MethodologySectionDef): string {
|
||||
const checkpointLines = getCheckpointItemsBySection(section)
|
||||
.map(cp => `${cp.id}. ${cp.item}`)
|
||||
.join('\n');
|
||||
|
||||
return `【系统输出协议(分治子任务-${section.key},研发固化)】
|
||||
请严格仅输出 JSON(不要 Markdown、不要代码块、不要解释文字),结构如下:
|
||||
{
|
||||
"part": "${section.part}",
|
||||
"score": 0,
|
||||
"issues": [
|
||||
{
|
||||
"type": "问题类型",
|
||||
"severity": "major",
|
||||
"description": "问题描述",
|
||||
"location": "位置(如:方法学第2段)",
|
||||
"suggestion": "可执行修改建议"
|
||||
}
|
||||
],
|
||||
"checkpoints": [
|
||||
{
|
||||
"id": ${section.start},
|
||||
"item": "${METHODOLOGY_CHECKPOINT_ITEMS[section.start - 1]}",
|
||||
"status": "major_issue",
|
||||
"finding": "该检查点发现",
|
||||
"suggestion": "可执行建议"
|
||||
}
|
||||
]
|
||||
}
|
||||
约束:
|
||||
1) 仅评估本子任务范围:id ${section.start}-${section.end}
|
||||
2) checkpoints 必须严格覆盖本范围全部 id(不可缺失、不可越界)
|
||||
3) checkpoints[].status 只能是 "pass" | "minor_issue" | "major_issue" | "not_mentioned"
|
||||
4) score 必须是 0-100 数字
|
||||
5) issues 为该分项问题清单,无问题时返回 []
|
||||
本子任务检查点如下:
|
||||
${checkpointLines}`;
|
||||
}
|
||||
|
||||
function isValidSectionReview(result: unknown, section: MethodologySectionDef): result is SectionReviewResult {
|
||||
if (!result || typeof result !== 'object') return false;
|
||||
const data = result as Record<string, unknown>;
|
||||
if (!Array.isArray(data.issues)) return false;
|
||||
if (!Array.isArray(data.checkpoints)) return false;
|
||||
if (typeof data.score !== 'number') return false;
|
||||
if (typeof data.part !== 'string') return false;
|
||||
const checkpoints = data.checkpoints as unknown[];
|
||||
const ids = checkpoints
|
||||
.map(cp => (cp && typeof cp === 'object' ? Number((cp as Record<string, unknown>).id) : NaN))
|
||||
.filter(id => Number.isInteger(id));
|
||||
const expected = new Set(Array.from({ length: section.end - section.start + 1 }, (_, i) => section.start + i));
|
||||
return ids.some(id => expected.has(id));
|
||||
}
|
||||
|
||||
function normalizeMethodologyIssues(input: unknown): MethodologyIssue[] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
return input
|
||||
.filter(row => row && typeof row === 'object')
|
||||
.map((row) => {
|
||||
const issue = row as Record<string, unknown>;
|
||||
return {
|
||||
type: typeof issue.type === 'string' && issue.type.trim() ? issue.type.trim() : '未分类问题',
|
||||
severity: issue.severity === 'major' ? 'major' : 'minor',
|
||||
description: typeof issue.description === 'string' && issue.description.trim() ? issue.description.trim() : '未提供详细描述',
|
||||
location: typeof issue.location === 'string' && issue.location.trim() ? issue.location.trim() : '未标注',
|
||||
suggestion: typeof issue.suggestion === 'string' && issue.suggestion.trim() ? issue.suggestion.trim() : '请补充可执行修改建议',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeSectionReview(raw: SectionReviewResult, section: MethodologySectionDef): SectionReviewResult {
|
||||
const issues = normalizeMethodologyIssues(raw.issues);
|
||||
const score = Math.max(0, Math.min(100, Math.round(raw.score)));
|
||||
const checkpointMap = new Map<number, MethodologyCheckpoint>();
|
||||
if (Array.isArray(raw.checkpoints)) {
|
||||
for (const cp of raw.checkpoints) {
|
||||
const id = Number(cp.id);
|
||||
if (!Number.isInteger(id) || id < section.start || id > section.end) continue;
|
||||
const status = typeof cp.status === 'string' && METHODOLOGY_CHECKPOINT_STATUSES.includes(cp.status as MethodologyCheckpointStatus)
|
||||
? cp.status as MethodologyCheckpointStatus
|
||||
: 'not_mentioned';
|
||||
checkpointMap.set(id, {
|
||||
id,
|
||||
item: typeof cp.item === 'string' && cp.item.trim() ? cp.item.trim() : METHODOLOGY_CHECKPOINT_ITEMS[id - 1],
|
||||
status,
|
||||
finding: typeof cp.finding === 'string' && cp.finding.trim() ? cp.finding.trim() : '该检查点未被充分展开',
|
||||
suggestion: typeof cp.suggestion === 'string' && cp.suggestion.trim() ? cp.suggestion.trim() : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const checkpoints: MethodologyCheckpoint[] = [];
|
||||
for (let id = section.start; id <= section.end; id += 1) {
|
||||
checkpoints.push(
|
||||
checkpointMap.get(id) ?? {
|
||||
id,
|
||||
item: METHODOLOGY_CHECKPOINT_ITEMS[id - 1],
|
||||
status: 'not_mentioned',
|
||||
finding: '该检查点未被模型明确覆盖,请人工复核。',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
part: section.part,
|
||||
score,
|
||||
issues,
|
||||
checkpoints,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSectionFallback(section: MethodologySectionDef, reason: string): SectionReviewResult {
|
||||
const checkpoints: MethodologyCheckpoint[] = [];
|
||||
for (let id = section.start; id <= section.end; id += 1) {
|
||||
checkpoints.push({
|
||||
id,
|
||||
item: METHODOLOGY_CHECKPOINT_ITEMS[id - 1],
|
||||
status: 'not_mentioned',
|
||||
finding: `分段评估失败:${reason}`,
|
||||
suggestion: '建议重试或人工复核该检查点。',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
part: section.part,
|
||||
score: 60,
|
||||
issues: [{
|
||||
type: '执行降级',
|
||||
severity: 'minor',
|
||||
description: `该分段评估未正常完成(${reason})`,
|
||||
location: '系统执行层',
|
||||
suggestion: '建议重试方法学评估任务或查看后端日志。',
|
||||
}],
|
||||
checkpoints,
|
||||
};
|
||||
}
|
||||
|
||||
function isValidMethodologyReview(result: unknown): result is MethodologyReview {
|
||||
if (!result || typeof result !== 'object') return false;
|
||||
const data = result as Record<string, unknown>;
|
||||
if (typeof data.overall_score !== 'number') return false;
|
||||
if (!Array.isArray(data.parts)) return false;
|
||||
if (typeof data.summary !== 'string') return false;
|
||||
if (data.conclusion != null && typeof data.conclusion !== 'string') return false;
|
||||
if (data.checkpoints != null && !Array.isArray(data.checkpoints)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizeMethodologyCheckpoints(input: unknown): MethodologyCheckpoint[] {
|
||||
const normalizedMap = new Map<number, MethodologyCheckpoint>();
|
||||
if (Array.isArray(input)) {
|
||||
for (const cp of input) {
|
||||
if (!cp || typeof cp !== 'object') continue;
|
||||
const row = cp as Record<string, unknown>;
|
||||
const id = typeof row.id === 'number' ? row.id : Number(row.id);
|
||||
if (!Number.isInteger(id) || id < 1 || id > 20) continue;
|
||||
const status = typeof row.status === 'string' && METHODOLOGY_CHECKPOINT_STATUSES.includes(row.status as MethodologyCheckpointStatus)
|
||||
? row.status as MethodologyCheckpointStatus
|
||||
: 'not_mentioned';
|
||||
const item = typeof row.item === 'string' && row.item.trim()
|
||||
? row.item.trim()
|
||||
: METHODOLOGY_CHECKPOINT_ITEMS[id - 1];
|
||||
const finding = typeof row.finding === 'string' && row.finding.trim()
|
||||
? row.finding.trim()
|
||||
: '该检查点未被充分展开';
|
||||
const suggestion = typeof row.suggestion === 'string' && row.suggestion.trim()
|
||||
? row.suggestion.trim()
|
||||
: undefined;
|
||||
normalizedMap.set(id, { id, item, status, finding, suggestion });
|
||||
}
|
||||
}
|
||||
|
||||
return METHODOLOGY_CHECKPOINT_ITEMS.map((item, idx) => {
|
||||
const id = idx + 1;
|
||||
return normalizedMap.get(id) ?? {
|
||||
id,
|
||||
item,
|
||||
status: 'not_mentioned',
|
||||
finding: '该检查点未被模型明确覆盖,请人工复核。',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeMethodologyReview(result: MethodologyReview): MethodologyReview {
|
||||
const conclusion = (result.conclusion && METHODOLOGY_CONCLUSIONS.includes(result.conclusion as MethodologyConclusion))
|
||||
? result.conclusion
|
||||
: undefined;
|
||||
const checkpoints = normalizeMethodologyCheckpoints(result.checkpoints);
|
||||
const missingCount = checkpoints.filter(cp => cp.status === 'not_mentioned').length;
|
||||
if (missingCount > 0) {
|
||||
logger.warn('[RVW:Methodology] 20项检查点覆盖不完整', { missingCount });
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
conclusion,
|
||||
checkpoints,
|
||||
};
|
||||
}
|
||||
|
||||
function aggregateMethodologySections(sections: SectionReviewResult[]): MethodologyReview {
|
||||
const parts: MethodologyPart[] = sections.map(section => ({
|
||||
part: section.part,
|
||||
score: section.score,
|
||||
issues: section.issues,
|
||||
}));
|
||||
const checkpoints = normalizeMethodologyCheckpoints(sections.flatMap(section => section.checkpoints));
|
||||
const validScores = parts.map(part => part.score).filter(score => Number.isFinite(score));
|
||||
const overall_score = validScores.length > 0
|
||||
? Math.round(validScores.reduce((sum, score) => sum + score, 0) / validScores.length)
|
||||
: 60;
|
||||
|
||||
const majorCount = checkpoints.filter(cp => cp.status === 'major_issue').length;
|
||||
const minorCount = checkpoints.filter(cp => cp.status === 'minor_issue').length;
|
||||
const uncoveredCount = checkpoints.filter(cp => cp.status === 'not_mentioned').length;
|
||||
const topFindings = checkpoints
|
||||
.filter(cp => cp.status === 'major_issue' || cp.status === 'minor_issue')
|
||||
.slice(0, 3)
|
||||
.map(cp => `${cp.id}.${cp.item}`)
|
||||
.join(';');
|
||||
|
||||
const summary = majorCount + minorCount === 0
|
||||
? '方法学20项检查点未发现明确缺陷,整体统计学规范性较好。'
|
||||
: `方法学评估发现 ${majorCount} 个严重问题、${minorCount} 个一般问题${topFindings ? `,重点涉及:${topFindings}` : ''}。`;
|
||||
|
||||
let conclusion: MethodologyConclusion = inferConclusionFromScore(overall_score);
|
||||
if (majorCount >= 8) conclusion = '拒稿';
|
||||
else if (majorCount >= 4 || uncoveredCount >= 4) conclusion = '大修';
|
||||
else if (majorCount >= 1 || minorCount >= 3 || uncoveredCount > 0) conclusion = '小修';
|
||||
|
||||
return normalizeMethodologyReview({
|
||||
overall_score,
|
||||
summary,
|
||||
conclusion,
|
||||
checkpoints,
|
||||
parts,
|
||||
});
|
||||
}
|
||||
|
||||
async function reviewMethodologySection(
|
||||
llmAdapter: ReturnType<typeof LLMFactory.getAdapter>,
|
||||
businessPrompt: string,
|
||||
text: string,
|
||||
section: MethodologySectionDef
|
||||
): Promise<SectionReviewResult> {
|
||||
const messages = [
|
||||
{ role: 'system' as const, content: `${businessPrompt}\n\n${buildSectionProtocol(section)}` },
|
||||
{ role: 'user' as const, content: `请仅评估“${section.part}”(检查点 ${section.start}-${section.end}),并按协议返回 JSON。\n\n稿件内容如下:\n${text}` },
|
||||
];
|
||||
const response = await llmAdapter.chat(messages, {
|
||||
temperature: 0.2,
|
||||
maxTokens: 2800,
|
||||
});
|
||||
const content = response.content ?? '';
|
||||
try {
|
||||
const parsed = parseJSONFromLLMResponse<SectionReviewResult>(content);
|
||||
if (!isValidSectionReview(parsed, section)) {
|
||||
throw new Error('section json invalid');
|
||||
}
|
||||
return normalizeSectionReview(parsed, section);
|
||||
} catch {
|
||||
const repairMessages = [
|
||||
{
|
||||
role: 'system' as const,
|
||||
content: `你是 JSON 结构化助手。把输入文本转成目标 JSON。\n\n${buildSectionProtocol(section)}`,
|
||||
},
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: `请将以下方法学评估文本重组为目标 JSON(仅检查点 ${section.start}-${section.end}):\n\n${content}`,
|
||||
},
|
||||
];
|
||||
const repaired = await llmAdapter.chat(repairMessages, {
|
||||
temperature: 0.1,
|
||||
maxTokens: 1800,
|
||||
});
|
||||
const repairedContent = repaired.content ?? '';
|
||||
const repairedParsed = parseJSONFromLLMResponse<SectionReviewResult>(repairedContent);
|
||||
if (!isValidSectionReview(repairedParsed, section)) {
|
||||
throw new Error('section repair invalid');
|
||||
}
|
||||
return normalizeSectionReview(repairedParsed, section);
|
||||
}
|
||||
}
|
||||
|
||||
async function reviewMethodologyLegacy(
|
||||
businessPrompt: string,
|
||||
text: string,
|
||||
modelType: ModelType
|
||||
): Promise<MethodologyReview> {
|
||||
const llmAdapter = LLMFactory.getAdapter(modelType);
|
||||
const messages = [
|
||||
{ role: 'system' as const, content: composeRvwSystemPrompt('methodology', businessPrompt) },
|
||||
{ role: 'user' as const, content: `请对以下稿件进行方法学评估。\n\n稿件内容如下:\n${text}` },
|
||||
];
|
||||
const response = await llmAdapter.chat(messages, {
|
||||
temperature: 0.3,
|
||||
maxTokens: 5000,
|
||||
});
|
||||
const methContent = response.content ?? '';
|
||||
try {
|
||||
const result = parseJSONFromLLMResponse<MethodologyReview>(methContent);
|
||||
if (!isValidMethodologyReview(result)) throw new Error('invalid json');
|
||||
return normalizeMethodologyReview(result);
|
||||
} catch {
|
||||
return repairMethodologyToJson(methContent, modelType);
|
||||
}
|
||||
}
|
||||
|
||||
async function repairMethodologyToJson(
|
||||
rawContent: string,
|
||||
modelType: ModelType
|
||||
): Promise<MethodologyReview> {
|
||||
logger.warn('[RVW:Methodology] 首次解析失败,尝试 LLM 结构化修复');
|
||||
|
||||
const llmAdapter = LLMFactory.getAdapter(modelType);
|
||||
const repairMessages = [
|
||||
{
|
||||
role: 'system' as const,
|
||||
content: `你是 JSON 结构化助手。你的唯一任务是把输入文本转换成目标 JSON。\n\n${getRvwProtocol('methodology')}`,
|
||||
},
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: `请将以下“方法学评估文本”重组为目标 JSON。\n\n${rawContent}`,
|
||||
},
|
||||
];
|
||||
|
||||
const repaired = await llmAdapter.chat(repairMessages, {
|
||||
temperature: 0.1,
|
||||
maxTokens: 4000,
|
||||
});
|
||||
|
||||
const repairedContent = repaired.content ?? '';
|
||||
const parsed = parseJSONFromLLMResponse<MethodologyReview>(repairedContent);
|
||||
|
||||
if (!isValidMethodologyReview(parsed)) {
|
||||
throw new Error('方法学评估结果结构化修复失败(JSON字段不完整)');
|
||||
}
|
||||
|
||||
return normalizeMethodologyReview(parsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法学评估
|
||||
@@ -30,44 +435,62 @@ export async function reviewMethodology(
|
||||
try {
|
||||
// 1. 从 PromptService 获取系统Prompt(支持灰度预览)
|
||||
const promptService = getPromptService(prisma);
|
||||
const { content: systemPrompt, isDraft } = await promptService.get(
|
||||
const { content: businessPrompt, isDraft, version } = await promptService.get(
|
||||
'RVW_METHODOLOGY',
|
||||
{},
|
||||
{ userId }
|
||||
);
|
||||
const promptFingerprint = createHash('sha1').update(businessPrompt).digest('hex').slice(0, 12);
|
||||
|
||||
if (isDraft) {
|
||||
logger.info('[RVW:Methodology] 使用 DRAFT 版本 Prompt(调试模式)', { userId });
|
||||
}
|
||||
logger.info('[RVW:Methodology] Prompt 已加载', {
|
||||
userId,
|
||||
isDraft,
|
||||
version,
|
||||
promptFingerprint,
|
||||
});
|
||||
|
||||
// 2. 构建消息
|
||||
const messages = [
|
||||
{ role: 'system' as const, content: systemPrompt },
|
||||
{ role: 'user' as const, content: `请对以下稿件进行方法学评估:\n\n${text}` },
|
||||
];
|
||||
|
||||
// 3. 调用LLM
|
||||
logger.info('[RVW:Methodology] 开始方法学评估', { modelType });
|
||||
const llmAdapter = LLMFactory.getAdapter(modelType);
|
||||
const response = await llmAdapter.chat(messages, {
|
||||
temperature: 0.3,
|
||||
maxTokens: 8000,
|
||||
});
|
||||
const methContent = response.content ?? '';
|
||||
logger.info('[RVW:Methodology] 评估完成', {
|
||||
logger.info('[RVW:Methodology] 开始分治并行评估', {
|
||||
modelType,
|
||||
responseLength: methContent.length
|
||||
sections: METHODOLOGY_SECTION_DEFS.map(section => `${section.part}(${section.start}-${section.end})`),
|
||||
});
|
||||
|
||||
// 4. 解析JSON响应
|
||||
const result = parseJSONFromLLMResponse<MethodologyReview>(methContent);
|
||||
|
||||
// 5. 验证响应格式
|
||||
if (!result || typeof result.overall_score !== 'number' || !Array.isArray(result.parts)) {
|
||||
throw new Error('LLM返回的数据格式不正确');
|
||||
const settled = await Promise.allSettled(
|
||||
METHODOLOGY_SECTION_DEFS.map(section =>
|
||||
reviewMethodologySection(llmAdapter, businessPrompt, text, section)
|
||||
)
|
||||
);
|
||||
const sectionResults: SectionReviewResult[] = [];
|
||||
let fulfilledCount = 0;
|
||||
for (let i = 0; i < settled.length; i += 1) {
|
||||
const outcome = settled[i];
|
||||
const section = METHODOLOGY_SECTION_DEFS[i];
|
||||
if (outcome.status === 'fulfilled') {
|
||||
fulfilledCount += 1;
|
||||
sectionResults.push(outcome.value);
|
||||
} else {
|
||||
const reason = outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);
|
||||
logger.warn('[RVW:Methodology] 分段评估失败,使用降级结果', {
|
||||
section: section.part,
|
||||
reason,
|
||||
});
|
||||
sectionResults.push(buildSectionFallback(section, reason));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
if (fulfilledCount === 0) {
|
||||
logger.warn('[RVW:Methodology] 分治并行全部失败,回退 legacy 模式');
|
||||
return await reviewMethodologyLegacy(businessPrompt, text, modelType);
|
||||
}
|
||||
|
||||
const merged = aggregateMethodologySections(sectionResults);
|
||||
logger.info('[RVW:Methodology] 分治评估完成', {
|
||||
fulfilledSections: fulfilledCount,
|
||||
overallScore: merged.overall_score,
|
||||
conclusion: merged.conclusion,
|
||||
missingCheckpoints: merged.checkpoints?.filter(cp => cp.status === 'not_mentioned').length ?? 0,
|
||||
});
|
||||
return merged;
|
||||
} catch (error) {
|
||||
logger.error('[RVW:Methodology] 方法学评估失败', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
|
||||
109
backend/src/modules/rvw/services/promptProtocols.ts
Normal file
109
backend/src/modules/rvw/services/promptProtocols.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* RVW 提示词协议固化层(动静分离)
|
||||
*
|
||||
* 设计原则:
|
||||
* - 动态部分(业务标准):来自 PromptService(运营管理端可编辑)
|
||||
* - 静态部分(输出协议):由研发在代码中固化,运营端不可编辑
|
||||
*/
|
||||
|
||||
export type RvwPromptChannel = 'editorial' | 'methodology' | 'clinical' | 'data_validation';
|
||||
|
||||
const RVW_PROTOCOLS: Record<RvwPromptChannel, string> = {
|
||||
editorial: `【系统输出协议(研发固化,不可更改)】
|
||||
请严格仅输出 JSON(不要 Markdown、不要代码块、不要解释文字),结构如下:
|
||||
{
|
||||
"overall_score": 0,
|
||||
"summary": "总体评价",
|
||||
"items": [
|
||||
{
|
||||
"criterion": "检查项名称",
|
||||
"status": "pass",
|
||||
"score": 0,
|
||||
"issues": ["问题1"],
|
||||
"suggestions": ["建议1"]
|
||||
}
|
||||
]
|
||||
}
|
||||
约束:
|
||||
1) overall_score、items[].score 必须是 0-100 数字
|
||||
2) status 只能是 "pass" | "warning" | "fail"
|
||||
3) issues/suggestions 必须是数组,无内容时返回 []
|
||||
4) items 至少包含 1 项`,
|
||||
|
||||
methodology: `【系统输出协议(研发固化,不可更改)】
|
||||
请严格仅输出 JSON(不要 Markdown、不要代码块、不要解释文字),结构如下:
|
||||
{
|
||||
"overall_score": 0,
|
||||
"summary": "总体评价",
|
||||
"conclusion": "小修",
|
||||
"checkpoints": [
|
||||
{
|
||||
"id": 1,
|
||||
"item": "设计类型界定",
|
||||
"status": "major_issue",
|
||||
"finding": "该检查点的发现",
|
||||
"suggestion": "可执行建议"
|
||||
}
|
||||
],
|
||||
"parts": [
|
||||
{
|
||||
"part": "科研设计评估",
|
||||
"score": 0,
|
||||
"issues": [
|
||||
{
|
||||
"type": "问题类型",
|
||||
"severity": "major",
|
||||
"description": "问题描述",
|
||||
"location": "位置(如:方法学第2段)",
|
||||
"suggestion": "可执行修改建议"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
约束:
|
||||
1) overall_score、parts[].score 必须是 0-100 数字
|
||||
2) severity 只能是 "major" 或 "minor"
|
||||
3) 若某 part 无问题,issues 返回 []
|
||||
4) conclusion 必须是以下之一:"直接接收" | "小修" | "大修" | "拒稿"
|
||||
5) 必须包含 "科研设计评估"、"统计学方法描述评估"、"统计分析与结果评估" 三个 part
|
||||
6) checkpoints 必须严格包含 20 项(id = 1..20 且不重复,不可缺失)
|
||||
7) checkpoints[].status 只能是 "pass" | "minor_issue" | "major_issue" | "not_mentioned"
|
||||
8) 若某检查点未发现问题,finding 也要给出简短判断依据,不可空`,
|
||||
|
||||
clinical: `【系统输出协议(研发固化,不可更改)】
|
||||
请严格按以下结构输出(可用 Markdown):
|
||||
1. 总体评价
|
||||
2. 详细问题清单与建议(按“严重问题”“一般问题”分组)
|
||||
3. 审稿结论(仅限:直接接收 / 小修 / 大修 / 拒稿)
|
||||
要求:
|
||||
- 用中文
|
||||
- 不要输出与上述结构无关的前后缀话术`,
|
||||
|
||||
data_validation: `【系统输出协议(研发固化,不可更改)】
|
||||
请逐表输出,每个表必须以“## 表N: <表格标题>”开头(N 从 1 开始连续编号)。
|
||||
每个表按以下结构输出:
|
||||
1. 核心发现与问题清单
|
||||
- 计算错误
|
||||
- 方法误用
|
||||
- 逻辑矛盾
|
||||
- 规范性建议
|
||||
2. 最终核查结论
|
||||
- [ ] 通过
|
||||
- [ ] 条件通过
|
||||
- [ ] 未通过
|
||||
注意:除上述结构外,不要输出无关前后缀。`,
|
||||
};
|
||||
|
||||
/**
|
||||
* 组装 RVW 最终系统提示词(动静分离)
|
||||
*/
|
||||
export function composeRvwSystemPrompt(channel: RvwPromptChannel, businessPrompt: string): string {
|
||||
const protocol = RVW_PROTOCOLS[channel];
|
||||
return `${businessPrompt}\n\n${protocol}`;
|
||||
}
|
||||
|
||||
export function getRvwProtocol(channel: RvwPromptChannel): string {
|
||||
return RVW_PROTOCOLS[channel];
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export const DEFAULT_PROFILE: JournalProfile = {
|
||||
skillId: 'MethodologySkill',
|
||||
enabled: true,
|
||||
optional: false,
|
||||
timeout: 300000, // 5min: 方法学分析耗时最长
|
||||
timeout: 480000, // 8min: 允许主调用+修复调用串行执行,避免误判超时
|
||||
parallelGroup: 'llm-review',
|
||||
},
|
||||
{
|
||||
@@ -96,7 +96,7 @@ export const CHINESE_CORE_PROFILE: JournalProfile = {
|
||||
skillId: 'MethodologySkill',
|
||||
enabled: true,
|
||||
optional: false,
|
||||
timeout: 300000,
|
||||
timeout: 480000,
|
||||
parallelGroup: 'llm-review',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -185,7 +185,7 @@ export interface SkillConfig {
|
||||
* DataForensicsSkill 配置 Schema
|
||||
*/
|
||||
export const DataForensicsConfigSchema = z.object({
|
||||
checkLevel: z.enum(['L1', 'L1_L2', 'L1_L2_L25']).default('L1_L2_L25'),
|
||||
checkLevel: z.enum(['EXTRACT_ONLY', 'L1', 'L1_L2', 'L1_L2_L25']).default('EXTRACT_ONLY'),
|
||||
tolerancePercent: z.number().min(0).max(1).default(0.1),
|
||||
});
|
||||
export type DataForensicsConfig = z.infer<typeof DataForensicsConfigSchema>;
|
||||
|
||||
@@ -31,6 +31,10 @@ import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { composeRvwSystemPrompt } from '../../services/promptProtocols.js';
|
||||
|
||||
// 默认关闭规则验证,仅做“表格提取 + LLM 判断”
|
||||
const RULE_BASED_VALIDATION_ENABLED = process.env.RVW_FORENSICS_RULES_ENABLED === 'true';
|
||||
|
||||
/**
|
||||
* 数据侦探 Skill
|
||||
@@ -114,15 +118,18 @@ export class DataForensicsSkill extends BaseSkill<SkillContext, DataForensicsCon
|
||||
context: SkillContext,
|
||||
config?: DataForensicsConfig
|
||||
): Promise<ExecuteResult> {
|
||||
const checkLevel = config?.checkLevel || 'L1_L2_L25';
|
||||
const requestedCheckLevel = config?.checkLevel || 'EXTRACT_ONLY';
|
||||
const effectiveCheckLevel = RULE_BASED_VALIDATION_ENABLED ? requestedCheckLevel : 'EXTRACT_ONLY';
|
||||
const tolerancePercent = config?.tolerancePercent || 0.1;
|
||||
const storageKey = context.documentPath!;
|
||||
|
||||
logger.info('[DataForensicsSkill] Starting analysis', {
|
||||
taskId: context.taskId,
|
||||
storageKey,
|
||||
checkLevel,
|
||||
requestedCheckLevel,
|
||||
effectiveCheckLevel,
|
||||
tolerancePercent,
|
||||
rulesEnabled: RULE_BASED_VALIDATION_ENABLED,
|
||||
});
|
||||
|
||||
// 创建临时文件路径
|
||||
@@ -148,44 +155,54 @@ export class DataForensicsSkill extends BaseSkill<SkillContext, DataForensicsCon
|
||||
|
||||
// 2. 调用 Python 服务分析临时文件
|
||||
const result = await this.extractionClient.analyzeDocx(tempFilePath, {
|
||||
checkLevel,
|
||||
checkLevel: effectiveCheckLevel,
|
||||
tolerancePercent,
|
||||
});
|
||||
|
||||
// 转换为内部格式
|
||||
const forensicsResult = this.convertResult(result);
|
||||
|
||||
// 规则模式关闭时:清空规则问题,仅保留表格提取结果 + 后续 LLM 核查结果
|
||||
if (!RULE_BASED_VALIDATION_ENABLED) {
|
||||
this.stripRuleBasedJudgement(forensicsResult);
|
||||
}
|
||||
|
||||
// 3. LLM 智能核查(批量发送所有表格)
|
||||
let llmValidationFailed = false;
|
||||
if (forensicsResult.tables.length > 0) {
|
||||
try {
|
||||
await this.runLlmValidation(forensicsResult, context);
|
||||
} catch (llmError) {
|
||||
llmValidationFailed = true;
|
||||
const errMsg = llmError instanceof Error ? llmError.message : String(llmError);
|
||||
const isTimeout = errMsg.includes('LLM_VALIDATION_TIMEOUT');
|
||||
logger.warn(`[DataForensicsSkill] LLM validation ${isTimeout ? 'timed out (180s)' : 'failed'}, falling back to rules only`, {
|
||||
logger.warn(`[DataForensicsSkill] LLM validation ${isTimeout ? 'timed out (180s)' : 'failed'}`, {
|
||||
taskId: context.taskId,
|
||||
error: errMsg,
|
||||
tableCount: forensicsResult.tables.length,
|
||||
rulesEnabled: RULE_BASED_VALIDATION_ENABLED,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 计算状态和评分(基于数据质量结论,非执行状态;发现问题不等于执行失败)
|
||||
// 状态与分数:
|
||||
// - 规则模式开启:沿用原有“规则问题计分”
|
||||
// - 规则模式关闭:仅按 LLM 核查是否成功决定 success/warning
|
||||
let status: 'success' | 'warning' | 'error' = 'success';
|
||||
let score = 100;
|
||||
|
||||
if (RULE_BASED_VALIDATION_ENABLED) {
|
||||
const hasErrors = forensicsResult.summary.errorCount > 0;
|
||||
const hasWarnings = forensicsResult.summary.warningCount > 0;
|
||||
|
||||
let status: 'success' | 'warning' | 'error';
|
||||
let score: number;
|
||||
|
||||
if (hasErrors) {
|
||||
status = 'warning';
|
||||
score = Math.max(0, 100 - forensicsResult.summary.errorCount * 20);
|
||||
} else if (hasWarnings) {
|
||||
status = 'warning';
|
||||
score = Math.max(60, 100 - forensicsResult.summary.warningCount * 5);
|
||||
} else {
|
||||
status = 'success';
|
||||
score = 100;
|
||||
}
|
||||
} else if (llmValidationFailed) {
|
||||
status = 'warning';
|
||||
}
|
||||
|
||||
logger.info('[DataForensicsSkill] Analysis completed', {
|
||||
@@ -278,7 +295,7 @@ export class DataForensicsSkill extends BaseSkill<SkillContext, DataForensicsCon
|
||||
});
|
||||
|
||||
const promptService = getPromptService(prisma);
|
||||
const { content: systemPrompt } = await promptService.get(
|
||||
const { content: businessPrompt } = await promptService.get(
|
||||
'RVW_DATA_VALIDATION',
|
||||
{},
|
||||
{ userId: context.userId }
|
||||
@@ -295,10 +312,10 @@ export class DataForensicsSkill extends BaseSkill<SkillContext, DataForensicsCon
|
||||
tableTexts.push(`## 表${i + 1}: ${caption}\n\n${dataText}`);
|
||||
}
|
||||
|
||||
const userMessage = `以下是从医学科研稿件中提取的 ${forensicsResult.tables.length} 张表格,请逐表核查:\n\n${tableTexts.join('\n\n---\n\n')}`;
|
||||
const userMessage = `以下是从医学科研稿件中提取的 ${forensicsResult.tables.length} 张表格,请逐表核查。\n\n${tableTexts.join('\n\n---\n\n')}`;
|
||||
|
||||
const messages = [
|
||||
{ role: 'system' as const, content: systemPrompt },
|
||||
{ role: 'system' as const, content: composeRvwSystemPrompt('data_validation', businessPrompt) },
|
||||
{ role: 'user' as const, content: userMessage },
|
||||
];
|
||||
|
||||
@@ -434,6 +451,21 @@ export class DataForensicsSkill extends BaseSkill<SkillContext, DataForensicsCon
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空规则验证产物(保留表格提取结果)
|
||||
* 用于“仅提取 + LLM 判断”模式
|
||||
*/
|
||||
private stripRuleBasedJudgement(result: ForensicsResult): void {
|
||||
result.issues = [];
|
||||
result.summary.totalIssues = 0;
|
||||
result.summary.errorCount = 0;
|
||||
result.summary.warningCount = 0;
|
||||
result.tables = result.tables.map((table) => ({
|
||||
...table,
|
||||
issues: [],
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
|
||||
@@ -44,7 +44,7 @@ export class MethodologySkill extends BaseSkill<SkillContext, MethodologyConfig>
|
||||
inputs: ['documentContent', 'methods'],
|
||||
outputs: ['methodologyResult'],
|
||||
|
||||
defaultTimeout: 300000, // 5min: 方法学分析最耗时,长文档可达 2-3 分钟
|
||||
defaultTimeout: 480000, // 8min: 主调用+结构化修复双阶段时预留足够超时窗口
|
||||
retryable: true,
|
||||
|
||||
icon: '🔬',
|
||||
|
||||
@@ -60,9 +60,19 @@ export interface MethodologyPart {
|
||||
issues: MethodologyIssue[];
|
||||
}
|
||||
|
||||
export interface MethodologyCheckpoint {
|
||||
id: number;
|
||||
item: string;
|
||||
status: 'pass' | 'minor_issue' | 'major_issue' | 'not_mentioned';
|
||||
finding: string;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
export interface MethodologyReview {
|
||||
overall_score: number;
|
||||
summary: string;
|
||||
conclusion?: '直接接收' | '小修' | '大修' | '拒稿';
|
||||
checkpoints?: MethodologyCheckpoint[];
|
||||
parts: MethodologyPart[];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# AIclinicalresearch 系统当前状态与开发指南
|
||||
|
||||
> **文档版本:** v6.9
|
||||
> **文档版本:** v7.0
|
||||
> **创建日期:** 2025-11-28
|
||||
> **维护者:** 开发团队
|
||||
> **最后更新:** 2026-03-09
|
||||
> **最后更新:** 2026-03-13
|
||||
> **🎉 重大里程碑:**
|
||||
> - **🆕 2026-03-13:RVW 方法学稳定性增强(V3.0.2)!** 方法学 20 检查点结构化增强 + A/B/C 分治并行评估 + 规则汇总器统一结论 + 前端展示口径收敛(按三大项分组展示检查点)
|
||||
> - **🆕 2026-03-09:认证模块接入阿里云短信验证码!** 登录验证码链路支持 `mock/aliyun` 双模式 + 后端短信服务封装 + 独立联调脚本(`npm run test:sms`)+ 实机发送验证通过
|
||||
> - **🆕 2026-03-08:SSA 智能统计分析 Agent 模式 MVP 完成!** Agent 核心 Prompt 接入运营管理端(PlannerAgent + CoderAgent 动态化 + 三级容灾)+ Phase 5A 防错护栏 + Prompt 全景盘点(Agent 仅用 2 个 Prompt,QPER 11 个已归档)
|
||||
> - **🆕 2026-03-07:SSA Agent 通道体验优化 + Plan-and-Execute 架构设计完成!** 方案 B 左右职责分离 + 10 项 Bug 修复(JWT 刷新/代码截断/重试流式/R Docker 结构化错误/进度同步/导出按钮)+ 分步执行架构评审通过(代码累加策略 + 5 项工程护栏)
|
||||
@@ -38,6 +39,7 @@
|
||||
> - **2026-01-22:OSS 存储集成完成!** 阿里云 OSS 正式接入平台基础层
|
||||
>
|
||||
> **🆕 最新进展(含认证短信集成 2026-03-09):**
|
||||
> - ✅ **🆕 RVW 方法学稳定性增强(V3.0.2)** — `checkpoints` 结构化输出(20项)+ 方法学 A/B/C 分治并行评估(1-9/10-14/15-20)+ 规则汇总器统一 `summary/conclusion` + 前端展示按三大项分组
|
||||
> - ✅ **🆕 认证短信验证码接入完成** — `sendVerificationCode` 接入阿里云短信网关(保留 `mock`)+ 发送成功后再落库验证码 + 环境变量校验 + 联调脚本 `test:sms` + 实机发送验证通过
|
||||
> - ✅ **🆕 SSA Agent 模式 MVP 完成** — Agent 核心 Prompt 接入运营管理端(`SSA_AGENT_PLANNER` + `SSA_AGENT_CODER` 动态化)+ 三级容灾(DB→缓存→fallback)+ 种子脚本幂等写入 + Prompt 全景盘点(Agent 2 个 / QPER 11 个归档)
|
||||
> - ✅ **🆕 SSA Agent 通道体验优化(12 文件, +931/-203 行)** — 方案 B 左右职责分离 + JWT 刷新 + 代码截断修复 + 重试流式生成 + R Docker 结构化错误(20+ 模式)+ Prompt 铁律 + 进度同步 + 导出按钮恢复 + ExecutingProgress 动态 UI
|
||||
@@ -96,7 +98,7 @@
|
||||
| **IIT** | IIT Manager Agent | CRA Agent - LLM Tool Use + 自驱动质控 + 统一驾驶舱 | ⭐⭐⭐⭐⭐ | 🎉 **V3.1完成 + GCP报表 + Bug修复!** 质控引擎升级 + 4张GCP业务报表 + AI时间线增强 + 一键全量质控 | **P1-2** |
|
||||
| **SSA** | 智能统计分析 | **Agent 模式(PlannerAgent + CoderAgent + R Docker)** + QPER 备用 | ⭐⭐⭐⭐⭐ | 🎉 **Agent 模式 MVP 完成** — Prompt 运营管理化 + Phase 5A 护栏 + 体验优化 + Plan-and-Execute 架构设计,仅用 2 个核心 Prompt | **P1** |
|
||||
| **ST** | 统计分析工具 | 126 个轻量化统计工具(旧系统 iframe 嵌入) | ⭐⭐⭐⭐ | ✅ **旧系统集成完成** — Token 注入 + Wrapper Bridge + E2E 验证通过 | P2 |
|
||||
| **RVW** | 稿件审查系统 | 方法学评估 + 🆕数据侦探(L1/L2/L2.5验证)+ Skills架构 + Word导出 | ⭐⭐⭐⭐ | 🚀 **V2.0 Week3完成(85%)** - 统计验证扩展+负号归一化+文件格式提示+用户体验优化 | P1 |
|
||||
| **RVW** | 稿件审查系统 | 方法学评估 + 🆕数据侦探(L1/L2/L2.5验证)+ Skills架构 + Word导出 | ⭐⭐⭐⭐ | 🚀 **V3.0.2 进行中(90%)** - 方法学分治并行+20检查点结构化+展示收敛 | P1 |
|
||||
| **ADMIN** | 运营管理端 | Prompt管理、租户管理、用户管理、运营监控、系统知识库 | ⭐⭐⭐⭐⭐ | 🎉 **Phase 4.6完成(88%)** - Prompt知识库集成+动态注入 | **P0** |
|
||||
|
||||
---
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# RVW稿件审查模块 - 当前状态与开发指南
|
||||
|
||||
> **文档版本:** v6.1
|
||||
> **文档版本:** v6.2
|
||||
> **创建日期:** 2026-01-07
|
||||
> **最后更新:** 2026-03-10
|
||||
> **最后更新:** 2026-03-13
|
||||
> **维护者:** 开发团队
|
||||
> **当前状态:** 🚀 **V3.0.1 "性能与体验增强" 完成(4模块并行 + 增量展示 + 导出补全)**
|
||||
> **当前状态:** 🚀 **V3.0.2 "方法学稳定性增强" 进行中(分治并行 + 20检查点覆盖 + 展示口径收敛)**
|
||||
> **文档目的:** 快速了解RVW模块状态,为新AI助手提供上下文
|
||||
>
|
||||
> **🎉 V3.0 进展(2026-03-07):**
|
||||
@@ -21,6 +21,13 @@
|
||||
> - ✅ **先出先看**:TaskDetail 在审查中即可展示已完成模块(无需等待全流程结束)
|
||||
> - ✅ **Word 导出修复**:补齐“数据验证”章节,导出汇总 + 表格明细 + 该表问题列表
|
||||
>
|
||||
> **🆕 V3.0.2 进展(2026-03-13):**
|
||||
> - ✅ **方法学 Prompt 动静分离收敛**:业务提示词继续走运营管理端,系统协议负责结构化输出
|
||||
> - ✅ **20检查点结构化增强**:方法学结果新增 `checkpoints`(id 1-20,状态与发现可追踪)
|
||||
> - ✅ **方法学分治并行评估(A/B/C)**:按 1-9 / 10-14 / 15-20 三段并行,降低整包超时概率
|
||||
> - ✅ **规则汇总器合并结果**:统一生成 `overall_score/summary/conclusion/checkpoints/parts`,并保留降级兜底
|
||||
> - ✅ **前端展示口径统一**:方法学报告按“三大项->检查点”展示,去除重复占位文案并显示真实LLM内容
|
||||
>
|
||||
> **V2.0 进展回顾:**
|
||||
> - ✅ L1 算术验证 + L2 统计验证 + L2.5 一致性取证
|
||||
> - ✅ Skills 核心框架(types, registry, executor, profile)
|
||||
@@ -40,7 +47,7 @@
|
||||
| **商业价值** | ⭐⭐⭐⭐⭐ 极高 |
|
||||
| **独立性** | ⭐⭐⭐⭐⭐ 极高(用户群完全不同) |
|
||||
| **目标用户** | 期刊初审编辑 |
|
||||
| **开发状态** | ✅ **V3.0.1 完成:4维审查并行提速 + 增量结果展示 + Word导出补全** |
|
||||
| **开发状态** | 🚀 **V3.0.2 进行中:方法学分治并行 + 20检查点覆盖展示 + 超时优化** |
|
||||
|
||||
### 核心目标
|
||||
|
||||
@@ -463,6 +470,17 @@ Content-Type: multipart/form-data
|
||||
| 前端先出先看 | ✅ 已完成 | 审查过程中实时展示已完成 Tab |
|
||||
| Word 导出补齐数据验证 | ✅ 已完成 | 导出包含数据验证汇总、表格明细、该表问题列表 |
|
||||
|
||||
### 🆕 V3.0.2 "方法学稳定性增强" 开发进度(2026-03-13)
|
||||
|
||||
| 任务 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| 方法学 `checkpoints` 扩展 | ✅ 已完成 | 结果结构支持 20 检查点逐项状态与发现 |
|
||||
| 方法学前端展示收敛 | ✅ 已完成 | 采用“三大项分组 + 检查点明细”,移除重复占位展示 |
|
||||
| 方法学分治并行评估 | ✅ 已完成 | A/B/C 三段并行执行(1-9/10-14/15-20) |
|
||||
| 方法学规则汇总器 | ✅ 已完成 | 合并分段结果并统一结论;分段失败可降级 |
|
||||
| 方法学超时窗口扩展 | ✅ 已完成 | MethodologySkill 超时从 5min 调整到 8min |
|
||||
| 快速模式开关(后续) | ⏳ 规划中 | 长文档自动降耗与更短输出预算 |
|
||||
|
||||
### 后续版本(V3.1+)
|
||||
|
||||
- [ ] 全面移除评分机制(只列问题,不打分)
|
||||
@@ -479,7 +497,7 @@ Content-Type: multipart/form-data
|
||||
|
||||
---
|
||||
|
||||
**文档版本:** v6.1
|
||||
**最后更新:** 2026-03-10
|
||||
**当前状态:** 🚀 V3.0.1 "性能与体验增强" 完成(4模块并行 + 增量展示 + 导出补全)
|
||||
**下一步:** V3.1 移除评分机制 + 单模块重试
|
||||
**文档版本:** v6.2
|
||||
**最后更新:** 2026-03-13
|
||||
**当前状态:** 🚀 V3.0.2 "方法学稳定性增强" 进行中(分治并行 + 20检查点覆盖 + 展示口径收敛)
|
||||
**下一步:** V3.0.2 收尾验证(超时率/覆盖率) + V3.1 单模块重试与评分策略优化
|
||||
|
||||
220
docs/03-业务模块/RVW-稿件审查系统/04-开发计划/RVW V4.0 智能审稿输出解耦开发计划.md
Normal file
220
docs/03-业务模块/RVW-稿件审查系统/04-开发计划/RVW V4.0 智能审稿输出解耦开发计划.md
Normal file
@@ -0,0 +1,220 @@
|
||||
# RVW V4.0 智能审稿输出解耦开发计划
|
||||
|
||||
> 文档版本:v1.1
|
||||
> 创建日期:2026-03-13
|
||||
> 维护者:RVW 模块开发组
|
||||
> 优先级:P0
|
||||
> 目标周期:2 阶段(P0: 1 周快速上线;P1: 2 周配置化增强)
|
||||
> 适用范围:方法学、稿约规范性、临床评估、数据验证四通道
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 当前痛点
|
||||
|
||||
1. 运营端可编辑 Prompt 与系统 JSON 协议同时约束输出,存在格式冲突。
|
||||
2. 专家希望掌控最终报告呈现(A/B/C、1/2/3/4 等),但当前展示仍受通道实现细节影响。
|
||||
3. JSON 解析稳定性与专家自然语言表达存在天然张力。
|
||||
4. 运营发布后难以快速验证“是否真的生效”(缺少统一版本指纹与可观测性)。
|
||||
|
||||
### 1.2 V4.0 核心目标
|
||||
|
||||
构建“数据与展示分离”的稳定架构,并采用“先快后全”的实施策略:
|
||||
|
||||
- LLM 负责结构化评估数据(系统可解析、可存储、可统计)。
|
||||
- P0 先使用代码内置默认模板渲染专家报告,快速验证核心链路。
|
||||
- P1 再开放运营端模板配置(Handlebars),实现风格动态化。
|
||||
- 系统协议只负责结构约束,不覆盖业务判断。
|
||||
- 支持版本化、回滚、可观测,避免“看起来没生效”。
|
||||
|
||||
---
|
||||
|
||||
## 2. 方案边界与设计原则
|
||||
|
||||
### 2.1 In Scope(本期纳入)
|
||||
|
||||
- 四通道统一双轨输出:`structured_review` + `rendered_report_text`。
|
||||
- P0:后端新增“硬编码默认模板”渲染层与失败降级策略。
|
||||
- P1:运营端新增“报告模板”配置能力(DRAFT/ACTIVE/发布/回滚)。
|
||||
- 前端报告页与导出统一使用 `rendered_report_text` 展示。
|
||||
- 增加 Prompt/Template 版本指纹日志与排障接口。
|
||||
|
||||
### 2.2 Out of Scope(本期不纳入)
|
||||
|
||||
- 双 Agent Writer 高拟人化撰写链路(保留为后续增强)。
|
||||
- 全量替换所有模型适配器为单一厂商 Structured Outputs(先做兼容层)。
|
||||
- 复杂可视化模板编辑器(先不上,后续按运营反馈决定)。
|
||||
- P0 阶段不开放模板运营配置界面(先用代码默认模板快速上线)。
|
||||
|
||||
### 2.3 设计原则
|
||||
|
||||
1. 业务可变(运营可配)与协议稳定(研发固化)严格分层。
|
||||
2. 失败可降级但不可静默:所有 fallback 必须留痕并告警。
|
||||
3. 默认向后兼容:旧任务、旧报告不破坏。
|
||||
4. 能复用现有能力就不重复造轮子(PromptService、Handlebars、pg-boss、现有审查流程)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 目标架构(V4.0)
|
||||
|
||||
## 3.1 双轨输出模型
|
||||
|
||||
- 结构化轨(系统轨)
|
||||
- 字段:`overall_score`、`conclusion`、`issues/parts/items` 等。
|
||||
- 用途:存库、统计、评分、后续机器处理。
|
||||
|
||||
- 展示轨(专家轨)
|
||||
- 字段:`report_text`(Markdown/纯文本)。
|
||||
- 来源:`report_template` + 结构化数据渲染。
|
||||
- 用途:前端展示、导出、人工审阅。
|
||||
|
||||
## 3.2 通道配置拆分
|
||||
|
||||
每个通道最终拆分为两份可配置资产(P1 落地):
|
||||
|
||||
- `RVW_xxx_PROMPT`:专家评估标准(不含输出格式约束)。
|
||||
- `RVW_xxx_REPORT_TEMPLATE`:专家展示模板(Handlebars)。
|
||||
|
||||
系统保留一份研发固化协议:
|
||||
|
||||
- `RVW_PROTOCOL_xxx`:结构化字段 schema/协议约束(代码侧维护)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 分阶段实施计划
|
||||
|
||||
## 4.1 Phase P0(Week 1)快速上线:硬编码默认模板 + 预留配置能力
|
||||
|
||||
目标:1 周内上线“稳定结构化 + 专家样式展示”的核心能力。
|
||||
|
||||
- 梳理四通道当前输入输出、协议、前端渲染路径。
|
||||
- 标记并移除“业务硬编码覆盖”点(仅保留协议校验/解析兜底)。
|
||||
- 新增 `reportRenderer` 服务,内置四通道默认模板(代码内维护)。
|
||||
- `reviewWorker` 汇总阶段写入 `rendered_report_text`。
|
||||
- 前端报告页与导出优先展示 `rendered_report_text`,旧数据兼容兜底。
|
||||
- 增加可观测字段:`promptVersion`、`promptFingerprint`、`render_fallback`。
|
||||
- 预留配置扩展点:模板来源抽象接口(先实现 `code` provider,后续接 `db` provider)。
|
||||
|
||||
交付物:
|
||||
|
||||
- `backend/src/modules/rvw/services/reportRenderer.ts`(默认模板模式)
|
||||
- 四通道渲染接入点
|
||||
- 前端展示/导出统一到展示轨
|
||||
- P0 上线验收报告与回滚脚本
|
||||
|
||||
## 4.2 Phase P1(Week 2-3)增强:运营端模板配置化
|
||||
|
||||
目标:在不改变 P0 主链路的前提下,实现模板动态可配、可发布、可回滚。
|
||||
|
||||
- 运营端 Prompt 管理增加四通道模板条目。
|
||||
- 模板版本流程:DRAFT 编辑 -> 预览渲染 -> 发布 ACTIVE -> 回滚。
|
||||
- `reportRenderer` 增加 `db` provider,并支持 provider 切换。
|
||||
- 增加“模板变量提示面板”(减少占位符写错)。
|
||||
- 增加模板指纹与版本审计:`templateVersion/templateFingerprint`。
|
||||
|
||||
交付物:
|
||||
|
||||
- 模板配置与发布流程可用
|
||||
- 模板预览页(输入样例 JSON 即时渲染)
|
||||
- 版本变更审计记录
|
||||
- P1 灰度发布与稳定性报告
|
||||
|
||||
---
|
||||
|
||||
## 5. 验收标准(分阶段)
|
||||
|
||||
### 5.1 P0 功能验收(快速上线门槛)
|
||||
|
||||
1. 四通道都能产出 `rendered_report_text`,并用于前端展示与导出。
|
||||
2. JSON 解析失败不影响最终展示(可读降级报告 + 告警)。
|
||||
3. 默认模板可输出专家认可的报告结构(如 1/2/3/结论)。
|
||||
4. 本阶段不依赖运营端模板配置即可上线。
|
||||
|
||||
### 5.2 P1 功能验收(配置化门槛)
|
||||
|
||||
1. 四通道可在运营端单独修改“展示模板”。
|
||||
2. 运营发布后,新任务可命中 ACTIVE 模板并产出对应格式报告。
|
||||
3. 模板从 A/B/C 改为 1/2/3/4,无需发版即可生效。
|
||||
|
||||
### 5.3 稳定性验收
|
||||
|
||||
1. 四通道 JSON 解析成功率 >= 99%(含修复链路)。
|
||||
2. 模板渲染失败率 < 1%,且全部可回退到默认模板。
|
||||
3. fallback 事件 100% 有日志与可追踪任务 ID。
|
||||
|
||||
### 5.4 可观测性验收
|
||||
|
||||
每次审稿必须可追溯:
|
||||
|
||||
- `promptVersion` / `promptFingerprint`
|
||||
- `templateVersion` / `templateFingerprint`(P1 生效)
|
||||
- `isDraft` / `render_fallback` / `protocol_repair_used`
|
||||
|
||||
---
|
||||
|
||||
## 6. 关键风险与应对
|
||||
|
||||
| 风险 | 影响 | 等级 | 应对 |
|
||||
|---|---|---|---|
|
||||
| 模板变量拼写错误导致渲染失败 | 报告显示异常 | 高 | P0 仅默认模板规避;P1 增加变量白名单 + 预览 + 默认模板降级 |
|
||||
| 多模型结构化能力差异 | JSON 不稳定 | 高 | 统一 schema 校验 + repair + 明确模型兼容矩阵 |
|
||||
| 历史任务缺少展示轨字段 | 页面兼容风险 | 中 | 前端双读逻辑(新字段优先,旧字段兜底) |
|
||||
| 运营频繁发布造成“版本感知混乱” | 排障困难 | 中 | 强制记录版本指纹并在任务详情可见 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 回滚策略
|
||||
|
||||
1. 模板回滚:P0 使用代码版本回滚,P1 支持运营端一键回滚到上一 ACTIVE 版本。
|
||||
2. 服务回滚:保留旧展示拼装逻辑,Feature Flag 控制新渲染链路。
|
||||
3. 故障兜底:渲染失败自动走默认模板,不阻塞任务完成状态。
|
||||
4. 数据回滚:不删除结构化结果,仅切换展示来源。
|
||||
|
||||
---
|
||||
|
||||
## 8. 任务拆解(可直接进入排期)
|
||||
|
||||
### 后端(P0 先做)
|
||||
|
||||
- [ ] 新增 `reportRenderer` 服务(代码默认模板)
|
||||
- [ ] 四通道接入模板渲染与降级日志
|
||||
- [ ] 任务结果结构新增 `rendered_report_text`(含兼容)
|
||||
- [ ] 增加 `promptVersion/promptFingerprint/render_fallback` 日志
|
||||
- [ ] 预留模板 provider 扩展接口(`code` -> `db`)
|
||||
|
||||
### 前端(P0 先做)
|
||||
|
||||
- [ ] 任务详情优先展示 `rendered_report_text`
|
||||
- [ ] 导出链路切换至展示轨
|
||||
- [ ] 旧数据兼容兜底视图
|
||||
|
||||
### 后端/前端(P1 再做)
|
||||
|
||||
- [ ] 模板配置页支持预览与变量提示
|
||||
- [ ] 接入模板版本发布/回滚流程
|
||||
- [ ] 版本信息可视化(可选:调试面板)
|
||||
|
||||
### QA/运维
|
||||
|
||||
- [ ] 四通道回归测试用例补齐
|
||||
- [ ] 模板异常、fallback、回滚演练
|
||||
- [ ] 上线检查清单与告警看板更新
|
||||
|
||||
---
|
||||
|
||||
## 9. 里程碑
|
||||
|
||||
- M1(Week 1 结束):P0 上线,四通道默认模板渲染可用,前端/导出展示轨打通。
|
||||
- M2(Week 2 结束):P1 配置化开发完成并进入灰度。
|
||||
- M3(Week 3 结束):P1 稳定上线,模板发布/回滚流程可用。
|
||||
|
||||
---
|
||||
|
||||
## 10. 版本记录
|
||||
|
||||
| 日期 | 版本 | 说明 |
|
||||
|---|---|---|
|
||||
| 2026-03-13 | v1.0 | 首版计划,确立“结构化评估 + 动态模板展示”双轨架构 |
|
||||
| 2026-03-13 | v1.1 | 调整为“P0 硬编码默认模板快速上线 + P1 模板配置化增强”两阶段策略 |
|
||||
|
||||
99
docs/03-业务模块/RVW-稿件审查系统/08-技术架构建议/智能审稿终极稳定架构白皮书.md
Normal file
99
docs/03-业务模块/RVW-稿件审查系统/08-技术架构建议/智能审稿终极稳定架构白皮书.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# **智能审稿系统 \- LLM 输出格式隔离与终极稳定架构白皮书**
|
||||
|
||||
**文档目的:** 彻底解决大模型“自然语言排版”与“系统结构化解析”之间的抢占与冲突,提供可作为平台底层规范的长期稳定架构方案。
|
||||
|
||||
**适用场景:** RVW 智能审稿、AIA 报告生成、ASL 深度检索总结等一切需要“既要系统存数据,又要给用户看报告”的业务。
|
||||
|
||||
## **一、 问题本质:为什么大模型总会“格式崩盘”?**
|
||||
|
||||
大模型本质上是一个“词汇接龙”的概率引擎。当我们要求它\*\*“既做评判专家(逻辑分析),又做排版文秘(JSON转义与文本排版)”**时,它面临着严重的**职责过载 (Responsibility Overload)\*\*。
|
||||
|
||||
1. **Attention(注意力)稀释**:Prompt 中复杂的业务规则(20条标准)占用了大量的注意力权重,导致模型在生成到后半段时,往往会“忘记”输出合规的格式。
|
||||
2. **JSON 转义灾难**:如果采用“将长文本报告塞入 JSON 字段”的做法,模型需要在生成长文本时,对每一个回车换行(\\n)、双引号(\\")进行严格的转义。一旦模型在几千字中漏掉一个转义符,前端 JSON.parse() 就会直接崩溃。
|
||||
|
||||
## **二、 终极演进方向:大模型的 MVC 架构 (LLM-MVC)**
|
||||
|
||||
软件工程中解决耦合的终极方案永远是**分层**。我们需要将大模型的工作流拆分为 **Model(数据层)**、**View(展示层)** 和 **Controller(控制层)**。
|
||||
|
||||
* **Model (判官)**:彻底剥离排版任务。LLM **只负责**看文章、找问题、打分。强制输出极致精简的、无大段长文本的 JSON 数据。
|
||||
* **View (文秘)**:负责将精简的 JSON 数据,渲染成带有专家个人风格(1/2/3 排版、温暖/严厉口吻)的自然语言报告。
|
||||
* **Controller (系统)**:负责业务调度、存库、进度条控制。
|
||||
|
||||
基于这个核心思想,针对贵司当前的基建现状,有以下三套递进的终极解决方案:
|
||||
|
||||
## **三、 三套终极解决方案 (按彻底程度递进)**
|
||||
|
||||
### **方案 A:底层原生强约束 (Structured Outputs) —— “从 API 层面锁死”**
|
||||
|
||||
不要在 Prompt 里写“请输出 JSON”,这是软约束。
|
||||
|
||||
各大主流模型(OpenAI, DeepSeek-V3)均已支持底层的结构化输出能力。
|
||||
|
||||
* **架构实现**:
|
||||
1. 在你们后端的 LLMFactory / StreamingService 中,调用模型时强行传入 response\_format: { type: "json\_schema", json\_schema: {...} }。
|
||||
2. 这个 Schema 由系统通过 Zod 或纯 JSON Schema 严格定义(必须有哪些字段,字段必须是枚举或数字)。
|
||||
3. 此时,大模型在底层的 Logits 采样阶段就会被加上 Mask(掩码),**它在物理层面上根本无法输出破坏 JSON 结构的 Token**。
|
||||
* **优点**:100% 解决 JSON 解析错误,改造成本极低。
|
||||
* **缺点**:依然没有解决“专家想自定义报告文本排版”的问题,且如果 JSON 中包含超长文本,依然可能导致 Token 浪费和轻微的截断。
|
||||
|
||||
### **方案 B:纯数据 JSON \+ Handlebars 动态模板 —— “零幻觉的完美解”**
|
||||
|
||||
💡 强烈推荐!我看你们在 ADMIN Phase 3.5.2 中已经实现了 Handlebars,这是最完美的契合点!
|
||||
|
||||
完全剥夺大模型的排版权利,大模型只负责提取“缺陷数据”。专家的排版要求,通过 Handlebars 模板在运营后台配置。
|
||||
|
||||
* **工作流**:
|
||||
1. **大模型推理**:输出极其干燥的数据(仅供系统流转)。
|
||||
{
|
||||
"score": 80,
|
||||
"issues": \[
|
||||
{"code": "ME-01", "name": "未做共线性检验", "suggestion": "补充 VIF 检验"}
|
||||
\]
|
||||
}
|
||||
|
||||
2. **专家后台配置 (View 模板)**:主编在运营管理端,像写 Word 模板一样配置:
|
||||
\#\#\# 方法学预审报告
|
||||
您好,本次得分:{{score}} 分。
|
||||
系统发现了以下 {{issues.length}} 个问题:
|
||||
{{\#each issues}}
|
||||
{{@index}}. 【{{this.name}}】:建议您 {{this.suggestion}}。
|
||||
{{/each}}
|
||||
|
||||
3. **系统渲染**:后端将 LLM 吐出的 JSON 喂给 Handlebars 模板,瞬间生成纯净的 Markdown 报告推给前端。
|
||||
* **优点**:
|
||||
* **彻底解耦**:专家爱怎么改排版就怎么改,永远不会导致系统报错。
|
||||
* **极致稳定**:LLM 只需要输出极短的 JSON,速度飞快,绝无转义灾难。
|
||||
* **复用基建**:完美复用你们已有的 PromptService 和 Handlebars 引擎。
|
||||
* **缺点**:要求主编掌握一点点 {{}} 占位符的写法(运营端可提供可视化插入按钮来降低门槛)。
|
||||
|
||||
### **方案 C:双轨 Agent 协同管线 (Pipeline/Map-Reduce) —— “高度拟人化的终局”**
|
||||
|
||||
如果期刊主编不仅要求格式,还要求极度灵活的语气(例如:“如果得分低于 60,就在开头严厉批评;如果高于 80,就热情表扬”),这就超出了 Handlebars 静态模板的能力。此时需要引入**双 Agent 协同**(类似你们 SSA 模块的 Plan-and-Execute 架构)。
|
||||
|
||||
* **工作流**:
|
||||
1. **Agent 1 (方法学判官 Evaluator)**:搭载复杂的 20 项规则 Prompt,负责深度思考和“找茬”。开启严格的 JSON 模式,只输出结构化问题列表,后台落库。
|
||||
2. **Agent 2 (撰稿编辑 Writer)**:接到 Agent 1 的 JSON 后被唤醒。它的 Prompt 是主编配置的口吻与格式要求(“请根据以下 JSON 缺陷数据,以极其严厉的专家口吻,按 1/2/3 的格式写一封给作者的退修信”)。
|
||||
3. **流式输出 (Streaming)**:Agent 2 直接采用流式 SSE 输出纯 Markdown 文本,前端打字机般实时渲染给责编。
|
||||
* **优点**:
|
||||
* **终极体验**:前端用户看到的是行云流水的打字效果,而后端数据库早已稳稳存下了 Agent 1 的结构化数据。
|
||||
* **职责单一**:每个 LLM 都在做自己最擅长的事,能力被压榨到极致。
|
||||
* **缺点**:消耗 2 次 LLM 调用,略微增加 Token 成本与耗时。但在多模块并行(Promise.allSettled)架构下,这一点时间是可以被接受的。
|
||||
|
||||
## **四、 给 RVW 模块 V4.0 的最终决策建议**
|
||||
|
||||
综合考量贵司“敏捷迭代”、“已有基建(Handlebars/PromptService)”和“商业化 SaaS”的诉求,我建议采取 **方案 B 作为核心底座,特定场景引入方案 C** 的融合架构:
|
||||
|
||||
#### **第一步:重构 PromptService 的输入输出协议 (落实方案 B)**
|
||||
|
||||
1. 运营端页面拆分:将现有的一个大输入框,拆分为\*\*【评判标准库 (Prompt)】**和**【报告展示模板 (Handlebars)】\*\*两个配置区。
|
||||
2. 约束模型:系统底层强制开启模型的 Structured Output (JSON Schema),LLM 输出只用于落库和填充模板。
|
||||
|
||||
#### **第二步:引入双通道确认机制 (类似 SSA Phase IV)**
|
||||
|
||||
在责编工作台中,左侧是 AI 找出的【结构化问题卡片】(供责编打勾/忽略),右侧是实时根据勾选状态,利用 Handlebars 重新渲染的【最终意见函预览】。
|
||||
|
||||
#### **长期演进**
|
||||
|
||||
当遇到需要根据不同分数输出截然不同口吻的高级定制化期刊时,再为该期刊开启 **方案 C (双 Agent 模式)**,把撰写意见函的工作交给 Writer Agent 实时生成。
|
||||
|
||||
**总结**:不要试图在一个 Prompt 里用自然语言去“恳求”大模型兼顾结构化和排版。**用底层 API(JSON Schema)锁死结构化数据,用中间层引擎(Handlebars / Writer Agent)解决排版展示**,才是云原生时代的终极稳定架构。
|
||||
89
docs/03-业务模块/RVW-稿件审查系统/08-技术架构建议/架构评审报告.md
Normal file
89
docs/03-业务模块/RVW-稿件审查系统/08-技术架构建议/架构评审报告.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# **RVW 模块方法学解析失败 \- 架构方案评审意见**
|
||||
|
||||
仔细阅读了您的分析与解决方案,以及《RVW稿件审查模块 \- 当前状态与开发指南 (v6.1)》,我对您的整体思路表示**高度赞同**。您的分析非常精准地抓住了当前大模型(LLM)工程化落地中的核心痛点:**人类语言的弹性与机器协议的刚性之间的矛盾。**
|
||||
|
||||
以下是我对您方案的详细评审,包括我完全认可的部分,以及我认为需要微调或补充的“不认可/需优化”部分。
|
||||
|
||||
## **✅ 一、 我完全认可的观点(高度赞同)**
|
||||
|
||||
### **1\. 对“为什么必须是 JSON”的业务判定**
|
||||
|
||||
**完全认可。** 您的分析一针见血。在 RVW V3.0.1 的架构中,方法学评估并不是一个“终点”,而是流水线的一环。
|
||||
|
||||
* 系统依赖解析出 overall\_score 和 methodologyStatus 来更新数据库。
|
||||
* 前端的 MethodologyReport.tsx 和 TaskDetail.tsx 依赖结构化数组(parts\[\]/issues\[\])来渲染多维度的进度条和增量展示。
|
||||
* 如果退化为纯文本,RVW 模块引以为傲的“4模块并行”、“增量结果持久化”和“结构化 Word 导出”将全线崩溃。
|
||||
|
||||
### **2\. 对“核心区别:软约束 vs 硬约束”的定性**
|
||||
|
||||
**完全认可。** 仅在 Prompt 中强调“请输出JSON”是典型的**软约束**。业务专家在运营端(PromptService)修改提示词时,往往会引入更多复杂的业务逻辑描述,这极易稀释模型对格式指令的注意力(Attention 偏移),导致模型在输出时“情不自禁”地加上“好的,以下是评估结果:”等前缀,从而破坏 JSON 解析。
|
||||
|
||||
### **3\. 将“方案A(Structured Output)”作为最优解**
|
||||
|
||||
**完全认可。** 采用 Function Calling 或 Structured Output(Response Format)是当前 LLM 工程界的最佳实践。它将“格式对齐”的工作下沉到了 API 协议层甚至模型的推理采样层(通过 Logits 掩码强制符合 Schema),从而释放了 Prompt 的空间,让 Prompt 可以纯粹服务于业务逻辑。
|
||||
|
||||
## **❌ 二、 我不完全认可 / 需要补充完善的观点**
|
||||
|
||||
虽然大方向极佳,但从您当前的系统架构(已使用 DeepSeek-V3,具有 LLMFactory 和 PromptService)来看,有几个工程落地的细节我**不完全认可**或认为**需要优化**:
|
||||
|
||||
### **1\. 关于“方案B:先自然语言,再二次抽取结构”的定位**
|
||||
|
||||
* **您的观点**:这是一个可选方案,增加了修复层。
|
||||
* **我的意见(不推荐)**:在你们当前的 V3.0.1 架构中,极度**不建议**将其作为常规链路。你们的核心指标是“上传到出报告 \< 3分钟(4模块并行)”。如果方法学每次都跑两遍 LLM(甚至第二遍还要等第一遍长文本生成完),不仅 token 成本翻倍,时延也会大幅增加,破坏现有的并发体验。二次抽取**只能**作为万不得已的 Error Retry 兜底,绝不能是主干方案。
|
||||
|
||||
### **2\. 关于“schema优先 \+ JSON兜底”的必要性**
|
||||
|
||||
* **您的观点**:优先走结构化输出,失败再走 JSON 解析或修复,本质是多层容灾。
|
||||
* **我的意见(略显冗余)**:现在的基座模型(如你们默认的 DeepSeek-V3 和备选的 Qwen3-72B)对 Structured Output / JSON Mode 的支持已经非常成熟。
|
||||
* 如果您在 API 层面开启了 response\_format: { type: "json\_object" } 或提供了强 Schema,模型返回的一定是格式完好的 JSON 字符串。
|
||||
* 此时如果解析失败,往往是**模型幻觉导致缺少必填字段**,而不是**JSON格式本身损坏**。因此,容灾的重点不应是“尝试用正则重新抠 JSON”,而应该是直接记录 error\_details(你们已在 V3.0 支持 partial\_completed)并触发基于格式错误的重试逻辑。
|
||||
|
||||
### **3\. 遗漏了最核心的系统层解法:“Prompt 动静分离”(关键补充!)**
|
||||
|
||||
您的方案主要集中在 LLM 的调用方式上,但忽略了**运营管理端的设计缺陷**。专家修改 Prompt 导致 JSON 崩坏,根本原因是**专家触碰了他们不该触碰的代码约定**。
|
||||
|
||||
* **真正的解法**:在您的 PromptService 中,应该将 Prompt 拆分为两部分:
|
||||
1. **System/Format Prompt(系统保留,研发控制)**:包含严格的 JSON Schema 定义、输出格式要求等。这部分在前端运营管理后台是**隐藏或只读**的。
|
||||
2. **Business Criteria Prompt(专家可编辑)**:仅包含纯粹的评估标准,如“如何判定T检验误用”、“FINER标准是什么”。
|
||||
3. **最终组装**:在 MethodologySkill 执行时,由代码自动将两部分拼接发送给 LLM。这样无论专家怎么改业务逻辑,都不会破坏格式指令。
|
||||
|
||||
## **🚀 三、 针对 RVW V3.0.1 的具体落地建议**
|
||||
|
||||
结合您的现有代码结构(backend/src/modules/rvw/),我建议您按以下三步实施“彻底解决 JSON 崩溃”的方案:
|
||||
|
||||
### **Step 1: 在 LLMFactory 中启用 JSON Mode**
|
||||
|
||||
修改 @/common/llm/LLMFactory,支持传入 response\_format 或 tools。DeepSeek-V3 原生支持强校验的 JSON 输出。
|
||||
|
||||
// 示例:强制要求模型仅返回 JSON
|
||||
const response \= await llmClient.chat.completions.create({
|
||||
model: 'deepseek-chat',
|
||||
messages: \[...\],
|
||||
response\_format: { type: 'json\_object' } // 启用 JSON Mode
|
||||
});
|
||||
|
||||
### **Step 2: 实施 Prompt 动静分离 (改造 PromptService)**
|
||||
|
||||
在数据库 prompt\_templates 表或 MethodologySkill 中做拆分:
|
||||
|
||||
// 在 methodologyService.ts 或 methodologySkill.ts 中组装
|
||||
const formatInstruction \= \`
|
||||
你必须严格按照以下 JSON 结构输出结果,不要包含任何额外的解释文本或 Markdown 标记:
|
||||
{
|
||||
"overall\_score": 85,
|
||||
"methodologyStatus": "error" | "warn" | "pass",
|
||||
"parts": \[
|
||||
{ "name": "统计方法描述", "issues": \[{"severity": "high", "desc": "..."}\] }
|
||||
\]
|
||||
}\`;
|
||||
|
||||
// expertPrompt 是专家在运营管理端配置的内容
|
||||
const finalPrompt \= expertPrompt \+ '\\n\\n' \+ formatInstruction;
|
||||
|
||||
### **Step 3: 善用 V3.0 现有的 partial\_completed 兜底**
|
||||
|
||||
既然 V3.0 已经实现了 Promise.allSettled 和 error\_details,如果(极小概率下)大模型返回的 JSON 缺少了关键字段导致 Schema 校验失败(比如少了 parts 数组):
|
||||
|
||||
* **不要尝试用正则表达式去猜**。
|
||||
* 直接 throw new Error("Methodology JSON schema validation failed")。
|
||||
* 让外层的 SkillExecutor 捕获,把当前任务标记为 partial\_completed,并在 error\_details 写入原因。前端的“琥珀色警告横幅”会自动提示用户该模块暂时失败,保证系统的绝对健壮性。
|
||||
@@ -26,6 +26,7 @@
|
||||
| BE-2 | 新增 Agent 计划参数编辑接口 `PATCH /api/v1/ssa/agent-executions/:executionId/plan-params`(复用参数约束配置) | `backend/src/modules/ssa/routes/agent-execution.routes.ts`, `backend/src/modules/ssa/index.ts` | 重新构建镜像 | Phase 5A.5 后端入口,限制 `plan_pending` 状态可编辑 |
|
||||
| BE-3 | Agent 切换为严格分步模式:`confirm_plan` 不生成整段代码,执行阶段统一按步骤生成 + 失败后依赖短路跳过后续步骤 | `backend/src/modules/ssa/services/ChatHandlerService.ts` | 重新构建镜像 | 修复“第3步失败仍尝试第4步”问题,降低无效重试与误导性结果 |
|
||||
| BE-4 | R 代码语法修复器纠正 `} else` 处理策略,避免引入 `unexpected 'else'` | `backend/src/modules/ssa/services/CodeRunnerService.ts` | 重新构建镜像 | 修复线上语法错误噪声,减少重试失败 |
|
||||
| BE-5 | RVW 审稿通道改造:4 通道 Prompt 动静分离(业务提示词可编辑 + 系统协议固化)+ 方法学/稿约 JSON 结构化修复兜底 + DataForensics 默认切换为 LLM-only(规则验证默认关闭) | `backend/src/modules/rvw/services/promptProtocols.ts`, `backend/src/modules/rvw/services/editorialService.ts`, `backend/src/modules/rvw/services/methodologyService.ts`, `backend/src/modules/rvw/services/clinicalService.ts`, `backend/src/modules/rvw/skills/library/DataForensicsSkill.ts`, `backend/src/modules/rvw/skills/core/types.ts`, `backend/src/common/document/ExtractionClient.ts`, `backend/src/common/prompt/prompt.fallbacks.ts` | 重新构建镜像 | 解决运营端改 Prompt 导致 JSON 解析失败;数据侦探默认仅“表格提取+LLM判断”,规则代码保留可回切 |
|
||||
|
||||
### 前端变更
|
||||
|
||||
@@ -39,7 +40,7 @@
|
||||
|
||||
| # | 变更内容 | 涉及文件 | 需要操作 | 备注 |
|
||||
|---|---------|---------|---------|------|
|
||||
| — | *暂无* | | | |
|
||||
| PY-1 | Forensics API 新增 `EXTRACT_ONLY` 模式并默认仅提取表格(不执行 L1/L2 规则校验) | `extraction_service/forensics/api.py`, `extraction_service/forensics/types.py` | 重新构建镜像 | 与后端 RVW LLM-only 路径配套,避免规则与 LLM 双轨冲突 |
|
||||
|
||||
### R 统计引擎变更
|
||||
|
||||
@@ -51,7 +52,7 @@
|
||||
|
||||
| # | 变更内容 | 服务 | 变量名 | 备注 |
|
||||
|---|---------|------|--------|------|
|
||||
| — | *暂无* | | | |
|
||||
| ENV-1 | 新增 RVW 数据侦探规则引擎开关(默认关闭) | nodejs-backend-test / nodejs-backend-prod | `RVW_FORENSICS_RULES_ENABLED=false` | `false`=仅表格提取+LLM判断(推荐);如需恢复规则验证可设为 `true` |
|
||||
|
||||
### 基础设施变更
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ async def forensics_health():
|
||||
@router.post("/analyze_docx")
|
||||
async def analyze_docx(
|
||||
file: UploadFile = File(...),
|
||||
check_level: str = "L1_L2",
|
||||
check_level: str = "EXTRACT_ONLY",
|
||||
tolerance_percent: float = 0.1,
|
||||
max_table_rows: int = 500
|
||||
):
|
||||
@@ -76,7 +76,7 @@ async def analyze_docx(
|
||||
|
||||
Args:
|
||||
file: 上传的 .docx 文件
|
||||
check_level: 验证级别 (L1 / L1_L2)
|
||||
check_level: 验证级别 (EXTRACT_ONLY / L1 / L1_L2)
|
||||
tolerance_percent: 百分比容错范围
|
||||
max_table_rows: 单表最大行数
|
||||
|
||||
@@ -125,13 +125,16 @@ async def analyze_docx(
|
||||
methods_found = detect_methods(full_text)
|
||||
logger.info(f"检测到统计方法: {methods_found}")
|
||||
|
||||
# 8. L1 算术验证
|
||||
# 8. 规则验证(可关闭)
|
||||
# EXTRACT_ONLY:仅提取表格,不执行任何规则验证
|
||||
if check_level != "EXTRACT_ONLY":
|
||||
# L1 算术验证
|
||||
arithmetic_validator = ArithmeticValidator(config)
|
||||
for table in tables:
|
||||
if not table.skipped:
|
||||
arithmetic_validator.validate(table)
|
||||
|
||||
# 9. L2 统计验证(如果启用)
|
||||
# L2 统计验证(如果启用)
|
||||
if check_level == "L1_L2":
|
||||
stat_validator = StatValidator(config)
|
||||
for table in tables:
|
||||
|
||||
@@ -41,8 +41,8 @@ class IssueType(str, Enum):
|
||||
class ForensicsConfig(BaseModel):
|
||||
"""数据侦探配置"""
|
||||
check_level: str = Field(
|
||||
default="L1_L2",
|
||||
description="验证级别:L1(仅算术)、L1_L2(算术+基础统计)"
|
||||
default="EXTRACT_ONLY",
|
||||
description="验证级别:EXTRACT_ONLY(仅提取表格)、L1(仅算术)、L1_L2(算术+基础统计)"
|
||||
)
|
||||
tolerance_percent: float = Field(
|
||||
default=0.1,
|
||||
|
||||
@@ -278,12 +278,12 @@ export interface PlatformUserOption {
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export async function searchPlatformUsers(search: string): Promise<PlatformUserOption[]> {
|
||||
export async function searchPlatformUsers(projectId: string, search: string): Promise<PlatformUserOption[]> {
|
||||
if (!search || search.length < 2) return [];
|
||||
const response = await apiClient.get('/api/admin/users', {
|
||||
params: { search, pageSize: 20 },
|
||||
const response = await apiClient.get(`${BASE_URL}/${projectId}/users/search`, {
|
||||
params: { search, limit: 20 },
|
||||
});
|
||||
const items = response.data.data?.data || response.data.data?.items || response.data.data?.list || [];
|
||||
const items = response.data.data || [];
|
||||
return items.map((u: any) => ({
|
||||
id: u.id,
|
||||
name: u.name || '',
|
||||
|
||||
@@ -105,7 +105,11 @@ const IitMemberManagePage: React.FC = () => {
|
||||
setUserSearching(true);
|
||||
searchTimerRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const users = await iitProjectApi.searchPlatformUsers(value);
|
||||
if (!selectedProjectId) {
|
||||
setUserSearchOptions([]);
|
||||
return;
|
||||
}
|
||||
const users = await iitProjectApi.searchPlatformUsers(selectedProjectId, value);
|
||||
setUserSearchOptions(users);
|
||||
} catch {
|
||||
setUserSearchOptions([]);
|
||||
|
||||
@@ -951,7 +951,7 @@ const UserMappingTab: React.FC<UserMappingTabProps> = ({ projectId }) => {
|
||||
setUserSearching(true);
|
||||
searchTimerRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const users = await iitProjectApi.searchPlatformUsers(value);
|
||||
const users = await iitProjectApi.searchPlatformUsers(projectId, value);
|
||||
setUserSearchOptions(users);
|
||||
} catch {
|
||||
setUserSearchOptions([]);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 方法学评估报告组件 - 专业版
|
||||
*/
|
||||
import { XCircle, AlertTriangle, CheckCircle, Microscope, Lightbulb, MapPin, TrendingUp } from 'lucide-react';
|
||||
import { XCircle, AlertTriangle, CheckCircle, Microscope } from 'lucide-react';
|
||||
import type { MethodologyReviewResult } from '../types';
|
||||
|
||||
interface MethodologyReportProps {
|
||||
@@ -9,16 +9,32 @@ interface MethodologyReportProps {
|
||||
}
|
||||
|
||||
export default function MethodologyReport({ data }: MethodologyReportProps) {
|
||||
const totalIssues = data.parts.reduce((sum, part) => sum + part.issues.length, 0);
|
||||
const majorIssues = data.parts.reduce((sum, part) => sum + part.issues.filter(i => i.severity === 'major').length, 0);
|
||||
const conclusion = data.conclusion || '未给出';
|
||||
const checkpoints = data.checkpoints || [];
|
||||
const majorCheckpointIssues = checkpoints.filter(cp => cp.status === 'major_issue');
|
||||
const minorCheckpointIssues = checkpoints.filter(cp => cp.status === 'minor_issue');
|
||||
const totalIssues = checkpoints.length > 0
|
||||
? checkpoints.filter(cp => cp.status === 'major_issue' || cp.status === 'minor_issue').length
|
||||
: data.parts.reduce((sum, part) => sum + part.issues.length, 0);
|
||||
const majorIssues = checkpoints.length > 0
|
||||
? checkpoints.filter(cp => cp.status === 'major_issue').length
|
||||
: data.parts.reduce((sum, part) => sum + part.issues.filter(i => i.severity === 'major').length, 0);
|
||||
const minorIssues = totalIssues - majorIssues;
|
||||
const uncoveredCount = checkpoints.filter(cp => cp.status === 'not_mentioned').length;
|
||||
|
||||
const getSeverityStyle = (severity: 'major' | 'minor') => {
|
||||
return severity === 'major'
|
||||
? { icon: <XCircle className="w-4 h-4 text-red-500" />, label: '严重', badge: 'bg-red-100 text-red-700 border-red-200' }
|
||||
: { icon: <AlertTriangle className="w-4 h-4 text-amber-500" />, label: '轻微', badge: 'bg-amber-100 text-amber-700 border-amber-200' };
|
||||
const getCheckpointStyle = (status: 'pass' | 'minor_issue' | 'major_issue' | 'not_mentioned') => {
|
||||
if (status === 'pass') return { text: '通过', cls: 'bg-green-100 text-green-700' };
|
||||
if (status === 'minor_issue') return { text: '一般问题', cls: 'bg-amber-100 text-amber-700' };
|
||||
if (status === 'major_issue') return { text: '严重问题', cls: 'bg-red-100 text-red-700' };
|
||||
return { text: '未覆盖', cls: 'bg-slate-100 text-slate-600' };
|
||||
};
|
||||
|
||||
const checkpointSections = [
|
||||
{ title: '一、科研设计评估 (Scientific Design)', start: 1, end: 9 },
|
||||
{ title: '二、统计学方法描述评估 (Statistical Methodology)', start: 10, end: 14 },
|
||||
{ title: '三、统计分析与结果评估 (Analysis & Results)', start: 15, end: 20 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6 fade-in">
|
||||
{/* 总览卡片 */}
|
||||
@@ -32,7 +48,13 @@ export default function MethodologyReport({ data }: MethodologyReportProps) {
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<span className="text-sm text-slate-600">共检测 <span className="font-bold text-slate-800">{data.parts.length}</span> 个方面</span>
|
||||
<span className="text-sm text-slate-600">
|
||||
共覆盖 <span className="font-bold text-slate-800">{checkpoints.length || 20}</span> 个检查点
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-indigo-50 rounded-lg border border-indigo-100">
|
||||
<span className="text-sm text-indigo-600">审稿结论</span>
|
||||
<span className="text-sm font-semibold text-indigo-700">{conclusion}</span>
|
||||
</div>
|
||||
{totalIssues === 0 ? (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-green-50 rounded-lg border border-green-100">
|
||||
@@ -59,100 +81,97 @@ export default function MethodologyReport({ data }: MethodologyReportProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分项详情标题 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<TrendingUp className="w-5 h-5 text-purple-500" />
|
||||
<h3 className="font-bold text-base text-slate-800">分项评估</h3>
|
||||
<span className="text-xs text-slate-400 bg-slate-100 px-2 py-0.5 rounded">共 {data.parts.length} 项</span>
|
||||
{/* 按专家要求的报告结构快速呈现 */}
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold text-slate-800">1. 总体评价</h4>
|
||||
<p className="text-sm text-slate-600 leading-relaxed">{data.summary || '未生成总体评价。'}</p>
|
||||
</div>
|
||||
|
||||
{/* 分项详情 */}
|
||||
<div className="space-y-4">
|
||||
{data.parts.map((part, partIndex) => (
|
||||
<div key={partIndex} className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
{/* 分项头部 */}
|
||||
<div className={`px-5 py-4 border-b ${part.issues.length === 0 ? 'bg-green-50/50 border-green-100' : 'bg-slate-50 border-slate-100'}`}>
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold text-slate-800">2. 详细问题清单与建议</h4>
|
||||
{totalIssues === 0 ? (
|
||||
<p className="text-sm text-green-700">未发现需要修订的问题。</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{majorCheckpointIssues.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-red-600">严重问题</p>
|
||||
<div className="space-y-2">
|
||||
{majorCheckpointIssues.map((cp) => (
|
||||
<div key={`major-${cp.id}`} className="rounded-lg border border-red-100 bg-red-50/50 p-3">
|
||||
<p className="text-sm font-medium text-slate-800">{cp.id}. {cp.item}</p>
|
||||
<p className="mt-1 text-sm text-slate-600">{cp.finding}</p>
|
||||
{cp.suggestion && <p className="mt-1 text-sm text-slate-700">建议:{cp.suggestion}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{minorCheckpointIssues.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-amber-600">一般问题</p>
|
||||
<div className="space-y-2">
|
||||
{minorCheckpointIssues.map((cp) => (
|
||||
<div key={`minor-${cp.id}`} className="rounded-lg border border-amber-100 bg-amber-50/50 p-3">
|
||||
<p className="text-sm font-medium text-slate-800">{cp.id}. {cp.item}</p>
|
||||
<p className="mt-1 text-sm text-slate-600">{cp.finding}</p>
|
||||
{cp.suggestion && <p className="mt-1 text-sm text-slate-700">建议:{cp.suggestion}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold text-slate-800">3. 审稿结论</h4>
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-indigo-50 rounded-lg border border-indigo-100">
|
||||
<span className="text-sm text-indigo-600">当前结论</span>
|
||||
<span className="text-sm font-semibold text-indigo-700">{conclusion}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{checkpoints.length > 0 && (
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div className="p-6 space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{part.issues.length === 0 ? (
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
) : (
|
||||
<AlertTriangle className="w-5 h-5 text-amber-500" />
|
||||
)}
|
||||
<h4 className="font-semibold text-slate-800">{part.part}</h4>
|
||||
</div>
|
||||
{part.issues.length === 0 ? (
|
||||
<span className="px-2.5 py-1 rounded-md text-xs font-medium bg-green-100 text-green-700 flex items-center gap-1">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
无问题
|
||||
<h4 className="text-sm font-semibold text-slate-800">20项检查点评估</h4>
|
||||
<span className={`text-xs px-2 py-1 rounded ${uncoveredCount > 0 ? 'bg-amber-100 text-amber-700' : 'bg-green-100 text-green-700'}`}>
|
||||
{uncoveredCount > 0 ? `仍有 ${uncoveredCount} 项未覆盖` : '20项已覆盖'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2.5 py-1 rounded-md text-xs font-medium bg-amber-100 text-amber-700">
|
||||
{part.issues.length} 个问题
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 问题列表 */}
|
||||
{part.issues.length > 0 && (
|
||||
<div className="divide-y divide-gray-50">
|
||||
{part.issues.map((issue, issueIndex) => {
|
||||
const severity = getSeverityStyle(issue.severity);
|
||||
{checkpointSections.map(section => {
|
||||
const sectionItems = checkpoints.filter(cp => cp.id >= section.start && cp.id <= section.end);
|
||||
return (
|
||||
<div key={issueIndex} className="px-5 py-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="mt-0.5">{severity.icon}</div>
|
||||
<div className="flex-1 space-y-3">
|
||||
{/* 问题标题和严重程度 */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-slate-800">{issue.type}</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium border ${severity.badge}`}>
|
||||
{severity.label}
|
||||
</span>
|
||||
<div key={section.title} className="space-y-3">
|
||||
<h5 className="text-sm font-semibold text-slate-800">{section.title}</h5>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{sectionItems.map(cp => {
|
||||
const style = getCheckpointStyle(cp.status);
|
||||
return (
|
||||
<div key={cp.id} className="border border-slate-100 rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium text-slate-800">{cp.id}. {cp.item}</p>
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${style.cls}`}>{style.text}</span>
|
||||
</div>
|
||||
|
||||
{/* 问题描述 */}
|
||||
<p className="text-sm text-slate-600 leading-relaxed">{issue.description}</p>
|
||||
|
||||
{/* 位置信息 */}
|
||||
{issue.location && (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400">
|
||||
<MapPin className="w-3.5 h-3.5" />
|
||||
<span>位置:{issue.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 改进建议 */}
|
||||
{issue.suggestion && (
|
||||
<div className="bg-indigo-50/50 rounded-lg p-3 border border-indigo-100">
|
||||
<div className="flex items-start gap-2">
|
||||
<Lightbulb className="w-4 h-4 text-indigo-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-indigo-600 mb-1">改进建议</p>
|
||||
<p className="text-sm text-slate-700">{issue.suggestion}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-slate-600">{cp.finding}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 无问题时的简洁显示 */}
|
||||
{part.issues.length === 0 && (
|
||||
<div className="px-5 py-4 text-sm text-green-600 flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span>该部分未发现方法学问题,符合规范要求</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -89,10 +89,20 @@ export interface MethodologyPart {
|
||||
issues: MethodologyIssue[];
|
||||
}
|
||||
|
||||
export interface MethodologyCheckpoint {
|
||||
id: number;
|
||||
item: string;
|
||||
status: 'pass' | 'minor_issue' | 'major_issue' | 'not_mentioned';
|
||||
finding: string;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
// 方法学评估结果
|
||||
export interface MethodologyReviewResult {
|
||||
overall_score: number;
|
||||
summary: string;
|
||||
conclusion?: '直接接收' | '小修' | '大修' | '拒稿';
|
||||
checkpoints?: MethodologyCheckpoint[];
|
||||
parts: MethodologyPart[];
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ import { AskUserCard } from './AskUserCard';
|
||||
import type { AskUserResponseData } from './AskUserCard';
|
||||
import { ThinkingBlock } from '@/shared/components/Chat';
|
||||
import type { ClarificationCardData, IntentResult } from '../types';
|
||||
import { safeRandomUUID } from '../utils/id';
|
||||
|
||||
export const SSAChatPane: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -228,7 +229,7 @@ export const SSAChatPane: React.FC = () => {
|
||||
} else {
|
||||
// 没有 session(未上传数据):走老的 generatePlan 流程
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
id: safeRandomUUID(),
|
||||
role: 'user',
|
||||
content: query,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -248,7 +249,7 @@ export const SSAChatPane: React.FC = () => {
|
||||
|
||||
const selectedLabel = Object.values(selections).join(', ');
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
id: safeRandomUUID(),
|
||||
role: 'user',
|
||||
content: selectedLabel,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -263,7 +264,7 @@ export const SSAChatPane: React.FC = () => {
|
||||
|
||||
if (resp.needsClarification && resp.clarificationCards?.length > 0) {
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
id: safeRandomUUID(),
|
||||
role: 'assistant',
|
||||
content: '还需要确认一下:',
|
||||
createdAt: new Date().toISOString(),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useCallback, useState } from 'react';
|
||||
import apiClient from '@/common/api/axios';
|
||||
import { getAccessToken } from '@/framework/auth/api';
|
||||
import { useSSAStore } from '../stores/ssaStore';
|
||||
import { safeRandomUUID } from '../utils/id';
|
||||
import type { AnalysisPlan, ExecutionResult, SSAMessage } from '../types';
|
||||
import {
|
||||
Document,
|
||||
@@ -119,7 +120,7 @@ export function useAnalysis(): UseAnalysisReturn {
|
||||
|
||||
try {
|
||||
const userMessage: SSAMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
id: safeRandomUUID(),
|
||||
role: 'user',
|
||||
content: query,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -146,7 +147,7 @@ export function useAnalysis(): UseAnalysisReturn {
|
||||
} as any);
|
||||
|
||||
const planMessage: SSAMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
id: safeRandomUUID(),
|
||||
role: 'assistant',
|
||||
content: `已生成分析方案:${plan.toolName}\n\n${plan.description}`,
|
||||
artifactType: 'sap',
|
||||
@@ -156,7 +157,7 @@ export function useAnalysis(): UseAnalysisReturn {
|
||||
addMessage(planMessage);
|
||||
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
id: safeRandomUUID(),
|
||||
role: 'assistant',
|
||||
content: '请确认数据映射并执行分析。',
|
||||
artifactType: 'confirm',
|
||||
@@ -221,7 +222,7 @@ export function useAnalysis(): UseAnalysisReturn {
|
||||
});
|
||||
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
id: safeRandomUUID(),
|
||||
role: 'assistant',
|
||||
content: result.interpretation || '分析完成,请查看右侧结果面板。',
|
||||
artifactType: 'result',
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { getAccessToken, isTokenExpired, refreshAccessToken } from '@/framework/auth/api';
|
||||
import { useSSAStore } from '../stores/ssaStore';
|
||||
import { safeRandomUUID } from '../utils/id';
|
||||
import type { AskUserEventData, AskUserResponseData } from '../components/AskUserCard';
|
||||
import type { WorkflowPlan, AgentStepResult } from '../types';
|
||||
|
||||
@@ -243,7 +244,7 @@ export function useSSAChat(): UseSSAChatReturn {
|
||||
|
||||
const isAgentAction = !!finalMetadata?.agentAction || isAgentInlineInstruction;
|
||||
|
||||
const assistantMsgId = crypto.randomUUID();
|
||||
const assistantMsgId = safeRandomUUID();
|
||||
const assistantPlaceholder: ChatMessage = {
|
||||
id: assistantMsgId,
|
||||
role: 'assistant',
|
||||
@@ -257,7 +258,7 @@ export function useSSAChat(): UseSSAChatReturn {
|
||||
setChatMessages(prev => [...prev, assistantPlaceholder]);
|
||||
} else {
|
||||
const userMsg: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
id: safeRandomUUID(),
|
||||
role: 'user',
|
||||
content,
|
||||
status: 'complete',
|
||||
@@ -359,7 +360,7 @@ export function useSSAChat(): UseSSAChatReturn {
|
||||
if (parsed.type === 'agent_planning') {
|
||||
const { pushAgentExecution, setWorkspaceOpen, setActivePane } = useSSAStore.getState();
|
||||
pushAgentExecution({
|
||||
id: parsed.executionId || crypto.randomUUID(),
|
||||
id: parsed.executionId || safeRandomUUID(),
|
||||
sessionId: sessionId,
|
||||
query: content,
|
||||
retryCount: 0,
|
||||
@@ -524,7 +525,7 @@ export function useSSAChat(): UseSSAChatReturn {
|
||||
const execId = curExec?.id;
|
||||
const execQuery = curExec?.query || content;
|
||||
setChatMessages(prev => [...prev, {
|
||||
id: crypto.randomUUID(),
|
||||
id: safeRandomUUID(),
|
||||
role: 'assistant' as const,
|
||||
content: `✅ 分析完成:${execQuery}`,
|
||||
status: 'complete' as const,
|
||||
@@ -664,7 +665,7 @@ export function useSSAChat(): UseSSAChatReturn {
|
||||
const auditContent = AUDIT_MESSAGES[action];
|
||||
|
||||
// 1. 追加审计轨迹消息(系统风格,不是用户消息)
|
||||
const auditMsgId = crypto.randomUUID();
|
||||
const auditMsgId = safeRandomUUID();
|
||||
setChatMessages(prev => [...prev, {
|
||||
id: auditMsgId,
|
||||
role: 'assistant' as const,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useCallback, useRef } from 'react';
|
||||
import apiClient from '@/common/api/axios';
|
||||
import { getAccessToken } from '@/framework/auth/api';
|
||||
import { useSSAStore } from '../stores/ssaStore';
|
||||
import { safeRandomUUID } from '../utils/id';
|
||||
import type { AnalysisRecord } from '../stores/ssaStore';
|
||||
import type {
|
||||
DataProfile,
|
||||
@@ -72,7 +73,7 @@ export function useWorkflow(): UseWorkflowReturn {
|
||||
const profile: DataProfile = response.data.profile;
|
||||
setDataProfile(profile);
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
id: safeRandomUUID(),
|
||||
role: 'assistant',
|
||||
content: `数据质量核查完成:${profile.quality_grade}级 (${profile.quality_score}分)`,
|
||||
artifactType: 'profile',
|
||||
@@ -112,7 +113,7 @@ export function useWorkflow(): UseWorkflowReturn {
|
||||
setWorkspaceOpen(true);
|
||||
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
id: safeRandomUUID(),
|
||||
role: 'assistant',
|
||||
content: `已生成分析方案:${plan.title}\n共 ${plan.total_steps} 个分析步骤`,
|
||||
artifactType: 'sap',
|
||||
@@ -120,7 +121,7 @@ export function useWorkflow(): UseWorkflowReturn {
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
id: safeRandomUUID(),
|
||||
role: 'assistant',
|
||||
content: '请确认分析计划并开始执行。',
|
||||
artifactType: 'confirm',
|
||||
@@ -183,7 +184,7 @@ export function useWorkflow(): UseWorkflowReturn {
|
||||
setActivePane('sap');
|
||||
setWorkspaceOpen(true);
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
id: safeRandomUUID(),
|
||||
role: 'assistant',
|
||||
content: `已生成分析方案:${data.plan.title}\n共 ${data.plan.total_steps} 个分析步骤`,
|
||||
artifactType: 'sap',
|
||||
@@ -346,7 +347,7 @@ export function useWorkflow(): UseWorkflowReturn {
|
||||
: String(firstErr);
|
||||
}
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
id: safeRandomUUID(),
|
||||
role: 'assistant',
|
||||
content: `分析执行失败:${errText}`,
|
||||
artifactType: 'execution',
|
||||
@@ -368,7 +369,7 @@ export function useWorkflow(): UseWorkflowReturn {
|
||||
if (conclusion) {
|
||||
setActivePane('result');
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
id: safeRandomUUID(),
|
||||
role: 'assistant',
|
||||
content: `分析完成!${conclusion.executive_summary?.slice(0, 100) || '查看右侧结果面板获取详细信息'}`,
|
||||
artifactType: 'result',
|
||||
@@ -377,7 +378,7 @@ export function useWorkflow(): UseWorkflowReturn {
|
||||
});
|
||||
} else if (hasAnySuccess) {
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
id: safeRandomUUID(),
|
||||
role: 'assistant',
|
||||
content: '分析执行完成!',
|
||||
artifactType: 'result',
|
||||
@@ -386,7 +387,7 @@ export function useWorkflow(): UseWorkflowReturn {
|
||||
});
|
||||
} else {
|
||||
addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
id: safeRandomUUID(),
|
||||
role: 'assistant',
|
||||
content: '分析执行完成,但未生成结论报告。',
|
||||
createdAt: new Date().toISOString(),
|
||||
|
||||
19
frontend-v2/src/modules/ssa/utils/id.ts
Normal file
19
frontend-v2/src/modules/ssa/utils/id.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 兼容性 ID 生成器:
|
||||
* - 安全上下文(HTTPS / localhost)优先使用 crypto.randomUUID()
|
||||
* - 非安全上下文(如内网 HTTP)自动降级,避免 runtime 报错
|
||||
*/
|
||||
export function safeRandomUUID(): string {
|
||||
try {
|
||||
if (typeof globalThis !== 'undefined' && globalThis.crypto?.randomUUID) {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
} catch {
|
||||
// ignore and fallback
|
||||
}
|
||||
|
||||
const time = Date.now().toString(36);
|
||||
const rand = Math.random().toString(36).slice(2, 10);
|
||||
return `ssa-${time}-${rand}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user