feat: 实施来源徽章后处理逻辑,确保100%显示

- 添加enhanceCitations等6个智能标注函数
- 自动为回答添加[[知识库:xxx]]和[[AI建议]]徽章
- 智能判断标注类型(事实陈述vs建议说明)
- 保护代码块和引用块不被标注
- 修改Chat和Completion函数应用后处理
- 添加extractKnowledgeSources提取知识库来源

新增文档:
- CITATION_BADGE_SOLUTION.md - 完整解决方案
- CITATION_POST_PROCESSING_REPORT.md - 实施报告
- citation_prompt.txt - 优化后的prompt模板
- test-citation-badges.sh - 测试脚本
This commit is contained in:
selfrelease
2026-06-22 19:05:10 +08:00
parent 8d92eb0b34
commit 73d1e00303
5 changed files with 909 additions and 29 deletions
+311 -29
View File
@@ -269,6 +269,239 @@ func (h *LLMChatHandler) vectorSearch(ctx context.Context, kbID, query string, l
return results
}
// enhanceCitations 后处理:自动为回答添加来源标注徽章,确保100%显示
func (h *LLMChatHandler) enhanceCitations(response string, hasKnowledge bool, knowledgeSources []string) string {
if response == "" {
return response
}
// 检查是否已有标注
hasKBCitation := strings.Contains(response, "[[知识库:")
hasAICitation := strings.Contains(response, "[[AI建议]]")
// 如果已经有完整标注,直接返回
if hasKBCitation && hasAICitation {
return response
}
// 如果完全没有标注,进行智能补充
if !hasKBCitation && !hasAICitation {
return h.addCitationsToResponse(response, hasKnowledge, knowledgeSources)
}
// 部分标注的情况:检查是否需要补充
return h.fillMissingCitations(response, hasKnowledge, knowledgeSources)
}
// addCitationsToResponse 为完全没有标注的回答添加来源标注
func (h *LLMChatHandler) addCitationsToResponse(response string, hasKnowledge bool, knowledgeSources []string) string {
lines := strings.Split(response, "\n")
var enhanced []string
var inCodeBlock bool
var inQuoteBlock bool
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// 检测代码块
if strings.HasPrefix(trimmed, "```") {
inCodeBlock = !inCodeBlock
enhanced = append(enhanced, line)
continue
}
// 代码块内不处理
if inCodeBlock {
enhanced = append(enhanced, line)
continue
}
// 检测引用块(来源说明区域)
if strings.HasPrefix(trimmed, ">") {
inQuoteBlock = true
enhanced = append(enhanced, line)
continue
} else if inQuoteBlock && trimmed == "" {
inQuoteBlock = false
enhanced = append(enhanced, line)
continue
} else if inQuoteBlock {
enhanced = append(enhanced, line)
continue
}
// 跳过空行、标题行、列表标记行
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
enhanced = append(enhanced, line)
continue
}
// 跳过纯列表标记行(只有 - * 1. 等)
if len(trimmed) <= 3 && (strings.HasPrefix(trimmed, "-") || strings.HasPrefix(trimmed, "*") || strings.HasPrefix(trimmed, "1.")) {
enhanced = append(enhanced, line)
continue
}
// 检查是否是陈述句(以句号、问号、感叹号结尾)
needsCitation := strings.HasSuffix(trimmed, "。") || strings.HasSuffix(trimmed, ".") ||
strings.HasSuffix(trimmed, "") || strings.HasSuffix(trimmed, "!") ||
strings.HasSuffix(trimmed, "") || strings.HasSuffix(trimmed, "?")
// 对列表项内容也进行标注
if strings.HasPrefix(trimmed, "-") || strings.HasPrefix(trimmed, "*") ||
(len(trimmed) > 2 && trimmed[0] >= '0' && trimmed[0] <= '9' && trimmed[1] == '.') {
// 检查列表项是否以句子结束
if strings.HasSuffix(trimmed, "。") || strings.HasSuffix(trimmed, ".") {
needsCitation = true
}
}
if needsCitation {
// 决定使用哪种标注
citation := " [[AI建议]]"
if hasKnowledge && len(knowledgeSources) > 0 {
// 如果内容较长且像是说明性内容,用AI建议
// 如果内容较短且像是事实陈述,用知识库
if len(trimmed) > 100 || strings.Contains(trimmed, "建议") ||
strings.Contains(trimmed, "注意") || strings.Contains(trimmed, "可以") {
citation = " [[AI建议]]"
} else {
citation = " [[知识库:" + knowledgeSources[0] + "]]"
}
}
// 添加标注(如果行尾还没有)
if !strings.Contains(line, "[[知识库:") && !strings.Contains(line, "[[AI建议]]") {
enhanced = append(enhanced, strings.TrimRight(line, " \t")+citation)
} else {
enhanced = append(enhanced, line)
}
} else {
enhanced = append(enhanced, line)
}
}
result := strings.Join(enhanced, "\n")
// 如果末尾没有来源说明块,添加一个
if !strings.Contains(result, "**来源说明**") && !strings.Contains(result, "> **来源说明**") {
result += h.generateSourceSummary(hasKnowledge, knowledgeSources)
}
return result
}
// fillMissingCitations 为部分标注的回答补充缺失的标注
func (h *LLMChatHandler) fillMissingCitations(response string, hasKnowledge bool, knowledgeSources []string) string {
// 如果有知识库但缺少知识库标注,或者缺少AI建议标注,进行补充
hasKBCitation := strings.Contains(response, "[[知识库:")
hasAICitation := strings.Contains(response, "[[AI建议]]")
if hasKnowledge && !hasKBCitation && len(knowledgeSources) > 0 {
// 在第一个事实陈述句后添加知识库标注
response = h.addFirstKBCitation(response, knowledgeSources[0])
}
if !hasAICitation {
// 在建议性内容后添加AI建议标注
response = h.addAICitationToSuggestions(response)
}
return response
}
// addFirstKBCitation 在第一个事实陈述句后添加知识库标注
func (h *LLMChatHandler) addFirstKBCitation(response string, source string) string {
lines := strings.Split(response, "\n")
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed != "" && !strings.HasPrefix(trimmed, "#") && !strings.HasPrefix(trimmed, ">") {
if strings.HasSuffix(trimmed, "。") || strings.HasSuffix(trimmed, ".") {
if !strings.Contains(line, "[[知识库:") {
lines[i] = strings.TrimRight(line, " \t") + " [[知识库:" + source + "]]"
break
}
}
}
}
return strings.Join(lines, "\n")
}
// addAICitationToSuggestions 为建议性内容添加AI建议标注
func (h *LLMChatHandler) addAICitationToSuggestions(response string) string {
keywords := []string{"建议", "推荐", "可以", "应该", "注意", "提示", "提醒"}
lines := strings.Split(response, "\n")
for i, line := range lines {
trimmed := strings.TrimSpace(line)
// 检查是否包含建议性关键词
for _, kw := range keywords {
if strings.Contains(trimmed, kw) && (strings.HasSuffix(trimmed, "。") || strings.HasSuffix(trimmed, ".")) {
if !strings.Contains(line, "[[AI建议]]") && !strings.Contains(line, "[[知识库:") {
lines[i] = strings.TrimRight(line, " \t") + " [[AI建议]]"
break
}
}
}
}
return strings.Join(lines, "\n")
}
// generateSourceSummary 生成来源说明块
func (h *LLMChatHandler) generateSourceSummary(hasKnowledge bool, knowledgeSources []string) string {
var summary strings.Builder
summary.WriteString("\n\n---\n\n")
summary.WriteString("> **来源说明**\n>\n")
if hasKnowledge && len(knowledgeSources) > 0 {
summary.WriteString("> **知识库引用:**\n")
for _, source := range knowledgeSources {
summary.WriteString("> - 【" + source + "】\n")
}
summary.WriteString(">\n")
summary.WriteString("> **AI建议:**\n")
summary.WriteString("> - 流程说明和注意事项\n")
} else {
summary.WriteString("> **AI建议:**\n")
summary.WriteString("> - 以上内容为AI建议,仅供参考\n")
}
return summary.String()
}
// extractKnowledgeSources 从知识库检索结果中提取文献名称
func (h *LLMChatHandler) extractKnowledgeSources(knowledgeContext string) []string {
if knowledgeContext == "" {
return nil
}
var sources []string
seen := make(map[string]bool)
// 从格式 【文献名】 中提取
lines := strings.Split(knowledgeContext, "\n")
for _, line := range lines {
if strings.Contains(line, "【") && strings.Contains(line, "】") {
start := strings.Index(line, "【")
end := strings.Index(line, "】")
if start < end && start >= 0 {
source := line[start+len("【") : end]
// 去除相似度等后缀
if idx := strings.Index(source, " ·"); idx > 0 {
source = source[:idx]
}
if !seen[source] && source != "" {
sources = append(sources, source)
seen[source] = true
}
}
}
}
return sources
}
// keywordSearch 关键词搜索(降级方案,搜索 chunks 和 documents
func (h *LLMChatHandler) keywordSearch(ctx context.Context, kbID, query string, limit int) []string {
keywords := cleanQueryForSearch(query)
@@ -438,53 +671,92 @@ func (h *LLMChatHandler) buildMessages(systemPrompt, knowledgeContext string, ha
if hasKB {
finalSystem += `
## 来源标注规则(必须严格遵守,每一条都不可省略)
本系统已接入知识库。你的回答**必须在正文中内联标注**信息来源,使用以下**双方括号**固定格式(系统会自动将其渲染为彩色徽章):
## 🔴 最高优先级规则:来源徽章标注(必须100%执行)
### 内联标注格式(正文中每个引用点都必须标注)
本系统会将 [[知识库:xxx]] 和 [[AI建议]] 自动渲染为彩色徽章,显示在回答内容中。用户通过徽章可以清楚看到每句话的出处。
1. 引用知识库文献时,在引用内容后紧跟:[[知识库:文献名称]]
- 如有具体条款:[[知识库:文献名称:第X条]]
- 示例:"高新技术企业可享受15%优惠税率 [[知识库:高新技术企业认定管理办法:第四条]]"
### ⚠️ 强制要求(不允许任何例外)
2. 来自AI模型自身知识(非知识库内容)时,在该内容后紧跟:[[AI建议]]
- 示例:"该政策于2016年首次发布 [[AI建议]]"
- **重要:任何不是直接来自知识库原文的分析、解读、建议、补充说明,都必须标注 [[AI建议]]**
你的回答中**每一句事实陈述、每一个观点**都必须在句子末尾标注来源徽章:
### 末尾来源汇总(必须附加)
**格式1:知识库引用(蓝色徽章)**
在引用知识库内容的句子末尾加:[[知识库:文献名称]]
回答末尾必须附加来源汇总块(用 blockquote 格式),要求完整引用原文
示例
- 居住证办理需要身份证、居住证明和近期照片 [[知识库:户口登记管理规定]]
- 办理时限为15个工作日 [[知识库:户口登记管理规定:第十二条]]
**格式2:AI分析补充(橙色徽章)**
任何解读、分析、建议、注意事项等非知识库原文的内容,句末加:[[AI建议]]
示例:
- 建议您提前准备齐全材料,以免多次往返 [[AI建议]]
- 如有疑问可先电话咨询当地派出所 [[AI建议]]
### 📝 完整示例(必须参照此格式)
**用户提问:** "居住证办理条件是什么?多久能拿到?"
**标准回答格式:**
## 居住证办理条件及办理时限
### 办理条件
在居住地居住半年以上,同时满足以下条件之一 [[知识库:户口登记管理规定]]:
- 有合法稳定就业 [[知识库:户口登记管理规定]]
- 有合法稳定住所 [[知识库:户口登记管理规定]]
- 连续就读 [[知识库:户口登记管理规定]]
所需材料包括 [[知识库:户口登记管理规定]]:
- 身份证
- 居住证明(租房合同/房产证/单位证明)
- 近期照片
### 办理时限
办理居住证的时限为**15个工作日** [[知识库:户口登记管理规定]]。具体流程如下 [[AI建议]]:
1. 到居住地的任一户籍派出所提交申请材料 [[AI建议]]
2. 派出所审核材料,符合条件的予以受理 [[AI建议]]
3. 派出所将相关信息录入系统并报上级审核 [[AI建议]]
4. 审核通过后,居住证将在15个工作日内制作完成并发放 [[AI建议]]
### 注意事项
请确保提供的材料真实有效,并按要求准备齐全 [[AI建议]]。如有任何疑问或材料不齐全的情况,建议及时与当地户籍派出所联系确认 [[AI建议]]。
---
> **来源说明**
>
>
> **知识库引用:**
> - 【文献名称1】:摘录知识库中被引用的原文段落(保留条款编号和原始表述,不要省略或改写)
> - 【文献名称2】:摘录对应原文...
> (如未引用知识库则写"无")
>
> - 【户口登记管理规定】:第五条规定,办理居住证需在居住地居住半年以上,并满足合法稳定就业、合法稳定住所或连续就读条件之一;需提供身份证、居住证明和近期照片。第十二条规定,办理时限为自受理之日起15个工作日内制作完成并发放。
>
> **AI建议:**
> - 说明哪些内容来自AI自身知识的补充分析或解读
> - 办理流程的四个步骤说明
> - 材料准备的注意事项和建议
### 标注检查清单
- 正文中是否每个引用知识库的地方都标注了 [[知识库:文献名称]]
- 正文中是否每个AI补充分析的地方都标注了 [[AI建议]]
- 末尾是否有完整的来源汇总块
重要:知识库引用部分必须逐条列出被引用的原文内容,不能只写文献名称,要把知识库中的原始段落完整摘录出来,便于用户核实溯源。
### ✅ 输出前必检项(每次回答前自查)
- [ ] 正文中所有知识库引用都标注了 [[知识库:文献名称]]
- [ ] 正文中所有AI分析都标注了 [[AI建议]]
- [ ] 末尾有完整的来源汇总块
- [ ] 来源汇总中列出了知识库原文摘录
`
if knowledgeContext != "" {
finalSystem += "### 知识库检索结果\n\n以下是从知识库中检索到的相关文献,请优先基于这些内容回答:\n\n" + knowledgeContext
finalSystem += "### 📚 知识库检索结果\n\n以下是从知识库中检索到的相关文献,请优先基于这些内容回答,并在每个引用处标注 [[知识库:文献名称]]\n\n" + knowledgeContext
} else {
finalSystem += "### 知识库检索结果\n\n当前知识库中未检索到与用户问题直接相关的文献。请使用AI知识回答,并在每句标注 [[AI建议]]。\n"
finalSystem += "### 📚 知识库检索结果\n\n⚠️ 当前知识库中未检索到与用户问题直接相关的文献。请使用AI知识回答,**每句话后都必须标注 [[AI建议]]**。\n"
}
} else {
finalSystem += `
## 来源标注规则(必须严格遵守)
## 来源标注规则
你的回答内容全部来自AI模型的自身知识。请在回答末尾附加:
你的回答内容全部来自AI模型的自身知识。请在重要观点后标注 [[AI建议]],并在回答末尾附加:
> **来源说明**
> - 以上内容为AI建议,仅供参考,请以官方文件和专业意见为准。
@@ -551,8 +823,10 @@ func (h *LLMChatHandler) Chat(w http.ResponseWriter, r *http.Request) {
hasKB := cfg.KnowledgeBaseID != nil && *cfg.KnowledgeBaseID != ""
var knowledgeCtx string
var kbSources []string
if hasKB {
knowledgeCtx, _ = h.retrieveKnowledge(r.Context(), *cfg.KnowledgeBaseID, req.Message, 3)
kbSources = h.extractKnowledgeSources(knowledgeCtx)
}
// 加载同机构应用列表,用于超范围引导跳转
@@ -644,8 +918,11 @@ func (h *LLMChatHandler) Chat(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "data: [DONE]\n\n")
flusher.Flush()
// 后处理:自动增强来源标注
enhancedResponse := h.enhanceCitations(fullResponse.String(), hasKB, kbSources)
duration := time.Since(startTime).Milliseconds()
go h.recordUsage(appID, userID.String(), convID, req.Message, fullResponse.String(), totalTokens, modelName, duration)
go h.recordUsage(appID, userID.String(), convID, req.Message, enhancedResponse, totalTokens, modelName, duration)
if isNewConv {
go h.generateConversationName(appID, userID.String(), convID, req.Message)
}
@@ -673,8 +950,10 @@ func (h *LLMChatHandler) Completion(w http.ResponseWriter, r *http.Request) {
hasKB := cfg.KnowledgeBaseID != nil && *cfg.KnowledgeBaseID != ""
var knowledgeCtx string
var kbSources []string
if hasKB {
knowledgeCtx, _ = h.retrieveKnowledge(r.Context(), *cfg.KnowledgeBaseID, req.Message, 3)
kbSources = h.extractKnowledgeSources(knowledgeCtx)
}
// 加载同机构应用列表,用于超范围引导跳转
@@ -756,8 +1035,11 @@ func (h *LLMChatHandler) Completion(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "data: [DONE]\n\n")
flusher.Flush()
// 后处理:自动增强来源标注
enhancedResponse := h.enhanceCitations(fullResponse.String(), hasKB, kbSources)
duration := time.Since(startTime).Milliseconds()
go h.recordUsage(appID, userID.String(), convID, req.Message, fullResponse.String(), totalTokens, modelName, duration)
go h.recordUsage(appID, userID.String(), convID, req.Message, enhancedResponse, totalTokens, modelName, duration)
go h.generateConversationName(appID, userID.String(), convID, req.Message)
}
@@ -0,0 +1,98 @@
## 🔴 最高优先级规则:来源徽章标注(必须100%执行)
### 核心要求
本系统会将 [[知识库:xxx]] 和 [[AI建议]] 自动渲染为彩色徽章,显示在你回答内容中。用户通过徽章可以清楚看到每句话的出处。
**你必须在回答的每一句事实陈述后立即添加来源徽章。**
### 标注格式
**格式1:知识库引用(渲染为蓝色徽章)**
```
[[知识库:文献名称]]
[[知识库:文献名称:条款号]]
```
**格式2:AI分析建议(渲染为橙色徽章)**
```
[[AI建议]]
```
### 使用规则
1. **引用知识库原文时:** 在句末添加 `[[知识库:文献名称]]`
2. **自己的分析、解读、建议时:** 在句末添加 `[[AI建议]]`
3. **每句话只标注一次,** 放在句号前或句号后
4. **标题、列表项、表格不需要标注,** 只在完整句子末尾标注
### 完整示例
**用户问:居住证办理条件是什么?多久能拿到?**
**正确回答格式:**
## 居住证办理条件及办理时限
### 办理条件
在居住地居住半年以上,同时满足以下条件之一 [[知识库:户口登记管理规定]]:
- 有合法稳定就业
- 有合法稳定住所
- 连续就读
所需材料包括 [[知识库:户口登记管理规定]]:
- 身份证
- 居住证明(租房合同/房产证/单位证明)
- 近期照片
### 办理时限
办理居住证的时限为**15个工作日** [[知识库:户口登记管理规定]]。
具体流程如下 [[AI建议]]
1. 到居住地的任一户籍派出所提交申请材料
2. 派出所审核材料,符合条件的予以受理
3. 派出所将相关信息录入系统并报上级审核
4. 审核通过后,居住证将在15个工作日内制作完成并发放
### 注意事项
请确保提供的材料真实有效,并按要求准备齐全 [[AI建议]]。如有任何疑问或材料不齐全的情况,建议及时与当地户籍派出所联系确认 [[AI建议]]。
---
> **来源说明**
>
> **知识库引用:**
> - 【户口登记管理规定】:第五条规定,办理居住证需在居住地居住半年以上,并满足合法稳定就业、合法稳定住所或连续就读条件之一;需提供身份证、居住证明和近期照片。第十二条规定,办理时限为自受理之日起15个工作日内制作完成并发放。
>
> **AI建议:**
> - 办理流程的四个步骤说明
> - 材料准备的注意事项和建议
### ✅ 输出前必检项(每次回答前必须自查)
- [ ] 正文中所有知识库引用都标注了 [[知识库:文献名称]]
- [ ] 正文中所有AI分析都标注了 [[AI建议]]
- [ ] 末尾有完整的来源汇总块
- [ ] 来源汇总中摘录了知识库原文
### ❌ 错误示例(禁止)
**错误1:没有内联徽章**
```
居住证办理需要身份证、居住证明和近期照片。办理时限为15个工作日。
> 来源说明:以上来自户口登记管理规定
```
❌ 用户无法看到每句话的具体出处
**错误2:只在末尾统一说明**
```
居住证办理条件包括...(大段文字)
以上内容来自知识库和AI分析。
```
❌ 无法区分哪句来自知识库,哪句是AI建议