feat(house): 重构AI找房近似推荐
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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<String, HouseAiIntent> 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;
|
||||
}
|
||||
}
|
||||
14
src/main/java/com/gxwebsoft/house/ai/HouseAiMatchTypes.java
Normal file
14
src/main/java/com/gxwebsoft/house/ai/HouseAiMatchTypes.java
Normal file
@@ -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() {
|
||||
}
|
||||
}
|
||||
@@ -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<HouseAiHouseCard> 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<String> 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<String> desc = buildConditionDescriptions(intent);
|
||||
if (!desc.isEmpty()) {
|
||||
sb.append(",条件包括:").append(String.join("、", desc));
|
||||
}
|
||||
sb.append("。");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private List<String> buildConditionDescriptions(HouseAiIntent intent) {
|
||||
List<String> 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<String> 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<String> 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<String> 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<String> 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();
|
||||
}
|
||||
}
|
||||
584
src/main/java/com/gxwebsoft/house/ai/HouseAiSearchEngine.java
Normal file
584
src/main/java/com/gxwebsoft/house/ai/HouseAiSearchEngine.java
Normal file
@@ -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<HouseInfo> structuredHouses = searchStructuredHouses(intent, question);
|
||||
if (!structuredHouses.isEmpty()) {
|
||||
return HouseAiSearchResult.exact(structuredHouses);
|
||||
}
|
||||
|
||||
List<HouseInfo> aiSqlHouses = searchHousesByAiSql(intent);
|
||||
if (!aiSqlHouses.isEmpty()) {
|
||||
return HouseAiSearchResult.exact(aiSqlHouses.stream().limit(EXACT_HOUSE_LIMIT).collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
List<HouseInfo> approximateHouses = searchApproximateHouses(intent);
|
||||
if (!approximateHouses.isEmpty()) {
|
||||
return HouseAiSearchResult.approximate(approximateHouses);
|
||||
}
|
||||
|
||||
return HouseAiSearchResult.none();
|
||||
}
|
||||
|
||||
private List<HouseInfo> 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<HouseInfo> 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<HouseInfo> 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<HouseInfo> filterHouses(List<HouseInfo> 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<HouseInfo> searchApproximateHouses(HouseAiIntent intent) {
|
||||
HouseInfoParam param = new HouseInfoParam();
|
||||
param.setStatus(0);
|
||||
|
||||
List<HouseInfo> 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<String> 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<String> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<HouseInfo> houses = new ArrayList<>();
|
||||
|
||||
public static HouseAiSearchResult exact(List<HouseInfo> houses) {
|
||||
return of(HouseAiMatchTypes.EXACT, houses);
|
||||
}
|
||||
|
||||
public static HouseAiSearchResult approximate(List<HouseInfo> 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<HouseInfo> houses) {
|
||||
HouseAiSearchResult result = new HouseAiSearchResult();
|
||||
result.setMatchType(matchType);
|
||||
result.setHouses(houses == null ? new ArrayList<>() : houses);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,9 @@ public class HouseAiChatResponse implements Serializable {
|
||||
@Schema(description = "推荐房源")
|
||||
private List<HouseAiHouseCard> houses = new ArrayList<>();
|
||||
|
||||
@Schema(description = "房源匹配结果类型 exact/approximate/none")
|
||||
private String matchType = "none";
|
||||
|
||||
@Schema(description = "语义解析结果")
|
||||
private HouseAiIntent intent;
|
||||
|
||||
|
||||
@@ -48,4 +48,7 @@ public class HouseAiHouseCard implements Serializable {
|
||||
|
||||
@Schema(description = "办公室配套")
|
||||
private String supporting;
|
||||
|
||||
@Schema(description = "房源匹配或接近原因")
|
||||
private String matchReason;
|
||||
}
|
||||
|
||||
@@ -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<String> 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<HouseFaq> 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<HouseInfo> 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<HouseFaq> 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<HouseInfo> searchHouses(HouseAiIntent intent, String question) {
|
||||
List<HouseInfo> 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<HouseInfo> houses = houseInfoService.listRel(param);
|
||||
return filterHouses(houses, intent).stream().limit(10).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private List<HouseInfo> 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<HouseInfo> filterHouses(List<HouseInfo> 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<HouseAiHouseCard> toHouseCards(List<HouseInfo> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user