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字)
|
||||
const lines = content.split('\n').filter(l => l.trim() && !l.startsWith('#'));
|
||||
const summary = lines.length > 0
|
||||
? lines[0].trim().substring(0, 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('#'));
|
||||
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响应
|
||||
const result = parseJSONFromLLMResponse<EditorialReview>(editContent);
|
||||
|
||||
// 5. 验证响应格式
|
||||
if (!result || typeof result.overall_score !== 'number' || !Array.isArray(result.items)) {
|
||||
throw new Error('LLM返回的数据格式不正确');
|
||||
// 4. 解析 JSON(失败则自动进入结构化修复)
|
||||
try {
|
||||
const result = parseJSONFromLLMResponse<EditorialReview>(editContent);
|
||||
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);
|
||||
}
|
||||
|
||||
return result;
|
||||
} 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] 评估完成', {
|
||||
modelType,
|
||||
responseLength: methContent.length
|
||||
logger.info('[RVW:Methodology] 开始分治并行评估', {
|
||||
modelType,
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 计算状态和评分(基于数据质量结论,非执行状态;发现问题不等于执行失败)
|
||||
const hasErrors = forensicsResult.summary.errorCount > 0;
|
||||
const hasWarnings = forensicsResult.summary.warningCount > 0;
|
||||
// 状态与分数:
|
||||
// - 规则模式开启:沿用原有“规则问题计分”
|
||||
// - 规则模式关闭:仅按 LLM 核查是否成功决定 success/warning
|
||||
let status: 'success' | 'warning' | 'error' = 'success';
|
||||
let score = 100;
|
||||
|
||||
let status: 'success' | 'warning' | 'error';
|
||||
let score: number;
|
||||
|
||||
if (hasErrors) {
|
||||
if (RULE_BASED_VALIDATION_ENABLED) {
|
||||
const hasErrors = forensicsResult.summary.errorCount > 0;
|
||||
const hasWarnings = forensicsResult.summary.warningCount > 0;
|
||||
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 if (llmValidationFailed) {
|
||||
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;
|
||||
}
|
||||
|
||||
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[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user