Jelajahi Sumber

导诊台常见问题回答

wangkangyjy 1 bulan lalu
induk
melakukan
a73b7d9a10

+ 392 - 0
medical-card-demo/FAQ_TEST_CASES.md

@@ -0,0 +1,392 @@
+# 甘肃省中医院智能导诊 FAQ 测试用例
+
+本文档包含所有FAQ问题的测试用例,用于医院演示时验证每个问题都能正确命中并返回答案。
+
+---
+
+## FAQ 001: 复诊挂号
+
+**标准问题**: 复诊需要重新挂号吗?复诊要带什么资料?
+
+**标准答案**: 我院现实行"一号管三天"惠民政策,首诊结束后三日内复诊,可携带身份证等有效证件到诊区导医处免费挂复诊号。
+
+### 一定能命中的测试Case
+
+| 序号 | 测试输入 | 说明 |
+|------|----------|------|
+| 1 | 复诊需要重新挂号吗 | 原文核心问法 |
+| 2 | 复诊要带什么资料 | 原文核心问法 |
+| 3 | 复诊需要带什么 | 变体问法 |
+| 4 | 复查需要挂号吗 | 同义词替换 |
+
+### 演示指令
+```bash
+curl -s -X POST http://localhost:8080/api/v1/chat/messages \
+  -H "Content-Type: application/json" \
+  -d '{"query":"复诊需要重新挂号吗","conversationId":"demo-001"}' \
+  --no-buffer
+```
+
+---
+
+## FAQ 002: 网上预约取号
+
+**标准问题**: 网上预约了号,去哪里取号?取号需要带什么证件?
+
+**标准答案**: 网上预约成功后,在就诊当天携带身份证等有效证件提前半小时在楼层自助机处签到取号。
+
+### 一定能命中的测试Case
+
+| 序号 | 测试输入 | 说明 |
+|------|----------|------|
+| 1 | 网上预约了号去哪里取号 | 核心关键词组合 |
+| 2 | 取号需要带什么证件 | 核心关键词组合 |
+| 3 | 网上挂号后怎么取号 | 变体问法 |
+| 4 | 预约了去哪里签到 | 同义词替换 |
+
+### 演示指令
+```bash
+curl -s -X POST http://localhost:8080/api/v1/chat/messages \
+  -H "Content-Type: application/json" \
+  -d '{"query":"网上预约了号去哪里取号","conversationId":"demo-002"}' \
+  --no-buffer
+```
+
+---
+
+## FAQ 003: 缴费方式
+
+**标准问题**: 缴费可以用微信 / 支付宝吗?除了收费窗口还有哪里能缴费?
+
+**标准答案**: 可以用微信支付宝呀,还可以去自助机进行缴费。
+
+### 一定能命中的测试Case
+
+| 序号 | 测试输入 | 说明 |
+|------|----------|------|
+| 1 | 缴费可以用微信支付吗 | 核心关键词组合 |
+| 2 | 可以用支付宝缴费吗 | 核心关键词组合 |
+| 3 | 除了窗口还能去哪缴费 | 核心关键词组合 |
+| 4 | 自助机能缴费吗 | 变体问法 |
+
+### 演示指令
+```bash
+curl -s -X POST http://localhost:8080/api/v1/chat/messages \
+  -H "Content-Type: application/json" \
+  -d '{"query":"缴费可以用微信支付吗","conversationId":"demo-003"}' \
+  --no-buffer
+```
+
+---
+
+## FAQ 004: 检查预约流程
+
+**标准问题**: 医生开了检查单,我应该先缴费还是先去检查科室预约?
+
+**标准答案**: 当然是先缴费啦,缴费后去检查科室签到。
+
+### 一定能命中的测试Case
+
+| 序号 | 测试输入 | 说明 |
+|------|----------|------|
+| 1 | 医生开了检查单先缴费还是预约 | 核心关键词组合 |
+| 2 | 检查单要先缴费吗 | 变体问法 |
+| 3 | 做检查要先预约吗 | 变体问法 |
+| 4 | 检查是先缴费还是先签到 | 同义词替换 |
+
+### 演示指令
+```bash
+curl -s -X POST http://localhost:8080/api/v1/chat/messages \
+  -H "Content-Type: application/json" \
+  -d '{"query":"医生开了检查单先缴费还是预约","conversationId":"demo-004"}' \
+  --no-buffer
+```
+
+---
+
+## FAQ 005: 异地医保报销
+
+**标准问题**: 异地医保能在这里直接报销吗?需要走什么流程?
+
+**标准答案**: 先通过线上或者在当地的医保部门备案,备案成功后在我院缴费时系统自动进行报销。
+
+### 一定能命中的测试Case
+
+| 序号 | 测试输入 | 说明 |
+|------|----------|------|
+| 1 | 异地医保能直接报销吗 | 核心关键词组合 |
+| 2 | 外地医保怎么报销 | 同义词替换 |
+| 3 | 异地医保需要什么流程 | 变体问法 |
+| 4 | 跨省医保能报吗 | 变体问法 |
+
+### 演示指令
+```bash
+curl -s -X POST http://localhost:8080/api/v1/chat/messages \
+  -H "Content-Type: application/json" \
+  -d '{"query":"异地医保能直接报销吗","conversationId":"demo-005"}' \
+  --no-buffer
+```
+
+---
+
+## FAQ 006: 轮椅租借
+
+**标准问题**: 轮椅怎么租借?需要押金吗?
+
+**标准答案**: 在一楼内外科诊区门前有轮椅租借处。扫码根据提示,支付押金后使用。
+
+### 一定能命中的测试Case
+
+| 序号 | 测试输入 | 说明 |
+|------|----------|------|
+| 1 | 轮椅怎么租借 | 核心关键词组合 |
+| 2 | 轮椅租借要押金吗 | 核心关键词组合 |
+| 3 | 哪里可以租轮椅 | 变体问法 |
+| 4 | 轮椅在哪里借 | 变体问法 |
+
+### 演示指令
+```bash
+curl -s -X POST http://localhost:8080/api/v1/chat/messages \
+  -H "Content-Type: application/json" \
+  -d '{"query":"轮椅怎么租借","conversationId":"demo-006"}' \
+  --no-buffer
+```
+
+---
+
+## FAQ 007: 母婴室位置
+
+**标准问题**: 医院有母婴室吗?在哪里?
+
+**标准答案**: 母婴室在1号楼四楼A417房间。
+
+### 一定能命中的测试Case
+
+| 序号 | 测试输入 | 说明 |
+|------|----------|------|
+| 1 | 医院有母婴室吗 | 核心关键词组合 |
+| 2 | 母婴室在哪里 | 核心关键词组合 |
+| 3 | 哪里有哺乳室 | 同义词替换 |
+| 4 | 母婴室在几楼 | 变体问法 |
+
+### 演示指令
+```bash
+curl -s -X POST http://localhost:8080/api/v1/chat/messages \
+  -H "Content-Type: application/json" \
+  -d '{"query":"医院有母婴室吗","conversationId":"demo-007"}' \
+  --no-buffer
+```
+
+---
+
+## FAQ 008: 检查报告时间
+
+**标准问题**: 我做的检查报告什么时候能出来?
+
+**标准答案**: 根据您做的检查项目不同,报告生成时间不同,具体需要咨询医生,或随时从手机微信"甘肃省中医院互联网医院"公众号查看是否出结果。
+
+### 一定能命中的测试Case
+
+| 序号 | 测试输入 | 说明 |
+|------|----------|------|
+| 1 | 检查报告什么时候出来 | 核心关键词组合 |
+| 2 | 报告多久能出来 | 变体问法 |
+| 3 | 检查结果什么时候出 | 同义词替换 |
+| 4 | 报告什么时候能取 | 变体问法 |
+
+### 演示指令
+```bash
+curl -s -X POST http://localhost:8080/api/v1/chat/messages \
+  -H "Content-Type: application/json" \
+  -d '{"query":"检查报告什么时候出来","conversationId":"demo-008"}' \
+  --no-buffer
+```
+
+---
+
+## FAQ 009: 检验报告查询
+
+**标准问题**: 检验报告在哪儿去,可以在手机上查吗?
+
+**标准答案**: 检验报告纸质版可在自助机进行打印,也可在手机微信"甘肃省中医院互联网医院"公众号查看。
+
+### 一定能命中的测试Case
+
+| 序号 | 测试输入 | 说明 |
+|------|----------|------|
+| 1 | 检验报告在哪儿取 | 核心关键词组合 |
+| 2 | 报告可以在手机上查吗 | 核心关键词组合 |
+| 3 | 化验单去哪里取 | 同义词替换 |
+| 4 | 检验报告怎么打印 | 变体问法 |
+
+### 演示指令
+```bash
+curl -s -X POST http://localhost:8080/api/v1/chat/messages \
+  -H "Content-Type: application/json" \
+  -d '{"query":"检验报告在哪儿取","conversationId":"demo-009"}' \
+  --no-buffer
+```
+
+---
+
+## FAQ 010: 医保报销范围
+
+**标准问题**: 医保能不能报销中药费、针灸费、推拿费?
+
+**标准答案**: 不同项目的报销情况不同,具体需要咨询医保处。
+
+### 一定能命中的测试Case
+
+| 序号 | 测试输入 | 说明 |
+|------|----------|------|
+| 1 | 医保能报销中药费吗 | 核心关键词组合 |
+| 2 | 针灸费用能报销吗 | 核心关键词组合 |
+| 3 | 推拿可以走医保吗 | 变体问法 |
+| 4 | 中药费医保报吗 | 变体问法 |
+
+### 演示指令
+```bash
+curl -s -X POST http://localhost:8080/api/v1/chat/messages \
+  -H "Content-Type: application/json" \
+  -d '{"query":"医保能报销中药费吗","conversationId":"demo-010"}' \
+  --no-buffer
+```
+
+---
+
+## FAQ 011: 便民门诊
+
+**标准问题**: 便民门诊在哪里,怎么开药?
+
+**标准答案**: 在我院2号楼1楼,携带身份证的等有效证件排队挂号,开药。
+
+### 一定能命中的测试Case
+
+| 序号 | 测试输入 | 说明 |
+|------|----------|------|
+| 1 | 便民门诊在哪里 | 核心关键词组合 |
+| 2 | 便民门诊怎么开药 | 核心关键词组合 |
+| 3 | 便民门诊在几号楼 | 变体问法 |
+| 4 | 便民门诊怎么挂号 | 变体问法 |
+
+### 演示指令
+```bash
+curl -s -X POST http://localhost:8080/api/v1/chat/messages \
+  -H "Content-Type: application/json" \
+  -d '{"query":"便民门诊在哪里","conversationId":"demo-011"}' \
+  --no-buffer
+```
+
+---
+
+## FAQ 012: 检查地点
+
+**标准问题**: 医生开的检查在哪里做?
+
+**标准答案**: 根据缴费单上的地址指引,去相应地点。
+
+### 一定能命中的测试Case
+
+| 序号 | 测试输入 | 说明 |
+|------|----------|------|
+| 1 | 医生开的检查在哪里做 | 原文问法 |
+| 2 | 检查要去哪里做 | 变体问法 |
+| 3 | 检查单上的检查在哪做 | 变体问法 |
+| 4 | 做检查要去哪里 | 变体问法 |
+
+### 演示指令
+```bash
+curl -s -X POST http://localhost:8080/api/v1/chat/messages \
+  -H "Content-Type: application/json" \
+  -d '{"query":"医生开的检查在哪里做","conversationId":"demo-012"}' \
+  --no-buffer
+```
+
+---
+
+## FAQ 013: 病历复印
+
+**标准问题**: 复印/打印、邮寄病历在什么地方?
+
+**标准答案**: 在我院1号楼1楼,扶梯下方。
+
+### 一定能命中的测试Case
+
+| 序号 | 测试输入 | 说明 |
+|------|----------|------|
+| 1 | 复印病历在什么地方 | 核心关键词组合 |
+| 2 | 打印病历去哪里 | 变体问法 |
+| 3 | 病历邮寄在哪里办 | 变体问法 |
+| 4 | 病历复印在几号楼 | 变体问法 |
+
+### 演示指令
+```bash
+curl -s -X POST http://localhost:8080/api/v1/chat/messages \
+  -H "Content-Type: application/json" \
+  -d '{"query":"复印病历在什么地方","conversationId":"demo-013"}' \
+  --no-buffer
+```
+
+---
+
+## 批量测试脚本
+
+```bash
+#!/bin/bash
+# FAQ批量测试脚本
+
+echo "=== FAQ功能批量测试 ==="
+
+questions=(
+  "复诊需要重新挂号吗"
+  "网上预约了号去哪里取号"
+  "缴费可以用微信支付吗"
+  "医生开了检查单先缴费还是预约"
+  "异地医保能直接报销吗"
+  "轮椅怎么租借"
+  "医院有母婴室吗"
+  "检查报告什么时候出来"
+  "检验报告在哪儿取"
+  "医保能报销中药费吗"
+  "便民门诊在哪里"
+  "医生开的检查在哪里做"
+  "复印病历在什么地方"
+)
+
+for i in "${!questions[@]}"; do
+  idx=$((i+1))
+  echo ""
+  echo "【FAQ $idx】${questions[$i]}"
+  curl -s -X POST http://localhost:8080/api/v1/chat/messages \
+    -H "Content-Type: application/json" \
+    -d "{\"query\":\"${questions[$i]}\",\"conversationId\":\"batch-test-$idx\"}" \
+    --no-buffer 2>&1 | grep -o '"content":"[^"]*"' | head -1
+done
+```
+
+---
+
+## 技术说明
+
+### 匹配逻辑
+
+1. **精确匹配**: 用户输入与标准问题或扩展问法完全一致
+2. **核心关键词匹配**: 根据每个FAQ定义的核心关键词组合进行匹配
+3. **关键词覆盖率匹配**: 关键词匹配率超过60%即命中
+4. **相似度匹配**: 基于Jaccard系数的字符重叠率匹配(阈值0.6)
+
+### 优先级规则
+
+- FAQ匹配优先级**低于**挂号、建档等核心业务意图
+- 如果用户输入同时匹配FAQ和业务意图,优先执行业务流程
+- FAQ仅用于回答医院导诊类常见问题
+
+### 新增/修改FAQ
+
+如需新增或修改FAQ,请编辑文件:`docs/省中导诊台.txt`
+
+格式要求:
+```
+问题:xxx?
+回答:xxx。
+```

+ 43 - 0
medical-card-demo/backend/src/main/java/com/medical/demo/dto/FAQItem.java

@@ -0,0 +1,43 @@
+package com.medical.demo.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * FAQ条目
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class FAQItem {
+    
+    /**
+     * FAQ唯一标识
+     */
+    private String id;
+    
+    /**
+     * 标准问题
+     */
+    private String question;
+    
+    /**
+     * 答案
+     */
+    private String answer;
+    
+    /**
+     * 扩展问法(语义相似的变体)
+     */
+    private List<String> variants;
+    
+    /**
+     * 关键词(用于快速匹配)
+     */
+    private List<String> keywords;
+}

+ 32 - 0
medical-card-demo/backend/src/main/java/com/medical/demo/service/ChatService.java

@@ -95,6 +95,9 @@ public class ChatService {
                 case REGISTRATION:
                 case REGISTRATION:
                     processRegistration(request, emitter);
                     processRegistration(request, emitter);
                     return;
                     return;
+                case FAQ_INQUIRY:
+                    processFAQ(conversationId, result.faqAnswer, emitter, history, session, query);
+                    return;
                 default:
                 default:
                     processNaturalConversation(request, emitter, history, session, false);
                     processNaturalConversation(request, emitter, history, session, false);
                     return;
                     return;
@@ -210,6 +213,35 @@ public class ChatService {
         return q.length() <= 4 || q.matches("^(你好|您好|在吗|在么|hi|hello|哈喽|喂)$");
         return q.length() <= 4 || q.matches("^(你好|您好|在吗|在么|hi|hello|哈喽|喂)$");
     }
     }
     
     
+    /**
+     * 处理FAQ常见问题咨询
+     */
+    private void processFAQ(String conversationId, String faqAnswer, SseEmitter emitter,
+                           List<Map<String, String>> history, Map<String, Object> session,
+                           String query) throws IOException {
+        log.info("处理FAQ: conversationId={}", conversationId);
+        
+        // 发送FAQ答案
+        sendEvent(emitter, ChatMessageDTO.builder()
+            .type("text")
+            .content(faqAnswer)
+            .conversationId(conversationId)
+            .build());
+        
+        // 更新对话历史
+        List<Map<String, String>> newHistory = new ArrayList<>(history);
+        newHistory.add(Map.of("role", "user", "content", query));
+        newHistory.add(Map.of("role", "assistant", "content", faqAnswer));
+        session.put("conversationHistory", newHistory);
+        sessionData.put(conversationId, session);
+        
+        // 发送结束标记
+        sendEvent(emitter, ChatMessageDTO.builder()
+            .type("message_end")
+            .conversationId(conversationId)
+            .build());
+    }
+    
     /**
     /**
      * 处理建档流程
      * 处理建档流程
      */
      */

+ 448 - 0
medical-card-demo/backend/src/main/java/com/medical/demo/service/FAQService.java

@@ -0,0 +1,448 @@
+package com.medical.demo.service;
+
+import com.medical.demo.dto.FAQItem;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import jakarta.annotation.PostConstruct;
+import java.io.BufferedReader;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * FAQ服务 - 加载和匹配常见问题
+ */
+@Slf4j
+@Service
+public class FAQService {
+    
+    private List<FAQItem> faqItems = new ArrayList<>();
+    
+    /**
+     * 相似度阈值(0-1之间)
+     */
+    private static final double SIMILARITY_THRESHOLD = 0.6;
+    
+    @PostConstruct
+    public void init() {
+        loadFAQData();
+    }
+    
+    /**
+     * 从文件加载FAQ数据
+     */
+    private void loadFAQData() {
+        try {
+            // 从resources目录加载FAQ文件
+            InputStream is = getClass().getClassLoader().getResourceAsStream("faq/hospital_faq.txt");
+            if (is == null) {
+                // 尝试从文件系统加载
+                java.io.File file = new java.io.File("docs/省中导诊台.txt");
+                if (file.exists()) {
+                    is = new java.io.FileInputStream(file);
+                }
+            }
+            
+            if (is == null) {
+                log.warn("未找到FAQ文件,使用默认数据");
+                loadDefaultFAQData();
+                return;
+            }
+            
+            parseFAQFile(is);
+            log.info("成功加载 {} 条FAQ数据", faqItems.size());
+        } catch (Exception e) {
+            log.error("加载FAQ数据失败", e);
+            loadDefaultFAQData();
+        }
+    }
+    
+    /**
+     * 解析FAQ文件
+     * 格式:问题:xxx? 回答:xxx
+     */
+    private void parseFAQFile(InputStream is) throws Exception {
+        BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8));
+        StringBuilder content = new StringBuilder();
+        String line;
+        
+        while ((line = reader.readLine()) != null) {
+            content.append(line).append("\n");
+        }
+        
+        String text = content.toString();
+        String[] entries = text.split("(?=问题:)");
+        
+        int id = 1;
+        for (String entry : entries) {
+            entry = entry.trim();
+            if (entry.isEmpty()) continue;
+            
+            // 解析问题和答案
+            String question = null;
+            String answer = null;
+            
+            String[] lines = entry.split("\n");
+            for (String l : lines) {
+                l = l.trim();
+                if (l.startsWith("问题:")) {
+                    question = l.substring(3).trim();
+                } else if (l.startsWith("回答:")) {
+                    answer = l.substring(3).trim();
+                }
+            }
+            
+            if (question != null && answer != null) {
+                FAQItem item = FAQItem.builder()
+                    .id("faq_" + String.format("%03d", id++))
+                    .question(question)
+                    .answer(answer)
+                    .variants(generateVariants(question))
+                    .keywords(extractKeywords(question))
+                    .build();
+                faqItems.add(item);
+            }
+        }
+    }
+    
+    /**
+     * 生成扩展问法
+     */
+    private List<String> generateVariants(String question) {
+        List<String> variants = new ArrayList<>();
+        
+        // 去除问号后的变体
+        String withoutQuestion = question.replace("?", "").replace("?", "");
+        variants.add(withoutQuestion);
+        
+        // 常见问法变体
+        if (question.contains("吗") || question.contains("么") || question.contains("什么") || 
+            question.contains("哪里") || question.contains("怎么") || question.contains("哪些")) {
+            // 疑问句变体:我想知道xxx
+            String knowVariant = withoutQuestion.replace("需要", "").replace("应该", "");
+            variants.add("我想知道" + knowVariant);
+            variants.add("请问" + knowVariant);
+            variants.add("我想问一下" + knowVariant);
+        }
+        
+        return variants;
+    }
+    
+    /**
+     * 提取关键词
+     */
+    private List<String> extractKeywords(String question) {
+        List<String> keywords = new ArrayList<>();
+        String lower = question.toLowerCase();
+        
+        // 根据问题内容提取核心关键词
+        if (lower.contains("复诊")) {
+            keywords.add("复诊");
+            keywords.add("重新挂号");
+        }
+        if (lower.contains("取号")) {
+            keywords.add("取号");
+            keywords.add("签到");
+        }
+        if (lower.contains("缴费") || lower.contains("支付")) {
+            keywords.add("缴费");
+            keywords.add("支付");
+            keywords.add("微信");
+            keywords.add("支付宝");
+        }
+        if (lower.contains("检查") && lower.contains("预约")) {
+            keywords.add("检查");
+            keywords.add("预约");
+            keywords.add("缴费");
+        }
+        if (lower.contains("医保") && lower.contains("报销")) {
+            keywords.add("医保");
+            keywords.add("报销");
+            keywords.add("异地");
+        }
+        if (lower.contains("轮椅")) {
+            keywords.add("轮椅");
+            keywords.add("租借");
+        }
+        if (lower.contains("母婴室")) {
+            keywords.add("母婴室");
+        }
+        if (lower.contains("报告") && lower.contains("出来")) {
+            keywords.add("报告");
+            keywords.add("结果");
+            keywords.add("多久");
+        }
+        if (lower.contains("报告") && (lower.contains("哪儿") || lower.contains("哪里"))) {
+            keywords.add("报告");
+            keywords.add("取");
+            keywords.add("打印");
+        }
+        if (lower.contains("医保") && (lower.contains("中药") || lower.contains("针灸") || lower.contains("推拿"))) {
+            keywords.add("医保");
+            keywords.add("中药");
+            keywords.add("针灸");
+            keywords.add("推拿");
+        }
+        if (lower.contains("便民门诊")) {
+            keywords.add("便民门诊");
+            keywords.add("开药");
+        }
+        if (lower.contains("检查") && lower.contains("做")) {
+            keywords.add("检查");
+            keywords.add("哪里做");
+        }
+        if (lower.contains("病历") && (lower.contains("复印") || lower.contains("打印") || lower.contains("邮寄"))) {
+            keywords.add("病历");
+            keywords.add("复印");
+            keywords.add("打印");
+            keywords.add("邮寄");
+        }
+        
+        return keywords;
+    }
+    
+    /**
+     * 加载默认FAQ数据(当文件加载失败时使用)
+     */
+    private void loadDefaultFAQData() {
+        int id = 1;
+        faqItems.add(createFAQItem(id++, "复诊需要重新挂号吗?复诊要带什么资料?", 
+            "我院现实行\"一号管三天\"惠民政策,首诊结束后三日内复诊,可携带身份证等有效证件到诊区导医处免费挂复诊号。"));
+        faqItems.add(createFAQItem(id++, "网上预约了号,去哪里取号?取号需要带什么证件?", 
+            "网上预约成功后,在就诊当天携带身份证等有效证件提前半小时在楼层自助机处签到取号。"));
+        faqItems.add(createFAQItem(id++, "缴费可以用微信 / 支付宝吗?除了收费窗口还有哪里能缴费?", 
+            "可以用微信支付宝呀,还可以去自助机进行缴费。"));
+        faqItems.add(createFAQItem(id++, "医生开了检查单,我应该先缴费还是先去检查科室预约?", 
+            "当然是先缴费啦,缴费后去检查科室签到。"));
+        faqItems.add(createFAQItem(id++, "异地医保能在这里直接报销吗?需要走什么流程?", 
+            "先通过线上或者在当地的医保部门备案,备案成功后在我院缴费时系统自动进行报销。"));
+        faqItems.add(createFAQItem(id++, "轮椅怎么租借?需要押金吗?", 
+            "在一楼内外科诊区门前有轮椅租借处。扫码根据提示,支付押金后使用。"));
+        faqItems.add(createFAQItem(id++, "医院有母婴室吗?在哪里?", 
+            "母婴室在1号楼四楼A417房间。"));
+        faqItems.add(createFAQItem(id++, "我做的检查报告什么时候能出来?", 
+            "根据您做的检查项目不同,报告生成时间不同,具体需要咨询医生,或随时从手机微信\"甘肃省中医院互联网医院\"公众号查看是否出结果。"));
+        faqItems.add(createFAQItem(id++, "检验报告在哪儿去,可以在手机上查吗?", 
+            "检验报告纸质版可在自助机进行打印,也可在手机微信\"甘肃省中医院互联网医院\"公众号查看。"));
+        faqItems.add(createFAQItem(id++, "医保能不能报销中药费、针灸费、推拿费?", 
+            "不同项目的报销情况不同,具体需要咨询医保处。"));
+        faqItems.add(createFAQItem(id++, "便民门诊在哪里,怎么开药?", 
+            "在我院2号楼1楼,携带身份证的等有效证件排队挂号,开药。"));
+        faqItems.add(createFAQItem(id++, "医生开的检查在哪里做?", 
+            "根据缴费单上的地址指引,去相应地点。"));
+        faqItems.add(createFAQItem(id++, "复印/打印、邮寄病历在什么地方?", 
+            "在我院1号楼1楼,扶梯下方。"));
+    }
+    
+    private FAQItem createFAQItem(int id, String question, String answer) {
+        return FAQItem.builder()
+            .id("faq_" + String.format("%03d", id))
+            .question(question)
+            .answer(answer)
+            .variants(generateVariants(question))
+            .keywords(extractKeywords(question))
+            .build();
+    }
+    
+    /**
+     * 匹配FAQ
+     * @param query 用户输入
+     * @return 匹配的FAQ项,未匹配返回null
+     */
+    public FAQItem matchFAQ(String query) {
+        if (query == null || query.trim().isEmpty()) {
+            return null;
+        }
+        
+        String lowerQuery = query.toLowerCase().trim();
+        
+        // 1. 精确匹配(去除标点)
+        for (FAQItem item : faqItems) {
+            String standardQuestion = item.getQuestion().replace("?", "").replace("?", "").trim();
+            if (lowerQuery.equals(standardQuestion.toLowerCase())) {
+                log.info("FAQ精确匹配: {}", item.getId());
+                return item;
+            }
+            // 检查扩展问法
+            for (String variant : item.getVariants()) {
+                if (lowerQuery.equals(variant.toLowerCase())) {
+                    log.info("FAQ扩展问法匹配: {}", item.getId());
+                    return item;
+                }
+            }
+        }
+        
+        // 2. 核心关键词匹配(只要包含核心关键词组合就匹配)
+        for (FAQItem item : faqItems) {
+            if (matchByCoreKeywords(lowerQuery, item)) {
+                log.info("FAQ核心关键词匹配: {}", item.getId());
+                return item;
+            }
+        }
+        
+        // 3. 关键词匹配
+        for (FAQItem item : faqItems) {
+            int matchCount = 0;
+            for (String keyword : item.getKeywords()) {
+                if (lowerQuery.contains(keyword.toLowerCase())) {
+                    matchCount++;
+                }
+            }
+            // 关键词匹配率超过60%
+            if (!item.getKeywords().isEmpty() && 
+                (double) matchCount / item.getKeywords().size() >= SIMILARITY_THRESHOLD) {
+                log.info("FAQ关键词匹配: {}, 匹配率 {}/{}" , item.getId(), matchCount, item.getKeywords().size());
+                return item;
+            }
+        }
+        
+        // 4. 相似度匹配(基于字符重叠率)
+        FAQItem bestMatch = null;
+        double bestScore = 0;
+        
+        for (FAQItem item : faqItems) {
+            double score = calculateSimilarity(lowerQuery, item.getQuestion());
+            if (score > bestScore) {
+                bestScore = score;
+                bestMatch = item;
+            }
+            // 也检查扩展问法
+            for (String variant : item.getVariants()) {
+                double variantScore = calculateSimilarity(lowerQuery, variant);
+                if (variantScore > bestScore) {
+                    bestScore = variantScore;
+                    bestMatch = item;
+                }
+            }
+        }
+        
+        if (bestScore >= SIMILARITY_THRESHOLD) {
+            log.info("FAQ相似度匹配: {}, 得分 {}", bestMatch.getId(), String.format("%.2f", bestScore));
+            return bestMatch;
+        }
+        
+        return null;
+    }
+    
+    /**
+     * 通过核心关键词组合匹配
+     */
+    private boolean matchByCoreKeywords(String query, FAQItem item) {
+        String q = query.toLowerCase();
+        String question = item.getQuestion().toLowerCase();
+        
+        // FAQ 001: 复诊
+        if (question.contains("复诊") && question.contains("挂号")) {
+            return (q.contains("复诊") || q.contains("复查")) && 
+                   (q.contains("挂号") || q.contains("资料") || q.contains("带什么"));
+        }
+        
+        // FAQ 002: 取号
+        if (question.contains("取号") || question.contains("签到")) {
+            return (q.contains("取号") || q.contains("签到") || q.contains("拿号")) &&
+                   (q.contains("网上") || q.contains("预约") || q.contains("证件") || q.contains("身份证"));
+        }
+        
+        // FAQ 003: 缴费
+        if (question.contains("缴费") || question.contains("支付")) {
+            return (q.contains("缴费") || q.contains("支付") || q.contains("付款")) &&
+                   (q.contains("微信") || q.contains("支付宝") || q.contains("自助机"));
+        }
+        
+        // FAQ 004: 检查预约
+        if (question.contains("检查") && question.contains("缴费")) {
+            return q.contains("检查") && (q.contains("缴费") || q.contains("预约") || q.contains("先"));
+        }
+        
+        // FAQ 005: 异地医保
+        if (question.contains("异地") && question.contains("医保")) {
+            return (q.contains("异地") || q.contains("外地")) && q.contains("医保") && q.contains("报销");
+        }
+        
+        // FAQ 006: 轮椅
+        if (question.contains("轮椅")) {
+            return q.contains("轮椅") && (q.contains("租借") || q.contains("租") || q.contains("押金"));
+        }
+        
+        // FAQ 007: 母婴室
+        if (question.contains("母婴室")) {
+            return q.contains("母婴室") || (q.contains("哺乳") && q.contains("哪里"));
+        }
+        
+        // FAQ 008: 报告出来时间
+        if (question.contains("报告") && question.contains("出来")) {
+            return q.contains("报告") && (q.contains("多久") || q.contains("什么时候") || q.contains("出来") || q.contains("出结果"));
+        }
+        
+        // FAQ 009: 检验报告查询
+        if (question.contains("检验报告") && (question.contains("哪儿") || question.contains("手机"))) {
+            return (q.contains("检验报告") || q.contains("化验单") || q.contains("报告")) && 
+                   (q.contains("哪儿") || q.contains("哪里") || q.contains("手机") || q.contains("查") || q.contains("打印"));
+        }
+        
+        // FAQ 010: 医保报销中药
+        if (question.contains("医保") && (question.contains("中药") || question.contains("针灸"))) {
+            return q.contains("医保") && (q.contains("中药") || q.contains("针灸") || q.contains("推拿")) && q.contains("报销");
+        }
+        
+        // FAQ 011: 便民门诊
+        if (question.contains("便民门诊")) {
+            return q.contains("便民门诊") || (q.contains("开药") && q.contains("哪里"));
+        }
+        
+        // FAQ 012: 检查地点
+        if (question.contains("检查") && question.contains("做")) {
+            return q.contains("检查") && (q.contains("哪里做") || q.contains("在哪做") || q.contains("去哪做"));
+        }
+        
+        // FAQ 013: 病历复印
+        if (question.contains("病历") && (question.contains("复印") || question.contains("打印"))) {
+            return q.contains("病历") && (q.contains("复印") || q.contains("打印") || q.contains("邮寄"));
+        }
+        
+        return false;
+    }
+    
+    /**
+     * 计算两个字符串的相似度(基于字符集合的Jaccard系数)
+     */
+    private double calculateSimilarity(String s1, String s2) {
+        // 提取字符集合(去除标点、空格)
+        Set<Character> set1 = s1.chars()
+            .mapToObj(c -> (char) c)
+            .filter(c -> !Character.isWhitespace(c) && !isPunctuation(c))
+            .collect(Collectors.toSet());
+        
+        Set<Character> set2 = s2.chars()
+            .mapToObj(c -> (char) c)
+            .filter(c -> !Character.isWhitespace(c) && !isPunctuation(c))
+            .collect(Collectors.toSet());
+        
+        if (set1.isEmpty() || set2.isEmpty()) {
+            return 0;
+        }
+        
+        // 计算Jaccard相似度
+        Set<Character> intersection = new HashSet<>(set1);
+        intersection.retainAll(set2);
+        
+        Set<Character> union = new HashSet<>(set1);
+        union.addAll(set2);
+        
+        return (double) intersection.size() / union.size();
+    }
+    
+    private boolean isPunctuation(char c) {
+        return ",。?!;:\"\"''()【】「」『』、·.,?!;:'\"()[]{}".indexOf(c) >= 0;
+    }
+    
+    /**
+     * 获取所有FAQ
+     */
+    public List<FAQItem> getAllFAQItems() {
+        return new ArrayList<>(faqItems);
+    }
+}

+ 62 - 1
medical-card-demo/backend/src/main/java/com/medical/demo/service/IntentRecognitionService.java

@@ -6,6 +6,7 @@ import org.springframework.http.HttpEntity;
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.MediaType;
 import org.springframework.http.MediaType;
 import org.springframework.http.ResponseEntity;
 import org.springframework.http.ResponseEntity;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 import org.springframework.web.client.RestTemplate;
 import org.springframework.web.client.RestTemplate;
 
 
@@ -29,6 +30,9 @@ public class IntentRecognitionService {
     
     
     private final RestTemplate restTemplate = new RestTemplate();
     private final RestTemplate restTemplate = new RestTemplate();
     
     
+    @Autowired
+    private FAQService faqService;
+    
     /**
     /**
      * 用户意图类型
      * 用户意图类型
      */
      */
@@ -39,9 +43,31 @@ public class IntentRecognitionService {
         REPORT_ANALYSIS_LAB,       // 检验报告解读(血常规等)
         REPORT_ANALYSIS_LAB,       // 检验报告解读(血常规等)
         REPORT_ANALYSIS_INSPECTION,// 检查报告解读(超声等)
         REPORT_ANALYSIS_INSPECTION,// 检查报告解读(超声等)
         TONGUE_DIAGNOSIS,          // 舌诊
         TONGUE_DIAGNOSIS,          // 舌诊
+        FAQ_INQUIRY,               // FAQ常见问题咨询
         UNKNOWN                    // 未知意图
         UNKNOWN                    // 未知意图
     }
     }
     
     
+    /**
+     * FAQ匹配结果
+     */
+    public static class FAQMatchResult {
+        public final boolean matched;
+        public final String answer;
+        
+        public FAQMatchResult(boolean matched, String answer) {
+            this.matched = matched;
+            this.answer = answer;
+        }
+        
+        public static FAQMatchResult noMatch() {
+            return new FAQMatchResult(false, null);
+        }
+        
+        public static FAQMatchResult match(String answer) {
+            return new FAQMatchResult(true, answer);
+        }
+    }
+    
     /**
     /**
      * 识别用户意图
      * 识别用户意图
      */
      */
@@ -49,7 +75,7 @@ public class IntentRecognitionService {
         log.info("识别用户意图: {}", query);
         log.info("识别用户意图: {}", query);
         
         
         try {
         try {
-            // 先使用关键词匹配进行快速判断
+            // 先使用关键词匹配进行快速判断(包含FAQ匹配)
             IntentType keywordIntent = recognizeByKeywords(query);
             IntentType keywordIntent = recognizeByKeywords(query);
             if (keywordIntent != IntentType.UNKNOWN) {
             if (keywordIntent != IntentType.UNKNOWN) {
                 return keywordIntent;
                 return keywordIntent;
@@ -63,6 +89,41 @@ public class IntentRecognitionService {
         }
         }
     }
     }
     
     
+    /**
+     * 识别用户意图并检查是否匹配FAQ
+     * @return 如果匹配FAQ,返回FAQ_INQUIRY并可通过getLastFAQAnswer获取答案
+     */
+    public IntentType recognizeIntentWithFAQ(String query) {
+        // 先尝试匹配FAQ(优先级低于挂号等核心业务)
+        FAQMatchResult faqResult = matchFAQ(query);
+        if (faqResult.matched) {
+            lastFAQAnswer = faqResult.answer;
+            return IntentType.FAQ_INQUIRY;
+        }
+        lastFAQAnswer = null;
+        return recognizeIntent(query);
+    }
+    
+    private String lastFAQAnswer;
+    
+    /**
+     * 获取最后一次匹配的FAQ答案
+     */
+    public String getLastFAQAnswer() {
+        return lastFAQAnswer;
+    }
+    
+    /**
+     * 匹配FAQ
+     */
+    public FAQMatchResult matchFAQ(String query) {
+        com.medical.demo.dto.FAQItem matchedItem = faqService.matchFAQ(query);
+        if (matchedItem != null) {
+            return FAQMatchResult.match(matchedItem.getAnswer());
+        }
+        return FAQMatchResult.noMatch();
+    }
+    
     /**
     /**
      * 基于关键词的意图识别
      * 基于关键词的意图识别
      */
      */

+ 27 - 0
medical-card-demo/backend/src/main/java/com/medical/demo/service/KeywordExtractionService.java

@@ -1,6 +1,7 @@
 package com.medical.demo.service;
 package com.medical.demo.service;
 
 
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.http.HttpEntity;
 import org.springframework.http.HttpEntity;
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.HttpHeaders;
@@ -29,6 +30,9 @@ public class KeywordExtractionService {
     private String model;
     private String model;
 
 
     private final RestTemplate restTemplate = new RestTemplate();
     private final RestTemplate restTemplate = new RestTemplate();
+    
+    @Autowired
+    private FAQService faqService;
 
 
     /** 已知功能关键词 -> 意图类型 */
     /** 已知功能关键词 -> 意图类型 */
     private static final Map<String, IntentRecognitionService.IntentType> KEYWORD_TO_INTENT = new HashMap<>();
     private static final Map<String, IntentRecognitionService.IntentType> KEYWORD_TO_INTENT = new HashMap<>();
@@ -39,6 +43,7 @@ public class KeywordExtractionService {
         KEYWORD_TO_INTENT.put("检验报告解读", IntentRecognitionService.IntentType.REPORT_ANALYSIS_LAB);
         KEYWORD_TO_INTENT.put("检验报告解读", IntentRecognitionService.IntentType.REPORT_ANALYSIS_LAB);
         KEYWORD_TO_INTENT.put("检查报告解读", IntentRecognitionService.IntentType.REPORT_ANALYSIS_INSPECTION);
         KEYWORD_TO_INTENT.put("检查报告解读", IntentRecognitionService.IntentType.REPORT_ANALYSIS_INSPECTION);
         KEYWORD_TO_INTENT.put("舌诊", IntentRecognitionService.IntentType.TONGUE_DIAGNOSIS);
         KEYWORD_TO_INTENT.put("舌诊", IntentRecognitionService.IntentType.TONGUE_DIAGNOSIS);
+        KEYWORD_TO_INTENT.put("FAQ", IntentRecognitionService.IntentType.FAQ_INQUIRY);
     }
     }
 
 
     /**
     /**
@@ -48,16 +53,25 @@ public class KeywordExtractionService {
         public IntentRecognitionService.IntentType intent;
         public IntentRecognitionService.IntentType intent;
         public List<String> keywords;
         public List<String> keywords;
         public boolean isNaturalConversation;
         public boolean isNaturalConversation;
+        public String faqAnswer; // FAQ匹配时的答案
 
 
         public ExtractResult(IntentRecognitionService.IntentType intent, List<String> keywords, boolean isNaturalConversation) {
         public ExtractResult(IntentRecognitionService.IntentType intent, List<String> keywords, boolean isNaturalConversation) {
             this.intent = intent;
             this.intent = intent;
             this.keywords = keywords != null ? keywords : Collections.emptyList();
             this.keywords = keywords != null ? keywords : Collections.emptyList();
             this.isNaturalConversation = isNaturalConversation;
             this.isNaturalConversation = isNaturalConversation;
         }
         }
+        
+        public ExtractResult(IntentRecognitionService.IntentType intent, List<String> keywords, boolean isNaturalConversation, String faqAnswer) {
+            this.intent = intent;
+            this.keywords = keywords != null ? keywords : Collections.emptyList();
+            this.isNaturalConversation = isNaturalConversation;
+            this.faqAnswer = faqAnswer;
+        }
     }
     }
 
 
     /**
     /**
      * 从用户输入中提取关键词并映射到意图
      * 从用户输入中提取关键词并映射到意图
+     * 优先检查FAQ匹配,再检查业务意图
      *
      *
      * @param query          用户输入
      * @param query          用户输入
      * @param conversationHistory 对话历史(可选,用于上下文)
      * @param conversationHistory 对话历史(可选,用于上下文)
@@ -66,6 +80,19 @@ public class KeywordExtractionService {
         log.info("提取关键词: query={}", query);
         log.info("提取关键词: query={}", query);
 
 
         try {
         try {
+            // 1. 优先检查FAQ匹配(非业务类问题)
+            com.medical.demo.dto.FAQItem faqMatch = faqService.matchFAQ(query);
+            if (faqMatch != null) {
+                log.info("匹配到FAQ: id={}, question={}", faqMatch.getId(), faqMatch.getQuestion());
+                return new ExtractResult(
+                    IntentRecognitionService.IntentType.FAQ_INQUIRY,
+                    Collections.singletonList("FAQ"),
+                    false,
+                    faqMatch.getAnswer()
+                );
+            }
+
+            // 2. 未匹配FAQ,继续检查业务意图
             String rawKeywords = callModel(query, conversationHistory);
             String rawKeywords = callModel(query, conversationHistory);
             log.info("模型返回的关键词(原始): {}", rawKeywords);
             log.info("模型返回的关键词(原始): {}", rawKeywords);
             ExtractResult result = parseAndMap(rawKeywords);
             ExtractResult result = parseAndMap(rawKeywords);

+ 38 - 0
medical-card-demo/docs/省中导诊台.txt

@@ -0,0 +1,38 @@
+问题:复诊需要重新挂号吗?复诊要带什么资料?
+回答:我院现实行“一号管三天”惠民政策,首诊结束后三日内复诊,可携带身份证等有效证件到诊区导医处免费挂复诊号。
+
+问题:网上预约了号,去哪里取号?取号需要带什么证件?
+回答:网上预约成功后,在就诊当天携带身份证等有效证件提前半小时在楼层自助机处签到取号。
+
+问题:缴费可以用微信 / 支付宝吗?除了收费窗口还有哪里能缴费?
+回答:可以用微信支付宝呀,还可以去自助机进行缴费。
+
+问题:医生开了检查单,我应该先缴费还是先去检查科室预约?
+回答:当然是先缴费啦,缴费后去检查科室签到。
+
+问题:异地医保能在这里直接报销吗?需要走什么流程?
+回答:先通过线上或者在当地的医保部门备案,备案成功后在我院缴费时系统自动进行报销。
+
+问题:轮椅怎么租借?需要押金吗?
+回答:在一楼内外科诊区门前有轮椅租借处。扫码根据提示,支付押金后使用。
+
+问题:医院有母婴室吗?在哪里?
+回答:母婴室在1号楼四楼A417房间。
+
+问题:我做的检查报告什么时候能出来?
+回答:根据您做的检查项目不同,报告生成时间不同,具体需要咨询医生,或随时从手机微信“甘肃省中医院互联网医院”公众号查看是否出结果。
+
+问题:检验报告在哪儿去,可以在手机上查吗?
+回答:检验报告纸质版可在自助机进行打印,也可在手机微信“甘肃省中医院互联网医院”公众号查看。
+
+问题:医保能不能报销中药费、针灸费、推拿费?
+回答:不同项目的报销情况不同,具体需要咨询医保处。
+
+问题:便民门诊在哪里,怎么开药?
+回答:在我院2号楼1楼,携带身份证的等有效证件排队挂号,开药。
+
+问题:医生开的检查在哪里做?
+回答:根据缴费单上的地址指引,去相应地点。
+
+问题:复印/打印、邮寄病历在什么地方?
+回答:在我院1号楼1楼,扶梯下方。