feat(rvw): implement Skills architecture (Day 7-10)
- Add Skills core framework (types, registry, executor, profile, context) - Implement DataForensicsSkill with DI, path security, graceful degradation - Implement EditorialSkill and MethodologySkill wrapping existing services - Extend ExtractionClient with IExtractionClient interface and analyzeDocx - Refactor reviewWorker to support V1/V2 architecture switching - Add Zod config validation and generic type support - Update development docs and module status Day 7: Skills core framework (~700 lines) Day 8: DataForensicsSkill + ExtractionClient extension (~400 lines) Day 9: EditorialSkill + MethodologySkill (~350 lines) Day 10: ReviewWorker integration (~280 lines) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
224
backend/src/modules/rvw/skills/core/context.ts
Normal file
224
backend/src/modules/rvw/skills/core/context.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* RVW V2.0 Skills 架构 - 上下文管理
|
||||
*
|
||||
* 提供上下文构建和管理功能
|
||||
*
|
||||
* @version 2.0.0
|
||||
* @since 2026-02-18
|
||||
*/
|
||||
|
||||
import {
|
||||
SkillContext,
|
||||
SkillResult,
|
||||
TableData,
|
||||
JournalProfile,
|
||||
DocumentMeta,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* 上下文构建器
|
||||
* 辅助创建和管理 SkillContext
|
||||
*/
|
||||
export class ContextBuilder {
|
||||
private context: Partial<SkillContext>;
|
||||
|
||||
constructor() {
|
||||
this.context = {
|
||||
previousResults: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置任务 ID
|
||||
*/
|
||||
taskId(taskId: string): this {
|
||||
this.context.taskId = taskId;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置用户 ID
|
||||
*/
|
||||
userId(userId?: string): this {
|
||||
this.context.userId = userId;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置文档路径
|
||||
*/
|
||||
documentPath(path: string): this {
|
||||
this.context.documentPath = path;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置文档内容
|
||||
*/
|
||||
documentContent(content: string): this {
|
||||
this.context.documentContent = content;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置文档元信息
|
||||
*/
|
||||
documentMeta(meta: DocumentMeta): this {
|
||||
this.context.documentMeta = meta;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Profile
|
||||
*/
|
||||
profile(profile: JournalProfile): this {
|
||||
this.context.profile = profile;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置表格数据(通常由 DataForensicsSkill 填充)
|
||||
*/
|
||||
tables(tables: TableData[]): this {
|
||||
this.context.tables = tables;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置检测到的统计方法
|
||||
*/
|
||||
methods(methods: string[]): this {
|
||||
this.context.methods = methods;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加前置结果
|
||||
*/
|
||||
addPreviousResult(result: SkillResult): this {
|
||||
if (!this.context.previousResults) {
|
||||
this.context.previousResults = [];
|
||||
}
|
||||
this.context.previousResults.push(result);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置前置结果列表
|
||||
*/
|
||||
previousResults(results: SkillResult[]): this {
|
||||
this.context.previousResults = results;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建上下文
|
||||
*/
|
||||
build(): SkillContext {
|
||||
// 验证必填字段
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!this.context.taskId) {
|
||||
errors.push('taskId is required');
|
||||
}
|
||||
if (!this.context.documentPath) {
|
||||
errors.push('documentPath is required');
|
||||
}
|
||||
if (this.context.documentContent === undefined) {
|
||||
errors.push('documentContent is required');
|
||||
}
|
||||
if (!this.context.profile) {
|
||||
errors.push('profile is required');
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`ContextBuilder validation failed: ${errors.join(', ')}`);
|
||||
}
|
||||
|
||||
return this.context as SkillContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建部分上下文(用于 Executor)
|
||||
*/
|
||||
buildPartial(): Omit<SkillContext, 'profile' | 'previousResults'> {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!this.context.taskId) {
|
||||
errors.push('taskId is required');
|
||||
}
|
||||
if (!this.context.documentPath) {
|
||||
errors.push('documentPath is required');
|
||||
}
|
||||
if (this.context.documentContent === undefined) {
|
||||
errors.push('documentContent is required');
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`ContextBuilder validation failed: ${errors.join(', ')}`);
|
||||
}
|
||||
|
||||
const { profile, previousResults, ...partial } = this.context;
|
||||
return partial as Omit<SkillContext, 'profile' | 'previousResults'>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置构建器
|
||||
*/
|
||||
reset(): this {
|
||||
this.context = {
|
||||
previousResults: [],
|
||||
};
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务数据接口(用于 createContextFromTask)
|
||||
*/
|
||||
export interface TaskData {
|
||||
id: string;
|
||||
userId: string;
|
||||
filePath: string;
|
||||
content?: string | null;
|
||||
fileName: string; // 对应数据库 file_name 字段
|
||||
fileSize?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从数据库任务记录创建上下文
|
||||
*/
|
||||
export function createContextFromTask(
|
||||
task: TaskData,
|
||||
profile: JournalProfile
|
||||
): SkillContext {
|
||||
return new ContextBuilder()
|
||||
.taskId(task.id)
|
||||
.userId(task.userId)
|
||||
.documentPath(task.filePath)
|
||||
.documentContent(task.content || '')
|
||||
.documentMeta({
|
||||
filename: task.fileName,
|
||||
fileSize: task.fileSize || 0,
|
||||
})
|
||||
.profile(profile)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从数据库任务记录创建部分上下文(用于 Executor)
|
||||
*/
|
||||
export function createPartialContextFromTask(
|
||||
task: TaskData
|
||||
): Omit<SkillContext, 'profile' | 'previousResults'> {
|
||||
return new ContextBuilder()
|
||||
.taskId(task.id)
|
||||
.userId(task.userId)
|
||||
.documentPath(task.filePath)
|
||||
.documentContent(task.content || '')
|
||||
.documentMeta({
|
||||
filename: task.fileName,
|
||||
fileSize: task.fileSize || 0,
|
||||
})
|
||||
.buildPartial();
|
||||
}
|
||||
333
backend/src/modules/rvw/skills/core/executor.ts
Normal file
333
backend/src/modules/rvw/skills/core/executor.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* RVW V2.0 Skills 架构 - Skill 执行引擎
|
||||
*
|
||||
* 负责按 Profile 配置顺序执行 Skills,支持超时熔断和故障隔离
|
||||
*
|
||||
* @version 2.0.0
|
||||
* @since 2026-02-18
|
||||
*/
|
||||
|
||||
import {
|
||||
Skill,
|
||||
SkillContext,
|
||||
SkillResult,
|
||||
SkillConfig,
|
||||
ExecutorConfig,
|
||||
ExecutionSummary,
|
||||
PipelineItem,
|
||||
JournalProfile,
|
||||
BaseSkillContext,
|
||||
SkillErrorCodes,
|
||||
} from './types.js';
|
||||
import { SkillRegistry } from './registry.js';
|
||||
import { logger } from '../../../../common/logging/index.js';
|
||||
|
||||
/**
|
||||
* 默认执行器配置
|
||||
*/
|
||||
const DEFAULT_EXECUTOR_CONFIG: ExecutorConfig = {
|
||||
defaultTimeout: 30000, // 30 秒
|
||||
maxRetries: 0,
|
||||
retryDelay: 1000,
|
||||
continueOnError: true,
|
||||
logLevel: 'info',
|
||||
};
|
||||
|
||||
/**
|
||||
* Skill 执行引擎
|
||||
*/
|
||||
export class SkillExecutor<TContext extends BaseSkillContext = SkillContext> {
|
||||
private config: ExecutorConfig<TContext>;
|
||||
|
||||
constructor(config?: Partial<ExecutorConfig<TContext>>) {
|
||||
this.config = { ...DEFAULT_EXECUTOR_CONFIG, ...config } as ExecutorConfig<TContext>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 Pipeline
|
||||
*/
|
||||
async execute(
|
||||
profile: JournalProfile,
|
||||
initialContext: Omit<TContext, 'profile' | 'previousResults'>
|
||||
): Promise<ExecutionSummary> {
|
||||
const startTime = Date.now();
|
||||
const results: SkillResult[] = [];
|
||||
|
||||
// 构建完整上下文
|
||||
const context = {
|
||||
...initialContext,
|
||||
profile,
|
||||
previousResults: [],
|
||||
} as TContext;
|
||||
|
||||
logger.info({
|
||||
taskId: context.taskId,
|
||||
profileId: profile.id,
|
||||
pipelineLength: profile.pipeline.length,
|
||||
}, '[SkillExecutor] Starting pipeline execution');
|
||||
|
||||
// 遍历 Pipeline
|
||||
for (const item of profile.pipeline) {
|
||||
// 跳过禁用的 Skill
|
||||
if (!item.enabled) {
|
||||
logger.debug({ skillId: item.skillId }, '[SkillExecutor] Skill disabled, skipping');
|
||||
results.push(this.createSkippedResult(item.skillId, 'Skill disabled in profile'));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 获取 Skill
|
||||
const skill = SkillRegistry.get(item.skillId);
|
||||
if (!skill) {
|
||||
logger.warn({ skillId: item.skillId }, '[SkillExecutor] Skill not found in registry');
|
||||
results.push(this.createSkippedResult(item.skillId, 'Skill not found'));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 前置检查
|
||||
if (skill.canRun && !skill.canRun(context as SkillContext)) {
|
||||
logger.info({ skillId: item.skillId }, '[SkillExecutor] Skill pre-check failed, skipping');
|
||||
results.push(this.createSkippedResult(item.skillId, 'Pre-check failed'));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 执行 Skill
|
||||
const result = await this.executeSkill(skill, context as SkillContext, item, profile);
|
||||
results.push(result);
|
||||
|
||||
// 调用完成回调(V2.1 扩展点)
|
||||
if (this.config.onSkillComplete) {
|
||||
try {
|
||||
await this.config.onSkillComplete(item.skillId, result, context);
|
||||
} catch (callbackError: unknown) {
|
||||
const errorMessage = callbackError instanceof Error ? callbackError.message : String(callbackError);
|
||||
logger.error({ skillId: item.skillId, error: errorMessage }, '[SkillExecutor] onSkillComplete callback failed');
|
||||
}
|
||||
}
|
||||
|
||||
// 更新上下文(传递给后续 Skills)
|
||||
context.previousResults.push(result);
|
||||
|
||||
// 更新共享数据
|
||||
this.updateContextWithResult(context, skill, result);
|
||||
|
||||
// 检查是否需要中断
|
||||
if (result.status === 'error' && !this.shouldContinue(item, profile)) {
|
||||
logger.warn({ skillId: item.skillId }, '[SkillExecutor] Skill failed and continueOnError=false, stopping');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 生成汇总
|
||||
const summary = this.buildSummary(context.taskId, profile.id, results, startTime);
|
||||
|
||||
logger.info({
|
||||
taskId: context.taskId,
|
||||
overallStatus: summary.overallStatus,
|
||||
totalTime: summary.totalExecutionTime,
|
||||
successCount: summary.successCount,
|
||||
errorCount: summary.errorCount,
|
||||
}, '[SkillExecutor] Pipeline execution completed');
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行单个 Skill(带超时和重试)
|
||||
*/
|
||||
private async executeSkill(
|
||||
skill: Skill,
|
||||
context: SkillContext,
|
||||
item: PipelineItem,
|
||||
profile: JournalProfile
|
||||
): Promise<SkillResult> {
|
||||
const startedAt = new Date();
|
||||
const timeoutMultiplier = profile.globalConfig?.timeoutMultiplier ?? 1;
|
||||
const timeout = Math.round((item.timeout ?? skill.metadata.defaultTimeout ?? this.config.defaultTimeout) * timeoutMultiplier);
|
||||
|
||||
logger.info({
|
||||
skillId: skill.metadata.id,
|
||||
taskId: context.taskId,
|
||||
timeout,
|
||||
}, '[SkillExecutor] Executing skill');
|
||||
|
||||
try {
|
||||
// 带超时执行
|
||||
const result = await this.executeWithTimeout(skill, context, item.config, timeout);
|
||||
|
||||
logger.info({
|
||||
skillId: skill.metadata.id,
|
||||
taskId: context.taskId,
|
||||
status: result.status,
|
||||
executionTime: result.executionTime,
|
||||
issueCount: result.issues.length,
|
||||
}, '[SkillExecutor] Skill execution completed');
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
const executionTime = Date.now() - startedAt.getTime();
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// 判断是否超时
|
||||
if (errorMessage === 'SKILL_TIMEOUT') {
|
||||
logger.warn({
|
||||
skillId: skill.metadata.id,
|
||||
taskId: context.taskId,
|
||||
timeout,
|
||||
}, '[SkillExecutor] Skill execution timed out');
|
||||
|
||||
return {
|
||||
skillId: skill.metadata.id,
|
||||
skillName: skill.metadata.name,
|
||||
status: 'timeout',
|
||||
issues: [{
|
||||
severity: 'WARNING',
|
||||
type: SkillErrorCodes.SKILL_TIMEOUT,
|
||||
message: `${skill.metadata.name} 执行超时 (${timeout}ms),已跳过`,
|
||||
}],
|
||||
executionTime: timeout,
|
||||
timedOut: true,
|
||||
startedAt,
|
||||
completedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
// 其他错误
|
||||
logger.error({
|
||||
skillId: skill.metadata.id,
|
||||
taskId: context.taskId,
|
||||
error: errorMessage,
|
||||
}, '[SkillExecutor] Skill execution failed');
|
||||
|
||||
return {
|
||||
skillId: skill.metadata.id,
|
||||
skillName: skill.metadata.name,
|
||||
status: 'error',
|
||||
issues: [{
|
||||
severity: 'ERROR',
|
||||
type: SkillErrorCodes.SKILL_EXECUTION_ERROR,
|
||||
message: `${skill.metadata.name} 执行失败: ${errorMessage}`,
|
||||
}],
|
||||
executionTime,
|
||||
error: errorMessage,
|
||||
startedAt,
|
||||
completedAt: new Date(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 带超时执行
|
||||
*/
|
||||
private async executeWithTimeout(
|
||||
skill: Skill,
|
||||
context: SkillContext,
|
||||
config: SkillConfig | undefined,
|
||||
timeout: number
|
||||
): Promise<SkillResult> {
|
||||
return Promise.race([
|
||||
skill.run(context, config),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('SKILL_TIMEOUT')), timeout)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 Skill 结果更新上下文
|
||||
*/
|
||||
private updateContextWithResult(context: TContext, skill: Skill, result: SkillResult): void {
|
||||
// DataForensicsSkill 的特殊处理
|
||||
if (skill.metadata.id === 'DataForensicsSkill' && result.status !== 'error') {
|
||||
const rvwContext = context as unknown as SkillContext;
|
||||
const data = result.data as {
|
||||
tables?: unknown[];
|
||||
methods?: string[];
|
||||
} | undefined;
|
||||
|
||||
if (data) {
|
||||
if (data.tables) {
|
||||
rvwContext.tables = data.tables as SkillContext['tables'];
|
||||
}
|
||||
if (data.methods) {
|
||||
rvwContext.methods = data.methods;
|
||||
}
|
||||
rvwContext.forensicsResult = data as SkillContext['forensicsResult'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建跳过结果
|
||||
*/
|
||||
private createSkippedResult(skillId: string, reason: string): SkillResult {
|
||||
const now = new Date();
|
||||
return {
|
||||
skillId,
|
||||
skillName: skillId,
|
||||
status: 'skipped',
|
||||
issues: [{
|
||||
severity: 'INFO',
|
||||
type: 'SKILL_SKIPPED',
|
||||
message: reason,
|
||||
}],
|
||||
executionTime: 0,
|
||||
startedAt: now,
|
||||
completedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否继续执行
|
||||
*/
|
||||
private shouldContinue(item: PipelineItem, profile: JournalProfile): boolean {
|
||||
if (item.optional) return true;
|
||||
return profile.globalConfig?.continueOnError ?? this.config.continueOnError;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建执行汇总
|
||||
*/
|
||||
private buildSummary(
|
||||
taskId: string,
|
||||
profileId: string,
|
||||
results: SkillResult[],
|
||||
startTime: number
|
||||
): ExecutionSummary {
|
||||
const completedAt = new Date();
|
||||
const totalExecutionTime = Date.now() - startTime;
|
||||
|
||||
const successCount = results.filter(r => r.status === 'success').length;
|
||||
const warningCount = results.filter(r => r.status === 'warning').length;
|
||||
const errorCount = results.filter(r => r.status === 'error').length;
|
||||
const skippedCount = results.filter(r => r.status === 'skipped').length;
|
||||
const timeoutCount = results.filter(r => r.status === 'timeout').length;
|
||||
|
||||
let overallStatus: 'success' | 'partial' | 'failed';
|
||||
if (errorCount === 0 && timeoutCount === 0) {
|
||||
overallStatus = 'success';
|
||||
} else if (successCount > 0) {
|
||||
overallStatus = 'partial';
|
||||
} else {
|
||||
overallStatus = 'failed';
|
||||
}
|
||||
|
||||
return {
|
||||
taskId,
|
||||
profileId,
|
||||
overallStatus,
|
||||
totalSkills: results.length,
|
||||
successCount,
|
||||
warningCount,
|
||||
errorCount,
|
||||
skippedCount,
|
||||
timeoutCount,
|
||||
results,
|
||||
totalExecutionTime,
|
||||
startedAt: new Date(startTime),
|
||||
completedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 导出默认实例
|
||||
export const defaultExecutor = new SkillExecutor();
|
||||
31
backend/src/modules/rvw/skills/core/index.ts
Normal file
31
backend/src/modules/rvw/skills/core/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* RVW V2.0 Skills 架构 - Core 模块统一导出
|
||||
*
|
||||
* @version 2.0.0
|
||||
* @since 2026-02-18
|
||||
*/
|
||||
|
||||
// 类型定义
|
||||
export * from './types.js';
|
||||
|
||||
// 注册表
|
||||
export { SkillRegistry } from './registry.js';
|
||||
|
||||
// 执行器
|
||||
export { SkillExecutor, defaultExecutor } from './executor.js';
|
||||
|
||||
// Profile 解析器
|
||||
export {
|
||||
ProfileResolver,
|
||||
DEFAULT_PROFILE,
|
||||
CHINESE_CORE_PROFILE,
|
||||
QUICK_FORENSICS_PROFILE,
|
||||
} from './profile.js';
|
||||
|
||||
// 上下文管理
|
||||
export {
|
||||
ContextBuilder,
|
||||
createContextFromTask,
|
||||
createPartialContextFromTask,
|
||||
} from './context.js';
|
||||
export type { TaskData } from './context.js';
|
||||
258
backend/src/modules/rvw/skills/core/profile.ts
Normal file
258
backend/src/modules/rvw/skills/core/profile.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* RVW V2.0 Skills 架构 - Profile 配置解析器
|
||||
*
|
||||
* MVP 阶段使用硬编码配置,V2.1 将支持数据库存储
|
||||
*
|
||||
* @version 2.0.0
|
||||
* @since 2026-02-18
|
||||
*/
|
||||
|
||||
import { JournalProfile, PipelineItem } from './types.js';
|
||||
import { logger } from '../../../../common/logging/index.js';
|
||||
|
||||
/**
|
||||
* 默认 Profile 配置
|
||||
*/
|
||||
export const DEFAULT_PROFILE: JournalProfile = {
|
||||
id: 'default',
|
||||
name: '通用期刊配置',
|
||||
description: 'RVW V2.0 默认审稿配置,适用于大多数期刊',
|
||||
version: '1.0.0',
|
||||
|
||||
pipeline: [
|
||||
{
|
||||
skillId: 'DataForensicsSkill',
|
||||
enabled: true,
|
||||
optional: true, // 数据侦探失败不影响其他审稿
|
||||
config: {
|
||||
checkLevel: 'L1_L2_L25',
|
||||
tolerancePercent: 0.1,
|
||||
},
|
||||
timeout: 60000, // 60 秒(需要调用 Python)
|
||||
},
|
||||
{
|
||||
skillId: 'EditorialSkill',
|
||||
enabled: true,
|
||||
optional: false,
|
||||
timeout: 45000,
|
||||
},
|
||||
{
|
||||
skillId: 'MethodologySkill',
|
||||
enabled: true,
|
||||
optional: false,
|
||||
timeout: 45000,
|
||||
},
|
||||
],
|
||||
|
||||
globalConfig: {
|
||||
strictness: 'STANDARD',
|
||||
continueOnError: true,
|
||||
timeoutMultiplier: 1.0,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 中文核心期刊 Profile
|
||||
*/
|
||||
export const CHINESE_CORE_PROFILE: JournalProfile = {
|
||||
id: 'chinese-core',
|
||||
name: '中文核心期刊配置',
|
||||
description: '适用于中文核心期刊,对数据准确性要求更高',
|
||||
version: '1.0.0',
|
||||
|
||||
pipeline: [
|
||||
{
|
||||
skillId: 'DataForensicsSkill',
|
||||
enabled: true,
|
||||
optional: false, // 中文核心对数据准确性要求高
|
||||
config: {
|
||||
checkLevel: 'L1_L2_L25',
|
||||
tolerancePercent: 0.05, // 更严格的容错
|
||||
},
|
||||
timeout: 60000,
|
||||
},
|
||||
{
|
||||
skillId: 'EditorialSkill',
|
||||
enabled: true,
|
||||
optional: false,
|
||||
config: {
|
||||
standard: 'chinese-core',
|
||||
},
|
||||
timeout: 45000,
|
||||
},
|
||||
{
|
||||
skillId: 'MethodologySkill',
|
||||
enabled: true,
|
||||
optional: false,
|
||||
timeout: 45000,
|
||||
},
|
||||
],
|
||||
|
||||
globalConfig: {
|
||||
strictness: 'STRICT',
|
||||
continueOnError: false, // 严格模式,失败即停止
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 快速预览 Profile(仅数据侦探)
|
||||
*/
|
||||
export const QUICK_FORENSICS_PROFILE: JournalProfile = {
|
||||
id: 'quick-forensics',
|
||||
name: '快速数据侦探',
|
||||
description: '仅执行数据侦探,用于快速预览表格验证结果',
|
||||
version: '1.0.0',
|
||||
|
||||
pipeline: [
|
||||
{
|
||||
skillId: 'DataForensicsSkill',
|
||||
enabled: true,
|
||||
optional: false,
|
||||
config: {
|
||||
checkLevel: 'L1_L2_L25',
|
||||
tolerancePercent: 0.1,
|
||||
},
|
||||
timeout: 60000,
|
||||
},
|
||||
],
|
||||
|
||||
globalConfig: {
|
||||
strictness: 'STANDARD',
|
||||
continueOnError: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 所有预定义 Profiles
|
||||
*/
|
||||
const PROFILES: Map<string, JournalProfile> = new Map([
|
||||
['default', DEFAULT_PROFILE],
|
||||
['chinese-core', CHINESE_CORE_PROFILE],
|
||||
['quick-forensics', QUICK_FORENSICS_PROFILE],
|
||||
]);
|
||||
|
||||
/**
|
||||
* V1.0 Agent 到 V2.0 Skill 的映射
|
||||
*/
|
||||
const AGENT_TO_SKILL_MAP: Record<string, string> = {
|
||||
'editorial': 'EditorialSkill',
|
||||
'methodology': 'MethodologySkill',
|
||||
'forensics': 'DataForensicsSkill',
|
||||
};
|
||||
|
||||
/**
|
||||
* Profile 解析器
|
||||
*/
|
||||
export class ProfileResolver {
|
||||
/**
|
||||
* 获取 Profile
|
||||
* MVP 阶段:从内存 Map 获取
|
||||
* V2.1 阶段:从数据库获取
|
||||
*/
|
||||
static resolve(profileId?: string): JournalProfile {
|
||||
const id = profileId || 'default';
|
||||
const profile = PROFILES.get(id);
|
||||
|
||||
if (!profile) {
|
||||
logger.warn({ profileId: id }, '[ProfileResolver] Profile not found, using default');
|
||||
return DEFAULT_PROFILE;
|
||||
}
|
||||
|
||||
logger.debug({ profileId: id }, '[ProfileResolver] Profile resolved');
|
||||
return profile;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户选择的 Agents 动态构建 Profile
|
||||
* 兼容 V1.0 的 selectedAgents 参数
|
||||
*/
|
||||
static resolveFromAgents(selectedAgents?: string[]): JournalProfile {
|
||||
const baseProfile = JSON.parse(JSON.stringify(DEFAULT_PROFILE)) as JournalProfile;
|
||||
baseProfile.id = 'dynamic';
|
||||
baseProfile.name = '动态生成配置';
|
||||
|
||||
if (!selectedAgents || selectedAgents.length === 0) {
|
||||
logger.debug('[ProfileResolver] No agents selected, using full default profile');
|
||||
return baseProfile;
|
||||
}
|
||||
|
||||
// 收集需要启用的 Skills
|
||||
const enabledSkills = new Set<string>();
|
||||
|
||||
// DataForensicsSkill 始终启用
|
||||
enabledSkills.add('DataForensicsSkill');
|
||||
|
||||
// 根据 V1.0 Agent 选择映射到 V2.0 Skills
|
||||
for (const agent of selectedAgents) {
|
||||
const skillId = AGENT_TO_SKILL_MAP[agent];
|
||||
if (skillId) {
|
||||
enabledSkills.add(skillId);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新 Pipeline
|
||||
baseProfile.pipeline = baseProfile.pipeline.map(item => ({
|
||||
...item,
|
||||
enabled: enabledSkills.has(item.skillId),
|
||||
}));
|
||||
|
||||
logger.debug({
|
||||
selectedAgents,
|
||||
enabledSkills: Array.from(enabledSkills),
|
||||
}, '[ProfileResolver] Profile built from agents');
|
||||
|
||||
return baseProfile;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可用 Profiles(用于 UI)
|
||||
*/
|
||||
static getAllProfiles(): JournalProfile[] {
|
||||
return Array.from(PROFILES.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Profile ID 列表
|
||||
*/
|
||||
static getProfileIds(): string[] {
|
||||
return Array.from(PROFILES.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册新 Profile(V2.1 支持动态添加)
|
||||
*/
|
||||
static register(profile: JournalProfile): void {
|
||||
PROFILES.set(profile.id, profile);
|
||||
logger.info({ profileId: profile.id }, '[ProfileResolver] Profile registered');
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Profile 配置
|
||||
*/
|
||||
static validate(profile: JournalProfile): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!profile.id) {
|
||||
errors.push('Profile ID is required');
|
||||
}
|
||||
|
||||
if (!profile.name) {
|
||||
errors.push('Profile name is required');
|
||||
}
|
||||
|
||||
if (!profile.pipeline || profile.pipeline.length === 0) {
|
||||
errors.push('Pipeline must have at least one skill');
|
||||
}
|
||||
|
||||
for (const item of profile.pipeline || []) {
|
||||
if (!item.skillId) {
|
||||
errors.push('Pipeline item must have skillId');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
}
|
||||
163
backend/src/modules/rvw/skills/core/registry.ts
Normal file
163
backend/src/modules/rvw/skills/core/registry.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* RVW V2.0 Skills 架构 - Skill 注册表
|
||||
*
|
||||
* 单例模式,管理所有已注册的 Skills
|
||||
*
|
||||
* @version 2.0.0
|
||||
* @since 2026-02-18
|
||||
*/
|
||||
|
||||
import { Skill, SkillMetadata, SkillCategory } from './types.js';
|
||||
import { logger } from '../../../../common/logging/index.js';
|
||||
|
||||
/**
|
||||
* Skill 注册表类
|
||||
*/
|
||||
class SkillRegistryClass {
|
||||
private skills: Map<string, Skill> = new Map();
|
||||
private initialized: boolean = false;
|
||||
|
||||
/**
|
||||
* 注册 Skill
|
||||
*/
|
||||
register(skill: Skill): void {
|
||||
const { id, version } = skill.metadata;
|
||||
|
||||
if (this.skills.has(id)) {
|
||||
logger.warn({ skillId: id }, '[SkillRegistry] Skill already registered, overwriting');
|
||||
}
|
||||
|
||||
this.skills.set(id, skill);
|
||||
logger.info({ skillId: id, version }, '[SkillRegistry] Skill registered');
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量注册
|
||||
*/
|
||||
registerAll(skills: Skill[]): void {
|
||||
for (const skill of skills) {
|
||||
this.register(skill);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Skill
|
||||
*/
|
||||
get(id: string): Skill | undefined {
|
||||
return this.skills.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Skill(必须存在)
|
||||
*/
|
||||
getRequired(id: string): Skill {
|
||||
const skill = this.skills.get(id);
|
||||
if (!skill) {
|
||||
throw new Error(`Skill not found: ${id}`);
|
||||
}
|
||||
return skill;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Skill 是否存在
|
||||
*/
|
||||
has(id: string): boolean {
|
||||
return this.skills.has(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有已注册的 Skill
|
||||
*/
|
||||
getAll(): Skill[] {
|
||||
return Array.from(this.skills.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有 Skill 元数据(用于 UI 展示)
|
||||
*/
|
||||
getAllMetadata(): SkillMetadata[] {
|
||||
return this.getAll().map(skill => skill.metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按分类获取 Skills
|
||||
*/
|
||||
getByCategory(category: SkillCategory): Skill[] {
|
||||
return this.getAll().filter(skill => skill.metadata.category === category);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销 Skill
|
||||
*/
|
||||
unregister(id: string): boolean {
|
||||
const result = this.skills.delete(id);
|
||||
if (result) {
|
||||
logger.info({ skillId: id }, '[SkillRegistry] Skill unregistered');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有 Skills(测试用)
|
||||
*/
|
||||
clear(): void {
|
||||
this.skills.clear();
|
||||
this.initialized = false;
|
||||
logger.debug('[SkillRegistry] All skills cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取注册的 Skill 数量
|
||||
*/
|
||||
get size(): number {
|
||||
return this.skills.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记为已初始化
|
||||
*/
|
||||
markInitialized(): void {
|
||||
this.initialized = true;
|
||||
logger.info({ skillCount: this.size }, '[SkillRegistry] Registry initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已初始化
|
||||
*/
|
||||
isInitialized(): boolean {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取注册表状态摘要
|
||||
*/
|
||||
getSummary(): {
|
||||
initialized: boolean;
|
||||
skillCount: number;
|
||||
categories: Record<SkillCategory, number>;
|
||||
} {
|
||||
const categories: Record<SkillCategory, number> = {
|
||||
forensics: 0,
|
||||
editorial: 0,
|
||||
methodology: 0,
|
||||
guardrail: 0,
|
||||
knowledge: 0,
|
||||
};
|
||||
|
||||
for (const skill of this.skills.values()) {
|
||||
const category = skill.metadata.category;
|
||||
if (category in categories) {
|
||||
categories[category]++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
initialized: this.initialized,
|
||||
skillCount: this.size,
|
||||
categories,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const SkillRegistry = new SkillRegistryClass();
|
||||
333
backend/src/modules/rvw/skills/core/types.ts
Normal file
333
backend/src/modules/rvw/skills/core/types.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* RVW V2.0 Skills 架构 - 核心类型定义
|
||||
*
|
||||
* ⚠️ 注意:此文件未来将移动到 common/skills/core/types.ts
|
||||
* ⚠️ 禁止在此文件中 import modules/rvw 下的业务代码
|
||||
*
|
||||
* @version 2.0.0
|
||||
* @since 2026-02-18
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ==========================================
|
||||
// Skill 基础类型定义(通用)
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* 问题严重程度
|
||||
*/
|
||||
export type IssueSeverity = 'ERROR' | 'WARNING' | 'INFO';
|
||||
|
||||
/**
|
||||
* Skill 执行状态
|
||||
*/
|
||||
export type SkillStatus = 'success' | 'warning' | 'error' | 'timeout' | 'skipped';
|
||||
|
||||
/**
|
||||
* Skill 分类
|
||||
*/
|
||||
export type SkillCategory = 'forensics' | 'editorial' | 'methodology' | 'guardrail' | 'knowledge';
|
||||
|
||||
/**
|
||||
* 问题定位信息
|
||||
*/
|
||||
export interface IssueLocation {
|
||||
tableId?: string; // 表格 ID
|
||||
cellRef?: string; // R1C1 坐标,如 "R3C4"
|
||||
paragraph?: number; // 段落编号
|
||||
lineRange?: [number, number]; // 行范围
|
||||
}
|
||||
|
||||
/**
|
||||
* 问题详情
|
||||
*/
|
||||
export interface Issue {
|
||||
severity: IssueSeverity;
|
||||
type: string; // 问题类型代码(如 ARITHMETIC_SUM_MISMATCH)
|
||||
message: string; // 人类可读描述
|
||||
location?: IssueLocation;
|
||||
evidence?: {
|
||||
expected?: string | number;
|
||||
actual?: string | number;
|
||||
formula?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 表格数据结构
|
||||
*/
|
||||
export interface TableData {
|
||||
id: string;
|
||||
caption: string;
|
||||
data: string[][];
|
||||
html?: string;
|
||||
headers?: string[];
|
||||
rowCount: number;
|
||||
colCount: number;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Skill Context(共享上下文)- 泛型设计
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* 基础上下文接口(通用)
|
||||
* 使用泛型,支持不同业务模块扩展
|
||||
*/
|
||||
export interface BaseSkillContext<TProfile = unknown> {
|
||||
taskId: string;
|
||||
userId?: string;
|
||||
previousResults: SkillResult[];
|
||||
profile: TProfile;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文档元信息
|
||||
*/
|
||||
export interface DocumentMeta {
|
||||
filename: string;
|
||||
fileSize: number;
|
||||
pageCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据侦探结果(Python 返回)
|
||||
*/
|
||||
export interface ForensicsResult {
|
||||
tables: TableData[];
|
||||
methods: string[];
|
||||
issues: Issue[];
|
||||
summary: {
|
||||
totalTables: number;
|
||||
totalIssues: number;
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* RVW 模块扩展字段
|
||||
*/
|
||||
export interface RvwContextExtras {
|
||||
documentPath: string;
|
||||
documentContent: string;
|
||||
documentMeta?: DocumentMeta;
|
||||
tables?: TableData[];
|
||||
methods?: string[];
|
||||
forensicsResult?: ForensicsResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* RVW Skill 执行上下文(组合类型)
|
||||
*/
|
||||
export interface SkillContext extends BaseSkillContext<JournalProfile>, RvwContextExtras {}
|
||||
|
||||
// ==========================================
|
||||
// Skill Result(执行结果)
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Skill 执行结果
|
||||
*/
|
||||
export interface SkillResult {
|
||||
skillId: string;
|
||||
skillName: string;
|
||||
status: SkillStatus;
|
||||
score?: number;
|
||||
scoreLabel?: string;
|
||||
issues: Issue[];
|
||||
data?: unknown;
|
||||
executionTime: number;
|
||||
timedOut?: boolean;
|
||||
error?: string;
|
||||
startedAt: Date;
|
||||
completedAt: Date;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Skill 接口定义
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Skill 元数据(用于注册和 UI 展示)
|
||||
*/
|
||||
export interface SkillMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
version: string;
|
||||
category: SkillCategory;
|
||||
inputs: string[];
|
||||
outputs: string[];
|
||||
configSchema?: z.ZodSchema<unknown>;
|
||||
defaultTimeout: number;
|
||||
retryable: boolean;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 配置(运行时)
|
||||
*/
|
||||
export interface SkillConfig {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Zod 配置 Schema 定义
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* DataForensicsSkill 配置 Schema
|
||||
*/
|
||||
export const DataForensicsConfigSchema = z.object({
|
||||
checkLevel: z.enum(['L1', 'L1_L2', 'L1_L2_L25']).default('L1_L2_L25'),
|
||||
tolerancePercent: z.number().min(0).max(1).default(0.1),
|
||||
});
|
||||
export type DataForensicsConfig = z.infer<typeof DataForensicsConfigSchema>;
|
||||
|
||||
/**
|
||||
* EditorialSkill 配置 Schema
|
||||
*/
|
||||
export const EditorialConfigSchema = z.object({
|
||||
standard: z.enum(['default', 'chinese-core', 'international']).default('default'),
|
||||
maxContentLength: z.number().default(100000),
|
||||
});
|
||||
export type EditorialConfig = z.infer<typeof EditorialConfigSchema>;
|
||||
|
||||
/**
|
||||
* MethodologySkill 配置 Schema
|
||||
*/
|
||||
export const MethodologyConfigSchema = z.object({
|
||||
focusAreas: z.array(z.string()).default(['design', 'statistics', 'reporting']),
|
||||
maxContentLength: z.number().default(100000),
|
||||
});
|
||||
export type MethodologyConfig = z.infer<typeof MethodologyConfigSchema>;
|
||||
|
||||
// ==========================================
|
||||
// Skill 接口(通用泛型)
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Skill 接口
|
||||
* 使用泛型支持不同上下文和配置类型
|
||||
*/
|
||||
export interface Skill<
|
||||
TContext extends BaseSkillContext = SkillContext,
|
||||
TConfig extends SkillConfig = SkillConfig
|
||||
> {
|
||||
readonly metadata: SkillMetadata;
|
||||
readonly configSchema?: z.ZodSchema<TConfig>;
|
||||
|
||||
run(context: TContext, config?: TConfig): Promise<SkillResult>;
|
||||
validateConfig?(config: unknown): TConfig;
|
||||
canRun?(context: TContext): boolean;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Profile 配置
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Pipeline 中的 Skill 配置项
|
||||
*/
|
||||
export interface PipelineItem {
|
||||
skillId: string;
|
||||
enabled: boolean;
|
||||
config?: SkillConfig;
|
||||
timeout?: number;
|
||||
optional?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 严格程度
|
||||
*/
|
||||
export type Strictness = 'STRICT' | 'STANDARD' | 'LENIENT';
|
||||
|
||||
/**
|
||||
* 全局配置
|
||||
*/
|
||||
export interface GlobalConfig {
|
||||
strictness: Strictness;
|
||||
timeoutMultiplier?: number;
|
||||
continueOnError?: boolean;
|
||||
maxConcurrency?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 期刊 Profile 配置
|
||||
*/
|
||||
export interface JournalProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
pipeline: PipelineItem[];
|
||||
globalConfig?: GlobalConfig;
|
||||
version: string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
createdBy?: string;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Executor 配置
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Skill 执行器配置
|
||||
*/
|
||||
export interface ExecutorConfig<TContext extends BaseSkillContext = SkillContext> {
|
||||
defaultTimeout: number;
|
||||
maxRetries: number;
|
||||
retryDelay: number;
|
||||
continueOnError: boolean;
|
||||
logLevel: 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
/**
|
||||
* Skill 执行完成回调(V2.1 扩展点)
|
||||
* 可用于:增量持久化、实时状态推送、监控上报
|
||||
*/
|
||||
onSkillComplete?: (
|
||||
skillId: string,
|
||||
result: SkillResult,
|
||||
context: TContext
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行结果汇总
|
||||
*/
|
||||
export interface ExecutionSummary {
|
||||
taskId: string;
|
||||
profileId: string;
|
||||
overallStatus: 'success' | 'partial' | 'failed';
|
||||
totalSkills: number;
|
||||
successCount: number;
|
||||
warningCount: number;
|
||||
errorCount: number;
|
||||
skippedCount: number;
|
||||
timeoutCount: number;
|
||||
results: SkillResult[];
|
||||
totalExecutionTime: number;
|
||||
startedAt: Date;
|
||||
completedAt: Date;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 错误码定义
|
||||
// ==========================================
|
||||
|
||||
export const SkillErrorCodes = {
|
||||
SKILL_NOT_FOUND: 'SKILL_NOT_FOUND',
|
||||
SKILL_TIMEOUT: 'SKILL_TIMEOUT',
|
||||
SKILL_EXECUTION_ERROR: 'SKILL_EXECUTION_ERROR',
|
||||
CONFIG_VALIDATION_ERROR: 'CONFIG_VALIDATION_ERROR',
|
||||
PROFILE_NOT_FOUND: 'PROFILE_NOT_FOUND',
|
||||
CONTEXT_INVALID: 'CONTEXT_INVALID',
|
||||
SECURITY_PATH_VIOLATION: 'SECURITY_PATH_VIOLATION',
|
||||
RESOURCE_LIMIT_EXCEEDED: 'RESOURCE_LIMIT_EXCEEDED',
|
||||
} as const;
|
||||
|
||||
export type SkillErrorCode = typeof SkillErrorCodes[keyof typeof SkillErrorCodes];
|
||||
12
backend/src/modules/rvw/skills/index.ts
Normal file
12
backend/src/modules/rvw/skills/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* RVW V2.0 Skills 架构 - 模块主入口
|
||||
*
|
||||
* @version 2.0.0
|
||||
* @since 2026-02-18
|
||||
*/
|
||||
|
||||
// 核心框架
|
||||
export * from './core/index.js';
|
||||
|
||||
// Skills Library
|
||||
export * from './library/index.js';
|
||||
154
backend/src/modules/rvw/skills/library/BaseSkill.ts
Normal file
154
backend/src/modules/rvw/skills/library/BaseSkill.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* RVW V2.0 Skills 架构 - Skill 基类
|
||||
*
|
||||
* 提供通用功能,简化 Skill 实现
|
||||
* 内置 Zod 配置验证
|
||||
*
|
||||
* @version 2.0.0
|
||||
* @since 2026-02-18
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
Skill,
|
||||
SkillMetadata,
|
||||
SkillContext,
|
||||
SkillResult,
|
||||
SkillConfig,
|
||||
BaseSkillContext,
|
||||
SkillErrorCodes,
|
||||
} from '../core/types.js';
|
||||
import { logger } from '../../../../common/logging/index.js';
|
||||
|
||||
/**
|
||||
* Skill 基类
|
||||
* 使用泛型支持不同上下文和配置类型
|
||||
* 内置 Zod 配置验证
|
||||
*/
|
||||
export abstract class BaseSkill<
|
||||
TContext extends BaseSkillContext = SkillContext,
|
||||
TConfig extends SkillConfig = SkillConfig
|
||||
> implements Skill<TContext, TConfig> {
|
||||
|
||||
abstract readonly metadata: SkillMetadata;
|
||||
|
||||
/**
|
||||
* 配置 Schema(子类定义)
|
||||
*/
|
||||
readonly configSchema?: z.ZodSchema<TConfig>;
|
||||
|
||||
/**
|
||||
* 子类实现具体逻辑
|
||||
*/
|
||||
abstract execute(
|
||||
context: TContext,
|
||||
config?: TConfig
|
||||
): Promise<Omit<SkillResult, 'skillId' | 'skillName' | 'startedAt' | 'completedAt'>>;
|
||||
|
||||
/**
|
||||
* 执行入口(统一处理日志、计时、配置验证等)
|
||||
*/
|
||||
async run(context: TContext, config?: TConfig): Promise<SkillResult> {
|
||||
const startedAt = new Date();
|
||||
const startTime = Date.now();
|
||||
|
||||
logger.info({
|
||||
skillId: this.metadata.id,
|
||||
taskId: context.taskId,
|
||||
}, `[${this.metadata.id}] Starting execution`);
|
||||
|
||||
try {
|
||||
// 配置验证(使用 Zod)
|
||||
const validatedConfig = this.validateConfig(config);
|
||||
|
||||
const result = await this.execute(context, validatedConfig);
|
||||
const executionTime = Date.now() - startTime;
|
||||
|
||||
logger.info({
|
||||
skillId: this.metadata.id,
|
||||
taskId: context.taskId,
|
||||
status: result.status,
|
||||
executionTime,
|
||||
issueCount: result.issues.length,
|
||||
}, `[${this.metadata.id}] Execution completed`);
|
||||
|
||||
return {
|
||||
...result,
|
||||
skillId: this.metadata.id,
|
||||
skillName: this.metadata.name,
|
||||
executionTime,
|
||||
startedAt,
|
||||
completedAt: new Date(),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const executionTime = Date.now() - startTime;
|
||||
|
||||
// 区分 Zod 验证错误和执行错误
|
||||
const isValidationError = error instanceof z.ZodError;
|
||||
const errorType = isValidationError
|
||||
? SkillErrorCodes.CONFIG_VALIDATION_ERROR
|
||||
: SkillErrorCodes.SKILL_EXECUTION_ERROR;
|
||||
|
||||
const errorMessage = isValidationError
|
||||
? `配置验证失败: ${(error as z.ZodError).errors.map(e => e.message).join(', ')}`
|
||||
: `执行失败: ${error instanceof Error ? error.message : String(error)}`;
|
||||
|
||||
logger.error({
|
||||
skillId: this.metadata.id,
|
||||
taskId: context.taskId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
errorType,
|
||||
}, `[${this.metadata.id}] Execution failed`);
|
||||
|
||||
return {
|
||||
skillId: this.metadata.id,
|
||||
skillName: this.metadata.name,
|
||||
status: 'error',
|
||||
issues: [{
|
||||
severity: 'ERROR',
|
||||
type: errorType,
|
||||
message: errorMessage,
|
||||
}],
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
executionTime,
|
||||
startedAt,
|
||||
completedAt: new Date(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置验证(使用 Zod Schema)
|
||||
* 子类可覆盖以实现自定义验证
|
||||
*/
|
||||
validateConfig(config: unknown): TConfig {
|
||||
if (this.configSchema) {
|
||||
return this.configSchema.parse(config ?? {});
|
||||
}
|
||||
return (config ?? {}) as TConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认前置检查(子类可覆盖)
|
||||
*/
|
||||
canRun(_context: TContext): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 辅助方法:从上下文获取前置 Skill 结果
|
||||
*/
|
||||
protected getPreviousResult(context: TContext, skillId: string): SkillResult | undefined {
|
||||
return context.previousResults.find(r => r.skillId === skillId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 辅助方法:获取评分标签
|
||||
*/
|
||||
protected getScoreLabel(score: number): string {
|
||||
if (score >= 90) return '优秀';
|
||||
if (score >= 80) return '良好';
|
||||
if (score >= 60) return '合格';
|
||||
return '需改进';
|
||||
}
|
||||
}
|
||||
241
backend/src/modules/rvw/skills/library/DataForensicsSkill.ts
Normal file
241
backend/src/modules/rvw/skills/library/DataForensicsSkill.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* RVW V2.0 Skills 架构 - 数据侦探 Skill
|
||||
*
|
||||
* 调用 Python 服务进行表格提取和数据验证
|
||||
* 特性:依赖注入、路径安全检查、优雅降级
|
||||
*
|
||||
* @version 2.0.0
|
||||
* @since 2026-02-18
|
||||
*/
|
||||
|
||||
import { BaseSkill } from './BaseSkill.js';
|
||||
import {
|
||||
SkillMetadata,
|
||||
SkillContext,
|
||||
SkillResult,
|
||||
DataForensicsConfigSchema,
|
||||
DataForensicsConfig,
|
||||
ForensicsResult,
|
||||
Issue,
|
||||
} from '../core/types.js';
|
||||
import {
|
||||
extractionClient,
|
||||
IExtractionClient,
|
||||
ForensicsResult as ClientForensicsResult,
|
||||
} from '../../../../common/document/ExtractionClient.js';
|
||||
import { logger } from '../../../../common/logging/index.js';
|
||||
|
||||
/**
|
||||
* 安全:允许的文件存储路径前缀
|
||||
*/
|
||||
const ALLOWED_PATH_PREFIXES = [
|
||||
'/app/uploads/', // Docker 容器内路径
|
||||
'D:\\MyCursor\\', // 开发环境 Windows
|
||||
'D:/MyCursor/', // 开发环境 Windows (forward slash)
|
||||
'/tmp/rvw-uploads/', // 临时目录
|
||||
'C:\\Users\\', // Windows 用户目录
|
||||
'/home/', // Linux 用户目录
|
||||
];
|
||||
|
||||
/**
|
||||
* 数据侦探 Skill
|
||||
* 依赖注入:ExtractionClient 可在测试中 Mock
|
||||
*/
|
||||
export class DataForensicsSkill extends BaseSkill<SkillContext, DataForensicsConfig> {
|
||||
/**
|
||||
* 依赖注入:ExtractionClient
|
||||
*/
|
||||
private readonly extractionClient: IExtractionClient;
|
||||
|
||||
constructor(client?: IExtractionClient) {
|
||||
super();
|
||||
this.extractionClient = client || extractionClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zod 配置 Schema
|
||||
*/
|
||||
readonly configSchema = DataForensicsConfigSchema;
|
||||
|
||||
readonly metadata: SkillMetadata = {
|
||||
id: 'DataForensicsSkill',
|
||||
name: '数据侦探',
|
||||
description: '提取 Word 文档表格,验证数据算术正确性和统计学一致性',
|
||||
version: '2.0.0',
|
||||
category: 'forensics',
|
||||
|
||||
inputs: ['documentPath'],
|
||||
outputs: ['tables', 'methods', 'forensicsResult'],
|
||||
|
||||
defaultTimeout: 60000, // 60 秒
|
||||
retryable: true,
|
||||
|
||||
icon: '🐍',
|
||||
color: '#3776ab',
|
||||
};
|
||||
|
||||
/**
|
||||
* 前置检查
|
||||
* 增加路径安全验证(防止路径遍历攻击)
|
||||
*/
|
||||
canRun(context: SkillContext): boolean {
|
||||
if (!context.documentPath) {
|
||||
logger.warn({ taskId: context.taskId }, '[DataForensicsSkill] No document path');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!context.documentPath.toLowerCase().endsWith('.docx')) {
|
||||
logger.info({ taskId: context.taskId }, '[DataForensicsSkill] Not a .docx file, skipping');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 安全检查:路径白名单
|
||||
const normalizedPath = context.documentPath.replace(/\\/g, '/');
|
||||
const isPathAllowed = ALLOWED_PATH_PREFIXES.some(prefix => {
|
||||
const normalizedPrefix = prefix.replace(/\\/g, '/');
|
||||
return normalizedPath.startsWith(normalizedPrefix);
|
||||
});
|
||||
|
||||
if (!isPathAllowed) {
|
||||
logger.error({
|
||||
taskId: context.taskId,
|
||||
documentPath: '[REDACTED]', // 不记录完整路径
|
||||
}, '[DataForensicsSkill] Document path not in allowed prefixes (security check)');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否包含路径遍历
|
||||
if (context.documentPath.includes('..')) {
|
||||
logger.error({
|
||||
taskId: context.taskId,
|
||||
}, '[DataForensicsSkill] Path traversal detected (security check)');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行数据侦探
|
||||
*/
|
||||
async execute(
|
||||
context: SkillContext,
|
||||
config?: DataForensicsConfig
|
||||
): Promise<Omit<SkillResult, 'skillId' | 'skillName' | 'startedAt' | 'completedAt'>> {
|
||||
const checkLevel = config?.checkLevel || 'L1_L2_L25';
|
||||
const tolerancePercent = config?.tolerancePercent || 0.1;
|
||||
|
||||
logger.info({
|
||||
taskId: context.taskId,
|
||||
checkLevel,
|
||||
tolerancePercent,
|
||||
}, '[DataForensicsSkill] Starting analysis');
|
||||
|
||||
try {
|
||||
// 使用依赖注入的 client
|
||||
const result = await this.extractionClient.analyzeDocx(context.documentPath, {
|
||||
checkLevel,
|
||||
tolerancePercent,
|
||||
});
|
||||
|
||||
// 转换为内部格式
|
||||
const forensicsResult = this.convertResult(result);
|
||||
|
||||
// 计算状态和评分
|
||||
const hasErrors = forensicsResult.summary.errorCount > 0;
|
||||
const hasWarnings = forensicsResult.summary.warningCount > 0;
|
||||
|
||||
let status: 'success' | 'warning' | 'error';
|
||||
let score: number;
|
||||
|
||||
if (hasErrors) {
|
||||
status = 'error';
|
||||
score = Math.max(0, 100 - forensicsResult.summary.errorCount * 20);
|
||||
} else if (hasWarnings) {
|
||||
status = 'warning';
|
||||
score = Math.max(60, 100 - forensicsResult.summary.warningCount * 5);
|
||||
} else {
|
||||
status = 'success';
|
||||
score = 100;
|
||||
}
|
||||
|
||||
logger.info({
|
||||
taskId: context.taskId,
|
||||
tableCount: forensicsResult.summary.totalTables,
|
||||
issueCount: forensicsResult.summary.totalIssues,
|
||||
errorCount: forensicsResult.summary.errorCount,
|
||||
warningCount: forensicsResult.summary.warningCount,
|
||||
}, '[DataForensicsSkill] Analysis completed');
|
||||
|
||||
return {
|
||||
status,
|
||||
score,
|
||||
scoreLabel: this.getScoreLabel(score),
|
||||
issues: forensicsResult.issues,
|
||||
data: forensicsResult,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
// 特殊处理:Python 服务不可用时的优雅降级
|
||||
const errorObj = error as NodeJS.ErrnoException;
|
||||
if (errorObj.code === 'ECONNREFUSED' || errorObj.code === 'ETIMEDOUT') {
|
||||
logger.warn({
|
||||
taskId: context.taskId,
|
||||
error: errorObj.message,
|
||||
}, '[DataForensicsSkill] Python service unavailable, degrading gracefully');
|
||||
|
||||
return {
|
||||
status: 'warning',
|
||||
issues: [{
|
||||
severity: 'WARNING',
|
||||
type: 'SERVICE_UNAVAILABLE',
|
||||
message: '数据验证服务暂不可用,已跳过表格验证。建议稍后重试。',
|
||||
}],
|
||||
data: {
|
||||
tables: [],
|
||||
methods: [],
|
||||
issues: [],
|
||||
summary: { totalTables: 0, totalIssues: 0, errorCount: 0, warningCount: 1 },
|
||||
} as ForensicsResult,
|
||||
};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换 Python 返回的结果为内部格式
|
||||
*/
|
||||
private convertResult(result: ClientForensicsResult): ForensicsResult {
|
||||
const issues: Issue[] = result.issues.map(issue => ({
|
||||
severity: issue.severity,
|
||||
type: issue.type,
|
||||
message: issue.message,
|
||||
location: issue.location,
|
||||
evidence: issue.evidence,
|
||||
}));
|
||||
|
||||
return {
|
||||
tables: result.tables.map(t => ({
|
||||
id: t.id,
|
||||
caption: t.caption,
|
||||
data: t.data,
|
||||
html: t.html,
|
||||
headers: t.headers,
|
||||
rowCount: t.rowCount,
|
||||
colCount: t.colCount,
|
||||
})),
|
||||
methods: result.methods,
|
||||
issues,
|
||||
summary: {
|
||||
totalTables: result.summary.totalTables,
|
||||
totalIssues: result.summary.totalIssues,
|
||||
errorCount: result.summary.errorCount,
|
||||
warningCount: result.summary.warningCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const dataForensicsSkill = new DataForensicsSkill();
|
||||
187
backend/src/modules/rvw/skills/library/EditorialSkill.ts
Normal file
187
backend/src/modules/rvw/skills/library/EditorialSkill.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* RVW V2.0 Skills 架构 - 稿约规范性评估 Skill
|
||||
*
|
||||
* 封装现有的 editorialService
|
||||
* 特性:资源限制检查、LLM 调用
|
||||
*
|
||||
* @version 2.0.0
|
||||
* @since 2026-02-18
|
||||
*/
|
||||
|
||||
import { BaseSkill } from './BaseSkill.js';
|
||||
import {
|
||||
SkillMetadata,
|
||||
SkillContext,
|
||||
SkillResult,
|
||||
EditorialConfigSchema,
|
||||
EditorialConfig,
|
||||
Issue,
|
||||
} from '../core/types.js';
|
||||
import { reviewEditorialStandards } from '../../services/editorialService.js';
|
||||
import { EditorialReview, EditorialItem } from '../../types/index.js';
|
||||
import { logger } from '../../../../common/logging/index.js';
|
||||
|
||||
/**
|
||||
* 默认最大内容长度
|
||||
*/
|
||||
const DEFAULT_MAX_CONTENT_LENGTH = 100000;
|
||||
|
||||
/**
|
||||
* 稿约规范性评估 Skill
|
||||
*/
|
||||
export class EditorialSkill extends BaseSkill<SkillContext, EditorialConfig> {
|
||||
/**
|
||||
* Zod 配置 Schema
|
||||
*/
|
||||
readonly configSchema = EditorialConfigSchema;
|
||||
|
||||
readonly metadata: SkillMetadata = {
|
||||
id: 'EditorialSkill',
|
||||
name: '稿约规范性评估',
|
||||
description: '评估稿件是否符合期刊稿约规范(11项标准)',
|
||||
version: '2.0.0',
|
||||
category: 'editorial',
|
||||
|
||||
inputs: ['documentContent'],
|
||||
outputs: ['editorialResult'],
|
||||
|
||||
defaultTimeout: 45000, // 45 秒
|
||||
retryable: true,
|
||||
|
||||
icon: '📋',
|
||||
color: '#52c41a',
|
||||
};
|
||||
|
||||
/**
|
||||
* 前置检查
|
||||
*/
|
||||
canRun(context: SkillContext): boolean {
|
||||
if (!context.documentContent || context.documentContent.trim().length === 0) {
|
||||
logger.warn({ taskId: context.taskId }, '[EditorialSkill] No document content');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 资源限制检查
|
||||
const maxLength = DEFAULT_MAX_CONTENT_LENGTH;
|
||||
if (context.documentContent.length > maxLength) {
|
||||
logger.warn({
|
||||
taskId: context.taskId,
|
||||
contentLength: context.documentContent.length,
|
||||
limit: maxLength,
|
||||
}, '[EditorialSkill] Content too long');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行稿约规范性评估
|
||||
*/
|
||||
async execute(
|
||||
context: SkillContext,
|
||||
config?: EditorialConfig
|
||||
): Promise<Omit<SkillResult, 'skillId' | 'skillName' | 'startedAt' | 'completedAt'>> {
|
||||
const maxContentLength = config?.maxContentLength || DEFAULT_MAX_CONTENT_LENGTH;
|
||||
|
||||
logger.info({
|
||||
taskId: context.taskId,
|
||||
contentLength: context.documentContent.length,
|
||||
}, '[EditorialSkill] Starting evaluation');
|
||||
|
||||
// 截断过长内容
|
||||
let content = context.documentContent;
|
||||
if (content.length > maxContentLength) {
|
||||
content = content.substring(0, maxContentLength);
|
||||
logger.warn({
|
||||
taskId: context.taskId,
|
||||
originalLength: context.documentContent.length,
|
||||
truncatedLength: maxContentLength,
|
||||
}, '[EditorialSkill] Content truncated');
|
||||
}
|
||||
|
||||
// 调用现有 editorialService
|
||||
const result = await reviewEditorialStandards(content, 'deepseek-v3', context.userId);
|
||||
|
||||
// 转换为 SkillResult 格式
|
||||
const issues = this.convertToIssues(result);
|
||||
|
||||
// 计算状态
|
||||
const errorCount = issues.filter(i => i.severity === 'ERROR').length;
|
||||
const warningCount = issues.filter(i => i.severity === 'WARNING').length;
|
||||
|
||||
let status: 'success' | 'warning' | 'error';
|
||||
if (errorCount > 0) {
|
||||
status = 'error';
|
||||
} else if (warningCount > 0) {
|
||||
status = 'warning';
|
||||
} else {
|
||||
status = 'success';
|
||||
}
|
||||
|
||||
logger.info({
|
||||
taskId: context.taskId,
|
||||
score: result.overall_score,
|
||||
itemCount: result.items.length,
|
||||
errorCount,
|
||||
warningCount,
|
||||
}, '[EditorialSkill] Evaluation completed');
|
||||
|
||||
return {
|
||||
status,
|
||||
score: result.overall_score,
|
||||
scoreLabel: this.getScoreLabel(result.overall_score),
|
||||
issues,
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 EditorialReview 转换为 Issue 列表
|
||||
*/
|
||||
private convertToIssues(result: EditorialReview): Issue[] {
|
||||
const issues: Issue[] = [];
|
||||
|
||||
for (const item of result.items) {
|
||||
if (item.status === 'fail') {
|
||||
issues.push({
|
||||
severity: 'ERROR',
|
||||
type: `EDITORIAL_${this.normalizeType(item.criterion)}`,
|
||||
message: item.issues.join('; ') || item.criterion,
|
||||
evidence: {
|
||||
criterion: item.criterion,
|
||||
score: item.score,
|
||||
suggestions: item.suggestions,
|
||||
},
|
||||
});
|
||||
} else if (item.status === 'warning') {
|
||||
issues.push({
|
||||
severity: 'WARNING',
|
||||
type: `EDITORIAL_${this.normalizeType(item.criterion)}`,
|
||||
message: item.issues.join('; ') || item.criterion,
|
||||
evidence: {
|
||||
criterion: item.criterion,
|
||||
score: item.score,
|
||||
suggestions: item.suggestions,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化类型名称
|
||||
*/
|
||||
private normalizeType(criterion: string): string {
|
||||
return criterion
|
||||
.toUpperCase()
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(/[^A-Z0-9_]/g, '')
|
||||
.substring(0, 30);
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const editorialSkill = new EditorialSkill();
|
||||
191
backend/src/modules/rvw/skills/library/MethodologySkill.ts
Normal file
191
backend/src/modules/rvw/skills/library/MethodologySkill.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* RVW V2.0 Skills 架构 - 方法学评估 Skill
|
||||
*
|
||||
* 封装现有的 methodologyService
|
||||
* 特性:资源限制检查、可利用前置 Skill 的统计方法检测结果
|
||||
*
|
||||
* @version 2.0.0
|
||||
* @since 2026-02-18
|
||||
*/
|
||||
|
||||
import { BaseSkill } from './BaseSkill.js';
|
||||
import {
|
||||
SkillMetadata,
|
||||
SkillContext,
|
||||
SkillResult,
|
||||
MethodologyConfigSchema,
|
||||
MethodologyConfig,
|
||||
Issue,
|
||||
} from '../core/types.js';
|
||||
import { reviewMethodology } from '../../services/methodologyService.js';
|
||||
import { MethodologyReview, MethodologyIssue } from '../../types/index.js';
|
||||
import { logger } from '../../../../common/logging/index.js';
|
||||
|
||||
/**
|
||||
* 默认最大内容长度
|
||||
*/
|
||||
const DEFAULT_MAX_CONTENT_LENGTH = 100000;
|
||||
|
||||
/**
|
||||
* 方法学评估 Skill
|
||||
*/
|
||||
export class MethodologySkill extends BaseSkill<SkillContext, MethodologyConfig> {
|
||||
/**
|
||||
* Zod 配置 Schema
|
||||
*/
|
||||
readonly configSchema = MethodologyConfigSchema;
|
||||
|
||||
readonly metadata: SkillMetadata = {
|
||||
id: 'MethodologySkill',
|
||||
name: '方法学评估',
|
||||
description: '评估研究设计、统计方法和结果报告的科学性(20个检查点)',
|
||||
version: '2.0.0',
|
||||
category: 'methodology',
|
||||
|
||||
inputs: ['documentContent', 'methods'],
|
||||
outputs: ['methodologyResult'],
|
||||
|
||||
defaultTimeout: 45000, // 45 秒
|
||||
retryable: true,
|
||||
|
||||
icon: '🔬',
|
||||
color: '#722ed1',
|
||||
};
|
||||
|
||||
/**
|
||||
* 前置检查
|
||||
*/
|
||||
canRun(context: SkillContext): boolean {
|
||||
if (!context.documentContent || context.documentContent.trim().length === 0) {
|
||||
logger.warn({ taskId: context.taskId }, '[MethodologySkill] No document content');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 资源限制检查
|
||||
const maxLength = DEFAULT_MAX_CONTENT_LENGTH;
|
||||
if (context.documentContent.length > maxLength) {
|
||||
logger.warn({
|
||||
taskId: context.taskId,
|
||||
contentLength: context.documentContent.length,
|
||||
limit: maxLength,
|
||||
}, '[MethodologySkill] Content too long');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行方法学评估
|
||||
*/
|
||||
async execute(
|
||||
context: SkillContext,
|
||||
config?: MethodologyConfig
|
||||
): Promise<Omit<SkillResult, 'skillId' | 'skillName' | 'startedAt' | 'completedAt'>> {
|
||||
const maxContentLength = config?.maxContentLength || DEFAULT_MAX_CONTENT_LENGTH;
|
||||
|
||||
logger.info({
|
||||
taskId: context.taskId,
|
||||
contentLength: context.documentContent.length,
|
||||
detectedMethods: context.methods?.length || 0,
|
||||
}, '[MethodologySkill] Starting evaluation');
|
||||
|
||||
// 截断过长内容
|
||||
let content = context.documentContent;
|
||||
if (content.length > maxContentLength) {
|
||||
content = content.substring(0, maxContentLength);
|
||||
logger.warn({
|
||||
taskId: context.taskId,
|
||||
originalLength: context.documentContent.length,
|
||||
truncatedLength: maxContentLength,
|
||||
}, '[MethodologySkill] Content truncated');
|
||||
}
|
||||
|
||||
// 如果 DataForensicsSkill 提取了统计方法,可以添加到 prompt 中
|
||||
// 目前 reviewMethodology 不支持此参数,留作未来扩展
|
||||
const methodsHint = context.methods?.join(', ') || '';
|
||||
if (methodsHint) {
|
||||
logger.debug({
|
||||
taskId: context.taskId,
|
||||
methodsHint,
|
||||
}, '[MethodologySkill] Using detected methods as hint');
|
||||
}
|
||||
|
||||
// 调用现有 methodologyService
|
||||
const result = await reviewMethodology(content, 'deepseek-v3', context.userId);
|
||||
|
||||
// 转换为 SkillResult 格式
|
||||
const issues = this.convertToIssues(result);
|
||||
|
||||
// 计算状态
|
||||
const errorCount = issues.filter(i => i.severity === 'ERROR').length;
|
||||
const warningCount = issues.filter(i => i.severity === 'WARNING').length;
|
||||
|
||||
let status: 'success' | 'warning' | 'error';
|
||||
if (errorCount > 0) {
|
||||
status = 'error';
|
||||
} else if (warningCount > 0) {
|
||||
status = 'warning';
|
||||
} else {
|
||||
status = 'success';
|
||||
}
|
||||
|
||||
logger.info({
|
||||
taskId: context.taskId,
|
||||
score: result.overall_score,
|
||||
partCount: result.parts.length,
|
||||
errorCount,
|
||||
warningCount,
|
||||
}, '[MethodologySkill] Evaluation completed');
|
||||
|
||||
return {
|
||||
status,
|
||||
score: result.overall_score,
|
||||
scoreLabel: this.getScoreLabel(result.overall_score),
|
||||
issues,
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 MethodologyReview 转换为 Issue 列表
|
||||
*/
|
||||
private convertToIssues(result: MethodologyReview): Issue[] {
|
||||
const issues: Issue[] = [];
|
||||
|
||||
for (const part of result.parts) {
|
||||
for (const issue of part.issues) {
|
||||
issues.push({
|
||||
severity: issue.severity === 'major' ? 'ERROR' : 'WARNING',
|
||||
type: `METHODOLOGY_${this.normalizeType(issue.type)}`,
|
||||
message: issue.description,
|
||||
location: {
|
||||
paragraph: undefined, // 可以根据 issue.location 解析
|
||||
},
|
||||
evidence: {
|
||||
part: part.part,
|
||||
issueType: issue.type,
|
||||
location: issue.location,
|
||||
suggestion: issue.suggestion,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化类型名称
|
||||
*/
|
||||
private normalizeType(type: string): string {
|
||||
return type
|
||||
.toUpperCase()
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(/[^A-Z0-9_]/g, '')
|
||||
.substring(0, 30);
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const methodologySkill = new MethodologySkill();
|
||||
54
backend/src/modules/rvw/skills/library/index.ts
Normal file
54
backend/src/modules/rvw/skills/library/index.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* RVW V2.0 Skills 架构 - Skills Library 统一导出
|
||||
*
|
||||
* 提供 Skills 注册入口和统一导出
|
||||
*
|
||||
* @version 2.0.0
|
||||
* @since 2026-02-18
|
||||
*/
|
||||
|
||||
import { SkillRegistry } from '../core/registry.js';
|
||||
import { dataForensicsSkill, DataForensicsSkill } from './DataForensicsSkill.js';
|
||||
import { editorialSkill, EditorialSkill } from './EditorialSkill.js';
|
||||
import { methodologySkill, MethodologySkill } from './MethodologySkill.js';
|
||||
|
||||
/**
|
||||
* 注册所有内置 Skills
|
||||
*/
|
||||
export function registerBuiltinSkills(): void {
|
||||
SkillRegistry.registerAll([
|
||||
dataForensicsSkill,
|
||||
editorialSkill,
|
||||
methodologySkill,
|
||||
]);
|
||||
|
||||
SkillRegistry.markInitialized();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有内置 Skills(用于测试)
|
||||
*/
|
||||
export function getBuiltinSkills() {
|
||||
return [
|
||||
dataForensicsSkill,
|
||||
editorialSkill,
|
||||
methodologySkill,
|
||||
];
|
||||
}
|
||||
|
||||
// 导出 Skill 类(用于类型引用和测试)
|
||||
export {
|
||||
DataForensicsSkill,
|
||||
EditorialSkill,
|
||||
MethodologySkill,
|
||||
};
|
||||
|
||||
// 导出单例(用于直接调用)
|
||||
export {
|
||||
dataForensicsSkill,
|
||||
editorialSkill,
|
||||
methodologySkill,
|
||||
};
|
||||
|
||||
// 导出基类
|
||||
export { BaseSkill } from './BaseSkill.js';
|
||||
@@ -1,15 +1,18 @@
|
||||
/**
|
||||
* RVW稿件审查 Worker(Platform-Only架构)
|
||||
*
|
||||
* V2.0 Skills 架构改造:
|
||||
* - 使用 SkillExecutor 执行 Skills Pipeline
|
||||
* - 支持 Profile 配置的审稿策略
|
||||
* - 保留向后兼容(通过环境变量控制)
|
||||
*
|
||||
* ✅ Platform-Only架构:
|
||||
* - 使用 pg-boss 队列处理审查任务
|
||||
* - 任务状态存储在 job.state (pg-boss管理)
|
||||
* - 审查结果更新到 ReviewTask表(业务信息)
|
||||
*
|
||||
* 任务流程:
|
||||
* 1. 获取任务信息和提取的文本
|
||||
* 2. 根据选择的智能体执行审查
|
||||
* 3. 更新任务状态和结果
|
||||
* @version 2.0.0
|
||||
* @since 2026-02-18
|
||||
*/
|
||||
|
||||
import { prisma } from '../../../config/database.js';
|
||||
@@ -24,6 +27,21 @@ import { calculateOverallScore, getMethodologyStatus } from '../services/utils.j
|
||||
import type { AgentType, EditorialReview, MethodologyReview } from '../types/index.js';
|
||||
import { activityService } from '../../../common/services/activity.service.js';
|
||||
|
||||
// V2.0 Skills 架构导入
|
||||
import {
|
||||
SkillExecutor,
|
||||
ProfileResolver,
|
||||
createPartialContextFromTask,
|
||||
registerBuiltinSkills,
|
||||
ExecutionSummary,
|
||||
} from '../skills/index.js';
|
||||
|
||||
/**
|
||||
* 是否使用 V2.0 Skills 架构
|
||||
* 通过环境变量控制,默认开启(MVP 阶段可随时回滚)
|
||||
*/
|
||||
const USE_SKILLS_ARCHITECTURE = process.env.RVW_USE_SKILLS !== 'false';
|
||||
|
||||
/**
|
||||
* 审查任务数据结构
|
||||
*/
|
||||
@@ -35,15 +53,32 @@ interface ReviewJob {
|
||||
modelType: ModelType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 Skills(仅执行一次)
|
||||
*/
|
||||
let skillsInitialized = false;
|
||||
function ensureSkillsInitialized() {
|
||||
if (!skillsInitialized && USE_SKILLS_ARCHITECTURE) {
|
||||
registerBuiltinSkills();
|
||||
skillsInitialized = true;
|
||||
logger.info('[reviewWorker] Skills architecture initialized');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册审查 Worker 到队列
|
||||
*
|
||||
* 此函数应在应用启动时调用(index.ts)
|
||||
*/
|
||||
export function registerReviewWorker() {
|
||||
logger.info('[reviewWorker] Registering reviewWorker');
|
||||
logger.info('[reviewWorker] Registering reviewWorker', {
|
||||
useSkillsArchitecture: USE_SKILLS_ARCHITECTURE,
|
||||
});
|
||||
|
||||
// 注册审查Worker(队列名使用下划线,不用冒号)
|
||||
// 初始化 Skills
|
||||
ensureSkillsInitialized();
|
||||
|
||||
// 注册审查Worker
|
||||
jobQueue.process<ReviewJob>('rvw_review_task', async (job: Job<ReviewJob>) => {
|
||||
const { taskId, userId, agents, extractedText, modelType } = job.data;
|
||||
const startTime = Date.now();
|
||||
@@ -54,6 +89,7 @@ export function registerReviewWorker() {
|
||||
userId,
|
||||
agents,
|
||||
textLength: extractedText.length,
|
||||
useSkillsArchitecture: USE_SKILLS_ARCHITECTURE,
|
||||
});
|
||||
|
||||
console.log(`\n📝 处理审查任务`);
|
||||
@@ -61,12 +97,20 @@ export function registerReviewWorker() {
|
||||
console.log(` Task ID: ${taskId}`);
|
||||
console.log(` 智能体: ${agents.join(', ')}`);
|
||||
console.log(` 文本长度: ${extractedText.length} 字符`);
|
||||
console.log(` 架构: ${USE_SKILLS_ARCHITECTURE ? 'V2.0 Skills' : 'V1.0 Legacy'}`);
|
||||
|
||||
try {
|
||||
// ✅ 检查任务是否已经完成(防止重复处理)
|
||||
const existingTask = await prisma.reviewTask.findUnique({
|
||||
where: { id: taskId },
|
||||
select: { status: true, completedAt: true, overallScore: true },
|
||||
select: {
|
||||
status: true,
|
||||
completedAt: true,
|
||||
overallScore: true,
|
||||
filePath: true,
|
||||
fileName: true,
|
||||
fileSize: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingTask?.status === 'completed' && existingTask.completedAt) {
|
||||
@@ -77,83 +121,132 @@ export function registerReviewWorker() {
|
||||
overallScore: existingTask.overallScore,
|
||||
});
|
||||
console.log(`\n⚠️ 任务已完成,跳过重复处理`);
|
||||
console.log(` Task ID: ${taskId}`);
|
||||
console.log(` 完成时间: ${existingTask.completedAt}`);
|
||||
console.log(` 得分: ${existingTask.overallScore}`);
|
||||
return {
|
||||
taskId,
|
||||
skipped: true,
|
||||
reason: 'Task already completed',
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 1. 运行选中的智能体
|
||||
// 根据架构选择执行路径
|
||||
// ========================================
|
||||
let editorialResult: EditorialReview | null = null;
|
||||
let methodologyResult: MethodologyReview | null = null;
|
||||
let skillsSummary: ExecutionSummary | null = null;
|
||||
|
||||
if (agents.includes('editorial')) {
|
||||
// 更新进度状态
|
||||
await prisma.reviewTask.update({
|
||||
where: { id: taskId },
|
||||
data: { status: 'reviewing_editorial' },
|
||||
});
|
||||
|
||||
logger.info('[reviewWorker] Running editorial review', { taskId });
|
||||
console.log(' 🔍 运行稿约规范性智能体...');
|
||||
|
||||
// ✅ Phase 3.5.5: 传递 userId 支持灰度预览
|
||||
editorialResult = await reviewEditorialStandards(extractedText, modelType, userId);
|
||||
|
||||
logger.info('[reviewWorker] Editorial review completed', {
|
||||
if (USE_SKILLS_ARCHITECTURE) {
|
||||
// ========================================
|
||||
// V2.0 Skills 架构
|
||||
// ========================================
|
||||
skillsSummary = await executeWithSkills(
|
||||
taskId,
|
||||
score: editorialResult?.overall_score,
|
||||
});
|
||||
console.log(` ✅ 稿约规范性完成,得分: ${editorialResult?.overall_score}`);
|
||||
}
|
||||
userId,
|
||||
agents,
|
||||
extractedText,
|
||||
existingTask?.filePath || '',
|
||||
existingTask?.fileName || 'unknown.docx',
|
||||
existingTask?.fileSize || 0
|
||||
);
|
||||
|
||||
if (agents.includes('methodology')) {
|
||||
// 更新进度状态
|
||||
await prisma.reviewTask.update({
|
||||
where: { id: taskId },
|
||||
data: { status: 'reviewing_methodology' },
|
||||
});
|
||||
// 从 Skills 结果中提取兼容数据
|
||||
const editorialSkillResult = skillsSummary.results.find(r => r.skillId === 'EditorialSkill');
|
||||
const methodologySkillResult = skillsSummary.results.find(r => r.skillId === 'MethodologySkill');
|
||||
|
||||
logger.info('[reviewWorker] Running methodology review', { taskId });
|
||||
console.log(' 🔬 运行方法学智能体...');
|
||||
|
||||
// ✅ Phase 3.5.5: 传递 userId 支持灰度预览
|
||||
methodologyResult = await reviewMethodology(extractedText, modelType, userId);
|
||||
|
||||
logger.info('[reviewWorker] Methodology review completed', {
|
||||
if (editorialSkillResult?.status !== 'skipped' && editorialSkillResult?.data) {
|
||||
editorialResult = editorialSkillResult.data as EditorialReview;
|
||||
}
|
||||
if (methodologySkillResult?.status !== 'skipped' && methodologySkillResult?.data) {
|
||||
methodologyResult = methodologySkillResult.data as MethodologyReview;
|
||||
}
|
||||
|
||||
logger.info('[reviewWorker] Skills execution completed', {
|
||||
taskId,
|
||||
score: methodologyResult?.overall_score,
|
||||
overallStatus: skillsSummary.overallStatus,
|
||||
skillCount: skillsSummary.totalSkills,
|
||||
successCount: skillsSummary.successCount,
|
||||
errorCount: skillsSummary.errorCount,
|
||||
});
|
||||
console.log(` ✅ 方法学评估完成,得分: ${methodologyResult?.overall_score}`);
|
||||
} else {
|
||||
// ========================================
|
||||
// V1.0 Legacy 架构
|
||||
// ========================================
|
||||
if (agents.includes('editorial')) {
|
||||
await prisma.reviewTask.update({
|
||||
where: { id: taskId },
|
||||
data: { status: 'reviewing_editorial' },
|
||||
});
|
||||
|
||||
logger.info('[reviewWorker] Running editorial review (legacy)', { taskId });
|
||||
console.log(' 🔍 运行稿约规范性智能体...');
|
||||
|
||||
editorialResult = await reviewEditorialStandards(extractedText, modelType, userId);
|
||||
|
||||
logger.info('[reviewWorker] Editorial review completed', {
|
||||
taskId,
|
||||
score: editorialResult?.overall_score,
|
||||
});
|
||||
console.log(` ✅ 稿约规范性完成,得分: ${editorialResult?.overall_score}`);
|
||||
}
|
||||
|
||||
if (agents.includes('methodology')) {
|
||||
await prisma.reviewTask.update({
|
||||
where: { id: taskId },
|
||||
data: { status: 'reviewing_methodology' },
|
||||
});
|
||||
|
||||
logger.info('[reviewWorker] Running methodology review (legacy)', { taskId });
|
||||
console.log(' 🔬 运行方法学智能体...');
|
||||
|
||||
methodologyResult = await reviewMethodology(extractedText, modelType, userId);
|
||||
|
||||
logger.info('[reviewWorker] Methodology review completed', {
|
||||
taskId,
|
||||
score: methodologyResult?.overall_score,
|
||||
});
|
||||
console.log(` ✅ 方法学评估完成,得分: ${methodologyResult?.overall_score}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 2. 计算综合分数
|
||||
// 计算综合分数
|
||||
// ========================================
|
||||
const editorialScore = editorialResult?.overall_score ?? null;
|
||||
const methodologyScore = methodologyResult?.overall_score ?? null;
|
||||
const overallScore = calculateOverallScore(editorialScore, methodologyScore, agents);
|
||||
|
||||
// 计算耗时
|
||||
const endTime = Date.now();
|
||||
const durationSeconds = Math.floor((endTime - startTime) / 1000);
|
||||
|
||||
// ========================================
|
||||
// 3. 更新任务结果
|
||||
// 更新任务结果
|
||||
// ========================================
|
||||
logger.info('[reviewWorker] Updating task result', { taskId });
|
||||
|
||||
// 构建 Skills 执行摘要(V2.0 新增,存储到 picoExtract 字段)
|
||||
// 注意:picoExtract 字段暂时复用,未来迁移后移到专用字段
|
||||
const skillsContext = USE_SKILLS_ARCHITECTURE && skillsSummary
|
||||
? {
|
||||
version: '2.0',
|
||||
executedAt: new Date().toISOString(),
|
||||
summary: {
|
||||
overallStatus: skillsSummary.overallStatus,
|
||||
totalSkills: skillsSummary.totalSkills,
|
||||
successCount: skillsSummary.successCount,
|
||||
errorCount: skillsSummary.errorCount,
|
||||
totalExecutionTime: skillsSummary.totalExecutionTime,
|
||||
},
|
||||
forensicsResult: skillsSummary.results.find(r => r.skillId === 'DataForensicsSkill')?.data,
|
||||
}
|
||||
: null;
|
||||
|
||||
await prisma.reviewTask.update({
|
||||
where: { id: taskId },
|
||||
data: {
|
||||
status: 'completed',
|
||||
editorialReview: editorialResult as unknown as Prisma.InputJsonValue ?? Prisma.JsonNull,
|
||||
methodologyReview: methodologyResult as unknown as Prisma.InputJsonValue ?? Prisma.JsonNull,
|
||||
picoExtract: skillsContext as unknown as Prisma.InputJsonValue ?? Prisma.JsonNull,
|
||||
overallScore,
|
||||
editorialScore: editorialScore,
|
||||
methodologyScore: methodologyScore,
|
||||
@@ -171,6 +264,7 @@ export function registerReviewWorker() {
|
||||
methodologyScore,
|
||||
overallScore,
|
||||
durationSeconds,
|
||||
architecture: USE_SKILLS_ARCHITECTURE ? 'skills' : 'legacy',
|
||||
});
|
||||
|
||||
console.log('\n✅ 审查完成:');
|
||||
@@ -179,7 +273,7 @@ export function registerReviewWorker() {
|
||||
console.log(` 耗时: ${durationSeconds}秒`);
|
||||
|
||||
// ========================================
|
||||
// 4. 埋点:记录审查完成
|
||||
// 埋点:记录审查完成
|
||||
// ========================================
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
@@ -201,7 +295,6 @@ export function registerReviewWorker() {
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// 埋点失败不影响主业务
|
||||
logger.warn('[reviewWorker] 埋点失败', { error: e });
|
||||
}
|
||||
|
||||
@@ -212,23 +305,27 @@ export function registerReviewWorker() {
|
||||
methodologyScore,
|
||||
durationSeconds,
|
||||
success: true,
|
||||
architecture: USE_SKILLS_ARCHITECTURE ? 'skills' : 'legacy',
|
||||
};
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const errorStack = error instanceof Error ? error.stack : undefined;
|
||||
|
||||
logger.error('[reviewWorker] ❌ Review failed', {
|
||||
jobId: job.id,
|
||||
taskId,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
error: errorMessage,
|
||||
stack: errorStack,
|
||||
});
|
||||
|
||||
console.error(`\n❌ 审查失败: ${error.message}`);
|
||||
console.error(`\n❌ 审查失败: ${errorMessage}`);
|
||||
|
||||
// 更新任务状态为失败
|
||||
await prisma.reviewTask.update({
|
||||
where: { id: taskId },
|
||||
data: {
|
||||
status: 'failed',
|
||||
errorMessage: error.message || 'Review failed',
|
||||
errorMessage: errorMessage || 'Review failed',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -240,4 +337,66 @@ export function registerReviewWorker() {
|
||||
logger.info('[reviewWorker] ✅ Worker registered: rvw_review_task');
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 V2.0 Skills 架构执行审查
|
||||
*/
|
||||
async function executeWithSkills(
|
||||
taskId: string,
|
||||
userId: string,
|
||||
agents: AgentType[],
|
||||
extractedText: string,
|
||||
filePath: string,
|
||||
fileName: string,
|
||||
fileSize: number
|
||||
): Promise<ExecutionSummary> {
|
||||
// 更新状态
|
||||
await prisma.reviewTask.update({
|
||||
where: { id: taskId },
|
||||
data: { status: 'reviewing' },
|
||||
});
|
||||
|
||||
// 构建 Profile
|
||||
const profile = ProfileResolver.resolveFromAgents(agents);
|
||||
|
||||
logger.info('[reviewWorker] Using Skills architecture', {
|
||||
taskId,
|
||||
profileId: profile.id,
|
||||
pipelineLength: profile.pipeline.length,
|
||||
});
|
||||
|
||||
console.log(` 🚀 使用 V2.0 Skills 架构`);
|
||||
console.log(` Profile: ${profile.name}`);
|
||||
console.log(` Pipeline: ${profile.pipeline.map(p => p.skillId).join(' → ')}`);
|
||||
|
||||
// 构建上下文
|
||||
const partialContext = createPartialContextFromTask({
|
||||
id: taskId,
|
||||
userId,
|
||||
filePath,
|
||||
content: extractedText,
|
||||
fileName,
|
||||
fileSize,
|
||||
});
|
||||
|
||||
// 执行 Pipeline
|
||||
const executor = new SkillExecutor();
|
||||
const summary = await executor.execute(profile, partialContext);
|
||||
|
||||
// 输出执行结果
|
||||
console.log(`\n 📊 Skills 执行结果:`);
|
||||
for (const result of summary.results) {
|
||||
const statusIcon = result.status === 'success' ? '✅' :
|
||||
result.status === 'warning' ? '⚠️' :
|
||||
result.status === 'error' ? '❌' :
|
||||
result.status === 'skipped' ? '⏭️' : '⏱️';
|
||||
console.log(` ${statusIcon} ${result.skillName}: ${result.status} (${result.executionTime}ms)`);
|
||||
if (result.score !== undefined) {
|
||||
console.log(` 得分: ${result.score}`);
|
||||
}
|
||||
if (result.issues.length > 0) {
|
||||
console.log(` 问题: ${result.issues.length} 个`);
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user