|
|
@@ -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);
|
|
|
+ }
|
|
|
+}
|