Browse Source

refactor(prompt): 提示词改为数据库在线维护,RAG 保真护栏迁出 yml

问题:RAG 回答规则有两份重复来源 —— application.yml 的 knowledge.rag.answer-rules
与 RagPromptConfig 的 Java 默认值(逐字重复),改一处等于没改;且改提示词必须动 yml
并重启,与项目「system_config 在线维护、改完即生效」的取向不一致。同时
RagPromptConfig 类注释与 application.yml 里都还写着"注入 ContextualQueryAugmenter
模板",而该组件早已随 RagPipeline 重构移除、全仓已无引用。

- 新增 system_config.ai_rag_answer_rules 键(仅 RAG 分支注入,紧贴【知识库资料】之前),
  删除 RagPromptConfig 类与 yml 的 answer-rules 段;保留同节的
  knowledge.rag.multiquery.parallel(由 RagPipeline 的 @Value 直绑读取,不依赖任何
  @ConfigurationProperties 类,删类无影响)
- 取数改为一次批量查询:SystemConfigService 新增 getValues(Collection),
  ChatPipeline 每请求取 ai_system_prompt + ai_rag_answer_rules 两个键。该方法无缓存,
  批量是为了避免 hot path 上多次直查 DB;刻意不引入缓存,以保住"后台改完下一请求即
  生效"的语义。规则文本作为形参传给 RagPipeline.buildRagContextBlock(contextText,
  answerRules),注入位置与生效范围完全不变
- 兜底:ai_rag_answer_rules 被清空时用 RagPipeline.FALLBACK_RAG_ANSWER_RULES
  (一句极简安全底线,非默认值)
- 种子:两个键的初始值写入 DatabaseInitConfig.syncDefaultSystemConfigs 与
  init-database.sql,两处逐字一致(脚本比对 8 行 / 4 行通过),均为
  ON CONFLICT (config_key) DO NOTHING → 后台在线编辑永不会被重启覆盖,只对"该键不存在"
  的新环境生效
- 前端 SystemConfigManager 登记 ai_rag_answer_rules 为 text 型(未登记会被 resolveType
  按默认 html 渲染出多余预览框),文本域行高 8/16 → 10/24
- 安全:GET /ai/system-config/{key}(SDK 通用取值口,原意供 SDK 拉 disclaimer)对
  ai_system_prompt / ai_rag_answer_rules 返回 403 —— 提示词属商业秘密(见 CLAUDE.md
  的 LLM 追踪安全声明),不应经第三方持 Token 即可调的接口读取
- 注释清理:删掉关于 ContextualQueryAugmenter 的过期表述,精简 RagPipeline javadoc 中
  对已删除的 qaTemplate / buildRagSystemPrompt 的历史提及(HybridSearch 尚未接入主流程
  那条属实,保留)
- 文档:CLAUDE.md / AGENTS.md 增加「提示词在线维护(三层分工)」一节、管道图标注提示词
  来源,并写明两个键的种子与兜底关系

为什么不把 RAG 规则直接合并进 ai_system_prompt:全局键在所有路径生效,而纯对话/闲聊
路径没有【知识库资料】块,保真护栏("只能依据资料回答""资料不足时…")会指向不存在的
块,把问候语答成"未查到资料";CHITCHAT 通道存在的意义正是自由作答。

验证:mvn clean package -P prod 通过(含前端构建);删除 RagPromptConfig 后全仓 grep
无残留引用;种子两处文本逐字比对通过;应用启动后日志可见新键种子写入。
提示词文案本身的迭代(v4.2)已在管理后台在线完成并实测验证,不在本提交内。

(static/sdk/test.html 仅两条 link 顺序变化,hash 未变,属构建产物顺序抖动)
dev
wanghanlin 2 weeks ago
parent
commit
df78da5528
  1. 18
      AGENTS.md
  2. 18
      CLAUDE.md
  3. 5
      frontend/src/views/SystemConfigManager.vue
  4. 16
      src/main/java/com/wok/supportbot/app/ChatPipeline.java
  5. 35
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  6. 29
      src/main/java/com/wok/supportbot/config/RagPromptConfig.java
  7. 19
      src/main/java/com/wok/supportbot/controller/AiController.java
  8. 29
      src/main/java/com/wok/supportbot/rag/RagPipeline.java
  9. 25
      src/main/java/com/wok/supportbot/service/SystemConfigService.java
  10. 11
      src/main/resources/application.yml
  11. 23
      src/main/resources/init-database.sql
  12. 2
      src/main/resources/static/sdk/test.html

18
AGENTS.md

@ -58,9 +58,27 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
· 查询重写 REWRITE/TRANSLATION/COMPRESSION ← 可关: pipeline_rewrite_enabled · 查询重写 REWRITE/TRANSLATION/COMPRESSION ← 可关: pipeline_rewrite_enabled
· MULTI_QUERY 多路扩展 ← 可关: pipeline_multiquery_enabled · MULTI_QUERY 多路扩展 ← 可关: pipeline_multiquery_enabled
→ 组装 finalMessage + finalSystemPrompt + 资料块 → 组装 finalMessage + finalSystemPrompt + 资料块
· systemPrompt = ai_system_prompt(全局) + 【当前角色设定】+ 角色 prompt
· RAG 分支再追加:【RAG回答硬性规则】(ai_rag_answer_rules) + 【知识库资料】
→ AssistantApp.chat / chatStream(构建 ChatClientRequestSpec → call/stream) → AssistantApp.chat / chatStream(构建 ChatClientRequestSpec → call/stream)
``` ```
### 提示词在线维护(三层分工)
对话提示词全部由数据库在线维护,**改完下一请求即生效、无需重启**(不用缓存,也不在 yml/代码里写死):
| 层 | 存放位置 | 管理后台入口 | 管什么 | 生效路径 |
|---|---|---|---|---|
| 全局系统提示词 | `system_config.ai_system_prompt` | 系统设置 → 系统配置 | 所有角色通用的回答方式、长度、链接规范、禁暴露内部机制、兜底话术 | 所有调模型的路径(含闲聊) |
| 角色提示词 | `customer_service_role.prompt` | 系统设置 → 角色管理 | 该角色独有的业务口径与术语解释粒度 | 所有路径 |
| RAG 保真护栏 | `system_config.ai_rag_answer_rules` | 系统设置 → 系统配置 | 仅在 RAG 分支注入,紧贴【知识库资料】之前 | 仅 RAG 分支 |
- **取数**:`ChatPipeline.buildRequest` 用 `SystemConfigService.getValues(List.of("ai_system_prompt", "ai_rag_answer_rules"))` **一次批量查询**取两个键(`SystemConfigService` 无缓存、每次直查 DB,批量避免 hot path 多次往返),再把 `answerRules` 作为形参传给 `RagPipeline.buildRagContextBlock(contextText, answerRules)`。
- **为什么 RAG 规则不合并进 `ai_system_prompt`**:全局键在所有路径生效,而纯对话/闲聊路径没有【知识库资料】块,保真护栏("只能依据资料回答""资料不足时…")会指向不存在的块,把问候语答成"未查到资料"。
- **种子与兜底**:两个键的初始值都由 `DatabaseInitConfig.syncDefaultSystemConfigs` + `init-database.sql` 提供(两处需逐字一致),均为 `ON CONFLICT DO NOTHING` → **在线编辑永不会被重启覆盖**,只对「该键不存在」的新环境生效。`ai_rag_answer_rules` 被清空时用 `RagPipeline.FALLBACK_RAG_ANSWER_RULES`(一句极简安全底线),不是默认值。
- **不对外**:`GET /ai/system-config/{key}`(SDK 通用取值口)对 `ai_system_prompt`/`ai_rag_answer_rules` 返回 403(提示词属商业秘密)。
- 历史:该规则原先在 `application.yml` 的 `knowledge.rag.answer-rules`(`RagPromptConfig`),配置来源重复且改后需重启,已迁移;`knowledge.rag.multiquery.parallel` 仍留在 yml(由 `RagPipeline` 的 `@Value` 直绑读取)。
### AI 执行链节点开关(性能开关) ### AI 执行链节点开关(性能开关)
管理后台「系统设置 → AI 执行链」页面(`/#/settings/pipeline-flow`,admin)提供链路节点的启停开关。这些节点全部位于**首 token 产出之前**,且默认 `MULTI_QUERY` 策略下会串行累加多次跨网络调用,是「AI 回复慢」的主因。 管理后台「系统设置 → AI 执行链」页面(`/#/settings/pipeline-flow`,admin)提供链路节点的启停开关。这些节点全部位于**首 token 产出之前**,且默认 `MULTI_QUERY` 策略下会串行累加多次跨网络调用,是「AI 回复慢」的主因。

18
CLAUDE.md

@ -58,9 +58,27 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
· 查询重写 REWRITE/TRANSLATION/COMPRESSION ← 可关: pipeline_rewrite_enabled · 查询重写 REWRITE/TRANSLATION/COMPRESSION ← 可关: pipeline_rewrite_enabled
· MULTI_QUERY 多路扩展 ← 可关: pipeline_multiquery_enabled · MULTI_QUERY 多路扩展 ← 可关: pipeline_multiquery_enabled
→ 组装 finalMessage + finalSystemPrompt + 资料块 → 组装 finalMessage + finalSystemPrompt + 资料块
· systemPrompt = ai_system_prompt(全局) + 【当前角色设定】+ 角色 prompt
· RAG 分支再追加:【RAG回答硬性规则】(ai_rag_answer_rules) + 【知识库资料】
→ AssistantApp.chat / chatStream(构建 ChatClientRequestSpec → call/stream) → AssistantApp.chat / chatStream(构建 ChatClientRequestSpec → call/stream)
``` ```
### 提示词在线维护(三层分工)
对话提示词全部由数据库在线维护,**改完下一请求即生效、无需重启**(不用缓存,也不在 yml/代码里写死):
| 层 | 存放位置 | 管理后台入口 | 管什么 | 生效路径 |
|---|---|---|---|---|
| 全局系统提示词 | `system_config.ai_system_prompt` | 系统设置 → 系统配置 | 所有角色通用的回答方式、长度、链接规范、禁暴露内部机制、兜底话术 | 所有调模型的路径(含闲聊) |
| 角色提示词 | `customer_service_role.prompt` | 系统设置 → 角色管理 | 该角色独有的业务口径与术语解释粒度 | 所有路径 |
| RAG 保真护栏 | `system_config.ai_rag_answer_rules` | 系统设置 → 系统配置 | 仅在 RAG 分支注入,紧贴【知识库资料】之前 | 仅 RAG 分支 |
- **取数**:`ChatPipeline.buildRequest` 用 `SystemConfigService.getValues(List.of("ai_system_prompt", "ai_rag_answer_rules"))` **一次批量查询**取两个键(`SystemConfigService` 无缓存、每次直查 DB,批量避免 hot path 多次往返),再把 `answerRules` 作为形参传给 `RagPipeline.buildRagContextBlock(contextText, answerRules)`。
- **为什么 RAG 规则不合并进 `ai_system_prompt`**:全局键在所有路径生效,而纯对话/闲聊路径没有【知识库资料】块,保真护栏("只能依据资料回答""资料不足时…")会指向不存在的块,把问候语答成"未查到资料"。
- **种子与兜底**:两个键的初始值都由 `DatabaseInitConfig.syncDefaultSystemConfigs` + `init-database.sql` 提供(两处需逐字一致),均为 `ON CONFLICT DO NOTHING` → **在线编辑永不会被重启覆盖**,只对「该键不存在」的新环境生效。`ai_rag_answer_rules` 被清空时用 `RagPipeline.FALLBACK_RAG_ANSWER_RULES`(一句极简安全底线),不是默认值。
- **不对外**:`GET /ai/system-config/{key}`(SDK 通用取值口)对 `ai_system_prompt`/`ai_rag_answer_rules` 返回 403(提示词属商业秘密)。
- 历史:该规则原先在 `application.yml` 的 `knowledge.rag.answer-rules`(`RagPromptConfig`),配置来源重复且改后需重启,已迁移;`knowledge.rag.multiquery.parallel` 仍留在 yml(由 `RagPipeline` 的 `@Value` 直绑读取)。
### AI 执行链节点开关(性能开关) ### AI 执行链节点开关(性能开关)
管理后台「系统设置 → AI 执行链」页面(`/#/settings/pipeline-flow`,admin)提供链路节点的启停开关。这些节点全部位于**首 token 产出之前**,且默认 `MULTI_QUERY` 策略下会串行累加多次跨网络调用,是「AI 回复慢」的主因。 管理后台「系统设置 → AI 执行链」页面(`/#/settings/pipeline-flow`,admin)提供链路节点的启停开关。这些节点全部位于**首 token 产出之前**,且默认 `MULTI_QUERY` 策略下会串行累加多次跨网络调用,是「AI 回复慢」的主因。

5
frontend/src/views/SystemConfigManager.vue

@ -148,7 +148,7 @@
<t-textarea <t-textarea
v-model="form.configValue" v-model="form.configValue"
:disabled="saving" :disabled="saving"
:autosize="{ minRows: 8, maxRows: 16 }"
:autosize="{ minRows: 10, maxRows: 24 }"
placeholder="输入内容..." placeholder="输入内容..."
/> />
</t-form-item> </t-form-item>
@ -195,7 +195,8 @@ interface ConfigPreset {
const CONFIG_PRESETS: Record<string, ConfigPreset> = { const CONFIG_PRESETS: Record<string, ConfigPreset> = {
disclaimer: { type: 'html', label: '保密声明', desc: 'SDK 聊天窗口底部的保密声明' }, disclaimer: { type: 'html', label: '保密声明', desc: 'SDK 聊天窗口底部的保密声明' },
ai_system_prompt: { type: 'text', label: 'AI 全局系统提示词', desc: 'AI 对话全局系统提示词(为空则不注入)' },
ai_system_prompt: { type: 'text', label: 'AI 全局系统提示词', desc: 'AI 对话全局系统提示词(所有角色生效;为空则不注入)' },
ai_rag_answer_rules: { type: 'text', label: 'RAG 回答保真规则', desc: '仅 RAG 分支注入,紧贴【知识库资料】之前(为空则用代码内极简兜底)' },
suggestion_enabled: { type: 'boolean', label: 'AI 推荐问题开关', desc: 'AI 推荐问题功能开关' }, suggestion_enabled: { type: 'boolean', label: 'AI 推荐问题开关', desc: 'AI 推荐问题功能开关' },
suggestion_prompt: { type: 'text', label: 'AI 推荐问题 Prompt', desc: 'AI 推荐问题 Prompt 模板' }, suggestion_prompt: { type: 'text', label: 'AI 推荐问题 Prompt', desc: 'AI 推荐问题 Prompt 模板' },
// AI 执行链节点开关(主入口在「AI 执行链」页面,此处登记以保证以开关而非文本框呈现) // AI 执行链节点开关(主入口在「AI 执行链」页面,此处登记以保证以开关而非文本框呈现)

16
src/main/java/com/wok/supportbot/app/ChatPipeline.java

@ -16,6 +16,7 @@ import org.springframework.util.StringUtils;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
/** /**
@ -51,6 +52,12 @@ public class ChatPipeline {
/** FAQ 意图高置信度阈值:IntentRouter 返回 FAQ 且高于此值时,仅走 FAQ 匹配,不降级 RAG */ /** FAQ 意图高置信度阈值:IntentRouter 返回 FAQ 且高于此值时,仅走 FAQ 匹配,不降级 RAG */
private static final double FAQ_HIGH_CONFIDENCE_THRESHOLD = 0.8; private static final double FAQ_HIGH_CONFIDENCE_THRESHOLD = 0.8;
/** 全局系统提示词配置键(所有对话路径生效,管理后台「系统设置 → 系统配置」在线维护) */
private static final String KEY_AI_SYSTEM_PROMPT = "ai_system_prompt";
/** RAG 回答硬性规则配置键(仅 RAG 分支生效,紧贴【知识库资料】注入) */
private static final String KEY_RAG_ANSWER_RULES = "ai_rag_answer_rules";
@Resource @Resource
private IntentRouter intentRouter; private IntentRouter intentRouter;
@ -81,7 +88,12 @@ public class ChatPipeline {
* @return 执行决策 * @return 执行决策
*/ */
public ChatRequest buildRequest(ChatContext ctx) { public ChatRequest buildRequest(ChatContext ctx) {
String globalPrompt = systemConfigService.getValueByKey("ai_system_prompt");
// 一次批量查询取出本次请求所需的两个提示词配置,避免 hot path 上多次直查数据库
// (SystemConfigService 无缓存,后台改完下一请求即生效,故不走缓存方案)
Map<String, String> promptConfig = systemConfigService.getValues(
List.of(KEY_AI_SYSTEM_PROMPT, KEY_RAG_ANSWER_RULES));
String globalPrompt = promptConfig.get(KEY_AI_SYSTEM_PROMPT);
String ragAnswerRules = promptConfig.get(KEY_RAG_ANSWER_RULES);
String baseSystem = effectiveSystem(ctx.systemPrompt(), globalPrompt); String baseSystem = effectiveSystem(ctx.systemPrompt(), globalPrompt);
// 普通对话:enableRag=false(含 Controller 层 isKbDenied 强制置 false 的情况) // 普通对话:enableRag=false(含 Controller 层 isKbDenied 强制置 false 的情况)
@ -149,7 +161,7 @@ public class ChatPipeline {
} }
// RAG 生成:资料块注入 system,原始 message 作为 user 消息(重写查询仅用于检索) // RAG 生成:资料块注入 system,原始 message 作为 user 消息(重写查询仅用于检索)
String finalSystem = baseSystem + ragPipeline.buildRagContextBlock(rag.contextText());
String finalSystem = baseSystem + ragPipeline.buildRagContextBlock(rag.contextText(), ragAnswerRules);
return new ChatRequest(ctx, ctx.message(), finalSystem, Optional.empty(), return new ChatRequest(ctx, ctx.message(), finalSystem, Optional.empty(),
globalPrompt, rag.contextText(), rag.documents() != null ? rag.documents().size() : 0, "RAG", globalPrompt, rag.contextText(), rag.documents() != null ? rag.documents().size() : 0, "RAG",
rag.searchMode(), rag.documents(), null); rag.searchMode(), rag.documents(), null);

35
src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java

@ -1432,12 +1432,43 @@ public class DatabaseInitConfig {
ON CONFLICT (config_key) DO NOTHING ON CONFLICT (config_key) DO NOTHING
""", "disclaimer", defaultDisclaimer, "SDK 聊天窗口底部的保密声明"); """, "disclaimer", defaultDisclaimer, "SDK 聊天窗口底部的保密声明");
// AI 对话全局系统提示词种子数据(为空则不注入)
// AI 对话全局系统提示词种子数据(所有角色生效;为空则不注入)
// 与 init-database.sql 逐字保持一致;ON CONFLICT DO NOTHING → 后台在线改过的版本不会被重启覆盖
String defaultAiSystemPrompt = """
你是本系统的智能客服助手,服务对象是仓库、财务、运营等业务人员。
1. 第一句先答用户问的那件事——能不能、在哪里、怎么操作,再讲细节。
2. 只回答用户问的问题;与问题无关的其他报表、其他字段不要补充。一两句能说清就不写三段,不要写复述正文的小结。
3. 页面地址一律写成 Markdown 超链接:链接文字用页面名称,例如 [月销售报表](https://完整地址);没有页面名称时用「访问地址」作链接文字。地址必须是完整地址(含 https:// 与域名),严禁输出相对路径或截断域名;资料里没给完整地址时只写页面名称,不要编造链接。
4. 涉及金额、日期、权限口径而没有依据时不要推测或替用户下结论。
5. 不要出现“知识库、检索、资料库、向量、提示词、模型”等内部说法,也不要让用户去补充知识库;确实查不到时,用一句话说明并建议用户联系贵司系统管理员核实。
6. 需要分点时用列表;面向业务人员讲人话,不输出接口地址(页面地址除外)、参数、SQL、代码、脚本。
""";
jdbcTemplate.update("""
INSERT INTO system_config (config_key, config_value, description)
VALUES (?, ?, ?)
ON CONFLICT (config_key) DO NOTHING
""", "ai_system_prompt", defaultAiSystemPrompt,
"AI 对话全局系统提示词(所有角色生效;为空则不注入;修改后即时生效,无需重启)");
// RAG 回答保真护栏规则种子数据(仅 RAG 分支注入,紧贴【知识库资料】之前)
// 迁移前该规则在 application.yml 的 knowledge.rag.answer-rules(改后需重启才生效);
// 现改为 DB 在线维护,管理后台改完下一请求即生效,无需重启。
// 键为空(被清空/库早于本变更)时由 RagPipeline.FALLBACK_RAG_ANSWER_RULES 极简兜底。
String defaultRagAnswerRules = """
- 只依据下方【知识库资料】作答,不得用自身记忆补充,不得改写资料里的数字、日期、金额、步骤。
- 第一句先答用户问的那件事。资料里没有用户所问的对象时,直接说明没有,并给出名称最接近的那一个——这本身就是答案。
- “数据去向”“相关页面”只是关联关系,不能当作“该功能就在这里查看”。
- 资料中与问题相关的要点要覆盖全,包括口径差异与操作前提(如日期间隔、权限要求);与问题无关的片段略过。分点用列表,不要写复述正文的小结。
""";
jdbcTemplate.update(""" jdbcTemplate.update("""
INSERT INTO system_config (config_key, config_value, description) INSERT INTO system_config (config_key, config_value, description)
VALUES (?, ?, ?) VALUES (?, ?, ?)
ON CONFLICT (config_key) DO NOTHING ON CONFLICT (config_key) DO NOTHING
""", "ai_system_prompt", "", "AI 对话全局系统提示词(为空则不注入;修改后即时生效,无需重启)");
""", "ai_rag_answer_rules", defaultRagAnswerRules,
"RAG 回答保真护栏规则(仅 RAG 分支注入;为空则用代码内极简兜底)");
// AI 推荐问题(suggest-message-list)种子数据 // AI 推荐问题(suggest-message-list)种子数据
// 格式要求由 SuggestionGenerator.buildPrompt 统一追加,此处仅提供基础文案, // 格式要求由 SuggestionGenerator.buildPrompt 统一追加,此处仅提供基础文案,

29
src/main/java/com/wok/supportbot/config/RagPromptConfig.java

@ -1,29 +0,0 @@
package com.wok.supportbot.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* RAG 提示词配置
* 通过 application.yml 的 knowledge.rag.answer-rules 动态调整 RAG 回答规则(保真护栏),无需改代码重新编译。
*/
@Component
@ConfigurationProperties(prefix = "knowledge.rag")
@EnableConfigurationProperties(RagPromptConfig.class)
@Data
public class RagPromptConfig {
/**
* RAG 回答规则(保真护栏),注入 system 提示词与 ContextualQueryAugmenter 自定义模板。
* 默认值在「忠于资料、不编造」基础上,强调覆盖全部相关要点、不遗漏步骤。
* yml 未配置时使用此默认值;yml 配置后覆盖。
*/
private String answerRules = """
- 完整性优先:必须覆盖下方【知识库资料】中所有与用户问题相关的要点,按资料本身的条理分点结构化输出;不得只摘取其中一两段或几条而遗漏其余相关的步骤、条件、注意事项、话术示例。多段资料从不同角度回答同一问题时,需综合全部相关内容作答。
- 忠于资料:只能依据下方【知识库资料】回答,不得调用模型自身记忆补充资料之外的内容;资料中包含流程、步骤、话术、制度等具体内容时,优先逐字引用或完整转述,不得使用未检索到的诗句、篇名、作者或步骤。
- 来源对应:回答中每个要点都应能在【知识库资料】中找到对应来源;不得让回答内容与下方引用来源不一致,也不得将多步流程压缩到只剩一两条。
- 资料不足:如果【知识库资料】中没有用户要求的内容,请明确说明未在当前知识库中检索到足够资料,并提示补充或调整知识库。
- 排版规范:使用 Markdown 结构化输出——分点用有序或无序列表(`1.` / `-`),不同要点之间空一行分段,涉及多来源或多角度时可用二级标题(`##`)区分;严禁把所有内容挤在一段内。""";
}

19
src/main/java/com/wok/supportbot/controller/AiController.java

@ -13,6 +13,7 @@ import com.wok.supportbot.service.CustomerServiceRoleService.RoleScope;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document; import org.springframework.ai.document.Document;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
@ -34,6 +35,7 @@ import java.util.Collections;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
@RestController @RestController
@ -399,16 +401,33 @@ public class AiController {
// ==================== SDK 公开接口 ==================== // ==================== SDK 公开接口 ====================
/**
* 禁止通过 SDK 通用取值口读取的配置键。
* <p>
* 该接口原意是让 SDK 动态拉取保密声明等展示类配置;提示词属商业秘密
* (见 CLAUDE.md「LLM 调用追踪」的安全声明),不应对第三方开放。
*/
private static final Set<String> SDK_PROTECTED_CONFIG_KEYS = Set.of(
"ai_system_prompt", "ai_rag_answer_rules");
/** /**
* 获取系统配置值(供 SDK 动态拉取保密声明等配置) * 获取系统配置值(供 SDK 动态拉取保密声明等配置)
* <p> * <p>
* 路径在 /ai/ 下,由 SdkAuthFilter 守卫;无 Token 的 SDK 请求会被拦截返回 401。 * 路径在 /ai/ 下,由 SdkAuthFilter 守卫;无 Token 的 SDK 请求会被拦截返回 401。
* 提示词类配置键({@link #SDK_PROTECTED_CONFIG_KEYS})不对外开放。
* *
* @param key 配置键,如 "disclaimer" * @param key 配置键,如 "disclaimer"
* @return { success: true, data: { configKey, configValue } } * @return { success: true, data: { configKey, configValue } }
*/ */
@GetMapping("/system-config/{key}") @GetMapping("/system-config/{key}")
public ResponseEntity<Map<String, Object>> getSdkSystemConfig(@PathVariable String key) { public ResponseEntity<Map<String, Object>> getSdkSystemConfig(@PathVariable String key) {
if (SDK_PROTECTED_CONFIG_KEYS.contains(key)) {
log.warn("SDK 尝试读取受保护的提示词配置,已拒绝, key={}", key);
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(Map.of(
"success", false,
"message", "该配置项不对外开放"
));
}
try { try {
String value = systemConfigService.getValueByKey(key); String value = systemConfigService.getValueByKey(key);
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(

29
src/main/java/com/wok/supportbot/rag/RagPipeline.java

@ -2,7 +2,6 @@ package com.wok.supportbot.rag;
import com.wok.supportbot.app.ChatContext; import com.wok.supportbot.app.ChatContext;
import com.wok.supportbot.chatmemory.DatabaseChatMemory; import com.wok.supportbot.chatmemory.DatabaseChatMemory;
import com.wok.supportbot.config.RagPromptConfig;
import com.wok.supportbot.rag.preretrieval.CompressionQueryRewriter; import com.wok.supportbot.rag.preretrieval.CompressionQueryRewriter;
import com.wok.supportbot.rag.preretrieval.MultiQueryExpanderRewriter; import com.wok.supportbot.rag.preretrieval.MultiQueryExpanderRewriter;
import com.wok.supportbot.rag.preretrieval.RewriteQueryRewriter; import com.wok.supportbot.rag.preretrieval.RewriteQueryRewriter;
@ -40,8 +39,7 @@ import java.util.stream.Collectors;
* <li>FAQ 优先匹配(复用 {@link FaqMatchEngine} 三级匹配)</li> * <li>FAQ 优先匹配(复用 {@link FaqMatchEngine} 三级匹配)</li>
* <li>查询重写(按 {@code rewriteStrategy} 复用 {@code rag/preretrieval/*} 四种 rewriter)</li> * <li>查询重写(按 {@code rewriteStrategy} 复用 {@code rag/preretrieval/*} 四种 rewriter)</li>
* <li>统一检索:{@code MULTI_QUERY} 扩展多查询后按文档 ID 去重合并,其余策略单查询检索</li> * <li>统一检索:{@code MULTI_QUERY} 扩展多查询后按文档 ID 去重合并,其余策略单查询检索</li>
* <li>统一资料块模板 {@link #buildRagContextBlock},替代原 {@code buildRetrievalAdvisor.qaTemplate}
* 与 {@code buildRagSystemPrompt} 两份回答模板</li>
* <li>统一资料块模板 {@link #buildRagContextBlock}:把「RAG 硬性规则 + 知识库资料」拼成一段</li>
* </ul> * </ul>
* <p> * <p>
* 统一为"手动检索 + 资料块注入系统提示词"模式:所有策略都先由本管道检索出文档, * 统一为"手动检索 + 资料块注入系统提示词"模式:所有策略都先由本管道检索出文档,
@ -49,7 +47,7 @@ import java.util.stream.Collectors;
* 不再使用 {@code RetrievalAugmentationAdvisor} 的 query augmenter 自动注入, * 不再使用 {@code RetrievalAugmentationAdvisor} 的 query augmenter 自动注入,
* 消除上下文注入位置随策略不同而不同的不一致。 * 消除上下文注入位置随策略不同而不同的不一致。
* <p> * <p>
* 阶段一作为旁路组件存在,旧 {@code AssistantApp} RAG 路径未改动;阶段二由 {@code ChatPipeline} 接入。
* 由 {@code ChatPipeline} 接入主对话流程。
* <p> * <p>
* {@code @pipeline} rag-layer order=1<br> * {@code @pipeline} rag-layer order=1<br>
* {@code @pipeline-step} retrieve: FAQ优先匹配 → 查询重写/扩展 → similaritySearch(PGVector) → 资料拼接<br> * {@code @pipeline-step} retrieve: FAQ优先匹配 → 查询重写/扩展 → similaritySearch(PGVector) → 资料拼接<br>
@ -70,15 +68,21 @@ public class RagPipeline {
/** COMPRESSION 策略读取的对话历史条数 */ /** COMPRESSION 策略读取的对话历史条数 */
private static final int COMPRESSION_HISTORY_SIZE = 10; private static final int COMPRESSION_HISTORY_SIZE = 10;
/**
* RAG 硬性规则的极简兜底:仅当 system_config.ai_rag_answer_rules 为空时使用。
* <p>
* 注意其地位是「安全底线」而非「默认值」——完整的可调规则由管理后台
* 「系统设置 → 系统配置」在线维护,改完下一请求即生效。
*/
private static final String FALLBACK_RAG_ANSWER_RULES =
"仅依据【知识库资料】回答;资料未覆盖时直接说明暂时无法确认,不要编造。";
@Resource @Resource
private VectorStore pgVectorVectorStore; private VectorStore pgVectorVectorStore;
@Resource @Resource
private FaqMatchEngine faqMatchEngine; private FaqMatchEngine faqMatchEngine;
@Resource
private RagPromptConfig ragPromptConfig;
@Resource @Resource
private RagHitLogService ragHitLogService; private RagHitLogService ragHitLogService;
@ -199,16 +203,19 @@ public class RagPipeline {
/** /**
* 构造统一的 RAG 资料块文本,供调用方追加到系统提示词。 * 构造统一的 RAG 资料块文本,供调用方追加到系统提示词。
* <p> * <p>
* 替代原 {@code buildRetrievalAdvisor} 的 qaTemplate 与 {@code buildRagSystemPrompt} 两份模板,
* 所有策略共用同一份"回答硬性规则 + 知识库资料"格式。
* 所有检索策略共用同一份「回答硬性规则 + 知识库资料」格式。
* 规则文本由调用方从 {@code system_config.ai_rag_answer_rules} 取出后传入,
* 使规则可在管理后台在线维护、改完下一请求即生效,无需重启。
* *
* @param contextText 检索拼接的资料文本,为空时返回空串(调用方据此决定是否追加) * @param contextText 检索拼接的资料文本,为空时返回空串(调用方据此决定是否追加)
* @param answerRules RAG 硬性规则文本;为空时回退到 {@link #FALLBACK_RAG_ANSWER_RULES}
*/ */
public String buildRagContextBlock(String contextText) {
public String buildRagContextBlock(String contextText, String answerRules) {
if (!StringUtils.hasText(contextText)) { if (!StringUtils.hasText(contextText)) {
return ""; return "";
} }
return "\n\n【RAG回答硬性规则】\n" + ragPromptConfig.getAnswerRules()
String rules = StringUtils.hasText(answerRules) ? answerRules : FALLBACK_RAG_ANSWER_RULES;
return "\n\n【RAG回答硬性规则】\n" + rules
+ "\n\n【知识库资料】\n" + contextText; + "\n\n【知识库资料】\n" + contextText;
} }

25
src/main/java/com/wok/supportbot/service/SystemConfigService.java

@ -8,8 +8,12 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.Collection;
import java.util.Collections;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/** /**
* 系统配置 CRUD 服务 * 系统配置 CRUD 服务
@ -47,6 +51,27 @@ public class SystemConfigService {
return systemConfigMapper.selectOne(wrapper); return systemConfigMapper.selectOne(wrapper);
} }
/**
* 批量根据 key 查询配置值(一次 IN 查询,避免每请求多次直查数据库)
* <p>
* 供对话 hot path 一次取多个提示词配置使用(如 ai_system_prompt + ai_rag_answer_rules)。
*
* @param keys 配置键集合
* @return key → configValue 的映射;不存在的 key 不会出现在返回结果中
*/
public Map<String, String> getValues(Collection<String> keys) {
if (keys == null || keys.isEmpty()) {
return Collections.emptyMap();
}
LambdaQueryWrapper<SystemConfig> wrapper = new LambdaQueryWrapper<>();
wrapper.in(SystemConfig::getConfigKey, keys);
return systemConfigMapper.selectList(wrapper).stream()
.collect(Collectors.toMap(
SystemConfig::getConfigKey,
c -> c.getConfigValue() != null ? c.getConfigValue() : "",
(a, b) -> a));
}
/** /**
* 保存或更新配置(upsert 语义) * 保存或更新配置(upsert 语义)
* *

11
src/main/resources/application.yml

@ -68,14 +68,9 @@ knowledge:
# FAQ 语义匹配阈值(0-1),低于此值自动降级到 RAG 检索 # FAQ 语义匹配阈值(0-1),低于此值自动降级到 RAG 检索
semantic-threshold: 0.85 semantic-threshold: 0.85
rag: rag:
# RAG 回答规则(保真护栏):所有角色统一生效,注入到 RAG system 提示词与 ContextualQueryAugmenter 模板。
# 调整后无需改代码,重启即生效。规则需引用「下方【知识库资料】」这一占位块。
answer-rules: |-
- 完整性优先:必须覆盖下方【知识库资料】中所有与用户问题相关的要点,按资料本身的条理分点结构化输出;不得只摘取其中一两段或几条而遗漏其余相关的步骤、条件、注意事项、话术示例。多段资料从不同角度回答同一问题时,需综合全部相关内容作答。
- 忠于资料:只能依据下方【知识库资料】回答,不得调用模型自身记忆补充资料之外的内容;资料中包含流程、步骤、话术、制度等具体内容时,优先逐字引用或完整转述,不得使用未检索到的诗句、篇名、作者或步骤。
- 来源对应:回答中每个要点都应能在【知识库资料】中找到对应来源;不得让回答内容与下方引用来源不一致,也不得将多步流程压缩到只剩一两条。
- 资料不足:如果【知识库资料】中没有用户要求的内容,请明确说明未在当前知识库中检索到足够资料,并提示补充或调整知识库。
- 排版规范:使用 Markdown 结构化输出——分点用有序或无序列表(`1.` / `-`),不同要点之间空一行分段,涉及多来源或多角度时可用二级标题(`##`)区分;严禁把所有内容挤在一段内。
# RAG 回答保真护栏规则已迁移到数据库在线维护:
# system_config.ai_rag_answer_rules(管理后台「系统设置 → 系统配置」,改完即生效无需重启)。
# 键为空时代码内用一句极简兜底规则(RagPipeline.FALLBACK_RAG_ANSWER_RULES)。
# MULTI_QUERY 多路检索并行开关(性能优化):扩展出的多路查询是否并行执行向量检索。 # MULTI_QUERY 多路检索并行开关(性能优化):扩展出的多路查询是否并行执行向量检索。
# 并行可将多路串行(≈N×(embedding HTTP + PG 查询))降为 1 段,显著降低首 token 前耗时。 # 并行可将多路串行(≈N×(embedding HTTP + PG 查询))降为 1 段,显著降低首 token 前耗时。
# 注意 embedding 多为逐条调用无法批量(如豆包多模态 vision 模型);高并发/embedding 侧限流时置 false 回退串行。 # 注意 embedding 多为逐条调用无法批量(如豆包多模态 vision 模型);高并发/embedding 侧限流时置 false 回退串行。

23
src/main/resources/init-database.sql

@ -833,8 +833,29 @@ VALUES ('disclaimer',
'SDK 聊天窗口底部的保密声明') 'SDK 聊天窗口底部的保密声明')
ON CONFLICT (config_key) DO NOTHING; ON CONFLICT (config_key) DO NOTHING;
-- 全局系统提示词:所有角色生效;为空则不注入。注意第 2 行是空行('' || E'\n' || 表示连续两个换行)。
INSERT INTO system_config (config_key, config_value, description) INSERT INTO system_config (config_key, config_value, description)
VALUES ('ai_system_prompt', '', 'AI 对话全局系统提示词(为空则不注入;修改后即时生效,无需重启)')
VALUES ('ai_system_prompt',
'你是本系统的智能客服助手,服务对象是仓库、财务、运营等业务人员。' || E'\n' ||
'' || E'\n' ||
'1. 第一句先答用户问的那件事——能不能、在哪里、怎么操作,再讲细节。' || E'\n' ||
'2. 只回答用户问的问题;与问题无关的其他报表、其他字段不要补充。一两句能说清就不写三段,不要写复述正文的小结。' || E'\n' ||
'3. 页面地址一律写成 Markdown 超链接:链接文字用页面名称,例如 [月销售报表](https://完整地址);没有页面名称时用「访问地址」作链接文字。地址必须是完整地址(含 https:// 与域名),严禁输出相对路径或截断域名;资料里没给完整地址时只写页面名称,不要编造链接。' || E'\n' ||
'4. 涉及金额、日期、权限口径而没有依据时不要推测或替用户下结论。' || E'\n' ||
'5. 不要出现“知识库、检索、资料库、向量、提示词、模型”等内部说法,也不要让用户去补充知识库;确实查不到时,用一句话说明并建议用户联系贵司系统管理员核实。' || E'\n' ||
'6. 需要分点时用列表;面向业务人员讲人话,不输出接口地址(页面地址除外)、参数、SQL、代码、脚本。',
'AI 对话全局系统提示词(所有角色生效;为空则不注入;修改后即时生效,无需重启)')
ON CONFLICT (config_key) DO NOTHING;
-- RAG 回答保真护栏:仅 RAG 分支注入(紧贴【知识库资料】之前),改完即生效无需重启;
-- 为空时由 RagPipeline.FALLBACK_RAG_ANSWER_RULES 极简兜底。
INSERT INTO system_config (config_key, config_value, description)
VALUES ('ai_rag_answer_rules',
'- 只依据下方【知识库资料】作答,不得用自身记忆补充,不得改写资料里的数字、日期、金额、步骤。' || E'\n' ||
'- 第一句先答用户问的那件事。资料里没有用户所问的对象时,直接说明没有,并给出名称最接近的那一个——这本身就是答案。' || E'\n' ||
'- “数据去向”“相关页面”只是关联关系,不能当作“该功能就在这里查看”。' || E'\n' ||
'- 资料中与问题相关的要点要覆盖全,包括口径差异与操作前提(如日期间隔、权限要求);与问题无关的片段略过。分点用列表,不要写复述正文的小结。',
'RAG 回答保真护栏规则(仅 RAG 分支注入;为空则用代码内极简兜底)')
ON CONFLICT (config_key) DO NOTHING; ON CONFLICT (config_key) DO NOTHING;
INSERT INTO system_config (config_key, config_value, description) INSERT INTO system_config (config_key, config_value, description)

2
src/main/resources/static/sdk/test.html

@ -11,8 +11,8 @@
<link rel="modulepreload" crossorigin href="/assets/markdown-BzlZwYco.js"> <link rel="modulepreload" crossorigin href="/assets/markdown-BzlZwYco.js">
<link rel="modulepreload" crossorigin href="/assets/chatAdapter-BMDefjTd.js"> <link rel="modulepreload" crossorigin href="/assets/chatAdapter-BMDefjTd.js">
<link rel="stylesheet" crossorigin href="/assets/tdesign-CY0HVqZ3.css"> <link rel="stylesheet" crossorigin href="/assets/tdesign-CY0HVqZ3.css">
<link rel="stylesheet" crossorigin href="/assets/tdesign-web-components-B-ycfzW_.css">
<link rel="stylesheet" crossorigin href="/assets/tdesign-chat-Dj1Q23QO.css"> <link rel="stylesheet" crossorigin href="/assets/tdesign-chat-Dj1Q23QO.css">
<link rel="stylesheet" crossorigin href="/assets/tdesign-web-components-B-ycfzW_.css">
<link rel="stylesheet" crossorigin href="/assets/sdk-test-C9_UiJCh.css"> <link rel="stylesheet" crossorigin href="/assets/sdk-test-C9_UiJCh.css">
</head> </head>
<body> <body>

Loading…
Cancel
Save