|
|
@@ -1,157 +1,666 @@
|
|
|
package com.zsjz.ai.module.agent.followup;
|
|
|
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
|
|
+import com.fasterxml.jackson.core.type.TypeReference;
|
|
|
import com.zsjz.ai.common.utils.Json;
|
|
|
import com.zsjz.ai.module.agent.config.EtlProperties;
|
|
|
-import com.zsjz.ai.module.agent.entity.AgentModel;
|
|
|
-import com.zsjz.ai.module.agent.mapper.AgentModelMapper;
|
|
|
-import com.zsjz.ai.module.agent.service.AgentModelFactory;
|
|
|
-import io.agentscope.core.message.Msg;
|
|
|
-import io.agentscope.core.message.MsgRole;
|
|
|
-import io.agentscope.core.message.TextBlock;
|
|
|
-import io.agentscope.core.model.GenerateOptions;
|
|
|
-import io.agentscope.core.model.Model;
|
|
|
+import com.zsjz.ai.module.agent.entity.AgentMessage;
|
|
|
+import com.zsjz.ai.module.agent.llm.LlmRequest;
|
|
|
+import com.zsjz.ai.module.agent.llm.LlmService;
|
|
|
+import com.zsjz.ai.module.agent.mapper.AgentMessageMapper;
|
|
|
+import com.zsjz.ai.module.agent.prompt.PromptLoader;
|
|
|
import lombok.extern.slf4j.Slf4j;
|
|
|
import org.springframework.stereotype.Service;
|
|
|
import org.springframework.util.StringUtils;
|
|
|
|
|
|
import java.time.Duration;
|
|
|
import java.util.ArrayList;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.LinkedHashSet;
|
|
|
import java.util.List;
|
|
|
import java.util.Map;
|
|
|
+import java.util.Set;
|
|
|
|
|
|
/**
|
|
|
* 下一步引导建议生成服务
|
|
|
*
|
|
|
- * <p>对话结束后,复用会话模型做一次轻量 LLM 调用:根据本轮用户问题与助手回答,
|
|
|
- * 推测用户接下来最想问的引导问题。全程 fail-open——任何异常/超时/解析失败均返回 null,
|
|
|
+ * <p>对话结束后,复用会话模型做一次轻量 LLM 调用:根据「最近几轮上下文 + 本轮问答」,
|
|
|
+ * 产出「分析方向 + 可直接发送的追问」。全程 fail-open —— 任何异常/超时/解析失败均返回 null,
|
|
|
* 由调用方静默跳过,绝不影响主链路。
|
|
|
+ *
|
|
|
+ * <h3>相比旧实现的关键变化</h3>
|
|
|
+ * <ul>
|
|
|
+ * <li><b>带上下文</b>:加载最近 {@code historyTurns} 条会话消息(排除本轮),
|
|
|
+ * 让追问延续多轮分析、并且不会重复用户已经问过的问题;</li>
|
|
|
+ * <li><b>问题抽取</b>:附件问答会拼「附件块 + 【用户问题】问题」,这里只把真实问题送进提示词,
|
|
|
+ * 避免几万字的附件正文把提示词和时延一起撑爆;</li>
|
|
|
+ * <li><b>回答压缩</b>:去掉 Markdown 表格/代码围栏(execute_sql、echarts 等载荷),
|
|
|
+ * 长回答保留「开头 + 结论段/结尾」,模型的 2000 上下文里塞的是结论而不是表格;</li>
|
|
|
+ * <li><b>方向化输出</b>:模型按「方向 → 追问」输出,系统提示词内置业务能力清单与少样本示例,
|
|
|
+ * 追问要求带主体/范围、可执行、不复述;</li>
|
|
|
+ * <li><b>结果清洗</b>:宽松解析 + 去重 + 与历史提问比对 + 长度/条数/方向名规范化,
|
|
|
+ * 弱模型吐出的重复、残缺、书面对话腔在这里被削掉。</li>
|
|
|
+ * </ul>
|
|
|
*/
|
|
|
@Slf4j
|
|
|
@Service
|
|
|
public class FollowupService {
|
|
|
|
|
|
- /** 助手回答参与上下文的截断长度(建议只关心结论部分,超长截断) */
|
|
|
- private static final int ANSWER_LIMIT = 2000;
|
|
|
-
|
|
|
/** 单条建议问题长度上限(防 LLM 输出整段话) */
|
|
|
- private static final int QUESTION_MAX_LEN = 50;
|
|
|
+ private static final int QUESTION_MAX_LEN = 40;
|
|
|
+
|
|
|
+ /** 方向名长度上限 */
|
|
|
+ private static final int DIRECTION_MAX_LEN = 12;
|
|
|
+
|
|
|
+ /** 参与提示词的单条历史消息截断长度 */
|
|
|
+ private static final int HISTORY_CONTENT_LIMIT = 160;
|
|
|
+
|
|
|
+ /** 历史上下文总预算(超了从最早的消息开始丢) */
|
|
|
+ private static final int HISTORY_TOTAL_LIMIT = 620;
|
|
|
+
|
|
|
+ /** 本轮问题参与提示词的长度上限(附件场景抽取真实问题后仍兜底) */
|
|
|
+ private static final int QUESTION_INPUT_LIMIT = 300;
|
|
|
+
|
|
|
+ /** 长回答压缩:保留开头长度 */
|
|
|
+ private static final int ANSWER_HEAD_LIMIT = 450;
|
|
|
+
|
|
|
+ /** 长回答压缩:保留结尾/结论段长度 */
|
|
|
+ private static final int ANSWER_TAIL_LIMIT = 700;
|
|
|
+
|
|
|
+ /** 回答压缩后的总长度上限(与问题/历史一起把 user 提示词控制在 ~2k 字内) */
|
|
|
+ private static final int ANSWER_TOTAL_LIMIT = 1200;
|
|
|
+
|
|
|
+ /** 上下文里禁止出现的空话/线下动作(弱模型对否定式规则遵从度低,服务端兜底过滤) */
|
|
|
+ private static final List<String> BANNED_PHRASES = List.of(
|
|
|
+ // 线下动作 / 索要材料:这些不是当前案件数据上可执行的分析动作。
|
|
|
+ // 注意不要收录「询问」:案件材料里本来就有「询问笔录」这类可查对象,误杀成本高。
|
|
|
+ "讯问", "调取", "传唤", "搜查", "走访",
|
|
|
+ "请提供", "提供材料", "联系银行", "线下核实",
|
|
|
+ // 泛泛空话:用户点了也得不到新信息
|
|
|
+ "详细数据", "更多信息", "进一步分析", "还需要我", "还需要什么",
|
|
|
+ "仔细分析", "全面分析", "深入分析");
|
|
|
+
|
|
|
+ /** 附件问答的消息分隔标记(与 AgentChatServiceImpl#buildUserText 一致) */
|
|
|
+ private static final String USER_QUESTION_MARKER = "【用户问题】";
|
|
|
+
|
|
|
+ /** 结论段常见标记(长回答优先保留结论,而不是被截掉) */
|
|
|
+ private static final String[] CONCLUSION_MARKERS = {
|
|
|
+ "【研判结论与建议】", "【研判结论】", "【结论与建议】",
|
|
|
+ "## 研判结论", "## 结论", "## 下一步", "结论与建议"
|
|
|
+ };
|
|
|
|
|
|
- private final AgentModelMapper agentModelMapper;
|
|
|
- private final AgentModelFactory agentModelFactory;
|
|
|
+ private final AgentMessageMapper agentMessageMapper;
|
|
|
+ private final LlmService llmService;
|
|
|
private final FollowupProperties followupProperties;
|
|
|
|
|
|
- public FollowupService(AgentModelMapper agentModelMapper,
|
|
|
- AgentModelFactory agentModelFactory,
|
|
|
+ public FollowupService(AgentMessageMapper agentMessageMapper,
|
|
|
+ LlmService llmService,
|
|
|
EtlProperties etlProperties) {
|
|
|
- this.agentModelMapper = agentModelMapper;
|
|
|
- this.agentModelFactory = agentModelFactory;
|
|
|
+ this.agentMessageMapper = agentMessageMapper;
|
|
|
+ this.llmService = llmService;
|
|
|
this.followupProperties = etlProperties.getFollowup();
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 生成下一步引导建议(fail-open:任何异常/超时返回 null)
|
|
|
*
|
|
|
- * @param modelId 会话当前模型 ID
|
|
|
- * @param question 本轮用户问题
|
|
|
- * @param answer 本轮助手回答
|
|
|
- * @return 建议问题列表(1~questionCount 条);模型配置缺失/调用失败/超时/解析失败时返回 null
|
|
|
+ * @param modelId 会话当前模型 ID
|
|
|
+ * @param sessionId 会话 ID(用于加载最近对话历史;可为 null)
|
|
|
+ * @param question 本轮用户消息原文(可能含附件块,内部会抽取真实问题)
|
|
|
+ * @param answer 本轮助手回答
|
|
|
+ * @return 建议列表(1~questionCount 条);模型配置缺失/调用失败/超时/解析失败时返回 null
|
|
|
*/
|
|
|
- public List<String> safeSuggest(Long modelId, String question, String answer) {
|
|
|
+ public List<FollowupSuggestion> safeSuggest(Long modelId, Long sessionId, String question, String answer) {
|
|
|
+ if (!StringUtils.hasText(answer)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ long start = System.currentTimeMillis();
|
|
|
try {
|
|
|
- AgentModel config = agentModelMapper.selectById(modelId);
|
|
|
- if (config == null) {
|
|
|
+ String realQuestion = extractRealQuestion(question);
|
|
|
+ if (!StringUtils.hasText(realQuestion)) {
|
|
|
return null;
|
|
|
}
|
|
|
- Model model = agentModelFactory.create(config);
|
|
|
-
|
|
|
- List<Msg> msgs = List.of(
|
|
|
- Msg.builder().role(MsgRole.SYSTEM).textContent(buildSystemPrompt()).build(),
|
|
|
- Msg.builder().role(MsgRole.USER).textContent(buildUserPrompt(question, answer)).build());
|
|
|
-
|
|
|
- StringBuilder sb = new StringBuilder();
|
|
|
- model.stream(msgs, List.of(), GenerateOptions.builder()
|
|
|
- .temperature(0.7)
|
|
|
- .maxTokens(followupProperties.getMaxTokens())
|
|
|
- .build())
|
|
|
- .doOnNext(resp -> {
|
|
|
- if (resp.getContent() != null) {
|
|
|
- resp.getContent().forEach(block -> {
|
|
|
- if (block instanceof TextBlock t && t.getText() != null) {
|
|
|
- sb.append(t.getText());
|
|
|
- }
|
|
|
- });
|
|
|
- }
|
|
|
- })
|
|
|
- .blockLast(Duration.ofSeconds(followupProperties.getTimeoutSeconds()));
|
|
|
+ List<AgentMessage> history = loadHistory(sessionId, realQuestion, answer);
|
|
|
+ String systemPrompt = fillTemplate(PromptLoader.loadPrompt("followup-suggestions"));
|
|
|
+ String userPrompt = buildUserPrompt(history, realQuestion, compactAnswer(answer));
|
|
|
|
|
|
- return parse(sb.toString().trim());
|
|
|
+ LlmRequest request = LlmRequest.builder()
|
|
|
+ .modelId(modelId)
|
|
|
+ .system(systemPrompt)
|
|
|
+ .user(userPrompt)
|
|
|
+ .temperature(followupProperties.getTemperature())
|
|
|
+ .maxTokens(followupProperties.getMaxTokens())
|
|
|
+ // 引导建议只要一小段 JSON:显式关掉思考,否则推理模型的 reasoning
|
|
|
+ // 会吃光 maxTokens,正文为空直接降级成“无建议”
|
|
|
+ .enableThinking(false)
|
|
|
+ .timeout(Duration.ofSeconds(followupProperties.getTimeoutSeconds()))
|
|
|
+ .build();
|
|
|
+ String raw = llmService.chat(request);
|
|
|
+
|
|
|
+ List<FollowupSuggestion> suggestions = filterAndLimit(parse(raw), history, realQuestion);
|
|
|
+ if (suggestions == null || suggestions.isEmpty()) {
|
|
|
+ log.info("引导建议为空(降级): sessionId={}, 耗时={}ms, 原始输出={}",
|
|
|
+ sessionId, System.currentTimeMillis() - start, abbreviate(raw));
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ long directions = suggestions.stream()
|
|
|
+ .map(FollowupSuggestion::direction)
|
|
|
+ .filter(StringUtils::hasText)
|
|
|
+ .distinct()
|
|
|
+ .count();
|
|
|
+ log.info("引导建议生成完成: sessionId={}, 条数={}, 方向数={}, 耗时={}ms",
|
|
|
+ sessionId, suggestions.size(), directions, System.currentTimeMillis() - start);
|
|
|
+ return suggestions;
|
|
|
} catch (Exception e) {
|
|
|
- log.error("下一步引导建议降级(fail-open): modelId={}, error={}", modelId, e.getMessage());
|
|
|
+ log.error("下一步引导建议降级(fail-open): modelId={}, sessionId={}, error={}",
|
|
|
+ modelId, sessionId, e.getMessage());
|
|
|
return null;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 建议生成系统提示词:聚焦数据分析场景的可继续深入方向
|
|
|
+ * 从用户消息里抽取真实问题:附件问答形如「附件块…\n【用户问题】xxx」,只取标记之后的正文。
|
|
|
*/
|
|
|
- private String buildSystemPrompt() {
|
|
|
- int count = followupProperties.getQuestionCount();
|
|
|
- return "你是数据分析助手的\"下一步引导\"模块。根据用户问题和助手的最新回答,"
|
|
|
- + "推测用户接下来最可能想问的 " + count + " 个引导问题。\n"
|
|
|
- + "要求:\n"
|
|
|
- + "- 问题必须与刚才的分析结果直接相关、可继续深入(如:换维度统计、下钻明细、TopN/排序、趋势、可视化、异常值、对比验证)\n"
|
|
|
- + "- 用中文口语化表述,每条不超过 30 字,不带编号、引号和标点前缀\n"
|
|
|
- + "- 禁止重复刚才已经回答过的内容,禁止泛泛的寒暄类问题\n"
|
|
|
- + "输出严格 JSON(不要输出任何其他内容,不要用 markdown 代码块包裹):\n"
|
|
|
- + "{\"questions\":[" + "\"问题1\",\"问题2\",\"问题3\"" + "]}\n"
|
|
|
- + "实际条数必须等于 " + count + "。";
|
|
|
+ static String extractRealQuestion(String userText) {
|
|
|
+ if (!StringUtils.hasText(userText)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ String text = userText;
|
|
|
+ int marker = text.lastIndexOf(USER_QUESTION_MARKER);
|
|
|
+ if (marker >= 0) {
|
|
|
+ text = text.substring(marker + USER_QUESTION_MARKER.length());
|
|
|
+ }
|
|
|
+ text = text.trim();
|
|
|
+ if (text.length() > QUESTION_INPUT_LIMIT) {
|
|
|
+ text = text.substring(0, QUESTION_INPUT_LIMIT) + "…";
|
|
|
+ }
|
|
|
+ return text;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 用户提示词:本轮问答原文(回答超长截断)
|
|
|
+ * 模板占位符替换(不用 StrUtil.format:提示词里含 JSON 花括号,会被误当占位符)。
|
|
|
+ *
|
|
|
+ * <p>两个计数做互相钳制:questionCount 至少等于 directionCount(否则"每个方向 1~2 条追问"
|
|
|
+ * 与"总数 ≤ N"在配置层面就无解)。
|
|
|
*/
|
|
|
- private static String buildUserPrompt(String question, String answer) {
|
|
|
- String ans = answer.length() > ANSWER_LIMIT
|
|
|
- ? answer.substring(0, ANSWER_LIMIT) + "..." : answer;
|
|
|
- return "[用户问题]\n" + question + "\n\n[助手回答]\n" + ans;
|
|
|
+ private String fillTemplate(String template) {
|
|
|
+ int directionCount = effectiveDirectionCount();
|
|
|
+ int questionCount = effectiveQuestionCount(directionCount);
|
|
|
+ return template
|
|
|
+ .replace("{direction_count}", String.valueOf(directionCount))
|
|
|
+ .replace("{question_count}", String.valueOf(questionCount));
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 生效的方向数(至少 1) */
|
|
|
+ private int effectiveDirectionCount() {
|
|
|
+ return Math.max(1, followupProperties.getDirectionCount());
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 生效的追问条数上限:不小于方向数,避免配置矛盾 */
|
|
|
+ private int effectiveQuestionCount(int directionCount) {
|
|
|
+ return Math.max(directionCount, Math.max(1, followupProperties.getQuestionCount()));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 加载最近对话历史(时间正序),并剔除本轮问答。
|
|
|
+ *
|
|
|
+ * <p>本轮 user 消息在流开始时就已落库,assistant 消息由 AgentResultEvent 异步落库,
|
|
|
+ * 因此这里按内容把本轮两条消息从历史里去掉,避免模型把"本轮"当"历史"。
|
|
|
+ */
|
|
|
+ private List<AgentMessage> loadHistory(Long sessionId, String question, String answer) {
|
|
|
+ int turns = followupProperties.getHistoryTurns();
|
|
|
+ if (sessionId == null || turns <= 0) {
|
|
|
+ return Collections.emptyList();
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ List<AgentMessage> queried = agentMessageMapper.selectList(
|
|
|
+ // 只取上下文需要的列:metadata/tool_events 可能是几百 k 的思考全文/工具结果。
|
|
|
+ // 用字符串列名而不是 Lambda:select() 会立即解析列,单测(无 MyBatis 容器)里
|
|
|
+ // 没有 TableInfo 缓存会直接抛异常。
|
|
|
+ new QueryWrapper<AgentMessage>()
|
|
|
+ .select("role", "content", "message_type", "create_at")
|
|
|
+ .eq("session_id", sessionId)
|
|
|
+ .orderByDesc("create_at", "id")
|
|
|
+ // +4:给"剔除本轮两条 + 错误消息"留余量,避免凑不满历史条数
|
|
|
+ .last("LIMIT " + (turns + 4)));
|
|
|
+ // 先拷贝再 reverse:MyBatis 返回可变 List,但单测/自定义 Mapper 可能返回不可变 List
|
|
|
+ List<AgentMessage> list = new ArrayList<>(queried == null ? List.of() : queried);
|
|
|
+ Collections.reverse(list);
|
|
|
+ // 错误消息("Model request timeout" 之类)不是有效上下文,直接剔掉
|
|
|
+ list.removeIf(m -> "error".equals(m.getMessageType()));
|
|
|
+
|
|
|
+ // 从后往前剔除本轮助手回答 / 本轮用户问题(各只剔一条)
|
|
|
+ for (int i = list.size() - 1; i >= 0; i--) {
|
|
|
+ AgentMessage m = list.get(i);
|
|
|
+ if ("assistant".equals(m.getRole()) && sameText(m.getContent(), answer)) {
|
|
|
+ list.remove(i);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ for (int i = list.size() - 1; i >= 0; i--) {
|
|
|
+ AgentMessage m = list.get(i);
|
|
|
+ if ("user".equals(m.getRole())
|
|
|
+ && sameText(extractRealQuestion(m.getContent()), question)) {
|
|
|
+ list.remove(i);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (list.size() > turns) {
|
|
|
+ list = new ArrayList<>(list.subList(list.size() - turns, list.size()));
|
|
|
+ }
|
|
|
+ return list;
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("加载会话历史失败(引导建议不带历史): sessionId={}, error={}", sessionId, e.getMessage());
|
|
|
+ return Collections.emptyList();
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 宽松解析 LLM 输出:剥围栏 → 截取首尾大括号 → Jackson 解析 → 过滤/截断
|
|
|
+ * 比较消息内容是否同一段文本:只做「忽略空白后完全相等」,不做前缀匹配
|
|
|
+ * (模板化回答前缀高度相似,前缀匹配会误删真正有效的上一轮回答)。
|
|
|
*/
|
|
|
- private List<String> parse(String raw) {
|
|
|
+ private static boolean sameText(String a, String b) {
|
|
|
+ if (!StringUtils.hasText(a) || !StringUtils.hasText(b)) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ return normalizeKey(a).equals(normalizeKey(b));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 组装用户提示词:最近对话 + 本轮问题 + 压缩后的回答
|
|
|
+ */
|
|
|
+ private static String buildUserPrompt(List<AgentMessage> history, String question, String answer) {
|
|
|
+ List<String> lines = new ArrayList<>();
|
|
|
+ for (AgentMessage m : history) {
|
|
|
+ String content = m.getContent();
|
|
|
+ if (!StringUtils.hasText(content)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ boolean user = "user".equals(m.getRole());
|
|
|
+ if (user) {
|
|
|
+ content = extractRealQuestion(content);
|
|
|
+ }
|
|
|
+ if (!StringUtils.hasText(content)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ String compact = truncate(stripForPrompt(content), HISTORY_CONTENT_LIMIT);
|
|
|
+ lines.add(user ? "用户: " + compact : "助手: " + compact);
|
|
|
+ }
|
|
|
+ // 历史总预算:超了从最早的消息开始丢,保住最近上下文
|
|
|
+ int total = lines.stream().mapToInt(String::length).sum();
|
|
|
+ while (lines.size() > 1 && total > HISTORY_TOTAL_LIMIT) {
|
|
|
+ total -= lines.remove(0).length();
|
|
|
+ }
|
|
|
+
|
|
|
+ StringBuilder sb = new StringBuilder("【最近对话】\n");
|
|
|
+ if (lines.isEmpty()) {
|
|
|
+ sb.append("(无)\n");
|
|
|
+ } else {
|
|
|
+ lines.forEach(line -> sb.append(line).append('\n'));
|
|
|
+ }
|
|
|
+ sb.append("\n【本轮问题】\n").append(question)
|
|
|
+ .append("\n\n【本轮回答】\n").append(answer);
|
|
|
+ return sb.toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 压缩助手回答:剥掉表格/代码围栏,超长时保留开头 + 结论段(没有结论段则保留结尾)。
|
|
|
+ */
|
|
|
+ static String compactAnswer(String answer) {
|
|
|
+ if (!StringUtils.hasText(answer)) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ String text = stripForPrompt(answer);
|
|
|
+ if (text.length() <= ANSWER_TOTAL_LIMIT) {
|
|
|
+ return text;
|
|
|
+ }
|
|
|
+ String head = text.substring(0, ANSWER_HEAD_LIMIT);
|
|
|
+
|
|
|
+ // 优先保留结论段:很多回答的"研判结论与建议"在尾部,纯头部截断会把它丢掉
|
|
|
+ int conclusionIdx = conclusionStart(text);
|
|
|
+ if (conclusionIdx > ANSWER_HEAD_LIMIT) {
|
|
|
+ String conclusion = text.substring(conclusionIdx);
|
|
|
+ return head + "\n……\n" + truncate(conclusion, ANSWER_TAIL_LIMIT);
|
|
|
+ }
|
|
|
+ String tail = text.substring(text.length() - ANSWER_TAIL_LIMIT);
|
|
|
+ return head + "\n……\n" + tail;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 提示词清洗:代码围栏整体替换为占位符,删除 Markdown 表格行,压缩空白。
|
|
|
+ */
|
|
|
+ private static String stripForPrompt(String text) {
|
|
|
+ String t = text;
|
|
|
+ // ``` 围栏(含 echarts / json / html 等载荷):整块省略,只留占位
|
|
|
+ t = t.replaceAll("(?s)```[^\\n]*\\n.*?```", "[代码或图表内容已省略]");
|
|
|
+ t = t.replaceAll("(?s)```.*?```", "[代码或图表内容已省略]");
|
|
|
+ // Markdown 表格:数据行与分隔行(表格是给前端渲染的,模型只需要结论)
|
|
|
+ t = t.replaceAll("(?m)^\\s*\\|.*\\|\\s*$", "");
|
|
|
+ t = t.replaceAll("(?m)^\\s*\\|?\\s*[:\\-]{3,}.*$", "");
|
|
|
+ // 连续空白/空行压缩
|
|
|
+ t = t.replaceAll("[ \\t\\u00A0]+", " ");
|
|
|
+ t = t.replaceAll("\\n{2,}", "\n");
|
|
|
+ return t.trim();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 宽松解析模型输出:剥围栏 → 截取最外层 JSON → 兼容 v2(suggestions 对象数组)
|
|
|
+ * 与 v1(questions 字符串数组)/ 裸数组。
|
|
|
+ */
|
|
|
+ private List<FollowupSuggestion> parse(String raw) {
|
|
|
if (!StringUtils.hasText(raw)) {
|
|
|
return null;
|
|
|
}
|
|
|
- int start = raw.indexOf('{');
|
|
|
- int end = raw.lastIndexOf('}');
|
|
|
- if (start < 0 || end <= start) {
|
|
|
+ String json = extractJson(raw);
|
|
|
+ if (json == null) {
|
|
|
log.info("引导建议输出非 JSON(降级): {}", abbreviate(raw));
|
|
|
return null;
|
|
|
}
|
|
|
try {
|
|
|
- Map<String, Object> map = Json.objectMapper().readValue(raw.substring(start, end + 1), Map.class);
|
|
|
- List<String> questions = new ArrayList<>();
|
|
|
- if (map.get("questions") instanceof List<?> list) {
|
|
|
- for (Object item : list) {
|
|
|
- if (item instanceof String s && StringUtils.hasText(s)
|
|
|
- && questions.size() < followupProperties.getQuestionCount()) {
|
|
|
- String q = s.trim();
|
|
|
- if (q.length() > QUESTION_MAX_LEN) {
|
|
|
- q = q.substring(0, QUESTION_MAX_LEN);
|
|
|
+ List<FollowupSuggestion> result = new ArrayList<>();
|
|
|
+ if (json.startsWith("[")) {
|
|
|
+ // 裸数组:元素可能是字符串或 {direction, question}
|
|
|
+ List<?> list = Json.objectMapper().readValue(json, new TypeReference<List<Object>>() {
|
|
|
+ });
|
|
|
+ collect(list, result);
|
|
|
+ } else {
|
|
|
+ Map<String, Object> map = Json.objectMapper().readValue(json,
|
|
|
+ new TypeReference<Map<String, Object>>() {
|
|
|
+ });
|
|
|
+ Object suggestions = map.get("suggestions");
|
|
|
+ if (suggestions instanceof List<?> list) {
|
|
|
+ collect(list, result);
|
|
|
+ }
|
|
|
+ // 弱模型常见变体:{"directions":[{name,questions}]} / {"suggestions":{"方向":[...]}}
|
|
|
+ if (result.isEmpty() && suggestions instanceof Map<?, ?> groupMap) {
|
|
|
+ collectDirectionGroups(groupMap, result);
|
|
|
+ }
|
|
|
+ if (result.isEmpty() && map.get("directions") instanceof List<?> directions) {
|
|
|
+ collectDirectionGroups(directions, result);
|
|
|
+ }
|
|
|
+ if (result.isEmpty() && map.get("questions") instanceof List<?> list) {
|
|
|
+ // 兼容旧契约:扁平问题数组
|
|
|
+ collect(list, result);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return result.isEmpty() ? null : result;
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("引导建议结果解析失败(降级): error={}, 原始输出={}", e.getMessage(), abbreviate(raw));
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 解析「方向分组」形态:支持
|
|
|
+ * {@code [{"name":"方向","questions":[...]}]} 与 {@code {"方向":[...]}} 两种常见弱模型输出。
|
|
|
+ */
|
|
|
+ private static void collectDirectionGroups(Object groups, List<FollowupSuggestion> target) {
|
|
|
+ if (groups instanceof List<?> list) {
|
|
|
+ for (Object item : list) {
|
|
|
+ if (!(item instanceof Map<?, ?> group)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ Object name = group.get("name") != null ? group.get("name") : group.get("direction");
|
|
|
+ Object qs = group.get("questions") != null ? group.get("questions") : group.get("suggestions");
|
|
|
+ String direction = name instanceof String s ? s : null;
|
|
|
+ if (qs instanceof List<?> questionList) {
|
|
|
+ for (Object q : questionList) {
|
|
|
+ if (q instanceof String s) {
|
|
|
+ addSuggestion(target, direction, s);
|
|
|
}
|
|
|
- questions.add(q);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
- return questions.isEmpty() ? null : questions;
|
|
|
- } catch (Exception e) {
|
|
|
- log.error("引导建议结果解析失败(降级): error={}", e.getMessage());
|
|
|
+ } else if (groups instanceof Map<?, ?> map) {
|
|
|
+ for (Map.Entry<?, ?> entry : map.entrySet()) {
|
|
|
+ String direction = entry.getKey() instanceof String s ? s : null;
|
|
|
+ if (entry.getValue() instanceof List<?> questionList) {
|
|
|
+ for (Object q : questionList) {
|
|
|
+ if (q instanceof String s) {
|
|
|
+ addSuggestion(target, direction, s);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } else if (entry.getValue() instanceof String s) {
|
|
|
+ addSuggestion(target, direction, s);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 收集建议条目:元素支持字符串 / {direction, question} / {direction, text}
|
|
|
+ */
|
|
|
+ private static void collect(List<?> list, List<FollowupSuggestion> target) {
|
|
|
+ for (Object item : list) {
|
|
|
+ if (item instanceof String s) {
|
|
|
+ addSuggestion(target, null, s);
|
|
|
+ } else if (item instanceof Map<?, ?> m) {
|
|
|
+ Object direction = m.get("direction");
|
|
|
+ Object question = m.get("question");
|
|
|
+ if (question == null) {
|
|
|
+ question = m.get("text");
|
|
|
+ }
|
|
|
+ addSuggestion(target,
|
|
|
+ direction instanceof String d ? d : null,
|
|
|
+ question instanceof String q ? q : null);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static void addSuggestion(List<FollowupSuggestion> target, String direction, String question) {
|
|
|
+ String q = normalizeQuestion(question);
|
|
|
+ if (q == null) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ target.add(new FollowupSuggestion(normalizeDirection(direction), q));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 结果清洗:去重、与历史提问/本轮问题比对、限制总条数。
|
|
|
+ */
|
|
|
+ private List<FollowupSuggestion> filterAndLimit(List<FollowupSuggestion> suggestions,
|
|
|
+ List<AgentMessage> history,
|
|
|
+ String currentQuestion) {
|
|
|
+ if (suggestions == null || suggestions.isEmpty()) {
|
|
|
return null;
|
|
|
}
|
|
|
+ Set<String> asked = new LinkedHashSet<>();
|
|
|
+ for (AgentMessage m : history) {
|
|
|
+ if (!"user".equals(m.getRole())) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ String q = extractRealQuestion(m.getContent());
|
|
|
+ String key = normalizeKey(q);
|
|
|
+ if (key.length() >= 4) {
|
|
|
+ asked.add(key);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // 本轮问题也算"已经问过",防止模型把用户刚问的原句又推荐一遍
|
|
|
+ String currentKey = normalizeKey(currentQuestion);
|
|
|
+ if (currentKey.length() >= 4) {
|
|
|
+ asked.add(currentKey);
|
|
|
+ }
|
|
|
+
|
|
|
+ int directionCount = effectiveDirectionCount();
|
|
|
+ int max = effectiveQuestionCount(directionCount);
|
|
|
+ Set<String> seen = new LinkedHashSet<>();
|
|
|
+ List<FollowupSuggestion> out = new ArrayList<>();
|
|
|
+ for (FollowupSuggestion s : suggestions) {
|
|
|
+ String key = normalizeKey(s.question());
|
|
|
+ if (key.length() < 4 || seen.contains(key)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ // 线下动作/索要材料/泛泛空话:规则里已禁止,但弱模型未必遵守,服务端兜底丢弃
|
|
|
+ if (isBanned(s.question())) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (repeatsAsked(key, asked)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ seen.add(key);
|
|
|
+ out.add(s);
|
|
|
+ if (out.size() >= max) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return out.isEmpty() ? null : out;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 是否命中「线下动作 / 泛泛空话」黑名单
|
|
|
+ */
|
|
|
+ private static boolean isBanned(String question) {
|
|
|
+ for (String phrase : BANNED_PHRASES) {
|
|
|
+ if (question.contains(phrase)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 是否与用户已经问过的问题重复:完全相同,或建议句被已问句完整包含
|
|
|
+ * (反过来"建议比已问句更具体"算合理下钻,保留)。
|
|
|
+ */
|
|
|
+ private static boolean repeatsAsked(String key, Set<String> asked) {
|
|
|
+ for (String q : asked) {
|
|
|
+ if (q.equals(key)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ if (key.length() >= 6 && q.contains(key)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 规范化问题:去编号/引号/前后缀,长度截断,过滤非中文
|
|
|
+ */
|
|
|
+ static String normalizeQuestion(String raw) {
|
|
|
+ if (!StringUtils.hasText(raw)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ String q = raw.trim();
|
|
|
+ q = q.replaceAll("^\\s*\\d{1,2}\\s*[.、,,))]\\s*", "");
|
|
|
+ q = q.replaceAll("^\\s*[一二三四五六七八九十]{1,2}\\s*[、.]\\s*", "");
|
|
|
+ q = q.replaceAll("^问题\\s*[::]\\s*", "");
|
|
|
+ q = q.replaceAll("^[\"“”'‘’《》\\[\\]()()]+", "");
|
|
|
+ q = q.replaceAll("[\"“”'‘’《》\\[\\]()()]+$", "");
|
|
|
+ q = q.replaceAll("\\s+", "");
|
|
|
+ if (q.length() < 4) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ if (!containsChinese(q)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ if (q.length() > QUESTION_MAX_LEN) {
|
|
|
+ q = q.substring(0, QUESTION_MAX_LEN - 1) + "…";
|
|
|
+ }
|
|
|
+ return q;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 规范化方向名:去编号/冒号前缀,超长截断;为空返回 null(前端退化为平铺)。
|
|
|
+ */
|
|
|
+ static String normalizeDirection(String raw) {
|
|
|
+ if (!StringUtils.hasText(raw)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ String d = raw.trim();
|
|
|
+ d = d.replaceAll("^\\s*\\d{1,2}\\s*[.、,,))]\\s*", "");
|
|
|
+ d = d.replaceAll("^\\s*[一二三四五六七八九十]{1,2}\\s*[、.]\\s*", "");
|
|
|
+ d = d.replaceAll("^方向\\s*[::]\\s*", "");
|
|
|
+ d = d.replaceAll("^[\"“”'‘’《》\\[\\]()()]+", "");
|
|
|
+ d = d.replaceAll("[\"“”'‘’《》\\[\\]()()]+$", "");
|
|
|
+ d = d.replaceAll("\\s+", "");
|
|
|
+ if (d.isEmpty()) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ if (d.length() > DIRECTION_MAX_LEN) {
|
|
|
+ d = d.substring(0, DIRECTION_MAX_LEN);
|
|
|
+ }
|
|
|
+ return d;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 去重键:去掉标点/空白,只比较语义文本
|
|
|
+ */
|
|
|
+ private static String normalizeKey(String text) {
|
|
|
+ if (!StringUtils.hasText(text)) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ return text.replaceAll("[\\p{P}\\s]", "");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 是否包含中文字符(模型偶尔吐英文,直接丢弃)
|
|
|
+ */
|
|
|
+ private static boolean containsChinese(String s) {
|
|
|
+ return s.codePoints().anyMatch(cp -> Character.UnicodeScript.of(cp) == Character.UnicodeScript.HAN);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 截取最外层 JSON(容忍前后解释文字、Markdown 围栏)
|
|
|
+ */
|
|
|
+ private static String extractJson(String raw) {
|
|
|
+ String text = raw.trim();
|
|
|
+ if (text.startsWith("```")) {
|
|
|
+ int firstBreak = text.indexOf('\n');
|
|
|
+ int lastFence = text.lastIndexOf("```");
|
|
|
+ if (firstBreak > 0 && lastFence > firstBreak) {
|
|
|
+ text = text.substring(firstBreak + 1, lastFence).trim();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ int startObj = text.indexOf('{');
|
|
|
+ int endObj = text.lastIndexOf('}');
|
|
|
+ int startArr = text.indexOf('[');
|
|
|
+ int endArr = text.lastIndexOf(']');
|
|
|
+ boolean arrayFirst = startArr >= 0 && (startObj < 0 || startArr < startObj);
|
|
|
+ if (arrayFirst) {
|
|
|
+ return endArr > startArr ? text.substring(startArr, endArr + 1) : null;
|
|
|
+ }
|
|
|
+ return startObj >= 0 && endObj > startObj ? text.substring(startObj, endObj + 1) : null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 定位结论段起点。
|
|
|
+ *
|
|
|
+ * <p>标记之间存在包含关系(如「结论与建议」是「【研判结论与建议】」的后缀),
|
|
|
+ * 直接取 lastIndexOf 会从标题中间切开、丢掉「【研判」前缀。这里先找最靠后的命中,
|
|
|
+ * 再向左吞并与之重叠的更长标记,保证结论段从完整标题开始。
|
|
|
+ */
|
|
|
+ private static int conclusionStart(String text) {
|
|
|
+ int best = -1;
|
|
|
+ for (String marker : CONCLUSION_MARKERS) {
|
|
|
+ int idx = text.lastIndexOf(marker);
|
|
|
+ if (idx > best) {
|
|
|
+ best = idx;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (best < 0) {
|
|
|
+ return -1;
|
|
|
+ }
|
|
|
+ for (String marker : CONCLUSION_MARKERS) {
|
|
|
+ int idx = text.lastIndexOf(marker);
|
|
|
+ if (idx >= 0 && idx < best && best - idx <= 12) {
|
|
|
+ best = idx;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return best;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String truncate(String text, int max) {
|
|
|
+ if (text == null) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ return text.length() > max ? text.substring(0, max) + "…" : text;
|
|
|
}
|
|
|
|
|
|
private static String abbreviate(String s) {
|
|
|
- return s.length() > 120 ? s.substring(0, 120) + "..." : s;
|
|
|
+ if (s == null) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ return s.length() > 200 ? s.substring(0, 200) + "..." : s;
|
|
|
}
|
|
|
}
|