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
212 lines
5.0 KiB
TypeScript
212 lines
5.0 KiB
TypeScript
/**
|
||
* 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个智能体');
|
||
}
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|