Spring AI + PostgreSQL 向量库:构建智能医院问诊客服助手

摘要

在医疗场景中,患者常常面临挂号困难、疾病咨询复杂等问题。本文将介绍如何使用 Spring AI 最新版本结合 PostgreSQL pgvector 扩展,构建一个生产级的智能医院问诊客服助手,实现症状识别、科室推荐、用药咨询等功能,大幅提升患者就医体验。

目录

  1. 项目背景与需求分析
  2. 技术架构设计
  3. 环境准备与配置
  4. PostgreSQL pgvector 详解
  5. 核心功能实现
  6. 医疗知识库构建
  7. 智能问诊引擎
  8. 多轮对话管理
  9. REST API 设计
  10. 生产环境部署
  11. 安全与合规
  12. 性能优化
  13. 测试与验证
  14. 总结与展望

1. 项目背景与需求分析

1.1 医疗行业痛点

┌─────────────────────────────────────────────────────────────┐
│                 传统医院就诊痛点分析                          │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  患者端痛点                      解决方案                    │
│  ─────────────────────────────────────────────────────────  │
│  • 不知道看哪个科室              ➜  AI 科室推荐              │
│  • 症状描述不清晰                ➜  症状识别引导              │
│  • 排队等待时间长                ➜  预问诊分流                │
│  • 用药咨询困难                  ➜  智能用药指导              │
│  • 复诊提醒遗漏                  ➜  智能随访管理              │
│                                                             │
│  医院端痛点                      解决方案                    │
│  ─────────────────────────────────────────────────────────  │
│  • 咨询窗口压力大                ➜  AI 客服分流               │
│  • 人工成本高                    ➜  24/7 智能服务            │
│  • 知识更新滞后                  ➜  知识库实时更新            │
│  • 服务质量不稳定                ➜  标准化服务流程            │
│  • 数据分析困难                  ➜  智能数据洞察              │
│                                                             │
└─────────────────────────────────────────────────────────────┘

1.2 功能需求

核心功能:

  • 🏥 智能导诊:根据症状推荐科室和医生
  • 💊 用药咨询:提供安全用药指导
  • 📋 病历解读:解释检查报告
  • 🔔 健康提醒:复诊、用药提醒
  • 📊 数据统计:患者画像分析

技术要求:

  • 高准确率(医疗知识检索准确率 > 90%)
  • 低延迟(响应时间 < 2秒)
  • 高可用(99.9% 可用性)
  • 可追溯(所有对话可审计)
  • 安全合规(符合医疗数据保护法规)

1.3 技术选型

组件 技术选型 理由
应用框架 Spring Boot 3.2+ 成熟稳定、生态完善
AI 框架 Spring AI 1.0.0-M4 原生 Spring 支持
向量数据库 PostgreSQL + pgvector 开源、关系型+向量支持
LLM OpenAI GPT-4 / Qwen 高质量生成能力
缓存 Redis 高性能缓存
消息队列 RabbitMQ 异步任务处理
监控 Prometheus + Grafana 全方位监控

2. 技术架构设计

2.1 整体架构图

┌──────────────────────────────────────────────────────────────────┐
│                    智能医院问诊客服助手架构                        │
└──────────────────────────────────────────────────────────────────┘

                          ┌─────────────────┐
                          │   Web/Mobile    │
                          │      前端        │
                          └────────┬────────┘
                                   │ HTTPS
                    ┌──────────────▼──────────────┐
                    │      API Gateway            │
                    │   (认证、限流、路由)         │
                    └──────────────┬──────────────┘
                                   │
        ┌──────────────────────────┼──────────────────────────┐
        │                          │                          │
┌───────▼────────┐      ┌──────────▼─────────┐      ┌────────▼────────┐
│  会话管理服务   │      │   问诊核心服务      │      │   知识库服务     │
│                │      │                    │      │                 │
│ • 会话上下文   │      │ • 症状识别         │      │ • 医学知识      │
│ • 用户状态     │◀────▶│ • 科室推荐         │◀────▶│ • 用药指南      │
│ • 对话历史     │      │ • 风险评估         │      │ • 疾病百科      │
│                │      │ • 应答生成         │      │ • 医生信息      │
└────────┬───────┘      └──────────┬─────────┘      └────────┬────────┘
         │                         │                         │
         │                         │                         │
┌────────▼───────────────────────────▼─────────────────────────▼────────┐
│                        Spring AI Framework                             │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐  ┌───────────┐ │
│  │ ChatClient   │  │ VectorStore  │  │ Embedding    │  │ Memory    │ │
│  │              │  │  (PgVector)  │  │    Model     │  │  Manager  │ │
│  └──────────────┘  └──────────────┘  └──────────────┘  └───────────┘ │
└────────────────────────────────────────────────────────────────────────┘
         │                         │                         │
         │                         │                         │
┌────────▼────────┐      ┌─────────▼────────┐      ┌────────▼────────┐
│   PostgreSQL    │      │   Redis Cache    │      │   RabbitMQ      │
│   + pgvector    │      │                  │      │                 │
│                 │      │ • 会话缓存        │      │ • 异步任务      │
│ • 向量数据      │      │ • 热点数据        │      │ • 消息通知      │
│ • 业务数据      │      │ • 查询缓存        │      │ • 事件处理      │
└─────────────────┘      └──────────────────┘      └─────────────────┘
         │
         │
┌────────▼────────┐
│   OpenAI API    │
│  / Qwen API     │
│                 │
│ • GPT-4         │
│ • Embedding     │
└─────────────────┘

2.2 RAG 工作流程

┌──────────────────────────────────────────────────────────────┐
│              智能问诊 RAG 工作流程                             │
└──────────────────────────────────────────────────────────────┘

【患者问诊流程】

  ┌─────────────┐
  │ 患者提问    │  "我最近总是头疼,应该看哪个科室?"
  └──────┬──────┘
         │
         ▼
  ┌─────────────┐      ┌──────────────────────────────┐
  │ 意图识别    │─────▶│ 识别类型:症状咨询             │
  └──────┬──────┘      │ 关键词:头疼、科室             │
         │             └──────────────────────────────┘
         ▼
  ┌─────────────┐      ┌──────────────────────────────┐
  │ 向量检索    │─────▶│ 从医学知识库检索相关文档       │
  │ (pgvector)  │      │ • 头疼症状说明                │
  └──────┬──────┘      │ • 神经内科介绍                │
         │             │ • 相关病例                    │
         ▼             └──────────────────────────────┘
  ┌─────────────┐
  │ 上下文增强  │      增加患者历史、当前状态
  └──────┬──────┘
         │
         ▼
  ┌─────────────┐      ┌──────────────────────────────┐
  │ LLM 生成    │─────▶│ 生成专业回答:                │
  │ 回答        │      │ "根据您的症状描述..."         │
  └──────┬──────┘      └──────────────────────────────┘
         │
         ▼
  ┌─────────────┐      ┌──────────────────────────────┐
  │ 结果优化    │─────▶│ • 添加科室链接                │
  │ & 审核      │      │ • 添加注意事项                │
  └──────┬──────┘      │ • 合规性检查                  │
         │             └──────────────────────────────┘
         ▼
  ┌─────────────┐
  │ 返回患者    │  结构化回答 + 推荐操作
  └─────────────┘

3. 环境准备与配置

3.1 PostgreSQL + pgvector 安装

Docker 方式(推荐):

# 拉取支持 pgvector 的 PostgreSQL 镜像
docker pull pgvector/pgvector:pg16

# 启动 PostgreSQL
docker run -d \
  --name postgres-vector \
  -e POSTGRES_USER=medical_admin \
  -e POSTGRES_PASSWORD=Medical@2024 \
  -e POSTGRES_DB=medical_ai \
  -p 5432:5432 \
  -v postgres-data:/var/lib/postgresql/data \
  pgvector/pgvector:pg16

# 验证安装
docker exec -it postgres-vector psql -U medical_admin -d medical_ai
medical_ai=# CREATE EXTENSION IF NOT EXISTS vector;
medical_ai=# SELECT * FROM pg_extension WHERE extname = 'vector';

Docker Compose 方式:

version: '3.8'

services:
  postgres:
    image: pgvector/pgvector:pg16
    container_name: medical-postgres
    environment:
      POSTGRES_USER: medical_admin
      POSTGRES_PASSWORD: Medical@2024
      POSTGRES_DB: medical_ai
      POSTGRES_INITDB_ARGS: "-E UTF8"
    ports:
      - "5432:5432"
    volumes:
      - postgres-data:/var/lib/postgresql/data
      - ./init-scripts:/docker-entrypoint-initdb.d
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U medical_admin"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    container_name: medical-redis
    ports:
      - "6379:6379"
    command: redis-server --requirepass Medical@Redis2024
    volumes:
      - redis-data:/data
    restart: unless-stopped

volumes:
  postgres-data:
  redis-data:

3.2 初始化数据库脚本

init-scripts/01-init.sql:

-- 创建 pgvector 扩展
CREATE EXTENSION IF NOT EXISTS vector;

-- 创建医学知识库表
CREATE TABLE medical_knowledge (
    id BIGSERIAL PRIMARY KEY,
    content TEXT NOT NULL,
    content_type VARCHAR(50) NOT NULL, -- disease, symptom, medication, department
    category VARCHAR(100),
    embedding vector(1536), -- OpenAI ada-002 维度
    metadata JSONB,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 创建向量索引(HNSW)
CREATE INDEX ON medical_knowledge USING hnsw (embedding vector_cosine_ops);

-- 创建元数据索引
CREATE INDEX idx_medical_knowledge_type ON medical_knowledge(content_type);
CREATE INDEX idx_medical_knowledge_category ON medical_knowledge(category);
CREATE INDEX idx_medical_knowledge_metadata ON medical_knowledge USING gin(metadata);

-- 创建患者会话表
CREATE TABLE patient_sessions (
    id BIGSERIAL PRIMARY KEY,
    session_id VARCHAR(100) UNIQUE NOT NULL,
    patient_id VARCHAR(100),
    patient_name VARCHAR(100),
    start_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    status VARCHAR(20) DEFAULT 'active', -- active, completed, expired
    metadata JSONB
);

-- 创建对话历史表
CREATE TABLE conversation_history (
    id BIGSERIAL PRIMARY KEY,
    session_id VARCHAR(100) NOT NULL,
    role VARCHAR(20) NOT NULL, -- user, assistant, system
    content TEXT NOT NULL,
    metadata JSONB,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (session_id) REFERENCES patient_sessions(session_id)
);

-- 创建索引
CREATE INDEX idx_conversation_session ON conversation_history(session_id);
CREATE INDEX idx_conversation_created ON conversation_history(created_at);

-- 创建科室信息表
CREATE TABLE departments (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    description TEXT,
    specialties TEXT[],
    keywords TEXT[],
    available_time VARCHAR(200),
    location VARCHAR(200),
    contact VARCHAR(50)
);

-- 创建医生信息表
CREATE TABLE doctors (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    department_id INTEGER REFERENCES departments(id),
    title VARCHAR(50),
    specialties TEXT[],
    introduction TEXT,
    schedule JSONB,
    rating DECIMAL(3,2)
);

-- 创建审计日志表
CREATE TABLE audit_logs (
    id BIGSERIAL PRIMARY KEY,
    session_id VARCHAR(100),
    action VARCHAR(50),
    details JSONB,
    ip_address INET,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 创建更新时间触发器
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = CURRENT_TIMESTAMP;
    RETURN NEW;
END;
$$ language 'plpgsql';

CREATE TRIGGER update_medical_knowledge_updated_at
    BEFORE UPDATE ON medical_knowledge
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

3.3 Maven 依赖配置

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.1</version>
        <relativePath/>
    </parent>

    <groupId>com.medical</groupId>
    <artifactId>ai-consultation-assistant</artifactId>
    <version>1.0.0</version>
    <name>AI Medical Consultation Assistant</name>

    <properties>
        <java.version>17</java.version>
        <spring-ai.version>1.0.0-M4</spring-ai.version>
    </properties>

    <dependencies>
        <!-- Spring Boot Web -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- Spring Boot Data JPA -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

        <!-- Spring Boot Validation -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

        <!-- Spring AI OpenAI -->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
        </dependency>

        <!-- Spring AI PostgreSQL Vector Store -->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
        </dependency>

        <!-- Spring AI PDF Document Reader -->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-pdf-document-reader</artifactId>
        </dependency>

        <!-- PostgreSQL Driver -->
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
        </dependency>

        <!-- PGVector JDBC -->
        <dependency>
            <groupId>com.pgvector</groupId>
            <artifactId>pgvector</artifactId>
            <version>0.1.4</version>
        </dependency>

        <!-- Redis -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <!-- Lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <!-- Apache Commons Lang -->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
        </dependency>

        <!-- JSON Processing -->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </dependency>

        <!-- Micrometer for Metrics -->
        <dependency>
            <groupId>io.micrometer</groupId>
            <artifactId>micrometer-registry-prometheus</artifactId>
        </dependency>

        <!-- Spring Boot Actuator -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>

        <!-- Test Dependencies -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.ai</groupId>
                <artifactId>spring-ai-bom</artifactId>
                <version>${spring-ai.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

    <repositories>
        <repository>
            <id>spring-milestones</id>
            <name>Spring Milestones</name>
            <url>https://repo.spring.io/milestone</url>
            <snapshots>
                <enabled>false</enabled>
            </snapshots>
        </repository>
    </repositories>
</project>

3.4 应用配置

application.yml:

spring:
  application:
    name: medical-ai-assistant

  # 数据源配置
  datasource:
    url: jdbc:postgresql://localhost:5432/medical_ai
    username: medical_admin
    password: Medical@2024
    driver-class-name: org.postgresql.Driver
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 30000
      idle-timeout: 600000
      max-lifetime: 1800000

  # JPA 配置
  jpa:
    database-platform: org.hibernate.dialect.PostgreSQLDialect
    hibernate:
      ddl-auto: validate
    show-sql: false
    properties:
      hibernate:
        format_sql: true
        jdbc:
          batch_size: 20

  # Redis 配置
  data:
    redis:
      host: localhost
      port: 6379
      password: Medical@Redis2024
      timeout: 60s
      jedis:
        pool:
          max-active: 20
          max-idle: 10
          min-idle: 5

  # AI 配置
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      chat:
        options:
          model: gpt-4
          temperature: 0.3  # 医疗场景需要更精确的回答
          max-tokens: 2000
          top-p: 0.9
      embedding:
        options:
          model: text-embedding-ada-002

    # PostgreSQL Vector Store 配置
    vectorstore:
      pgvector:
        index-type: HNSW
        distance-type: COSINE_DISTANCE
        dimensions: 1536
        initialize-schema: false
        remove-existing-vector-store-table: false

# 服务器配置
server:
  port: 8080
  servlet:
    context-path: /api

# 日志配置
logging:
  level:
    root: INFO
    com.medical: DEBUG
    org.springframework.ai: DEBUG
    org.hibernate.SQL: DEBUG
  pattern:
    console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"

# 管理端点
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus
  metrics:
    export:
      prometheus:
        enabled: true

# 医疗 AI 助手自定义配置
medical:
  assistant:
    # 知识库配置
    knowledge:
      chunk-size: 800
      chunk-overlap: 200
      similarity-threshold: 0.75
      max-results: 5

    # 会话配置
    session:
      timeout-minutes: 30
      max-history-messages: 20

    # 安全配置
    security:
      enable-content-filter: true
      enable-audit-log: true
      sensitive-words:
        - 诊断
        - 确诊
        - 处方

    # 推荐配置
    recommendation:
      min-confidence: 0.8
      max-departments: 3

4. PostgreSQL pgvector 详解

4.1 pgvector 核心概念

向量索引类型:

┌──────────────────────────────────────────────────────────┐
│            pgvector 索引类型对比                          │
├──────────────────────────────────────────────────────────┤
│                                                          │
│  索引类型    精确度    速度     内存占用    适用场景      │
│  ───────────────────────────────────────────────────────│
│  HNSW       95-99%    快       中等        大规模数据    │
│  IVFFlat    90-95%    很快     低          超大规模      │
│  无索引     100%      慢       低          小规模/测试   │
│                                                          │
│  HNSW 参数:                                            │
│  • m: 16-64 (连接数,越大越精确)                        │
│  • ef_construction: 64-400 (构建时搜索深度)            │
│                                                          │
└──────────────────────────────────────────────────────────┘

距离度量:

-- 余弦距离(推荐用于文本向量)
<=> operator  -- 范围 [0, 2],0 表示完全相同

-- L2 距离(欧氏距离)
<-> operator  -- 范围 [0, ∞)

-- 内积(需要归一化向量)
<#> operator  -- 范围 (-∞, ∞)

4.2 医疗知识向量化示例

package com.medical.ai.config;

import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.openai.OpenAiEmbeddingModel;
import org.springframework.ai.vectorstore.PgVectorStore;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;

@Configuration
public class VectorStoreConfig {

    @Value("${medical.assistant.knowledge.similarity-threshold:0.75}")
    private double similarityThreshold;

    @Bean
    public VectorStore vectorStore(EmbeddingModel embeddingModel, JdbcTemplate jdbcTemplate) {
        return new PgVectorStore.Builder(jdbcTemplate, embeddingModel)
                .withSchemaName("public")
                .withTableName("medical_knowledge")
                .withVectorColumnName("embedding")
                .withContentColumnName("content")
                .withMetadataColumnName("metadata")
                .withDistanceType(PgVectorStore.PgDistanceType.COSINE_DISTANCE)
                .withIndexType(PgVectorStore.PgIndexType.HNSW)
                .withDimensions(1536)
                .withInitializeSchema(false)
                .build();
    }
}

5. 核心功能实现

5.1 实体类定义

package com.medical.ai.entity;

import com.fasterxml.jackson.databind.JsonNode;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.Type;

import java.time.LocalDateTime;

/**
 * 医学知识实体
 */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "medical_knowledge")
public class MedicalKnowledge {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, columnDefinition = "TEXT")
    private String content;

    @Column(name = "content_type", nullable = false, length = 50)
    private String contentType; // disease, symptom, medication, department

    @Column(length = 100)
    private String category;

    @Column(columnDefinition = "jsonb")
    private JsonNode metadata;

    @Column(name = "created_at")
    private LocalDateTime createdAt;

    @Column(name = "updated_at")
    private LocalDateTime updatedAt;

    @PrePersist
    protected void onCreate() {
        createdAt = LocalDateTime.now();
        updatedAt = LocalDateTime.now();
    }

    @PreUpdate
    protected void onUpdate() {
        updatedAt = LocalDateTime.now();
    }
}

/**
 * 患者会话实体
 */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "patient_sessions")
public class PatientSession {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "session_id", unique = true, nullable = false)
    private String sessionId;

    @Column(name = "patient_id")
    private String patientId;

    @Column(name = "patient_name")
    private String patientName;

    @Column(name = "start_time")
    private LocalDateTime startTime;

    @Column(name = "last_active")
    private LocalDateTime lastActive;

    @Column(length = 20)
    private String status; // active, completed, expired

    @Column(columnDefinition = "jsonb")
    private JsonNode metadata;

    @PrePersist
    protected void onCreate() {
        startTime = LocalDateTime.now();
        lastActive = LocalDateTime.now();
        status = "active";
    }
}

/**
 * 对话历史实体
 */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "conversation_history")
public class ConversationHistory {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "session_id", nullable = false)
    private String sessionId;

    @Column(nullable = false, length = 20)
    private String role; // user, assistant, system

    @Column(nullable = false, columnDefinition = "TEXT")
    private String content;

    @Column(columnDefinition = "jsonb")
    private JsonNode metadata;

    @Column(name = "created_at")
    private LocalDateTime createdAt;

    @PrePersist
    protected void onCreate() {
        createdAt = LocalDateTime.now();
    }
}

/**
 * 科室信息实体
 */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "departments")
public class Department {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(columnDefinition = "TEXT")
    private String description;

    @Column(columnDefinition = "TEXT[]")
    private String[] specialties;

    @Column(columnDefinition = "TEXT[]")
    private String[] keywords;

    @Column(name = "available_time")
    private String availableTime;

    private String location;

    private String contact;
}

5.2 Repository 层

package com.medical.ai.repository;

import com.medical.ai.entity.*;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;

@Repository
public interface MedicalKnowledgeRepository extends JpaRepository<MedicalKnowledge, Long> {

    List<MedicalKnowledge> findByContentType(String contentType);

    List<MedicalKnowledge> findByCategory(String category);
}

@Repository
public interface PatientSessionRepository extends JpaRepository<PatientSession, Long> {

    Optional<PatientSession> findBySessionId(String sessionId);

    List<PatientSession> findByPatientId(String patientId);

    @Query("SELECT s FROM PatientSession s WHERE s.lastActive < ?1 AND s.status = 'active'")
    List<PatientSession> findExpiredSessions(LocalDateTime cutoffTime);
}

@Repository
public interface ConversationHistoryRepository extends JpaRepository<ConversationHistory, Long> {

    List<ConversationHistory> findBySessionIdOrderByCreatedAtAsc(String sessionId);

    @Query(value = "SELECT * FROM conversation_history WHERE session_id = ?1 " +
                   "ORDER BY created_at DESC LIMIT ?2", nativeQuery = true)
    List<ConversationHistory> findRecentBySessionId(String sessionId, int limit);
}

@Repository
public interface DepartmentRepository extends JpaRepository<Department, Long> {

    Optional<Department> findByName(String name);

    @Query(value = "SELECT * FROM departments WHERE ?1 = ANY(keywords)", nativeQuery = true)
    List<Department> findByKeyword(String keyword);
}

6. 医疗知识库构建

6.1 知识库加载服务

package com.medical.ai.service;

import com.medical.ai.entity.MedicalKnowledge;
import com.medical.ai.repository.MedicalKnowledgeRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.TextReader;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Slf4j
@Service
public class MedicalKnowledgeService {

    private final VectorStore vectorStore;
    private final MedicalKnowledgeRepository knowledgeRepository;

    @Value("${medical.assistant.knowledge.chunk-size:800}")
    private int chunkSize;

    @Value("${medical.assistant.knowledge.chunk-overlap:200}")
    private int chunkOverlap;

    public MedicalKnowledgeService(
            VectorStore vectorStore,
            MedicalKnowledgeRepository knowledgeRepository) {
        this.vectorStore = vectorStore;
        this.knowledgeRepository = knowledgeRepository;
    }

    /**
     * 加载医学文档到知识库
     */
    @Transactional
    public String loadMedicalDocument(Resource resource, String contentType, String category) {
        try {
            log.info("开始加载医学文档: {}, 类型: {}, 分类: {}",
                    resource.getFilename(), contentType, category);

            // 1. 读取文档
            TextReader textReader = new TextReader(resource);
            List<Document> documents = textReader.get();

            // 2. 分割文档
            TokenTextSplitter splitter = new TokenTextSplitter(chunkSize, chunkOverlap, 5, 10000, true);
            List<Document> chunks = splitter.apply(documents);

            // 3. 添加元数据
            chunks.forEach(doc -> {
                Map<String, Object> metadata = new HashMap<>(doc.getMetadata());
                metadata.put("content_type", contentType);
                metadata.put("category", category);
                metadata.put("source", resource.getFilename());
                metadata.put("loaded_at", System.currentTimeMillis());
                doc.setMetadata(metadata);
            });

            // 4. 存储到向量库
            vectorStore.add(chunks);

            // 5. 保存到数据库(用于管理和审计)
            List<MedicalKnowledge> knowledgeEntries = chunks.stream()
                    .map(doc -> MedicalKnowledge.builder()
                            .content(doc.getContent())
                            .contentType(contentType)
                            .category(category)
                            .build())
                    .collect(Collectors.toList());

            knowledgeRepository.saveAll(knowledgeEntries);

            log.info("✅ 医学文档加载完成: {} 个文档块", chunks.size());
            return String.format("成功加载 %d 个文档块", chunks.size());

        } catch (Exception e) {
            log.error("加载医学文档失败", e);
            throw new RuntimeException("文档加载失败: " + e.getMessage(), e);
        }
    }

    /**
     * 初始化基础医学知识
     */
    @Transactional
    public void initializeBasicKnowledge() {
        log.info("开始初始化基础医学知识库...");

        // 疾病知识
        List<Document> diseases = createDiseaseDocuments();
        vectorStore.add(diseases);

        // 症状知识
        List<Document> symptoms = createSymptomDocuments();
        vectorStore.add(symptoms);

        // 用药知识
        List<Document> medications = createMedicationDocuments();
        vectorStore.add(medications);

        // 科室信息
        List<Document> departments = createDepartmentDocuments();
        vectorStore.add(departments);

        log.info("✅ 基础医学知识库初始化完成");
    }

    /**
     * 创建疾病知识文档
     */
    private List<Document> createDiseaseDocuments() {
        return List.of(
                createDocument(
                        "感冒(普通感冒):由病毒引起的上呼吸道感染,主要症状包括打喷嚏、鼻塞、流鼻涕、咽痛、咳嗽、" +
                        "低热、头痛和全身不适。多数情况下7-10天可自愈。需多休息、多饮水,症状严重时可用对症药物。" +
                        "如果高烧不退或症状持续超过10天,应就医检查。推荐科室:呼吸内科、全科医学科。",
                        "disease", "呼吸系统疾病"
                ),
                createDocument(
                        "高血压:成人收缩压≥140mmHg和/或舒张压≥90mmHg。长期高血压可导致心脑血管疾病、肾脏疾病等。" +
                        "需要长期规律服药,定期监测血压,保持健康生活方式,包括低盐饮食、适度运动、控制体重、戒烟限酒。" +
                        "推荐科室:心血管内科、老年医学科。",
                        "disease", "心血管疾病"
                ),
                createDocument(
                        "糖尿病(2型):以高血糖为特征的代谢性疾病。主要症状:多饮、多尿、多食、体重下降。" +
                        "需要控制饮食、规律运动、监测血糖、规律用药。并发症包括糖尿病肾病、视网膜病变、神经病变等。" +
                        "推荐科室:内分泌科。",
                        "disease", "内分泌疾病"
                ),
                createDocument(
                        "偏头痛:反复发作的搏动性头痛,常伴有恶心、呕吐、畏光、畏声。发作持续4-72小时。" +
                        "诱因包括压力、睡眠不足、某些食物、激素变化等。治疗包括急性期止痛和预防性治疗。" +
                        "推荐科室:神经内科、疼痛科。",
                        "disease", "神经系统疾病"
                )
        );
    }

    /**
     * 创建症状知识文档
     */
    private List<Document> createSymptomDocuments() {
        return List.of(
                createDocument(
                        "头痛:常见症状,可能由多种原因引起。紧张性头痛、偏头痛、丛集性头痛是最常见的原发性头痛。" +
                        "继发性头痛可能提示严重疾病,如脑出血、脑膜炎、脑肿瘤等。伴有发热、颈部僵硬、意识改变、" +
                        "突然剧烈头痛应立即就医。推荐科室:神经内科。",
                        "symptom", "神经系统症状"
                ),
                createDocument(
                        "胸痛:需要警惕心绞痛、心肌梗死等严重疾病。心源性胸痛特点:胸骨后压榨性疼痛,可放射至左肩、" +
                        "左臂,持续数分钟至数十分钟,休息或含服硝酸甘油可缓解。伴有呼吸困难、出汗、恶心应立即就医。" +
                        "推荐科室:心血管内科、急诊科。",
                        "symptom", "心血管症状"
                ),
                createDocument(
                        "腹痛:根据部位不同可能提示不同疾病。右上腹痛:肝胆疾病;左上腹痛:胃、脾、胰腺疾病;" +
                        "右下腹痛:阑尾炎、妇科疾病;左下腹痛:结肠疾病。伴有发热、呕吐、腹泻、便血应及时就医。" +
                        "推荐科室:消化内科、普外科、妇科。",
                        "symptom", "消化系统症状"
                )
        );
    }

    /**
     * 创建用药知识文档
     */
    private List<Document> createMedicationDocuments() {
        return List.of(
                createDocument(
                        "布洛芬:非甾体抗炎药,用于解热镇痛。适应症:发热、头痛、牙痛、肌肉痛、关节痛等。" +
                        "成人常用量:200-400mg,每日3-4次。注意事项:空腹服用可能刺激胃粘膜,建议饭后服用。" +
                        "禁忌症:消化性溃疡、严重肝肾功能不全、孕晚期。",
                        "medication", "解热镇痛药"
                ),
                createDocument(
                        "阿莫西林:青霉素类抗生素,用于细菌感染。适应症:呼吸道感染、泌尿道感染、皮肤软组织感染等。" +
                        "用法:成人500mg,每日3次,饭前或饭后均可。疗程:一般7-14天。" +
                        "注意事项:青霉素过敏者禁用,需在医生指导下使用,不可自行停药。",
                        "medication", "抗生素"
                ),
                createDocument(
                        "硝苯地平:钙通道阻滞剂,用于治疗高血压和心绞痛。成人常用量:10-20mg,每日2-3次。" +
                        "缓释片:每日1-2次。注意事项:可能出现头痛、面部潮红、踝部水肿。不可突然停药。" +
                        "定期监测血压。孕妇慎用。",
                        "medication", "降压药"
                )
        );
    }

    /**
     * 创建科室信息文档
     */
    private List<Document> createDepartmentDocuments() {
        return List.of(
                createDocument(
                        "心血管内科:诊治心脏和血管系统疾病。常见疾病:高血压、冠心病、心律失常、心力衰竭、" +
                        "心肌病等。症状包括:胸痛、胸闷、心悸、气短、晕厥等。科室配备心电图、超声心动图、" +
                        "冠脉造影等检查设备。",
                        "department", "内科"
                ),
                createDocument(
                        "呼吸内科:诊治呼吸系统疾病。常见疾病:肺炎、支气管炎、哮喘、慢阻肺、肺癌等。" +
                        "症状包括:咳嗽、咳痰、气促、胸痛、咯血等。可进行肺功能检查、支气管镜检查等。",
                        "department", "内科"
                ),
                createDocument(
                        "消化内科:诊治消化系统疾病。常见疾病:胃炎、消化性溃疡、肝炎、胆囊炎、胰腺炎、" +
                        "炎症性肠病等。症状包括:腹痛、腹胀、恶心、呕吐、腹泻、便秘、黄疸等。" +
                        "可进行胃镜、肠镜、超声等检查。",
                        "department", "内科"
                ),
                createDocument(
                        "神经内科:诊治神经系统疾病。常见疾病:脑卒中、癫痫、帕金森病、头痛、眩晕、周围神经病等。" +
                        "症状包括:头痛、头晕、肢体麻木无力、抽搐、意识障碍等。可进行头颅CT/MRI、脑电图等检查。",
                        "department", "内科"
                )
        );
    }

    /**
     * 创建文档辅助方法
     */
    private Document createDocument(String content, String contentType, String category) {
        Map<String, Object> metadata = new HashMap<>();
        metadata.put("content_type", contentType);
        metadata.put("category", category);
        metadata.put("source", "system_init");
        metadata.put("loaded_at", System.currentTimeMillis());

        return new Document(content, metadata);
    }
}

7. 智能问诊引擎

7.1 问诊服务核心实现

package com.medical.ai.service;

import lombok.Builder;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

@Slf4j
@Service
public class MedicalConsultationService {

    private final ChatClient chatClient;
    private final VectorStore vectorStore;
    private final ConversationService conversationService;

    @Value("${medical.assistant.knowledge.similarity-threshold:0.75}")
    private double similarityThreshold;

    @Value("${medical.assistant.knowledge.max-results:5}")
    private int maxResults;

    private static final String SYSTEM_PROMPT = """
            你是一位专业的医疗咨询助手,具备丰富的医学知识。你的职责是:

            1. 根据患者描述的症状,结合医学知识库,提供专业的初步判断和建议
            2. 推荐合适的就诊科室和医生
            3. 提供用药咨询和健康建议
            4. 解答患者的医疗相关问题

            重要准则:
            - 你不能进行确诊,只能提供参考意见
            - 对于严重症状,必须建议患者立即就医
            - 回答要通俗易懂,避免过多专业术语
            - 保持同理心,理解患者的焦虑
            - 对于用药咨询,强调需在医生指导下使用
            - 涉及诊断、处方等医疗行为时,提醒患者咨询专业医生

            参考的医学知识:
            {context}
            """;

    public MedicalConsultationService(
            ChatClient.Builder chatClientBuilder,
            VectorStore vectorStore,
            ConversationService conversationService) {
        this.chatClient = chatClientBuilder.build();
        this.vectorStore = vectorStore;
        this.conversationService = conversationService;
    }

    /**
     * 处理患者咨询
     */
    public ConsultationResponse consult(String sessionId, String question) {
        log.info("处理咨询 - 会话: {}, 问题: {}", sessionId, question);

        try {
            // 1. 保存用户消息
            conversationService.saveMessage(sessionId, "user", question);

            // 2. 检索相关医学知识
            List<Document> relevantDocs = retrieveRelevantKnowledge(question);

            if (relevantDocs.isEmpty()) {
                String fallbackAnswer = "抱歉,我在知识库中没有找到相关信息。为了您的健康," +
                        "建议您咨询专业医生或到医院就诊。";
                conversationService.saveMessage(sessionId, "assistant", fallbackAnswer);
                return ConsultationResponse.builder()
                        .answer(fallbackAnswer)
                        .confidence(0.0)
                        .requiresUrgentCare(false)
                        .build();
            }

            // 3. 构建上下文
            String context = buildContext(relevantDocs);

            // 4. 获取对话历史
            List<Message> conversationHistory = conversationService.getConversationHistory(sessionId);

            // 5. 生成回答
            List<Message> messages = new ArrayList<>();
            messages.add(new SystemMessage(SYSTEM_PROMPT.replace("{context}", context)));
            messages.addAll(conversationHistory);
            messages.add(new UserMessage(question));

            Prompt prompt = new Prompt(messages);
            String answer = chatClient.prompt(prompt).call().content();

            // 6. 分析回答并提取建议
            ConsultationAnalysis analysis = analyzeResponse(answer, question);

            // 7. 保存助手回答
            conversationService.saveMessage(sessionId, "assistant", answer);

            // 8. 构建响应
            return ConsultationResponse.builder()
                    .answer(answer)
                    .confidence(calculateConfidence(relevantDocs))
                    .recommendedDepartments(analysis.getDepartments())
                    .requiresUrgentCare(analysis.isUrgent())
                    .references(extractReferences(relevantDocs))
                    .build();

        } catch (Exception e) {
            log.error("处理咨询失败", e);
            throw new RuntimeException("咨询处理失败: " + e.getMessage(), e);
        }
    }

    /**
     * 检索相关医学知识
     */
    private List<Document> retrieveRelevantKnowledge(String query) {
        return vectorStore.similaritySearch(
                SearchRequest.query(query)
                        .withTopK(maxResults)
                        .withSimilarityThreshold(similarityThreshold)
        );
    }

    /**
     * 构建上下文
     */
    private String buildContext(List<Document> documents) {
        return documents.stream()
                .map(doc -> {
                    String type = doc.getMetadata().getOrDefault("content_type", "").toString();
                    String category = doc.getMetadata().getOrDefault("category", "").toString();
                    return String.format("[%s - %s]\n%s", type, category, doc.getContent());
                })
                .collect(Collectors.joining("\n\n"));
    }

    /**
     * 分析回答内容
     */
    private ConsultationAnalysis analyzeResponse(String answer, String question) {
        // 检测紧急关键词
        boolean isUrgent = answer.toLowerCase().contains("立即") ||
                          answer.toLowerCase().contains("紧急") ||
                          answer.toLowerCase().contains("急诊") ||
                          question.toLowerCase().contains("剧烈") ||
                          question.toLowerCase().contains("突然");

        // 提取推荐科室
        List<String> departments = extractDepartments(answer);

        return ConsultationAnalysis.builder()
                .isUrgent(isUrgent)
                .departments(departments)
                .build();
    }

    /**
     * 提取科室信息
     */
    private List<String> extractDepartments(String text) {
        List<String> departments = new ArrayList<>();
        String[] commonDepts = {
                "心血管内科", "呼吸内科", "消化内科", "神经内科",
                "内分泌科", "肾内科", "血液科", "风湿免疫科",
                "普外科", "骨科", "神经外科", "泌尿外科",
                "妇科", "产科", "儿科", "眼科", "耳鼻喉科",
                "皮肤科", "急诊科", "全科"
        };

        for (String dept : commonDepts) {
            if (text.contains(dept)) {
                departments.add(dept);
            }
        }

        return departments;
    }

    /**
     * 计算置信度
     */
    private double calculateConfidence(List<Document> documents) {
        if (documents.isEmpty()) {
            return 0.0;
        }
        // 简化计算:基于检索到的文档数量和相关性
        return Math.min(0.95, documents.size() * 0.15 + 0.5);
    }

    /**
     * 提取参考资料
     */
    private List<String> extractReferences(List<Document> documents) {
        return documents.stream()
                .limit(3)
                .map(doc -> {
                    String type = doc.getMetadata().getOrDefault("content_type", "").toString();
                    String category = doc.getMetadata().getOrDefault("category", "").toString();
                    return String.format("%s - %s", type, category);
                })
                .collect(Collectors.toList());
    }

    /**
     * 咨询响应
     */
    @Data
    @Builder
    public static class ConsultationResponse {
        private String answer;
        private Double confidence;
        private List<String> recommendedDepartments;
        private Boolean requiresUrgentCare;
        private List<String> references;
    }

    /**
     * 咨询分析结果
     */
    @Data
    @Builder
    private static class ConsultationAnalysis {
        private boolean isUrgent;
        private List<String> departments;
    }
}

7.2 症状识别服务

package com.medical.ai.service;

import lombok.Builder;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

@Slf4j
@Service
public class SymptomRecognitionService {

    private final ChatClient chatClient;

    private static final String SYMPTOM_EXTRACTION_PROMPT = """
            从以下患者描述中提取症状信息,以JSON格式返回:

            患者描述:{description}

            请提取:
            1. 主要症状(main_symptoms)
            2. 伴随症状(accompanying_symptoms)
            3. 持续时间(duration)
            4. 严重程度(severity: mild/moderate/severe)
            5. 诱发因素(triggers)

            返回格式:
            {
              "main_symptoms": ["症状1", "症状2"],
              "accompanying_symptoms": ["伴随症状1"],
              "duration": "持续时间描述",
              "severity": "严重程度",
              "triggers": ["诱发因素"]
            }
            """;

    public SymptomRecognitionService(ChatClient.Builder chatClientBuilder) {
        this.chatClient = chatClientBuilder.build();
    }

    /**
     * 识别症状
     */
    public SymptomInfo recognizeSymptoms(String description) {
        log.info("识别症状: {}", description);

        try {
            String prompt = SYMPTOM_EXTRACTION_PROMPT.replace("{description}", description);
            String response = chatClient.prompt()
                    .user(prompt)
                    .call()
                    .content();

            // 解析 JSON 响应(实际应用中使用 Jackson 等工具)
            return parseSymptomInfo(response);

        } catch (Exception e) {
            log.error("症状识别失败", e);
            return SymptomInfo.builder()
                    .mainSymptoms(List.of(description))
                    .severity("unknown")
                    .build();
        }
    }

    /**
     * 引导式问诊
     */
    public List<String> generateFollowUpQuestions(SymptomInfo symptomInfo) {
        List<String> questions = new ArrayList<>();

        if (symptomInfo.getDuration() == null || symptomInfo.getDuration().isEmpty()) {
            questions.add("这些症状持续多久了?");
        }

        if (symptomInfo.getSeverity() == null || symptomInfo.getSeverity().equals("unknown")) {
            questions.add("症状的严重程度如何?(轻微/中度/严重)");
        }

        if (symptomInfo.getTriggers().isEmpty()) {
            questions.add("有什么情况会加重或缓解这些症状吗?");
        }

        questions.add("之前有类似症状吗?");
        questions.add("目前是否在服用任何药物?");
        questions.add("有慢性病史或家族病史吗?");

        return questions.stream().limit(3).toList();
    }

    /**
     * 解析症状信息(简化版)
     */
    private SymptomInfo parseSymptomInfo(String jsonResponse) {
        // 实际应用中应使用 Jackson 等工具解析
        return SymptomInfo.builder()
                .mainSymptoms(new ArrayList<>())
                .accompanyingSymptoms(new ArrayList<>())
                .duration("")
                .severity("moderate")
                .triggers(new ArrayList<>())
                .build();
    }

    /**
     * 症状信息
     */
    @Data
    @Builder
    public static class SymptomInfo {
        private List<String> mainSymptoms;
        private List<String> accompanyingSymptoms;
        private String duration;
        private String severity;
        private List<String> triggers;
    }
}

8. 多轮对话管理

package com.medical.ai.service;

import com.medical.ai.entity.ConversationHistory;
import com.medical.ai.entity.PatientSession;
import com.medical.ai.repository.ConversationHistoryRepository;
import com.medical.ai.repository.PatientSessionRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;

@Slf4j
@Service
public class ConversationService {

    private final PatientSessionRepository sessionRepository;
    private final ConversationHistoryRepository historyRepository;

    @Value("${medical.assistant.session.max-history-messages:20}")
    private int maxHistoryMessages;

    @Value("${medical.assistant.session.timeout-minutes:30}")
    private int sessionTimeoutMinutes;

    public ConversationService(
            PatientSessionRepository sessionRepository,
            ConversationHistoryRepository historyRepository) {
        this.sessionRepository = sessionRepository;
        this.historyRepository = historyRepository;
    }

    /**
     * 创建新会话
     */
    @Transactional
    public String createSession(String patientId, String patientName) {
        String sessionId = UUID.randomUUID().toString();

        PatientSession session = PatientSession.builder()
                .sessionId(sessionId)
                .patientId(patientId)
                .patientName(patientName)
                .build();

        sessionRepository.save(session);

        log.info("创建新会话: {}, 患者: {}", sessionId, patientName);
        return sessionId;
    }

    /**
     * 保存消息
     */
    @Transactional
    public void saveMessage(String sessionId, String role, String content) {
        ConversationHistory history = ConversationHistory.builder()
                .sessionId(sessionId)
                .role(role)
                .content(content)
                .build();

        historyRepository.save(history);

        // 更新会话最后活动时间
        sessionRepository.findBySessionId(sessionId).ifPresent(session -> {
            session.setLastActive(LocalDateTime.now());
            sessionRepository.save(session);
        });

        log.debug("保存消息 - 会话: {}, 角色: {}", sessionId, role);
    }

    /**
     * 获取对话历史
     */
    @Cacheable(value = "conversationHistory", key = "#sessionId")
    public List<Message> getConversationHistory(String sessionId) {
        List<ConversationHistory> histories =
                historyRepository.findRecentBySessionId(sessionId, maxHistoryMessages);

        return histories.stream()
                .map(history -> {
                    if ("user".equals(history.getRole())) {
                        return new UserMessage(history.getContent());
                    } else {
                        return new AssistantMessage(history.getContent());
                    }
                })
                .collect(Collectors.toList());
    }

    /**
     * 结束会话
     */
    @Transactional
    public void endSession(String sessionId) {
        sessionRepository.findBySessionId(sessionId).ifPresent(session -> {
            session.setStatus("completed");
            sessionRepository.save(session);
            log.info("会话结束: {}", sessionId);
        });
    }

    /**
     * 清理过期会话
     */
    @Transactional
    public void cleanupExpiredSessions() {
        LocalDateTime cutoffTime = LocalDateTime.now().minusMinutes(sessionTimeoutMinutes);
        List<PatientSession> expiredSessions = sessionRepository.findExpiredSessions(cutoffTime);

        expiredSessions.forEach(session -> {
            session.setStatus("expired");
            sessionRepository.save(session);
        });

        log.info("清理了 {} 个过期会话", expiredSessions.size());
    }

    /**
     * 获取会话信息
     */
    public PatientSession getSession(String sessionId) {
        return sessionRepository.findBySessionId(sessionId)
                .orElseThrow(() -> new RuntimeException("会话不存在: " + sessionId));
    }
}

9. REST API 设计

9.1 咨询控制器

package com.medical.ai.controller;

import com.medical.ai.service.MedicalConsultationService;
import com.medical.ai.service.ConversationService;
import com.medical.ai.service.SymptomRecognitionService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;

@Slf4j
@RestController
@RequestMapping("/consultation")
public class ConsultationController {

    private final MedicalConsultationService consultationService;
    private final ConversationService conversationService;
    private final SymptomRecognitionService symptomService;

    public ConsultationController(
            MedicalConsultationService consultationService,
            ConversationService conversationService,
            SymptomRecognitionService symptomService) {
        this.consultationService = consultationService;
        this.conversationService = conversationService;
        this.symptomService = symptomService;
    }

    /**
     * 开始咨询会话
     */
    @PostMapping("/session/start")
    public ResponseEntity<ApiResponse<SessionResponse>> startSession(
            @RequestBody @Valid StartSessionRequest request) {
        log.info("开始咨询会话 - 患者: {}", request.getPatientName());

        String sessionId = conversationService.createSession(
                request.getPatientId(),
                request.getPatientName()
        );

        return ResponseEntity.ok(ApiResponse.success(
                SessionResponse.builder()
                        .sessionId(sessionId)
                        .welcomeMessage("您好!我是智能医疗助手,请描述您的症状或健康问题,我会尽力帮助您。")
                        .build()
        ));
    }

    /**
     * 发送咨询问题
     */
    @PostMapping("/ask")
    public ResponseEntity<ApiResponse<MedicalConsultationService.ConsultationResponse>> ask(
            @RequestBody @Valid ConsultationRequest request) {
        log.info("收到咨询 - 会话: {}, 问题: {}", request.getSessionId(), request.getQuestion());

        try {
            MedicalConsultationService.ConsultationResponse response =
                    consultationService.consult(request.getSessionId(), request.getQuestion());

            return ResponseEntity.ok(ApiResponse.success(response));

        } catch (Exception e) {
            log.error("咨询处理失败", e);
            return ResponseEntity.ok(ApiResponse.error("处理失败: " + e.getMessage()));
        }
    }

    /**
     * 症状识别
     */
    @PostMapping("/symptom/recognize")
    public ResponseEntity<ApiResponse<SymptomResponse>> recognizeSymptom(
            @RequestBody @Valid SymptomRequest request) {
        log.info("症状识别 - 描述: {}", request.getDescription());

        SymptomRecognitionService.SymptomInfo symptomInfo =
                symptomService.recognizeSymptoms(request.getDescription());

        var followUpQuestions = symptomService.generateFollowUpQuestions(symptomInfo);

        return ResponseEntity.ok(ApiResponse.success(
                SymptomResponse.builder()
                        .symptomInfo(symptomInfo)
                        .followUpQuestions(followUpQuestions)
                        .build()
        ));
    }

    /**
     * 结束会话
     */
    @PostMapping("/session/end")
    public ResponseEntity<ApiResponse<String>> endSession(
            @RequestBody @Valid EndSessionRequest request) {
        log.info("结束会话: {}", request.getSessionId());

        conversationService.endSession(request.getSessionId());

        return ResponseEntity.ok(ApiResponse.success("会话已结束,感谢使用!"));
    }

    // ===== 请求/响应 DTO =====

    @Data
    public static class StartSessionRequest {
        private String patientId;
        @NotBlank(message = "患者姓名不能为空")
        private String patientName;
    }

    @Data
    @lombok.Builder
    public static class SessionResponse {
        private String sessionId;
        private String welcomeMessage;
    }

    @Data
    public static class ConsultationRequest {
        @NotBlank(message = "会话ID不能为空")
        private String sessionId;
        @NotBlank(message = "问题不能为空")
        private String question;
    }

    @Data
    public static class SymptomRequest {
        @NotBlank(message = "症状描述不能为空")
        private String description;
    }

    @Data
    @lombok.Builder
    public static class SymptomResponse {
        private SymptomRecognitionService.SymptomInfo symptomInfo;
        private java.util.List<String> followUpQuestions;
    }

    @Data
    public static class EndSessionRequest {
        @NotBlank(message = "会话ID不能为空")
        private String sessionId;
    }

    @Data
    @lombok.Builder
    public static class ApiResponse<T> {
        private Boolean success;
        private String message;
        private T data;

        public static <T> ApiResponse<T> success(T data) {
            return ApiResponse.<T>builder()
                    .success(true)
                    .data(data)
                    .build();
        }

        public static <T> ApiResponse<T> error(String message) {
            return ApiResponse.<T>builder()
                    .success(false)
                    .message(message)
                    .build();
        }
    }
}

9.2 知识库管理 API

package com.medical.ai.controller;

import com.medical.ai.service.MedicalKnowledgeService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

@Slf4j
@RestController
@RequestMapping("/knowledge")
public class KnowledgeController {

    private final MedicalKnowledgeService knowledgeService;

    public KnowledgeController(MedicalKnowledgeService knowledgeService) {
        this.knowledgeService = knowledgeService;
    }

    /**
     * 上传医学文档
     */
    @PostMapping("/upload")
    public ResponseEntity<String> uploadDocument(
            @RequestParam("file") MultipartFile file,
            @RequestParam("contentType") String contentType,
            @RequestParam("category") String category) {

        log.info("上传医学文档: {}, 类型: {}, 分类: {}",
                file.getOriginalFilename(), contentType, category);

        try {
            ByteArrayResource resource = new ByteArrayResource(file.getBytes()) {
                @Override
                public String getFilename() {
                    return file.getOriginalFilename();
                }
            };

            String result = knowledgeService.loadMedicalDocument(resource, contentType, category);
            return ResponseEntity.ok(result);

        } catch (Exception e) {
            log.error("文档上传失败", e);
            return ResponseEntity.internalServerError().body("上传失败: " + e.getMessage());
        }
    }

    /**
     * 初始化知识库
     */
    @PostMapping("/initialize")
    public ResponseEntity<String> initializeKnowledge() {
        log.info("初始化医学知识库");

        try {
            knowledgeService.initializeBasicKnowledge();
            return ResponseEntity.ok("知识库初始化成功");

        } catch (Exception e) {
            log.error("知识库初始化失败", e);
            return ResponseEntity.internalServerError().body("初始化失败: " + e.getMessage());
        }
    }
}

10. 生产环境部署

10.1 Docker Compose 完整配置

version: '3.8'

services:
  # PostgreSQL + pgvector
  postgres:
    image: pgvector/pgvector:pg16
    container_name: medical-postgres
    environment:
      POSTGRES_USER: medical_admin
      POSTGRES_PASSWORD: Medical@2024
      POSTGRES_DB: medical_ai
    ports:
      - "5432:5432"
    volumes:
      - postgres-data:/var/lib/postgresql/data
      - ./init-scripts:/docker-entrypoint-initdb.d
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U medical_admin"]
      interval: 10s
      timeout: 5s
      retries: 5

  # Redis
  redis:
    image: redis:7-alpine
    container_name: medical-redis
    command: redis-server --requirepass Medical@Redis2024
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data
    restart: unless-stopped

  # Spring Boot 应用
  app:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: medical-ai-app
    environment:
      SPRING_PROFILES_ACTIVE: prod
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/medical_ai
      SPRING_DATASOURCE_USERNAME: medical_admin
      SPRING_DATASOURCE_PASSWORD: Medical@2024
      SPRING_DATA_REDIS_HOST: redis
      SPRING_DATA_REDIS_PASSWORD: Medical@Redis2024
      OPENAI_API_KEY: ${OPENAI_API_KEY}
    ports:
      - "8080:8080"
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_started
    restart: unless-stopped

volumes:
  postgres-data:
  redis-data:

10.2 Dockerfile

FROM openjdk:17-jdk-slim

WORKDIR /app

COPY target/ai-consultation-assistant-1.0.0.jar app.jar

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "-Xmx2g", "-Xms512m", "app.jar"]

11. 安全与合规

11.1 数据脱敏

package com.medical.ai.util;

import org.springframework.stereotype.Component;

import java.util.regex.Pattern;

@Component
public class DataMaskingUtil {

    private static final Pattern PHONE_PATTERN = Pattern.compile("(\\d{3})\\d{4}(\\d{4})");
    private static final Pattern ID_CARD_PATTERN = Pattern.compile("(\\d{6})\\d{8}(\\d{4})");

    /**
     * 手机号脱敏
     */
    public String maskPhone(String phone) {
        if (phone == null || phone.length() != 11) {
            return phone;
        }
        return PHONE_PATTERN.matcher(phone).replaceAll("$1****$2");
    }

    /**
     * 身份证号脱敏
     */
    public String maskIdCard(String idCard) {
        if (idCard == null || idCard.length() != 18) {
            return idCard;
        }
        return ID_CARD_PATTERN.matcher(idCard).replaceAll("$1********$2");
    }

    /**
     * 姓名脱敏
     */
    public String maskName(String name) {
        if (name == null || name.length() < 2) {
            return name;
        }
        return name.charAt(0) + "*".repeat(name.length() - 1);
    }
}

12. 总结与展望

12.1 项目总结

┌────────────────────────────────────────────────────────┐
│        智能医院问诊客服助手核心价值                      │
├────────────────────────────────────────────────────────┤
│                                                        │
│  ✅ 技术创新                                            │
│     • Spring AI + PostgreSQL pgvector 深度集成        │
│     • RAG 技术在医疗场景的应用                          │
│                                                        │
│  ✅ 业务价值                                            │
│     • 提升患者就医体验                                  │
│     • 降低医院运营成本                                  │
│     • 24/7 智能服务                                    │
│                                                        │
│  ✅ 生产就绪                                            │
│     • 完整的安全合规设计                                │
│     • 高可用架构                                        │
│     • 性能优化                                          │
│                                                        │
│  ✅ 可扩展性                                            │
│     • 模块化设计                                        │
│     • 易于集成其他系统                                  │
│     • 支持持续优化                                      │
│                                                        │
└────────────────────────────────────────────────────────┘

12.2 未来展望

  • 🎯 多模态支持(图像识别、语音交互)
  • 📊 患者画像与智能推荐
  • 🔬 集成检验报告解读
  • 🌐 多语言支持
  • 🤖 更智能的 Agent 能力

Logo

火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。

更多推荐