|
|
|
|
@ -0,0 +1,434 @@
|
|
|
|
|
package com.ruoyi.portal.ai.service.impl;
|
|
|
|
|
|
|
|
|
|
import java.util.ArrayList;
|
|
|
|
|
import java.util.Comparator;
|
|
|
|
|
import java.util.LinkedHashSet;
|
|
|
|
|
import java.util.List;
|
|
|
|
|
import java.util.Locale;
|
|
|
|
|
import java.util.Set;
|
|
|
|
|
import java.util.regex.Matcher;
|
|
|
|
|
import java.util.regex.Pattern;
|
|
|
|
|
import java.lang.reflect.Field;
|
|
|
|
|
|
|
|
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
|
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
|
|
import com.ruoyi.portal.ai.config.AiProperties;
|
|
|
|
|
import com.ruoyi.portal.ai.domain.TextChunk;
|
|
|
|
|
import com.ruoyi.portal.ai.service.IKnowledgeBaseService;
|
|
|
|
|
import com.ruoyi.portal.domain.HwWeb;
|
|
|
|
|
import com.ruoyi.portal.domain.HwWeb1;
|
|
|
|
|
import com.ruoyi.portal.service.IHwWebService;
|
|
|
|
|
import com.ruoyi.portal.service.IHwWebService1;
|
|
|
|
|
import org.springframework.beans.factory.annotation.Autowired;
|
|
|
|
|
import org.springframework.stereotype.Service;
|
|
|
|
|
import org.springframework.util.StringUtils;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 基于现有官网 JSON 的轻量本地知识库检索服务实现。
|
|
|
|
|
*
|
|
|
|
|
* <p>该类实现了一个嵌入式的基于词频权重的搜索引擎,避免引入 ElasticSearch 等重量级外部搜索中间件。
|
|
|
|
|
* 业务意图在于以极低的资源开销,提供官网已发布内容的高召回率本地检索。</p>
|
|
|
|
|
*/
|
|
|
|
|
@Service
|
|
|
|
|
public class KnowledgeBaseServiceImpl implements IKnowledgeBaseService
|
|
|
|
|
{
|
|
|
|
|
/**
|
|
|
|
|
* 过滤富文本及官网编辑内容中可能夹杂的 HTML 标签。
|
|
|
|
|
* 避坑考量:在提取知识库纯文本时,若保留 HTML 标签会导致大模型将其误识别为系统指令,且会占用多余的 Token 预算。
|
|
|
|
|
*/
|
|
|
|
|
private static final Pattern HTML_TAG_PATTERN = Pattern.compile("<[^>]+>");
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 分词提取正则,匹配长度大于等于2的中文、英文字母和数字。
|
|
|
|
|
* 业务意图:作为本地模糊搜索的切分边界,过滤掉诸如标点符号、单字停用词(如“的”、“了”)等无检索价值的噪音。
|
|
|
|
|
*/
|
|
|
|
|
private static final Pattern TOKEN_PATTERN = Pattern.compile("[\\p{IsHan}A-Za-z0-9]{2,}");
|
|
|
|
|
|
|
|
|
|
@Autowired
|
|
|
|
|
private IHwWebService hwWebService;
|
|
|
|
|
|
|
|
|
|
@Autowired
|
|
|
|
|
private IHwWebService1 hwWebService1;
|
|
|
|
|
|
|
|
|
|
@Autowired
|
|
|
|
|
private AiProperties aiProperties;
|
|
|
|
|
|
|
|
|
|
@Autowired
|
|
|
|
|
private ObjectMapper objectMapper;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 缓存失效时间戳(毫秒数),使用 volatile 保证多线程可见性。
|
|
|
|
|
*/
|
|
|
|
|
private volatile long cacheExpireAt;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 本地内存知识库分片缓存,使用 volatile 保证多线程间的可见性,防止 JVM 指令重排。
|
|
|
|
|
*/
|
|
|
|
|
private volatile List<TextChunk> cachedChunks = new ArrayList<>();
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 本地知识库的词频打分搜索方法。
|
|
|
|
|
*
|
|
|
|
|
* @param question 用户提问文本
|
|
|
|
|
* @param topK 最大召回的分片数
|
|
|
|
|
* @return 降序排列的 Top-K 文本分片
|
|
|
|
|
*/
|
|
|
|
|
@Override
|
|
|
|
|
public List<TextChunk> search(String question, int topK)
|
|
|
|
|
{
|
|
|
|
|
// 1. 分词转换:将用户输入的问题切分为关键词列表(含滑动窗口字词)
|
|
|
|
|
List<String> terms = buildTerms(question);
|
|
|
|
|
if (terms.isEmpty())
|
|
|
|
|
{
|
|
|
|
|
return new ArrayList<>();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
List<TextChunk> matched = new ArrayList<>();
|
|
|
|
|
// 2. 词频匹配打分:遍历已经拉取并在内存缓存的所有知识切片进行打分评估
|
|
|
|
|
for (TextChunk chunk : loadChunks())
|
|
|
|
|
{
|
|
|
|
|
int score = score(chunk.getContent(), terms);
|
|
|
|
|
if (score > 0)
|
|
|
|
|
{
|
|
|
|
|
TextChunk copy = new TextChunk(chunk.getSource(), chunk.getContent());
|
|
|
|
|
copy.setScore(score);
|
|
|
|
|
matched.add(copy);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. 排序及 TopK 截断:按分值降序排列,只返回相关度最高的部分,利用 subList 进行越界防御
|
|
|
|
|
matched.sort(Comparator.comparingInt(TextChunk::getScore).reversed());
|
|
|
|
|
return matched.subList(0, Math.min(Math.max(topK, 1), matched.size()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 懒加载拉取知识库切片,应用双重检查锁(DCL, Double-Checked Locking)。
|
|
|
|
|
* 避坑考量:
|
|
|
|
|
* 官网的内容配置存放在大表 JSON 中,反射提取和 JSON 树深度递归解析非常消耗 CPU。
|
|
|
|
|
* 为防范高并发访客提问时直接击穿数据库并导致服务器 CPU 瞬间飙到 100%,此处采用 DCL 将结果驻留内存。
|
|
|
|
|
* 配合 volatile 关键字,防止多线程环境下由于指令重排引发的局部初始化半对象逸出安全漏洞。
|
|
|
|
|
*/
|
|
|
|
|
private List<TextChunk> loadChunks()
|
|
|
|
|
{
|
|
|
|
|
long now = System.currentTimeMillis();
|
|
|
|
|
// 第一层检查:未过期直接无锁返回,提升读取吞吐
|
|
|
|
|
if (now < cacheExpireAt && !cachedChunks.isEmpty())
|
|
|
|
|
{
|
|
|
|
|
return cachedChunks;
|
|
|
|
|
}
|
|
|
|
|
synchronized (this)
|
|
|
|
|
{
|
|
|
|
|
// 第二层检查:多线程竞争锁排队进入后,再次确认过期时间,防止重复执行解析逻辑
|
|
|
|
|
if (now < cacheExpireAt && !cachedChunks.isEmpty())
|
|
|
|
|
{
|
|
|
|
|
return cachedChunks;
|
|
|
|
|
}
|
|
|
|
|
List<TextChunk> chunks = new ArrayList<>();
|
|
|
|
|
// 解析并追加通用配置页面表(hw_web)的文本内容
|
|
|
|
|
appendHwWebChunks(chunks);
|
|
|
|
|
// 解析并追加产品详情关联表(hw_web1)的设备文本内容
|
|
|
|
|
appendHwWeb1Chunks(chunks);
|
|
|
|
|
cachedChunks = chunks;
|
|
|
|
|
// 官网内容由后台低频发布,短 TTL(如 5分钟)可减少每次问答重复解析大 JSON 的成本。
|
|
|
|
|
cacheExpireAt = now + Math.max(aiProperties.getKnowledgeBase().getCacheSeconds(), 30) * 1000L;
|
|
|
|
|
return cachedChunks;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 提取 hw_web 表的知识切片。
|
|
|
|
|
*/
|
|
|
|
|
private void appendHwWebChunks(List<TextChunk> chunks)
|
|
|
|
|
{
|
|
|
|
|
List<HwWeb> list = hwWebService.selectHwWebList(new HwWeb());
|
|
|
|
|
for (HwWeb item : list)
|
|
|
|
|
{
|
|
|
|
|
// 拼接可溯源的唯一位置标识,如 "hw_web:webCode=7",方便 AI 回复时进行精准定位与前端高亮跳转
|
|
|
|
|
String source = "hw_web:webCode=" + fieldValue(item, "webCode");
|
|
|
|
|
appendJsonChunks(chunks, source, stringFieldValue(item, "webJsonString"));
|
|
|
|
|
appendJsonChunks(chunks, source + ":english", stringFieldValue(item, "webJsonEnglish"));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 提取 hw_web1 关联表的知识切片。
|
|
|
|
|
*/
|
|
|
|
|
private void appendHwWeb1Chunks(List<TextChunk> chunks)
|
|
|
|
|
{
|
|
|
|
|
List<HwWeb1> list = hwWebService1.selectHwWebList(new HwWeb1());
|
|
|
|
|
for (HwWeb1 item : list)
|
|
|
|
|
{
|
|
|
|
|
// 针对设备对比等强结构数据,定位信息需要包含三元业务键(webCode, typeId, deviceId)
|
|
|
|
|
String source = "hw_web1:webCode=" + fieldValue(item, "webCode")
|
|
|
|
|
+ ",typeId=" + fieldValue(item, "typeId")
|
|
|
|
|
+ ",deviceId=" + fieldValue(item, "deviceId");
|
|
|
|
|
appendJsonChunks(chunks, source, stringFieldValue(item, "webJsonString"));
|
|
|
|
|
appendJsonChunks(chunks, source + ":english", stringFieldValue(item, "webJsonEnglish"));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private String stringFieldValue(Object target, String fieldName)
|
|
|
|
|
{
|
|
|
|
|
Object value = fieldValue(target, fieldName);
|
|
|
|
|
return value == null ? null : String.valueOf(value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 利用反射动态获取 POJO 对象的属性值。
|
|
|
|
|
* 业务意图:
|
|
|
|
|
* HwWeb 和 HwWeb1 是两个独立的 POJO 类,并且在旧代码中没有提取出包含 webCode 等属性的公共父接口。
|
|
|
|
|
* 使用反射获取这三个核心定位键,能够避免在逻辑中为不同类编写大量重复的 if-else 类型转换代码,提高了组件的通用性。
|
|
|
|
|
*/
|
|
|
|
|
private Object fieldValue(Object target, String fieldName)
|
|
|
|
|
{
|
|
|
|
|
if (target == null)
|
|
|
|
|
{
|
|
|
|
|
// 空防御,避免后续 getClass 抛出 NullPointerException
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
Field field = target.getClass().getDeclaredField(fieldName);
|
|
|
|
|
field.setAccessible(true);
|
|
|
|
|
return field.get(target);
|
|
|
|
|
}
|
|
|
|
|
catch (ReflectiveOperationException e)
|
|
|
|
|
{
|
|
|
|
|
// 反射报错时返回空,不让反射异常向外扩散拖垮正常的主流程逻辑(Let it Crash vs. 容错折衷)
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 将包含富文本或结构化的 JSON 字符串解析为内存文本分片。
|
|
|
|
|
*/
|
|
|
|
|
private void appendJsonChunks(List<TextChunk> chunks, String source, String json)
|
|
|
|
|
{
|
|
|
|
|
if (!StringUtils.hasText(json))
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
JsonNode root = objectMapper.readTree(json);
|
|
|
|
|
// 针对编辑端输出的数组型 JSON 区块配置,展开每一行独立作为段落提取,以保持语意聚合度
|
|
|
|
|
if (root.isArray())
|
|
|
|
|
{
|
|
|
|
|
int index = 0;
|
|
|
|
|
for (JsonNode element : root)
|
|
|
|
|
{
|
|
|
|
|
appendText(chunks, source + "#" + index, extractPlainText(element));
|
|
|
|
|
index++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
appendText(chunks, source, extractPlainText(root));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch (Exception e)
|
|
|
|
|
{
|
|
|
|
|
// 避坑考量:官网的历史脏数据可能并不全是严格的标准 JSON 字符串。
|
|
|
|
|
// 如果遇到解析异常,绝不能让其向上抛出破坏了整个问答系统的可用性,而是采取“优雅退化”:
|
|
|
|
|
// 直接将这段不合规的 JSON 当作纯文本,强行剥离 HTML 标签后做兜底提取,以保障知识库的最大抗造性。
|
|
|
|
|
appendText(chunks, source, stripHtml(json));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 将 Jackson 的 Node 树级结构扁平化提纯为用空格相连的纯文本串。
|
|
|
|
|
*/
|
|
|
|
|
private String extractPlainText(JsonNode node)
|
|
|
|
|
{
|
|
|
|
|
List<String> values = new ArrayList<>();
|
|
|
|
|
collectText(node, values);
|
|
|
|
|
return normalizeText(String.join(" ", values));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 递归遍历 JsonNode 节点,收集其中所有文本型的值。
|
|
|
|
|
*/
|
|
|
|
|
private void collectText(JsonNode node, List<String> values)
|
|
|
|
|
{
|
|
|
|
|
if (node == null || node.isNull())
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (node.isTextual())
|
|
|
|
|
{
|
|
|
|
|
String text = stripHtml(node.asText());
|
|
|
|
|
// 过滤无意义的空串及可能的静态资源链接
|
|
|
|
|
if (StringUtils.hasText(text) && !looksLikeAsset(text))
|
|
|
|
|
{
|
|
|
|
|
values.add(text);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (node.isArray())
|
|
|
|
|
{
|
|
|
|
|
for (JsonNode child : node)
|
|
|
|
|
{
|
|
|
|
|
collectText(child, values);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (node.isObject())
|
|
|
|
|
{
|
|
|
|
|
// 深度遍历 JSON 对象的每个 KV 字段值,抽取其文本内容进行扁平化拼接
|
|
|
|
|
node.fields().forEachRemaining(entry -> collectText(entry.getValue(), values));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 静态资源与链接过滤检测。
|
|
|
|
|
* 避坑考量:在提取知识内容时,必须将图片链接、CDN地址及资源后缀过滤掉。
|
|
|
|
|
* 否则大模型可能会误将这些杂乱无章的静态 URL 当作回答的一部分返还给访客,造成极差的视觉体验,同时也会浪费上下文 Token 计费。
|
|
|
|
|
*/
|
|
|
|
|
private boolean looksLikeAsset(String text)
|
|
|
|
|
{
|
|
|
|
|
String value = text.toLowerCase(Locale.ROOT);
|
|
|
|
|
return value.startsWith("http://")
|
|
|
|
|
|| value.startsWith("https://")
|
|
|
|
|
|| value.endsWith(".png")
|
|
|
|
|
|| value.endsWith(".jpg")
|
|
|
|
|
|| value.endsWith(".jpeg")
|
|
|
|
|
|| value.endsWith(".gif")
|
|
|
|
|
|| value.endsWith(".webp");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 对提取出的长文本,依据最大分片大小规则进行物理分块。
|
|
|
|
|
*/
|
|
|
|
|
private void appendText(List<TextChunk> chunks, String source, String text)
|
|
|
|
|
{
|
|
|
|
|
String normalized = normalizeText(text);
|
|
|
|
|
if (!StringUtils.hasText(normalized))
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
int maxSize = Math.max(aiProperties.getKnowledgeBase().getMaxChunkSize(), 500);
|
|
|
|
|
// 如果文本长度小于单片阈值,直接单片存入,避免不必要的截取
|
|
|
|
|
if (normalized.length() <= maxSize)
|
|
|
|
|
{
|
|
|
|
|
chunks.add(new TextChunk(source, normalized));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 当内容过长时,以 maxSize 为步长进行切块,并用 ".0"、".1" 后缀命名,防止单块过大撑破大模型的 Input Window
|
|
|
|
|
int start = 0;
|
|
|
|
|
int index = 0;
|
|
|
|
|
while (start < normalized.length())
|
|
|
|
|
{
|
|
|
|
|
int end = Math.min(start + maxSize, normalized.length());
|
|
|
|
|
chunks.add(new TextChunk(source + "." + index, normalized.substring(start, end)));
|
|
|
|
|
start = end;
|
|
|
|
|
index++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 自研轻量级中文滑动分词器(Sliding Window Segmenter)。
|
|
|
|
|
* 避坑考量:
|
|
|
|
|
* 为保证系统低延迟、免除大 JAR 依赖包引入造成的 Jar 包冲突与内存膨胀风险,本服务不引入诸如 IKAnalyzer 或 HanLP 等重型分词中间件。
|
|
|
|
|
* 针对中文没有英文天然的空格分词符特征,此处先利用正则 TOKEN_PATTERN 将连贯文字切出,
|
|
|
|
|
* 并针对长度大于4的中文词段,强制使用 2~4 长度字符的滑动窗口生成子词倒排索引(比如“工业物联网”会被拆出“工业”、“物业”、“联网”、“物联”等子词)。
|
|
|
|
|
* 这一折衷设计,以极少的代码行数,奇迹般地提升了官网短词(产品名称、型号)在内存匹配时的召回率。
|
|
|
|
|
*/
|
|
|
|
|
private List<String> buildTerms(String question)
|
|
|
|
|
{
|
|
|
|
|
Set<String> terms = new LinkedHashSet<>(); // 保证分词去重且维护插入顺序
|
|
|
|
|
String normalized = normalizeText(question).toLowerCase(Locale.ROOT);
|
|
|
|
|
Matcher matcher = TOKEN_PATTERN.matcher(normalized);
|
|
|
|
|
while (matcher.find())
|
|
|
|
|
{
|
|
|
|
|
String term = matcher.group();
|
|
|
|
|
terms.add(term);
|
|
|
|
|
|
|
|
|
|
// 对连续中文长词,利用 2 至 4 个字的滑动窗口进行细粒度词块切分,大幅提高搜索词与知识库的命中率
|
|
|
|
|
if (containsChinese(term) && term.length() > 4)
|
|
|
|
|
{
|
|
|
|
|
for (int size = 2; size <= 4; size++)
|
|
|
|
|
{
|
|
|
|
|
for (int i = 0; i + size <= term.length(); i++)
|
|
|
|
|
{
|
|
|
|
|
terms.add(term.substring(i, i + size));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return new ArrayList<>(terms);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 判断是否包含汉字。
|
|
|
|
|
*/
|
|
|
|
|
private boolean containsChinese(String text)
|
|
|
|
|
{
|
|
|
|
|
for (int i = 0; i < text.length(); i++)
|
|
|
|
|
{
|
|
|
|
|
if (Character.UnicodeScript.of(text.charAt(i)) == Character.UnicodeScript.HAN)
|
|
|
|
|
{
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 对当前知识切片内容进行相关性评分。
|
|
|
|
|
* 得分机制:遍历所有搜索项,利用其出现频次乘以该词的字符长度,优先偏向长词命中(长词承载更精准的语义),
|
|
|
|
|
* 这样能让命中高精度长关键词的段落被排在最前面。
|
|
|
|
|
*/
|
|
|
|
|
private int score(String content, List<String> terms)
|
|
|
|
|
{
|
|
|
|
|
String text = content.toLowerCase(Locale.ROOT);
|
|
|
|
|
int score = 0;
|
|
|
|
|
for (String term : terms)
|
|
|
|
|
{
|
|
|
|
|
int count = countOccurrences(text, term);
|
|
|
|
|
// 权重为:频次 * max(词长, 2)。加权长词,弱化单字/超短字命中的噪音
|
|
|
|
|
score += count * Math.max(term.length(), 2);
|
|
|
|
|
}
|
|
|
|
|
return score;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 检索指定关键词在字符串中出现的频次。
|
|
|
|
|
*/
|
|
|
|
|
private int countOccurrences(String text, String term)
|
|
|
|
|
{
|
|
|
|
|
int count = 0;
|
|
|
|
|
int index = text.indexOf(term);
|
|
|
|
|
while (index >= 0)
|
|
|
|
|
{
|
|
|
|
|
count++;
|
|
|
|
|
index = text.indexOf(term, index + term.length()); // 推进指针,防止死循环
|
|
|
|
|
}
|
|
|
|
|
return count;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 快速剥离 HTML 标签。
|
|
|
|
|
*/
|
|
|
|
|
private String stripHtml(String text)
|
|
|
|
|
{
|
|
|
|
|
return HTML_TAG_PATTERN.matcher(text).replaceAll(" ");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 标准化整理过滤文本,将不规范的富文本空格( )和多余空白压缩,维护文本的可读性与一致性。
|
|
|
|
|
*/
|
|
|
|
|
private String normalizeText(String text)
|
|
|
|
|
{
|
|
|
|
|
if (text == null)
|
|
|
|
|
{
|
|
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
return text.replace(" ", " ")
|
|
|
|
|
.replace("&", "&")
|
|
|
|
|
.replace("<", "<")
|
|
|
|
|
.replace(">", ">")
|
|
|
|
|
.replaceAll("\\s+", " ") // 合并多余的连续空白字符为单空格,节省存储与 Token 消耗
|
|
|
|
|
.trim();
|
|
|
|
|
}
|
|
|
|
|
}
|