Files
AIclinicalresearch/backend/src/modules/rvw/services/utils.ts
HaHafeng c3554fd61d feat(rvw): harden json parsing and finalize 0316 rollout
Stabilize RVW editorial and methodology JSON parsing in production with layered repair and fallback handling, then publish the paired frontend task-level language selector updates. Also reset deployment checklist, record the 0316 deployment summary, and refresh the SAE runtime status with latest backend/frontend IPs.

Made-with: Cursor
2026-03-16 00:24:33 +08:00

212 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* RVW稿件审查模块 - 工具函数
* @module rvw/services/utils
*/
import { MethodologyReview, MethodologyStatus } from '../types/index.js';
import { jsonrepair } from 'jsonrepair';
function tryParseJsonCandidate<T>(candidate: string): T | null {
const normalized = candidate.trim().replace(/^\uFEFF/, '');
if (!normalized) return null;
try {
return JSON.parse(normalized) as T;
} catch {
try {
const repaired = jsonrepair(normalized);
return JSON.parse(repaired) as T;
} catch {
return null;
}
}
}
function extractBalancedJsonCandidates(content: string): string[] {
const text = content || '';
const candidates: string[] = [];
const stack: string[] = [];
let start = -1;
let inString = false;
let escaped = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (inString) {
if (escaped) {
escaped = false;
} else if (ch === '\\') {
escaped = true;
} else if (ch === '"') {
inString = false;
}
continue;
}
if (ch === '"') {
inString = true;
continue;
}
if (ch === '{' || ch === '[') {
if (stack.length === 0) start = i;
stack.push(ch);
continue;
}
if (ch === '}' || ch === ']') {
if (stack.length === 0) continue;
const open = stack[stack.length - 1];
if ((open === '{' && ch === '}') || (open === '[' && ch === ']')) {
stack.pop();
if (stack.length === 0 && start >= 0) {
candidates.push(text.slice(start, i + 1));
start = -1;
}
} else {
// 栈失配时重置,继续寻找下一个合法片段
stack.length = 0;
start = -1;
}
}
}
return candidates;
}
/**
* 从LLM响应中解析JSON
* 支持多种格式纯JSON、```json代码块、混合文本
*/
export function parseJSONFromLLMResponse<T>(content: string): T {
// 1) 直接解析 + jsonrepair
const direct = tryParseJsonCandidate<T>(content);
if (direct !== null) return direct;
// 2) 提取 Markdown 代码块(```json / ```
const fenceRegex = /```(?:json)?\s*\n?([\s\S]*?)\n?```/gi;
for (const match of content.matchAll(fenceRegex)) {
const parsed = tryParseJsonCandidate<T>(match[1] || '');
if (parsed !== null) return parsed;
}
// 3) 平衡括号提取,逐候选尝试
const balancedCandidates = extractBalancedJsonCandidates(content);
for (const candidate of balancedCandidates) {
const parsed = tryParseJsonCandidate<T>(candidate);
if (parsed !== null) return parsed;
}
// 4) 最后兜底:贪婪正则对象 / 数组(兼容极端场景)
const objectMatch = content.match(/(\{[\s\S]*\})/);
if (objectMatch) {
const parsed = tryParseJsonCandidate<T>(objectMatch[1]);
if (parsed !== null) return parsed;
}
const arrayMatch = content.match(/(\[[\s\S]*\])/);
if (arrayMatch) {
const parsed = tryParseJsonCandidate<T>(arrayMatch[1]);
if (parsed !== null) return parsed;
}
// 5) 所有尝试都失败
throw new Error('无法从LLM响应中解析JSON');
}
/**
* 根据方法学评估结果判断状态
* @param review 方法学评估结果
* @returns pass | warn | fail
*/
export function getMethodologyStatus(review: MethodologyReview | null | undefined): MethodologyStatus | undefined {
if (!review) return undefined;
const score = review.overall_score;
if (score >= 80) return 'pass';
if (score >= 60) return 'warn';
return 'fail';
}
/**
* 根据选择的智能体计算综合分数
* @param editorialScore 稿约规范性分数
* @param methodologyScore 方法学分数
* @param agents 选择的智能体
* @returns 综合分数保留1位小数
*/
export function calculateOverallScore(
editorialScore: number | null | undefined,
methodologyScore: number | null | undefined,
agents: string[]
): number | null {
const hasEditorial = agents.includes('editorial') && editorialScore != null;
const hasMethodology = agents.includes('methodology') && methodologyScore != null;
let score: number | null = null;
if (hasEditorial && hasMethodology) {
// 两个都选稿约40% + 方法学60%
score = editorialScore! * 0.4 + methodologyScore! * 0.6;
} else if (hasEditorial) {
// 只选规范性
score = editorialScore!;
} else if (hasMethodology) {
// 只选方法学
score = methodologyScore!;
}
// 修复浮点数精度问题保留1位小数
return score !== null ? Math.round(score * 10) / 10 : null;
}
/**
* 验证智能体选择
* @param agents 选择的智能体列表
* @throws 如果选择无效
*/
export function validateAgentSelection(agents: string[]): void {
if (!agents || agents.length === 0) {
throw new Error('请至少选择一个智能体');
}
const validAgents = ['editorial', 'methodology', 'clinical'];
for (const agent of agents) {
if (!validAgents.includes(agent)) {
throw new Error(`无效的智能体类型: ${agent}`);
}
}
if (agents.length > 3) {
throw new Error('最多只能选择3个智能体');
}
}