feat(house): 重构AI找房近似推荐

This commit is contained in:
2026-07-31 00:37:31 +08:00
parent 70354148b4
commit 7fe8fe47c8
10 changed files with 1612 additions and 425 deletions

View File

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

View File

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

View 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() {
}
}

View File

@@ -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("", "")
.replace("m2", "")
.replace("M²", "")
.replace("", "(")
.replace("", ")")
.replace("", "+")
.trim();
}
}

View 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("", "")
.replace("m2", "")
.replace("M²", "")
.replace("", "(")
.replace("", ")")
.replace("", "+")
.trim();
}
private String safeText(String text) {
return text == null ? "" : text;
}
}

View File

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

View File

@@ -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;

View File

@@ -48,4 +48,7 @@ public class HouseAiHouseCard implements Serializable {
@Schema(description = "办公室配套")
private String supporting;
@Schema(description = "房源匹配或接近原因")
private String matchReason;
}

View File

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

View File

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