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:
2026-02-18 10:09:40 +08:00
parent e785969e54
commit 9f256c4a02
20 changed files with 5603 additions and 72 deletions

View 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();
}

View 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();

View 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';

View 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());
}
/**
* 注册新 ProfileV2.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,
};
}
}

View 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();

View 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];

View 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';

View 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 '需改进';
}
}

View 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();

View 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();

View 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();

View 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';

View File

@@ -1,15 +1,18 @@
/**
* RVW稿件审查 WorkerPlatform-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;
}