医院HIS系统需要在医生问诊过程中自动填写表单,主要流程:
| 需求项 | 说明 | 优先级 |
|---|---|---|
| 语音录制 | WebRTC录音,支持实时/分段录制 | P0 |
| 语音转文字 | 内网ASR服务,中文医疗场景优化 | P0 |
| 信息提取 | 大模型从对话提取结构化数据 | P0 |
| 自动填表 | 基于OCR的无感知自动填写 | P0 |
| 提示词管理 | 后台配置不同科室的提取模板 | P1 |
| 字段映射 | 智能匹配HIS表单字段 | P0 |
| 指标 | 要求 |
|---|---|
| 响应时间 | 录音到填写完成 < 10秒 |
| 并发支持 | 支持100个医生同时使用 |
| 准确率 | 字段提取准确率 > 95% |
| 可用性 | 99.9%可用性 |
┌─────────────────────────────────────────────────────────────────┐
│ 医院内网环境 │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ HIS系统 │ │ 录音H5页面 │ │ OCR填表服务 │ │
│ │ (已有系统) │◄────►│ (Vue3) │◄────►│ (Playwright)│ │
│ │ │ │ │ │ │ │
│ │ 添加按钮: │ │ - WebRTC录音 │ │ - 视觉识别 │ │
│ │ <button> │ │ - 实时预览 │ │ - 自动填表 │ │
│ │ │ │ - 提取进度 │ │ - 字段匹配 │ │
│ └──────────────┘ └──────┬───────┘ └──────────────┘ │
│ │ │ │
│ │ ▼ │
│ │ ┌──────────────┐ │
│ │ │ Spring Boot │ │
│ │ │ 后端服务 │ │
│ │ │ │ │
│ │ │ - Langchain4J│ │
│ │ │ - 提示词管理 │ │
│ │ │ - 数据库 │ │
│ │ └──────┬───────┘ │
│ │ │ │
│ │ ┌─────────────┼─────────────┐ │
│ │ ▼ ▼ ▼ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ │ ASR │ │ LLM │ │ Admin │ │
│ │ │ 服务 │ │ 服务 │ │ 后台 │ │
│ │ │Paraformer│ │ Qwen │ │(Vue3+ │ │
│ │ │ │ │ 14B │ │Element) │ │
│ │ └──────────┘ └──────────┘ └──────────┘ │
│ │ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Docker 部署 (单机/集群) │ │
│ │ 所有服务容器化,支持横向扩展 │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
核心框架:
- 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 (图像处理)
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();
}
}
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"}
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
}'
# 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": "你好"
}'
# 量化模型
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
┌─────────────────────────────────────────────┐
│ 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"]
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"
#!/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官方文档
#!/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)"
#!/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)"
#!/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)"
#!/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)"
#!/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 "✅ 所有服务已启动"
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>
// 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);
// 规则配置
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;
}
// 使用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;
}
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;
}
第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周: 测试上线
├─ 功能测试
├─ 性能测试
├─ 安全测试
└─ 生产环境部署
| 时间 | 里程碑 | 交付物 |
|---|---|---|
| 第2周末 | 模型服务可用 | ASR/LLM API可调用 |
| 第4周末 | 后端API完成 | 提取接口可用 |
| 第6周末 | OCR功能完成 | 可自动填表 |
| 第8周末 | 前端完成 | 录音页面可用 |
| 第10周末 | 集成完成 | HIS集成可用 |
| 第12周末 | 上线 | 生产环境运行 |
| 设备 | 配置 | 数量 | 单价 | 小计 |
|---|---|---|---|---|
| GPU服务器 | A100 80GB × 4 | 2台 | 40万 | 80万 |
| 存储服务器 | 20TB RAID10 | 1台 | 5万 | 5万 |
| 网络设备 | 交换机/防火墙 | 1套 | 2万 | 2万 |
| 备用服务器 | 常规配置 | 1台 | 3万 | 3万 |
| 合计 | 90万 |
| 软件 | 版本 | 成本 |
|---|---|---|
| Qwen-14B | 开源 | 免费 |
| Paraformer | 开源 | 免费 |
| Spring Boot | 开源 | 免费 |
| Langchain4J | 开源 | 免费 |
| Playwright | 开源 | 免费 |
| Tesseract | 开源 | 免费 |
| MySQL | 开源 | 免费 |
| Redis | 开源 | 免费 |
| 合计 | 0元 |
| 角色 | 人数 | 月薪 | 月数 | 小计 |
|---|---|---|---|---|
| 后端开发 | 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万 |
| 项目 | 月成本 | 年成本 |
|---|---|---|
| 电费 | 5000元 | 6万 |
| 机房租用 | 1万 | 12万 |
| 网络带宽 | 3000元 | 3.6万 |
| 维护人员 | 2万 | 24万 |
| 合计 | 45.6万/年 |
| 类型 | 金额 |
|---|---|
| 硬件成本 | 90万 |
| 软件成本 | 0元 |
| 人力成本 | 81.6万 |
| 运维成本 | 45.6万 |
| 总计 | 217.2万 |
| 风险 | 影响 | 应对措施 |
|---|---|---|
| LLM提取准确率不足 | 字段识别错误 | 1. 使用大参数模型 2. 提示词工程优化 3. 人工审核机制 |
| OCR识别错误 | 自动填写失败 | 1. 多模型融合 2. 位置验证 3. 填写后验证 |
| 并发性能不足 | 响应慢 | 1. 模型量化 2. 负载均衡 3. 缓存优化 |
| HIS系统兼容性 | 无法填表 | 1. 多方案支持 2. 手动兜底 3. 视觉识别 |
| 风险 | 影响 | 应对措施 |
|---|---|---|
| 患者数据泄露 | 合规问题 | 1. 内网完全隔离 2. 数据加密存储 3. 访问审计 |
| 未授权访问 | 安全漏洞 | 1. CA认证 2. 权限控制 3. 操作日志 |
| 模型被攻击 | 系统异常 | 1. 输入校验 2. 限制并发 3. 熔断降级 |
| 风险 | 影响 | 应对措施 |
|---|---|---|
| GPU故障 | 服务不可用 | 1. 硬件冗余 2. 自动故障转移 3. 备用服务器 |
| 数据丢失 | 数据恢复失败 | 1. 定期备份 2. 异地灾备 3. RAID10 |
| 网络故障 | 无法访问 | 1. 网络冗余 2. 本地缓存 3. 离线模式 |
# 1. 克隆项目
git clone http://internal-git.com/medical-ai.git
cd medical-ai
# 2. 一键部署
./deploy.sh
# 3. 访问系统
open http://localhost:8080
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
| 版本 | 日期 | 作者 | 说明 |
|---|---|---|---|
| v1.0 | 2025-01-01 | AI Team | 初始版本 |
文档结束