diff --git a/backend/package-lock.json b/backend/package-lock.json index 8815978f..36dd7414 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -28,6 +28,7 @@ "handlebars": "^4.7.8", "html2canvas": "^1.4.1", "js-yaml": "^4.1.0", + "json-logic-js": "^2.0.5", "jsonrepair": "^3.13.1", "jsonwebtoken": "^9.0.2", "jspdf": "^3.0.3", @@ -45,6 +46,7 @@ "@types/ali-oss": "^6.23.1", "@types/bcryptjs": "^2.4.6", "@types/js-yaml": "^4.0.9", + "@types/json-logic-js": "^2.0.8", "@types/jsonwebtoken": "^9.0.7", "@types/node": "^24.7.1", "@types/uuid": "^10.0.0", @@ -1056,6 +1058,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-logic-js": { + "version": "2.0.8", + "resolved": "https://registry.npmmirror.com/@types/json-logic-js/-/json-logic-js-2.0.8.tgz", + "integrity": "sha512-WgNsDPuTPKYXl0Jh0IfoCoJoAGGYZt5qzpmjuLSEg7r0cKp/kWtWp0HAsVepyPSPyXiHo6uXp/B/kW/2J1fa2Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/jsonwebtoken": { "version": "9.0.10", "resolved": "https://registry.npmmirror.com/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", @@ -3344,6 +3353,12 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/json-logic-js": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/json-logic-js/-/json-logic-js-2.0.5.tgz", + "integrity": "sha512-rTT2+lqcuUmj4DgWfmzupZqQDA64AdmYqizzMPWj3DxGdfFNsxPpcNVSaTj4l8W2tG/+hg7/mQhxjU3aPacO6g==", + "license": "MIT" + }, "node_modules/json-schema-ref-resolver": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index 63b15426..d8387054 100644 --- a/backend/package.json +++ b/backend/package.json @@ -45,6 +45,7 @@ "handlebars": "^4.7.8", "html2canvas": "^1.4.1", "js-yaml": "^4.1.0", + "json-logic-js": "^2.0.5", "jsonrepair": "^3.13.1", "jsonwebtoken": "^9.0.2", "jspdf": "^3.0.3", @@ -62,6 +63,7 @@ "@types/ali-oss": "^6.23.1", "@types/bcryptjs": "^2.4.6", "@types/js-yaml": "^4.0.9", + "@types/json-logic-js": "^2.0.8", "@types/jsonwebtoken": "^9.0.7", "@types/node": "^24.7.1", "@types/uuid": "^10.0.0", diff --git a/backend/prisma/migrations/20251010075003_init/migration.sql b/backend/prisma/migrations/20251010075003_init/migration.sql index 869403bd..b758b8b1 100644 --- a/backend/prisma/migrations/20251010075003_init/migration.sql +++ b/backend/prisma/migrations/20251010075003_init/migration.sql @@ -1,3 +1,20 @@ +-- CreateSchemas (added for shadow database compatibility) +CREATE SCHEMA IF NOT EXISTS "admin_schema"; +CREATE SCHEMA IF NOT EXISTS "agent_schema"; +CREATE SCHEMA IF NOT EXISTS "aia_schema"; +CREATE SCHEMA IF NOT EXISTS "asl_schema"; +CREATE SCHEMA IF NOT EXISTS "capability_schema"; +CREATE SCHEMA IF NOT EXISTS "common_schema"; +CREATE SCHEMA IF NOT EXISTS "dc_schema"; +CREATE SCHEMA IF NOT EXISTS "ekb_schema"; +CREATE SCHEMA IF NOT EXISTS "iit_schema"; +CREATE SCHEMA IF NOT EXISTS "pkb_schema"; +CREATE SCHEMA IF NOT EXISTS "platform_schema"; +CREATE SCHEMA IF NOT EXISTS "protocol_schema"; +CREATE SCHEMA IF NOT EXISTS "rvw_schema"; +CREATE SCHEMA IF NOT EXISTS "ssa_schema"; +CREATE SCHEMA IF NOT EXISTS "st_schema"; + -- CreateTable CREATE TABLE "users" ( "id" TEXT NOT NULL, diff --git a/backend/prisma/migrations/20251208_add_column_mapping/migration.sql b/backend/prisma/migrations/20251208_add_column_mapping/migration.sql index 7c1f7782..d751ed59 100644 --- a/backend/prisma/migrations/20251208_add_column_mapping/migration.sql +++ b/backend/prisma/migrations/20251208_add_column_mapping/migration.sql @@ -1,3 +1,22 @@ +-- CreateTable (merged from create_tool_c_session.sql for shadow database compatibility) +CREATE TABLE IF NOT EXISTS "dc_schema"."dc_tool_c_sessions" ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id VARCHAR(255) NOT NULL, + file_name VARCHAR(500) NOT NULL, + file_key VARCHAR(500) NOT NULL, + total_rows INTEGER NOT NULL, + total_cols INTEGER NOT NULL, + columns JSONB NOT NULL, + encoding VARCHAR(50), + file_size INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_dc_tool_c_sessions_user_id ON "dc_schema"."dc_tool_c_sessions"(user_id); +CREATE INDEX IF NOT EXISTS idx_dc_tool_c_sessions_expires_at ON "dc_schema"."dc_tool_c_sessions"(expires_at); + -- AlterTable -- 添加 column_mapping 字段到 dc_tool_c_sessions 表 -- 用于解决表头特殊字符问题 diff --git a/backend/prisma/migrations/20260207112544_add_iit_manager_agent_tables/migration.sql b/backend/prisma/migrations/20260207112544_add_iit_manager_agent_tables/migration.sql new file mode 100644 index 00000000..58150c6b --- /dev/null +++ b/backend/prisma/migrations/20260207112544_add_iit_manager_agent_tables/migration.sql @@ -0,0 +1,162 @@ +-- IIT Manager Agent V2.9.1 数据库迁移 +-- 创建 8 张新表用于支持完整的 Agent 功能 +-- 日期: 2026-02-07 + +-- CreateTable: iit_skills (Skill 配置存储) +CREATE TABLE "iit_schema"."skills" ( + "id" TEXT NOT NULL, + "project_id" TEXT NOT NULL, + "skill_type" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "config" JSONB NOT NULL, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "version" INTEGER NOT NULL DEFAULT 1, + "trigger_type" TEXT NOT NULL DEFAULT 'webhook', + "cron_schedule" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "skills_pkey" PRIMARY KEY ("id") +); + +-- CreateTable: iit_field_mapping (字段名映射字典) +CREATE TABLE "iit_schema"."field_mapping" ( + "id" TEXT NOT NULL, + "project_id" TEXT NOT NULL, + "alias_name" TEXT NOT NULL, + "actual_name" TEXT NOT NULL, + "field_type" TEXT, + "field_label" TEXT, + "validation" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "field_mapping_pkey" PRIMARY KEY ("id") +); + +-- CreateTable: iit_conversation_history (对话历史/流水账) +CREATE TABLE "iit_schema"."conversation_history" ( + "id" TEXT NOT NULL, + "project_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "record_id" TEXT, + "role" TEXT NOT NULL, + "content" TEXT NOT NULL, + "intent" TEXT, + "entities" JSONB, + "feedback" TEXT, + "feedback_reason" TEXT, + "embedding" vector(1536), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "conversation_history_pkey" PRIMARY KEY ("id") +); + +-- CreateTable: iit_project_memory (项目级热记忆) +CREATE TABLE "iit_schema"."project_memory" ( + "id" TEXT NOT NULL, + "project_id" TEXT NOT NULL, + "content" TEXT NOT NULL, + "last_updated_by" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "project_memory_pkey" PRIMARY KEY ("id") +); + +-- CreateTable: iit_weekly_reports (周报归档/历史书) +CREATE TABLE "iit_schema"."weekly_reports" ( + "id" TEXT NOT NULL, + "project_id" TEXT NOT NULL, + "week_number" INTEGER NOT NULL, + "week_start" TIMESTAMP(3) NOT NULL, + "week_end" TIMESTAMP(3) NOT NULL, + "summary" TEXT NOT NULL, + "metrics" JSONB, + "created_by" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "weekly_reports_pkey" PRIMARY KEY ("id") +); + +-- CreateTable: iit_agent_trace (ReAct 推理轨迹) +CREATE TABLE "iit_schema"."agent_trace" ( + "id" TEXT NOT NULL, + "project_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "query" TEXT NOT NULL, + "intent_type" TEXT, + "trace" JSONB NOT NULL, + "token_usage" INTEGER, + "duration" INTEGER, + "success" BOOLEAN NOT NULL DEFAULT true, + "error_msg" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "agent_trace_pkey" PRIMARY KEY ("id") +); + +-- CreateTable: iit_pii_audit_log (PII 脱敏审计日志) +CREATE TABLE "iit_schema"."pii_audit_log" ( + "id" TEXT NOT NULL, + "project_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "session_id" TEXT NOT NULL, + "original_hash" TEXT NOT NULL, + "masked_payload" TEXT NOT NULL, + "masking_map" TEXT NOT NULL, + "pii_count" INTEGER NOT NULL, + "pii_types" TEXT[], + "llm_provider" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "pii_audit_log_pkey" PRIMARY KEY ("id") +); + +-- CreateTable: iit_form_templates (表单模板) +CREATE TABLE "iit_schema"."form_templates" ( + "id" TEXT NOT NULL, + "project_id" TEXT NOT NULL, + "form_name" TEXT NOT NULL, + "field_schema" JSONB NOT NULL, + "keywords" TEXT[], + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "form_templates_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex: iit_skills +CREATE INDEX "idx_iit_skill_project" ON "iit_schema"."skills"("project_id"); +CREATE INDEX "idx_iit_skill_active" ON "iit_schema"."skills"("is_active"); +CREATE UNIQUE INDEX "unique_iit_skill_project_type" ON "iit_schema"."skills"("project_id", "skill_type"); + +-- CreateIndex: iit_field_mapping +CREATE INDEX "idx_iit_field_mapping_project" ON "iit_schema"."field_mapping"("project_id"); +CREATE UNIQUE INDEX "unique_iit_field_mapping" ON "iit_schema"."field_mapping"("project_id", "alias_name"); + +-- CreateIndex: iit_conversation_history +CREATE INDEX "idx_iit_conv_project_user" ON "iit_schema"."conversation_history"("project_id", "user_id"); +CREATE INDEX "idx_iit_conv_project_record" ON "iit_schema"."conversation_history"("project_id", "record_id"); +CREATE INDEX "idx_iit_conv_created" ON "iit_schema"."conversation_history"("created_at"); + +-- CreateIndex: iit_project_memory +CREATE UNIQUE INDEX "project_memory_project_id_key" ON "iit_schema"."project_memory"("project_id"); + +-- CreateIndex: iit_weekly_reports +CREATE INDEX "idx_iit_weekly_report_project" ON "iit_schema"."weekly_reports"("project_id"); +CREATE UNIQUE INDEX "unique_iit_weekly_report" ON "iit_schema"."weekly_reports"("project_id", "week_number"); + +-- CreateIndex: iit_agent_trace +CREATE INDEX "idx_iit_trace_project_time" ON "iit_schema"."agent_trace"("project_id", "created_at"); +CREATE INDEX "idx_iit_trace_user" ON "iit_schema"."agent_trace"("user_id"); + +-- CreateIndex: iit_pii_audit_log +CREATE INDEX "idx_iit_pii_project_user" ON "iit_schema"."pii_audit_log"("project_id", "user_id"); +CREATE INDEX "idx_iit_pii_session" ON "iit_schema"."pii_audit_log"("session_id"); +CREATE INDEX "idx_iit_pii_created" ON "iit_schema"."pii_audit_log"("created_at"); + +-- CreateIndex: iit_form_templates +CREATE INDEX "idx_iit_form_template_project" ON "iit_schema"."form_templates"("project_id"); +CREATE UNIQUE INDEX "unique_iit_form_template" ON "iit_schema"."form_templates"("project_id", "form_name"); diff --git a/backend/prisma/migrations/create_tool_c_session.sql b/backend/prisma/migrations/create_tool_c_session.sql deleted file mode 100644 index 98514bc4..00000000 --- a/backend/prisma/migrations/create_tool_c_session.sql +++ /dev/null @@ -1,105 +0,0 @@ --- 创建 Tool C Session 表 --- 日期: 2025-12-06 --- 用途: 科研数据编辑器会话管理 - -CREATE TABLE IF NOT EXISTS dc_schema.dc_tool_c_sessions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id VARCHAR(255) NOT NULL, - file_name VARCHAR(500) NOT NULL, - file_key VARCHAR(500) NOT NULL, - - -- 数据元信息 - total_rows INTEGER NOT NULL, - total_cols INTEGER NOT NULL, - columns JSONB NOT NULL, - encoding VARCHAR(50), - file_size INTEGER NOT NULL, - - -- 时间戳 - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - expires_at TIMESTAMP NOT NULL -); - --- 创建索引 -CREATE INDEX IF NOT EXISTS idx_dc_tool_c_sessions_user_id ON dc_schema.dc_tool_c_sessions(user_id); -CREATE INDEX IF NOT EXISTS idx_dc_tool_c_sessions_expires_at ON dc_schema.dc_tool_c_sessions(expires_at); - --- 添加注释 -COMMENT ON TABLE dc_schema.dc_tool_c_sessions IS 'Tool C (科研数据编辑器) Session会话表'; -COMMENT ON COLUMN dc_schema.dc_tool_c_sessions.file_key IS 'OSS存储路径: dc/tool-c/sessions/{timestamp}-{fileName}'; -COMMENT ON COLUMN dc_schema.dc_tool_c_sessions.columns IS '列名数组 ["age", "gender", "diagnosis"]'; -COMMENT ON COLUMN dc_schema.dc_tool_c_sessions.expires_at IS '过期时间(创建后10分钟)'; - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/backend/prisma/migrations/manual/ekb_create_indexes.sql b/backend/prisma/migrations/manual/ekb_create_indexes.sql deleted file mode 100644 index 9cdbb17c..00000000 --- a/backend/prisma/migrations/manual/ekb_create_indexes.sql +++ /dev/null @@ -1,71 +0,0 @@ --- ============================================================ --- EKB Schema 索引创建脚本 --- 执行时机:prisma migrate 之后手动执行 --- 参考文档:docs/02-通用能力层/03-RAG引擎/04-数据模型设计.md --- ============================================================ - --- 1. 确保 pgvector 扩展已启用 -CREATE EXTENSION IF NOT EXISTS vector; - --- 2. 确保 pg_bigm 扩展已启用(中文关键词检索) -CREATE EXTENSION IF NOT EXISTS pg_bigm; - --- ===== MVP 阶段必须创建 ===== - --- 3. HNSW 向量索引(语义检索核心) --- 参数说明:m=16 每层最大连接数,ef_construction=64 构建时搜索范围 -CREATE INDEX IF NOT EXISTS idx_ekb_chunk_embedding -ON "ekb_schema"."ekb_chunk" -USING hnsw (embedding vector_cosine_ops) -WITH (m = 16, ef_construction = 64); - --- ===== Phase 2 阶段使用(可预创建)===== - --- 4. pg_bigm 中文关键词索引 -CREATE INDEX IF NOT EXISTS idx_ekb_chunk_content_bigm -ON "ekb_schema"."ekb_chunk" -USING gin (content gin_bigm_ops); - --- 5. 文档摘要关键词索引 -CREATE INDEX IF NOT EXISTS idx_ekb_doc_summary_bigm -ON "ekb_schema"."ekb_document" -USING gin (summary gin_bigm_ops); - --- 6. 全文内容关键词索引 -CREATE INDEX IF NOT EXISTS idx_ekb_doc_text_bigm -ON "ekb_schema"."ekb_document" -USING gin (extracted_text gin_bigm_ops); - --- ===== Phase 3 阶段使用(可预创建)===== - --- 7. JSONB GIN 索引(metadata 查询加速) -CREATE INDEX IF NOT EXISTS idx_ekb_doc_metadata_gin -ON "ekb_schema"."ekb_document" -USING gin (metadata jsonb_path_ops); - --- 8. JSONB GIN 索引(structuredData 查询加速) -CREATE INDEX IF NOT EXISTS idx_ekb_doc_structured_gin -ON "ekb_schema"."ekb_document" -USING gin (structured_data jsonb_path_ops); - --- 9. 标签数组索引 -CREATE INDEX IF NOT EXISTS idx_ekb_doc_tags_gin -ON "ekb_schema"."ekb_document" -USING gin (tags); - --- 10. 切片元数据索引 -CREATE INDEX IF NOT EXISTS idx_ekb_chunk_metadata_gin -ON "ekb_schema"."ekb_chunk" -USING gin (metadata jsonb_path_ops); - --- ===== 验证索引创建 ===== --- SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'ekb_schema'; - - - - - - - - - diff --git a/backend/prisma/migrations/manual/ekb_create_indexes_mvp.sql b/backend/prisma/migrations/manual/ekb_create_indexes_mvp.sql deleted file mode 100644 index 5aa343ca..00000000 --- a/backend/prisma/migrations/manual/ekb_create_indexes_mvp.sql +++ /dev/null @@ -1,38 +0,0 @@ --- ============================================================ --- EKB Schema MVP 索引创建脚本 --- 执行时机:prisma db push 之后手动执行 --- 说明:MVP 阶段只创建 HNSW 向量索引,pg_bigm 索引在 Phase 2 创建 --- ============================================================ - --- 1. 确保 pgvector 扩展已启用 -CREATE EXTENSION IF NOT EXISTS vector; - --- 2. HNSW 向量索引(语义检索核心) --- 参数说明:m=16 每层最大连接数,ef_construction=64 构建时搜索范围 -CREATE INDEX IF NOT EXISTS idx_ekb_chunk_embedding -ON "ekb_schema"."ekb_chunk" -USING hnsw (embedding vector_cosine_ops) -WITH (m = 16, ef_construction = 64); - --- 3. JSONB GIN 索引(可选,提升查询性能) -CREATE INDEX IF NOT EXISTS idx_ekb_doc_metadata_gin -ON "ekb_schema"."ekb_document" -USING gin (metadata jsonb_path_ops); - -CREATE INDEX IF NOT EXISTS idx_ekb_doc_structured_gin -ON "ekb_schema"."ekb_document" -USING gin (structured_data jsonb_path_ops); - --- 4. 标签数组索引 -CREATE INDEX IF NOT EXISTS idx_ekb_doc_tags_gin -ON "ekb_schema"."ekb_document" -USING gin (tags); - - - - - - - - - diff --git a/backend/prisma/migrations/manual_fulltext_screening.sql b/backend/prisma/migrations/manual_fulltext_screening.sql deleted file mode 100644 index 18f6e072..00000000 --- a/backend/prisma/migrations/manual_fulltext_screening.sql +++ /dev/null @@ -1,141 +0,0 @@ --- ===================================================== --- 全文复筛数据库迁移脚本(手动执行) --- Schema: asl_schema --- 日期: 2025-11-23 --- 说明: 只操作asl_schema,不影响其他schema --- ===================================================== - --- 1. 修改 literatures 表,添加全文复筛相关字段 -ALTER TABLE asl_schema.literatures - ADD COLUMN IF NOT EXISTS stage TEXT DEFAULT 'imported', - ADD COLUMN IF NOT EXISTS has_pdf BOOLEAN DEFAULT false, - ADD COLUMN IF NOT EXISTS pdf_storage_type TEXT, - ADD COLUMN IF NOT EXISTS pdf_storage_ref TEXT, - ADD COLUMN IF NOT EXISTS pdf_status TEXT DEFAULT 'pending', - ADD COLUMN IF NOT EXISTS pdf_uploaded_at TIMESTAMP(3), - ADD COLUMN IF NOT EXISTS full_text_storage_type TEXT, - ADD COLUMN IF NOT EXISTS full_text_storage_ref TEXT, - ADD COLUMN IF NOT EXISTS full_text_url TEXT, - ADD COLUMN IF NOT EXISTS full_text_format TEXT, - ADD COLUMN IF NOT EXISTS full_text_source TEXT, - ADD COLUMN IF NOT EXISTS full_text_token_count INTEGER, - ADD COLUMN IF NOT EXISTS full_text_extracted_at TIMESTAMP(3); - --- 添加索引 -CREATE INDEX IF NOT EXISTS idx_literatures_stage ON asl_schema.literatures(stage); -CREATE INDEX IF NOT EXISTS idx_literatures_has_pdf ON asl_schema.literatures(has_pdf); -CREATE INDEX IF NOT EXISTS idx_literatures_pdf_status ON asl_schema.literatures(pdf_status); - --- 2. 创建 fulltext_screening_tasks 表 -CREATE TABLE IF NOT EXISTS asl_schema.fulltext_screening_tasks ( - id TEXT PRIMARY KEY, - project_id TEXT NOT NULL, - model_a TEXT NOT NULL, - model_b TEXT NOT NULL, - prompt_version TEXT, - status TEXT NOT NULL DEFAULT 'pending', - total_count INTEGER NOT NULL DEFAULT 0, - processed_count INTEGER NOT NULL DEFAULT 0, - success_count INTEGER NOT NULL DEFAULT 0, - failed_count INTEGER NOT NULL DEFAULT 0, - degraded_count INTEGER NOT NULL DEFAULT 0, - total_tokens INTEGER DEFAULT 0, - total_cost DOUBLE PRECISION DEFAULT 0, - started_at TIMESTAMP(3), - completed_at TIMESTAMP(3), - estimated_end_at TIMESTAMP(3), - error_message TEXT, - error_stack TEXT, - created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT fk_fulltext_task_project FOREIGN KEY (project_id) - REFERENCES asl_schema.screening_projects(id) ON DELETE CASCADE -); - --- 添加索引 -CREATE INDEX IF NOT EXISTS idx_fulltext_tasks_project_id ON asl_schema.fulltext_screening_tasks(project_id); -CREATE INDEX IF NOT EXISTS idx_fulltext_tasks_status ON asl_schema.fulltext_screening_tasks(status); -CREATE INDEX IF NOT EXISTS idx_fulltext_tasks_created_at ON asl_schema.fulltext_screening_tasks(created_at); - --- 3. 创建 fulltext_screening_results 表 -CREATE TABLE IF NOT EXISTS asl_schema.fulltext_screening_results ( - id TEXT PRIMARY KEY, - task_id TEXT NOT NULL, - project_id TEXT NOT NULL, - literature_id TEXT NOT NULL, - - -- Model A (DeepSeek-V3) 结果 - model_a_name TEXT, - model_a_status TEXT, - model_a_fields JSONB, - model_a_overall JSONB, - model_a_processing_log JSONB, - model_a_verification JSONB, - model_a_tokens INTEGER, - model_a_cost DOUBLE PRECISION, - model_a_error TEXT, - - -- Model B (Qwen-Max) 结果 - model_b_name TEXT, - model_b_status TEXT, - model_b_fields JSONB, - model_b_overall JSONB, - model_b_processing_log JSONB, - model_b_verification JSONB, - model_b_tokens INTEGER, - model_b_cost DOUBLE PRECISION, - model_b_error TEXT, - - -- 验证结果 - medical_logic_issues JSONB, - evidence_chain_issues JSONB, - - -- 冲突检测 - is_conflict BOOLEAN DEFAULT false, - conflict_severity TEXT, - conflict_fields TEXT[], - conflict_details JSONB, - review_priority INTEGER DEFAULT 50, - review_deadline TIMESTAMP(3), - - -- 人工复核 - final_decision TEXT, - final_decision_by TEXT, - final_decision_at TIMESTAMP(3), - exclusion_reason TEXT, - review_notes TEXT, - - -- 处理状态 - processing_status TEXT DEFAULT 'pending', - is_degraded BOOLEAN DEFAULT false, - degraded_model TEXT, - - -- 元数据 - processed_at TIMESTAMP(3), - prompt_version TEXT, - raw_output_a JSONB, - raw_output_b JSONB, - created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT fk_fulltext_result_task FOREIGN KEY (task_id) - REFERENCES asl_schema.fulltext_screening_tasks(id) ON DELETE CASCADE, - CONSTRAINT fk_fulltext_result_project FOREIGN KEY (project_id) - REFERENCES asl_schema.screening_projects(id) ON DELETE CASCADE, - CONSTRAINT fk_fulltext_result_literature FOREIGN KEY (literature_id) - REFERENCES asl_schema.literatures(id) ON DELETE CASCADE, - CONSTRAINT unique_project_literature_fulltext UNIQUE (project_id, literature_id) -); - --- 添加索引 -CREATE INDEX IF NOT EXISTS idx_fulltext_results_task_id ON asl_schema.fulltext_screening_results(task_id); -CREATE INDEX IF NOT EXISTS idx_fulltext_results_project_id ON asl_schema.fulltext_screening_results(project_id); -CREATE INDEX IF NOT EXISTS idx_fulltext_results_literature_id ON asl_schema.fulltext_screening_results(literature_id); -CREATE INDEX IF NOT EXISTS idx_fulltext_results_is_conflict ON asl_schema.fulltext_screening_results(is_conflict); -CREATE INDEX IF NOT EXISTS idx_fulltext_results_final_decision ON asl_schema.fulltext_screening_results(final_decision); -CREATE INDEX IF NOT EXISTS idx_fulltext_results_review_priority ON asl_schema.fulltext_screening_results(review_priority); - --- 完成 -SELECT 'Migration completed successfully!' AS status; - - diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index c23bfe7f..18e5d9e7 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -833,7 +833,8 @@ model IitProject { id String @id @default(uuid()) name String description String? - difyDatasetId String? @unique @map("dify_dataset_id") + difyDatasetId String? @unique @map("dify_dataset_id") // 已废弃,使用 knowledgeBaseId + knowledgeBaseId String? @map("knowledge_base_id") // 关联 ekb_schema.knowledge_bases protocolFileKey String? @map("protocol_file_key") cachedRules Json? @map("cached_rules") fieldMappings Json @map("field_mappings") @@ -851,6 +852,7 @@ model IitProject { userMappings IitUserMapping[] @@index([status, deletedAt]) + @@index([knowledgeBaseId], map: "idx_iit_project_kb") @@map("projects") @@schema("iit_schema") } @@ -950,6 +952,336 @@ model IitAuditLog { @@schema("iit_schema") } +// ============================================================ +// IIT Manager Agent 新增表 (V2.9.1) +// Phase 1-6 完整数据库设计 +// ============================================================ + +/// Skill 配置存储表 - 存储质控规则、SOP流程图 +/// 支持 webhook/cron/event 三种触发方式 +model IitSkill { + id String @id @default(uuid()) + projectId String @map("project_id") + skillType String @map("skill_type") // qc_process | daily_briefing | general_chat | weekly_report | visit_reminder + name String // 技能名称 + description String? // 技能描述 + config Json @db.JsonB // 核心配置 JSON(SOP 流程图) + isActive Boolean @default(true) @map("is_active") + version Int @default(1) + + // V2.9 新增:主动触发能力 + triggerType String @default("webhook") @map("trigger_type") // 'webhook' | 'cron' | 'event' + cronSchedule String? @map("cron_schedule") // Cron 表达式,如 "0 9 * * *" + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([projectId, skillType], map: "unique_iit_skill_project_type") + @@index([projectId], map: "idx_iit_skill_project") + @@index([isActive], map: "idx_iit_skill_active") + @@map("skills") + @@schema("iit_schema") +} + +/// 字段名映射字典表 - 解决 LLM 生成的字段名与 REDCap 实际字段名不一致的问题 +model IitFieldMapping { + id String @id @default(uuid()) + projectId String @map("project_id") + aliasName String @map("alias_name") // LLM 可能传的名称(如 "gender", "性别") + actualName String @map("actual_name") // REDCap 实际字段名(如 "sex") + fieldType String? @map("field_type") // 字段类型:text, number, date, radio, checkbox + fieldLabel String? @map("field_label") // 字段显示标签 + validation Json? @db.JsonB // 验证规则 { min, max, pattern, choices } + createdAt DateTime @default(now()) @map("created_at") + + @@unique([projectId, aliasName], map: "unique_iit_field_mapping") + @@index([projectId], map: "idx_iit_field_mapping_project") + @@map("field_mapping") + @@schema("iit_schema") +} + +/// 对话历史表(流水账)- 存储原始对话,用于生成周报 +/// V2.9 新增反馈字段支持用户点赞/点踩 +model IitConversationHistory { + id String @id @default(uuid()) + projectId String @map("project_id") + userId String @map("user_id") + recordId String? @map("record_id") // 关联的患者(如有) + role String // user | assistant + content String @db.Text + intent String? // 识别出的意图类型 + entities Json? @db.JsonB // 提取的实体 { record_id, visit, ... } + + // V2.9 新增:反馈循环 + feedback String? @map("feedback") // 'thumbs_up' | 'thumbs_down' | null + feedbackReason String? @map("feedback_reason") // 点踩原因:'too_long' | 'inaccurate' | 'unclear' + + // 向量嵌入(用于语义搜索) + embedding Unsupported("vector(1536)")? + + createdAt DateTime @default(now()) @map("created_at") + + @@index([projectId, userId], map: "idx_iit_conv_project_user") + @@index([projectId, recordId], map: "idx_iit_conv_project_record") + @@index([createdAt], map: "idx_iit_conv_created") + @@map("conversation_history") + @@schema("iit_schema") +} + +/// 项目级热记忆表 - 存储 Markdown 格式的热记忆 +/// 每次对话都注入 System Prompt,包含用户画像、当前状态、系统禁令 +model IitProjectMemory { + id String @id @default(uuid()) + projectId String @unique @map("project_id") + content String @db.Text // Markdown 格式的热记忆 + lastUpdatedBy String @map("last_updated_by") // 'system_daily_job' | 'admin_user_id' | 'profiler_job' + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("project_memory") + @@schema("iit_schema") +} + +/// 周报归档表(历史书)- 存储每周的关键决策、进度、踩坑记录 +model IitWeeklyReport { + id String @id @default(uuid()) + projectId String @map("project_id") + weekNumber Int @map("week_number") // 第几周(从项目开始计算) + weekStart DateTime @map("week_start") // 周起始日期 + weekEnd DateTime @map("week_end") // 周结束日期 + summary String @db.Text // Markdown 格式的周报内容 + metrics Json? @db.JsonB // 结构化指标 { enrolled, queries, ... } + createdBy String @map("created_by") // 'system_scheduler' | 'admin_user_id' + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([projectId, weekNumber], map: "unique_iit_weekly_report") + @@index([projectId], map: "idx_iit_weekly_report_project") + @@map("weekly_reports") + @@schema("iit_schema") +} + +/// ReAct 推理轨迹表 - 用于调试,记录 AI 的思考过程 +model IitAgentTrace { + id String @id @default(uuid()) + projectId String @map("project_id") + userId String @map("user_id") + query String @db.Text // 用户原始问题 + intentType String? @map("intent_type") // 识别的意图类型 + trace Json @db.JsonB // ReAct 的完整思考过程 + tokenUsage Int? @map("token_usage") // 消耗的 Token 数 + duration Int? // 执行时长(ms) + success Boolean @default(true) + errorMsg String? @map("error_msg") + createdAt DateTime @default(now()) @map("created_at") + + @@index([projectId, createdAt], map: "idx_iit_trace_project_time") + @@index([userId], map: "idx_iit_trace_user") + @@map("agent_trace") + @@schema("iit_schema") +} + +/// PII 脱敏审计日志表 - 记录所有发送给 LLM 的脱敏信息(合规必需) +/// 暂时创建表结构,功能延后到 Phase 1.5 实现 +model IitPiiAuditLog { + id String @id @default(uuid()) + projectId String @map("project_id") + userId String @map("user_id") // 操作者 + sessionId String @map("session_id") // 会话 ID + + // 脱敏内容(加密存储) + originalHash String @map("original_hash") // 原始内容的 SHA256 哈希 + maskedPayload String @db.Text @map("masked_payload") // 脱敏后发送给 LLM 的内容 + maskingMap String @db.Text @map("masking_map") // 加密存储的映射表 + + // 元数据 + piiCount Int @map("pii_count") // 检测到的 PII 数量 + piiTypes String[] @map("pii_types") // 检测到的 PII 类型 + llmProvider String @map("llm_provider") // 'qwen' | 'deepseek' | 'openai' + + createdAt DateTime @default(now()) @map("created_at") + + @@index([projectId, userId], map: "idx_iit_pii_project_user") + @@index([sessionId], map: "idx_iit_pii_session") + @@index([createdAt], map: "idx_iit_pii_created") + @@map("pii_audit_log") + @@schema("iit_schema") +} + +/// 表单模板表(Phase 6 视觉能力)- 预留表结构 +model IitFormTemplate { + id String @id @default(uuid()) + projectId String @map("project_id") + formName String @map("form_name") // REDCap 表单名称 + fieldSchema Json @db.JsonB @map("field_schema") // 表单字段结构 + keywords String[] // 用于匹配的关键词 + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([projectId, formName], map: "unique_iit_form_template") + @@index([projectId], map: "idx_iit_form_template_project") + @@map("form_templates") + @@schema("iit_schema") +} + +// ============================================================ +// IIT Manager Agent - 实时质控系统表 (V2.9.1) +// 参考文档: docs/03-业务模块/IIT Manager Agent/04-开发计划/06-实时质控系统开发计划.md +// ============================================================ + +/// REDCap 字段元数据表 - 从 REDCap 同步的字段信息,用于自动生成质控规则 +/// 注意:与 IitFieldMapping(别名映射表)不同,此表存储 REDCap 原始元数据 +model IitFieldMetadata { + id String @id @default(uuid()) + projectId String @map("project_id") + + // REDCap 字段信息 + fieldName String @map("field_name") // REDCap 字段名 + fieldLabel String @map("field_label") // 字段标签(中文) + fieldType String @map("field_type") // text, radio, checkbox, dropdown, etc. + formName String @map("form_name") // ⭐ 所属表单名,用于单表质控规则过滤 + sectionHeader String? @map("section_header") // 所属区段 + + // 验证规则(从 REDCap 元数据提取) + validation String? @map("validation") // text_validation_type + validationMin String? @map("validation_min") // 最小值 + validationMax String? @map("validation_max") // 最大值 + choices String? @map("choices") // 选项,如 "1, 男 | 2, 女" + required Boolean @default(false) // 是否必填 + branching String? @map("branching") // 分支逻辑 + + // LLM 友好名称(可选) + alias String? + + // 规则来源标记 + ruleSource String? @map("rule_source") // 'auto' | 'manual' + + // 时间戳 + syncedAt DateTime @default(now()) @map("synced_at") + + @@unique([projectId, fieldName], map: "unique_iit_field_metadata") + @@index([projectId], map: "idx_iit_field_metadata_project") + @@index([projectId, formName], map: "idx_iit_field_metadata_form") + @@map("field_metadata") + @@schema("iit_schema") +} + +/// 质控日志表 - 仅新增,不覆盖,保留完整审计轨迹 +/// 设计原则:每次质控都新增记录,用于审计轨迹和趋势分析 +model IitQcLog { + id String @id @default(uuid()) + projectId String @map("project_id") + recordId String @map("record_id") + eventId String? @map("event_id") + + // 质控类型 + qcType String @map("qc_type") // 'form' | 'holistic' + formName String? @map("form_name") // 单表质控时记录表单名 + + // 核心结果 + status String // 'PASS' | 'FAIL' | 'WARNING' + + // 字段级详情 (JSONB) + // 格式: [{ field: "age", rule: "range_check", level: "RED", message: "..." }, ...] + issues Json @default("[]") @db.JsonB + + // 规则统计 + rulesEvaluated Int @default(0) @map("rules_evaluated") // 实际评估的规则数 + rulesSkipped Int @default(0) @map("rules_skipped") // 逻辑守卫跳过的规则数 + rulesPassed Int @default(0) @map("rules_passed") + rulesFailed Int @default(0) @map("rules_failed") + + // 规则版本(用于历史追溯) + ruleVersion String @map("rule_version") + + // 入排标准检查(全案质控时填充) + inclusionPassed Boolean? @map("inclusion_passed") + exclusionPassed Boolean? @map("exclusion_passed") + + // 审计信息 + triggeredBy String @map("triggered_by") // 'webhook' | 'manual' | 'batch' + createdAt DateTime @default(now()) @map("created_at") + + // 索引 - 支持历史查询和趋势分析 + @@index([projectId, recordId, createdAt], map: "idx_iit_qc_log_record_time") + @@index([projectId, status, createdAt], map: "idx_iit_qc_log_status_time") + @@index([projectId, qcType, createdAt], map: "idx_iit_qc_log_type_time") + @@map("qc_logs") + @@schema("iit_schema") +} + +/// 录入汇总表 - 记录入组和录入进度 +/// 设计原则:使用 upsert,每个记录只有一条汇总 +model IitRecordSummary { + id String @id @default(uuid()) + projectId String @map("project_id") + recordId String @map("record_id") + + // 入组信息 + enrolledAt DateTime? @map("enrolled_at") // 首次录入时间 = 入组时间 + enrolledBy String? @map("enrolled_by") // 入组录入人(REDCap username) + + // 最新录入信息 + lastUpdatedAt DateTime @map("last_updated_at") + lastUpdatedBy String? @map("last_updated_by") + lastFormName String? @map("last_form_name") // 最后更新的表单 + + // 表单完成状态 (JSONB) + // 格式: { "demographics": 2, "baseline": 1, "visit1": 0 } + // 0=未开始, 1=进行中, 2=完成 + formStatus Json @default("{}") @db.JsonB @map("form_status") + + // 数据完整度 + totalForms Int @default(0) @map("total_forms") + completedForms Int @default(0) @map("completed_forms") + completionRate Float @default(0) @map("completion_rate") // 0-100% + + // 最新质控状态(冗余存储,查询更快) + latestQcStatus String? @map("latest_qc_status") // 'PASS' | 'FAIL' | 'WARNING' + latestQcAt DateTime? @map("latest_qc_at") + + // 更新次数(用于趋势分析) + updateCount Int @default(0) @map("update_count") + + // 时间戳 + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // 唯一约束 - 每个记录只有一条汇总 + @@unique([projectId, recordId], map: "unique_iit_record_summary") + @@index([projectId, enrolledAt], map: "idx_iit_record_summary_enrolled") + @@index([projectId, latestQcStatus], map: "idx_iit_record_summary_qc_status") + @@index([projectId, completionRate], map: "idx_iit_record_summary_completion") + @@map("record_summary") + @@schema("iit_schema") +} + +/// 项目级汇总表 - 用于 Dashboard 快速展示 +/// 设计原则:使用 upsert,每个项目只有一条汇总 +model IitQcProjectStats { + id String @id @default(uuid()) + projectId String @unique @map("project_id") + + // 汇总统计 + totalRecords Int @default(0) @map("total_records") + passedRecords Int @default(0) @map("passed_records") + failedRecords Int @default(0) @map("failed_records") + warningRecords Int @default(0) @map("warning_records") + + // 入排标准统计 + inclusionMet Int @default(0) @map("inclusion_met") + exclusionMet Int @default(0) @map("exclusion_met") + + // 录入进度统计 + avgCompletionRate Float @default(0) @map("avg_completion_rate") + + // 更新时间 + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("qc_project_stats") + @@schema("iit_schema") +} + model admin_operation_logs { id Int @id @default(autoincrement()) admin_id String diff --git a/backend/prisma/seed-iit-qc-rules.ts b/backend/prisma/seed-iit-qc-rules.ts new file mode 100644 index 00000000..944e6655 --- /dev/null +++ b/backend/prisma/seed-iit-qc-rules.ts @@ -0,0 +1,359 @@ +/** + * IIT Manager Agent - 质控规则初始化脚本 + * + * 项目:原发性痛经队列研究 + * + * 使用方法: + * npx tsx prisma/seed-iit-qc-rules.ts + * + * 创建日期:2026-02-07 + */ + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +// ============================================================ +// 项目配置 +// ============================================================ +const PROJECT_ID = 'test0102-project-id'; // 需要替换为实际的项目 ID + +// ============================================================ +// 纳入标准规则 +// ============================================================ +const INCLUSION_RULES = [ + { + id: 'inc_001', + name: '年龄范围检查', + field: 'age', + logic: { + and: [ + { '>=': [{ var: 'age' }, 16] }, + { '<=': [{ var: 'age' }, 35] } + ] + }, + message: '年龄不在 16-35 岁范围内', + severity: 'error', + category: 'inclusion' + }, + { + id: 'inc_002', + name: '出生日期范围检查', + field: 'birth_date', + logic: { + and: [ + { '>=': [{ var: 'birth_date' }, '1989-01-01'] }, + { '<=': [{ var: 'birth_date' }, '2008-01-01'] } + ] + }, + message: '出生日期不在 1989-01-01 至 2008-01-01 范围内', + severity: 'error', + category: 'inclusion' + }, + { + id: 'inc_003', + name: '月经周期规律性检查', + field: 'menstrual_cycle', + logic: { + and: [ + { '>=': [{ var: 'menstrual_cycle' }, 21] }, + { '<=': [{ var: 'menstrual_cycle' }, 35] } + ] + }, + message: '月经周期不在 21-35 天范围内(28±7天)', + severity: 'error', + category: 'inclusion' + }, + { + id: 'inc_004', + name: 'VAS 评分检查', + field: 'vas_score', + logic: { '>=': [{ var: 'vas_score' }, 4] }, + message: 'VAS 疼痛评分 < 4 分,不符合入组条件', + severity: 'error', + category: 'inclusion' + }, + { + id: 'inc_005', + name: '知情同意书签署检查', + field: 'informed_consent', + logic: { '==': [{ var: 'informed_consent' }, 1] }, + message: '未签署知情同意书', + severity: 'error', + category: 'inclusion' + } +]; + +// ============================================================ +// 排除标准规则 +// ============================================================ +const EXCLUSION_RULES = [ + { + id: 'exc_001', + name: '继发性痛经排除', + field: 'secondary_dysmenorrhea', + logic: { '!=': [{ var: 'secondary_dysmenorrhea' }, 1] }, + message: '存在继发性痛经(盆腔炎、子宫内膜异位症、子宫腺肌病等)', + severity: 'error', + category: 'exclusion' + }, + { + id: 'exc_002', + name: '妊娠哺乳期排除', + field: 'pregnancy_lactation', + logic: { '!=': [{ var: 'pregnancy_lactation' }, 1] }, + message: '妊娠或哺乳期妇女,不符合入组条件', + severity: 'error', + category: 'exclusion' + }, + { + id: 'exc_003', + name: '严重疾病排除', + field: 'severe_disease', + logic: { '!=': [{ var: 'severe_disease' }, 1] }, + message: '合并有心脑血管、肝、肾、造血系统等严重疾病或精神病', + severity: 'error', + category: 'exclusion' + }, + { + id: 'exc_004', + name: '月经周期不规律排除', + field: 'irregular_menstruation', + logic: { '!=': [{ var: 'irregular_menstruation' }, 1] }, + message: '月经周期不规律或间歇性痛经发作', + severity: 'error', + category: 'exclusion' + } +]; + +// ============================================================ +// 血常规变量范围规则 +// ============================================================ +const LAB_VALUE_RULES = [ + // 1. 白细胞计数 + { id: 'lab_001', name: '白细胞计数', field: 'wbc', min: 3.5, max: 9.5, unit: '*10^9/L' }, + // 2. 红细胞计数 + { id: 'lab_002', name: '红细胞计数', field: 'rbc', min: 3.8, max: 5.1, unit: '*10^12/L' }, + // 3. 血红蛋白 + { id: 'lab_003', name: '血红蛋白', field: 'hgb', min: 115, max: 150, unit: 'g/L' }, + // 4. 红细胞压积 + { id: 'lab_004', name: '红细胞压积', field: 'hct', min: 35, max: 45, unit: '%' }, + // 5. 平均红细胞体积 + { id: 'lab_005', name: '平均红细胞体积', field: 'mcv', min: 82, max: 100, unit: 'fL' }, + // 6. 平均血红蛋白量 + { id: 'lab_006', name: '平均血红蛋白量', field: 'mch', min: 27, max: 34, unit: 'pg' }, + // 7. 平均血红蛋白浓度 + { id: 'lab_007', name: '平均血红蛋白浓度', field: 'mchc', min: 316, max: 354, unit: 'g/L' }, + // 8. 红细胞体积分布宽度-SD + { id: 'lab_008', name: '红细胞体积分布宽度-SD', field: 'rdw_sd', min: 37, max: 51, unit: 'fL' }, + // 9. 红细胞体积分布宽度-CV + { id: 'lab_009', name: '红细胞体积分布宽度-CV', field: 'rdw_cv', min: 0, max: 14.9, unit: '%' }, + // 10. 血小板计数 + { id: 'lab_010', name: '血小板计数', field: 'plt', min: 125, max: 350, unit: '*10^9/L' }, + // 11. 血小板平均体积 + { id: 'lab_011', name: '血小板平均体积', field: 'mpv', min: 7.2, max: 13.2, unit: 'fL' }, + // 12. 血小板体积分布宽度 + { id: 'lab_012', name: '血小板体积分布宽度', field: 'pdw', min: 9, max: 13, unit: 'fL' }, + // 13. 血小板比积 + { id: 'lab_013', name: '血小板比积', field: 'pct', min: 0.18, max: 0.22, unit: '%' }, + // 14. 大血小板比率 + { id: 'lab_014', name: '大血小板比率', field: 'p_lcr', min: 13, max: 43, unit: '%' }, + // 15. 中性粒细胞绝对值 + { id: 'lab_015', name: '中性粒细胞绝对值', field: 'neut_abs', min: 1.8, max: 6.3, unit: '*10^9/L' }, + // 16. 淋巴细胞绝对值 + { id: 'lab_016', name: '淋巴细胞绝对值', field: 'lymph_abs', min: 1.1, max: 3.2, unit: '*10^9/L' }, + // 17. 单核细胞绝对值 + { id: 'lab_017', name: '单核细胞绝对值', field: 'mono_abs', min: 0.1, max: 0.6, unit: '*10^9/L' }, + // 18. 嗜酸细胞绝对值 + { id: 'lab_018', name: '嗜酸细胞绝对值', field: 'eo_abs', min: 0.02, max: 0.52, unit: '*10^9/L' }, + // 19. 嗜碱细胞绝对值 + { id: 'lab_019', name: '嗜碱细胞绝对值', field: 'baso_abs', min: 0, max: 0.06, unit: '*10^9/L' }, + // 20. 中性粒细胞相对值 + { id: 'lab_020', name: '中性粒细胞相对值', field: 'neut_pct', min: 40, max: 75, unit: '%' }, + // 21. 淋巴细胞相对值 + { id: 'lab_021', name: '淋巴细胞相对值', field: 'lymph_pct', min: 20, max: 50, unit: '%' }, + // 22. 单核细胞相对值 + { id: 'lab_022', name: '单核细胞相对值', field: 'mono_pct', min: 3, max: 10, unit: '%' }, + // 23. 嗜酸细胞相对值 + { id: 'lab_023', name: '嗜酸细胞相对值', field: 'eo_pct', min: 0.4, max: 8, unit: '%' }, + // 24. 嗜碱细胞相对值 + { id: 'lab_024', name: '嗜碱细胞相对值', field: 'baso_pct', min: 0, max: 1, unit: '%' }, +]; + +// 将实验室检查转换为 JSON Logic 规则 +const LAB_RULES = LAB_VALUE_RULES.map(item => ({ + id: item.id, + name: `${item.name}范围检查`, + field: item.field, + logic: { + or: [ + { '==': [{ var: item.field }, null] }, // 允许为空(可选检查项) + { + and: [ + { '>=': [{ var: item.field }, item.min] }, + { '<=': [{ var: item.field }, item.max] } + ] + } + ] + }, + message: `${item.name}超出正常范围(${item.min}-${item.max} ${item.unit})`, + severity: 'warning', + category: 'lab_values', + metadata: { min: item.min, max: item.max, unit: item.unit } +})); + +// ============================================================ +// 字段映射配置 +// ============================================================ +const FIELD_MAPPINGS = [ + // 基本信息 + { aliasName: '年龄', actualName: 'age', fieldType: 'number', fieldLabel: '年龄' }, + { aliasName: 'age', actualName: 'age', fieldType: 'number', fieldLabel: '年龄' }, + { aliasName: '出生日期', actualName: 'birth_date', fieldType: 'date', fieldLabel: '出生日期' }, + { aliasName: 'birth_date', actualName: 'birth_date', fieldType: 'date', fieldLabel: '出生日期' }, + + // 入排标准相关 + { aliasName: '月经周期', actualName: 'menstrual_cycle', fieldType: 'number', fieldLabel: '月经周期(天)' }, + { aliasName: 'menstrual_cycle', actualName: 'menstrual_cycle', fieldType: 'number', fieldLabel: '月经周期(天)' }, + { aliasName: 'VAS评分', actualName: 'vas_score', fieldType: 'number', fieldLabel: 'VAS疼痛评分' }, + { aliasName: 'vas_score', actualName: 'vas_score', fieldType: 'number', fieldLabel: 'VAS疼痛评分' }, + { aliasName: '知情同意', actualName: 'informed_consent', fieldType: 'radio', fieldLabel: '是否签署知情同意书' }, + { aliasName: 'informed_consent', actualName: 'informed_consent', fieldType: 'radio', fieldLabel: '是否签署知情同意书' }, + + // 排除标准相关 + { aliasName: '继发性痛经', actualName: 'secondary_dysmenorrhea', fieldType: 'radio', fieldLabel: '继发性痛经' }, + { aliasName: '妊娠哺乳', actualName: 'pregnancy_lactation', fieldType: 'radio', fieldLabel: '妊娠或哺乳期' }, + { aliasName: '严重疾病', actualName: 'severe_disease', fieldType: 'radio', fieldLabel: '严重疾病' }, + { aliasName: '月经不规律', actualName: 'irregular_menstruation', fieldType: 'radio', fieldLabel: '月经周期不规律' }, + + // 血常规字段映射 + { aliasName: '白细胞', actualName: 'wbc', fieldType: 'number', fieldLabel: '白细胞计数' }, + { aliasName: '红细胞', actualName: 'rbc', fieldType: 'number', fieldLabel: '红细胞计数' }, + { aliasName: '血红蛋白', actualName: 'hgb', fieldType: 'number', fieldLabel: '血红蛋白' }, + { aliasName: '血小板', actualName: 'plt', fieldType: 'number', fieldLabel: '血小板计数' }, + // ... 更多字段可按需添加 +]; + +// ============================================================ +// 主函数 +// ============================================================ +async function main() { + console.log('🚀 开始初始化 IIT Manager 质控规则...\n'); + + // 1. 先获取项目 ID + const project = await prisma.iitProject.findFirst({ + where: { name: 'test0102' } + }); + + if (!project) { + console.error('❌ 未找到项目 test0102,请先创建项目'); + console.log('💡 提示:可以在 iit_schema.projects 表中创建项目'); + return; + } + + const projectId = project.id; + console.log(`✅ 找到项目: ${project.name} (${projectId})\n`); + + // 2. 创建质控技能配置 + console.log('📋 创建质控技能配置...'); + + const allRules = [...INCLUSION_RULES, ...EXCLUSION_RULES, ...LAB_RULES]; + + await prisma.iitSkill.upsert({ + where: { + projectId_skillType: { + projectId: projectId, + skillType: 'qc_process' + } + }, + update: { + name: '原发性痛经队列研究-入组质控', + description: '包含纳入标准、排除标准、血常规范围检查', + config: { + version: '1.0', + rules: allRules, + summary: { + totalRules: allRules.length, + inclusionRules: INCLUSION_RULES.length, + exclusionRules: EXCLUSION_RULES.length, + labRules: LAB_RULES.length + } + }, + isActive: true, + triggerType: 'webhook', + updatedAt: new Date() + }, + create: { + projectId: projectId, + skillType: 'qc_process', + name: '原发性痛经队列研究-入组质控', + description: '包含纳入标准、排除标准、血常规范围检查', + config: { + version: '1.0', + rules: allRules, + summary: { + totalRules: allRules.length, + inclusionRules: INCLUSION_RULES.length, + exclusionRules: EXCLUSION_RULES.length, + labRules: LAB_RULES.length + } + }, + isActive: true, + triggerType: 'webhook' + } + }); + + console.log(` ✅ 已创建质控技能,共 ${allRules.length} 条规则`); + console.log(` - 纳入标准: ${INCLUSION_RULES.length} 条`); + console.log(` - 排除标准: ${EXCLUSION_RULES.length} 条`); + console.log(` - 血常规范围: ${LAB_RULES.length} 条\n`); + + // 3. 创建字段映射 + console.log('🔗 创建字段映射...'); + + for (const mapping of FIELD_MAPPINGS) { + await prisma.iitFieldMapping.upsert({ + where: { + projectId_aliasName: { + projectId: projectId, + aliasName: mapping.aliasName + } + }, + update: { + actualName: mapping.actualName, + fieldType: mapping.fieldType, + fieldLabel: mapping.fieldLabel + }, + create: { + projectId: projectId, + aliasName: mapping.aliasName, + actualName: mapping.actualName, + fieldType: mapping.fieldType, + fieldLabel: mapping.fieldLabel + } + }); + } + + console.log(` ✅ 已创建 ${FIELD_MAPPINGS.length} 条字段映射\n`); + + // 4. 输出汇总 + console.log('=' .repeat(60)); + console.log('📊 初始化完成汇总'); + console.log('=' .repeat(60)); + console.log(`项目名称: ${project.name}`); + console.log(`项目 ID: ${projectId}`); + console.log(`质控规则总数: ${allRules.length}`); + console.log(`字段映射总数: ${FIELD_MAPPINGS.length}`); + console.log('=' .repeat(60)); + + console.log('\n✅ IIT Manager 质控规则初始化完成!'); +} + +main() + .catch((e) => { + console.error('❌ 初始化失败:', e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/backend/src/common/jobs/PgBossQueue.ts b/backend/src/common/jobs/PgBossQueue.ts index 1cc94e86..f6429c4b 100644 --- a/backend/src/common/jobs/PgBossQueue.ts +++ b/backend/src/common/jobs/PgBossQueue.ts @@ -147,19 +147,29 @@ export class PgBossQueue implements JobQueue { // 避免每次 push 都尝试 createQueue 导致重复定义 // 发送任务到pg-boss - // ✅ 使用 singletonKey 防止同一任务被重复入队 - const singletonKey = (data as any).taskId || jobId + // ✅ 支持自定义 singletonKey 和 options(通过 data 中的特殊字段) + // 特殊字段:__singletonKey, __singletonSeconds, __expireInSeconds + const dataObj = data as any + const singletonKey = dataObj.__singletonKey || dataObj.taskId || jobId + const singletonSeconds = dataObj.__singletonSeconds || 3600 // 默认 1 小时 + const expireInSeconds = dataObj.__expireInSeconds || 6 * 60 * 60 // 默认 6 小时 + + // 移除特殊字段,不传入 pg-boss + const cleanData = { ...dataObj } + delete cleanData.__singletonKey + delete cleanData.__singletonSeconds + delete cleanData.__expireInSeconds const bossJobId = await this.boss.send(type, { - ...data, + ...cleanData, __jobId: jobId, // 嵌入我们的jobId __createdAt: now.toISOString() }, { retryLimit: 3, retryDelay: 60, - expireInSeconds: 6 * 60 * 60, // 6小时过期(更适合长批次任务) + expireInSeconds, singletonKey, // ✅ 防止同一任务重复入队 - singletonSeconds: 3600, // 1小时内不允许重复 + singletonSeconds, }) console.log(`[PgBossQueue] Job pushed: ${jobId} -> pg-boss:${bossJobId} (type: ${type})`) diff --git a/backend/src/index.ts b/backend/src/index.ts index d0851257..9fb772b7 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -109,13 +109,18 @@ import { tenantRoutes, moduleRoutes } from './modules/admin/routes/tenantRoutes. import { userRoutes } from './modules/admin/routes/userRoutes.js'; import { statsRoutes, userOverviewRoute } from './modules/admin/routes/statsRoutes.js'; import { systemKbRoutes } from './modules/admin/system-kb/index.js'; +import { iitProjectRoutes, iitQcRuleRoutes, iitUserMappingRoutes, iitBatchRoutes } from './modules/admin/iit-projects/index.js'; await fastify.register(tenantRoutes, { prefix: '/api/admin/tenants' }); await fastify.register(moduleRoutes, { prefix: '/api/admin/modules' }); await fastify.register(userRoutes, { prefix: '/api/admin/users' }); await fastify.register(statsRoutes, { prefix: '/api/admin/stats' }); await fastify.register(userOverviewRoute, { prefix: '/api/admin/users' }); await fastify.register(systemKbRoutes, { prefix: '/api/v1/admin/system-kb' }); -logger.info('✅ 运营管理路由已注册: /api/admin/tenants, /api/admin/modules, /api/admin/users, /api/admin/stats, /api/v1/admin/system-kb'); +await fastify.register(iitProjectRoutes, { prefix: '/api/v1/admin/iit-projects' }); +await fastify.register(iitQcRuleRoutes, { prefix: '/api/v1/admin/iit-projects' }); +await fastify.register(iitUserMappingRoutes, { prefix: '/api/v1/admin/iit-projects' }); +await fastify.register(iitBatchRoutes, { prefix: '/api/v1/admin/iit-projects' }); // 一键全量质控/汇总 +logger.info('✅ 运营管理路由已注册: /api/admin/tenants, /api/admin/modules, /api/admin/users, /api/admin/stats, /api/v1/admin/system-kb, /api/v1/admin/iit-projects'); // ============================================ // 【临时】平台基础设施测试API diff --git a/backend/src/modules/admin/iit-projects/iitBatchController.ts b/backend/src/modules/admin/iit-projects/iitBatchController.ts new file mode 100644 index 00000000..0a221e21 --- /dev/null +++ b/backend/src/modules/admin/iit-projects/iitBatchController.ts @@ -0,0 +1,354 @@ +/** + * IIT 批量操作 Controller + * + * 功能: + * - 一键全量质控 + * - 一键全量数据汇总 + * + * 用途: + * - 运营管理端手动触发 + * - 未来可作为 AI 工具暴露 + */ + +import { FastifyRequest, FastifyReply } from 'fastify'; +import { PrismaClient } from '@prisma/client'; +import { logger } from '../../../common/logging/index.js'; +import { RedcapAdapter } from '../../iit-manager/adapters/RedcapAdapter.js'; +import { createHardRuleEngine } from '../../iit-manager/engines/HardRuleEngine.js'; + +const prisma = new PrismaClient(); + +interface BatchRequest { + Params: { projectId: string }; +} + +export class IitBatchController { + /** + * 一键全量质控 + * + * POST /api/v1/admin/iit-projects/:projectId/batch-qc + * + * 功能: + * 1. 获取 REDCap 中所有记录 + * 2. 对每条记录执行质控 + * 3. 存储质控日志到 iit_qc_logs + * 4. 更新项目统计到 iit_qc_project_stats + */ + async batchQualityCheck( + request: FastifyRequest, + reply: FastifyReply + ) { + const { projectId } = request.params; + const startTime = Date.now(); + + try { + logger.info('🔄 开始全量质控', { projectId }); + + // 1. 获取项目配置 + const project = await prisma.iitProject.findUnique({ + where: { id: projectId } + }); + + if (!project) { + return reply.status(404).send({ error: '项目不存在' }); + } + + // 2. 从 REDCap 获取所有记录 + const adapter = new RedcapAdapter(project.redcapUrl, project.redcapApiToken); + const allRecords = await adapter.exportRecords({}); + + if (!allRecords || allRecords.length === 0) { + return reply.send({ + success: true, + message: '项目暂无记录', + stats: { totalRecords: 0 } + }); + } + + // 3. 按 record_id 分组 + const recordMap = new Map(); + for (const record of allRecords) { + const recordId = record.record_id || record.id; + if (recordId) { + // 合并同一记录的多个事件数据 + const existing = recordMap.get(recordId) || {}; + recordMap.set(recordId, { ...existing, ...record }); + } + } + + // 4. 执行质控 + const engine = await createHardRuleEngine(projectId); + const ruleVersion = new Date().toISOString().split('T')[0]; + + let passCount = 0; + let failCount = 0; + let warningCount = 0; + + for (const [recordId, recordData] of recordMap.entries()) { + const qcResult = engine.execute(recordId, recordData); + + // 存储质控日志 + const issues = [ + ...qcResult.errors.map((e: any) => ({ + field: e.field, + rule: e.ruleName, + level: 'RED', + message: e.message + })), + ...qcResult.warnings.map((w: any) => ({ + field: w.field, + rule: w.ruleName, + level: 'YELLOW', + message: w.message + })) + ]; + + await prisma.iitQcLog.create({ + data: { + projectId, + recordId, + qcType: 'holistic', // 全案质控 + status: qcResult.overallStatus, + issues, + rulesEvaluated: qcResult.summary.totalRules, + rulesSkipped: 0, + rulesPassed: qcResult.summary.passed, + rulesFailed: qcResult.summary.failed, + ruleVersion, + triggeredBy: 'manual' + } + }); + + // 更新录入汇总表的质控状态 + await prisma.iitRecordSummary.upsert({ + where: { + projectId_recordId: { projectId, recordId } + }, + create: { + projectId, + recordId, + lastUpdatedAt: new Date(), + latestQcStatus: qcResult.overallStatus, + latestQcAt: new Date(), + formStatus: {}, + updateCount: 1 + }, + update: { + latestQcStatus: qcResult.overallStatus, + latestQcAt: new Date() + } + }); + + // 统计 + if (qcResult.overallStatus === 'PASS') passCount++; + else if (qcResult.overallStatus === 'FAIL') failCount++; + else warningCount++; + } + + // 5. 更新项目统计表 + await prisma.iitQcProjectStats.upsert({ + where: { projectId }, + create: { + projectId, + totalRecords: recordMap.size, + passedRecords: passCount, + failedRecords: failCount, + warningRecords: warningCount + }, + update: { + totalRecords: recordMap.size, + passedRecords: passCount, + failedRecords: failCount, + warningRecords: warningCount + } + }); + + const durationMs = Date.now() - startTime; + logger.info('✅ 全量质控完成', { + projectId, + totalRecords: recordMap.size, + passCount, + failCount, + warningCount, + durationMs + }); + + return reply.send({ + success: true, + message: '全量质控完成', + stats: { + totalRecords: recordMap.size, + passed: passCount, + failed: failCount, + warnings: warningCount, + passRate: `${((passCount / recordMap.size) * 100).toFixed(1)}%` + }, + durationMs + }); + + } catch (error: any) { + logger.error('❌ 全量质控失败', { projectId, error: error.message }); + return reply.status(500).send({ error: `质控失败: ${error.message}` }); + } + } + + /** + * 一键全量数据汇总 + * + * POST /api/v1/admin/iit-projects/:projectId/batch-summary + * + * 功能: + * 1. 获取 REDCap 中所有记录 + * 2. 获取项目的所有表单(instruments) + * 3. 为每条记录生成/更新录入汇总 + */ + async batchSummary( + request: FastifyRequest, + reply: FastifyReply + ) { + const { projectId } = request.params; + const startTime = Date.now(); + + try { + logger.info('🔄 开始全量数据汇总', { projectId }); + + // 1. 获取项目配置 + const project = await prisma.iitProject.findUnique({ + where: { id: projectId } + }); + + if (!project) { + return reply.status(404).send({ error: '项目不存在' }); + } + + // 2. 从 REDCap 获取所有记录和表单信息 + const adapter = new RedcapAdapter(project.redcapUrl, project.redcapApiToken); + + const [allRecords, instruments] = await Promise.all([ + adapter.exportRecords({}), + adapter.exportInstruments() + ]); + + if (!allRecords || allRecords.length === 0) { + return reply.send({ + success: true, + message: '项目暂无记录', + stats: { totalRecords: 0 } + }); + } + + const totalForms = instruments?.length || 10; + + // 3. 按 record_id 分组并计算表单完成状态 + const recordMap = new Map; firstSeen: Date }>(); + + for (const record of allRecords) { + const recordId = record.record_id || record.id; + if (!recordId) continue; + + const existing = recordMap.get(recordId); + if (existing) { + // 合并数据 + existing.data = { ...existing.data, ...record }; + // 记录表单 + if (record.redcap_repeat_instrument) { + existing.forms.add(record.redcap_repeat_instrument); + } + } else { + recordMap.set(recordId, { + data: record, + forms: new Set(record.redcap_repeat_instrument ? [record.redcap_repeat_instrument] : []), + firstSeen: new Date() + }); + } + } + + // 4. 为每条记录更新汇总 + let summaryCount = 0; + + for (const [recordId, { data, forms, firstSeen }] of recordMap.entries()) { + // 计算表单完成状态(简化:有数据即认为完成) + const formStatus: Record = {}; + const completedForms = forms.size || 1; // 至少有1个表单有数据 + + for (const form of forms) { + formStatus[form] = 2; // 2 = 完成 + } + + const completionRate = Math.min(100, Math.round((completedForms / totalForms) * 100)); + + await prisma.iitRecordSummary.upsert({ + where: { + projectId_recordId: { projectId, recordId } + }, + create: { + projectId, + recordId, + enrolledAt: firstSeen, + lastUpdatedAt: new Date(), + formStatus, + totalForms, + completedForms, + completionRate, + updateCount: 1 + }, + update: { + lastUpdatedAt: new Date(), + formStatus, + totalForms, + completedForms, + completionRate, + updateCount: { increment: 1 } + } + }); + + summaryCount++; + } + + // 5. 更新项目统计 + const avgCompletionRate = await prisma.iitRecordSummary.aggregate({ + where: { projectId }, + _avg: { completionRate: true } + }); + + await prisma.iitQcProjectStats.upsert({ + where: { projectId }, + create: { + projectId, + totalRecords: recordMap.size, + avgCompletionRate: avgCompletionRate._avg.completionRate || 0 + }, + update: { + totalRecords: recordMap.size, + avgCompletionRate: avgCompletionRate._avg.completionRate || 0 + } + }); + + const durationMs = Date.now() - startTime; + logger.info('✅ 全量数据汇总完成', { + projectId, + totalRecords: recordMap.size, + summaryCount, + durationMs + }); + + return reply.send({ + success: true, + message: '全量数据汇总完成', + stats: { + totalRecords: recordMap.size, + summariesUpdated: summaryCount, + totalForms, + avgCompletionRate: `${(avgCompletionRate._avg.completionRate || 0).toFixed(1)}%` + }, + durationMs + }); + + } catch (error: any) { + logger.error('❌ 全量数据汇总失败', { projectId, error: error.message }); + return reply.status(500).send({ error: `汇总失败: ${error.message}` }); + } + } +} + +export const iitBatchController = new IitBatchController(); diff --git a/backend/src/modules/admin/iit-projects/iitBatchRoutes.ts b/backend/src/modules/admin/iit-projects/iitBatchRoutes.ts new file mode 100644 index 00000000..b655150f --- /dev/null +++ b/backend/src/modules/admin/iit-projects/iitBatchRoutes.ts @@ -0,0 +1,81 @@ +/** + * IIT 批量操作路由 + * + * API: + * - POST /api/v1/admin/iit-projects/:projectId/batch-qc 一键全量质控 + * - POST /api/v1/admin/iit-projects/:projectId/batch-summary 一键全量数据汇总 + */ + +import { FastifyInstance } from 'fastify'; +import { iitBatchController } from './iitBatchController.js'; + +export async function iitBatchRoutes(fastify: FastifyInstance) { + // 一键全量质控 + fastify.post('/:projectId/batch-qc', { + schema: { + description: '一键全量质控 - 对项目中所有记录执行质控', + tags: ['IIT Admin - 批量操作'], + params: { + type: 'object', + properties: { + projectId: { type: 'string', description: 'IIT 项目 ID' } + }, + required: ['projectId'] + }, + response: { + 200: { + type: 'object', + properties: { + success: { type: 'boolean' }, + message: { type: 'string' }, + stats: { + type: 'object', + properties: { + totalRecords: { type: 'number' }, + passed: { type: 'number' }, + failed: { type: 'number' }, + warnings: { type: 'number' }, + passRate: { type: 'string' } + } + }, + durationMs: { type: 'number' } + } + } + } + } + }, iitBatchController.batchQualityCheck.bind(iitBatchController)); + + // 一键全量数据汇总 + fastify.post('/:projectId/batch-summary', { + schema: { + description: '一键全量数据汇总 - 从 REDCap 同步所有记录的录入状态', + tags: ['IIT Admin - 批量操作'], + params: { + type: 'object', + properties: { + projectId: { type: 'string', description: 'IIT 项目 ID' } + }, + required: ['projectId'] + }, + response: { + 200: { + type: 'object', + properties: { + success: { type: 'boolean' }, + message: { type: 'string' }, + stats: { + type: 'object', + properties: { + totalRecords: { type: 'number' }, + summariesUpdated: { type: 'number' }, + totalForms: { type: 'number' }, + avgCompletionRate: { type: 'string' } + } + }, + durationMs: { type: 'number' } + } + } + } + } + }, iitBatchController.batchSummary.bind(iitBatchController)); +} diff --git a/backend/src/modules/admin/iit-projects/iitProjectController.ts b/backend/src/modules/admin/iit-projects/iitProjectController.ts new file mode 100644 index 00000000..54910dcc --- /dev/null +++ b/backend/src/modules/admin/iit-projects/iitProjectController.ts @@ -0,0 +1,348 @@ +/** + * IIT 项目管理控制器 + */ + +import { FastifyRequest, FastifyReply } from 'fastify'; +import { getIitProjectService, CreateProjectInput, UpdateProjectInput } from './iitProjectService.js'; +import { prisma } from '../../../config/database.js'; +import { logger } from '../../../common/logging/index.js'; + +// ==================== 类型定义 ==================== + +interface ProjectIdParams { + id: string; +} + +interface ListProjectsQuery { + status?: string; + search?: string; +} + +interface TestConnectionBody { + redcapUrl: string; + redcapApiToken: string; +} + +interface LinkKbBody { + knowledgeBaseId: string; +} + +// ==================== 控制器函数 ==================== + +/** + * 获取项目列表 + */ +export async function listProjects( + request: FastifyRequest<{ Querystring: ListProjectsQuery }>, + reply: FastifyReply +) { + try { + const { status, search } = request.query; + const service = getIitProjectService(prisma); + const projects = await service.listProjects({ status, search }); + + return reply.send({ + success: true, + data: projects, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('获取 IIT 项目列表失败', { error: message }); + return reply.status(500).send({ + success: false, + error: message, + }); + } +} + +/** + * 获取项目详情 + */ +export async function getProject( + request: FastifyRequest<{ Params: ProjectIdParams }>, + reply: FastifyReply +) { + try { + const { id } = request.params; + const service = getIitProjectService(prisma); + const project = await service.getProject(id); + + if (!project) { + return reply.status(404).send({ + success: false, + error: '项目不存在', + }); + } + + return reply.send({ + success: true, + data: project, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('获取 IIT 项目详情失败', { error: message }); + return reply.status(500).send({ + success: false, + error: message, + }); + } +} + +/** + * 创建项目 + */ +export async function createProject( + request: FastifyRequest<{ Body: CreateProjectInput }>, + reply: FastifyReply +) { + try { + const input = request.body; + + // 验证必填字段 + if (!input.name) { + return reply.status(400).send({ + success: false, + error: '项目名称为必填项', + }); + } + + if (!input.redcapUrl || !input.redcapProjectId || !input.redcapApiToken) { + return reply.status(400).send({ + success: false, + error: 'REDCap 配置信息为必填项(URL、项目ID、API Token)', + }); + } + + const service = getIitProjectService(prisma); + const project = await service.createProject(input); + + return reply.status(201).send({ + success: true, + data: project, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('创建 IIT 项目失败', { error: message }); + + if (message.includes('连接测试失败')) { + return reply.status(400).send({ + success: false, + error: message, + }); + } + + return reply.status(500).send({ + success: false, + error: message, + }); + } +} + +/** + * 更新项目 + */ +export async function updateProject( + request: FastifyRequest<{ Params: ProjectIdParams; Body: UpdateProjectInput }>, + reply: FastifyReply +) { + try { + const { id } = request.params; + const input = request.body; + + const service = getIitProjectService(prisma); + const project = await service.updateProject(id, input); + + return reply.send({ + success: true, + data: project, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('更新 IIT 项目失败', { error: message }); + + if (message.includes('连接测试失败')) { + return reply.status(400).send({ + success: false, + error: message, + }); + } + + return reply.status(500).send({ + success: false, + error: message, + }); + } +} + +/** + * 删除项目 + */ +export async function deleteProject( + request: FastifyRequest<{ Params: ProjectIdParams }>, + reply: FastifyReply +) { + try { + const { id } = request.params; + const service = getIitProjectService(prisma); + await service.deleteProject(id); + + return reply.send({ + success: true, + message: '删除成功', + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('删除 IIT 项目失败', { error: message }); + return reply.status(500).send({ + success: false, + error: message, + }); + } +} + +/** + * 测试 REDCap 连接(新配置) + */ +export async function testConnection( + request: FastifyRequest<{ Body: TestConnectionBody }>, + reply: FastifyReply +) { + try { + const { redcapUrl, redcapApiToken } = request.body; + + if (!redcapUrl || !redcapApiToken) { + return reply.status(400).send({ + success: false, + error: '请提供 REDCap URL 和 API Token', + }); + } + + const service = getIitProjectService(prisma); + const result = await service.testConnection(redcapUrl, redcapApiToken); + + return reply.send({ + success: result.success, + data: result, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('测试 REDCap 连接失败', { error: message }); + return reply.status(500).send({ + success: false, + error: message, + }); + } +} + +/** + * 测试项目的 REDCap 连接 + */ +export async function testProjectConnection( + request: FastifyRequest<{ Params: ProjectIdParams }>, + reply: FastifyReply +) { + try { + const { id } = request.params; + const service = getIitProjectService(prisma); + const result = await service.testProjectConnection(id); + + return reply.send({ + success: result.success, + data: result, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('测试项目连接失败', { error: message }); + return reply.status(500).send({ + success: false, + error: message, + }); + } +} + +/** + * 同步 REDCap 元数据 + */ +export async function syncMetadata( + request: FastifyRequest<{ Params: ProjectIdParams }>, + reply: FastifyReply +) { + try { + const { id } = request.params; + const service = getIitProjectService(prisma); + const result = await service.syncMetadata(id); + + return reply.send({ + success: true, + data: result, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('同步元数据失败', { error: message }); + return reply.status(500).send({ + success: false, + error: message, + }); + } +} + +/** + * 关联知识库 + */ +export async function linkKnowledgeBase( + request: FastifyRequest<{ Params: ProjectIdParams; Body: LinkKbBody }>, + reply: FastifyReply +) { + try { + const { id } = request.params; + const { knowledgeBaseId } = request.body; + + if (!knowledgeBaseId) { + return reply.status(400).send({ + success: false, + error: '请提供知识库 ID', + }); + } + + const service = getIitProjectService(prisma); + await service.linkKnowledgeBase(id, knowledgeBaseId); + + return reply.send({ + success: true, + message: '关联成功', + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('关联知识库失败', { error: message }); + return reply.status(500).send({ + success: false, + error: message, + }); + } +} + +/** + * 解除知识库关联 + */ +export async function unlinkKnowledgeBase( + request: FastifyRequest<{ Params: ProjectIdParams }>, + reply: FastifyReply +) { + try { + const { id } = request.params; + const service = getIitProjectService(prisma); + await service.unlinkKnowledgeBase(id); + + return reply.send({ + success: true, + message: '解除关联成功', + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('解除知识库关联失败', { error: message }); + return reply.status(500).send({ + success: false, + error: message, + }); + } +} diff --git a/backend/src/modules/admin/iit-projects/iitProjectRoutes.ts b/backend/src/modules/admin/iit-projects/iitProjectRoutes.ts new file mode 100644 index 00000000..fb682719 --- /dev/null +++ b/backend/src/modules/admin/iit-projects/iitProjectRoutes.ts @@ -0,0 +1,44 @@ +/** + * IIT 项目管理路由 + */ + +import { FastifyInstance } from 'fastify'; +import * as controller from './iitProjectController.js'; + +export async function iitProjectRoutes(fastify: FastifyInstance) { + // ==================== 项目 CRUD ==================== + + // 获取项目列表 + fastify.get('/', controller.listProjects); + + // 获取项目详情 + fastify.get('/:id', controller.getProject); + + // 创建项目 + fastify.post('/', controller.createProject); + + // 更新项目 + fastify.put('/:id', controller.updateProject); + + // 删除项目 + fastify.delete('/:id', controller.deleteProject); + + // ==================== REDCap 连接 ==================== + + // 测试 REDCap 连接(新配置,不需要项目 ID) + fastify.post('/test-connection', controller.testConnection); + + // 测试指定项目的 REDCap 连接 + fastify.post('/:id/test-connection', controller.testProjectConnection); + + // 同步 REDCap 元数据 + fastify.post('/:id/sync-metadata', controller.syncMetadata); + + // ==================== 知识库关联 ==================== + + // 关联知识库 + fastify.post('/:id/knowledge-base', controller.linkKnowledgeBase); + + // 解除知识库关联 + fastify.delete('/:id/knowledge-base', controller.unlinkKnowledgeBase); +} diff --git a/backend/src/modules/admin/iit-projects/iitProjectService.ts b/backend/src/modules/admin/iit-projects/iitProjectService.ts new file mode 100644 index 00000000..b05f9ac1 --- /dev/null +++ b/backend/src/modules/admin/iit-projects/iitProjectService.ts @@ -0,0 +1,372 @@ +/** + * IIT 项目管理服务 + * 提供 IIT 项目的 CRUD 操作及相关功能 + */ + +import { PrismaClient, Prisma } from '@prisma/client'; +import { logger } from '../../../common/logging/index.js'; +import { RedcapAdapter } from '../../iit-manager/adapters/RedcapAdapter.js'; + +// ==================== 类型定义 ==================== + +export interface CreateProjectInput { + name: string; + description?: string; + redcapUrl: string; + redcapProjectId: string; + redcapApiToken: string; + fieldMappings?: Record; + knowledgeBaseId?: string; +} + +export interface UpdateProjectInput { + name?: string; + description?: string; + redcapUrl?: string; + redcapProjectId?: string; + redcapApiToken?: string; + fieldMappings?: Record; + knowledgeBaseId?: string; + status?: string; +} + +export interface TestConnectionResult { + success: boolean; + version?: string; + projectTitle?: string; + recordCount?: number; + error?: string; +} + +export interface ProjectListFilters { + status?: string; + search?: string; +} + +// ==================== 服务实现 ==================== + +export class IitProjectService { + constructor(private prisma: PrismaClient) {} + + /** + * 获取项目列表 + */ + async listProjects(filters?: ProjectListFilters) { + const where: Prisma.IitProjectWhereInput = { + deletedAt: null, + }; + + if (filters?.status) { + where.status = filters.status; + } + + if (filters?.search) { + where.OR = [ + { name: { contains: filters.search, mode: 'insensitive' } }, + { description: { contains: filters.search, mode: 'insensitive' } }, + ]; + } + + const projects = await this.prisma.iitProject.findMany({ + where, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + name: true, + description: true, + redcapProjectId: true, + redcapUrl: true, + knowledgeBaseId: true, + status: true, + lastSyncAt: true, + createdAt: true, + updatedAt: true, + _count: { + select: { + userMappings: true, + }, + }, + }, + }); + + return projects.map((p) => ({ + ...p, + userMappingCount: p._count.userMappings, + })); + } + + /** + * 获取项目详情 + */ + async getProject(id: string) { + const project = await this.prisma.iitProject.findFirst({ + where: { id, deletedAt: null }, + include: { + userMappings: { + select: { + id: true, + systemUserId: true, + redcapUsername: true, + wecomUserId: true, + role: true, + createdAt: true, + }, + }, + }, + }); + + if (!project) { + return null; + } + + // 获取关联的质控规则数量 + const skillCount = await this.prisma.iitSkill.count({ + where: { projectId: id }, + }); + + // 获取关联的知识库信息 + let knowledgeBase = null; + if (project.knowledgeBaseId) { + knowledgeBase = await this.prisma.ekbKnowledgeBase.findUnique({ + where: { id: project.knowledgeBaseId }, + select: { + id: true, + name: true, + _count: { select: { documents: true } }, + }, + }); + } + + return { + ...project, + skillCount, + knowledgeBase: knowledgeBase + ? { + id: knowledgeBase.id, + name: knowledgeBase.name, + documentCount: knowledgeBase._count.documents, + } + : null, + // 返回完整 token 供编辑使用 + redcapApiToken: project.redcapApiToken, + }; + } + + /** + * 创建项目 + */ + async createProject(input: CreateProjectInput) { + // 验证 REDCap 连接 + const testResult = await this.testConnection( + input.redcapUrl, + input.redcapApiToken + ); + + if (!testResult.success) { + throw new Error(`REDCap 连接测试失败: ${testResult.error}`); + } + + const project = await this.prisma.iitProject.create({ + data: { + name: input.name, + description: input.description, + redcapUrl: input.redcapUrl, + redcapProjectId: input.redcapProjectId, + redcapApiToken: input.redcapApiToken, + fieldMappings: input.fieldMappings || {}, + knowledgeBaseId: input.knowledgeBaseId, + status: 'active', + }, + }); + + logger.info('创建 IIT 项目成功', { projectId: project.id, name: project.name }); + return project; + } + + /** + * 更新项目 + */ + async updateProject(id: string, input: UpdateProjectInput) { + // 如果更新了 REDCap 配置,需要验证连接 + if (input.redcapUrl && input.redcapApiToken) { + const testResult = await this.testConnection( + input.redcapUrl, + input.redcapApiToken + ); + + if (!testResult.success) { + throw new Error(`REDCap 连接测试失败: ${testResult.error}`); + } + } + + const project = await this.prisma.iitProject.update({ + where: { id }, + data: { + name: input.name, + description: input.description, + redcapUrl: input.redcapUrl, + redcapProjectId: input.redcapProjectId, + redcapApiToken: input.redcapApiToken, + fieldMappings: input.fieldMappings, + knowledgeBaseId: input.knowledgeBaseId, + status: input.status, + updatedAt: new Date(), + }, + }); + + logger.info('更新 IIT 项目成功', { projectId: project.id }); + return project; + } + + /** + * 删除项目(软删除) + */ + async deleteProject(id: string) { + await this.prisma.iitProject.update({ + where: { id }, + data: { + deletedAt: new Date(), + status: 'deleted', + }, + }); + + logger.info('删除 IIT 项目成功', { projectId: id }); + } + + /** + * 测试 REDCap 连接 + */ + async testConnection( + redcapUrl: string, + redcapApiToken: string + ): Promise { + try { + const adapter = new RedcapAdapter(redcapUrl, redcapApiToken); + + // 测试连接 + const connected = await adapter.testConnection(); + if (!connected) { + return { + success: false, + error: 'REDCap API 连接失败', + }; + } + + // 获取记录数量 + const recordCount = await adapter.getRecordCount(); + + return { + success: true, + version: 'REDCap API', + recordCount, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('REDCap 连接测试失败', { error: message }); + return { + success: false, + error: message, + }; + } + } + + /** + * 测试指定项目的 REDCap 连接 + */ + async testProjectConnection(projectId: string): Promise { + const project = await this.prisma.iitProject.findFirst({ + where: { id: projectId, deletedAt: null }, + }); + + if (!project) { + return { + success: false, + error: '项目不存在', + }; + } + + return this.testConnection(project.redcapUrl, project.redcapApiToken); + } + + /** + * 同步 REDCap 元数据 + */ + async syncMetadata(projectId: string) { + const project = await this.prisma.iitProject.findFirst({ + where: { id: projectId, deletedAt: null }, + }); + + if (!project) { + throw new Error('项目不存在'); + } + + try { + const adapter = new RedcapAdapter(project.redcapUrl, project.redcapApiToken); + const metadata = await adapter.exportMetadata(); + + // 更新最后同步时间 + await this.prisma.iitProject.update({ + where: { id: projectId }, + data: { lastSyncAt: new Date() }, + }); + + logger.info('同步 REDCap 元数据成功', { + projectId, + fieldCount: metadata.length, + }); + + return { + success: true, + fieldCount: metadata.length, + metadata, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('同步 REDCap 元数据失败', { projectId, error: message }); + throw new Error(`同步失败: ${message}`); + } + } + + /** + * 关联知识库 + */ + async linkKnowledgeBase(projectId: string, knowledgeBaseId: string) { + // 验证知识库存在 + const kb = await this.prisma.ekbKnowledgeBase.findUnique({ + where: { id: knowledgeBaseId }, + }); + + if (!kb) { + throw new Error('知识库不存在'); + } + + await this.prisma.iitProject.update({ + where: { id: projectId }, + data: { knowledgeBaseId }, + }); + + logger.info('关联知识库成功', { projectId, knowledgeBaseId }); + } + + /** + * 解除知识库关联 + */ + async unlinkKnowledgeBase(projectId: string) { + await this.prisma.iitProject.update({ + where: { id: projectId }, + data: { knowledgeBaseId: null }, + }); + + logger.info('解除知识库关联成功', { projectId }); + } +} + +// 单例工厂函数 +let serviceInstance: IitProjectService | null = null; + +export function getIitProjectService(prisma: PrismaClient): IitProjectService { + if (!serviceInstance) { + serviceInstance = new IitProjectService(prisma); + } + return serviceInstance; +} diff --git a/backend/src/modules/admin/iit-projects/iitQcRuleController.ts b/backend/src/modules/admin/iit-projects/iitQcRuleController.ts new file mode 100644 index 00000000..addf37b7 --- /dev/null +++ b/backend/src/modules/admin/iit-projects/iitQcRuleController.ts @@ -0,0 +1,288 @@ +/** + * IIT 质控规则管理控制器 + */ + +import { FastifyRequest, FastifyReply } from 'fastify'; +import { getIitQcRuleService, CreateRuleInput, UpdateRuleInput, TestRuleInput } from './iitQcRuleService.js'; +import { prisma } from '../../../config/database.js'; +import { logger } from '../../../common/logging/index.js'; + +// ==================== 类型定义 ==================== + +interface ProjectIdParams { + projectId: string; +} + +interface RuleIdParams { + projectId: string; + ruleId: string; +} + +interface ImportRulesBody { + rules: CreateRuleInput[]; +} + +// ==================== 控制器函数 ==================== + +/** + * 获取项目的所有质控规则 + */ +export async function listRules( + request: FastifyRequest<{ Params: ProjectIdParams }>, + reply: FastifyReply +) { + try { + const { projectId } = request.params; + const service = getIitQcRuleService(prisma); + const rules = await service.listRules(projectId); + + return reply.send({ + success: true, + data: rules, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('获取质控规则列表失败', { error: message }); + return reply.status(500).send({ + success: false, + error: message, + }); + } +} + +/** + * 获取单条规则 + */ +export async function getRule( + request: FastifyRequest<{ Params: RuleIdParams }>, + reply: FastifyReply +) { + try { + const { projectId, ruleId } = request.params; + const service = getIitQcRuleService(prisma); + const rule = await service.getRule(projectId, ruleId); + + if (!rule) { + return reply.status(404).send({ + success: false, + error: '规则不存在', + }); + } + + return reply.send({ + success: true, + data: rule, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('获取质控规则失败', { error: message }); + return reply.status(500).send({ + success: false, + error: message, + }); + } +} + +/** + * 添加规则 + */ +export async function addRule( + request: FastifyRequest<{ Params: ProjectIdParams; Body: CreateRuleInput }>, + reply: FastifyReply +) { + try { + const { projectId } = request.params; + const input = request.body; + + // 验证必填字段 + if (!input.name || !input.field || !input.logic || !input.message || !input.severity || !input.category) { + return reply.status(400).send({ + success: false, + error: '缺少必填字段:name, field, logic, message, severity, category', + }); + } + + const service = getIitQcRuleService(prisma); + const rule = await service.addRule(projectId, input); + + return reply.status(201).send({ + success: true, + data: rule, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('添加质控规则失败', { error: message }); + return reply.status(500).send({ + success: false, + error: message, + }); + } +} + +/** + * 更新规则 + */ +export async function updateRule( + request: FastifyRequest<{ Params: RuleIdParams; Body: UpdateRuleInput }>, + reply: FastifyReply +) { + try { + const { projectId, ruleId } = request.params; + const input = request.body; + + const service = getIitQcRuleService(prisma); + const rule = await service.updateRule(projectId, ruleId, input); + + return reply.send({ + success: true, + data: rule, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('更新质控规则失败', { error: message }); + + if (message.includes('规则不存在')) { + return reply.status(404).send({ + success: false, + error: message, + }); + } + + return reply.status(500).send({ + success: false, + error: message, + }); + } +} + +/** + * 删除规则 + */ +export async function deleteRule( + request: FastifyRequest<{ Params: RuleIdParams }>, + reply: FastifyReply +) { + try { + const { projectId, ruleId } = request.params; + const service = getIitQcRuleService(prisma); + await service.deleteRule(projectId, ruleId); + + return reply.send({ + success: true, + message: '删除成功', + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('删除质控规则失败', { error: message }); + + if (message.includes('规则不存在')) { + return reply.status(404).send({ + success: false, + error: message, + }); + } + + return reply.status(500).send({ + success: false, + error: message, + }); + } +} + +/** + * 批量导入规则 + */ +export async function importRules( + request: FastifyRequest<{ Params: ProjectIdParams; Body: ImportRulesBody }>, + reply: FastifyReply +) { + try { + const { projectId } = request.params; + const { rules } = request.body; + + if (!rules || !Array.isArray(rules) || rules.length === 0) { + return reply.status(400).send({ + success: false, + error: '请提供规则数组', + }); + } + + const service = getIitQcRuleService(prisma); + const importedRules = await service.importRules(projectId, rules); + + return reply.status(201).send({ + success: true, + data: { + count: importedRules.length, + rules: importedRules, + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('批量导入质控规则失败', { error: message }); + return reply.status(500).send({ + success: false, + error: message, + }); + } +} + +/** + * 测试规则逻辑 + */ +export async function testRule( + request: FastifyRequest<{ Body: TestRuleInput }>, + reply: FastifyReply +) { + try { + const input = request.body; + + if (!input.logic || !input.testData) { + return reply.status(400).send({ + success: false, + error: '请提供 logic 和 testData', + }); + } + + const service = getIitQcRuleService(prisma); + const result = await service.testRule(input); + + return reply.send({ + success: true, + data: result, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('测试规则失败', { error: message }); + return reply.status(400).send({ + success: false, + error: message, + }); + } +} + +/** + * 获取规则统计 + */ +export async function getRuleStats( + request: FastifyRequest<{ Params: ProjectIdParams }>, + reply: FastifyReply +) { + try { + const { projectId } = request.params; + const service = getIitQcRuleService(prisma); + const stats = await service.getRuleStats(projectId); + + return reply.send({ + success: true, + data: stats, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('获取规则统计失败', { error: message }); + return reply.status(500).send({ + success: false, + error: message, + }); + } +} diff --git a/backend/src/modules/admin/iit-projects/iitQcRuleRoutes.ts b/backend/src/modules/admin/iit-projects/iitQcRuleRoutes.ts new file mode 100644 index 00000000..ebd9560b --- /dev/null +++ b/backend/src/modules/admin/iit-projects/iitQcRuleRoutes.ts @@ -0,0 +1,32 @@ +/** + * IIT 质控规则管理路由 + */ + +import { FastifyInstance } from 'fastify'; +import * as controller from './iitQcRuleController.js'; + +export async function iitQcRuleRoutes(fastify: FastifyInstance) { + // 获取项目的所有质控规则 + fastify.get('/:projectId/rules', controller.listRules); + + // 获取规则统计 + fastify.get('/:projectId/rules/stats', controller.getRuleStats); + + // 获取单条规则 + fastify.get('/:projectId/rules/:ruleId', controller.getRule); + + // 添加规则 + fastify.post('/:projectId/rules', controller.addRule); + + // 更新规则 + fastify.put('/:projectId/rules/:ruleId', controller.updateRule); + + // 删除规则 + fastify.delete('/:projectId/rules/:ruleId', controller.deleteRule); + + // 批量导入规则 + fastify.post('/:projectId/rules/import', controller.importRules); + + // 测试规则逻辑(不需要项目 ID) + fastify.post('/rules/test', controller.testRule); +} diff --git a/backend/src/modules/admin/iit-projects/iitQcRuleService.ts b/backend/src/modules/admin/iit-projects/iitQcRuleService.ts new file mode 100644 index 00000000..b38b3111 --- /dev/null +++ b/backend/src/modules/admin/iit-projects/iitQcRuleService.ts @@ -0,0 +1,272 @@ +/** + * IIT 质控规则管理服务 + * 管理 JSON Logic 格式的质控规则 + */ + +import { PrismaClient } from '@prisma/client'; +import { logger } from '../../../common/logging/index.js'; +import jsonLogic from 'json-logic-js'; + +// ==================== 类型定义 ==================== + +export interface QCRule { + id: string; + name: string; + field: string | string[]; + logic: Record; + message: string; + severity: 'error' | 'warning' | 'info'; + category: 'inclusion' | 'exclusion' | 'lab_values' | 'logic_check'; + metadata?: Record; +} + +export interface CreateRuleInput { + name: string; + field: string | string[]; + logic: Record; + message: string; + severity: 'error' | 'warning' | 'info'; + category: 'inclusion' | 'exclusion' | 'lab_values' | 'logic_check'; + metadata?: Record; +} + +export interface UpdateRuleInput { + name?: string; + field?: string | string[]; + logic?: Record; + message?: string; + severity?: 'error' | 'warning' | 'info'; + category?: 'inclusion' | 'exclusion' | 'lab_values' | 'logic_check'; + metadata?: Record; +} + +export interface QCRuleConfig { + rules: QCRule[]; + version: number; + updatedAt: string; +} + +export interface TestRuleInput { + logic: Record; + testData: Record; +} + +// ==================== 服务实现 ==================== + +export class IitQcRuleService { + constructor(private prisma: PrismaClient) {} + + /** + * 获取项目的所有质控规则 + */ + async listRules(projectId: string): Promise { + const skill = await this.prisma.iitSkill.findFirst({ + where: { + projectId, + skillType: 'qc_process', + }, + }); + + if (!skill || !skill.config) { + return []; + } + + const config = skill.config as unknown as QCRuleConfig; + return config.rules || []; + } + + /** + * 获取单条规则 + */ + async getRule(projectId: string, ruleId: string): Promise { + const rules = await this.listRules(projectId); + return rules.find((r) => r.id === ruleId) || null; + } + + /** + * 添加规则 + */ + async addRule(projectId: string, input: CreateRuleInput): Promise { + const skill = await this.getOrCreateSkill(projectId); + const config = (skill.config as unknown as QCRuleConfig) || { rules: [], version: 1, updatedAt: '' }; + + // 生成规则 ID + const ruleId = `rule_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + const newRule: QCRule = { + id: ruleId, + ...input, + }; + + config.rules.push(newRule); + config.version += 1; + config.updatedAt = new Date().toISOString(); + + await this.prisma.iitSkill.update({ + where: { id: skill.id }, + data: { config: config as unknown as object }, + }); + + logger.info('添加质控规则成功', { projectId, ruleId, ruleName: input.name }); + return newRule; + } + + /** + * 更新规则 + */ + async updateRule(projectId: string, ruleId: string, input: UpdateRuleInput): Promise { + const skill = await this.getOrCreateSkill(projectId); + const config = (skill.config as unknown as QCRuleConfig) || { rules: [], version: 1, updatedAt: '' }; + + const ruleIndex = config.rules.findIndex((r) => r.id === ruleId); + if (ruleIndex === -1) { + throw new Error('规则不存在'); + } + + const updatedRule: QCRule = { + ...config.rules[ruleIndex], + ...input, + }; + + config.rules[ruleIndex] = updatedRule; + config.version += 1; + config.updatedAt = new Date().toISOString(); + + await this.prisma.iitSkill.update({ + where: { id: skill.id }, + data: { config: config as unknown as object }, + }); + + logger.info('更新质控规则成功', { projectId, ruleId }); + return updatedRule; + } + + /** + * 删除规则 + */ + async deleteRule(projectId: string, ruleId: string): Promise { + const skill = await this.getOrCreateSkill(projectId); + const config = (skill.config as unknown as QCRuleConfig) || { rules: [], version: 1, updatedAt: '' }; + + const ruleIndex = config.rules.findIndex((r) => r.id === ruleId); + if (ruleIndex === -1) { + throw new Error('规则不存在'); + } + + config.rules.splice(ruleIndex, 1); + config.version += 1; + config.updatedAt = new Date().toISOString(); + + await this.prisma.iitSkill.update({ + where: { id: skill.id }, + data: { config: config as unknown as object }, + }); + + logger.info('删除质控规则成功', { projectId, ruleId }); + } + + /** + * 批量导入规则 + */ + async importRules(projectId: string, rules: CreateRuleInput[]): Promise { + const skill = await this.getOrCreateSkill(projectId); + + const newRules: QCRule[] = rules.map((input, index) => ({ + id: `rule_${Date.now()}_${index}_${Math.random().toString(36).substr(2, 9)}`, + ...input, + })); + + const config: QCRuleConfig = { + rules: newRules, + version: 1, + updatedAt: new Date().toISOString(), + }; + + await this.prisma.iitSkill.update({ + where: { id: skill.id }, + data: { config: config as unknown as object }, + }); + + logger.info('批量导入质控规则成功', { projectId, ruleCount: newRules.length }); + return newRules; + } + + /** + * 测试规则逻辑 + */ + async testRule(input: TestRuleInput): Promise<{ passed: boolean; result: unknown }> { + try { + const result = jsonLogic.apply(input.logic, input.testData); + return { + passed: !!result, + result, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`规则执行失败: ${message}`); + } + } + + /** + * 获取规则分类统计 + */ + async getRuleStats(projectId: string) { + const rules = await this.listRules(projectId); + + const stats = { + total: rules.length, + byCategory: {} as Record, + bySeverity: {} as Record, + }; + + rules.forEach((rule) => { + stats.byCategory[rule.category] = (stats.byCategory[rule.category] || 0) + 1; + stats.bySeverity[rule.severity] = (stats.bySeverity[rule.severity] || 0) + 1; + }); + + return stats; + } + + /** + * 获取或创建 qc_process Skill + */ + private async getOrCreateSkill(projectId: string) { + let skill = await this.prisma.iitSkill.findFirst({ + where: { + projectId, + skillType: 'qc_process', + }, + }); + + if (!skill) { + skill = await this.prisma.iitSkill.create({ + data: { + projectId, + skillType: 'qc_process', + name: '质控规则', + description: '数据质量控制规则集', + config: { + rules: [], + version: 1, + updatedAt: new Date().toISOString(), + }, + isActive: true, + }, + }); + + logger.info('创建 qc_process Skill', { projectId, skillId: skill.id }); + } + + return skill; + } +} + +// 单例工厂函数 +let serviceInstance: IitQcRuleService | null = null; + +export function getIitQcRuleService(prisma: PrismaClient): IitQcRuleService { + if (!serviceInstance) { + serviceInstance = new IitQcRuleService(prisma); + } + return serviceInstance; +} diff --git a/backend/src/modules/admin/iit-projects/iitUserMappingController.ts b/backend/src/modules/admin/iit-projects/iitUserMappingController.ts new file mode 100644 index 00000000..b86c3e5c --- /dev/null +++ b/backend/src/modules/admin/iit-projects/iitUserMappingController.ts @@ -0,0 +1,256 @@ +/** + * IIT 用户映射管理控制器 + */ + +import { FastifyRequest, FastifyReply } from 'fastify'; +import { getIitUserMappingService, CreateUserMappingInput, UpdateUserMappingInput } from './iitUserMappingService.js'; +import { prisma } from '../../../config/database.js'; +import { logger } from '../../../common/logging/index.js'; + +// ==================== 类型定义 ==================== + +interface ProjectIdParams { + projectId: string; +} + +interface MappingIdParams { + projectId: string; + mappingId: string; +} + +interface ListMappingsQuery { + role?: string; + search?: string; +} + +// ==================== 控制器函数 ==================== + +/** + * 获取项目的用户映射列表 + */ +export async function listUserMappings( + request: FastifyRequest<{ Params: ProjectIdParams; Querystring: ListMappingsQuery }>, + reply: FastifyReply +) { + try { + const { projectId } = request.params; + const { role, search } = request.query; + + const service = getIitUserMappingService(prisma); + const mappings = await service.listUserMappings(projectId, { role, search }); + + return reply.send({ + success: true, + data: mappings, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('获取用户映射列表失败', { error: message }); + return reply.status(500).send({ + success: false, + error: message, + }); + } +} + +/** + * 获取单个用户映射 + */ +export async function getUserMapping( + request: FastifyRequest<{ Params: MappingIdParams }>, + reply: FastifyReply +) { + try { + const { projectId, mappingId } = request.params; + const service = getIitUserMappingService(prisma); + const mapping = await service.getUserMapping(projectId, mappingId); + + if (!mapping) { + return reply.status(404).send({ + success: false, + error: '用户映射不存在', + }); + } + + return reply.send({ + success: true, + data: mapping, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('获取用户映射失败', { error: message }); + return reply.status(500).send({ + success: false, + error: message, + }); + } +} + +/** + * 创建用户映射 + */ +export async function createUserMapping( + request: FastifyRequest<{ Params: ProjectIdParams; Body: CreateUserMappingInput }>, + reply: FastifyReply +) { + try { + const { projectId } = request.params; + const input = request.body; + + // 验证必填字段 - 只有企业微信用户 ID 是必填的 + if (!input.wecomUserId) { + return reply.status(400).send({ + success: false, + error: '请输入企业微信用户 ID', + }); + } + + // 如果没有提供 systemUserId,使用 wecomUserId 作为默认值 + if (!input.systemUserId) { + input.systemUserId = input.wecomUserId; + } + // 如果没有提供 redcapUsername,使用 wecomUserId 作为默认值 + if (!input.redcapUsername) { + input.redcapUsername = input.wecomUserId; + } + // 如果没有提供 role,默认为 PI + if (!input.role) { + input.role = 'PI'; + } + + const service = getIitUserMappingService(prisma); + const mapping = await service.createUserMapping(projectId, input); + + return reply.status(201).send({ + success: true, + data: mapping, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('创建用户映射失败', { error: message }); + + if (message.includes('已存在')) { + return reply.status(409).send({ + success: false, + error: message, + }); + } + + return reply.status(500).send({ + success: false, + error: message, + }); + } +} + +/** + * 更新用户映射 + */ +export async function updateUserMapping( + request: FastifyRequest<{ Params: MappingIdParams; Body: UpdateUserMappingInput }>, + reply: FastifyReply +) { + try { + const { projectId, mappingId } = request.params; + const input = request.body; + + const service = getIitUserMappingService(prisma); + const mapping = await service.updateUserMapping(projectId, mappingId, input); + + return reply.send({ + success: true, + data: mapping, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('更新用户映射失败', { error: message }); + + if (message.includes('不存在')) { + return reply.status(404).send({ + success: false, + error: message, + }); + } + + return reply.status(500).send({ + success: false, + error: message, + }); + } +} + +/** + * 删除用户映射 + */ +export async function deleteUserMapping( + request: FastifyRequest<{ Params: MappingIdParams }>, + reply: FastifyReply +) { + try { + const { projectId, mappingId } = request.params; + const service = getIitUserMappingService(prisma); + await service.deleteUserMapping(projectId, mappingId); + + return reply.send({ + success: true, + message: '删除成功', + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('删除用户映射失败', { error: message }); + + if (message.includes('不存在')) { + return reply.status(404).send({ + success: false, + error: message, + }); + } + + return reply.status(500).send({ + success: false, + error: message, + }); + } +} + +/** + * 获取角色选项 + */ +export async function getRoleOptions( + _request: FastifyRequest, + reply: FastifyReply +) { + const service = getIitUserMappingService(prisma); + const options = service.getRoleOptions(); + + return reply.send({ + success: true, + data: options, + }); +} + +/** + * 获取用户映射统计 + */ +export async function getUserMappingStats( + request: FastifyRequest<{ Params: ProjectIdParams }>, + reply: FastifyReply +) { + try { + const { projectId } = request.params; + const service = getIitUserMappingService(prisma); + const stats = await service.getUserMappingStats(projectId); + + return reply.send({ + success: true, + data: stats, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('获取用户映射统计失败', { error: message }); + return reply.status(500).send({ + success: false, + error: message, + }); + } +} diff --git a/backend/src/modules/admin/iit-projects/iitUserMappingRoutes.ts b/backend/src/modules/admin/iit-projects/iitUserMappingRoutes.ts new file mode 100644 index 00000000..d7d1db52 --- /dev/null +++ b/backend/src/modules/admin/iit-projects/iitUserMappingRoutes.ts @@ -0,0 +1,29 @@ +/** + * IIT 用户映射管理路由 + */ + +import { FastifyInstance } from 'fastify'; +import * as controller from './iitUserMappingController.js'; + +export async function iitUserMappingRoutes(fastify: FastifyInstance) { + // 获取角色选项(不需要项目 ID) + fastify.get('/roles', controller.getRoleOptions); + + // 获取项目的用户映射列表 + fastify.get('/:projectId/users', controller.listUserMappings); + + // 获取用户映射统计 + fastify.get('/:projectId/users/stats', controller.getUserMappingStats); + + // 获取单个用户映射 + fastify.get('/:projectId/users/:mappingId', controller.getUserMapping); + + // 创建用户映射 + fastify.post('/:projectId/users', controller.createUserMapping); + + // 更新用户映射 + fastify.put('/:projectId/users/:mappingId', controller.updateUserMapping); + + // 删除用户映射 + fastify.delete('/:projectId/users/:mappingId', controller.deleteUserMapping); +} diff --git a/backend/src/modules/admin/iit-projects/iitUserMappingService.ts b/backend/src/modules/admin/iit-projects/iitUserMappingService.ts new file mode 100644 index 00000000..52d2ca17 --- /dev/null +++ b/backend/src/modules/admin/iit-projects/iitUserMappingService.ts @@ -0,0 +1,206 @@ +/** + * IIT 用户映射管理服务 + * 管理企业微信用户与 REDCap 用户的映射关系 + */ + +import { PrismaClient, Prisma } from '@prisma/client'; +import { logger } from '../../../common/logging/index.js'; + +// ==================== 类型定义 ==================== + +export interface CreateUserMappingInput { + systemUserId: string; + redcapUsername: string; + wecomUserId?: string; + role: string; +} + +export interface UpdateUserMappingInput { + systemUserId?: string; + redcapUsername?: string; + wecomUserId?: string; + role?: string; +} + +export interface UserMappingListFilters { + role?: string; + search?: string; +} + +// ==================== 服务实现 ==================== + +export class IitUserMappingService { + constructor(private prisma: PrismaClient) {} + + /** + * 获取项目的用户映射列表 + */ + async listUserMappings(projectId: string, filters?: UserMappingListFilters) { + const where: Prisma.IitUserMappingWhereInput = { + projectId, + }; + + if (filters?.role) { + where.role = filters.role; + } + + if (filters?.search) { + where.OR = [ + { systemUserId: { contains: filters.search, mode: 'insensitive' } }, + { redcapUsername: { contains: filters.search, mode: 'insensitive' } }, + { wecomUserId: { contains: filters.search, mode: 'insensitive' } }, + ]; + } + + const mappings = await this.prisma.iitUserMapping.findMany({ + where, + orderBy: { createdAt: 'desc' }, + }); + + return mappings; + } + + /** + * 获取单个用户映射 + */ + async getUserMapping(projectId: string, mappingId: string) { + return this.prisma.iitUserMapping.findFirst({ + where: { + id: mappingId, + projectId, + }, + }); + } + + /** + * 创建用户映射 + */ + async createUserMapping(projectId: string, input: CreateUserMappingInput) { + // 检查项目是否存在 + const project = await this.prisma.iitProject.findFirst({ + where: { id: projectId, deletedAt: null }, + }); + + if (!project) { + throw new Error('项目不存在'); + } + + // 检查是否已存在 + const existing = await this.prisma.iitUserMapping.findFirst({ + where: { + projectId, + OR: [ + { systemUserId: input.systemUserId }, + { redcapUsername: input.redcapUsername }, + ], + }, + }); + + if (existing) { + throw new Error('该用户或 REDCap 用户名已存在映射'); + } + + const mapping = await this.prisma.iitUserMapping.create({ + data: { + projectId, + systemUserId: input.systemUserId, + redcapUsername: input.redcapUsername, + wecomUserId: input.wecomUserId, + role: input.role, + }, + }); + + logger.info('创建用户映射成功', { projectId, mappingId: mapping.id }); + return mapping; + } + + /** + * 更新用户映射 + */ + async updateUserMapping(projectId: string, mappingId: string, input: UpdateUserMappingInput) { + const existing = await this.prisma.iitUserMapping.findFirst({ + where: { id: mappingId, projectId }, + }); + + if (!existing) { + throw new Error('用户映射不存在'); + } + + const mapping = await this.prisma.iitUserMapping.update({ + where: { id: mappingId }, + data: { + systemUserId: input.systemUserId, + redcapUsername: input.redcapUsername, + wecomUserId: input.wecomUserId, + role: input.role, + }, + }); + + logger.info('更新用户映射成功', { projectId, mappingId }); + return mapping; + } + + /** + * 删除用户映射 + */ + async deleteUserMapping(projectId: string, mappingId: string) { + const existing = await this.prisma.iitUserMapping.findFirst({ + where: { id: mappingId, projectId }, + }); + + if (!existing) { + throw new Error('用户映射不存在'); + } + + await this.prisma.iitUserMapping.delete({ + where: { id: mappingId }, + }); + + logger.info('删除用户映射成功', { projectId, mappingId }); + } + + /** + * 获取可用角色列表 + */ + getRoleOptions() { + return [ + { value: 'PI', label: '主要研究者 (PI)' }, + { value: 'Sub-I', label: '次要研究者 (Sub-I)' }, + { value: 'CRC', label: '临床研究协调员 (CRC)' }, + { value: 'CRA', label: '临床监查员 (CRA)' }, + { value: 'DM', label: '数据管理员 (DM)' }, + { value: 'Statistician', label: '统计师' }, + { value: 'Other', label: '其他' }, + ]; + } + + /** + * 按角色统计用户数量 + */ + async getUserMappingStats(projectId: string) { + const mappings = await this.prisma.iitUserMapping.findMany({ + where: { projectId }, + select: { role: true }, + }); + + const stats: Record = {}; + mappings.forEach((m) => { + stats[m.role] = (stats[m.role] || 0) + 1; + }); + + return { + total: mappings.length, + byRole: stats, + }; + } +} + +// 单例工厂函数 +let serviceInstance: IitUserMappingService | null = null; + +export function getIitUserMappingService(prisma: PrismaClient): IitUserMappingService { + if (!serviceInstance) { + serviceInstance = new IitUserMappingService(prisma); + } + return serviceInstance; +} diff --git a/backend/src/modules/admin/iit-projects/index.ts b/backend/src/modules/admin/iit-projects/index.ts new file mode 100644 index 00000000..720b17ac --- /dev/null +++ b/backend/src/modules/admin/iit-projects/index.ts @@ -0,0 +1,15 @@ +/** + * IIT 项目管理模块导出 + */ + +export { iitProjectRoutes } from './iitProjectRoutes.js'; +export { iitQcRuleRoutes } from './iitQcRuleRoutes.js'; +export { iitUserMappingRoutes } from './iitUserMappingRoutes.js'; +export { iitBatchRoutes } from './iitBatchRoutes.js'; +export { IitProjectService, getIitProjectService } from './iitProjectService.js'; +export { IitQcRuleService, getIitQcRuleService } from './iitQcRuleService.js'; +export { IitUserMappingService, getIitUserMappingService } from './iitUserMappingService.js'; +export * from './iitProjectController.js'; +export * from './iitQcRuleController.js'; +export * from './iitUserMappingController.js'; +export * from './iitBatchController.js'; diff --git a/backend/src/modules/iit-manager/__tests__/HardRuleEngine.test.ts b/backend/src/modules/iit-manager/__tests__/HardRuleEngine.test.ts new file mode 100644 index 00000000..8ff5e352 --- /dev/null +++ b/backend/src/modules/iit-manager/__tests__/HardRuleEngine.test.ts @@ -0,0 +1,608 @@ +/** + * HardRuleEngine 单元测试 + * + * 测试质控规则引擎的核心功能: + * - JSON Logic 规则执行 + * - 纳入标准检查 + * - 排除标准检查 + * - 变量范围检查 + * - 批量质控 + */ + +import * as assert from 'assert'; +import jsonLogic from 'json-logic-js'; + +// ============================================================ +// 测试用规则定义(与 seed-iit-qc-rules.ts 保持一致) +// ============================================================ + +const INCLUSION_RULES = [ + { + id: 'inc_001', + name: '年龄范围检查', + field: 'age', + logic: { + and: [ + { '>=': [{ var: 'age' }, 16] }, + { '<=': [{ var: 'age' }, 35] } + ] + }, + message: '年龄不在 16-35 岁范围内', + severity: 'error' as const, + category: 'inclusion' as const + }, + { + id: 'inc_003', + name: '月经周期规律性检查', + field: 'menstrual_cycle', + logic: { + and: [ + { '>=': [{ var: 'menstrual_cycle' }, 21] }, + { '<=': [{ var: 'menstrual_cycle' }, 35] } + ] + }, + message: '月经周期不在 21-35 天范围内(28±7天)', + severity: 'error' as const, + category: 'inclusion' as const + }, + { + id: 'inc_004', + name: 'VAS 评分检查', + field: 'vas_score', + logic: { + '>=': [{ var: 'vas_score' }, 4] + }, + message: 'VAS 疼痛评分 < 4 分,不符合入组条件', + severity: 'error' as const, + category: 'inclusion' as const + } +]; + +const EXCLUSION_RULES = [ + { + id: 'exc_002', + name: '妊娠或哺乳期检查', + field: 'pregnancy_status', + logic: { + or: [ + { '==': [{ var: 'pregnancy_status' }, null] }, + { '==': [{ var: 'pregnancy_status' }, ''] }, + { '==': [{ var: 'pregnancy_status' }, 0] }, + { '==': [{ var: 'pregnancy_status' }, '0'] } + ] + }, + message: '妊娠或哺乳期妇女不符合入组条件', + severity: 'error' as const, + category: 'exclusion' as const + } +]; + +const LAB_RULES = [ + { + id: 'lab_001', + name: '白细胞计数范围检查', + field: 'wbc', + logic: { + or: [ + { '==': [{ var: 'wbc' }, null] }, + { '==': [{ var: 'wbc' }, ''] }, + { + and: [ + { '>=': [{ var: 'wbc' }, 3.5] }, + { '<=': [{ var: 'wbc' }, 9.5] } + ] + } + ] + }, + message: '白细胞计数超出正常范围(3.5-9.5 *10^9/L)', + severity: 'warning' as const, + category: 'lab_values' as const + }, + { + id: 'lab_002', + name: '血红蛋白范围检查', + field: 'hemoglobin', + logic: { + or: [ + { '==': [{ var: 'hemoglobin' }, null] }, + { '==': [{ var: 'hemoglobin' }, ''] }, + { + and: [ + { '>=': [{ var: 'hemoglobin' }, 115] }, + { '<=': [{ var: 'hemoglobin' }, 150] } + ] + } + ] + }, + message: '血红蛋白超出正常范围(115-150 g/L)', + severity: 'warning' as const, + category: 'lab_values' as const + } +]; + +const ALL_RULES = [...INCLUSION_RULES, ...EXCLUSION_RULES, ...LAB_RULES]; + +// ============================================================ +// 模拟 HardRuleEngine 核心逻辑(不依赖数据库) +// ============================================================ + +interface RuleResult { + ruleId: string; + ruleName: string; + field: string; + passed: boolean; + message: string; + severity: 'error' | 'warning' | 'info'; + category: string; + actualValue?: any; +} + +interface QCResult { + recordId: string; + overallStatus: 'PASS' | 'FAIL' | 'WARNING'; + summary: { + totalRules: number; + passed: number; + failed: number; + warnings: number; + }; + results: RuleResult[]; + errors: RuleResult[]; + warnings: RuleResult[]; +} + +function executeQC(recordId: string, data: Record, rules: typeof ALL_RULES): QCResult { + const results: RuleResult[] = []; + const errors: RuleResult[] = []; + const warnings: RuleResult[] = []; + + // 数据类型转换 + const normalizedData: Record = {}; + for (const [key, value] of Object.entries(data)) { + if (typeof value === 'string' && value !== '' && !isNaN(Number(value))) { + normalizedData[key] = Number(value); + } else { + normalizedData[key] = value; + } + } + + for (const rule of rules) { + const passed = jsonLogic.apply(rule.logic, normalizedData) as boolean; + const result: RuleResult = { + ruleId: rule.id, + ruleName: rule.name, + field: rule.field, + passed, + message: passed ? '通过' : rule.message, + severity: rule.severity, + category: rule.category, + actualValue: normalizedData[rule.field] + }; + + results.push(result); + + if (!passed) { + if (rule.severity === 'error') { + errors.push(result); + } else if (rule.severity === 'warning') { + warnings.push(result); + } + } + } + + let overallStatus: 'PASS' | 'FAIL' | 'WARNING' = 'PASS'; + if (errors.length > 0) { + overallStatus = 'FAIL'; + } else if (warnings.length > 0) { + overallStatus = 'WARNING'; + } + + return { + recordId, + overallStatus, + summary: { + totalRules: rules.length, + passed: results.filter(r => r.passed).length, + failed: errors.length, + warnings: warnings.length + }, + results, + errors, + warnings + }; +} + +// ============================================================ +// 测试用例 +// ============================================================ + +describe('HardRuleEngine - 质控规则引擎', () => { + + describe('纳入标准检查', () => { + + it('符合所有纳入标准的记录应该通过', () => { + const data = { + age: 25, + menstrual_cycle: 28, + vas_score: 6 + }; + + const result = executeQC('001', data, INCLUSION_RULES); + + assert.strictEqual(result.overallStatus, 'PASS', '应该通过所有纳入标准'); + assert.strictEqual(result.errors.length, 0, '不应有错误'); + assert.strictEqual(result.summary.passed, 3, '应该通过3条规则'); + + console.log('✅ 符合纳入标准测试通过'); + }); + + it('年龄超出范围应该失败', () => { + const data = { + age: 40, // 超出 16-35 范围 + menstrual_cycle: 28, + vas_score: 6 + }; + + const result = executeQC('002', data, INCLUSION_RULES); + + assert.strictEqual(result.overallStatus, 'FAIL', '应该失败'); + assert.strictEqual(result.errors.length, 1, '应该有1个错误'); + assert.strictEqual(result.errors[0].ruleId, 'inc_001', '应该是年龄规则失败'); + assert.strictEqual(result.errors[0].actualValue, 40, '实际值应该是40'); + + console.log('✅ 年龄超出范围测试通过'); + }); + + it('年龄低于下限应该失败', () => { + const data = { + age: 14, // 低于 16 岁 + menstrual_cycle: 28, + vas_score: 6 + }; + + const result = executeQC('003', data, INCLUSION_RULES); + + assert.strictEqual(result.overallStatus, 'FAIL'); + assert.ok(result.errors.some(e => e.ruleId === 'inc_001')); + + console.log('✅ 年龄低于下限测试通过'); + }); + + it('VAS评分不足应该失败', () => { + const data = { + age: 25, + menstrual_cycle: 28, + vas_score: 2 // 低于 4 分 + }; + + const result = executeQC('004', data, INCLUSION_RULES); + + assert.strictEqual(result.overallStatus, 'FAIL'); + assert.ok(result.errors.some(e => e.ruleId === 'inc_004')); + assert.ok(result.errors[0].message.includes('VAS')); + + console.log('✅ VAS评分不足测试通过'); + }); + + it('边界值测试 - 年龄恰好16岁应该通过', () => { + const data = { + age: 16, // 边界值 + menstrual_cycle: 21, // 边界值 + vas_score: 4 // 边界值 + }; + + const result = executeQC('005', data, INCLUSION_RULES); + + assert.strictEqual(result.overallStatus, 'PASS', '边界值应该通过'); + + console.log('✅ 边界值测试通过'); + }); + + it('边界值测试 - 年龄恰好35岁应该通过', () => { + const data = { + age: 35, + menstrual_cycle: 35, + vas_score: 10 + }; + + const result = executeQC('006', data, INCLUSION_RULES); + + assert.strictEqual(result.overallStatus, 'PASS'); + + console.log('✅ 上边界值测试通过'); + }); + + }); + + describe('排除标准检查', () => { + + it('非妊娠状态应该通过', () => { + const data = { + pregnancy_status: 0 + }; + + const result = executeQC('007', data, EXCLUSION_RULES); + + assert.strictEqual(result.overallStatus, 'PASS'); + + console.log('✅ 非妊娠状态测试通过'); + }); + + it('妊娠状态应该失败', () => { + const data = { + pregnancy_status: 1 // 妊娠 + }; + + const result = executeQC('008', data, EXCLUSION_RULES); + + assert.strictEqual(result.overallStatus, 'FAIL'); + assert.ok(result.errors[0].message.includes('妊娠')); + + console.log('✅ 妊娠状态测试通过'); + }); + + it('空值妊娠状态应该通过(允许未填写)', () => { + const data = { + pregnancy_status: null + }; + + const result = executeQC('009', data, EXCLUSION_RULES); + + assert.strictEqual(result.overallStatus, 'PASS'); + + console.log('✅ 空值妊娠状态测试通过'); + }); + + }); + + describe('实验室值范围检查', () => { + + it('正常实验室值应该通过(无警告)', () => { + const data = { + wbc: 6.0, + hemoglobin: 130 + }; + + const result = executeQC('010', data, LAB_RULES); + + assert.strictEqual(result.overallStatus, 'PASS'); + assert.strictEqual(result.warnings.length, 0); + + console.log('✅ 正常实验室值测试通过'); + }); + + it('白细胞偏高应该警告', () => { + const data = { + wbc: 12.0, // 超出 9.5 上限 + hemoglobin: 130 + }; + + const result = executeQC('011', data, LAB_RULES); + + assert.strictEqual(result.overallStatus, 'WARNING'); + assert.strictEqual(result.warnings.length, 1); + assert.strictEqual(result.errors.length, 0, '应该是警告而非错误'); + assert.ok(result.warnings[0].message.includes('白细胞')); + + console.log('✅ 白细胞偏高警告测试通过'); + }); + + it('血红蛋白偏低应该警告', () => { + const data = { + wbc: 6.0, + hemoglobin: 100 // 低于 115 下限 + }; + + const result = executeQC('012', data, LAB_RULES); + + assert.strictEqual(result.overallStatus, 'WARNING'); + assert.ok(result.warnings.some(w => w.field === 'hemoglobin')); + + console.log('✅ 血红蛋白偏低警告测试通过'); + }); + + it('实验室值为空应该通过(允许未检测)', () => { + const data = { + wbc: null, + hemoglobin: '' + }; + + const result = executeQC('013', data, LAB_RULES); + + assert.strictEqual(result.overallStatus, 'PASS'); + + console.log('✅ 实验室值为空测试通过'); + }); + + }); + + describe('综合质控场景', () => { + + it('完美记录 - 全部通过', () => { + const data = { + age: 25, + menstrual_cycle: 28, + vas_score: 7, + pregnancy_status: 0, + wbc: 5.5, + hemoglobin: 125 + }; + + const result = executeQC('014', data, ALL_RULES); + + assert.strictEqual(result.overallStatus, 'PASS'); + assert.strictEqual(result.summary.passed, ALL_RULES.length); + assert.strictEqual(result.errors.length, 0); + assert.strictEqual(result.warnings.length, 0); + + console.log('✅ 完美记录测试通过'); + }); + + it('有错误的记录 - 应该失败', () => { + const data = { + age: 45, // 超龄 + menstrual_cycle: 28, + vas_score: 7, + pregnancy_status: 0, + wbc: 5.5, + hemoglobin: 125 + }; + + const result = executeQC('015', data, ALL_RULES); + + assert.strictEqual(result.overallStatus, 'FAIL'); + assert.ok(result.errors.length > 0); + + console.log('✅ 有错误记录测试通过'); + }); + + it('只有警告的记录 - 状态为WARNING', () => { + const data = { + age: 25, + menstrual_cycle: 28, + vas_score: 7, + pregnancy_status: 0, + wbc: 10.5, // 偏高 + hemoglobin: 125 + }; + + const result = executeQC('016', data, ALL_RULES); + + assert.strictEqual(result.overallStatus, 'WARNING'); + assert.strictEqual(result.errors.length, 0); + assert.ok(result.warnings.length > 0); + + console.log('✅ 只有警告记录测试通过'); + }); + + it('同时有错误和警告 - 状态为FAIL', () => { + const data = { + age: 50, // 超龄 (error) + menstrual_cycle: 28, + vas_score: 7, + pregnancy_status: 0, + wbc: 12.0, // 偏高 (warning) + hemoglobin: 100 // 偏低 (warning) + }; + + const result = executeQC('017', data, ALL_RULES); + + assert.strictEqual(result.overallStatus, 'FAIL', '有错误时应该是FAIL'); + assert.ok(result.errors.length > 0); + assert.ok(result.warnings.length > 0); + + console.log('✅ 同时有错误和警告测试通过'); + }); + + }); + + describe('数据类型处理', () => { + + it('字符串数字应该正确转换', () => { + const data = { + age: '25', // 字符串 + menstrual_cycle: '28', + vas_score: '6' + }; + + const result = executeQC('018', data, INCLUSION_RULES); + + assert.strictEqual(result.overallStatus, 'PASS', '字符串数字应该被正确转换'); + + console.log('✅ 字符串数字转换测试通过'); + }); + + it('空字符串应该被当作空值处理', () => { + const data = { + pregnancy_status: '' + }; + + const result = executeQC('019', data, EXCLUSION_RULES); + + assert.strictEqual(result.overallStatus, 'PASS'); + + console.log('✅ 空字符串处理测试通过'); + }); + + }); + + describe('批量质控', () => { + + it('批量执行多条记录', () => { + const records = [ + { recordId: '001', data: { age: 25, menstrual_cycle: 28, vas_score: 6 } }, + { recordId: '002', data: { age: 40, menstrual_cycle: 28, vas_score: 6 } }, + { recordId: '003', data: { age: 25, menstrual_cycle: 28, vas_score: 2 } }, + ]; + + const results = records.map(r => executeQC(r.recordId, r.data, INCLUSION_RULES)); + + assert.strictEqual(results.length, 3); + assert.strictEqual(results[0].overallStatus, 'PASS'); + assert.strictEqual(results[1].overallStatus, 'FAIL'); // 年龄超标 + assert.strictEqual(results[2].overallStatus, 'FAIL'); // VAS 不足 + + const passCount = results.filter(r => r.overallStatus === 'PASS').length; + const failCount = results.filter(r => r.overallStatus === 'FAIL').length; + + assert.strictEqual(passCount, 1); + assert.strictEqual(failCount, 2); + + console.log('✅ 批量质控测试通过'); + console.log(` - 通过: ${passCount}, 失败: ${failCount}`); + }); + + }); + + describe('JSON Logic 基础验证', () => { + + it('and 操作符', () => { + const logic = { and: [true, true, true] }; + assert.strictEqual(jsonLogic.apply(logic, {}), true); + + const logic2 = { and: [true, false, true] }; + assert.strictEqual(jsonLogic.apply(logic2, {}), false); + + console.log('✅ and 操作符测试通过'); + }); + + it('or 操作符', () => { + const logic = { or: [false, false, true] }; + assert.strictEqual(jsonLogic.apply(logic, {}), true); + + console.log('✅ or 操作符测试通过'); + }); + + it('比较操作符', () => { + const data = { value: 10 }; + + assert.strictEqual(jsonLogic.apply({ '>': [{ var: 'value' }, 5] }, data), true); + assert.strictEqual(jsonLogic.apply({ '>=': [{ var: 'value' }, 10] }, data), true); + assert.strictEqual(jsonLogic.apply({ '<': [{ var: 'value' }, 15] }, data), true); + assert.strictEqual(jsonLogic.apply({ '<=': [{ var: 'value' }, 10] }, data), true); + assert.strictEqual(jsonLogic.apply({ '==': [{ var: 'value' }, 10] }, data), true); + + console.log('✅ 比较操作符测试通过'); + }); + + it('var 操作符获取嵌套值', () => { + const data = { patient: { age: 25 } }; + const logic = { '>': [{ var: 'patient.age' }, 18] }; + + assert.strictEqual(jsonLogic.apply(logic, data), true); + + console.log('✅ var 嵌套值测试通过'); + }); + + }); + +}); + +// 运行测试统计 +describe('测试汇总', () => { + it('所有测试完成', () => { + console.log('\n========================================'); + console.log('🎉 HardRuleEngine 单元测试全部完成!'); + console.log('========================================\n'); + }); +}); diff --git a/backend/src/modules/iit-manager/adapters/RedcapAdapter.ts b/backend/src/modules/iit-manager/adapters/RedcapAdapter.ts index be5b58db..4a22ea4c 100644 --- a/backend/src/modules/iit-manager/adapters/RedcapAdapter.ts +++ b/backend/src/modules/iit-manager/adapters/RedcapAdapter.ts @@ -305,5 +305,587 @@ export class RedcapAdapter { return false; } } + + // ============================================================ + // MVP 新增便捷方法 + // ============================================================ + + /** + * 获取单条记录(便捷方法) + * + * 对于纵向研究项目,同一个 record_id 可能有多个事件(访视), + * 此方法会将所有事件的数据合并到一个对象中。 + * + * @param recordId 记录ID + * @returns 合并后的记录对象,未找到则返回 null + */ + async getRecordById(recordId: string): Promise | null> { + try { + const records = await this.exportRecords({ records: [recordId] }); + if (records.length === 0) { + logger.warn('REDCap: Record not found', { recordId }); + return null; + } + + // 如果只有一条记录,直接返回 + if (records.length === 1) { + return records[0]; + } + + // 纵向研究:合并所有事件的数据 + // 策略:非空值覆盖空值,保留所有事件名称 + const mergedRecord: Record = { + record_id: recordId, + _events: [] as string[], // 记录所有事件名称 + _event_count: records.length, + }; + + for (const record of records) { + const eventName = record.redcap_event_name || record.event_id || 'unknown'; + mergedRecord._events.push(eventName); + + // 遍历该事件的所有字段 + for (const [key, value] of Object.entries(record)) { + // 跳过元数据字段 + if (key === 'redcap_event_name' || key === 'event_id' || key === 'redcap_repeat_instrument' || key === 'redcap_repeat_instance') { + continue; + } + + // 如果当前字段为空,或新值非空,则更新 + const currentValue = mergedRecord[key]; + const isCurrentEmpty = currentValue === undefined || currentValue === null || currentValue === ''; + const isNewEmpty = value === undefined || value === null || value === ''; + + if (isCurrentEmpty && !isNewEmpty) { + mergedRecord[key] = value; + } else if (!isCurrentEmpty && !isNewEmpty && currentValue !== value) { + // 两个值都非空且不同,保留最新的(后面的事件可能是更新的数据) + mergedRecord[key] = value; + } + } + } + + logger.info('REDCap: Merged multi-event record', { + recordId, + eventCount: records.length, + mergedFieldCount: Object.keys(mergedRecord).length, + }); + + return mergedRecord; + } catch (error: any) { + logger.error('REDCap: getRecordById failed', { recordId, error: error.message }); + throw error; + } + } + + /** + * 获取记录的指定字段(用于质控) + * + * 对于纵向研究项目,会合并所有事件的数据 + * + * @param recordId 记录ID + * @param fields 字段列表 + * @returns 合并后包含指定字段的记录对象 + */ + async getRecordFields(recordId: string, fields: string[]): Promise | null> { + try { + const records = await this.exportRecords({ + records: [recordId], + fields: fields + }); + if (records.length === 0) { + return null; + } + + // 如果只有一条记录,直接返回 + if (records.length === 1) { + return records[0]; + } + + // 纵向研究:合并所有事件的指定字段 + const mergedRecord: Record = { + record_id: recordId, + }; + + for (const record of records) { + for (const [key, value] of Object.entries(record)) { + if (key === 'redcap_event_name' || key === 'event_id') { + continue; + } + const currentValue = mergedRecord[key]; + const isCurrentEmpty = currentValue === undefined || currentValue === null || currentValue === ''; + const isNewEmpty = value === undefined || value === null || value === ''; + + if (isCurrentEmpty && !isNewEmpty) { + mergedRecord[key] = value; + } + } + } + + return mergedRecord; + } catch (error: any) { + logger.error('REDCap: getRecordFields failed', { recordId, fields, error: error.message }); + throw error; + } + } + + /** + * 获取所有记录的指定字段(批量质控用) + * + * @param fields 字段列表 + * @returns 记录数组 + */ + async getAllRecordsFields(fields: string[]): Promise[]> { + try { + return await this.exportRecords({ fields }); + } catch (error: any) { + logger.error('REDCap: getAllRecordsFields failed', { fields, error: error.message }); + throw error; + } + } + + /** + * 导出项目信息 + * + * @returns 项目基本信息 + */ + async exportProjectInfo(): Promise<{ + project_id: number; + project_title: string; + creation_time: string; + production_time: string | null; + in_production: boolean; + project_language: string; + purpose: number; + purpose_other: string | null; + record_autonumbering_enabled: boolean; + }> { + const formData = new FormData(); + formData.append('token', this.apiToken); + formData.append('content', 'project'); + formData.append('format', 'json'); + + try { + const response = await this.client.post( + `${this.baseUrl}/api/`, + formData, + { headers: formData.getHeaders() } + ); + + logger.info('REDCap API: exportProjectInfo success', { + projectTitle: response.data.project_title + }); + + return response.data; + } catch (error: any) { + logger.error('REDCap API: exportProjectInfo failed', { error: error.message }); + throw new Error(`REDCap API error: ${error.message}`); + } + } + + /** + * 导出表单/工具列表 + * + * @returns 表单列表 + */ + async exportInstruments(): Promise> { + const formData = new FormData(); + formData.append('token', this.apiToken); + formData.append('content', 'instrument'); + formData.append('format', 'json'); + + try { + const response = await this.client.post( + `${this.baseUrl}/api/`, + formData, + { headers: formData.getHeaders() } + ); + + logger.info('REDCap API: exportInstruments success', { + instrumentCount: response.data.length + }); + + return response.data; + } catch (error: any) { + logger.error('REDCap API: exportInstruments failed', { error: error.message }); + throw new Error(`REDCap API error: ${error.message}`); + } + } + + /** + * 导出审计日志(Logging/Audit Trail) + * + * 用途:获取谁在什么时间录入/修改了数据 + * + * @param options 查询选项 + * @param options.record 指定记录ID(可选) + * @param options.beginTime 开始时间(可选) + * @param options.endTime 结束时间(可选) + * @returns 审计日志数组 + */ + async exportLogging(options?: { + record?: string; + beginTime?: Date; + endTime?: Date; + }): Promise> { + const formData = new FormData(); + formData.append('token', this.apiToken); + formData.append('content', 'log'); + formData.append('format', 'json'); + formData.append('logtype', 'record'); // 只获取记录相关的日志 + + if (options?.record) { + formData.append('record', options.record); + } + if (options?.beginTime) { + formData.append('beginTime', this.formatRedcapDate(options.beginTime)); + } + if (options?.endTime) { + formData.append('endTime', this.formatRedcapDate(options.endTime)); + } + + try { + const startTime = Date.now(); + const response = await this.client.post( + `${this.baseUrl}/api/`, + formData, + { headers: formData.getHeaders() } + ); + + const duration = Date.now() - startTime; + + logger.info('REDCap API: exportLogging success', { + logCount: response.data.length, + duration: `${duration}ms` + }); + + return response.data; + } catch (error: any) { + logger.error('REDCap API: exportLogging failed', { error: error.message }); + throw new Error(`REDCap API error: ${error.message}`); + } + } + + /** + * 获取记录的审计信息(谁、什么时间录入/修改) + * + * @param recordId 记录ID + * @returns 该记录的所有操作日志 + */ + async getRecordAuditInfo(recordId: string): Promise<{ + recordId: string; + createdAt?: string; + createdBy?: string; + lastModifiedAt?: string; + lastModifiedBy?: string; + logs: Array<{ + timestamp: string; + username: string; + action: string; + details?: string; + }>; + }> { + const logs = await this.exportLogging({ record: recordId }); + + // 按时间排序(最早的在前) + logs.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); + + let createdAt: string | undefined; + let createdBy: string | undefined; + let lastModifiedAt: string | undefined; + let lastModifiedBy: string | undefined; + + if (logs.length > 0) { + // 第一条是创建记录 + const firstLog = logs[0]; + createdAt = firstLog.timestamp; + createdBy = firstLog.username; + + // 最后一条是最近修改 + const lastLog = logs[logs.length - 1]; + lastModifiedAt = lastLog.timestamp; + lastModifiedBy = lastLog.username; + } + + return { + recordId, + createdAt, + createdBy, + lastModifiedAt, + lastModifiedBy, + logs: logs.map(log => ({ + timestamp: log.timestamp, + username: log.username, + action: log.action, + details: log.details, + })), + }; + } + + /** + * 解析元数据为字段信息映射(增强版) + * + * 包含:Field Name、Field Label、字段类型、验证规则、选项值等 + * + * @returns 字段名 -> 字段信息的映射 + */ + async getFieldInfoMap(): Promise | null; + required: boolean; + branching: string | null; + }>> { + const metadata = await this.exportMetadata(); + const fieldMap = new Map(); + + for (const field of metadata) { + // 解析选项值 (格式: "1, 男 | 2, 女") + let choicesParsed: Array<{ value: string; label: string }> | null = null; + if (field.select_choices_or_calculations && field.field_type !== 'calc') { + choicesParsed = field.select_choices_or_calculations + .split('|') + .map((choice: string) => { + const parts = choice.trim().split(','); + if (parts.length >= 2) { + return { + value: parts[0].trim(), + label: parts.slice(1).join(',').trim() + }; + } + return null; + }) + .filter((c: any) => c !== null); + } + + fieldMap.set(field.field_name, { + fieldName: field.field_name, + fieldLabel: field.field_label, + fieldType: field.field_type, + formName: field.form_name, + sectionHeader: field.section_header || null, + fieldNote: field.field_note || null, + validation: field.text_validation_type_or_show_slider_number || null, + validationMin: field.text_validation_min || null, + validationMax: field.text_validation_max || null, + choices: field.select_choices_or_calculations || null, + choicesParsed, + required: field.required_field === 'y', + branching: field.branching_logic || null, + }); + } + + logger.info('REDCap: Field info map created', { + fieldCount: fieldMap.size + }); + + return fieldMap; + } + + /** + * 获取带中文标签的记录数据 + * + * 将 Field Name 转换为 Field Label,并保留原始值 + * + * @param recordId 记录ID + * @returns 带中文标签的记录数据 + */ + async getRecordWithLabels(recordId: string): Promise<{ + recordId: string; + data: Array<{ + fieldName: string; + fieldLabel: string; + value: any; + displayValue: string; + formName: string; + }>; + auditInfo?: { + createdAt?: string; + createdBy?: string; + lastModifiedAt?: string; + lastModifiedBy?: string; + }; + } | null> { + // 1. 获取记录数据(合并多事件) + const record = await this.getRecordById(recordId); + if (!record) { + return null; + } + + // 2. 获取字段信息映射 + const fieldMap = await this.getFieldInfoMap(); + + // 3. 获取审计信息 + let auditInfo: any; + try { + const audit = await this.getRecordAuditInfo(recordId); + auditInfo = { + createdAt: audit.createdAt, + createdBy: audit.createdBy, + lastModifiedAt: audit.lastModifiedAt, + lastModifiedBy: audit.lastModifiedBy, + }; + } catch (e) { + // 审计日志可能需要额外权限,忽略错误 + logger.warn('REDCap: Failed to get audit info', { recordId }); + } + + // 4. 转换数据 + const data: Array<{ + fieldName: string; + fieldLabel: string; + value: any; + displayValue: string; + formName: string; + }> = []; + + for (const [fieldName, value] of Object.entries(record)) { + // 跳过内部元数据字段 + if (fieldName.startsWith('_') || fieldName === 'redcap_event_name') { + continue; + } + + const fieldInfo = fieldMap.get(fieldName); + if (!fieldInfo) { + continue; + } + + // 转换选项值为显示值 + let displayValue = String(value ?? ''); + if (fieldInfo.choicesParsed && value !== '' && value !== null) { + const choice = fieldInfo.choicesParsed.find(c => c.value === String(value)); + if (choice) { + displayValue = choice.label; + } + } + + data.push({ + fieldName, + fieldLabel: fieldInfo.fieldLabel, + value, + displayValue, + formName: fieldInfo.formName, + }); + } + + return { + recordId, + data, + auditInfo, + }; + } + + /** + * 获取记录总数(快速统计) + * + * @returns 唯一记录数 + */ + async getRecordCount(): Promise { + const records = await this.exportRecords({ fields: ['record_id'] }); + const uniqueIds = new Set(records.map(r => r.record_id)); + return uniqueIds.size; + } + + /** + * 获取所有记录(合并多事件数据) + * + * 对于纵向研究项目,将每个 record_id 的所有事件数据合并为一条记录 + * + * @param options 可选参数 + * @param options.fields 指定字段列表(可选,默认获取所有字段) + * @returns 合并后的记录数组 + */ + async getAllRecordsMerged(options?: { fields?: string[] }): Promise[]> { + try { + // 导出所有记录(或指定字段) + const rawRecords = options?.fields + ? await this.exportRecords({ fields: options.fields }) + : await this.exportRecords(); + + if (rawRecords.length === 0) { + return []; + } + + // 按 record_id 分组 + const recordGroups = new Map(); + for (const record of rawRecords) { + const recordId = record.record_id; + if (!recordGroups.has(recordId)) { + recordGroups.set(recordId, []); + } + recordGroups.get(recordId)!.push(record); + } + + // 合并每个 record_id 的所有事件 + const mergedRecords: Record[] = []; + + for (const [recordId, events] of recordGroups) { + const merged: Record = { + record_id: recordId, + _event_count: events.length, + _events: events.map(e => e.redcap_event_name || 'unknown'), + }; + + // 合并所有事件的字段 + for (const event of events) { + for (const [key, value] of Object.entries(event)) { + // 跳过元数据字段 + if (key === 'redcap_event_name' || key === 'event_id' || + key === 'redcap_repeat_instrument' || key === 'redcap_repeat_instance') { + continue; + } + + const currentValue = merged[key]; + const isCurrentEmpty = currentValue === undefined || currentValue === null || currentValue === ''; + const isNewEmpty = value === undefined || value === null || value === ''; + + if (isCurrentEmpty && !isNewEmpty) { + merged[key] = value; + } else if (!isCurrentEmpty && !isNewEmpty && currentValue !== value) { + // 保留最新的值 + merged[key] = value; + } + } + } + + mergedRecords.push(merged); + } + + // 按 record_id 排序 + mergedRecords.sort((a, b) => { + const idA = parseInt(a.record_id) || 0; + const idB = parseInt(b.record_id) || 0; + return idA - idB; + }); + + logger.info('REDCap: getAllRecordsMerged success', { + rawRecordCount: rawRecords.length, + mergedRecordCount: mergedRecords.length, + }); + + return mergedRecords; + } catch (error: any) { + logger.error('REDCap: getAllRecordsMerged failed', { error: error.message }); + throw error; + } + } } diff --git a/backend/src/modules/iit-manager/controllers/WebhookController.ts b/backend/src/modules/iit-manager/controllers/WebhookController.ts index b2810a5f..c2aa7f03 100644 --- a/backend/src/modules/iit-manager/controllers/WebhookController.ts +++ b/backend/src/modules/iit-manager/controllers/WebhookController.ts @@ -1,8 +1,8 @@ import { FastifyRequest, FastifyReply } from 'fastify'; import { logger } from '../../../common/logging/index.js'; import { PrismaClient } from '@prisma/client'; -import { RedcapAdapter } from '../adapters/RedcapAdapter.js'; import { jobQueue } from '../../../common/jobs/index.js'; +// ⭐ RedcapAdapter 移到 Worker 中使用,Webhook 只负责接收和入队 /** * REDCap DET Webhook请求体 @@ -136,68 +136,40 @@ export class WebhookController { } // ============================================= - // 2. 防重复检查(幂等性保证) + // 2. 防重复检查 - 改用 pg-boss singletonKey(更优雅) // ============================================= - const isDuplicate = await this.checkDuplicate( - projectConfig.id, - payload.record, - payload.instrument - ); - - if (isDuplicate) { - logger.info('Duplicate webhook detected, skipping', { - project_id: payload.project_id, - record: payload.record, - instrument: payload.instrument - }); - return; - } + // 注意:防重复现在由 pg-boss singletonKey 保证,见下方 jobQueue.push 调用 + // 5分钟内同一 projectId+recordId 的任务不会重复执行 // ============================================= - // 3. 拉取完整记录数据 + // 3. 记录数据拉取移到 Worker 中执行 // ============================================= - const adapter = new RedcapAdapter( - projectConfig.redcapUrl, - projectConfig.redcapApiToken - ); - - const records = await adapter.exportRecords({ - records: [payload.record] - }); - - if (!records || records.length === 0) { - logger.warn('No data returned from REDCap', { - project_id: payload.project_id, - record: payload.record - }); - return; - } - - logger.info('Record data fetched from REDCap', { - project_id: payload.project_id, - record: payload.record, - recordCount: records.length - }); + // ⭐ 优化:Webhook 只负责接收和入队,数据拉取在 Worker 中异步执行 + // 这样可以更快返回响应,避免 REDCap 超时 // ============================================= // 4. 推送到质控队列(pg-boss) // ============================================= + // ⭐ 使用 __singletonKey 实现防抖,5分钟内同一记录不重复执行 + // ⭐ Payload 精简:只传 ID,不传 records 数据(在 Worker 中获取) await jobQueue.push('iit_quality_check', { projectId: projectConfig.id, - redcapProjectId: parseInt(payload.project_id), recordId: payload.record, instrument: payload.instrument, - event: payload.redcap_event_name, - records: records, + eventId: payload.redcap_event_name, triggeredBy: 'webhook', - triggeredAt: new Date().toISOString() + // ⭐ 特殊字段:传递给 PgBossQueue.push() 用于自定义 options + __singletonKey: `qc-${projectConfig.id}-${payload.record}`, + __singletonSeconds: 300, // 5分钟防抖 + __expireInSeconds: 15 * 60, // 15分钟过期 }); - logger.info('Quality check job queued', { + logger.info('Quality check job queued with debounce', { projectId: projectConfig.id, recordId: payload.record, - instrument: payload.instrument + instrument: payload.instrument, + singletonKey: `qc-${projectConfig.id}-${payload.record}`, }); // ============================================= @@ -260,52 +232,18 @@ export class WebhookController { } } - /** - * 防重复检查(幂等性保证) - * - * 场景: - * - REDCap可能重复发送Webhook - * - 网络重试可能导致重复 - * - CRC快速保存多次 - * - * 策略:5分钟内同一record+instrument不重复处理 - * - * @param projectId IIT项目ID - * @param recordId REDCap记录ID - * @param instrument 表单名称 - * @returns 是否重复 - */ - private async checkDuplicate( - projectId: string, - recordId: string, - instrument: string - ): Promise { - const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); - - const existingLog = await this.prisma.iitAuditLog.findFirst({ - where: { - projectId: projectId, - actionType: 'WEBHOOK_RECEIVED', - entityId: recordId, - createdAt: { - gte: fiveMinutesAgo - } - }, - orderBy: { - createdAt: 'desc' - } - }); - - // 如果找到了,还需要检查instrument是否匹配 - if (existingLog) { - const detail = existingLog.details as any; - if (detail?.instrument === instrument) { - return true; - } - } - - return false; - } + // ============================================================ + // ⚠️ 已废弃:checkDuplicate 方法 + // 原因:改用 pg-boss singletonKey 实现防抖,更优雅 + // + // 原策略:查询审计日志,5分钟内同一 record+instrument 不重复处理 + // 新策略:pg-boss singletonKey: `qc-${projectId}-${recordId}`, singletonSeconds: 300 + // + // 优势: + // - 无需额外数据库查询 + // - pg-boss 原生支持,更可靠 + // - 即使审计日志写入失败,防抖仍然有效 + // ============================================================ /** * 健康检查端点 diff --git a/backend/src/modules/iit-manager/engines/HardRuleEngine.ts b/backend/src/modules/iit-manager/engines/HardRuleEngine.ts new file mode 100644 index 00000000..5862b8c4 --- /dev/null +++ b/backend/src/modules/iit-manager/engines/HardRuleEngine.ts @@ -0,0 +1,385 @@ +/** + * HardRuleEngine - 硬规则质控引擎 + * + * 功能: + * - 基于 JSON Logic 执行质控规则 + * - 支持纳入标准、排除标准、变量范围检查 + * - 返回结构化的质控结果 + * + * 设计原则: + * - 零容忍:规则判断是确定性的,不依赖 AI 猜测 + * - 可追溯:每条规则执行结果都有详细记录 + * - 高性能:纯逻辑计算,无 LLM 调用 + */ + +import jsonLogic from 'json-logic-js'; +import { PrismaClient } from '@prisma/client'; +import { logger } from '../../../common/logging/index.js'; + +const prisma = new PrismaClient(); + +// ============================================================ +// 类型定义 +// ============================================================ + +/** + * 质控规则定义 + */ +export interface QCRule { + id: string; + name: string; + field: string | string[]; // 单字段或多字段 + logic: Record; // JSON Logic 表达式 + message: string; + severity: 'error' | 'warning' | 'info'; + category: 'inclusion' | 'exclusion' | 'lab_values' | 'logic_check'; + metadata?: Record; +} + +/** + * 单条规则执行结果 + */ +export interface RuleResult { + ruleId: string; + ruleName: string; + field: string | string[]; + passed: boolean; + message: string; + severity: 'error' | 'warning' | 'info'; + category: string; + actualValue?: any; + expectedCondition?: string; +} + +/** + * 质控执行结果 + */ +export interface QCResult { + recordId: string; + projectId: string; + timestamp: string; + overallStatus: 'PASS' | 'FAIL' | 'WARNING'; + summary: { + totalRules: number; + passed: number; + failed: number; + warnings: number; + }; + results: RuleResult[]; + errors: RuleResult[]; + warnings: RuleResult[]; +} + +// ============================================================ +// HardRuleEngine 实现 +// ============================================================ + +export class HardRuleEngine { + private projectId: string; + private rules: QCRule[] = []; + private fieldMappings: Map = new Map(); + + constructor(projectId: string) { + this.projectId = projectId; + } + + /** + * 初始化引擎(加载规则和字段映射) + * + * @param formName 可选,按表单名过滤规则(用于单表实时质控) + */ + async initialize(formName?: string): Promise { + // 1. 加载质控规则 + const skill = await prisma.iitSkill.findFirst({ + where: { + projectId: this.projectId, + skillType: 'qc_process', + isActive: true + } + }); + + if (!skill) { + throw new Error(`No active QC rules found for project: ${this.projectId}`); + } + + const config = skill.config as any; + let allRules = config.rules || []; + + // ⭐ 如果指定了 formName,则只加载该表单相关的规则 + // 规则通过 formName 或 field 字段来判断所属表单 + if (formName) { + allRules = allRules.filter((rule: any) => { + // 优先使用规则中的 formName 字段 + if (rule.formName) { + return rule.formName === formName; + } + // 如果规则没有 formName,则默认包含(兼容旧规则) + // TODO: 后续可以通过 field_metadata 表来判断字段所属表单 + return true; + }); + } + + this.rules = allRules; + + logger.info('[HardRuleEngine] Rules loaded', { + projectId: this.projectId, + ruleCount: this.rules.length + }); + + // 2. 加载字段映射 + const mappings = await prisma.iitFieldMapping.findMany({ + where: { projectId: this.projectId } + }); + + for (const m of mappings) { + this.fieldMappings.set(m.aliasName, m.actualName); + } + + logger.info('[HardRuleEngine] Field mappings loaded', { + mappingCount: this.fieldMappings.size + }); + } + + /** + * 执行质控检查 + * + * @param recordId 记录ID + * @param data 记录数据(REDCap 格式) + * @returns 质控结果 + */ + execute(recordId: string, data: Record): QCResult { + const startTime = Date.now(); + const results: RuleResult[] = []; + const errors: RuleResult[] = []; + const warnings: RuleResult[] = []; + + // 1. 数据预处理:应用字段映射 + const normalizedData = this.normalizeData(data); + + // 2. 执行每条规则 + for (const rule of this.rules) { + const result = this.executeRule(rule, normalizedData); + results.push(result); + + if (!result.passed) { + if (result.severity === 'error') { + errors.push(result); + } else if (result.severity === 'warning') { + warnings.push(result); + } + } + } + + // 3. 计算整体状态 + let overallStatus: 'PASS' | 'FAIL' | 'WARNING' = 'PASS'; + if (errors.length > 0) { + overallStatus = 'FAIL'; + } else if (warnings.length > 0) { + overallStatus = 'WARNING'; + } + + const duration = Date.now() - startTime; + + logger.info('[HardRuleEngine] QC completed', { + recordId, + overallStatus, + totalRules: this.rules.length, + errors: errors.length, + warnings: warnings.length, + duration: `${duration}ms` + }); + + return { + recordId, + projectId: this.projectId, + timestamp: new Date().toISOString(), + overallStatus, + summary: { + totalRules: this.rules.length, + passed: results.filter(r => r.passed).length, + failed: errors.length, + warnings: warnings.length + }, + results, + errors, + warnings + }; + } + + /** + * 批量质控检查 + * + * @param records 记录数组 + * @returns 质控结果数组 + */ + executeBatch(records: Array<{ recordId: string; data: Record }>): QCResult[] { + return records.map(r => this.execute(r.recordId, r.data)); + } + + /** + * 执行单条规则 + */ + private executeRule(rule: QCRule, data: Record): RuleResult { + try { + // 获取字段值 + const fieldValue = this.getFieldValue(rule.field, data); + + // 执行 JSON Logic + const passed = jsonLogic.apply(rule.logic, data) as boolean; + + return { + ruleId: rule.id, + ruleName: rule.name, + field: rule.field, + passed, + message: passed ? '通过' : rule.message, + severity: rule.severity, + category: rule.category, + actualValue: fieldValue, + expectedCondition: this.describeLogic(rule.logic) + }; + + } catch (error: any) { + logger.error('[HardRuleEngine] Rule execution error', { + ruleId: rule.id, + error: error.message + }); + + return { + ruleId: rule.id, + ruleName: rule.name, + field: rule.field, + passed: false, + message: `规则执行出错: ${error.message}`, + severity: 'error', + category: rule.category + }; + } + } + + /** + * 获取字段值(支持映射) + */ + private getFieldValue(field: string | string[], data: Record): any { + if (Array.isArray(field)) { + return field.map(f => data[f]); + } + + // 先尝试直接获取 + if (data[field] !== undefined) { + return data[field]; + } + + // 再尝试通过映射获取 + const mappedField = this.fieldMappings.get(field); + if (mappedField && data[mappedField] !== undefined) { + return data[mappedField]; + } + + return undefined; + } + + /** + * 数据标准化(应用字段映射,转换类型) + */ + private normalizeData(data: Record): Record { + const normalized: Record = { ...data }; + + // 1. 应用字段映射(反向映射:actualName -> aliasName) + for (const [alias, actual] of this.fieldMappings.entries()) { + if (data[actual] !== undefined && normalized[alias] === undefined) { + normalized[alias] = data[actual]; + } + } + + // 2. 类型转换(字符串数字转数字) + for (const [key, value] of Object.entries(normalized)) { + if (typeof value === 'string' && value !== '' && !isNaN(Number(value))) { + normalized[key] = Number(value); + } + } + + return normalized; + } + + /** + * 描述 JSON Logic 表达式(用于报告) + */ + private describeLogic(logic: Record): string { + const operator = Object.keys(logic)[0]; + const args = logic[operator]; + + switch (operator) { + case '>=': + return `>= ${args[1]}`; + case '<=': + return `<= ${args[1]}`; + case '>': + return `> ${args[1]}`; + case '<': + return `< ${args[1]}`; + case '==': + return `= ${args[1]}`; + case '!=': + return `≠ ${args[1]}`; + case 'and': + return args.map((a: any) => this.describeLogic(a)).join(' 且 '); + case 'or': + return args.map((a: any) => this.describeLogic(a)).join(' 或 '); + case '!!': + return '非空'; + default: + return JSON.stringify(logic); + } + } + + /** + * 获取规则列表 + */ + getRules(): QCRule[] { + return this.rules; + } + + /** + * 获取规则统计 + */ + getRuleStats(): { + total: number; + byCategory: Record; + bySeverity: Record; + } { + const byCategory: Record = {}; + const bySeverity: Record = {}; + + for (const rule of this.rules) { + byCategory[rule.category] = (byCategory[rule.category] || 0) + 1; + bySeverity[rule.severity] = (bySeverity[rule.severity] || 0) + 1; + } + + return { + total: this.rules.length, + byCategory, + bySeverity + }; + } +} + +// ============================================================ +// 工厂函数 +// ============================================================ + +/** + * 创建并初始化 HardRuleEngine + * + * @param projectId 项目ID + * @param formName 可选,按表单名过滤规则(用于单表实时质控) + * 如果不传,则加载所有规则(用于全案批量质控) + */ +export async function createHardRuleEngine( + projectId: string, + formName?: string +): Promise { + const engine = new HardRuleEngine(projectId); + await engine.initialize(formName); + return engine; +} diff --git a/backend/src/modules/iit-manager/engines/SopEngine.ts b/backend/src/modules/iit-manager/engines/SopEngine.ts new file mode 100644 index 00000000..0e0512bd --- /dev/null +++ b/backend/src/modules/iit-manager/engines/SopEngine.ts @@ -0,0 +1,472 @@ +/** + * SopEngine - SOP 执行引擎(简化版) + * + * 功能: + * - 状态机驱动的 SOP 执行 + * - 步骤自动执行 + * - 工具调用集成 + * - 结果汇总生成 + * + * 状态流转: + * PENDING → RUNNING → COMPLETED + * ↘ FAILED + * ↘ SUSPENDED (需人工确认) + * + * MVP 简化: + * - 不实现 SUSPENDED(人工确认)机制 + * - 不实现持久化到数据库 + * - 专注于质控 SOP 闭环 + */ + +import { logger } from '../../../common/logging/index.js'; +import { ToolsService, ToolResult, createToolsService } from '../services/ToolsService.js'; + +// ============================================================ +// 类型定义 +// ============================================================ + +/** + * SOP 任务状态 + */ +export type SopStatus = 'PENDING' | 'RUNNING' | 'COMPLETED' | 'FAILED' | 'SUSPENDED'; + +/** + * SOP 步骤定义 + */ +export interface SopStep { + id: string; + name: string; + description: string; + tool: string; // 工具名称 + params: Record | ((context: SopContext) => Record); // 参数(静态或动态) + onSuccess?: (result: ToolResult, context: SopContext) => void; // 成功回调 + onError?: (error: string, context: SopContext) => void; // 失败回调 + continueOnError?: boolean; // 失败时是否继续 +} + +/** + * SOP 定义 + */ +export interface SopDefinition { + id: string; + name: string; + description: string; + steps: SopStep[]; + generateSummary: (context: SopContext) => string; // 生成汇总 +} + +/** + * SOP 执行上下文 + */ +export interface SopContext { + sopId: string; + projectId: string; + userId: string; + sessionId?: string; + variables: Record; // 执行过程中的变量 + stepResults: Map; // 步骤执行结果 + currentStepIndex: number; + status: SopStatus; + startTime: Date; + endTime?: Date; + error?: string; +} + +/** + * SOP 执行结果 + */ +export interface SopResult { + sopId: string; + sopName: string; + status: SopStatus; + summary: string; + stepCount: number; + successCount: number; + failedCount: number; + duration: number; // 毫秒 + stepResults: Array<{ + stepId: string; + stepName: string; + success: boolean; + error?: string; + }>; +} + +// ============================================================ +// 预定义 SOP:质控检查 +// ============================================================ + +/** + * 单条记录质控 SOP + */ +export const QC_SINGLE_RECORD_SOP: SopDefinition = { + id: 'qc_single_record', + name: '单条记录质控', + description: '对指定患者记录执行完整质控检查', + steps: [ + { + id: 'step_1', + name: '获取项目信息', + description: '获取当前研究项目基本信息', + tool: 'get_project_info', + params: {}, + onSuccess: (result, context) => { + if (result.data) { + context.variables.projectName = result.data.name; + } + } + }, + { + id: 'step_2', + name: '读取患者数据', + description: '从 REDCap 读取患者临床数据', + tool: 'read_clinical_data', + params: (context) => ({ + record_id: context.variables.recordId + }), + onSuccess: (result, context) => { + if (result.data && result.data.length > 0) { + context.variables.patientData = result.data[0]; + context.variables.hasData = true; + } else { + context.variables.hasData = false; + } + }, + continueOnError: false + }, + { + id: 'step_3', + name: '执行质控检查', + description: '验证患者数据是否符合纳入/排除标准和变量范围', + tool: 'run_quality_check', + params: (context) => ({ + record_id: context.variables.recordId + }), + onSuccess: (result, context) => { + if (result.data) { + context.variables.qcResult = result.data; + } + }, + continueOnError: false + } + ], + generateSummary: (context) => { + const qcResult = context.variables.qcResult; + if (!qcResult) { + return `❌ 质控检查失败:无法获取记录 ${context.variables.recordId} 的数据`; + } + + const { overallStatus, summary, errors, warnings } = qcResult; + let statusEmoji = '✅'; + if (overallStatus === 'FAIL') statusEmoji = '❌'; + else if (overallStatus === 'WARNING') statusEmoji = '⚠️'; + + let summaryText = `${statusEmoji} **记录 ${context.variables.recordId} 质控结果:${overallStatus}**\n\n`; + summaryText += `📊 统计:通过 ${summary.passed}/${summary.totalRules} 条规则\n`; + + if (errors && errors.length > 0) { + summaryText += `\n❌ **错误(${errors.length}项):**\n`; + for (const e of errors) { + summaryText += `- ${e.rule}:${e.message}(当前值:${e.actualValue ?? '空'})\n`; + } + } + + if (warnings && warnings.length > 0) { + summaryText += `\n⚠️ **警告(${warnings.length}项):**\n`; + for (const w of warnings) { + summaryText += `- ${w.rule}:${w.message}(当前值:${w.actualValue ?? '空'})\n`; + } + } + + if (overallStatus === 'PASS') { + summaryText += `\n🎉 该记录完全符合入组标准!`; + } + + return summaryText; + } +}; + +/** + * 批量质控 SOP + */ +export const QC_BATCH_SOP: SopDefinition = { + id: 'qc_batch', + name: '批量质控检查', + description: '对所有患者记录执行质控检查并生成汇总报告', + steps: [ + { + id: 'step_1', + name: '获取项目信息', + description: '获取当前研究项目基本信息', + tool: 'get_project_info', + params: {}, + onSuccess: (result, context) => { + if (result.data) { + context.variables.projectName = result.data.name; + } + } + }, + { + id: 'step_2', + name: '执行批量质控', + description: '对所有记录执行质控检查', + tool: 'batch_quality_check', + params: {}, + onSuccess: (result, context) => { + if (result.data) { + context.variables.batchResult = result.data; + } + }, + continueOnError: false + } + ], + generateSummary: (context) => { + const batchResult = context.variables.batchResult; + if (!batchResult) { + return `❌ 批量质控失败:无法获取数据`; + } + + if (batchResult.message) { + return `📭 ${batchResult.message}`; + } + + const { totalRecords, summary, problemRecords } = batchResult; + + let summaryText = `📊 **批量质控报告**\n\n`; + summaryText += `**项目:** ${context.variables.projectName || '未知'}\n`; + summaryText += `**记录总数:** ${totalRecords}\n\n`; + + summaryText += `**质控结果:**\n`; + summaryText += `- ✅ 通过:${summary.pass} (${summary.passRate})\n`; + summaryText += `- ❌ 失败:${summary.fail}\n`; + summaryText += `- ⚠️ 警告:${summary.warning}\n`; + + if (problemRecords && problemRecords.length > 0) { + summaryText += `\n**问题记录(前${problemRecords.length}条):**\n`; + for (const r of problemRecords) { + summaryText += `\n📌 **记录 ${r.recordId}** [${r.status}]\n`; + for (const issue of r.issues || []) { + summaryText += ` - ${issue.rule}:${issue.message}\n`; + } + } + } + + return summaryText; + } +}; + +// ============================================================ +// SopEngine 实现 +// ============================================================ + +export class SopEngine { + private toolsService: ToolsService; + private sops: Map = new Map(); + + constructor(toolsService: ToolsService) { + this.toolsService = toolsService; + + // 注册预定义 SOP + this.registerSop(QC_SINGLE_RECORD_SOP); + this.registerSop(QC_BATCH_SOP); + } + + /** + * 注册 SOP + */ + registerSop(sop: SopDefinition): void { + this.sops.set(sop.id, sop); + logger.debug('[SopEngine] SOP registered', { sopId: sop.id }); + } + + /** + * 获取 SOP + */ + getSop(sopId: string): SopDefinition | undefined { + return this.sops.get(sopId); + } + + /** + * 执行 SOP + */ + async execute( + sopId: string, + userId: string, + variables: Record = {}, + sessionId?: string + ): Promise { + const startTime = Date.now(); + + // 1. 获取 SOP 定义 + const sop = this.sops.get(sopId); + if (!sop) { + return { + sopId, + sopName: 'Unknown', + status: 'FAILED', + summary: `SOP "${sopId}" 不存在`, + stepCount: 0, + successCount: 0, + failedCount: 0, + duration: Date.now() - startTime, + stepResults: [] + }; + } + + logger.info('[SopEngine] Executing SOP', { + sopId, + sopName: sop.name, + userId, + variables + }); + + // 2. 创建执行上下文 + const context: SopContext = { + sopId, + projectId: (this.toolsService as any).projectId, + userId, + sessionId, + variables: { ...variables }, + stepResults: new Map(), + currentStepIndex: 0, + status: 'RUNNING', + startTime: new Date() + }; + + // 3. 逐步执行 + const stepResults: SopResult['stepResults'] = []; + let successCount = 0; + let failedCount = 0; + + for (let i = 0; i < sop.steps.length; i++) { + const step = sop.steps[i]; + context.currentStepIndex = i; + + logger.debug('[SopEngine] Executing step', { + sopId, + stepId: step.id, + stepName: step.name + }); + + // 3.1 计算参数 + const params = typeof step.params === 'function' + ? step.params(context) + : step.params; + + // 3.2 执行工具 + const result = await this.toolsService.execute( + step.tool, + params, + userId, + sessionId + ); + + // 3.3 保存结果 + context.stepResults.set(step.id, result); + + if (result.success) { + successCount++; + stepResults.push({ + stepId: step.id, + stepName: step.name, + success: true + }); + + // 执行成功回调 + if (step.onSuccess) { + step.onSuccess(result, context); + } + } else { + failedCount++; + stepResults.push({ + stepId: step.id, + stepName: step.name, + success: false, + error: result.error + }); + + // 执行失败回调 + if (step.onError) { + step.onError(result.error || 'Unknown error', context); + } + + // 检查是否继续 + if (!step.continueOnError) { + context.status = 'FAILED'; + context.error = result.error; + break; + } + } + } + + // 4. 设置最终状态 + if (context.status !== 'FAILED') { + context.status = 'COMPLETED'; + } + context.endTime = new Date(); + + // 5. 生成汇总 + let summary: string; + try { + summary = sop.generateSummary(context); + } catch (e: any) { + summary = `SOP 执行${context.status === 'COMPLETED' ? '完成' : '失败'}`; + } + + const duration = Date.now() - startTime; + + logger.info('[SopEngine] SOP execution completed', { + sopId, + status: context.status, + stepCount: sop.steps.length, + successCount, + failedCount, + duration: `${duration}ms` + }); + + return { + sopId, + sopName: sop.name, + status: context.status, + summary, + stepCount: sop.steps.length, + successCount, + failedCount, + duration, + stepResults + }; + } + + /** + * 执行质控 SOP(便捷方法) + */ + async runQualityCheck( + recordId: string, + userId: string, + sessionId?: string + ): Promise { + return this.execute('qc_single_record', userId, { recordId }, sessionId); + } + + /** + * 执行批量质控 SOP(便捷方法) + */ + async runBatchQualityCheck( + userId: string, + sessionId?: string + ): Promise { + return this.execute('qc_batch', userId, {}, sessionId); + } +} + +// ============================================================ +// 工厂函数 +// ============================================================ + +/** + * 创建并初始化 SopEngine + */ +export async function createSopEngine(projectId: string): Promise { + const toolsService = await createToolsService(projectId); + return new SopEngine(toolsService); +} diff --git a/backend/src/modules/iit-manager/engines/index.ts b/backend/src/modules/iit-manager/engines/index.ts new file mode 100644 index 00000000..17852a26 --- /dev/null +++ b/backend/src/modules/iit-manager/engines/index.ts @@ -0,0 +1,6 @@ +/** + * IIT Manager Agent 引擎层导出 + */ + +export * from './HardRuleEngine.js'; +export * from './SopEngine.js'; diff --git a/backend/src/modules/iit-manager/index.ts b/backend/src/modules/iit-manager/index.ts index 0582c06e..45e542d6 100644 --- a/backend/src/modules/iit-manager/index.ts +++ b/backend/src/modules/iit-manager/index.ts @@ -16,6 +16,8 @@ import { SyncManager } from './services/SyncManager.js'; import { wechatService } from './services/WechatService.js'; import { PrismaClient } from '@prisma/client'; import { logger } from '../../common/logging/index.js'; +import { createHardRuleEngine, QCResult } from './engines/HardRuleEngine.js'; +import { RedcapAdapter } from './adapters/RedcapAdapter.js'; // 初始化 Prisma Client const prisma = new PrismaClient(); @@ -26,6 +28,8 @@ interface QualityCheckJobData { projectId: string; recordId: string; instrument: string; + eventId?: string; // REDCap event name (纵向项目) + triggeredBy: 'webhook' | 'manual' | 'batch'; // 触发来源 } export * from './routes/index.js'; @@ -82,117 +86,234 @@ export async function initIitManager(): Promise { logger.info('IIT Manager: Worker registered - iit_redcap_poll'); // ============================================= - // 3. 注册Worker:处理质控任务 + 企微推送 + // 3. 注册Worker:处理质控任务 + 双产出(质控日志 + 录入汇总) + // ============================================= + // ⭐ V2.9.1 改造:一次 Worker 执行,两个产出 + // 产出1: iit_qc_logs(仅新增,审计轨迹) + // 产出2: iit_record_summary(upsert,最新状态) // ============================================= jobQueue.process('iit_quality_check', async (job: { id: string; data: QualityCheckJobData }) => { + const startTime = Date.now(); + const { projectId, recordId, instrument, eventId, triggeredBy = 'webhook' } = job.data; + logger.info('🚀 Quality check job started', { jobId: job.id, - projectId: job.data.projectId, - recordId: job.data.recordId, - instrument: job.data.instrument, + projectId, + recordId, + instrument, + triggeredBy, timestamp: new Date().toISOString() }); try { - const { projectId, recordId, instrument } = job.data; + // ============================================= + // Step 1: 获取项目配置 + // ============================================= + const projectConfig = await prisma.iitProject.findUnique({ + where: { id: projectId } + }); - // 1. 获取项目基本信息 - const project = await prisma.$queryRaw>` - SELECT id, name, redcap_project_id - FROM iit_schema.projects - WHERE id = ${projectId} - `; - - if (!project || project.length === 0) { + if (!projectConfig) { logger.warn('⚠️ Project not found', { projectId }); return { status: 'project_not_found' }; } - const projectInfo = project[0]; - - // 🔧 测试模式:直接使用环境变量 - const piUserId = process.env.WECHAT_TEST_USER_ID || 'FengZhiBo'; - const userIdSource = 'env_variable_direct'; - - logger.info('📤 Preparing to send WeChat notification', { - projectId, - projectName: projectInfo.name, - recordId, - piUserId, - source: userIdSource, - envValue: process.env.WECHAT_TEST_USER_ID - }); - - // 2. 执行简单质控检查(目前为占位逻辑,后续接入LLM) - const qualityCheckResult = await performSimpleQualityCheck( - projectId, - recordId, - instrument + // ============================================= + // Step 2: 从 REDCap 获取完整记录数据 + // ============================================= + const adapter = new RedcapAdapter( + projectConfig.redcapUrl, + projectConfig.redcapApiToken ); - - // 3. 构建企业微信通知消息 - const message = buildWechatNotification( - projectInfo.name, - recordId, - instrument, - qualityCheckResult - ); - - // 4. 推送到企业微信 - await wechatService.sendTextMessage(piUserId, message); - - // 5. 记录审计日志(非致命错误) - try { - await prisma.$executeRaw` - INSERT INTO iit_schema.audit_logs (project_id, action_type, entity_id, details) - VALUES ( - ${projectId}, - 'wechat_notification_sent', - ${recordId}, - ${JSON.stringify({ - recordId, - instrument, - piUserId, - userIdSource, - issuesCount: qualityCheckResult.issues.length, - timestamp: new Date().toISOString() - })}::jsonb - ) - `; - logger.info('✅ 审计日志记录成功', { recordId }); - } catch (auditError: any) { - // 审计日志失败不影响主流程 - logger.warn('⚠️ 记录审计日志失败(非致命)', { - error: auditError.message, - recordId - }); + + const recordData = await adapter.getRecordById(recordId); + + if (!recordData) { + logger.warn('⚠️ Record not found in REDCap', { recordId }); + return { status: 'record_not_found' }; } - logger.info('✅ Quality check completed and notification sent', { + // ============================================= + // Step 3: 执行质控(获取单表质控规则) + // ============================================= + const ruleEngine = await createHardRuleEngine(projectId, instrument); + const qcResult = await ruleEngine.execute(recordId, recordData); + + logger.info('📋 Quality check executed', { + recordId, + status: qcResult.overallStatus, + totalRules: qcResult.summary.totalRules, + failed: qcResult.summary.failed, + warnings: qcResult.summary.warnings + }); + + // ============================================= + // Step 4: 产出1 - 存储质控日志(仅新增,审计轨迹) + // ============================================= + const ruleVersion = new Date().toISOString().split('T')[0]; // 简化版本号 + + // 构建 issues 数组(从 errors 和 warnings 合并) + const issues = [ + ...qcResult.errors.map((r: any) => ({ + field: r.field, + rule: r.ruleName, + level: 'RED', + message: r.errorMessage || r.message + })), + ...qcResult.warnings.map((r: any) => ({ + field: r.field, + rule: r.ruleName, + level: 'YELLOW', + message: r.errorMessage || r.message + })) + ]; + + await prisma.iitQcLog.create({ + data: { + projectId, + recordId, + eventId: eventId || null, + qcType: 'form', // 单表质控 + formName: instrument, + status: qcResult.overallStatus, + issues: issues, + rulesEvaluated: qcResult.summary.totalRules, + rulesSkipped: 0, // 单表质控暂无跳过 + rulesPassed: qcResult.summary.passed, + rulesFailed: qcResult.summary.failed, + ruleVersion, + triggeredBy, + } + }); + + logger.info('📝 QC log created (append-only)', { projectId, recordId, status: qcResult.overallStatus }); + + // ============================================= + // Step 5: 产出2 - 更新录入汇总表(upsert) + // ============================================= + const now = new Date(); + + // 获取当前表单完成状态 + const existingSummary = await prisma.iitRecordSummary.findUnique({ + where: { + projectId_recordId: { projectId, recordId } + } + }); + + const existingFormStatus = (existingSummary?.formStatus as Record) || {}; + const updatedFormStatus = { + ...existingFormStatus, + [instrument]: 2 // 2 = 完成(有数据即认为完成) + }; + + // 计算完成率(简化:已录入表单数 / 总表单数) + const completedForms = Object.keys(updatedFormStatus).length; + // TODO: 从 REDCap 获取总表单数 + const totalForms = 10; // 暂时硬编码,后续从项目配置获取 + const completionRate = Math.round((completedForms / totalForms) * 100); + + await prisma.iitRecordSummary.upsert({ + where: { + projectId_recordId: { projectId, recordId } + }, + create: { + projectId, + recordId, + enrolledAt: now, + enrolledBy: null, // TODO: 从 REDCap 审计日志获取 + lastUpdatedAt: now, + lastUpdatedBy: null, + lastFormName: instrument, + formStatus: updatedFormStatus, + totalForms, + completedForms, + completionRate, + latestQcStatus: qcResult.overallStatus, + latestQcAt: now, + updateCount: 1 + }, + update: { + lastUpdatedAt: now, + lastFormName: instrument, + formStatus: updatedFormStatus, + completedForms, + completionRate, + latestQcStatus: qcResult.overallStatus, + latestQcAt: now, + updateCount: { increment: 1 } + } + }); + + logger.info('📊 Record summary updated (upsert)', { projectId, recordId, completionRate }); + + // ============================================= + // Step 6: 主动干预 - 严重问题发企业微信 + // ============================================= + const piUserId = process.env.WECHAT_TEST_USER_ID || 'FengZhiBo'; + + // ⭐ 分级干预:只有 FAIL 才发通知 + if (qcResult.overallStatus === 'FAIL') { + const message = buildQCWechatNotification( + projectConfig.name, + recordId, + instrument, + qcResult + ); + await wechatService.sendTextMessage(piUserId, message); + logger.info('📤 Alert sent for FAIL status', { recordId, piUserId }); + } else { + logger.info('ℹ️ No alert needed', { recordId, status: qcResult.overallStatus }); + } + + // ============================================= + // Step 7: 记录审计日志 + // ============================================= + try { + await prisma.iitAuditLog.create({ + data: { + projectId, + userId: 'system', + actionType: 'QC_COMPLETED', + entityType: 'RECORD', + entityId: recordId, + details: { + instrument, + eventId, + triggeredBy, + overallStatus: qcResult.overallStatus, + summary: qcResult.summary, + durationMs: Date.now() - startTime + }, + traceId: `qc-${job.id}`, + createdAt: now + } + }); + } catch (auditError: any) { + logger.warn('⚠️ 记录审计日志失败(非致命)', { error: auditError.message, recordId }); + } + + logger.info('✅ Quality check completed', { jobId: job.id, projectId, recordId, - piUserId, - userIdSource, - hasIssues: qualityCheckResult.issues.length > 0 + status: qcResult.overallStatus, + durationMs: Date.now() - startTime }); return { status: 'success', - issuesFound: qualityCheckResult.issues.length + qcStatus: qcResult.overallStatus, + errorsFound: qcResult.summary.failed, + warningsFound: qcResult.summary.warnings, + durationMs: Date.now() - startTime }; } catch (error: any) { logger.error('❌ Quality check job failed', { jobId: job.id, - projectId: job.data.projectId, - recordId: job.data.recordId, + projectId, + recordId, error: error.message, - stack: error.stack, - errorDetails: JSON.stringify(error, null, 2) + stack: error.stack }); throw error; } @@ -288,7 +409,74 @@ async function performSimpleQualityCheck( } /** - * 构建企业微信通知消息 + * 构建基于 HardRuleEngine 质控结果的企业微信通知消息 + */ +function buildQCWechatNotification( + projectName: string, + recordId: string, + instrument: string, + qcResult: QCResult +): string { + const time = new Date().toLocaleString('zh-CN'); + const { overallStatus, summary, results } = qcResult; + + let message = `📊 IIT Manager 质控通知\n\n`; + message += `项目:${projectName}\n`; + message += `记录ID:${recordId}\n`; + message += `表单:${instrument}\n`; + message += `时间:${time}\n\n`; + + // 总体状态 + if (overallStatus === 'PASS') { + message += `✅ 质控结果:通过\n`; + message += `📈 规则检查:${summary.passed}/${summary.totalRules} 通过\n\n`; + } else if (overallStatus === 'FAIL') { + message += `❌ 质控结果:未通过\n`; + message += `📈 规则检查:${summary.passed}/${summary.totalRules} 通过\n\n`; + } else { + message += `⚠️ 质控结果:需关注\n`; + message += `📈 规则检查:${summary.passed}/${summary.totalRules} 通过\n\n`; + } + + // 错误项(最多显示5条) + const errors = results.filter(r => !r.passed && r.severity === 'error'); + if (errors.length > 0) { + message += `❌ 错误 (${errors.length}项):\n`; + errors.slice(0, 5).forEach((err, index) => { + message += `${index + 1}. ${err.ruleName}\n`; + message += ` ${err.message}\n`; + }); + if (errors.length > 5) { + message += ` ... 还有 ${errors.length - 5} 项错误\n`; + } + message += `\n`; + } + + // 警告项(最多显示3条) + const warnings = results.filter(r => !r.passed && r.severity === 'warning'); + if (warnings.length > 0) { + message += `⚠️ 警告 (${warnings.length}项):\n`; + warnings.slice(0, 3).forEach((warn, index) => { + message += `${index + 1}. ${warn.ruleName}\n`; + }); + if (warnings.length > 3) { + message += ` ... 还有 ${warnings.length - 3} 项警告\n`; + } + message += `\n`; + } + + // 如果全部通过 + if (errors.length === 0 && warnings.length === 0) { + message += `🎉 所有质控检查均已通过!\n\n`; + } + + message += `💬 回复"查看详情 ${recordId}"获取完整报告`; + + return message; +} + +/** + * 构建企业微信通知消息(旧版,保留兼容) */ function buildWechatNotification( projectName: string, diff --git a/backend/src/modules/iit-manager/services/ChatService.ts b/backend/src/modules/iit-manager/services/ChatService.ts index 4af331ea..0126125e 100644 --- a/backend/src/modules/iit-manager/services/ChatService.ts +++ b/backend/src/modules/iit-manager/services/ChatService.ts @@ -19,7 +19,8 @@ import { logger } from '../../../common/logging/index.js'; import { sessionMemory } from '../agents/SessionMemory.js'; import { PrismaClient } from '@prisma/client'; import { RedcapAdapter } from '../adapters/RedcapAdapter.js'; -import { difyClient } from '../../../common/rag/DifyClient.js'; +import { getVectorSearchService } from '../../../common/rag/index.js'; +import { HardRuleEngine, createHardRuleEngine, QCResult } from '../engines/HardRuleEngine.js'; const prisma = new PrismaClient(); @@ -61,12 +62,24 @@ export class ChatService { toolResult = await this.countRedcapRecords(); } else if (intent === 'project_info') { toolResult = await this.getProjectInfo(); + } else if (intent === 'qc_record' && params?.recordId) { + // ⭐ 单条记录质控 + toolResult = await this.qcSingleRecord(params.recordId); + } else if (intent === 'qc_all') { + // ⭐ 全量质控 + toolResult = await this.qcAllRecords(); + } else if (intent === 'query_enrollment') { + // ⭐ 录入进度查询(优先查询汇总表) + toolResult = await this.queryEnrollmentStatus(); + } else if (intent === 'query_qc_status') { + // ⭐ 质控状态查询(优先查询质控表) + toolResult = await this.queryQcStatus(); } - // 4. 如果需要查询文档(Dify知识库),执行检索 - let difyKnowledge: string = ''; + // 4. 如果需要查询文档(自研RAG知识库),执行检索 + let ragKnowledge: string = ''; if (intent === 'query_protocol') { - difyKnowledge = await this.queryDifyKnowledge(userMessage); + ragKnowledge = await this.queryKnowledgeBase(userMessage); } // 5. 获取上下文(最近2轮对话) @@ -77,17 +90,18 @@ export class ChatService { messageLength: userMessage.length, hasContext: !!context, hasToolResult: !!toolResult, - hasDifyKnowledge: !!difyKnowledge, + hasRagKnowledge: !!ragKnowledge, intent, }); - // 6. 构建LLM消息(包含查询结果 + Dify知识库) + // 6. 构建LLM消息(包含查询结果 + RAG知识库) const messages = this.buildMessagesWithData( userMessage, context, toolResult, - difyKnowledge, - userId + ragKnowledge, + userId, + intent ); // 7. 调用LLM(复用通用能力层) @@ -107,7 +121,7 @@ export class ChatService { userId, intent, hasToolResult: !!toolResult, - hasDifyKnowledge: !!difyKnowledge, + hasRagKnowledge: !!ragKnowledge, duration: `${duration}ms`, inputTokens: response.usage?.promptTokens, outputTokens: response.usage?.completionTokens, @@ -132,19 +146,75 @@ export class ChatService { * 简单意图识别(基于关键词) */ private detectIntent(message: string): { - intent: 'query_record' | 'count_records' | 'project_info' | 'query_protocol' | 'general_chat'; + intent: 'query_record' | 'count_records' | 'project_info' | 'query_protocol' | 'qc_record' | 'qc_all' | 'query_enrollment' | 'query_qc_status' | 'general_chat'; params?: any; } { const lowerMessage = message.toLowerCase(); + // ⭐ 识别录入进度查询(优先级最高 - 从汇总表快速返回) + // "录入情况"、"入组进度"、"数据完整度"等 + if (/(录入|入组|填写|完成).*?(情况|进度|状态|汇总|统计|概况)/.test(message) || + /(数据|表单).*?(完整|进度|填写)/.test(message) || + /录入了多少|入组了多少/.test(message)) { + return { intent: 'query_enrollment' }; + } + + // ⭐ 识别质控状态查询(从质控表快速返回) + // "质控情况"、"质控状态"、"有多少问题"等 + if (/(质控|QC).*?(情况|状态|汇总|统计|概况|结果)/.test(message) || + /(问题|错误|警告).*?(多少|几个|统计)/.test(message) || + /哪些.*?(问题|不合格|失败)/.test(message)) { + return { intent: 'query_qc_status' }; + } + + // ⭐ 识别统计查询(优先级次高 - 避免被质控意图误识别) + // 如果用户问"多少条"、"一共有"、"总共"等统计类问题 + if (/(多少|几个|几条|总共|一共|统计|总数|数量).*?(记录|患者|受试者|人|条|数据)/.test(message) || + /(记录|患者|受试者|人|数据).*?(多少|几个|几条|总共|一共)/.test(message) || + /有多少条|有几条|一共有/.test(message)) { + return { intent: 'count_records' }; + } + + // 识别质控请求 + // 单条记录质控(必须明确提到"质控"等关键词 + 记录ID) + const qcRecordMatch = message.match(/(?:质控|核查|审核).*?(?:ID|记录|患者|受试者).*?(\d+)|(\d+).*?(?:质控|核查|审核)/i); + if (qcRecordMatch) { + const idMatch = message.match(/(\d+)/); + if (idMatch) { + return { + intent: 'qc_record', + params: { recordId: idMatch[1] } + }; + } + } + + // 全量质控(必须明确提到"质控"等关键词) + if (/(质控|核查|审核).*?(全部|所有|批量|全体|所有人|全部记录)/.test(message) || + /(全部|所有|批量).*?(质控|核查|审核)/.test(message)) { + return { intent: 'qc_all' }; + } + // 识别文档查询(研究方案、伦理、CRF等) // 注意:包含"入选"(等同于"纳入") - if (/(研究方案|伦理|知情同意|CRF|病例报告表|纳入|入选|排除|标准|入组标准|治疗方案|试验设计|研究目的|研究流程|观察指标|诊断标准|疾病标准)/.test(message)) { + if (/(研究方案|伦理|知情同意|CRF|病例报告表|纳入|入选|排除|标准|入组标准|治疗方案|试验设计|研究目的|研究流程|观察指标|诊断标准|疾病标准|入排)/.test(message)) { return { intent: 'query_protocol' }; } // 识别记录查询(包含ID号码) - const recordIdMatch = message.match(/(?:ID|记录|患者|受试者).*?(\d+)|(\d+).*?(?:入组|数据|信息|情况)/i); + // 支持多种格式: + // - "查看详情 1" / "查看详情1" + // - "ID 13" / "记录13" + // - "患者 5 的信息" + // - "受试者10入组情况" + const detailMatch = message.match(/查看详情\s*(\d+)/i); + if (detailMatch) { + return { + intent: 'query_record', + params: { recordId: detailMatch[1] } + }; + } + + const recordIdMatch = message.match(/(?:ID|记录|患者|受试者)\s*(\d+)|(\d+)\s*(?:入组|数据|信息|情况|详情)/i); if (recordIdMatch) { return { intent: 'query_record', @@ -152,12 +222,6 @@ export class ChatService { }; } - // 识别统计查询 - if (/(多少|几个|几条|总共|统计).*?(记录|患者|受试者|人)/.test(message) || - /(记录|患者|受试者|人).*?(多少|几个|几条)/.test(message)) { - return { intent: 'count_records' }; - } - // 识别项目信息查询 if (/(项目|研究).*?(名称|信息|情况|怎么样)/.test(message) || /什么项目|哪个项目/.test(message)) { @@ -175,8 +239,9 @@ export class ChatService { userMessage: string, context: string, toolResult: any, - difyKnowledge: string, - userId: string + ragKnowledge: string, + userId: string, + intent: string ): Message[] { const messages: Message[] = []; @@ -190,15 +255,45 @@ export class ChatService { if (toolResult) { messages.push({ role: 'system', - content: `【REDCap数据查询结果】\n${JSON.stringify(toolResult, null, 2)}\n\n请基于以上真实数据回答用户问题。如果数据中包含error字段,说明查询失败,请友好地告知用户。` + content: `【REDCap数据查询结果 - 这是真实数据,必须使用】 +${JSON.stringify(toolResult, null, 2)} + +⚠️ 重要提示: +1. 上述数据是从REDCap系统实时查询的真实数据 +2. 你必须且只能使用上述数据中的字段值回答用户 +3. 如果某个字段为空或不存在,请如实告知"该字段未填写" +4. 绝对禁止编造任何不在上述数据中的信息 +5. 如果数据中包含error字段,说明查询失败,请友好地告知用户` }); + } else { + // 没有查询到数据时的提示 + logger.warn('[ChatService] 没有REDCap查询结果', { userMessage }); } - // 3. 如果有Dify知识库检索结果,注入到System消息 - if (difyKnowledge) { + // 3. 如果有知识库检索结果,注入到System消息 + if (ragKnowledge && ragKnowledge.trim().length > 0) { messages.push({ role: 'system', - content: `【研究方案文档检索结果】\n${difyKnowledge}\n\n请基于以上文档内容回答用户问题。` + content: `【研究方案文档检索结果 - 这是真实文档内容】 +${ragKnowledge} + +⚠️ 重要提示: +1. 上述内容是从知识库检索的真实文档 +2. 你必须且只能使用上述文档内容回答用户 +3. 绝对禁止编造任何不在上述文档中的信息` + }); + } else if (intent === 'query_protocol') { + // 用户询问研究方案,但没有检索到内容 + messages.push({ + role: 'system', + content: `⚠️ 【严重警告】 +用户正在询问研究方案/入排标准相关问题,但知识库未检索到任何相关文档。 + +你必须: +1. 明确告知用户"抱歉,当前项目的研究方案文档尚未上传到知识库" +2. 建议用户联系管理员上传相关文档 +3. 绝对禁止编造任何入排标准、研究方案、变量范围等信息 +4. 如果你编造信息,将对临床研究造成严重后果` }); } @@ -225,25 +320,53 @@ export class ChatService { private getSystemPromptWithData(userId: string): string { return `你是IIT Manager智能助手,负责帮助PI管理临床研究项目。 -【重要原则】 -⚠️ 你**必须基于系统提供的数据和文档**回答问题,**绝对不能编造信息**。 -⚠️ 如果系统提供了查询结果或文档内容,请使用这些真实信息;如果没有提供,明确告知用户。 +【最高优先级规则 - 绝对禁止编造信息】 +你绝对不能编造任何信息,尤其是: +- 入组标准、排除标准 +- 研究方案内容 +- 变量范围、检验值范围 +- 患者数据 +- 任何未经系统提供的信息 + +如果系统没有提供相关数据或文档,你必须明确回复: +"抱歉,当前没有相关信息。请联系管理员上传文档或确认数据是否已录入。" + +编造临床研究信息可能导致严重医疗事故! 【你的能力】 -✅ 回答研究进展问题(基于REDCap实时数据) -✅ 查询患者记录详情 -✅ 统计入组人数 -✅ 提供项目信息 -✅ 解答研究方案相关问题(基于知识库文档) +- 回答研究进展问题(仅基于REDCap实时数据) +- 查询患者记录详情(仅基于REDCap实时数据) +- 统计入组人数(仅基于REDCap实时数据) +- 解答研究方案问题(仅基于知识库检索到的文档) +- 数据质控(仅基于系统执行的规则检查结果) + +【质控结果解读】 +- PASS:记录完全符合所有规则 +- FAIL:记录存在严重问题,可能影响入组资格 +- WARNING:记录存在警告,需人工复核 + +【回复格式要求 - 非常重要】 +你的回复将直接显示在企业微信中,企业微信不支持Markdown格式。 +禁止使用以下格式: +- 不要使用 **粗体** 或 *斜体* +- 不要使用 # 标题 +- 不要使用 [链接](url) 格式 +- 不要使用 \`代码\` 格式 + +请使用纯文本回复,可以使用以下方式强调: +- 使用【】括号强调重要信息 +- 使用数字列表 1. 2. 3. +- 使用破折号 - 做列表 +- 使用空行分隔段落 【回复原则】 -1. **基于事实**:只使用系统提供的数据和文档,不编造 -2. **简洁专业**:控制在150字以内 -3. **友好礼貌**:使用"您"称呼PI -4. **引导行动**:如需更多详细信息,建议查看完整文档或登录REDCap系统 +1. 只用真实数据:只使用系统提供的REDCap数据或文档检索结果 +2. 诚实告知不足:没有数据就说"暂无相关信息" +3. 简洁专业:控制在200字以内 +4. 引导行动:建议登录REDCap或联系管理员获取更多信息 【当前用户】 -- 企业微信UserID: ${userId} +企业微信UserID: ${userId} 现在请开始对话。`; } @@ -393,17 +516,378 @@ export class ChatService { } } + // ============================================================ + // 质控功能方法 + // ============================================================ + /** - * 查询Dify知识库(研究方案文档) + * 单条记录质控 + * + * ⭐ V2.9.1 优化:优先查询质控表(IitQcLog),而不是每次都执行质控 + * 1. 先查质控表,如果有最近的结果,直接返回 + * 2. 如果没有,则执行实时质控 */ - private async queryDifyKnowledge(query: string): Promise { + private async qcSingleRecord(recordId: string): Promise { try { - // 1. 获取项目配置(包含difyDatasetId) + // 1. 获取项目配置 + const project = await prisma.iitProject.findFirst({ + where: { status: 'active' }, + select: { + id: true, + name: true, + redcapUrl: true, + redcapApiToken: true, + } + }); + + if (!project) { + return { error: '未找到活跃项目配置' }; + } + + // ⭐ 2. 优先查询质控表 + const latestQcLog = await prisma.iitQcLog.findFirst({ + where: { + projectId: project.id, + recordId: recordId + }, + orderBy: { createdAt: 'desc' } + }); + + // 如果有最近的质控结果(1小时内),直接返回 + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); + if (latestQcLog && latestQcLog.createdAt > oneHourAgo) { + logger.info('[ChatService] 返回缓存的质控结果', { + recordId, + cachedAt: latestQcLog.createdAt + }); + + return { + projectName: project.name, + recordId: recordId, + source: 'cached', // 标记来源 + qcResult: { + overallStatus: latestQcLog.status, + summary: { + totalRules: latestQcLog.rulesEvaluated, + passed: latestQcLog.rulesPassed, + failed: latestQcLog.rulesFailed, + warnings: 0 // 暂不区分 + }, + errors: (latestQcLog.issues as any[]).filter((i: any) => i.level === 'RED'), + warnings: (latestQcLog.issues as any[]).filter((i: any) => i.level === 'YELLOW'), + checkedAt: latestQcLog.createdAt + } + }; + } + + // 3. 没有缓存,执行实时质控 + const redcap = new RedcapAdapter( + project.redcapUrl, + project.redcapApiToken + ); + + const record = await redcap.getRecordById(recordId); + if (!record) { + return { + error: `未找到记录 ID: ${recordId}`, + projectName: project.name + }; + } + + // 4. 创建质控引擎并执行检查 + const engine = await createHardRuleEngine(project.id); + const qcResult = engine.execute(recordId, record); + + // 5. 格式化返回结果 + return { + projectName: project.name, + recordId: recordId, + source: 'realtime', // 标记来源 + qcResult: { + overallStatus: qcResult.overallStatus, + summary: qcResult.summary, + errors: qcResult.errors.map((e: any) => ({ + rule: e.ruleName, + field: e.field, + message: e.message, + actualValue: e.actualValue + })), + warnings: qcResult.warnings.map((w: any) => ({ + rule: w.ruleName, + field: w.field, + message: w.message, + actualValue: w.actualValue + })) + } + }; + + } catch (error: any) { + logger.error('[ChatService] 质控执行失败', { + recordId, + error: error.message + }); + return { + error: `质控执行失败: ${error.message}` + }; + } + } + + /** + * 全量记录质控 + */ + private async qcAllRecords(): Promise { + try { + // 1. 获取项目配置 + const project = await prisma.iitProject.findFirst({ + where: { status: 'active' }, + select: { + id: true, + name: true, + redcapUrl: true, + redcapApiToken: true, + } + }); + + if (!project) { + return { error: '未找到活跃项目配置' }; + } + + // 2. 获取所有记录 + const redcap = new RedcapAdapter( + project.redcapUrl, + project.redcapApiToken + ); + + const allRecords = await redcap.exportRecords({}); + if (allRecords.length === 0) { + return { + projectName: project.name, + message: '项目中暂无记录' + }; + } + + // 3. 去重(纵向研究可能同一 record_id 有多行) + const recordMap = new Map>(); + for (const r of allRecords) { + // 保留第一条(或合并多事件数据) + if (!recordMap.has(r.record_id)) { + recordMap.set(r.record_id, r); + } + } + + // 4. 创建质控引擎并批量执行 + const engine = await createHardRuleEngine(project.id); + const records = Array.from(recordMap.entries()).map(([id, data]) => ({ + recordId: id, + data + })); + + const qcResults = engine.executeBatch(records); + + // 5. 统计汇总 + const passCount = qcResults.filter(r => r.overallStatus === 'PASS').length; + const failCount = qcResults.filter(r => r.overallStatus === 'FAIL').length; + const warningCount = qcResults.filter(r => r.overallStatus === 'WARNING').length; + + // 6. 找出问题记录(最多显示5条) + const problemRecords = qcResults + .filter(r => r.overallStatus !== 'PASS') + .slice(0, 5) + .map(r => ({ + recordId: r.recordId, + status: r.overallStatus, + errorCount: r.errors.length, + warningCount: r.warnings.length, + topIssues: [...r.errors, ...r.warnings].slice(0, 3).map(i => ({ + rule: i.ruleName, + message: i.message + })) + })); + + return { + projectName: project.name, + totalRecords: records.length, + summary: { + pass: passCount, + fail: failCount, + warning: warningCount, + passRate: `${((passCount / records.length) * 100).toFixed(1)}%` + }, + problemRecords + }; + + } catch (error: any) { + logger.error('[ChatService] 批量质控执行失败', { + error: error.message + }); + return { + error: `批量质控执行失败: ${error.message}` + }; + } + } + + /** + * 查询录入进度(优先查询汇总表) + * + * ⭐ V2.9.1 新增:从 iit_record_summary 表快速返回 + */ + private async queryEnrollmentStatus(): Promise { + try { + const project = await prisma.iitProject.findFirst({ + where: { status: 'active' }, + select: { id: true, name: true } + }); + + if (!project) { + return { error: '未找到活跃项目配置' }; + } + + // ⭐ 优先查询汇总表(比实时查询 REDCap 快很多) + const summaries = await prisma.iitRecordSummary.findMany({ + where: { projectId: project.id }, + orderBy: { lastUpdatedAt: 'desc' } + }); + + if (summaries.length === 0) { + // 如果汇总表没有数据,返回提示 + return { + projectName: project.name, + source: 'summary_table', + message: '暂无录入汇总数据。请先进行一次全量数据汇总,或等待数据录入后自动生成。', + totalRecords: 0 + }; + } + + // 统计计算 + const totalRecords = summaries.length; + const avgCompletionRate = summaries.reduce((acc, s) => acc + (s.completionRate || 0), 0) / totalRecords; + const recentEnrollments = summaries.filter(s => { + if (!s.enrolledAt) return false; + const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + return s.enrolledAt > oneWeekAgo; + }).length; + + // 分类统计 + const byQcStatus = { + pass: summaries.filter(s => s.latestQcStatus === 'PASS').length, + fail: summaries.filter(s => s.latestQcStatus === 'FAIL').length, + warning: summaries.filter(s => s.latestQcStatus === 'WARNING').length, + pending: summaries.filter(s => !s.latestQcStatus).length + }; + + // 最近录入的记录(最多5条) + const recentRecords = summaries.slice(0, 5).map(s => ({ + recordId: s.recordId, + enrolledAt: s.enrolledAt, + lastUpdatedAt: s.lastUpdatedAt, + completionRate: s.completionRate, + qcStatus: s.latestQcStatus || '待质控' + })); + + return { + projectName: project.name, + source: 'summary_table', // 标记数据来源 + totalRecords, + avgCompletionRate: `${avgCompletionRate.toFixed(1)}%`, + recentEnrollments, // 近一周入组数 + byQcStatus, + recentRecords + }; + + } catch (error: any) { + logger.error('[ChatService] 录入进度查询失败', { error: error.message }); + return { error: `查询失败: ${error.message}` }; + } + } + + /** + * 查询质控状态(优先查询质控表) + * + * ⭐ V2.9.1 新增:从 iit_qc_logs 和 iit_qc_project_stats 表快速返回 + */ + private async queryQcStatus(): Promise { + try { + const project = await prisma.iitProject.findFirst({ + where: { status: 'active' }, + select: { id: true, name: true } + }); + + if (!project) { + return { error: '未找到活跃项目配置' }; + } + + // ⭐ 优先查询项目级统计表 + const projectStats = await prisma.iitQcProjectStats.findUnique({ + where: { projectId: project.id } + }); + + // 查询最近的质控日志 + const recentLogs = await prisma.iitQcLog.findMany({ + where: { projectId: project.id }, + orderBy: { createdAt: 'desc' }, + take: 10 + }); + + if (!projectStats && recentLogs.length === 0) { + return { + projectName: project.name, + source: 'qc_tables', + message: '暂无质控数据。请先进行一次全量质控,或等待数据录入后自动触发质控。', + totalChecks: 0 + }; + } + + // 获取有问题的记录 + const problemRecords = await prisma.iitQcLog.findMany({ + where: { + projectId: project.id, + status: 'FAIL' + }, + orderBy: { createdAt: 'desc' }, + take: 5, + distinct: ['recordId'] + }); + + return { + projectName: project.name, + source: 'qc_tables', // 标记数据来源 + stats: projectStats ? { + totalRecords: projectStats.totalRecords, + passedRecords: projectStats.passedRecords, + failedRecords: projectStats.failedRecords, + warningRecords: projectStats.warningRecords, + passRate: projectStats.totalRecords > 0 + ? `${((projectStats.passedRecords / projectStats.totalRecords) * 100).toFixed(1)}%` + : 'N/A', + lastUpdated: projectStats.updatedAt + } : null, + recentChecks: recentLogs.length, + problemRecords: problemRecords.map(log => ({ + recordId: log.recordId, + status: log.status, + issueCount: (log.issues as any[]).length, + checkedAt: log.createdAt, + topIssues: (log.issues as any[]).slice(0, 2) + })) + }; + + } catch (error: any) { + logger.error('[ChatService] 质控状态查询失败', { error: error.message }); + return { error: `查询失败: ${error.message}` }; + } + } + + /** + * 查询知识库(研究方案文档)- 使用自研 RAG 引擎 + */ + private async queryKnowledgeBase(query: string): Promise { + try { + // 1. 获取项目配置(包含知识库ID) const project = await prisma.iitProject.findFirst({ where: { status: 'active' }, select: { name: true, - difyDatasetId: true, + knowledgeBaseId: true, // 关联 ekb_knowledge_bases } }); @@ -412,49 +896,52 @@ export class ChatService { return ''; } - if (!project.difyDatasetId) { - logger.warn('[ChatService] 项目未配置Dify知识库'); + const kbId = project.knowledgeBaseId; + if (!kbId) { + logger.warn('[ChatService] 项目未配置知识库', { projectName: project.name }); return ''; } - // 2. 调用Dify检索API - const retrievalResult = await difyClient.retrieveKnowledge( - project.difyDatasetId, - query, - { - retrieval_model: { - search_method: 'semantic_search', - top_k: 5, // 检索Top 5相关片段 - } - } - ); + logger.info('[ChatService] 使用知识库查询', { projectName: project.name, kbId }); - // 3. 格式化检索结果 - if (!retrievalResult.records || retrievalResult.records.length === 0) { - logger.info('[ChatService] Dify未检索到相关文档'); + // 2. 使用自研 RAG 引擎检索 + const searchService = getVectorSearchService(prisma); + + // 向量检索 + const results = await searchService.vectorSearch(query, { + topK: 5, + minScore: 0.3, + filter: { kbId } + }); + + // 3. 检查结果 + if (!results || results.length === 0) { + logger.info('[ChatService] 知识库未检索到相关文档', { query, kbId }); return ''; } + // 4. 格式化检索结果 let formattedKnowledge = ''; - retrievalResult.records.forEach((record, index) => { - const score = (record.score * 100).toFixed(1); - const documentName = record.segment?.document?.name || '未知文档'; - const content = record.segment?.content || ''; + results.forEach((result: any, index: number) => { + const score = ((result.score || 0) * 100).toFixed(1); + const documentName = result.metadata?.filename || result.metadata?.documentName || '未知文档'; + const content = result.content || ''; formattedKnowledge += `\n[文档${index + 1}] ${documentName} (相关度: ${score}%)\n`; formattedKnowledge += `${content}\n`; formattedKnowledge += `---\n`; }); - logger.info('[ChatService] Dify检索成功', { + logger.info('[ChatService] 知识库检索成功', { query, - recordCount: retrievalResult.records.length, + resultCount: results.length, projectName: project.name, + kbId }); return formattedKnowledge; } catch (error: any) { - logger.error('[ChatService] Dify查询失败', { + logger.error('[ChatService] 知识库查询失败', { query, error: error.message }); diff --git a/backend/src/modules/iit-manager/services/QcService.ts b/backend/src/modules/iit-manager/services/QcService.ts new file mode 100644 index 00000000..1fe3fad9 --- /dev/null +++ b/backend/src/modules/iit-manager/services/QcService.ts @@ -0,0 +1,288 @@ +/** + * QcService - 质控数据查询服务 + * + * 职责: + * - 查询质控日志(IitQcLog) + * - 查询录入汇总(IitRecordSummary) + * - 查询项目统计(IitQcProjectStats) + * + * 用途: + * - ChatService 使用此服务回答用户关于质控状态的问题 + * - 仪表盘使用此服务展示质控概览 + */ + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +// ============================================================ +// 类型定义 +// ============================================================ + +export interface QcLogQuery { + projectId: string; + recordId?: string; + status?: 'PASS' | 'FAIL' | 'WARNING'; + qcType?: 'form' | 'holistic'; + formName?: string; + startDate?: Date; + endDate?: Date; + limit?: number; + offset?: number; +} + +export interface QcLogSummary { + projectId: string; + recordId: string; + latestStatus: string; + totalChecks: number; + passCount: number; + failCount: number; + warningCount: number; + lastCheckedAt: Date | null; + issues: any[]; +} + +export interface ProjectQcStats { + projectId: string; + totalRecords: number; + passedRecords: number; + failedRecords: number; + warningRecords: number; + avgCompletionRate: number; + lastUpdated: Date | null; +} + +// ============================================================ +// QcService 实现 +// ============================================================ + +class QcService { + /** + * 查询质控日志 + * 支持按记录ID、状态、日期范围等条件过滤 + */ + async queryQcLogs(query: QcLogQuery) { + const { + projectId, + recordId, + status, + qcType, + formName, + startDate, + endDate, + limit = 50, + offset = 0 + } = query; + + const where: any = { projectId }; + + if (recordId) { + where.recordId = recordId; + } + if (status) { + where.status = status; + } + if (qcType) { + where.qcType = qcType; + } + if (formName) { + where.formName = formName; + } + if (startDate || endDate) { + where.createdAt = {}; + if (startDate) { + where.createdAt.gte = startDate; + } + if (endDate) { + where.createdAt.lte = endDate; + } + } + + const [logs, total] = await Promise.all([ + prisma.iitQcLog.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: limit, + skip: offset + }), + prisma.iitQcLog.count({ where }) + ]); + + return { logs, total, limit, offset }; + } + + /** + * 获取某条记录的质控历史摘要 + * 用于 AI 回答"记录 X 的质控情况" + */ + async getRecordQcSummary(projectId: string, recordId: string): Promise { + const logs = await prisma.iitQcLog.findMany({ + where: { projectId, recordId }, + orderBy: { createdAt: 'desc' } + }); + + if (logs.length === 0) { + return null; + } + + const latestLog = logs[0]; + + return { + projectId, + recordId, + latestStatus: latestLog.status, + totalChecks: logs.length, + passCount: logs.filter(l => l.status === 'PASS').length, + failCount: logs.filter(l => l.status === 'FAIL').length, + warningCount: logs.filter(l => l.status === 'WARNING').length, + lastCheckedAt: latestLog.createdAt, + issues: latestLog.issues as any[] + }; + } + + /** + * 获取某条记录的录入汇总 + * 用于 AI 回答"记录 X 的录入进度" + */ + async getRecordSummary(projectId: string, recordId: string) { + return prisma.iitRecordSummary.findUnique({ + where: { + projectId_recordId: { projectId, recordId } + } + }); + } + + /** + * 获取项目所有记录的汇总列表 + * 用于 AI 回答"当前项目录入进度" + */ + async getProjectRecordSummaries( + projectId: string, + options: { limit?: number; orderBy?: 'enrolledAt' | 'lastUpdatedAt' | 'completionRate' } = {} + ) { + const { limit = 50, orderBy = 'lastUpdatedAt' } = options; + + return prisma.iitRecordSummary.findMany({ + where: { projectId }, + orderBy: { [orderBy]: 'desc' }, + take: limit + }); + } + + /** + * 获取项目级别的质控统计 + * 用于仪表盘和 AI 回答"项目整体质控情况" + */ + async getProjectStats(projectId: string): Promise { + // 先尝试从缓存表获取 + const cachedStats = await prisma.iitQcProjectStats.findUnique({ + where: { projectId } + }); + + if (cachedStats) { + return { + projectId, + totalRecords: cachedStats.totalRecords, + passedRecords: cachedStats.passedRecords, + failedRecords: cachedStats.failedRecords, + warningRecords: cachedStats.warningRecords, + avgCompletionRate: cachedStats.avgCompletionRate, + lastUpdated: cachedStats.updatedAt + }; + } + + // 如果没有缓存,实时计算 + const summaries = await prisma.iitRecordSummary.findMany({ + where: { projectId } + }); + + if (summaries.length === 0) { + return null; + } + + const stats = { + projectId, + totalRecords: summaries.length, + passedRecords: summaries.filter(s => s.latestQcStatus === 'PASS').length, + failedRecords: summaries.filter(s => s.latestQcStatus === 'FAIL').length, + warningRecords: summaries.filter(s => s.latestQcStatus === 'WARNING').length, + avgCompletionRate: summaries.reduce((acc, s) => acc + (s.completionRate || 0), 0) / summaries.length, + lastUpdated: new Date() + }; + + // 更新缓存表 + await prisma.iitQcProjectStats.upsert({ + where: { projectId }, + create: { + projectId, + totalRecords: stats.totalRecords, + passedRecords: stats.passedRecords, + failedRecords: stats.failedRecords, + warningRecords: stats.warningRecords, + avgCompletionRate: stats.avgCompletionRate + }, + update: { + totalRecords: stats.totalRecords, + passedRecords: stats.passedRecords, + failedRecords: stats.failedRecords, + warningRecords: stats.warningRecords, + avgCompletionRate: stats.avgCompletionRate + } + }); + + return stats; + } + + /** + * 获取有问题的记录列表 + * 用于 AI 回答"哪些记录有问题" + */ + async getProblematicRecords( + projectId: string, + options: { limit?: number; minSeverity?: 'WARNING' | 'FAIL' } = {} + ) { + const { limit = 20, minSeverity = 'FAIL' } = options; + + const statusFilter = minSeverity === 'FAIL' + ? ['FAIL'] + : ['FAIL', 'WARNING']; + + return prisma.iitRecordSummary.findMany({ + where: { + projectId, + latestQcStatus: { in: statusFilter } + }, + orderBy: { latestQcAt: 'desc' }, + take: limit + }); + } + + /** + * 获取质控趋势数据 + * 用于分析质量改善情况 + */ + async getQcTrend(projectId: string, recordId: string, days: number = 30) { + const startDate = new Date(); + startDate.setDate(startDate.getDate() - days); + + return prisma.iitQcLog.findMany({ + where: { + projectId, + recordId, + createdAt: { gte: startDate } + }, + orderBy: { createdAt: 'asc' }, + select: { + createdAt: true, + status: true, + rulesFailed: true, + formName: true + } + }); + } +} + +// 导出单例 +export const qcService = new QcService(); +export default qcService; diff --git a/backend/src/modules/iit-manager/services/ToolsService.ts b/backend/src/modules/iit-manager/services/ToolsService.ts new file mode 100644 index 00000000..918293ae --- /dev/null +++ b/backend/src/modules/iit-manager/services/ToolsService.ts @@ -0,0 +1,723 @@ +/** + * ToolsService - Agent 工具服务 + * + * 功能: + * - 工具注册与管理 + * - 工具执行与结果格式化 + * - 字段映射集成 + * - LLM 工具描述生成 + * + * 设计原则: + * - 统一接口:所有工具遵循相同的调用规范 + * - 安全隔离:读写工具分离,支持权限控制 + * - 可追溯:所有工具调用记录 Trace + */ + +import { PrismaClient } from '@prisma/client'; +import { logger } from '../../../common/logging/index.js'; +import { RedcapAdapter } from '../adapters/RedcapAdapter.js'; +import { createHardRuleEngine, QCResult } from '../engines/HardRuleEngine.js'; + +const prisma = new PrismaClient(); + +// ============================================================ +// 类型定义 +// ============================================================ + +/** + * 工具参数定义 + */ +export interface ToolParameter { + name: string; + type: 'string' | 'number' | 'boolean' | 'array' | 'object'; + description: string; + required: boolean; + default?: any; + enum?: string[]; // 可选枚举值 +} + +/** + * 工具定义 + */ +export interface ToolDefinition { + name: string; + description: string; + category: 'read' | 'write' | 'compute'; + parameters: ToolParameter[]; + execute: (params: Record, context: ToolContext) => Promise; +} + +/** + * 工具执行上下文 + */ +export interface ToolContext { + projectId: string; + userId: string; + sessionId?: string; + fieldMappings: Map; // alias -> actual + redcapAdapter?: RedcapAdapter; +} + +/** + * 工具执行结果 + */ +export interface ToolResult { + success: boolean; + data?: any; + error?: string; + metadata?: { + executionTime: number; + recordCount?: number; + source?: string; + }; +} + +/** + * LLM 工具描述格式(OpenAI Function Calling 兼容) + */ +export interface LLMToolDescription { + type: 'function'; + function: { + name: string; + description: string; + parameters: { + type: 'object'; + properties: Record; + required: string[]; + }; + }; +} + +// ============================================================ +// ToolsService 实现 +// ============================================================ + +export class ToolsService { + private tools: Map = new Map(); + private projectId: string; + private fieldMappings: Map = new Map(); + private reverseMappings: Map = new Map(); // actual -> alias + private redcapAdapter?: RedcapAdapter; + + constructor(projectId: string) { + this.projectId = projectId; + } + + /** + * 初始化服务(加载字段映射、创建 REDCap 适配器) + */ + async initialize(): Promise { + // 1. 加载字段映射 + const mappings = await prisma.iitFieldMapping.findMany({ + where: { projectId: this.projectId } + }); + + for (const m of mappings) { + this.fieldMappings.set(m.aliasName, m.actualName); + this.reverseMappings.set(m.actualName, m.aliasName); + } + + logger.info('[ToolsService] Field mappings loaded', { + projectId: this.projectId, + count: this.fieldMappings.size + }); + + // 2. 获取项目配置并创建 REDCap 适配器 + const project = await prisma.iitProject.findUnique({ + where: { id: this.projectId }, + select: { + redcapUrl: true, + redcapApiToken: true + } + }); + + if (project?.redcapUrl && project?.redcapApiToken) { + this.redcapAdapter = new RedcapAdapter( + project.redcapUrl, + project.redcapApiToken + ); + logger.info('[ToolsService] REDCap adapter created'); + } + + // 3. 注册内置工具 + this.registerBuiltinTools(); + + logger.info('[ToolsService] Initialized', { + projectId: this.projectId, + toolCount: this.tools.size + }); + } + + /** + * 注册工具 + */ + registerTool(tool: ToolDefinition): void { + if (this.tools.has(tool.name)) { + logger.warn('[ToolsService] Tool already registered, overwriting', { + name: tool.name + }); + } + this.tools.set(tool.name, tool); + logger.debug('[ToolsService] Tool registered', { name: tool.name }); + } + + /** + * 获取工具 + */ + getTool(name: string): ToolDefinition | undefined { + return this.tools.get(name); + } + + /** + * 获取所有工具 + */ + getAllTools(): ToolDefinition[] { + return Array.from(this.tools.values()); + } + + /** + * 获取只读工具(用于 ReAct 模式) + */ + getReadOnlyTools(): ToolDefinition[] { + return this.getAllTools().filter(t => t.category === 'read' || t.category === 'compute'); + } + + /** + * 执行工具 + */ + async execute( + toolName: string, + params: Record, + userId: string, + sessionId?: string + ): Promise { + const startTime = Date.now(); + + // 1. 获取工具定义 + const tool = this.tools.get(toolName); + if (!tool) { + logger.error('[ToolsService] Tool not found', { toolName }); + return { + success: false, + error: `工具 "${toolName}" 不存在` + }; + } + + // 2. 参数验证 + const validationError = this.validateParams(tool, params); + if (validationError) { + return { + success: false, + error: validationError + }; + } + + // 3. 应用字段映射(将别名转换为实际字段名) + const mappedParams = this.applyFieldMappings(params); + + // 4. 构建执行上下文 + const context: ToolContext = { + projectId: this.projectId, + userId, + sessionId, + fieldMappings: this.fieldMappings, + redcapAdapter: this.redcapAdapter + }; + + // 5. 执行工具 + try { + const result = await tool.execute(mappedParams, context); + + const executionTime = Date.now() - startTime; + + // 6. 应用反向映射(将实际字段名转回别名,便于 LLM 理解) + if (result.data) { + result.data = this.applyReverseMappings(result.data); + } + + result.metadata = { + ...result.metadata, + executionTime + }; + + logger.info('[ToolsService] Tool executed', { + toolName, + success: result.success, + executionTime: `${executionTime}ms` + }); + + return result; + + } catch (error: any) { + logger.error('[ToolsService] Tool execution failed', { + toolName, + error: error.message + }); + + return { + success: false, + error: `工具执行失败: ${error.message}`, + metadata: { + executionTime: Date.now() - startTime + } + }; + } + } + + /** + * 生成 LLM 工具描述(OpenAI Function Calling 格式) + */ + getLLMToolDescriptions(readOnly: boolean = false): LLMToolDescription[] { + const tools = readOnly ? this.getReadOnlyTools() : this.getAllTools(); + + return tools.map(tool => ({ + type: 'function' as const, + function: { + name: tool.name, + description: tool.description, + parameters: { + type: 'object' as const, + properties: this.buildParameterProperties(tool.parameters), + required: tool.parameters.filter(p => p.required).map(p => p.name) + } + } + })); + } + + /** + * 生成工具使用说明(用于 Prompt) + */ + getToolsPrompt(readOnly: boolean = false): string { + const tools = readOnly ? this.getReadOnlyTools() : this.getAllTools(); + + let prompt = '## 可用工具\n\n'; + + for (const tool of tools) { + prompt += `### ${tool.name}\n`; + prompt += `${tool.description}\n\n`; + prompt += `**参数:**\n`; + for (const param of tool.parameters) { + const required = param.required ? '(必填)' : '(可选)'; + prompt += `- \`${param.name}\` ${required}: ${param.description}\n`; + } + prompt += '\n'; + } + + return prompt; + } + + // ============================================================ + // 私有方法 + // ============================================================ + + /** + * 注册内置工具 + */ + private registerBuiltinTools(): void { + // 1. read_clinical_data - 读取临床数据 + this.registerTool({ + name: 'read_clinical_data', + description: '从 REDCap 读取患者临床数据。可以查询单个患者或多个患者,支持指定字段。', + category: 'read', + parameters: [ + { + name: 'record_id', + type: 'string', + description: '患者记录ID。如果不指定,将返回所有记录。', + required: false + }, + { + name: 'fields', + type: 'array', + description: '要查询的字段列表。如果不指定,将返回所有字段。可以使用中文别名(如"年龄")或实际字段名。', + required: false + } + ], + execute: async (params, context) => { + if (!context.redcapAdapter) { + return { success: false, error: 'REDCap 未配置' }; + } + + try { + let records: any[]; + + if (params.record_id) { + // 查询单个记录 + const record = await context.redcapAdapter.getRecordById(params.record_id); + records = record ? [record] : []; + } else if (params.fields && params.fields.length > 0) { + // 查询指定字段 + records = await context.redcapAdapter.getAllRecordsFields(params.fields); + } else { + // 查询所有记录 + records = await context.redcapAdapter.exportRecords({}); + } + + return { + success: true, + data: records, + metadata: { + executionTime: 0, + recordCount: records.length, + source: 'REDCap' + } + }; + } catch (error: any) { + return { success: false, error: error.message }; + } + } + }); + + // 2. run_quality_check - 执行质控检查 + this.registerTool({ + name: 'run_quality_check', + description: '对患者数据执行质控检查,验证是否符合纳入/排除标准和变量范围。', + category: 'compute', + parameters: [ + { + name: 'record_id', + type: 'string', + description: '要检查的患者记录ID', + required: true + } + ], + execute: async (params, context) => { + if (!context.redcapAdapter) { + return { success: false, error: 'REDCap 未配置' }; + } + + try { + // 1. 获取记录数据 + const record = await context.redcapAdapter.getRecordById(params.record_id); + if (!record) { + return { + success: false, + error: `未找到记录 ID: ${params.record_id}` + }; + } + + // 2. 执行质控 + const engine = await createHardRuleEngine(context.projectId); + const qcResult = engine.execute(params.record_id, record); + + return { + success: true, + data: { + recordId: params.record_id, + overallStatus: qcResult.overallStatus, + summary: qcResult.summary, + errors: qcResult.errors.map(e => ({ + rule: e.ruleName, + field: e.field, + message: e.message, + actualValue: e.actualValue + })), + warnings: qcResult.warnings.map(w => ({ + rule: w.ruleName, + field: w.field, + message: w.message, + actualValue: w.actualValue + })) + }, + metadata: { + executionTime: 0, + source: 'HardRuleEngine' + } + }; + } catch (error: any) { + return { success: false, error: error.message }; + } + } + }); + + // 3. batch_quality_check - 批量质控 + this.registerTool({ + name: 'batch_quality_check', + description: '对所有患者数据执行批量质控检查,返回汇总统计。', + category: 'compute', + parameters: [], + execute: async (params, context) => { + if (!context.redcapAdapter) { + return { success: false, error: 'REDCap 未配置' }; + } + + try { + // 1. 获取所有记录 + const allRecords = await context.redcapAdapter.exportRecords({}); + if (allRecords.length === 0) { + return { + success: true, + data: { message: '暂无记录' } + }; + } + + // 2. 去重 + const recordMap = new Map>(); + for (const r of allRecords) { + if (!recordMap.has(r.record_id)) { + recordMap.set(r.record_id, r); + } + } + + // 3. 批量质控 + const engine = await createHardRuleEngine(context.projectId); + const records = Array.from(recordMap.entries()).map(([id, data]) => ({ + recordId: id, + data + })); + + const qcResults = engine.executeBatch(records); + + // 4. 统计汇总 + const passCount = qcResults.filter(r => r.overallStatus === 'PASS').length; + const failCount = qcResults.filter(r => r.overallStatus === 'FAIL').length; + const warningCount = qcResults.filter(r => r.overallStatus === 'WARNING').length; + + // 5. 问题记录 + const problemRecords = qcResults + .filter(r => r.overallStatus !== 'PASS') + .slice(0, 10) + .map(r => ({ + recordId: r.recordId, + status: r.overallStatus, + issues: [...r.errors, ...r.warnings].slice(0, 3).map(i => ({ + rule: i.ruleName, + message: i.message + })) + })); + + return { + success: true, + data: { + totalRecords: records.length, + summary: { + pass: passCount, + fail: failCount, + warning: warningCount, + passRate: `${((passCount / records.length) * 100).toFixed(1)}%` + }, + problemRecords + }, + metadata: { + executionTime: 0, + recordCount: records.length, + source: 'HardRuleEngine' + } + }; + } catch (error: any) { + return { success: false, error: error.message }; + } + } + }); + + // 4. get_project_info - 获取项目信息 + this.registerTool({ + name: 'get_project_info', + description: '获取当前研究项目的基本信息。', + category: 'read', + parameters: [], + execute: async (params, context) => { + try { + const project = await prisma.iitProject.findUnique({ + where: { id: context.projectId }, + select: { + id: true, + name: true, + description: true, + redcapProjectId: true, + status: true, + createdAt: true, + lastSyncAt: true + } + }); + + if (!project) { + return { success: false, error: '项目不存在' }; + } + + return { + success: true, + data: project, + metadata: { + executionTime: 0, + source: 'Database' + } + }; + } catch (error: any) { + return { success: false, error: error.message }; + } + } + }); + + // 5. count_records - 统计记录数 + this.registerTool({ + name: 'count_records', + description: '统计当前项目的患者记录总数。', + category: 'read', + parameters: [], + execute: async (params, context) => { + if (!context.redcapAdapter) { + return { success: false, error: 'REDCap 未配置' }; + } + + try { + const count = await context.redcapAdapter.getRecordCount(); + return { + success: true, + data: { totalRecords: count }, + metadata: { + executionTime: 0, + source: 'REDCap' + } + }; + } catch (error: any) { + return { success: false, error: error.message }; + } + } + }); + + // 6. search_protocol - 搜索研究方案 + this.registerTool({ + name: 'search_protocol', + description: '在研究方案文档中搜索相关信息,如纳入标准、排除标准、研究流程等。', + category: 'read', + parameters: [ + { + name: 'query', + type: 'string', + description: '搜索关键词或问题', + required: true + } + ], + execute: async (params, context) => { + try { + // TODO: 集成 Dify 知识库检索 + // 目前返回占位信息 + return { + success: true, + data: { + message: '研究方案检索功能开发中', + query: params.query + }, + metadata: { + executionTime: 0, + source: 'Dify (TODO)' + } + }; + } catch (error: any) { + return { success: false, error: error.message }; + } + } + }); + } + + /** + * 验证参数 + */ + private validateParams(tool: ToolDefinition, params: Record): string | null { + for (const param of tool.parameters) { + if (param.required && (params[param.name] === undefined || params[param.name] === null)) { + return `缺少必填参数: ${param.name}`; + } + + if (params[param.name] !== undefined && params[param.name] !== null) { + const value = params[param.name]; + const expectedType = param.type; + + // 类型检查 + if (expectedType === 'array' && !Array.isArray(value)) { + return `参数 ${param.name} 应该是数组类型`; + } + if (expectedType === 'number' && typeof value !== 'number') { + return `参数 ${param.name} 应该是数字类型`; + } + if (expectedType === 'boolean' && typeof value !== 'boolean') { + return `参数 ${param.name} 应该是布尔类型`; + } + + // 枚举检查 + if (param.enum && !param.enum.includes(value)) { + return `参数 ${param.name} 的值必须是: ${param.enum.join(', ')}`; + } + } + } + return null; + } + + /** + * 应用字段映射(别名 -> 实际字段名) + */ + private applyFieldMappings(params: Record): Record { + const mapped = { ...params }; + + // 处理 fields 数组参数 + if (Array.isArray(mapped.fields)) { + mapped.fields = mapped.fields.map(field => + this.fieldMappings.get(field) || field + ); + } + + return mapped; + } + + /** + * 应用反向映射(实际字段名 -> 别名) + */ + private applyReverseMappings(data: any): any { + if (Array.isArray(data)) { + return data.map(item => this.applyReverseMappings(item)); + } + + if (typeof data === 'object' && data !== null) { + const mapped: Record = {}; + for (const [key, value] of Object.entries(data)) { + const alias = this.reverseMappings.get(key) || key; + mapped[alias] = value; + } + return mapped; + } + + return data; + } + + /** + * 构建参数属性(用于 LLM 工具描述) + */ + private buildParameterProperties(parameters: ToolParameter[]): Record { + const properties: Record = {}; + + for (const param of parameters) { + properties[param.name] = { + type: param.type === 'array' ? 'array' : param.type, + description: param.description + }; + + if (param.type === 'array') { + properties[param.name].items = { type: 'string' }; + } + + if (param.enum) { + properties[param.name].enum = param.enum; + } + + if (param.default !== undefined) { + properties[param.name].default = param.default; + } + } + + return properties; + } +} + +// ============================================================ +// 工厂函数 +// ============================================================ + +/** + * 创建并初始化 ToolsService + */ +export async function createToolsService(projectId: string): Promise { + const service = new ToolsService(projectId); + await service.initialize(); + return service; +} diff --git a/backend/src/modules/iit-manager/test-all-records-data.ts b/backend/src/modules/iit-manager/test-all-records-data.ts new file mode 100644 index 00000000..a34b9d9c --- /dev/null +++ b/backend/src/modules/iit-manager/test-all-records-data.ts @@ -0,0 +1,179 @@ +/** + * 测试脚本:查看所有记录的所有字段数据(合并多事件) + * + * 使用新的 getAllRecordsMerged() API + */ + +import { PrismaClient } from '@prisma/client'; +import { RedcapAdapter } from './adapters/RedcapAdapter.js'; + +const prisma = new PrismaClient(); + +async function showAllRecordsData() { + console.log('='.repeat(100)); + console.log('📊 所有记录完整数据(合并多事件)'); + console.log('='.repeat(100)); + + try { + // 1. 获取项目配置 + const project = await prisma.iitProject.findFirst({ + where: { status: 'active' }, + select: { + name: true, + redcapUrl: true, + redcapApiToken: true, + } + }); + + if (!project) { + console.log('❌ 未找到活跃项目'); + return; + } + + console.log(`\n📁 项目: ${project.name}`); + console.log(`🔗 URL: ${project.redcapUrl}\n`); + + const adapter = new RedcapAdapter(project.redcapUrl, project.redcapApiToken!); + + // 2. 获取所有记录(合并多事件) + console.log('⏳ 正在获取所有记录数据...\n'); + const startTime = Date.now(); + const allRecords = await adapter.getAllRecordsMerged(); + const duration = Date.now() - startTime; + + console.log(`✅ 获取完成 (${duration}ms)`); + console.log(`📈 总记录数: ${allRecords.length}\n`); + + // 3. 获取表单信息 + const instruments = await adapter.exportInstruments(); + console.log('📝 表单列表:'); + instruments.forEach((inst, i) => { + console.log(` ${i + 1}. ${inst.instrument_name}: ${inst.instrument_label}`); + }); + console.log(); + + // 4. 定义关键字段分组 + const fieldGroups = { + '基本信息': ['record_id', 'name', 'date_of_birth', 'sex', 'residential_address', 'visiting_date'], + '人口学': ['marital_status', 'educational_level', 'smoking_history', 'alcohol_consumption_history'], + '病史': ['course_of_the_disease', 'number_of_pregnancies', 'number_of_births', 'number_of_miscarriages', 'survival_count'], + '知情同意': ['sign_informed_consent_form', 'date_of_signature', 'informed_consent_complete'], + '入排标准': ['inclusion_criteria1', 'inclusion_criteria2', 'inclusion_criteria3', 'inclusion_criteria4', 'inclusion_criteria5', + 'exclusion_criteria1', 'exclusion_criteria2', 'exclusion_criteria3', 'exclusion_criteria4', 'exclusion_criteria5', + 'inclusion_and_exclusion_criteria_complete'], + '表单完成状态': ['basic_demography_form_complete', 'medical_history_and_diagnosis_complete', 'informed_consent_complete', + 'inclusion_and_exclusion_criteria_complete', 'cmss_complete', 'mcgill_sfmpq_complete', 'blood_routine_test_complete'], + '系统元数据': ['_event_count', '_events'], + }; + + // 5. 显示每条记录的详细信息 + console.log('='.repeat(100)); + console.log('📋 记录详情'); + console.log('='.repeat(100)); + + for (const record of allRecords) { + console.log(`\n${'─'.repeat(80)}`); + console.log(`📌 记录 ${record.record_id} (${record._event_count} 个事件)`); + console.log(`${'─'.repeat(80)}`); + + // 显示各分组的字段 + for (const [groupName, fields] of Object.entries(fieldGroups)) { + const groupData: string[] = []; + for (const field of fields) { + const value = record[field]; + if (value !== undefined && value !== null && value !== '') { + // 格式化显示 + let displayValue = value; + if (Array.isArray(value)) { + displayValue = value.join(', '); + } + groupData.push(`${field}=${displayValue}`); + } + } + if (groupData.length > 0) { + console.log(`\n 【${groupName}】`); + groupData.forEach(d => console.log(` - ${d}`)); + } + } + + // 统计非空字段 + const allFields = Object.keys(record); + const nonEmptyFields = allFields.filter(k => + record[k] !== '' && record[k] !== null && record[k] !== undefined + ); + console.log(`\n 【统计】总字段: ${allFields.length}, 非空字段: ${nonEmptyFields.length}`); + } + + // 6. 汇总统计 + console.log('\n' + '='.repeat(100)); + console.log('📊 汇总统计'); + console.log('='.repeat(100)); + + // 统计各表单完成情况 + const completionStats: Record = {}; + const completeFields = [ + 'basic_demography_form_complete', + 'medical_history_and_diagnosis_complete', + 'informed_consent_complete', + 'inclusion_and_exclusion_criteria_complete', + 'cmss_complete', + 'mcgill_sfmpq_complete', + 'blood_routine_test_complete' + ]; + + for (const field of completeFields) { + completionStats[field] = { complete: 0, incomplete: 0, notStarted: 0 }; + } + + for (const record of allRecords) { + for (const field of completeFields) { + const value = record[field]; + if (value === '2' || value === 2) { + completionStats[field].complete++; + } else if (value === '1' || value === 1) { + completionStats[field].incomplete++; + } else { + completionStats[field].notStarted++; + } + } + } + + console.log('\n表单完成情况统计:'); + console.log('┌────────────────────────────────────────┬──────────┬──────────┬──────────┐'); + console.log('│ 表单 │ 已完成 │ 未完成 │ 未开始 │'); + console.log('├────────────────────────────────────────┼──────────┼──────────┼──────────┤'); + for (const [field, stats] of Object.entries(completionStats)) { + const formName = field.replace('_complete', '').padEnd(38); + console.log(`│ ${formName} │ ${String(stats.complete).padStart(8)} │ ${String(stats.incomplete).padStart(8)} │ ${String(stats.notStarted).padStart(8)} │`); + } + console.log('└────────────────────────────────────────┴──────────┴──────────┴──────────┘'); + + // 入排标准统计 + console.log('\n入组标准符合情况:'); + const inclusionFields = ['inclusion_criteria1', 'inclusion_criteria2', 'inclusion_criteria3', 'inclusion_criteria4', 'inclusion_criteria5']; + let allMet = 0; + let someMet = 0; + let noneMet = 0; + + for (const record of allRecords) { + const metCount = inclusionFields.filter(f => record[f] === '1' || record[f] === 1).length; + if (metCount === 5) allMet++; + else if (metCount > 0) someMet++; + else noneMet++; + } + console.log(` - 全部符合(5/5): ${allMet} 人`); + console.log(` - 部分符合: ${someMet} 人`); + console.log(` - 未填写/不符合: ${noneMet} 人`); + + console.log('\n' + '='.repeat(100)); + console.log('✅ 数据展示完成'); + console.log('='.repeat(100)); + + } catch (error) { + console.error('❌ 错误:', error); + } finally { + await prisma.$disconnect(); + } +} + +showAllRecordsData(); diff --git a/backend/src/modules/iit-manager/test-all-redcap-apis.ts b/backend/src/modules/iit-manager/test-all-redcap-apis.ts new file mode 100644 index 00000000..84d7c2b2 --- /dev/null +++ b/backend/src/modules/iit-manager/test-all-redcap-apis.ts @@ -0,0 +1,404 @@ +/** + * REDCap API 完整测试脚本 + * + * 目的:测试所有 RedcapAdapter API 方法,确保底层能力稳固 + * + * 测试的 API 列表: + * 1. testConnection() - 测试连接 + * 2. exportProjectInfo() - 导出项目信息 + * 3. exportInstruments() - 导出表单列表 + * 4. exportMetadata() - 导出元数据(字段定义) + * 5. getFieldInfoMap() - 获取字段信息映射 + * 6. getRecordCount() - 获取记录总数 + * 7. exportRecords() - 导出所有记录 + * 8. exportRecords({ records }) - 导出指定记录 + * 9. exportRecords({ fields }) - 导出指定字段 + * 10. getRecordById() - 获取单条记录(合并多事件) + * 11. getRecordFields() - 获取记录的指定字段 + * 12. getAllRecordsFields() - 获取所有记录的指定字段 + */ + +import { PrismaClient } from '@prisma/client'; +import { RedcapAdapter } from './adapters/RedcapAdapter.js'; + +const prisma = new PrismaClient(); + +interface TestResult { + name: string; + success: boolean; + duration: number; + details?: any; + error?: string; +} + +const testResults: TestResult[] = []; + +// 辅助函数:记录测试结果 +function recordResult(name: string, success: boolean, duration: number, details?: any, error?: string) { + testResults.push({ name, success, duration, details, error }); + const status = success ? '✅' : '❌'; + console.log(`${status} ${name} (${duration}ms)`); + if (details) { + console.log(` 详情: ${JSON.stringify(details)}`); + } + if (error) { + console.log(` 错误: ${error}`); + } +} + +async function runAllTests() { + console.log('='.repeat(80)); + console.log('🔬 REDCap API 完整测试'); + console.log('='.repeat(80)); + console.log(); + + // 1. 获取项目配置 + const project = await prisma.iitProject.findFirst({ + where: { status: 'active' }, + select: { + id: true, + name: true, + redcapUrl: true, + redcapApiToken: true, + } + }); + + if (!project) { + console.log('❌ 未找到活跃项目'); + return; + } + + console.log('📁 项目配置:'); + console.log(` - 名称: ${project.name}`); + console.log(` - URL: ${project.redcapUrl}`); + console.log(` - Token: ${project.redcapApiToken?.substring(0, 8)}...`); + console.log(); + + const adapter = new RedcapAdapter(project.redcapUrl, project.redcapApiToken!); + + // ============================================================ + // 测试 1: testConnection + // ============================================================ + console.log('\n' + '-'.repeat(60)); + console.log('【测试 1】testConnection() - 测试连接'); + console.log('-'.repeat(60)); + + let start = Date.now(); + try { + const connected = await adapter.testConnection(); + recordResult('testConnection', connected, Date.now() - start, { connected }); + } catch (e: any) { + recordResult('testConnection', false, Date.now() - start, undefined, e.message); + } + + // ============================================================ + // 测试 2: exportProjectInfo + // ============================================================ + console.log('\n' + '-'.repeat(60)); + console.log('【测试 2】exportProjectInfo() - 导出项目信息'); + console.log('-'.repeat(60)); + + start = Date.now(); + try { + const projectInfo = await adapter.exportProjectInfo(); + recordResult('exportProjectInfo', true, Date.now() - start, { + project_id: projectInfo.project_id, + project_title: projectInfo.project_title, + in_production: projectInfo.in_production, + record_autonumbering: projectInfo.record_autonumbering_enabled + }); + console.log(` 项目标题: ${projectInfo.project_title}`); + console.log(` 项目ID: ${projectInfo.project_id}`); + console.log(` 创建时间: ${projectInfo.creation_time}`); + console.log(` 是否生产环境: ${projectInfo.in_production}`); + } catch (e: any) { + recordResult('exportProjectInfo', false, Date.now() - start, undefined, e.message); + } + + // ============================================================ + // 测试 3: exportInstruments + // ============================================================ + console.log('\n' + '-'.repeat(60)); + console.log('【测试 3】exportInstruments() - 导出表单列表'); + console.log('-'.repeat(60)); + + start = Date.now(); + try { + const instruments = await adapter.exportInstruments(); + recordResult('exportInstruments', true, Date.now() - start, { count: instruments.length }); + console.log(` 表单数量: ${instruments.length}`); + instruments.forEach((inst, i) => { + console.log(` ${i + 1}. ${inst.instrument_name}: ${inst.instrument_label}`); + }); + } catch (e: any) { + recordResult('exportInstruments', false, Date.now() - start, undefined, e.message); + } + + // ============================================================ + // 测试 4: exportMetadata + // ============================================================ + console.log('\n' + '-'.repeat(60)); + console.log('【测试 4】exportMetadata() - 导出元数据'); + console.log('-'.repeat(60)); + + start = Date.now(); + try { + const metadata = await adapter.exportMetadata(); + recordResult('exportMetadata', true, Date.now() - start, { fieldCount: metadata.length }); + console.log(` 字段总数: ${metadata.length}`); + + // 按表单分组统计 + const formStats: Record = {}; + metadata.forEach((field: any) => { + const formName = field.form_name || '未知'; + formStats[formName] = (formStats[formName] || 0) + 1; + }); + console.log(' 按表单统计:'); + Object.entries(formStats).forEach(([form, count]) => { + console.log(` - ${form}: ${count} 个字段`); + }); + } catch (e: any) { + recordResult('exportMetadata', false, Date.now() - start, undefined, e.message); + } + + // ============================================================ + // 测试 5: getFieldInfoMap + // ============================================================ + console.log('\n' + '-'.repeat(60)); + console.log('【测试 5】getFieldInfoMap() - 获取字段信息映射'); + console.log('-'.repeat(60)); + + start = Date.now(); + try { + const fieldMap = await adapter.getFieldInfoMap(); + recordResult('getFieldInfoMap', true, Date.now() - start, { fieldCount: fieldMap.size }); + console.log(` 字段映射数量: ${fieldMap.size}`); + + // 显示前5个字段信息 + let count = 0; + console.log(' 示例字段:'); + for (const [name, info] of fieldMap) { + if (count >= 5) break; + console.log(` - ${name}: ${info.fieldLabel} (${info.fieldType})`); + count++; + } + } catch (e: any) { + recordResult('getFieldInfoMap', false, Date.now() - start, undefined, e.message); + } + + // ============================================================ + // 测试 6: getRecordCount + // ============================================================ + console.log('\n' + '-'.repeat(60)); + console.log('【测试 6】getRecordCount() - 获取记录总数'); + console.log('-'.repeat(60)); + + start = Date.now(); + try { + const count = await adapter.getRecordCount(); + recordResult('getRecordCount', true, Date.now() - start, { totalRecords: count }); + console.log(` 记录总数: ${count}`); + } catch (e: any) { + recordResult('getRecordCount', false, Date.now() - start, undefined, e.message); + } + + // ============================================================ + // 测试 7: exportRecords() - 导出所有记录 + // ============================================================ + console.log('\n' + '-'.repeat(60)); + console.log('【测试 7】exportRecords() - 导出所有记录'); + console.log('-'.repeat(60)); + + start = Date.now(); + let allRecords: any[] = []; + try { + allRecords = await adapter.exportRecords(); + const uniqueIds = new Set(allRecords.map(r => r.record_id)); + recordResult('exportRecords()', true, Date.now() - start, { + rawRecordCount: allRecords.length, + uniqueRecordIds: uniqueIds.size + }); + console.log(` 原始记录数: ${allRecords.length} (包含多事件)`); + console.log(` 唯一记录ID: ${uniqueIds.size}`); + console.log(` 记录ID列表: ${Array.from(uniqueIds).join(', ')}`); + } catch (e: any) { + recordResult('exportRecords()', false, Date.now() - start, undefined, e.message); + } + + // ============================================================ + // 测试 8: exportRecords({ records }) - 导出指定记录 + // ============================================================ + console.log('\n' + '-'.repeat(60)); + console.log('【测试 8】exportRecords({ records: ["1", "10"] }) - 导出指定记录'); + console.log('-'.repeat(60)); + + start = Date.now(); + try { + const specificRecords = await adapter.exportRecords({ records: ['1', '10'] }); + recordResult('exportRecords({ records })', true, Date.now() - start, { + requestedIds: ['1', '10'], + returnedCount: specificRecords.length + }); + console.log(` 请求记录: 1, 10`); + console.log(` 返回行数: ${specificRecords.length}`); + + // 按 record_id 分组 + const byId: Record = {}; + specificRecords.forEach(r => { + byId[r.record_id] = (byId[r.record_id] || 0) + 1; + }); + Object.entries(byId).forEach(([id, count]) => { + console.log(` - 记录 ${id}: ${count} 个事件`); + }); + } catch (e: any) { + recordResult('exportRecords({ records })', false, Date.now() - start, undefined, e.message); + } + + // ============================================================ + // 测试 9: exportRecords({ fields }) - 导出指定字段 + // ============================================================ + console.log('\n' + '-'.repeat(60)); + console.log('【测试 9】exportRecords({ fields }) - 导出指定字段'); + console.log('-'.repeat(60)); + + const testFields = ['record_id', 'name', 'age', 'sex', 'date_of_birth']; + start = Date.now(); + try { + const fieldRecords = await adapter.exportRecords({ fields: testFields }); + recordResult('exportRecords({ fields })', true, Date.now() - start, { + requestedFields: testFields, + returnedCount: fieldRecords.length + }); + console.log(` 请求字段: ${testFields.join(', ')}`); + console.log(` 返回行数: ${fieldRecords.length}`); + + // 统计非空值 + if (fieldRecords.length > 0) { + console.log(' 前3条记录:'); + fieldRecords.slice(0, 3).forEach((r, i) => { + const nonEmpty = Object.entries(r).filter(([k, v]) => v !== '' && v !== null).map(([k, v]) => `${k}=${v}`); + console.log(` ${i + 1}. ${nonEmpty.join(', ')}`); + }); + } + } catch (e: any) { + recordResult('exportRecords({ fields })', false, Date.now() - start, undefined, e.message); + } + + // ============================================================ + // 测试 10: getRecordById() - 获取单条记录(合并多事件) + // ============================================================ + console.log('\n' + '-'.repeat(60)); + console.log('【测试 10】getRecordById("10") - 获取单条记录(合并多事件)'); + console.log('-'.repeat(60)); + + start = Date.now(); + try { + const record = await adapter.getRecordById('10'); + if (record) { + const nonEmptyCount = Object.keys(record).filter(k => record[k] !== '' && record[k] !== null && record[k] !== undefined).length; + recordResult('getRecordById', true, Date.now() - start, { + totalFields: Object.keys(record).length, + nonEmptyFields: nonEmptyCount, + eventCount: record._event_count, + events: record._events + }); + console.log(` 总字段数: ${Object.keys(record).length}`); + console.log(` 非空字段: ${nonEmptyCount}`); + console.log(` 事件数: ${record._event_count}`); + console.log(` 事件列表: ${record._events?.join(', ')}`); + + // 显示关键字段 + console.log(' 关键字段值:'); + const keyFields = ['name', 'age', 'sex', 'date_of_birth', 'residential_address', 'enrollment_status']; + keyFields.forEach(field => { + const value = record[field]; + console.log(` - ${field}: ${value === '' || value === null || value === undefined ? '(空)' : value}`); + }); + } else { + recordResult('getRecordById', false, Date.now() - start, undefined, '记录不存在'); + } + } catch (e: any) { + recordResult('getRecordById', false, Date.now() - start, undefined, e.message); + } + + // ============================================================ + // 测试 11: getRecordFields() - 获取记录的指定字段 + // ============================================================ + console.log('\n' + '-'.repeat(60)); + console.log('【测试 11】getRecordFields("10", fields) - 获取记录的指定字段'); + console.log('-'.repeat(60)); + + start = Date.now(); + try { + const recordFields = await adapter.getRecordFields('10', testFields); + if (recordFields) { + recordResult('getRecordFields', true, Date.now() - start, recordFields); + console.log(' 返回数据:'); + Object.entries(recordFields).forEach(([k, v]) => { + console.log(` - ${k}: ${v === '' ? '(空)' : v}`); + }); + } else { + recordResult('getRecordFields', false, Date.now() - start, undefined, '记录不存在'); + } + } catch (e: any) { + recordResult('getRecordFields', false, Date.now() - start, undefined, e.message); + } + + // ============================================================ + // 测试 12: getAllRecordsFields() - 获取所有记录的指定字段 + // ============================================================ + console.log('\n' + '-'.repeat(60)); + console.log('【测试 12】getAllRecordsFields(fields) - 获取所有记录的指定字段'); + console.log('-'.repeat(60)); + + start = Date.now(); + try { + const allFieldRecords = await adapter.getAllRecordsFields(['record_id', 'name', 'age']); + const uniqueIds = new Set(allFieldRecords.map(r => r.record_id)); + recordResult('getAllRecordsFields', true, Date.now() - start, { + rawCount: allFieldRecords.length, + uniqueCount: uniqueIds.size + }); + console.log(` 原始行数: ${allFieldRecords.length}`); + console.log(` 唯一记录: ${uniqueIds.size}`); + + // 显示非空名称 + const withNames = allFieldRecords.filter(r => r.name && r.name !== ''); + console.log(` 有姓名的记录: ${withNames.length}`); + withNames.slice(0, 5).forEach(r => { + console.log(` - 记录${r.record_id}: ${r.name}, 年龄=${r.age || '(空)'}`); + }); + } catch (e: any) { + recordResult('getAllRecordsFields', false, Date.now() - start, undefined, e.message); + } + + // ============================================================ + // 测试总结 + // ============================================================ + console.log('\n' + '='.repeat(80)); + console.log('📊 测试总结'); + console.log('='.repeat(80)); + + const passed = testResults.filter(r => r.success).length; + const failed = testResults.filter(r => !r.success).length; + const totalDuration = testResults.reduce((sum, r) => sum + r.duration, 0); + + console.log(`\n总计: ${testResults.length} 个测试`); + console.log(`✅ 通过: ${passed}`); + console.log(`❌ 失败: ${failed}`); + console.log(`⏱️ 总耗时: ${totalDuration}ms`); + + if (failed > 0) { + console.log('\n失败的测试:'); + testResults.filter(r => !r.success).forEach(r => { + console.log(` - ${r.name}: ${r.error}`); + }); + } + + console.log('\n' + '='.repeat(80)); +} + +// 运行测试 +runAllTests() + .catch(console.error) + .finally(() => prisma.$disconnect()); diff --git a/backend/src/modules/iit-manager/test-e2e-qc-webhook.ts b/backend/src/modules/iit-manager/test-e2e-qc-webhook.ts new file mode 100644 index 00000000..c5a5b3b1 --- /dev/null +++ b/backend/src/modules/iit-manager/test-e2e-qc-webhook.ts @@ -0,0 +1,121 @@ +/** + * 端到端质控 Webhook 测试脚本 + * + * 测试流程: + * 1. 模拟 REDCap DET 发送 webhook + * 2. 触发质控引擎执行 + * 3. 验证企业微信通知 + * + * 使用方法: + * npx tsx src/modules/iit-manager/test-e2e-qc-webhook.ts + * + * 前提条件: + * - 后端服务已启动 (npm run dev) + * - 本地 REDCap 已运行 (localhost:8080) + * - 数据库中有项目配置 + */ + +import axios from 'axios'; + +const BACKEND_URL = 'http://localhost:3001'; +const WEBHOOK_ENDPOINT = '/api/v1/iit/webhooks/redcap'; + +interface TestCase { + name: string; + recordId: string; + instrument: string; + description: string; +} + +// 测试用例 +const TEST_CASES: TestCase[] = [ + { + name: '测试记录 1', + recordId: '1', + instrument: 'demographics', + description: '触发记录 1 的质控检查' + }, + { + name: '测试记录 2', + recordId: '2', + instrument: 'baseline_data', + description: '触发记录 2 的质控检查' + } +]; + +async function testWebhook(testCase: TestCase): Promise { + console.log(`\n📌 测试: ${testCase.name}`); + console.log(` ${testCase.description}`); + console.log(` 记录ID: ${testCase.recordId}`); + console.log(` 表单: ${testCase.instrument}`); + + try { + const startTime = Date.now(); + + // 模拟 REDCap DET 发送的 webhook + const response = await axios.post( + `${BACKEND_URL}${WEBHOOK_ENDPOINT}`, + new URLSearchParams({ + project_id: '17', // test0207 的 PID + record: testCase.recordId, + instrument: testCase.instrument, + redcap_event_name: 'event_1_arm_1', + redcap_version: '15.8.0' + }), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + timeout: 5000 + } + ); + + const duration = Date.now() - startTime; + + console.log(` ✅ Webhook 发送成功`); + console.log(` 响应: ${JSON.stringify(response.data)}`); + console.log(` 耗时: ${duration}ms`); + + } catch (error: any) { + console.log(` ❌ Webhook 发送失败: ${error.message}`); + } +} + +async function main(): Promise { + console.log('🧪 端到端质控 Webhook 测试'); + console.log('============================================================'); + console.log(`后端地址: ${BACKEND_URL}`); + console.log(`Webhook 端点: ${WEBHOOK_ENDPOINT}`); + + // 1. 检查后端服务是否可用 + console.log('\n📡 检查后端服务...'); + try { + const healthCheck = await axios.get(`${BACKEND_URL}/health`, { timeout: 3000 }); + console.log(` ✅ 后端服务正常: ${healthCheck.data.status || 'ok'}`); + } catch (error: any) { + console.log(` ❌ 后端服务不可用: ${error.message}`); + console.log('\n💡 请先启动后端服务: npm run dev'); + process.exit(1); + } + + // 2. 执行测试用例 + console.log('\n============================================================'); + console.log('📋 执行测试用例'); + + for (const testCase of TEST_CASES) { + await testWebhook(testCase); + // 等待 2 秒,让异步任务有时间执行 + await new Promise(resolve => setTimeout(resolve, 2000)); + } + + // 3. 总结 + console.log('\n============================================================'); + console.log('📊 测试完成'); + console.log('\n💡 请检查:'); + console.log(' 1. 后端日志 - 查看质控执行详情'); + console.log(' 2. 企业微信 - 检查是否收到通知'); + console.log(' 3. 数据库 - iit_schema.audit_logs 表中的记录'); + console.log('\n⏰ 注意:webhook 返回是异步的,质控和通知可能需要几秒钟'); +} + +main().catch(console.error); diff --git a/backend/src/modules/iit-manager/test-hard-rule-engine.ts b/backend/src/modules/iit-manager/test-hard-rule-engine.ts new file mode 100644 index 00000000..c388601a --- /dev/null +++ b/backend/src/modules/iit-manager/test-hard-rule-engine.ts @@ -0,0 +1,423 @@ +/** + * HardRuleEngine 独立测试脚本 + * + * 运行方式:npx tsx src/modules/iit-manager/test-hard-rule-engine.ts + */ + +import jsonLogic from 'json-logic-js'; + +console.log('🧪 HardRuleEngine 质控规则引擎测试\n'); +console.log('='.repeat(50)); + +// ============================================================ +// 测试用规则定义 +// ============================================================ + +const INCLUSION_RULES = [ + { + id: 'inc_001', + name: '年龄范围检查', + field: 'age', + logic: { + and: [ + { '>=': [{ var: 'age' }, 16] }, + { '<=': [{ var: 'age' }, 35] } + ] + }, + message: '年龄不在 16-35 岁范围内', + severity: 'error' as const, + category: 'inclusion' as const + }, + { + id: 'inc_003', + name: '月经周期规律性检查', + field: 'menstrual_cycle', + logic: { + and: [ + { '>=': [{ var: 'menstrual_cycle' }, 21] }, + { '<=': [{ var: 'menstrual_cycle' }, 35] } + ] + }, + message: '月经周期不在 21-35 天范围内(28±7天)', + severity: 'error' as const, + category: 'inclusion' as const + }, + { + id: 'inc_004', + name: 'VAS 评分检查', + field: 'vas_score', + logic: { + '>=': [{ var: 'vas_score' }, 4] + }, + message: 'VAS 疼痛评分 < 4 分,不符合入组条件', + severity: 'error' as const, + category: 'inclusion' as const + } +]; + +const EXCLUSION_RULES = [ + { + id: 'exc_002', + name: '妊娠或哺乳期检查', + field: 'pregnancy_status', + logic: { + or: [ + { '==': [{ var: 'pregnancy_status' }, null] }, + { '==': [{ var: 'pregnancy_status' }, ''] }, + { '==': [{ var: 'pregnancy_status' }, 0] }, + { '==': [{ var: 'pregnancy_status' }, '0'] } + ] + }, + message: '妊娠或哺乳期妇女不符合入组条件', + severity: 'error' as const, + category: 'exclusion' as const + } +]; + +const LAB_RULES = [ + { + id: 'lab_001', + name: '白细胞计数范围检查', + field: 'wbc', + logic: { + or: [ + { '==': [{ var: 'wbc' }, null] }, + { '==': [{ var: 'wbc' }, ''] }, + { + and: [ + { '>=': [{ var: 'wbc' }, 3.5] }, + { '<=': [{ var: 'wbc' }, 9.5] } + ] + } + ] + }, + message: '白细胞计数超出正常范围(3.5-9.5 *10^9/L)', + severity: 'warning' as const, + category: 'lab_values' as const + }, + { + id: 'lab_002', + name: '血红蛋白范围检查', + field: 'hemoglobin', + logic: { + or: [ + { '==': [{ var: 'hemoglobin' }, null] }, + { '==': [{ var: 'hemoglobin' }, ''] }, + { + and: [ + { '>=': [{ var: 'hemoglobin' }, 115] }, + { '<=': [{ var: 'hemoglobin' }, 150] } + ] + } + ] + }, + message: '血红蛋白超出正常范围(115-150 g/L)', + severity: 'warning' as const, + category: 'lab_values' as const + } +]; + +const ALL_RULES = [...INCLUSION_RULES, ...EXCLUSION_RULES, ...LAB_RULES]; + +// ============================================================ +// 质控执行函数 +// ============================================================ + +interface RuleResult { + ruleId: string; + ruleName: string; + field: string; + passed: boolean; + message: string; + severity: 'error' | 'warning' | 'info'; + category: string; + actualValue?: any; +} + +interface QCResult { + recordId: string; + overallStatus: 'PASS' | 'FAIL' | 'WARNING'; + summary: { + totalRules: number; + passed: number; + failed: number; + warnings: number; + }; + errors: RuleResult[]; + warnings: RuleResult[]; +} + +function executeQC(recordId: string, data: Record, rules: typeof ALL_RULES): QCResult { + const errors: RuleResult[] = []; + const warnings: RuleResult[] = []; + let passedCount = 0; + + // 数据类型转换 + const normalizedData: Record = {}; + for (const [key, value] of Object.entries(data)) { + if (typeof value === 'string' && value !== '' && !isNaN(Number(value))) { + normalizedData[key] = Number(value); + } else { + normalizedData[key] = value; + } + } + + for (const rule of rules) { + const passed = jsonLogic.apply(rule.logic, normalizedData) as boolean; + + if (passed) { + passedCount++; + } else { + const result: RuleResult = { + ruleId: rule.id, + ruleName: rule.name, + field: rule.field, + passed: false, + message: rule.message, + severity: rule.severity, + category: rule.category, + actualValue: normalizedData[rule.field] + }; + + if (rule.severity === 'error') { + errors.push(result); + } else if (rule.severity === 'warning') { + warnings.push(result); + } + } + } + + let overallStatus: 'PASS' | 'FAIL' | 'WARNING' = 'PASS'; + if (errors.length > 0) { + overallStatus = 'FAIL'; + } else if (warnings.length > 0) { + overallStatus = 'WARNING'; + } + + return { + recordId, + overallStatus, + summary: { + totalRules: rules.length, + passed: passedCount, + failed: errors.length, + warnings: warnings.length + }, + errors, + warnings + }; +} + +// ============================================================ +// 测试执行 +// ============================================================ + +let passed = 0; +let failed = 0; + +function test(name: string, fn: () => boolean) { + try { + const result = fn(); + if (result) { + console.log(`✅ ${name}`); + passed++; + } else { + console.log(`❌ ${name}`); + failed++; + } + } catch (e: any) { + console.log(`❌ ${name} - Error: ${e.message}`); + failed++; + } +} + +console.log('\n📋 纳入标准检查\n'); + +test('符合所有纳入标准的记录应该通过', () => { + const data = { age: 25, menstrual_cycle: 28, vas_score: 6 }; + const result = executeQC('001', data, INCLUSION_RULES); + return result.overallStatus === 'PASS' && result.errors.length === 0; +}); + +test('年龄超出范围应该失败 (age=40)', () => { + const data = { age: 40, menstrual_cycle: 28, vas_score: 6 }; + const result = executeQC('002', data, INCLUSION_RULES); + return result.overallStatus === 'FAIL' && + result.errors.some(e => e.ruleId === 'inc_001'); +}); + +test('年龄低于下限应该失败 (age=14)', () => { + const data = { age: 14, menstrual_cycle: 28, vas_score: 6 }; + const result = executeQC('003', data, INCLUSION_RULES); + return result.overallStatus === 'FAIL'; +}); + +test('VAS评分不足应该失败 (vas_score=2)', () => { + const data = { age: 25, menstrual_cycle: 28, vas_score: 2 }; + const result = executeQC('004', data, INCLUSION_RULES); + return result.overallStatus === 'FAIL' && + result.errors.some(e => e.ruleId === 'inc_004'); +}); + +test('边界值 - 年龄恰好16岁应该通过', () => { + const data = { age: 16, menstrual_cycle: 21, vas_score: 4 }; + const result = executeQC('005', data, INCLUSION_RULES); + return result.overallStatus === 'PASS'; +}); + +test('边界值 - 年龄恰好35岁应该通过', () => { + const data = { age: 35, menstrual_cycle: 35, vas_score: 10 }; + const result = executeQC('006', data, INCLUSION_RULES); + return result.overallStatus === 'PASS'; +}); + +console.log('\n📋 排除标准检查\n'); + +test('非妊娠状态应该通过 (pregnancy_status=0)', () => { + const data = { pregnancy_status: 0 }; + const result = executeQC('007', data, EXCLUSION_RULES); + return result.overallStatus === 'PASS'; +}); + +test('妊娠状态应该失败 (pregnancy_status=1)', () => { + const data = { pregnancy_status: 1 }; + const result = executeQC('008', data, EXCLUSION_RULES); + return result.overallStatus === 'FAIL'; +}); + +test('空值妊娠状态应该通过 (pregnancy_status=null)', () => { + const data = { pregnancy_status: null }; + const result = executeQC('009', data, EXCLUSION_RULES); + return result.overallStatus === 'PASS'; +}); + +console.log('\n📋 实验室值范围检查\n'); + +test('正常实验室值应该通过', () => { + const data = { wbc: 6.0, hemoglobin: 130 }; + const result = executeQC('010', data, LAB_RULES); + return result.overallStatus === 'PASS' && result.warnings.length === 0; +}); + +test('白细胞偏高应该警告 (wbc=12.0)', () => { + const data = { wbc: 12.0, hemoglobin: 130 }; + const result = executeQC('011', data, LAB_RULES); + return result.overallStatus === 'WARNING' && + result.warnings.length === 1 && + result.errors.length === 0; +}); + +test('血红蛋白偏低应该警告 (hemoglobin=100)', () => { + const data = { wbc: 6.0, hemoglobin: 100 }; + const result = executeQC('012', data, LAB_RULES); + return result.overallStatus === 'WARNING' && + result.warnings.some(w => w.field === 'hemoglobin'); +}); + +test('实验室值为空应该通过 (允许未检测)', () => { + const data = { wbc: null, hemoglobin: '' }; + const result = executeQC('013', data, LAB_RULES); + return result.overallStatus === 'PASS'; +}); + +console.log('\n📋 综合质控场景\n'); + +test('完美记录 - 全部通过', () => { + const data = { + age: 25, + menstrual_cycle: 28, + vas_score: 7, + pregnancy_status: 0, + wbc: 5.5, + hemoglobin: 125 + }; + const result = executeQC('014', data, ALL_RULES); + return result.overallStatus === 'PASS' && + result.summary.passed === ALL_RULES.length; +}); + +test('有错误的记录 - 应该失败', () => { + const data = { + age: 45, // 超龄 + menstrual_cycle: 28, + vas_score: 7, + pregnancy_status: 0, + wbc: 5.5, + hemoglobin: 125 + }; + const result = executeQC('015', data, ALL_RULES); + return result.overallStatus === 'FAIL' && result.errors.length > 0; +}); + +test('只有警告的记录 - 状态为WARNING', () => { + const data = { + age: 25, + menstrual_cycle: 28, + vas_score: 7, + pregnancy_status: 0, + wbc: 10.5, // 偏高 + hemoglobin: 125 + }; + const result = executeQC('016', data, ALL_RULES); + return result.overallStatus === 'WARNING' && + result.errors.length === 0 && + result.warnings.length > 0; +}); + +test('同时有错误和警告 - 状态为FAIL', () => { + const data = { + age: 50, // 超龄 (error) + menstrual_cycle: 28, + vas_score: 7, + pregnancy_status: 0, + wbc: 12.0, // 偏高 (warning) + hemoglobin: 100 // 偏低 (warning) + }; + const result = executeQC('017', data, ALL_RULES); + return result.overallStatus === 'FAIL' && + result.errors.length > 0 && + result.warnings.length > 0; +}); + +console.log('\n📋 数据类型处理\n'); + +test('字符串数字应该正确转换', () => { + const data = { age: '25', menstrual_cycle: '28', vas_score: '6' }; + const result = executeQC('018', data, INCLUSION_RULES); + return result.overallStatus === 'PASS'; +}); + +console.log('\n📋 批量质控\n'); + +test('批量执行多条记录', () => { + const records = [ + { recordId: '001', data: { age: 25, menstrual_cycle: 28, vas_score: 6 } }, + { recordId: '002', data: { age: 40, menstrual_cycle: 28, vas_score: 6 } }, + { recordId: '003', data: { age: 25, menstrual_cycle: 28, vas_score: 2 } }, + ]; + + const results = records.map(r => executeQC(r.recordId, r.data, INCLUSION_RULES)); + + const passCount = results.filter(r => r.overallStatus === 'PASS').length; + const failCount = results.filter(r => r.overallStatus === 'FAIL').length; + + return passCount === 1 && failCount === 2; +}); + +// ============================================================ +// 测试结果汇总 +// ============================================================ + +console.log('\n' + '='.repeat(50)); +console.log(`\n📊 测试结果汇总`); +console.log(` ✅ 通过: ${passed}`); +console.log(` ❌ 失败: ${failed}`); +console.log(` 📈 通过率: ${((passed / (passed + failed)) * 100).toFixed(1)}%\n`); + +if (failed === 0) { + console.log('🎉 所有测试通过!HardRuleEngine 质控规则引擎工作正常。\n'); + process.exit(0); +} else { + console.log('⚠️ 部分测试失败,请检查规则逻辑。\n'); + process.exit(1); +} diff --git a/backend/src/modules/iit-manager/test-kb-rag.ts b/backend/src/modules/iit-manager/test-kb-rag.ts new file mode 100644 index 00000000..9a26ba70 --- /dev/null +++ b/backend/src/modules/iit-manager/test-kb-rag.ts @@ -0,0 +1,138 @@ +/** + * 测试 IIT 项目知识库 RAG 查询 + * 验证知识库关联是否生效 + */ + +import { PrismaClient } from '@prisma/client'; +import { getVectorSearchService } from '../../common/rag/index.js'; + +const prisma = new PrismaClient(); + +async function testKnowledgeBase() { + try { + // 1. 查询项目配置,获取知识库 ID + console.log('='.repeat(60)); + console.log('📚 IIT 项目知识库 RAG 测试'); + console.log('='.repeat(60)); + + const project = await prisma.iitProject.findFirst({ + where: { id: 'test0102-pd-study' }, + select: { + id: true, + name: true, + knowledgeBaseId: true, + }, + }); + + if (!project) { + console.log('❌ 未找到项目配置'); + return; + } + + console.log('\n📁 项目信息:'); + console.log(' - ID:', project.id); + console.log(' - 名称:', project.name); + console.log(' - 知识库 ID:', project.knowledgeBaseId || '未设置'); + + const kbId = project.knowledgeBaseId; + + if (!kbId) { + console.log('\n❌ 项目未关联知识库'); + return; + } + + // 2. 查询知识库详情 + const kb = await prisma.ekbKnowledgeBase.findUnique({ + where: { id: kbId }, + select: { + id: true, + name: true, + description: true, + }, + }); + + if (!kb) { + console.log('\n❌ 知识库不存在:', kbId); + return; + } + + console.log('\n📖 知识库信息:'); + console.log(' - ID:', kb.id); + console.log(' - 名称:', kb.name); + console.log(' - 描述:', kb.description || '-'); + + // 3. 查询知识库文档数量 + const docCount = await prisma.ekbDocument.count({ + where: { kbId }, + }); + + console.log(' - 文档数量:', docCount); + + // 4. 列出所有文档 + const docs = await prisma.ekbDocument.findMany({ + where: { kbId }, + select: { + id: true, + filename: true, + contentType: true, + status: true, + createdAt: true, + }, + }); + + console.log('\n📄 文档列表:'); + for (const doc of docs) { + console.log(` - ${doc.filename} (${doc.contentType || '未分类'}) [${doc.status}]`); + } + + // 5. 测试 RAG 查询 + console.log('\n' + '='.repeat(60)); + console.log('🔍 RAG 查询测试'); + console.log('='.repeat(60)); + + const searchService = getVectorSearchService(prisma); + + const testQueries = [ + '入排标准是什么?', + '纳入标准有哪些?', + '排除标准是什么?', + '研究方案的主要目的是什么?', + ]; + + for (const query of testQueries) { + console.log(`\n📝 查询: "${query}"`); + + try { + const results = await searchService.vectorSearch(query, { + topK: 3, + minScore: 0.3, + filter: { kbId }, + }); + + if (results.length === 0) { + console.log(' ⚠️ 未找到相关结果'); + } else { + console.log(` ✅ 找到 ${results.length} 条相关结果:`); + for (const result of results) { + const score = (result.score * 100).toFixed(1); + const preview = result.content.substring(0, 100).replace(/\n/g, ' '); + console.log(` - [${score}%] ${preview}...`); + } + } + } catch (err) { + console.log(' ❌ 查询失败:', err instanceof Error ? err.message : err); + } + } + + console.log('\n' + '='.repeat(60)); + console.log('✅ 知识库 RAG 测试完成'); + console.log('='.repeat(60)); + + } catch (error) { + console.error('❌ 测试失败:', error); + } finally { + await prisma.$disconnect(); + } +} + +testKnowledgeBase(); diff --git a/backend/src/modules/iit-manager/test-qc-integration.ts b/backend/src/modules/iit-manager/test-qc-integration.ts new file mode 100644 index 00000000..e29fbb66 --- /dev/null +++ b/backend/src/modules/iit-manager/test-qc-integration.ts @@ -0,0 +1,259 @@ +/** + * 质控功能端到端集成测试 + * + * 测试完整流程: + * 1. 从数据库加载 QC 规则配置 + * 2. 创建 HardRuleEngine + * 3. 执行质控检查 + * 4. 输出报告 + * + * 运行方式:npx tsx src/modules/iit-manager/test-qc-integration.ts + */ + +import { PrismaClient } from '@prisma/client'; +import { createHardRuleEngine } from './engines/HardRuleEngine.js'; + +const prisma = new PrismaClient(); + +async function main() { + console.log('🧪 质控功能端到端集成测试\n'); + console.log('='.repeat(60)); + + try { + // 1. 检查数据库连接 + console.log('\n📡 检查数据库连接...'); + await prisma.$connect(); + console.log('✅ 数据库连接成功'); + + // 2. 查询项目配置 + console.log('\n📋 查询项目配置...'); + const project = await prisma.iitProject.findFirst({ + where: { status: 'active' }, + select: { + id: true, + name: true, + description: true + } + }); + + if (!project) { + console.log('⚠️ 未找到活跃项目,请先创建项目配置'); + console.log(' 运行: npx tsx prisma/seed-iit-qc-rules.ts'); + process.exit(1); + } + + console.log(`✅ 找到项目: ${project.name} (ID: ${project.id})`); + + // 3. 查询 QC 规则 + console.log('\n📋 查询 QC 规则配置...'); + const skill = await prisma.iitSkill.findFirst({ + where: { + projectId: project.id, + skillType: 'qc_process', + isActive: true + } + }); + + if (!skill) { + console.log('⚠️ 未找到 QC 规则配置'); + console.log(' 运行: npx tsx prisma/seed-iit-qc-rules.ts'); + process.exit(1); + } + + const config = skill.config as any; + const ruleCount = config.rules?.length || 0; + console.log(`✅ 找到 ${ruleCount} 条 QC 规则`); + + // 4. 查询字段映射 + console.log('\n📋 查询字段映射...'); + const mappings = await prisma.iitFieldMapping.findMany({ + where: { projectId: project.id } + }); + console.log(`✅ 找到 ${mappings.length} 条字段映射`); + + // 5. 创建 HardRuleEngine + console.log('\n🔧 创建 HardRuleEngine...'); + const engine = await createHardRuleEngine(project.id); + const stats = engine.getRuleStats(); + console.log(`✅ 引擎创建成功`); + console.log(` - 总规则数: ${stats.total}`); + console.log(` - 按类别: ${JSON.stringify(stats.byCategory)}`); + console.log(` - 按严重度: ${JSON.stringify(stats.bySeverity)}`); + + // 6. 测试用例 + console.log('\n' + '='.repeat(60)); + console.log('📊 执行测试用例\n'); + + // 完整的测试数据(包含所有必填字段) + // 注意:字段名需要与 seed-iit-qc-rules.ts 中的规则定义一致 + const baseRecord = { + birth_date: '2000-01-15', // 在 1989-2008 范围内 + informed_consent: 1, // 已签署知情同意书 + pregnancy_lactation: 0, // 非妊娠/哺乳期(字段名与规则一致) + secondary_dysmenorrhea: 0, // 非继发性痛经 + severe_disease: 0, // 无严重疾病 + irregular_menstruation: 0, // 月经规律 + wbc: 5.5, + rbc: 4.2, + hemoglobin: 125, + platelet: 200, + neutrophil_ratio: 60, + lymphocyte_ratio: 30, + alt: 25, + ast: 22, + bun: 5.0, + creatinine: 60, + fasting_glucose: 5.0, + cholesterol: 4.5, + triglyceride: 1.2, + urine_protein: 0, + urine_glucose: 0, + urine_wbc: 0, + estradiol: 100, + progesterone: 0.5, + fsh: 6, + lh: 5 + }; + + const testCases = [ + { + name: '完美记录 - 应该通过', + recordId: 'TEST_001', + data: { + ...baseRecord, + age: 25, + menstrual_cycle: 28, + vas_score: 7 + }, + expected: 'PASS' + }, + { + name: '年龄超标 - 应该失败', + recordId: 'TEST_002', + data: { + ...baseRecord, + age: 45, // 超出 16-35 范围 + menstrual_cycle: 28, + vas_score: 7 + }, + expected: 'FAIL' + }, + { + name: '实验室值异常 - 应该警告', + recordId: 'TEST_003', + data: { + ...baseRecord, + age: 25, + menstrual_cycle: 28, + vas_score: 7, + wbc: 12.0, // 偏高 (正常 3.5-9.5) + hemoglobin: 100 // 偏低 (正常 115-150) + }, + expected: 'WARNING' + }, + { + name: 'VAS评分不足 - 应该失败', + recordId: 'TEST_004', + data: { + ...baseRecord, + age: 25, + menstrual_cycle: 28, + vas_score: 2 // 低于 4 分 + }, + expected: 'FAIL' + }, + { + name: '妊娠状态异常 - 应该失败', + recordId: 'TEST_005', + data: { + ...baseRecord, + age: 25, + menstrual_cycle: 28, + vas_score: 7, + pregnancy_lactation: 1 // 妊娠/哺乳期 + }, + expected: 'FAIL' + } + ]; + + let passed = 0; + let failed = 0; + + for (const tc of testCases) { + const result = engine.execute(tc.recordId, tc.data); + const success = result.overallStatus === tc.expected; + + if (success) { + console.log(`✅ ${tc.name}`); + console.log(` 状态: ${result.overallStatus} | 错误: ${result.errors.length} | 警告: ${result.warnings.length}`); + passed++; + } else { + console.log(`❌ ${tc.name}`); + console.log(` 期望: ${tc.expected}, 实际: ${result.overallStatus}`); + console.log(` 错误: ${result.errors.map(e => e.ruleName).join(', ')}`); + failed++; + } + console.log(''); + } + + // 7. 模拟批量质控报告 + console.log('='.repeat(60)); + console.log('📊 批量质控模拟报告\n'); + + const mockRecords = [ + { recordId: '001', data: { ...baseRecord, age: 25, menstrual_cycle: 28, vas_score: 7 } }, // 正常 + { recordId: '002', data: { ...baseRecord, age: 28, menstrual_cycle: 30, vas_score: 5 } }, // 正常 + { recordId: '003', data: { ...baseRecord, age: 22, menstrual_cycle: 26, vas_score: 8 } }, // 正常 + { recordId: '004', data: { ...baseRecord, age: 40, menstrual_cycle: 28, vas_score: 6 } }, // 超龄 (FAIL) + { recordId: '005', data: { ...baseRecord, age: 30, menstrual_cycle: 28, vas_score: 3 } }, // VAS不足 (FAIL) + { recordId: '006', data: { ...baseRecord, age: 26, menstrual_cycle: 27, vas_score: 6, wbc: 11.0, hemoglobin: 110 } }, // 实验室异常 (WARNING) + { recordId: '007', data: { ...baseRecord, age: 24, menstrual_cycle: 28, vas_score: 5, pregnancy_lactation: 1 } }, // 妊娠 (FAIL) + { recordId: '008', data: { ...baseRecord, age: 27, menstrual_cycle: 29, vas_score: 6 } }, // 正常 + { recordId: '009', data: { ...baseRecord, age: 23, menstrual_cycle: 25, vas_score: 7 } }, // 正常 + { recordId: '010', data: { ...baseRecord, age: 29, menstrual_cycle: 31, vas_score: 4 } }, // 正常 + ]; + + const batchResults = engine.executeBatch(mockRecords); + + const passCount = batchResults.filter(r => r.overallStatus === 'PASS').length; + const failCount = batchResults.filter(r => r.overallStatus === 'FAIL').length; + const warningCount = batchResults.filter(r => r.overallStatus === 'WARNING').length; + + console.log(`📈 批量质控结果 (${mockRecords.length} 条记录)`); + console.log(` ✅ 通过 (PASS): ${passCount} (${((passCount / mockRecords.length) * 100).toFixed(1)}%)`); + console.log(` ❌ 失败 (FAIL): ${failCount} (${((failCount / mockRecords.length) * 100).toFixed(1)}%)`); + console.log(` ⚠️ 警告 (WARNING): ${warningCount} (${((warningCount / mockRecords.length) * 100).toFixed(1)}%)`); + + console.log('\n📋 问题记录详情:'); + for (const r of batchResults.filter(r => r.overallStatus !== 'PASS')) { + console.log(` - 记录 ${r.recordId}: ${r.overallStatus}`); + if (r.errors.length > 0) { + r.errors.forEach(e => console.log(` ❌ ${e.ruleName}: ${e.message} (值: ${e.actualValue})`)); + } + if (r.warnings.length > 0) { + r.warnings.forEach(w => console.log(` ⚠️ ${w.ruleName}: ${w.message} (值: ${w.actualValue})`)); + } + } + + // 8. 测试结果汇总 + console.log('\n' + '='.repeat(60)); + console.log(`\n📊 测试结果汇总`); + console.log(` ✅ 通过: ${passed}`); + console.log(` ❌ 失败: ${failed}`); + + if (failed === 0) { + console.log('\n🎉 所有测试通过!质控功能集成正常。\n'); + } else { + console.log('\n⚠️ 部分测试失败,请检查规则配置。\n'); + } + + } catch (error: any) { + console.error('\n❌ 测试失败:', error.message); + console.error(error.stack); + process.exit(1); + } finally { + await prisma.$disconnect(); + } +} + +main(); diff --git a/backend/src/modules/iit-manager/test-record-detail.ts b/backend/src/modules/iit-manager/test-record-detail.ts new file mode 100644 index 00000000..2caabdff --- /dev/null +++ b/backend/src/modules/iit-manager/test-record-detail.ts @@ -0,0 +1,162 @@ +/** + * 测试脚本:检查 REDCap API 获取记录详情的完整性 + * + * 目的:排查为什么 AI 只能获取部分记录信息 + */ + +import { PrismaClient } from '@prisma/client'; +import { RedcapAdapter } from './adapters/RedcapAdapter.js'; + +const prisma = new PrismaClient(); + +async function testRecordDetail() { + console.log('='.repeat(70)); + console.log('🔍 REDCap 记录详情获取测试'); + console.log('='.repeat(70)); + + try { + // 1. 获取项目配置 + const project = await prisma.iitProject.findFirst({ + where: { status: 'active' }, + select: { + id: true, + name: true, + redcapUrl: true, + redcapApiToken: true, + } + }); + + if (!project) { + console.log('❌ 未找到活跃项目'); + return; + } + + console.log('\n📁 项目配置:'); + console.log(' - ID:', project.id); + console.log(' - 名称:', project.name); + console.log(' - REDCap URL:', project.redcapUrl); + console.log(' - API Token:', project.redcapApiToken?.substring(0, 8) + '...'); + + // 2. 创建 RedcapAdapter + const adapter = new RedcapAdapter(project.redcapUrl, project.redcapApiToken!); + + // 3. 测试连接 + console.log('\n🔗 测试 REDCap 连接...'); + const connected = await adapter.testConnection(); + console.log(' - 连接状态:', connected ? '✅ 成功' : '❌ 失败'); + + if (!connected) { + return; + } + + // 4. 获取记录总数 + const totalRecords = await adapter.getRecordCount(); + console.log(' - 记录总数:', totalRecords); + + // 5. 获取记录 10 的详细信息 + const recordId = '10'; + console.log(`\n📋 获取记录 ${recordId} 的详细信息...`); + console.log('-'.repeat(70)); + + // 方法1:使用 getRecordById + console.log('\n【方法1】使用 getRecordById():'); + const record = await adapter.getRecordById(recordId); + + if (!record) { + console.log(' ❌ 未找到记录'); + } else { + console.log(' ✅ 找到记录'); + console.log(' - 返回数据类型:', typeof record); + console.log(' - 是否为数组:', Array.isArray(record)); + + // 如果是对象,显示所有字段 + if (typeof record === 'object' && !Array.isArray(record)) { + const keys = Object.keys(record); + console.log(` - 字段数量: ${keys.length}`); + console.log('\n 📊 所有字段及值:'); + + // 按字段名排序显示 + keys.sort().forEach(key => { + const value = record[key]; + const displayValue = value === '' ? '(空)' : + value === null ? '(null)' : + value === undefined ? '(undefined)' : + String(value).length > 50 ? String(value).substring(0, 50) + '...' : value; + console.log(` - ${key}: ${displayValue}`); + }); + + // 统计非空字段 + const nonEmptyFields = keys.filter(k => record[k] !== '' && record[k] !== null && record[k] !== undefined); + console.log(`\n 📈 统计:`); + console.log(` - 总字段数: ${keys.length}`); + console.log(` - 非空字段: ${nonEmptyFields.length}`); + console.log(` - 空字段: ${keys.length - nonEmptyFields.length}`); + } + } + + // 方法2:使用 exportRecords 直接导出 + console.log('\n【方法2】使用 exportRecords() 直接查询:'); + const exportedRecords = await adapter.exportRecords({ + records: [recordId] + }); + + console.log(' - 返回记录数:', exportedRecords.length); + + if (exportedRecords.length > 0) { + console.log(' - 第一条记录的字段数:', Object.keys(exportedRecords[0]).length); + + // 如果有多条记录(多个 event),显示每条 + exportedRecords.forEach((rec, idx) => { + const eventName = rec.redcap_event_name || rec.event_id || '默认事件'; + const nonEmpty = Object.keys(rec).filter(k => rec[k] !== '' && rec[k] !== null).length; + console.log(` - 记录 ${idx + 1} (${eventName}): ${nonEmpty} 个非空字段`); + }); + } + + // 方法3:获取项目元数据,看有哪些表单 + console.log('\n【方法3】获取项目元数据:'); + const metadata = await adapter.exportMetadata(); + console.log(' - 元数据字段总数:', metadata.length); + + // 按表单分组统计 + const formStats: Record = {}; + metadata.forEach((field: any) => { + const formName = field.form_name || '未知表单'; + formStats[formName] = (formStats[formName] || 0) + 1; + }); + + console.log('\n 📝 表单列表:'); + Object.entries(formStats).forEach(([form, count]) => { + console.log(` - ${form}: ${count} 个字段`); + }); + + // 6. 比较 getRecordById 返回的字段与元数据定义的字段 + console.log('\n【分析】比较实际返回字段与元数据定义:'); + if (record && typeof record === 'object') { + const returnedFields = Object.keys(record); + const definedFields = metadata.map((m: any) => m.field_name); + + // 找出元数据中定义但未返回的字段 + const missingFields = definedFields.filter((f: string) => !returnedFields.includes(f)); + + console.log(` - 元数据定义字段数: ${definedFields.length}`); + console.log(` - 实际返回字段数: ${returnedFields.length}`); + console.log(` - 缺失字段数: ${missingFields.length}`); + + if (missingFields.length > 0 && missingFields.length <= 20) { + console.log(` - 缺失字段列表: ${missingFields.join(', ')}`); + } + } + + console.log('\n' + '='.repeat(70)); + console.log('✅ 测试完成'); + console.log('='.repeat(70)); + + } catch (error) { + console.error('❌ 测试失败:', error); + } finally { + await prisma.$disconnect(); + } +} + +testRecordDetail(); diff --git a/backend/src/modules/iit-manager/test-record-with-labels.ts b/backend/src/modules/iit-manager/test-record-with-labels.ts new file mode 100644 index 00000000..cd08a2e3 --- /dev/null +++ b/backend/src/modules/iit-manager/test-record-with-labels.ts @@ -0,0 +1,175 @@ +/** + * 测试脚本:查看带中文标签和审计信息的记录数据 + * + * 展示: + * 1. Field Name ↔ Field Label 映射 + * 2. 选项值 → 选项标签 转换 + * 3. 审计信息(谁、什么时间录入) + */ + +import { PrismaClient } from '@prisma/client'; +import { RedcapAdapter } from './adapters/RedcapAdapter.js'; + +const prisma = new PrismaClient(); + +async function testRecordWithLabels() { + console.log('='.repeat(100)); + console.log('📊 记录数据展示(带中文标签 + 审计信息)'); + console.log('='.repeat(100)); + + try { + // 1. 获取项目配置 + const project = await prisma.iitProject.findFirst({ + where: { status: 'active' }, + select: { + name: true, + redcapUrl: true, + redcapApiToken: true, + } + }); + + if (!project) { + console.log('❌ 未找到活跃项目'); + return; + } + + console.log(`\n📁 项目: ${project.name}`); + + const adapter = new RedcapAdapter(project.redcapUrl, project.redcapApiToken!); + + // 2. 先展示字段映射表 + console.log('\n' + '='.repeat(100)); + console.log('📝 字段映射表(Field Name ↔ Field Label)'); + console.log('='.repeat(100)); + + const fieldMap = await adapter.getFieldInfoMap(); + console.log(`\n共 ${fieldMap.size} 个字段:\n`); + + // 按表单分组显示 + const byForm: Map = new Map(); + for (const [name, info] of fieldMap) { + const formName = info.formName; + if (!byForm.has(formName)) { + byForm.set(formName, []); + } + byForm.get(formName)!.push(info); + } + + for (const [formName, fields] of byForm) { + console.log(`\n【表单: ${formName}】`); + console.log('┌' + '─'.repeat(30) + '┬' + '─'.repeat(40) + '┬' + '─'.repeat(15) + '┬' + '─'.repeat(10) + '┐'); + console.log(`│ ${'Field Name'.padEnd(28)} │ ${'Field Label'.padEnd(38)} │ ${'类型'.padEnd(13)} │ ${'必填'.padEnd(8)} │`); + console.log('├' + '─'.repeat(30) + '┼' + '─'.repeat(40) + '┼' + '─'.repeat(15) + '┼' + '─'.repeat(10) + '┤'); + + for (const field of fields) { + const name = field.fieldName.substring(0, 28).padEnd(28); + const label = field.fieldLabel.substring(0, 38).padEnd(38); + const type = field.fieldType.padEnd(13); + const required = (field.required ? '是' : '否').padEnd(8); + console.log(`│ ${name} │ ${label} │ ${type} │ ${required} │`); + + // 如果有选项,显示选项 + if (field.choicesParsed && field.choicesParsed.length > 0) { + const choices = field.choicesParsed.map((c: any) => `${c.value}=${c.label}`).join(', '); + console.log(`│ ${'选项: '.padEnd(26)} │ ${choices.substring(0, 38).padEnd(38)} │ ${''.padEnd(13)} │ ${''.padEnd(8)} │`); + } + } + console.log('└' + '─'.repeat(30) + '┴' + '─'.repeat(40) + '┴' + '─'.repeat(15) + '┴' + '─'.repeat(10) + '┘'); + } + + // 3. 展示记录1的详细数据(带中文标签) + console.log('\n' + '='.repeat(100)); + console.log('📋 记录 1 详细数据(带中文标签)'); + console.log('='.repeat(100)); + + const record1 = await adapter.getRecordWithLabels('1'); + if (record1) { + // 显示审计信息 + if (record1.auditInfo) { + console.log('\n【审计信息】'); + console.log(` - 创建时间: ${record1.auditInfo.createdAt || '未知'}`); + console.log(` - 创建者: ${record1.auditInfo.createdBy || '未知'}`); + console.log(` - 最后修改: ${record1.auditInfo.lastModifiedAt || '未知'}`); + console.log(` - 修改者: ${record1.auditInfo.lastModifiedBy || '未知'}`); + } + + // 按表单分组显示数据 + const dataByForm: Map = new Map(); + for (const item of record1.data) { + if (!dataByForm.has(item.formName)) { + dataByForm.set(item.formName, []); + } + dataByForm.get(item.formName)!.push(item); + } + + for (const [formName, items] of dataByForm) { + console.log(`\n【${formName}】`); + for (const item of items) { + if (item.value !== '' && item.value !== null && item.value !== undefined) { + const valueDisplay = item.value !== item.displayValue + ? `${item.value} → ${item.displayValue}` + : item.value; + console.log(` - ${item.fieldLabel} (${item.fieldName}): ${valueDisplay}`); + } + } + } + + // 统计 + const nonEmpty = record1.data.filter(d => d.value !== '' && d.value !== null); + console.log(`\n【统计】非空字段: ${nonEmpty.length} / ${record1.data.length}`); + } else { + console.log('❌ 记录不存在'); + } + + // 4. 展示记录10的详细数据 + console.log('\n' + '='.repeat(100)); + console.log('📋 记录 10 详细数据(带中文标签)'); + console.log('='.repeat(100)); + + const record10 = await adapter.getRecordWithLabels('10'); + if (record10) { + if (record10.auditInfo) { + console.log('\n【审计信息】'); + console.log(` - 创建时间: ${record10.auditInfo.createdAt || '未知'}`); + console.log(` - 创建者: ${record10.auditInfo.createdBy || '未知'}`); + console.log(` - 最后修改: ${record10.auditInfo.lastModifiedAt || '未知'}`); + console.log(` - 修改者: ${record10.auditInfo.lastModifiedBy || '未知'}`); + } + + // 按表单分组显示 + const dataByForm: Map = new Map(); + for (const item of record10.data) { + if (!dataByForm.has(item.formName)) { + dataByForm.set(item.formName, []); + } + dataByForm.get(item.formName)!.push(item); + } + + for (const [formName, items] of dataByForm) { + console.log(`\n【${formName}】`); + for (const item of items) { + if (item.value !== '' && item.value !== null && item.value !== undefined) { + const valueDisplay = item.value !== item.displayValue + ? `${item.value} → ${item.displayValue}` + : item.value; + console.log(` - ${item.fieldLabel} (${item.fieldName}): ${valueDisplay}`); + } + } + } + + const nonEmpty = record10.data.filter(d => d.value !== '' && d.value !== null); + console.log(`\n【统计】非空字段: ${nonEmpty.length} / ${record10.data.length}`); + } + + console.log('\n' + '='.repeat(100)); + console.log('✅ 测试完成'); + console.log('='.repeat(100)); + + } catch (error) { + console.error('❌ 错误:', error); + } finally { + await prisma.$disconnect(); + } +} + +testRecordWithLabels(); diff --git a/backend/src/modules/iit-manager/test-sop-engine.ts b/backend/src/modules/iit-manager/test-sop-engine.ts new file mode 100644 index 00000000..e0e4f67f --- /dev/null +++ b/backend/src/modules/iit-manager/test-sop-engine.ts @@ -0,0 +1,104 @@ +/** + * SopEngine 测试脚本 + * + * 运行方式:npx tsx src/modules/iit-manager/test-sop-engine.ts + */ + +import { createSopEngine } from './engines/SopEngine.js'; + +async function main() { + console.log('🧪 SopEngine SOP 执行引擎测试\n'); + console.log('='.repeat(60)); + + try { + // 1. 创建 SopEngine + console.log('\n📦 创建 SopEngine...'); + const sopEngine = await createSopEngine('test0102-pd-study'); + console.log('✅ SopEngine 创建成功'); + + // 2. 列出已注册的 SOP + console.log('\n📋 已注册 SOP:'); + const qcSingleSop = sopEngine.getSop('qc_single_record'); + const qcBatchSop = sopEngine.getSop('qc_batch'); + + if (qcSingleSop) { + console.log(` - ${qcSingleSop.id}: ${qcSingleSop.name}`); + console.log(` 步骤数: ${qcSingleSop.steps.length}`); + } + if (qcBatchSop) { + console.log(` - ${qcBatchSop.id}: ${qcBatchSop.name}`); + console.log(` 步骤数: ${qcBatchSop.steps.length}`); + } + + // 3. 执行单条记录质控 SOP + console.log('\n' + '='.repeat(60)); + console.log('🔧 执行单条记录质控 SOP\n'); + + console.log('📌 执行 qc_single_record (recordId=1)...'); + const singleResult = await sopEngine.runQualityCheck('1', 'test-user'); + + console.log(`\n📊 执行结果:`); + console.log(` SOP: ${singleResult.sopName}`); + console.log(` 状态: ${singleResult.status}`); + console.log(` 步骤: ${singleResult.successCount}/${singleResult.stepCount} 成功`); + console.log(` 耗时: ${singleResult.duration}ms`); + + console.log(`\n📋 步骤详情:`); + for (const step of singleResult.stepResults) { + const icon = step.success ? '✅' : '❌'; + console.log(` ${icon} ${step.stepName}${step.error ? `: ${step.error}` : ''}`); + } + + console.log(`\n📝 汇总报告:`); + console.log('---'); + console.log(singleResult.summary); + console.log('---'); + + // 4. 执行批量质控 SOP + console.log('\n' + '='.repeat(60)); + console.log('🔧 执行批量质控 SOP\n'); + + console.log('📌 执行 qc_batch...'); + const batchResult = await sopEngine.runBatchQualityCheck('test-user'); + + console.log(`\n📊 执行结果:`); + console.log(` SOP: ${batchResult.sopName}`); + console.log(` 状态: ${batchResult.status}`); + console.log(` 步骤: ${batchResult.successCount}/${batchResult.stepCount} 成功`); + console.log(` 耗时: ${batchResult.duration}ms`); + + console.log(`\n📋 步骤详情:`); + for (const step of batchResult.stepResults) { + const icon = step.success ? '✅' : '❌'; + console.log(` ${icon} ${step.stepName}${step.error ? `: ${step.error}` : ''}`); + } + + console.log(`\n📝 汇总报告:`); + console.log('---'); + console.log(batchResult.summary); + console.log('---'); + + // 5. 测试不存在的 SOP + console.log('\n' + '='.repeat(60)); + console.log('🔧 边界情况测试\n'); + + console.log('📌 执行不存在的 SOP...'); + const invalidResult = await sopEngine.execute('invalid_sop', 'test-user'); + if (invalidResult.status === 'FAILED' && invalidResult.summary.includes('不存在')) { + console.log('✅ 正确处理不存在的 SOP'); + } else { + console.log('❌ 未正确处理不存在的 SOP'); + } + + // 6. 完成 + console.log('\n' + '='.repeat(60)); + console.log('\n🎉 SopEngine 测试完成!\n'); + + } catch (error: any) { + console.error('\n❌ 测试失败:', error.message); + console.error(error.stack); + process.exit(1); + } +} + +main().then(() => process.exit(0)); diff --git a/backend/src/modules/iit-manager/test-tools-service.ts b/backend/src/modules/iit-manager/test-tools-service.ts new file mode 100644 index 00000000..1d0fd3ea --- /dev/null +++ b/backend/src/modules/iit-manager/test-tools-service.ts @@ -0,0 +1,173 @@ +/** + * ToolsService 测试脚本 + * + * 运行方式:npx tsx src/modules/iit-manager/test-tools-service.ts + */ + +import { createToolsService } from './services/ToolsService.js'; + +async function main() { + console.log('🧪 ToolsService 工具框架测试\n'); + console.log('='.repeat(60)); + + try { + // 1. 创建 ToolsService + console.log('\n📦 创建 ToolsService...'); + const toolsService = await createToolsService('test0102-pd-study'); + console.log('✅ ToolsService 创建成功'); + + // 2. 列出所有工具 + console.log('\n📋 已注册工具:'); + const tools = toolsService.getAllTools(); + for (const tool of tools) { + console.log(` - ${tool.name} [${tool.category}]: ${tool.description.slice(0, 50)}...`); + } + console.log(` 共 ${tools.length} 个工具`); + + // 3. 列出只读工具 + console.log('\n🔒 只读工具(用于 ReAct 模式):'); + const readOnlyTools = toolsService.getReadOnlyTools(); + for (const tool of readOnlyTools) { + console.log(` - ${tool.name}`); + } + + // 4. 生成 LLM 工具描述 + console.log('\n📝 LLM 工具描述示例:'); + const llmTools = toolsService.getLLMToolDescriptions(true); + console.log(JSON.stringify(llmTools[0], null, 2)); + + // 5. 生成 Prompt 格式的工具说明 + console.log('\n📄 Prompt 工具说明:'); + const prompt = toolsService.getToolsPrompt(true); + console.log(prompt.slice(0, 500) + '...'); + + // 6. 测试工具执行 + console.log('\n' + '='.repeat(60)); + console.log('🔧 工具执行测试\n'); + + // 6.1 get_project_info + console.log('📌 测试 get_project_info...'); + const projectResult = await toolsService.execute( + 'get_project_info', + {}, + 'test-user' + ); + if (projectResult.success) { + console.log('✅ get_project_info 成功'); + console.log(` 项目名称: ${projectResult.data?.name}`); + console.log(` 执行时间: ${projectResult.metadata?.executionTime}ms`); + } else { + console.log(`❌ get_project_info 失败: ${projectResult.error}`); + } + + // 6.2 count_records + console.log('\n📌 测试 count_records...'); + const countResult = await toolsService.execute( + 'count_records', + {}, + 'test-user' + ); + if (countResult.success) { + console.log('✅ count_records 成功'); + console.log(` 记录总数: ${countResult.data?.totalRecords}`); + } else { + console.log(`⚠️ count_records: ${countResult.error}`); + } + + // 6.3 read_clinical_data(单条记录) + console.log('\n📌 测试 read_clinical_data (record_id=1)...'); + const readResult = await toolsService.execute( + 'read_clinical_data', + { record_id: '1' }, + 'test-user' + ); + if (readResult.success) { + console.log('✅ read_clinical_data 成功'); + console.log(` 记录数: ${readResult.data?.length || 0}`); + if (readResult.data?.[0]) { + const record = readResult.data[0]; + console.log(` 示例字段: record_id=${record.record_id}`); + } + } else { + console.log(`⚠️ read_clinical_data: ${readResult.error}`); + } + + // 6.4 run_quality_check + console.log('\n📌 测试 run_quality_check (record_id=1)...'); + const qcResult = await toolsService.execute( + 'run_quality_check', + { record_id: '1' }, + 'test-user' + ); + if (qcResult.success) { + console.log('✅ run_quality_check 成功'); + console.log(` 状态: ${qcResult.data?.overallStatus}`); + console.log(` 错误数: ${qcResult.data?.summary?.failed}`); + console.log(` 警告数: ${qcResult.data?.summary?.warnings}`); + } else { + console.log(`⚠️ run_quality_check: ${qcResult.error}`); + } + + // 6.5 batch_quality_check + console.log('\n📌 测试 batch_quality_check...'); + const batchResult = await toolsService.execute( + 'batch_quality_check', + {}, + 'test-user' + ); + if (batchResult.success) { + console.log('✅ batch_quality_check 成功'); + if (batchResult.data?.summary) { + console.log(` 总记录数: ${batchResult.data.totalRecords}`); + console.log(` 通过率: ${batchResult.data.summary.passRate}`); + console.log(` 问题记录: ${batchResult.data.problemRecords?.length || 0} 条`); + } else { + console.log(` ${batchResult.data?.message}`); + } + } else { + console.log(`⚠️ batch_quality_check: ${batchResult.error}`); + } + + // 6.6 测试参数验证 + console.log('\n📌 测试参数验证 (缺少必填参数)...'); + const invalidResult = await toolsService.execute( + 'run_quality_check', + {}, // 缺少 record_id + 'test-user' + ); + if (!invalidResult.success && invalidResult.error?.includes('缺少必填参数')) { + console.log('✅ 参数验证正常工作'); + console.log(` 错误信息: ${invalidResult.error}`); + } else { + console.log('❌ 参数验证未按预期工作'); + } + + // 7. 测试字段映射 + console.log('\n' + '='.repeat(60)); + console.log('🔄 字段映射测试\n'); + + console.log('📌 测试使用中文别名查询字段...'); + const mappedResult = await toolsService.execute( + 'read_clinical_data', + { fields: ['年龄', '月经周期', 'vas_score'] }, // 混合使用别名和实际字段名 + 'test-user' + ); + if (mappedResult.success) { + console.log('✅ 字段映射查询成功'); + console.log(` 记录数: ${mappedResult.data?.length || 0}`); + } else { + console.log(`⚠️ 字段映射查询: ${mappedResult.error}`); + } + + // 8. 完成 + console.log('\n' + '='.repeat(60)); + console.log('\n🎉 ToolsService 测试完成!\n'); + + } catch (error: any) { + console.error('\n❌ 测试失败:', error.message); + console.error(error.stack); + process.exit(1); + } +} + +main().then(() => process.exit(0)); diff --git a/backend/src/modules/iit-manager/update-project-config.ts b/backend/src/modules/iit-manager/update-project-config.ts new file mode 100644 index 00000000..25197d21 --- /dev/null +++ b/backend/src/modules/iit-manager/update-project-config.ts @@ -0,0 +1,64 @@ +/** + * 更新本地开发环境项目配置 + * + * 运行方式:npx tsx src/modules/iit-manager/update-project-config.ts + */ + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + console.log('🔧 更新项目配置\n'); + + // 1. 查看当前配置 + console.log('📋 当前项目配置:'); + const currentProjects = await prisma.iitProject.findMany({ + select: { + id: true, + name: true, + redcapProjectId: true, + redcapUrl: true, + status: true + } + }); + console.log(JSON.stringify(currentProjects, null, 2)); + + // 2. 更新项目配置 + const projectId = 'test0102-pd-study'; + const newConfig = { + name: 'test0207', + redcapProjectId: '17', + redcapUrl: 'http://localhost:8080', // Docker 内 REDCap,基础 URL(不含 /api/) + redcapApiToken: '5A296DC36E9EB20618885684AA8AEE8A' + }; + + console.log('\n📝 更新配置:'); + console.log(` 项目ID: ${projectId}`); + console.log(` 新名称: ${newConfig.name}`); + console.log(` REDCap PID: ${newConfig.redcapProjectId}`); + console.log(` REDCap URL: ${newConfig.redcapUrl}`); + console.log(` API Token: ${newConfig.redcapApiToken.slice(0, 8)}...`); + + const updated = await prisma.iitProject.update({ + where: { id: projectId }, + data: newConfig + }); + + console.log('\n✅ 更新成功!'); + console.log(JSON.stringify({ + id: updated.id, + name: updated.name, + redcapProjectId: updated.redcapProjectId, + redcapUrl: updated.redcapUrl, + status: updated.status + }, null, 2)); + + await prisma.$disconnect(); +} + +main().catch(async (e) => { + console.error('❌ 更新失败:', e.message); + await prisma.$disconnect(); + process.exit(1); +}); diff --git a/backend/src/modules/iit-manager/update-qc-rule.ts b/backend/src/modules/iit-manager/update-qc-rule.ts new file mode 100644 index 00000000..b8458a6d --- /dev/null +++ b/backend/src/modules/iit-manager/update-qc-rule.ts @@ -0,0 +1,104 @@ +/** + * 更新质控规则脚本 + * 将年龄范围从 16-35 修改为 25-35 + */ + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +interface QCRule { + id: string; + name: string; + field: string | string[]; + logic: Record; + message: string; + severity: 'error' | 'warning' | 'info'; + category: string; +} + +interface QCRuleConfig { + rules: QCRule[]; + version: number; + updatedAt: string; +} + +async function updateAgeRule() { + try { + // 查找项目的质控规则 + const skill = await prisma.iitSkill.findFirst({ + where: { + skillType: 'qc_process', + }, + }); + + if (!skill) { + console.log('❌ 未找到质控规则配置'); + return; + } + + console.log('✅ 找到质控规则配置,项目 ID:', skill.projectId); + + const config = skill.config as unknown as QCRuleConfig; + console.log('📋 当前规则数量:', config.rules.length); + + // 找到年龄范围规则并修改 + let ageRuleFound = false; + for (const rule of config.rules) { + if (rule.id === 'age_range' || rule.name.includes('年龄')) { + console.log('\n📌 找到年龄规则:'); + console.log(' - 名称:', rule.name); + console.log(' - 原始逻辑:', JSON.stringify(rule.logic, null, 2)); + console.log(' - 原始消息:', rule.message); + + // 修改逻辑:16-35 改为 25-35 + rule.logic = { + "and": [ + { ">=": [{ "var": "age" }, 25] }, + { "<=": [{ "var": "age" }, 35] } + ] + }; + rule.message = '年龄不在 25-35 岁范围内'; + + console.log('\n📝 修改后:'); + console.log(' - 新逻辑:', JSON.stringify(rule.logic, null, 2)); + console.log(' - 新消息:', rule.message); + + ageRuleFound = true; + } + } + + if (!ageRuleFound) { + console.log('❌ 未找到年龄范围规则'); + console.log('📋 现有规则 ID:'); + for (const rule of config.rules) { + console.log(' -', rule.id, ':', rule.name); + } + return; + } + + // 更新版本号和时间 + config.version = (config.version || 1) + 1; + config.updatedAt = new Date().toISOString(); + + // 保存到数据库 + await prisma.iitSkill.update({ + where: { id: skill.id }, + data: { + config: config as any, + updatedAt: new Date(), + }, + }); + + console.log('\n✅ 质控规则更新成功!'); + console.log(' - 新版本:', config.version); + console.log(' - 更新时间:', config.updatedAt); + + } catch (error) { + console.error('❌ 更新失败:', error); + } finally { + await prisma.$disconnect(); + } +} + +updateAgeRule(); diff --git a/backend/src/modules/iit-manager/verify-qc-rule.ts b/backend/src/modules/iit-manager/verify-qc-rule.ts new file mode 100644 index 00000000..0586ac68 --- /dev/null +++ b/backend/src/modules/iit-manager/verify-qc-rule.ts @@ -0,0 +1,92 @@ +/** + * 验证质控规则脚本 + * 检查年龄范围规则是否已更新为 25-35 + */ + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +interface QCRule { + id: string; + name: string; + field: string | string[]; + logic: Record; + message: string; + severity: 'error' | 'warning' | 'info'; + category: string; +} + +interface QCRuleConfig { + rules: QCRule[]; + version: number; + updatedAt: string; +} + +async function verifyAgeRule() { + try { + // 查找项目的质控规则 + const skills = await prisma.iitSkill.findMany({ + where: { + skillType: 'qc_process', + }, + select: { + id: true, + projectId: true, + config: true, + updatedAt: true, + }, + }); + + if (skills.length === 0) { + console.log('❌ 未找到质控规则配置'); + return; + } + + console.log(`✅ 找到 ${skills.length} 个质控规则配置\n`); + + for (const skill of skills) { + console.log('='.repeat(50)); + console.log('📁 项目 ID:', skill.projectId); + console.log('📅 最后更新:', skill.updatedAt); + + const config = skill.config as unknown as QCRuleConfig; + console.log('📋 规则数量:', config.rules?.length || 0); + console.log('📌 配置版本:', config.version); + + // 找到年龄范围规则 + const ageRule = config.rules?.find(r => r.id === 'age_range' || r.name.includes('年龄')); + + if (ageRule) { + console.log('\n🔍 年龄规则详情:'); + console.log(' - ID:', ageRule.id); + console.log(' - 名称:', ageRule.name); + console.log(' - 消息:', ageRule.message); + console.log(' - 逻辑:', JSON.stringify(ageRule.logic, null, 4)); + + // 检查是否为 25-35 + const logicStr = JSON.stringify(ageRule.logic); + if (logicStr.includes('25') && logicStr.includes('35')) { + console.log('\n✅ 年龄规则已更新为 25-35 范围!'); + } else if (logicStr.includes('16') && logicStr.includes('35')) { + console.log('\n⚠️ 年龄规则仍然是 16-35 范围,未更新'); + } else { + console.log('\n❓ 年龄规则范围:', logicStr); + } + } else { + console.log('\n⚠️ 未找到年龄规则'); + console.log('📋 所有规则:'); + for (const rule of config.rules || []) { + console.log(` - ${rule.id}: ${rule.name}`); + } + } + } + + } catch (error) { + console.error('❌ 查询失败:', error); + } finally { + await prisma.$disconnect(); + } +} + +verifyAgeRule(); diff --git a/docs/00-系统总体设计/00-系统当前状态与开发指南.md b/docs/00-系统总体设计/00-系统当前状态与开发指南.md index 3df39477..c4f0459d 100644 --- a/docs/00-系统总体设计/00-系统当前状态与开发指南.md +++ b/docs/00-系统总体设计/00-系统当前状态与开发指南.md @@ -1,10 +1,11 @@ # AIclinicalresearch 系统当前状态与开发指南 -> **文档版本:** v4.6 +> **文档版本:** v4.7 > **创建日期:** 2025-11-28 > **维护者:** 开发团队 -> **最后更新:** 2026-02-02 +> **最后更新:** 2026-02-05 > **🎉 重大里程碑:** +> - **2026-02-05:IIT Manager Agent V2.9.1 架构设计完成!** 双脑架构 + 三层记忆 + 主动性增强 + 隐私合规 > - **2026-02-02:REDCap 生产环境部署完成!** ECS + RDS + HTTPS + 域名全部配置完成 > - **2026-01-28:Prompt 知识库集成完成!** Prompt 可动态引用系统知识库内容 > - **2026-01-27:系统知识库管理功能完成!** 运营管理端新增知识库管理+文档上传下载 @@ -13,12 +14,12 @@ > - **2026-01-22:OSS 存储集成完成!** 阿里云 OSS 正式接入平台基础层 > - **2026-01-21:成功替换 Dify!** PKB 模块完全使用自研 pgvector RAG 引擎 > -> **最新进展(REDCap 生产环境部署 2026-02-02):** -> - ✅ **ECS 服务器配置**:Docker CE 26.1.3 + Docker Compose v2.27.0 -> - ✅ **RDS MySQL 配置**:redcap_prod 数据库 + utf8mb4 字符集 -> - ✅ **REDCap 15.8.0 部署**:Docker 容器化部署,数据库初始化完成 -> - ✅ **HTTPS 配置**:Nginx 反向代理 + 阿里云 SSL 证书 -> - ✅ **域名配置**:https://redcap.xunzhengyixue.com/ 正式上线 +> **最新进展(IIT Manager Agent V2.9.1 架构 2026-02-05):** +> - ✅ **双脑架构**:SOP 状态机(结构化任务) + ReAct 引擎(模糊查询) +> - ✅ **三层记忆**:流水账(全量日志) + 热记忆(高频注入) + 历史书(按需检索) +> - ✅ **主动性增强**:Cron Skill 定时任务 + 用户画像 + 反馈循环 +> - ✅ **隐私合规**:PII 脱敏中间件 + 审计日志 + 可恢复脱敏 +> - ✅ **自动化工具**:AutoMapper REDCap Schema 自动对齐 > > **部署状态:** ✅ 生产环境运行中 | 公网地址:http://8.140.53.236/ > **REDCap 状态:** ✅ 生产环境运行中 | 地址:https://redcap.xunzhengyixue.com/ @@ -58,7 +59,7 @@ | **PKB** | 个人知识库 | RAG问答、私人文献库 | ⭐⭐⭐ | 🎉 **Dify已替换!自研RAG上线(95%)** | P1 | | **ASL** | AI智能文献 | 文献筛选、Meta分析、证据图谱 | ⭐⭐⭐⭐⭐ | 🎉 **智能检索MVP完成(60%)** - DeepSearch集成 | **P0** | | **DC** | 数据清洗整理 | ETL + 医学NER(百万行级数据) | ⭐⭐⭐⭐⭐ | ✅ **Tool B完成 + Tool C 99%(异步架构+性能优化-99%+多指标转换+7大功能)** | **P0** | -| **IIT** | IIT Manager Agent | AI驱动IIT研究助手 - 智能质控+REDCap集成 | ⭐⭐⭐⭐⭐ | 🎉 **Phase 2.0(65%)- AI对话+REDCap生产环境部署完成** | **P0** | +| **IIT** | IIT Manager Agent | AI驱动IIT研究助手 - 双脑架构+REDCap集成 | ⭐⭐⭐⭐⭐ | 🎉 **V2.9.1 架构完成 - 准备实施(设计100%,代码25%)** | **P0** | | **SSA** | 智能统计分析 | 队列/预测模型/RCT分析 | ⭐⭐⭐⭐⭐ | 📋 规划中 | P2 | | **ST** | 统计分析工具 | 100+轻量化统计工具 | ⭐⭐⭐⭐ | 📋 规划中 | P2 | | **RVW** | 稿件审查系统 | 方法学评估、审稿流程、Word导出 | ⭐⭐⭐⭐ | ✅ **开发完成(95%)** | P3 | @@ -142,9 +143,44 @@ --- -## 🚀 当前开发状态(2026-01-25) +## 🚀 当前开发状态(2026-02-05) -### 🎉 最新进展:Protocol Agent MVP 完整交付(2026-01-25) +### 🎉 最新进展:IIT Manager Agent V2.9.1 架构设计完成(2026-02-05) + +#### ✅ 双脑架构 + 三层记忆 + 主动性增强 + 隐私合规 + +**重大里程碑**: +- 🎉 **完整架构设计**:V2.9.1 开发计划发布,6份模块化文档 +- 🎉 **双脑路由**:SOP 状态机处理结构化任务,ReAct 引擎处理模糊查询 +- 🎉 **三层记忆**:流水账(全量日志)+ 热记忆(高频注入)+ 历史书(按需检索) +- 🎉 **隐私合规**:PII 脱敏中间件,P0 合规必需 + +**核心设计**: +| 组件 | 功能 | 设计文档 | +|------|------|----------| +| 双脑架构 | SOP(写操作)+ ReAct(只读查询) | 02-核心引擎实现指南.md | +| 三层记忆 | 周报卷叠 + 意图驱动检索 | 01-数据库设计.md | +| 主动性增强 | Cron Skill + 用户画像 + 反馈循环 | 03-服务层实现指南.md | +| 隐私合规 | AnonymizerService + PII 审计日志 | 03-服务层实现指南.md | +| 自动化工具 | AutoMapper REDCap Schema 对齐 | 03-服务层实现指南.md | + +**开发阶段规划**: +| Phase | 内容 | 优先级 | +|-------|------|--------| +| Phase 1 | 基础工具层(ToolsService + HardRuleEngine) | P0 | +| Phase 1.5 | 隐私安全(AnonymizerService) | **P0 合规必需** | +| Phase 2 | SOP 引擎 + 热记忆 | P0 | +| Phase 3 | ReAct 引擎 + 流水账 | P0 | +| Phase 4 | 调度系统(Cron Skill + ProfilerService) | P1 | +| Phase 5 | 智能路由(IntentService) | P1 | + +**相关文档**: +- 综合开发计划:`docs/03-业务模块/IIT Manager Agent/04-开发计划/IIT Manager Agent V2.6 综合开发计划.md` +- 模块状态:`docs/03-业务模块/IIT Manager Agent/00-模块当前状态与开发指南.md` + +--- + +### 🎉 Protocol Agent MVP 完整交付(2026-01-25) #### ✅ 一键生成研究方案 + Word 导出 diff --git a/docs/03-业务模块/IIT Manager Agent/00-模块当前状态与开发指南.md b/docs/03-业务模块/IIT Manager Agent/00-模块当前状态与开发指南.md index 7aeacbf8..94b7d58d 100644 --- a/docs/03-业务模块/IIT Manager Agent/00-模块当前状态与开发指南.md +++ b/docs/03-业务模块/IIT Manager Agent/00-模块当前状态与开发指南.md @@ -1,10 +1,14 @@ # IIT Manager Agent模块 - 当前状态与开发指南 -> **文档版本:** v1.6 +> **文档版本:** v2.1 > **创建日期:** 2026-01-01 > **维护者:** IIT Manager开发团队 -> **最后更新:** 2026-01-04 🎉 **Dify知识库集成完成 - 混合检索实现!** -> **重大里程碑:** ✅ 混合检索架构实现(REDCap实时数据 + Dify文档知识库)! +> **最后更新:** 2026-02-07 🎉 **实时质控系统核心功能开发完成!** +> **重大里程碑:** +> - ✅ 2026-02-07:**实时质控系统开发完成**(pg-boss防抖 + 质控日志 + 录入汇总 + 管理端批量操作) +> - ✅ 2026-02-05:**V2.9.1 完整开发计划发布**(双脑架构 + 三层记忆 + 主动性增强 + 隐私合规) +> - ✅ 2026-02-02:**REDCap 生产环境部署完成**(ECS + RDS + HTTPS) +> - ✅ 2026-01-04:混合检索架构实现(REDCap实时数据 + Dify文档知识库) > **文档目的:** 反映模块真实状态,记录开发历程 --- @@ -27,70 +31,65 @@ IIT Manager Agent(研究者发起试验管理助手)是一个基于企业微信的主动式AI Agent产品,为IIT(研究者发起试验)提供智能化管理能力。 ### 架构设计 -- **核心理念**:Native Orchestration(原生编排)+ Dify RAG + Shadow State(影子状态) +- **核心理念**:**双脑架构(V2.9.1)** - SOP 状态机 + ReAct 引擎 + 三层记忆 + 隐私合规 +- **双脑路由**: + - **左脑(SOP 引擎)**:结构化任务执行,写操作必经 + - **右脑(ReAct 引擎)**:模糊查询,只读不写 - **技术栈**: - 后端:Node.js (Fastify) + PostgreSQL (Prisma) + pg-boss - - 前端:微信小程序 (Taro 4.x) + - 前端:企业微信 + 微信小程序 (Taro 4.x) - 数据源:REDCap (EDC系统) - 通知:企业微信 - - AI能力:Dify RAG + DeepSeek/Qwen + - AI能力:DeepSeek/Qwen + 自研 RAG ### 当前状态 -- **开发阶段**:🎉 **Phase 1.5完成 - 混合检索架构实现!** -- **整体完成度**:65%(Day 1-3 + Phase 1.5完成 + Dify集成完成) -- **已完成功能**: - - ✅ 数据库Schema创建(iit_schema,5个表) - - ✅ Prisma Schema编写(223行类型定义) - - ✅ 模块目录结构创建 - - ✅ 企业微信应用注册和配置 - - ✅ 企业微信Access Token获取成功 - - ✅ **企业微信可信域名配置成功**(devlocal.xunzhengyixue.com) - - ✅ **REDCap本地Docker环境部署成功**(15.8.0) - - ✅ **REDCap对接技术方案确定**(DET + REST API) - - ✅ **REDCap测试项目创建**(test0102, PID 16, 11条记录) - - ✅ **REDCap实时集成完成**(DET + REST API + WebhookController + SyncManager) - - ✅ **企业微信推送服务完成**(WechatService, 314行) - - ✅ **企业微信回调处理完成**(WechatCallbackController, 501行) - - ✅ **natapp内网穿透配置成功**(https://devlocal.xunzhengyixue.com + 公司备案域名) - - ✅ **RedcapAdapter API适配器完成**(271行,7个API方法) - - ✅ **WebhookController完成**(327行,<10ms响应) - - ✅ **SyncManager完成**(398行,增量+全量同步) - - ✅ **Worker注册完成**(iit_quality_check, iit_redcap_poll) - - ✅ **质控Worker完善**(质控逻辑 + 企业微信推送 + 审计日志) - - ✅ **🎯 端到端测试通过**(REDCap → Node.js → 企业微信,<2秒延迟) - - ✅ **🎯 MVP闭环完全打通**(100%消息成功率) - - ✅ **🚀 Phase 1.5: AI对话集成完成**(2026-01-03 & 2026-01-04) - - ✅ ChatService集成(485行) - - ✅ SessionMemory(120行,上下文记忆) - - ✅ REDCap数据查询集成(queryRedcapRecord, countRedcapRecords) - - ✅ **Dify知识库集成**(queryDifyKnowledge) - - ✅ **混合检索实现**(REDCap实时数据 + Dify文档知识库) - - ✅ **智能路由**(根据意图自动选择数据源) - - ✅ 意图识别优化(扩充医学关键词库) - - ✅ 数据注入LLM(基于真实数据,不编造) - - ✅ 即时反馈("正在查询") - - ✅ **Dify Dataset关联**(test0102 → b49595b2-bf71-4e47-9988-4aa2816d3c6f) - - ✅ **Bug修复**(Dify API字段路径错误修正) - - ✅ 测试通过(5个场景:REDCap查询、Dify文档查询、混合查询) -- **未开发功能**: - - ⏳ Function Calling(LLM自主决策)- Phase 2 - - ⏳ 多项目支持(项目切换)- Phase 2 - - ⏳ 文档API上传(自动化上传到Dify)- Phase 2 - - ⏳ 数据质量Agent(AI质控逻辑)- Phase 2 - - ⏳ 任务驱动引擎 - Phase 2 - - ⏳ 患者随访Agent - Phase 2 - - ⏳ 微信小程序前端 - Phase 3 - - ⏳ REDCap双向回写 - Phase 2 -- **部署状态**:✅ AI对话正常运行,支持REDCap实时数据 + Dify文档查询 +- **开发阶段**:🎉 **实时质控系统核心功能开发完成!待端到端测试** +- **整体完成度**: + - **基础设施**:85%(REDCap + 企业微信 + AI 对话 + 实时质控) + - **架构设计**:100%(V2.9.1 完整开发计划发布) + - **代码实现**:45%(实时质控系统已实现) + +#### ✅ 已完成功能(基础设施) +- ✅ 数据库Schema创建(iit_schema,9个表 = 原5个 + 新增4个质控表) +- ✅ Prisma Schema编写(扩展至 ~350 行类型定义) +- ✅ 企业微信应用注册和配置 +- ✅ **REDCap 生产环境部署完成**(ECS + RDS + HTTPS) +- ✅ **REDCap实时集成完成**(DET + REST API) +- ✅ **企业微信推送服务完成**(WechatService) +- ✅ **端到端测试通过**(REDCap → Node.js → 企业微信) +- ✅ **AI对话集成完成**(ChatService + SessionMemory) + +#### ✅ 已完成功能(实时质控系统 - 2026-02-07) +- ✅ **质控数据库表**(iit_qc_logs + iit_record_summary + iit_qc_project_stats + iit_field_metadata) +- ✅ **pg-boss 防抖机制**(WebhookController + singletonKey) +- ✅ **Worker 双产出**(质控日志 + 录入汇总,一次执行两个产出) +- ✅ **HardRuleEngine 增强**(按表单过滤规则) +- ✅ **QcService 创建**(质控查询服务,6 个核心方法) +- ✅ **ChatService 优化**(优先查询汇总表/质控表,新增录入/质控意图识别) +- ✅ **管理端批量操作**(一键全量质控 + 一键全量汇总,前后端完整实现) + +#### ✅ 已完成功能(架构设计 V2.9.1) +- ✅ **双脑架构设计**(SOP 状态机 + ReAct 引擎) +- ✅ **三层记忆系统设计**(流水账 + 热记忆 + 历史书) +- ✅ **主动性增强设计**(Cron Skill + 用户画像 + 反馈循环) +- ✅ **隐私合规设计**(PII 脱敏中间件 + 审计日志) +- ✅ **自动化工具设计**(AutoMapper REDCap Schema 对齐) +- ✅ **模块化开发文档**(6 份专项文档) + +#### ⏳ 待实施功能(按 Phase 规划) +| Phase | 内容 | 优先级 | 状态 | +|-------|------|--------|------| +| **Phase 1** | 基础工具层(ToolsService + HardRuleEngine + AutoMapper) | P0 | ✅ 部分完成(HardRuleEngine 已实现) | +| **Phase 1.5** | 隐私安全(AnonymizerService + PII 脱敏) | **P0 合规必需** | 待开始 | +| **Phase 2** | 实时质控系统(QC Worker + QcService + 批量操作) | P0 | ✅ **已完成** | +| **Phase 3** | ReAct 引擎 + 流水账(ReActEngine + 反馈循环) | P0 | 待开始 | +| **Phase 4** | 调度系统(SchedulerService + Cron Skill + ProfilerService) | P1 | 待开始 | +| **Phase 5** | 智能路由(IntentService + 多意图处理) | P1 | 待开始 | +| **Phase 6** | 视觉能力(VisionService) | 延后 | 待开始 | + +- **部署状态**:✅ REDCap 生产环境运行中(https://redcap.xunzhengyixue.com/) - **已知问题**:无 -- **临时措施**: - - ⚠️ 使用关键词匹配识别意图(Phase 2升级为Function Calling) - - ⚠️ SessionMemory基于内存(Phase 2改为Redis) - - ⚠️ 默认查询第一个active项目(Phase 2支持项目选择) - - ⚠️ Dify文档通过Web界面手动上传(Phase 2开发API自动上传) - - ⚠️ 单项目单知识库(Phase 2支持多知识库) - - ⚠️ UserID从环境变量获取(`WECHAT_TEST_USER_ID`)- Phase 2改进 - - ⚠️ 定时轮询暂时禁用(REDCap DET已足够)- Phase 2添加 +- **开发计划文档**:[V2.9.1 综合开发计划](./04-开发计划/IIT%20Manager%20Agent%20V2.6%20综合开发计划.md) --- @@ -105,6 +104,8 @@ IIT Manager Agent(研究者发起试验管理助手)是一个基于企业微 | **Day 2:REDCap拉取** | ✅ 已完成 | 2026-01-02 | RedcapAdapter(271行) + WebhookController(327行) + SyncManager(398行) | | **Day 3:企微推送** | ✅ 已完成 | 2026-01-03 | WechatService(314行) + WechatCallbackController(501行) + 质控Worker | | **Phase 1.5:AI对话** | ✅ 已完成 | 2026-01-03 & 2026-01-04 | ChatService(485行) + SessionMemory(120行) + Dify集成 | +| **IIT 管理端配置** | ✅ 已完成 | 2026-02-07 | 运营管理端 IIT 项目管理(REDCap配置 + 质控规则 + 用户映射 + 知识库) | +| **实时质控系统** | ✅ 已完成 | 2026-02-07 | 4个新表 + pg-boss防抖 + Worker双产出 + QcService + 管理端批量操作 | | **Day 6-7:小程序** | ⏳ 待开始 | - | Taro前端 + 审批界面 | | **Day 8:回写+集成** | ⏳ 待开始 | - | REDCap回写 + 端到端测试 | | **Day 9-10:完善+测试** | ⏳ 待开始 | - | 错误处理 + 日志 + 性能优化 | diff --git a/docs/03-业务模块/IIT Manager Agent/04-开发计划/01-数据库设计.md b/docs/03-业务模块/IIT Manager Agent/04-开发计划/01-数据库设计.md index f4897a80..b6697cbd 100644 --- a/docs/03-业务模块/IIT Manager Agent/04-开发计划/01-数据库设计.md +++ b/docs/03-业务模块/IIT Manager Agent/04-开发计划/01-数据库设计.md @@ -4,10 +4,11 @@ > **更新日期:** 2026-02-05 > **关联文档:** [IIT Manager Agent V2.6 综合开发计划](./IIT%20Manager%20Agent%20V2.6%20综合开发计划.md) > -> **V2.9 更新**: +> **V2.9.1 更新**: > - 扩展 `iit_skills` 表支持 Cron Skill(主动提醒) > - 扩展 `iit_conversation_history` 表增加反馈字段 > - 更新 `iit_project_memory` 内容结构(用户画像) +> - **新增 `iit_pii_audit_log` 表**:PII 脱敏审计日志(合规必需) --- @@ -17,6 +18,7 @@ |------|------|-------|--------| | `iit_skills` | Skill 配置存储 | 1 | P0 | | `iit_field_mapping` | 字段名映射字典 | 1 | P0 | +| `iit_pii_audit_log` | PII 脱敏审计日志 | 1.5 | P0 | | `iit_task_run` | SOP 任务执行记录 | 2 | P0 | | `iit_pending_actions` | 待处理的违规记录 | 2 | P0 | | `iit_conversation_history` | 对话历史(流水账) | 2 | P1 | @@ -159,6 +161,64 @@ INSERT INTO iit_field_mapping (project_id, alias_name, actual_name) VALUES --- +## 2.5 Phase 1.5:隐私安全表(P0 合规必需) + +### 2.5.1 iit_pii_audit_log - PII 脱敏审计日志 + +> **重要**:临床数据包含大量患者隐私信息(姓名、身份证、手机号),在调用第三方 LLM 之前**必须脱敏**。 +> 此表用于存储脱敏记录,便于事后合规审计。 + +```prisma +model IitPiiAuditLog { + id String @id @default(uuid()) + projectId String + userId String // 操作者 + sessionId String // 会话 ID(关联 conversation_history) + + // 脱敏内容(加密存储) + originalHash String // 原始内容的 SHA256 哈希(不存明文) + maskedPayload String @db.Text // 脱敏后发送给 LLM 的内容 + maskingMap String @db.Text // 加密存储的映射表 { "[PATIENT_1]": "张三", ... } + + // 元数据 + piiCount Int // 检测到的 PII 数量 + piiTypes String[] // 检测到的 PII 类型 ['name', 'id_card', 'phone'] + llmProvider String // 'qwen' | 'deepseek' | 'openai' + + createdAt DateTime @default(now()) + + @@index([projectId, userId]) + @@index([sessionId]) + @@index([createdAt]) + @@map("iit_pii_audit_log") + @@schema("iit_schema") +} +``` + +**PII 类型说明**: + +| PII 类型 | 正则模式 | 脱敏示例 | +|----------|----------|----------| +| `name` | 中文姓名(2-4字) | 张三 → [PATIENT_1] | +| `id_card` | 身份证号(18位) | 420101... → [ID_CARD_1] | +| `phone` | 手机号(11位) | 13800138000 → [PHONE_1] | +| `mrn` | 病历号 | MRN123456 → [MRN_1] | + +**脱敏流程**: + +``` +用户输入: "张三(身份证420101199001011234)今天血压偏高" + ↓ AnonymizerService.mask() +LLM 收到: "[PATIENT_1](身份证[ID_CARD_1])今天血压偏高" + ↓ 同时写入 iit_pii_audit_log + ↓ LLM 处理 +LLM 返回: "[PATIENT_1] 的血压需要关注..." + ↓ AnonymizerService.unmask() +用户看到: "张三 的血压需要关注..." +``` + +--- + ## 3. Phase 2:SOP 执行与记忆表 ### 3.1 iit_task_run - SOP 任务执行记录 diff --git a/docs/03-业务模块/IIT Manager Agent/04-开发计划/03-服务层实现指南.md b/docs/03-业务模块/IIT Manager Agent/04-开发计划/03-服务层实现指南.md index 3f3baac3..d954b695 100644 --- a/docs/03-业务模块/IIT Manager Agent/04-开发计划/03-服务层实现指南.md +++ b/docs/03-业务模块/IIT Manager Agent/04-开发计划/03-服务层实现指南.md @@ -4,10 +4,12 @@ > **更新日期:** 2026-02-05 > **关联文档:** [IIT Manager Agent V2.6 综合开发计划](./IIT%20Manager%20Agent%20V2.6%20综合开发计划.md) > -> **V2.9 更新**: +> **V2.9.1 更新**: > - 新增 `ProfilerService` 用户画像服务 > - `ChatService` 增加反馈循环功能 > - `SchedulerService` 支持 Cron Skill 触发 +> - **新增 `AnonymizerService`**:PII 脱敏中间件(P0 合规必需) +> - **新增 `AutoMapperService`**:REDCap Schema 自动对齐工具 --- @@ -16,6 +18,8 @@ | 服务 | 职责 | Phase | |------|------|-------| | `ToolsService` | 统一工具管理(字段映射 + 执行) | 1 | +| `AnonymizerService` | **PII 脱敏中间件(P0 合规必需)** | 1.5 | +| `AutoMapperService` | **REDCap Schema 自动对齐** | 1 | | `ChatService` | 消息路由(双脑入口)+ 反馈收集 | 2 | | `IntentService` | 意图识别(混合路由) | 5 | | `MemoryService` | 记忆管理(V2.8 架构) | 2-3 | @@ -315,6 +319,460 @@ export class ToolsService { --- +## 2.5 AnonymizerService - PII 脱敏中间件(P0 合规必需) + +> **文件路径**: `backend/src/modules/iit-manager/services/AnonymizerService.ts` +> +> **⚠️ 重要**:临床数据包含大量患者隐私信息,在调用第三方 LLM 之前**必须脱敏**! + +### 2.5.1 核心职责 + +- 识别文本中的 PII(个人身份信息) +- 发送 LLM 前脱敏(Masking) +- 接收 LLM 回复后还原(Unmasking) +- 记录脱敏审计日志 + +### 2.5.2 PII 识别正则库 + +```typescript +// PII 类型定义 +const PII_PATTERNS = { + // 中文姓名(2-4字,排除常见非姓名词) + name: /(?; // { "[PATIENT_1]": "张三" } + piiCount: number; + piiTypes: string[]; +} + +interface UnmaskingContext { + maskingMap: Record; +} + +export class AnonymizerService { + private encryptionKey: string; + + constructor() { + this.encryptionKey = process.env.PII_ENCRYPTION_KEY || 'default-key-change-me'; + } + + /** + * 脱敏:发送 LLM 前调用 + */ + async mask( + text: string, + context: { projectId: string; userId: string; sessionId: string } + ): Promise { + const maskingMap: Record = {}; + const piiTypes: string[] = []; + let maskedText = text; + let counter = { name: 0, id_card: 0, phone: 0, mrn: 0 }; + + // 按优先级处理(先处理身份证,再处理姓名,避免误识别) + + // 1. 身份证号 + maskedText = maskedText.replace(PII_PATTERNS.id_card, (match) => { + counter.id_card++; + const placeholder = `[ID_CARD_${counter.id_card}]`; + maskingMap[placeholder] = match; + if (!piiTypes.includes('id_card')) piiTypes.push('id_card'); + return placeholder; + }); + + // 2. 手机号 + maskedText = maskedText.replace(PII_PATTERNS.phone, (match) => { + counter.phone++; + const placeholder = `[PHONE_${counter.phone}]`; + maskingMap[placeholder] = match; + if (!piiTypes.includes('phone')) piiTypes.push('phone'); + return placeholder; + }); + + // 3. 病历号 + maskedText = maskedText.replace(PII_PATTERNS.mrn, (match, mrn) => { + counter.mrn++; + const placeholder = `[MRN_${counter.mrn}]`; + maskingMap[placeholder] = mrn; + if (!piiTypes.includes('mrn')) piiTypes.push('mrn'); + return placeholder.padEnd(match.length); + }); + + // 4. 中文姓名(需要更精细的判断) + maskedText = maskedText.replace(PII_PATTERNS.name, (match) => { + // 排除非姓名词 + if (NAME_EXCLUSIONS.includes(match)) return match; + // 排除已被其他规则处理的部分 + if (Object.values(maskingMap).includes(match)) return match; + + counter.name++; + const placeholder = `[PATIENT_${counter.name}]`; + maskingMap[placeholder] = match; + if (!piiTypes.includes('name')) piiTypes.push('name'); + return placeholder; + }); + + const piiCount = Object.keys(maskingMap).length; + + // 记录审计日志 + if (piiCount > 0) { + await this.saveAuditLog({ + projectId: context.projectId, + userId: context.userId, + sessionId: context.sessionId, + originalHash: this.hashText(text), + maskedPayload: maskedText, + maskingMap: this.encrypt(JSON.stringify(maskingMap)), + piiCount, + piiTypes + }); + } + + return { maskedText, maskingMap, piiCount, piiTypes }; + } + + /** + * 还原:接收 LLM 回复后调用 + */ + unmask(text: string, context: UnmaskingContext): string { + let result = text; + + // 将占位符替换回原始值 + for (const [placeholder, original] of Object.entries(context.maskingMap)) { + result = result.replace(new RegExp(this.escapeRegex(placeholder), 'g'), original); + } + + return result; + } + + // ===== 辅助方法 ===== + + private hashText(text: string): string { + return crypto.createHash('sha256').update(text).digest('hex'); + } + + private encrypt(text: string): string { + const cipher = crypto.createCipheriv( + 'aes-256-gcm', + crypto.scryptSync(this.encryptionKey, 'salt', 32), + crypto.randomBytes(16) + ); + return cipher.update(text, 'utf8', 'hex') + cipher.final('hex'); + } + + private escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + private async saveAuditLog(data: { + projectId: string; + userId: string; + sessionId: string; + originalHash: string; + maskedPayload: string; + maskingMap: string; + piiCount: number; + piiTypes: string[]; + }): Promise { + await prisma.iitPiiAuditLog.create({ + data: { + ...data, + llmProvider: process.env.LLM_PROVIDER || 'qwen' + } + }); + } +} +``` + +### 2.5.4 集成到 ChatService + +```typescript +// ChatService.ts 中的使用 +export class ChatService { + private anonymizer: AnonymizerService; + + async handleMessage(userId: string, message: string): Promise { + const projectId = await this.getUserProject(userId); + const sessionId = this.sessionMemory.getSessionId(userId); + + // ⚠️ 调用 LLM 前脱敏 + const { maskedText, maskingMap, piiCount } = await this.anonymizer.mask( + message, + { projectId, userId, sessionId } + ); + + if (piiCount > 0) { + console.log(`[Anonymizer] 检测到 ${piiCount} 个 PII,已脱敏`); + } + + // 使用脱敏后的文本调用 LLM + const llmResponse = await this.llm.chat(maskedText, ...); + + // ⚠️ 收到 LLM 回复后还原 + const unmaskedResponse = this.anonymizer.unmask(llmResponse, { maskingMap }); + + return unmaskedResponse; + } +} +``` + +--- + +## 2.6 AutoMapperService - REDCap Schema 自动对齐 + +> **文件路径**: `backend/src/modules/iit-manager/services/AutoMapperService.ts` +> +> **目的**:大幅减少 `iit_field_mapping` 表的人工配置工作量 + +### 2.6.1 核心职责 + +- 解析 REDCap Data Dictionary(CSV/JSON) +- 使用 LLM 进行语义映射 +- 提供管理后台确认界面 + +### 2.6.2 完整实现 + +```typescript +import { parse } from 'papaparse'; +import { LLMFactory } from '../../common/llm/adapters/LLMFactory'; +import { prisma } from '../../common/prisma'; + +interface FieldDefinition { + variableName: string; + fieldLabel: string; + fieldType: string; + choices?: string; +} + +interface MappingSuggestion { + redcapField: string; + redcapLabel: string; + suggestedAlias: string[]; + confidence: number; + status: 'pending' | 'confirmed' | 'rejected'; +} + +export class AutoMapperService { + private llm = LLMFactory.create('qwen'); + + // 系统标准字段列表 + private readonly STANDARD_FIELDS = [ + { name: 'age', aliases: ['年龄', 'age', '岁数'] }, + { name: 'gender', aliases: ['性别', 'sex', 'gender', '男女'] }, + { name: 'ecog', aliases: ['ECOG', 'PS评分', '体力状态'] }, + { name: 'visit_date', aliases: ['访视日期', '就诊日期', 'visit date'] }, + { name: 'height', aliases: ['身高', 'height', 'ht'] }, + { name: 'weight', aliases: ['体重', 'weight', 'wt'] }, + { name: 'bmi', aliases: ['BMI', '体质指数'] }, + { name: 'consent_date', aliases: ['知情同意日期', 'ICF日期', 'consent date'] }, + { name: 'enrollment_date', aliases: ['入组日期', 'enrollment date', '入选日期'] } + ]; + + /** + * 解析 REDCap Data Dictionary + */ + async parseDataDictionary(fileContent: string, format: 'csv' | 'json'): Promise { + if (format === 'csv') { + const result = parse(fileContent, { header: true }); + return result.data.map((row: any) => ({ + variableName: row['Variable / Field Name'] || row['variable_name'], + fieldLabel: row['Field Label'] || row['field_label'], + fieldType: row['Field Type'] || row['field_type'], + choices: row['Choices, Calculations, OR Slider Labels'] || row['choices'] + })); + } else { + return JSON.parse(fileContent); + } + } + + /** + * 使用 LLM 生成映射建议 + */ + async generateMappingSuggestions( + projectId: string, + fields: FieldDefinition[] + ): Promise { + const prompt = `你是一个临床研究数据专家。请将以下 REDCap 字段与系统标准字段进行语义匹配。 + +## 系统标准字段 +${this.STANDARD_FIELDS.map(f => `- ${f.name}: ${f.aliases.join(', ')}`).join('\n')} + +## REDCap 字段列表 +${fields.slice(0, 50).map(f => `- ${f.variableName}: ${f.fieldLabel}`).join('\n')} + +请返回 JSON 格式的映射建议: +{ + "mappings": [ + { + "redcapField": "nl_age", + "suggestedAlias": ["age", "年龄"], + "confidence": 0.95 + } + ] +} + +注意: +1. 只返回有把握的映射(confidence >= 0.7) +2. 如果不确定,不要强行映射 +3. 一个 REDCap 字段可以有多个别名`; + + const response = await this.llm.chat([ + { role: 'user', content: prompt } + ]); + + try { + const jsonMatch = response.content.match(/\{[\s\S]*\}/); + const result = JSON.parse(jsonMatch?.[0] || '{"mappings":[]}'); + + return result.mappings.map((m: any) => ({ + redcapField: m.redcapField, + redcapLabel: fields.find(f => f.variableName === m.redcapField)?.fieldLabel || '', + suggestedAlias: m.suggestedAlias, + confidence: m.confidence, + status: 'pending' as const + })); + } catch (e) { + console.error('[AutoMapper] LLM 返回解析失败', e); + return []; + } + } + + /** + * 批量确认映射 + */ + async confirmMappings( + projectId: string, + confirmations: Array<{ + redcapField: string; + aliases: string[]; + confirmed: boolean; + }> + ): Promise<{ created: number; skipped: number }> { + let created = 0; + let skipped = 0; + + for (const conf of confirmations) { + if (!conf.confirmed) { + skipped++; + continue; + } + + for (const alias of conf.aliases) { + try { + await prisma.iitFieldMapping.upsert({ + where: { + projectId_aliasName: { projectId, aliasName: alias } + }, + create: { + projectId, + aliasName: alias, + actualName: conf.redcapField + }, + update: { + actualName: conf.redcapField + } + }); + created++; + } catch (e) { + console.error(`[AutoMapper] 创建映射失败: ${alias} -> ${conf.redcapField}`, e); + } + } + } + + return { created, skipped }; + } + + /** + * 一键导入流程 + */ + async autoImport( + projectId: string, + fileContent: string, + format: 'csv' | 'json' + ): Promise<{ + suggestions: MappingSuggestion[]; + message: string; + }> { + // 1. 解析 Data Dictionary + const fields = await this.parseDataDictionary(fileContent, format); + console.log(`[AutoMapper] 解析到 ${fields.length} 个字段`); + + // 2. 生成 LLM 建议 + const suggestions = await this.generateMappingSuggestions(projectId, fields); + console.log(`[AutoMapper] 生成 ${suggestions.length} 个映射建议`); + + return { + suggestions, + message: `已解析 ${fields.length} 个 REDCap 字段,生成 ${suggestions.length} 个映射建议,请在管理后台确认。` + }; + } +} +``` + +### 2.6.3 管理后台 API + +```typescript +// routes/autoMapperRoutes.ts + +router.post('/auto-mapper/import', async (req, res) => { + const { projectId, fileContent, format } = req.body; + + const result = await autoMapperService.autoImport(projectId, fileContent, format); + + res.json({ + success: true, + suggestions: result.suggestions, + message: result.message + }); +}); + +router.post('/auto-mapper/confirm', async (req, res) => { + const { projectId, confirmations } = req.body; + + const result = await autoMapperService.confirmMappings(projectId, confirmations); + + res.json({ + success: true, + created: result.created, + skipped: result.skipped, + message: `已创建 ${result.created} 个映射,跳过 ${result.skipped} 个` + }); +}); +``` + +### 2.6.4 效率对比 + +| 配置方式 | 100 个字段耗时 | 准确率 | +|----------|---------------|--------| +| 手动逐条配置 | 2-4 小时 | 100%(人工保证) | +| LLM 猜测 + 人工确认 | 15-30 分钟 | 95%(LLM猜测)→ 100%(人工确认) | + +--- + ## 3. IntentService - 意图识别 > **文件路径**: `backend/src/modules/iit-manager/services/IntentService.ts` diff --git a/docs/03-业务模块/IIT Manager Agent/04-开发计划/05-开发阶段与任务清单.md b/docs/03-业务模块/IIT Manager Agent/04-开发计划/05-开发阶段与任务清单.md index 001c6ec1..935e8fe1 100644 --- a/docs/03-业务模块/IIT Manager Agent/04-开发计划/05-开发阶段与任务清单.md +++ b/docs/03-业务模块/IIT Manager Agent/04-开发计划/05-开发阶段与任务清单.md @@ -4,7 +4,9 @@ > **更新日期:** 2026-02-05 > **关联文档:** [IIT Manager Agent V2.6 综合开发计划](./IIT%20Manager%20Agent%20V2.6%20综合开发计划.md) > -> **V2.9 更新**: +> **V2.9.1 更新**: +> - **新增 Phase 1.5**:隐私安全与自动化工具(P0 合规必需) +> - Phase 1 新增 AutoMapperService 任务 > - Phase 3 新增反馈循环任务 > - Phase 4 新增 ProfilerService 和 Cron Skill 任务 > - Phase 5 新增多意图处理任务 @@ -14,15 +16,15 @@ ## 1. 开发阶段总览 ``` -Phase 1 Phase 2 Phase 3 Phase 4 Phase 5 Phase 6 -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ 基础工具层 │ ──▶ │ SOP 引擎 │ ──▶ │ ReAct 引擎 │ ──▶ │ 调度系统 │ ──▶ │ 智能路由 │ ──▶ │ 视觉能力 │ -│ │ │ + 记忆L2 │ │ + 记忆L1 │ │ + 记忆L3 │ │ │ │ (延后) │ -└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ - ▼ ▼ ▼ ▼ ▼ ▼ - ToolsService SopEngine ReActEngine SchedulerService IntentService VisionService - FieldMapping HotMemory FlowMemory WeeklyReports MixedRouting (Postponed) - HardRuleEngine SoftRuleEngine AgentTrace ReportService StreamingFB +Phase 1 Phase 1.5 Phase 2 Phase 3 Phase 4 Phase 5 Phase 6 +┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│基础工具层│ ──▶│ 隐私安全 │ ──▶│ SOP 引擎 │ ──▶│ReAct 引擎│ ──▶│ 调度系统 │ ──▶│ 智能路由 │ ──▶│ 视觉能力 │ +│ │ │ P0必需 │ │ + 记忆L2 │ │ + 记忆L1 │ │ + 记忆L3 │ │ │ │ (延后) │ +└──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ + ▼ ▼ ▼ ▼ ▼ ▼ ▼ + ToolsService Anonymizer SopEngine ReActEngine Scheduler IntentService VisionService + FieldMapping AutoMapper HotMemory FlowMemory WeeklyReports MixedRouting (Postponed) + HardRule PII Audit SoftRule AgentTrace ReportService StreamingFB ``` --- @@ -46,17 +48,72 @@ Phase 1 Phase 2 Phase 3 Phase 4 | P1-05 | 实现 `search_protocol` 工具 | 高 | 待开始 | P1-03 | | P1-06 | 实现 `HardRuleEngine` | 高 | 待开始 | - | | P1-07 | 集成字段映射到 ToolsService | 中 | 待开始 | P1-02, P1-03 | -| P1-08 | 单元测试覆盖 | 中 | 待开始 | P1-01~P1-07 | +| P1-08 | **[V2.9.1]** 实现 `AutoMapperService` | 中 | 待开始 | P1-02 | +| P1-09 | **[V2.9.1]** 实现 REDCap Data Dictionary 解析器 | 中 | 待开始 | P1-08 | +| P1-10 | **[V2.9.1]** 实现 LLM 语义映射 Job | 中 | 待开始 | P1-08 | +| P1-11 | **[V2.9.1]** 实现管理后台映射确认 UI | 低 | 待开始 | P1-10 | +| P1-12 | 单元测试覆盖 | 中 | 待开始 | P1-01~P1-11 | ### 2.3 验收标准 - [ ] 工具可通过名称调用 - [ ] 字段映射正确生效(LLM 用 "年龄" → 实际调用 "dem_age") - [ ] 硬规则拦截生效 +- [ ] **[V2.9.1]** AutoMapper 可解析 REDCap Data Dictionary +- [ ] **[V2.9.1]** LLM 可生成字段映射建议 - [ ] 测试覆盖率 > 80% --- +## 2.5 Phase 1.5: 隐私安全与自动化工具(P0 合规必需) + +> **⚠️ 重要**:此阶段必须在 Phase 2 调用 LLM 之前完成! +> 临床数据包含患者隐私信息,未脱敏直接发送给 LLM 将违反数据保护法规。 + +### 2.5.1 目标 + +- 实现 PII 脱敏中间件 +- 建立脱敏审计日志 +- 确保合规性 + +### 2.5.2 任务清单 + +| 任务ID | 任务名称 | 优先级 | 状态 | 前置依赖 | +|--------|----------|--------|------|----------| +| P1.5-01 | 创建 `iit_pii_audit_log` 表 | **P0** | 待开始 | - | +| P1.5-02 | 实现 PII 识别正则库 | **P0** | 待开始 | - | +| P1.5-03 | 实现 `AnonymizerService.mask()` | **P0** | 待开始 | P1.5-01, P1.5-02 | +| P1.5-04 | 实现 `AnonymizerService.unmask()` | **P0** | 待开始 | P1.5-03 | +| P1.5-05 | 实现脱敏映射加密存储 | 高 | 待开始 | P1.5-03 | +| P1.5-06 | 集成到 ChatService 调用链 | **P0** | 待开始 | P1.5-04 | +| P1.5-07 | 单元测试:各类 PII 识别 | 高 | 待开始 | P1.5-02 | +| P1.5-08 | 端到端测试:脱敏还原完整流程 | 高 | 待开始 | P1.5-01~P1.5-06 | + +### 2.5.3 验收标准 + +- [ ] 身份证号正确识别并脱敏(18位) +- [ ] 手机号正确识别并脱敏(11位) +- [ ] 中文姓名正确识别并脱敏(2-4字) +- [ ] 病历号正确识别并脱敏 +- [ ] LLM 收到的 Payload 不包含任何 PII +- [ ] LLM 回复正确还原占位符 +- [ ] 审计日志正确记录(加密存储) + +### 2.5.4 PII 脱敏流程 + +``` +用户输入: "张三(身份证420101199001011234)今天血压偏高" + ↓ AnonymizerService.mask() +LLM 收到: "[PATIENT_1](身份证[ID_CARD_1])今天血压偏高" + ↓ 同时写入 iit_pii_audit_log(加密存储映射表) + ↓ LLM 处理 +LLM 返回: "[PATIENT_1] 的血压需要关注..." + ↓ AnonymizerService.unmask() +用户看到: "张三 的血压需要关注..." +``` + +--- + ## 3. Phase 2: SOP 引擎 + 热记忆 ### 3.1 目标 @@ -260,6 +317,8 @@ gantt | **[V2.9]** 用户多意图混乱 | 任务遗漏 | ReAct Prompt 多意图拆分 | ✅ | | **[V2.9]** 回复不符用户偏好 | 体验差 | 反馈循环 + 用户画像 | ✅ | | **[V2.9]** 主动提醒打扰用户 | 用户投诉 | 最佳通知时间 + 个性化 | ✅ | +| **[V2.9.1]** 患者隐私泄露给 LLM | **法律风险** | PII 脱敏中间件 + 审计日志 | ✅ | +| **[V2.9.1]** 字段映射配置繁琐 | 效率低 | AutoMapper LLM 语义匹配 | ✅ | --- diff --git a/docs/03-业务模块/IIT Manager Agent/04-开发计划/06-实时质控系统开发计划.md b/docs/03-业务模块/IIT Manager Agent/04-开发计划/06-实时质控系统开发计划.md new file mode 100644 index 00000000..d18aa934 --- /dev/null +++ b/docs/03-业务模块/IIT Manager Agent/04-开发计划/06-实时质控系统开发计划.md @@ -0,0 +1,883 @@ +# IIT Manager Agent 实时质控系统开发计划 + +> **文档版本:** v1.1 +> **创建日期:** 2026-02-07 +> **最后更新:** 2026-02-07 +> **参考文档:** +> - `docs/02-通用能力层/Postgres-Only异步任务处理指南.md` +> - `docs/03-业务模块/IIT Manager Agent/05-测试文档/IIT Manager Agent V2.9 补充:业务规则与数据治理细则.md` + +--- + +## 📋 概述 + +### 背景 + +当前 IIT Manager Agent 的质控功能存在以下问题: +1. **无持久化**:每次查询需要实时调用 REDCap API 并执行质控规则 +2. **规则管理繁琐**:需要手动配置所有质控规则 +3. **缺乏审计信息**:不知道数据谁录入、何时录入 +4. **AI响应慢**:每次都要实时查询和计算 + +### 目标 + +建立一套完整的实时质控系统: +1. **字段自动同步**:从 REDCap 元数据自动同步字段并生成默认规则 +2. **实时质控存储**:每次数据录入后自动执行质控并存储结果 +3. **录入汇总跟踪**:同步记录入组时间、录入人、表单完成状态 +4. **分级告警**:严重问题立即推送,非紧急问题汇总推送 +5. **AI快速响应**:AI 直接查询质控结果表,无需实时计算 + +--- + +## 🎯 核心设计原则 + +### 双模式质控策略(Skill 分层配置) + +针对 "录了3张表" vs "录了10张表" 的不同质控需求,采用 **Skill 分层配置策略**,无需配置复杂的前置条件。 + +#### 策略 A:单表实时质控 (Real-time / Form-based) + +| 属性 | 说明 | +|------|------| +| **触发** | Webhook (DET) | +| **上下文** | `instrument` 参数限制了范围 | +| **逻辑** | 系统自动只加载 `formName` 匹配的规则,无需人工配置 preconditions | +| **场景** | 刚录完"人口学",系统只检查"年龄",**绝不会报"实验室检查缺失"的错** | + +```typescript +// 实时质控:只加载当前表单的规则 +async executeFormQc(projectId: string, recordId: string, instrument: string) { + // ✅ 根据 instrument 自动过滤规则 + const rules = await getRulesByForm(projectId, instrument); + + // 只质控这张表单涉及的字段 + return executeRules(rules, record); +} +``` + +#### 策略 B:全案定时质控 (Daily Batch / Holistic) + +| 属性 | 说明 | +|------|------| +| **触发** | Cron Job (每日凌晨) | +| **上下文** | 全量数据 | +| **逻辑** | 加载跨表规则(Cross-Form Logic) | +| **依赖处理** | **逻辑守卫 (Logic Guard)**:规则逻辑内部判断,未录入则静默跳过 | + +```typescript +// ✅ 正确:逻辑守卫模式 +// 不要在 JSON 里配 dependencies: ['lab_test'] +// 而是直接在规则逻辑里写判断 +const crossFormRule = { + id: 'age_lab_consistency', + name: '年龄与实验室结果一致性检查', + logic: { + "if": [ + // 逻辑守卫:如果 lab_test 表单未完成,直接跳过(返回 true = 通过) + { "!": { "var": "lab_test_complete" } }, + true, // 未录入 = 跳过,不报错 + // 否则执行实际检查 + { "and": [ + { ">=": [{ "var": "age" }, 18] }, + { "<=": [{ "var": "creatinine" }, 1.5] } + ]} + ] + } +}; +``` + +### 质控日志记录策略:仅新增,不覆盖 + +**原则**:每次质控都 **新增记录**,而不是覆盖。 + +**原因**: + +1. **审计轨迹 (Audit Trail)** + - 调出周一的 Log,发现当时 `age` 字段是空的(规则没触发) + - 周五补录了 `16`(触发了规则) + - **这能证明系统是清白的** + +2. **趋势分析** + - P001 患者在入组初期有 10 个错误 + - 经过 3 次修改后,错误降为 0 + - **这展示了数据质量提升的过程** + +```typescript +// ✅ 正确:每次质控都新增记录 +await prisma.iitQcLog.create({ + data: { + projectId, + recordId, + formName: instrument, + status, + issues, + ruleVersion, + triggeredBy, + createdAt: new Date(), // 每次都是新记录 + } +}); + +// ❌ 错误:覆盖模式会丢失历史 +await prisma.iitQcLog.upsert({ ... }); // 不要用 upsert +``` + +--- + +## 🏗️ 架构设计 + +### 整体架构 + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ CRC 在 REDCap 录入数据 │ +└─────────────────────────────────────┬────────────────────────────────────┘ + │ + ▼ DET Webhook (含 instrument 参数) +┌──────────────────────────────────────────────────────────────────────────┐ +│ 同步层 (WebhookController) │ +│ - 接收 HTTP 请求 │ +│ - 校验签名 │ +│ - 防抖检查 (pg-boss singletonKey) │ +│ - 推入队列(携带 instrument 参数) │ +│ - 立即返回 200 OK (< 10ms) │ +└─────────────────────────────────────┬────────────────────────────────────┘ + │ + ▼ pg-boss 队列 +┌──────────────────────────────────────────────────────────────────────────┐ +│ 异步层 (QcWorker) - 一次处理,两个产出 │ +│ │ +│ ┌──────────────────────────────────────────────────────────────────────┐│ +│ │ 1. 拉取记录数据 (RedcapAdapter) ││ +│ └──────────────────────────────────────────────────────────────────────┘│ +│ │ │ +│ ┌─────────────┴─────────────┐ │ +│ ▼ ▼ │ +│ ┌─────────────────────┐ ┌─────────────────────┐ │ +│ │ 2a. 更新录入汇总表 │ │ 2b. 执行单表质控 │ │ +│ │ iit_record_summary │ │ (只评估当前表单规则) │ │ +│ │ - 入组时间 │ │ 根据 instrument 过滤 │ │ +│ │ - 录入人 │ └─────────────────────┘ │ +│ │ - 表单完成状态 │ │ │ +│ └─────────────────────┘ ▼ │ +│ │ ┌─────────────────────┐ │ +│ │ │ 2c. 新增质控日志 │ │ +│ │ │ iit_qc_logs │ │ +│ │ │ (仅新增,不覆盖) │ │ +│ │ └─────────────────────┘ │ +│ │ │ │ +│ └─────────────┬─────────────┘ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ 3. 主动干预 ││ +│ │ - 🔴 RED: 立即推送企业微信 ││ +│ │ - 🟡 YELLOW: 存入每日摘要队列 ││ +│ │ - 🟢 GREEN: 仅记录日志 ││ +│ └─────────────────────────────────────────────────────────────┘│ +└──────────────────────────────────────────────────────────────────────────┘ + │ + ┌─────────────────────────┴─────────────────────────┐ + ▼ ▼ +┌───────────────────────────────┐ ┌───────────────────────────────┐ +│ 每日定时任务 (Cron Job) │ │ AI Agent 查询层 │ +│ - 全案定时质控(跨表规则) │ │ - 查询 iit_qc_logs(毫秒级) │ +│ - 逻辑守卫模式处理依赖 │ │ - 查询 iit_record_summary │ +│ - 生成每日摘要报告 │ │ - 查询 iit_qc_project_stats │ +└───────────────────────────────┘ └───────────────────────────────┘ +``` + +--- + +## 📦 数据库设计 + +### 新增表 + +#### 1. iit_qc_logs(质控日志表) + +质控结果存储,**仅新增,不覆盖**,保留完整审计轨迹。 + +```prisma +model IitQcLog { + id String @id @default(uuid()) + projectId String @map("project_id") + recordId String @map("record_id") + eventId String? @map("event_id") + + // 质控类型 + qcType String @map("qc_type") // 'form' | 'holistic' + formName String? @map("form_name") // 单表质控时记录表单名 + + // 核心结果 + status String // 'PASS' | 'FAIL' | 'WARNING' + + // 字段级详情 (JSONB) + // 格式: [{ field: "age", rule: "range_check", level: "RED", message: "..." }, ...] + issues Json @default("[]") + + // 规则统计 + rulesEvaluated Int @default(0) @map("rules_evaluated") // 实际评估的规则数 + rulesSkipped Int @default(0) @map("rules_skipped") // 逻辑守卫跳过的规则数 + rulesPassed Int @default(0) @map("rules_passed") + rulesFailed Int @default(0) @map("rules_failed") + + // 规则版本(用于历史追溯) + ruleVersion String @map("rule_version") + + // 入排标准检查(全案质控时填充) + inclusionPassed Boolean? @map("inclusion_passed") + exclusionPassed Boolean? @map("exclusion_passed") + + // 审计信息 + triggeredBy String @map("triggered_by") // 'webhook' | 'manual' | 'batch' + createdAt DateTime @default(now()) @map("created_at") + + // 索引 - 支持历史查询和趋势分析 + @@index([projectId, recordId, createdAt]) // 查询某记录的质控历史 + @@index([projectId, status, createdAt]) // 查询某状态的质控记录 + @@index([projectId, qcType, createdAt]) // 按质控类型查询 + @@map("iit_qc_logs") + @@schema("iit_schema") +} +``` + +**设计说明**: +- **无唯一约束**:同一 `projectId + recordId` 可以有多条记录 +- **`qcType`**:区分单表实时质控 (`form`) 和全案定时质控 (`holistic`) +- **`rulesSkipped`**:逻辑守卫跳过的规则数,用于分析数据完整度 + +#### 2. iit_record_summary(录入汇总表) + +**一次处理,两个产出**:与质控同时更新,记录入组和录入进度。 + +```prisma +model IitRecordSummary { + id String @id @default(uuid()) + projectId String @map("project_id") + recordId String @map("record_id") + + // 入组信息 + enrolledAt DateTime? @map("enrolled_at") // 首次录入时间 = 入组时间 + enrolledBy String? @map("enrolled_by") // 入组录入人(REDCap username) + + // 最新录入信息 + lastUpdatedAt DateTime @map("last_updated_at") + lastUpdatedBy String? @map("last_updated_by") + lastFormName String? @map("last_form_name") // 最后更新的表单 + + // 表单完成状态 (JSONB) + // 格式: { "demographics": 2, "baseline": 1, "visit1": 0 } + // 0=未开始, 1=进行中, 2=完成 + formStatus Json @default("{}") @map("form_status") + + // 数据完整度 + totalForms Int @default(0) @map("total_forms") + completedForms Int @default(0) @map("completed_forms") + completionRate Float @default(0) @map("completion_rate") // 0-100% + + // 最新质控状态(冗余存储,查询更快) + latestQcStatus String? @map("latest_qc_status") // 'PASS' | 'FAIL' | 'WARNING' + latestQcAt DateTime? @map("latest_qc_at") + + // 更新次数(用于趋势分析) + updateCount Int @default(0) @map("update_count") + + // 时间戳 + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // 唯一约束 - 每个记录只有一条汇总 + @@unique([projectId, recordId]) + @@index([projectId, enrolledAt]) + @@index([projectId, latestQcStatus]) + @@index([projectId, completionRate]) + @@map("iit_record_summary") + @@schema("iit_schema") +} +``` + +**设计说明**: +- **使用 upsert**:与 `iit_qc_logs` 不同,汇总表使用覆盖模式 +- **`enrolledAt`**:首次录入时自动设置,后续不更新(入组时间锁定) +- **`formStatus`**:JSONB 存储各表单完成状态,便于前端展示进度条 + +#### 3. iit_qc_project_stats(项目级汇总表) + +用于 Dashboard 快速展示,避免每次 COUNT。 + +```prisma +model IitQcProjectStats { + id String @id @default(uuid()) + projectId String @unique @map("project_id") + + // 汇总统计 + totalRecords Int @default(0) @map("total_records") + passedRecords Int @default(0) @map("passed_records") + failedRecords Int @default(0) @map("failed_records") + warningRecords Int @default(0) @map("warning_records") + + // 入排标准统计 + inclusionMet Int @default(0) @map("inclusion_met") + exclusionMet Int @default(0) @map("exclusion_met") + + // 录入进度统计 + avgCompletionRate Float @default(0) @map("avg_completion_rate") + + // 更新时间 + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("iit_qc_project_stats") + @@schema("iit_schema") +} +``` + +#### 4. iit_field_mapping(字段映射表)- 增强 + +```prisma +model IitFieldMapping { + id String @id @default(uuid()) + projectId String @map("project_id") + + // REDCap 字段信息 + fieldName String @map("field_name") + fieldLabel String @map("field_label") + fieldType String @map("field_type") + formName String @map("form_name") + + // 验证规则(从 REDCap 元数据提取) + validation String? @map("validation") + validationMin String? @map("validation_min") + validationMax String? @map("validation_max") + choices String? @map("choices") + required Boolean @default(false) + + // 别名(LLM 友好名称) + alias String? + + // 规则来源标记 + ruleSource String? @map("rule_source") // 'auto' | 'manual' + + // 时间戳 + syncedAt DateTime @default(now()) @map("synced_at") + + // 唯一约束 + @@unique([projectId, fieldName]) + @@index([projectId]) + @@index([projectId, formName]) // 按表单查询字段 + @@map("iit_field_mapping") + @@schema("iit_schema") +} +``` + +--- + +## 🔧 核心模块设计 + +### 1. 字段同步服务 (FieldSyncService) + +```typescript +// backend/src/modules/iit-manager/services/FieldSyncService.ts + +export class FieldSyncService { + /** + * 从 REDCap 同步字段元数据 + * + * 流程: + * 1. 调用 RedcapAdapter.exportMetadata() + * 2. 解析元数据,提取字段信息(含 formName) + * 3. 更新 iit_field_mapping 表 + * 4. 自动生成默认质控规则(标记 ruleSource = 'auto') + */ + async syncFields(projectId: string): Promise { + const adapter = await getRedcapAdapter(projectId); + const metadata = await adapter.exportMetadata(); + + for (const field of metadata) { + await prisma.iitFieldMapping.upsert({ + where: { projectId_fieldName: { projectId, fieldName: field.field_name } }, + create: { + projectId, + fieldName: field.field_name, + fieldLabel: field.field_label, + fieldType: field.field_type, + formName: field.form_name, // ⭐ 关键:用于单表质控规则匹配 + validation: field.text_validation_type_or_show_slider_number, + validationMin: field.text_validation_min, + validationMax: field.text_validation_max, + choices: field.select_choices_or_calculations, + required: field.required_field === 'y', + }, + update: { /* ... */ }, + }); + } + } + + /** + * 自动生成默认质控规则 + * + * 根据 REDCap 元数据自动生成: + * - 范围规则(from validation_min/max) + * - 必填规则(from required) + * - 枚举规则(from choices) + * + * 规则携带 formName,用于单表质控时过滤 + */ + async generateDefaultRules(projectId: string): Promise { + const fields = await prisma.iitFieldMapping.findMany({ where: { projectId } }); + const rules: GeneratedRule[] = []; + + for (const field of fields) { + // 范围规则 + if (field.validationMin || field.validationMax) { + rules.push({ + id: `${field.fieldName}_range`, + name: `${field.fieldLabel}范围检查`, + formName: field.formName, // ⭐ 规则绑定到表单 + source: 'auto', + logic: { /* JSON Logic */ }, + }); + } + } + + return rules; + } +} +``` + +### 2. 质控 Worker (QcWorker) + +**关键改动**:根据 `instrument` 参数过滤规则,一次处理更新两个表。 + +```typescript +// backend/src/modules/iit-manager/workers/qcWorker.ts + +import { jobQueue } from '@/common/jobs'; +import { logger } from '@/common/logging'; + +interface QcJob { + projectId: string; + recordId: string; + eventId?: string; + instrument?: string; // ⭐ Webhook 携带的表单名 + username?: string; // ⭐ REDCap 录入人 + triggeredBy: 'webhook' | 'manual' | 'batch'; +} + +export function registerQcWorker() { + logger.info('[QcWorker] Registering worker'); + + jobQueue.process('iit_quality_check', async (job) => { + const { projectId, recordId, eventId, instrument, username, triggeredBy } = job.data; + + logger.info('[QcWorker] Processing', { jobId: job.id, recordId, instrument }); + + try { + // 1. 从 REDCap 获取记录数据 + const adapter = await getRedcapAdapter(projectId); + const record = await adapter.getRecordById(recordId); + + // ========== 产出1:更新录入汇总表 ========== + await updateRecordSummary({ + projectId, + recordId, + username, + formName: instrument, + record, + }); + + // ========== 产出2:执行单表质控 ========== + // ⭐ 策略 A:根据 instrument 过滤规则 + const rules = instrument + ? await getRulesByForm(projectId, instrument) // 只加载当前表单的规则 + : await getAllRules(projectId); // batch 任务加载全部规则 + + const ruleEngine = createHardRuleEngine(rules); + const qcResult = ruleEngine.execute(recordId, record); + + // 3. 新增质控日志(仅新增,不覆盖) + await prisma.iitQcLog.create({ + data: { + projectId, + recordId, + eventId, + qcType: instrument ? 'form' : 'holistic', + formName: instrument, + status: qcResult.overallStatus, + issues: qcResult.results.filter(r => r.status !== 'PASS'), + rulesEvaluated: qcResult.summary.totalRules, + rulesSkipped: qcResult.summary.skipped || 0, + rulesPassed: qcResult.summary.passed, + rulesFailed: qcResult.summary.failed, + ruleVersion: await getRuleVersion(projectId), + triggeredBy, + } + }); + + // 4. 更新汇总统计 + await updateProjectStats(projectId); + + // 5. 主动干预 + await handleAlerts(projectId, recordId, qcResult); + + logger.info('[QcWorker] ✅ Job completed', { jobId: job.id }); + + return { success: true, recordId, status: qcResult.overallStatus }; + } catch (error: any) { + logger.error('[QcWorker] ❌ Job failed', { + jobId: job.id, + error: error.message + }); + throw error; + } + }); + + logger.info('[QcWorker] ✅ Worker registered: iit_quality_check'); +} + +/** + * 更新录入汇总表 + */ +async function updateRecordSummary(params: { + projectId: string; + recordId: string; + username?: string; + formName?: string; + record: Record; +}) { + const { projectId, recordId, username, formName, record } = params; + + // 获取现有汇总 + const existing = await prisma.iitRecordSummary.findUnique({ + where: { projectId_recordId: { projectId, recordId } } + }); + + // 计算表单完成状态 + const formStatus = calculateFormStatus(record); + const totalForms = Object.keys(formStatus).length; + const completedForms = Object.values(formStatus).filter(s => s === 2).length; + + await prisma.iitRecordSummary.upsert({ + where: { projectId_recordId: { projectId, recordId } }, + create: { + projectId, + recordId, + enrolledAt: new Date(), // 首次录入 = 入组时间 + enrolledBy: username, + lastUpdatedAt: new Date(), + lastUpdatedBy: username, + lastFormName: formName, + formStatus, + totalForms, + completedForms, + completionRate: (completedForms / totalForms) * 100, + updateCount: 1, + }, + update: { + // 入组时间不更新 + lastUpdatedAt: new Date(), + lastUpdatedBy: username, + lastFormName: formName, + formStatus, + completedForms, + completionRate: (completedForms / totalForms) * 100, + updateCount: { increment: 1 }, + }, + }); +} +``` + +### 3. 每日定时质控服务 (DailyQcService) + +**策略 B:全案定时质控,使用逻辑守卫处理依赖。** + +```typescript +// backend/src/modules/iit-manager/services/DailyQcService.ts + +export class DailyQcService { + /** + * 每日凌晨执行全案质控 + * + * 特点: + * - 加载所有规则,包括跨表规则 + * - 使用逻辑守卫处理依赖表单未完成的情况 + */ + async executeHolisticQc(projectId: string) { + const records = await redcapAdapter.exportRecords(); + + for (const record of records) { + // 推入队列,分批处理 + await jobQueue.push('iit_quality_check', { + projectId, + recordId: record.record_id, + triggeredBy: 'batch', + // 不传 instrument,加载全部规则 + }, { + singletonKey: `batch-qc-${projectId}-${record.record_id}`, + }); + } + } +} + +// 跨表规则示例:使用逻辑守卫 +const crossFormRules = [ + { + id: 'age_lab_consistency', + name: '年龄与实验室结果一致性检查', + formName: null, // 跨表规则,不绑定单一表单 + logic: { + "if": [ + // 逻辑守卫:检查依赖表单是否完成 + { "!": { "var": "lab_test_complete" } }, + { "__skip": true }, // 返回特殊标记,表示跳过 + // 实际检查逻辑 + { "and": [ + { ">=": [{ "var": "age" }, 18] }, + { "<=": [{ "var": "creatinine" }, 1.5] } + ]} + ] + } + } +]; +``` + +### 4. 告警服务 (AlertService) + +```typescript +// backend/src/modules/iit-manager/services/AlertService.ts + +export class AlertService { + /** + * 处理告警分级 + */ + async handleAlerts(projectId: string, recordId: string, qcResult: QCResult) { + const redIssues = qcResult.results.filter(r => r.severity === 'error'); + const yellowIssues = qcResult.results.filter(r => r.severity === 'warning'); + + // 🔴 RED: 立即推送 + if (redIssues.length > 0) { + await this.sendImmediateAlert(projectId, recordId, redIssues); + } + + // 🟡 YELLOW: 存入每日摘要队列 + if (yellowIssues.length > 0) { + await this.addToDailyDigest(projectId, recordId, yellowIssues); + } + + // 🟢 GREEN: 只记录日志,不推送 + } + + /** + * 立即推送企业微信告警 + */ + private async sendImmediateAlert( + projectId: string, + recordId: string, + issues: QCRuleResult[] + ) { + const message = this.formatAlertMessage(recordId, issues); + const piUserId = await this.getProjectPiUserId(projectId); + await wechatService.sendTextMessage(piUserId, message); + } + + /** + * 每日摘要定时发送(17:00) + */ + async sendDailyDigest(projectId: string) { + // 由定时任务触发 + } +} + +--- + +## 📋 开发阶段 + +### Phase 1: 字段同步与自动规则(预计 2 天) + +| 序号 | 任务 | 优先级 | 状态 | +|------|------|--------|------| +| 1.1 | 创建 `iit_field_mapping` 表迁移(含 `formName` 字段) | P0 | ⬜ | +| 1.2 | 实现 `FieldSyncService.syncFields()` | P0 | ⬜ | +| 1.3 | 实现 `FieldSyncService.generateDefaultRules()`(规则绑定 formName) | P0 | ⬜ | +| 1.4 | 在运营端添加"同步字段"按钮 | P1 | ⬜ | +| 1.5 | 规则中添加 `source` 和 `formName` 字段 | P1 | ⬜ | + +### Phase 2: 实时质控与录入汇总(预计 3 天) + +| 序号 | 任务 | 优先级 | 状态 | +|------|------|--------|------| +| 2.1 | 创建 `iit_qc_logs` 表迁移(支持仅新增模式) | P0 | ⬜ | +| 2.2 | 创建 `iit_record_summary` 表迁移 | P0 | ⬜ | +| 2.3 | 创建 `iit_qc_project_stats` 表迁移 | P0 | ⬜ | +| 2.4 | 重构 `WebhookController`:传递 `instrument` 和 `username` 参数 | P0 | ⬜ | +| 2.5 | 实现 `QcWorker`(一次处理两个产出) | P0 | ⬜ | +| 2.6 | 实现 `updateRecordSummary()` 函数 | P0 | ⬜ | +| 2.7 | 实现 `getRulesByForm()` 函数(根据表单过滤规则) | P0 | ⬜ | +| 2.8 | 实现 `updateProjectStats()` 函数 | P1 | ⬜ | +| 2.9 | 添加 JSONB 索引优化查询性能 | P1 | ⬜ | + +### Phase 3: AI Agent 集成(预计 2 天) + +| 序号 | 任务 | 优先级 | 状态 | +|------|------|--------|------| +| 3.1 | 新增 `queryQcLogs()` 方法(查询质控历史) | P0 | ⬜ | +| 3.2 | 新增 `getRecordSummary()` 方法(查询录入汇总) | P0 | ⬜ | +| 3.3 | 新增 `getProjectQcStats()` 方法(查询项目汇总) | P0 | ⬜ | +| 3.4 | 修改 `ChatService`:优先查询质控表和汇总表 | P0 | ⬜ | +| 3.5 | 支持"哪些记录有问题"等汇总查询 | P1 | ⬜ | +| 3.6 | 支持"记录10质控历史"等趋势查询 | P1 | ⬜ | +| 3.7 | 支持"最近入组的患者"等录入查询 | P1 | ⬜ | + +### Phase 4: 每日定时质控(预计 2 天) + +| 序号 | 任务 | 优先级 | 状态 | +|------|------|--------|------| +| 4.1 | 实现 `DailyQcService.executeHolisticQc()` | P0 | ⬜ | +| 4.2 | 实现跨表规则的逻辑守卫模式 | P0 | ⬜ | +| 4.3 | 添加定时任务(凌晨 2:00 执行全案质控) | P0 | ⬜ | +| 4.4 | 实现每日摘要报告生成 | P1 | ⬜ | + +### Phase 5: 主动干预(预计 2 天) + +| 序号 | 任务 | 优先级 | 状态 | +|------|------|--------|------| +| 5.1 | 实现 `AlertService.handleAlerts()` | P0 | ⬜ | +| 5.2 | 实现紧急告警立即推送 | P0 | ⬜ | +| 5.3 | 实现每日摘要队列 | P1 | ⬜ | +| 5.4 | 添加定时任务(17:00 发送每日摘要) | P1 | ⬜ | +| 5.5 | 告警级别可配置化 | P2 | ⬜ | + +### Phase 6: 批量回溯(预计 1 天) + +| 序号 | 任务 | 优先级 | 状态 | +|------|------|--------|------| +| 6.1 | 实现 `BatchQcJob`(批量质控任务) | P1 | ⬜ | +| 6.2 | 分片执行(每批 50 条,流控) | P1 | ⬜ | +| 6.3 | 运营端添加"重新质控"按钮 | P2 | ⬜ | + +--- + +## 🛡️ 安全规范 + +### 1. 幂等性保证 + +**质控日志表(仅新增模式)**:通过 pg-boss 防抖保证不重复执行。 + +```typescript +// ✅ 质控日志:仅新增,不需要 upsert +// 幂等性由 pg-boss singletonKey 保证 +await prisma.iitQcLog.create({ + data: { projectId, recordId, ... } +}); +``` + +**录入汇总表(覆盖模式)**:使用 upsert 确保重试安全。 + +```typescript +// ✅ 录入汇总:使用 upsert +await prisma.iitRecordSummary.upsert({ + where: { projectId_recordId: { projectId, recordId } }, + create: { enrolledAt: new Date(), ... }, + update: { lastUpdatedAt: new Date(), ... }, +}); +``` + +### 2. pg-boss 防抖 + +```typescript +// WebhookController.ts +await jobQueue.push('iit_quality_check', + { projectId, recordId, triggeredBy: 'webhook' }, + { + singletonKey: `qc-${projectId}-${recordId}`, + singletonSeconds: 300, // 5分钟防抖 + } +); +``` + +### 3. Payload 精简 + +```typescript +// ✅ 只存 ID,不存大数据 +await jobQueue.push('iit_quality_check', { + projectId: 'xxx', + recordId: '10', + triggeredBy: 'webhook', +}); + +// ❌ 禁止存大数据 +await jobQueue.push('iit_quality_check', { + recordData: { ... }, // 禁止! +}); +``` + +### 4. 任务过期时间 + +```typescript +// 质控任务 15 分钟过期 +await jobQueue.push('iit_quality_check', data, { + expireInSeconds: 15 * 60, +}); + +// 批量任务 1 小时过期 +await jobQueue.push('iit_batch_qc', data, { + expireInSeconds: 60 * 60, +}); +``` + +--- + +## 📊 性能预期 + +| 指标 | 当前 | 优化后 | 改善 | +|------|------|--------|------| +| Webhook 响应时间 | 2-5秒 | < 50ms | ✅ -99% | +| AI 查询响应时间 | 3-5秒(调REDCap) | < 200ms(查本地表) | ✅ -95% | +| 批量质控 1000条 | 不支持 | ~5分钟(分片) | ✅ 新增 | +| 并发 Webhook | 可能重复执行 | 防抖去重 | ✅ 修复 | + +--- + +## ✅ 检查清单 + +### 数据库 +- [ ] `iit_field_mapping` 表已创建(含 `formName` 字段) +- [ ] `iit_qc_logs` 表已创建(支持仅新增模式) +- [ ] `iit_record_summary` 表已创建 +- [ ] `iit_qc_project_stats` 表已创建 +- [ ] JSONB 索引已添加 + +### 双模式质控 +- [ ] 单表质控:根据 `instrument` 过滤规则 +- [ ] 全案质控:逻辑守卫模式处理依赖 +- [ ] 质控规则绑定 `formName` + +### 异步处理 +- [ ] `QcWorker` 已注册 +- [ ] 队列名称使用下划线(`iit_quality_check`) +- [ ] Worker 在 `jobQueue.start()` 之后注册 +- [ ] 防抖配置已添加 + +### 安全规范 +- [ ] 质控日志:仅新增,不覆盖 +- [ ] 录入汇总:使用 `upsert` +- [ ] Payload:只存 ID +- [ ] 错误处理:直接 `throw error` + +### 功能完整性 +- [ ] 字段同步功能可用 +- [ ] 实时质控存储正常 +- [ ] 录入汇总更新正常 +- [ ] AI 查询质控表正常 +- [ ] AI 查询录入汇总正常 +- [ ] 告警推送正常 +- [ ] 每日定时质控正常 + +### 趋势分析 +- [ ] 可查询记录的质控历史 +- [ ] 可分析数据质量提升过程 +- [ ] 审计轨迹完整 + +--- + +**维护者**: IIT Manager Agent 开发团队 +**最后更新**: 2026-02-07 +**文档状态**: ✅ 已完成(v1.1 更新双模式质控策略) diff --git a/docs/03-业务模块/IIT Manager Agent/04-开发计划/IIT Manager Agent V2.5 综合开发计划.md b/docs/03-业务模块/IIT Manager Agent/04-开发计划/IIT Manager Agent V2.5 综合开发计划.md deleted file mode 100644 index 92b5622e..00000000 --- a/docs/03-业务模块/IIT Manager Agent/04-开发计划/IIT Manager Agent V2.5 综合开发计划.md +++ /dev/null @@ -1,1438 +0,0 @@ -# IIT Manager Agent V2.6 综合开发计划 - -> **版本:** V2.6(极简架构 + SOP状态机 + 双脑路由) -> **日期:** 2026-02-02 -> **团队规模:** 2人 -> **预估周期:** 6周 -> **核心目标:** 实现数据质控 Agent 的完整闭环 + 智能化交互 + 扩展能力层 - ---- - -## 0. 架构适配性评估 - -本架构设计充分考虑了临床研究的多种业务场景,通过 **SOP状态机 + 双引擎 + 可扩展工具层** 的设计,实现了良好的适配性和扩展性。 - -### 0.1 目标场景覆盖度 - -| 场景 | 覆盖度 | 核心支撑组件 | 备注 | -|------|--------|-------------|------| -| **1. 拍照识别 + 自动录入** | 🟡 60% | VisionService + ToolsService | 需新增视觉能力 | -| **2. 数据质控** | 🟢 95% | HardRuleEngine + SoftRuleEngine | 核心场景,完全覆盖 | -| **3. 入排标准判断** | 🟢 90% | SopEngine + 硬规则配置 | 配置 Skill 即可 | -| **4. 方案偏离检测** | 🟢 80% | SoftRuleEngine + search_protocol | 需配置访视窗口规则 | -| **5. AE事件检测** | 🟢 80% | 硬规则触发 + 软指令评估 | 需配置AE识别规则 | -| **6. 伦理合规检测** | 🟢 80% | HardRuleEngine | 配置伦理规则即可 | -| **7. 定期报告生成** | 🟡 50% | SchedulerService + ReportService | 需新增定时任务 | - -### 0.2 架构扩展性评价 - -**为什么说这套架构适配性强?** - -| 扩展维度 | 实现方式 | 复杂度 | -|----------|----------|--------| -| **新增质控规则** | 在 `iit_skills` 表插入 JSON 配置 | ⭐ 低 | -| **新增业务场景** | 新增 Skill 类型 + 配置 SOP 流程 | ⭐⭐ 中低 | -| **新增工具能力** | 在 ToolsService 增加工具定义 | ⭐⭐ 中低 | -| **新增数据源** | 新增 Adapter(如 OdmAdapter) | ⭐⭐⭐ 中 | -| **新增交互入口** | 复用 ChatService 路由逻辑 | ⭐⭐ 中低 | - -**核心优势**: - -1. **配置驱动**:新业务场景主要是"写配置"而非"写代码" -2. **插件式工具**:ToolsService 支持动态注册新工具 -3. **引擎复用**:所有场景共享 HardRuleEngine / SoftRuleEngine -4. **SOP 状态机**:流程可配置、可追溯、可审计 - -### 0.3 完整架构图(含扩展能力) - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ 入口层 (Multi-Channel) │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ │ -│ │ 企业微信文本 │ │ 企业微信图片 │ │ PC Workbench │ │ 定时触发 │ │ -│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────┘ │ -└─────────────────────────────────────────────────────────────────────┘ - │ │ │ │ - ▼ ▼ ▼ ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ 路由层 (Router) │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ │ -│ │ ChatService │ │VisionService │ │ API Routes │ │Scheduler │ │ -│ │ (文本路由) │ │ (图片识别) │ │ (REST API) │ │ (定时) │ │ -│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────┘ │ -└─────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ 调度层 (SopEngine) │ -│ ┌────────────────────────────────────────────────────────────────┐ │ -│ │ Skill 配置 (Postgres) │ │ -│ │ • qc_process (质控流程) • ae_detection (AE检测) │ │ -│ │ • inclusion_check (入排) • protocol_deviation (方案偏离) │ │ -│ │ • ethics_check (伦理) • weekly_report (周报) │ │ -│ └────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ┌──────────────────┼──────────────────┐ │ -│ ▼ ▼ ▼ │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ -│ │HardRuleEngine│ │SoftRuleEngine│ │ReportService │ │ -│ │ (CPU规则) │ │ (LLM推理) │ │ (报告生成) │ │ -│ └──────────────┘ └──────────────┘ └──────────────┘ │ -└─────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ 工具层 (ToolsService) │ -│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ -│ │read_clinical_data│ │write_clinical_data│ │search_protocol │ │ -│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ -│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ -│ │check_visit_window│ │assess_ae_causality│ │check_ethics │ │ -│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ -│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ -│ │get_project_stats │ │manage_issue │ │send_notification│ │ -│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ -└─────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ 适配器层 (Adapters) │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ │ -│ │RedcapAdapter │ │ DifyClient │ │WechatAdapter │ │VLMAdapter│ │ -│ │ (EDC数据) │ │ (知识库) │ │ (消息推送) │ │ (视觉) │ │ -│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────┘ │ -└─────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 1. 架构决策总结 - -本计划基于以下 5 份架构设计文档的综合审查: - -| 文档 | 核心观点 | 状态 | -|------|----------|------| -| **架构决策白皮书** | 放弃 MCP/复杂框架,采用 Postgres-Only + Service-First | ✅ 认可 | -| **V2.2 实施指南** | 混合双引擎(硬规则 + 软指令)+ 用户偏好 | ✅ 认可 | -| **V2.2 工具泛化** | 3-5 个"瑞士军刀"通用工具替代 100 个专用工具 | ✅ 认可 | -| **V2.3 健壮性设计** | 三层防御(模糊映射 + 容错重试 + 空结果兜底) | ✅ 认可 | -| **V2.4 SOP状态机** | 粗粒度 SOP 节点 + 节点内 ReAct | ✅ 认可 | - -### 1.1 最终架构选型:双脑路由模型 - -> **核心理念**:左脑(SOP) 保证严谨合规,右脑(ReAct) 提供灵活智能 - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ 企业微信 / 前端入口 │ -│ [文本消息] [图片消息] [定时触发] │ -└─────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ 🧠 意图路由层 (IntentService) │ -│ LLM 驱动,非正则匹配 │ -│ ┌────────────────────────────────────────────────────────────────┐ │ -│ │ 输入: "帮我查下那个发烧的病人是谁?" │ │ -│ │ 输出: { type: "QA_QUERY", entities: {...}, needsClarification } │ │ -│ └────────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────┘ - │ │ │ - ▼ ▼ ▼ -┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ -│ 📐 左脑 (SOP) │ │ 🎨 右脑 (ReAct) │ │ ❓ 追问机制 │ -│ 结构化任务 │ │ 开放性查询 │ │ 信息不全 │ -│ 写操作必经 │ │ 只读不写 │ │ 主动澄清 │ -│ │ │ │ │ │ -│ • 质控流程 │ │ • 多步推理 │ │ • "请问您指的是 │ -│ • 入排判断 │ │ • 模糊查询 │ │ 哪位患者?" │ -│ • 数据录入 │ │ • 统计分析 │ │ │ -└──────────────────┘ └──────────────────┘ └──────────────────┘ - │ │ - ▼ ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ SopEngine (状态机) │ -│ ┌────────────────────────────────────────────────────────────────┐ │ -│ │ 节点A: HardRuleEngine → 节点B: SoftRuleEngine → 节点C: ... │ │ -│ └────────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ ToolsService (工具层) │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ 🔓 只读工具 (ReAct 可用) │ 🔒 读写工具 (仅 SOP 可用) │ │ -│ │ • read_clinical_data │ • write_clinical_data │ │ -│ │ • search_protocol │ • manage_issue │ │ -│ │ • get_project_stats │ • update_record │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ 适配器层 (Adapters) │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ │ -│ │RedcapAdapter │ │ DifyClient │ │WechatAdapter │ │VLMAdapter│ │ -│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────┘ │ -└─────────────────────────────────────────────────────────────────────┘ -``` - -**双脑对比**: - -| 维度 | 左脑 (SOP 状态机) | 右脑 (ReAct Agent) | -|------|-------------------|-------------------| -| **擅长** | 执行标准流程、合规检查 | 处理模糊提问、多步查询 | -| **典型指令** | "对 P001 进行入排质控" | "帮我查下最近那个发烧的病人" | -| **思维方式** | 线性执行 (Step 1 → Step 2) | 循环推理 (思考→查库→再思考) | -| **数据权限** | 读写皆可 | **只读 (Read-Only)** | -| **用户评价** | "靠谱但死板" | "聪明但不可控" | - -### 1.2 核心设计原则 - -| 原则 | 描述 | -|------|------| -| **Postgres-Only** | 无 Redis,用 pg-boss 替代队列,用 Postgres 存储 Skill 配置 | -| **Service-First** | 不用 MCP Server,用 Service Class 实现工具调用 | -| **混合双引擎** | 硬规则(CPU) + 软指令(LLM),能用规则的不用 AI | -| **SOP 状态机** | 粗粒度节点控制流程,节点内 ReAct 保持灵活性 | -| **三层防御** | 字段映射 + 自我修正 + 空结果兜底 | - ---- - -## 2. 现有代码资产盘点 - -### 2.1 已完成的组件 - -| 组件 | 路径 | 状态 | 备注 | -|------|------|------|------| -| ChatService | `services/ChatService.ts` | ✅ 可复用 | 需扩展路由逻辑 | -| SessionMemory | `agents/SessionMemory.ts` | ✅ 可复用 | 内存存储,支持过期清理 | -| RedcapAdapter | `adapters/RedcapAdapter.ts` | ✅ 可复用 | 核心数据访问层 | -| WechatService | `services/WechatService.ts` | ✅ 可复用 | 企业微信消息推送 | -| DifyClient | `common/rag/DifyClient.ts` | ✅ 可复用 | 知识库检索 | -| LLMFactory | `common/llm/adapters/LLMFactory.ts` | ✅ 可复用 | 统一 LLM 调用 | - -### 2.2 待开发的组件 - -| 组件 | 优先级 | 说明 | -|------|--------|------| -| `iit_skills` 表 | P0 | Skill 配置存储 | -| `iit_field_mapping` 表 | P0 | 字段名映射字典 | -| `ToolsService` 类 | P0 | 统一工具管理 | -| `HardRuleEngine` 类 | P0 | JSON Logic 执行器 | -| `SoftRuleEngine` 类 | P1 | LLM 推理 + 自我修正 | -| `SopEngine` 类 | P1 | 状态机调度器 | -| `iit_user_preferences` 表 | P2 | 用户偏好存储 | - ---- - -## 3. 开发阶段规划 - -### Phase 1:基础设施层(Week 1) - -#### Day 1-2:数据库 Schema 扩展 - -**目标**:创建 Skill 配置和字段映射表 - -##### 任务清单 - -- [ ] **T1.1** 设计 `iit_skills` 表结构 - ```prisma - model IitSkill { - id String @id @default(uuid()) - projectId String // 绑定项目 - skillType String // qc_process | daily_briefing | general_chat - name String // 技能名称 - config Json // 核心配置 JSON - isActive Boolean @default(true) - version Int @default(1) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@unique([projectId, skillType]) - @@map("iit_skills") - @@schema("iit_schema") - } - ``` - -- [ ] **T1.2** 设计 `iit_field_mapping` 表结构 - ```prisma - model IitFieldMapping { - id String @id @default(uuid()) - projectId String // 项目级别映射 - aliasName String // LLM 可能传的名称(如 "gender", "性别") - actualName String // REDCap 实际字段名(如 "sex") - createdAt DateTime @default(now()) - - @@unique([projectId, aliasName]) - @@map("iit_field_mapping") - @@schema("iit_schema") - } - ``` - -- [ ] **T1.3** 执行数据库迁移 - ```bash - npx prisma db push - npx prisma generate - ``` - -- [ ] **T1.4** 插入测试数据(第一个 Skill 配置) - -**验收标准**: -- [ ] 表创建成功 -- [ ] 能正常 CRUD 操作 -- [ ] 测试 Skill 配置可查询 - ---- - -#### Day 3-4:ToolsService 实现 - -**目标**:创建统一的工具管理层 - -##### 任务清单 - -- [ ] **T2.1** 创建 `ToolsService.ts` - ``` - backend/src/modules/iit-manager/services/ToolsService.ts - ``` - -- [ ] **T2.2** 实现工具定义(TOOL_DEFINITIONS) - ```typescript - export const TOOL_DEFINITIONS = [ - { - name: "read_clinical_data", - description: "读取 REDCap 临床数据", - parameters: { - type: "object", - properties: { - record_id: { type: "string" }, - fields: { type: "array", items: { type: "string" } } - }, - required: ["record_id", "fields"] - } - }, - { - name: "search_protocol", - description: "检索研究方案文档", - parameters: { ... } - }, - { - name: "manage_issue", - description: "提出质疑或发送通知", - parameters: { ... } - }, - { - name: "get_project_stats", - description: "获取项目统计数据", - parameters: { ... } - } - ]; - ``` - -- [ ] **T2.3** 实现字段映射逻辑(第一层防御) - ```typescript - private async mapFields(projectId: string, fields: string[]): Promise { - const mappings = await prisma.iitFieldMapping.findMany({ - where: { projectId } - }); - const mappingDict = Object.fromEntries( - mappings.map(m => [m.aliasName.toLowerCase(), m.actualName]) - ); - return fields.map(f => mappingDict[f.toLowerCase()] || f); - } - ``` - -- [ ] **T2.4** 实现 `executeTool()` 统一入口 - -- [ ] **T2.5** 实现空结果兜底(第三层防御) - -**验收标准**: -- [ ] 4 个工具定义完成 -- [ ] 字段映射逻辑生效 -- [ ] 空结果返回友好消息 - ---- - -#### Day 5:HardRuleEngine 实现 - -**目标**:创建 CPU 执行的硬规则引擎 - -##### 任务清单 - -- [ ] **T3.1** 安装 json-logic-js - ```bash - npm install json-logic-js - npm install -D @types/json-logic-js - ``` - -- [ ] **T3.2** 创建 `HardRuleEngine.ts` - ``` - backend/src/modules/iit-manager/engines/HardRuleEngine.ts - ``` - -- [ ] **T3.3** 实现规则执行逻辑 - ```typescript - import jsonLogic from 'json-logic-js'; - - export class HardRuleEngine { - run(rules: HardRule[], data: Record): RuleResult[] { - const errors: RuleResult[] = []; - - for (const rule of rules) { - const passed = jsonLogic.apply(rule.logic, data); - if (!passed) { - errors.push({ - field: rule.field, - message: rule.message, - severity: rule.severity || 'error' - }); - } - } - - return errors; - } - } - ``` - -- [ ] **T3.4** 编写单元测试 - -**验收标准**: -- [ ] 能正确执行 `{ ">=": [{ "var": "age" }, 18] }` 类规则 -- [ ] 返回结构化错误列表 -- [ ] 单元测试覆盖主要场景 - ---- - -### Phase 2:引擎层实现(Week 2) - -#### Day 6-7:SoftRuleEngine 实现 - -**目标**:创建 LLM 推理引擎(含自我修正) - -##### 任务清单 - -- [ ] **T4.1** 创建 `SoftRuleEngine.ts` - ``` - backend/src/modules/iit-manager/engines/SoftRuleEngine.ts - ``` - -- [ ] **T4.2** 实现自我修正回路(第二层防御) - ```typescript - async runWithRetry( - instruction: string, - context: any, - maxRetries: number = 3 - ): Promise { - let history: Message[] = [...]; - - for (let i = 0; i < maxRetries; i++) { - const response = await this.llm.chat(history); - - if (response.hasToolCall) { - try { - const result = await this.tools.executeTool( - response.toolName, - response.args - ); - history.push({ role: 'tool', content: JSON.stringify(result) }); - } catch (error) { - // 自我修正:告诉 LLM 它错了 - history.push({ - role: 'user', - content: `工具调用失败: ${error.message}。请检查参数并重试。` - }); - continue; - } - } else { - return this.parseResult(response.content); - } - } - - return { passed: false, reason: '多次重试后仍失败' }; - } - ``` - -- [ ] **T4.3** 实现结果解析(要求 LLM 返回结构化 JSON) - -- [ ] **T4.4** 编写单元测试 - -**验收标准**: -- [ ] 能调用工具并获取结果 -- [ ] 工具失败时能自动重试 -- [ ] 返回结构化判断结果 - ---- - -#### Day 8-9:SopEngine 实现 - -**目标**:创建 SOP 状态机调度器 - -##### 任务清单 - -- [ ] **T5.1** 创建 `SopEngine.ts` - ``` - backend/src/modules/iit-manager/engines/SopEngine.ts - ``` - -- [ ] **T5.2** 定义 Skill 配置格式(SOP 流程图) - ```typescript - interface SopConfig { - name: string; - start_node: string; - nodes: { - [nodeId: string]: { - type: 'hard_rule' | 'soft_instruction'; - // hard_rule 类型 - rules?: HardRule[]; - // soft_instruction 类型 - instruction?: string; - tools?: string[]; - // 流转 - on_pass: string; - on_fail: string; - on_error?: string; - } - } - } - ``` - -- [ ] **T5.3** 实现状态机执行逻辑 - ```typescript - async run(skillConfig: SopConfig, data: any): Promise { - let currentNodeId = skillConfig.start_node; - let context = { ...data }; - const trace: TraceItem[] = []; - - while (currentNodeId && !currentNodeId.startsWith('end')) { - const node = skillConfig.nodes[currentNodeId]; - trace.push({ node: currentNodeId, timestamp: new Date() }); - - let result: NodeResult; - - if (node.type === 'hard_rule') { - result = this.hardEngine.run(node.rules, context); - } else { - result = await this.softEngine.runWithRetry(node.instruction, context); - } - - // 状态流转 - currentNodeId = result.passed ? node.on_pass : node.on_fail; - - // 记录违规 - if (!result.passed) { - await this.savePendingAction(result); - } - } - - return { trace, finalState: currentNodeId }; - } - ``` - -- [ ] **T5.4** 实现违规记录保存(Shadow State) - -- [ ] **T5.5** 编写集成测试 - -**验收标准**: -- [ ] 能按流程图顺序执行节点 -- [ ] 硬规则和软指令都能正确调度 -- [ ] 违规记录保存到 `iit_pending_actions` 表 - ---- - -#### Day 10:ChatService 集成 - -**目标**:将 SopEngine 集成到现有 ChatService - -##### 任务清单 - -- [ ] **T6.1** 扩展意图识别逻辑 - ```typescript - private detectIntent(message: string): Intent { - // 识别质控任务 - if (/质控|检查|校验|QC/.test(message)) { - return { type: 'qc_task', ... }; - } - // 其他意图... - } - ``` - -- [ ] **T6.2** 增加质控任务路由 - ```typescript - async handleMessage(userId: string, message: string): Promise { - const intent = this.detectIntent(message); - - if (intent.type === 'qc_task') { - // 路由到 SopEngine - return this.handleQcTask(userId, intent); - } - - // 普通问答继续走原有逻辑 - return this.handleGeneralChat(userId, message); - } - ``` - -- [ ] **T6.3** 实现 `handleQcTask()` 方法 - -**验收标准**: -- [ ] 质控任务走 SopEngine -- [ ] 普通问答走原有 LLM 逻辑 -- [ ] 两条路径互不干扰 - ---- - -### Phase 3:配置与测试(Week 3) - -#### Day 11-12:第一个完整 Skill 配置 - -**目标**:配置第一个项目的质控流程 - -##### 任务清单 - -- [ ] **T7.1** 设计肺癌研究质控流程(示例) - ```json - { - "name": "肺癌研究入组质控", - "start_node": "baseline_check", - "nodes": { - "baseline_check": { - "type": "hard_rule", - "rules": [ - { "field": "age", "logic": { ">=": [{"var":"age"}, 18] }, "message": "年龄<18岁" }, - { "field": "age", "logic": { "<=": [{"var":"age"}, 75] }, "message": "年龄>75岁" }, - { "field": "ecog", "logic": { "<=": [{"var":"ecog"}, 2] }, "message": "ECOG>2" } - ], - "on_pass": "history_check", - "on_fail": "end_with_violation" - }, - "history_check": { - "type": "soft_instruction", - "instruction": "检查既往史,排除间质性肺炎、活动性感染、严重心血管疾病。", - "tools": ["read_clinical_data"], - "on_pass": "medication_check", - "on_fail": "end_review_required" - }, - "medication_check": { - "type": "soft_instruction", - "instruction": "检查合并用药,排除其他抗肿瘤药物。", - "tools": ["read_clinical_data"], - "on_pass": "end_success", - "on_fail": "end_review_required" - } - } - } - ``` - -- [ ] **T7.2** 插入 Skill 配置到数据库 - -- [ ] **T7.3** 配置字段映射 - ```sql - INSERT INTO iit_field_mapping (project_id, alias_name, actual_name) VALUES - ('project-uuid', 'age', 'age_calculated'), - ('project-uuid', '年龄', 'age_calculated'), - ('project-uuid', 'ecog', 'ecog_score'), - ('project-uuid', '既往史', 'medical_history_text'), - ('project-uuid', 'history', 'medical_history_text'); - ``` - -**验收标准**: -- [ ] Skill 配置存储成功 -- [ ] 字段映射配置完成 - ---- - -#### Day 13-14:端到端测试 - -**目标**:完整流程测试 - -##### 测试场景 - -| 场景 | 输入 | 期望输出 | -|------|------|----------| -| **场景1:年龄不合规** | `{ "age": 16, "ecog": 1 }` | 触发硬规则违规,记录到 pending_actions | -| **场景2:病史违规** | `{ "age": 30, "ecog": 1, "history": "间质性肺炎" }` | AI 识别违规,转人工复核 | -| **场景3:全部通过** | `{ "age": 45, "ecog": 0, "history": "无特殊" }` | 流程正常结束 | -| **场景4:字段名映射** | Agent 传 `fields=["年龄"]` | 自动映射为 `age_calculated` | -| **场景5:工具失败重试** | REDCap 返回空 | Agent 收到友好提示,正确回复用户 | - -##### 任务清单 - -- [ ] **T8.1** 编写端到端测试脚本 -- [ ] **T8.2** 通过企业微信发送测试消息 -- [ ] **T8.3** 验证 pending_actions 记录 -- [ ] **T8.4** 验证 audit_log 记录 -- [ ] **T8.5** 性能测试(目标 < 5秒响应) - -**验收标准**: -- [ ] 5 个测试场景全部通过 -- [ ] 响应时间 < 5秒 -- [ ] 日志完整可追溯 - ---- - -#### Day 15:文档与收尾 - -##### 任务清单 - -- [ ] **T9.1** 更新 API 文档 -- [ ] **T9.2** 更新模块状态文档 -- [ ] **T9.3** 记录技术债务 -- [ ] **T9.4** 代码 Review 与合并 - ---- - -### Phase 4:定时任务与高级工具(Week 4 前半) - -> **目标**:实现定时报告、高级质控工具 - -#### Day 16-17:定时任务与报告生成 - -**目标**:实现每周自动生成研究进度报告 - -##### 任务清单 - -- [ ] **T10.1** 创建 `SchedulerService.ts`(基于 pg-boss) - ``` - backend/src/modules/iit-manager/services/SchedulerService.ts - ``` - -- [ ] **T10.2** 实现定时任务调度 - ```typescript - import PgBoss from 'pg-boss'; - - export class SchedulerService { - private boss: PgBoss; - - async init() { - this.boss = new PgBoss(process.env.DATABASE_URL); - await this.boss.start(); - - // 注册周报任务处理器 - await this.boss.work('weekly-report', this.handleWeeklyReport.bind(this)); - - // 每周一早上9点执行 - await this.boss.schedule('weekly-report', '0 9 * * 1', { projectId: 'all' }); - } - - private async handleWeeklyReport(job: Job) { - const report = await this.reportService.generateWeeklyReport(job.data.projectId); - await this.wechatService.sendToAdmins(report); - } - } - ``` - -- [ ] **T10.3** 创建 `ReportService.ts` - ``` - backend/src/modules/iit-manager/services/ReportService.ts - ``` - -- [ ] **T10.4** 实现周报生成逻辑 - ```typescript - export class ReportService { - async generateWeeklyReport(projectId: string): Promise { - const stats = await this.getProjectStats(projectId); - - const report = ` -📊 **${stats.projectName} 周报** -📅 ${stats.weekRange} - -**入组进度** -- 本周新入组:${stats.newEnrollments} 例 -- 累计入组:${stats.totalEnrollments} / ${stats.targetEnrollments} 例 -- 完成率:${stats.completionRate}% - -**数据质量** -- 待处理质疑:${stats.pendingQueries} 条 -- 本周关闭质疑:${stats.closedQueries} 条 -- 方案偏离:${stats.protocolDeviations} 例 - -**AE/SAE** -- 本周新增 AE:${stats.newAEs} 例 -- 本周新增 SAE:${stats.newSAEs} 例 - -**下周重点** -${stats.upcomingVisits.map(v => `- ${v.patientId}: ${v.visitName} (${v.dueDate})`).join('\n')} - `; - - return report; - } - } - ``` - -- [ ] **T10.5** 配置周报 Skill - ```json - { - "skillType": "weekly_report", - "config": { - "schedule": "0 9 * * 1", - "recipients": ["admin_group"], - "sections": ["enrollment", "data_quality", "ae_summary", "upcoming_visits"] - } - } - ``` - -**验收标准**: -- [ ] 每周一自动生成周报 -- [ ] 周报通过企业微信发送给管理员 -- [ ] 周报内容完整、格式美观 - ---- - -#### Day 18-19:高级质控工具扩展 - -**目标**:新增方案偏离、AE评估、伦理检查工具 - -##### 任务清单 - -- [ ] **T11.1** 新增 `check_visit_window` 工具(方案偏离检测) - ```typescript - { - name: "check_visit_window", - description: "检查访视是否在方案允许的时间窗口内", - parameters: { - record_id: { type: "string" }, - visit_id: { type: "string" }, - actual_date: { type: "string", format: "date" } - }, - handler: async (args) => { - const baseline = await this.getBaselineDate(args.record_id); - const window = await this.getVisitWindow(args.visit_id); - const actualDays = this.daysBetween(baseline, args.actual_date); - - const inWindow = actualDays >= window.minDays && actualDays <= window.maxDays; - return { - inWindow, - expectedRange: `Day ${window.minDays} - Day ${window.maxDays}`, - actualDay: actualDays, - deviation: inWindow ? 0 : Math.min( - Math.abs(actualDays - window.minDays), - Math.abs(actualDays - window.maxDays) - ) - }; - } - } - ``` - -- [ ] **T11.2** 新增 `assess_ae_causality` 工具(AE因果关系评估) - ```typescript - { - name: "assess_ae_causality", - description: "评估不良事件与研究药物的因果关系", - parameters: { - record_id: { type: "string" }, - ae_id: { type: "string" } - }, - handler: async (args) => { - const ae = await this.getAEDetails(args.record_id, args.ae_id); - const drugInfo = await this.getDrugExposure(args.record_id); - - // 使用 SoftRuleEngine 评估 - const assessment = await this.softEngine.runWithRetry( - `根据以下信息评估AE与研究药物的因果关系: - AE信息:${JSON.stringify(ae)} - 用药信息:${JSON.stringify(drugInfo)} - 请给出:肯定相关/可能相关/可能无关/肯定无关/无法评估`, - { ae, drugInfo } - ); - - return assessment; - } - } - ``` - -- [ ] **T11.3** 新增 `check_ethics_compliance` 工具(伦理合规检查) - ```typescript - { - name: "check_ethics_compliance", - description: "检查是否符合伦理要求", - parameters: { - record_id: { type: "string" }, - check_type: { - type: "string", - enum: ["informed_consent", "age_requirement", "vulnerable_population"] - } - }, - handler: async (args) => { - const rules = { - informed_consent: { - logic: { "<=": [{ "var": "icf_date" }, { "var": "enrollment_date" }] }, - message: "入组日期早于知情同意签署日期" - }, - age_requirement: { - logic: { ">=": [{ "var": "age" }, 18] }, - message: "未成年受试者需要法定监护人签署同意书" - } - }; - - const data = await this.getRecordData(args.record_id); - return this.hardEngine.run([rules[args.check_type]], data); - } - } - ``` - -- [ ] **T11.4** 更新 ToolsService 工具列表 - -**验收标准**: -- [ ] 访视超窗能被正确检测 -- [ ] AE 因果关系能给出评估结论 -- [ ] 伦理违规能被识别 - ---- - -### Phase 5:智能化增强 - 双脑架构(Week 4 后半 - Week 5) - -> **目标**:实现 LLM 意图路由 + ReAct 多步推理 + 追问机制,让 Agent "懂人话" -> -> **优先级调整说明**:智能化交互比视觉识别更影响用户留存,优先实现 - -#### Day 20-21:意图路由层实现 - -**目标**:用 LLM 替代正则匹配,实现智能意图识别 - -##### 任务清单 - -- [ ] **T12.1** 创建 `IntentService.ts` - ``` - backend/src/modules/iit-manager/services/IntentService.ts - ``` - -- [ ] **T12.2** 实现 LLM 驱动的意图识别 - ```typescript - export class IntentService { - private llm; - - async detect(message: string, history: Message[]): Promise { - const prompt = ` -你是一个临床研究助手的"分诊台"。请分析用户输入,返回 JSON。 - -用户输入: "${message}" - -分类标准: -1. QC_TASK: 明确的质控、检查、录入指令(如"检查P001的入排标准") -2. QA_QUERY: 模糊的查询、分析、统计问题(如"查下那个发烧的病人是谁") -3. PROTOCOL_QA: 关于研究方案的问题(如"访视窗口是多少天") -4. UNCLEAR: 指代不清,缺少关键信息(如"他怎么样了?") - -返回格式: -{ - "type": "QC_TASK" | "QA_QUERY" | "PROTOCOL_QA" | "UNCLEAR", - "confidence": 0.0-1.0, - "entities": { "record_id": "...", "visit": "..." }, - "missing_info": "如果 UNCLEAR,说明缺什么信息", - "clarification_question": "如果 UNCLEAR,生成追问句" -}`; - - const response = await this.llm.chat([ - { role: 'system', content: prompt }, - ...history.slice(-3), - { role: 'user', content: message } - ]); - - return JSON.parse(response.content); - } - } - ``` - -- [ ] **T12.3** 实现降级策略(LLM 不可用时回退关键词匹配) - ```typescript - async detectWithFallback(message: string, history: Message[]): Promise { - try { - return await this.detect(message, history); - } catch (error) { - logger.warn('[IntentService] LLM 不可用,回退到关键词匹配'); - return this.keywordFallback(message); - } - } - - private keywordFallback(message: string): IntentResult { - if (/质控|检查|校验|QC|入排/.test(message)) { - return { type: 'QC_TASK', confidence: 0.6, entities: {} }; - } - return { type: 'QA_QUERY', confidence: 0.5, entities: {} }; - } - ``` - -- [ ] **T12.4** 集成到 ChatService 路由层 - -**验收标准**: -- [ ] "查下那个发烧的病人" → 识别为 QA_QUERY -- [ ] "对 P001 进行入排质控" → 识别为 QC_TASK -- [ ] "他怎么样了" → 识别为 UNCLEAR,生成追问句 -- [ ] LLM 服务中断时自动降级 - ---- - -#### Day 22-23:ReAct Agent 实现 - -**目标**:创建多步推理引擎,支持循环思考和工具调用 - -##### 任务清单 - -- [ ] **T13.1** 创建 `ReActEngine.ts` - ``` - backend/src/modules/iit-manager/engines/ReActEngine.ts - ``` - -- [ ] **T13.2** 实现 ReAct 循环(思考→行动→观察→再思考) - ```typescript - export class ReActEngine { - private maxIterations = 5; // 防止死循环 - private maxTokens = 4000; // Token 预算限制 - private tokenCounter = 0; - - // 工具白名单:ReAct 只能调用只读工具 - private readonly READONLY_TOOLS = [ - 'read_clinical_data', - 'search_protocol', - 'get_project_stats', - 'check_visit_window' - ]; - - async run(query: string, context: AgentContext): Promise { - const systemPrompt = `你是一个临床研究智能助手。你可以使用以下工具回答问题: -${this.formatToolDescriptions(this.READONLY_TOOLS)} - -请按照 ReAct 模式思考: -1. 思考:分析问题,决定下一步 -2. 行动:调用工具获取信息 -3. 观察:查看工具返回结果 -4. 重复以上步骤,直到能回答用户问题 - -注意:你只能查询数据,不能修改数据。如果用户需要修改操作,请引导他们使用正式的质控流程。`; - - let messages: Message[] = [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: query } - ]; - - const trace: TraceItem[] = []; - - for (let i = 0; i < this.maxIterations; i++) { - // Token 预算检查 - if (this.tokenCounter > this.maxTokens) { - return { - success: false, - content: '抱歉,这个问题比较复杂,请尝试更具体的描述。', - trace - }; - } - - const response = await this.llm.chat(messages, { - tools: this.getReadonlyToolDefinitions() - }); - - this.tokenCounter += response.usage?.total_tokens || 0; - trace.push({ iteration: i, response: response.content }); - - // AI 决定结束 - if (!response.toolCalls || response.toolCalls.length === 0) { - return { success: true, content: response.content, trace }; - } - - // 执行工具调用 - let errorCount = 0; - for (const toolCall of response.toolCalls) { - // 安全检查:只允许只读工具 - if (!this.READONLY_TOOLS.includes(toolCall.name)) { - messages.push({ - role: 'tool', - toolCallId: toolCall.id, - content: `错误:工具 ${toolCall.name} 不在允许列表中。你只能使用只读工具。` - }); - errorCount++; - continue; - } - - try { - const result = await this.tools.executeTool(toolCall.name, toolCall.args); - messages.push({ - role: 'tool', - toolCallId: toolCall.id, - content: JSON.stringify(result) - }); - } catch (error) { - errorCount++; - messages.push({ - role: 'tool', - toolCallId: toolCall.id, - content: `工具调用失败: ${error.message}` - }); - } - } - - // 幻觉熔断:连续 2 次工具调用全部失败 - if (errorCount >= 2) { - return { - success: false, - content: '抱歉,我遇到了一些技术问题,无法获取相关信息。请稍后重试或联系管理员。', - trace - }; - } - } - - return { - success: false, - content: '抱歉,我无法在有限步骤内完成这个查询。请尝试更具体的问题。', - trace - }; - } - } - ``` - -- [ ] **T13.3** 实现 trace 日志记录(调试用) - ```typescript - private async saveTrace(userId: string, query: string, trace: TraceItem[]) { - await prisma.iitAgentTrace.create({ - data: { - userId, - query, - trace: JSON.stringify(trace), - createdAt: new Date() - } - }); - } - ``` - -- [ ] **T13.4** 编写单元测试 - -**验收标准**: -- [ ] "最近入组的女性患者平均年龄" → 自动调用工具查询并计算 -- [ ] 连续工具失败时触发熔断 -- [ ] Token 超预算时优雅终止 -- [ ] 尝试调用写工具时被拦截 - ---- - -#### Day 24:追问机制与上下文增强 - -**目标**:信息不全时主动追问,增强上下文记忆 - -##### 任务清单 - -- [ ] **T14.1** 实现追问机制 - ```typescript - // ChatService 中的追问逻辑 - async handleMessage(userId: string, message: string): Promise { - const history = this.sessionMemory.getHistory(userId); - const intent = await this.intentService.detect(message, history); - - // 信息不全,主动追问 - if (intent.type === 'UNCLEAR') { - const clarification = intent.clarification_question - || `请问您能具体说明一下吗?例如:${this.getSuggestions(intent)}`; - - this.sessionMemory.addMessage(userId, 'assistant', clarification); - return clarification; - } - - // 根据意图路由 - if (intent.type === 'QC_TASK') { - return this.sopEngine.run(intent); - } else { - return this.reactEngine.run(message, { history, intent }); - } - } - ``` - -- [ ] **T14.2** 增强 SessionMemory,支持实体记忆 - ```typescript - export class SessionMemory { - // 新增:记住对话中提到的实体 - private entityMemory: Map = new Map(); - - addEntityContext(userId: string, entities: Record) { - const existing = this.entityMemory.get(userId) || {}; - this.entityMemory.set(userId, { ...existing, ...entities }); - } - - resolveReference(userId: string, reference: string): string | null { - const context = this.entityMemory.get(userId); - if (!context) return null; - - // 解析 "他"、"这个患者" 等指代 - if (['他', '她', '这个患者', '那个病人'].includes(reference)) { - return context.lastMentionedPatient; - } - return null; - } - } - ``` - -- [ ] **T14.3** 实现指代消解 - ```typescript - // 在处理消息前,先解析指代 - private resolveReferences(userId: string, message: string): string { - const pronouns = ['他', '她', '这个患者', '那个病人']; - let resolved = message; - - for (const pronoun of pronouns) { - if (message.includes(pronoun)) { - const actual = this.sessionMemory.resolveReference(userId, pronoun); - if (actual) { - resolved = resolved.replace(pronoun, actual); - } - } - } - - return resolved; - } - ``` - -**验收标准**: -- [ ] "他怎么样了" → 回复 "请问您指的是哪位患者?" -- [ ] 如果上文提到 P001,"他怎么样了" → 自动解析为 P001 -- [ ] 对话上下文在会话内保持连贯 - ---- - -#### Day 25:集成测试与优化 - -**目标**:完成双脑架构的完整测试 - -##### 测试场景(Phase 5) - -| 场景 | 输入 | 期望输出 | -|------|------|----------| -| **场景6:模糊查询** | "查下最近入组的病人" | ReAct 自动查询并返回列表 | -| **场景7:多步推理** | "最近入组的女性平均年龄" | 多步工具调用 + 计算结果 | -| **场景8:追问** | "他怎么样了" | "请问您指的是哪位患者?" | -| **场景9:指代消解** | (上文提到P001) "他的入排状态" | 自动识别为 P001 | -| **场景10:写操作拦截** | (ReAct中) 尝试修改数据 | 被拦截并引导到 SOP 流程 | -| **场景11:熔断** | 连续工具失败 | 优雅终止并提示 | - -##### 任务清单 - -- [ ] **T15.1** 编写 Phase 5 测试脚本 -- [ ] **T15.2** 验证意图识别准确率(目标 > 85%) -- [ ] **T15.3** 验证 ReAct 多步推理成功率 -- [ ] **T15.4** 验证追问机制用户体验 - -**验收标准**: -- [ ] 11 个测试场景全部通过 -- [ ] 意图识别准确率 > 85% -- [ ] ReAct 平均迭代次数 < 3 -- [ ] 用户反馈"不再觉得像傻子" - ---- - -### Phase 6:视觉能力(Week 6) - -> **目标**:支持拍照上传 → 识别 → 自动录入 REDCap - -#### Day 26-27:视觉能力集成 - -**目标**:支持拍照上传 → 识别 → 自动录入 REDCap - -##### 任务清单 - -- [ ] **T16.1** 创建 `VisionService.ts` - ``` - backend/src/modules/iit-manager/services/VisionService.ts - ``` - -- [ ] **T16.2** 集成视觉大模型(推荐 Qwen-VL / GPT-4V) - ```typescript - export class VisionService { - private vlmAdapter: VLMAdapter; - - async extractFromImage(imageUrl: string, projectId: string): Promise { - // 1. 调用视觉模型识别内容 - const rawText = await this.vlmAdapter.recognize(imageUrl); - - // 2. 结构化提取 - const structured = await this.structureData(rawText, projectId); - - // 3. 匹配表单 - const formMatch = await this.matchForm(structured, projectId); - - return { rawText, structured, formMatch }; - } - } - ``` - -- [ ] **T16.3** 创建表单模板表 `iit_form_templates` - ```prisma - model IitFormTemplate { - id String @id @default(uuid()) - projectId String - formName String // REDCap 表单名称 - fieldSchema Json // 表单字段结构 - keywords String[] // 用于匹配的关键词 - createdAt DateTime @default(now()) - - @@map("iit_form_templates") - @@schema("iit_schema") - } - ``` - -- [ ] **T16.4** 扩展 `RedcapAdapter.writeRecord()` 写入能力 - -- [ ] **T16.5** ChatService 增加图片消息路由 - -**验收标准**: -- [ ] 上传化验单图片能识别内容 -- [ ] 自动匹配到正确的 REDCap 表单 -- [ ] 高置信度时自动录入,低置信度时人工确认 - ---- - -#### Day 28:最终集成测试 - -**目标**:完成全部场景测试 - -##### 测试场景(全量) - -| 场景 | 输入 | 期望输出 | -|------|------|----------| -| **场景12:拍照识别** | 上传化验单图片 | 识别内容,匹配表单,自动录入 | -| **场景13:访视超窗** | V3 访视超出窗口期 5 天 | 检测到方案偏离 | -| **场景14:AE评估** | SAE 事件数据 | 给出因果关系评估 | -| **场景15:伦理违规** | ICF 日期晚于入组日期 | 识别伦理违规 | -| **场景16:周报生成** | 触发定时任务 | 生成并发送周报 | - -##### 任务清单 - -- [ ] **T17.1** 编写全量测试脚本 -- [ ] **T17.2** 验证视觉识别准确率(目标 > 85%) -- [ ] **T17.3** 性能优化(图片处理 < 10s) -- [ ] **T17.4** 更新文档和部署指南 - -**验收标准**: -- [ ] 16 个测试场景全部通过 -- [ ] 图片识别准确率 > 85% -- [ ] 定时任务连续运行 7 天无故障 - ---- - -## 4. 风险与应对 - -### 4.1 基础架构风险 - -| 风险 | 影响 | 应对措施 | -|------|------|----------| -| JSON Logic 表达能力不足 | 复杂规则无法配置 | 支持 `function_name` 模式,调用预定义函数 | -| LLM 响应慢 | 用户体验差 | 硬规则先行,减少 LLM 调用 | -| 字段映射字典不全 | 工具调用失败 | 自我修正回路 + 日志记录 + 运营补充 | -| REDCap 不可用 | 流程中断 | 增加 `on_error` 分支,友好提示 | - -### 4.2 双脑架构风险 (Phase 5) - -| 风险 | 影响 | 应对措施 | -|------|------|----------| -| 意图识别错误 | 路由到错误分支 | 置信度阈值 + 低置信度时追问确认 | -| ReAct 死循环 | 烧钱、用户等待 | 最大迭代次数 = 5,Token 预算 = 4000 | -| ReAct 调用写工具 | 数据被误改 | **工具白名单**,只允许只读工具 | -| LLM 幻觉 | 返回错误信息 | 连续 2 次工具失败触发熔断 | -| LLM 服务中断 | 无法响应 | 降级到关键词匹配 + 友好提示 | -| Token 成本失控 | 费用超预算 | Token 计数 + 单次查询预算限制 | - -### 4.3 扩展能力风险 (Phase 4 & 6) - -| 风险 | 影响 | 应对措施 | -|------|------|----------| -| 视觉模型识别错误 | 录入错误数据 | 低置信度人工确认,高置信度才自动录入 | -| 定时任务失败 | 周报未发送 | pg-boss 自动重试 + 失败告警 | -| 图片处理超时 | 用户体验差 | 异步处理 + 进度提示 | - ---- - -## 5. 成功标准 - -### Phase 1-3 验收标准(核心质控) - -- [ ] 企业微信发送"质控 ID=001",3秒内收到回复 -- [ ] 硬规则违规能自动识别并记录 -- [ ] 软指令能正确调用工具并判断 -- [ ] 字段名映射生效 -- [ ] 工具失败能自动重试 -- [ ] 违规记录可在后台查看 - -### Phase 4 验收标准(定时任务与高级工具) - -- [ ] 访视超窗能被自动检测并记录 -- [ ] AE 事件能给出因果关系评估 -- [ ] 伦理违规能被识别并告警 -- [ ] 每周一自动生成并发送周报 -- [ ] 定时任务连续运行 7 天无故障 - -### Phase 5 验收标准(双脑架构) - -- [ ] 自然语言意图识别准确率 > 85% -- [ ] "查下最近入组的病人" → ReAct 自动查询返回 -- [ ] "他怎么样了" → 主动追问 "请问您指的是哪位患者?" -- [ ] 上文提到 P001 后,"他的状态" → 自动识别为 P001 -- [ ] ReAct 尝试调用写工具 → 被拦截并引导到 SOP -- [ ] LLM 服务中断 → 自动降级到关键词匹配 -- [ ] 用户反馈:**"不再觉得 Agent 像个傻子"** - -### Phase 6 验收标准(视觉能力) - -- [ ] 拍照上传化验单,自动识别并录入 REDCap -- [ ] 图片识别准确率 > 85% -- [ ] 低置信度时要求人工确认 - -### 性能指标 - -| 指标 | 目标值 | -|------|--------| -| 硬规则执行时间 | < 100ms | -| 软指令执行时间 | < 3s | -| 端到端响应时间 | < 5s | -| **意图识别时间** | < 1s | -| **ReAct 平均迭代次数** | < 3 | -| 图片识别+录入时间 | < 10s | -| 视觉识别准确率 | > 85% | -| **意图识别准确率** | > 85% | -| 自我修正成功率 | > 80% | - ---- - -## 6. 附录:文件路径清单 - -``` -backend/src/modules/iit-manager/ -├── services/ -│ ├── ChatService.ts # 扩展路由逻辑(双脑路由) -│ ├── IntentService.ts # 新建:LLM 意图识别 (Phase 5) -│ ├── ToolsService.ts # 新建:统一工具管理 -│ ├── SchedulerService.ts # 新建:定时任务调度 (Phase 4) -│ ├── ReportService.ts # 新建:报告生成服务 (Phase 4) -│ ├── VisionService.ts # 新建:视觉识别服务 (Phase 6) -│ └── WechatService.ts # 已存在 -├── engines/ -│ ├── HardRuleEngine.ts # 新建:JSON Logic 执行器 -│ ├── SoftRuleEngine.ts # 新建:LLM 推理引擎 -│ ├── SopEngine.ts # 新建:状态机调度器 -│ └── ReActEngine.ts # 新建:多步推理引擎 (Phase 5) -├── agents/ -│ └── SessionMemory.ts # 已存在,扩展实体记忆 (Phase 5) -├── adapters/ -│ ├── RedcapAdapter.ts # 已存在,扩展 writeRecord() -│ └── VLMAdapter.ts # 新建:视觉大模型适配器 (Phase 6) -└── types/ - └── index.ts # 扩展类型定义 -``` - -### 数据库新增表 - -``` -iit_schema.iit_skills # Skill 配置存储 -iit_schema.iit_field_mapping # 字段名映射 -iit_schema.iit_agent_trace # ReAct 推理轨迹 (Phase 5) -iit_schema.iit_form_templates # 表单模板 (Phase 6) -``` - ---- - -## 7. 参考文档 - -1. [架构决策白皮书:极简主义的胜利](../00-系统设计/IIT%20Manager%20Agent%20架构决策白皮书:极简主义的胜利.md) -2. [V2.2 落地实施指南](../00-系统设计/IIT%20Manager%20Agent%20V2.2%20落地实施指南:极简架构版%20(融合%20Moltbot).md) -3. [V2.2 工具泛化与灵活性提升指南](../00-系统设计/IIT%20Manager%20Agent%20V2.2:工具泛化与灵活性提升指南.md) -4. [V2.3 健壮性设计与最佳实践](../00-系统设计/IIT%20Manager%20Agent%20V2.3:健壮性设计与最佳实践.md) -5. [V2.4 架构模式选型与 SOP 状态机推荐](../00-系统设计/IIT%20Manager%20Agent%20V2.4:架构模式选型与%20SOP%20状态机推荐.md) -6. [V2.6 智能化升级方案:双脑架构](../00-系统设计/IIT%20Manager%20Agent%20V2.6%20智能化升级方案:双脑架构.md) - ---- - -**文档维护人**:AI Agent -**最后更新**:2026-02-02(V2.6 整合双脑架构) diff --git a/docs/03-业务模块/IIT Manager Agent/04-开发计划/IIT Manager Agent V2.6 综合开发计划.md b/docs/03-业务模块/IIT Manager Agent/04-开发计划/IIT Manager Agent V2.6 综合开发计划.md index 6fd990bf..f698a117 100644 --- a/docs/03-业务模块/IIT Manager Agent/04-开发计划/IIT Manager Agent V2.6 综合开发计划.md +++ b/docs/03-业务模块/IIT Manager Agent/04-开发计划/IIT Manager Agent V2.6 综合开发计划.md @@ -1,10 +1,10 @@ # IIT Manager Agent V2.6 综合开发计划 -> **版本:** V2.9(极简架构 + SOP状态机 + 双脑路由 + 三层记忆 + 主动性增强) +> **版本:** V2.9.1(极简架构 + SOP状态机 + 双脑路由 + 三层记忆 + 主动性增强 + 隐私合规) > **日期:** 2026-02-05 > **团队规模:** 2人 > **预估周期:** 6周 -> **核心目标:** 实现数据质控 Agent 的完整闭环 + 智能化交互 + 长期记忆 + 主动提醒 + 个性化 +> **核心目标:** 实现数据质控 Agent 的完整闭环 + 智能化交互 + 长期记忆 + 主动提醒 + 个性化 + **隐私合规** --- @@ -63,8 +63,32 @@ | **V2.4 SOP状态机** | 粗粒度 SOP 节点 + 节点内 ReAct | ✅ 认可 | | **V2.8 记忆系统** | 三层记忆(流水账 + 热记忆 + 历史书) | ✅ 认可 | | **V2.9 主动性增强** | Cron Skill + 用户画像 + 反馈循环 | ✅ 认可 | +| **V2.9.1 隐私合规** | PII 脱敏中间件 + REDCap Schema 自动对齐 | ✅ 认可 | -### 1.0 V2.9 核心增强(新) +### 1.0 V2.9.1 隐私合规增强(P0 必需) + +> **⚠️ 重要**:临床数据包含大量患者隐私信息,在调用第三方 LLM 之前**必须脱敏**! + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ V2.9.1 隐私合规能力 │ +├─────────────────────────────────┬───────────────────────────────────┤ +│ 🔒 AnonymizerService │ 🔧 AutoMapperService │ +│ PII 脱敏中间件 │ REDCap Schema 自动对齐 │ +│ - 身份证号脱敏 │ - Data Dictionary 解析 │ +│ - 手机号脱敏 │ - LLM 语义映射 │ +│ - 中文姓名脱敏 │ - 人工确认 UI │ +│ - 审计日志加密存储 │ - 效率提升 8-16x │ +└─────────────────────────────────┴───────────────────────────────────┘ +``` + +| 能力 | 实现方式 | 价值 | +|------|----------|------| +| **PII 脱敏** | `AnonymizerService.mask/unmask()` + 正则库 | 合规必需,防止隐私泄露 | +| **审计日志** | `iit_pii_audit_log` 表 + 加密存储 | 事后合规审计 | +| **Schema 自动对齐** | `AutoMapperService` + LLM 语义匹配 | 字段配置效率提升 8-16x | + +### 1.1 V2.9 核心增强 > **目标**:让 Agent 从"被动应答"进化为"主动协作",同时根据用户反馈持续优化 @@ -325,3 +349,4 @@ backend/src/modules/iit-manager/ | V2.6.1 | 2026-02-05 | 整合团队风险审查建议;拆分为多个专项文档 | | V2.6.2 | 2026-02-05 | 简化表结构:删除 `iit_user_preferences` 和 `iit_patient_notes`(合并到 `project_memory`) | | V2.9 | 2026-02-05 | 主动性增强:Cron Skill、用户画像、反馈循环、多意图处理 | +| V2.9.1 | 2026-02-05 | 隐私合规:PII 脱敏中间件、REDCap Schema 自动对齐工具 | diff --git a/docs/03-业务模块/IIT Manager Agent/05-测试文档/IIT Manager Agent V2.9 补充:业务规则与数据治理细则 (1).md b/docs/03-业务模块/IIT Manager Agent/05-测试文档/IIT Manager Agent V2.9 补充:业务规则与数据治理细则 (1).md new file mode 100644 index 00000000..1b2de0d8 --- /dev/null +++ b/docs/03-业务模块/IIT Manager Agent/05-测试文档/IIT Manager Agent V2.9 补充:业务规则与数据治理细则 (1).md @@ -0,0 +1,200 @@ +# **IIT Manager Agent V2.9 补充:业务规则与数据治理细则** + +**文档性质:** 业务逻辑落地方案 + +**评审结论:** 4 点建议逻辑清晰,完全可行。本方案在原有基础上进行了 Postgres 原生特性的优化。 + +## **1\. 质控规则来源:自动化与人工的完美结合** + +**评价**:非常合理。这是降低实施成本的关键。如果全部靠人工配,运营会累死;如果全部靠自动,医学逻辑会缺失。 + +**落地优化建议**: + +我们在 iit\_skills 表的 JSON 配置中,引入 **source** 字段来标记规则来源,方便管理。 + +// qc\_skill.json (最终生成的配置文件) +{ + "hard\_rules": \[ + // \=== 自动生成区 (Auto-Generated) \=== + { + "field": "age", + "logic": { "\>=": \[{ "var": "age" }, 18\] }, + "message": "年龄必须 \>= 18", + "source": "meta\_validation\_min", // 标记来源 + "level": "warning" + }, + { + "field": "informed\_consent\_date", + "logic": { "\!=": \[{ "var": "informed\_consent\_date" }, ""\] }, + "message": "知情同意日期必填", + "source": "meta\_required", + "level": "error" + }, + + // \=== 人工配置区 (Manual) \=== + { + "field": "visit\_date", + "logic": { "\<=": \[{ "var": "visit\_date" }, { "var": "today" }\] }, + "message": "访视日期不能是未来", + "source": "manual\_config", + "level": "error" + } + \] +} + +**实施流程**: + +1. **Sync**: RedcapAdapter 拉取 Metadata。 +2. **Generate**: 代码遍历 Metadata,生成一个 draft\_rules 数组。 +3. **Merge**: 将 draft\_rules 与数据库里已有的 manual\_rules 合并。 +4. **Confirm**: 在管理端展示,人工确认后保存。 + +## **2\. 质控数据存储:利用 JSONB 简化分层** + +**评价**:分层思路是对的,但**建议简化物理表结构**。 + +如果每个字段的错误都存一行记录(iit\_qc\_results),当项目有 1000 个病人 x 400 个变量时,这张表会瞬间爆炸(千万级行数),查询变慢。 + +**Postgres 优化方案**: + +利用 Postgres 强大的 **JSONB** 能力,将(1)和(2)合并,保留(3)。 + +### **优化后的 Schema:** + +**(1) \+ (2) 合并为:iit\_qc\_logs (记录级 \+ 字段级详情)** + +model IitQcLog { + id String @id @default(uuid()) + projectId String + recordId String + eventId String + + // 核心结果 + status String // 'PASS' | 'FAIL' | 'WARNING' + + // 字段级详情,直接存 JSONB + // 格式: \[{ field: "age", error: "范围越界", level: "RED" }, { ... }\] + // 优势: Postgres 支持对 JSONB 内部字段建立索引,查询速度一样快,但表行数少 100 倍 + issues Json + + ruleVersion String // 对应问题4的解决方案 + createdAt DateTime @default(now()) + + @@index(\[projectId, recordId\]) + @@map("iit\_qc\_logs") +} + +**(3) 保留:iit\_qc\_project\_stats (每日汇总)** + +* 用于 Dashboard 快速展示,避免每次都 COUNT(\*) 几百万行日志。 + +## **3\. 主动干预分级:防打扰机制** + +**评价**:非常棒的\*\*“抗疲劳设计”\*\*。如果不分级,PI 一天收 50 条推送,第二天就会把机器人拉黑。 + +**落地实现**: + +在 WechatService 中实现一个 **Notification Filter**。 + +// NotificationFilter.ts + +async function handleAlert(projectId, logs) { + // 1\. 红色:立即发送 + const redIssues \= logs.filter(i \=\> i.level \=== 'RED'); + if (redIssues.length \> 0\) { + await sendWechat("🚨 紧急报警: 发现 SAE 或严重违规..."); + await sendSms("..."); // 可选 + } + + // 2\. 黄色:存入每日摘要队列 (Redis/DB),不立即发 + const yellowIssues \= logs.filter(i \=\> i.level \=== 'YELLOW'); + if (yellowIssues.length \> 0\) { + await addToDailyDigest(projectId, yellowIssues); + } + + // 3\. 绿色:只记录日志,完全不发 +} + +## **4\. 潜在问题与解决方案:技术细节补全** + +你提出的解决方案都很到位,我从技术落地角度做一点补充。 + +### **(1) 规则版本管理** + +* **你的方案**:增加 rule\_version。✅ +* **补充**:iit\_skills 表本身应该有一个 version 字段(自增 Int)。每次生成 IitQcLog 时,把这个 version 抄进去。这样以后回溯时,就知道当时是按哪套法律判的案。 + +### **(2) 性能问题** + +* **你的方案**:Webhook 只查单条,全量用异步。✅ +* **补充**:这就是我们 V2.9 架构的天然优势。 + * **实时**:WebhookController \-\> SopEngine (单条)。 + * **全量**:pg-boss 定时任务 \-\> BatchQcJob (批量)。 + +### **(3) 重复质控 (防抖)** + +* **你的方案**:幂等性检查(5分钟)。✅ +* **技术落地**:利用 pg-boss 的 **Debounce** 功能。 + // WebhookController.ts + // 如果 5 分钟内来了同一个 record\_id 的 Webhook,只执行最后一次 + await boss.send('qc-job', { recordId }, { + singletonKey: \`qc-${projectId}-${recordId}\`, // 唯一键 + singletonSeconds: 300 // 300秒防抖窗口 + }); + + 这样根本不需要写复杂的 Redis 锁,pg-boss 帮你搞定。 + +### **(4) 字段映射变更** + +* **你的方案**:重新同步。✅ +* **补充**:增加一个 **"变更检测"**。 + * 每次 Webhook 数据来了,检查一下 payload 里的字段名是否在我们的 iit\_field\_mapping 里。如果不认识,说明 REDCap 改了,Agent 自动触发一次 Metadata Sync 任务。 + +## **5\. 异步架构落地场景 (Asynchronous Implementation)** + +为了保证高并发下的系统稳定性,本方案在以下 4 个关键环节强制使用异步处理(基于 pg-boss): + +### **5.1 核心质控执行 (Core Execution)** + +* **场景**:CRC 在 REDCap 点击保存,触发 DET Webhook。 +* **机制**: + * **同步层 (Node.js)**:接收 HTTP 请求 ![][image1] 校验签名 ![][image1] 写入 pg-boss 队列 ![][image1] 立即返回 200 OK (耗时 \< 10ms)。 + * **异步层 (Worker)**:后台 Worker 从队列获取 record\_id ![][image1] 拉取数据 ![][image1] 执行 Engine A/B ![][image1] 写入日志。 +* **价值**:确保 EDC 前端操作零卡顿,彻底解耦录入与质控。 + +### **5.2 批量全量回溯 (Batch Processing)** + +* **场景**:规则变更后(如入排标准修改),需要重新检查历史数据;或每日定时巡检。 +* **机制**: + * **调度器**:创建 BatchQcJob。 + * **分片执行**:将 1000 条记录拆分为 20 个子任务(每批 50 条),并行推入队列。 + * **流控**:Worker 控制并发数,避免瞬间打爆 REDCap API。 + +### **5.3 "黄色"报警汇总 (Delayed Notifications)** + +* **场景**:发现非紧急的逻辑矛盾或缺失值(黄色/绿色级别)。 +* **机制**: + * **写入**:质控 Worker 发现问题 ![][image1] 写入 iit\_qc\_logs ![][image1] 标记为 pending\_digest。 + * **发送**:每日下午 17:00 定时任务触发 ![][image1] 聚合当日所有黄色问题 ![][image1] 生成一份简报发送给 PI。 +* **价值**:防消息轰炸,提升用户体验。 + +### **5.4 规则自动同步 (Metadata Sync)** + +* **场景**:项目初始化或 REDCap 字段变更。 +* **机制**: + * **触发**:用户点击"同步字段"按钮。 + * **执行**:前端收到"任务已提交" ![][image1] 后台 Worker 调用 exportMetadata (耗时较长) ![][image1] 解析并更新 iit\_field\_mapping ![][image1] 更新任务状态。 +* **价值**:防止长连接超时。 + +## **6\. 总结** + +这 4 个建议是**完全成熟的生产级方案**。 + +* 它们解决了 **"规则从哪来"** (1)。 +* 解决了 **"数据怎么存"** (2)。 +* 解决了 **"怎么发通知"** (3)。 +* 解决了 **"异常怎么办"** (4)。 + +配合之前的 **V2.9 极简架构**(Engine \+ Tools \+ Skill),以及本补充文档中的 **异步处理规范**,这套系统已经具备了极高的商业交付价值。建议直接纳入 PRD 开发。 + +[image1]: \ No newline at end of file diff --git a/docs/03-业务模块/IIT Manager Agent/05-测试文档/IIT Manager Agent 策略汇报与讨论材料.md b/docs/03-业务模块/IIT Manager Agent/05-测试文档/IIT Manager Agent 策略汇报与讨论材料.md new file mode 100644 index 00000000..7333acd8 --- /dev/null +++ b/docs/03-业务模块/IIT Manager Agent/05-测试文档/IIT Manager Agent 策略汇报与讨论材料.md @@ -0,0 +1,93 @@ +# **IIT Manager Agent:智能化临床研究管理解决方案** + +## **—— 策略汇报与核心机制讨论稿** + +**汇报对象:** 临床研究负责人(PI)、申办方策略官、临床方法学专家 + +**汇报目标:** 确认 Agent 的行为准则、记忆逻辑与风险控制策略 + +**日期:** 2026-02-05 + +## **1\. 核心愿景:我们要打造什么样的 AI 助手?** + +我们开发的不仅仅是一个问答机器人,而是一个**24/7 在线的、具备“长期记忆”与“合规意识”的虚拟项目经理**。 + +它旨在解决 IIT(研究者发起的临床研究)中的三大痛点: + +1. **数据质控滞后**:往往等到数据锁库前才发现录入错误,修正成本极高。 +2. **项目记忆断层**:CRC/CRA 人员流动导致对患者情况、历史决策的记忆丢失。 +3. **执行偏差**:方案(Protocol)执行细节依赖个人经验,难以标准化。 + +## **2\. 策略架构:严谨与智能的“双脑”平衡** + +为了适应临床研究既要“死扣方案”又要“灵活应变”的特点,我们设计了\*\*“双脑协同”\*\*模型: + +### **🧠 左脑(严谨执行者)—— 对应“SOP 质控引擎”** + +* **角色**:像一位铁面无私的质控员(QC)。 +* **职责**:执行入排标准、访视窗口、不良事件(AE)逻辑检查。 +* **特点**:**零容忍**。它不依赖 AI 的“猜测”,而是基于既定的医学逻辑规则。如果方案规定年龄必须 \<75岁,76岁的患者绝对无法通过。 +* **价值**:确保合规性,规避审计风险。 + +### **🎨 右脑(智能助理)—— 对应“ReAct 推理引擎”** + +* **角色**:像一位经验丰富的 CRC 组长。 +* **职责**:回答模糊问题(“查一下最近发烧的病人”)、生成周报、解读复杂方案。 +* **特点**:**灵活**。它能理解自然语言,综合多维度信息给出建议,并具备主动性(如主动提醒访视)。 +* **价值**:提高效率,降低沟通成本。 + +## **3\. 核心议题:记忆系统(Memory System)的策略设置** + +**这是需要方法学团队重点讨论的部分。** + +临床试验周期长达 1-3 年,普通的 AI 聊几句就“忘事”。我们设计了仿生的\*\*“三层记忆体系”\*\*,让 Agent 能够陪伴项目全周期。 + +### **3.1 记忆分层与业务含义** + +| 记忆层级 | 对应业务场景 | 策略价值 | +| :---- | :---- | :---- | +| **L1:短期流水账** *(Working Memory)* | **“刚才说了什么”** 如:刚才提到的患者 ID 是多少? | 保证对话连贯性,像人一样交流,无需重复上下文。 | +| **L2:项目热记忆** *(Active Context)* | **“当前关注焦点”** 如:P003 患者依从性差需重点盯防;PI 偏好简报。 | **个性化与主动性**。Agent 知道每个人的角色偏好,也知道当前项目的“风险点”,不再是冷冰冰的机器。 | +| **L3:项目历史书** *(Project Archive)* | **“项目大事记”** 如:3个月前为什么修改了入排标准?上周的 SAE 判定结论是什么? | **对抗人员流动**。即使 CRC 换人,Agent 依然记得项目的所有历史决策和关键事件,实现“无缝交接”。 | + +### **3.2 待讨论的策略问题(请方法学团队决策)** + +**Q1:什么样的信息值得进入“历史书”(L3)?** + +* *现状*:我们设定周报自动归档。 +* *讨论*:是否需要将每一次“违规录入”都永久记录?还是只记录“经人工确认的违规”?这涉及到未来审计的痕迹管理。 + +**Q2:Agent 的“主动性”边界在哪里(基于 L2 热记忆)?** + +* *场景*:Agent 发现某患者有脱落风险(基于过往对话)。 +* *讨论*:是仅仅在周报中提示?还是每天早上发消息提醒 CRC?还是直接向 PI 发出预警?我们需要定义“打扰预算”。 + +## **4\. 安全与合规:隐私保护策略** + +针对临床数据的敏感性,我们在技术底层执行了严格的\*\*“隐私隔离策略”\*\*(Phase 1.5 重点任务): + +1. **PII 信息物理隔离**: + * Agent 的大脑(大模型)**永远看不到**患者的真实姓名、身份证号或手机号。 + * 所有敏感信息在发送给 AI 前,都会被替换为代号(如 \[PATIENT\_001\]),AI 处理完逻辑后,再由本地系统还原显示给医生。 +2. **数据所有权**: + * 所有核心临床数据(EDC数据)只存储在私有数据库中,不会用于训练公有模型。 + +## **5\. 开发路线图与里程碑** + +我们计划用 **6周** 时间完成 MVP(最小可行性产品)交付: + +* **第 1-2 周(地基阶段)**:完成隐私脱敏中间件、基础质控规则(左脑)。 + * *交付物*:能自动检查入排标准和逻辑错误的机器人。 +* **第 3-4 周(记忆觉醒)**:上线三层记忆系统,实现周报自动生成。 + * *交付物*:一个能记住项目历史、每周向 PI 汇报进度的助手。 +* **第 5-6 周(智能进化)**:上线右脑推理与主动提醒功能。 + * *交付物*:能回答复杂问题、主动管理任务的完整 Agent。 + +## **6\. 总结与下一步** + +**IIT Manager Agent** 不是在替代 CRC,而是在为临床研究团队配备一个\*\*“永远不睡觉、永远不遗忘、永远守规矩”\*\*的超级助理。 + +**下一步行动:** + +1. **方法学团队**:请针对“记忆策略”中的 Q1、Q2 给出指导意见。 +2. **技术团队**:立即启动 Phase 1 开发,优先部署隐私安全模块。 \ No newline at end of file diff --git a/docs/03-业务模块/IIT Manager Agent/05-测试文档/Phase 1 补充任务清单.md b/docs/03-业务模块/IIT Manager Agent/05-测试文档/Phase 1 补充任务清单.md new file mode 100644 index 00000000..5d195924 --- /dev/null +++ b/docs/03-业务模块/IIT Manager Agent/05-测试文档/Phase 1 补充任务清单.md @@ -0,0 +1,38 @@ +# **Phase 1.5 补充任务:隐私安全与自动化工具** + +**优先级:** P0 (必须在正式处理患者数据前完成) + +**目的:** 解决合规性风险,降低项目部署的人力成本 + +## **1\. PII 数据脱敏中间件 (Anonymizer Middleware)** + +在 SoftRuleEngine 和 ChatService 调用 LLM 之前,必须对文本进行处理。 + +* \[ \] **实现 PII 识别正则库**: + * 识别身份证号、手机号、中文姓名(2-4字)、MRN 号。 +* \[ \] **实现脱敏/还原逻辑**: + * **发送前 (Masking)**: 张三 (ID: 420101...) \-\> \[PATIENT\_NAME\_1\] (ID: \[ID\_CARD\_1\]) + * **接收后 (Unmasking)**: 将 LLM 回复中的 \[PATIENT\_NAME\_1\] 还原为 张三 显示给前端。 +* \[ \] **安全审计日志**: + * 记录所有发送给 LLM 的原始 Payload(加密存储),用于事后合规审计。 + +## **2\. Redcap Schema 自动对齐工具 (Auto-Mapper)** + +减少 iit\_field\_mapping 的人工配置工作量。 + +* \[ \] **Data Dictionary 解析器**: + * 读取 Redcap 导出的 Data Dictionary (CSV/JSON)。 + * 提取所有字段的 Variable Name 和 Field Label。 +* \[ \] **LLM 语义映射 Job**: + * 输入:系统标准字段列表(如 age, gender, visit\_date)。 + * 输入:Redcap 字段列表(如 nl\_age, sex\_v2, d\_visit)。 + * Prompt: "请将以下 Redcap 字段与系统标准字段进行语义匹配,返回 JSON 映射表。" +* \[ \] **人工确认 UI**: + * 在管理后台提供一个界面,显示 LLM 猜测的映射关系,管理员点击 "Confirm" 后写入数据库。 + +## **3\. 错误处理与熔断机制** + +* \[ \] **ReAct 循环熔断**: + * 设置 SoftRuleEngine 最大重试次数为 3。 + * 设置 ReActEngine 最大 Step 为 5。 + * 超过限制时,返回固定的 Fallback 回复:"抱歉,该任务过于复杂或数据不足,请人工介入。" \ No newline at end of file diff --git a/docs/03-业务模块/IIT Manager Agent/05-测试文档/测试案例/原发性痛经队列研究方案.pdf b/docs/03-业务模块/IIT Manager Agent/05-测试文档/测试案例/原发性痛经队列研究方案.pdf new file mode 100644 index 00000000..f50feedb Binary files /dev/null and b/docs/03-业务模块/IIT Manager Agent/05-测试文档/测试案例/原发性痛经队列研究方案.pdf differ diff --git a/docs/03-业务模块/IIT Manager Agent/05-测试文档/测试案例/原发性痛经队列研究病例报告表.pdf b/docs/03-业务模块/IIT Manager Agent/05-测试文档/测试案例/原发性痛经队列研究病例报告表.pdf new file mode 100644 index 00000000..a2412c2c Binary files /dev/null and b/docs/03-业务模块/IIT Manager Agent/05-测试文档/测试案例/原发性痛经队列研究病例报告表.pdf differ diff --git a/docs/03-业务模块/IIT Manager Agent/05-测试文档/测试案例/变量设置范围.docx b/docs/03-业务模块/IIT Manager Agent/05-测试文档/测试案例/变量设置范围.docx new file mode 100644 index 00000000..10fd53c0 Binary files /dev/null and b/docs/03-业务模块/IIT Manager Agent/05-测试文档/测试案例/变量设置范围.docx differ diff --git a/docs/03-业务模块/IIT Manager Agent/05-测试文档/测试案例/模拟数据_10例.xlsx b/docs/03-业务模块/IIT Manager Agent/05-测试文档/测试案例/模拟数据_10例.xlsx new file mode 100644 index 00000000..c1c69d8a Binary files /dev/null and b/docs/03-业务模块/IIT Manager Agent/05-测试文档/测试案例/模拟数据_10例.xlsx differ diff --git a/docs/03-业务模块/IIT Manager Agent/05-测试文档/测试案例/知情同意书.docx b/docs/03-业务模块/IIT Manager Agent/05-测试文档/测试案例/知情同意书.docx new file mode 100644 index 00000000..f7b8ebbe Binary files /dev/null and b/docs/03-业务模块/IIT Manager Agent/05-测试文档/测试案例/知情同意书.docx differ diff --git a/docs/03-业务模块/IIT Manager Agent/05-测试文档/测试案例/纳入排除标准.docx b/docs/03-业务模块/IIT Manager Agent/05-测试文档/测试案例/纳入排除标准.docx new file mode 100644 index 00000000..5b4579f8 Binary files /dev/null and b/docs/03-业务模块/IIT Manager Agent/05-测试文档/测试案例/纳入排除标准.docx differ diff --git a/docs/03-业务模块/IIT Manager Agent/06-开发记录/2026-02-07-实时质控系统开发记录.md b/docs/03-业务模块/IIT Manager Agent/06-开发记录/2026-02-07-实时质控系统开发记录.md new file mode 100644 index 00000000..91443645 --- /dev/null +++ b/docs/03-业务模块/IIT Manager Agent/06-开发记录/2026-02-07-实时质控系统开发记录.md @@ -0,0 +1,243 @@ +# 2026-02-07 实时质控系统开发记录 + +> **开发日期:** 2026-02-07 +> **开发者:** AI Assistant +> **开发时长:** 约 4 小时 +> **主要内容:** 实时质控系统核心功能实现 + +--- + +## 一、开发背景 + +根据 [06-实时质控系统开发计划.md](../04-开发计划/06-实时质控系统开发计划.md) 的设计,今天完成了实时质控系统的核心功能开发,包括: + +- 数据库表创建 +- Webhook 防抖机制 +- Worker 双产出改造 +- AI 查询优化 +- 管理端批量操作功能 + +--- + +## 二、完成的开发任务 + +### 2.1 数据库表创建(Phase 1 & 2) + +新增 4 个质控相关表到 `iit_schema`: + +| 表名 | Prisma 模型 | 用途 | +|------|-------------|------| +| `field_metadata` | `IitFieldMetadata` | REDCap 字段元数据同步 | +| `qc_logs` | `IitQcLog` | 质控日志(仅新增,审计轨迹) | +| `record_summary` | `IitRecordSummary` | 录入汇总(upsert,最新状态) | +| `qc_project_stats` | `IitQcProjectStats` | 项目级统计(Dashboard 用) | + +**核心设计原则:** +- `qc_logs`:**仅新增**,不覆盖,保留完整审计轨迹 +- `record_summary`:**upsert**,每个记录只有一条汇总 +- 支持 `formName` 字段,用于单表质控规则过滤 + +### 2.2 WebhookController 重构 + +**文件:** `backend/src/modules/iit-manager/controllers/WebhookController.ts` + +**改动:** +1. 移除旧的 `checkDuplicate()` 方法 +2. 使用 pg-boss `singletonKey` 实现防抖 +3. Payload 精简:移除 `records` 数据,改在 Worker 中获取 + +**关键代码:** +```typescript +await jobQueue.push('iit_quality_check', { + projectId: projectConfig.id, + recordId: payload.record, + instrument: payload.instrument, + eventId: payload.redcap_event_name, + triggeredBy: 'webhook', + // pg-boss 防抖参数 + __singletonKey: `qc-${projectConfig.id}-${payload.record}`, + __singletonSeconds: 300, // 5分钟 + __expireInSeconds: 15 * 60, // 15分钟过期 +}); +``` + +### 2.3 PgBossQueue 增强 + +**文件:** `backend/src/common/jobs/PgBossQueue.ts` + +**改动:** +- 支持通过 `data` 中的特殊字段传递 pg-boss options +- 新增字段:`__singletonKey`, `__singletonSeconds`, `__expireInSeconds` + +### 2.4 Worker 双产出改造 + +**文件:** `backend/src/modules/iit-manager/index.ts` + +**改动:** +- 一次 Worker 执行,两个产出: + - 产出1: `iit_qc_logs`(仅新增,审计轨迹) + - 产出2: `iit_record_summary`(upsert,最新状态) +- 分级干预:只有 FAIL 状态才发企业微信通知 + +### 2.5 HardRuleEngine 增强 + +**文件:** `backend/src/modules/iit-manager/engines/HardRuleEngine.ts` + +**改动:** +- `createHardRuleEngine()` 新增可选参数 `formName` +- 支持按表单名过滤规则(用于单表实时质控) + +### 2.6 QcService 创建 + +**文件:** `backend/src/modules/iit-manager/services/QcService.ts`(新建) + +**功能:** +- `queryQcLogs()` - 查询质控日志 +- `getRecordQcSummary()` - 获取记录质控摘要 +- `getRecordSummary()` - 获取录入汇总 +- `getProjectStats()` - 获取项目统计 +- `getProblematicRecords()` - 获取问题记录 +- `getQcTrend()` - 获取质控趋势 + +### 2.7 ChatService 优化 + +**文件:** `backend/src/modules/iit-manager/services/ChatService.ts` + +**改动:** +1. 新增意图识别: + - `query_enrollment` - 录入进度查询 + - `query_qc_status` - 质控状态查询 +2. 优先查询汇总表/质控表,而不是每次都调用 REDCap +3. `qcSingleRecord()` 优先返回缓存的质控结果(1小时内) + +### 2.8 管理端批量操作功能 + +#### 后端 API + +**新增文件:** +- `backend/src/modules/admin/iit-projects/iitBatchController.ts` +- `backend/src/modules/admin/iit-projects/iitBatchRoutes.ts` + +**API 端点:** +- `POST /api/v1/admin/iit-projects/:projectId/batch-qc` - 一键全量质控 +- `POST /api/v1/admin/iit-projects/:projectId/batch-summary` - 一键全量数据汇总 + +#### 前端 UI + +**修改文件:** +- `frontend-v2/src/modules/admin/pages/IitProjectDetailPage.tsx` +- `frontend-v2/src/modules/admin/api/iitProjectApi.ts` + +**功能:** +- 在 IIT 项目详情页顶部添加两个按钮 +- ⚡ **一键全量质控** - 对所有记录执行质控 +- 📊 **一键全量汇总** - 同步所有记录的录入状态 + +--- + +## 三、修改文件清单 + +| 文件路径 | 操作 | 说明 | +|---------|------|------| +| `backend/prisma/schema.prisma` | 修改 | 新增 4 个质控相关表 | +| `backend/src/modules/iit-manager/controllers/WebhookController.ts` | 修改 | pg-boss 防抖 | +| `backend/src/common/jobs/PgBossQueue.ts` | 修改 | 支持自定义 singletonKey | +| `backend/src/modules/iit-manager/index.ts` | 修改 | Worker 双产出 | +| `backend/src/modules/iit-manager/engines/HardRuleEngine.ts` | 修改 | 按表单过滤规则 | +| `backend/src/modules/iit-manager/services/QcService.ts` | **新建** | 质控查询服务 | +| `backend/src/modules/iit-manager/services/ChatService.ts` | 修改 | 意图识别优化 | +| `backend/src/modules/admin/iit-projects/iitBatchController.ts` | **新建** | 批量操作 Controller | +| `backend/src/modules/admin/iit-projects/iitBatchRoutes.ts` | **新建** | 批量操作路由 | +| `backend/src/modules/admin/iit-projects/index.ts` | 修改 | 导出新模块 | +| `backend/src/index.ts` | 修改 | 注册批量操作路由 | +| `frontend-v2/src/modules/admin/pages/IitProjectDetailPage.tsx` | 修改 | 添加批量操作按钮 | +| `frontend-v2/src/modules/admin/api/iitProjectApi.ts` | 修改 | 添加批量操作 API | + +--- + +## 四、数据库变更 + +### 数据库备份 + +```bash +# 备份位置(Docker 容器内) +/tmp/backup_iit_schema_20260207.dump +``` + +### 执行命令 + +```bash +npx prisma db push --accept-data-loss +npx prisma generate +``` + +--- + +## 五、待测试内容 + +1. **端到端测试**:REDCap 录入 → Webhook → Worker → 质控日志 + 录入汇总 +2. **批量操作测试**:一键全量质控、一键全量汇总 +3. **AI 查询测试**:验证优先查询汇总表的效果 +4. **防抖测试**:5分钟内重复 Webhook 不重复执行 + +--- + +## 六、架构图 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 实时质控系统架构 │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌───────────────────┐ ┌──────────────────────────┐ │ +│ │ REDCap │───▶│ WebhookController │───▶│ pg-boss Queue │ │ +│ │ (DET) │ │ (singletonKey防抖)│ │ (iit_quality_check) │ │ +│ └─────────────┘ └───────────────────┘ └────────────┬─────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────┐ │ +│ │ QC Worker │ │ +│ │ (一次执行,双产出) │ │ +│ └────────────┬─────────────┘ │ +│ │ │ +│ ┌────────────────────────────┼────────────┐ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌──────┐ │ +│ │ iit_qc_logs │ │iit_record_summary│ │ 企微 │ │ +│ │ (仅新增,审计) │ │ (upsert,汇总) │ │ 通知 │ │ +│ └─────────────────┘ └─────────────────┘ └──────┘ │ +│ ▲ ▲ │ +│ │ │ │ +│ └──────────────┬─────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌────────────────────────────────────────────────────┐ │ +│ │ ChatService / QcService │ │ +│ │ (优先查询汇总表,而非每次调 REDCap) │ │ +│ └────────────────────────────────────────────────────┘ │ +│ ▲ │ +│ │ │ +│ ┌──────────────────────────────┴──────────────────────────┐ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌─────────────────┐ ┌─────────────┐ │ +│ │ 企业微信端 │ │ 管理端 │ │ +│ │ (AI 问答) │ │ (批量操作) │ │ +│ └─────────────────┘ └─────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 七、下一步计划 + +1. 重启前后端服务进行端到端测试 +2. 验证质控闭环功能 +3. 验证 AI 回答问题的准确性提升 +4. 考虑实现日批量质控(Cron Job) + +--- + +**记录人:** AI Assistant +**记录时间:** 2026-02-07 diff --git a/frontend-v2/src/App.tsx b/frontend-v2/src/App.tsx index 0150cb2a..61056225 100644 --- a/frontend-v2/src/App.tsx +++ b/frontend-v2/src/App.tsx @@ -24,6 +24,9 @@ import UserDetailPage from './modules/admin/pages/UserDetailPage' // 系统知识库管理 import SystemKbListPage from './modules/admin/pages/SystemKbListPage' import SystemKbDetailPage from './modules/admin/pages/SystemKbDetailPage' +// IIT 项目管理 +import IitProjectListPage from './modules/admin/pages/IitProjectListPage' +import IitProjectDetailPage from './modules/admin/pages/IitProjectDetailPage' // 运营日志 import ActivityLogsPage from './pages/admin/ActivityLogsPage' // 个人中心页面 @@ -117,6 +120,9 @@ function App() { {/* 系统知识库 */} } /> } /> + {/* IIT 项目管理 */} + } /> + } /> {/* 运营日志 */} } /> {/* 系统配置 */} diff --git a/frontend-v2/src/framework/layout/AdminLayout.tsx b/frontend-v2/src/framework/layout/AdminLayout.tsx index 9b619cd2..62c0bea5 100644 --- a/frontend-v2/src/framework/layout/AdminLayout.tsx +++ b/frontend-v2/src/framework/layout/AdminLayout.tsx @@ -14,6 +14,7 @@ import { BellOutlined, BookOutlined, FileTextOutlined, + ExperimentOutlined, } from '@ant-design/icons' import type { MenuProps } from 'antd' import { useAuth } from '../auth' @@ -90,6 +91,11 @@ const AdminLayout = () => { icon: , label: '系统知识库', }, + { + key: '/admin/iit-projects', + icon: , + label: 'IIT 项目管理', + }, { key: '/admin/tenants', icon: , diff --git a/frontend-v2/src/modules/admin/api/iitProjectApi.ts b/frontend-v2/src/modules/admin/api/iitProjectApi.ts new file mode 100644 index 00000000..42d761e6 --- /dev/null +++ b/frontend-v2/src/modules/admin/api/iitProjectApi.ts @@ -0,0 +1,262 @@ +/** + * IIT 项目管理 API + */ + +import apiClient from '@/common/api/axios'; +import type { + IitProject, + CreateProjectRequest, + UpdateProjectRequest, + TestConnectionRequest, + TestConnectionResult, + QCRule, + CreateRuleRequest, + RuleStats, + IitUserMapping, + CreateUserMappingRequest, + UpdateUserMappingRequest, + RoleOption, + KnowledgeBaseOption, +} from '../types/iitProject'; + +const BASE_URL = '/api/v1/admin/iit-projects'; + +// ==================== 项目 CRUD ==================== + +/** 获取项目列表 */ +export async function listProjects(params?: { + status?: string; + search?: string; +}): Promise { + const response = await apiClient.get(BASE_URL, { params }); + return response.data.data; +} + +/** 获取项目详情 */ +export async function getProject(id: string): Promise { + const response = await apiClient.get(`${BASE_URL}/${id}`); + return response.data.data; +} + +/** 创建项目 */ +export async function createProject(data: CreateProjectRequest): Promise { + const response = await apiClient.post(BASE_URL, data); + return response.data.data; +} + +/** 更新项目 */ +export async function updateProject( + id: string, + data: UpdateProjectRequest +): Promise { + const response = await apiClient.put(`${BASE_URL}/${id}`, data); + return response.data.data; +} + +/** 删除项目 */ +export async function deleteProject(id: string): Promise { + await apiClient.delete(`${BASE_URL}/${id}`); +} + +// ==================== REDCap 连接 ==================== + +/** 测试 REDCap 连接(新配置) */ +export async function testConnection( + data: TestConnectionRequest +): Promise { + const response = await apiClient.post(`${BASE_URL}/test-connection`, data); + return response.data.data; +} + +/** 测试项目的 REDCap 连接 */ +export async function testProjectConnection( + projectId: string +): Promise { + const response = await apiClient.post(`${BASE_URL}/${projectId}/test-connection`); + return response.data.data; +} + +/** 同步 REDCap 元数据 */ +export async function syncMetadata(projectId: string): Promise<{ + success: boolean; + fieldCount: number; +}> { + const response = await apiClient.post(`${BASE_URL}/${projectId}/sync-metadata`); + return response.data.data; +} + +// ==================== 知识库关联 ==================== + +/** 关联知识库 */ +export async function linkKnowledgeBase( + projectId: string, + knowledgeBaseId: string +): Promise { + await apiClient.post(`${BASE_URL}/${projectId}/knowledge-base`, { knowledgeBaseId }); +} + +/** 解除知识库关联 */ +export async function unlinkKnowledgeBase(projectId: string): Promise { + await apiClient.delete(`${BASE_URL}/${projectId}/knowledge-base`); +} + +/** 获取可用知识库列表 */ +export async function listKnowledgeBases(): Promise { + const response = await apiClient.get('/api/v1/admin/system-kb'); + return response.data.data; +} + +// ==================== 质控规则 ==================== + +/** 获取项目的质控规则列表 */ +export async function listRules(projectId: string): Promise { + const response = await apiClient.get(`${BASE_URL}/${projectId}/rules`); + return response.data.data; +} + +/** 获取规则统计 */ +export async function getRuleStats(projectId: string): Promise { + const response = await apiClient.get(`${BASE_URL}/${projectId}/rules/stats`); + return response.data.data; +} + +/** 获取单条规则 */ +export async function getRule(projectId: string, ruleId: string): Promise { + const response = await apiClient.get(`${BASE_URL}/${projectId}/rules/${ruleId}`); + return response.data.data; +} + +/** 添加规则 */ +export async function addRule( + projectId: string, + data: CreateRuleRequest +): Promise { + const response = await apiClient.post(`${BASE_URL}/${projectId}/rules`, data); + return response.data.data; +} + +/** 更新规则 */ +export async function updateRule( + projectId: string, + ruleId: string, + data: Partial +): Promise { + const response = await apiClient.put(`${BASE_URL}/${projectId}/rules/${ruleId}`, data); + return response.data.data; +} + +/** 删除规则 */ +export async function deleteRule(projectId: string, ruleId: string): Promise { + await apiClient.delete(`${BASE_URL}/${projectId}/rules/${ruleId}`); +} + +/** 批量导入规则 */ +export async function importRules( + projectId: string, + rules: CreateRuleRequest[] +): Promise<{ count: number; rules: QCRule[] }> { + const response = await apiClient.post(`${BASE_URL}/${projectId}/rules/import`, { + rules, + }); + return response.data.data; +} + +/** 测试规则逻辑 */ +export async function testRule( + logic: Record, + testData: Record +): Promise<{ passed: boolean; result: unknown }> { + const response = await apiClient.post(`${BASE_URL}/rules/test`, { logic, testData }); + return response.data.data; +} + +// ==================== 用户映射 ==================== + +/** 获取角色选项 */ +export async function getRoleOptions(): Promise { + const response = await apiClient.get(`${BASE_URL}/roles`); + return response.data.data; +} + +/** 获取项目的用户映射列表 */ +export async function listUserMappings( + projectId: string, + params?: { role?: string; search?: string } +): Promise { + const response = await apiClient.get(`${BASE_URL}/${projectId}/users`, { params }); + return response.data.data; +} + +/** 获取用户映射统计 */ +export async function getUserMappingStats(projectId: string): Promise<{ + total: number; + byRole: Record; +}> { + const response = await apiClient.get(`${BASE_URL}/${projectId}/users/stats`); + return response.data.data; +} + +/** 创建用户映射 */ +export async function createUserMapping( + projectId: string, + data: CreateUserMappingRequest +): Promise { + const response = await apiClient.post(`${BASE_URL}/${projectId}/users`, data); + return response.data.data; +} + +/** 更新用户映射 */ +export async function updateUserMapping( + projectId: string, + mappingId: string, + data: UpdateUserMappingRequest +): Promise { + const response = await apiClient.put( + `${BASE_URL}/${projectId}/users/${mappingId}`, + data + ); + return response.data.data; +} + +/** 删除用户映射 */ +export async function deleteUserMapping( + projectId: string, + mappingId: string +): Promise { + await apiClient.delete(`${BASE_URL}/${projectId}/users/${mappingId}`); +} + +// ==================== 批量操作 ==================== + +/** 一键全量质控 */ +export async function batchQualityCheck(projectId: string): Promise<{ + success: boolean; + message: string; + stats: { + totalRecords: number; + passed: number; + failed: number; + warnings: number; + passRate: string; + }; + durationMs: number; +}> { + const response = await apiClient.post(`${BASE_URL}/${projectId}/batch-qc`); + return response.data; +} + +/** 一键全量数据汇总 */ +export async function batchSummary(projectId: string): Promise<{ + success: boolean; + message: string; + stats: { + totalRecords: number; + summariesUpdated: number; + totalForms: number; + avgCompletionRate: string; + }; + durationMs: number; +}> { + const response = await apiClient.post(`${BASE_URL}/${projectId}/batch-summary`); + return response.data; +} diff --git a/frontend-v2/src/modules/admin/index.tsx b/frontend-v2/src/modules/admin/index.tsx index f3abfc39..a21df7d8 100644 --- a/frontend-v2/src/modules/admin/index.tsx +++ b/frontend-v2/src/modules/admin/index.tsx @@ -7,6 +7,7 @@ * - 租户管理(已有) * - Prompt管理(已有) * - 系统知识库管理 + * - IIT 项目管理 */ import React from 'react'; @@ -17,6 +18,8 @@ import UserDetailPage from './pages/UserDetailPage'; import StatsDashboardPage from './pages/StatsDashboardPage'; import SystemKbListPage from './pages/SystemKbListPage'; import SystemKbDetailPage from './pages/SystemKbDetailPage'; +import IitProjectListPage from './pages/IitProjectListPage'; +import IitProjectDetailPage from './pages/IitProjectDetailPage'; const AdminModule: React.FC = () => { return ( @@ -35,6 +38,10 @@ const AdminModule: React.FC = () => { {/* 系统知识库管理 */} } /> } /> + + {/* IIT 项目管理 */} + } /> + } /> ); }; diff --git a/frontend-v2/src/modules/admin/pages/IitProjectDetailPage.tsx b/frontend-v2/src/modules/admin/pages/IitProjectDetailPage.tsx new file mode 100644 index 00000000..bfc57a87 --- /dev/null +++ b/frontend-v2/src/modules/admin/pages/IitProjectDetailPage.tsx @@ -0,0 +1,912 @@ +/** + * IIT 项目配置详情页 + * + * 包含4个Tab:基本配置、质控规则、用户映射、知识库 + */ + +import React, { useState, useEffect, useCallback } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { + Card, + Tabs, + Button, + Form, + Input, + Select, + Table, + Modal, + message, + Popconfirm, + Typography, + Space, + Tag, + Spin, + Alert, + Descriptions, + Empty, + Tooltip, + Badge, +} from 'antd'; +import { + ArrowLeftOutlined, + SaveOutlined, + PlusOutlined, + DeleteOutlined, + EditOutlined, + CheckCircleOutlined, + CloseCircleOutlined, + SyncOutlined, + LinkOutlined, + DisconnectOutlined, + ExclamationCircleOutlined, + BookOutlined, + ThunderboltOutlined, + BarChartOutlined, +} from '@ant-design/icons'; +import * as iitProjectApi from '../api/iitProjectApi'; +import type { + IitProject, + UpdateProjectRequest, + QCRule, + CreateRuleRequest, + IitUserMapping, + CreateUserMappingRequest, + RoleOption, + KnowledgeBaseOption, +} from '../types/iitProject'; + +const { Title, Text, Paragraph } = Typography; +const { TextArea } = Input; + +// ==================== 常量定义 ==================== + +const SEVERITY_MAP = { + error: { color: 'error', text: '错误' }, + warning: { color: 'warning', text: '警告' }, + info: { color: 'processing', text: '信息' }, +}; + +const CATEGORY_MAP = { + inclusion: { color: '#52c41a', text: '纳入标准' }, + exclusion: { color: '#ff4d4f', text: '排除标准' }, + lab_values: { color: '#1890ff', text: '变量范围' }, + logic_check: { color: '#722ed1', text: '逻辑检查' }, +}; + +// ==================== 主组件 ==================== + +const IitProjectDetailPage: React.FC = () => { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + + const [project, setProject] = useState(null); + const [loading, setLoading] = useState(true); + const [activeTab, setActiveTab] = useState('basic'); + const [batchQcLoading, setBatchQcLoading] = useState(false); + const [batchSummaryLoading, setBatchSummaryLoading] = useState(false); + + // 加载项目详情 + const loadProject = useCallback(async () => { + if (!id) return; + setLoading(true); + try { + const data = await iitProjectApi.getProject(id); + setProject(data); + } catch (error) { + message.error('加载项目详情失败'); + navigate('/admin/iit-projects'); + } finally { + setLoading(false); + } + }, [id, navigate]); + + useEffect(() => { + loadProject(); + }, [loadProject]); + + // ⭐ 一键全量质控 + const handleBatchQc = async () => { + if (!id) return; + setBatchQcLoading(true); + try { + const result = await iitProjectApi.batchQualityCheck(id); + message.success(`质控完成!共 ${result.stats.totalRecords} 条记录,通过率 ${result.stats.passRate}`); + } catch (error: any) { + message.error(error.message || '质控失败'); + } finally { + setBatchQcLoading(false); + } + }; + + // ⭐ 一键全量数据汇总 + const handleBatchSummary = async () => { + if (!id) return; + setBatchSummaryLoading(true); + try { + const result = await iitProjectApi.batchSummary(id); + message.success(`汇总完成!共 ${result.stats.totalRecords} 条记录,平均完成率 ${result.stats.avgCompletionRate}`); + } catch (error: any) { + message.error(error.message || '汇总失败'); + } finally { + setBatchSummaryLoading(false); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (!project) { + return ; + } + + const tabItems = [ + { + key: 'basic', + label: 'REDCap 配置', + children: , + }, + { + key: 'rules', + label: '质控规则', + children: , + }, + { + key: 'users', + label: '通知设置', + children: , + }, + { + key: 'kb', + label: '知识库', + children: , + }, + ]; + + return ( +
+ {/* 页面标题 */} +
+
+ + + + {project.name} + + + + {/* ⭐ 批量操作按钮 */} + + + + +
+ {project.description && ( + + {project.description} + + )} +
+ + {/* Tab 页 */} + + + +
+ ); +}; + +// ==================== Tab 1: 基本配置 ==================== + +interface BasicConfigTabProps { + project: IitProject; + onUpdate: () => void; +} + +const BasicConfigTab: React.FC = ({ project, onUpdate }) => { + const [form] = Form.useForm(); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + const [syncing, setSyncing] = useState(false); + + useEffect(() => { + form.setFieldsValue({ + name: project.name, + description: project.description, + redcapUrl: project.redcapUrl, + redcapProjectId: project.redcapProjectId, + redcapApiToken: project.redcapApiToken, + }); + }, [form, project]); + + const handleSave = async (values: UpdateProjectRequest) => { + setSaving(true); + try { + await iitProjectApi.updateProject(project.id, values); + message.success('保存成功'); + onUpdate(); + } catch (error) { + message.error('保存失败'); + } finally { + setSaving(false); + } + }; + + const handleTestConnection = async () => { + setTesting(true); + try { + const result = await iitProjectApi.testProjectConnection(project.id); + if (result.success) { + message.success(`连接成功!REDCap 版本: ${result.version},记录数: ${result.recordCount}`); + } else { + message.error(`连接失败: ${result.error}`); + } + } catch (error) { + message.error('测试连接失败'); + } finally { + setTesting(false); + } + }; + + const handleSyncMetadata = async () => { + setSyncing(true); + try { + const result = await iitProjectApi.syncMetadata(project.id); + message.success(`同步成功!共 ${result.fieldCount} 个字段`); + } catch (error) { + message.error('同步失败'); + } finally { + setSyncing(false); + } + }; + + return ( +
+
+ + + + + +