Files
HaHafeng 66255368b7 feat(admin): Add user management and upgrade to module permission system
Features - User Management (Phase 4.1):
- Database: Add user_modules table for fine-grained module permissions
- Database: Add 4 user permissions (view/create/edit/delete) to role_permissions
- Backend: UserService (780 lines) - CRUD with tenant isolation
- Backend: UserController + UserRoutes (648 lines) - 13 API endpoints
- Backend: Batch import users from Excel
- Frontend: UserListPage (412 lines) - list/filter/search/pagination
- Frontend: UserFormPage (341 lines) - create/edit with module config
- Frontend: UserDetailPage (393 lines) - details/tenant/module management
- Frontend: 3 modal components (592 lines) - import/assign/configure
- API: GET/POST/PUT/DELETE /api/admin/users/* endpoints

Architecture Upgrade - Module Permission System:
- Backend: Add getUserModules() method in auth.service
- Backend: Login API returns modules array in user object
- Frontend: AuthContext adds hasModule() method
- Frontend: Navigation filters modules based on user.modules
- Frontend: RouteGuard checks requiredModule instead of requiredVersion
- Frontend: Remove deprecated version-based permission system
- UX: Only show accessible modules in navigation (clean UI)
- UX: Smart redirect after login (avoid 403 for regular users)

Fixes:
- Fix UTF-8 encoding corruption in ~100 docs files
- Fix pageSize type conversion in userService (String to Number)
- Fix authUser undefined error in TopNavigation
- Fix login redirect logic with role-based access check
- Update Git commit guidelines v1.2 with UTF-8 safety rules

Database Changes:
- CREATE TABLE user_modules (user_id, tenant_id, module_code, is_enabled)
- ADD UNIQUE CONSTRAINT (user_id, tenant_id, module_code)
- INSERT 4 permissions + role assignments
- UPDATE PUBLIC tenant with 8 module subscriptions

Technical:
- Backend: 5 new files (~2400 lines)
- Frontend: 10 new files (~2500 lines)
- Docs: 1 development record + 2 status updates + 1 guideline update
- Total: ~4900 lines of code

Status: User management 100% complete, module permission system operational
2026-01-16 13:42:10 +08:00

72 KiB
Raw Permalink Blame History

AI智能文献 - 全文复筛开发计划

文档版本: V1.2
创建日期: 2025-11-22
最后更新: 2025-11-23
适用阶段: MVP阶段
预计工期: 2周
维护者: ASL开发团队


📊 开发进度概览

当前状态🚧 Day 1-5 已完成(后端全部完成),待前端开发

阶段 时间 状态 完成度
Week 1 2025-11-22 ~ 2025-11-23 已完成 100%
- Day 1: PDF存储服务 2025-11-22 已完成 100%
- Day 2: LLM 12字段服务 2025-11-22 已完成 100%
- Day 3: 验证服务 2025-11-22 已完成 100%
- Day 4上午: 数据库设计与迁移 2025-11-23 已完成 100%
- Day 4下午: 批处理服务 2025-11-23 已完成 100%
- Day 5: API开发 2025-11-23 已完成 100%
Week 2 2025-11-24 ~ 2025-11-27 待开始 0%
- Day 6-7: 前端开发 待开始 待开始 0%
- Day 8: 前后端联调测试 待开始 待开始 0%

已完成核心功能

  • PDF存储与提取服务包装层
  • Prompt工程体系System/User/Schema
  • LLM 12字段服务Nougat优先 + 双模型 + 3层JSON解析
  • 医学逻辑验证器5条规则
  • 证据链验证器
  • 冲突检测服务
  • 集成测试框架
  • 数据库Schema设计3张表
  • 数据库手动迁移完成
  • FulltextScreeningService批处理服务
  • 5个核心API接口
  • Excel导出服务4个Sheet
  • Zod参数验证
  • REST Client测试用例31个

下一步Day 6 前端UI开发


📋 目录


1. 项目概述

1.1 功能定位

全文复筛是系统评价/Meta分析的第二阶段筛选对标题摘要初筛后"初步纳入"的文献,基于全文内容进行严格的二次筛选。

1.2 核心价值

维度 说明
目的 判断文献数据的完整性和可用性,确定最终纳入
依据 12字段模板基于Cochrane RoB 2.0标准)
输入 标题摘要初筛的"初步纳入"文献已获取全文PDF
输出 最终纳入列表 + 排除列表 + PRISMA统计 + 完整证据链
后续 最终纳入的文献进入"全文数据提取"阶段
质量标准 准确率≥85%MVP≥92%V1.0≥96%V2.0

1.3 12字段模板基于Cochrane RoB 2.0标准)

1. 文献来源第一作者、年份、期刊、DOI
2. 研究类型RCT、队列研究等
3. 研究设计细节(随访时间、数据来源)
4. 疾病诊断标准
5. 人群特征(样本量、人口统计学)⭐
6. 基线数据(功能指标、合并症)⭐
7. 干预措施(药物、剂量、疗程)⭐
8. 对照措施
9. 结局指标(主要/次要结局)⭐⭐⭐ 最关键
10. 统计方法
11. 质量评价随机化、盲法、ITT分析等⭐⭐ 关键方法学
12. 其他信息(注册号、利益冲突)

全文复筛的任务评估这12个字段的存在性、完整性、可提取性,基于Cochrane偏倚风险评估标准判断是否纳入。

关键字段重点评估参考Cochrane RoB 2.0

  • 随机化方法(序列生成、分配隐藏)
  • 盲法(施盲对象、施盲方法)
  • 结果完整性失访率、ITT分析
  • 选择性报告(注册方案对比)

1.4 与全文提取的关系

维度 全文复筛 全文提取
目的 判断是否可用(纳入/排除) 提取具体数据(用于分析)
12字段用法 评估是否完整 提取具体数值
输出 二分类(纳入/排除)+ 理由 详细的字段值(数据表)
底层技术 完全相同PDF、LLM、冲突检测等 完全相同
数据库/API 独立设计(各自演进) 独立设计

核心设计思想

  • 底层技术高度复用PDF存储、全文提取、LLM调用、冲突检测等抽象为通用能力层
  • 应用层独立设计全文复筛和全文提取各自的数据库表、API、业务逻辑独立演进
  • 未来不需拆分:从一开始就是独立的模块

2. 架构设计

2.1 三层架构

┌─────────────────────────────────────────────────┐
│         应用层(本次开发重点)                    │
│                                                  │
│  全文复筛模块                                     │
│  ├─ AslFulltextScreeningTask数据表          │
│  ├─ AslFulltextScreeningResult数据表        │
│  ├─ FulltextScreeningService业务逻辑        │
│  ├─ FulltextScreeningController控制器       │
│  ├─ fulltext-screening routesAPI            │
│  └─ 前端审核工作台                               │
└────────────┬────────────────────────────────────┘
             │ 调用
             ↓
┌─────────────────────────────────────────────────┐
│       通用能力层(本次开发,未来复用)⭐          │
│                                                  │
│  ├─ PDFStorageServicePDF上传/存储/下载)      │
│  ├─ FulltextExtractionClient调用Python微服务│
│  ├─ LLM12FieldsServiceLLM处理12字段         │
│  ├─ ConflictDetectionService冲突检测        │
│  └─ AsyncTaskService异步任务+进度追踪)       │
└─────────────────────────────────────────────────┘
             ↓ 调用
┌─────────────────────────────────────────────────┐
│       平台基础层(已实现)                       │
│  storage | logger | cache | jobQueue | prisma   │
└─────────────────────────────────────────────────┘

2.2 目录结构

backend/src/modules/asl/
├── common/                              # 通用能力层 ⭐ 可被extraction复用
│   ├── pdf/                             # ← PDF存储已完成Day 1
│   │   ├── PDFStorageService.ts
│   │   ├── PDFStorageFactory.ts
│   │   ├── adapters/
│   │   │   ├── DifyPDFStorageAdapter.ts
│   │   │   └── OSSPDFStorageAdapter.ts
│   │   └── types.ts
│   ├── llm/                             # ← LLM服务
│   │   ├── LLM12FieldsService.ts        # LLM处理12字段
│   │   ├── PromptBuilder.ts             # Prompt构建Section-Aware
│   │   └── types.ts
│   ├── validation/                      # ← 验证服务(新增)⭐
│   │   ├── MedicalLogicValidator.ts     # 医学逻辑验证
│   │   ├── EvidenceChainValidator.ts    # 证据链验证
│   │   └── ConflictDetectionService.ts  # 冲突检测
│   ├── tasks/                           # ← 异步任务
│   │   └── AsyncTaskService.ts
│   └── utils/
│       ├── tokenCalculator.ts
│       └── jsonParser.ts
│
├── fulltext-screening/                  # 全文复筛模块 ⭐ 本次开发重点
│   ├── routes/
│   │   └── fulltext-screening.ts        # 5个API接口
│   ├── controllers/
│   │   └── FulltextScreeningController.ts
│   ├── services/
│   │   └── FulltextScreeningService.ts  # 业务逻辑
│   ├── types/
│   │   └── screening.types.ts           # TypeScript类型
│   └── prompts/
│       ├── system_prompt.md                 # System PromptSection-Aware
│       ├── user_prompt_template.md          # User Prompt模板
│       ├── cochrane_standards/              # Cochrane标准描述分字段
│       │   ├── 随机化方法.md
│       │   ├── 盲法.md
│       │   ├── 结果完整性.md
│       │   └── ...
│       └── few_shot_examples/               # Few-shot医学案例库⭐
│           ├── 高质量RCT.md
│           ├── 质量不足案例.md
│           └── 信息在中间位置案例.md         # ← 特别重要
│
└── title-screening/                     # 标题摘要初筛(已完成)
    └── ...

frontend-v2/src/modules/asl/
├── pages/
│   ├── FulltextScreeningSettings.tsx    # 设置与启动
│   ├── FulltextScreeningWorkbench.tsx   # 审核工作台 ⭐ 核心
│   └── FulltextScreeningResults.tsx     # 复筛结果
│
├── components/
│   ├── screening/
│   │   ├── PICOSPanel.tsx               # PICOS标准展示可复用
│   │   ├── ScreeningTable.tsx           # 表格化审核(可复用)
│   │   ├── ReviewModal.tsx              # 双视图原文审查
│   │   └── PDFViewer.tsx                # PDF阅读器
│   └── shared/
│       ├── LiteratureAcquisitionTable.tsx  # 全文获取表格
│       └── ExcelExporter.tsx               # Excel导出

3. 通用能力层设计(可复用)

重要说明:通用能力层是本次开发的重要部分,这些服务未来将被全文提取模块复用,因此需要高质量实现。

3.1 PDFStorageService - PDF存储服务

3.1.1 功能定位

⚠️ 注意:这是包装现有能力,不是重新开发

统一的PDF文件管理服务负责

  • PDF上传Dify or OSS- 复用现有storage服务
  • 全文提取调用Python微服务- 复用ExtractionClient
  • PDF下载/删除
  • 支持适配器模式(零代码切换)

现有系统已完成

  • Python微服务PyMuPDF + Nougat
  • ExtractionClient.ts后端集成
  • TokenService.tsToken计算
  • storage服务平台基础层

本次开发:只需包装成统一接口约200行代码

3.1.2 核心接口

// backend/src/modules/asl/common/services/PDFStorageService.ts

export interface PDFUploadResult {
  storageRef: string        // 存储引用Dify: documentId, OSS: key
  storageType: string       // 'dify' or 'oss'
  url: string | null        // Dify: null, OSS: 公开URL
  
  fullText: string          // 提取的全文
  tokenCount: number        // Token数量
  charCount: number         // 字符数
  
  extractionMethod: string  // 'pymupdf' or 'nougat'
  extractionQuality: number // 0.0-1.0
  detectedLanguage: string  // 'chinese' or 'english'
}

export class PDFStorageService {
  /**
   * 上传PDF并提取全文
   */
  async uploadAndExtract(
    literatureId: string,
    pdfBuffer: Buffer,
    filename: string
  ): Promise<PDFUploadResult>
  
  /**
   * 下载PDF
   */
  async download(storageRef: string): Promise<Buffer>
  
  /**
   * 删除PDF
   */
  async delete(storageRef: string): Promise<void>
  
  /**
   * 检查PDF是否存在
   */
  async exists(storageRef: string): Promise<boolean>
}

3.1.3 MVP阶段存储方案

使用Dify存储复用PKB模块

// backend/src/modules/asl/common/adapters/DifyPDFStorageAdapter.ts

export class DifyPDFStorageAdapter implements PDFStorageAdapter {
  
  private difyClient: DifyClient  // ← 复用现有DifyClient ✅
  
  constructor() {
    // 复用common/rag/DifyClient.ts已实现    this.difyClient = new DifyClient(
      process.env.DIFY_API_KEY!,
      process.env.DIFY_BASE_URL!
    )
  }
  
  async upload(literatureId: string, pdfBuffer: Buffer, filename: string) {
    // 1. 上传到Dify复用现有PKB功能    const datasetId = process.env.DIFY_ASL_DATASET_ID!
    const difyDocId = await this.difyClient.uploadDocument(
      datasetId,
      pdfBuffer,
      filename
    )
    
    logger.info('PDF uploaded to Dify', { literatureId, difyDocId })
    
    return {
      ref: difyDocId,
      type: 'dify',
      url: null  // Dify没有公开URL
    }
  }
  
  async download(difyDocId: string): Promise<Buffer> {
    return await this.difyClient.downloadDocument(difyDocId)
  }
}

环境配置

# .env
PDF_STORAGE_TYPE=dify  # MVP阶段使用Dify
DIFY_API_KEY=app-xxx
DIFY_BASE_URL=http://localhost:5001/v1
DIFY_ASL_DATASET_ID=dataset-xxx  # ASL专用知识库

# 未来迁移OSS只需改配置
# PDF_STORAGE_TYPE=oss
# OSS_REGION=cn-hangzhou
# OSS_BUCKET=asl-literatures

3.1.4 全文提取集成

⚠️ 注意直接复用现有ExtractionClient不需要重新实现

// 复用现有ExtractionClient已实现import { ExtractionClient } from '@/common/document/ExtractionClient'

private async extractFulltext(pdfBuffer: Buffer): Promise<ExtractionResult> {
  // ExtractionClient已实现直接使用 ✅
  const extractionClient = new ExtractionClient(
    process.env.EXTRACTION_SERVICE_URL || 'http://localhost:8000'
  )
  
  // Python微服务PyMuPDF + Nougat已部署运行 ✅
  const result = await extractionClient.extractPDF(pdfBuffer)
  
  return {
    text: result.text,
    method: result.extraction_method,      // 'pymupdf' or 'nougat'
    quality: result.extraction_quality,    // 0.0-1.0
    language: result.detected_language     // 'chinese' or 'english'
  }
}

现有系统(可直接使用)

  • backend/src/common/document/ExtractionClient.ts(已实现)
  • extraction_service/Python微服务已部署
  • PyMuPDF、Nougat、语言检测已完成

3.2 LLM12FieldsService - LLM处理12字段服务

3.2.1 功能定位

统一的LLM调用服务支持

  • screening模式评估12字段完整性基于Cochrane标准
  • extraction模式提取12字段详细数据未来
  • 双模型并行DeepSeek-V3 + Qwen3-Max
  • 结果缓存(降低成本)
  • Nougat结构化优先(英文论文)

3.2.2 核心接口

// backend/src/modules/asl/common/services/LLM12FieldsService.ts

export enum LLM12FieldsMode {
  SCREENING = '12fields-screening',    // 评估模式Cochrane标准
  EXTRACTION = '12fields-extraction'   // 提取模式(未来)
}

export interface LLMResult {
  result: any               // 解析后的JSON结果
  processingTime: number    // 处理时间(毫秒)
  tokenUsage: number        // Token使用量
  cost: number              // 成本(人民币)
  extractionMethod: string  // 'nougat' | 'pymupdf'
  structuredFormat: boolean // 是否为结构化格式Markdown
}

export class LLM12FieldsService {
  /**
   * 处理12字段screening or extraction
   * 
   * ⚠️ 策略全文一次性输入但通过Prompt工程优化
   */
  async process12Fields(
    mode: LLM12FieldsMode,
    model: string,          // 'deepseek-v3' | 'qwen-max'
    fullTextMarkdown: string, // ⭐ Nougat提取的Markdown格式全文
    context: any            // 研究方案上下文 + PICOS标准
  ): Promise<LLMResult>
  
  /**
   * 双模型并行调用
   */
  async processDualModels(
    mode: LLM12FieldsMode,
    modelA: string,         // 默认 'deepseek-v3'
    modelB: string,         // 默认 'qwen-max'
    fullTextMarkdown: string,
    context: any
  ): Promise<{ resultA: LLMResult, resultB: LLMResult }>
}

3.2.3 Nougat优先策略

/**
 * PDF全文提取策略
 * 
 * 优先使用Nougat结构化Markdown降级使用PyMuPDF
 */
async extractFullTextStructured(
  pdfBuffer: Buffer,
  filename: string
): Promise<{ text: string; method: string; structured: boolean }> {
  
  // Step 1: 检测语言
  const language = await detectLanguage(pdfBuffer);
  
  // Step 2: 英文论文优先用Nougat
  if (language === 'english') {
    try {
      const nougatResult = await extractionClient.extractPdf(
        pdfBuffer, filename, 'nougat'
      );
      
      if (nougatResult.quality > 0.8) {
        logger.info('✅ 使用Nougat提取结构化Markdown');
        return {
          text: nougatResult.text,
          method: 'nougat',
          structured: true  // ⭐ Markdown格式
        };
      }
    } catch (error) {
      logger.warn('⚠️ Nougat失败降级到PyMuPDF');
    }
  }
  
  // Step 3: 降级使用PyMuPDF
  const pymupdfResult = await extractionClient.extractPdf(
    pdfBuffer, filename, 'pymupdf'
  );
  
  return {
    text: pymupdfResult.text,
    method: 'pymupdf',
    structured: false  // 纯文本
  };
}

Nougat的优势

  • 输出Markdown格式保留章节结构# Methods、## Randomization
  • 表格转换为Markdown表格LLM可直接理解
  • 公式识别为LaTeX
  • 多栏布局智能处理

3.2.4 缓存策略

// LLM响应缓存降低成本
async process12Fields(mode, model, fullText, context): Promise<LLMResult> {
  // 1. 生成缓存key
  const cacheKey = `llm:${mode}:${model}:${hash(fullText + JSON.stringify(context))}`
  
  // 2. 检查缓存
  const cached = await cache.get(cacheKey)
  if (cached) {
    logger.info('LLM cache hit', { mode, model })
    return cached
  }
  
  // 3. 调用LLM全文一次性输入
  const result = await this.callLLM(mode, model, fullText, context)
  
  // 4. 缓存1小时
  await cache.set(cacheKey, result, 3600)
  
  return result
}

3.3 MedicalLogicValidator - 医学逻辑验证服务(新增)

3.3.1 功能定位

基于循证医学标准的自动逻辑验证,检查:

  • RCT研究必须有随机化描述
  • 双盲研究必须说明盲法
  • 样本量与基线数据一致性
  • 基线不平衡需要调整分析
  • ITT分析完整性

3.3.2 核心接口

// backend/src/modules/asl/common/services/MedicalLogicValidator.ts

export interface LogicViolation {
  ruleId: string;
  ruleName: string;
  severity: 'error' | 'warning';
  message: string;
  field: string;
  suggestedAction: string;
}

export interface LogicReport {
  totalRules: number;
  passedRules: number;
  violations: LogicViolation[];
  overallValidity: boolean;
}

export class MedicalLogicValidator {
  /**
   * 验证医学逻辑一致性
   */
  validate(extractedData: ExtractionResult): LogicReport {
    const violations = [];
    
    // 规则1RCT必须有随机化
    if (this.isRCT(extractedData) && !this.hasRandomization(extractedData)) {
      violations.push({
        ruleId: 'rule_001',
        ruleName: 'RCT必须有随机化',
        severity: 'error',
        message: '研究声称是RCT但未找到随机化方法描述',
        field: '随机化方法',
        suggestedAction: 'flag_for_urgent_review'
      });
    }
    
    // 规则2-5其他验证规则...
    
    return {
      totalRules: MEDICAL_LOGIC_RULES.length,
      passedRules: MEDICAL_LOGIC_RULES.length - violations.length,
      violations,
      overallValidity: violations.filter(v => v.severity === 'error').length === 0
    };
  }
}

3.4 ConflictDetectionService - 冲突检测服务

3.4.1 功能定位

双模型结果冲突检测,支持:

  • screening冲突12字段完整性评估冲突
  • extraction冲突数值差异冲突未来
  • 冲突严重程度分级(基于字段重要性)

3.4.2 核心接口

// backend/src/modules/asl/common/services/ConflictDetectionService.ts

export interface ConflictAnalysis {
  hasConflict: boolean        // 是否存在冲突
  conflictFields: string[]    // 冲突的字段列表
  overallConflict: boolean    // 总体决策是否冲突
  severity: string            // 'low' | 'medium' | 'high'
  criticalFieldConflicts: string[]  // 关键字段冲突(随机化、盲法、结果完整性)
  needUrgentReview: boolean   // 是否需要紧急人工复核
}

export class ConflictDetectionService {
  /**
   * 检测screening冲突12字段完整性评估
   */
  detectScreeningConflict(
    modelAResult: ScreeningResult,
    modelBResult: ScreeningResult
  ): ConflictAnalysis
  
  /**
   * 检测extraction冲突数值差异未来
   */
  detectExtractionConflict(
    modelAResult: ExtractionResult,
    modelBResult: ExtractionResult
  ): ConflictAnalysis
  
  /**
   * 智能分流(基于冲突严重程度)
   */
  prioritizeReview(conflict: ConflictAnalysis): {
    priority: number;        // 0-100
    reviewDeadline: Date;
    reasons: string[];
  }
}

3.4.3 冲突严重程度规则基于Cochrane标准

/**
 * Screening冲突严重程度参考Cochrane RoB 2.0关键字段)
 */
private calculateSeverity(
  conflictFields: string[],
  overallConflict: boolean
): string {
  
  // 关键方法学字段Cochrane RoB 2.0核心域)
  const criticalFields = ['随机化方法', '盲法', '结果完整性'];
  const hasCriticalConflict = conflictFields.some(f => criticalFields.includes(f));
  
  if (hasCriticalConflict) {
    return 'high';  // 关键字段冲突 → 高优先级
  }
  // 1. field9结局指标冲突 → high
  if (conflictFields.includes('field9')) {
    return 'high'
  }
  
  // 2. 总体决策冲突 → high
  if (overallConflict) {
    return 'high'
  }
  
  // 3. 关键字段field5/6/7冲突 → medium
  const criticalFields = ['field5', 'field6', 'field7']
  const hasCriticalConflict = conflictFields.some(f => criticalFields.includes(f))
  if (hasCriticalConflict) {
    return 'medium'
  }
  
  // 4. 冲突字段>3个 → medium
  if (conflictFields.length > 3) {
    return 'medium'
  }
  
  // 5. 其他 → low
  return conflictFields.length > 0 ? 'low' : 'none'
}

3.5 AsyncTaskService - 异步任务服务

3.5.1 功能定位

批量处理服务,支持:

  • 固定并发3并发
  • 进度追踪
  • 失败重试
  • 任务取消

3.4.2 核心接口

// backend/src/modules/asl/common/services/AsyncTaskService.ts

export interface BatchResult<T> {
  totalCount: number
  successCount: number
  failedCount: number
  results: T[]
}

export class AsyncTaskService {
  /**
   * 批量处理文献
   */
  async processBatch<T>(
    taskId: string,
    literatureIds: string[],
    processor: (litId: string) => Promise<T>,
    onProgress?: (completed: number, total: number) => void
  ): Promise<BatchResult<T>>
}

4. 数据库设计

4.1 核心表结构

4.1.1 AslFulltextScreeningTask - 全文复筛任务表

model AslFulltextScreeningTask {
  @@schema("asl_schema")
  
  id                String    @id @default(uuid())
  projectId         String
  userId            String
  name              String    @default("全文复筛")
  
  // 数据来源
  sourceTitleTaskId String?   // 来源标题初筛任务ID
  sourceType        String    @default("title_screening")  // title_screening/manual_import
  
  // 任务配置MVP阶段成本友好模型⭐
  modelA            String    @default("deepseek-v3")   // ¥0.001/1K tokens
  modelB            String    @default("qwen-max")      // ¥0.004/1K tokens
  
  // 统计
  totalCount        Int       @default(0)
  pdfReadyCount     Int       @default(0)
  processedCount    Int       @default(0)
  includedCount     Int       @default(0)
  excludedCount     Int       @default(0)
  conflictCount     Int       @default(0)
  pendingCount      Int       @default(0)
  
  // 任务状态
  status            String    @default("pending")  
  // pending → acquiring_pdfs → processing → completed/failed
  errorMessage      String?   @db.Text
  
  // 时间统计
  startedAt         DateTime?
  completedAt       DateTime?
  processingTime    Int?      // 秒
  
  // 成本统计
  totalCost         Float?    // 美元
  totalTokens       Int?
  
  createdAt         DateTime  @default(now())
  updatedAt         DateTime  @updatedAt
  
  // 关联关系
  project           AslScreeningProject @relation(fields: [projectId], references: [id], onDelete: Cascade)
  results           AslFulltextScreeningResult[]
  
  @@index([userId, status])
  @@index([projectId])
  @@index([status, createdAt])
}

4.1.2 AslFulltextScreeningResult - 全文复筛结果表

model AslFulltextScreeningResult {
  @@schema("asl_schema")
  
  id                String    @id @default(uuid())
  taskId            String
  literatureId      String    @unique  // 一个文献只有一条screening结果
  
  // 模型A判断12字段评估
  modelAResult      Json      
  // 结构:{
  //   field1_source: { present: true, completeness: "完整", note: "..." },
  //   field2_studyType: { present: true, completeness: "完整", note: "RCT" },
  //   ... field3-12 ...
  //   field9_outcomes: { present: true, completeness: "完整", extractable: true, note: "..." },
  //   overallAssessment: {
  //     fieldsComplete: "10/12",
  //     criticalFieldsMissing: [],
  //     dataQuality: "高",
  //     extractability: "可提取",
  //     decision: "纳入",
  //     reason: "所有关键字段完整,数据可提取",
  //     confidence: 0.95
  //   }
  // }
  
  modelAProcessTime Int?      // 毫秒
  modelATokens      Int?
  modelACost        Float?    // 美元
  
  // 模型B判断同上结构
  modelBResult      Json
  modelBProcessTime Int?
  modelBTokens      Int?
  modelBCost        Float?
  
  // 冲突分析
  isConflict        Boolean   @default(false)
  conflictFields    Json?     // ["field5", "field9"]
  conflictSeverity  String?   // low/medium/high
  overallConflict   Boolean   @default(false)  // 总体决策是否冲突
  
  // 最终决策
  finalDecision     String    @default("pending")  
  // pending → included/excluded
  decisionMethod    String?   // ai_consensus/manual
  exclusionReason   String?   @db.Text
  exclusionCategory String?   
  // missing_outcomes/incomplete_data/poor_quality/
  // protocol_violation/duplicate/other
  
  // 质量标记
  dataQuality       String?   // high/medium/low
  extractability    String?   // extractable/partial/non_extractable
  
  // 人工审核
  reviewedBy        String?
  reviewedAt        DateTime?
  reviewNote        String?   @db.Text
  
  createdAt         DateTime  @default(now())
  updatedAt         DateTime  @updatedAt
  
  // 关联关系
  task              AslFulltextScreeningTask @relation(fields: [taskId], references: [id], onDelete: Cascade)
  literature        AslLiterature @relation(fields: [literatureId], references: [id], onDelete: Cascade)
  
  @@index([taskId, finalDecision])
  @@index([taskId, isConflict])
  @@index([exclusionCategory])
  @@index([dataQuality])
}

4.1.3 AslLiterature - 文献表(更新字段)

model AslLiterature {
  @@schema("asl_schema")
  
  id                  String    @id @default(uuid())
  projectId           String
  
  // 基本信息
  pmid                String?
  doi                 String?
  title               String    @db.Text
  authors             String?   @db.Text
  journal             String?
  year                Int?
  abstract            String?   @db.Text
  
  // PDF存储由PDFStorageService管理
  hasPDF              Boolean   @default(false)
  pdfStorageType      String?   // 'dify' or 'oss'
  pdfStorageRef       String?   // Dify: documentId, OSS: key
  pdfUrl              String?   // Dify: null, OSS: 公开URL
  pdfStatus           String?   // acquiring/ready/failed
  pdfAcquireMethod    String?   // auto/manual/knowledge_base
  
  // 全文由PDFStorageService提取并存储
  fullText            String?   @db.Text
  fullTextTokenCount  Int?
  fullTextCharCount   Int?
  extractionMethod    String?   // pymupdf/nougat
  extractionQuality   Float?    // 0.0-1.0
  detectedLanguage    String?   // chinese/english
  
  // 阶段标记
  stage               String    @default("imported")  
  // imported → title_screened → pdf_acquired → fulltext_screened → extracted
  
  // 时间戳
  importedAt          DateTime  @default(now())
  updatedAt           DateTime  @updatedAt
  
  // 关联关系(独立)
  project             AslScreeningProject @relation(fields: [projectId], references: [id])
  titleScreening      AslTitleScreeningResult?
  fulltextScreening   AslFulltextScreeningResult?  // 全文复筛(独立)
  dataExtraction      AslDataExtractionResult?     // 全文提取(未来,独立)
  
  @@index([projectId, stage])
  @@index([pmid])
  @@index([pdfStatus])
}

4.2 数据库迁移

⚠️ 澄清:"迁移"就是"创建新表"的意思

因为AI智能文献模块是全新的,这些表之前不存在所以Prisma的"migrate"操作实际上就是创建新表

# 创建新表(第一次执行"迁移"
cd backend
npx prisma migrate dev --name create_asl_fulltext_screening_tables

# 这个命令会:
# 1. 在 asl_schema 中创建3个新表
#    - AslFulltextScreeningTask全新创建
#    - AslFulltextScreeningResult全新创建
#    - AslLiterature如果已存在则跳过不存在则创建
# 2. 生成SQL脚本prisma/migrations/xxx/migration.sql
# 3. 执行SQL创建表
# 4. 记录迁移历史

# SQL示例实际执行的
CREATE TABLE "asl_schema"."AslFulltextScreeningTask" (
  "id" TEXT NOT NULL,
  "projectId" TEXT NOT NULL,
  "userId" TEXT NOT NULL,
  -- ... 其他字段
  PRIMARY KEY ("id")
);

CREATE TABLE "asl_schema"."AslFulltextScreeningResult" (
  -- ...
);

注意事项

  • 不会修改PKB/AIA等其他模块的表
  • 完全独立的新表asl_schema
  • Schema隔离数据隔离

5. API设计

5.1 API端点列表

// 前缀:/api/v1/asl/fulltext-screening

// 1. 创建全文复筛任务
POST   /tasks

// 2. 获取任务列表
GET    /tasks

// 3. 获取任务详情(含进度)
GET    /tasks/:taskId

// 4. 获取任务结果
GET    /tasks/:taskId/results

// 5. 人工审核决策
PUT    /results/:resultId/decision

// 6. 导出Excel
GET    /tasks/:taskId/export-excel

// 7. 取消任务
POST   /tasks/:taskId/cancel

// 8. 重试失败项
POST   /tasks/:taskId/retry-failed

5.2 详细API设计

5.2.1 创建全文复筛任务

POST /api/v1/asl/fulltext-screening/tasks

Request:
{
  "projectId": "proj-123",
  "name": "全文复筛-第一批",
  "sourceTitleTaskId": "title-task-456",  // 来源标题初筛任务
  "literatureIds": ["lit-001", "lit-002", ...],  // 初步纳入的文献
  "modelA": "deepseek-v3",
  "modelB": "qwen-max"
}

Response:
{
  "success": true,
  "data": {
    "taskId": "fs-task-789",
    "status": "pending",
    "totalCount": 30,
    "pdfReadyCount": 28,  // PDF已就绪
    "message": "任务创建成功,正在开始处理"
  }
}

// 后端逻辑
async createTask(req, reply) {
  // 1. 验证文献是否有PDF
  const literatures = await prisma.aslLiterature.findMany({
    where: { id: { in: req.body.literatureIds } }
  })
  
  const pdfReady = literatures.filter(lit => lit.pdfStatus === 'ready')
  
  // 2. 创建任务
  const task = await prisma.aslFulltextScreeningTask.create({
    data: {
      projectId: req.body.projectId,
      userId: req.userId,
      name: req.body.name,
      sourceTitleTaskId: req.body.sourceTitleTaskId,
      modelA: req.body.modelA,
      modelB: req.body.modelB,
      totalCount: literatures.length,
      pdfReadyCount: pdfReady.length,
      status: 'pending'
    }
  })
  
  // 3. 异步处理(不阻塞响应)
  this.screeningService.processTask(task.id).catch(err => {
    logger.error('Screening task failed', { taskId: task.id, error: err })
  })
  
  return { taskId: task.id, status: 'pending', totalCount: literatures.length }
}

5.2.2 获取任务进度

GET /api/v1/asl/fulltext-screening/tasks/:taskId

Response:
{
  "success": true,
  "data": {
    "taskId": "fs-task-789",
    "name": "全文复筛-第一批",
    "status": "processing",  // pending/acquiring_pdfs/processing/completed/failed
    
    "totalCount": 30,
    "pdfReadyCount": 28,
    "processedCount": 15,
    "includedCount": 10,
    "excludedCount": 5,
    "conflictCount": 3,
    "pendingCount": 12,
    
    "progress": 50,  // 百分比
    "estimatedTimeRemaining": 300,  // 秒
    
    "totalCost": 0.15,  // 美元
    "totalTokens": 150000,
    
    "startedAt": "2025-11-22T10:00:00Z",
    "updatedAt": "2025-11-22T10:05:00Z"
  }
}

5.2.3 获取任务结果

GET /api/v1/asl/fulltext-screening/tasks/:taskId/results
Query: 
  - filter: 'all' | 'conflict' | 'included' | 'excluded' | 'pending'
  - page: 1
  - pageSize: 20

Response:
{
  "success": true,
  "data": {
    "taskId": "fs-task-789",
    "totalCount": 30,
    "filteredCount": 3,  // 符合filter的数量
    
    "results": [
      {
        "resultId": "result-001",
        "literatureId": "lit-001",
        "literature": {
          "pmid": "PMID12345",
          "title": "SGLT2抑制剂治疗糖尿病肾病的RCT研究",
          "authors": "Smith JA, et al.",
          "journal": "Lancet",
          "year": 2023
        },
        
        // 模型A判断
        "modelAResult": {
          "field1_source": { "present": true, "completeness": "完整", "note": "第一作者Smith, Lancet 2023" },
          "field2_studyType": { "present": true, "completeness": "完整", "note": "RCT" },
          "field5_population": { "present": true, "completeness": "完整", "note": "样本量500例基线特征详细" },
          "field9_outcomes": { 
            "present": true, 
            "completeness": "完整", 
            "extractable": true,
            "note": "主要结局eGFR有完整数值数据均值±标准差" 
          },
          // ... field3-4, 6-8, 10-12
          
          "overallAssessment": {
            "fieldsComplete": "12/12",
            "criticalFieldsMissing": [],
            "dataQuality": "高",
            "extractability": "可提取",
            "decision": "纳入",
            "reason": "所有12个字段完整关键数据样本量、基线、干预、结局均可提取数据质量高",
            "confidence": 0.95
          }
        },
        
        // 模型B判断
        "modelBResult": {
          // 同上结构
          "overallAssessment": {
            "decision": "纳入",
            "confidence": 0.92
          }
        },
        
        // 冲突分析
        "isConflict": false,
        "conflictFields": [],
        "overallConflict": false,
        "conflictSeverity": "none",
        
        // 最终决策
        "finalDecision": "pending",  // 待人工审核确认
        "dataQuality": "high",
        "extractability": "extractable",
        
        "processingTime": 25000,  // 25秒
        "totalCost": 0.008  // 美元
      },
      
      // 冲突案例
      {
        "resultId": "result-002",
        "literatureId": "lit-005",
        "literature": { ... },
        
        "modelAResult": {
          "field9_outcomes": { 
            "present": false,  // ← A认为缺失
            "completeness": "缺失", 
            "extractable": false,
            "note": "Results部分未报告主要结局数据" 
          },
          "overallAssessment": {
            "decision": "排除",  // ← A建议排除
            "reason": "关键字段field9缺失无法用于Meta分析"
          }
        },
        
        "modelBResult": {
          "field9_outcomes": { 
            "present": true,  // ← B认为存在
            "completeness": "部分完整", 
            "extractable": true,
            "note": "主要结局数据在Discussion部分报告" 
          },
          "overallAssessment": {
            "decision": "纳入",  // ← B建议纳入
            "reason": "虽然结局指标在Discussion报告但数据完整可提取"
          }
        },
        
        "isConflict": true,  // ← 标记冲突
        "conflictFields": ["field9"],
        "overallConflict": true,
        "conflictSeverity": "high",  // field9冲突 → 高严重程度
        
        "finalDecision": "pending"  // 需要人工仲裁
      }
    ],
    
    "pagination": {
      "page": 1,
      "pageSize": 20,
      "totalPages": 2
    }
  }
}

5.2.4 人工审核决策

PUT /api/v1/asl/fulltext-screening/results/:resultId/decision

Request:
{
  "finalDecision": "excluded",  // included/excluded
  "exclusionReason": "关键字段field9结局指标数据不完整仅有P值无具体数值",
  "exclusionCategory": "missing_outcomes",
  "reviewNote": "虽然报告了显著性P<0.05但缺少均值±标准差无法用于Meta分析"
}

Response:
{
  "success": true,
  "message": "决策已保存"
}

5.2.5 导出Excel

GET /api/v1/asl/fulltext-screening/tasks/:taskId/export-excel

Response:
// 直接下载Excel文件

// Excel结构双Sheet
Sheet 1: 最终纳入文献列表
- 文献ID
- PMID
- 研究ID(作者+年份)
- 文献来源
- 标题
- 最终决策
- 决策方式
- 数据质量
- 可提取性

Sheet 2: 排除文献列表
- 文献ID
- PMID
- 研究ID
- 标题
- 排除原因
- 排除分类

Sheet 3: PRISMA统计
- 总计复筛: n=30
- 最终纳入: n=18
- 排除: n=12
  - 结局指标缺失: n=5
  - 数据不完整: n=3
  - 质量问题: n=2
  - 方案违背: n=1
  - 其他: n=1

6. 全文复筛业务层设计

6.1 FulltextScreeningService

// backend/src/modules/asl/fulltext-screening/services/FulltextScreeningService.ts

export class FulltextScreeningService {
  
  private pdfStorage: PDFStorageService
  private llmService: LLM12FieldsService
  private conflictDetection: ConflictDetectionService
  private asyncTask: AsyncTaskService
  
  /**
   * 处理全文复筛任务
   */
  async processTask(taskId: string): Promise<void> {
    const task = await prisma.aslFulltextScreeningTask.findUnique({
      where: { id: taskId },
      include: { project: true }
    })
    
    // 1. 获取待筛选文献仅PDF已就绪
    const literatures = await this.getLiteratures(task)
    
    // 2. 更新任务状态
    await prisma.aslFulltextScreeningTask.update({
      where: { id: taskId },
      data: { 
        status: 'processing',
        startedAt: new Date()
      }
    })
    
    // 3. 批量处理3并发
    try {
      await this.asyncTask.processBatch(
        taskId,
        literatures.map(lit => lit.id),
        async (litId) => await this.screenLiterature(taskId, litId, task),
        async (completed, total) => {
          // 进度回调:更新任务统计
          await this.updateTaskProgress(taskId, completed, total)
        }
      )
      
      // 4. 任务完成
      await this.completeTask(taskId)
      
    } catch (error) {
      // 5. 任务失败
      await prisma.aslFulltextScreeningTask.update({
        where: { id: taskId },
        data: { 
          status: 'failed',
          errorMessage: error.message
        }
      })
      throw error
    }
  }
  
  /**
   * 筛选单篇文献
   */
  private async screenLiterature(
    taskId: string,
    literatureId: string,
    task: AslFulltextScreeningTask
  ): Promise<AslFulltextScreeningResult> {
    
    // 1. 获取文献全文
    const literature = await prisma.aslLiterature.findUnique({
      where: { id: literatureId }
    })
    
    if (!literature.fullText) {
      throw new Error('文献全文未就绪')
    }
    
    // 2. 双模型并行调用
    const { resultA, resultB } = await this.llmService.processDualModels(
      LLM12FieldsMode.SCREENING,
      task.modelA,
      task.modelB,
      literature.fullText,
      task.project  // PICOS上下文
    )
    
    // 3. 冲突检测
    const conflict = this.conflictDetection.detectScreeningConflict(
      resultA.result,
      resultB.result
    )
    
    // 4. 自动决策(无冲突且双模型一致)
    let finalDecision = 'pending'
    let decisionMethod = null
    
    if (!conflict.hasConflict) {
      finalDecision = resultA.result.overallAssessment.decision
      decisionMethod = 'ai_consensus'
    }
    
    // 5. 保存结果
    const result = await prisma.aslFulltextScreeningResult.create({
      data: {
        taskId,
        literatureId,
        
        modelAResult: resultA.result,
        modelAProcessTime: resultA.processingTime,
        modelATokens: resultA.tokenUsage,
        modelACost: resultA.cost,
        
        modelBResult: resultB.result,
        modelBProcessTime: resultB.processingTime,
        modelBTokens: resultB.tokenUsage,
        modelBCost: resultB.cost,
        
        isConflict: conflict.hasConflict,
        conflictFields: conflict.conflictFields,
        overallConflict: conflict.overallConflict,
        conflictSeverity: conflict.severity,
        
        finalDecision,
        decisionMethod,
        
        dataQuality: resultA.result.overallAssessment.dataQuality,
        extractability: resultA.result.overallAssessment.extractability
      }
    })
    
    // 6. 更新文献阶段
    await prisma.aslLiterature.update({
      where: { id: literatureId },
      data: { stage: 'fulltext_screened' }
    })
    
    logger.info('Literature screened', {
      literatureId,
      decision: finalDecision,
      conflict: conflict.hasConflict
    })
    
    return result
  }
}

6.2 Prompt模板

6.2.1 System Prompt

// backend/src/modules/asl/fulltext-screening/prompts/12fields_screening_system.txt

您是循证医学专家,负责评估文献的数据完整性和可用性。

## 任务
基于12字段评估框架判断文献是否可用于Meta分析。

## 研究方案
- **人群(P):** {{population}}
- **干预(I):** {{intervention}}
- **对照(C):** {{comparison}}
- **结局(O):** {{outcome}}
- **研究设计(S):** {{studyDesign}}

## 12字段评估标准

### 1. 文献来源
- 检查第一作者、年份、期刊、DOI是否齐全
- 标准4项齐全为"完整"

### 2. 研究类型
- 检查是否明确说明研究设计RCT、队列研究等
- 标准:类型清晰明确

### 3. 研究设计细节
- 检查:(1) 随访时间 (2) 数据来源(单/多中心)
- 标准:两项都有为"完整"

### 4. 疾病诊断标准
- 检查:是否有明确诊断标准引用
- 标准:有标准引用

### 5. 人群特征 ⭐ 关键
- 检查:(1) 样本量(总数+分组) (2) 人口统计学(年龄、性别)
- 标准:样本量完整,基线特征有均值±标准差

### 6. 基线数据 ⭐ 关键
- 检查:(1) 主要功能指标 (2) 合并症
- 标准:功能指标有基线值(均值±标准差)

### 7. 干预措施 ⭐ 关键
- 检查:(1) 药物类别 (2) 剂量与疗程
- 标准:药物、剂量、频次、疗程都明确

### 8. 对照措施
- 检查:对照组干预是否明确
- 标准:对照内容清晰

### 9. 结局指标 ⭐⭐⭐ 最关键
- 检查:(1) 主要结局是否报告数据 (2) 是否有均值±标准差
- 标准:**必须有完整数值数据可供提取**
- **排除标准**只有P值无具体数据、只有定性描述

### 10. 统计方法
- 检查:统计软件、检验方法
- 标准:方法恰当

### 11. 质量评价
- 检查:偏倚风险评估
- 标准:低风险优先

### 12. 其他信息
- 检查:注册号、利益冲突
- 标准:信息透明

## 输出格式严格JSON

{
  "field1_source": {
    "present": true,
    "completeness": "完整/不完整/缺失",
    "note": "第一作者Smith, Lancet 2023, DOI: 10.1016/..."
  },
  "field2_studyType": {
    "present": true,
    "completeness": "完整",
    "note": "RCT明确说明双盲随机对照试验"
  },
  // ... field3-8 同样结构
  
  "field9_outcomes": {
    "present": true/false,
    "completeness": "完整/不完整/缺失",
    "extractable": true/false,  // ⭐ 关键:数据是否可提取
    "note": "主要结局eGFR下降速率有完整数值数据干预组-2.4±5.2, 对照组-5.5±6.8"
  },
  
  // ... field10-12
  
  "overallAssessment": {
    "fieldsComplete": "10/12",
    "criticalFieldsMissing": ["field9"],  // 缺失的关键字段
    "dataQuality": "高/中/低",
    "extractability": "可提取/部分可提取/无法提取",
    
    "decision": "纳入/排除",  // ⭐ 最终判断
    "reason": "详细说明理由100字内",
    "confidence": 0.0-1.0
  }
}

## 特别注意
1. **field9最关键**:无数值数据必须排除
2. **field5/6/7也重要**:样本量、基线、干预必须完整
3. **排除优先**:有关键数据缺失果断排除
4. **数据可提取性>发表质量**:数据完整比期刊影响因子重要

6.2.2 User Prompt

// backend/src/modules/asl/fulltext-screening/prompts/12fields_screening_user.txt

请仔细阅读以下文献的**全文内容**逐项评估12个字段。

## 全文内容
{{fullText}}

7. 前端设计

7.1 页面结构(三子视图)

全文复筛模块
├── 子视图1设置与启动
│   ├── PICOS标准展示从研究方案继承
│   ├── 全文获取管理表格
│   └── 开始复筛按钮
│
├── 子视图2审核工作台 ⭐ 核心
│   ├── PICOS标准参考可折叠
│   ├── 表格化审核界面
│   │   ├── 双行表头(模型 + 12字段
│   │   ├── 主行:文献信息、判断结果、冲突状态、最终决策
│   │   └── 展开行双模型12字段详细评估
│   └── 点击判断 → 弹出双视图原文审查
│
└── 子视图3复筛结果
    ├── 统计卡片(总计/纳入/排除)
    ├── PRISMA排除原因统计
    ├── Tab切换纳入/排除列表)
    └── Excel导出

7.2 核心组件

7.2.1 FulltextScreeningWorkbench - 审核工作台

// frontend-v2/src/modules/asl/pages/FulltextScreeningWorkbench.tsx

export const FulltextScreeningWorkbench: React.FC = () => {
  const { taskId } = useParams()
  const [task, setTask] = useState<ScreeningTask | null>(null)
  const [results, setResults] = useState<ScreeningResult[]>([])
  const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
  const [reviewModalVisible, setReviewModalVisible] = useState(false)
  const [currentReview, setCurrentReview] = useState<any>(null)
  
  // 轮询任务进度
  useEffect(() => {
    const interval = setInterval(async () => {
      const taskData = await fetchTaskProgress(taskId)
      setTask(taskData)
      
      if (taskData.status === 'completed' || taskData.status === 'failed') {
        clearInterval(interval)
      }
    }, 2000)
    
    return () => clearInterval(interval)
  }, [taskId])
  
  // 加载结果
  useEffect(() => {
    if (task?.status === 'processing' || task?.status === 'completed') {
      fetchTaskResults(taskId).then(setResults)
    }
  }, [task?.status])
  
  return (
    <div className="fulltext-screening-workbench">
      {/* 进度条 */}
      {task?.status === 'processing' && (
        <Progress 
          percent={task.progress} 
          status="active"
          format={() => `${task.processedCount} / ${task.totalCount}`}
        />
      )}
      
      {/* PICOS标准面板可折叠 */}
      <PICOSPanel project={task?.project} collapsible />
      
      {/* 表格化审核界面 */}
      <ScreeningTable
        taskType="fulltext"
        results={results}
        expandedRows={expandedRows}
        onToggleExpand={(id) => toggleExpanded(id)}
        onClickJudge={(result, field) => {
          setCurrentReview({ result, field })
          setReviewModalVisible(true)
        }}
        onDecisionChange={(resultId, decision) => 
          updateDecision(resultId, decision)
        }
      />
      
      {/* 双视图原文审查模态框 */}
      <ReviewModal
        visible={reviewModalVisible}
        result={currentReview?.result}
        field={currentReview?.field}
        onClose={() => setReviewModalVisible(false)}
      />
    </div>
  )
}

7.2.2 ScreeningTable - 表格化审核

// frontend-v2/src/modules/asl/components/screening/ScreeningTable.tsx

interface ScreeningTableProps {
  taskType: 'title' | 'fulltext'
  results: ScreeningResult[]
  expandedRows: Set<string>
  onToggleExpand: (id: string) => void
  onClickJudge: (result: ScreeningResult, field: string) => void
  onDecisionChange: (resultId: string, decision: string) => void
}

export const ScreeningTable: React.FC<ScreeningTableProps> = ({
  taskType,
  results,
  expandedRows,
  onToggleExpand,
  onClickJudge,
  onDecisionChange
}) => {
  
  return (
    <div className="screening-table">
      <table>
        {/* 双行表头 */}
        <thead>
          <tr>
            <th rowSpan={2}>展开</th>
            <th rowSpan={2}>文献ID</th>
            <th rowSpan={2}>研究ID</th>
            <th rowSpan={2}>文献来源</th>
            
            {/* DeepSeek判断 */}
            <th colSpan={taskType === 'title' ? 5 : 13}>
              DeepSeek 判断
            </th>
            
            {/* Qwen判断 */}
            <th colSpan={taskType === 'title' ? 5 : 13}>
              Qwen 判断
            </th>
            
            <th rowSpan={2}>冲突状态</th>
            <th rowSpan={2}>最终决策</th>
          </tr>
          <tr>
            {/* DeepSeek细分全文复筛12字段 */}
            {taskType === 'fulltext' ? (
              <>
                <th>F1</th><th>F2</th><th>F3</th><th>F4</th>
                <th>F5</th><th>F6</th><th>F7</th><th>F8</th>
                <th>F9</th><th>F10</th><th>F11</th><th>F12</th>
                <th>结论</th>
              </>
            ) : (
              // 标题初筛PICOS
              <>
                <th>P</th><th>I</th><th>C</th><th>S</th><th>结论</th>
              </>
            )}
            
            {/* Qwen细分同上 */}
            {/* ... */}
          </tr>
        </thead>
        
        <tbody>
          {results.map(result => (
            <React.Fragment key={result.id}>
              {/* 主行 */}
              <tr className={result.isConflict ? 'conflict-row' : ''}>
                <td onClick={() => onToggleExpand(result.id)}>
                  {expandedRows.has(result.id) ? '-' : '+'}
                </td>
                <td>{result.literature.pmid}</td>
                <td>{result.literature.studyId}</td>
                <td>{result.literature.source}</td>
                
                {/* DeepSeek判断12字段 */}
                {taskType === 'fulltext' && (
                  <>
                    {[1,2,3,4,5,6,7,8,9,10,11,12].map(i => (
                      <td 
                        key={`a-f${i}`}
                        className="judge-cell"
                        onClick={() => onClickJudge(result, `field${i}`)}
                      >
                        {getFieldIcon(result.modelAResult[`field${i}`])}
                      </td>
                    ))}
                    <td className={result.modelAResult.overallAssessment.decision === '纳入' ? 'text-green' : 'text-red'}>
                      {result.modelAResult.overallAssessment.decision}
                    </td>
                  </>
                )}
                
                {/* Qwen判断同上 */}
                {/* ... */}
                
                {/* 冲突状态 */}
                <td>
                  {result.isConflict ? (
                    <Badge status="error" text="冲突" />
                  ) : (
                    <Badge status="success" text="一致" />
                  )}
                </td>
                
                {/* 最终决策 */}
                <td>
                  <Select
                    value={result.finalDecision}
                    onChange={(value) => onDecisionChange(result.id, value)}
                  >
                    <Option value="pending">待定</Option>
                    <Option value="included">纳入</Option>
                    <Option value="excluded">排除</Option>
                  </Select>
                </td>
              </tr>
              
              {/* 展开行12字段详细评估 */}
              {expandedRows.has(result.id) && (
                <tr className="expanded-row">
                  <td colSpan={30}>
                    <div className="grid grid-cols-2 gap-4">
                      {/* DeepSeek详细评估 */}
                      <div>
                        <h4>DeepSeek 评估</h4>
                        {Object.entries(result.modelAResult).map(([field, assessment]) => {
                          if (field.startsWith('field')) {
                            return (
                              <div key={field} className="field-assessment">
                                <strong>{field}:</strong>
                                <span>{assessment.completeness}</span>
                                <span className="note">{assessment.note}</span>
                              </div>
                            )
                          }
                        })}
                      </div>
                      
                      {/* Qwen详细评估 */}
                      <div>
                        {/* 同上 */}
                      </div>
                    </div>
                  </td>
                </tr>
              )}
            </React.Fragment>
          ))}
        </tbody>
      </table>
    </div>
  )
}

// 辅助函数根据completeness返回图标
function getFieldIcon(assessment: any): ReactNode {
  const { completeness } = assessment
  
  switch (completeness) {
    case '完整':
      return <CheckCircle className="text-green-600" />
    case '不完整':
      return <ExclamationCircle className="text-yellow-600" />
    case '缺失':
      return <CloseCircle className="text-red-600" />
    default:
      return <QuestionCircle className="text-gray-400" />
  }
}

7.2.3 ReviewModal - 双视图原文审查

// frontend-v2/src/modules/asl/components/screening/ReviewModal.tsx

interface ReviewModalProps {
  visible: boolean
  result: ScreeningResult
  field: string  // 'field1'-'field12'
  onClose: () => void
}

export const ReviewModal: React.FC<ReviewModalProps> = ({
  visible,
  result,
  field,
  onClose
}) => {
  
  return (
    <Modal
      open={visible}
      onCancel={onClose}
      width="90vw"
      footer={null}
      title={`原文审查 - ${result?.literature.title}`}
    >
      <div className="flex h-[80vh]">
        {/* 左侧PDF全文 */}
        <div className="w-1/2 border-r overflow-y-auto p-4">
          <h3 className="font-bold mb-2">全文</h3>
          <PDFViewer
            pdfUrl={result?.literature.pdfUrl}
            highlightEvidence={getFieldEvidence(result, field)}
          />
        </div>
        
        {/* 右侧:双模型判断 */}
        <div className="w-1/2 overflow-y-auto p-4 bg-gray-50">
          <h3 className="font-bold mb-4">
            12字段评估详情 - {field}
          </h3>
          
          <div className="space-y-6">
            {/* DeepSeek判断 */}
            <div className="model-panel border rounded-lg p-4 bg-white">
              <h4 className="font-bold mb-3">DeepSeek</h4>
              <FieldAssessmentDetail
                assessment={result?.modelAResult[field]}
                field={field}
              />
            </div>
            
            {/* Qwen判断 */}
            <div className="model-panel border rounded-lg p-4 bg-white">
              <h4 className="font-bold mb-3">Qwen</h4>
              <FieldAssessmentDetail
                assessment={result?.modelBResult[field]}
                field={field}
              />
            </div>
            
            {/* 冲突标记 */}
            {result?.conflictFields?.includes(field) && (
              <Alert
                type="warning"
                message="双模型判断不一致"
                description="请仔细核对全文,做出最终决策"
              />
            )}
          </div>
        </div>
      </div>
    </Modal>
  )
}

// 字段评估详情组件
const FieldAssessmentDetail: React.FC<{
  assessment: any
  field: string
}> = ({ assessment, field }) => {
  return (
    <div className="field-detail">
      <div className="mb-2">
        <strong>存在性:</strong>
        <Tag color={assessment.present ? 'green' : 'red'}>
          {assessment.present ? '存在' : '不存在'}
        </Tag>
      </div>
      
      <div className="mb-2">
        <strong>完整性:</strong>
        <Tag color={
          assessment.completeness === '完整' ? 'green' :
          assessment.completeness === '不完整' ? 'orange' : 'red'
        }>
          {assessment.completeness}
        </Tag>
      </div>
      
      {field === 'field9' && (
        <div className="mb-2">
          <strong>可提取性:</strong>
          <Tag color={assessment.extractable ? 'green' : 'red'}>
            {assessment.extractable ? '可提取' : '不可提取'}
          </Tag>
        </div>
      )}
      
      <div className="mt-3 p-3 bg-gray-50 rounded">
        <strong>说明:</strong>
        <p className="mt-1 text-sm">{assessment.note}</p>
      </div>
    </div>
  )
}

8. 开发排期

8.1 Week 1通用能力层 + 数据库 + 后端核心

Day 12025-11-22 周五):通用能力层 - PDF存储 已完成

目标包装现有PDF能力为统一服务接口

⚠️ 重要:不是重新开发,是包装现有能力

任务

  • 创建 PDFStorageService.ts(统一接口,包装现有服务)
  • 实现 DifyPDFStorageAdapter.ts复用DifyClient
  • 实现 OSSPDFStorageAdapter.ts(预留)
  • 实现 PDFStorageFactory.ts(工厂类)
  • 集成现有服务:
    • 复用 ExtractionClient.ts(已实现)
    • 复用 DifyClient.ts(已实现)
    • 复用 storage(平台基础层)
  • 编写单元测试
  • 环境配置(.env

产出

  • 6个文件~500行代码包含types和index
  • 测试覆盖率>80%

复用清单

  • backend/src/common/document/ExtractionClient.ts
  • backend/src/common/rag/DifyClient.ts
  • backend/src/common/storage/
  • extraction_service/Python微服务

Day 22025-11-22 周五):通用能力层 - LLM服务 已完成

目标实现LLM处理12字段服务

任务

  • 创建 LLM12FieldsService.ts
  • 实现screening模式Prompt加载
  • 实现extraction模式Prompt加载预留
  • 集成LLM适配器复用现有
    • DeepSeek-V3已实现
    • Qwen3-Max已实现
    • LLMFactory已实现
  • 实现缓存策略复用cache服务
  • 实现成本计算
  • 编写Prompt模板12字段screening 核心工作
  • 编写单元测试
  • 额外完成PromptBuilder服务动态Prompt组装
  • 额外完成3层JSON解析策略json-repair集成
  • 额外完成Promise.allSettled双模型容错

复用清单

  • backend/src/common/llm/adapters/DeepSeekAdapter.ts
  • backend/src/common/llm/adapters/QwenAdapter.ts
  • backend/src/common/llm/LLMFactory.ts
  • backend/src/common/cache/

产出

  • LLM12FieldsService.ts~400行
  • 2个Prompt文件~500行
  • 测试覆盖率>80%

Day 32025-11-22 周五):通用能力层 - 验证服务 已完成

目标:完成验证服务和冲突检测

任务

  • 创建 MedicalLogicValidator.ts
    • 5条医学逻辑验证规则
    • safeGetFieldValue容错机制
  • 创建 EvidenceChainValidator.ts
    • 证据链完整性验证
    • 引用长度和位置验证
    • null/undefined安全处理
  • 创建 ConflictDetectionService.ts
    • 实现screening冲突检测
    • 实现extraction冲突检测预留
    • 冲突严重程度分级
    • 复核优先级计算
    • logger容错修复
  • 编写单元测试
  • 集成测试(通用能力层整体)
    • integration-test.ts完整测试
    • quick-test.ts快速测试
    • cached-result-test.ts容错验证

产出

  • 3个验证服务~1,300行代码
  • 3个测试文件~600行代码
  • 通用能力层核心完成

AsyncTaskService延后到Day 4实现批处理服务中


Day 42025-11-28 周四):数据库设计 + 全文复筛业务逻辑

目标:完成数据库迁移和核心业务逻辑

上午任务(数据库)

  • 设计Prisma Schema
    • AslFulltextScreeningTask
    • AslFulltextScreeningResult
    • AslLiterature 表更新PDF存储字段
  • 执行迁移:npx prisma migrate dev --name add_fulltext_screening
  • 验证表结构

下午任务(业务逻辑)

  • 创建 FulltextScreeningService.ts
    • processTask() - 任务处理入口
    • screenLiterature() - 单篇文献筛选
    • updateTaskProgress() - 进度更新
    • completeTask() - 任务完成
  • 集成通用能力层服务

产出

  • 3个数据表
  • FulltextScreeningService.ts~600行

Day 52025-11-29 周五后端API实现

目标完成全文复筛API5个核心接口

任务

  • 创建 FulltextScreeningController.ts
    • createTask() - 创建任务
    • getTaskProgress() - 获取进度
    • getTaskResults() - 获取结果
    • updateDecision() - 人工审核决策
    • exportExcel() - 导出Excel
  • 创建 fulltext-screening.ts 路由
  • API测试Postman
  • 错误处理完善

产出

  • 5个API接口
  • API测试通过
  • 后端完成

8.2 Week 2前端开发 + 联调测试

Day 62025-12-02 周一):前端核心组件(上)

目标:实现设置页面和审核工作台基础

任务

  • 创建 FulltextScreeningSettings.tsx
    • PICOS标准展示
    • 全文获取管理表格
    • 开始复筛按钮
  • 创建 ScreeningTable.tsx复用title-screening
    • 双行表头
    • 主行渲染
    • 展开行渲染
    • 判断图标点击
  • API集成创建任务

产出

  • 2个页面组件
  • 表格组件基础完成

Day 72025-12-03 周二):前端核心组件(下)

目标完成审核工作台和PDF查看器

任务

  • 创建 FulltextScreeningWorkbench.tsx
    • 进度条
    • 任务状态轮询
    • 结果加载
  • 创建 PDFViewer.tsxreact-pdf
    • PDF渲染
    • 页面翻页
    • 证据高亮
  • 创建 ReviewModal.tsx
    • 双视图布局
    • 12字段详情展示
    • 冲突标记
  • API集成获取进度、获取结果

产出

  • 审核工作台完成
  • PDF查看器完成
  • 双视图模态框完成

Day 82025-12-04 周三):前端结果页面 + Excel导出

目标:完成复筛结果页面

任务

  • 创建 FulltextScreeningResults.tsx
    • 统计卡片
    • PRISMA排除统计图表
    • Tab切换纳入/排除)
    • 结果列表
  • 创建 ExcelExporter.ts
    • 双Sheet导出纳入+排除)
    • PRISMA统计Sheet
  • API集成导出Excel
  • 路由配置

产出

  • 结果页面完成
  • Excel导出完成
  • 前端完成

Day 92025-12-05 周四):前后端联调

目标:完整流程测试

任务

  • 准备测试数据30篇文献
  • 完整流程测试
    • 创建任务
    • PDF上传测试Dify集成
    • 任务执行(测试双模型调用)
    • 进度更新
    • 审核工作台(测试冲突检测)
    • 人工决策
    • 结果导出
  • Bug修复
  • 性能优化(缓存、并发)

产出

  • 测试报告
  • Bug列表

Day 102025-12-06 周五):优化 + 文档

目标:代码优化和文档完善

任务

  • 代码优化
    • 错误处理完善
    • 日志输出优化
    • 类型定义完善
  • UI/UX优化
    • 加载状态
    • 空状态
    • 错误提示
  • 文档编写
    • API文档
    • 用户使用手册
    • 开发者文档
  • 代码Review

产出

  • 代码质量提升
  • 完整文档
  • 全文复筛MVP完成

9. 技术要点

9.1 核心技术挑战

9.1.1 LLM成本控制

问题双模型全文筛选需要控制成本DeepSeek-V3 + Qwen3-Max

解决方案

  1. 三级缓存策略

    • L1: 内存缓存(开发环境)
    • L2: Redis缓存生产环境
    • L3: 数据库缓存(永久存储)
  2. 统一模型配置MVP阶段

    // MVP阶段统一使用成本友好的模型组合
    const DEFAULT_MODELS = {
      modelA: 'deepseek-v3',  // ¥1/百万tokens性价比极高
      modelB: 'qwen-max'      // ¥4/百万tokens中文友好
    }
    
    // 成本对比:
    // - DeepSeek-V3 + Qwen-Max: ≈¥0.05/篇10K tokens
    // - GPT-4o + Claude-4.5:   ≈¥3.2/篇10K tokens
    // 节省成本64倍 ⭐
    
  3. 批量预取

    • 一次性获取多篇文献全文
    • 减少API调用次数

9.1.2 Serverless超时问题

问题30篇文献筛选可能需要15-20分钟超过Serverless限制30秒

解决方案

  • 异步任务模式(已实现)
    • 创建任务立即返回(<1秒
    • 后台异步处理
    • 前端轮询进度

9.1.3 PDF处理性能

问题PDF提取可能较慢Nougat需要40秒/篇)

解决方案

  1. 智能提取策略

    // 语言检测 → 选择提取方法
    if (language === 'chinese') {
      method = 'pymupdf'  // 快速2秒/篇)
    } else {
      try {
        method = 'nougat'  // 高质量40秒/篇)
      } catch {
        method = 'pymupdf'  // 降级
      }
    }
    
  2. 提前批量提取

    • 标题初筛完成后,后台自动提取全文
    • 全文复筛时直接使用

9.2 数据一致性保证

9.2.1 事务处理

// 使用Prisma事务保证一致性
await prisma.$transaction(async (tx) => {
  // 1. 创建筛选结果
  const result = await tx.aslFulltextScreeningResult.create({ ... })
  
  // 2. 更新文献阶段
  await tx.aslLiterature.update({
    where: { id: literatureId },
    data: { stage: 'fulltext_screened' }
  })
  
  // 3. 更新任务统计
  await tx.aslFulltextScreeningTask.update({
    where: { id: taskId },
    data: { processedCount: { increment: 1 } }
  })
})

9.2.2 失败重试

// 指数退避重试
async function retryWithBackoff<T>(
  fn: () => Promise<T>,
  maxRetries: number = 3
): Promise<T> {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await fn()
    } catch (error) {
      if (i === maxRetries - 1) throw error
      
      const delay = Math.pow(2, i) * 1000  // 1s, 2s, 4s
      await sleep(delay)
    }
  }
}

9.3 前端性能优化

9.3.1 虚拟滚动

// 使用react-window处理大量文献
import { FixedSizeList } from 'react-window'

<FixedSizeList
  height={600}
  itemCount={results.length}
  itemSize={100}
  width="100%"
>
  {({ index, style }) => (
    <div style={style}>
      <ResultRow result={results[index]} />
    </div>
  )}
</FixedSizeList>

9.3.2 懒加载

// PDF查看器懒加载
const PDFViewer = lazy(() => import('./PDFViewer'))

<Suspense fallback={<Spin />}>
  <PDFViewer pdfUrl={url} />
</Suspense>

10. 风险与注意事项

10.1 技术风险

风险 等级 缓解措施
Dify存储限制 🟡 适配器模式预留OSS迁移
LLM成本超预算 🟡 三级缓存 + 智能模型选择
Python微服务不稳定 🟡 重试机制 + 降级策略
前端PDF渲染性能 🟢 react-pdf + 虚拟滚动

10.2 开发注意事项

10.2.1 云原生规范

// ✅ 正确:使用平台服务
import { storage, logger, cache, jobQueue } from '@/common'

// ❌ 错误:本地文件存储
fs.writeFileSync('./uploads/file.pdf', buffer)  // 禁止!

10.2.2 数据库设计

// ✅ 正确指定Schema
model AslFulltextScreeningTask {
  @@schema("asl_schema")  // 必须指定
  // ...
}

// ❌ 错误不指定Schema
model FulltextScreeningTask {
  // 会放到public违反隔离原则
}

10.2.3 Git提交规范

# ✅ 正确:完成功能后统一提交
git commit -m "feat(asl): 完成全文复筛功能

- 实现通用能力层PDF存储、LLM服务、冲突检测
- 实现全文复筛业务逻辑
- 完成前端审核工作台
- 添加Excel导出功能

Tested: 完整流程测试通过"

# ❌ 错误:频繁碎片化提交
git commit -m "fix bug"  # 每改一次就提交

10.3 未来迁移注意事项

10.3.1 Dify → OSS迁移

# 只需修改环境变量
PDF_STORAGE_TYPE=oss  # 从dify改为oss

# 业务代码零改动✅
# 数据库字段兼容✅pdfStorageType: 'dify' or 'oss'

10.3.2 全文提取模块复用

// 全文提取模块可以直接复用通用能力层
import { 
  PDFStorageService,        // ✅ 复用
  LLM12FieldsService,       // ✅ 复用切换到extraction模式
  ConflictDetectionService, // ✅ 复用
  AsyncTaskService          // ✅ 复用
} from '@/modules/asl/common/services'

// 只需开发extraction特定的业务逻辑
export class DataExtractionService {
  // 调用通用服务
  private llmService = new LLM12FieldsService()
  
  async extractLiterature(literatureId: string) {
    // 使用extraction模式
    const result = await this.llmService.process12Fields(
      LLM12FieldsMode.EXTRACTION,  // ← 切换模式
      'gpt-4o',
      fullText,
      context
    )
  }
}

附录

A. 相关文档

文档 路径 说明
全文复筛需求 01-需求分析/03-全文复筛需求详述.md 功能需求
12字段模板 01-需求分析/全文复筛及全文提取模版.txt 循证医学模板
UI原型 03-UI设计/AI智能文献-全文复筛.html 前端原型
云原生规范 docs/04-开发规范/08-云原生开发规范.md 必读
系统架构 docs/00-系统总体设计/00-系统当前状态与开发指南.md 必读

B. 环境配置示例

# .env.development

# PDF存储MVP阶段使用Dify
PDF_STORAGE_TYPE=dify
DIFY_API_KEY=app-xxx
DIFY_BASE_URL=http://localhost:5001/v1
DIFY_ASL_DATASET_ID=dataset-xxx

# Python微服务
EXTRACTION_SERVICE_URL=http://localhost:8000

# LLM配置
CLOSEAI_API_KEY=sk-xxx
CLOSEAI_OPENAI_BASE_URL=https://api.openai-proxy.org/v1
CLOSEAI_CLAUDE_BASE_URL=https://api.openai-proxy.org/anthropic

# 缓存配置
CACHE_TYPE=memory  # memory/redis
REDIS_URL=redis://localhost:6379

# 数据库
DATABASE_URL=postgresql://user:pass@localhost:5432/asl_db

C. 测试数据准备

-- 准备30篇测试文献
INSERT INTO asl_schema."AslLiterature" (
  id, project_id, pmid, title, abstract, 
  has_pdf, pdf_storage_type, pdf_storage_ref, pdf_status,
  full_text, full_text_token_count, stage
) VALUES
  ('lit-001', 'proj-123', 'PMID12345', '...', '...', 
   true, 'dify', 'doc-001', 'ready',
   '全文内容...', 8500, 'pdf_acquired'),
  -- ... 29条记录

文档维护者: ASL开发团队
最后更新: 2025-11-22
文档状态: 已完成,待开发

📝 版本历史:

  • V1.0 (2025-11-22): 初始版本,完整开发计划