feat: 完善来源标注徽章功能和配置优化

后端改进:
- 改进 addCitationsToResponse 函数,更激进地添加来源标注
- 支持识别所有有实质内容的行(长度>5)
- 优化特殊行的识别逻辑,保留'依据引用'、'分析'、'建议'等内容行
- 修复 go.mod 中 lib/pq 的依赖问题
- 优化字符串拼接效率(使用 fmt.Fprintf)
- 添加 Scanner 错误检查

前端改进:
- 优化徽章样式,增大尺寸和间距
- 改用 bg-blue-100 和 bg-orange-100 背景
- 添加 hover 效果和 cursor-pointer

配置优化:
- LLM 服务地址改为 IP(192.168.1.6:18888),支持外部调用
- 本地服务保持 localhost,性能更优

测试:
- 新增 6 个单元测试用例,全部通过
- 验证清单项、列表项、陈述句等各种格式的标注

文档:
- 添加实现方案文档
- 添加用户使用指南
This commit is contained in:
freedakgmail
2026-06-22 20:50:18 +08:00
parent 73d1e00303
commit 95dee5a70e
10 changed files with 614 additions and 51 deletions
+21 -5
View File
@@ -12,14 +12,28 @@ JWT_SECRET=change-this-to-a-random-string-in-production
JWT_EXPIRY=24h
# ---- LLM 直连(替代 Dify 对话引擎) ----
LLM_PROVIDER=openai # openai | anthropic
OPENAI_API_KEY=sk-c0c5174892c44ff48d587cd040fbdd40 # 阿里云百炼 API Key
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 # 阿里云百炼兼容端点
OPENAI_MODEL=qwen-plus # 通义千问-Plus
ANTHROPIC_API_KEY= # Anthropic API Key(可选)
LLM_PROVIDER=openai
# OpenAI 兼容接口
OPENAI_API_KEY=sk-local
OPENAI_BASE_URL=http://192.168.1.6:18888/v1
OPENAI_MODEL=qwen2.5-7b-instruct
# Anthropic
ANTHROPIC_API_KEY=
ANTHROPIC_BASE_URL=https://api.anthropic.com
ANTHROPIC_MODEL=claude-sonnet-4-20250514
# 阿里千问 (Qwen)
QWEN_API_KEY=sk-c0c5174892c44ff48d587cd040fbdd40
QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
QWEN_MODEL=qwen-plus
# 本地 LLM 服务
LOCAL_LLM_BASE_URL=http://192.168.1.6:18888/v1
LOCAL_LLM_MODEL=qwen2.5-7b-instruct
LOCAL_LLM_API_KEY=
# ---- Dify 对接(知识库/创作中心仍可用) ----
DIFY_API_URL=http://localhost:5001/v1
DIFY_API_KEY=app-xxxx
@@ -29,11 +43,13 @@ MODEL_GATEWAY_URL=http://localhost:8081
# ---- SSO 认证 ----
SSO_TYPE=password
# LDAP
LDAP_URL=ldap://ldap.company.com:389
LDAP_BASE_DN=dc=company,dc=com
LDAP_BIND_DN=cn=admin,dc=company,dc=com
LDAP_BIND_PASSWORD=
# OAuth2
OAUTH2_CLIENT_ID=
OAUTH2_CLIENT_SECRET=
+1 -1
View File
@@ -9,6 +9,7 @@ require (
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.2
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.12.3
github.com/redis/go-redis/v9 v9.19.0
github.com/rs/zerolog v1.35.1
golang.org/x/crypto v0.51.0
@@ -19,7 +20,6 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/lib/pq v1.12.3 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
go.uber.org/atomic v1.11.0 // indirect
+5
View File
@@ -15,6 +15,7 @@ import (
"github.com/enterprise-ai-platform/server/pkg/dify"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rs/zerolog/log"
)
type ChatHandler struct {
@@ -132,6 +133,10 @@ func (h *ChatHandler) Chat(w http.ResponseWriter, r *http.Request) {
}
}
if err := scanner.Err(); err != nil {
log.Error().Err(err).Msg("scanner error reading from body")
}
// Record usage asynchronously
duration := time.Since(startTime).Milliseconds()
go func() {
+62 -40
View File
@@ -279,18 +279,24 @@ func (h *LLMChatHandler) enhanceCitations(response string, hasKnowledge bool, kn
hasKBCitation := strings.Contains(response, "[[知识库:")
hasAICitation := strings.Contains(response, "[[AI建议]]")
// 如果已经有完整标注,直接返回
if hasKBCitation && hasAICitation {
return response
}
var result string
// 如果完全没有标注,进行智能补充
if !hasKBCitation && !hasAICitation {
return h.addCitationsToResponse(response, hasKnowledge, knowledgeSources)
result = h.addCitationsToResponse(response, hasKnowledge, knowledgeSources)
} else if hasKBCitation && hasAICitation {
// 如果已经有完整标注,检查是否需要添加来源说明块
result = response
} else {
// 部分标注的情况:检查是否需要补充
result = h.fillMissingCitations(response, hasKnowledge, knowledgeSources)
}
// 部分标注的情况:检查是否需要补充
return h.fillMissingCitations(response, hasKnowledge, knowledgeSources)
// 确保末尾有来源说明块(如果还没有)
if !strings.Contains(result, "**来源说明**") && !strings.Contains(result, "> **来源说明**") {
result += h.generateSourceSummary(hasKnowledge, knowledgeSources)
}
return result
}
// addCitationsToResponse 为完全没有标注的回答添加来源标注
@@ -330,31 +336,48 @@ func (h *LLMChatHandler) addCitationsToResponse(response string, hasKnowledge bo
continue
}
// 跳过空行、标题行、列表标记行
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
// 跳过空行
if trimmed == "" {
enhanced = append(enhanced, line)
continue
}
// 跳过纯列表标记行(只有 - * 1. 等
if len(trimmed) <= 3 && (strings.HasPrefix(trimmed, "-") || strings.HasPrefix(trimmed, "*") || strings.HasPrefix(trimmed, "1.")) {
// 跳过标题行(仅一级和二级标题
if strings.HasPrefix(trimmed, "# ") || strings.HasPrefix(trimmed, "## ") {
enhanced = append(enhanced, line)
continue
}
// 检查是否是陈述句(以句号、问号、感叹号结尾
needsCitation := strings.HasSuffix(trimmed, "。") || strings.HasSuffix(trimmed, ".") ||
// 跳过某些特殊行(如"来源说明"、"免责声明"等
// 但保留"依据引用"、"分析"、"建议"等内容行
if (strings.Contains(trimmed, "来源说明") || strings.Contains(trimmed, "免责声明")) &&
!strings.Contains(trimmed, "依据") && !strings.Contains(trimmed, "分析") &&
!strings.Contains(trimmed, "建议") {
enhanced = append(enhanced, line)
continue
}
// 跳过已有标注的"知识库引用"和"AI建议"标题行
if (strings.HasPrefix(trimmed, "知识库引用") || strings.HasPrefix(trimmed, "AI建议")) &&
!strings.Contains(trimmed, "【") {
enhanced = append(enhanced, line)
continue
}
// 对列表项内容也进行标注(包括 - * 和数字列表)
isListItem := strings.HasPrefix(trimmed, "-") || strings.HasPrefix(trimmed, "*") ||
(len(trimmed) > 2 && trimmed[0] >= '0' && trimmed[0] <= '9' && trimmed[1] == '.')
// 检查是否需要标注:
// 1. 以句号、问号、感叹号结尾的陈述句
// 2. 以问号结尾的问题(包括清单中的"是否"问题)
// 3. 列表项(无论是否以标点符号结尾)
// 4. 其他有实质内容的行(长度>5且不是特殊标记)
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
}
}
strings.HasSuffix(trimmed, "") || strings.HasSuffix(trimmed, "?") ||
isListItem) ||
(len(trimmed) > 5 && !strings.HasPrefix(trimmed, "【") && !strings.HasPrefix(trimmed, "---"))
if needsCitation {
// 决定使用哪种标注
@@ -362,8 +385,9 @@ func (h *LLMChatHandler) addCitationsToResponse(response string, hasKnowledge bo
if hasKnowledge && len(knowledgeSources) > 0 {
// 如果内容较长且像是说明性内容,用AI建议
// 如果内容较短且像是事实陈述,用知识库
if len(trimmed) > 100 || strings.Contains(trimmed, "建议") ||
strings.Contains(trimmed, "注意") || strings.Contains(trimmed, "可以") {
if len(trimmed) > 100 || strings.Contains(trimmed, "建议") ||
strings.Contains(trimmed, "注意") || strings.Contains(trimmed, "可以") ||
strings.Contains(trimmed, "分析") || strings.Contains(trimmed, "风险") {
citation = " [[AI建议]]"
} else {
citation = " [[知识库:" + knowledgeSources[0] + "]]"
@@ -381,14 +405,7 @@ func (h *LLMChatHandler) addCitationsToResponse(response string, hasKnowledge bo
}
}
result := strings.Join(enhanced, "\n")
// 如果末尾没有来源说明块,添加一个
if !strings.Contains(result, "**来源说明**") && !strings.Contains(result, "> **来源说明**") {
result += h.generateSourceSummary(hasKnowledge, knowledgeSources)
}
return result
return strings.Join(enhanced, "\n")
}
// fillMissingCitations 为部分标注的回答补充缺失的标注
@@ -431,7 +448,7 @@ func (h *LLMChatHandler) addFirstKBCitation(response string, source string) stri
func (h *LLMChatHandler) addAICitationToSuggestions(response string) string {
keywords := []string{"建议", "推荐", "可以", "应该", "注意", "提示", "提醒"}
lines := strings.Split(response, "\n")
for i, line := range lines {
trimmed := strings.TrimSpace(line)
// 检查是否包含建议性关键词
@@ -444,7 +461,7 @@ func (h *LLMChatHandler) addAICitationToSuggestions(response string) string {
}
}
}
return strings.Join(lines, "\n")
}
@@ -453,11 +470,11 @@ func (h *LLMChatHandler) generateSourceSummary(hasKnowledge bool, knowledgeSourc
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")
fmt.Fprintf(&summary, "> - 【%s】\n", source)
}
summary.WriteString(">\n")
summary.WriteString("> **AI建议:**\n")
@@ -466,7 +483,7 @@ func (h *LLMChatHandler) generateSourceSummary(hasKnowledge bool, knowledgeSourc
summary.WriteString("> **AI建议:**\n")
summary.WriteString("> - 以上内容为AI建议,仅供参考\n")
}
return summary.String()
}
@@ -678,7 +695,7 @@ func (h *LLMChatHandler) buildMessages(systemPrompt, knowledgeContext string, ha
### ⚠️ 强制要求(不允许任何例外)
你的回答中**每一句事实陈述、每一个观点**都必须在句子末尾标注来源徽章
你的回答中**每一句、每一个观点、每一个列表项**都必须在句子末尾标注来源徽章。这是最高优先级要求,必须100%执行,不允许遗漏。
**格式1:知识库引用(蓝色徽章)**
在引用知识库内容的句子末尾加:[[知识库:文献名称]]
@@ -745,6 +762,11 @@ func (h *LLMChatHandler) buildMessages(systemPrompt, knowledgeContext string, ha
- [ ] 末尾有完整的来源汇总块
- [ ] 来源汇总中列出了知识库原文摘录
### 🚨 关键提醒
- 如果你的回答中有任何句子、列表项、问题没有标注来源,系统会自动补充标注
- 为了避免系统自动补充,你必须在生成回答时就添加所有标注
- 特别是对于清单、问卷、检查表等列表格式的内容,每一项都必须标注
`
if knowledgeContext != "" {
finalSystem += "### 📚 知识库检索结果\n\n以下是从知识库中检索到的相关文献,请优先基于这些内容回答,并在每个引用处标注 [[知识库:文献名称]]:\n\n" + knowledgeContext
+136
View File
@@ -0,0 +1,136 @@
package handler
import (
"strings"
"testing"
)
// TestEnhanceCitations 测试来源标注增强功能
func TestEnhanceCitations(t *testing.T) {
h := &LLMChatHandler{}
tests := []struct {
name string
response string
hasKnowledge bool
knowledgeSources []string
expectedContains []string
}{
{
name: "清单格式 - 应该为每个问题添加标注",
response: `劳动用工合规审查清单
一、劳动合同管理
劳动合同签订
是否所有员工均签订了书面劳动合同?
合同内容是否符合《劳动合同法》的相关规定?
是否存在劳动合同未明确约定工作地点、工作内容、劳动报酬等情况?`,
hasKnowledge: true,
knowledgeSources: []string{"劳动合同法"},
expectedContains: []string{
"是否所有员工均签订了书面劳动合同? [[",
"合同内容是否符合《劳动合同法》的相关规定? [[",
"是否存在劳动合同未明确约定工作地点、工作内容、劳动报酬等情况? [[",
},
},
{
name: "列表项 - 应该为每个列表项添加标注",
response: `办理条件:
- 有合法稳定就业
- 有合法稳定住所
- 连续就读`,
hasKnowledge: true,
knowledgeSources: []string{"居住证管理规定"},
expectedContains: []string{
"- 有合法稳定就业 [[",
"- 有合法稳定住所 [[",
"- 连续就读 [[",
},
},
{
name: "数字列表 - 应该为每个列表项添加标注",
response: `办理流程:
1. 到派出所提交申请材料
2. 派出所审核材料
3. 制作并发放居住证`,
hasKnowledge: true,
knowledgeSources: []string{"居住证管理规定"},
expectedContains: []string{
"1. 到派出所提交申请材料 [[",
"2. 派出所审核材料 [[",
"3. 制作并发放居住证 [[",
},
},
{
name: "陈述句 - 应该添加标注",
response: `居住证办理需要身份证、居住证明和近期照片。
办理时限为15个工作日。`,
hasKnowledge: true,
knowledgeSources: []string{"居住证管理规定"},
expectedContains: []string{
"居住证办理需要身份证、居住证明和近期照片。 [[",
"办理时限为15个工作日。 [[",
},
},
{
name: "无知识库 - 应该添加AI建议标注",
response: `建议您提前准备齐全材料。
如有疑问可先电话咨询。`,
hasKnowledge: false,
knowledgeSources: []string{},
expectedContains: []string{
"建议您提前准备齐全材料。 [[AI建议]]",
"如有疑问可先电话咨询。 [[AI建议]]",
},
},
{
name: "已有标注 - 不应该重复添加",
response: `居住证办理需要身份证、居住证明和近期照片 [[知识库:居住证管理规定]]。
建议您提前准备齐全材料 [[AI建议]]。`,
hasKnowledge: true,
knowledgeSources: []string{"居住证管理规定"},
expectedContains: []string{
"[[知识库:居住证管理规定]]",
"[[AI建议]]",
"**来源说明**",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := h.enhanceCitations(tt.response, tt.hasKnowledge, tt.knowledgeSources)
for _, expected := range tt.expectedContains {
if !strings.Contains(result, expected) {
t.Errorf("期望结果包含: %q\n实际结果: %s", expected, result)
}
}
// 验证来源说明块存在
if !strings.Contains(result, "**来源说明**") {
t.Errorf("期望结果包含来源说明块")
}
})
}
}
// TestAddCitationsToResponse 测试自动添加标注的逻辑
func TestAddCitationsToResponse(t *testing.T) {
h := &LLMChatHandler{}
response := `劳动合同管理
是否所有员工均签订了书面劳动合同?
合同内容是否符合《劳动合同法》的相关规定?`
result := h.addCitationsToResponse(response, true, []string{"劳动合同法"})
// 验证每个问题都被标注了
if !strings.Contains(result, "是否所有员工均签订了书面劳动合同? [[") {
t.Errorf("第一个问题没有被标注: %s", result)
}
if !strings.Contains(result, "合同内容是否符合《劳动合同法》的相关规定? [[") {
t.Errorf("第二个问题没有被标注: %s", result)
}
}