From 7fe8fe47c8e3578b039fd7be87657fc7120a4708 Mon Sep 17 00:00:00 2001 From: weicw Date: Fri, 31 Jul 2026 00:37:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(house):=20=E9=87=8D=E6=9E=84AI=E6=89=BE?= =?UTF-8?q?=E6=88=BF=E8=BF=91=E4=BC=BC=E6=8E=A8=E8=8D=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../house/ai/HouseAiClarificationAdvisor.java | 51 ++ .../house/ai/HouseAiConversationMemory.java | 137 ++++ .../gxwebsoft/house/ai/HouseAiMatchTypes.java | 14 + .../ai/HouseAiRecommendationExplainer.java | 345 ++++++++++ .../house/ai/HouseAiSearchEngine.java | 584 +++++++++++++++++ .../house/ai/HouseAiSearchResult.java | 41 ++ .../house/entity/HouseAiChatResponse.java | 3 + .../house/entity/HouseAiHouseCard.java | 3 + .../service/impl/HouseAiChatServiceImpl.java | 617 ++++++------------ .../impl/HouseAiChatServiceImplTest.java | 242 +++++++ 10 files changed, 1612 insertions(+), 425 deletions(-) create mode 100644 src/main/java/com/gxwebsoft/house/ai/HouseAiClarificationAdvisor.java create mode 100644 src/main/java/com/gxwebsoft/house/ai/HouseAiConversationMemory.java create mode 100644 src/main/java/com/gxwebsoft/house/ai/HouseAiMatchTypes.java create mode 100644 src/main/java/com/gxwebsoft/house/ai/HouseAiRecommendationExplainer.java create mode 100644 src/main/java/com/gxwebsoft/house/ai/HouseAiSearchEngine.java create mode 100644 src/main/java/com/gxwebsoft/house/ai/HouseAiSearchResult.java create mode 100644 src/test/java/com/gxwebsoft/house/service/impl/HouseAiChatServiceImplTest.java diff --git a/src/main/java/com/gxwebsoft/house/ai/HouseAiClarificationAdvisor.java b/src/main/java/com/gxwebsoft/house/ai/HouseAiClarificationAdvisor.java new file mode 100644 index 0000000..d40e422 --- /dev/null +++ b/src/main/java/com/gxwebsoft/house/ai/HouseAiClarificationAdvisor.java @@ -0,0 +1,51 @@ +package com.gxwebsoft.house.ai; + +import cn.hutool.core.util.StrUtil; +import com.gxwebsoft.house.entity.HouseAiIntent; +import org.springframework.stereotype.Component; + +/** + * AI找房追问建议器,负责在需求过少时先问关键问题。 + */ +@Component +public class HouseAiClarificationAdvisor { + + public String buildBlockingQuestion(HouseAiIntent intent) { + if (intent == null) { + return "您可以告诉我预算、面积和区域,我再帮您筛选合适房源。"; + } + if (!requiresHouseSearch(intent)) { + return "我可以继续帮您找房。您先告诉我预算、面积和区域中的任意两项,我会按条件筛选。"; + } + if (!hasHouseCondition(intent)) { + return "您想找哪个区域或商圈?预算和面积大概是多少?"; + } + return ""; + } + + public boolean requiresHouseSearch(HouseAiIntent intent) { + return hasHouseCondition(intent) || "mixed".equals(intent.getIntentType()) || "house".equals(intent.getIntentType()); + } + + private boolean hasHouseCondition(HouseAiIntent intent) { + if (intent == null) { + return false; + } + return intent.getExtentMin() != null + || intent.getExtentMax() != null + || intent.getFloorMin() != null + || intent.getFloorMax() != null + || intent.getMonthlyRentMin() != null + || intent.getMonthlyRentMax() != null + || intent.getSalePriceMin() != null + || intent.getSalePriceMax() != null + || intent.getTotalPriceMin() != null + || intent.getTotalPriceMax() != null + || StrUtil.isNotBlank(intent.getCityKeyword()) + || StrUtil.isNotBlank(intent.getRegionKeyword()) + || StrUtil.isNotBlank(intent.getDecorationType()) + || StrUtil.isNotBlank(intent.getSupportingKeyword()) + || StrUtil.isNotBlank(intent.getToward()) + || StrUtil.isNotBlank(intent.getHouseType()); + } +} diff --git a/src/main/java/com/gxwebsoft/house/ai/HouseAiConversationMemory.java b/src/main/java/com/gxwebsoft/house/ai/HouseAiConversationMemory.java new file mode 100644 index 0000000..5254807 --- /dev/null +++ b/src/main/java/com/gxwebsoft/house/ai/HouseAiConversationMemory.java @@ -0,0 +1,137 @@ +package com.gxwebsoft.house.ai; + +import cn.hutool.core.util.StrUtil; +import com.gxwebsoft.house.entity.HouseAiChatRequest; +import com.gxwebsoft.house.entity.HouseAiIntent; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * AI找房会话记忆。当前为进程内短期记忆,后续可替换为Redis或数据库适配器。 + */ +@Component +public class HouseAiConversationMemory { + + private static final BigDecimal CHEAPER_RATE = new BigDecimal("0.90"); + + private final Map intentCache = new ConcurrentHashMap<>(); + + public HouseAiIntent merge(HouseAiChatRequest request, HouseAiIntent current) { + String key = buildKey(request); + if (StrUtil.isBlank(key) || current == null) { + return current; + } + HouseAiIntent previous = intentCache.get(key); + if (previous == null) { + return current; + } + HouseAiIntent merged = copy(current); + fillMissing(merged, previous); + applyFollowUpWords(request.getQuestion(), merged, previous); + return merged; + } + + public void save(HouseAiChatRequest request, HouseAiIntent intent) { + String key = buildKey(request); + if (StrUtil.isBlank(key) || intent == null || !hasHouseCondition(intent)) { + return; + } + intentCache.put(key, copy(intent)); + } + + public void clear() { + intentCache.clear(); + } + + private void fillMissing(HouseAiIntent target, HouseAiIntent previous) { + if (target.getExtentMin() == null) target.setExtentMin(previous.getExtentMin()); + if (target.getExtentMax() == null) target.setExtentMax(previous.getExtentMax()); + if (target.getFloorMin() == null) target.setFloorMin(previous.getFloorMin()); + if (target.getFloorMax() == null) target.setFloorMax(previous.getFloorMax()); + if (target.getMonthlyRentMin() == null) target.setMonthlyRentMin(previous.getMonthlyRentMin()); + if (target.getMonthlyRentMax() == null) target.setMonthlyRentMax(previous.getMonthlyRentMax()); + if (target.getSalePriceMin() == null) target.setSalePriceMin(previous.getSalePriceMin()); + if (target.getSalePriceMax() == null) target.setSalePriceMax(previous.getSalePriceMax()); + if (target.getTotalPriceMin() == null) target.setTotalPriceMin(previous.getTotalPriceMin()); + if (target.getTotalPriceMax() == null) target.setTotalPriceMax(previous.getTotalPriceMax()); + if (StrUtil.isBlank(target.getRegionKeyword())) target.setRegionKeyword(previous.getRegionKeyword()); + if (StrUtil.isBlank(target.getCityKeyword())) target.setCityKeyword(previous.getCityKeyword()); + if (StrUtil.isBlank(target.getTradeType())) target.setTradeType(previous.getTradeType()); + if (StrUtil.isBlank(target.getDecorationType())) target.setDecorationType(previous.getDecorationType()); + if (StrUtil.isBlank(target.getSupportingKeyword())) target.setSupportingKeyword(previous.getSupportingKeyword()); + if (StrUtil.isBlank(target.getToward())) target.setToward(previous.getToward()); + if (StrUtil.isBlank(target.getHouseType())) target.setHouseType(previous.getHouseType()); + if ((target.getTags() == null || target.getTags().isEmpty()) && previous.getTags() != null) { + target.setTags(new ArrayList<>(previous.getTags())); + } + } + + private void applyFollowUpWords(String question, HouseAiIntent target, HouseAiIntent previous) { + String text = question == null ? "" : question.trim(); + if ((text.contains("便宜") || text.contains("低一点") || text.contains("低点")) + && previous.getMonthlyRentMax() != null + && target.getMonthlyRentMax() != null + && target.getMonthlyRentMax().compareTo(previous.getMonthlyRentMax()) == 0) { + target.setMonthlyRentMax(previous.getMonthlyRentMax().multiply(CHEAPER_RATE).setScale(0, RoundingMode.DOWN)); + } + } + + private String buildKey(HouseAiChatRequest request) { + if (request == null || StrUtil.isBlank(request.getConversationId())) { + return ""; + } + return request.getConversationId(); + } + + private boolean hasHouseCondition(HouseAiIntent intent) { + return intent.getExtentMin() != null + || intent.getExtentMax() != null + || intent.getFloorMin() != null + || intent.getFloorMax() != null + || intent.getMonthlyRentMin() != null + || intent.getMonthlyRentMax() != null + || intent.getSalePriceMin() != null + || intent.getSalePriceMax() != null + || intent.getTotalPriceMin() != null + || intent.getTotalPriceMax() != null + || StrUtil.isNotBlank(intent.getCityKeyword()) + || StrUtil.isNotBlank(intent.getRegionKeyword()) + || StrUtil.isNotBlank(intent.getDecorationType()) + || StrUtil.isNotBlank(intent.getSupportingKeyword()) + || StrUtil.isNotBlank(intent.getToward()) + || StrUtil.isNotBlank(intent.getHouseType()); + } + + private HouseAiIntent copy(HouseAiIntent source) { + HouseAiIntent target = new HouseAiIntent(); + target.setOriginalQuestion(source.getOriginalQuestion()); + target.setIntentType(source.getIntentType()); + target.setNormalizedQuestion(source.getNormalizedQuestion()); + target.setExtentMin(source.getExtentMin()); + target.setExtentMax(source.getExtentMax()); + target.setFloorMin(source.getFloorMin()); + target.setFloorMax(source.getFloorMax()); + target.setMonthlyRentMin(source.getMonthlyRentMin()); + target.setMonthlyRentMax(source.getMonthlyRentMax()); + target.setSalePriceMin(source.getSalePriceMin()); + target.setSalePriceMax(source.getSalePriceMax()); + target.setTotalPriceMin(source.getTotalPriceMin()); + target.setTotalPriceMax(source.getTotalPriceMax()); + target.setRegionKeyword(source.getRegionKeyword()); + target.setCityKeyword(source.getCityKeyword()); + target.setTradeType(source.getTradeType()); + target.setDecorationType(source.getDecorationType()); + target.setSupportingKeyword(source.getSupportingKeyword()); + target.setToward(source.getToward()); + target.setHouseType(source.getHouseType()); + target.setWhereSql(source.getWhereSql()); + target.setOrderSql(source.getOrderSql()); + target.setTags(source.getTags() == null ? new ArrayList<>() : new ArrayList<>(source.getTags())); + return target; + } +} diff --git a/src/main/java/com/gxwebsoft/house/ai/HouseAiMatchTypes.java b/src/main/java/com/gxwebsoft/house/ai/HouseAiMatchTypes.java new file mode 100644 index 0000000..e06d9be --- /dev/null +++ b/src/main/java/com/gxwebsoft/house/ai/HouseAiMatchTypes.java @@ -0,0 +1,14 @@ +package com.gxwebsoft.house.ai; + +/** + * AI找房匹配结果类型。 + */ +public final class HouseAiMatchTypes { + + public static final String EXACT = "exact"; + public static final String APPROXIMATE = "approximate"; + public static final String NONE = "none"; + + private HouseAiMatchTypes() { + } +} diff --git a/src/main/java/com/gxwebsoft/house/ai/HouseAiRecommendationExplainer.java b/src/main/java/com/gxwebsoft/house/ai/HouseAiRecommendationExplainer.java new file mode 100644 index 0000000..ec58979 --- /dev/null +++ b/src/main/java/com/gxwebsoft/house/ai/HouseAiRecommendationExplainer.java @@ -0,0 +1,345 @@ +package com.gxwebsoft.house.ai; + +import cn.hutool.core.util.StrUtil; +import com.gxwebsoft.house.entity.HouseAiHouseCard; +import com.gxwebsoft.house.entity.HouseAiIntent; +import com.gxwebsoft.house.entity.HouseInfo; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * AI找房推荐解释器,负责回答话术和每套房源的匹配说明。 + */ +@Component +public class HouseAiRecommendationExplainer { + + private static final Pattern HOUSE_TYPE_COMPARTMENT_PATTERN = Pattern.compile("([一二两三四五六七八九十0-9]{1,2})\\s*隔间"); + private static final Pattern HOUSE_TYPE_ROOM_HALL_PATTERN = Pattern.compile("([一二两三四五六七八九十0-9]{1,2})\\s*室\\s*([一二两三四五六七八九十0-9]{1,2})\\s*厅"); + + public String buildHouseAnswer(HouseAiIntent intent, HouseAiSearchResult result, boolean hasFaqMatches) { + if (hasFaqMatches && HouseAiMatchTypes.EXACT.equals(result.getMatchType())) { + return "优先为您匹配到常见问题答案,同时按您的需求筛选到以下房源:"; + } + if (HouseAiMatchTypes.APPROXIMATE.equals(result.getMatchType())) { + return buildApproximateAnswer(intent, result.getHouses().size()); + } + return buildExactAnswer(intent, result.getHouses().size()); + } + + public String buildNoCandidateAnswer(HouseAiIntent intent) { + StringBuilder sb = new StringBuilder("暂时没有找到符合条件或接近条件的房源。"); + if (StrUtil.isNotBlank(intent.getRegionKeyword())) { + sb.append("可以先放宽").append(intent.getRegionKeyword()).append("周边范围,"); + } else { + sb.append("可以补充区域或商圈,"); + } + sb.append("也可以调整面积、预算或留下联系方式,顾问会继续为您跟进。"); + return sb.toString(); + } + + public List toHouseCards(HouseAiSearchResult result, HouseAiIntent intent) { + return result.getHouses().stream() + .map(item -> toHouseCard(item, intent, result.getMatchType())) + .collect(Collectors.toList()); + } + + private HouseAiHouseCard toHouseCard(HouseInfo item, HouseAiIntent intent, String matchType) { + HouseAiHouseCard card = new HouseAiHouseCard(); + card.setHouseId(item.getHouseId()); + card.setHouseTitle(item.getHouseTitle()); + card.setHouseType(item.getHouseType()); + card.setExtent(item.getExtent()); + card.setFloor(item.getFloor()); + card.setToward(item.getToward()); + card.setMonthlyRent(item.getMonthlyRent() == null ? null : item.getMonthlyRent().stripTrailingZeros().toPlainString()); + card.setCity(item.getCity()); + card.setRegion(item.getRegion()); + card.setAddress(item.getAddress()); + card.setFiles(item.getFiles()); + card.setSupporting(item.getSupporting()); + card.setMatchReason(buildMatchReason(item, intent, matchType)); + return card; + } + + private String buildApproximateAnswer(HouseAiIntent intent, int size) { + StringBuilder sb = new StringBuilder("我按"); + List desc = buildConditionDescriptions(intent); + if (desc.isEmpty()) { + sb.append("您的找房需求"); + } else { + sb.append(String.join("、", desc)); + } + sb.append("筛了一遍,暂时没有完全匹配的房源。先给您看"); + sb.append(size).append("套比较接近的,主要差异我也标在卡片里。"); + return sb.toString(); + } + + private String buildExactAnswer(HouseAiIntent intent, int size) { + StringBuilder sb = new StringBuilder("已根据您的需求筛选到"); + sb.append(size).append("套较匹配的房源"); + List desc = buildConditionDescriptions(intent); + if (!desc.isEmpty()) { + sb.append(",条件包括:").append(String.join("、", desc)); + } + sb.append("。"); + return sb.toString(); + } + + private List buildConditionDescriptions(HouseAiIntent intent) { + List desc = new ArrayList<>(); + if (intent.getExtentMin() != null && intent.getExtentMax() != null) { + desc.add(intent.getExtentMin() + "-" + intent.getExtentMax() + "平"); + } else if (intent.getExtentMax() != null) { + desc.add(intent.getExtentMax() + "平以下"); + } else if (intent.getExtentMin() != null) { + desc.add(intent.getExtentMin() + "平以上"); + } + if (intent.getFloorMin() != null && intent.getFloorMax() != null) { + desc.add(intent.getFloorMin() + "-" + intent.getFloorMax() + "楼"); + } else if (intent.getFloorMin() != null) { + desc.add(intent.getFloorMin() + "楼以上"); + } else if (intent.getFloorMax() != null) { + desc.add(intent.getFloorMax() + "楼以下"); + } + if (intent.getMonthlyRentMin() != null && intent.getMonthlyRentMax() != null) { + desc.add("月租" + formatMoney(intent.getMonthlyRentMin()) + "-" + formatMoney(intent.getMonthlyRentMax()) + "元"); + } else if (intent.getMonthlyRentMax() != null) { + desc.add("月租" + formatMoney(intent.getMonthlyRentMax()) + "元以内"); + } else if (intent.getMonthlyRentMin() != null) { + desc.add("月租" + formatMoney(intent.getMonthlyRentMin()) + "元以上"); + } + String saleText = buildSaleText(intent); + if (StrUtil.isNotBlank(saleText)) { + desc.add(saleText); + } + if (StrUtil.isNotBlank(intent.getCityKeyword())) { + desc.add(intent.getCityKeyword()); + } + if (StrUtil.isNotBlank(intent.getRegionKeyword())) { + desc.add(intent.getRegionKeyword()); + } + if (StrUtil.isNotBlank(intent.getHouseType())) { + desc.add(intent.getHouseType()); + } + if (StrUtil.isNotBlank(intent.getDecorationType())) { + desc.add(intent.getDecorationType()); + } + if (StrUtil.isNotBlank(intent.getSupportingKeyword())) { + desc.add(intent.getSupportingKeyword()); + } + return desc; + } + + private String buildMatchReason(HouseInfo item, HouseAiIntent intent, String matchType) { + List reasons = new ArrayList<>(); + addExtentReason(reasons, item, intent); + addRentReason(reasons, item, intent); + addTextReason(reasons, item.getHouseType(), intent.getHouseType(), "户型"); + addTextReason(reasons, item.getToward(), intent.getToward(), "朝向"); + if (HouseAiMatchTypes.EXACT.equals(matchType) && reasons.isEmpty()) { + return "匹配您的主要找房条件"; + } + if (reasons.isEmpty()) { + return HouseAiMatchTypes.APPROXIMATE.equals(matchType) ? "整体条件接近您的需求" : ""; + } + return String.join(",", reasons); + } + + private void addExtentReason(List reasons, HouseInfo item, HouseAiIntent intent) { + if (intent.getExtentMin() == null && intent.getExtentMax() == null) { + return; + } + BigDecimal current = parseDecimal(item.getExtent()); + if (current == null) { + return; + } + if (withinRange(current, toDecimal(intent.getExtentMin()), toDecimal(intent.getExtentMax()))) { + reasons.add("面积" + formatMoney(current) + "平,符合需求"); + return; + } + BigDecimal target = pickTarget(toDecimal(intent.getExtentMin()), toDecimal(intent.getExtentMax())); + if (target != null) { + reasons.add("面积" + formatMoney(current) + "平,接近" + formatMoney(target) + "平"); + } + } + + private void addRentReason(List reasons, HouseInfo item, HouseAiIntent intent) { + if (intent.getMonthlyRentMin() == null && intent.getMonthlyRentMax() == null) { + return; + } + BigDecimal current = item.getMonthlyRent(); + if (current == null) { + return; + } + if (withinRange(current, intent.getMonthlyRentMin(), intent.getMonthlyRentMax())) { + reasons.add("租金" + formatMoney(current) + "元/月,在预算内"); + return; + } + if (intent.getMonthlyRentMax() != null && current.compareTo(intent.getMonthlyRentMax()) > 0) { + BigDecimal overRate = current.subtract(intent.getMonthlyRentMax()) + .multiply(new BigDecimal("100")) + .divide(intent.getMonthlyRentMax(), 0, RoundingMode.HALF_UP); + reasons.add("租金超预算约" + overRate.stripTrailingZeros().toPlainString() + "%"); + } + } + + private void addTextReason(List reasons, String current, String expected, String label) { + if (StrUtil.isBlank(expected) || StrUtil.isBlank(current)) { + return; + } + if (normalizeSearchText(current).contains(normalizeSearchText(expected))) { + reasons.add(label + "匹配"); + } else { + reasons.add(label + "略有差异"); + } + } + + private boolean withinRange(BigDecimal current, BigDecimal min, BigDecimal max) { + if (current == null) { + return false; + } + if (min != null && current.compareTo(min) < 0) { + return false; + } + if (max != null && current.compareTo(max) > 0) { + return false; + } + return true; + } + + private BigDecimal pickTarget(BigDecimal min, BigDecimal max) { + if (min != null && max != null) { + return min.add(max).divide(new BigDecimal("2"), 0, RoundingMode.HALF_UP); + } + return min != null ? min : max; + } + + private BigDecimal toDecimal(Integer value) { + return value == null ? null : new BigDecimal(value); + } + + private String buildSaleText(HouseAiIntent intent) { + if (intent.getTradeType() != null && "sale".equals(intent.getTradeType())) { + if (intent.getTotalPriceMin() != null && intent.getTotalPriceMax() != null) { + return "总价" + formatMoney(intent.getTotalPriceMin()) + "-" + formatMoney(intent.getTotalPriceMax()) + "元"; + } + if (intent.getTotalPriceMax() != null) { + return "总价" + formatMoney(intent.getTotalPriceMax()) + "元以内"; + } + if (intent.getSalePriceMin() != null && intent.getSalePriceMax() != null) { + return "售价" + formatMoney(intent.getSalePriceMin()) + "-" + formatMoney(intent.getSalePriceMax()) + "元"; + } + if (intent.getSalePriceMax() != null) { + return "售价" + formatMoney(intent.getSalePriceMax()) + "元以内"; + } + } + return ""; + } + + private String formatMoney(BigDecimal value) { + if (value == null) { + return ""; + } + return value.stripTrailingZeros().toPlainString(); + } + + private BigDecimal parseDecimal(String raw) { + if (StrUtil.isBlank(raw)) { + return null; + } + String number = raw.replaceAll("[^0-9.]", ""); + if (StrUtil.isBlank(number)) { + return null; + } + try { + return new BigDecimal(number); + } catch (Exception e) { + return null; + } + } + + private String normalizeSearchText(String text) { + String normalized = normalize(text); + normalized = replaceHouseNumberPattern(normalized, HOUSE_TYPE_COMPARTMENT_PATTERN, "隔间"); + Matcher matcher = HOUSE_TYPE_ROOM_HALL_PATTERN.matcher(normalized); + StringBuffer buffer = new StringBuffer(); + while (matcher.find()) { + String replacement = toChineseHouseNumber(matcher.group(1)) + "室" + toChineseHouseNumber(matcher.group(2)) + "厅"; + matcher.appendReplacement(buffer, Matcher.quoteReplacement(replacement)); + } + matcher.appendTail(buffer); + return buffer.toString(); + } + + private String replaceHouseNumberPattern(String text, Pattern pattern, String suffix) { + Matcher matcher = pattern.matcher(text); + StringBuffer buffer = new StringBuffer(); + while (matcher.find()) { + matcher.appendReplacement(buffer, Matcher.quoteReplacement(toChineseHouseNumber(matcher.group(1)) + suffix)); + } + matcher.appendTail(buffer); + return buffer.toString(); + } + + private String toChineseHouseNumber(String raw) { + String value = normalize(raw).replace("两", "二"); + switch (value) { + case "1": + case "一": + return "一"; + case "2": + case "二": + return "二"; + case "3": + case "三": + return "三"; + case "4": + case "四": + return "四"; + case "5": + case "五": + return "五"; + case "6": + case "六": + return "六"; + case "7": + case "七": + return "七"; + case "8": + case "八": + return "八"; + case "9": + case "九": + return "九"; + case "10": + case "十": + return "十"; + default: + return value; + } + } + + private String normalize(String text) { + if (text == null) { + return ""; + } + return text.toLowerCase(Locale.ROOT) + .replace("㎡", "平") + .replace("平方", "平") + .replace("m²", "平") + .replace("m2", "平") + .replace("M²", "平") + .replace("(", "(") + .replace(")", ")") + .replace("+", "+") + .trim(); + } +} diff --git a/src/main/java/com/gxwebsoft/house/ai/HouseAiSearchEngine.java b/src/main/java/com/gxwebsoft/house/ai/HouseAiSearchEngine.java new file mode 100644 index 0000000..95fee4b --- /dev/null +++ b/src/main/java/com/gxwebsoft/house/ai/HouseAiSearchEngine.java @@ -0,0 +1,584 @@ +package com.gxwebsoft.house.ai; + +import cn.hutool.core.util.NumberUtil; +import cn.hutool.core.util.StrUtil; +import com.gxwebsoft.house.entity.HouseAiIntent; +import com.gxwebsoft.house.entity.HouseInfo; +import com.gxwebsoft.house.mapper.HouseInfoMapper; +import com.gxwebsoft.house.param.HouseInfoParam; +import com.gxwebsoft.house.service.HouseInfoService; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * AI找房搜索引擎,封装精确匹配、AI SQL兜底和近似推荐。 + */ +@Component +public class HouseAiSearchEngine { + + private static final Pattern NUMBER_PATTERN = Pattern.compile("(\\d+(?:\\.\\d+)?)"); + private static final Pattern HOUSE_TYPE_COMPARTMENT_PATTERN = Pattern.compile("([一二两三四五六七八九十0-9]{1,2})\\s*隔间"); + private static final Pattern HOUSE_TYPE_ROOM_HALL_PATTERN = Pattern.compile("([一二两三四五六七八九十0-9]{1,2})\\s*室\\s*([一二两三四五六七八九十0-9]{1,2})\\s*厅"); + private static final int EXACT_HOUSE_LIMIT = 10; + private static final int APPROXIMATE_HOUSE_LIMIT = 5; + private static final BigDecimal RELAX_RATE = new BigDecimal("0.20"); + private static final BigDecimal RELAX_MIN_RATE = BigDecimal.ONE.subtract(RELAX_RATE); + private static final BigDecimal RELAX_MAX_RATE = BigDecimal.ONE.add(RELAX_RATE); + private static final long PRICE_SCORE_WEIGHT = 1000000L; + private static final long EXTENT_SCORE_WEIGHT = 10000L; + private static final long HOUSE_TYPE_SCORE_WEIGHT = 1000L; + private static final long DETAIL_SCORE_WEIGHT = 100L; + + @Resource + private HouseInfoService houseInfoService; + @Resource + private HouseInfoMapper houseInfoMapper; + + public HouseAiSearchResult search(HouseAiIntent intent, String question) { + List structuredHouses = searchStructuredHouses(intent, question); + if (!structuredHouses.isEmpty()) { + return HouseAiSearchResult.exact(structuredHouses); + } + + List aiSqlHouses = searchHousesByAiSql(intent); + if (!aiSqlHouses.isEmpty()) { + return HouseAiSearchResult.exact(aiSqlHouses.stream().limit(EXACT_HOUSE_LIMIT).collect(Collectors.toList())); + } + + List approximateHouses = searchApproximateHouses(intent); + if (!approximateHouses.isEmpty()) { + return HouseAiSearchResult.approximate(approximateHouses); + } + + return HouseAiSearchResult.none(); + } + + private List searchStructuredHouses(HouseAiIntent intent, String question) { + HouseInfoParam param = new HouseInfoParam(); + param.setStatus(0); + if (intent.getExtentMin() != null) { + param.setExtentStart(intent.getExtentMin()); + } + if (intent.getExtentMax() != null) { + param.setExtentEnd(intent.getExtentMax()); + } + if (StrUtil.isNotBlank(intent.getCityKeyword())) { + param.setCity(intent.getCityKeyword()); + } + if (StrUtil.isNotBlank(intent.getRegionKeyword())) { + param.setRegion(intent.getRegionKeyword()); + } + if (StrUtil.isNotBlank(intent.getToward())) { + param.setToward(intent.getToward()); + } + if (StrUtil.isNotBlank(intent.getHouseType())) { + param.setHouseType(normalizeHouseTypeKeyword(intent.getHouseType())); + } + if (StrUtil.isNotBlank(intent.getDecorationType())) { + param.setHouseLabel(intent.getDecorationType()); + } + if (StrUtil.isNotBlank(intent.getSupportingKeyword())) { + param.setContent(intent.getSupportingKeyword()); + } + if (!hasStructuredQueryCondition(intent)) { + param.setKeywords(shortenQuestion(question)); + } + + List houses = houseInfoService.listRel(param); + return filterHouses(houses, intent).stream().limit(EXACT_HOUSE_LIMIT).collect(Collectors.toList()); + } + + private boolean hasStructuredQueryCondition(HouseAiIntent intent) { + return intent.getExtentMin() != null + || intent.getExtentMax() != null + || intent.getFloorMin() != null + || intent.getFloorMax() != null + || intent.getMonthlyRentMin() != null + || intent.getMonthlyRentMax() != null + || intent.getSalePriceMin() != null + || intent.getSalePriceMax() != null + || intent.getTotalPriceMin() != null + || intent.getTotalPriceMax() != null + || StrUtil.isNotBlank(intent.getCityKeyword()) + || StrUtil.isNotBlank(intent.getRegionKeyword()) + || StrUtil.isNotBlank(intent.getToward()) + || StrUtil.isNotBlank(intent.getHouseType()) + || StrUtil.isNotBlank(intent.getDecorationType()) + || StrUtil.isNotBlank(intent.getSupportingKeyword()); + } + + private List searchHousesByAiSql(HouseAiIntent intent) { + if (StrUtil.isBlank(intent.getWhereSql())) { + return Collections.emptyList(); + } + String whereSql = sanitizeWhereSql(intent.getWhereSql()); + String orderSql = sanitizeOrderSql(intent.getOrderSql()); + if (StrUtil.isBlank(whereSql)) { + return Collections.emptyList(); + } + try { + return houseInfoMapper.selectListByAiSql(whereSql, orderSql); + } catch (Exception e) { + return Collections.emptyList(); + } + } + + private List filterHouses(List houses, HouseAiIntent intent) { + if (houses == null || houses.isEmpty()) { + return Collections.emptyList(); + } + return houses.stream() + .filter(item -> matchExtent(item, intent)) + .filter(item -> matchFloor(item.getFloor(), intent)) + .filter(item -> matchMoney(item.getMonthlyRent(), intent.getMonthlyRentMin(), intent.getMonthlyRentMax())) + .filter(item -> matchMoney(parseDecimal(item.getSalePrice()), intent.getSalePriceMin(), intent.getSalePriceMax())) + .filter(item -> matchMoney(parseDecimal(item.getTotalPrice()), intent.getTotalPriceMin(), intent.getTotalPriceMax())) + .filter(item -> matchTradeType(item, intent)) + .filter(item -> matchText(item, intent)) + .collect(Collectors.toList()); + } + + private List searchApproximateHouses(HouseAiIntent intent) { + HouseInfoParam param = new HouseInfoParam(); + param.setStatus(0); + + List candidates = houseInfoService.listRel(param); + if (candidates == null || candidates.isEmpty()) { + return Collections.emptyList(); + } + + return candidates.stream() + .filter(item -> matchHardConditions(item, intent)) + .filter(item -> matchRelaxedMoney(item.getMonthlyRent(), intent.getMonthlyRentMin(), intent.getMonthlyRentMax())) + .filter(item -> matchRelaxedMoney(parseDecimal(item.getSalePrice()), intent.getSalePriceMin(), intent.getSalePriceMax())) + .filter(item -> matchRelaxedMoney(parseDecimal(item.getTotalPrice()), intent.getTotalPriceMin(), intent.getTotalPriceMax())) + .filter(item -> matchRelaxedExtent(item, intent)) + .sorted((left, right) -> compareApproximateHouses(left, right, intent)) + .limit(APPROXIMATE_HOUSE_LIMIT) + .collect(Collectors.toList()); + } + + private int compareApproximateHouses(HouseInfo left, HouseInfo right, HouseAiIntent intent) { + int scoreCompare = Long.compare(buildApproximateScore(left, intent), buildApproximateScore(right, intent)); + if (scoreCompare != 0) { + return scoreCompare; + } + Integer leftSort = left.getSortNumber() == null ? Integer.MAX_VALUE : left.getSortNumber(); + Integer rightSort = right.getSortNumber() == null ? Integer.MAX_VALUE : right.getSortNumber(); + return leftSort.compareTo(rightSort); + } + + private long buildApproximateScore(HouseInfo item, HouseAiIntent intent) { + long score = 0L; + score += moneyDistanceScore(item.getMonthlyRent(), intent.getMonthlyRentMin(), intent.getMonthlyRentMax()) * PRICE_SCORE_WEIGHT; + score += moneyDistanceScore(parseDecimal(item.getSalePrice()), intent.getSalePriceMin(), intent.getSalePriceMax()) * PRICE_SCORE_WEIGHT; + score += moneyDistanceScore(parseDecimal(item.getTotalPrice()), intent.getTotalPriceMin(), intent.getTotalPriceMax()) * PRICE_SCORE_WEIGHT; + score += extentDistanceScore(item, intent) * EXTENT_SCORE_WEIGHT; + score += textMissPenalty(item.getHouseType(), intent.getHouseType()) * HOUSE_TYPE_SCORE_WEIGHT; + score += floorDistanceScore(item.getFloor(), intent) * DETAIL_SCORE_WEIGHT; + score += textMissPenalty(item.getToward(), intent.getToward()) * DETAIL_SCORE_WEIGHT; + score += textMissPenalty(safeText(item.getHouseLabel()) + " " + safeText(item.getSupporting()) + " " + safeText(item.getContent()), intent.getDecorationType()) * DETAIL_SCORE_WEIGHT; + score += textMissPenalty(safeText(item.getSupporting()) + " " + safeText(item.getContent()) + " " + safeText(item.getHouseLabel()), intent.getSupportingKeyword()) * DETAIL_SCORE_WEIGHT; + if (item.getRecommend() != null && item.getRecommend() == 1) { + score -= 50L; + } + return score; + } + + private boolean matchHardConditions(HouseInfo item, HouseAiIntent intent) { + return matchTradeType(item, intent) && matchCity(item, intent) && matchRegion(item, intent); + } + + private boolean matchText(HouseInfo item, HouseAiIntent intent) { + if (!matchCity(item, intent) || !matchRegion(item, intent)) { + return false; + } + if (StrUtil.isNotBlank(intent.getHouseType())) { + if (!normalizeSearchText(safeText(item.getHouseType())).contains(normalizeSearchText(intent.getHouseType()))) { + return false; + } + } + if (StrUtil.isNotBlank(intent.getToward())) { + if (!normalize(safeText(item.getToward())).contains(normalize(intent.getToward()))) { + return false; + } + } + if (StrUtil.isNotBlank(intent.getDecorationType())) { + String text = normalize(safeText(item.getHouseLabel()) + " " + safeText(item.getSupporting()) + " " + safeText(item.getContent())); + if (!text.contains(normalize(intent.getDecorationType()))) { + return false; + } + } + if (StrUtil.isNotBlank(intent.getSupportingKeyword())) { + String text = normalize(safeText(item.getSupporting()) + " " + safeText(item.getContent()) + " " + safeText(item.getHouseLabel())); + if (!text.contains(normalize(intent.getSupportingKeyword()))) { + return false; + } + } + return true; + } + + private boolean matchCity(HouseInfo item, HouseAiIntent intent) { + if (StrUtil.isNotBlank(intent.getCityKeyword())) { + String cityText = normalize(safeText(item.getCity()) + " " + safeText(item.getCityByHouse())); + if (!cityText.contains(normalize(intent.getCityKeyword()))) { + return false; + } + } + return true; + } + + private boolean matchRegion(HouseInfo item, HouseAiIntent intent) { + if (StrUtil.isNotBlank(intent.getRegionKeyword())) { + String text = normalize(safeText(item.getRegion()) + " " + safeText(item.getArea()) + " " + safeText(item.getAddress()) + " " + safeText(item.getCity()) + " " + safeText(item.getCityByHouse())); + if (!text.contains(normalize(intent.getRegionKeyword()))) { + return false; + } + } + return true; + } + + private boolean matchTradeType(HouseInfo item, HouseAiIntent intent) { + if (StrUtil.isBlank(intent.getTradeType())) { + return true; + } + if ("sale".equals(intent.getTradeType())) { + return parseDecimal(item.getSalePrice()) != null || parseDecimal(item.getTotalPrice()) != null; + } + if ("rent".equals(intent.getTradeType())) { + return item.getMonthlyRent() != null || item.getRent() != null; + } + return true; + } + + private boolean matchExtent(HouseInfo item, HouseAiIntent intent) { + if (intent.getExtentMin() == null && intent.getExtentMax() == null) { + return true; + } + BigDecimal current = parseDecimal(item.getExtent()); + if (current == null) { + return true; + } + if (intent.getExtentMin() != null && current.compareTo(new BigDecimal(intent.getExtentMin())) < 0) { + return false; + } + if (intent.getExtentMax() != null && current.compareTo(new BigDecimal(intent.getExtentMax())) > 0) { + return false; + } + return true; + } + + private boolean matchRelaxedMoney(BigDecimal current, BigDecimal min, BigDecimal max) { + if (min == null && max == null) { + return true; + } + if (current == null) { + return false; + } + if (min != null && current.compareTo(min.multiply(RELAX_MIN_RATE)) < 0) { + return false; + } + if (max != null && current.compareTo(max.multiply(RELAX_MAX_RATE)) > 0) { + return false; + } + return true; + } + + private boolean matchRelaxedExtent(HouseInfo item, HouseAiIntent intent) { + if (intent.getExtentMin() == null && intent.getExtentMax() == null) { + return true; + } + BigDecimal current = parseDecimal(item.getExtent()); + if (current == null) { + return false; + } + if (intent.getExtentMin() != null) { + BigDecimal min = new BigDecimal(intent.getExtentMin()).multiply(RELAX_MIN_RATE); + if (current.compareTo(min) < 0) { + return false; + } + } + if (intent.getExtentMax() != null) { + BigDecimal max = new BigDecimal(intent.getExtentMax()).multiply(RELAX_MAX_RATE); + if (current.compareTo(max) > 0) { + return false; + } + } + return true; + } + + private long moneyDistanceScore(BigDecimal current, BigDecimal min, BigDecimal max) { + if (min == null && max == null) { + return 0L; + } + return distanceScore(current, min, max); + } + + private long extentDistanceScore(HouseInfo item, HouseAiIntent intent) { + if (intent.getExtentMin() == null && intent.getExtentMax() == null) { + return 0L; + } + BigDecimal min = intent.getExtentMin() == null ? null : new BigDecimal(intent.getExtentMin()); + BigDecimal max = intent.getExtentMax() == null ? null : new BigDecimal(intent.getExtentMax()); + return distanceScore(parseDecimal(item.getExtent()), min, max); + } + + private long distanceScore(BigDecimal current, BigDecimal min, BigDecimal max) { + if (current == null) { + return 10000L; + } + if (min != null && current.compareTo(min) < 0) { + return percentDistance(min.subtract(current), min); + } + if (max != null && current.compareTo(max) > 0) { + return percentDistance(current.subtract(max), max); + } + return 0L; + } + + private long percentDistance(BigDecimal distance, BigDecimal base) { + double divisor = Math.max(Math.abs(base.doubleValue()), 1D); + return Math.round(distance.abs().doubleValue() * 100D / divisor); + } + + private long textMissPenalty(String text, String keyword) { + if (StrUtil.isBlank(keyword)) { + return 0L; + } + return normalizeSearchText(safeText(text)).contains(normalizeSearchText(keyword)) ? 0L : 1L; + } + + private long floorDistanceScore(String floor, HouseAiIntent intent) { + if (intent.getFloorMin() == null && intent.getFloorMax() == null) { + return 0L; + } + Integer currentFloor = extractFirstInteger(floor); + if (currentFloor == null) { + return 1L; + } + if (intent.getFloorMin() != null && currentFloor < intent.getFloorMin()) { + return intent.getFloorMin() - currentFloor; + } + if (intent.getFloorMax() != null && currentFloor > intent.getFloorMax()) { + return currentFloor - intent.getFloorMax(); + } + return 0L; + } + + private boolean matchFloor(String floor, HouseAiIntent intent) { + Integer currentFloor = extractFirstInteger(floor); + if (currentFloor == null) { + return true; + } + if (intent.getFloorMin() != null && currentFloor < intent.getFloorMin()) { + return false; + } + if (intent.getFloorMax() != null && currentFloor > intent.getFloorMax()) { + return false; + } + return true; + } + + private boolean matchMoney(BigDecimal current, BigDecimal min, BigDecimal max) { + if (current == null) { + return true; + } + if (min != null && current.compareTo(min) < 0) { + return false; + } + if (max != null && current.compareTo(max) > 0) { + return false; + } + return true; + } + + private String shortenQuestion(String question) { + String normalized = normalize(question); + return normalized.length() > 12 ? normalized.substring(0, 12) : normalized; + } + + private String sanitizeWhereSql(String whereSql) { + if (StrUtil.isBlank(whereSql)) { + return null; + } + String normalized = whereSql.trim() + .replaceAll("(?i)^\\s*where\\s+", "") + .replaceAll("(?i)\\bselect\\b", "") + .replaceAll("(?i)\\bupdate\\b", "") + .replaceAll("(?i)\\bdelete\\b", "") + .replaceAll("(?i)\\binsert\\b", "") + .replaceAll("(?i)\\bdrop\\b", "") + .replaceAll("(?i)\\btruncate\\b", "") + .replaceAll("(?i)\\bunion\\b", "") + .replaceAll(";", "") + .trim(); + if (StrUtil.isBlank(normalized)) { + return null; + } + if (Pattern.compile("(?i)\\border\\s+by\\b").matcher(normalized).find()) { + return null; + } + List allowedColumns = Arrays.asList( + "a.house_type", "a.monthly_rent", "a.sale_price", "a.total_price", "a.extent", "a.floor", + "a.city", "a.city_by_house", "a.region", "a.area", "a.address", "a.house_label", + "a.supporting", "a.content", "a.toward", "a.lease_method" + ); + Matcher matcher = Pattern.compile("a\\.[a-zA-Z_]+").matcher(normalized); + while (matcher.find()) { + String column = matcher.group(); + if (!allowedColumns.contains(column)) { + return null; + } + } + if (normalized.contains("--") || normalized.contains("/*") || normalized.contains("*/")) { + return null; + } + return normalized; + } + + private String sanitizeOrderSql(String orderSql) { + if (StrUtil.isBlank(orderSql)) { + return null; + } + String normalized = orderSql.trim() + .replaceAll("(?i)\\border\\s+by\\b", "") + .replaceAll(";", "") + .trim(); + if (StrUtil.isBlank(normalized)) { + return null; + } + List allowedColumns = Arrays.asList( + "a.sort_number", "a.create_time", "a.monthly_rent", "a.sale_price", "a.total_price", "a.extent", "a.floor" + ); + for (String item : normalized.split(",")) { + String[] parts = item.trim().split("\\s+"); + if (parts.length == 0 || !allowedColumns.contains(parts[0])) { + return null; + } + if (parts.length > 1 && !("asc".equalsIgnoreCase(parts[1]) || "desc".equalsIgnoreCase(parts[1]))) { + return null; + } + } + return normalized; + } + + private BigDecimal parseDecimal(String raw) { + if (StrUtil.isBlank(raw)) { + return null; + } + String number = raw.replaceAll("[^0-9.]", ""); + if (StrUtil.isBlank(number)) { + return null; + } + try { + return new BigDecimal(number); + } catch (Exception e) { + return null; + } + } + + private Integer extractFirstInteger(String raw) { + if (StrUtil.isBlank(raw)) { + return null; + } + Matcher matcher = NUMBER_PATTERN.matcher(raw); + if (matcher.find()) { + return NumberUtil.parseInt(matcher.group(1)); + } + return null; + } + + private String normalizeHouseTypeKeyword(String keyword) { + return normalizeSearchText(keyword); + } + + private String normalizeSearchText(String text) { + String normalized = normalize(text); + normalized = replaceHouseNumberPattern(normalized, HOUSE_TYPE_COMPARTMENT_PATTERN, "隔间"); + Matcher matcher = HOUSE_TYPE_ROOM_HALL_PATTERN.matcher(normalized); + StringBuffer buffer = new StringBuffer(); + while (matcher.find()) { + String replacement = toChineseHouseNumber(matcher.group(1)) + "室" + toChineseHouseNumber(matcher.group(2)) + "厅"; + matcher.appendReplacement(buffer, Matcher.quoteReplacement(replacement)); + } + matcher.appendTail(buffer); + return buffer.toString(); + } + + private String replaceHouseNumberPattern(String text, Pattern pattern, String suffix) { + Matcher matcher = pattern.matcher(text); + StringBuffer buffer = new StringBuffer(); + while (matcher.find()) { + matcher.appendReplacement(buffer, Matcher.quoteReplacement(toChineseHouseNumber(matcher.group(1)) + suffix)); + } + matcher.appendTail(buffer); + return buffer.toString(); + } + + private String toChineseHouseNumber(String raw) { + String value = normalize(raw).replace("两", "二"); + switch (value) { + case "1": + case "一": + return "一"; + case "2": + case "二": + return "二"; + case "3": + case "三": + return "三"; + case "4": + case "四": + return "四"; + case "5": + case "五": + return "五"; + case "6": + case "六": + return "六"; + case "7": + case "七": + return "七"; + case "8": + case "八": + return "八"; + case "9": + case "九": + return "九"; + case "10": + case "十": + return "十"; + default: + return value; + } + } + + private String normalize(String text) { + if (text == null) { + return ""; + } + return text.toLowerCase(Locale.ROOT) + .replace("㎡", "平") + .replace("平方", "平") + .replace("m²", "平") + .replace("m2", "平") + .replace("M²", "平") + .replace("(", "(") + .replace(")", ")") + .replace("+", "+") + .trim(); + } + + private String safeText(String text) { + return text == null ? "" : text; + } +} diff --git a/src/main/java/com/gxwebsoft/house/ai/HouseAiSearchResult.java b/src/main/java/com/gxwebsoft/house/ai/HouseAiSearchResult.java new file mode 100644 index 0000000..97476e2 --- /dev/null +++ b/src/main/java/com/gxwebsoft/house/ai/HouseAiSearchResult.java @@ -0,0 +1,41 @@ +package com.gxwebsoft.house.ai; + +import com.gxwebsoft.house.entity.HouseInfo; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * AI找房搜索结果。 + */ +@Data +public class HouseAiSearchResult { + + private String matchType = HouseAiMatchTypes.NONE; + + private List houses = new ArrayList<>(); + + public static HouseAiSearchResult exact(List houses) { + return of(HouseAiMatchTypes.EXACT, houses); + } + + public static HouseAiSearchResult approximate(List houses) { + return of(HouseAiMatchTypes.APPROXIMATE, houses); + } + + public static HouseAiSearchResult none() { + return of(HouseAiMatchTypes.NONE, new ArrayList<>()); + } + + public boolean hasHouses() { + return houses != null && !houses.isEmpty(); + } + + private static HouseAiSearchResult of(String matchType, List houses) { + HouseAiSearchResult result = new HouseAiSearchResult(); + result.setMatchType(matchType); + result.setHouses(houses == null ? new ArrayList<>() : houses); + return result; + } +} diff --git a/src/main/java/com/gxwebsoft/house/entity/HouseAiChatResponse.java b/src/main/java/com/gxwebsoft/house/entity/HouseAiChatResponse.java index 843edf9..b552b4d 100644 --- a/src/main/java/com/gxwebsoft/house/entity/HouseAiChatResponse.java +++ b/src/main/java/com/gxwebsoft/house/entity/HouseAiChatResponse.java @@ -27,6 +27,9 @@ public class HouseAiChatResponse implements Serializable { @Schema(description = "推荐房源") private List houses = new ArrayList<>(); + @Schema(description = "房源匹配结果类型 exact/approximate/none") + private String matchType = "none"; + @Schema(description = "语义解析结果") private HouseAiIntent intent; diff --git a/src/main/java/com/gxwebsoft/house/entity/HouseAiHouseCard.java b/src/main/java/com/gxwebsoft/house/entity/HouseAiHouseCard.java index a0a134e..64f880f 100644 --- a/src/main/java/com/gxwebsoft/house/entity/HouseAiHouseCard.java +++ b/src/main/java/com/gxwebsoft/house/entity/HouseAiHouseCard.java @@ -48,4 +48,7 @@ public class HouseAiHouseCard implements Serializable { @Schema(description = "办公室配套") private String supporting; + + @Schema(description = "房源匹配或接近原因") + private String matchReason; } diff --git a/src/main/java/com/gxwebsoft/house/service/impl/HouseAiChatServiceImpl.java b/src/main/java/com/gxwebsoft/house/service/impl/HouseAiChatServiceImpl.java index 229b067..9bf983c 100644 --- a/src/main/java/com/gxwebsoft/house/service/impl/HouseAiChatServiceImpl.java +++ b/src/main/java/com/gxwebsoft/house/service/impl/HouseAiChatServiceImpl.java @@ -5,17 +5,18 @@ import cn.hutool.core.util.StrUtil; import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; +import com.gxwebsoft.house.ai.HouseAiClarificationAdvisor; +import com.gxwebsoft.house.ai.HouseAiConversationMemory; +import com.gxwebsoft.house.ai.HouseAiMatchTypes; +import com.gxwebsoft.house.ai.HouseAiRecommendationExplainer; +import com.gxwebsoft.house.ai.HouseAiSearchEngine; +import com.gxwebsoft.house.ai.HouseAiSearchResult; import com.gxwebsoft.house.entity.HouseAiChatRequest; import com.gxwebsoft.house.entity.HouseAiChatResponse; -import com.gxwebsoft.house.entity.HouseAiHouseCard; import com.gxwebsoft.house.entity.HouseAiIntent; import com.gxwebsoft.house.entity.HouseFaq; -import com.gxwebsoft.house.entity.HouseInfo; -import com.gxwebsoft.house.mapper.HouseInfoMapper; -import com.gxwebsoft.house.param.HouseInfoParam; import com.gxwebsoft.house.service.HouseAiChatService; import com.gxwebsoft.house.service.HouseFaqService; -import com.gxwebsoft.house.service.HouseInfoService; import org.springframework.stereotype.Service; import javax.annotation.Resource; @@ -24,6 +25,7 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.math.BigDecimal; +import java.math.RoundingMode; import java.net.HttpURLConnection; import java.net.URL; import java.nio.charset.StandardCharsets; @@ -34,7 +36,6 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Set; -import java.util.regex.MatchResult; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -49,6 +50,11 @@ public class HouseAiChatServiceImpl implements HouseAiChatService { private static final String QWEN_API_KEY = "sk-3ce4f27d08ab4bdfac42b828119a694a"; private static final String QWEN_MODEL = "qwen3.6-flash"; private static final Pattern NUMBER_PATTERN = Pattern.compile("(\\d+(?:\\.\\d+)?)"); + private static final Pattern HOUSE_TYPE_COMPARTMENT_PATTERN = Pattern.compile("([一二两三四五六七八九十0-9]{1,2})\\s*隔间"); + private static final Pattern HOUSE_TYPE_ROOM_HALL_PATTERN = Pattern.compile("([一二两三四五六七八九十0-9]{1,2})\\s*室\\s*([一二两三四五六七八九十0-9]{1,2})\\s*厅"); + private static final BigDecimal RELAX_RATE = new BigDecimal("0.20"); + private static final BigDecimal RELAX_MIN_RATE = BigDecimal.ONE.subtract(RELAX_RATE); + private static final BigDecimal RELAX_MAX_RATE = BigDecimal.ONE.add(RELAX_RATE); private static final List FAQ_HINTS = Arrays.asList( "怎么", "如何", "能不能", "可以吗", "流程", "材料", "多久", "联系客服", "人工", "押金", "佣金", "停车", "发票", "签约", "看房" ); @@ -59,9 +65,13 @@ public class HouseAiChatServiceImpl implements HouseAiChatService { @Resource private HouseFaqService houseFaqService; @Resource - private HouseInfoService houseInfoService; + private HouseAiSearchEngine houseAiSearchEngine; @Resource - private HouseInfoMapper houseInfoMapper; + private HouseAiRecommendationExplainer recommendationExplainer; + @Resource + private HouseAiClarificationAdvisor clarificationAdvisor; + @Resource + private HouseAiConversationMemory conversationMemory; @Override public HouseAiIntent analyzeIntent(String question) { @@ -77,48 +87,58 @@ public class HouseAiChatServiceImpl implements HouseAiChatService { @Override public HouseAiChatResponse answer(HouseAiChatRequest request) { String question = request.getQuestion(); - HouseAiIntent intent = analyzeIntent(question); + HouseAiIntent intent = conversationMemory.merge(request, analyzeIntent(question)); HouseAiChatResponse response = new HouseAiChatResponse(); response.setIntent(intent); List faqMatches = houseFaqService.findBestMatches(question, 3); - if ("faq".equals(intent.getIntentType()) || "mixed".equals(intent.getIntentType())) { - if (!faqMatches.isEmpty()) { - response.setFaqs(faqMatches); - response.setAnswer("优先为您匹配到以下常见问题答案:"); - response.setSource("faq"); - if (!requiresHouseSearch(intent)) { - return response; - } + boolean shouldSearchHouses = clarificationAdvisor.requiresHouseSearch(intent); + if (!shouldSearchHouses) { + if (("faq".equals(intent.getIntentType()) || "mixed".equals(intent.getIntentType())) && !faqMatches.isEmpty()) { + fillFaqResponse(response, faqMatches); + return response; } + response.setAnswer(clarificationAdvisor.buildBlockingQuestion(intent)); + response.setMatchType(HouseAiMatchTypes.NONE); + response.setSource("ai"); + return response; } - - List houses = searchHouses(intent, question); - if (!houses.isEmpty()) { - response.setHouses(toHouseCards(houses)); - if (faqMatches.isEmpty()) { - response.setAnswer(buildHouseAnswer(intent, houses.size())); - response.setSource("house"); - } else { - response.setAnswer("优先为您匹配到常见问题答案,同时按您的需求筛选到以下房源:"); - response.setSource("faq"); - response.setFaqs(faqMatches); - } + String blockingQuestion = clarificationAdvisor.buildBlockingQuestion(intent); + if (StrUtil.isNotBlank(blockingQuestion)) { + response.setAnswer(blockingQuestion); + response.setMatchType(HouseAiMatchTypes.NONE); + response.setSource("ai"); return response; } if (!faqMatches.isEmpty()) { response.setFaqs(faqMatches); - response.setAnswer("优先为您匹配到以下常见问题答案:"); - response.setSource("faq"); + } + + HouseAiSearchResult searchResult = houseAiSearchEngine.search(intent, question); + if (searchResult.hasHouses()) { + response.setHouses(recommendationExplainer.toHouseCards(searchResult, intent)); + response.setAnswer(recommendationExplainer.buildHouseAnswer(intent, searchResult, !faqMatches.isEmpty())); + response.setMatchType(searchResult.getMatchType()); + response.setSource(faqMatches.isEmpty() ? "house" : "faq"); + conversationMemory.save(request, intent); return response; } - response.setAnswer("我先帮您理解了需求,但暂时没有筛到完全匹配的房源。您可以再补充面积、楼层、预算、区域或装修要求,我继续帮您细筛。"); - response.setSource("ai"); + response.setAnswer(recommendationExplainer.buildNoCandidateAnswer(intent)); + response.setMatchType(HouseAiMatchTypes.NONE); + response.setSource("house"); + conversationMemory.save(request, intent); return response; } + private void fillFaqResponse(HouseAiChatResponse response, List faqMatches) { + response.setFaqs(faqMatches); + response.setAnswer("优先为您匹配到以下常见问题答案:"); + response.setMatchType(HouseAiMatchTypes.NONE); + response.setSource("faq"); + } + private HouseAiIntent analyzeByAi(String question) { if (StrUtil.isBlank(question)) { return null; @@ -318,7 +338,7 @@ public class HouseAiChatServiceImpl implements HouseAiChatService { if (StrUtil.isNotBlank(aiIntent.getDecorationType())) base.setDecorationType(aiIntent.getDecorationType()); if (StrUtil.isNotBlank(aiIntent.getSupportingKeyword())) base.setSupportingKeyword(aiIntent.getSupportingKeyword()); if (StrUtil.isNotBlank(aiIntent.getToward())) base.setToward(aiIntent.getToward()); - if (StrUtil.isNotBlank(aiIntent.getHouseType())) base.setHouseType(aiIntent.getHouseType()); + if (StrUtil.isNotBlank(aiIntent.getHouseType())) base.setHouseType(normalizeHouseTypeKeyword(aiIntent.getHouseType())); if (StrUtil.isNotBlank(aiIntent.getWhereSql())) base.setWhereSql(aiIntent.getWhereSql()); if (StrUtil.isNotBlank(aiIntent.getOrderSql())) base.setOrderSql(aiIntent.getOrderSql()); if (aiIntent.getTags() != null && !aiIntent.getTags().isEmpty()) { @@ -352,274 +372,19 @@ public class HouseAiChatServiceImpl implements HouseAiChatService { if (StrUtil.isBlank(target.getSupportingKeyword())) target.setSupportingKeyword(fallback.getSupportingKeyword()); if (StrUtil.isBlank(target.getToward())) target.setToward(fallback.getToward()); if (StrUtil.isBlank(target.getHouseType())) target.setHouseType(fallback.getHouseType()); + if (StrUtil.isNotBlank(target.getHouseType())) target.setHouseType(normalizeHouseTypeKeyword(target.getHouseType())); if ((target.getTags() == null || target.getTags().isEmpty()) && fallback.getTags() != null) { target.setTags(fallback.getTags()); } } - private List searchHouses(HouseAiIntent intent, String question) { - List aiSqlHouses = searchHousesByAiSql(intent); - if (!aiSqlHouses.isEmpty()) { - return aiSqlHouses.stream().limit(10).collect(Collectors.toList()); - } - HouseInfoParam param = new HouseInfoParam(); - param.setStatus(0); - if (intent.getExtentMin() != null) { - param.setExtentStart(intent.getExtentMin()); - } - if (intent.getExtentMax() != null) { - param.setExtentEnd(intent.getExtentMax()); - } - if (StrUtil.isNotBlank(intent.getCityKeyword())) { - param.setCity(intent.getCityKeyword()); - } - if (StrUtil.isNotBlank(intent.getRegionKeyword())) { - param.setRegion(intent.getRegionKeyword()); - } - if (StrUtil.isNotBlank(intent.getToward())) { - param.setToward(intent.getToward()); - } - if (StrUtil.isNotBlank(intent.getHouseType())) { - param.setHouseType(intent.getHouseType()); - } - if (StrUtil.isNotBlank(intent.getDecorationType())) { - param.setHouseLabel(intent.getDecorationType()); - } - if (StrUtil.isNotBlank(intent.getSupportingKeyword())) { - param.setContent(intent.getSupportingKeyword()); - } - param.setKeywords(buildHouseKeywords(intent, question)); - - List houses = houseInfoService.listRel(param); - return filterHouses(houses, intent).stream().limit(10).collect(Collectors.toList()); - } - - private List searchHousesByAiSql(HouseAiIntent intent) { - if (StrUtil.isBlank(intent.getWhereSql())) { - return Collections.emptyList(); - } - String whereSql = sanitizeWhereSql(intent.getWhereSql()); - String orderSql = sanitizeOrderSql(intent.getOrderSql()); - if (StrUtil.isBlank(whereSql)) { - return Collections.emptyList(); - } - try { - return houseInfoMapper.selectListByAiSql(whereSql, orderSql); - } catch (Exception e) { - return Collections.emptyList(); - } - } - - private List filterHouses(List houses, HouseAiIntent intent) { - if (houses == null || houses.isEmpty()) { - return Collections.emptyList(); - } - return houses.stream() - .filter(item -> matchFloor(item.getFloor(), intent)) - .filter(item -> matchMoney(item.getMonthlyRent(), intent.getMonthlyRentMin(), intent.getMonthlyRentMax())) - .filter(item -> matchMoney(parseDecimal(item.getSalePrice()), intent.getSalePriceMin(), intent.getSalePriceMax())) - .filter(item -> matchMoney(parseDecimal(item.getTotalPrice()), intent.getTotalPriceMin(), intent.getTotalPriceMax())) - .filter(item -> matchTradeType(item, intent)) - .filter(item -> matchText(item, intent)) - .collect(Collectors.toList()); - } - - private List toHouseCards(List houses) { - return houses.stream().map(item -> { - HouseAiHouseCard card = new HouseAiHouseCard(); - card.setHouseId(item.getHouseId()); - card.setHouseTitle(item.getHouseTitle()); - card.setHouseType(item.getHouseType()); - card.setExtent(item.getExtent()); - card.setFloor(item.getFloor()); - card.setToward(item.getToward()); - card.setMonthlyRent(item.getMonthlyRent() == null ? null : item.getMonthlyRent().stripTrailingZeros().toPlainString()); - card.setCity(item.getCity()); - card.setRegion(item.getRegion()); - card.setAddress(item.getAddress()); - card.setFiles(item.getFiles()); - card.setSupporting(item.getSupporting()); - return card; - }).collect(Collectors.toList()); - } - - private boolean matchTradeType(HouseInfo item, HouseAiIntent intent) { - if (StrUtil.isBlank(intent.getTradeType())) { - return true; - } - if ("sale".equals(intent.getTradeType())) { - return parseDecimal(item.getSalePrice()) != null || parseDecimal(item.getTotalPrice()) != null; - } - if ("rent".equals(intent.getTradeType())) { - return item.getMonthlyRent() != null || item.getRent() != null; - } - return true; - } - - private boolean matchText(HouseInfo item, HouseAiIntent intent) { - if (StrUtil.isNotBlank(intent.getCityKeyword())) { - String cityText = normalize(item.getCity()) + " " + normalize(item.getCityByHouse()); - if (!cityText.contains(normalize(intent.getCityKeyword()))) { - return false; - } - } - if (StrUtil.isNotBlank(intent.getRegionKeyword())) { - String text = normalize(item.getRegion()) + " " + normalize(item.getArea()) + " " + normalize(item.getAddress()) + " " + normalize(item.getCity()) + " " + normalize(item.getCityByHouse()); - if (!text.contains(normalize(intent.getRegionKeyword()))) { - return false; - } - } - if (StrUtil.isNotBlank(intent.getDecorationType())) { - String text = normalize(item.getHouseLabel()) + " " + normalize(item.getSupporting()) + " " + normalize(item.getContent()); - if (!text.contains(normalize(intent.getDecorationType()))) { - return false; - } - } - if (StrUtil.isNotBlank(intent.getSupportingKeyword())) { - String text = normalize(item.getSupporting()) + " " + normalize(item.getContent()) + " " + normalize(item.getHouseLabel()); - if (!text.contains(normalize(intent.getSupportingKeyword()))) { - return false; - } - } - return true; - } - - private boolean matchFloor(String floor, HouseAiIntent intent) { - Integer currentFloor = extractFirstInteger(floor); - if (currentFloor == null) { - return true; - } - if (intent.getFloorMin() != null && currentFloor < intent.getFloorMin()) { - return false; - } - if (intent.getFloorMax() != null && currentFloor > intent.getFloorMax()) { - return false; - } - return true; - } - - private boolean matchMoney(BigDecimal current, BigDecimal min, BigDecimal max) { - if (current == null) { - return true; - } - if (min != null && current.compareTo(min) < 0) { - return false; - } - if (max != null && current.compareTo(max) > 0) { - return false; - } - return true; - } - - private String buildHouseKeywords(HouseAiIntent intent, String question) { - Set keywords = new LinkedHashSet<>(); - if (StrUtil.isNotBlank(intent.getCityKeyword())) { - keywords.add(intent.getCityKeyword()); - } - if (StrUtil.isNotBlank(intent.getRegionKeyword())) { - keywords.add(intent.getRegionKeyword()); - } - if (StrUtil.isNotBlank(intent.getDecorationType())) { - keywords.add(intent.getDecorationType()); - } - if (StrUtil.isNotBlank(intent.getSupportingKeyword())) { - keywords.add(intent.getSupportingKeyword()); - } - if (StrUtil.isNotBlank(intent.getToward())) { - keywords.add(intent.getToward()); - } - if (StrUtil.isNotBlank(intent.getHouseType())) { - keywords.add(intent.getHouseType()); - } - if (intent.getTags() != null) { - keywords.addAll(intent.getTags()); - } - if (!keywords.isEmpty()) { - return keywords.iterator().next(); - } - return shortenQuestion(question); - } - - private boolean requiresHouseSearch(HouseAiIntent intent) { - return hasHouseCondition(intent) || "mixed".equals(intent.getIntentType()) || "house".equals(intent.getIntentType()); - } - - private boolean hasHouseCondition(HouseAiIntent intent) { - return intent.getExtentMin() != null - || intent.getExtentMax() != null - || intent.getFloorMin() != null - || intent.getFloorMax() != null - || intent.getMonthlyRentMin() != null - || intent.getMonthlyRentMax() != null - || intent.getSalePriceMin() != null - || intent.getSalePriceMax() != null - || intent.getTotalPriceMin() != null - || intent.getTotalPriceMax() != null - || StrUtil.isNotBlank(intent.getCityKeyword()) - || StrUtil.isNotBlank(intent.getRegionKeyword()) - || StrUtil.isNotBlank(intent.getDecorationType()) - || StrUtil.isNotBlank(intent.getSupportingKeyword()) - || StrUtil.isNotBlank(intent.getToward()) - || StrUtil.isNotBlank(intent.getHouseType()); - } - - private String buildHouseAnswer(HouseAiIntent intent, int size) { - StringBuilder sb = new StringBuilder("已根据您的需求筛选到"); - sb.append(size).append("套较匹配的房源"); - List desc = new ArrayList<>(); - if (intent.getExtentMin() != null && intent.getExtentMax() != null) { - desc.add(intent.getExtentMin() + "-" + intent.getExtentMax() + "平"); - } else if (intent.getExtentMax() != null) { - desc.add(intent.getExtentMax() + "平以下"); - } else if (intent.getExtentMin() != null) { - desc.add(intent.getExtentMin() + "平以上"); - } - if (intent.getFloorMin() != null && intent.getFloorMax() != null) { - desc.add(intent.getFloorMin() + "-" + intent.getFloorMax() + "楼"); - } else if (intent.getFloorMin() != null) { - desc.add(intent.getFloorMin() + "楼以上"); - } else if (intent.getFloorMax() != null) { - desc.add(intent.getFloorMax() + "楼以下"); - } - if (intent.getMonthlyRentMin() != null && intent.getMonthlyRentMax() != null) { - desc.add("月租" + formatMoney(intent.getMonthlyRentMin()) + "-" + formatMoney(intent.getMonthlyRentMax()) + "元"); - } else if (intent.getMonthlyRentMax() != null) { - desc.add("月租" + intent.getMonthlyRentMax().stripTrailingZeros().toPlainString() + "元以内"); - } else if (intent.getMonthlyRentMin() != null) { - desc.add("月租" + intent.getMonthlyRentMin().stripTrailingZeros().toPlainString() + "元以上"); - } - if (intent.getSalePriceMin() != null || intent.getSalePriceMax() != null || intent.getTotalPriceMin() != null || intent.getTotalPriceMax() != null) { - String saleText = buildSaleText(intent); - if (StrUtil.isNotBlank(saleText)) { - desc.add(saleText); - } - } - if (StrUtil.isNotBlank(intent.getCityKeyword())) { - desc.add(intent.getCityKeyword()); - } - if (StrUtil.isNotBlank(intent.getRegionKeyword())) { - desc.add(intent.getRegionKeyword()); - } - if (StrUtil.isNotBlank(intent.getDecorationType())) { - desc.add(intent.getDecorationType()); - } - if (StrUtil.isNotBlank(intent.getSupportingKeyword())) { - desc.add(intent.getSupportingKeyword()); - } - if (!desc.isEmpty()) { - sb.append(",条件包括:").append(String.join("、", desc)); - } - sb.append("。"); - return sb.toString(); - } - private String detectIntentType(String question) { String normalized = normalize(question); boolean faq = FAQ_HINTS.stream().anyMatch(normalized::contains); boolean house = normalized.contains("平") || normalized.contains("楼") || normalized.contains("租") || normalized.contains("预算") || normalized.contains("区域") || normalized.contains("地段") || normalized.contains("装修") || normalized.contains("朝向") || normalized.contains("房型") || - normalized.contains("室") || normalized.contains("厅") || normalized.contains("电梯"); + normalized.contains("室") || normalized.contains("厅") || normalized.contains("隔间") || normalized.contains("电梯"); if (faq && house) { return "mixed"; } @@ -648,11 +413,20 @@ public class HouseAiChatServiceImpl implements HouseAiChatService { } else if (containsAny(context, "以上", "不少于", "大于", "不低于")) { intent.setExtentMin(value); } else if (intent.getExtentMin() == null && intent.getExtentMax() == null) { - intent.setExtentMax(value); + setTargetExtentRange(value, intent); } } } + private void setTargetExtentRange(Integer value, HouseAiIntent intent) { + if (value == null) { + return; + } + BigDecimal target = new BigDecimal(value); + intent.setExtentMin(target.multiply(RELAX_MIN_RATE).setScale(0, RoundingMode.FLOOR).intValue()); + intent.setExtentMax(target.multiply(RELAX_MAX_RATE).setScale(0, RoundingMode.CEILING).intValue()); + } + private void parseFloor(String question, HouseAiIntent intent) { String normalized = normalize(question); Matcher rangeMatcher = Pattern.compile("(\\d+)\\s*(?:-|到|至)\\s*(\\d+)\\s*楼").matcher(normalized); @@ -676,22 +450,28 @@ public class HouseAiChatServiceImpl implements HouseAiChatService { private void parseMonthlyRent(String question, HouseAiIntent intent) { String normalized = normalize(question); - Matcher rangeMatcher = Pattern.compile("(月租|租金|预算)?\\s*(\\d+(?:\\.\\d+)?)\\s*(?:-|到|至)\\s*(\\d+(?:\\.\\d+)?)\\s*(元|块|w|万)").matcher(normalized); + Matcher rangeMatcher = Pattern.compile("(月租|租金|预算|租)?\\s*(\\d+(?:\\.\\d+)?)\\s*(?:-|到|至)\\s*(\\d+(?:\\.\\d+)?)\\s*(元|块|w|万)").matcher(normalized); if (rangeMatcher.find()) { intent.setMonthlyRentMin(parseMoney(rangeMatcher.group(2), rangeMatcher.group(4))); intent.setMonthlyRentMax(parseMoney(rangeMatcher.group(3), rangeMatcher.group(4))); } - Matcher matcher = Pattern.compile("(月租|租金|预算)?\\s*(\\d+(?:\\.\\d+)?)\\s*(元|块|w|万)?").matcher(normalized); + Matcher matcher = Pattern.compile("(月租|租金|预算|租)?\\s*(\\d+(?:\\.\\d+)?)\\s*(元|块|w|万)?").matcher(normalized); while (matcher.find()) { String prefix = matcher.group(1); String raw = matcher.group(2); String unit = matcher.group(3); - if (StrUtil.isBlank(prefix) && !normalized.contains("预算") && !normalized.contains("租")) { + if (StrUtil.isBlank(prefix) && StrUtil.isBlank(unit)) { continue; } BigDecimal value = parseMoney(raw, unit); String context = normalized.substring(Math.max(0, matcher.start() - 8), Math.min(normalized.length(), matcher.end() + 8)); - if (containsAny(context, "月租", "租金", "预算", "元", "块", "w", "万")) { + if (StrUtil.isBlank(prefix) && containsAny(context, "平", "平方", "室", "厅", "隔间", "楼")) { + continue; + } + if (StrUtil.isBlank(prefix) && containsAny(context, "售价", "卖价", "总价")) { + continue; + } + if (containsAny(context, "月租", "租金", "预算", "租", "元", "块", "w", "万")) { if (containsAny(context, "以下", "以内", "不超过", "小于", "最多")) { intent.setMonthlyRentMax(value); } else if (containsAny(context, "以上", "不少于", "大于", "至少")) { @@ -801,15 +581,7 @@ public class HouseAiChatServiceImpl implements HouseAiChatService { if (index >= 0) { String part = normalized.substring(index + marker.length()).trim(); if (part.length() > 0) { - part = part.replaceAll("^(的|位于|靠近)", ""); - for (String stopWord : REGION_STOP_WORDS) { - int stopIndex = part.indexOf(stopWord); - if (stopIndex > 0) { - part = part.substring(0, stopIndex); - } - } - part = part.replaceAll("([++]|并且|而且|然后).*", ""); - part = part.trim(); + part = normalizeRegionCandidate(part); if (part.length() >= 2) { intent.setRegionKeyword(part.length() > 12 ? part.substring(0, 12) : part); return; @@ -852,17 +624,123 @@ public class HouseAiChatServiceImpl implements HouseAiChatService { private void parseHouseType(String question, HouseAiIntent intent) { String normalized = normalize(question); - for (String item : Arrays.asList("一室一厅", "两室一厅", "三室一厅", "三室两厅", "四室两厅", "一房一厅", "两房一厅", "三房一厅", "一房", "两房", "三房", "四房", "开间", "loft", "写字楼", "办公室", "商铺")) { + for (String item : Arrays.asList("一隔间", "二隔间", "三隔间", "四隔间", "五隔间", "一室一厅", "两室一厅", "三室一厅", "三室两厅", "四室两厅", "一房一厅", "两房一厅", "三房一厅", "一房", "两房", "三房", "四房", "开间", "loft", "写字楼", "办公室", "商铺")) { if (normalized.contains(normalize(item))) { - intent.setHouseType(item); + intent.setHouseType(normalizeHouseTypeKeyword(item)); return; } } - Matcher matcher = Pattern.compile("([一二三四五12345])\\s*室\\s*([一二三四五12345])\\s*厅").matcher(question); - if (matcher.find()) { - intent.setHouseType(matcher.group(1) + "室" + matcher.group(2) + "厅"); + Matcher compartmentMatcher = HOUSE_TYPE_COMPARTMENT_PATTERN.matcher(normalized); + if (compartmentMatcher.find()) { + intent.setHouseType(toChineseHouseNumber(compartmentMatcher.group(1)) + "隔间"); return; } + Matcher matcher = HOUSE_TYPE_ROOM_HALL_PATTERN.matcher(normalized); + if (matcher.find()) { + intent.setHouseType(toChineseHouseNumber(matcher.group(1)) + "室" + toChineseHouseNumber(matcher.group(2)) + "厅"); + return; + } + } + + private String normalizeRegionCandidate(String part) { + String candidate = safeText(part).trim() + .replaceAll("^(的|位于|靠近|个|一个|一套|套|间|房子|房源)", ""); + int stopIndex = firstRegionStopIndex(candidate); + if (stopIndex >= 0) { + candidate = candidate.substring(0, stopIndex); + } + candidate = candidate.replaceAll("([,,。;;]|[++]|并且|而且|然后).*", ""); + candidate = candidate.replaceAll("(的|附近)$", ""); + candidate = candidate.trim(); + if (candidate.matches(".*\\d.*")) { + return ""; + } + if (containsAny(candidate, "平方", "预算", "月租", "租金", "隔间", "室", "厅", "楼", "装修")) { + return ""; + } + return candidate; + } + + private int firstRegionStopIndex(String text) { + int first = -1; + List stopWords = new ArrayList<>(REGION_STOP_WORDS); + stopWords.addAll(Arrays.asList("平方", "预算", "月租", "租金", "隔间", "室", "厅", "楼", "装修")); + for (String stopWord : stopWords) { + int index = text.indexOf(stopWord); + if (index >= 0 && (first < 0 || index < first)) { + first = index; + } + } + Matcher matcher = Pattern.compile("\\d").matcher(text); + if (matcher.find() && (first < 0 || matcher.start() < first)) { + first = matcher.start(); + } + return first; + } + + private String normalizeHouseTypeKeyword(String keyword) { + return normalizeSearchText(keyword); + } + + private String normalizeSearchText(String text) { + String normalized = normalize(text); + normalized = replaceHouseNumberPattern(normalized, HOUSE_TYPE_COMPARTMENT_PATTERN, "隔间"); + Matcher matcher = HOUSE_TYPE_ROOM_HALL_PATTERN.matcher(normalized); + StringBuffer buffer = new StringBuffer(); + while (matcher.find()) { + String replacement = toChineseHouseNumber(matcher.group(1)) + "室" + toChineseHouseNumber(matcher.group(2)) + "厅"; + matcher.appendReplacement(buffer, Matcher.quoteReplacement(replacement)); + } + matcher.appendTail(buffer); + return buffer.toString(); + } + + private String replaceHouseNumberPattern(String text, Pattern pattern, String suffix) { + Matcher matcher = pattern.matcher(text); + StringBuffer buffer = new StringBuffer(); + while (matcher.find()) { + matcher.appendReplacement(buffer, Matcher.quoteReplacement(toChineseHouseNumber(matcher.group(1)) + suffix)); + } + matcher.appendTail(buffer); + return buffer.toString(); + } + + private String toChineseHouseNumber(String raw) { + String value = normalize(raw).replace("两", "二"); + switch (value) { + case "1": + case "一": + return "一"; + case "2": + case "二": + return "二"; + case "3": + case "三": + return "三"; + case "4": + case "四": + return "四"; + case "5": + case "五": + return "五"; + case "6": + case "六": + return "六"; + case "7": + case "七": + return "七"; + case "8": + case "八": + return "八"; + case "9": + case "九": + return "九"; + case "10": + case "十": + return "十"; + default: + return value; + } } private List extractTags(String question) { @@ -908,6 +786,10 @@ public class HouseAiChatServiceImpl implements HouseAiChatService { .trim(); } + private String safeText(String text) { + return text == null ? "" : text; + } + private boolean containsAny(String text, String... values) { if (text == null) { return false; @@ -931,119 +813,4 @@ public class HouseAiChatServiceImpl implements HouseAiChatService { return value; } - private String formatMoney(BigDecimal value) { - if (value == null) { - return ""; - } - return value.stripTrailingZeros().toPlainString(); - } - - private String buildSaleText(HouseAiIntent intent) { - if (intent.getTradeType() != null && "sale".equals(intent.getTradeType())) { - if (intent.getTotalPriceMin() != null && intent.getTotalPriceMax() != null) { - return "总价" + formatMoney(intent.getTotalPriceMin()) + "-" + formatMoney(intent.getTotalPriceMax()) + "元"; - } - if (intent.getTotalPriceMax() != null) { - return "总价" + formatMoney(intent.getTotalPriceMax()) + "元以内"; - } - if (intent.getSalePriceMin() != null && intent.getSalePriceMax() != null) { - return "售价" + formatMoney(intent.getSalePriceMin()) + "-" + formatMoney(intent.getSalePriceMax()) + "元"; - } - if (intent.getSalePriceMax() != null) { - return "售价" + formatMoney(intent.getSalePriceMax()) + "元以内"; - } - } - return ""; - } - - private String sanitizeWhereSql(String whereSql) { - if (StrUtil.isBlank(whereSql)) { - return null; - } - String normalized = whereSql.trim() - .replaceAll("(?i)^\\s*where\\s+", "") - .replaceAll("(?i)\\bselect\\b", "") - .replaceAll("(?i)\\bupdate\\b", "") - .replaceAll("(?i)\\bdelete\\b", "") - .replaceAll("(?i)\\binsert\\b", "") - .replaceAll("(?i)\\bdrop\\b", "") - .replaceAll("(?i)\\btruncate\\b", "") - .replaceAll("(?i)\\bunion\\b", "") - .replaceAll(";", "") - .trim(); - if (StrUtil.isBlank(normalized)) { - return null; - } - if (Pattern.compile("(?i)\\border\\s+by\\b").matcher(normalized).find()) { - return null; - } - List allowedColumns = Arrays.asList( - "a.house_type", "a.monthly_rent", "a.sale_price", "a.total_price", "a.extent", "a.floor", - "a.city", "a.city_by_house", "a.region", "a.area", "a.address", "a.house_label", - "a.supporting", "a.content", "a.toward", "a.lease_method" - ); - Matcher matcher = Pattern.compile("a\\.[a-zA-Z_]+").matcher(normalized); - while (matcher.find()) { - String column = matcher.group(); - if (!allowedColumns.contains(column)) { - return null; - } - } - if (normalized.contains("--") || normalized.contains("/*") || normalized.contains("*/")) { - return null; - } - return normalized; - } - - private String sanitizeOrderSql(String orderSql) { - if (StrUtil.isBlank(orderSql)) { - return null; - } - String normalized = orderSql.trim() - .replaceAll("(?i)\\border\\s+by\\b", "") - .replaceAll(";", "") - .trim(); - if (StrUtil.isBlank(normalized)) { - return null; - } - List allowedColumns = Arrays.asList( - "a.sort_number", "a.create_time", "a.monthly_rent", "a.sale_price", "a.total_price", "a.extent", "a.floor" - ); - for (String item : normalized.split(",")) { - String[] parts = item.trim().split("\\s+"); - if (parts.length == 0 || !allowedColumns.contains(parts[0])) { - return null; - } - if (parts.length > 1 && !("asc".equalsIgnoreCase(parts[1]) || "desc".equalsIgnoreCase(parts[1]))) { - return null; - } - } - return normalized; - } - - private BigDecimal parseDecimal(String raw) { - if (StrUtil.isBlank(raw)) { - return null; - } - String number = raw.replaceAll("[^0-9.]", ""); - if (StrUtil.isBlank(number)) { - return null; - } - try { - return new BigDecimal(number); - } catch (Exception e) { - return null; - } - } - - private Integer extractFirstInteger(String raw) { - if (StrUtil.isBlank(raw)) { - return null; - } - Matcher matcher = NUMBER_PATTERN.matcher(raw); - if (matcher.find()) { - return NumberUtil.parseInt(matcher.group(1)); - } - return null; - } } diff --git a/src/test/java/com/gxwebsoft/house/service/impl/HouseAiChatServiceImplTest.java b/src/test/java/com/gxwebsoft/house/service/impl/HouseAiChatServiceImplTest.java new file mode 100644 index 0000000..4a25026 --- /dev/null +++ b/src/test/java/com/gxwebsoft/house/service/impl/HouseAiChatServiceImplTest.java @@ -0,0 +1,242 @@ +package com.gxwebsoft.house.service.impl; + +import com.gxwebsoft.house.ai.HouseAiClarificationAdvisor; +import com.gxwebsoft.house.ai.HouseAiConversationMemory; +import com.gxwebsoft.house.ai.HouseAiRecommendationExplainer; +import com.gxwebsoft.house.ai.HouseAiSearchEngine; +import com.gxwebsoft.house.entity.HouseAiChatRequest; +import com.gxwebsoft.house.entity.HouseAiChatResponse; +import com.gxwebsoft.house.entity.HouseAiIntent; +import com.gxwebsoft.house.entity.HouseInfo; +import com.gxwebsoft.house.mapper.HouseInfoMapper; +import com.gxwebsoft.house.param.HouseInfoParam; +import com.gxwebsoft.house.service.HouseFaqService; +import com.gxwebsoft.house.service.HouseInfoService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class HouseAiChatServiceImplTest { + + @Mock + private HouseFaqService houseFaqService; + + @Mock + private HouseInfoService houseInfoService; + + @Mock + private HouseInfoMapper houseInfoMapper; + + private HouseAiChatServiceImpl service; + + @BeforeEach + void setUp() { + service = spy(new HouseAiChatServiceImpl()); + HouseAiSearchEngine searchEngine = new HouseAiSearchEngine(); + ReflectionTestUtils.setField(searchEngine, "houseInfoService", houseInfoService); + ReflectionTestUtils.setField(searchEngine, "houseInfoMapper", houseInfoMapper); + ReflectionTestUtils.setField(service, "houseFaqService", houseFaqService); + ReflectionTestUtils.setField(service, "houseAiSearchEngine", searchEngine); + ReflectionTestUtils.setField(service, "recommendationExplainer", new HouseAiRecommendationExplainer()); + ReflectionTestUtils.setField(service, "clarificationAdvisor", new HouseAiClarificationAdvisor()); + ReflectionTestUtils.setField(service, "conversationMemory", new HouseAiConversationMemory()); + lenient().when(houseFaqService.findBestMatches(anyString(), anyInt())).thenReturn(Collections.emptyList()); + } + + @Test + void answerReturnsExactMatchWhenStrictSearchHasHouses() { + HouseAiIntent intent = rentIntent(); + HouseInfo exactHouse = house(1, "青秀近地铁 100 平", "南宁", "青秀区", "100", "2800", 0); + doReturn(intent).when(service).analyzeIntent(anyString()); + when(houseInfoService.listRel(any(HouseInfoParam.class))).thenReturn(Collections.singletonList(exactHouse)); + + HouseAiChatResponse response = service.answer(request()); + + assertEquals("exact", response.getMatchType()); + assertEquals(1, response.getHouses().size()); + assertEquals(Integer.valueOf(1), response.getHouses().get(0).getHouseId()); + assertNotNull(response.getHouses().get(0).getMatchReason()); + assertTrue(response.getAnswer().contains("已根据您的需求筛选到")); + } + + @Test + void answerReturnsApproximateHousesWhenExactSearchIsEmpty() { + HouseAiIntent intent = rentIntent(); + HouseInfo closeHouse = house(2, "青秀预算略超 90 平", "南宁", "青秀区", "90", "3300", 0); + HouseInfo tooExpensive = house(3, "青秀超预算 90 平", "南宁", "青秀区", "90", "3700", 0); + HouseInfo wrongRegion = house(4, "西乡塘 90 平", "南宁", "西乡塘区", "90", "2800", 0); + doReturn(intent).when(service).analyzeIntent(anyString()); + when(houseInfoService.listRel(any(HouseInfoParam.class))) + .thenReturn(Collections.emptyList()) + .thenReturn(Arrays.asList(closeHouse, tooExpensive, wrongRegion)); + + HouseAiChatResponse response = service.answer(request()); + + assertEquals("approximate", response.getMatchType()); + assertTrue(response.getAnswer().contains("比较接近")); + assertEquals(1, response.getHouses().size()); + assertEquals(Integer.valueOf(2), response.getHouses().get(0).getHouseId()); + assertNotNull(response.getHouses().get(0).getMatchReason()); + } + + @Test + void answerReturnsNoneWhenHardConditionHasNoCandidate() { + HouseAiIntent intent = rentIntent(); + HouseInfo wrongRegion = house(5, "西乡塘 90 平", "南宁", "西乡塘区", "90", "2800", 0); + doReturn(intent).when(service).analyzeIntent(anyString()); + when(houseInfoService.listRel(any(HouseInfoParam.class))) + .thenReturn(Collections.emptyList()) + .thenReturn(Collections.singletonList(wrongRegion)); + + HouseAiChatResponse response = service.answer(request()); + + assertEquals("none", response.getMatchType()); + assertTrue(response.getHouses().isEmpty()); + assertTrue(response.getAnswer().contains("暂时没有找到")); + } + + @Test + void answerSortsApproximateHousesByBudgetBeforeExtent() { + HouseAiIntent intent = rentIntent(); + HouseInfo overBudgetExactExtent = house(6, "青秀面积合适预算略超", "南宁", "青秀区", "100", "3030", 0); + HouseInfo underBudgetRelaxedExtent = house(7, "青秀预算合适面积略小", "南宁", "青秀区", "80", "2900", 0); + doReturn(intent).when(service).analyzeIntent(anyString()); + when(houseInfoService.listRel(any(HouseInfoParam.class))) + .thenReturn(Collections.emptyList()) + .thenReturn(Arrays.asList(overBudgetExactExtent, underBudgetRelaxedExtent)); + + HouseAiChatResponse response = service.answer(request()); + + assertEquals("approximate", response.getMatchType()); + assertEquals(Integer.valueOf(7), response.getHouses().get(0).getHouseId()); + } + + @Test + void fallbackIntentParsesOriginalQuestionWithoutFakeRegion() { + HouseAiIntent intent = ReflectionTestUtils.invokeMethod( + service, + "buildFallbackIntent", + "帮我找个100平的2隔间,预算3000左右" + ); + + assertEquals("house", intent.getIntentType()); + assertEquals(Integer.valueOf(80), intent.getExtentMin()); + assertEquals(Integer.valueOf(120), intent.getExtentMax()); + assertEquals(new BigDecimal("3000"), intent.getMonthlyRentMax()); + assertEquals("二隔间", intent.getHouseType()); + assertNull(intent.getRegionKeyword()); + } + + @Test + void answerReturnsApproximateForOriginalQuestionWhenExactSearchIsEmpty() { + String question = "帮我找个100平的2隔间,预算3000左右"; + HouseAiIntent intent = ReflectionTestUtils.invokeMethod(service, "buildFallbackIntent", question); + HouseInfo closeHouse = house(8, "太平金融大厦 106平二隔间", "南宁", "良庆区", "106.78", "747.46", 0); + closeHouse.setHouseType("二隔间"); + doReturn(intent).when(service).analyzeIntent(question); + when(houseInfoService.listRel(any(HouseInfoParam.class))) + .thenReturn(Collections.emptyList()) + .thenReturn(Collections.singletonList(closeHouse)); + + HouseAiChatResponse response = service.answer(request(question)); + + assertEquals("approximate", response.getMatchType()); + assertEquals(1, response.getHouses().size()); + assertEquals(Integer.valueOf(8), response.getHouses().get(0).getHouseId()); + assertNotNull(response.getHouses().get(0).getMatchReason()); + } + + @Test + void answerUsesConversationMemoryForCheaperFollowUp() { + HouseAiIntent firstIntent = rentIntent(); + HouseAiIntent followUpIntent = new HouseAiIntent(); + followUpIntent.setIntentType("house"); + HouseInfo firstHouse = house(9, "青秀 100 平", "南宁", "青秀区", "100", "2800", 0); + HouseInfo cheapHouse = house(10, "青秀更便宜 100 平", "南宁", "青秀区", "100", "2600", 0); + doReturn(firstIntent).doReturn(followUpIntent).when(service).analyzeIntent(anyString()); + when(houseInfoService.listRel(any(HouseInfoParam.class))) + .thenReturn(Collections.singletonList(firstHouse)) + .thenReturn(Collections.singletonList(cheapHouse)); + + service.answer(request("南宁青秀区找 100 平以上月租 3000 以内的房子", "conv-1")); + HouseAiChatResponse response = service.answer(request("便宜点", "conv-1")); + + assertEquals(new BigDecimal("2700"), response.getIntent().getMonthlyRentMax()); + assertEquals(Integer.valueOf(10), response.getHouses().get(0).getHouseId()); + } + + @Test + void answerAsksClarifyingQuestionWhenHouseIntentHasNoCondition() { + HouseAiIntent emptyHouseIntent = new HouseAiIntent(); + emptyHouseIntent.setIntentType("house"); + doReturn(emptyHouseIntent).when(service).analyzeIntent(anyString()); + + HouseAiChatResponse response = service.answer(request("帮我找房")); + + assertEquals("none", response.getMatchType()); + assertTrue(response.getHouses().isEmpty()); + assertTrue(response.getAnswer().contains("区域")); + verify(houseInfoService, never()).listRel(any(HouseInfoParam.class)); + } + + private HouseAiChatRequest request() { + return request("南宁青秀区找 100 平以上月租 3000 以内的房子"); + } + + private HouseAiChatRequest request(String question) { + return request(question, null); + } + + private HouseAiChatRequest request(String question, String conversationId) { + HouseAiChatRequest request = new HouseAiChatRequest(); + request.setConversationId(conversationId); + request.setUserId(1); + request.setQuestion(question); + return request; + } + + private HouseAiIntent rentIntent() { + HouseAiIntent intent = new HouseAiIntent(); + intent.setIntentType("house"); + intent.setTradeType("rent"); + intent.setCityKeyword("南宁"); + intent.setRegionKeyword("青秀区"); + intent.setExtentMin(100); + intent.setMonthlyRentMax(new BigDecimal("3000")); + return intent; + } + + private HouseInfo house(Integer id, String title, String city, String region, String extent, String monthlyRent, Integer recommend) { + HouseInfo house = new HouseInfo(); + house.setHouseId(id); + house.setHouseTitle(title); + house.setCity(city); + house.setRegion(region); + house.setExtent(extent); + house.setMonthlyRent(new BigDecimal(monthlyRent)); + house.setRecommend(recommend); + return house; + } +}