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;
|
backgroundImageUrl?: string;
|
||||||
modules: string[];
|
modules: string[];
|
||||||
isReviewOnly: boolean;
|
isReviewOnly: boolean;
|
||||||
|
editorialDefaultStandard: 'zh' | 'en';
|
||||||
} | null> {
|
} | null> {
|
||||||
// 根据 code 查找租户
|
// 根据 code 查找租户
|
||||||
const tenant = await prisma.tenants.findUnique({
|
const tenant = await prisma.tenants.findUnique({
|
||||||
@@ -414,6 +415,7 @@ class TenantService {
|
|||||||
backgroundImageUrl: tenant.login_background_url || undefined,
|
backgroundImageUrl: tenant.login_background_url || undefined,
|
||||||
modules,
|
modules,
|
||||||
isReviewOnly,
|
isReviewOnly,
|
||||||
|
editorialDefaultStandard: tenant.journal_language === 'ZH' ? 'zh' : 'en',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,19 +190,28 @@ export async function createTask(
|
|||||||
export async function runReview(
|
export async function runReview(
|
||||||
request: FastifyRequest<{
|
request: FastifyRequest<{
|
||||||
Params: { taskId: string };
|
Params: { taskId: string };
|
||||||
Body: { agents: AgentType[] };
|
Body: { agents: AgentType[]; editorialBaseStandard?: 'zh' | 'en' };
|
||||||
}>,
|
}>,
|
||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const userId = getUserId(request);
|
const userId = getUserId(request);
|
||||||
const { taskId } = request.params;
|
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架构)
|
// ✅ 返回 jobId(Platform-Only架构)
|
||||||
const { jobId } = await reviewService.runReview({ taskId, agents, userId });
|
const { jobId } = await reviewService.runReview({
|
||||||
|
taskId,
|
||||||
|
agents,
|
||||||
|
editorialBaseStandard: normalizedEditorialBaseStandard,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
return reply.send({
|
return reply.send({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -232,17 +241,31 @@ export async function batchRunReview(
|
|||||||
Body: {
|
Body: {
|
||||||
taskIds: string[];
|
taskIds: string[];
|
||||||
agents: AgentType[];
|
agents: AgentType[];
|
||||||
|
editorialBaseStandard?: 'zh' | 'en';
|
||||||
};
|
};
|
||||||
}>,
|
}>,
|
||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const userId = getUserId(request);
|
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({
|
return reply.send({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { EditorialReview } from '../types/index.js';
|
|||||||
import { parseJSONFromLLMResponse } from './utils.js';
|
import { parseJSONFromLLMResponse } from './utils.js';
|
||||||
import { composeRvwSystemPrompt, getRvwProtocol, sanitizeRvwBusinessPrompt } from './promptProtocols.js';
|
import { composeRvwSystemPrompt, getRvwProtocol, sanitizeRvwBusinessPrompt } from './promptProtocols.js';
|
||||||
|
|
||||||
|
const EDITORIAL_FALLBACK_MODEL: ModelType = 'qwen3-72b';
|
||||||
|
|
||||||
function isValidEditorialReview(result: unknown): result is EditorialReview {
|
function isValidEditorialReview(result: unknown): result is EditorialReview {
|
||||||
if (!result || typeof result !== 'object') return false;
|
if (!result || typeof result !== 'object') return false;
|
||||||
const data = result as Record<string, unknown>;
|
const data = result as Record<string, unknown>;
|
||||||
@@ -25,6 +27,14 @@ function isValidEditorialReview(result: unknown): result is EditorialReview {
|
|||||||
return true;
|
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(
|
async function repairEditorialToJson(
|
||||||
rawContent: string,
|
rawContent: string,
|
||||||
modelType: ModelType
|
modelType: ModelType
|
||||||
@@ -48,11 +58,44 @@ async function repairEditorialToJson(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const repairedContent = repaired.content ?? '';
|
const repairedContent = repaired.content ?? '';
|
||||||
const parsed = parseJSONFromLLMResponse<EditorialReview>(repairedContent);
|
try {
|
||||||
if (!isValidEditorialReview(parsed)) {
|
const parsed = parseJSONFromLLMResponse<EditorialReview>(repairedContent);
|
||||||
throw new Error('稿约规范性评估结果结构化修复失败(JSON字段不完整)');
|
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 });
|
logger.info('[RVW:Editorial] 使用 DRAFT 版本 Prompt(调试模式)', { userId });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 构建消息
|
// 2. 按模型序列尝试(主模型 -> 备用模型)
|
||||||
const messages = [
|
const candidateModels = Array.from(new Set([modelType, EDITORIAL_FALLBACK_MODEL]));
|
||||||
{ role: 'system' as const, content: composeRvwSystemPrompt('editorial', businessPrompt) },
|
let lastError: Error | null = null;
|
||||||
{ role: 'user' as const, content: `请对以下稿件进行稿约规范性评估。\n\n稿件内容如下:\n${text}` },
|
|
||||||
];
|
|
||||||
|
|
||||||
// 3. 调用LLM
|
for (const candidateModel of candidateModels) {
|
||||||
logger.info('[RVW:Editorial] 开始稿约规范性评估', { modelType });
|
try {
|
||||||
const llmAdapter = LLMFactory.getAdapter(modelType);
|
const messages = [
|
||||||
const response = await llmAdapter.chat(messages, {
|
{ role: 'system' as const, content: composeRvwSystemPrompt('editorial', businessPrompt) },
|
||||||
temperature: 0.3, // 较低温度以获得更稳定的评估
|
{ role: 'user' as const, content: `请对以下稿件进行稿约规范性评估。\n\n稿件内容如下:\n${text}` },
|
||||||
maxTokens: 8000, // 确保完整输出
|
];
|
||||||
});
|
|
||||||
const editContent = response.content ?? '';
|
|
||||||
logger.info('[RVW:Editorial] 评估完成', {
|
|
||||||
modelType,
|
|
||||||
responseLength: editContent.length
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. 解析 JSON(失败则自动进入结构化修复)
|
logger.info('[RVW:Editorial] 开始稿约规范性评估', {
|
||||||
try {
|
modelType: candidateModel,
|
||||||
const result = parseJSONFromLLMResponse<EditorialReview>(editContent);
|
fallback: candidateModel !== modelType,
|
||||||
if (!isValidEditorialReview(result)) {
|
});
|
||||||
throw new Error('LLM返回的数据格式不正确');
|
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) {
|
} catch (error) {
|
||||||
logger.error('[RVW:Editorial] 稿约规范性评估失败', {
|
logger.error('[RVW:Editorial] 稿约规范性评估失败', {
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { MethodologyCheckpoint, MethodologyIssue, MethodologyPart, MethodologyRe
|
|||||||
import { parseJSONFromLLMResponse } from './utils.js';
|
import { parseJSONFromLLMResponse } from './utils.js';
|
||||||
import { composeRvwSystemPrompt, getRvwProtocol, sanitizeRvwBusinessPrompt } from './promptProtocols.js';
|
import { composeRvwSystemPrompt, getRvwProtocol, sanitizeRvwBusinessPrompt } from './promptProtocols.js';
|
||||||
|
|
||||||
|
const METHODOLOGY_FALLBACK_MODEL: ModelType = 'qwen3-72b';
|
||||||
const METHODOLOGY_CONCLUSIONS = ['直接接收', '小修', '大修', '拒稿'] as const;
|
const METHODOLOGY_CONCLUSIONS = ['直接接收', '小修', '大修', '拒稿'] as const;
|
||||||
type MethodologyConclusion = typeof METHODOLOGY_CONCLUSIONS[number];
|
type MethodologyConclusion = typeof METHODOLOGY_CONCLUSIONS[number];
|
||||||
const METHODOLOGY_CHECKPOINT_ITEMS = [
|
const METHODOLOGY_CHECKPOINT_ITEMS = [
|
||||||
@@ -228,6 +229,14 @@ function isValidMethodologyReview(result: unknown): result is MethodologyReview
|
|||||||
return true;
|
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[] {
|
function normalizeMethodologyCheckpoints(input: unknown): MethodologyCheckpoint[] {
|
||||||
const normalizedMap = new Map<number, MethodologyCheckpoint>();
|
const normalizedMap = new Map<number, MethodologyCheckpoint>();
|
||||||
if (Array.isArray(input)) {
|
if (Array.isArray(input)) {
|
||||||
@@ -319,48 +328,93 @@ function aggregateMethodologySections(sections: SectionReviewResult[]): Methodol
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function reviewMethodologySection(
|
async function reviewMethodologySection(
|
||||||
llmAdapter: ReturnType<typeof LLMFactory.getAdapter>,
|
modelType: ModelType,
|
||||||
businessPrompt: string,
|
businessPrompt: string,
|
||||||
text: string,
|
text: string,
|
||||||
section: MethodologySectionDef
|
section: MethodologySectionDef
|
||||||
): Promise<SectionReviewResult> {
|
): Promise<SectionReviewResult> {
|
||||||
const messages = [
|
const candidateModels = Array.from(new Set([modelType, METHODOLOGY_FALLBACK_MODEL]));
|
||||||
{ role: 'system' as const, content: `${businessPrompt}\n\n${buildSectionProtocol(section)}` },
|
let lastError: Error | null = null;
|
||||||
{ role: 'user' as const, content: `请仅评估“${section.part}”(检查点 ${section.start}-${section.end}),并按协议返回 JSON。\n\n稿件内容如下:\n${text}` },
|
|
||||||
];
|
for (const candidateModel of candidateModels) {
|
||||||
const response = await llmAdapter.chat(messages, {
|
try {
|
||||||
temperature: 0.2,
|
const llmAdapter = LLMFactory.getAdapter(candidateModel);
|
||||||
maxTokens: 2800,
|
const messages = [
|
||||||
});
|
{ role: 'system' as const, content: `${businessPrompt}\n\n${buildSectionProtocol(section)}` },
|
||||||
const content = response.content ?? '';
|
{ role: 'user' as const, content: `请仅评估“${section.part}”(检查点 ${section.start}-${section.end}),并按协议返回 JSON。\n\n稿件内容如下:\n${text}` },
|
||||||
try {
|
];
|
||||||
const parsed = parseJSONFromLLMResponse<SectionReviewResult>(content);
|
const response = await llmAdapter.chat(messages, {
|
||||||
if (!isValidSectionReview(parsed, section)) {
|
temperature: 0.2,
|
||||||
throw new Error('section json invalid');
|
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(
|
async function reviewMethodologyLegacy(
|
||||||
@@ -368,23 +422,42 @@ async function reviewMethodologyLegacy(
|
|||||||
text: string,
|
text: string,
|
||||||
modelType: ModelType
|
modelType: ModelType
|
||||||
): Promise<MethodologyReview> {
|
): Promise<MethodologyReview> {
|
||||||
const llmAdapter = LLMFactory.getAdapter(modelType);
|
const candidateModels = Array.from(new Set([modelType, METHODOLOGY_FALLBACK_MODEL]));
|
||||||
const messages = [
|
let lastError: Error | null = null;
|
||||||
{ role: 'system' as const, content: composeRvwSystemPrompt('methodology', businessPrompt) },
|
for (const candidateModel of candidateModels) {
|
||||||
{ role: 'user' as const, content: `请对以下稿件进行方法学评估。\n\n稿件内容如下:\n${text}` },
|
try {
|
||||||
];
|
const llmAdapter = LLMFactory.getAdapter(candidateModel);
|
||||||
const response = await llmAdapter.chat(messages, {
|
const messages = [
|
||||||
temperature: 0.3,
|
{ role: 'system' as const, content: composeRvwSystemPrompt('methodology', businessPrompt) },
|
||||||
maxTokens: 5000,
|
{ role: 'user' as const, content: `请对以下稿件进行方法学评估。\n\n稿件内容如下:\n${text}` },
|
||||||
});
|
];
|
||||||
const methContent = response.content ?? '';
|
const response = await llmAdapter.chat(messages, {
|
||||||
try {
|
temperature: 0.2,
|
||||||
const result = parseJSONFromLLMResponse<MethodologyReview>(methContent);
|
maxTokens: 5000,
|
||||||
if (!isValidMethodologyReview(result)) throw new Error('invalid json');
|
});
|
||||||
return normalizeMethodologyReview(result);
|
const methContent = response.content ?? '';
|
||||||
} catch {
|
try {
|
||||||
return repairMethodologyToJson(methContent, modelType);
|
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(
|
async function repairMethodologyToJson(
|
||||||
@@ -411,13 +484,37 @@ async function repairMethodologyToJson(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const repairedContent = repaired.content ?? '';
|
const repairedContent = repaired.content ?? '';
|
||||||
const parsed = parseJSONFromLLMResponse<MethodologyReview>(repairedContent);
|
try {
|
||||||
|
const parsed = parseJSONFromLLMResponse<MethodologyReview>(repairedContent);
|
||||||
if (!isValidMethodologyReview(parsed)) {
|
if (!isValidMethodologyReview(parsed)) {
|
||||||
throw new Error('方法学评估结果结构化修复失败(JSON字段不完整)');
|
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);
|
businessPrompt = sanitizeRvwBusinessPrompt('methodology', businessPrompt);
|
||||||
|
|
||||||
const llmAdapter = LLMFactory.getAdapter(modelType);
|
|
||||||
logger.info('[RVW:Methodology] 开始分治并行评估', {
|
logger.info('[RVW:Methodology] 开始分治并行评估', {
|
||||||
modelType,
|
modelType,
|
||||||
sections: METHODOLOGY_SECTION_DEFS.map(section => `${section.part}(${section.start}-${section.end})`),
|
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(
|
const settled = await Promise.allSettled(
|
||||||
METHODOLOGY_SECTION_DEFS.map(section =>
|
METHODOLOGY_SECTION_DEFS.map(section =>
|
||||||
reviewMethodologySection(llmAdapter, businessPrompt, text, section)
|
reviewMethodologySection(modelType, businessPrompt, text, section)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const sectionResults: SectionReviewResult[] = [];
|
const sectionResults: SectionReviewResult[] = [];
|
||||||
|
|||||||
@@ -93,7 +93,9 @@ export async function createTask(
|
|||||||
logger.info('[RVW] 任务已创建', { taskId: task.id, status: task.status });
|
logger.info('[RVW] 任务已创建', { taskId: task.id, status: task.status });
|
||||||
|
|
||||||
// 2. 生成 OSS 存储 Key 并上传文件
|
// 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;
|
let updatedTask = task;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -181,7 +183,7 @@ async function extractDocumentAsync(taskId: string, file: Buffer, filename: stri
|
|||||||
* @returns jobId 供前端轮询状态
|
* @returns jobId 供前端轮询状态
|
||||||
*/
|
*/
|
||||||
export async function runReview(params: RunReviewParams): Promise<{ jobId: string }> {
|
export async function runReview(params: RunReviewParams): Promise<{ jobId: string }> {
|
||||||
const { taskId, agents, userId } = params;
|
const { taskId, agents, editorialBaseStandard, userId } = params;
|
||||||
|
|
||||||
// 验证智能体选择
|
// 验证智能体选择
|
||||||
validateAgentSelection(agents);
|
validateAgentSelection(agents);
|
||||||
@@ -236,6 +238,7 @@ export async function runReview(params: RunReviewParams): Promise<{ jobId: strin
|
|||||||
taskId,
|
taskId,
|
||||||
userId,
|
userId,
|
||||||
agents,
|
agents,
|
||||||
|
editorialBaseStandard,
|
||||||
extractedText: task.extractedText,
|
extractedText: task.extractedText,
|
||||||
modelType: (task.modelUsed || 'deepseek-v3') as ModelType,
|
modelType: (task.modelUsed || 'deepseek-v3') as ModelType,
|
||||||
__expireInSeconds: 15 * 60, // 15min: 串行(5min)+并行(5min)+提取+余量,长文档可达8-10min
|
__expireInSeconds: 15 * 60, // 15min: 串行(5min)+并行(5min)+提取+余量,长文档可达8-10min
|
||||||
@@ -259,7 +262,7 @@ export async function batchRunReview(params: BatchRunParams): Promise<{
|
|||||||
success: string[];
|
success: string[];
|
||||||
failed: { taskId: string; error: string }[]
|
failed: { taskId: string; error: string }[]
|
||||||
}> {
|
}> {
|
||||||
const { taskIds, agents, userId } = params;
|
const { taskIds, agents, editorialBaseStandard, userId } = params;
|
||||||
|
|
||||||
// 验证智能体选择
|
// 验证智能体选择
|
||||||
validateAgentSelection(agents);
|
validateAgentSelection(agents);
|
||||||
@@ -283,7 +286,7 @@ export async function batchRunReview(params: BatchRunParams): Promise<{
|
|||||||
const batch = taskIds.slice(i, i + MAX_CONCURRENT);
|
const batch = taskIds.slice(i, i + MAX_CONCURRENT);
|
||||||
|
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
batch.map(taskId => runReview({ taskId, agents, userId }))
|
batch.map(taskId => runReview({ taskId, agents, editorialBaseStandard, userId }))
|
||||||
);
|
);
|
||||||
|
|
||||||
results.forEach((result, index) => {
|
results.forEach((result, index) => {
|
||||||
|
|||||||
@@ -6,71 +6,111 @@
|
|||||||
import { MethodologyReview, MethodologyStatus } from '../types/index.js';
|
import { MethodologyReview, MethodologyStatus } from '../types/index.js';
|
||||||
import { jsonrepair } from 'jsonrepair';
|
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
|
* 从LLM响应中解析JSON
|
||||||
* 支持多种格式:纯JSON、```json代码块、混合文本
|
* 支持多种格式:纯JSON、```json代码块、混合文本
|
||||||
*/
|
*/
|
||||||
export function parseJSONFromLLMResponse<T>(content: string): T {
|
export function parseJSONFromLLMResponse<T>(content: string): T {
|
||||||
try {
|
// 1) 直接解析 + jsonrepair
|
||||||
// 1. 尝试直接解析
|
const direct = tryParseJsonCandidate<T>(content);
|
||||||
return JSON.parse(content) as T;
|
if (direct !== null) return direct;
|
||||||
} catch {
|
|
||||||
// 1.1 先尝试 jsonrepair(处理尾逗号、引号缺失等常见脏 JSON)
|
|
||||||
try {
|
|
||||||
const repaired = jsonrepair(content);
|
|
||||||
return JSON.parse(repaired) as T;
|
|
||||||
} catch {
|
|
||||||
// 继续后续提取策略
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 尝试提取```json代码块
|
// 2) 提取 Markdown 代码块(```json / ```)
|
||||||
const jsonMatch = content.match(/```json\s*\n?([\s\S]*?)\n?```/);
|
const fenceRegex = /```(?:json)?\s*\n?([\s\S]*?)\n?```/gi;
|
||||||
if (jsonMatch) {
|
for (const match of content.matchAll(fenceRegex)) {
|
||||||
try {
|
const parsed = tryParseJsonCandidate<T>(match[1] || '');
|
||||||
return JSON.parse(jsonMatch[1].trim()) as T;
|
if (parsed !== null) return parsed;
|
||||||
} 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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
export interface RunReviewParams {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
agents: AgentType[]; // 可选1个或2个
|
agents: AgentType[]; // 可选1个或2个
|
||||||
|
editorialBaseStandard?: 'zh' | 'en';
|
||||||
userId: string;
|
userId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +135,7 @@ export interface RunReviewParams {
|
|||||||
export interface BatchRunParams {
|
export interface BatchRunParams {
|
||||||
taskIds: string[];
|
taskIds: string[];
|
||||||
agents: AgentType[];
|
agents: AgentType[];
|
||||||
|
editorialBaseStandard?: 'zh' | 'en';
|
||||||
userId: string;
|
userId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ interface ReviewJob {
|
|||||||
taskId: string;
|
taskId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
agents: AgentType[];
|
agents: AgentType[];
|
||||||
|
editorialBaseStandard?: 'zh' | 'en';
|
||||||
extractedText: string;
|
extractedText: string;
|
||||||
modelType: ModelType;
|
modelType: ModelType;
|
||||||
}
|
}
|
||||||
@@ -115,7 +116,7 @@ export async function registerReviewWorker() {
|
|||||||
|
|
||||||
// 注册审查Worker
|
// 注册审查Worker
|
||||||
jobQueue.process<ReviewJob>('rvw_review_task', async (job: Job<ReviewJob>) => {
|
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();
|
const startTime = Date.now();
|
||||||
|
|
||||||
logger.info('[reviewWorker] Processing review job', {
|
logger.info('[reviewWorker] Processing review job', {
|
||||||
@@ -123,6 +124,7 @@ export async function registerReviewWorker() {
|
|||||||
taskId,
|
taskId,
|
||||||
userId,
|
userId,
|
||||||
agents,
|
agents,
|
||||||
|
editorialBaseStandard,
|
||||||
textLength: extractedText.length,
|
textLength: extractedText.length,
|
||||||
useSkillsArchitecture: USE_SKILLS_ARCHITECTURE,
|
useSkillsArchitecture: USE_SKILLS_ARCHITECTURE,
|
||||||
});
|
});
|
||||||
@@ -188,6 +190,7 @@ export async function registerReviewWorker() {
|
|||||||
taskId,
|
taskId,
|
||||||
userId,
|
userId,
|
||||||
agents,
|
agents,
|
||||||
|
editorialBaseStandard,
|
||||||
extractedText,
|
extractedText,
|
||||||
existingTask?.filePath || '',
|
existingTask?.filePath || '',
|
||||||
existingTask?.fileName || 'unknown.docx',
|
existingTask?.fileName || 'unknown.docx',
|
||||||
@@ -229,7 +232,13 @@ export async function registerReviewWorker() {
|
|||||||
logger.info('[reviewWorker] Running editorial review (legacy)', { taskId });
|
logger.info('[reviewWorker] Running editorial review (legacy)', { taskId });
|
||||||
console.log(' 🔍 运行稿约规范性智能体...');
|
console.log(' 🔍 运行稿约规范性智能体...');
|
||||||
|
|
||||||
editorialResult = await reviewEditorialStandards(extractedText, modelType, userId);
|
editorialResult = await reviewEditorialStandards(
|
||||||
|
extractedText,
|
||||||
|
modelType,
|
||||||
|
userId,
|
||||||
|
undefined,
|
||||||
|
editorialBaseStandard
|
||||||
|
);
|
||||||
|
|
||||||
logger.info('[reviewWorker] Editorial review completed', {
|
logger.info('[reviewWorker] Editorial review completed', {
|
||||||
taskId,
|
taskId,
|
||||||
@@ -461,6 +470,7 @@ async function executeWithSkills(
|
|||||||
taskId: string,
|
taskId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
agents: AgentType[],
|
agents: AgentType[],
|
||||||
|
taskEditorialBaseStandard: 'zh' | 'en' | undefined,
|
||||||
extractedText: string,
|
extractedText: string,
|
||||||
filePath: string,
|
filePath: string,
|
||||||
fileName: string,
|
fileName: string,
|
||||||
@@ -524,28 +534,43 @@ async function executeWithSkills(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (cfg) {
|
if (cfg) {
|
||||||
|
const resolvedEditorialBaseStandard =
|
||||||
|
taskEditorialBaseStandard ??
|
||||||
|
(cfg.editorialBaseStandard === 'zh' ? 'zh' : inferredEditorialBase);
|
||||||
tenantRvwConfig = {
|
tenantRvwConfig = {
|
||||||
...cfg,
|
...cfg,
|
||||||
editorialBaseStandard: cfg.editorialBaseStandard === 'zh' ? 'zh' : inferredEditorialBase,
|
editorialBaseStandard: resolvedEditorialBaseStandard,
|
||||||
};
|
};
|
||||||
logger.info('[reviewWorker] 已加载租户审稿配置', {
|
logger.info('[reviewWorker] 已加载租户审稿配置', {
|
||||||
taskId,
|
taskId,
|
||||||
tenantId: taskWithTenant.tenantId,
|
tenantId: taskWithTenant.tenantId,
|
||||||
|
taskEditorialBaseStandard,
|
||||||
|
resolvedEditorialBaseStandard,
|
||||||
hasMethodologyPrompt: !!cfg.methodologyExpertPrompt,
|
hasMethodologyPrompt: !!cfg.methodologyExpertPrompt,
|
||||||
hasEditorialPrompt: !!cfg.editorialExpertPrompt,
|
hasEditorialPrompt: !!cfg.editorialExpertPrompt,
|
||||||
hasDataPrompt: !!cfg.dataForensicsExpertPrompt,
|
hasDataPrompt: !!cfg.dataForensicsExpertPrompt,
|
||||||
hasClinicalPrompt: !!cfg.clinicalExpertPrompt,
|
hasClinicalPrompt: !!cfg.clinicalExpertPrompt,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
const resolvedEditorialBaseStandard = taskEditorialBaseStandard ?? inferredEditorialBase;
|
||||||
tenantRvwConfig = {
|
tenantRvwConfig = {
|
||||||
editorialBaseStandard: inferredEditorialBase,
|
editorialBaseStandard: resolvedEditorialBaseStandard,
|
||||||
};
|
};
|
||||||
logger.info('[reviewWorker] 未找到租户审稿配置,回退系统基线', {
|
logger.info('[reviewWorker] 未找到租户审稿配置,回退系统基线', {
|
||||||
taskId,
|
taskId,
|
||||||
tenantId: taskWithTenant.tenantId,
|
tenantId: taskWithTenant.tenantId,
|
||||||
editorialBaseStandard: inferredEditorialBase,
|
taskEditorialBaseStandard,
|
||||||
|
editorialBaseStandard: resolvedEditorialBaseStandard,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else if (taskEditorialBaseStandard) {
|
||||||
|
tenantRvwConfig = {
|
||||||
|
editorialBaseStandard: taskEditorialBaseStandard,
|
||||||
|
};
|
||||||
|
logger.info('[reviewWorker] 使用任务级稿约基线语言', {
|
||||||
|
taskId,
|
||||||
|
editorialBaseStandard: taskEditorialBaseStandard,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建上下文(V4.0:注入租户配置实现 Hybrid Prompt)
|
// 构建上下文(V4.0:注入租户配置实现 Hybrid Prompt)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 🚀 AI临床研究平台 - 阿里云SAE最新真实状态记录
|
# 🚀 AI临床研究平台 - 阿里云SAE最新真实状态记录
|
||||||
|
|
||||||
> **文档用途**:记录阿里云SAE服务器最新真实状态 + 每次部署记录
|
> **文档用途**:记录阿里云SAE服务器最新真实状态 + 每次部署记录
|
||||||
> **最后更新**:2026-03-10
|
> **最后更新**:2026-03-16
|
||||||
> **维护人员**:开发团队
|
> **维护人员**:开发团队
|
||||||
> **说明**:本文档准确记录SAE上所有应用的当前状态,包括内网地址、镜像版本、用户名密码等关键资源信息
|
> **说明**:本文档准确记录SAE上所有应用的当前状态,包括内网地址、镜像版本、用户名密码等关键资源信息
|
||||||
|
|
||||||
@@ -11,10 +11,10 @@
|
|||||||
|
|
||||||
| 服务名称 | 部署状态 | 镜像版本 | 部署位置 | 最后更新时间 |
|
| 服务名称 | 部署状态 | 镜像版本 | 部署位置 | 最后更新时间 |
|
||||||
|---------|---------|---------|---------|-------------|
|
|---------|---------|---------|---------|-------------|
|
||||||
| **PostgreSQL数据库** | ✅ 运行中 | PostgreSQL 15 + 插件 | RDS | 2026-03-10 |
|
| **PostgreSQL数据库** | ✅ 运行中 | PostgreSQL 15 + 插件 | RDS | 2026-03-16 |
|
||||||
| **前端Nginx服务** | ✅ 运行中 | **v2.8** | SAE | 2026-03-10 |
|
| **前端Nginx服务** | ✅ 运行中 | **v2.9** | SAE | 2026-03-16 |
|
||||||
| **Python微服务** | ✅ 运行中 | **v1.2** | SAE | 2026-02-27 |
|
| **Python微服务** | ✅ 运行中 | **v1.2** | SAE | 2026-02-27 |
|
||||||
| **Node.js后端** | ✅ 运行中 | **v2.11** | SAE | 2026-03-10 |
|
| **Node.js后端** | ✅ 运行中 | **v2.12** | SAE | 2026-03-16 |
|
||||||
| **R统计引擎** | ✅ 运行中 | **v1.0.5** | SAE | 2026-03-09 |
|
| **R统计引擎** | ✅ 运行中 | **v1.0.5** | SAE | 2026-03-09 |
|
||||||
| **Dify AI服务** | ⚠️ 已废弃 | - | - | 使用pgvector替代 |
|
| **Dify AI服务** | ⚠️ 已废弃 | - | - | 使用pgvector替代 |
|
||||||
|
|
||||||
@@ -35,10 +35,10 @@
|
|||||||
|
|
||||||
| 仓库名称 | 最新版本 | 镜像大小 | VPC地址 |
|
| 仓库名称 | 最新版本 | 镜像大小 | VPC地址 |
|
||||||
|---------|---------|---------|---------|
|
|---------|---------|---------|---------|
|
||||||
| **python-extraction** | **v1.2** | ~1.1GB | `crpi-cd5ij4pjt65mweeo-vpc.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/python-extraction:v1.2` |
|
| **python-extraction** | **v1.3** | ~1.1GB | `crpi-cd5ij4pjt65mweeo-vpc.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/python-extraction:v1.3` |
|
||||||
| **ssa-r-statistics** | **v1.0.5** | ~2.1GB | `crpi-cd5ij4pjt65mweeo-vpc.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/ssa-r-statistics:v1.0.5` |
|
| **ssa-r-statistics** | **v1.0.6** | ~2.1GB | `crpi-cd5ij4pjt65mweeo-vpc.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/ssa-r-statistics:v1.0.6` |
|
||||||
| **ai-clinical_frontend-nginx** | **v2.8** | ~100MB | `crpi-cd5ij4pjt65mweeo-vpc.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/ai-clinical_frontend-nginx:v2.8` |
|
| **ai-clinical_frontend-nginx** | **v2.9** | ~100MB | `crpi-cd5ij4pjt65mweeo-vpc.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/ai-clinical_frontend-nginx:v2.9` |
|
||||||
| **backend-service** | **v2.11** | ~900MB | `crpi-cd5ij4pjt65mweeo-vpc.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/backend-service:v2.11` |
|
| **backend-service** | **v2.12** | ~900MB | `crpi-cd5ij4pjt65mweeo-vpc.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/backend-service:v2.12` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -129,8 +129,8 @@ postgresql://airesearch:Xibahe%40fengzhibo117@pgm-2zex1m2y3r23hdn5.pg.rds.aliyun
|
|||||||
|---------|------|------|-------|------|---------|---------|
|
|---------|------|------|-------|------|---------|---------|
|
||||||
| **r-statistics-test** | ✅ 运行中 | 1核2GB | 1 | 8080 | `http://172.17.197.26:8080` | **v1.0.5** |
|
| **r-statistics-test** | ✅ 运行中 | 1核2GB | 1 | 8080 | `http://172.17.197.26:8080` | **v1.0.5** |
|
||||||
| **python-extraction-test** | ✅ 运行中 | **2核4GB** | 1 | 8000 | `http://172.17.173.102:8000` | **v1.2** |
|
| **python-extraction-test** | ✅ 运行中 | **2核4GB** | 1 | 8000 | `http://172.17.173.102:8000` | **v1.2** |
|
||||||
| **nodejs-backend-test** | ✅ 运行中 | **2核4GB** | 1 | 3001 | `http://172.17.173.110:3001` | **v2.11** |
|
| **nodejs-backend-test** | ✅ 运行中 | **2核4GB** | 1 | 3001 | `http://172.17.197.30:3001` | **v2.12** |
|
||||||
| **frontend-nginx-service** | ✅ 运行中 | 0.5核1GB | 1 | 80 | `http://172.17.197.28:80` | **v2.8** |
|
| **frontend-nginx-service** | ✅ 运行中 | 0.5核1GB | 1 | 80 | `http://172.17.197.31:80` | **v2.9** |
|
||||||
|
|
||||||
**环境变量配置**:
|
**环境变量配置**:
|
||||||
|
|
||||||
@@ -191,7 +191,7 @@ LEGACY_MYSQL_DATABASE=xzyx_online
|
|||||||
|
|
||||||
**前端Nginx(frontend-nginx-service)**:
|
**前端Nginx(frontend-nginx-service)**:
|
||||||
```bash
|
```bash
|
||||||
BACKEND_SERVICE_HOST=172.17.173.110
|
BACKEND_SERVICE_HOST=172.17.197.30
|
||||||
BACKEND_SERVICE_PORT=3001
|
BACKEND_SERVICE_PORT=3001
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -259,19 +259,19 @@ TEMP_DIR=/tmp/extraction_service
|
|||||||
|
|
||||||
### 3.2 前端Nginx服务
|
### 3.2 前端Nginx服务
|
||||||
|
|
||||||
**当前部署版本**:v2.8
|
**当前部署版本**:v2.9
|
||||||
|
|
||||||
**镜像信息**:
|
**镜像信息**:
|
||||||
- **仓库名称**:`ai-clinical_frontend-nginx`
|
- **仓库名称**:`ai-clinical_frontend-nginx`
|
||||||
- **镜像版本**:`v2.8` ✅(当前部署版本)
|
- **镜像版本**:`v2.9` ✅(当前部署版本)
|
||||||
- **镜像大小**:约50MB
|
- **镜像大小**:约50MB
|
||||||
- **基础镜像**:`nginx:alpine`
|
- **基础镜像**:`nginx:alpine`
|
||||||
- **构建时间**:2026-03-05
|
- **构建时间**:2026-03-16
|
||||||
- **镜像摘要**:sha256:6cb9e8be2bcd21fd8ccfe09dabdbb04d64c252fd9a5b5b3a55d5ba6fb52dcde1
|
- **镜像摘要**:sha256:68ec5521b9322d3a99770f702ed604d5af58dfac7eaeb5e2bba763bd5dfe7a10
|
||||||
|
|
||||||
**部署状态**:
|
**部署状态**:
|
||||||
- ✅ 已成功部署到SAE(2026-03-05)
|
- ✅ 已成功部署到SAE(2026-03-05)
|
||||||
- ✅ 服务运行正常(内网地址:http://172.17.197.28:80)
|
- ✅ 服务运行正常(内网地址:http://172.17.197.31:80)
|
||||||
- ✅ 企业微信域名验证文件已部署(WW_verify_YnhsQBwI0ARnNoG0.txt)
|
- ✅ 企业微信域名验证文件已部署(WW_verify_YnhsQBwI0ARnNoG0.txt)
|
||||||
|
|
||||||
**v2.5版本更新内容**:
|
**v2.5版本更新内容**:
|
||||||
@@ -293,16 +293,16 @@ AIclinicalresearch/frontend-v2/
|
|||||||
|
|
||||||
### 3.3 Node.js后端服务
|
### 3.3 Node.js后端服务
|
||||||
|
|
||||||
**当前部署版本**:v2.11
|
**当前部署版本**:v2.12
|
||||||
|
|
||||||
**镜像信息**:
|
**镜像信息**:
|
||||||
- **仓库名称**:`backend-service`
|
- **仓库名称**:`backend-service`
|
||||||
- **镜像版本**:`v2.11` ✅(已部署)
|
- **镜像版本**:`v2.12` ✅(已部署)
|
||||||
- **镜像大小**:~838MB
|
- **镜像大小**:~838MB
|
||||||
- **基础镜像**:`node:alpine`
|
- **基础镜像**:`node:alpine`
|
||||||
- **构建时间**:2026-03-05
|
- **构建时间**:2026-03-16
|
||||||
- **构建策略**:改进版方案B(本地编译+Docker打包)
|
- **构建策略**:改进版方案B(本地编译+Docker打包)
|
||||||
- **镜像摘要**:sha256:45886ffd90edbaf6b9a57c1938f14b076fdae175b5d8e53caebabdd8c7ef8b7c
|
- **镜像摘要**:sha256:2a0730dc1b54f82450a1b11881572430918621eef310c6647f55e7225b9c027d
|
||||||
|
|
||||||
**技术架构**:
|
**技术架构**:
|
||||||
- **Node.js版本**:22.x
|
- **Node.js版本**:22.x
|
||||||
@@ -314,7 +314,7 @@ AIclinicalresearch/frontend-v2/
|
|||||||
|
|
||||||
**部署状态**:
|
**部署状态**:
|
||||||
- ✅ 已成功部署到SAE(2026-03-05)
|
- ✅ 已成功部署到SAE(2026-03-05)
|
||||||
- ✅ 服务运行正常(内网地址:http://172.17.173.110:3001)
|
- ✅ 服务运行正常(内网地址:http://172.17.197.30:3001)
|
||||||
- ✅ 健康检查通过
|
- ✅ 健康检查通过
|
||||||
|
|
||||||
**Git文件结构**:
|
**Git文件结构**:
|
||||||
@@ -364,6 +364,36 @@ AIclinicalresearch/extraction_service/
|
|||||||
|
|
||||||
## 🔄 四、部署历史记录
|
## 🔄 四、部署历史记录
|
||||||
|
|
||||||
|
### 2026-03-16(0316部署 - RVW V4.0 全量发布 + JSON 稳态修复)
|
||||||
|
|
||||||
|
#### 部署概览
|
||||||
|
- **部署时间**:2026-03-16
|
||||||
|
- **部署范围**:数据库(DB-1~DB-4)+ Node.js后端 + 前端Nginx + 镜像仓库更新
|
||||||
|
- **主要变更**:RVW 租户化全链路上线、期刊配置中心 MVP、JSON 解析稳态增强、中英稿约语言任务级选择
|
||||||
|
|
||||||
|
#### 数据库更新
|
||||||
|
- ✅ 迁移执行:`20260311_add_ssa_agent_step_seed_fields`
|
||||||
|
- ✅ 迁移执行:`20260314_add_tenant_rvw_configs`
|
||||||
|
- ✅ 迁移执行:`20260314_add_tenant_id_to_review_tasks`
|
||||||
|
- ✅ 迁移执行:`20260315_journal_config_center_mvp`
|
||||||
|
- ✅ 迁移状态:RDS 30/30,Schema Up To Date
|
||||||
|
|
||||||
|
#### Node.js后端更新(v2.11 → v2.12)
|
||||||
|
- ✅ 镜像推送:`backend-service:v2.12`
|
||||||
|
- ✅ 内网地址变更:`172.17.197.29` → `172.17.197.30`
|
||||||
|
- ✅ 线上问题修复:稿约规范性 + 方法学 JSON 解析稳态加固(BE-10)
|
||||||
|
|
||||||
|
#### 前端Nginx更新(v2.8 → v2.9)
|
||||||
|
- ✅ 镜像推送:`ai-clinical_frontend-nginx:v2.9`
|
||||||
|
- ✅ 内网地址变更:`172.17.173.114` → `172.17.197.31`
|
||||||
|
- ✅ 新增任务级“稿约基线语言(中文/英文)”选择(FE-9)
|
||||||
|
|
||||||
|
#### 环境变量同步
|
||||||
|
- ✅ `nodejs-backend-test`:`RVW_FORENSICS_RULES_ENABLED=false`
|
||||||
|
- ✅ `frontend-nginx-service`:`BACKEND_SERVICE_HOST=172.17.197.30`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### 2026-03-10(0310部署 - 用户直授权限体系 + 运营看板增强)
|
### 2026-03-10(0310部署 - 用户直授权限体系 + 运营看板增强)
|
||||||
|
|
||||||
#### 部署概览
|
#### 部署概览
|
||||||
@@ -779,5 +809,5 @@ AIclinicalresearch/extraction_service/
|
|||||||
---
|
---
|
||||||
|
|
||||||
> **提示**:本文档记录SAE服务器的最新真实状态,每次部署后必须更新!
|
> **提示**:本文档记录SAE服务器的最新真实状态,每次部署后必须更新!
|
||||||
> **最后更新**:2026-03-10
|
> **最后更新**:2026-03-16
|
||||||
> **当前版本**:前端v2.8 | 后端v2.11 | Python v1.2 | R统计v1.0.5 | PostgreSQL 15
|
> **当前版本**:前端v2.9 | 后端v2.12 | Python v1.2 | R统计v1.0.5 | PostgreSQL 15
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
> **用途**: 开发过程中实时记录所有待部署的变更,下次部署时按此清单逐项执行
|
> **用途**: 开发过程中实时记录所有待部署的变更,下次部署时按此清单逐项执行
|
||||||
> **维护规则**: 每次修改 Schema / 新增依赖 / 改配置时,**立即**在此文档追加记录
|
> **维护规则**: 每次修改 Schema / 新增依赖 / 改配置时,**立即**在此文档追加记录
|
||||||
> **Cursor Rule**: `.cursor/rules/deployment-change-tracking.mdc` 会自动提醒
|
> **Cursor Rule**: `.cursor/rules/deployment-change-tracking.mdc` 会自动提醒
|
||||||
> **最后清零**: 2026-03-10(0310 部署完成后清零)
|
> **最后清零**: 2026-03-16(0316 部署完成后清零)
|
||||||
> **本次变更**: 已新增待部署项(2026-03-11,含 Agent 严格分步执行模式)
|
> **本次变更**: 无(当前待部署清单已清零)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -16,64 +16,43 @@
|
|||||||
|
|
||||||
| # | 变更内容 | 迁移文件 | 优先级 | 备注 |
|
| # | 变更内容 | 迁移文件 | 优先级 | 备注 |
|
||||||
|---|---------|---------|--------|------|
|
|---|---------|---------|--------|------|
|
||||||
| DB-1 | SSA Agent 执行记录新增分步执行与种子审计字段(`step_results/current_step/seed_audit`) | `20260311_add_ssa_agent_step_seed_fields` | 高 | 按数据库规范生成;Shadow DB 失败后采用降级流程产出 SQL,并已人工收敛为仅本次字段变更 |
|
| — | *暂无* | | | |
|
||||||
| DB-2 | RVW V4.0:新增 `platform_schema.tenant_rvw_configs` 表(每期刊独立审稿配置,含4维提示词+Handlebars模板) | `20260314_add_tenant_rvw_configs` | 高 | ⚠️ 部署前无需前置条件;使用降级流程手动创建迁移SQL,需执行 `prisma migrate resolve --applied 20260314_add_tenant_rvw_configs` 标记为已应用 |
|
|
||||||
| DB-3 | RVW V4.0:`rvw_schema.review_tasks` 新增 `tenant_id` 字段 + 索引(历史数据平滑回填两步走) | `20260314_add_tenant_id_to_review_tasks` | 高 | ⚠️ **部署前必须先确认** `platform_schema.tenants` 中存在 `code='yanjiu'` 的主站默认租户;迁移会自动将历史记录回填为该租户ID;需执行 `prisma migrate resolve --applied 20260314_add_tenant_id_to_review_tasks` 标记为已应用 |
|
|
||||||
| DB-4 | RVW V4.0:期刊配置中心 MVP 一次性补齐字段(`tenants` 新增期刊字段;`tenant_rvw_configs` 升级为 4 维 Prompt+Template;`TenantType` 新增 `JOURNAL`) | `20260315_journal_config_center_mvp` | 高 | ⚠️ `prisma migrate dev` 因历史 shadow DB 迁移失败触发降级流程,已按规范收敛手工 SQL;部署后执行 `prisma migrate resolve --applied 20260315_journal_config_center_mvp` |
|
|
||||||
|
|
||||||
### 后端变更 (Node.js)
|
### 后端变更 (Node.js)
|
||||||
|
|
||||||
| # | 变更内容 | 涉及文件 | 需要操作 | 备注 |
|
| # | 变更内容 | 涉及文件 | 需要操作 | 备注 |
|
||||||
|---|---------|---------|---------|------|
|
|---|---------|---------|---------|------|
|
||||||
| BE-1 | SSA Agent 执行链路增加确定性种子注入、错误分类、seed 审计透传 + 分步执行事件(step_*) | `backend/src/modules/ssa/services/ChatHandlerService.ts`, `backend/src/modules/ssa/services/CodeRunnerService.ts`, `backend/src/modules/ssa/services/AgentCoderService.ts` | 重新构建镜像 | 与 DB-1 配套上线,确保执行可复现与可追溯 |
|
| — | *暂无* | | | |
|
||||||
| BE-2 | 新增 Agent 计划参数编辑接口 `PATCH /api/v1/ssa/agent-executions/:executionId/plan-params`(复用参数约束配置) | `backend/src/modules/ssa/routes/agent-execution.routes.ts`, `backend/src/modules/ssa/index.ts` | 重新构建镜像 | Phase 5A.5 后端入口,限制 `plan_pending` 状态可编辑 |
|
|
||||||
| BE-3 | Agent 切换为严格分步模式:`confirm_plan` 不生成整段代码,执行阶段统一按步骤生成 + 失败后依赖短路跳过后续步骤 | `backend/src/modules/ssa/services/ChatHandlerService.ts` | 重新构建镜像 | 修复“第3步失败仍尝试第4步”问题,降低无效重试与误导性结果 |
|
|
||||||
| BE-4 | R 代码语法修复器纠正 `} else` 处理策略,避免引入 `unexpected 'else'` | `backend/src/modules/ssa/services/CodeRunnerService.ts` | 重新构建镜像 | 修复线上语法错误噪声,减少重试失败 |
|
|
||||||
| BE-5 | RVW 审稿通道改造:4 通道 Prompt 动静分离(业务提示词可编辑 + 系统协议固化)+ 方法学/稿约 JSON 结构化修复兜底 + DataForensics 默认切换为 LLM-only(规则验证默认关闭) | `backend/src/modules/rvw/services/promptProtocols.ts`, `backend/src/modules/rvw/services/editorialService.ts`, `backend/src/modules/rvw/services/methodologyService.ts`, `backend/src/modules/rvw/services/clinicalService.ts`, `backend/src/modules/rvw/skills/library/DataForensicsSkill.ts`, `backend/src/modules/rvw/skills/core/types.ts`, `backend/src/common/document/ExtractionClient.ts`, `backend/src/common/prompt/prompt.fallbacks.ts` | 重新构建镜像 | 解决运营端改 Prompt 导致 JSON 解析失败;数据侦探默认仅“表格提取+LLM判断”,规则代码保留可回切 |
|
|
||||||
| BE-6 | RVW V4.0 Phase 1:Prisma Schema 新增 TenantRvwConfig 模型 + ReviewTask.tenantId 字段 + RVW租户中间件(rvwTenantMiddleware,slug到UUID解析+tenant_members校验+缓存)+ FastifyRequest 扩展 tenantId/tenant 字段 | `backend/prisma/schema.prisma`, `backend/src/modules/rvw/middleware/rvwTenantMiddleware.ts`, `backend/src/common/auth/auth.middleware.ts` | 重新构建镜像 + 执行 DB-2/DB-3 迁移 | 与 DB-2/DB-3 配套上线;Prisma Client 已重新生成 |
|
|
||||||
| BE-7 | RVW V4.0 Phase 2:RVW Config CRUD API(GET/PUT `/api/admin/tenants/:id/rvw-config`)+ Handlebars 渲染引擎(Zod 校验 + 默认模板)+ SkillExecutor 按租户装配 Hybrid Prompt | `backend/src/modules/admin/rvw-config/rvwConfigController.ts`, `backend/src/modules/admin/rvw-config/rvwConfigService.ts`, `backend/src/modules/admin/rvw-config/rvwConfigRoutes.ts`, `backend/src/modules/rvw/services/rvwReportRenderer.ts`, `backend/src/modules/rvw/workers/reviewWorker.ts`, `backend/src/index.ts` | 重新构建镜像 | 与 BE-6/DB-2/DB-3 配套上线 |
|
|
||||||
| BE-8 | RVW V4.0 Phase 3:后端 CORS 配置新增 x-tenant-id 白名单(多租户 Header 跨域支持) | `backend/src/index.ts` | 重新构建镜像 | ⚠️ 缺少此项会导致浏览器预检请求拦截 x-tenant-id,整个多租户功能失效 |
|
|
||||||
| BE-9 | RVW V4.0 Phase 4(期刊配置中心 MVP):新增 `/api/admin/journal-configs` 专用 API;租户模型扩展期刊字段;RVW 执行链路接入最小配置适配(`tenantCustom ?? systemDefault`),Editorial 支持 `zh/en` 基线路由;创建/更新为 `JOURNAL` 时自动兜底开通 `RVW` 模块 | `backend/src/modules/admin/journal-config/*`, `backend/src/modules/admin/services/tenantService.ts`, `backend/src/modules/admin/types/tenant.types.ts`, `backend/src/modules/admin/rvw-config/*`, `backend/src/modules/rvw/workers/reviewWorker.ts`, `backend/src/modules/rvw/skills/library/*`, `backend/src/modules/rvw/services/editorialService.ts`, `backend/src/modules/rvw/services/clinicalService.ts`, `backend/src/common/prompt/prompt.fallbacks.ts`, `backend/src/index.ts` | 重新构建镜像 + 执行 DB-4 迁移 | 与 FE-7 联动上线,满足「独立一级菜单 + 中英文基线 + 继承/覆盖」MVP 范围,并避免新期刊用户登录后无 RVW 模块权限 |
|
|
||||||
|
|
||||||
### 前端变更
|
### 前端变更
|
||||||
|
|
||||||
| # | 变更内容 | 涉及文件 | 需要操作 | 备注 |
|
| # | 变更内容 | 涉及文件 | 需要操作 | 备注 |
|
||||||
|---|---------|---------|---------|------|
|
|---|---------|---------|---------|------|
|
||||||
| FE-1 | Agent 通道接入 step_* SSE 事件并展示分步执行状态(兼容旧 code_* 事件) | `frontend-v2/src/modules/ssa/hooks/useSSAChat.ts`, `frontend-v2/src/modules/ssa/components/AgentCodePanel.tsx`, `frontend-v2/src/modules/ssa/types/index.ts`, `frontend-v2/src/modules/ssa/stores/ssaStore.ts` | 重新构建镜像 | 右侧工作区可见每步状态/错误/耗时,便于排障 |
|
| — | *暂无* | | | |
|
||||||
| FE-2 | Agent 计划阶段复用 QPER 变量编辑控件(单变量/多变量)并接入保存、确认前自动保存 | `frontend-v2/src/modules/ssa/components/AgentCodePanel.tsx`, `frontend-v2/src/modules/ssa/components/WorkflowTimeline.tsx`, `frontend-v2/src/modules/ssa/components/SSAWorkspacePane.tsx` | 重新构建镜像 | 对接 `PATCH /agent-executions/:executionId/plan-params`,实现 5A.5 前后端闭环 |
|
|
||||||
| FE-3 | Agent 工作区增强:在分步状态下可展开查看每步已生成结果(`reportBlocks`),并兼容严格分步模式下的 `code_pending` 空代码预览 | `frontend-v2/src/modules/ssa/components/AgentCodePanel.tsx`, `frontend-v2/src/modules/ssa/hooks/useSSAChat.ts` | 重新构建镜像 | 修复“有结果但代码下方不可见”与状态显示误导问题 |
|
|
||||||
| FE-4 | RVW V4.0 Phase 2:TenantDetailPage 新增「智能审稿配置」Tab(4 Panel:稿约规范占位 / 方法学Prompt+Handlebars模板 / 数据验证深度L1-L3 / 临床FINER权重)+ tenantApi 新增 fetchRvwConfig/saveRvwConfig | `frontend-v2/src/pages/admin/tenants/TenantDetailPage.tsx`, `frontend-v2/src/pages/admin/tenants/api/tenantApi.ts` | 重新构建镜像 | 与 BE-7/DB-2/DB-3 配套上线 |
|
|
||||||
| FE-5 | RVW V4.0 Phase 3:新增 TenantPortalLayout 极简期刊门户布局 + App.tsx 新增 /:tenantSlug/rvw/* 路由(期刊专属 URL)+ LoginPage.tsx 修复跳转逻辑(读取 ?redirect= 查询参数 + 租户默认落地页 /:tenantSlug/rvw) | `frontend-v2/src/framework/layout/TenantPortalLayout.tsx`, `frontend-v2/src/App.tsx`, `frontend-v2/src/pages/LoginPage.tsx` | 重新构建镜像 | 与 BE-6/BE-7/BE-8 配套上线;实现期刊租户完整访问路径 /jtim → /jtim/rvw |
|
|
||||||
| FE-6 | RVW V4.0 租户门户体验收敛:上传按钮稳定触发(原生文件选择器 API + input 回退)、执行审稿后跳转旧版过程页(复用 `TaskDetail`)、列表四维状态图标修复(完成态正确显示绿勾/警告) | `frontend-v2/src/modules/rvw/pages/TenantDashboard.tsx`, `frontend-v2/src/modules/rvw/pages/TenantTaskDetail.tsx` | 重新构建镜像 | 已本地联调通过;上线后重点回归 `/:tenant/login -> /:tenant/rvw` 主流程 |
|
|
||||||
| FE-7 | RVW V4.0 期刊配置中心 MVP:ADMIN 新增独立一级菜单“期刊配置中心”,新增列表页/详情页(基础信息 + 4 维审稿配置),并对齐新 API DTO(`journalLanguage`、`editorialBaseStandard`、Prompt/Handlebars 字段) | `frontend-v2/src/framework/layout/AdminLayout.tsx`, `frontend-v2/src/App.tsx`, `frontend-v2/src/pages/admin/journal-configs/*`, `frontend-v2/src/pages/admin/tenants/api/tenantApi.ts`, `frontend-v2/src/pages/admin/tenants/TenantDetailPage.tsx` | 重新构建镜像 | 与 BE-9/DB-4 配套上线;MVP 阶段先跑通配置闭环,品牌视觉字段先存储后逐步渲染 |
|
|
||||||
| FE-8 | 登录路径策略统一为单一路径:仅保留 `/:tenantCode/login`,未登录重定向与退出登录统一 `/:tenant/login`,开发/生产同路径仅域名不同 | `frontend-v2/src/App.tsx`, `frontend-v2/src/framework/router/RouteGuard.tsx`, `frontend-v2/src/framework/layout/TenantPortalLayout.tsx`, `frontend-v2/src/pages/TenantLoginPage.tsx`, `frontend-v2/src/pages/LoginPage.tsx` | 重新构建镜像 | 对齐生产目标链接 `review.xunzhengyixue.com/test-qikan01`;不再保留 `/t/:tenantCode/login` 路径,需同步更新测试文档/书签 |
|
|
||||||
|
|
||||||
### Python 微服务变更
|
### Python 微服务变更
|
||||||
|
|
||||||
| # | 变更内容 | 涉及文件 | 需要操作 | 备注 |
|
| # | 变更内容 | 涉及文件 | 需要操作 | 备注 |
|
||||||
|---|---------|---------|---------|------|
|
|---|---------|---------|---------|------|
|
||||||
| PY-1 | Forensics API 新增 `EXTRACT_ONLY` 模式并默认仅提取表格(不执行 L1/L2 规则校验) | `extraction_service/forensics/api.py`, `extraction_service/forensics/types.py` | 重新构建镜像 | 与后端 RVW LLM-only 路径配套,避免规则与 LLM 双轨冲突 |
|
| — | *暂无* | | | |
|
||||||
|
|
||||||
### R 统计引擎变更
|
### R 统计引擎变更
|
||||||
|
|
||||||
| # | 变更内容 | 涉及文件 | 需要操作 | 备注 |
|
| # | 变更内容 | 涉及文件 | 需要操作 | 备注 |
|
||||||
|---|---------|---------|---------|------|
|
|---|---------|---------|---------|------|
|
||||||
| R-1 | execute-code 端点升级为语法+安全双层预检,新增 E_SECURITY 与运行时高危函数拦截 | `r-statistics-service/plumber.R` | 重新构建镜像 | 阻断 system/eval/source/file.remove/setwd 等风险调用 |
|
| — | *暂无* | | | |
|
||||||
|
|
||||||
### 环境变量 / 配置变更
|
### 环境变量 / 配置变更
|
||||||
|
|
||||||
| # | 变更内容 | 服务 | 变量名 | 备注 |
|
| # | 变更内容 | 服务 | 变量名 | 备注 |
|
||||||
|---|---------|------|--------|------|
|
|---|---------|------|--------|------|
|
||||||
| ENV-1 | 新增 RVW 数据侦探规则引擎开关(默认关闭) | nodejs-backend-test / nodejs-backend-prod | `RVW_FORENSICS_RULES_ENABLED=false` | `false`=仅表格提取+LLM判断(推荐);如需恢复规则验证可设为 `true` |
|
| — | *暂无* | | | |
|
||||||
|
|
||||||
### 基础设施变更
|
### 基础设施变更
|
||||||
|
|
||||||
| # | 变更内容 | 范围 | 备注 |
|
| # | 变更内容 | 范围 | 备注 |
|
||||||
|---|---------|------|------|
|
|---|---------|------|------|
|
||||||
| INF-1 | 前端 Nginx 增加 SPA 深链回退(`try_files $uri /index.html`),并确保 `/api/` 路由优先反代后端(不能被 index.html 吞掉) | frontend-nginx-service / ingress | ⚠️ 否则直接访问 `review.xunzhengyixue.com/test-qikan01` 或 `.../test-qikan01/login` 会 404;部署后需在无缓存浏览器验证 |
|
| — | *暂无* | | |
|
||||||
| INF-2 | 前端 Docker 构建与发布注意:确认镜像内 Nginx 配置已包含深链规则、并清理旧静态资源缓存(CDN/浏览器)后再灰度 | frontend-v2 build/deploy pipeline | 建议发布后先执行硬刷新(Ctrl+F5)与隐身窗口验证,避免旧 bundle 缓存导致仍跳旧路径 |
|
|
||||||
| INF-3 | SAE 接入 RVW 多租户链路:`review.xunzhengyixue.com` 仅负责流量入口,实际租户/模块判定由应用完成(`/:tenantCode/login`、`/:tenantSlug/rvw` + `x-tenant-id` + `tenant_members` + `tenant_modules`) | frontend SAE + backend SAE + ingress/SLB | ⚠️ SAE 不会自动“识别审稿模块”;必须保证前端路由命中、`/api` 正确转发后端、后端开启 RVW 路由与租户中间件;并确认租户已开通 `RVW` 且用户在该租户下有成员关系 |
|
|
||||||
| INF-4 | 前端镜像构建发布标准流程(生产):安装依赖→构建 `frontend-v2/dist`→打包 Nginx 镜像→推送镜像仓库→SAE 更新版本→灰度验证→全量发布 | frontend-v2 build/deploy pipeline | 推荐命令:`npm ci && npm run build`(在 `frontend-v2` 目录);镜像发布后必须验证 `review.xunzhengyixue.com/{tenant}/login`、`/{tenant}/rvw`、`/api/v1/auth/verification-code` 三条链路可用 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -99,6 +78,14 @@
|
|||||||
- `验证码发送成功 + 倒计时启动 + 登录成功` 需完整通过
|
- `验证码发送成功 + 倒计时启动 + 登录成功` 需完整通过
|
||||||
- 弱网场景若偶发失败,先排查网络/网关,再看后端日志;前端已增加“发送中”防重入保护
|
- 弱网场景若偶发失败,先排查网络/网关,再看后端日志;前端已增加“发送中”防重入保护
|
||||||
|
|
||||||
|
5. **中英稿约语言链路验证(新增)**
|
||||||
|
- 在任务发起弹窗中确认“稿约基线语言”默认值与租户语言一致(中文期刊默认中文,英文期刊默认英文)
|
||||||
|
- 手动切换语言后执行审稿,确认稿约模块按所选语言输出,且任务状态不出现解析失败导致的 `partial_completed`
|
||||||
|
|
||||||
|
6. **JSON 解析稳态验证(新增)**
|
||||||
|
- 分别用中文/英文稿件各执行至少 3 次“稿约规范性 + 方法学”组合,确认无 `无法从LLM响应中解析JSON` / `section repair invalid`
|
||||||
|
- 如出现兜底修复日志(warn)但任务完成,可判定为“已自动恢复”;若连续失败,再排查模型配额/网络超时/提示词内容
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 记录模板
|
## 记录模板
|
||||||
@@ -129,6 +116,19 @@
|
|||||||
|
|
||||||
## 历史(已部署,仅供追溯)
|
## 历史(已部署,仅供追溯)
|
||||||
|
|
||||||
|
### 0316 部署已清零项
|
||||||
|
|
||||||
|
| # | 变更内容 | 部署日期 | 结果 |
|
||||||
|
|---|---------|---------|------|
|
||||||
|
| DB | DB-1~DB-4 全量完成(SSA 分步审计字段 + RVW 租户配置 + review_tasks.tenant_id 回填 + JOURNAL/MVP 字段) | 2026-03-16 | ✅ |
|
||||||
|
| BE | 后端 v2.11 → v2.12(含 BE-10 JSON 解析稳态加固与观测日志) | 2026-03-16 | ✅ |
|
||||||
|
| FE | 前端 v2.8 → v2.9(含 FE-9 稿约基线语言任务级选择) | 2026-03-16 | ✅ |
|
||||||
|
| PY | Python 提取服务镜像构建并推送 v1.3(与 RVW LLM-only 路径配套) | 2026-03-16 | ✅ |
|
||||||
|
| R | R 统计引擎镜像构建并推送 v1.0.6(语法+安全预检) | 2026-03-16 | ✅ |
|
||||||
|
| ENV | nodejs-backend-test 新增 `RVW_FORENSICS_RULES_ENABLED=false` | 2026-03-16 | ✅ |
|
||||||
|
| ENV | frontend-nginx-service: `BACKEND_SERVICE_HOST` 更新为 `172.17.197.30` | 2026-03-16 | ✅ |
|
||||||
|
| INF | `review.xunzhengyixue.com` 链路发布:深链回退 + `/api` 反代优先级 + 租户路由可用 | 2026-03-16 | ✅ |
|
||||||
|
|
||||||
### 0310 部署已清零项
|
### 0310 部署已清零项
|
||||||
|
|
||||||
| # | 变更内容 | 部署日期 | 结果 |
|
| # | 变更内容 | 部署日期 | 结果 |
|
||||||
|
|||||||
73
docs/05-部署文档/0316部署/01-部署完成总结.md
Normal file
73
docs/05-部署文档/0316部署/01-部署完成总结.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# 0316 部署完成总结(v1.0)
|
||||||
|
|
||||||
|
> **部署日期**:2026-03-16
|
||||||
|
> **部署环境**:阿里云 SAE 测试环境(cn-beijing:test-airesearch)
|
||||||
|
> **部署主题**:RVW V4.0 全量发布 + JSON 解析稳态修复二次发布
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、部署范围
|
||||||
|
|
||||||
|
### 1) 数据库(RDS PostgreSQL)
|
||||||
|
- 已执行 DB-1~DB-4 迁移并完成状态对齐:
|
||||||
|
- `20260311_add_ssa_agent_step_seed_fields`
|
||||||
|
- `20260314_add_tenant_rvw_configs`
|
||||||
|
- `20260314_add_tenant_id_to_review_tasks`
|
||||||
|
- `20260315_journal_config_center_mvp`
|
||||||
|
- 已完成 `prisma migrate resolve --applied` 标记,迁移链路一致。
|
||||||
|
- 已补齐默认租户:`code='yanjiu'`,并完成 `review_tasks.tenant_id` 历史回填(空值为 0)。
|
||||||
|
|
||||||
|
### 2) 镜像构建与推送(ACR)
|
||||||
|
- 后端:`backend-service:v2.12`
|
||||||
|
- 前端:`ai-clinical_frontend-nginx:v2.9`
|
||||||
|
- Python:`python-extraction:v1.3`
|
||||||
|
- R:`ssa-r-statistics:v1.0.6`
|
||||||
|
|
||||||
|
### 3) SAE 发布结果(本轮重点)
|
||||||
|
- `nodejs-backend-test`:`172.17.197.30`
|
||||||
|
- `frontend-nginx-service`:`172.17.197.31`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、核心修复说明(本轮二次发布)
|
||||||
|
|
||||||
|
### 后端 JSON 解析稳态(BE-10)
|
||||||
|
- 针对 RVW 稿约规范性与方法学输出,增强多策略 JSON 解析与二次严格修复。
|
||||||
|
- 增加解析失败观测日志(长度、预览),便于线上快速定位。
|
||||||
|
- 目标问题:`无法从LLM响应中解析JSON`、`section repair invalid`。
|
||||||
|
|
||||||
|
### 前端联动能力(FE-9)
|
||||||
|
- 任务发起弹窗新增“稿约基线语言(中文/英文)”任务级选择。
|
||||||
|
- 优先级规则:任务级显式选择 > 租户默认语言。
|
||||||
|
- 用于覆盖中英稿件混合审稿场景,降低提示词与输出格式错配风险。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、环境变量与配置
|
||||||
|
|
||||||
|
- Node.js 服务新增:
|
||||||
|
- `RVW_FORENSICS_RULES_ENABLED=false`
|
||||||
|
- 前端 Nginx 服务更新:
|
||||||
|
- `BACKEND_SERVICE_HOST=172.17.197.30`
|
||||||
|
- `BACKEND_SERVICE_PORT=3001`
|
||||||
|
- `review.xunzhengyixue.com` 路由链路依赖:
|
||||||
|
- SPA 深链回退(`try_files ... /index.html`)
|
||||||
|
- `/api/` 反代优先级高于 SPA 回退
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、验收结论
|
||||||
|
|
||||||
|
- 数据库结构与迁移链路:✅ 完成
|
||||||
|
- 后端与前端二次发布:✅ 完成
|
||||||
|
- JSON 解析稳态修复版本已上线:✅ 完成
|
||||||
|
- 待部署清单:✅ 已清零并归档到 0316 历史
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、回滚建议
|
||||||
|
|
||||||
|
若出现异常,建议按以下顺序回滚:
|
||||||
|
1. 前端回滚到 `v2.8`
|
||||||
|
2. 后端回滚到 `v2.11`
|
||||||
|
3. 数据库不建议直接反向 DDL;优先应用层回滚。如必须回退,使用部署前备份恢复并复核迁移基线。
|
||||||
@@ -2,7 +2,13 @@
|
|||||||
* RVW模块API
|
* RVW模块API
|
||||||
*/
|
*/
|
||||||
import apiClient from '../../../common/api/axios';
|
import apiClient from '../../../common/api/axios';
|
||||||
import type { ReviewTask, ReviewReport, ApiResponse, AgentType } from '../types';
|
import type {
|
||||||
|
ReviewTask,
|
||||||
|
ReviewReport,
|
||||||
|
ApiResponse,
|
||||||
|
AgentType,
|
||||||
|
EditorialBaseStandard,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
const API_BASE = '/api/v1/rvw';
|
const API_BASE = '/api/v1/rvw';
|
||||||
|
|
||||||
@@ -45,8 +51,15 @@ export async function getTaskReport(taskId: string): Promise<ReviewReport> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 运行审查任务(返回jobId供轮询)
|
// 运行审查任务(返回jobId供轮询)
|
||||||
export async function runTask(taskId: string, agents: AgentType[]): Promise<{ taskId: string; jobId: string }> {
|
export async function runTask(
|
||||||
const response = await apiClient.post<ApiResponse<{ taskId: string; jobId: string }>>(`${API_BASE}/tasks/${taskId}/run`, { agents });
|
taskId: string,
|
||||||
|
agents: AgentType[],
|
||||||
|
editorialBaseStandard?: EditorialBaseStandard
|
||||||
|
): Promise<{ taskId: string; jobId: string }> {
|
||||||
|
const response = await apiClient.post<ApiResponse<{ taskId: string; jobId: string }>>(`${API_BASE}/tasks/${taskId}/run`, {
|
||||||
|
agents,
|
||||||
|
editorialBaseStandard,
|
||||||
|
});
|
||||||
if (!response.data.success) {
|
if (!response.data.success) {
|
||||||
throw new Error(response.data.error || '运行失败');
|
throw new Error(response.data.error || '运行失败');
|
||||||
}
|
}
|
||||||
@@ -54,8 +67,16 @@ export async function runTask(taskId: string, agents: AgentType[]): Promise<{ ta
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 批量运行审查任务
|
// 批量运行审查任务
|
||||||
export async function batchRunTasks(taskIds: string[], agents: AgentType[]): Promise<void> {
|
export async function batchRunTasks(
|
||||||
const response = await apiClient.post<ApiResponse<void>>(`${API_BASE}/tasks/batch/run`, { taskIds, agents });
|
taskIds: string[],
|
||||||
|
agents: AgentType[],
|
||||||
|
editorialBaseStandard?: EditorialBaseStandard
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await apiClient.post<ApiResponse<void>>(`${API_BASE}/tasks/batch/run`, {
|
||||||
|
taskIds,
|
||||||
|
agents,
|
||||||
|
editorialBaseStandard,
|
||||||
|
});
|
||||||
if (!response.data.success) {
|
if (!response.data.success) {
|
||||||
throw new Error(response.data.error || '批量运行失败');
|
throw new Error(response.data.error || '批量运行失败');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,35 @@
|
|||||||
/**
|
/**
|
||||||
* 智能体选择弹窗
|
* 智能体选择弹窗
|
||||||
*/
|
*/
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { PlayCircle, X } from 'lucide-react';
|
import { PlayCircle, X } from 'lucide-react';
|
||||||
import type { AgentType } from '../types';
|
import type { AgentType, EditorialBaseStandard } from '../types';
|
||||||
|
|
||||||
interface AgentModalProps {
|
interface AgentModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
taskCount: number;
|
taskCount: number;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirm: (agents: AgentType[]) => void;
|
onConfirm: (agents: AgentType[], editorialBaseStandard?: EditorialBaseStandard) => void;
|
||||||
|
defaultEditorialBaseStandard?: EditorialBaseStandard;
|
||||||
isSubmitting?: boolean; // 🔒 防止重复提交
|
isSubmitting?: boolean; // 🔒 防止重复提交
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AgentModal({ visible, taskCount, onClose, onConfirm, isSubmitting = false }: AgentModalProps) {
|
export default function AgentModal({
|
||||||
|
visible,
|
||||||
|
taskCount,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
defaultEditorialBaseStandard = 'zh',
|
||||||
|
isSubmitting = false,
|
||||||
|
}: AgentModalProps) {
|
||||||
const [selectedAgents, setSelectedAgents] = useState<AgentType[]>(['editorial']);
|
const [selectedAgents, setSelectedAgents] = useState<AgentType[]>(['editorial']);
|
||||||
|
const [editorialBaseStandard, setEditorialBaseStandard] = useState<EditorialBaseStandard>(defaultEditorialBaseStandard);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
setSelectedAgents(['editorial']);
|
||||||
|
setEditorialBaseStandard(defaultEditorialBaseStandard);
|
||||||
|
}, [visible, defaultEditorialBaseStandard]);
|
||||||
|
|
||||||
const toggleAgent = (agent: AgentType) => {
|
const toggleAgent = (agent: AgentType) => {
|
||||||
if (selectedAgents.includes(agent)) {
|
if (selectedAgents.includes(agent)) {
|
||||||
@@ -29,14 +44,17 @@ export default function AgentModal({ visible, taskCount, onClose, onConfirm, isS
|
|||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
// 只调用onConfirm,让调用方控制关闭时机
|
// 只调用onConfirm,让调用方控制关闭时机
|
||||||
onConfirm(selectedAgents);
|
onConfirm(
|
||||||
|
selectedAgents,
|
||||||
|
selectedAgents.includes('editorial') ? editorialBaseStandard : undefined
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-slate-900/50 z-50 flex items-center justify-center backdrop-blur-sm">
|
<div className="fixed inset-0 bg-slate-900/50 z-50 flex items-center justify-center backdrop-blur-sm">
|
||||||
<div className="bg-white rounded-2xl shadow-2xl w-[400px] overflow-hidden transform transition-all scale-100 fade-in">
|
<div className="bg-white rounded-2xl shadow-2xl w-[560px] max-w-[92vw] overflow-hidden transform transition-all scale-100 fade-in">
|
||||||
{/* 头部 */}
|
{/* 头部 */}
|
||||||
<div className="bg-slate-900 p-5 text-white flex items-center justify-between">
|
<div className="bg-slate-900 p-5 text-white flex items-center justify-between">
|
||||||
<h3 className="font-bold text-lg flex items-center gap-2">
|
<h3 className="font-bold text-lg flex items-center gap-2">
|
||||||
@@ -76,6 +94,34 @@ export default function AgentModal({ visible, taskCount, onClose, onConfirm, isS
|
|||||||
</div>
|
</div>
|
||||||
<span className="tag tag-blue">快速</span>
|
<span className="tag tag-blue">快速</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{selectedAgents.includes('editorial') && (
|
||||||
|
<div className="rounded-xl border border-sky-200 bg-sky-50 p-4 mt-2">
|
||||||
|
<div className="text-sm font-bold text-slate-800 mb-2">稿约基线语言</div>
|
||||||
|
<div className="flex items-center gap-8">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-slate-700 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="editorialBaseStandard"
|
||||||
|
checked={editorialBaseStandard === 'zh'}
|
||||||
|
onChange={() => setEditorialBaseStandard('zh')}
|
||||||
|
className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
中文
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-slate-700 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="editorialBaseStandard"
|
||||||
|
checked={editorialBaseStandard === 'en'}
|
||||||
|
onChange={() => setEditorialBaseStandard('en')}
|
||||||
|
className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
英文
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 方法学智能体 */}
|
{/* 方法学智能体 */}
|
||||||
<label
|
<label
|
||||||
@@ -122,6 +168,7 @@ export default function AgentModal({ visible, taskCount, onClose, onConfirm, isS
|
|||||||
</div>
|
</div>
|
||||||
<span className="tag tag-pink">专业</span>
|
<span className="tag tag-pink">专业</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 底部按钮 */}
|
{/* 底部按钮 */}
|
||||||
|
|||||||
@@ -14,10 +14,18 @@ import {
|
|||||||
TaskDetail,
|
TaskDetail,
|
||||||
} from '../components';
|
} from '../components';
|
||||||
import * as api from '../api';
|
import * as api from '../api';
|
||||||
import type { ReviewTask, ReviewReport, TaskFilters, AgentType } from '../types';
|
import type {
|
||||||
|
ReviewTask,
|
||||||
|
ReviewReport,
|
||||||
|
TaskFilters,
|
||||||
|
AgentType,
|
||||||
|
EditorialBaseStandard,
|
||||||
|
} from '../types';
|
||||||
|
import { useAuth } from '../../../framework/auth';
|
||||||
import '../styles/index.css';
|
import '../styles/index.css';
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
|
const { user } = useAuth();
|
||||||
// ==================== State ====================
|
// ==================== State ====================
|
||||||
const [currentView, setCurrentView] = useState<'dashboard' | 'archive'>('dashboard');
|
const [currentView, setCurrentView] = useState<'dashboard' | 'archive'>('dashboard');
|
||||||
const [tasks, setTasks] = useState<ReviewTask[]>([]);
|
const [tasks, setTasks] = useState<ReviewTask[]>([]);
|
||||||
@@ -27,6 +35,8 @@ export default function Dashboard() {
|
|||||||
const [agentModalVisible, setAgentModalVisible] = useState(false);
|
const [agentModalVisible, setAgentModalVisible] = useState(false);
|
||||||
const [pendingTaskForRun, setPendingTaskForRun] = useState<ReviewTask | null>(null);
|
const [pendingTaskForRun, setPendingTaskForRun] = useState<ReviewTask | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false); // 🔒 防止重复提交
|
const [isSubmitting, setIsSubmitting] = useState(false); // 🔒 防止重复提交
|
||||||
|
const [defaultEditorialBaseStandard, setDefaultEditorialBaseStandard] =
|
||||||
|
useState<EditorialBaseStandard>('zh');
|
||||||
|
|
||||||
// 报告详情
|
// 报告详情
|
||||||
const [reportDetail, setReportDetail] = useState<ReviewReport | null>(null);
|
const [reportDetail, setReportDetail] = useState<ReviewReport | null>(null);
|
||||||
@@ -64,6 +74,18 @@ export default function Dashboard() {
|
|||||||
loadTasks();
|
loadTasks();
|
||||||
}, [loadTasks]);
|
}, [loadTasks]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tenantCode = user?.tenantCode;
|
||||||
|
if (!tenantCode) return;
|
||||||
|
fetch(`/api/v1/public/tenant-config/${tenantCode}`)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
const base = data?.data?.editorialDefaultStandard;
|
||||||
|
setDefaultEditorialBaseStandard(base === 'en' ? 'en' : 'zh');
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, [user?.tenantCode]);
|
||||||
|
|
||||||
// 轮询更新进行中的任务
|
// 轮询更新进行中的任务
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const processingTasks = tasks.filter(t =>
|
const processingTasks = tasks.filter(t =>
|
||||||
@@ -119,7 +141,7 @@ export default function Dashboard() {
|
|||||||
setAgentModalVisible(true);
|
setAgentModalVisible(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirmRun = async (agents: AgentType[]) => {
|
const handleConfirmRun = async (agents: AgentType[], editorialBaseStandard?: EditorialBaseStandard) => {
|
||||||
// 🔒 防止重复提交
|
// 🔒 防止重复提交
|
||||||
if (isSubmitting) {
|
if (isSubmitting) {
|
||||||
console.warn('[Dashboard] 已在提交中,忽略重复请求');
|
console.warn('[Dashboard] 已在提交中,忽略重复请求');
|
||||||
@@ -138,7 +160,7 @@ export default function Dashboard() {
|
|||||||
if (taskToRun) {
|
if (taskToRun) {
|
||||||
// 单个任务 - 启动后跳转到详情页显示进度
|
// 单个任务 - 启动后跳转到详情页显示进度
|
||||||
message.loading({ content: '正在启动审查...', key: 'run' });
|
message.loading({ content: '正在启动审查...', key: 'run' });
|
||||||
const { jobId } = await api.runTask(taskToRun.id, agents);
|
const { jobId } = await api.runTask(taskToRun.id, agents, editorialBaseStandard);
|
||||||
message.success({ content: '审查已启动', key: 'run', duration: 2 });
|
message.success({ content: '审查已启动', key: 'run', duration: 2 });
|
||||||
|
|
||||||
// 更新任务状态后跳转到详情页(传递jobId)
|
// 更新任务状态后跳转到详情页(传递jobId)
|
||||||
@@ -159,7 +181,7 @@ export default function Dashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
message.loading({ content: `正在启动 ${pendingIds.length} 个任务...`, key: 'run' });
|
message.loading({ content: `正在启动 ${pendingIds.length} 个任务...`, key: 'run' });
|
||||||
await api.batchRunTasks(pendingIds, agents);
|
await api.batchRunTasks(pendingIds, agents, editorialBaseStandard);
|
||||||
message.success({ content: `${pendingIds.length} 个任务已启动`, key: 'run', duration: 2 });
|
message.success({ content: `${pendingIds.length} 个任务已启动`, key: 'run', duration: 2 });
|
||||||
setSelectedIds([]);
|
setSelectedIds([]);
|
||||||
}
|
}
|
||||||
@@ -282,6 +304,7 @@ export default function Dashboard() {
|
|||||||
<AgentModal
|
<AgentModal
|
||||||
visible={agentModalVisible}
|
visible={agentModalVisible}
|
||||||
taskCount={pendingTaskForRun ? 1 : selectedIds.length}
|
taskCount={pendingTaskForRun ? 1 : selectedIds.length}
|
||||||
|
defaultEditorialBaseStandard={defaultEditorialBaseStandard}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setAgentModalVisible(false);
|
setAgentModalVisible(false);
|
||||||
setPendingTaskForRun(null);
|
setPendingTaskForRun(null);
|
||||||
|
|||||||
@@ -6,10 +6,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { message } from 'antd';
|
import { message } from 'antd';
|
||||||
import * as api from '../api';
|
import * as api from '../api';
|
||||||
import type { ReviewTask, AgentType } from '../types';
|
import type { ReviewTask, AgentType, EditorialBaseStandard } from '../types';
|
||||||
import AgentModal from '../components/AgentModal';
|
import AgentModal from '../components/AgentModal';
|
||||||
|
|
||||||
// ── 内联 SVG 状态图标 ─────────────────────────────────────────────
|
// ── 内联 SVG 状态图标 ─────────────────────────────────────────────
|
||||||
@@ -137,6 +137,7 @@ function StatusBadge({ status }: { status: ReviewTask['status'] }) {
|
|||||||
|
|
||||||
export default function TenantDashboard() {
|
export default function TenantDashboard() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { tenantSlug } = useParams<{ tenantSlug: string }>();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [tasks, setTasks] = useState<ReviewTask[]>([]);
|
const [tasks, setTasks] = useState<ReviewTask[]>([]);
|
||||||
@@ -147,6 +148,19 @@ export default function TenantDashboard() {
|
|||||||
const [agentModalVisible, setAgentModalVisible] = useState(false);
|
const [agentModalVisible, setAgentModalVisible] = useState(false);
|
||||||
const [pendingTask, setPendingTask] = useState<ReviewTask | null>(null);
|
const [pendingTask, setPendingTask] = useState<ReviewTask | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [defaultEditorialBaseStandard, setDefaultEditorialBaseStandard] =
|
||||||
|
useState<EditorialBaseStandard>('zh');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tenantSlug) return;
|
||||||
|
fetch(`/api/v1/public/tenant-config/${tenantSlug}`)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
const base = data?.data?.editorialDefaultStandard;
|
||||||
|
setDefaultEditorialBaseStandard(base === 'en' ? 'en' : 'zh');
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, [tenantSlug]);
|
||||||
|
|
||||||
// ── 数据加载 ──────────────────────────────────────────────────
|
// ── 数据加载 ──────────────────────────────────────────────────
|
||||||
const loadTasks = useCallback(async () => {
|
const loadTasks = useCallback(async () => {
|
||||||
@@ -265,7 +279,7 @@ export default function TenantDashboard() {
|
|||||||
setAgentModalVisible(true);
|
setAgentModalVisible(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirmRun = async (agents: AgentType[]) => {
|
const handleConfirmRun = async (agents: AgentType[], editorialBaseStandard?: EditorialBaseStandard) => {
|
||||||
if (isSubmitting || !pendingTask) return;
|
if (isSubmitting || !pendingTask) return;
|
||||||
const task = pendingTask;
|
const task = pendingTask;
|
||||||
setAgentModalVisible(false);
|
setAgentModalVisible(false);
|
||||||
@@ -273,7 +287,7 @@ export default function TenantDashboard() {
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
message.loading({ content: '正在启动审查…', key: 'run' });
|
message.loading({ content: '正在启动审查…', key: 'run' });
|
||||||
const { jobId } = await api.runTask(task.id, agents);
|
const { jobId } = await api.runTask(task.id, agents, editorialBaseStandard);
|
||||||
message.success({ content: '审查已启动', key: 'run', duration: 2 });
|
message.success({ content: '审查已启动', key: 'run', duration: 2 });
|
||||||
// 对齐旧版体验:启动后立即进入“审稿过程页”,动态查看进度与分模块结果
|
// 对齐旧版体验:启动后立即进入“审稿过程页”,动态查看进度与分模块结果
|
||||||
navigate(`${task.id}?jobId=${encodeURIComponent(jobId)}`);
|
navigate(`${task.id}?jobId=${encodeURIComponent(jobId)}`);
|
||||||
@@ -502,6 +516,7 @@ export default function TenantDashboard() {
|
|||||||
<AgentModal
|
<AgentModal
|
||||||
visible={agentModalVisible}
|
visible={agentModalVisible}
|
||||||
taskCount={1}
|
taskCount={1}
|
||||||
|
defaultEditorialBaseStandard={defaultEditorialBaseStandard}
|
||||||
onClose={() => { setAgentModalVisible(false); setPendingTask(null); }}
|
onClose={() => { setAgentModalVisible(false); setPendingTask(null); }}
|
||||||
onConfirm={handleConfirmRun}
|
onConfirm={handleConfirmRun}
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type TaskStatus =
|
|||||||
|
|
||||||
// 智能体类型
|
// 智能体类型
|
||||||
export type AgentType = 'editorial' | 'methodology' | 'clinical';
|
export type AgentType = 'editorial' | 'methodology' | 'clinical';
|
||||||
|
export type EditorialBaseStandard = 'zh' | 'en';
|
||||||
|
|
||||||
// 审查任务
|
// 审查任务
|
||||||
export interface ReviewTask {
|
export interface ReviewTask {
|
||||||
@@ -23,6 +24,7 @@ export interface ReviewTask {
|
|||||||
fileSize: number;
|
fileSize: number;
|
||||||
status: TaskStatus;
|
status: TaskStatus;
|
||||||
selectedAgents: AgentType[];
|
selectedAgents: AgentType[];
|
||||||
|
editorialBaseStandard?: EditorialBaseStandard;
|
||||||
wordCount?: number;
|
wordCount?: number;
|
||||||
overallScore?: number;
|
overallScore?: number;
|
||||||
editorialScore?: number;
|
editorialScore?: number;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user