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
This commit is contained in:
@@ -385,6 +385,7 @@ class TenantService {
|
||||
backgroundImageUrl?: string;
|
||||
modules: string[];
|
||||
isReviewOnly: boolean;
|
||||
editorialDefaultStandard: 'zh' | 'en';
|
||||
} | null> {
|
||||
// 根据 code 查找租户
|
||||
const tenant = await prisma.tenants.findUnique({
|
||||
@@ -414,6 +415,7 @@ class TenantService {
|
||||
backgroundImageUrl: tenant.login_background_url || undefined,
|
||||
modules,
|
||||
isReviewOnly,
|
||||
editorialDefaultStandard: tenant.journal_language === 'ZH' ? 'zh' : 'en',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,19 +190,28 @@ export async function createTask(
|
||||
export async function runReview(
|
||||
request: FastifyRequest<{
|
||||
Params: { taskId: string };
|
||||
Body: { agents: AgentType[] };
|
||||
Body: { agents: AgentType[]; editorialBaseStandard?: 'zh' | 'en' };
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const userId = getUserId(request);
|
||||
const { taskId } = request.params;
|
||||
const { agents } = request.body;
|
||||
const { agents, editorialBaseStandard } = request.body;
|
||||
const normalizedEditorialBaseStandard =
|
||||
editorialBaseStandard === 'zh' || editorialBaseStandard === 'en'
|
||||
? editorialBaseStandard
|
||||
: undefined;
|
||||
|
||||
logger.info('[RVW:Controller] 运行审查', { taskId, agents });
|
||||
logger.info('[RVW:Controller] 运行审查', { taskId, agents, editorialBaseStandard: normalizedEditorialBaseStandard });
|
||||
|
||||
// ✅ 返回 jobId(Platform-Only架构)
|
||||
const { jobId } = await reviewService.runReview({ taskId, agents, userId });
|
||||
const { jobId } = await reviewService.runReview({
|
||||
taskId,
|
||||
agents,
|
||||
editorialBaseStandard: normalizedEditorialBaseStandard,
|
||||
userId,
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
@@ -232,17 +241,31 @@ export async function batchRunReview(
|
||||
Body: {
|
||||
taskIds: string[];
|
||||
agents: AgentType[];
|
||||
editorialBaseStandard?: 'zh' | 'en';
|
||||
};
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const userId = getUserId(request);
|
||||
const { taskIds, agents } = request.body;
|
||||
const { taskIds, agents, editorialBaseStandard } = request.body;
|
||||
const normalizedEditorialBaseStandard =
|
||||
editorialBaseStandard === 'zh' || editorialBaseStandard === 'en'
|
||||
? editorialBaseStandard
|
||||
: undefined;
|
||||
|
||||
logger.info('[RVW:Controller] 批量运行审查', { taskCount: taskIds.length, agents });
|
||||
logger.info('[RVW:Controller] 批量运行审查', {
|
||||
taskCount: taskIds.length,
|
||||
agents,
|
||||
editorialBaseStandard: normalizedEditorialBaseStandard,
|
||||
});
|
||||
|
||||
const result = await reviewService.batchRunReview({ taskIds, agents, userId });
|
||||
const result = await reviewService.batchRunReview({
|
||||
taskIds,
|
||||
agents,
|
||||
editorialBaseStandard: normalizedEditorialBaseStandard,
|
||||
userId,
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
|
||||
@@ -16,6 +16,8 @@ import { EditorialReview } from '../types/index.js';
|
||||
import { parseJSONFromLLMResponse } from './utils.js';
|
||||
import { composeRvwSystemPrompt, getRvwProtocol, sanitizeRvwBusinessPrompt } from './promptProtocols.js';
|
||||
|
||||
const EDITORIAL_FALLBACK_MODEL: ModelType = 'qwen3-72b';
|
||||
|
||||
function isValidEditorialReview(result: unknown): result is EditorialReview {
|
||||
if (!result || typeof result !== 'object') return false;
|
||||
const data = result as Record<string, unknown>;
|
||||
@@ -25,6 +27,14 @@ function isValidEditorialReview(result: unknown): result is EditorialReview {
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildContentObservability(content: string): { length: number; preview: string } {
|
||||
const compact = (content || '').replace(/\s+/g, ' ').trim();
|
||||
return {
|
||||
length: content?.length ?? 0,
|
||||
preview: compact.slice(0, 500),
|
||||
};
|
||||
}
|
||||
|
||||
async function repairEditorialToJson(
|
||||
rawContent: string,
|
||||
modelType: ModelType
|
||||
@@ -48,11 +58,44 @@ async function repairEditorialToJson(
|
||||
});
|
||||
|
||||
const repairedContent = repaired.content ?? '';
|
||||
const parsed = parseJSONFromLLMResponse<EditorialReview>(repairedContent);
|
||||
if (!isValidEditorialReview(parsed)) {
|
||||
throw new Error('稿约规范性评估结果结构化修复失败(JSON字段不完整)');
|
||||
try {
|
||||
const parsed = parseJSONFromLLMResponse<EditorialReview>(repairedContent);
|
||||
if (!isValidEditorialReview(parsed)) {
|
||||
throw new Error('稿约规范性评估结果结构化修复失败(JSON字段不完整)');
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
// 二次兜底:使用更严格、更短的指令,强制输出单一 JSON 对象
|
||||
logger.warn('[RVW:Editorial] 第一次结构化修复失败,进入超严格兜底修复');
|
||||
const strictMessages = [
|
||||
{
|
||||
role: 'system' as const,
|
||||
content:
|
||||
'You are a JSON formatter. Output ONE valid JSON object only. No markdown, no prose, no code fence.\n' +
|
||||
'{' +
|
||||
'"overall_score":0,' +
|
||||
'"summary":"",' +
|
||||
'"items":[{"criterion":"","status":"pass","score":0,"issues":[],"suggestions":[]}]' +
|
||||
'}\n' +
|
||||
'Rules: status must be pass|warning|fail; scores must be numbers 0-100; issues/suggestions must be arrays.',
|
||||
},
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: `Convert the following editorial review text into that JSON schema:\n\n${rawContent}\n\nReturn JSON object only.`,
|
||||
},
|
||||
];
|
||||
|
||||
const strictRepaired = await llmAdapter.chat(strictMessages, {
|
||||
temperature: 0,
|
||||
maxTokens: 2500,
|
||||
});
|
||||
const strictContent = strictRepaired.content ?? '';
|
||||
const strictParsed = parseJSONFromLLMResponse<EditorialReview>(strictContent);
|
||||
if (!isValidEditorialReview(strictParsed)) {
|
||||
throw new Error('稿约规范性评估结果结构化修复失败(JSON字段不完整)');
|
||||
}
|
||||
return strictParsed;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,38 +137,58 @@ export async function reviewEditorialStandards(
|
||||
logger.info('[RVW:Editorial] 使用 DRAFT 版本 Prompt(调试模式)', { userId });
|
||||
}
|
||||
|
||||
// 2. 构建消息
|
||||
const messages = [
|
||||
{ role: 'system' as const, content: composeRvwSystemPrompt('editorial', businessPrompt) },
|
||||
{ role: 'user' as const, content: `请对以下稿件进行稿约规范性评估。\n\n稿件内容如下:\n${text}` },
|
||||
];
|
||||
// 2. 按模型序列尝试(主模型 -> 备用模型)
|
||||
const candidateModels = Array.from(new Set([modelType, EDITORIAL_FALLBACK_MODEL]));
|
||||
let lastError: Error | null = null;
|
||||
|
||||
// 3. 调用LLM
|
||||
logger.info('[RVW:Editorial] 开始稿约规范性评估', { modelType });
|
||||
const llmAdapter = LLMFactory.getAdapter(modelType);
|
||||
const response = await llmAdapter.chat(messages, {
|
||||
temperature: 0.3, // 较低温度以获得更稳定的评估
|
||||
maxTokens: 8000, // 确保完整输出
|
||||
});
|
||||
const editContent = response.content ?? '';
|
||||
logger.info('[RVW:Editorial] 评估完成', {
|
||||
modelType,
|
||||
responseLength: editContent.length
|
||||
});
|
||||
for (const candidateModel of candidateModels) {
|
||||
try {
|
||||
const messages = [
|
||||
{ role: 'system' as const, content: composeRvwSystemPrompt('editorial', businessPrompt) },
|
||||
{ role: 'user' as const, content: `请对以下稿件进行稿约规范性评估。\n\n稿件内容如下:\n${text}` },
|
||||
];
|
||||
|
||||
// 4. 解析 JSON(失败则自动进入结构化修复)
|
||||
try {
|
||||
const result = parseJSONFromLLMResponse<EditorialReview>(editContent);
|
||||
if (!isValidEditorialReview(result)) {
|
||||
throw new Error('LLM返回的数据格式不正确');
|
||||
logger.info('[RVW:Editorial] 开始稿约规范性评估', {
|
||||
modelType: candidateModel,
|
||||
fallback: candidateModel !== modelType,
|
||||
});
|
||||
const llmAdapter = LLMFactory.getAdapter(candidateModel);
|
||||
const response = await llmAdapter.chat(messages, {
|
||||
temperature: 0.2,
|
||||
maxTokens: 8000,
|
||||
});
|
||||
const editContent = response.content ?? '';
|
||||
logger.info('[RVW:Editorial] 评估完成', {
|
||||
modelType: candidateModel,
|
||||
responseLength: editContent.length,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = parseJSONFromLLMResponse<EditorialReview>(editContent);
|
||||
if (!isValidEditorialReview(result)) {
|
||||
throw new Error('LLM返回的数据格式不正确');
|
||||
}
|
||||
return result;
|
||||
} catch (parseError) {
|
||||
logger.warn('[RVW:Editorial] 原始响应解析失败,进入修复流程', {
|
||||
modelType: candidateModel,
|
||||
reason: parseError instanceof Error ? parseError.message : 'Unknown parse error',
|
||||
...buildContentObservability(editContent),
|
||||
});
|
||||
return await repairEditorialToJson(editContent, candidateModel);
|
||||
}
|
||||
} catch (candidateError) {
|
||||
const err = candidateError instanceof Error ? candidateError : new Error(String(candidateError));
|
||||
lastError = err;
|
||||
logger.warn('[RVW:Editorial] 模型评估失败,尝试下一候选模型', {
|
||||
modelType: candidateModel,
|
||||
fallback: candidateModel !== modelType,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
} catch (parseError) {
|
||||
logger.warn('[RVW:Editorial] 原始响应解析失败,进入修复流程', {
|
||||
reason: parseError instanceof Error ? parseError.message : 'Unknown parse error',
|
||||
});
|
||||
return await repairEditorialToJson(editContent, modelType);
|
||||
}
|
||||
|
||||
throw lastError ?? new Error('稿约规范性评估失败(所有候选模型均失败)');
|
||||
} catch (error) {
|
||||
logger.error('[RVW:Editorial] 稿约规范性评估失败', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
|
||||
@@ -17,6 +17,7 @@ import { MethodologyCheckpoint, MethodologyIssue, MethodologyPart, MethodologyRe
|
||||
import { parseJSONFromLLMResponse } from './utils.js';
|
||||
import { composeRvwSystemPrompt, getRvwProtocol, sanitizeRvwBusinessPrompt } from './promptProtocols.js';
|
||||
|
||||
const METHODOLOGY_FALLBACK_MODEL: ModelType = 'qwen3-72b';
|
||||
const METHODOLOGY_CONCLUSIONS = ['直接接收', '小修', '大修', '拒稿'] as const;
|
||||
type MethodologyConclusion = typeof METHODOLOGY_CONCLUSIONS[number];
|
||||
const METHODOLOGY_CHECKPOINT_ITEMS = [
|
||||
@@ -228,6 +229,14 @@ function isValidMethodologyReview(result: unknown): result is MethodologyReview
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildContentObservability(content: string): { length: number; preview: string } {
|
||||
const compact = (content || '').replace(/\s+/g, ' ').trim();
|
||||
return {
|
||||
length: content?.length ?? 0,
|
||||
preview: compact.slice(0, 500),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMethodologyCheckpoints(input: unknown): MethodologyCheckpoint[] {
|
||||
const normalizedMap = new Map<number, MethodologyCheckpoint>();
|
||||
if (Array.isArray(input)) {
|
||||
@@ -319,48 +328,93 @@ function aggregateMethodologySections(sections: SectionReviewResult[]): Methodol
|
||||
}
|
||||
|
||||
async function reviewMethodologySection(
|
||||
llmAdapter: ReturnType<typeof LLMFactory.getAdapter>,
|
||||
modelType: ModelType,
|
||||
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');
|
||||
const candidateModels = Array.from(new Set([modelType, METHODOLOGY_FALLBACK_MODEL]));
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (const candidateModel of candidateModels) {
|
||||
try {
|
||||
const llmAdapter = LLMFactory.getAdapter(candidateModel);
|
||||
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 ?? '';
|
||||
try {
|
||||
const repairedParsed = parseJSONFromLLMResponse<SectionReviewResult>(repairedContent);
|
||||
if (!isValidSectionReview(repairedParsed, section)) {
|
||||
throw new Error('section repair invalid');
|
||||
}
|
||||
return normalizeSectionReview(repairedParsed, section);
|
||||
} catch {
|
||||
// 二次兜底:严格 JSON 输出
|
||||
const strictMessages = [
|
||||
{
|
||||
role: 'system' as const,
|
||||
content:
|
||||
'You are a JSON formatter. Output ONE valid JSON object only. No markdown, no prose.\n' +
|
||||
`Must include: part="${section.part}", score(0-100), issues[], checkpoints[] for ids ${section.start}-${section.end}.`,
|
||||
},
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: `Convert the following section review text into strict JSON:\n\n${content}`,
|
||||
},
|
||||
];
|
||||
const strictRepaired = await llmAdapter.chat(strictMessages, {
|
||||
temperature: 0,
|
||||
maxTokens: 1500,
|
||||
});
|
||||
const strictContent = strictRepaired.content ?? '';
|
||||
const strictParsed = parseJSONFromLLMResponse<SectionReviewResult>(strictContent);
|
||||
if (!isValidSectionReview(strictParsed, section)) {
|
||||
throw new Error('section strict repair invalid');
|
||||
}
|
||||
return normalizeSectionReview(strictParsed, section);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
lastError = err;
|
||||
logger.warn('[RVW:Methodology] 分段评估模型失败,尝试下一候选模型', {
|
||||
section: section.part,
|
||||
modelType: candidateModel,
|
||||
fallback: candidateModel !== modelType,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
throw lastError ?? new Error('section evaluate failed');
|
||||
}
|
||||
|
||||
async function reviewMethodologyLegacy(
|
||||
@@ -368,23 +422,42 @@ async function reviewMethodologyLegacy(
|
||||
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);
|
||||
const candidateModels = Array.from(new Set([modelType, METHODOLOGY_FALLBACK_MODEL]));
|
||||
let lastError: Error | null = null;
|
||||
for (const candidateModel of candidateModels) {
|
||||
try {
|
||||
const llmAdapter = LLMFactory.getAdapter(candidateModel);
|
||||
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.2,
|
||||
maxTokens: 5000,
|
||||
});
|
||||
const methContent = response.content ?? '';
|
||||
try {
|
||||
const result = parseJSONFromLLMResponse<MethodologyReview>(methContent);
|
||||
if (!isValidMethodologyReview(result)) throw new Error('invalid json');
|
||||
return normalizeMethodologyReview(result);
|
||||
} catch {
|
||||
logger.warn('[RVW:Methodology] legacy原始响应解析失败,进入修复流程', {
|
||||
modelType: candidateModel,
|
||||
...buildContentObservability(methContent),
|
||||
});
|
||||
return repairMethodologyToJson(methContent, candidateModel);
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
lastError = err;
|
||||
logger.warn('[RVW:Methodology] legacy模型失败,尝试下一候选模型', {
|
||||
modelType: candidateModel,
|
||||
fallback: candidateModel !== modelType,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
throw lastError ?? new Error('legacy evaluate failed');
|
||||
}
|
||||
|
||||
async function repairMethodologyToJson(
|
||||
@@ -411,13 +484,37 @@ async function repairMethodologyToJson(
|
||||
});
|
||||
|
||||
const repairedContent = repaired.content ?? '';
|
||||
const parsed = parseJSONFromLLMResponse<MethodologyReview>(repairedContent);
|
||||
|
||||
if (!isValidMethodologyReview(parsed)) {
|
||||
throw new Error('方法学评估结果结构化修复失败(JSON字段不完整)');
|
||||
try {
|
||||
const parsed = parseJSONFromLLMResponse<MethodologyReview>(repairedContent);
|
||||
if (!isValidMethodologyReview(parsed)) {
|
||||
throw new Error('方法学评估结果结构化修复失败(JSON字段不完整)');
|
||||
}
|
||||
return normalizeMethodologyReview(parsed);
|
||||
} catch {
|
||||
logger.warn('[RVW:Methodology] 第一次结构化修复失败,进入超严格兜底修复');
|
||||
const strictMessages = [
|
||||
{
|
||||
role: 'system' as const,
|
||||
content:
|
||||
'You are a JSON formatter. Output ONE valid JSON object only. No markdown, no prose.\n' +
|
||||
'{"overall_score":0,"summary":"","conclusion":"小修","checkpoints":[{"id":1,"item":"","status":"pass","finding":"","suggestion":""}],"parts":[{"part":"科研设计评估","score":0,"issues":[]}]}',
|
||||
},
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: `Convert the following methodology review text into strict JSON:\n\n${rawContent}`,
|
||||
},
|
||||
];
|
||||
const strictRepaired = await llmAdapter.chat(strictMessages, {
|
||||
temperature: 0,
|
||||
maxTokens: 2500,
|
||||
});
|
||||
const strictContent = strictRepaired.content ?? '';
|
||||
const strictParsed = parseJSONFromLLMResponse<MethodologyReview>(strictContent);
|
||||
if (!isValidMethodologyReview(strictParsed)) {
|
||||
throw new Error('方法学评估结果结构化修复失败(JSON字段不完整)');
|
||||
}
|
||||
return normalizeMethodologyReview(strictParsed);
|
||||
}
|
||||
|
||||
return normalizeMethodologyReview(parsed);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -466,7 +563,6 @@ export async function reviewMethodology(
|
||||
}
|
||||
businessPrompt = sanitizeRvwBusinessPrompt('methodology', businessPrompt);
|
||||
|
||||
const llmAdapter = LLMFactory.getAdapter(modelType);
|
||||
logger.info('[RVW:Methodology] 开始分治并行评估', {
|
||||
modelType,
|
||||
sections: METHODOLOGY_SECTION_DEFS.map(section => `${section.part}(${section.start}-${section.end})`),
|
||||
@@ -474,7 +570,7 @@ export async function reviewMethodology(
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
METHODOLOGY_SECTION_DEFS.map(section =>
|
||||
reviewMethodologySection(llmAdapter, businessPrompt, text, section)
|
||||
reviewMethodologySection(modelType, businessPrompt, text, section)
|
||||
)
|
||||
);
|
||||
const sectionResults: SectionReviewResult[] = [];
|
||||
|
||||
@@ -93,7 +93,9 @@ export async function createTask(
|
||||
logger.info('[RVW] 任务已创建', { taskId: task.id, status: task.status });
|
||||
|
||||
// 2. 生成 OSS 存储 Key 并上传文件
|
||||
const storageKey = generateRvwStorageKey(tenantId, userId, task.id, filename);
|
||||
// 单租户历史数据 tenantId 可能为空,存储层使用固定命名空间兜底
|
||||
const storageTenantId = tenantId ?? 'public';
|
||||
const storageKey = generateRvwStorageKey(storageTenantId, userId, task.id, filename);
|
||||
let updatedTask = task;
|
||||
|
||||
try {
|
||||
@@ -181,7 +183,7 @@ async function extractDocumentAsync(taskId: string, file: Buffer, filename: stri
|
||||
* @returns jobId 供前端轮询状态
|
||||
*/
|
||||
export async function runReview(params: RunReviewParams): Promise<{ jobId: string }> {
|
||||
const { taskId, agents, userId } = params;
|
||||
const { taskId, agents, editorialBaseStandard, userId } = params;
|
||||
|
||||
// 验证智能体选择
|
||||
validateAgentSelection(agents);
|
||||
@@ -236,6 +238,7 @@ export async function runReview(params: RunReviewParams): Promise<{ jobId: strin
|
||||
taskId,
|
||||
userId,
|
||||
agents,
|
||||
editorialBaseStandard,
|
||||
extractedText: task.extractedText,
|
||||
modelType: (task.modelUsed || 'deepseek-v3') as ModelType,
|
||||
__expireInSeconds: 15 * 60, // 15min: 串行(5min)+并行(5min)+提取+余量,长文档可达8-10min
|
||||
@@ -259,7 +262,7 @@ export async function batchRunReview(params: BatchRunParams): Promise<{
|
||||
success: string[];
|
||||
failed: { taskId: string; error: string }[]
|
||||
}> {
|
||||
const { taskIds, agents, userId } = params;
|
||||
const { taskIds, agents, editorialBaseStandard, userId } = params;
|
||||
|
||||
// 验证智能体选择
|
||||
validateAgentSelection(agents);
|
||||
@@ -283,7 +286,7 @@ export async function batchRunReview(params: BatchRunParams): Promise<{
|
||||
const batch = taskIds.slice(i, i + MAX_CONCURRENT);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(taskId => runReview({ taskId, agents, userId }))
|
||||
batch.map(taskId => runReview({ taskId, agents, editorialBaseStandard, userId }))
|
||||
);
|
||||
|
||||
results.forEach((result, index) => {
|
||||
|
||||
@@ -6,71 +6,111 @@
|
||||
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 {
|
||||
try {
|
||||
// 1. 尝试直接解析
|
||||
return JSON.parse(content) as T;
|
||||
} catch {
|
||||
// 1.1 先尝试 jsonrepair(处理尾逗号、引号缺失等常见脏 JSON)
|
||||
try {
|
||||
const repaired = jsonrepair(content);
|
||||
return JSON.parse(repaired) as T;
|
||||
} catch {
|
||||
// 继续后续提取策略
|
||||
}
|
||||
// 1) 直接解析 + jsonrepair
|
||||
const direct = tryParseJsonCandidate<T>(content);
|
||||
if (direct !== null) return direct;
|
||||
|
||||
// 2. 尝试提取```json代码块
|
||||
const jsonMatch = content.match(/```json\s*\n?([\s\S]*?)\n?```/);
|
||||
if (jsonMatch) {
|
||||
try {
|
||||
return JSON.parse(jsonMatch[1].trim()) as T;
|
||||
} catch {
|
||||
// 尝试修复代码块 JSON
|
||||
try {
|
||||
const repaired = jsonrepair(jsonMatch[1].trim());
|
||||
return JSON.parse(repaired) as T;
|
||||
} catch {
|
||||
// 继续尝试其他方法
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 尝试提取{}或[]包裹的内容
|
||||
const objectMatch = content.match(/(\{[\s\S]*\})/);
|
||||
if (objectMatch) {
|
||||
try {
|
||||
return JSON.parse(objectMatch[1]) as T;
|
||||
} catch {
|
||||
try {
|
||||
const repaired = jsonrepair(objectMatch[1]);
|
||||
return JSON.parse(repaired) as T;
|
||||
} catch {
|
||||
// 继续尝试其他方法
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const arrayMatch = content.match(/(\[[\s\S]*\])/);
|
||||
if (arrayMatch) {
|
||||
try {
|
||||
return JSON.parse(arrayMatch[1]) as T;
|
||||
} catch {
|
||||
try {
|
||||
const repaired = jsonrepair(arrayMatch[1]);
|
||||
return JSON.parse(repaired) as T;
|
||||
} catch {
|
||||
// 失败
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 所有尝试都失败
|
||||
throw new Error('无法从LLM响应中解析JSON');
|
||||
// 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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -125,6 +125,7 @@ export interface ForensicsResult {
|
||||
export interface RunReviewParams {
|
||||
taskId: string;
|
||||
agents: AgentType[]; // 可选1个或2个
|
||||
editorialBaseStandard?: 'zh' | 'en';
|
||||
userId: string;
|
||||
}
|
||||
|
||||
@@ -134,6 +135,7 @@ export interface RunReviewParams {
|
||||
export interface BatchRunParams {
|
||||
taskIds: string[];
|
||||
agents: AgentType[];
|
||||
editorialBaseStandard?: 'zh' | 'en';
|
||||
userId: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ interface ReviewJob {
|
||||
taskId: string;
|
||||
userId: string;
|
||||
agents: AgentType[];
|
||||
editorialBaseStandard?: 'zh' | 'en';
|
||||
extractedText: string;
|
||||
modelType: ModelType;
|
||||
}
|
||||
@@ -115,7 +116,7 @@ export async function registerReviewWorker() {
|
||||
|
||||
// 注册审查Worker
|
||||
jobQueue.process<ReviewJob>('rvw_review_task', async (job: Job<ReviewJob>) => {
|
||||
const { taskId, userId, agents, extractedText, modelType } = job.data;
|
||||
const { taskId, userId, agents, editorialBaseStandard, extractedText, modelType } = job.data;
|
||||
const startTime = Date.now();
|
||||
|
||||
logger.info('[reviewWorker] Processing review job', {
|
||||
@@ -123,6 +124,7 @@ export async function registerReviewWorker() {
|
||||
taskId,
|
||||
userId,
|
||||
agents,
|
||||
editorialBaseStandard,
|
||||
textLength: extractedText.length,
|
||||
useSkillsArchitecture: USE_SKILLS_ARCHITECTURE,
|
||||
});
|
||||
@@ -188,6 +190,7 @@ export async function registerReviewWorker() {
|
||||
taskId,
|
||||
userId,
|
||||
agents,
|
||||
editorialBaseStandard,
|
||||
extractedText,
|
||||
existingTask?.filePath || '',
|
||||
existingTask?.fileName || 'unknown.docx',
|
||||
@@ -229,7 +232,13 @@ export async function registerReviewWorker() {
|
||||
logger.info('[reviewWorker] Running editorial review (legacy)', { taskId });
|
||||
console.log(' 🔍 运行稿约规范性智能体...');
|
||||
|
||||
editorialResult = await reviewEditorialStandards(extractedText, modelType, userId);
|
||||
editorialResult = await reviewEditorialStandards(
|
||||
extractedText,
|
||||
modelType,
|
||||
userId,
|
||||
undefined,
|
||||
editorialBaseStandard
|
||||
);
|
||||
|
||||
logger.info('[reviewWorker] Editorial review completed', {
|
||||
taskId,
|
||||
@@ -461,6 +470,7 @@ async function executeWithSkills(
|
||||
taskId: string,
|
||||
userId: string,
|
||||
agents: AgentType[],
|
||||
taskEditorialBaseStandard: 'zh' | 'en' | undefined,
|
||||
extractedText: string,
|
||||
filePath: string,
|
||||
fileName: string,
|
||||
@@ -524,28 +534,43 @@ async function executeWithSkills(
|
||||
},
|
||||
});
|
||||
if (cfg) {
|
||||
const resolvedEditorialBaseStandard =
|
||||
taskEditorialBaseStandard ??
|
||||
(cfg.editorialBaseStandard === 'zh' ? 'zh' : inferredEditorialBase);
|
||||
tenantRvwConfig = {
|
||||
...cfg,
|
||||
editorialBaseStandard: cfg.editorialBaseStandard === 'zh' ? 'zh' : inferredEditorialBase,
|
||||
editorialBaseStandard: resolvedEditorialBaseStandard,
|
||||
};
|
||||
logger.info('[reviewWorker] 已加载租户审稿配置', {
|
||||
taskId,
|
||||
tenantId: taskWithTenant.tenantId,
|
||||
taskEditorialBaseStandard,
|
||||
resolvedEditorialBaseStandard,
|
||||
hasMethodologyPrompt: !!cfg.methodologyExpertPrompt,
|
||||
hasEditorialPrompt: !!cfg.editorialExpertPrompt,
|
||||
hasDataPrompt: !!cfg.dataForensicsExpertPrompt,
|
||||
hasClinicalPrompt: !!cfg.clinicalExpertPrompt,
|
||||
});
|
||||
} else {
|
||||
const resolvedEditorialBaseStandard = taskEditorialBaseStandard ?? inferredEditorialBase;
|
||||
tenantRvwConfig = {
|
||||
editorialBaseStandard: inferredEditorialBase,
|
||||
editorialBaseStandard: resolvedEditorialBaseStandard,
|
||||
};
|
||||
logger.info('[reviewWorker] 未找到租户审稿配置,回退系统基线', {
|
||||
taskId,
|
||||
tenantId: taskWithTenant.tenantId,
|
||||
editorialBaseStandard: inferredEditorialBase,
|
||||
taskEditorialBaseStandard,
|
||||
editorialBaseStandard: resolvedEditorialBaseStandard,
|
||||
});
|
||||
}
|
||||
} else if (taskEditorialBaseStandard) {
|
||||
tenantRvwConfig = {
|
||||
editorialBaseStandard: taskEditorialBaseStandard,
|
||||
};
|
||||
logger.info('[reviewWorker] 使用任务级稿约基线语言', {
|
||||
taskId,
|
||||
editorialBaseStandard: taskEditorialBaseStandard,
|
||||
});
|
||||
}
|
||||
|
||||
// 构建上下文(V4.0:注入租户配置实现 Hybrid Prompt)
|
||||
|
||||
Reference in New Issue
Block a user