医院内网私有化部署方案.md 51 KB

🏥 医疗AI智能填表系统 - 内网私有化部署方案

📋 目录


1. 项目背景

1.1 业务场景

医院HIS系统需要在医生问诊过程中自动填写表单,主要流程:

  1. 医生与患者对话(语音录制)
  2. 语音转文字(ASR)
  3. 大模型提取结构化信息
  4. 自动填回HIS系统表单

1.2 环境限制

  • ⚠️ 完全内网环境: 医生PC无法连接外网
  • ⚠️ HIS系统零改造: 只能添加一个按钮
  • ⚠️ 无浏览器插件: 无法安装Chrome扩展
  • ⚠️ 数据安全要求: 所有数据不出院内

2. 核心需求

2.1 功能需求

需求项 说明 优先级
语音录制 WebRTC录音,支持实时/分段录制 P0
语音转文字 内网ASR服务,中文医疗场景优化 P0
信息提取 大模型从对话提取结构化数据 P0
自动填表 基于OCR的无感知自动填写 P0
提示词管理 后台配置不同科室的提取模板 P1
字段映射 智能匹配HIS表单字段 P0

2.2 非功能需求

指标 要求
响应时间 录音到填写完成 < 10秒
并发支持 支持100个医生同时使用
准确率 字段提取准确率 > 95%
可用性 99.9%可用性

3. 技术架构

3.1 整体架构图

┌─────────────────────────────────────────────────────────────────┐
│                        医院内网环境                              │
│                                                                  │
│  ┌──────────────┐      ┌──────────────┐      ┌──────────────┐ │
│  │   HIS系统    │      │ 录音H5页面   │      │  OCR填表服务 │ │
│  │  (已有系统)  │◄────►│  (Vue3)      │◄────►│  (Playwright)│ │
│  │              │      │              │      │              │ │
│  │ 添加按钮:    │      │ - WebRTC录音 │      │ - 视觉识别   │ │
│  │ <button>     │      │ - 实时预览   │      │ - 自动填表   │ │
│  │              │      │ - 提取进度   │      │ - 字段匹配   │ │
│  └──────────────┘      └──────┬───────┘      └──────────────┘ │
│         │                      │                              │
│         │                      ▼                              │
│         │              ┌──────────────┐                       │
│         │              │ Spring Boot  │                       │
│         │              │ 后端服务      │                       │
│         │              │              │                       │
│         │              │ - Langchain4J│                       │
│         │              │ - 提示词管理 │                       │
│         │              │ - 数据库     │                       │
│         │              └──────┬───────┘                       │
│         │                      │                              │
│         │        ┌─────────────┼─────────────┐                │
│         │        ▼             ▼             ▼                │
│         │   ┌──────────┐ ┌──────────┐ ┌──────────┐            │
│         │   │   ASR    │ │   LLM    │ │  Admin   │            │
│         │   │  服务    │ │  服务    │ │  后台    │            │
│         │   │Paraformer│ │  Qwen    │ │(Vue3+    │            │
│         │   │          │ │  14B     │ │Element)  │            │
│         │   └──────────┘ └──────────┘ └──────────┘            │
│         │                                                      │
│  ┌──────────────────────────────────────────────────────────┐ │
│  │              Docker 部署 (单机/集群)                      │ │
│  │   所有服务容器化,支持横向扩展                             │ │
│  └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘

3.2 技术栈选型

后端技术栈

核心框架:
  - Spring Boot: 3.2.0
  - Langchain4j: 0.36.2 (大模型集成框架)
  - Java: 17

大模型集成:
  - LLM: Qwen-14B-Chat (通义千问开源版)
  - ASR: Paraformer (阿里开源语音识别)
  - Embedding: text2vec-base-chinese (语义匹配)

数据存储:
  - MySQL: 8.0 (提示词模板/字段映射/使用记录)
  - Redis: 7.0 (缓存/会话管理)
  - MinIO: (音频文件存储)

浏览器自动化:
  - Playwright: 1.40.0 (OCR填表)
  - Tesseract: 5.0.0 (OCR引擎)
  - EasyOCR: 备选方案

容器化:
  - Docker: 24.0+
  - Docker Compose: 2.20+

前端技术栈

录音页面 (H5):
  - Vue 3.3+ (Composition API)
  - Vant 4.0+ (移动端UI)
  - WebRTC API (录音)
  - WebSocket (实时通信)

管理后台:
  - Vue 3.3+
  - Element Plus (PC端UI)
  - ECharts (数据可视化)

OCR填表服务:
  - Playwright (Node.js)
  - sharp (图像处理)

4. 核心组件详解

4.1 Spring Boot + Langchain4J 集成

项目结构

medical-ai-backend/
├── pom.xml                                 # Maven配置
├── src/main/java/com/medical/ai/
│   ├── MedicalAiApplication.java           # 启动类
│   ├── config/
│   │   ├── LangchainConfig.java            # Langchain4J配置
│   │   ├── QwenConfig.java                 # 通义千问配置
│   │   └── AsrConfig.java                  # ASR服务配置
│   ├── controller/
│   │   ├── RecordController.java           # 录音接口
│   │   ├── ExtractController.java          # 信息提取接口
│   │   ├── TemplateController.java         # 提示词管理
│   │   └── OcrController.java              # OCR填表接口
│   ├── service/
│   │   ├── AsrService.java                 # ASR服务
│   │   ├── ExtractService.java             # 大模型提取服务
│   │   ├── TemplateService.java            # 提示词管理服务
│   │   ├── OcrService.java                 # OCR识别服务
│   │   └── AutoFillService.java            # 自动填表服务
│   ├── entity/
│   │   ├── Template.java                   # 提示词模板实体
│   │   ├── FieldMapping.java               # 字段映射实体
│   │   └── ExtractionRecord.java           # 提取记录实体
│   ├── repository/
│   │   ├── TemplateRepository.java
│   │   └── FieldMappingRepository.java
│   └── dto/
│       ├── ExtractRequest.java
│       └── ExtractResponse.java
└── src/main/resources/
    ├── application.yml                     # 配置文件
    └── prompts/                            # 提示词模板目录
        ├── internal-medicine-template.txt
        ├── surgery-template.txt
        └── pediatrics-template.txt

核心代码示例

1. Langchain4J配置类

package com.medical.ai.config;

import dev.langchain4j.model.chat.ChatLanguageModel;
import dev.langchain4j.model.openai.OpenAiChatModel;
import dev.langchain4j.model.openai.OpenAiStreamingChatModel;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.store.embedding.EmbeddingStore;
import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class LangchainConfig {

    @Value("${llm.base-url}")
    private String baseUrl;

    @Value("${llm.api-key}")
    private String apiKey;

    @Value("${llm.model}")
    private String modelName;

    @Value("${llm.temperature}")
    private Double temperature;

    @Value("${llm.max-tokens}")
    private Integer maxTokens;

    /**
     * 配置通义千问大模型
     * 使用OpenAI兼容API格式
     */
    @Bean
    public ChatLanguageModel chatLanguageModel() {
        return OpenAiChatModel.builder()
                .baseUrl(baseUrl + "/v1")  // 内网LLM服务地址
                .apiKey(apiKey)             // 内网部署可使用任意key
                .modelName(modelName)       // qwen-14b-chat
                .temperature(temperature)
                .maxTokens(maxTokens)
                .timeout(Duration.ofSeconds(60))
                .build();
    }

    /**
     * 流式输出模型(可选,用于实时显示提取进度)
     */
    @Bean
    public ChatLanguageModel streamingChatModel() {
        return OpenAiStreamingChatModel.builder()
                .baseUrl(baseUrl + "/v1")
                .apiKey(apiKey)
                .modelName(modelName)
                .temperature(temperature)
                .maxTokens(maxTokens)
                .build();
    }

    /**
     * 向量存储(用于语义匹配字段)
     */
    @Bean
    public EmbeddingStore<TextSegment> embeddingStore() {
        // 生产环境使用Milvus/Weaviate
        return new InMemoryEmbeddingStore<>();
    }
}

2. 信息提取服务

package com.medical.ai.service;

import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.model.chat.ChatLanguageModel;
import dev.langchain4j.model.openai.OpenAiChatModel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.util.Map;

@Slf4j
@Service
public class ExtractService {

    private final ChatLanguageModel chatModel;
    private final TemplateService templateService;
    private final ObjectMapper objectMapper;

    @Value("${llm.system-prompt}")
    private String systemPrompt;

    public ExtractService(
            ChatLanguageModel chatModel,
            TemplateService templateService,
            ObjectMapper objectMapper) {
        this.chatModel = chatModel;
        this.templateService = templateService;
        this.objectMapper = objectMapper;
    }

    /**
     * 从对话中提取医疗信息
     * @param dialogueText 对话文本
     * @param templateId 提示词模板ID
     * @return 结构化医疗信息
     */
    public Map<String, Object> extractMedicalInfo(
            String dialogueText,
            String templateId) {

        try {
            // 1. 获取提示词模板
            String template = templateService.getTemplate(templateId);
            String prompt = buildPrompt(template, dialogueText);

            // 2. 调用大模型
            log.info("调用大模型提取, 模板ID: {}", templateId);
            AiMessage response = chatModel.generate(prompt).content();

            // 3. 解析JSON响应
            String jsonStr = extractJson(response.text());
            Map<String, Object> result = objectMapper.readValue(
                jsonStr,
                Map.class
            );

            log.info("提取成功: {}", result);
            return result;

        } catch (Exception e) {
            log.error("提取失败", e);
            throw new RuntimeException("医疗信息提取失败: " + e.getMessage());
        }
    }

    /**
     * 构建完整提示词
     */
    private String buildPrompt(String template, String dialogue) {
        return String.format("""
            %s

            请从以下医生与患者的对话中提取信息,并以JSON格式返回:

            %s

            要求:
            1. 只返回JSON,不要其他说明文字
            2. 如果某字段无法提取,使用null
            3. 日期格式统一为: yyyy-MM-dd
            """, template, dialogue);
    }

    /**
     * 从响应中提取JSON
     * 处理markdown代码块包裹的情况
     */
    private String extractJson(String response) {
        // 去除markdown代码块标记
        response = response.trim();
        if (response.startsWith("```json")) {
            response = response.substring(7);
        } else if (response.startsWith("```")) {
            response = response.substring(3);
        }
        if (response.endsWith("```")) {
            response = response.substring(0, response.length() - 3);
        }
        return response.trim();
    }
}

3. 提示词模板服务

package com.medical.ai.service;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;

import jakarta.annotation.PostConstruct;
import java.util.HashMap;
import java.util.Map;

@Slf4j
@Service
public class TemplateService {

    @Value("classpath:prompts/*.txt")
    private Resource[] promptResources;

    private final Map<String, String> templates = new HashMap<>();

    @PostConstruct
    public void loadTemplates() {
        // 加载所有提示词模板
        for (Resource resource : promptResources) {
            try {
                String filename = resource.getFilename();
                String templateId = filename.replace(".txt", "");
                String content = new String(resource.getInputStream().readAllBytes());
                templates.put(templateId, content);
                log.info("加载模板: {}", templateId);
            } catch (Exception e) {
                log.error("加载模板失败", e);
            }
        }
    }

    /**
     * 获取提示词模板
     */
    public String getTemplate(String templateId) {
        String template = templates.get(templateId);
        if (template == null) {
            // 返回默认模板
            return getDefaultTemplate();
        }
        return template;
    }

    /**
     * 默认提示词模板
     */
    private String getDefaultTemplate() {
        return """
            你是医疗信息提取专家。请从对话中提取以下信息:

            {
              "patientName": "患者姓名",
              "patientAge": 年龄(数字),
              "patientGender": "性别(男/女)",
              "patientPhone": "联系电话",
              "chiefComplaint": "主诉",
              "presentIllness": "现病史",
              "pastHistory": "既往史",
              "allergyHistory": "过敏史",
              "symptoms": ["症状1", "症状2"],
              "visitType": "就诊类型(门诊/急诊/住院)"
            }
            """;
    }
}

4. application.yml配置

server:
  port: 8080

spring:
  application:
    name: medical-ai-backend

  # 数据库配置
  datasource:
    url: jdbc:mysql://localhost:3306/medical_ai?useUnicode=true&characterEncoding=utf8
    username: root
    password: ${DB_PASSWORD:123456}
    driver-class-name: com.mysql.cj.jdbc.Driver

  # Redis配置
  redis:
    host: localhost
    port: 6379
    password: ${REDIS_PASSWORD:}
    timeout: 3000

  # 文件上传配置
  servlet:
    multipart:
      max-file-size: 50MB
      max-request-size: 50MB

# Langchain4J配置
langchain:
  # 大模型配置
  llm:
    base-url: http://localhost:8000  # 内网LLM服务地址
    api-key: internal-deployment     # 内网部署可使用任意key
    model: qwen-14b-chat             # 模型名称
    temperature: 0.7
    max-tokens: 2048
    timeout: 60s

  # ASR服务配置
  asr:
    base-url: http://localhost:8001
    model: paraformer-zh
    language: zh

  # OCR配置
  ocr:
    enabled: true
    engine: tesseract  # tesseract / easyocr
    language: chi_sim

# 提示词配置
prompts:
  default-template: internal-medicine
  template-dir: classpath:prompts/

# 日志配置
logging:
  level:
    com.medical.ai: DEBUG
    dev.langchain4j: DEBUG

5. 提取Controller

package com.medical.ai.controller;

import com.medical.ai.service.ExtractService;
import com.medical.ai.dto.ExtractRequest;
import com.medical.ai.dto.ExtractResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
@RequestMapping("/api/extract")
@RequiredArgsConstructor
public class ExtractController {

    private final ExtractService extractService;

    /**
     * 从对话文本中提取医疗信息
     */
    @PostMapping
    public ExtractResponse extract(@RequestBody ExtractRequest request) {
        Map<String, Object> data = extractService.extractMedicalInfo(
            request.getDialogueText(),
            request.getTemplateId()
        );

        return ExtractResponse.builder()
                .success(true)
                .data(data)
                .message("提取成功")
                .build();
    }
}

4.2 ASR服务部署

使用Paraformer模型

1. Docker Compose部署

# docker-compose.asr.yml
version: '3.8'

services:
  paraformer-asr:
    image: registry.internal.com/paraformer-asr:latest
    container_name: paraformer-asr
    ports:
      - "8001:8000"
    environment:
      - MODEL_NAME=paraformer-zh
      - BATCH_SIZE=16
      - DEVICE=cuda  # cuda / cpu
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    volumes:
      - ./models:/models
      - ./logs:/logs
    restart: unless-stopped

  # 备选: 使用FunASR
  funasr:
    image: registry.internal.com/funasr:latest
    ports:
      - "8002:8000"
    environment:
      - MODEL=paraformer
      - VAD_MODEL=fsmn-vad

2. Dockerfile

FROM nvidia/cuda:11.8.0-cudnn8-runtime-ubuntu22.04

# 安装Python
RUN apt-get update && apt-get install -y \
    python3.10 \
    python3-pip \
    && rm -rf /var/lib/apt/lists/*

# 安装依赖
COPY requirements.txt /app/
RUN pip3 install --no-cache-dir -r /app/requirements.txt

# 复制代码
COPY . /app/
WORKDIR /app

# 暴露端口
EXPOSE 8000

# 启动服务
CMD ["uvicorn", "asr_server:app", "--host", "0.0.0.0", "--port", "8000"]

3. FastAPI服务代码

# asr_server.py
from fastapi import FastAPI, UploadFile, File
from funasr import AutoModel
import torch

app = FastAPI()

# 加载Paraformer模型
model = AutoModel(
    model="paraformer-zh",
    device="cuda" if torch.cuda.is_available() else "cpu"
)

@app.post("/transcribe")
async def transcribe(audio: UploadFile = File(...)):
    """
    语音转文字接口
    """
    # 保存临时文件
    temp_path = f"/tmp/{audio.filename}"
    with open(temp_path, "wb") as f:
        f.write(await audio.read())

    # 识别
    result = model.generate(
        input=temp_path,
        batch_size_s=300,
        hotword="医生,患者"
    )

    # 返回文本
    text = result[0]["text"]
    return {"text": text, "confidence": 0.95}

@app.get("/health")
def health():
    return {"status": "healthy"}

4.3 大模型服务部署 (Qwen-14B)

方案1: vLLM部署 (推荐生产环境)

1. 启动脚本

#!/bin/bash
# start_qwen_vllm.sh

# 拉取镜像
docker pull vllm/vllm-openai:latest

# 启动服务
docker run -d \
  --name qwen-14b-vllm \
  --gpus all \
  -p 8000:8000 \
  -v ./models:/models \
  -e HF_HOME=/models \
  vllm/vllm-openai:latest \
  --model Qwen/Qwen-14B-Chat \
  --trust-remote-code \
  --tensor-parallel-size 2 \
  --gpu-memory-utilization 0.9 \
  --max-model-len 2048 \
  --host 0.0.0.0 \
  --port 8000

# 查看日志
docker logs -f qwen-14b-vllm

2. 测试接口

curl http://localhost:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "Qwen-14B-Chat",
    "messages": [
      {"role": "user", "content": "从对话中提取医疗信息..."}
    ],
    "temperature": 0.7
  }'

方案2: Ollama部署 (开发测试)

# 1. 安装Ollama
curl -fsSL https://ollama.com/install.sh | sh

# 2. 下载模型
ollama pull qwen:14b

# 3. 启动服务
ollama serve --host 0.0.0.0 --port 8000

# 4. 测试
curl http://localhost:8000/api/generate -d '{
  "model": "qwen:14b",
  "prompt": "你好"
}'

方案3: llama.cpp部署 (低配置)

# 量化模型
python convert.py qwen-14b-chat \
  --outfile qwen-14b-q4_k_m.gguf \
  --outtype q4_k_m

# 启动服务
./server --model qwen-14b-q4_k_m.gguf \
  --port 8000 \
  --host 0.0.0.0 \
  --threads 8

4.4 OCR自动填表服务

架构设计

┌─────────────────────────────────────────────┐
│         OCR自动填表服务                       │
├─────────────────────────────────────────────┤
│                                             │
│  1. 页面截图                                 │
│     └─ Playwright截图HIS表单                │
│                                             │
│  2. OCR识别                                 │
│     ├─ Tesseract识别文本                    │
│     └─ 提取字段标签和位置                    │
│                                             │
│  3. 字段匹配                                 │
│     ├─ 语义相似度匹配                       │
│     └─ 位置坐标定位                         │
│                                             │
│  4. 自动填写                                 │
│     ├─ Playwright输入数据                   │
│     └─ 触发事件验证                         │
│                                             │
└─────────────────────────────────────────────┘

实现代码

1. OCR服务主程序

// ocr-service.js
const { chromium } = require('playwright');
const Tesseract = require('tesseract.js');
const fs = require('fs');

class OcrAutoFillService {

    constructor() {
        this.browser = null;
        this.page = null;
    }

    /**
     * 初始化浏览器
     */
    async init() {
        this.browser = await chromium.launch({
            headless: true,  // 无头模式
            args: ['--disable-dev-shm-usage']
        });
        this.page = await this.browser.newPage();
    }

    /**
     * OCR识别页面文本和位置
     */
    async recognizePage(url) {
        // 1. 访问页面
        await this.page.goto(url, { waitUntil: 'networkidle' });

        // 2. 截图
        const screenshot = await this.page.screenshot({
            path: '/tmp/his-form.png',
            fullPage: true
        });

        // 3. OCR识别
        const { data } = await Tesseract.recognize(
            screenshot,
            'chi_sim',  // 中文简体
            {
                logger: m => console.log(m)
            }
        );

        // 4. 提取文本和位置信息
        const fields = this.extractFields(data);

        return {
            imageUrl: screenshot.toString('base64'),
            fields: fields,
            text: data.text
        };
    }

    /**
     * 从OCR结果中提取表单字段
     */
    extractFields(ocrData) {
        const fields = [];

        // 解析OCR识别的文本块
        for (const word of ocrData.words) {
            // 跳过置信度过低的
            if (word.confidence < 0.6) continue;

            fields.push({
                text: word.text,
                bbox: {
                    x0: word.bbox.x0,
                    y0: word.bbox.y0,
                    x1: word.bbox.x1,
                    y1: word.bbox.y1
                },
                confidence: word.confidence
            });
        }

        return fields;
    }

    /**
     * 智能匹配字段
     */
    matchFields(ocrFields, dataSchema) {
        const matched = {};

        for (const [key, label] of Object.entries(dataSchema)) {
            // 查找最匹配的OCR文本
            const bestMatch = this.findBestMatch(ocrFields, label);

            if (bestMatch) {
                matched[key] = {
                    label: bestMatch.text,
                    bbox: bestMatch.bbox,
                    inputElement: null  // 稍后定位input元素
                };
            }
        }

        return matched;
    }

    /**
     * 文本相似度匹配
     */
    findBestMatch(ocrFields, targetLabel) {
        let bestMatch = null;
        let bestScore = 0;

        for (const field of ocrFields) {
            const score = this.calculateSimilarity(
                field.text,
                targetLabel
            );

            if (score > bestScore && score > 0.6) {
                bestScore = score;
                bestMatch = field;
            }
        }

        return bestMatch;
    }

    /**
     * 计算字符串相似度 (Levenshtein距离)
     */
    calculateSimilarity(str1, str2) {
        const len1 = str1.length;
        const len2 = str2.length;
        const matrix = [];

        // 初始化矩阵
        for (let i = 0; i <= len1; i++) {
            matrix[i] = [i];
        }
        for (let j = 0; j <= len2; j++) {
            matrix[0][j] = j;
        }

        // 动态规划计算
        for (let i = 1; i <= len1; i++) {
            for (let j = 1; j <= len2; j++) {
                const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
                matrix[i][j] = Math.min(
                    matrix[i - 1][j] + 1,
                    matrix[i][j - 1] + 1,
                    matrix[i - 1][j - 1] + cost
                );
            }
        }

        const maxLen = Math.max(len1, len2);
        return 1 - matrix[len1][len2] / maxLen;
    }

    /**
     * 定位输入框元素
     */
    async locateInputFields(matchedFields) {
        for (const [key, field] of Object.entries(matchedFields)) {
            // 根据OCR位置查找附近的input元素
            const input = await this.page.locator('input').filter(async (input) => {
                const box = await input.boundingBox();
                if (!box) return false;

                // 计算距离
                const distance = Math.sqrt(
                    Math.pow(box.x - field.bbox.x0, 2) +
                    Math.pow(box.y - field.bbox.y0, 2)
                );

                return distance < 100;  // 100像素内
            }).first();

            if (await input.count() > 0) {
                field.inputElement = input;
            }
        }
    }

    /**
     * 自动填写表单
     */
    async autoFillForm(url, medicalData, schema) {
        try {
            // 1. OCR识别页面
            const { fields } = await this.recognizePage(url);

            // 2. 匹配字段
            const matched = this.matchFields(fields, schema);

            // 3. 定位输入框
            await this.locateInputFields(matched);

            // 4. 填写数据
            for (const [key, value] of Object.entries(medicalData)) {
                const field = matched[key];
                if (field && field.inputElement) {
                    await field.inputElement.fill(value);
                    await this.page.waitForTimeout(100);  // 等待100ms
                }
            }

            // 5. 验证填写结果
            const success = await this.validateFill(matched, medicalData);

            return {
                success: success,
                filled: Object.keys(matched),
                failed: Object.keys(medicalData).filter(k => !matched[k])
            };

        } catch (error) {
            console.error('自动填写失败:', error);
            throw error;
        }
    }

    /**
     * 验证填写结果
     */
    async validateFill(matchedFields, medicalData) {
        for (const [key, field] of Object.entries(matchedFields)) {
            if (!field.inputElement) continue;

            const value = await field.inputElement.inputValue();
            const expected = medicalData[key];

            if (value !== expected) {
                console.warn(`字段 ${key} 填写不匹配: ${value} != ${expected}`);
                return false;
            }
        }
        return true;
    }

    /**
     * 关闭浏览器
     */
    async close() {
        if (this.browser) {
            await this.browser.close();
        }
    }
}

module.exports = OcrAutoFillService;

2. Express服务封装

// server.js
const express = require('express');
const OcrAutoFillService = require('./ocr-service');
const multer = require('multer');

const app = express();
const ocrService = new OcrAutoFillService();
const upload = multer();

// 初始化OCR服务
ocrService.init().then(() => {
    console.log('OCR服务已启动');
});

/**
 * OCR识别接口
 */
app.post('/api/ocr/recognize', upload.single('screenshot'), async (req, res) => {
    try {
        const { url } = req.body;

        // 识别页面
        const result = await ocrService.recognizePage(url);

        res.json({
            success: true,
            data: result
        });
    } catch (error) {
        res.status(500).json({
            success: false,
            error: error.message
        });
    }
});

/**
 * 自动填表接口
 */
app.post('/api/ocr/autofill', async (req, res) => {
    try {
        const { url, data, schema } = req.body;

        // 自动填写
        const result = await ocrService.autoFillForm(url, data, schema);

        res.json({
            success: true,
            data: result
        });
    } catch (error) {
        res.status(500).json({
            success: false,
            error: error.message
        });
    }
});

app.listen(3000, () => {
    console.log('OCR服务已启动: http://localhost:3000');
});

3. Docker部署

# Dockerfile
FROM node:18-alpine

# 安装依赖
RUN apk add --no-cache \
    tesseract-ocr \
    tesseract-ocr-chi-sim \
    chromium

WORKDIR /app

# 复制代码
COPY package*.json ./
RUN npm install --production

COPY . .

EXPOSE 3000

CMD ["node", "server.js"]

5. 部署方案

5.1 单机Docker Compose部署

docker-compose.yml

version: '3.8'

services:
  # MySQL数据库
  mysql:
    image: mysql:8.0
    container_name: medical-mysql
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
      MYSQL_DATABASE: medical_ai
    ports:
      - "3306:3306"
    volumes:
      - mysql-data:/var/lib/mysql
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
    restart: unless-stopped

  # Redis缓存
  redis:
    image: redis:7-alpine
    container_name: medical-redis
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data
    restart: unless-stopped

  # Spring Boot后端
  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
    container_name: medical-backend
    ports:
      - "8080:8080"
    environment:
      SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/medical_ai
      SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
      SPRING_REDIS_HOST: redis
      LLM_BASE_URL: http://llm-service:8000
      ASR_BASE_URL: http://asr-service:8001
    depends_on:
      - mysql
      - redis
      - llm-service
      - asr-service
    restart: unless-stopped

  # ASR服务
  asr-service:
    image: registry.internal.com/paraformer-asr:latest
    container_name: medical-asr
    ports:
      - "8001:8000"
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    restart: unless-stopped

  # LLM服务
  llm-service:
    image: vllm/vllm-openai:latest
    container_name: medical-llm
    ports:
      - "8000:8000"
    environment:
      - MODEL_NAME=Qwen/Qwen-14B-Chat
    command: >
      --model Qwen/Qwen-14B-Chat
      --trust-remote-code
      --tensor-parallel-size 2
      --gpu-memory-utilization 0.9
      --host 0.0.0.0
      --port 8000
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 2
              capabilities: [gpu]
    volumes:
      - ./models:/models
    restart: unless-stopped

  # OCR服务
  ocr-service:
    build:
      context: ./ocr-service
      dockerfile: Dockerfile
    container_name: medical-ocr
    ports:
      - "3000:3000"
    volumes:
      - /tmp:/tmp
    restart: unless-stopped

  # Nginx反向代理
  nginx:
    image: nginx:alpine
    container_name: medical-nginx
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf
      - ./ssl:/etc/nginx/ssl
    depends_on:
      - backend
    restart: unless-stopped

volumes:
  mysql-data:
  redis-data:

一键部署脚本

#!/bin/bash
# deploy.sh

set -e

echo "🚀 开始部署医疗AI系统..."

# 1. 检查环境
echo "📋 检查Docker环境..."
docker --version || (echo "请先安装Docker" && exit 1)
docker-compose --version || (echo "请先安装Docker Compose" && exit 1)

# 2. 创建.env文件
if [ ! -f .env ]; then
    echo "📝 创建配置文件..."
    cat > .env << EOF
DB_PASSWORD=your_secure_password_here
REDIS_PASSWORD=
LLM_API_KEY=internal-deployment
EOF
fi

# 3. 创建必要的目录
mkdir -p models ssl logs

# 4. 拉取镜像
echo "📦 拉取Docker镜像..."
docker-compose pull

# 5. 启动服务
echo "🎯 启动服务..."
docker-compose up -d

# 6. 等待服务启动
echo "⏳ 等待服务启动..."
sleep 30

# 7. 检查服务状态
docker-compose ps

# 8. 查看日志
echo ""
echo "📊 服务日志:"
docker-compose logs --tail=20

echo ""
echo "✅ 部署完成!"
echo ""
echo "访问地址:"
echo "  - 后端API: http://localhost:8080"
echo "  - 录音页面: http://localhost/recorder"
echo "  - 管理后台: http://localhost/admin"
echo ""
echo "停止服务: docker-compose down"
echo "查看日志: docker-compose logs -f"

5.2 命令行直接部署 (无Docker)

1. 安装依赖

#!/bin/bash
# install.sh

# 安装Java 17
sudo apt install openjdk-17-jdk -y

# 安装MySQL 8.0
sudo apt install mysql-server -y
sudo mysql_secure_installation

# 安装Redis
sudo apt install redis-server -y

# 安装Python 3.10
sudo apt install python3.10 python3-pip -y

# 安装Node.js 18
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt install nodejs -y

# 安装Tesseract OCR
sudo apt install tesseract-ocr tesseract-ocr-chi-sim -y

# 安装CUDA (GPU支持)
# 详见NVIDIA官方文档

2. 启动ASR服务

#!/bin/bash
# start_asr.sh

cd asr-service

# 创建虚拟环境
python3.10 -m venv venv
source venv/bin/activate

# 安装依赖
pip install -r requirements.txt

# 下载模型
mkdir -p models
# 从内网模型服务器下载Paraformer模型
wget http://internal-model-server.com/paraformer-zh.tar.gz
tar -xzf paraformer-zh.tar.gz -C models/

# 启动服务
nohup python -m funasr.server \
  --model-path ./models/paraformer-zh \
  --host 0.0.0.0 \
  --port 8001 \
  > logs/asr.log 2>&1 &

echo $! > asr.pid
echo "ASR服务已启动, PID: $(cat asr.pid)"

3. 启动LLM服务

#!/bin/bash
# start_llm.sh

# 安装Ollama
curl -fsSL https://ollama.com/install.sh | sh

# 下载Qwen模型
ollama pull qwen:14b

# 启动服务
nohup ollama serve --host 0.0.0.0 --port 8000 > logs/llm.log 2>&1 &

echo $! > llm.pid
echo "LLM服务已启动, PID: $(cat llm.pid)"

4. 启动Spring Boot

#!/bin/bash
# start_backend.sh

cd backend

# 打包
mvn clean package -DskipTests

# 启动服务
nohup java -jar \
  -Xms2g \
  -Xmx4g \
  -Dspring.profiles.active=prod \
  target/medical-ai-backend.jar \
  > logs/backend.log 2>&1 &

echo $! > backend.pid
echo "后端服务已启动, PID: $(cat backend.pid)"

5. 启动OCR服务

#!/bin/bash
# start_ocr.sh

cd ocr-service

# 安装依赖
npm install

# 启动服务
nohup node server.js > logs/ocr.log 2>&1 &

echo $! > ocr.pid
echo "OCR服务已启动, PID: $(cat ocr.pid)"

6. 一键启动脚本

#!/bin/bash
# start_all.sh

echo "🚀 启动医疗AI系统..."

# 检查端口占用
check_port() {
    if lsof -Pi :$1 -sTCP:LISTEN -t >/dev/null 2>&1 ; then
        echo "⚠️  端口 $1 已被占用"
        return 1
    fi
    return 0
}

# 启动各服务
if check_port 8001; then
    ./start_asr.sh
fi

if check_port 8000; then
    ./start_llm.sh
fi

if check_port 3306; then
    sudo service mysql start
fi

if check_port 6379; then
    sudo service redis-server start
fi

if check_port 8080; then
    ./start_backend.sh
fi

if check_port 3000; then
    ./start_ocr.sh
fi

echo "✅ 所有服务已启动"

6. HIS集成方案

6.1 最小侵入方案 (推荐)

HIS系统只需添加一个按钮:

<!-- HIS页面添加的代码 -->
<button id="medicalAiBtn" onclick="openMedicalAI()">
  🎤 智能录音填写
</button>

<script src="https://internal-cdn.com/medical-ai-sdk.js"></script>
<script>
function openMedicalAI() {
  // SDK会自动完成所有工作
  MedicalAI.open({
    templateId: 'internal-medicine',  // 提示词模板ID
    hospitalId: 'hosp001',             // 医院ID
    departmentId: 'dept001',           // 科室ID
    onExtracted: function(data) {
      console.log('提取成功:', data);
    }
  });
}
</script>

6.2 SDK实现 (medical-ai-sdk.js)

// medical-ai-sdk.js
(function(window) {
  'use strict';

  const MedicalAI = {
    config: {
      apiBaseUrl: 'http://medical-ai.internal.com',
      recorderUrl: 'http://recorder.internal.com',
      ocrUrl: 'http://ocr.internal.com'
    },

    /**
     * 打开录音页面
     */
    open: function(options) {
      const defaultOptions = {
        templateId: 'default',
        hospitalId: '',
        departmentId: '',
        onExtracted: null,
        onError: null
      };

      this.options = Object.assign(defaultOptions, options);

      // 打开录音H5页面
      const url = `${this.config.recorderUrl}?` +
        `templateId=${this.options.templateId}&` +
        `hospitalId=${this.options.hospitalId}&` +
        `departmentId=${this.options.departmentId}&` +
        `callbackUrl=${encodeURIComponent(window.location.href)}`;

      const recorder = window.open(
        url,
        'MedicalAIRecorder',
        'width=600,height=800,scrollbars=yes,resizable=yes'
      );

      // 监听录音页面返回的消息
      this.listenForMessage(recorder);
    },

    /**
     * 监听跨窗口消息
     */
    listenForMessage: function(recorderWindow) {
      const messageHandler = (event) => {
        // 验证来源
        if (event.origin !== this.config.recorderUrl) return;

        const { type, data } = event.data;

        switch (type) {
          case 'EXTRACTED':
            // 提取成功,开始OCR填表
            this.ocrAutoFill(data);
            recorderWindow.close();
            break;

          case 'ERROR':
            if (this.options.onError) {
              this.options.onError(data);
            }
            break;
        }
      };

      window.addEventListener('message', messageHandler);

      // 清理事件监听
      this.messageHandler = messageHandler;
    },

    /**
     * OCR自动填表
     */
    ocrAutoFill: async function(medicalData) {
      try {
        const hisUrl = window.location.href;

        // 1. 调用OCR服务识别页面
        const recognizeResponse = await fetch(
          `${this.config.ocrUrl}/api/ocr/recognize`,
          {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ url: hisUrl })
          }
        );

        const { data: ocrData } = await recognizeResponse.json();

        // 2. 获取字段映射配置
        const schema = await this.getFieldSchema();

        // 3. 调用OCR服务自动填写
        const fillResponse = await fetch(
          `${this.config.ocrUrl}/api/ocr/autofill`,
          {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              url: hisUrl,
              data: medicalData,
              schema: schema,
              ocrData: ocrData
            })
          }
        );

        const { data: result } = await fillResponse.json();

        if (result.success) {
          // 通知用户
          this.showSuccess(result);
          if (this.options.onExtracted) {
            this.options.onExtracted(medicalData);
          }
        } else {
          this.showError(result.failed);
        }

      } catch (error) {
        console.error('OCR填表失败:', error);
        this.showError([error.message]);
      }
    },

    /**
     * 获取字段映射配置
     */
    getFieldSchema: async function() {
      try {
        // 从后端API获取字段映射
        const response = await fetch(
          `${this.config.apiBaseUrl}/api/field-mapping?` +
          `hospitalId=${this.options.hospitalId}&` +
          `departmentId=${this.options.departmentId}`
        );

        const { data } = await response.json();
        return data.schema;

      } catch (error) {
        // 使用默认schema
        return {
          patientName: ['姓名', 'name', 'patientName'],
          patientAge: ['年龄', 'age', 'patientAge'],
          patientGender: ['性别', 'gender', 'patientGender'],
          // ... 更多字段
        };
      }
    },

    /**
     * 显示成功提示
     */
    showSuccess: function(result) {
      const message = `✅ 自动填写成功!\n` +
        `已填写字段: ${result.filled.length}\n` +
        `跳过字段: ${result.failed.length}`;

      alert(message);
    },

    /**
     * 显示错误提示
     */
    showError: function(failedFields) {
      const message = `⚠️ 部分字段填写失败:\n` +
        failedFields.join('\n');

      alert(message);
    }
  };

  // 暴露到全局
  window.MedicalAI = MedicalAI;

})(window);

7. OCR自动填表实现

7.1 字段匹配策略

1. 基于规则匹配

// 规则配置
const FIELD_RULES = {
  patientName: {
    keywords: ['姓名', 'name', 'xm', 'patientName'],
    inputTypes: ['text'],
    position: 'left'  // 标签在输入框左侧
  },
  patientAge: {
    keywords: ['年龄', 'age', 'nl', 'patientAge'],
    inputTypes: ['number', 'text'],
    position: 'left'
  },
  patientGender: {
    keywords: ['性别', 'gender', 'xb', 'patientGender'],
    inputTypes: ['select', 'radio'],
    position: 'left'
  }
};

// 匹配函数
function matchFieldByRules(ocrFields, rules) {
  const matched = [];

  for (const field of ocrFields) {
    for (const [fieldName, rule] of Object.entries(rules)) {
      // 检查关键词匹配
      if (rule.keywords.some(kw => field.text.includes(kw))) {
        matched.push({
          field: fieldName,
          text: field.text,
          bbox: field.bbox,
          confidence: field.confidence
        });
      }
    }
  }

  return matched;
}

2. 基于语义匹配

// 使用Embedding模型计算语义相似度
async function matchFieldByEmbedding(ocrFields, schema, model) {
  const matched = {};

  for (const [fieldName, labels] of Object.entries(schema)) {
    let bestMatch = null;
    let bestScore = 0;

    for (const ocrField of ocrFields) {
      for (const label of labels) {
        // 计算embedding相似度
        const score = await calculateEmbeddingSimilarity(
          ocrField.text,
          label,
          model
        );

        if (score > bestScore && score > 0.7) {
          bestScore = score;
          bestMatch = {
            field: fieldName,
            text: ocrField.text,
            bbox: ocrField.bbox,
            score: score
          };
        }
      }
    }

    if (bestMatch) {
      matched[fieldName] = bestMatch;
    }
  }

  return matched;
}

7.2 Playwright自动填写

async function autoFillByOCR(page, medicalData, matchedFields) {
  const results = {
    filled: [],
    failed: []
  };

  for (const [fieldName, value] of Object.entries(medicalData)) {
    const matched = matchedFields[fieldName];

    if (!matched || !matched.inputElement) {
      results.failed.push(fieldName);
      continue;
    }

    try {
      // 根据输入类型选择填写方法
      const inputType = await matched.inputElement.evaluate(el => {
        if (el.tagName === 'SELECT') return 'select';
        if (el.type === 'radio' || el.type === 'checkbox') return 'radio';
        return 'text';
      });

      switch (inputType) {
        case 'select':
          await matched.inputElement.selectOption(value);
          break;

        case 'radio':
          await page.locator(`input[type="${inputType}"][value="${value}"]`)
            .click();
          break;

        default:
          await matched.inputElement.fill(value);
          await matched.inputElement.dispatchEvent('input');
          await matched.inputElement.dispatchEvent('change');
      }

      results.filled.push(fieldName);

    } catch (error) {
      console.error(`填写 ${fieldName} 失败:`, error);
      results.failed.push(fieldName);
    }
  }

  return results;
}

8. 开发计划

8.1 开发阶段 (12周)

第1-2周: 基础设施搭建
├─ Docker环境配置
├─ MySQL/Redis安装配置
├─ Qwen模型部署测试
└─ Paraformer ASR部署

第3-4周: 后端核心功能
├─ Spring Boot项目搭建
├─ Langchain4J集成
├─ ASR/LLM服务对接
├─ 提示词管理功能
└─ RESTful API开发

第5-6周: OCR填表功能
├─ Playwright环境搭建
├─ Tesseract OCR集成
├─ 字段匹配算法实现
├─ 自动填写逻辑开发
└─ 测试优化

第7-8周: 前端开发
├─ 录音H5页面开发
├─ 管理后台开发
├─ SDK开发
└─ 前后端联调

第9-10周: HIS集成
├─ SDK封装
├─ HIS系统集成测试
├─ OCR填表测试
└─ 性能优化

第11-12周: 测试上线
├─ 功能测试
├─ 性能测试
├─ 安全测试
└─ 生产环境部署

8.2 里程碑

时间 里程碑 交付物
第2周末 模型服务可用 ASR/LLM API可调用
第4周末 后端API完成 提取接口可用
第6周末 OCR功能完成 可自动填表
第8周末 前端完成 录音页面可用
第10周末 集成完成 HIS集成可用
第12周末 上线 生产环境运行

9. 成本分析

9.1 硬件成本 (一次性)

设备 配置 数量 单价 小计
GPU服务器 A100 80GB × 4 2台 40万 80万
存储服务器 20TB RAID10 1台 5万 5万
网络设备 交换机/防火墙 1套 2万 2万
备用服务器 常规配置 1台 3万 3万
合计 90万

9.2 软件成本 (0元 - 全开源)

软件 版本 成本
Qwen-14B 开源 免费
Paraformer 开源 免费
Spring Boot 开源 免费
Langchain4J 开源 免费
Playwright 开源 免费
Tesseract 开源 免费
MySQL 开源 免费
Redis 开源 免费
合计 0元

9.3 人力成本 (6个月)

角色 人数 月薪 月数 小计
后端开发 2 2万 6 24万
前端开发 1 1.8万 6 10.8万
算法工程师 1 2.5万 6 15万
测试工程师 1 1.5万 6 9万
产品经理 1 2万 6 12万
运维工程师 1 1.8万 6 10.8万
合计 81.6万

9.4 运维成本 (年度)

项目 月成本 年成本
电费 5000元 6万
机房租用 1万 12万
网络带宽 3000元 3.6万
维护人员 2万 24万
合计 45.6万/年

9.5 总投资 (第一年)

类型 金额
硬件成本 90万
软件成本 0元
人力成本 81.6万
运维成本 45.6万
总计 217.2万

10. 风险控制

10.1 技术风险

风险 影响 应对措施
LLM提取准确率不足 字段识别错误 1. 使用大参数模型
2. 提示词工程优化
3. 人工审核机制
OCR识别错误 自动填写失败 1. 多模型融合
2. 位置验证
3. 填写后验证
并发性能不足 响应慢 1. 模型量化
2. 负载均衡
3. 缓存优化
HIS系统兼容性 无法填表 1. 多方案支持
2. 手动兜底
3. 视觉识别

10.2 安全风险

风险 影响 应对措施
患者数据泄露 合规问题 1. 内网完全隔离
2. 数据加密存储
3. 访问审计
未授权访问 安全漏洞 1. CA认证
2. 权限控制
3. 操作日志
模型被攻击 系统异常 1. 输入校验
2. 限制并发
3. 熔断降级

10.3 运维风险

风险 影响 应对措施
GPU故障 服务不可用 1. 硬件冗余
2. 自动故障转移
3. 备用服务器
数据丢失 数据恢复失败 1. 定期备份
2. 异地灾备
3. RAID10
网络故障 无法访问 1. 网络冗余
2. 本地缓存
3. 离线模式

11. 附录

11.1 快速开始

# 1. 克隆项目
git clone http://internal-git.com/medical-ai.git
cd medical-ai

# 2. 一键部署
./deploy.sh

# 3. 访问系统
open http://localhost:8080

11.2 常见问题

Q1: GPU内存不足怎么办?

# 使用量化模型
ollama pull qwen:7b

# 或使用CPU模式
export DEVICE=cpu

Q2: HIS页面无法自动填写?

// 检查是否被CORS阻止
// 使用SDK代理请求
MedicalAI.setProxy(true);

Q3: 如何更新模型?

# 停止服务
docker-compose stop llm-service

# 拉取新模型
docker pull vllm/vllm-openai:latest

# 重启服务
docker-compose up -d llm-service

11.3 联系方式


版本历史

版本 日期 作者 说明
v1.0 2025-01-01 AI Team 初始版本

文档结束